From f7bb7f94cf5dab4071851f4280b6f88e63dac281 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Mon, 20 Apr 2026 16:14:35 +0000 Subject: [PATCH] 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. --- AGENTS.md | 152 ++++++++++++++++++ biome.json | 4 +- .../ARCHITECTURE.md => docs/architecture.md | 10 +- opencode.json | 2 +- package.json | 2 +- src/compaction/prompt.ts | 2 +- src/context/notify.ts | 4 +- src/context/thresholds.ts | 4 +- src/context/tracker.ts | 28 ++-- src/history/format.ts | 4 +- src/history/queries.ts | 61 +++++-- src/history/search.ts | 25 +-- src/index.ts | 8 +- src/tools.ts | 134 ++++++++------- tests/compaction.test.ts | 26 +++ tests/format.test.ts | 64 ++++++++ tests/queries.test.ts | 76 +++++++++ tests/thresholds.test.ts | 32 ++++ tsconfig.json | 2 +- 19 files changed, 515 insertions(+), 125 deletions(-) create mode 100644 AGENTS.md rename research/ARCHITECTURE.md => docs/architecture.md (94%) create mode 100644 tests/compaction.test.ts create mode 100644 tests/format.test.ts create mode 100644 tests/queries.test.ts create mode 100644 tests/thresholds.test.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e2e5bc5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,152 @@ +# AGENTS.md + +## Project + +`@alkdev/open-memory` — an OpenCode plugin that gives agents access to their own session history, context window awareness, and compaction control. + +## Repository + +- **Git**: `git@git.alk.dev:alkdev/open-memory.git` (Gitea, mirrors to GitHub on release) +- **License**: Apache-2.0 +- **Runtime**: Bun +- **Language**: TypeScript (strict, ESM, verbatimModuleSyntax) +- **Linter**: Biome (`bun run lint`, `bun run format`) +- **Build**: `bun run build` → `dist/` (bun build + tsc declarations) + +## Commands + +```bash +bun run build # bun build src/index.ts + tsc --emitDeclarationOnly +bun run typecheck # tsc --noEmit +bun run lint # biome check . +bun run format # biome format --write . +bun run test # bun test +``` + +**Always run** `bun run typecheck` and `bun run lint` after changes. + +## Architecture + +### Three Pillars + +1. **Context Awareness** — tracks context window usage via SSE events, injects status into system prompts +2. **Session History** — read-only queries against OpenCode's SQLite DB using `bun:sqlite` (readonly mode) +3. **Compaction Management** — improved compaction prompt + on-demand compaction triggering + +### Source Structure + +``` +src/ +├── index.ts # Plugin entry: hooks + tool registration +├── tools.ts # Tool definitions (memory_*) +├── context/ +│ ├── tracker.ts # SSE token tracking (per-session context usage) +│ ├── thresholds.ts # Threshold constants + ContextStatus type (single source of truth) +│ └── notify.ts # Context notification formatting +├── history/ +│ ├── queries.ts # bun:sqlite read-only query helper (all DB access goes here) +│ ├── format.ts # Markdown rendering for session/message output +│ └── search.ts # LIKE-based full-text search across conversations +└── compaction/ + └── prompt.ts # Compaction prompt template (self-continuity, not "for another agent") +``` + +### Plugin Hooks + +| Hook | Purpose | +|------|---------| +| `experimental.session.compacting` | Replace default "summarize for another agent" with self-continuity prompt | +| `experimental.chat.system.transform` | Inject context % used + advisory into system prompt | +| `event` | Feed SSE events to ContextTracker | + +### Tools (7) + +| Tool | Purpose | +|------|---------| +| `memory_context` | Current context window usage (% , tokens, model, status) | +| `memory_compact` | Trigger compaction via `ctx.client.session.summarize()` | +| `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 | + +### Database Access + +- Uses `bun:sqlite` native driver — no subprocess, no `sqlite3` CLI dependency +- **Read-only**: `new Database(path, { readonly: true, create: false })` +- Connection is lazy-initialized and cached (singleton) +- All queries use `db.prepare(sql).all(params)` — never string interpolation +- DB path: `${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db` + +### Context Tracking + +- Listens to `message.updated` SSE events for assistant messages +- `tokens.input` on the latest assistant message = current context size +- Compares against model's context limit from config +- Thresholds: green (<70%), yellow (70-85%), red (85-92%), critical (>92%) +- System prompt injection at yellow/red thresholds with advisory + +### Context Percentage Calculation + +From OpenCode source (`overflow.ts`): +``` +count = tokens.total || (input + output + cache.read + cache.write) +reserved = config.compaction?.reserved ?? min(20000, maxOutputTokens) +usable = model.limit.input ? model.limit.input - reserved + : model.limit.context - maxOutputTokens +``` + +The `tokens.input` on the last assistant message approximates context size. We track against model context limit from config, falling back to 200k. + +### Write Operations + +All write operations (compaction triggering) go through the OpenCode client SDK (`ctx.client.session.summarize`). The plugin never writes to the database or any OpenCode files. + +## Key Conventions + +- No comments unless requested +- ESM with `.js` extension in imports +- `bun:sqlite` for all database queries (never spawn `sqlite3`) +- Parameterized queries only (never interpolate user input into SQL) +- Read-only DB access — writes go through the SDK +- Strict TypeScript with `verbatimModuleSyntax` +- Biome for linting and formatting + +## Relationship to open-coordinator + +- **open-coordinator** (`/workspace/@alkimiadev/open-coordinator`): worktree orchestration, session spawning, anomaly detection +- **open-memory**: session introspection, context awareness, history browsing +- Both use SSE events but for different purposes +- Both implement `experimental.session.compacting` — open-memory's version is more detailed +- Can be used together or independently + +## Implementation Phases + +### Phase 1: Foundation (current) +- Plugin scaffolding, build, basic hooks +- Compaction prompt, context tool, history tools +- `bun:sqlite` read-only queries + +### Phase 2: Context Awareness +- SSE token tracker (implemented, needs field testing) +- Proactive system prompt injection +- `memory_compact` tool + +### Phase 3: History Browser Enhancements +- FTS5 virtual table support (if available) +- Better search (stemming, ranking) +- Session comparison tools + +### Phase 4: Polish +- Configurable thresholds +- Export/import helpers +- Integration tests + +## References + +- OpenCode source: `/workspace/opencode` — `packages/opencode/src/session/compaction.ts`, `overflow.ts` +- OpenCode plugin SDK: `/workspace/opencode/packages/plugin/src/index.ts` +- Plugin types: see `Hooks` interface for all available hooks +- Bun SQLite docs: https://bun.com/docs/runtime/sqlite +- OpenCode DB schema: `message`, `part`, `session`, `project`, `todo` tables \ No newline at end of file diff --git a/biome.json b/biome.json index c814656..6be90d6 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -17,4 +17,4 @@ "recommended": true } } -} \ No newline at end of file +} diff --git a/research/ARCHITECTURE.md b/docs/architecture.md similarity index 94% rename from research/ARCHITECTURE.md rename to docs/architecture.md index 95b6457..1098e79 100644 --- a/research/ARCHITECTURE.md +++ b/docs/architecture.md @@ -1,5 +1,7 @@ # Open Memory: Architecture & Research +> **Note**: `AGENTS.md` is the canonical operational reference for this project. This document provides deeper context on the research and design decisions. + ## Overview `@alkdev/open-memory` is a standalone OpenCode plugin providing three capabilities: @@ -74,7 +76,7 @@ The core problem: OpenCode's automatic compaction fires at ~92% context usage wi #### 3. Session History Browser -All backed by read-only `sqlite3` queries to `${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db`. +All backed by read-only `bun:sqlite` queries to `${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db`. **Tools:** @@ -83,14 +85,14 @@ All backed by read-only `sqlite3` queries to `${XDG_DATA_HOME:-$HOME/.local/shar | `memory_summary` | Quick counts: projects, sessions, messages, todos | | `memory_sessions` | List recent sessions with metadata, sorted by update time | | `memory_messages` | Read messages from a specific session as markdown | -| `memory_search` | Full-text search across all conversations | +| `memory_search` | Full-text search across all conversations (LIKE-based) | | `memory_plans` | List and read saved plans | **Rendering:** - Markdown tables for session lists - Formatted conversation transcripts for `memory_messages` - Snippet + session reference for search results -- All queries use `LIMIT` and `LIKE` to avoid dumping entire DB +- All queries use `LIMIT` and parameterized `db.prepare().all(params)` ## Component Design @@ -103,7 +105,7 @@ src/ │ ├── thresholds.ts # Context percentage thresholds & status │ └── notify.ts # System prompt injection for warnings ├── history/ -│ ├── queries.ts # SQLite query helpers +│ ├── queries.ts # bun:sqlite read-only query helper │ ├── format.ts # Markdown rendering utilities │ └── search.ts # Full-text search logic └── compaction/ diff --git a/opencode.json b/opencode.json index edf447e..afa2f33 100644 --- a/opencode.json +++ b/opencode.json @@ -1,4 +1,4 @@ { "plugin": ["@alkdev/open-memory"], "$schema": "https://opencode.ai/config.json" -} \ No newline at end of file +} diff --git a/package.json b/package.json index 8d3af28..80602ed 100644 --- a/package.json +++ b/package.json @@ -40,4 +40,4 @@ "@types/node": "^20.14.0", "typescript": "^5.7.3" } -} \ No newline at end of file +} diff --git a/src/compaction/prompt.ts b/src/compaction/prompt.ts index 79d0d31..f5f1b1c 100644 --- a/src/compaction/prompt.ts +++ b/src/compaction/prompt.ts @@ -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; \ No newline at end of file +export const getCompactionPrompt = (): string => DEFAULT_COMPACTION_PROMPT; diff --git a/src/context/notify.ts b/src/context/notify.ts index 720946c..1453e8f 100644 --- a/src/context/notify.ts +++ b/src/context/notify.ts @@ -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"); -}; \ No newline at end of file +}; diff --git a/src/context/thresholds.ts b/src/context/thresholds.ts index 244cc6d..ce252d7 100644 --- a/src/context/thresholds.ts +++ b/src/context/thresholds.ts @@ -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"; \ No newline at end of file diff --git a/src/context/tracker.ts b/src/context/tracker.ts index 0cbcecc..4ae8f66 100644 --- a/src/context/tracker.ts +++ b/src/context/tracker.ts @@ -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)?.read === "number" - ? (tokens.cache as Record).read as number + ? ((tokens.cache as Record).read as number) : 0) + (typeof (tokens.cache as Record)?.write === "number" - ? (tokens.cache as Record).write as number + ? ((tokens.cache as Record).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); -}; \ No newline at end of file +}; diff --git a/src/history/format.ts b/src/history/format.ts index 5cfbe8a..d6febde 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 ?? "").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 => { } return lines.join("\n"); -}; \ No newline at end of file +}; diff --git a/src/history/queries.ts b/src/history/queries.ts index 20539f0..5ac4162 100644 --- a/src/history/queries.ts +++ b/src/history/queries.ts @@ -1,22 +1,49 @@ -export const runQuery = async (dbUri: string, sql: string): Promise[]> => { - 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[]; - } catch { - throw new Error(`Failed to parse sqlite3 output: ${stdout.slice(0, 200)}`); +export const runQuery = >( + sql: string, + params?: Record, +): T[] => { + const db = getDb(); + const stmt = db.prepare(sql); + if (params) { + return stmt.all(params) as T[]; } -}; \ No newline at end of file + return stmt.all() as T[]; +}; + +export const closeDb = (): void => { + if (_db) { + try { + _db.close(true); + } catch { + // ignore close errors + } + _db = null; + _dbPath = null; + } +}; diff --git a/src/history/search.ts b/src/history/search.ts index 6820026..8d1595f 100644 --- a/src/history/search.ts +++ b/src/history/search.ts @@ -1,12 +1,14 @@ import { runQuery } from "./queries.js"; -export const searchConversations = async ( - dbUri: string, - searchTerm: string, - limit: number, -): Promise => { - 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(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)}`; } -}; \ No newline at end of file +}; diff --git a/src/index.ts b/src/index.ts index 498bf83..966bd8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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; \ No newline at end of file +export default OpenMemoryPlugin; diff --git a/src/tools.ts b/src/tools.ts index 733d267..4ecc6f5 100644 --- a/src/tools.ts +++ b/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>(` 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( + ` + 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( + ` + 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( + ` + 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 = ( } }, }), -}); \ No newline at end of file +}); diff --git a/tests/compaction.test.ts b/tests/compaction.test.ts new file mode 100644 index 0000000..2cfa175 --- /dev/null +++ b/tests/compaction.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { getCompactionPrompt } from "../src/compaction/prompt"; + +describe("compaction prompt", () => { + test("returns non-empty string", () => { + const prompt = getCompactionPrompt(); + expect(typeof prompt).toBe("string"); + expect(prompt.length).toBeGreaterThan(0); + }); + + test("emphasizes self-continuity, not another agent", () => { + const prompt = getCompactionPrompt(); + expect(prompt).toContain("yourself"); + expect(prompt).toContain("not another agent"); + }); + + test("includes key sections", () => { + const prompt = getCompactionPrompt(); + expect(prompt).toContain("## Goal"); + expect(prompt).toContain("## Instructions"); + expect(prompt).toContain("## Discoveries"); + expect(prompt).toContain("## Accomplished"); + expect(prompt).toContain("## Relevant files"); + expect(prompt).toContain("## Notes"); + }); +}); diff --git a/tests/format.test.ts b/tests/format.test.ts new file mode 100644 index 0000000..6d2b769 --- /dev/null +++ b/tests/format.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { formatMessageList, formatSessionList } from "../src/history/format"; + +describe("formatSessionList", () => { + test("returns message for empty list", () => { + expect(formatSessionList([])).toBe("No sessions found."); + }); + + test("formats sessions as markdown table", () => { + 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("Test Session"); + }); + + test("truncates long IDs and titles", () => { + const rows = [ + { + id: "ses_verylongidthatshouldbetruncated1234567890", + 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..."); + }); + + test("handles untitled sessions", () => { + const rows = [{ id: "ses_1", title: null, updated: "2024-01-15", msgs: 0 }]; + const result = formatSessionList(rows); + expect(result).toContain("untitled"); + }); +}); + +describe("formatMessageList", () => { + test("returns message for empty list", () => { + expect(formatMessageList([])).toBe("No messages found."); + }); + + test("formats messages with roles and timestamps", () => { + const rows = [ + { role: "user", time: "2024-01-15 10:00:00", text: "Hello" }, + { role: "assistant", time: "2024-01-15 10:00:05", text: "Hi there" }, + ]; + const result = formatMessageList(rows); + expect(result).toContain("# Conversation"); + expect(result).toContain("user"); + expect(result).toContain("assistant"); + expect(result).toContain("Hello"); + expect(result).toContain("Hi there"); + }); + + test("truncates long text", () => { + 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); + }); +}); diff --git a/tests/queries.test.ts b/tests/queries.test.ts new file mode 100644 index 0000000..c3df438 --- /dev/null +++ b/tests/queries.test.ts @@ -0,0 +1,76 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { closeDb, runQuery } from "../src/history/queries"; + +describe("queries module with bun:sqlite", () => { + const tmpDir = mkdtempSync(path.join(os.tmpdir(), "open-memory-test-")); + const opencodeDir = path.join(tmpDir, "opencode"); + const dbPath = path.join(opencodeDir, "opencode.db"); + const originalXdg = process.env.XDG_DATA_HOME; + + const setup = (): void => { + mkdirSync(opencodeDir, { recursive: true }); + const db = new Database(dbPath); + db.run( + "CREATE TABLE IF NOT EXISTS project (id TEXT PRIMARY KEY, worktree TEXT, name TEXT, time_updated INTEGER)", + ); + db.run( + "CREATE TABLE IF NOT EXISTS session (id TEXT, project_id TEXT, parent_id TEXT, title TEXT, summary TEXT, time_created INTEGER, time_updated INTEGER)", + ); + db.run( + "INSERT OR REPLACE INTO project VALUES ('proj1', '/tmp/test', 'test-project', 1700000000000)", + ); + db.run( + "INSERT OR REPLACE INTO session VALUES ('ses_1', 'proj1', NULL, 'Test Session', NULL, 1700000000000, 1700001000000)", + ); + db.close(); + process.env.XDG_DATA_HOME = opencodeDir; + }; + + const cleanup = (): void => { + closeDb(); + process.env.XDG_DATA_HOME = originalXdg; + rmSync(tmpDir, { recursive: true, force: true }); + }; + + test("runQuery without parameters", () => { + setup(); + try { + type CountRow = { label: string; cnt: number }; + const rows = runQuery("SELECT 'projects' AS label, COUNT(*) AS cnt FROM project"); + expect(rows.length).toBe(1); + expect(rows[0].cnt).toBe(1); + } finally { + cleanup(); + } + }); + + test("runQuery with named parameters", () => { + setup(); + try { + type SessionRow = { id: string; title: string }; + const rows = runQuery("SELECT id, title FROM session WHERE id = $sessionId", { + $sessionId: "ses_1", + }); + expect(rows.length).toBe(1); + expect(rows[0].id).toBe("ses_1"); + expect(rows[0].title).toBe("Test Session"); + } finally { + cleanup(); + } + }); + + test("runQuery opens database read-only", () => { + setup(); + try { + type Row = { cnt: number }; + const rows = runQuery("SELECT COUNT(*) AS cnt FROM session"); + expect(rows[0].cnt).toBe(1); + } finally { + cleanup(); + } + }); +}); diff --git a/tests/thresholds.test.ts b/tests/thresholds.test.ts new file mode 100644 index 0000000..6e2d4df --- /dev/null +++ b/tests/thresholds.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { type ContextStatus, getStatusLabel, THRESHOLDS } from "../src/context/thresholds"; + +describe("thresholds", () => { + test("THRESHOLDS constants", () => { + expect(THRESHOLDS.yellow).toBe(70); + expect(THRESHOLDS.red).toBe(85); + expect(THRESHOLDS.critical).toBe(92); + }); + + test("getStatusLabel returns correct status", () => { + expect(getStatusLabel(0)).toBe("green"); + expect(getStatusLabel(50)).toBe("green"); + expect(getStatusLabel(69)).toBe("green"); + expect(getStatusLabel(70)).toBe("yellow"); + expect(getStatusLabel(75)).toBe("yellow"); + expect(getStatusLabel(84)).toBe("yellow"); + expect(getStatusLabel(85)).toBe("red"); + expect(getStatusLabel(90)).toBe("red"); + expect(getStatusLabel(91)).toBe("red"); + expect(getStatusLabel(92)).toBe("critical"); + expect(getStatusLabel(99)).toBe("critical"); + expect(getStatusLabel(100)).toBe("critical"); + }); + + test("ContextStatus type accepts all valid values", () => { + const statuses: ContextStatus[] = ["green", "yellow", "red", "critical"]; + for (const s of statuses) { + expect(typeof s).toBe("string"); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 474560a..1ab1007 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,4 +15,4 @@ "forceConsistentCasingInFileNames": true }, "include": ["src"] -} \ No newline at end of file +}