-
Notifications
You must be signed in to change notification settings - Fork 7
feat: support custom prompt and excluded directories for title generation #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
f82ea72
08a2a43
50ff758
a4a04b3
bc7435e
00ff81f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,9 @@ export interface PluginConfig { | |
| enabled: boolean | ||
| debug: boolean | ||
| model?: string | ||
| prompt?: string | ||
| updateThreshold: number | ||
| excludeDirectories?: string[] | ||
| } | ||
|
|
||
| const defaultConfig: PluginConfig = { | ||
|
|
@@ -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"] | ||
| } | ||
| ` | ||
|
|
||
|
|
@@ -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
|
||
|
|
||
| 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 | ||
| * | ||
|
|
@@ -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() | ||
|
|
@@ -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) | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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.