chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,687 @@
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user