Files
wehub-resource-sync 979fb22d7c
CI / test (20, macos-latest) (push) Waiting to run
CI / test (20, ubuntu-latest) (push) Waiting to run
CI / test (22, macos-latest) (push) Waiting to run
CI / test (22, ubuntu-latest) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:01:18 +08:00

688 lines
25 KiB
TypeScript

import type { Plugin } from "@opencode-ai/plugin";
const API = process.env.AGENTMEMORY_URL || "http://localhost:3111";
const FILE_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "Grep"]);
const FILE_KEYS = ["filePath", "file_path", "path", "file", "pattern"];
const MAX_STASHED_FILES = 20;
const DEBUG = process.env.OPENCODE_AGENTMEMORY_DEBUG === "1";
const SECRET = process.env.AGENTMEMORY_SECRET || "";
function authHeaders(): Record<string, string> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (SECRET) headers["Authorization"] = `Bearer ${SECRET}`;
return headers;
}
async function post(path: string, body: Record<string, unknown>, timeoutMs = 5000): Promise<void> {
try {
await fetch(`${API}/agentmemory${path}`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (e) {
if (DEBUG) console.error(`[agentmemory] POST ${path} failed:`, (e as Error).message);
}
}
async function postJson(path: string, body: Record<string, unknown>): Promise<unknown | null> {
try {
const res = await fetch(`${API}/agentmemory${path}`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(body),
signal: AbortSignal.timeout(5000),
});
return res.ok ? await res.json() : null;
} catch (e) {
if (DEBUG) console.error(`[agentmemory] POST ${path} failed:`, (e as Error).message);
return null;
}
}
async function observe(
sessionId: string,
hookType: string,
data: Record<string, unknown>,
): Promise<void> {
await post("/observe", {
hookType,
sessionId,
project: projectPath,
cwd: projectPath,
timestamp: new Date().toISOString(),
data,
});
}
let activeSessionId: string | null = null;
let pendingConfig: Record<string, unknown> | null = null;
let projectPath: string | null = null;
const stashedFiles = new Map<string, Set<string>>();
const seenSubtaskIds = new Map<string, Set<string>>();
const seenToolCallIds = new Map<string, Set<string>>();
const contextInjectedSessions = new Set<string>();
// cache the context returned by POST /session/start so the chat
// system-transform hook can inject it without a second /context fetch.
// Auto-injection now happens at session.created (immediately) AND at
// the first prompt_submit (fallback for older OpenCode builds that
// don't implement experimental.chat.system.transform).
const startContextCache = new Map<string, string>();
function stashFor(sid: string): Set<string> {
let s = stashedFiles.get(sid);
if (!s) { s = new Set<string>(); stashedFiles.set(sid, s); }
return s;
}
function subtaskSetFor(sid: string): Set<string> {
let s = seenSubtaskIds.get(sid);
if (!s) { s = new Set<string>(); seenSubtaskIds.set(sid, s); }
return s;
}
function toolCallSetFor(sid: string): Set<string> {
let s = seenToolCallIds.get(sid);
if (!s) { s = new Set<string>(); seenToolCallIds.set(sid, s); }
return s;
}
function pruneSessionMaps(sid: string): void {
stashedFiles.delete(sid);
seenSubtaskIds.delete(sid);
seenToolCallIds.delete(sid);
}
function safeSlice(v: unknown, max: number): string {
if (typeof v === "string") return v.slice(0, max);
if (v == null) return "";
try { return JSON.stringify(v).slice(0, max); } catch { return ""; }
}
const AGENTMEMORY_INSTRUCTIONS = `<agentmemory-instructions>
You have access to agentmemory for persistent cross-session memory. Use these tools proactively.
CORE TOOLS:
memory_save — Save an insight, decision, or fact to long-term memory.
Required: content (text), concepts (2-5 comma-separated keywords), type (pattern/preference/architecture/bug/workflow/fact)
Optional: files (comma-separated paths)
Use when: user says "remember this", after discovering a bug, after making an architectural decision, after learning a project convention.
memory_recall — Search past observations by keywords.
Use when: user says "recall", "what did we do", "do you remember", or needs context from past sessions.
memory_smart_search — Hybrid semantic+keyword search with progressive disclosure.
Use when: you need the most relevant past context, fuzzy/conceptual searches, or recall doesn't find what you need.
memory_sessions — List recent sessions with status and observation counts.
Use when: user asks about session/past history, "what did we work on".
memory_file_history — Get past observations about specific files (across all sessions).
Use when: you're about to edit a file and want to know its history, common pitfalls, or past edits.
memory_lesson_save — Save a lesson learned (what worked, what to avoid).
Use when: you discover a pattern that could help future sessions avoid mistakes.
memory_lesson_recall — Search lessons by query. Returns lessons sorted by confidence.
Use when: before making a decision, check if past lessons apply.
memory_governance_delete — Delete specific memories. Requires explicit user confirmation.
Use when: user says "forget this", "delete that memory".
memory_patterns — Detect recurring patterns across sessions.
Use when: you want to understand project-level trends over time.
memory_consolidate — Run the 4-tier memory consolidation pipeline.
Use when: you want to compress and organize accumulated session observations.
All memory tools start with \`agentmemory_memory_\`. Use the exact names as they appear in your tool list. Tool results are JSON. Always check what was returned before presenting to the user.
</agentmemory-instructions>`;
function extractFilePaths(args: Record<string, unknown>): string[] {
const files: string[] = [];
for (const key of FILE_KEYS) {
const val = args[key];
if (typeof val === "string" && val.length > 0) {
files.push(val);
}
}
return files;
}
function extractErrorMessage(err: unknown): string {
if (typeof err === "string") return err;
if (err && typeof err === "object") {
const e = err as Record<string, unknown>;
if (typeof e.message === "string") return e.message;
if (e.data && typeof e.data === "object") {
const d = e.data as Record<string, unknown>;
if (typeof d.message === "string") return d.message;
}
if (typeof e.name === "string") return e.name;
try { return JSON.stringify(err); } catch { return ""; }
}
return String(err ?? "");
}
export const AgentmemoryCapturePlugin: Plugin = async (ctx) => {
projectPath = ctx.worktree || ctx.project?.id || process.cwd();
return {
event: async ({ event }) => {
const type = event.type;
const props = (event as any).properties || {};
// ── session.created ──
if (type === "session.created") {
const info = props.info as Record<string, unknown> | undefined;
activeSessionId = (info?.id as string) || props.sessionID || null;
if (!activeSessionId) return;
stashedFiles.set(activeSessionId, new Set());
seenSubtaskIds.delete(activeSessionId);
seenToolCallIds.delete(activeSessionId);
contextInjectedSessions.delete(activeSessionId);
// Snapshot the session id locally — `activeSessionId` is mutable
// and another `session.created` event during the await could
// rebind it, causing context to be cached against the wrong key.
const sessionId = activeSessionId;
const startResult = await postJson("/session/start", {
sessionId,
title: info?.title ?? null,
parentID: info?.parentID ?? null,
version: info?.version ?? null,
project: projectPath,
cwd: projectPath,
});
// cache the context returned at session/start so the
// chat.system.transform hook injects it without a second fetch.
const startCtx = (startResult as any)?.context;
if (typeof startCtx === "string" && startCtx.length > 0) {
startContextCache.set(sessionId, startCtx);
}
if (pendingConfig) {
await observe(sessionId, "config_loaded", pendingConfig);
pendingConfig = null;
}
}
// ── session.idle ── (summarize handled in session.status idle branch)
// ── session.status ──
if (type === "session.status") {
const status = props.status as Record<string, unknown> | undefined;
const sid = props.sessionID || activeSessionId;
if (!sid || !status) return;
if (status.type === "idle") {
await post("/summarize", { sessionId: sid });
}
await observe(sid, "session_status", {
status_type: status.type,
attempt: status.attempt ?? null,
message: safeSlice(status.message, 2000),
});
}
// ── session.compacted ──
if (type === "session.compacted") {
const sid = props.sessionID || activeSessionId;
if (sid) {
await post("/summarize", { sessionId: sid });
await observe(sid, "session_compacted", {});
}
}
// ── session.updated ──
if (type === "session.updated") {
const info = props.info as Record<string, unknown> | undefined;
const sid = (info?.id as string) || props.sessionID || activeSessionId;
if (!sid) return;
await observe(sid, "session_updated", {
title: info?.title ?? null,
parentID: info?.parentID ?? null,
additions: (info?.summary as any)?.additions ?? null,
deletions: (info?.summary as any)?.deletions ?? null,
files: (info?.summary as any)?.files ?? null,
});
}
// ── session.diff ──
if (type === "session.diff") {
const sid = props.sessionID || activeSessionId;
if (!sid || !Array.isArray(props.diff)) return;
const diffs = props.diff as Array<Record<string, unknown>>;
await observe(sid, "session_diff", {
files: diffs.map(d => d.file),
additions: diffs.reduce((s, d) => s + ((d.additions as number) || 0), 0),
deletions: diffs.reduce((s, d) => s + ((d.deletions as number) || 0), 0),
diffs: diffs.slice(0, 50),
});
}
// ── session.deleted ──
if (type === "session.deleted") {
const sid = props.info?.id || props.sessionID || activeSessionId;
if (!sid) {
if (DEBUG) console.error("[agentmemory] session.deleted with no session ID");
return;
}
await post("/session/end", { sessionId: sid });
post("/crystals/auto", { olderThanDays: 7 }, 30000);
post("/consolidate-pipeline", { tier: "all", force: true }, 30000);
if (sid === activeSessionId) activeSessionId = null;
stashedFiles.delete(sid);
startContextCache.delete(sid);
seenSubtaskIds.delete(sid);
seenToolCallIds.delete(sid);
contextInjectedSessions.delete(sid);
}
// ── session.error ──
if (type === "session.error") {
const sid = props.sessionID || activeSessionId;
if (sid) {
await observe(sid, "post_tool_failure", {
tool_name: "session.error",
tool_input: "",
tool_output: safeSlice(props.error, 8000),
});
}
}
// ── message.updated ──
if (type === "message.updated") {
const info = props.info as Record<string, unknown> | undefined;
if (!info) return;
if (info.role === "assistant") {
const sid = props.sessionID || (info.sessionID as string) || activeSessionId;
if (!sid) return;
const tokens = info.tokens as Record<string, unknown> | undefined;
const error = info.error ? extractErrorMessage(info.error) : null;
await observe(sid, "assistant_message", {
messageID: info.id,
parentID: info.parentID,
modelID: info.modelID,
providerID: info.providerID,
mode: info.mode,
cost: info.cost ?? 0,
tokens: {
input: tokens?.input ?? 0,
output: tokens?.output ?? 0,
reasoning: tokens?.reasoning ?? 0,
cache_read: (tokens?.cache as any)?.read ?? 0,
cache_write: (tokens?.cache as any)?.write ?? 0,
},
finish: info.finish ?? null,
error,
duration_ms: (info.time && typeof (info.time as any).completed === "number")
? (info.time as any).completed - ((info.time as any).created || 0)
: null,
});
}
}
// ── message.removed ──
if (type === "message.removed") {
const sid = props.sessionID || activeSessionId;
if (sid) {
await observe(sid, "message_removed", {
messageID: props.messageID,
});
}
}
// ── message.part.updated ──
if (type === "message.part.updated") {
const part = props.part as Record<string, unknown> | undefined;
if (!part) return;
const sid = (part.sessionID as string) || props.sessionID || activeSessionId;
if (!sid) return;
if (part.type === "subtask") {
const subtaskId = part.id as string;
if (!subtaskId) return;
const subtaskSet = subtaskSetFor(sid);
if (subtaskSet.has(subtaskId)) return;
subtaskSet.add(subtaskId);
await observe(sid, "subagent_start", {
subtask_id: part.id,
agent: part.agent,
prompt: safeSlice(part.prompt, 4000),
description: safeSlice(part.description, 2000),
});
return;
}
if (part.type === "tool") {
const state = part.state as Record<string, unknown> | undefined;
if (!state) return;
const callId = part.callID as string;
if (!callId) return;
const toolName = part.tool as string;
if (state.status === "completed") {
const callSet = toolCallSetFor(sid);
if (callSet.has(callId)) return;
callSet.add(callId);
const st = state as Record<string, unknown>;
const rawTime = (st.time as any) || {};
const startTime = typeof rawTime.start === "number" ? rawTime.start : null;
const endTime = typeof rawTime.end === "number" ? rawTime.end : null;
await observe(sid, "post_tool_use", {
tool_name: toolName,
call_id: callId,
tool_input: safeSlice(st.input, 4000),
tool_output: safeSlice(st.output, 8000),
title: st.title ?? null,
metadata: st.metadata || {},
duration_ms: (startTime != null && endTime != null) ? endTime - startTime : null,
attachments: Array.isArray(st.attachments)
? (st.attachments as Array<Record<string, unknown>>).map(a => a.filename || a.url)
: [],
});
} else if (state.status === "error") {
const callSet = toolCallSetFor(sid);
if (callSet.has(callId)) return;
callSet.add(callId);
const st = state as Record<string, unknown>;
const rawTime = (st.time as any) || {};
const startTime = typeof rawTime.start === "number" ? rawTime.start : null;
const endTime = typeof rawTime.end === "number" ? rawTime.end : null;
await observe(sid, "post_tool_failure", {
tool_name: toolName,
call_id: callId,
tool_input: safeSlice(st.input, 4000),
tool_output: safeSlice(st.error, 8000),
duration_ms: (startTime != null && endTime != null) ? endTime - startTime : null,
});
}
return;
}
if (part.type === "step-finish") {
await observe(sid, "step_finish", {
messageID: part.messageID,
reason: part.reason ?? null,
cost: (part as any).cost ?? 0,
input_tokens: ((part as any).tokens?.input as number) ?? 0,
output_tokens: ((part as any).tokens?.output as number) ?? 0,
reasoning_tokens: ((part as any).tokens?.reasoning as number) ?? 0,
});
return;
}
if (part.type === "reasoning") {
await observe(sid, "reasoning", {
messageID: part.messageID,
text: safeSlice((part as any).text, 4000),
});
return;
}
if (part.type === "file") {
const filename = (part as any).filename || (part as any).url || null;
if (filename) stashFor(sid).add(filename);
return;
}
if (part.type === "patch") {
await observe(sid, "patch_applied", {
messageID: part.messageID,
hash: (part as any).hash,
files: (part as any).files || [],
});
return;
}
if (part.type === "compaction") {
await observe(sid, "compaction_event", {
messageID: part.messageID,
auto: (part as any).auto ?? false,
});
return;
}
if (part.type === "agent") {
await observe(sid, "agent_selected", {
messageID: part.messageID,
name: (part as any).name,
});
return;
}
if (part.type === "retry") {
await observe(sid, "retry_attempt", {
messageID: part.messageID,
attempt: (part as any).attempt,
error: safeSlice((part as any).error, 2000),
});
return;
}
}
// ── file.edited ──
if (type === "file.edited") {
const sid = props.sessionID || activeSessionId;
if (sid && typeof props.file === "string" && props.file.length > 0) {
const stash = stashFor(sid);
stash.add(props.file);
if (stash.size > MAX_STASHED_FILES) {
const keep = [...stash].slice(-MAX_STASHED_FILES);
stash.clear();
for (const f of keep) stash.add(f);
}
}
}
// ── permission.updated ──
if (type === "permission.updated") {
const sid = props.sessionID || activeSessionId;
if (!sid) return;
await observe(sid, "notification", {
notification_type: "permission_prompt",
permission: props.type || "unknown",
pattern: Array.isArray(props.pattern)
? props.pattern.join(", ")
: (props.pattern || ""),
tool_call_id: props.callID || null,
title: props.title || props.type || "",
metadata: props.metadata || {},
});
}
// ── permission.replied ──
if (type === "permission.replied") {
const sid = props.sessionID || activeSessionId;
if (!sid) return;
await observe(sid, "permission_replied", {
permission_id: props.permissionID || props.requestID || "",
response: props.response || props.reply || "",
});
}
// ── todo.updated ──
if (type === "todo.updated") {
const sid = props.sessionID || activeSessionId;
const todos = Array.isArray(props.todos) ? props.todos.slice(0, 100) : [];
if (!sid || todos.length === 0) return;
const completed = todos.filter((t: any) => t.status === "completed");
const active = todos.filter((t: any) => t.status !== "completed");
await observe(sid, "task_completed", {
completed: completed.map((t: any) => ({ content: t.content, priority: t.priority })),
in_progress: active.map((t: any) => ({ content: t.content, priority: t.priority })),
total: todos.length,
});
}
// ── command.executed ──
if (type === "command.executed") {
const sid = props.sessionID || activeSessionId;
if (sid) {
await observe(sid, "command_executed", {
name: props.name,
arguments: props.arguments || "",
});
}
}
},
// ── chat.message ──
"chat.message": async (input, output) => {
const sid = input.sessionID || activeSessionId;
if (!sid) return;
const parts = output.parts || [];
const files = parts
.filter((p: any) => p.type === "file")
.map((p: any) => p.filename || p.url)
.filter(Boolean);
for (const f of files) {
const stash = stashFor(sid);
stash.add(f);
if (stash.size > MAX_STASHED_FILES) {
const keep = [...stash].slice(-MAX_STASHED_FILES);
stash.clear();
for (const k of keep) stash.add(k);
}
}
const textParts = parts.filter((p: any) => p.type === "text" && !p.synthetic && !p.ignored);
const userText = textParts.map((p: any) => p.text || "").join("\n");
await observe(sid, "prompt_submit", {
agent: input.agent ?? null,
model: input.model ?? null,
variant: input.variant ?? null,
prompt: userText.slice(0, 8000),
files: files.slice(0, 20),
parts_summary: parts.map((p: any) => p.type).filter(Boolean),
});
},
// ── chat.params ──
"chat.params": async (input, output) => {
if (!input.model || !output) return;
const sid = input.sessionID || activeSessionId;
if (!sid) return;
await observe(sid, "llm_params", {
agent: input.agent,
model: `${input.model.providerID}/${input.model.id}`,
provider_url: input.model.api?.url ?? null,
temperature: output.temperature,
topP: output.topP,
max_output_tokens: input.model.limit?.output ?? null,
context_limit: input.model.limit?.context ?? null,
cost_1k_input: input.model.cost?.input ?? 0,
cost_1k_output: input.model.cost?.output ?? 0,
});
},
// ── tool.execute.before ──
"tool.execute.before": async (input, output) => {
if (!FILE_TOOLS.has(input.tool)) return;
const sid = input.sessionID || activeSessionId;
if (!sid) return;
const args = output.args as Record<string, unknown> | undefined;
if (!args) return;
const stash = stashFor(sid);
for (const fp of extractFilePaths(args)) {
stash.add(fp);
}
if (stash.size > MAX_STASHED_FILES) {
const keep = [...stash].slice(-MAX_STASHED_FILES);
stash.clear();
for (const f of keep) stash.add(f);
}
},
// ── experimental.chat.system.transform ──
"experimental.chat.system.transform": async (input, output) => {
const sid = input.sessionID || activeSessionId;
if (!sid) return;
if (!contextInjectedSessions.has(sid)) {
if (!Array.isArray(output.system)) return;
output.system.push(AGENTMEMORY_INSTRUCTIONS);
// prefer the context already fetched at session.created;
// fall back to a fresh /context call if the cache missed (e.g.
// session resumed across plugin reloads).
let ctx = startContextCache.get(sid);
if (typeof ctx !== "string" || ctx.length === 0) {
const result = await postJson("/context", {
sessionId: sid,
project: projectPath,
});
ctx = (result as any)?.context;
} else {
startContextCache.delete(sid);
}
if (typeof ctx === "string" && ctx.length > 0) {
output.system.push(ctx);
}
contextInjectedSessions.add(sid);
}
const stash = stashFor(sid);
if (stash.size === 0) return;
const files = [...stash].slice(0, 10);
const enrichResult = await postJson("/enrich", {
sessionId: sid,
files,
toolName: "enrich_inject",
});
const enrichCtx = (enrichResult as any)?.context;
if (typeof enrichCtx === "string" && enrichCtx.length > 0) {
if (Array.isArray(output.system)) {
output.system.push(enrichCtx);
}
for (const f of files) stash.delete(f);
}
},
// ── experimental.session.compacting (WIP) ──
"experimental.session.compacting": async (input, output) => {
const sid = input.sessionID || activeSessionId;
if (!sid) return;
const result = await postJson("/context", {
sessionId: sid,
project: projectPath,
});
const ctx = (result as any)?.context;
if (typeof ctx === "string" && ctx.length > 0) {
if (Array.isArray(output.context)) {
output.context.push(ctx);
}
}
},
// ── config ──
config: async (input) => {
const payload: Record<string, unknown> = {
theme: input.theme ?? null,
model: input.model ?? null,
autoupdate: input.autoupdate ?? null,
agents: typeof input.agent === "object" && input.agent !== null && !Array.isArray(input.agent)
? Object.keys(input.agent as Record<string, unknown>)
: Array.isArray(input.agent) ? input.agent : [],
mcp_servers: typeof input.mcp === "object" && input.mcp !== null && !Array.isArray(input.mcp)
? Object.keys(input.mcp as Record<string, unknown>)
: Array.isArray(input.mcp) ? input.mcp : [],
providers: typeof input.provider === "object" && input.provider !== null && !Array.isArray(input.provider)
? Object.keys(input.provider as Record<string, unknown>)
: Array.isArray(input.provider) ? input.provider : [],
permission: input.permission ?? null,
};
if (activeSessionId) {
await observe(activeSessionId, "config_loaded", payload);
} else {
pendingConfig = payload;
}
},
};
};