From 22f75e3de6f131d34d06aaf5196ebf885ce5ed7a Mon Sep 17 00:00:00 2001 From: Julius Brussee Date: Mon, 1 Jun 2026 21:05:24 +0200 Subject: [PATCH] fix(opencode): real lifecycle hooks + ship caveman-compress command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds in #419 (replace the non-existent session.created/tui.prompt.append hooks with real opencode hooks: event dispatcher for session.created, chat.message for mode parsing, experimental.chat.system.transform for reinforcement; fixes #418/#421), #398 (ship the missing caveman-compress.md command + un-ignore it; fixes #426/#451/#464), and #376 plugin-side (drop %APPDATA% branch). Smoke test rewritten for the new hook shapes. NOTE: not smoke-tested against a real opencode runtime here — verify before release. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 - .../opencode/commands/caveman-compress.md | 15 +++ src/plugins/opencode/plugin.js | 109 ++++++++++++------ tests/installer/opencode.test.mjs | 54 +++++++-- 4 files changed, 129 insertions(+), 50 deletions(-) create mode 100644 src/plugins/opencode/commands/caveman-compress.md diff --git a/.gitignore b/.gitignore index be5bedb..acd7bd5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ __pycache__/ *.pyc .venv/ .env.local -caveman-compress.md **/.DS_Store .claude/worktrees/ evals/snapshots/*.html diff --git a/src/plugins/opencode/commands/caveman-compress.md b/src/plugins/opencode/commands/caveman-compress.md new file mode 100644 index 0000000..50fff74 --- /dev/null +++ b/src/plugins/opencode/commands/caveman-compress.md @@ -0,0 +1,15 @@ +--- +description: Compress a markdown/text file into caveman format to save tokens +--- +Compress the file at: $ARGUMENTS + +Run the `caveman-compress` skill against the given filepath. The skill rewrites +prose into terse caveman style — drops articles, filler, hedging — while +preserving code blocks, inline code, URLs, file paths, commands, and markdown +structure exactly. Original is backed up as `.original.md` before +overwrite. + +Only compress natural-language files (`.md`, `.txt`, `.typ`, `.tex`, +extensionless). Refuse source/config files (`.py`, `.js`, `.ts`, `.json`, +`.yaml`, `.toml`, `.sh`, etc.). Never compress an existing `*.original.md` +backup. diff --git a/src/plugins/opencode/plugin.js b/src/plugins/opencode/plugin.js index 7f3a870..733c39f 100644 --- a/src/plugins/opencode/plugin.js +++ b/src/plugins/opencode/plugin.js @@ -1,22 +1,37 @@ // caveman — opencode plugin // -// Mirrors the Claude Code SessionStart + UserPromptSubmit hook pair using -// opencode's lifecycle hook system. Bun ESM module; loads the existing -// security-hardened helpers from caveman-config.js via createRequire so the -// symlink-safe flag-write code lives in one place. +// Provides dynamic caveman mode tracking for opencode: +// - Writes the mode flag on each session start (via the `event` dispatcher) +// - Parses user messages for /caveman commands and natural-language toggles +// - Injects per-turn reinforcement into the system prompt +// +// Bun ESM module; loads the existing security-hardened helpers from +// caveman-config.js via createRequire so the symlink-safe flag-write code +// lives in one place. // // Layout once installed: // ~/.config/opencode/plugins/caveman/ // ├── package.json // ├── plugin.js ← this file -// └── caveman-config.js ← copied sibling of src/hooks/caveman-config.js +// └── caveman-config.cjs ← copied sibling of src/hooks/caveman-config.js // -// Always-on caveman ruleset is provided separately via -// ~/.config/opencode/AGENTS.md (Tier-3 base) so this plugin only handles -// dynamic state — flag writes, slash-command parsing, natural-language -// activation, and per-prompt reinforcement. opencode's `session.created` -// payload doesn't expose a documented system-prompt-injection return, so we -// don't try to emit ruleset content here. +// The always-on caveman ruleset is provided separately via +// ~/.config/opencode/AGENTS.md (Tier-3 base). This plugin handles dynamic +// state only: flag writes, slash-command parsing, natural-language +// activation, and per-turn reinforcement. +// +// Hook mapping (opencode >= 1.15.x): +// - event (event.type === 'session.created'): session-init flag write, +// re-fires per session rather than once per plugin-process load +// - chat.message: intercept user prompts for mode changes +// - experimental.chat.system.transform: inject reinforcement per-turn +// +// Note: opencode does NOT support 'session.created' or 'tui.prompt.append' +// as named plugin-hook keys. 'session.created' is an event *type* dispatched +// through the single `event` handler; the old direct-key handlers were +// silently ignored. See: +// https://github.com/JuliusBrussee/caveman/issues/418 +// https://github.com/JuliusBrussee/caveman/issues/421 import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; @@ -37,7 +52,10 @@ const here = dirname(fileURLToPath(import.meta.url)); // either way. function loadConfig() { try { return require(join(here, 'caveman-config.cjs')); } - catch (_) { return require(join(here, '..', '..', 'hooks', 'caveman-config.js')); } + catch (e) { + if (e.code !== 'MODULE_NOT_FOUND') throw e; + return require(join(here, '..', '..', 'hooks', 'caveman-config.js')); + } } const config = loadConfig(); @@ -46,16 +64,14 @@ const { getDefaultMode, safeWriteFlag, readFlag, VALID_MODES } = config; // Modes handled by independent skills — not selectable via /caveman . const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']); +// opencode resolves its config dir from $XDG_CONFIG_HOME, else ~/.config/opencode +// on every platform — including Windows, where it uses %USERPROFILE%\.config\opencode +// (NOT %APPDATA%). os.homedir() is %USERPROFILE% on win32, so the default branch +// is already correct cross-platform. function opencodeConfigDir() { if (process.env.XDG_CONFIG_HOME) { return path.join(process.env.XDG_CONFIG_HOME, 'opencode'); } - if (process.platform === 'win32') { - return path.join( - process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), - 'opencode' - ); - } return path.join(os.homedir(), '.config', 'opencode'); } @@ -122,32 +138,51 @@ function applyModeChange(mode) { safeWriteFlag(flagPath, mode); } +// Session-start logic — extracted so the `event` dispatcher (opencode >= 1.15) +// drives one shared implementation. Re-fires on every `session.created` event, +// so a new session in a long-lived plugin process re-asserts the flag. +function handleSessionCreated() { + const mode = getDefaultMode(); + if (mode === 'off') { + try { if (existsSync(flagPath)) unlinkSync(flagPath); } catch (e) {} + return; + } + safeWriteFlag(flagPath, mode); +} + export const CavemanPlugin = async (_ctx) => ({ - 'session.created': async () => { - const mode = getDefaultMode(); - if (mode === 'off') { - try { if (existsSync(flagPath)) unlinkSync(flagPath); } catch (e) {} - return; - } - safeWriteFlag(flagPath, mode); + // opencode >= 1.15 dispatches session/lifecycle events through a single + // `event` handler keyed on event.type; the older direct top-level + // 'session.created' key is silently ignored. Routing session-init through + // here means the flag is rewritten on every new session, not just once when + // the plugin module loads. See https://opencode.ai/docs/plugins#events. + event: async ({ event } = {}) => { + if (event && event.type === 'session.created') handleSessionCreated(); }, - // opencode's TUI prompt-append hook fires before the prompt is sent to the - // model. We use it for two things: react to mode-changing prompts (slash - // commands + natural language), and append a one-line reinforcement when - // caveman is active so the model can't drift mid-session. Returning an - // object with `append` is the documented way to inject prompt content. - 'tui.prompt.append': async (input) => { - const promptText = (input && (input.prompt || input.text)) || ''; - - const change = parseModeChange(promptText); - if (change) applyModeChange(change); + // Intercept user messages to detect /caveman commands and natural-language + // mode toggles. opencode fires chat.message with (input, output) where + // output.parts is the array of message parts; text parts carry .text. + // Return value is ignored — state changes happen via the flag file. + 'chat.message': async (_input, output) => { + if (!output || !output.parts) return; + for (const part of output.parts) { + if (part && part.type === 'text' && part.text) { + const change = parseModeChange(part.text); + if (change) applyModeChange(change); + } + } + }, + // Inject the reinforcement line into the system prompt when caveman is + // active. opencode calls this before every LLM request and expects the hook + // to mutate output.system (a string[]); the return value is discarded. + 'experimental.chat.system.transform': async (_input, output) => { + if (!output || !Array.isArray(output.system)) return; const active = readFlag(flagPath); if (active && !INDEPENDENT_MODES.has(active)) { - return { append: reinforcementLine(active) }; + output.system.push(reinforcementLine(active)); } - return undefined; }, }); diff --git a/tests/installer/opencode.test.mjs b/tests/installer/opencode.test.mjs index f2691db..d5b5d56 100644 --- a/tests/installer/opencode.test.mjs +++ b/tests/installer/opencode.test.mjs @@ -247,10 +247,16 @@ test('opencode uninstall removes plugin dir, command/agent/skill files, prunes o } }); -// ── 5. Plugin smoke: load installed plugin.js, fire fake hooks ──────────── -test('opencode plugin handles /caveman ultra and stop caveman via tui.prompt.append', async () => { +// ── 5. Plugin smoke: load installed plugin.js, fire the real opencode hooks ── +// opencode (>= 1.15) has no `tui.prompt.append` or top-level `session.created` +// plugin-hook keys (#418/#421). The plugin now uses `chat.message` for mode +// parsing, `experimental.chat.system.transform` for reinforcement, and the +// `event` dispatcher (filtering event.type === 'session.created') for session +// init. This test drives those real hooks. +test('opencode plugin handles /caveman ultra, stop caveman, and session init via real hooks', async () => { const xdg = freshTmpDir(); const shimDir = shimOpencode(); + const origDefault = process.env.CAVEMAN_DEFAULT_MODE; try { const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' }; const r = runInstaller(['--only', 'opencode'], env); @@ -259,28 +265,52 @@ test('opencode plugin handles /caveman ultra and stop caveman via tui.prompt.app const pluginPath = path.join(xdg, 'opencode', 'plugins', 'caveman', 'plugin.js'); const flagPath = path.join(xdg, 'opencode', '.caveman-active'); - // Set XDG_CONFIG_HOME for the plugin so flagPath resolves to our temp dir. + // Set XDG_CONFIG_HOME so the plugin's flagPath resolves to our temp dir, + // and pin the default mode so session-init is deterministic regardless of + // any ambient user/repo-local caveman config. process.env.XDG_CONFIG_HOME = xdg; + process.env.CAVEMAN_DEFAULT_MODE = 'full'; const mod = await import(pathToFileURL(pluginPath).href); const factory = mod.default || mod.CavemanPlugin; const handlers = await factory({}); - // Slash command activates ultra - const out1 = await handlers['tui.prompt.append']({ prompt: '/caveman ultra' }); + // The dead direct-key hooks must NOT be registered. + assert.equal(handlers['tui.prompt.append'], undefined, 'tui.prompt.append should not exist'); + assert.equal(handlers['session.created'], undefined, 'session.created direct key should not exist'); + assert.equal(typeof handlers.event, 'function', 'event dispatcher should be a function'); + assert.equal(typeof handlers['chat.message'], 'function', 'chat.message should be a function'); + assert.equal(typeof handlers['experimental.chat.system.transform'], 'function', + 'system.transform should be a function'); + + // Slash command in a chat.message text part activates ultra. + await handlers['chat.message']({}, { parts: [{ type: 'text', text: '/caveman ultra' }] }); assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra'); - assert.ok(out1 && typeof out1.append === 'string', 'expected reinforcement append'); - assert.match(out1.append, /CAVEMAN MODE ACTIVE \(ultra\)/); - // Natural-language deactivation removes flag - const out2 = await handlers['tui.prompt.append']({ prompt: 'stop caveman please' }); + // system.transform injects the reinforcement line while active. + const sys1 = { system: [] }; + await handlers['experimental.chat.system.transform']({}, sys1); + assert.equal(sys1.system.length, 1, 'expected one reinforcement line'); + assert.match(sys1.system[0], /CAVEMAN MODE ACTIVE \(ultra\)/); + + // Natural-language deactivation removes the flag. + await handlers['chat.message']({}, { parts: [{ type: 'text', text: 'stop caveman please' }] }); assert.equal(fs.existsSync(flagPath), false, 'flag should be deleted after deactivation'); - assert.equal(out2, undefined, 'no reinforcement when flag absent'); - // session.created writes default mode - await handlers['session.created'](); + // No reinforcement injected when inactive. + const sys2 = { system: [] }; + await handlers['experimental.chat.system.transform']({}, sys2); + assert.equal(sys2.system.length, 0, 'no reinforcement when flag absent'); + + // The `event` dispatcher writes the default mode on session.created, and + // ignores unrelated event types. + await handlers.event({ event: { type: 'session.idle' } }); + assert.equal(fs.existsSync(flagPath), false, 'non-session.created event must not write the flag'); + await handlers.event({ event: { type: 'session.created' } }); assert.equal(fs.readFileSync(flagPath, 'utf8'), 'full'); } finally { + if (origDefault === undefined) delete process.env.CAVEMAN_DEFAULT_MODE; + else process.env.CAVEMAN_DEFAULT_MODE = origDefault; fs.rmSync(xdg, { recursive: true, force: true }); fs.rmSync(shimDir, { recursive: true, force: true }); }