335 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 112: 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;
};