From 047621d83462fa53d401f7da6631bc7d97dc4db6 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Tue, 21 Apr 2026 21:15:28 +0000 Subject: [PATCH] v0.2.0: add message operation, role filter, showTools, full session IDs - Add message operation to retrieve a single message by ID (cleaner output, higher default maxLength of 8000) - Add role filter to messages operation (user/assistant/system) - Hide tool-call parts by default in messages/message output; showTools:true to include them - Show full session IDs instead of truncating to 12 chars - Make per-message maxLength configurable (default 2000 for messages, 8000 for message) - Increase search snippet length from 300 to 500 chars - Add local development & testing docs to AGENTS.md with symlink instructions - Bump version from 0.1.0 to 0.2.0 --- AGENTS.md | 45 +++++++++++++++++++++++++- package.json | 2 +- src/history/format.ts | 36 +++++++++++++++++++-- src/history/search.ts | 6 ++-- src/tools.ts | 73 +++++++++++++++++++++++++++++++++++++------ tests/format.test.ts | 46 ++++++++++++++++++++++----- 6 files changed, 184 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 91266a4..308a77c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,12 +74,17 @@ The `memory` tool dispatches to internal handlers by `tool` name, keeping the ag | 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 | +| messages | Read messages from a session, with filtering options | sessionId, limit, role, showTools, maxLength | +| message | Read a single message by ID (cleaner output, no tool-call noise) | messageId, maxLength | | search | Text search across all conversations (LIKE-based) | query, limit | | compactions | List/read compaction checkpoints for a session | sessionId, read (1-based index) | | context | Current context window usage (% , tokens, model, status) | — | | plans | List or read saved plan files | read (filename) | +**`messages` operation** hides tool-call parts (Read, Write, Bash, etc.) by default for cleaner output. Set `showTools: true` to include them. Use `role` to filter to `"user"`, `"assistant"`, or `"system"`. Use `maxLength` to override the default 2000-char per-message limit. + +**`message` operation** retrieves a single message by its ID (`msg_...`). Default maxLength is 8000 (higher since it's a single message). Tool calls are hidden by default; set `showTools: true` to include them. + ### Database Access - Uses `bun:sqlite` native driver — no subprocess, no `sqlite3` CLI dependency @@ -123,6 +128,41 @@ All write operations (compaction triggering) go through the OpenCode client SDK **`memory_compact` must NOT await `ctx.client.session.summarize()`** — it returns immediately and schedules via `setTimeout(() => { ... }, 0)` because compaction cannot start until the tool returns control to the event loop. +## Local Development & Testing + +OpenCode installs plugins from npm into `~/.cache/opencode/node_modules/`. When doing local development, you must symlink your local repo to that location, otherwise OpenCode will load the stale npm-published version even after you rebuild. + +### Setup (one-time) + +```bash +# Remove the npm-installed copy and replace with a symlink +rm -rf ~/.cache/opencode/node_modules/@alkdev/open-memory +ln -s /workspace/@alkdev/open-memory ~/.cache/opencode/node_modules/@alkdev/open-memory +``` + +### Iteration loop + +```bash +bun run build # rebuild dist/index.js +bun run typecheck # verify types +bun run lint # verify style +bun run test # run tests +``` + +After rebuilding, restart OpenCode to pick up the new build. OpenCode loads plugins at startup and caches the ESM module in memory for the session. + +### Also clear Bun's global cache + +If you previously installed via `bun add`, Bun caches the package in `~/.bun/install/cache/`. After publishing a new version, clear it: + +```bash +rm -rf ~/.bun/install/cache/@alkdev/open-memory* +``` + +### Without the symlink + +Without the symlink, `bun run build` will update `dist/index.js` in your repo, but OpenCode will still load from `~/.cache/opencode/node_modules/@alkdev/open-memory/dist/index.js` which is a separate copy from the npm registry. Rebuilding does NOT update that copy. This is the most common source of "my changes aren't showing up" during plugin development. + ## Key Conventions - No comments unless requested @@ -157,6 +197,9 @@ Read-only tool for introspecting your session history and context state. Availab - `memory({tool: "summary"})` — quick counts of projects, sessions, messages, todos - `memory({tool: "sessions"})` — list recent sessions (useful for finding past work) - `memory({tool: "messages", args: {sessionId: "..."}})` — read a session's conversation +- `memory({tool: "messages", args: {sessionId: "...", role: "assistant"}})` — read only assistant messages +- `memory({tool: "messages", args: {sessionId: "...", showTools: true}})` — include tool-call output +- `memory({tool: "message", args: {messageId: "msg_..."}})` — read a single message by ID - `memory({tool: "search", args: {query: "..."}})` — search across all conversations - `memory({tool: "compactions", args: {sessionId: "..."}})` — view compaction checkpoints - `memory({tool: "context"})` — check your current context window usage diff --git a/package.json b/package.json index 15e90d8..c4a241f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@alkdev/open-memory", - "version": "0.1.0", + "version": "0.2.0", "description": "OpenCode plugin for session memory browsing, context awareness, and compaction management.", "type": "module", "main": "dist/index.js", diff --git a/src/history/format.ts b/src/history/format.ts index ff1d234..23836aa 100644 --- a/src/history/format.ts +++ b/src/history/format.ts @@ -6,7 +6,7 @@ export const formatSessionList = (rows: Record[]): string => { lines.push("|----|-------|---------|----------|"); for (const row of rows) { - const id = `${String(row.id ?? "").slice(0, 12)}...`; + const id = String(row.id ?? ""); const title = String(row.title ?? "untitled").slice(0, 40); const updated = String(row.updated ?? ""); const msgs = String(row.msgs ?? "0"); @@ -22,9 +22,14 @@ export const formatSessionList = (rows: Record[]): string => { return lines.join("\n"); }; -export const formatMessageList = (rows: Record[]): string => { +export const formatMessageList = ( + rows: Record[], + options?: { maxLength?: number }, +): string => { if (rows.length === 0) return "No messages found."; + const maxLen = options?.maxLength ?? 2000; + const lines: string[] = ["# Conversation\n"]; for (const row of rows) { @@ -35,9 +40,34 @@ export const formatMessageList = (rows: Record[]): string => { const header = `${icon} **${role}** _${time}_`; lines.push(header); - lines.push(text.slice(0, 2000)); + const truncated = text.length > maxLen; + lines.push( + truncated + ? text.slice(0, maxLen) + + `\n... (${text.length} chars total, use maxLength or message tool for full content)` + : text, + ); lines.push("---"); } return lines.join("\n"); }; + +export const formatSingleMessage = ( + row: Record, + options?: { maxLength?: number }, +): string => { + const maxLen = options?.maxLength ?? 8000; + const role = String(row.role ?? "unknown"); + const time = String(row.time ?? ""); + const text = String(row.text ?? ""); + const icon = role === "user" ? "👤" : role === "assistant" ? "🤖" : "📝"; + + const truncated = text.length > maxLen; + const displayText = truncated + ? text.slice(0, maxLen) + + `\n... (${text.length} chars total, increase maxLength for full content)` + : text; + + return [`${icon} **${role}** _${time}_\n`, displayText].join("\n"); +}; diff --git a/src/history/search.ts b/src/history/search.ts index 5e17c69..e761d93 100644 --- a/src/history/search.ts +++ b/src/history/search.ts @@ -15,7 +15,7 @@ export const searchConversations = (searchTerm: string, limit: number): string = COALESCE(s.title, 'untitled') AS title, json_extract(m.data, '$.role') AS role, datetime(m.time_created/1000, 'unixepoch', 'localtime') AS time, - substr(json_extract(p.data, '$.text'), 1, 300) AS snippet + substr(json_extract(p.data, '$.text'), 1, 500) AS snippet FROM part p JOIN message m ON m.id = p.message_id JOIN session s ON s.id = m.session_id @@ -38,14 +38,14 @@ export const searchConversations = (searchTerm: string, limit: number): string = const lines: string[] = [`# Search: "${searchTerm}"\n`]; for (const row of rows) { - const sessionId = String(row.session_id ?? "").slice(0, 16); + const sessionId = String(row.session_id ?? ""); const title = String(row.title ?? "untitled"); const time = String(row.time ?? ""); const role = String(row.role ?? "unknown"); const snippet = String(row.snippet ?? ""); lines.push(`### ${title} (${time})`); - lines.push(`- Session: \`${sessionId}...\``); + lines.push(`- Session: \`${sessionId}\``); lines.push(`- Role: ${role}`); lines.push(`- Snippet: ${snippet}...`); lines.push(""); diff --git a/src/tools.ts b/src/tools.ts index f8fd4af..207395b 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,7 +1,7 @@ import type { PluginInput, ToolDefinition } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin"; import type { ContextTracker } from "./context/tracker.js"; -import { formatMessageList, formatSessionList } from "./history/format.js"; +import { formatMessageList, formatSessionList, formatSingleMessage } from "./history/format.js"; import { runQuery } from "./history/queries.js"; import { searchConversations } from "./history/search.js"; @@ -19,7 +19,8 @@ Call \`memory({tool: "", args: {...}})\` to use one. |------|-------------|----------| | 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 | +| messages | Read messages from a session as formatted conversation | sessionId, limit, role, showTools, maxLength | +| message | Read a single message by ID (cleaner output, no tool-call noise) | messageId, maxLength | | 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) | — | @@ -29,6 +30,8 @@ Call \`memory({tool: "", args: {...}})\` to use one. Examples: - \`memory({tool: "search", args: {query: "safetensors"}})\` - \`memory({tool: "compactions", args: {sessionId: "ses_abc", read: 1}})\` +- \`memory({tool: "messages", args: {sessionId: "ses_abc", role: "assistant"}})\` +- \`memory({tool: "message", args: {messageId: "msg_abc"}})\` - \`memory({tool: "help", args: {tool: "search"}})\``; const TOOL_HELP: Record = { @@ -36,7 +39,10 @@ const TOOL_HELP: Record = { 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).`, +By default, tool-call parts (Read, Write, Bash, etc.) are hidden to reduce noise. Set showTools: true to include them. +Args: sessionId (string, required), limit (number, default 50), role (string, optional filter: "user", "assistant", "system"), showTools (boolean, default false), maxLength (number, default 2000 per message).`, + message: `**message** — Read a single message by ID. Shows the message with tool calls hidden by default, formatted cleanly. +Args: messageId (string, required), showTools (boolean, default false), maxLength (number, default 8000).`, 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. @@ -133,22 +139,71 @@ const handlers: Record = { messages(args) { const sessionId = args.sessionId as string; const limit = (args.limit as number) ?? 50; + const roleFilter = args.role as string | undefined; + const showTools = (args.showTools as boolean) ?? false; + const maxLength = args.maxLength as number | undefined; if (!sessionId) return "sessionId is required."; try { type MessageRow = { role: string; time: string; text: string }; + let rows: MessageRow[]; + + if (roleFilter) { + 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 ${showTools ? "" : "AND json_extract(p.data, '$.type') = 'text'"} + WHERE m.session_id = $sessionId AND json_extract(m.data, '$.role') = $role + GROUP BY m.id ORDER BY m.time_created ASC LIMIT $limit`, + { $sessionId: sessionId, $role: roleFilter, $limit: limit }, + ); + } else { + 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 ${showTools ? "" : "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) { + const filterDesc = roleFilter ? ` with role="${roleFilter}"` : ""; + return `No messages found for session ${sessionId}${filterDesc}.`; + } + return formatMessageList(rows, { maxLength }); + } catch (err) { + return `Failed to query messages: ${err instanceof Error ? err.message : String(err)}`; + } + }, + + message(args) { + const messageId = args.messageId as string; + const showTools = (args.showTools as boolean) ?? false; + const maxLength = args.maxLength as number | undefined; + if (!messageId) return "messageId is required."; + + try { + type MessageRow = { role: string; time: string; text: string }; + const partFilter = showTools ? "" : "AND json_extract(p.data, '$.type') = 'text'"; + 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 }, + FROM message m LEFT JOIN part p ON p.message_id = m.id ${partFilter} + WHERE m.id = $messageId + GROUP BY m.id`, + { $messageId: messageId }, ); - if (!rows || rows.length === 0) return `No messages found for session ${sessionId}.`; - return formatMessageList(rows); + + if (!rows || rows.length === 0) return `No message found with ID ${messageId}.`; + return formatSingleMessage(rows[0], { maxLength }); } catch (err) { - return `Failed to query messages: ${err instanceof Error ? err.message : String(err)}`; + return `Failed to query message: ${err instanceof Error ? err.message : String(err)}`; } }, diff --git a/tests/format.test.ts b/tests/format.test.ts index 6d2b769..068ee70 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -1,33 +1,34 @@ import { describe, expect, test } from "bun:test"; -import { formatMessageList, formatSessionList } from "../src/history/format"; +import { formatMessageList, formatSessionList, formatSingleMessage } from "../src/history/format"; describe("formatSessionList", () => { test("returns message for empty list", () => { expect(formatSessionList([])).toBe("No sessions found."); }); - test("formats sessions as markdown table", () => { + test("formats sessions as markdown table with full IDs", () => { const rows = [ { id: "ses_abc123def", title: "Test Session", updated: "2024-01-15 10:30:00", msgs: 12 }, ]; const result = formatSessionList(rows); expect(result).toContain("# Recent Sessions"); expect(result).toContain("| ID | Title | Updated | Messages |"); - expect(result).toContain("ses_abc123de..."); + expect(result).toContain("ses_abc123def"); expect(result).toContain("Test Session"); }); - test("truncates long IDs and titles", () => { + test("shows full IDs even for long session IDs", () => { const rows = [ { - id: "ses_verylongidthatshouldbetruncated1234567890", + id: "ses_verylongidthatshouldnotbetruncated1234567890", title: "A very long title that should be truncated for display", updated: "2024-01-15", msgs: 5, }, ]; const result = formatSessionList(rows); - expect(result).toContain("ses_verylong..."); + expect(result).toContain("ses_verylongidthatshouldnotbetruncated1234567890"); + expect(result).toContain("A very long title that should be truncat"); }); test("handles untitled sessions", () => { @@ -55,10 +56,41 @@ describe("formatMessageList", () => { expect(result).toContain("Hi there"); }); - test("truncates long text", () => { + test("truncates long text at default maxLength 2000", () => { const longText = "x".repeat(3000); const rows = [{ role: "assistant", time: "2024-01-15", text: longText }]; const result = formatMessageList(rows); expect(result.length).toBeLessThan(longText.length + 200); + expect(result).toContain("3000 chars total"); + }); + + test("respects custom maxLength", () => { + const longText = "x".repeat(500); + const rows = [{ role: "assistant", time: "2024-01-15", text: longText }]; + const result = formatMessageList(rows, { maxLength: 100 }); + expect(result).toContain("500 chars total"); + expect(result.length).toBeLessThan(500); + }); + + test("does not truncate short text", () => { + const rows = [{ role: "assistant", time: "2024-01-15", text: "Short message" }]; + const result = formatMessageList(rows); + expect(result).toContain("Short message"); + expect(result).not.toContain("chars total"); + }); +}); + +describe("formatSingleMessage", () => { + test("formats a single message", () => { + const row = { role: "assistant", time: "2024-01-15 10:00:00", text: "Hello world" }; + const result = formatSingleMessage(row); + expect(result).toContain("assistant"); + expect(result).toContain("Hello world"); + }); + + test("respects custom maxLength", () => { + const row = { role: "assistant", time: "2024-01-15", text: "x".repeat(10000) }; + const result = formatSingleMessage(row, { maxLength: 500 }); + expect(result).toContain("10000 chars total"); }); });