Replace sqlite3 subprocess with bun:sqlite native driver, add AGENTS.md and tests
Rewrites history/queries.ts to use bun:sqlite with readonly mode and parameterized queries instead of spawning sqlite3. Consolidates threshold constants into a single source of truth. Adds AGENTS.md as the canonical operational doc, moves architecture to docs/, and adds initial test suite.
This commit is contained in:
@@ -39,4 +39,4 @@ When constructing the summary, try to stick to this template:
|
||||
[Anything else you need to remember — patterns observed, gotchas, tool quirks, environment details]
|
||||
---`;
|
||||
|
||||
export const getCompactionPrompt = (): string => DEFAULT_COMPACTION_PROMPT;
|
||||
export const getCompactionPrompt = (): string => DEFAULT_COMPACTION_PROMPT;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const formatAnomalyNotification = (
|
||||
sessionID: string,
|
||||
type: string,
|
||||
_type: string,
|
||||
percentage: number,
|
||||
status: string,
|
||||
): string => {
|
||||
@@ -23,4 +23,4 @@ export const formatAnomalyNotification = (
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type ContextStatus = "green" | "yellow" | "red" | "critical";
|
||||
|
||||
export const THRESHOLDS = {
|
||||
yellow: 70,
|
||||
red: 85,
|
||||
@@ -10,5 +12,3 @@ export const getStatusLabel = (percentage: number): ContextStatus => {
|
||||
if (percentage >= THRESHOLDS.yellow) return "yellow";
|
||||
return "green";
|
||||
};
|
||||
|
||||
export type ContextStatus = "green" | "yellow" | "red" | "critical";
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { Event } from "@opencode-ai/sdk";
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import type { Event } from "@opencode-ai/sdk";
|
||||
import { type ContextStatus, THRESHOLDS } from "./thresholds.js";
|
||||
|
||||
export type ContextInfo = {
|
||||
usedTokens: number;
|
||||
limitTokens: number;
|
||||
percentage: number;
|
||||
status: "green" | "yellow" | "red" | "critical";
|
||||
status: ContextStatus;
|
||||
model: string;
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
@@ -21,12 +22,6 @@ type SessionContextData = {
|
||||
previousInputTokens: number[];
|
||||
};
|
||||
|
||||
const THRESHOLDS = {
|
||||
yellow: 0.70,
|
||||
red: 0.85,
|
||||
critical: 0.92,
|
||||
} as const;
|
||||
|
||||
const DEFAULT_CONTEXT_LIMIT = 200_000;
|
||||
|
||||
export class ContextTracker {
|
||||
@@ -88,10 +83,10 @@ export class ContextTracker {
|
||||
: 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
|
||||
? ((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
|
||||
? ((tokens.cache as Record<string, unknown>).write as number)
|
||||
: 0);
|
||||
|
||||
const infoModel =
|
||||
@@ -131,16 +126,15 @@ export class ContextTracker {
|
||||
if (!data || data.lastInputTokens === 0) return null;
|
||||
|
||||
const modelKey = `${data.providerID}/${data.modelID}`;
|
||||
const limitTokens =
|
||||
this.modelContextLimits.get(modelKey) ?? DEFAULT_CONTEXT_LIMIT;
|
||||
const limitTokens = this.modelContextLimits.get(modelKey) ?? DEFAULT_CONTEXT_LIMIT;
|
||||
|
||||
const percentage = Math.round((data.lastInputTokens / limitTokens) * 100);
|
||||
const status =
|
||||
percentage >= THRESHOLDS.critical * 100
|
||||
const status: ContextStatus =
|
||||
percentage >= THRESHOLDS.critical
|
||||
? "critical"
|
||||
: percentage >= THRESHOLDS.red * 100
|
||||
: percentage >= THRESHOLDS.red
|
||||
? "red"
|
||||
: percentage >= THRESHOLDS.yellow * 100
|
||||
: percentage >= THRESHOLDS.yellow
|
||||
? "yellow"
|
||||
: "green";
|
||||
|
||||
@@ -169,4 +163,4 @@ export class ContextTracker {
|
||||
|
||||
export const startContextTracker = (ctx: PluginInput): ContextTracker => {
|
||||
return new ContextTracker(ctx);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 ?? "").slice(0, 12)}...`;
|
||||
const title = String(row.title ?? "untitled").slice(0, 40);
|
||||
const updated = String(row.updated ?? "");
|
||||
const msgs = String(row.msgs ?? "0");
|
||||
@@ -38,4 +38,4 @@ export const formatMessageList = (rows: Record<string, unknown>[]): string => {
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,22 +1,49 @@
|
||||
export const runQuery = async (dbUri: string, sql: string): Promise<Record<string, unknown>[]> => {
|
||||
const proc = Bun.spawn(["sqlite3", "-json", dbUri, sql], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
const getDbPath = (): string => {
|
||||
const dataRoot = process.env.XDG_DATA_HOME || `${process.env.HOME}/.local/share/opencode`;
|
||||
return `${dataRoot}/opencode.db`;
|
||||
};
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`sqlite3 exited with code ${exitCode}: ${stderr}`);
|
||||
let _db: Database | null = null;
|
||||
let _dbPath: string | null = null;
|
||||
|
||||
const getDb = (): Database => {
|
||||
const dbPath = getDbPath();
|
||||
if (!_db || _dbPath !== dbPath) {
|
||||
if (_db) {
|
||||
try {
|
||||
_db.close(true);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
_db = new Database(dbPath, { readonly: true, create: false });
|
||||
_dbPath = dbPath;
|
||||
}
|
||||
return _db;
|
||||
};
|
||||
|
||||
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)}`);
|
||||
export const runQuery = <T = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: Record<string, string | number | null>,
|
||||
): T[] => {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare(sql);
|
||||
if (params) {
|
||||
return stmt.all(params) as T[];
|
||||
}
|
||||
};
|
||||
return stmt.all() as T[];
|
||||
};
|
||||
|
||||
export const closeDb = (): void => {
|
||||
if (_db) {
|
||||
try {
|
||||
_db.close(true);
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
_db = null;
|
||||
_dbPath = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { runQuery } from "./queries.js";
|
||||
|
||||
export const searchConversations = async (
|
||||
dbUri: string,
|
||||
searchTerm: string,
|
||||
limit: number,
|
||||
): Promise<string> => {
|
||||
const escaped = searchTerm.replace(/'/g, "''");
|
||||
type SearchResult = {
|
||||
session_id: string;
|
||||
title: string;
|
||||
role: string;
|
||||
time: string;
|
||||
snippet: string;
|
||||
};
|
||||
|
||||
export const searchConversations = (searchTerm: string, limit: number): string => {
|
||||
const query = `
|
||||
SELECT
|
||||
s.id AS session_id,
|
||||
@@ -19,13 +21,16 @@ export const searchConversations = async (
|
||||
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}%'
|
||||
AND json_extract(p.data, '$.text') LIKE $searchPattern
|
||||
ORDER BY m.time_created DESC
|
||||
LIMIT ${limit}
|
||||
LIMIT $limit
|
||||
`;
|
||||
|
||||
try {
|
||||
const rows = await runQuery(dbUri, query);
|
||||
const rows = runQuery<SearchResult>(query, {
|
||||
$searchPattern: `%${searchTerm}%`,
|
||||
$limit: limit,
|
||||
});
|
||||
if (!rows || rows.length === 0) {
|
||||
return `No results found for "${searchTerm}".`;
|
||||
}
|
||||
@@ -51,4 +56,4 @@ export const searchConversations = async (
|
||||
} catch (err) {
|
||||
return `Search failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Plugin, PluginInput } from "@opencode-ai/plugin";
|
||||
import { createTools } from "./tools.js";
|
||||
import { startContextTracker } from "./context/tracker.js";
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import { getCompactionPrompt } from "./compaction/prompt.js";
|
||||
import { startContextTracker } from "./context/tracker.js";
|
||||
import { createTools } from "./tools.js";
|
||||
|
||||
const OpenMemoryPlugin: Plugin = async (ctx) => {
|
||||
const contextTracker = startContextTracker(ctx);
|
||||
@@ -54,4 +54,4 @@ const OpenMemoryPlugin: Plugin = async (ctx) => {
|
||||
};
|
||||
};
|
||||
|
||||
export default OpenMemoryPlugin;
|
||||
export default OpenMemoryPlugin;
|
||||
|
||||
134
src/tools.ts
134
src/tools.ts
@@ -1,15 +1,13 @@
|
||||
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 { formatMessageList, formatSessionList } 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,
|
||||
@@ -50,7 +48,9 @@ export const createTools = (
|
||||
|
||||
if (info.status === "red" || info.status === "critical") {
|
||||
lines.push("");
|
||||
lines.push("Recommendation: Use memory_compact to trigger compaction at a natural break point.");
|
||||
lines.push(
|
||||
"Recommendation: Use memory_compact to trigger compaction at a natural break point.",
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
@@ -133,7 +133,7 @@ export const createTools = (
|
||||
args: {},
|
||||
async execute() {
|
||||
try {
|
||||
const rows = await runQuery(DB_URI, `
|
||||
const rows = runQuery<Record<string, unknown>>(`
|
||||
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
|
||||
@@ -159,47 +159,58 @@ export const createTools = (
|
||||
"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."),
|
||||
projectPath: z.string().optional().describe("Filter to a specific project worktree path."),
|
||||
},
|
||||
async execute(args) {
|
||||
const limit = args.limit ?? 10;
|
||||
|
||||
try {
|
||||
let query: string;
|
||||
type SessionRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
project?: string;
|
||||
updated: string;
|
||||
msgs: number;
|
||||
};
|
||||
|
||||
let rows: SessionRow[];
|
||||
|
||||
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}
|
||||
`;
|
||||
rows = runQuery<SessionRow>(
|
||||
`
|
||||
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 {
|
||||
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}
|
||||
`;
|
||||
rows = runQuery<SessionRow>(
|
||||
`
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await runQuery(DB_URI, query);
|
||||
if (!rows || rows.length === 0) {
|
||||
return "No sessions found.";
|
||||
}
|
||||
@@ -221,20 +232,28 @@ export const createTools = (
|
||||
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);
|
||||
type MessageRow = {
|
||||
role: string;
|
||||
time: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
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: args.sessionId, $limit: limit },
|
||||
);
|
||||
if (!rows || rows.length === 0) {
|
||||
return `No messages found for session ${args.sessionId}.`;
|
||||
}
|
||||
@@ -256,11 +275,7 @@ export const createTools = (
|
||||
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;
|
||||
return searchConversations(args.query, limit);
|
||||
} catch (err) {
|
||||
return `Search failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
@@ -270,10 +285,7 @@ export const createTools = (
|
||||
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)."),
|
||||
read: z.string().optional().describe("Filename of a specific plan to read (without path)."),
|
||||
},
|
||||
async execute(args) {
|
||||
const plansDir = `${DATA_ROOT}/plans`;
|
||||
@@ -318,4 +330,4 @@ export const createTools = (
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user