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
This commit is contained in:
2026-04-21 21:15:28 +00:00
parent cb9d981b6f
commit 047621d834
6 changed files with 184 additions and 24 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -6,7 +6,7 @@ export const formatSessionList = (rows: Record<string, unknown>[]): 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, unknown>[]): string => {
return lines.join("\n");
};
export const formatMessageList = (rows: Record<string, unknown>[]): string => {
export const formatMessageList = (
rows: Record<string, unknown>[],
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, unknown>[]): 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<string, unknown>,
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");
};

View File

@@ -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("");

View File

@@ -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: "<name>", 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: "<name>", 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<string, string> = {
@@ -36,7 +39,10 @@ const TOOL_HELP: Record<string, string> = {
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<string, MemoryHandler> = {
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<MessageRow>(
`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<MessageRow>(
`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<MessageRow>(
`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)}`;
}
},

View File

@@ -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");
});
});