diff --git a/README.md b/README.md index 624000a..490e7a9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/index.ts b/index.ts index 643fcbb..d8561a3 100644 --- a/index.ts +++ b/index.ts @@ -72,7 +72,7 @@ async function isSubagentSession( client: OpenCodeClient, sessionID: string, logger: Logger -): Promise { +): Promise<{ isSubagent: boolean; directory?: string }> { try { const result = await client.session.get({ path: { id: sessionID } }) @@ -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 } } } @@ -268,7 +268,8 @@ async function generateTitleFromContext( context: string, configModel: string | undefined, logger: Logger, - client: OpenCodeClient + client: OpenCodeClient, + customPrompt?: string ): Promise { try { logger.debug('title-generation', 'Selecting model', { configModel }) @@ -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 @@ -320,7 +324,7 @@ async function generateTitleFromContext( messages: [ { role: 'user', - content: `${TITLE_PROMPT}\n\n\n${context}\n\n\nOutput the title now:` + content: `${prompt}\n\n\n${context}\n\n\nOutput the title now:` } ] }) @@ -386,7 +390,8 @@ async function updateSessionTitle( context, config.model, logger, - client + client, + config.prompt ) if (!newTitle) { @@ -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(/\/+$/, '') + 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) diff --git a/lib/config.ts b/lib/config.ts index 13397f4..4f64c6f 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -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 | 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) +} + +function mergeConfig(base: PluginConfig, overlay: Partial): 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) } } diff --git a/lib/model-selector.ts b/lib/model-selector.ts index ae23c8b..9405750 100644 --- a/lib/model-selector.ts +++ b/lib/model-selector.ts @@ -11,6 +11,46 @@ import type { LanguageModel } from 'ai'; import type { Logger } from './logger'; +function trimText(value: unknown, max = 500): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + return value.length > max ? `${value.slice(0, max)}...` : value; +} + +function serializeError(error: unknown): Record { + if (!error || typeof error !== 'object') { + return { + type: typeof error, + value: String(error) + }; + } + + const err = error as any; + const cause = err?.cause && typeof err.cause === 'object' + ? { + name: err.cause.name, + message: trimText(err.cause.message), + constructor: err.cause.constructor?.name, + code: err.cause.code, + stack: trimText(err.cause.stack) + } + : undefined; + + return { + name: err.name, + message: trimText(err.message), + constructor: err.constructor?.name, + code: err.code, + providerID: err.providerID, + modelID: err.modelID, + status: err.status, + stack: trimText(err.stack), + keys: Object.keys(err), + cause + }; +} + export interface ModelInfo { providerID: string; modelID: string; @@ -61,11 +101,22 @@ export async function selectModel( logger?: Logger, configModel?: string ): Promise { + const startedAt = Date.now(); logger?.info('model-selector', 'Model selection started', { configModel }); - + // Lazy import - only load the 812KB auth provider package when actually needed + logger?.debug('model-selector', 'Importing @tarquinen/opencode-auth-provider'); + const importStartedAt = Date.now(); const { OpencodeAI } = await import('@tarquinen/opencode-auth-provider'); + logger?.debug('model-selector', 'Auth provider imported', { + importDurationMs: Date.now() - importStartedAt + }); + + const initStartedAt = Date.now(); const opencodeAI = new OpencodeAI(); + logger?.debug('model-selector', 'OpencodeAI instance created', { + initDurationMs: Date.now() - initStartedAt + }); let failedModelInfo: ModelInfo | undefined; @@ -83,10 +134,12 @@ export async function selectModel( }); try { + const attemptStartedAt = Date.now(); const model = await opencodeAI.getLanguageModel(providerID, modelID); logger?.info('model-selector', '✓ Successfully using config-specified model', { providerID, - modelID + modelID, + durationMs: Date.now() - attemptStartedAt }); return { model, @@ -98,7 +151,7 @@ export async function selectModel( logger?.warn('model-selector', '✗ Failed to use config-specified model, falling back', { providerID, modelID, - error: error.message + error: serializeError(error) }); // Track the failed model failedModelInfo = { providerID, modelID }; @@ -107,7 +160,12 @@ export async function selectModel( } logger?.debug('model-selector', 'Fetching available authenticated providers'); + const listStartedAt = Date.now(); const providers = await opencodeAI.listProviders(); + logger?.debug('model-selector', 'Authenticated providers fetched', { + durationMs: Date.now() - listStartedAt + }); + const availableProviderIDs = Object.keys(providers); logger?.info('model-selector', 'Available authenticated providers', { providerCount: availableProviderIDs.length, @@ -115,7 +173,9 @@ export async function selectModel( providers: Object.entries(providers).map(([id, info]) => ({ id, source: info.source, - name: info.info.name + name: info.info.name, + modelCount: Object.keys(info.info.models || {}).length, + sampleModels: Object.keys(info.info.models || {}).slice(0, 10) })) }); @@ -138,10 +198,12 @@ export async function selectModel( logger?.debug('model-selector', `Attempting ${providerID}/${fallbackModelID}`); try { + const attemptStartedAt = Date.now(); const model = await opencodeAI.getLanguageModel(providerID, fallbackModelID); logger?.info('model-selector', `✓ Successfully using fallback model`, { providerID, - modelID: fallbackModelID + modelID: fallbackModelID, + durationMs: Date.now() - attemptStartedAt }); return { model, @@ -152,13 +214,20 @@ export async function selectModel( }; } catch (error: any) { logger?.warn('model-selector', `✗ Failed to use ${providerID}/${fallbackModelID}`, { - error: error.message + error: serializeError(error) }); continue; } } + logger?.error('model-selector', 'Model selection failed after exhausting configured and fallback models', { + configModel, + providerPriority: PROVIDER_PRIORITY, + fallbackModels: FALLBACK_MODELS, + totalDurationMs: Date.now() - startedAt, + failedModelInfo + }); + throw new Error('No available models for title generation. Please authenticate with at least one provider.'); } - diff --git a/package.json b/package.json index c325dff..fd43160 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", - "name": "@tarquinen/opencode-smart-title", - "version": "0.1.7", + "name": "@the3asic/opencode-smart-title", + "version": "0.2.1", "type": "module", "description": "OpenCode plugin that automatically generates meaningful session titles using AI and smart context selection", "main": "./dist/index.js", @@ -25,21 +25,21 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/Tarquinen/opencode-smart-title.git" + "url": "git+https://github.com/the3asic/opencode-smart-title.git" }, "bugs": { - "url": "https://github.com/Tarquinen/opencode-smart-title/issues" + "url": "https://github.com/the3asic/opencode-smart-title/issues" }, - "homepage": "https://github.com/Tarquinen/opencode-smart-title#readme", + "homepage": "https://github.com/the3asic/opencode-smart-title#readme", "author": "Dan Mindru", "license": "MIT", "peerDependencies": { "@opencode-ai/plugin": ">=0.13.7" }, "dependencies": { - "@ai-sdk/openai-compatible": "^1.0.27", + "@ai-sdk/openai-compatible": "^2.0.30", "@tarquinen/opencode-auth-provider": "^0.1.7", - "ai": "^5.0.98", + "ai": "^6.0.99", "jsonc-parser": "^3.3.1" }, "devDependencies": {