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:
2026-04-20 16:14:35 +00:00
parent 9a42dcfb94
commit f7bb7f94cf
19 changed files with 515 additions and 125 deletions

152
AGENTS.md Normal file
View File

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

View File

@@ -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": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

@@ -1,5 +1,7 @@
# Open Memory: Architecture & Research # 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 ## Overview
`@alkdev/open-memory` is a standalone OpenCode plugin providing three capabilities: `@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 #### 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:** **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_summary` | Quick counts: projects, sessions, messages, todos |
| `memory_sessions` | List recent sessions with metadata, sorted by update time | | `memory_sessions` | List recent sessions with metadata, sorted by update time |
| `memory_messages` | Read messages from a specific session as markdown | | `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 | | `memory_plans` | List and read saved plans |
**Rendering:** **Rendering:**
- Markdown tables for session lists - Markdown tables for session lists
- Formatted conversation transcripts for `memory_messages` - Formatted conversation transcripts for `memory_messages`
- Snippet + session reference for search results - 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 ## Component Design
@@ -103,7 +105,7 @@ src/
│ ├── thresholds.ts # Context percentage thresholds & status │ ├── thresholds.ts # Context percentage thresholds & status
│ └── notify.ts # System prompt injection for warnings │ └── notify.ts # System prompt injection for warnings
├── history/ ├── history/
│ ├── queries.ts # SQLite query helpers │ ├── queries.ts # bun:sqlite read-only query helper
│ ├── format.ts # Markdown rendering utilities │ ├── format.ts # Markdown rendering utilities
│ └── search.ts # Full-text search logic │ └── search.ts # Full-text search logic
└── compaction/ └── compaction/

View File

@@ -1,6 +1,6 @@
export const formatAnomalyNotification = ( export const formatAnomalyNotification = (
sessionID: string, sessionID: string,
type: string, _type: string,
percentage: number, percentage: number,
status: string, status: string,
): string => { ): string => {

View File

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

View File

@@ -1,11 +1,12 @@
import type { Event } from "@opencode-ai/sdk";
import type { PluginInput } from "@opencode-ai/plugin"; import type { PluginInput } from "@opencode-ai/plugin";
import type { Event } from "@opencode-ai/sdk";
import { type ContextStatus, THRESHOLDS } from "./thresholds.js";
export type ContextInfo = { export type ContextInfo = {
usedTokens: number; usedTokens: number;
limitTokens: number; limitTokens: number;
percentage: number; percentage: number;
status: "green" | "yellow" | "red" | "critical"; status: ContextStatus;
model: string; model: string;
providerID: string; providerID: string;
modelID: string; modelID: string;
@@ -21,12 +22,6 @@ type SessionContextData = {
previousInputTokens: number[]; previousInputTokens: number[];
}; };
const THRESHOLDS = {
yellow: 0.70,
red: 0.85,
critical: 0.92,
} as const;
const DEFAULT_CONTEXT_LIMIT = 200_000; const DEFAULT_CONTEXT_LIMIT = 200_000;
export class ContextTracker { export class ContextTracker {
@@ -88,10 +83,10 @@ export class ContextTracker {
: inputTokens + : inputTokens +
(typeof tokens.output === "number" ? tokens.output : 0) + (typeof tokens.output === "number" ? tokens.output : 0) +
(typeof (tokens.cache as Record<string, unknown>)?.read === "number" (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) + : 0) +
(typeof (tokens.cache as Record<string, unknown>)?.write === "number" (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); : 0);
const infoModel = const infoModel =
@@ -131,16 +126,15 @@ export class ContextTracker {
if (!data || data.lastInputTokens === 0) return null; if (!data || data.lastInputTokens === 0) return null;
const modelKey = `${data.providerID}/${data.modelID}`; const modelKey = `${data.providerID}/${data.modelID}`;
const limitTokens = const limitTokens = this.modelContextLimits.get(modelKey) ?? DEFAULT_CONTEXT_LIMIT;
this.modelContextLimits.get(modelKey) ?? DEFAULT_CONTEXT_LIMIT;
const percentage = Math.round((data.lastInputTokens / limitTokens) * 100); const percentage = Math.round((data.lastInputTokens / limitTokens) * 100);
const status = const status: ContextStatus =
percentage >= THRESHOLDS.critical * 100 percentage >= THRESHOLDS.critical
? "critical" ? "critical"
: percentage >= THRESHOLDS.red * 100 : percentage >= THRESHOLDS.red
? "red" ? "red"
: percentage >= THRESHOLDS.yellow * 100 : percentage >= THRESHOLDS.yellow
? "yellow" ? "yellow"
: "green"; : "green";

View File

@@ -6,7 +6,7 @@ export const formatSessionList = (rows: Record<string, unknown>[]): string => {
lines.push("|----|-------|---------|----------|"); lines.push("|----|-------|---------|----------|");
for (const row of rows) { 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 title = String(row.title ?? "untitled").slice(0, 40);
const updated = String(row.updated ?? ""); const updated = String(row.updated ?? "");
const msgs = String(row.msgs ?? "0"); const msgs = String(row.msgs ?? "0");

View File

@@ -1,22 +1,49 @@
export const runQuery = async (dbUri: string, sql: string): Promise<Record<string, unknown>[]> => { import { Database } from "bun:sqlite";
const proc = Bun.spawn(["sqlite3", "-json", dbUri, sql], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited; const getDbPath = (): string => {
const stdout = await new Response(proc.stdout).text(); const dataRoot = process.env.XDG_DATA_HOME || `${process.env.HOME}/.local/share/opencode`;
const stderr = await new Response(proc.stderr).text(); return `${dataRoot}/opencode.db`;
};
if (exitCode !== 0) { let _db: Database | null = null;
throw new Error(`sqlite3 exited with code ${exitCode}: ${stderr}`); let _dbPath: string | null = null;
}
if (!stdout.trim()) return [];
const getDb = (): Database => {
const dbPath = getDbPath();
if (!_db || _dbPath !== dbPath) {
if (_db) {
try { try {
return JSON.parse(stdout) as Record<string, unknown>[]; _db.close(true);
} catch { } catch {
throw new Error(`Failed to parse sqlite3 output: ${stdout.slice(0, 200)}`); // ignore
}
}
_db = new Database(dbPath, { readonly: true, create: false });
_dbPath = dbPath;
}
return _db;
};
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;
} }
}; };

View File

@@ -1,12 +1,14 @@
import { runQuery } from "./queries.js"; import { runQuery } from "./queries.js";
export const searchConversations = async ( type SearchResult = {
dbUri: string, session_id: string;
searchTerm: string, title: string;
limit: number, role: string;
): Promise<string> => { time: string;
const escaped = searchTerm.replace(/'/g, "''"); snippet: string;
};
export const searchConversations = (searchTerm: string, limit: number): string => {
const query = ` const query = `
SELECT SELECT
s.id AS session_id, s.id AS session_id,
@@ -19,13 +21,16 @@ export const searchConversations = async (
JOIN session s ON s.id = m.session_id JOIN session s ON s.id = m.session_id
WHERE s.parent_id IS NULL WHERE s.parent_id IS NULL
AND json_extract(p.data, '$.type') = 'text' 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 ORDER BY m.time_created DESC
LIMIT ${limit} LIMIT $limit
`; `;
try { try {
const rows = await runQuery(dbUri, query); const rows = runQuery<SearchResult>(query, {
$searchPattern: `%${searchTerm}%`,
$limit: limit,
});
if (!rows || rows.length === 0) { if (!rows || rows.length === 0) {
return `No results found for "${searchTerm}".`; return `No results found for "${searchTerm}".`;
} }

View File

@@ -1,7 +1,7 @@
import type { Plugin, PluginInput } from "@opencode-ai/plugin"; import type { Plugin } from "@opencode-ai/plugin";
import { createTools } from "./tools.js";
import { startContextTracker } from "./context/tracker.js";
import { getCompactionPrompt } from "./compaction/prompt.js"; import { getCompactionPrompt } from "./compaction/prompt.js";
import { startContextTracker } from "./context/tracker.js";
import { createTools } from "./tools.js";
const OpenMemoryPlugin: Plugin = async (ctx) => { const OpenMemoryPlugin: Plugin = async (ctx) => {
const contextTracker = startContextTracker(ctx); const contextTracker = startContextTracker(ctx);

View File

@@ -1,15 +1,13 @@
import type { PluginInput, ToolDefinition } from "@opencode-ai/plugin"; import type { PluginInput, ToolDefinition } from "@opencode-ai/plugin";
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import type { ContextTracker } from "./context/tracker.js"; 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 { runQuery } from "./history/queries.js";
import { searchConversations } from "./history/search.js"; import { searchConversations } from "./history/search.js";
const z = tool.schema; const z = tool.schema;
const DATA_ROOT = process.env.XDG_DATA_HOME || `${process.env.HOME}/.local/share/opencode`; 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 = ( export const createTools = (
ctx: PluginInput, ctx: PluginInput,
@@ -50,7 +48,9 @@ export const createTools = (
if (info.status === "red" || info.status === "critical") { if (info.status === "red" || info.status === "critical") {
lines.push(""); 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"); return lines.join("\n");
@@ -133,7 +133,7 @@ export const createTools = (
args: {}, args: {},
async execute() { async execute() {
try { try {
const rows = await runQuery(DB_URI, ` const rows = runQuery<Record<string, unknown>>(`
SELECT 'projects', COUNT(*) FROM project SELECT 'projects', COUNT(*) FROM project
UNION ALL SELECT 'sessions (main)', COUNT(*) FROM session WHERE parent_id IS NULL UNION ALL SELECT 'sessions (main)', COUNT(*) FROM session WHERE parent_id IS NULL
UNION ALL SELECT 'sessions (total)', COUNT(*) FROM session UNION ALL SELECT 'sessions (total)', COUNT(*) FROM session
@@ -159,18 +159,25 @@ export const createTools = (
"List recent sessions with titles, update times, and message counts. Optionally filter by project path.", "List recent sessions with titles, update times, and message counts. Optionally filter by project path.",
args: { args: {
limit: z.number().optional().describe("Number of sessions to show (default: 10)."), limit: z.number().optional().describe("Number of sessions to show (default: 10)."),
projectPath: z projectPath: z.string().optional().describe("Filter to a specific project worktree path."),
.string()
.optional()
.describe("Filter to a specific project worktree path."),
}, },
async execute(args) { async execute(args) {
const limit = args.limit ?? 10; const limit = args.limit ?? 10;
try { try {
let query: string; type SessionRow = {
id: string;
title: string;
project?: string;
updated: string;
msgs: number;
};
let rows: SessionRow[];
if (args.projectPath) { if (args.projectPath) {
query = ` rows = runQuery<SessionRow>(
`
SELECT SELECT
s.id, s.id,
COALESCE(s.title, 'untitled') AS title, COALESCE(s.title, 'untitled') AS title,
@@ -178,13 +185,16 @@ export const createTools = (
(SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) AS msgs (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) AS msgs
FROM session s FROM session s
JOIN project p ON p.id = s.project_id JOIN project p ON p.id = s.project_id
WHERE p.worktree = '${args.projectPath.replace(/'/g, "''")}' WHERE p.worktree = $projectPath
AND s.parent_id IS NULL AND s.parent_id IS NULL
ORDER BY s.time_updated DESC ORDER BY s.time_updated DESC
LIMIT ${limit} LIMIT $limit
`; `,
{ $projectPath: args.projectPath, $limit: limit },
);
} else { } else {
query = ` rows = runQuery<SessionRow>(
`
SELECT SELECT
s.id, s.id,
COALESCE(s.title, 'untitled') AS title, COALESCE(s.title, 'untitled') AS title,
@@ -195,11 +205,12 @@ export const createTools = (
LEFT JOIN project p ON p.id = s.project_id LEFT JOIN project p ON p.id = s.project_id
WHERE s.parent_id IS NULL WHERE s.parent_id IS NULL
ORDER BY s.time_updated DESC ORDER BY s.time_updated DESC
LIMIT ${limit} LIMIT $limit
`; `,
{ $limit: limit },
);
} }
const rows = await runQuery(DB_URI, query);
if (!rows || rows.length === 0) { if (!rows || rows.length === 0) {
return "No sessions found."; return "No sessions found.";
} }
@@ -221,7 +232,14 @@ export const createTools = (
const limit = args.limit ?? 50; const limit = args.limit ?? 50;
try { try {
const query = ` type MessageRow = {
role: string;
time: string;
text: string;
};
const rows = runQuery<MessageRow>(
`
SELECT SELECT
json_extract(m.data, '$.role') AS role, json_extract(m.data, '$.role') AS role,
datetime(m.time_created/1000, 'unixepoch', 'localtime') AS time, datetime(m.time_created/1000, 'unixepoch', 'localtime') AS time,
@@ -229,12 +247,13 @@ export const createTools = (
FROM message m FROM message m
LEFT JOIN part p ON p.message_id = m.id LEFT JOIN part p ON p.message_id = m.id
AND json_extract(p.data, '$.type') = 'text' AND json_extract(p.data, '$.type') = 'text'
WHERE m.session_id = '${args.sessionId.replace(/'/g, "''")}' WHERE m.session_id = $sessionId
GROUP BY m.id GROUP BY m.id
ORDER BY m.time_created ASC ORDER BY m.time_created ASC
LIMIT ${limit} LIMIT $limit
`; `,
const rows = await runQuery(DB_URI, query); { $sessionId: args.sessionId, $limit: limit },
);
if (!rows || rows.length === 0) { if (!rows || rows.length === 0) {
return `No messages found for session ${args.sessionId}.`; return `No messages found for session ${args.sessionId}.`;
} }
@@ -256,11 +275,7 @@ export const createTools = (
const limit = args.limit ?? 10; const limit = args.limit ?? 10;
try { try {
const results = await searchConversations(DB_URI, args.query, limit); return searchConversations(args.query, limit);
if (!results || results.length === 0) {
return `No results found for "${args.query}".`;
}
return results;
} catch (err) { } catch (err) {
return `Search failed: ${err instanceof Error ? err.message : String(err)}`; return `Search failed: ${err instanceof Error ? err.message : String(err)}`;
} }
@@ -270,10 +285,7 @@ export const createTools = (
memory_plans: tool({ memory_plans: tool({
description: "List saved plan files from OpenCode's plans directory.", description: "List saved plan files from OpenCode's plans directory.",
args: { args: {
read: z read: z.string().optional().describe("Filename of a specific plan to read (without path)."),
.string()
.optional()
.describe("Filename of a specific plan to read (without path)."),
}, },
async execute(args) { async execute(args) {
const plansDir = `${DATA_ROOT}/plans`; const plansDir = `${DATA_ROOT}/plans`;

26
tests/compaction.test.ts Normal file
View File

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

64
tests/format.test.ts Normal file
View File

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

76
tests/queries.test.ts Normal file
View File

@@ -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<CountRow>("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<SessionRow>("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<Row>("SELECT COUNT(*) AS cnt FROM session");
expect(rows[0].cnt).toBe(1);
} finally {
cleanup();
}
});
});

32
tests/thresholds.test.ts Normal file
View File

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