Initial scaffold: open-memory plugin for OpenCode

- Plugin entry point with hooks: experimental.session.compacting,
  experimental.chat.system.transform, event
- Context tracker: SSE-based token tracking per session with
  green/yellow/red/critical thresholds
- Tools: memory_context, memory_compact, memory_summary,
  memory_sessions, memory_messages, memory_search, memory_plans
- History module: sqlite3 queries + markdown rendering
- Compaction: improved prompt emphasizing self-continuity
- Research docs: ARCHITECTURE.md + opencode-memory reference
This commit is contained in:
2026-04-20 14:55:20 +00:00
commit 9a42dcfb94
16 changed files with 1296 additions and 0 deletions

42
src/compaction/prompt.ts Normal file
View File

@@ -0,0 +1,42 @@
const DEFAULT_COMPACTION_PROMPT = `You are compacting your own session to free context space. You will continue this session after compaction with this summary as your starting context.
Include what YOU will need to effectively resume your work:
- Current task and progress
- Files being worked on
- Key decisions made and why
- Next steps to take
- Important context that would be hard to rediscover
- Any active debug sessions, in-progress edits, or partial implementations
Be concise but preserve enough detail that you can continue seamlessly.
You are summarizing for yourself, not another agent.
When constructing the summary, try to stick to this template:
---
## Goal
[What goal(s) are you trying to accomplish?]
## Instructions
- [What important instructions are relevant to your current work]
- [If there is a plan or spec, include key details so you can continue using it]
## Discoveries
[What notable things were learned that would be useful to remember when continuing]
## Accomplished
[What work has been completed, what work is still in progress, and what work is left?]
## Relevant files / directories
[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.]
## Notes
[Anything else you need to remember — patterns observed, gotchas, tool quirks, environment details]
---`;
export const getCompactionPrompt = (): string => DEFAULT_COMPACTION_PROMPT;

26
src/context/notify.ts Normal file
View File

@@ -0,0 +1,26 @@
export const formatAnomalyNotification = (
sessionID: string,
type: string,
percentage: number,
status: string,
): string => {
const lines: string[] = [];
lines.push(`Context threshold reached [${status}]`);
lines.push("");
lines.push(`Session: ${sessionID}`);
lines.push(`Context: ${percentage}% used`);
if (status === "critical") {
lines.push("");
lines.push("Imminent automatic compaction. Consider triggering memory_compact now.");
} else if (status === "red") {
lines.push("");
lines.push("Context is running low. Use memory_compact at your next natural break point.");
} else if (status === "yellow") {
lines.push("");
lines.push("Context usage is getting high. Consider memory_compact when convenient.");
}
return lines.join("\n");
};

14
src/context/thresholds.ts Normal file
View File

@@ -0,0 +1,14 @@
export const THRESHOLDS = {
yellow: 70,
red: 85,
critical: 92,
} as const;
export const getStatusLabel = (percentage: number): ContextStatus => {
if (percentage >= THRESHOLDS.critical) return "critical";
if (percentage >= THRESHOLDS.red) return "red";
if (percentage >= THRESHOLDS.yellow) return "yellow";
return "green";
};
export type ContextStatus = "green" | "yellow" | "red" | "critical";

172
src/context/tracker.ts Normal file
View File

@@ -0,0 +1,172 @@
import type { Event } from "@opencode-ai/sdk";
import type { PluginInput } from "@opencode-ai/plugin";
export type ContextInfo = {
usedTokens: number;
limitTokens: number;
percentage: number;
status: "green" | "yellow" | "red" | "critical";
model: string;
providerID: string;
modelID: string;
trend: "growing" | "stable" | "unknown";
};
type SessionContextData = {
lastInputTokens: number;
lastTotalTokens: number;
providerID: string;
modelID: string;
lastUpdateTime: number;
previousInputTokens: number[];
};
const THRESHOLDS = {
yellow: 0.70,
red: 0.85,
critical: 0.92,
} as const;
const DEFAULT_CONTEXT_LIMIT = 200_000;
export class ContextTracker {
private sessions = new Map<string, SessionContextData>();
private ctx: PluginInput;
private modelContextLimits = new Map<string, number>();
constructor(ctx: PluginInput) {
this.ctx = ctx;
this.loadModelLimits().catch(() => {});
}
private async loadModelLimits() {
try {
const config = await this.ctx.client.config.get();
if (config.data) {
const providers = config.data as Record<string, unknown>;
if (providers && typeof providers === "object") {
const models = (providers as Record<string, unknown>).models;
if (models && typeof models === "object") {
for (const [key, value] of Object.entries(models as Record<string, unknown>)) {
if (value && typeof value === "object") {
const limit = (value as Record<string, unknown>).limit;
if (limit && typeof limit === "object") {
const context = (limit as Record<string, unknown>).context;
if (typeof context === "number") {
this.modelContextLimits.set(key, context);
}
}
}
}
}
}
}
} catch {
// Config not available, will use defaults
}
}
handleEvent(event: Event) {
if (event.type !== "message.updated") return;
const props = event.properties as Record<string, unknown>;
if (!props) return;
const info = props.info as Record<string, unknown> | undefined;
if (!info || info.role !== "assistant") return;
const sessionID = info.sessionID as string | undefined;
if (!sessionID) return;
const tokens = info.tokens as Record<string, unknown> | undefined;
if (!tokens) return;
const inputTokens = typeof tokens.input === "number" ? tokens.input : 0;
const totalTokens =
typeof tokens.total === "number"
? tokens.total
: inputTokens +
(typeof tokens.output === "number" ? tokens.output : 0) +
(typeof (tokens.cache as Record<string, unknown>)?.read === "number"
? (tokens.cache as Record<string, unknown>).read as number
: 0) +
(typeof (tokens.cache as Record<string, unknown>)?.write === "number"
? (tokens.cache as Record<string, unknown>).write as number
: 0);
const infoModel =
typeof info.model === "object" && info.model !== null
? (info.model as Record<string, unknown>)
: {};
const providerID = (info.providerID ?? infoModel.providerID ?? "") as string;
const modelID = (info.modelID ?? infoModel.modelID ?? "") as string;
let existing = this.sessions.get(sessionID);
if (!existing) {
existing = {
lastInputTokens: 0,
lastTotalTokens: 0,
providerID,
modelID,
lastUpdateTime: Date.now(),
previousInputTokens: [],
};
this.sessions.set(sessionID, existing);
}
existing.previousInputTokens.push(existing.lastInputTokens);
if (existing.previousInputTokens.length > 5) {
existing.previousInputTokens.shift();
}
existing.lastInputTokens = inputTokens;
existing.lastTotalTokens = totalTokens;
existing.providerID = providerID || existing.providerID;
existing.modelID = modelID || existing.modelID;
existing.lastUpdateTime = Date.now();
}
getContextInfo(sessionID: string): ContextInfo | null {
const data = this.sessions.get(sessionID);
if (!data || data.lastInputTokens === 0) return null;
const modelKey = `${data.providerID}/${data.modelID}`;
const limitTokens =
this.modelContextLimits.get(modelKey) ?? DEFAULT_CONTEXT_LIMIT;
const percentage = Math.round((data.lastInputTokens / limitTokens) * 100);
const status =
percentage >= THRESHOLDS.critical * 100
? "critical"
: percentage >= THRESHOLDS.red * 100
? "red"
: percentage >= THRESHOLDS.yellow * 100
? "yellow"
: "green";
const prevTokens = data.previousInputTokens;
let trend: ContextInfo["trend"] = "unknown";
if (prevTokens.length >= 2) {
const recentGrowth = prevTokens.slice(-3).reduce((acc, t, i, arr) => {
if (i === 0) return 0;
return acc + (t - arr[i - 1]);
}, 0);
trend = recentGrowth > prevTokens[prevTokens.length - 1] * 0.1 ? "growing" : "stable";
}
return {
usedTokens: data.lastInputTokens,
limitTokens,
percentage,
status,
model: modelKey,
providerID: data.providerID,
modelID: data.modelID,
trend,
};
}
}
export const startContextTracker = (ctx: PluginInput): ContextTracker => {
return new ContextTracker(ctx);
};

41
src/history/format.ts Normal file
View File

@@ -0,0 +1,41 @@
export const formatSessionList = (rows: Record<string, unknown>[]): string => {
if (rows.length === 0) return "No sessions found.";
const lines: string[] = ["# Recent Sessions\n"];
lines.push("| ID | Title | Updated | Messages |");
lines.push("|----|-------|---------|----------|");
for (const row of rows) {
const id = String(row.id ?? "").slice(0, 12) + "...";
const title = String(row.title ?? "untitled").slice(0, 40);
const updated = String(row.updated ?? "");
const msgs = String(row.msgs ?? "0");
lines.push(`| ${id} | ${title} | ${updated} | ${msgs} |`);
}
lines.push("");
lines.push("Use memory_messages with a session ID to read the full conversation.");
return lines.join("\n");
};
export const formatMessageList = (rows: Record<string, unknown>[]): string => {
if (rows.length === 0) return "No messages found.";
const lines: string[] = ["# Conversation\n"];
for (const row of rows) {
const role = String(row.role ?? "unknown");
const time = String(row.time ?? "");
const text = String(row.text ?? "");
const icon = role === "user" ? "👤" : role === "assistant" ? "🤖" : "📝";
const header = `${icon} **${role}** _${time}_`;
lines.push(header);
lines.push(text.slice(0, 2000));
lines.push("---");
}
return lines.join("\n");
};

22
src/history/queries.ts Normal file
View File

@@ -0,0 +1,22 @@
export const runQuery = async (dbUri: string, sql: string): Promise<Record<string, unknown>[]> => {
const proc = Bun.spawn(["sqlite3", "-json", dbUri, sql], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
throw new Error(`sqlite3 exited with code ${exitCode}: ${stderr}`);
}
if (!stdout.trim()) return [];
try {
return JSON.parse(stdout) as Record<string, unknown>[];
} catch {
throw new Error(`Failed to parse sqlite3 output: ${stdout.slice(0, 200)}`);
}
};

54
src/history/search.ts Normal file
View File

@@ -0,0 +1,54 @@
import { runQuery } from "./queries.js";
export const searchConversations = async (
dbUri: string,
searchTerm: string,
limit: number,
): Promise<string> => {
const escaped = searchTerm.replace(/'/g, "''");
const query = `
SELECT
s.id AS session_id,
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
FROM part p
JOIN message m ON m.id = p.message_id
JOIN session s ON s.id = m.session_id
WHERE s.parent_id IS NULL
AND json_extract(p.data, '$.type') = 'text'
AND json_extract(p.data, '$.text') LIKE '%${escaped}%'
ORDER BY m.time_created DESC
LIMIT ${limit}
`;
try {
const rows = await runQuery(dbUri, query);
if (!rows || rows.length === 0) {
return `No results found for "${searchTerm}".`;
}
const lines: string[] = [`# Search: "${searchTerm}"\n`];
for (const row of rows) {
const sessionId = String(row.session_id ?? "").slice(0, 16);
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(`- Role: ${role}`);
lines.push(`- Snippet: ${snippet}...`);
lines.push("");
}
lines.push("Use memory_messages with a session ID to read the full conversation.");
return lines.join("\n");
} catch (err) {
return `Search failed: ${err instanceof Error ? err.message : String(err)}`;
}
};

57
src/index.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { Plugin, PluginInput } from "@opencode-ai/plugin";
import { createTools } from "./tools.js";
import { startContextTracker } from "./context/tracker.js";
import { getCompactionPrompt } from "./compaction/prompt.js";
const OpenMemoryPlugin: Plugin = async (ctx) => {
const contextTracker = startContextTracker(ctx);
return {
tool: createTools(ctx, contextTracker),
"experimental.session.compacting": async (_input, output) => {
output.prompt = getCompactionPrompt();
},
"experimental.chat.system.transform": async (input, output) => {
if (!input.sessionID) return;
const info = contextTracker.getContextInfo(input.sessionID);
if (!info) return;
const statusEmoji =
info.status === "critical"
? "🔴"
: info.status === "red"
? "🟠"
: info.status === "yellow"
? "🟡"
: "🟢";
const advisory =
info.status === "critical"
? "Context is nearly full. Use memory_compact immediately if possible."
: info.status === "red"
? "Context is running low. Use memory_compact at your next natural break point."
: info.status === "yellow"
? "Context usage is getting high. Consider memory_compact when convenient."
: null;
const lines = [
`${statusEmoji} Context: ${info.percentage}% used (${info.usedTokens.toLocaleString()} / ${info.limitTokens.toLocaleString()} tokens, ${info.model})`,
];
if (advisory) {
lines.push(advisory);
}
output.system.push(lines.join("\n"));
},
event: async ({ event }) => {
contextTracker.handleEvent(event);
},
};
};
export default OpenMemoryPlugin;

321
src/tools.ts Normal file
View File

@@ -0,0 +1,321 @@
import type { PluginInput, ToolDefinition } from "@opencode-ai/plugin";
import { tool } from "@opencode-ai/plugin";
import type { ContextTracker } from "./context/tracker.js";
import { formatSessionList, formatMessageList } from "./history/format.js";
import { runQuery } from "./history/queries.js";
import { searchConversations } from "./history/search.js";
const z = tool.schema;
const DATA_ROOT = process.env.XDG_DATA_HOME || `${process.env.HOME}/.local/share/opencode`;
const DB = `${DATA_ROOT}/opencode.db`;
const DB_URI = `file:${DB}?mode=ro`;
export const createTools = (
ctx: PluginInput,
tracker: ContextTracker,
): Record<string, ToolDefinition> => ({
memory_context: 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.";
}
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%.",
args: {},
async execute(_args, context) {
if (!context.sessionID) {
return "No active session.";
}
const info = tracker.getContextInfo(context.sessionID);
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 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()
.find((m) => m.info.role === "user");
let providerID = info?.providerID ?? "";
let modelID = info?.model ?? "";
if (lastUserMessage) {
const infoAny = lastUserMessage.info as Record<string, unknown>;
const modelObj =
typeof infoAny.model === "object" && infoAny.model !== null
? (infoAny.model as Record<string, unknown>)
: null;
if (modelObj?.providerID && typeof modelObj.providerID === "string") {
providerID = modelObj.providerID;
}
if (modelObj?.modelID && typeof modelObj.modelID === "string") {
modelID = modelObj.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 },
});
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) {
return `Failed to trigger compaction: ${err instanceof Error ? err.message : String(err)}`;
}
},
}),
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 = await runQuery(DB_URI, `
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 {
let query: string;
if (args.projectPath) {
query = `
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 = '${args.projectPath.replace(/'/g, "''")}'
AND s.parent_id IS NULL
ORDER BY s.time_updated DESC
LIMIT ${limit}
`;
} else {
query = `
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}
`;
}
const rows = await runQuery(DB_URI, query);
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 {
const query = `
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 = '${args.sessionId.replace(/'/g, "''")}'
GROUP BY m.id
ORDER BY m.time_created ASC
LIMIT ${limit}
`;
const rows = await runQuery(DB_URI, query);
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 {
const results = await searchConversations(DB_URI, args.query, limit);
if (!results || results.length === 0) {
return `No results found for "${args.query}".`;
}
return results;
} catch (err) {
return `Search failed: ${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.";
}
},
}),
});