From f822d93662f5fa6360d858053c20a77b27a93ba4 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Tue, 21 Apr 2026 11:17:34 +0000 Subject: [PATCH] Collapse 8 tools to 2: memory router + memory_compact All read-only operations now dispatch through a single memory tool that routes by {tool: "name", args: {...}}. This cuts visible tool count from 8 to 2, significantly reducing context bloat for small models. The memory_compact tool remains separate as it is a write/mutation operation. --- AGENTS.md | 27 +- src/tools.ts | 726 ++++++++++++++++++++++++--------------------------- 2 files changed, 355 insertions(+), 398 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 064915b..a1778fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,18 +59,27 @@ src/ | `experimental.chat.system.transform` | Inject context % used + advisory into system prompt | | `event` | Feed SSE events to ContextTracker | -### Tools (8) +### Tools (2) | Tool | Purpose | |------|---------| -| `memory_context` | Current context window usage (% , tokens, model, status) | -| `memory_compact` | Trigger compaction via `ctx.client.session.summarize()` | -| `memory_compactions` | List/read compaction checkpoints (summaries) for a session | -| `memory_summary` | Quick counts: projects, sessions, messages, todos | -| `memory_sessions` | List recent sessions, optionally filtered by project path | -| `memory_messages` | Read messages from a specific session | -| `memory_search` | Text search across all conversations (LIKE-based) | -| `memory_plans` | List/read saved plan files | +| `memory` | Router for all read-only operations: summary, sessions, messages, search, compactions, context, plans, help. Call with `{tool: "help"}` to see available operations. | +| `memory_compact` | Trigger compaction via `ctx.client.session.summarize()` — kept separate because it's a mutation | + +The `memory` tool dispatches to internal handlers by `tool` name, keeping the agent's visible tool count low (2 instead of 8) to minimize context bloat. + +**Internal operations** (accessed via `memory({tool: "...", args: {...}})`): + +| Operation | Purpose | Key args | +|-----------|---------|----------| +| help | Show available operations, or details for a specific one | tool (optional) | +| summary | Quick counts: projects, sessions, messages, todos | — | +| sessions | List recent sessions, optionally filtered by project | limit, projectPath | +| messages | Read messages from a specific session | sessionId, limit | +| search | Text search across all conversations (LIKE-based) | query, limit | +| compactions | List/read compaction checkpoints for a session | sessionId, read | +| context | Current context window usage (% , tokens, model, status) | — | +| plans | List or read saved plan files | read (filename) | ### Database Access diff --git a/src/tools.ts b/src/tools.ts index 708b72d..fa68e45 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -9,81 +9,357 @@ const z = tool.schema; const DATA_ROOT = process.env.XDG_DATA_HOME || `${process.env.HOME}/.local/share/opencode`; +type ToolArgs = Record; + +const HELP_TEXT = `# Memory Tools + +Call \`memory({tool: "", args: {...}})\` to use one. + +| Tool | Description | Key args | +|------|-------------|----------| +| summary | Count of projects, sessions, messages, todos | — | +| sessions | List recent sessions, optionally filtered by project | limit, projectPath | +| messages | Read messages from a session as formatted conversation | sessionId, limit | +| search | Text search across all conversations | query, limit | +| compactions | List/read compaction checkpoints for a session | sessionId, read (1-based index) | +| context | Current context window usage (% , tokens, status) | — | +| plans | List or read saved plan files | read (filename) | +| help | Show this reference, or details for a specific tool | tool | + +Examples: +- \`memory({tool: "search", args: {query: "safetensors"}})\` +- \`memory({tool: "compactions", args: {sessionId: "ses_abc", read: 1}})\` +- \`memory({tool: "help", args: {tool: "search"}})\``; + +const TOOL_HELP: Record = { + summary: `**summary** — Quick counts: projects, sessions, messages, todos. No args needed.`, + sessions: `**sessions** — List recent sessions with titles, update times, message counts. +Args: limit (number, default 10), projectPath (string, optional filter by worktree path).`, + messages: `**messages** — Read messages from a specific session as formatted conversation. +Args: sessionId (string, required), limit (number, default 50).`, + search: `**search** — Text search across all conversations. Returns matching snippets with session references. +Args: query (string, required), limit (number, default 10).`, + compactions: `**compactions** — List compaction checkpoints for a session. Compactions are summaries created when context was freed. Use 'read' to get the full summary text — these act as checkpoints showing what was important at that point. +Args: sessionId (string, required), read (number, optional 1-based index to read full summary).`, + context: `**context** — Current context window usage: percentage, token counts, model, status (green/yellow/red/critical). No args needed.`, + plans: `**plans** — List or read saved plan files from OpenCode's plans directory. +Args: read (string, optional filename to read full content). Lists all plans if omitted.`, + help: `**help** — Show available tools and usage. Args: tool (string, optional tool name for details).`, +}; + +type MemoryHandler = ( + args: ToolArgs, + context: { sessionID?: string }, + ctx: PluginInput, + tracker: ContextTracker, +) => string | Promise; + +const handlers: Record = { + help(args) { + if (args.tool && typeof args.tool === "string") { + return ( + TOOL_HELP[args.tool] ?? + `Unknown tool: ${args.tool}. Call memory({tool: "help"}) for the full list.` + ); + } + return HELP_TEXT; + }, + + summary() { + try { + const rows = runQuery>(` + SELECT 'projects', COUNT(*) FROM project + UNION ALL SELECT 'sessions (main)', COUNT(*) FROM session WHERE parent_id IS NULL + UNION ALL SELECT 'sessions (total)', COUNT(*) FROM session + UNION ALL SELECT 'messages', COUNT(*) FROM message + UNION ALL SELECT 'todos', COUNT(*) FROM todo + `); + if (!rows || rows.length === 0) return "No data found."; + + const lines = ["# OpenCode Memory Summary\n"]; + for (const row of rows) { + const values = Object.values(row); + lines.push(`- **${values[0]}**: ${values[1]}`); + } + return lines.join("\n"); + } catch (err) { + return `Failed to query database: ${err instanceof Error ? err.message : String(err)}`; + } + }, + + sessions(args) { + const limit = (args.limit as number) ?? 10; + const projectPath = args.projectPath as string | undefined; + + try { + type SessionRow = { + id: string; + title: string; + project?: string; + updated: string; + msgs: number; + }; + let rows: SessionRow[]; + + if (projectPath) { + rows = runQuery( + `SELECT s.id, COALESCE(s.title, 'untitled') AS title, + datetime(s.time_updated/1000, 'unixepoch', 'localtime') AS updated, + (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) AS msgs + FROM session s JOIN project p ON p.id = s.project_id + WHERE p.worktree = $projectPath AND s.parent_id IS NULL + ORDER BY s.time_updated DESC LIMIT $limit`, + { $projectPath: projectPath, $limit: limit }, + ); + } else { + rows = runQuery( + `SELECT s.id, COALESCE(s.title, 'untitled') AS title, + COALESCE(p.name, CASE WHEN p.worktree = '/' THEN '(global)' ELSE REPLACE(p.worktree, RTRIM(p.worktree, REPLACE(p.worktree, '/', '')), '') END) AS project, + datetime(s.time_updated/1000, 'unixepoch', 'localtime') AS updated, + (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) AS msgs + FROM session s LEFT JOIN project p ON p.id = s.project_id + WHERE s.parent_id IS NULL ORDER BY s.time_updated DESC LIMIT $limit`, + { $limit: limit }, + ); + } + + if (!rows || rows.length === 0) return "No sessions found."; + return formatSessionList(rows); + } catch (err) { + return `Failed to query sessions: ${err instanceof Error ? err.message : String(err)}`; + } + }, + + messages(args) { + const sessionId = args.sessionId as string; + const limit = (args.limit as number) ?? 50; + if (!sessionId) return "sessionId is required."; + + try { + type MessageRow = { role: string; time: string; text: string }; + const rows = runQuery( + `SELECT json_extract(m.data, '$.role') AS role, + datetime(m.time_created/1000, 'unixepoch', 'localtime') AS time, + GROUP_CONCAT(json_extract(p.data, '$.text'), char(10)) AS text + FROM message m LEFT JOIN part p ON p.message_id = m.id AND json_extract(p.data, '$.type') = 'text' + WHERE m.session_id = $sessionId GROUP BY m.id ORDER BY m.time_created ASC LIMIT $limit`, + { $sessionId: sessionId, $limit: limit }, + ); + if (!rows || rows.length === 0) return `No messages found for session ${sessionId}.`; + return formatMessageList(rows); + } catch (err) { + return `Failed to query messages: ${err instanceof Error ? err.message : String(err)}`; + } + }, + + search(args) { + const query = args.query as string; + const limit = (args.limit as number) ?? 10; + if (!query) return "query is required."; + + try { + return searchConversations(query, limit); + } catch (err) { + return `Search failed: ${err instanceof Error ? err.message : String(err)}`; + } + }, + + compactions(args) { + const sessionId = args.sessionId as string; + const read = args.read as number | undefined; + if (!sessionId) return "sessionId is required."; + + try { + type CompactionMeta = { + compaction_msg_id: string; + compaction_time: number; + time: string; + is_auto: number; + overflow: number; + }; + type SummaryRow = { summary_text: string }; + + const compactions = runQuery( + `SELECT cp_msg.id AS compaction_msg_id, cp_msg.time_created AS compaction_time, + datetime(cp_msg.time_created/1000, 'unixepoch', 'localtime') AS time, + COALESCE(json_extract(cp_part.data, '$.auto'), 0) AS is_auto, + COALESCE(json_extract(cp_part.data, '$.overflow'), 0) AS overflow + FROM part cp_part JOIN message cp_msg ON cp_msg.id = cp_part.message_id + WHERE cp_msg.session_id = $sessionId AND json_extract(cp_part.data, '$.type') = 'compaction' + ORDER BY cp_msg.time_created ASC`, + { $sessionId: sessionId }, + ); + + if (!compactions || compactions.length === 0) return "No compactions found for this session."; + + if (read !== undefined) { + const idx = read - 1; + if (idx < 0 || idx >= compactions.length) + return `Invalid compaction index. Session has ${compactions.length} compaction(s). Use 1-${compactions.length}.`; + const comp = compactions[idx]; + const summaryRows = runQuery( + `SELECT json_extract(p.data, '$.text') AS summary_text FROM message m + JOIN part p ON p.message_id = m.id + WHERE m.session_id = $sessionId AND json_extract(m.data, '$.role') = 'assistant' + AND json_extract(p.data, '$.type') = 'text' AND m.time_created > $compactionTime + ORDER BY m.time_created ASC LIMIT 1`, + { $sessionId: sessionId, $compactionTime: comp.compaction_time }, + ); + const summaryText = summaryRows?.[0]?.summary_text ?? "(no summary text found)"; + return [ + `# Compaction ${read}`, + `Time: ${comp.time}`, + `Auto: ${comp.is_auto ? "yes" : "no"}`, + `Overflow: ${comp.overflow ? "yes" : "no"}`, + "", + summaryText, + ].join("\n"); + } + + const lines = [ + `# Compactions (${compactions.length})\n`, + "| # | Time | Auto | Summary |", + "|---|------|------|---------|", + ]; + for (let i = 0; i < compactions.length; i++) { + const comp = compactions[i]; + const summaryRows = runQuery( + `SELECT substr(json_extract(p.data, '$.text'), 1, 150) AS summary_text FROM message m + JOIN part p ON p.message_id = m.id + WHERE m.session_id = $sessionId AND json_extract(m.data, '$.role') = 'assistant' + AND json_extract(p.data, '$.type') = 'text' AND m.time_created > $compactionTime + ORDER BY m.time_created ASC LIMIT 1`, + { $sessionId: sessionId, $compactionTime: comp.compaction_time }, + ); + const preview = summaryRows?.[0]?.summary_text + ? `${summaryRows[0].summary_text.replace(/\n/g, " ").substring(0, 60)}...` + : "(no summary)"; + lines.push(`| ${i + 1} | ${comp.time} | ${comp.is_auto ? "yes" : "no"} | ${preview} |`); + } + lines.push( + "", + `Use memory({tool: "compactions", args: {sessionId: "...", read: N}}) to read a full summary.`, + ); + return lines.join("\n"); + } catch (err) { + return `Failed to query compactions: ${err instanceof Error ? err.message : String(err)}`; + } + }, + + context(_args, context, _ctx, tracker) { + if (!context.sessionID) return "No active session."; + const info = tracker.getContextInfo(context.sessionID); + if (!info) + return "No context data available yet. Send a message first to establish context tracking."; + + const statusLabel = + info.status === "critical" + ? "CRITICAL — imminent compaction" + : info.status === "red" + ? "RED — compact soon" + : info.status === "yellow" + ? "YELLOW — consider compacting" + : "GREEN — healthy"; + + const lines = [ + `Context: ${info.percentage}% used`, + `Tokens: ${info.usedTokens.toLocaleString()} / ${info.limitTokens.toLocaleString()}`, + `Model: ${info.model}`, + `Status: ${statusLabel}`, + ]; + if (info.trend === "growing") lines.push("Trend: Context is growing rapidly."); + if (info.status === "red" || info.status === "critical") + lines.push( + "", + "Recommendation: Use memory_compact to trigger compaction at a natural break point.", + ); + return lines.join("\n"); + }, + + async plans(args) { + const plansDir = `${DATA_ROOT}/plans`; + + if (args.read && typeof args.read === "string") { + try { + return await Bun.file(`${plansDir}/${args.read}`).text(); + } catch { + return `Plan file "${args.read}" not found.`; + } + } + + try { + const glob = new Bun.Glob("*.md"); + const files: { name: string; mtime: number; size: number }[] = []; + for await (const file of glob.scan({ cwd: plansDir })) { + const stat = await Bun.file(`${plansDir}/${file}`).stat(); + files.push({ name: file, mtime: stat.mtime.getTime(), size: stat.size }); + } + if (files.length === 0) return "No plans found."; + files.sort((a, b) => b.mtime - a.mtime); + const lines = ["# Plans\n", "| File | Size |", "|------|------|"]; + for (const f of files) { + const sizeStr = f.size > 1024 ? `${(f.size / 1024).toFixed(1)}KB` : `${f.size}B`; + lines.push(`| ${f.name} | ${sizeStr} |`); + } + lines.push( + "", + `Use memory({tool: "plans", args: {read: "filename.md"}}) to view a specific plan.`, + ); + return lines.join("\n"); + } catch { + return "No plans directory found."; + } + }, +}; + export const createTools = ( ctx: PluginInput, tracker: ContextTracker, ): Record => ({ - memory_context: tool({ + memory: tool({ description: - "Check current session context window usage. Shows percentage used, token counts, model limit, and status (green/yellow/red/critical). Use when you need to understand how close you are to automatic compaction.", - args: {}, - async execute(_args, context) { - if (!context.sessionID) { - return "No active session."; + 'Access your session history, context status, compaction checkpoints, and search past conversations. Call with {tool: "help"} to see available operations.', + args: { + tool: z + .string() + .describe( + "Operation name: summary, sessions, messages, search, compactions, context, plans, help.", + ), + args: z + .record(z.string(), z.unknown()) + .optional() + .describe('Arguments for the operation. Call {tool: "help"} for details.'), + }, + async execute(input, context) { + const toolName = input.tool; + const toolArgs = (input.args as ToolArgs) ?? {}; + const handler = handlers[toolName]; + if (!handler) + return `Unknown tool: ${toolName}. Call memory({tool: "help"}) for available operations.`; + try { + return await handler(toolArgs, context, ctx, tracker); + } catch (err) { + return `Error in ${toolName}: ${err instanceof Error ? err.message : String(err)}`; } - const info = tracker.getContextInfo(context.sessionID); - if (!info) { - return "No context data available yet. Send a message first to establish context tracking."; - } - - const statusLabel = - info.status === "critical" - ? "CRITICAL — imminent compaction" - : info.status === "red" - ? "RED — compact soon" - : info.status === "yellow" - ? "YELLOW — consider compacting" - : "GREEN — healthy"; - - const lines = [ - `Context: ${info.percentage}% used`, - `Tokens: ${info.usedTokens.toLocaleString()} / ${info.limitTokens.toLocaleString()}`, - `Model: ${info.model}`, - `Status: ${statusLabel}`, - ]; - - if (info.trend === "growing") { - lines.push("Trend: Context is growing rapidly."); - } - - if (info.status === "red" || info.status === "critical") { - lines.push(""); - lines.push( - "Recommendation: Use memory_compact to trigger compaction at a natural break point.", - ); - } - - return lines.join("\n"); }, }), memory_compact: tool({ description: - "Trigger compaction on the current session. This summarizes the conversation so far to free context space. Use when context is getting full (80%+) and you want to control when compaction happens, rather than letting it fire automatically at 92%.", + "Trigger compaction on the current session. Summarizes the conversation so far to free context space. Use when context is getting full (80%+) to control when compaction happens, rather than letting it fire automatically at 92%.", args: {}, async execute(_args, context) { - if (!context.sessionID) { - return "No active session."; - } + if (!context.sessionID) return "No active session."; const info = tracker.getContextInfo(context.sessionID); - if (info && info.percentage < 50) { + if (info && info.percentage < 50) return `Context is only at ${info.percentage}%. Compaction is not needed yet. Consider waiting until 80%+ for best results.`; - } - const session = await ctx.client.session.get({ - path: { id: context.sessionID }, - }); - if (session.error) { - return `Failed to get session: ${session.error}`; - } + const session = await ctx.client.session.get({ path: { id: context.sessionID } }); + if (session.error) return `Failed to get session: ${session.error}`; - const messages = await ctx.client.session.messages({ - path: { id: context.sessionID }, - }); - if (messages.error) { - return `Failed to get messages: ${messages.error}`; - } + const messages = await ctx.client.session.messages({ path: { id: context.sessionID } }); + if (messages.error) return `Failed to get messages: ${messages.error}`; const lastUserMessage = [...(messages.data ?? [])] .reverse() @@ -98,27 +374,19 @@ export const createTools = ( typeof infoAny.model === "object" && infoAny.model !== null ? (infoAny.model as Record) : null; - if (modelObj?.providerID && typeof modelObj.providerID === "string") { + if (modelObj?.providerID && typeof modelObj.providerID === "string") providerID = modelObj.providerID; - } - if (modelObj?.modelID && typeof modelObj.modelID === "string") { - modelID = modelObj.modelID; - } + if (modelObj?.modelID && typeof modelObj.modelID === "string") modelID = modelObj.modelID; } - if (!providerID || !modelID) { + if (!providerID || !modelID) return "Cannot determine model for compaction. Please ensure the session has at least one message."; - } - - const pid = providerID as string; - const mid = modelID as string; try { await ctx.client.session.summarize({ path: { id: context.sessionID }, - body: { providerID: pid, modelID: mid }, + body: { providerID, modelID }, }); - const contextNote = info ? ` (was at ${info.percentage}%)` : ""; return `Compaction triggered successfully${contextNote}. The session will be summarized and you'll continue with freed context space.`; } catch (err) { @@ -126,324 +394,4 @@ export const createTools = ( } }, }), - - memory_summary: tool({ - description: - "Get a quick summary of your OpenCode local memory: count of projects, sessions, messages, and todos.", - args: {}, - async execute() { - try { - const rows = runQuery>(` - SELECT 'projects', COUNT(*) FROM project - UNION ALL SELECT 'sessions (main)', COUNT(*) FROM session WHERE parent_id IS NULL - UNION ALL SELECT 'sessions (total)', COUNT(*) FROM session - UNION ALL SELECT 'messages', COUNT(*) FROM message - UNION ALL SELECT 'todos', COUNT(*) FROM todo - `); - if (!rows || rows.length === 0) return "No data found."; - - const lines = ["# OpenCode Memory Summary\n"]; - for (const row of rows) { - const values = Object.values(row); - lines.push(`- **${values[0]}**: ${values[1]}`); - } - return lines.join("\n"); - } catch (err) { - return `Failed to query database: ${err instanceof Error ? err.message : String(err)}`; - } - }, - }), - - memory_sessions: tool({ - description: - "List recent sessions with titles, update times, and message counts. Optionally filter by project path.", - args: { - limit: z.number().optional().describe("Number of sessions to show (default: 10)."), - projectPath: z.string().optional().describe("Filter to a specific project worktree path."), - }, - async execute(args) { - const limit = args.limit ?? 10; - - try { - type SessionRow = { - id: string; - title: string; - project?: string; - updated: string; - msgs: number; - }; - - let rows: SessionRow[]; - - if (args.projectPath) { - rows = runQuery( - ` - SELECT - s.id, - COALESCE(s.title, 'untitled') AS title, - datetime(s.time_updated/1000, 'unixepoch', 'localtime') AS updated, - (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) AS msgs - FROM session s - JOIN project p ON p.id = s.project_id - WHERE p.worktree = $projectPath - AND s.parent_id IS NULL - ORDER BY s.time_updated DESC - LIMIT $limit - `, - { $projectPath: args.projectPath, $limit: limit }, - ); - } else { - rows = runQuery( - ` - SELECT - s.id, - COALESCE(s.title, 'untitled') AS title, - COALESCE(p.name, CASE WHEN p.worktree = '/' THEN '(global)' ELSE REPLACE(p.worktree, RTRIM(p.worktree, REPLACE(p.worktree, '/', '')), '') END) AS project, - datetime(s.time_updated/1000, 'unixepoch', 'localtime') AS updated, - (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) AS msgs - FROM session s - LEFT JOIN project p ON p.id = s.project_id - WHERE s.parent_id IS NULL - ORDER BY s.time_updated DESC - LIMIT $limit - `, - { $limit: limit }, - ); - } - - if (!rows || rows.length === 0) { - return "No sessions found."; - } - return formatSessionList(rows); - } catch (err) { - return `Failed to query sessions: ${err instanceof Error ? err.message : String(err)}`; - } - }, - }), - - memory_messages: tool({ - description: - "Read messages from a specific session. Returns formatted conversation with roles and timestamps.", - args: { - sessionId: z.string().describe("Session ID to read messages from."), - limit: z.number().optional().describe("Number of messages to return (default: 50)."), - }, - async execute(args) { - const limit = args.limit ?? 50; - - try { - type MessageRow = { - role: string; - time: string; - text: string; - }; - - const rows = runQuery( - ` - SELECT - json_extract(m.data, '$.role') AS role, - datetime(m.time_created/1000, 'unixepoch', 'localtime') AS time, - GROUP_CONCAT(json_extract(p.data, '$.text'), char(10)) AS text - FROM message m - LEFT JOIN part p ON p.message_id = m.id - AND json_extract(p.data, '$.type') = 'text' - WHERE m.session_id = $sessionId - GROUP BY m.id - ORDER BY m.time_created ASC - LIMIT $limit - `, - { $sessionId: args.sessionId, $limit: limit }, - ); - if (!rows || rows.length === 0) { - return `No messages found for session ${args.sessionId}.`; - } - return formatMessageList(rows); - } catch (err) { - return `Failed to query messages: ${err instanceof Error ? err.message : String(err)}`; - } - }, - }), - - memory_search: tool({ - description: - "Search across all conversations for a term. Returns matching snippets with session references.", - args: { - query: z.string().describe("Search term to find in conversations."), - limit: z.number().optional().describe("Max results (default: 10)."), - }, - async execute(args) { - const limit = args.limit ?? 10; - - try { - return searchConversations(args.query, limit); - } catch (err) { - return `Search failed: ${err instanceof Error ? err.message : String(err)}`; - } - }, - }), - - memory_compactions: tool({ - description: - "List compaction events (checkpoints) for a session. Compactions are moments where the conversation was summarized to free context space. Use 'read' to get the full summary text of a specific compaction — these act as checkpoints showing what the agent considered important at that point in the session.", - args: { - sessionId: z.string().describe("Session ID to check compactions for."), - read: z - .number() - .optional() - .describe("Index (1-based) of a specific compaction to read the full summary text for."), - }, - async execute(args) { - try { - type CompactionMeta = { - compaction_msg_id: string; - compaction_time: number; - time: string; - is_auto: number; - overflow: number; - }; - - type SummaryRow = { - summary_text: string; - }; - - const compactions = runQuery( - ` - SELECT - cp_msg.id AS compaction_msg_id, - cp_msg.time_created AS compaction_time, - datetime(cp_msg.time_created/1000, 'unixepoch', 'localtime') AS time, - COALESCE(json_extract(cp_part.data, '$.auto'), 0) AS is_auto, - COALESCE(json_extract(cp_part.data, '$.overflow'), 0) AS overflow - FROM part cp_part - JOIN message cp_msg ON cp_msg.id = cp_part.message_id - WHERE cp_msg.session_id = $sessionId - AND json_extract(cp_part.data, '$.type') = 'compaction' - ORDER BY cp_msg.time_created ASC - `, - { $sessionId: args.sessionId }, - ); - - if (!compactions || compactions.length === 0) { - return "No compactions found for this session."; - } - - if (args.read !== undefined) { - const idx = args.read - 1; - if (idx < 0 || idx >= compactions.length) { - return `Invalid compaction index. Session has ${compactions.length} compaction(s). Use 1-${compactions.length}.`; - } - const comp = compactions[idx]; - const summaryRows = runQuery( - ` - SELECT json_extract(p.data, '$.text') AS summary_text - FROM message m - JOIN part p ON p.message_id = m.id - WHERE m.session_id = $sessionId - AND json_extract(m.data, '$.role') = 'assistant' - AND json_extract(p.data, '$.type') = 'text' - AND m.time_created > $compactionTime - ORDER BY m.time_created ASC - LIMIT 1 - `, - { $sessionId: args.sessionId, $compactionTime: comp.compaction_time }, - ); - const summaryText = - summaryRows && summaryRows.length > 0 && summaryRows[0].summary_text - ? summaryRows[0].summary_text - : "(no summary text found)"; - const header = [ - `# Compaction ${args.read}`, - `Time: ${comp.time}`, - `Auto: ${comp.is_auto ? "yes" : "no"}`, - `Overflow: ${comp.overflow ? "yes" : "no"}`, - "", - ].join("\n"); - return `${header}${summaryText}`; - } - - const lines = [`# Compactions (${compactions.length})\n`]; - lines.push("| # | Time | Auto | Summary |"); - lines.push("|---|------|------|---------|"); - - for (let i = 0; i < compactions.length; i++) { - const comp = compactions[i]; - const summaryRows = runQuery( - ` - SELECT substr(json_extract(p.data, '$.text'), 1, 150) AS summary_text - FROM message m - JOIN part p ON p.message_id = m.id - WHERE m.session_id = $sessionId - AND json_extract(m.data, '$.role') = 'assistant' - AND json_extract(p.data, '$.type') = 'text' - AND m.time_created > $compactionTime - ORDER BY m.time_created ASC - LIMIT 1 - `, - { $sessionId: args.sessionId, $compactionTime: comp.compaction_time }, - ); - const preview = summaryRows?.[0]?.summary_text - ? `${summaryRows[0].summary_text.replace(/\n/g, " ").substring(0, 60)}...` - : "(no summary)"; - lines.push(`| ${i + 1} | ${comp.time} | ${comp.is_auto ? "yes" : "no"} | ${preview} |`); - } - - lines.push( - "", - `Use memory_compactions with a "read" argument (1-${compactions.length}) to view the full summary for a specific compaction.`, - ); - return lines.join("\n"); - } catch (err) { - return `Failed to query compactions: ${err instanceof Error ? err.message : String(err)}`; - } - }, - }), - - memory_plans: tool({ - description: "List saved plan files from OpenCode's plans directory.", - args: { - read: z.string().optional().describe("Filename of a specific plan to read (without path)."), - }, - async execute(args) { - const plansDir = `${DATA_ROOT}/plans`; - - if (args.read) { - try { - const content = await Bun.file(`${plansDir}/${args.read}`).text(); - return content; - } catch { - return `Plan file "${args.read}" not found.`; - } - } - - try { - const glob = new Bun.Glob("*.md"); - const files: { name: string; mtime: number; size: number }[] = []; - - for await (const file of glob.scan({ cwd: plansDir })) { - const stat = await Bun.file(`${plansDir}/${file}`).stat(); - files.push({ - name: file, - mtime: stat.mtime.getTime(), - size: stat.size, - }); - } - - if (files.length === 0) { - return "No plans found."; - } - - files.sort((a, b) => b.mtime - a.mtime); - - const lines = ["# Plans\n", "| File | Size |", "|------|------|"]; - for (const f of files) { - const sizeStr = f.size > 1024 ? `${(f.size / 1024).toFixed(1)}KB` : `${f.size}B`; - lines.push(`| ${f.name} | ${sizeStr} |`); - } - lines.push("", `Use memory_plans with a "read" argument to view a specific plan.`); - return lines.join("\n"); - } catch { - return "No plans directory found."; - } - }, - }), });