Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1919,6 +1919,8 @@ New-Item -ItemType Directory -Force .opencode/plugins | Out-Null
Copy-Item integrations/opencode/.opencode/plugins/token-optimizer.js .opencode/plugins/
```

On OpenCode 1.3.3 or earlier (check `opencode --version`), copy `integrations/opencode/legacy/.opencode/plugins/token-optimizer.js` to the same place instead. Those releases call every export of a plugin file as a plugin function, so they cannot load the current file, whose default export is the object OpenCode 2.x requires. Both files have the same name, so copying one replaces the other.

The plugin preserves Token Optimizer usage state in OpenCode's compaction prompt. Under `TOKEN_OPTIMIZER_MODE=enforce` its `tool.execute.before` hook rejects full-file reads over 25 KB and steers the agent to `smart_read`; the default `assist` lets them through. Small and partial reads always pass normally, and `TOKEN_OPTIMIZER_MODE=advise` gives non-blocking guidance. Restart OpenCode after adding the plugin. See the official [OpenCode MCP guide](https://opencode.ai/docs/mcp-servers/) and [plugin hook guide](https://opencode.ai/docs/plugins/).

### Generic MCP configuration
Expand Down
6 changes: 6 additions & 0 deletions integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ cp -R opencode/hooks/* /path/to/project/.opencode/hooks/token-optimizer/
The local plugin invokes the generated shared-engine entries, preserves guidance
during compaction, and appends applicable graph findings to tool results.

On OpenCode 1.3.3 or earlier, copy
`opencode/legacy/.opencode/plugins/token-optimizer.js` to the same destination
instead. Those releases call every export of a plugin file as a plugin function
and cannot load the current file, whose default export is the object OpenCode
2.x requires. The two files share a name, so copying one replaces the other.

## Hook behavior and controls

All adapters are fail-open for malformed payloads, missing files, small files,
Expand Down
131 changes: 126 additions & 5 deletions integrations/opencode/.opencode/plugins/token-optimizer.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { basename, join } from 'node:path';

/**
* Runtime used to execute the generated hook entries. The hooks target Node,
* and OpenCode 1.x ran plugins inside a Node server, so process.execPath used
* to be node. OpenCode 2.x is a compiled binary: spawning it with a .mjs path
* makes the CLI treat the script as a project directory (ENOTDIR) instead of
* executing it, and enforcement silently no-ops. Keep process.execPath when it
* is Node and fall back to node on PATH otherwise.
*/
const HOOK_RUNTIME = /^node/i.test(basename(process.execPath))
? process.execPath
: 'node';

const compactionGuidance = `
## Token Optimizer state
Expand All @@ -10,13 +22,16 @@ files, smart_glob/smart_grep for noisy searches, optimize_text for bulky output,
and get_optimization_report when savings are requested.
`;

export const TokenOptimizerPlugin = async ({ directory }) => {
/**
* Spawn one generated shared hook entry over the stdin/stdout JSON contract.
* Shared by both plugin APIs below so V1 and V2 hosts enforce identically.
*/
const createInvoke = (directory) => {
const hooks = join(directory, '.opencode', 'hooks', 'token-optimizer');
const pending = new Map();
const invoke = (entry, payload) => {
return (entry, payload) => {
try {
const result = spawnSync(
process.execPath,
HOOK_RUNTIME,
[join(hooks, `${entry}.mjs`)],
{
input: JSON.stringify(payload),
Expand All @@ -36,6 +51,16 @@ export const TokenOptimizerPlugin = async ({ directory }) => {
return null;
}
};
};

/**
* OpenCode V1 entrypoint: the V1 hook-name map. OpenCode 2.x rejects this shape
* outright ("Plugin must export a default definition with an id and an effect
* or setup function"), so 2.x is served by a separate `setup` instead.
*/
export const TokenOptimizerPlugin = async ({ directory }) => {
const invoke = createInvoke(directory);
const pending = new Map();
const keyFor = (input) => input.callID || `${input.sessionID}:${input.tool}`;
const payloadFor = (input, args) => ({
session_id: input.sessionID || 'default',
Expand Down Expand Up @@ -82,3 +107,99 @@ export const TokenOptimizerPlugin = async ({ directory }) => {
},
};
};

// LEGACY CUT. scripts/generate-opencode-legacy-plugin.mjs copies everything
// above this line to
// integrations/opencode/legacy/.opencode/plugins/token-optimizer.js.
// OpenCode 1.3.3 and earlier call EVERY export as a plugin function, so the
// default object below makes them fail to load the whole file, while OpenCode
// 2.x accepts nothing but that object. No one module satisfies both loaders.

/**
* OpenCode V2 entrypoint. V2 loads plugins from a default export with an `id`
* and a `setup(ctx)` that registers hooks through the V2 hook domains
* (`session.hook` / `tool.hook`) instead of returning a hook-name
* map. `server` serves OpenCode 1.3.4 and later 1.x releases, which read a
* default `{ id, server }` object, so this file serves both plugin APIs from
* the same hook logic.
*
* V1 hook -> V2 hook mapping:
* experimental.chat.system.transform -> session.hook('context')
* tool.execute.before -> tool.hook('execute.before')
* tool.execute.after -> tool.hook('execute.after')
* experimental.session.compacting -> session.hook('compaction')
*/
export default {
id: 'token-optimizer',
server: TokenOptimizerPlugin,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
async setup(ctx) {
const directory = ctx.location.directory;
const invoke = createInvoke(directory);
const pending = new Map();
const payloadFor = (event, toolInput) => ({
session_id: event.sessionID || 'default',
cwd: directory,
tool_name: event.tool,
tool_input: toolInput || {},
});

// V1 experimental.chat.system.transform: inject the session-start policy
// into every agent-loop model request.
await ctx.session.hook('context', (event) => {
const result = invoke('session-start', {});
const policy = result?.hookSpecificOutput?.additionalContext;
if (policy) event.system.push({ type: 'text', text: policy });
});

// V1 tool.execute.before: deny oversized reads before they run and
// remember the pre-tool context for the matching execute.after.
await ctx.tool.hook('execute.before', async (event) => {
const shared = invoke('pre-tool', payloadFor(event, event.input));
const hook = shared?.hookSpecificOutput;
if (hook?.permissionDecision === 'deny') {
throw new Error(hook.permissionDecisionReason);
}
pending.set(event.id, {
context: hook?.additionalContext || '',
input: event.input,
});
});

// V1 tool.execute.after: append the captured context and the post-tool
// graph knowledge to the tool result the model actually receives. V2
// distinguishes completed from failed calls; a failed call has no result
// to append to, so the graph context is dropped there instead of crashing.
await ctx.tool.hook('execute.after', async (event) => {
const parts = [];
const prior = pending.get(event.id);
pending.delete(event.id);
if (prior?.context) parts.push(prior.context);

if (/^(?:edit|write|apply_patch|replace)$/i.test(event.tool)) {
const result = invoke(
'post-tool',
payloadFor(event, prior?.input ?? event.input)
);
const context = result?.hookSpecificOutput?.additionalContext;
if (context) parts.push(context);
}
if (parts.length && event.status === 'completed') {
const appended = `[Token Optimizer graph]\n${parts.join('\n\n')}`.trim();
const content = event.result.content;
if (typeof content === 'string') {
event.result.content = `${content}\n\n${appended}`.trim();
} else if (Array.isArray(content)) {
event.result.content = [...content, { type: 'text', text: appended }];
} else {
event.result.content = appended;
}
}
});

// V1 experimental.session.compacting: keep the optimizer state guidance
// in the compaction summary request.
await ctx.session.hook('compaction', (event) => {
event.system.push({ type: 'text', text: compactionGuidance });
});
},
};
114 changes: 114 additions & 0 deletions integrations/opencode/legacy/.opencode/plugins/token-optimizer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// GENERATED from integrations/opencode/.opencode/plugins/token-optimizer.js by
// scripts/generate-opencode-legacy-plugin.mjs -- edit that file, then run
// `npm run sync:hooks`. For OpenCode 1.3.3 and earlier only: it exports the
// V1 plugin function and nothing else, because those releases call every
// export as a plugin. Install this file OR the current one, never both.
import { spawnSync } from 'node:child_process';
import { basename, join } from 'node:path';

/**
* Runtime used to execute the generated hook entries. The hooks target Node,
* and OpenCode 1.x ran plugins inside a Node server, so process.execPath used
* to be node. OpenCode 2.x is a compiled binary: spawning it with a .mjs path
* makes the CLI treat the script as a project directory (ENOTDIR) instead of
* executing it, and enforcement silently no-ops. Keep process.execPath when it
* is Node and fall back to node on PATH otherwise.
*/
const HOOK_RUNTIME = /^node/i.test(basename(process.execPath))
? process.execPath
: 'node';

const compactionGuidance = `
## Token Optimizer state

Preserve which large files and bulky outputs have already been processed. After
compaction, continue using token-optimizer smart_read for large or repeated
files, smart_glob/smart_grep for noisy searches, optimize_text for bulky output,
and get_optimization_report when savings are requested.
`;

/**
* Spawn one generated shared hook entry over the stdin/stdout JSON contract.
* Shared by both plugin APIs below so V1 and V2 hosts enforce identically.
*/
const createInvoke = (directory) => {
const hooks = join(directory, '.opencode', 'hooks', 'token-optimizer');
return (entry, payload) => {
try {
const result = spawnSync(
HOOK_RUNTIME,
[join(hooks, `${entry}.mjs`)],
{
input: JSON.stringify(payload),
encoding: 'utf8',
// Preserve the live enforcement/capability contract for the generated
// child hook. Making this explicit also keeps test hosts and embedders
// from spawning with an implementation-defined environment snapshot.
env: { ...process.env },
timeout: 5_000,
maxBuffer: 64 * 1024,
}
);
return result.status === 0 && result.stdout.trim()
? JSON.parse(result.stdout)
: null;
} catch {
return null;
}
};
};

/**
* OpenCode V1 entrypoint: the V1 hook-name map. OpenCode 2.x rejects this shape
* outright ("Plugin must export a default definition with an id and an effect
* or setup function"), so 2.x is served by a separate `setup` instead.
*/
export const TokenOptimizerPlugin = async ({ directory }) => {
const invoke = createInvoke(directory);
const pending = new Map();
const keyFor = (input) => input.callID || `${input.sessionID}:${input.tool}`;
const payloadFor = (input, args) => ({
session_id: input.sessionID || 'default',
cwd: directory,
tool_name: input.tool,
tool_input: args || {},
});

return {
'experimental.chat.system.transform': async (_input, output) => {
const result = invoke('session-start', {});
const policy = result?.hookSpecificOutput?.additionalContext;
if (policy) output.system.push(policy);
},
'tool.execute.before': async (input, output) => {
const shared = invoke('pre-tool', payloadFor(input, output.args));
const hook = shared?.hookSpecificOutput;
if (hook?.permissionDecision === 'deny') {
throw new Error(hook.permissionDecisionReason);
}
pending.set(keyFor(input), {
context: hook?.additionalContext || '',
args: output.args,
});
},
'tool.execute.after': async (input, output) => {
const parts = [];
const prior = pending.get(keyFor(input));
pending.delete(keyFor(input));
if (prior?.context) parts.push(prior.context);

if (/^(?:edit|write|apply_patch|replace)$/i.test(input.tool)) {
const result = invoke('post-tool', payloadFor(input, prior?.args));
const context = result?.hookSpecificOutput?.additionalContext;
if (context) parts.push(context);
}
if (parts.length) {
output.output =
`${output.output || ''}\n\n[Token Optimizer graph]\n${parts.join('\n\n')}`.trim();
}
},
'experimental.session.compacting': async (_input, output) => {
output.context.push(compactionGuidance);
},
};
};
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@
"validate": "node scripts/validate-package.js",
"verify:package-contents": "node scripts/verify-package-contents.mjs",
"verify:published-launch": "node scripts/verify-published-launch.mjs",
"sync:hooks": "node scripts/sync-hook-core.mjs && node scripts/generate-client-entries.mjs && node scripts/generate-client-configs.mjs && node scripts/pin-mcp-version.mjs",
"sync:hooks:check": "node scripts/sync-hook-core.mjs --check && node scripts/generate-client-entries.mjs --check && node scripts/generate-client-configs.mjs --check && node scripts/pin-mcp-version.mjs --check",
"sync:hooks": "node scripts/sync-hook-core.mjs && node scripts/generate-client-entries.mjs && node scripts/generate-opencode-legacy-plugin.mjs && node scripts/generate-client-configs.mjs && node scripts/pin-mcp-version.mjs",
"sync:hooks:check": "node scripts/sync-hook-core.mjs --check && node scripts/generate-client-entries.mjs --check && node scripts/generate-opencode-legacy-plugin.mjs --check && node scripts/generate-client-configs.mjs --check && node scripts/pin-mcp-version.mjs --check",
"stamp:version": "node scripts/sync-hook-core.mjs --stamp && node scripts/generate-client-entries.mjs --stamp",
"stamp:telemetry": "node scripts/stamp-telemetry-key.mjs",
"verify:ui": "node scripts/verify-wiki-ui.mjs",
Expand Down
52 changes: 52 additions & 0 deletions scripts/generate-opencode-legacy-plugin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* Generates the OpenCode plugin for OpenCode 1.3.3 and earlier.
*
* Those releases call every export of a plugin file as a plugin function
* (packages/opencode/src/plugin/index.ts up to v1.3.3), and the current
* plugin's default export is the object OpenCode 2.x requires, so a release
* that old fails to load the whole file. No one module satisfies both loaders.
* The legacy build is the current plugin cut at its LEGACY CUT marker, so the
* two can never disagree about a hook.
*/

import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
contentMatches,
normalizeEol,
readIfExists,
writeIfChanged,
} from './lib/text.mjs';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SOURCE = 'integrations/opencode/.opencode/plugins/token-optimizer.js';
const LEGACY = 'integrations/opencode/legacy/.opencode/plugins/token-optimizer.js';
const check = process.argv.includes('--check');

const text = normalizeEol(readFileSync(join(ROOT, SOURCE), 'utf8'));
const cut = text.indexOf('\n// LEGACY CUT.');
if (cut < 0) {
console.error(`${SOURCE} has no LEGACY CUT marker`);
process.exit(1);
}
const contents = `// GENERATED from ${SOURCE} by
// scripts/generate-opencode-legacy-plugin.mjs -- edit that file, then run
// \`npm run sync:hooks\`. For OpenCode 1.3.3 and earlier only: it exports the
// V1 plugin function and nothing else, because those releases call every
// export as a plugin. Install this file OR the current one, never both.
${text.slice(0, cut).trimEnd()}
`;

const destination = join(ROOT, LEGACY);
if (check) {
if (!contentMatches(readIfExists(destination), contents)) {
console.error(`DRIFT: ${LEGACY}\nRun: npm run sync:hooks`);
process.exit(1);
}
console.log('opencode legacy plugin in sync');
} else {
writeIfChanged(destination, contents);
console.log(`generated ${LEGACY}`);
}
Loading
Loading