mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
fix(opencode): real lifecycle hooks + ship caveman-compress command
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f0dd780305
commit
22f75e3de6
@@ -3,7 +3,6 @@ __pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.env.local
|
||||
caveman-compress.md
|
||||
**/.DS_Store
|
||||
.claude/worktrees/
|
||||
evals/snapshots/*.html
|
||||
|
||||
@@ -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 `<file>.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.
|
||||
@@ -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 <arg>.
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user