335 lines
15 KiB
TypeScript
335 lines
15 KiB
TypeScript
import type { Plugin, Hooks } from '@opencode-ai/plugin';
|
||
import type { TextPart, Model } from '@opencode-ai/sdk';
|
||
import { resolve, dirname } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
/**
|
||
* Agent support plugin for Remnant.
|
||
*
|
||
* Responsibilities:
|
||
* 1. chat.message (first turn) — session-start.sh (once per session)
|
||
* 2. chat.message — user-prompt-submit.sh (each turn)
|
||
* 3. tool.execute.before — pre-tool-use.sh (project policy)
|
||
* 4. tool.execute.after — post-tool-use.sh + context pressure warning
|
||
* 5. experimental.session.compacting — pre-compact.sh
|
||
*
|
||
* Note: stop.sh has no equivalent OpenCode plugin event; it only fires in Copilot.
|
||
*/
|
||
|
||
export const GlobalPlugin: Plugin = async ({ $, client }) => {
|
||
// Resolve hooks relative to this plugin file's real path (resolves symlinks).
|
||
// This makes the plugin work both as a project-local plugin and as a global
|
||
// plugin installed via install.sh — in either case, hooks live in ../../hooks/
|
||
// relative to this file in the .agents/frameworks/opencode/ directory.
|
||
const hooksDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../hooks');
|
||
|
||
// Running cumulative context size estimate (characters)
|
||
let contextCharsUsed = 0;
|
||
|
||
// Track sessions that have had session-start injected (fires once per session)
|
||
const initializedSessions = new Set<string>();
|
||
|
||
const agentBySession = new Map<string, { agent: string; model: Model; }>();
|
||
|
||
const hooks: Hooks = {
|
||
'chat.params': async (input, output) => {
|
||
logInfoData('chat.params', { input, output });
|
||
agentBySession.set(input.sessionID, { agent: input.agent, model: input.model });
|
||
},
|
||
|
||
// ── 1 & 2. Session start + user prompt ──────────────────────────────────
|
||
// Session-start was previously injected via experimental.chat.system.transform
|
||
// (pushing to output.system). That caused a Jinja "System message must be at
|
||
// the beginning" error on Qwen-family local models when the orchestrator spawns
|
||
// a subagent via `task`: system.transform fires after the task prompt (a user
|
||
// message) is already in the conversation, so the system push lands at a
|
||
// non-zero position. Injecting as a synthetic text part on the first
|
||
// chat.message turn avoids the position constraint entirely.
|
||
'chat.message': async (input, output) => {
|
||
logInfoData('chat.message', { input, output });
|
||
|
||
const sessionID = output.message.sessionID;
|
||
const messageID = output.message.id;
|
||
|
||
// Session-start injection — runs exactly once per session, prepended so it
|
||
// reads before the user-prompt-submit nudges on the first turn.
|
||
if (!initializedSessions.has(input.sessionID)) {
|
||
initializedSessions.add(input.sessionID);
|
||
const startOutput = await runHookScript('session-start.sh');
|
||
const startContext = parseAdditionalContext(startOutput);
|
||
if (startContext) {
|
||
output.parts.unshift({
|
||
id: `prt_${crypto.randomUUID()}`,
|
||
sessionID,
|
||
messageID,
|
||
type: 'text',
|
||
text: startContext,
|
||
synthetic: true,
|
||
});
|
||
}
|
||
}
|
||
|
||
const promptText = output.parts
|
||
.filter((p): p is TextPart => p.type === 'text')
|
||
.map((p) => p.text)
|
||
.join('\n');
|
||
const hookOutput = await runHookScript(
|
||
'user-prompt-submit.sh',
|
||
JSON.stringify({ prompt: promptText }),
|
||
);
|
||
const context = parseAdditionalContext(hookOutput);
|
||
if (context) {
|
||
output.parts.push({
|
||
id: `prt_${crypto.randomUUID()}`,
|
||
sessionID,
|
||
messageID,
|
||
type: 'text',
|
||
text: context,
|
||
synthetic: true,
|
||
});
|
||
}
|
||
},
|
||
// ── 3. Pre-tool-use ─────────────────────────────────────────────────────
|
||
'tool.execute.before': async (input, output) => {
|
||
logInfoData('tool.execute.before', { input, output });
|
||
|
||
// ── read guards ───────────────────────────────────────────────────
|
||
if (input.tool === 'read') {
|
||
const args = (output.args ?? {}) as {
|
||
filePath?: string;
|
||
offset?: number;
|
||
limit?: number;
|
||
};
|
||
const filePath = args.filePath ?? '';
|
||
|
||
// package.json read guard:
|
||
// Reading workspace package.json files auto-loads nested AGENTS.md files
|
||
// via OpenCode's context injection, burning through the 32K context budget.
|
||
// Block package.json reads under apps/ and packages/ only.
|
||
if (/(^|\/)(apps|packages)\/[^/]+\/package\.json$/.test(filePath)) {
|
||
throw new Error(
|
||
'BLOCKED: Reading workspace package.json files auto-loads nested AGENTS.md files and exhausts the 32K context. Use `grep_search` to find the specific field you need (e.g. a dependency version or script name) instead of reading the whole file.',
|
||
);
|
||
}
|
||
|
||
// Pagination guard:
|
||
// Large sequential reads exhaust the 32K context window quickly.
|
||
// The OpenCode `read` tool uses `offset` (1-indexed start) and `limit` (max lines).
|
||
// Unbounded reads (no limit) default to 2000 lines — always blocked.
|
||
// docs/ files may read up to 500 lines; all other files are capped at 50.
|
||
// Directory reads (e.g. `Read .`) never carry a limit — skip the guard.
|
||
let isDirectory = false;
|
||
try {
|
||
const { statSync } = await import('node:fs');
|
||
isDirectory = statSync(filePath).isDirectory();
|
||
} catch (_error) {
|
||
// path doesn't exist or inaccessible — treat as file
|
||
}
|
||
if (!isDirectory) {
|
||
const isDocsFile = /(^|\/)docs\//.test(filePath);
|
||
const readLimit: number | undefined = args.limit;
|
||
if (readLimit === undefined) {
|
||
throw new Error(
|
||
isDocsFile
|
||
? `BLOCKED: Unbounded read (no limit) is prohibited. Specify offset and limit to read in ≤500-line chunks for docs/ files.`
|
||
: `BLOCKED: Unbounded read (no limit) is prohibited. Use grep_search first to find the relevant section, then read with offset and limit in ≤50-line chunks.`,
|
||
);
|
||
}
|
||
const lineLimit = isDocsFile ? 500 : 50;
|
||
if (readLimit > lineLimit) {
|
||
throw new Error(
|
||
isDocsFile
|
||
? `BLOCKED: Read more than 500 lines at once is prohibited for docs/ files. Use offset and limit to paginate in ≤500-line chunks.`
|
||
: `BLOCKED: Read more than 50 lines at once is prohibited. Use offset and limit to paginate in ≤50-line chunks. For docs/ files the limit is 500 lines. Use grep_search first to find the right offset.`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Task prompt size guard ─────────────────────────────────────────────
|
||
// The `task` tool has a JSON serialization limit. Embedding file contents
|
||
// or long inventories inline in a task prompt causes "Unterminated string"
|
||
// parse errors. Cap task prompts at 1200 chars — workers should be told
|
||
// WHICH files to read, not given the contents inline.
|
||
if (input.tool === 'task') {
|
||
const args = (output.args ?? {}) as { prompt?: string };
|
||
const prompt = args.prompt ?? '';
|
||
if (prompt.length > 1200) {
|
||
throw new Error(
|
||
`BLOCKED (task prompt too long: ${prompt.length} chars, max 1200): Task prompts must not embed file contents, dependency lists, or long context inline — this causes JSON parse failures. Instead, tell the worker WHICH files to read and WHAT to do. Example: "Read the root package.json and all workspace package.json files, then update the Technology Stack section in README.md to match."`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// Shell out to pre-tool-use hook (project policy enforcement).
|
||
// Policies 1–12: command/file guards. Policy 13: read_file range limit
|
||
// (≤50 lines for source files, ≤500 for docs/). Deny = throws Error.
|
||
const hookInput = JSON.stringify({
|
||
tool_name: input.tool,
|
||
tool_input: output.args ?? {},
|
||
});
|
||
const hookResult = await runHookScript('pre-tool-use.sh', hookInput);
|
||
|
||
// If the hook emitted a deny decision, surface it as an error
|
||
if (hookResult.includes('"permissionDecision": "deny"')) {
|
||
const match = hookResult.match(/"permissionDecisionReason":\s*"([^"]+)"/);
|
||
const reason = match?.[1] ?? 'Blocked by project policy (pre-tool-use hook).';
|
||
throw new Error(reason);
|
||
}
|
||
},
|
||
|
||
// ── 4. Post-tool-use ────────────────────────────────────────────────────
|
||
'tool.execute.after': async (input, output) => {
|
||
logInfoData('tool.execute.after', { input, output });
|
||
|
||
// MCP tools populate content differently — output.output may be undefined.
|
||
// Skip truncation/pressure/hook logic for those; the MCP content flows
|
||
// through OpenCode's internal parts pipeline instead.
|
||
const text = output.output;
|
||
if (!text) {
|
||
return;
|
||
}
|
||
|
||
// Approximate token estimate: 4 chars ≈ 1 token (conservative for code).
|
||
const CHARS_PER_TOKEN = 4;
|
||
const CONTEXT_LIMIT_TOKENS = 32768;
|
||
const PRESSURE_THRESHOLD = 0.7; // 70%
|
||
|
||
// build agent (local profile) truncates at 1500 tokens to respect OmniCoder's 32K context window.
|
||
// orchestrator gets a higher limit (2500) since it only reads, not edits.
|
||
// All other agents receive full tool responses.
|
||
const LOCAL_WORKER_MAX_TOKENS = 1500;
|
||
const LOCAL_ORCHESTRATOR_MAX_TOKENS = 2500;
|
||
|
||
function truncate(t: string, maxTokens: number): { text: string; truncated: boolean } {
|
||
const maxChars = maxTokens * CHARS_PER_TOKEN;
|
||
if (t.length <= maxChars) return { text: t, truncated: false };
|
||
return {
|
||
text:
|
||
t.slice(0, maxChars) +
|
||
`\n\n[Response truncated at ~${maxTokens} tokens. Use a more targeted query to retrieve the relevant section.]`,
|
||
truncated: true,
|
||
};
|
||
}
|
||
|
||
// a) Response truncation — local agents (build/orchestrator) and any llama-server/ model;
|
||
// orchestrator gets a higher limit since it only reads, not edits.
|
||
const { agent, model } = agentBySession.get(input.sessionID) ?? {};
|
||
const isLocalAgent = agent === 'build' || agent === 'orchestrator' || model?.providerID === 'llama-server';
|
||
if (isLocalAgent) {
|
||
const maxTokens = agent === 'orchestrator' ? LOCAL_ORCHESTRATOR_MAX_TOKENS : LOCAL_WORKER_MAX_TOKENS;
|
||
const { text: truncated } = truncate(text, maxTokens);
|
||
output.output = truncated;
|
||
}
|
||
|
||
// b) Context pressure tracking — accumulate and inject warning when ≥70%
|
||
contextCharsUsed += output.output.length;
|
||
const charLimit = CONTEXT_LIMIT_TOKENS * CHARS_PER_TOKEN;
|
||
const pct = contextCharsUsed / charLimit;
|
||
|
||
if (pct >= PRESSURE_THRESHOLD) {
|
||
const pctDisplay = Math.round(pct * 100);
|
||
const pressure = `[CONTEXT PRESSURE: ~${pctDisplay}% used. Be concise. Prefer targeted tool calls. Write progress to NOTES.md before continuing.]`;
|
||
output.output = `${pressure}\n\n${output.output}`;
|
||
// Reset after injection so we don't spam every subsequent turn
|
||
contextCharsUsed = 0;
|
||
}
|
||
|
||
// c) Shell out to post-tool-use hook (metacognitive reminders, methodology)
|
||
const hookInput = JSON.stringify({
|
||
tool_name: input.tool,
|
||
tool_input: input.args ?? {},
|
||
tool_response: output.output.slice(0, 500), // truncated for hook
|
||
});
|
||
const postToolOutput = await runHookScript('post-tool-use.sh', hookInput);
|
||
const postToolContext = parseAdditionalContext(postToolOutput);
|
||
if (postToolContext) {
|
||
output.output = `${output.output}\n\n${postToolContext}`;
|
||
}
|
||
},
|
||
|
||
// ── 5. Pre-compact: export state before context summarization ─────────────
|
||
'experimental.session.compacting': async (input, output) => {
|
||
logInfoData('experimental.session.compacting', { input, output });
|
||
|
||
await runHookScript('pre-compact.sh');
|
||
|
||
output.prompt = `
|
||
You are a context summarizer for coding sessions. Summarize only the conversation history given — do not answer it.
|
||
|
||
If a <previous-summary> block is present, update it: preserve still-true facts, remove stale ones, merge new facts.
|
||
|
||
Output exactly this Markdown structure. Keep every section even when empty. Use terse bullets, not prose. Preserve exact file paths, commands, error strings, and identifiers.
|
||
|
||
---
|
||
## Original Prompt
|
||
## Clarifications
|
||
## Constraints & Preferences
|
||
## Progress
|
||
### Done
|
||
### In Progress
|
||
### Blocked
|
||
## Key Decisions
|
||
## Next Steps
|
||
## Critical Context
|
||
## Relevant Files
|
||
---
|
||
|
||
For Clarifications: include only follow-ups that changed scope, added constraints, or redirected work. Do not mention that you are summarizing. Respond in the conversation's language.`;
|
||
},
|
||
};
|
||
|
||
/** Parse the additionalContext string from a hook's JSON output. */
|
||
function parseAdditionalContext(hookOutput: string): string | undefined {
|
||
try {
|
||
const parsed = JSON.parse(hookOutput.trim()) as {
|
||
hookSpecificOutput?: { additionalContext?: string };
|
||
};
|
||
return parsed?.hookSpecificOutput?.additionalContext ?? undefined;
|
||
} catch (_error) {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
async function runHookScript(scriptName: string, stdinJson?: string): Promise<string> {
|
||
const script = `${hooksDir}/${scriptName}`;
|
||
try {
|
||
const proc = stdinJson
|
||
? await $`bash ${script} < ${Buffer.from(stdinJson)}`.text()
|
||
: await $`bash ${script}`.text();
|
||
return proc;
|
||
} catch (_error) {
|
||
await client.app.log({
|
||
body: {
|
||
service: 'global-plugin',
|
||
level: 'error',
|
||
message: `(Global Plugin) Error in hook script ${script}`,
|
||
extra: {
|
||
ts: new Date().toISOString(),
|
||
script,
|
||
error: String(_error),
|
||
},
|
||
},
|
||
});
|
||
// Hooks are advisory — never block on hook failure
|
||
return '';
|
||
}
|
||
}
|
||
|
||
async function logInfoData(message: string, obj?: Record<string, unknown>) {
|
||
await client.app.log({
|
||
body: {
|
||
service: 'global-plugin',
|
||
level: 'info',
|
||
message: `(Global Plugin) ${message}`,
|
||
extra: {
|
||
ts: new Date().toISOString(),
|
||
...(obj ?? {}),
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
return hooks;
|
||
};
|