Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 61 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,53 +1,94 @@
# Smart Title Plugin
# Smart Title Plugin (Fork)

Auto-generates meaningful session titles for your OpenCode conversations using AI.
This plugin auto-generates meaningful OpenCode session titles from your conversation.

## What It Does
## Fork Notice

- Watches your conversation and generates short, descriptive titles
- Updates automatically when the session becomes idle (you stop typing)
- Uses OpenCode's unified auth - no API keys needed
- Works with any authenticated AI provider
This repository is a fork of:

- Upstream: `Tarquinen/opencode-smart-title`
- Fork: `the3asic/opencode-smart-title`

The npm package for this fork is:

- `@the3asic/opencode-smart-title`

## What This Fork Adds

Compared to upstream, this fork adds:

- **Custom prompt support** via config (`prompt`)
- **Directory exclusion** (`excludeDirectories`) so specific paths are ignored
- **Config model fallback flow** with better provider/model selection behavior
- **Subagent skip logic** (does not rename subagent sessions)
- **Smarter context extraction** (keeps full user message + first/last assistant reply per turn)
- **Improved logging and error output** for debugging and operations

## How It Works

1. Plugin listens for OpenCode `session.status` events.
2. When status becomes `idle`, it checks gates:
- plugin enabled
- not a subagent session
- current directory not excluded
- `updateThreshold` reached
3. It loads recent conversation context and builds a compact prompt payload.
4. It picks a model:
- use configured `model` first (if set)
- fallback to authenticated providers in priority order
5. It generates a short title and updates the current session title.

## Installation

```bash
npm install @tarquinen/opencode-smart-title
npm install @the3asic/opencode-smart-title
```

Add to `~/.config/opencode/opencode.json`:

```json
{
"plugin": ["@tarquinen/opencode-smart-title"]
"plugin": ["@the3asic/opencode-smart-title"]
}
```

## Configuration

The plugin supports both global and project-level configuration:
The plugin supports global and project-level config:

- **Global:** `~/.config/opencode/smart-title.jsonc` - Applies to all sessions
- **Project:** `.opencode/smart-title.jsonc` - Overrides global config
- Global: `~/.config/opencode/smart-title.jsonc`
- Project: `.opencode/smart-title.jsonc` (overrides global)

The plugin creates a default global config on first run.
The plugin auto-creates a default global config on first run.

```jsonc
{
// Enable or disable the plugin
"enabled": true,

// Enable debug logging
"debug": false,

// Optional: Use a specific model (otherwise uses smart fallbacks)
// Optional, for example: "opencode/gpt-5-nano"
// "model": "anthropic/claude-haiku-4-5",
// Optional custom generation prompt
// "prompt": "Generate very short technical titles...",
"updateThreshold": 1,
// Optional directory blacklist
// "excludeDirectories": ["/home/ubuntu/.heartbeat"]
}
```

## Local Development (Optional)

// Update title every N idle events (1 = every time you pause)
"updateThreshold": 1
If you are actively developing this plugin, you can load local build output directly:

```json
{
"plugin": [
"file:///absolute/path/to/opencode-smart-title/dist/index.js"
]
}
```

Use package install for daily usage across machines, and local file mode only for development/debugging.

## License

MIT
45 changes: 35 additions & 10 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async function isSubagentSession(
client: OpenCodeClient,
sessionID: string,
logger: Logger
): Promise<boolean> {
): Promise<{ isSubagent: boolean; directory?: string }> {
try {
const result = await client.session.get({ path: { id: sessionID } })

Expand All @@ -81,16 +81,16 @@ async function isSubagentSession(
sessionID,
parentID: result.data.parentID
})
return true
return { isSubagent: true }
}

return false
return { isSubagent: false, directory: result.data?.directory }
} catch (error: any) {
logger.error("subagent-check", "Failed to check if session is subagent", {
sessionID,
error: error.message
})
return false
return { isSubagent: false }
}
}

Expand Down Expand Up @@ -268,7 +268,8 @@ async function generateTitleFromContext(
context: string,
configModel: string | undefined,
logger: Logger,
client: OpenCodeClient
client: OpenCodeClient,
customPrompt?: string
): Promise<string | null> {
try {
logger.debug('title-generation', 'Selecting model', { configModel })
Expand Down Expand Up @@ -308,8 +309,11 @@ async function generateTitleFromContext(
}
}

const prompt = customPrompt || TITLE_PROMPT

logger.debug('title-generation', 'Generating title', {
contextLength: context.length
contextLength: context.length,
promptSource: customPrompt ? 'custom' : 'built-in'
})

// Lazy import - only load the 2.8MB ai package when actually needed
Expand All @@ -320,7 +324,7 @@ async function generateTitleFromContext(
messages: [
{
role: 'user',
content: `${TITLE_PROMPT}\n\n<conversation>\n${context}\n</conversation>\n\nOutput the title now:`
content: `${prompt}\n\n<conversation>\n${context}\n</conversation>\n\nOutput the title now:`
}
]
})
Expand Down Expand Up @@ -386,7 +390,8 @@ async function updateSessionTitle(
context,
config.model,
logger,
client
client,
config.prompt
)

if (!newTitle) {
Expand Down Expand Up @@ -453,11 +458,31 @@ const SmartTitlePlugin: Plugin = async (ctx) => {

logger.debug('event', 'Session became idle', { sessionId })

// Skip if this is a subagent session
if (await isSubagentSession(client, sessionId, logger)) {
// Skip if this is a subagent session, and get directory
const { isSubagent, directory } = await isSubagentSession(client, sessionId, logger)
if (isSubagent) {
return
}

// Check excludeDirectories
if (config.excludeDirectories && config.excludeDirectories.length > 0 && directory) {
const normalizedDir = directory.replace(/\/+$/, '')
Comment on lines +468 to +469
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing handling for undefined or null directory values. If result.data?.directory is undefined or null, the exclusion check at line 468 correctly guards against it. However, if directory is an empty string "", the normalization on line 469 would result in an empty string, and the exclusion check would still proceed. An empty string would never match any exclusion patterns, but it's inefficient to run the check. Consider adding a length check after normalization or including it in the initial guard condition.

Copilot uses AI. Check for mistakes.
if (!normalizedDir) {
return
}
const excluded = config.excludeDirectories.some(excl => {
return normalizedDir === excl || normalizedDir.startsWith(excl + '/')
})
if (excluded) {
logger.debug('event', 'Session directory excluded from title generation', {
sessionId,
directory: normalizedDir,
excludeDirectories: config.excludeDirectories
})
return
}
}

// Increment idle count for this session
const currentCount = (sessionIdleCount.get(sessionId) || 0) + 1
sessionIdleCount.set(sessionId, currentCount)
Expand Down
81 changes: 68 additions & 13 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ export interface PluginConfig {
enabled: boolean
debug: boolean
model?: string
prompt?: string
updateThreshold: number
excludeDirectories?: string[]
}

const defaultConfig: PluginConfig = {
Expand Down Expand Up @@ -93,8 +95,17 @@ function createDefaultConfig(): void {
// Examples: "anthropic/claude-haiku-4-5", "openai/gpt-5-mini"
// "model": "anthropic/claude-haiku-4-5",

// Optional: Custom prompt for title generation
// If not specified, uses the built-in English prompt
// "prompt": "你是一个标题生成器。分析对话内容,生成一个简短的中文标题。",

// Update title every N idle events (default: 1)
"updateThreshold": 1
"updateThreshold": 1,

// Optional: Directories to exclude from title generation
// Sessions in these directories will not get automatic titles
// Uses prefix matching (e.g. "/home/ubuntu/.heartbeat" matches any subdirectory)
// "excludeDirectories": ["/home/ubuntu/.heartbeat"]
}
`

Expand All @@ -113,6 +124,60 @@ function loadConfigFile(configPath: string): Partial<PluginConfig> | null {
}
}

function normalizeBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback
}

function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}

const normalized = value.trim()
return normalized.length > 0 ? normalized : undefined
}

function normalizePositiveInt(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return fallback
}

const normalized = Math.floor(value)
return normalized > 0 ? normalized : fallback
}

function normalizeExcludeDirectories(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined
}

return value
.filter((entry): entry is string => typeof entry === 'string')
.map(entry => {
const trimmed = entry.trim()
if (trimmed === '/') {
return '/'
}
return trimmed.replace(/\/+$/, '')
})
.filter(entry => entry.length > 0)
}
Comment on lines +149 to +164
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential issue with empty array handling. If all entries in the excludeDirectories array are filtered out (e.g., all empty strings or non-strings), the function returns an empty array rather than undefined. This differs from the undefined return for non-arrays, which could lead to inconsistent config merging behavior. Consider returning undefined when the filtered array is empty to maintain consistency with the "no valid values" semantics.

Copilot uses AI. Check for mistakes.

function mergeConfig(base: PluginConfig, overlay: Partial<PluginConfig>): PluginConfig {
const model = normalizeOptionalString(overlay.model)
const prompt = normalizeOptionalString(overlay.prompt)
const excludeDirectories = normalizeExcludeDirectories(overlay.excludeDirectories)

return {
enabled: normalizeBoolean(overlay.enabled, base.enabled),
debug: normalizeBoolean(overlay.debug, base.debug),
model: model ?? base.model,
prompt: prompt ?? base.prompt,
updateThreshold: normalizePositiveInt(overlay.updateThreshold, base.updateThreshold),
excludeDirectories: excludeDirectories ?? base.excludeDirectories
}
}

/**
* Loads configuration with support for both global and project-level configs
*
Expand All @@ -133,12 +198,7 @@ export function getConfig(ctx?: PluginInput): PluginConfig {
if (configPaths.global) {
const globalConfig = loadConfigFile(configPaths.global)
if (globalConfig) {
config = {
enabled: globalConfig.enabled ?? config.enabled,
debug: globalConfig.debug ?? config.debug,
model: globalConfig.model ?? config.model,
updateThreshold: globalConfig.updateThreshold ?? config.updateThreshold
}
config = mergeConfig(config, globalConfig)
}
} else {
createDefaultConfig()
Expand All @@ -147,12 +207,7 @@ export function getConfig(ctx?: PluginInput): PluginConfig {
if (configPaths.project) {
const projectConfig = loadConfigFile(configPaths.project)
if (projectConfig) {
config = {
enabled: projectConfig.enabled ?? config.enabled,
debug: projectConfig.debug ?? config.debug,
model: projectConfig.model ?? config.model,
updateThreshold: projectConfig.updateThreshold ?? config.updateThreshold
}
config = mergeConfig(config, projectConfig)
}
}

Expand Down
Loading