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:
152
AGENTS.md
Normal file
152
AGENTS.md
Normal 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
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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/
|
||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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";
|
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -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}".`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
76
src/tools.ts
76
src/tools.ts
@@ -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
26
tests/compaction.test.ts
Normal 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
64
tests/format.test.ts
Normal 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
76
tests/queries.test.ts
Normal 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
32
tests/thresholds.test.ts
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user