opencode-lore 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BYK
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # opencode-lore
2
+
3
+ > **Experimental** — This plugin is under active development. APIs, storage format, and behavior may change.
4
+
5
+ An implementation of [Sanity's Nuum](https://www.sanity.io/blog/how-we-solved-the-agent-memory-problem) memory architecture and [Mastra's Observational Memory](https://mastra.ai/research/observational-memory) system as a plugin for [OpenCode](https://opencode.ai). Both projects pioneered the idea that coding agents need **distillation, not summarization** — preserving operational intelligence (file paths, error messages, exact decisions) rather than narrative summaries that lose the details agents need to keep working. This plugin brings those ideas to OpenCode.
6
+
7
+ ## Why
8
+
9
+ Coding agents forget. Once a conversation exceeds the context window, earlier decisions, bug fixes, and architectural choices vanish. The default approach — summarize-and-compact — loses exactly the operational details agents need. After a few compaction passes, the agent knows you "discussed authentication" but can't actually continue the work.
10
+
11
+ ## How it works
12
+
13
+ Lore uses a three-tier memory architecture (following [Nuum's design](https://www.sanity.io/blog/how-we-solved-the-agent-memory-problem)):
14
+
15
+ 1. **Temporal storage** — every message is stored in a local SQLite FTS5 database, searchable on demand via the `recall` tool.
16
+
17
+ 2. **Distillation** — messages are incrementally distilled into an observation log (dated, timestamped, priority-tagged entries), following [Mastra's observer/reflector pattern](https://mastra.ai/research/observational-memory). When segments accumulate, older distillations are recursively merged to prevent unbounded growth. The observer prompt is tuned to preserve exact numbers, bug fixes, file paths, and assistant-generated content.
18
+
19
+ 3. **Long-term knowledge** — a curated knowledge base of facts, patterns, decisions, and gotchas that matter across projects, maintained by a background curator agent.
20
+
21
+ A **gradient context manager** decides how much of each tier to include in each turn, using a 4-layer safety system that calibrates overhead dynamically from real API token counts. This handles the unpredictable context consumption of coding agents (large tool outputs, system prompts, injected instructions) better than a fixed-budget approach.
22
+
23
+ ## Benchmarks
24
+
25
+ > Scores below are on Claude Sonnet 4 (claude-sonnet-4-6). Results may vary with other models.
26
+
27
+ ### General memory recall
28
+
29
+ 500-question evaluation using the [LongMemEval](https://github.com/xiaowu0162/LongMemEval) benchmark (ICLR 2025), tested in oracle mode (full message history provided as conversation context).
30
+
31
+ | Category | No plugin | Lore |
32
+ |---------------------------|-----------|---------|
33
+ | Single-session (user) | 71.9% | 93.8% |
34
+ | Single-session (prefs) | 46.7% | 86.7% |
35
+ | Single-session (assistant)| 91.1% | 96.4% |
36
+ | Multi-session | 76.9% | 85.1% |
37
+ | Knowledge updates | 84.7% | 93.1% |
38
+ | Temporal reasoning | 64.6% | 81.9% |
39
+ | Abstention | 53.3% | 86.7% |
40
+ | **Overall** | **72.6%** | **88.0%** |
41
+
42
+ ### Coding session recall
43
+
44
+ 15 questions across 3 real coding sessions, each asking about a specific fact from the conversation. Compared against OpenCode's default behavior (last ~80K tokens of context).
45
+
46
+ | Metric | Default | Lore |
47
+ |----------------|---------|--------------|
48
+ | Score | 10/15 | **14/15** |
49
+ | Accuracy | 66.7% | **93.3%** |
50
+
51
+ Lore's advantage is largest on early/mid-session details that fall outside the recent-context window — facts like which PR was being tested, why an endpoint was changed, how many rows were updated, or what a specific bug's root cause was. The `recall` tool covers gaps where the distilled observations lack fine-grained detail.
52
+
53
+ ## How we got here
54
+
55
+ This plugin was built in a few intense sessions. Some highlights:
56
+
57
+ **v1 — structured distillation.** The initial version used Nuum's `{ narrative, facts }` JSON format. It worked well for single-session preference recall (+40pp over baseline) but *regressed* on multi-session and temporal reasoning — the structured format was too rigid and lost temporal context.
58
+
59
+ **Markdown injection.** Property-based testing with fast-check revealed that user-generated content in facts (code fences, heading markers, thematic breaks) could break the markdown structure of the injected context, confusing the model.
60
+
61
+ **v2 — observation logs.** Switching to Mastra's observer/reflector architecture with plain-text timestamped observation logs was the breakthrough — LongMemEval jumped from 73.8% to 88.0%. The key insight: dated event logs preserve temporal relationships that structured JSON destroys.
62
+
63
+ **Prompt refinements.** The final push from 80% to 93.3% on coding recall came from two observer prompt additions: "EXACT NUMBERS — NEVER APPROXIMATE" (the observer was rounding counts) and "BUG FIXES — ALWAYS RECORD" (early-session fixes were being compressed away during reflection).
64
+
65
+ ## Installation
66
+
67
+ ### Prerequisites
68
+
69
+ - [OpenCode](https://opencode.ai)
70
+
71
+ ### Setup
72
+
73
+ Add `opencode-lore` to the `plugin` array in your project's `opencode.json`:
74
+
75
+ ```json
76
+ {
77
+ "plugin": [
78
+ "opencode-lore"
79
+ ]
80
+ }
81
+ ```
82
+
83
+ Restart OpenCode and the plugin will be installed automatically.
84
+
85
+ #### Development setup
86
+
87
+ To use a local clone instead of the published package:
88
+
89
+ ```json
90
+ {
91
+ "plugin": [
92
+ "file:///absolute/path/to/opencode-lore"
93
+ ]
94
+ }
95
+ ```
96
+
97
+ ## What gets stored
98
+
99
+ All data lives locally in `~/.local/share/opencode-lore/lore.db`:
100
+
101
+ - **Session observations** — timestamped event log of each conversation: what was asked, what was done, decisions made, errors found
102
+ - **Long-term knowledge** — patterns, gotchas, and architectural decisions curated across sessions and projects
103
+ - **Raw messages** — full message history in FTS5-indexed SQLite for the `recall` tool
104
+
105
+ ## The `recall` tool
106
+
107
+ The assistant gets a `recall` tool that searches across stored messages and knowledge. It's used automatically when the distilled context doesn't have enough detail:
108
+
109
+ - "What did we decide about auth last week?"
110
+ - "What was the error from the migration?"
111
+ - "What's my database schema convention?"
112
+
113
+ ## Standing on the shoulders of
114
+
115
+ - [How we solved the agent memory problem](https://www.sanity.io/blog/how-we-solved-the-agent-memory-problem) — Simen Svale at Sanity on the Nuum memory architecture: three-tier storage, distillation not summarization, recursive compression. The foundation this plugin is built on.
116
+ - [Mastra Observational Memory](https://mastra.ai/research/observational-memory) — the observer/reflector architecture and the switch from structured JSON to timestamped observation logs that made v2 work.
117
+ - [Mastra Memory source](https://github.com/mastra-ai/mastra/tree/main/packages/memory) — reference implementation.
118
+ - [LongMemEval](https://arxiv.org/abs/2410.10813) — the evaluation benchmark (ICLR 2025) we used to measure progress.
119
+ - [OpenCode](https://opencode.ai) — the coding agent this plugin extends.
120
+
121
+ ## License
122
+
123
+ MIT
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "opencode-lore",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "Three-tier memory architecture for OpenCode — distillation, not summarization",
7
+ "main": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "typecheck": "bun run tsc --noEmit",
13
+ "test": "bun test"
14
+ },
15
+ "peerDependencies": {
16
+ "@opencode-ai/plugin": ">=1.1.0"
17
+ },
18
+ "dependencies": {
19
+ "remark": "^15.0.1",
20
+ "zod": "^3.25.0"
21
+ },
22
+ "devDependencies": {
23
+ "@opencode-ai/plugin": "^1.1.39",
24
+ "@opencode-ai/sdk": "^1.1.39",
25
+ "@types/bun": "^1.2.0",
26
+ "fast-check": "^4.5.3",
27
+ "typescript": "^5.8.0"
28
+ },
29
+ "files": [
30
+ "src/",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/BYK/opencode-lore.git"
37
+ },
38
+ "keywords": [
39
+ "opencode",
40
+ "plugin",
41
+ "memory",
42
+ "agent",
43
+ "distillation",
44
+ "llm"
45
+ ],
46
+ "author": "BYK"
47
+ }
package/src/config.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { z } from "zod";
2
+
3
+ export const LoreConfig = z.object({
4
+ model: z
5
+ .object({
6
+ providerID: z.string(),
7
+ modelID: z.string(),
8
+ })
9
+ .optional(),
10
+ budget: z
11
+ .object({
12
+ distilled: z.number().min(0.05).max(0.5).default(0.25),
13
+ raw: z.number().min(0.1).max(0.7).default(0.4),
14
+ output: z.number().min(0.1).max(0.5).default(0.25),
15
+ })
16
+ .default({}),
17
+ distillation: z
18
+ .object({
19
+ minMessages: z.number().min(3).default(8),
20
+ maxSegment: z.number().min(5).default(50),
21
+ metaThreshold: z.number().min(3).default(10),
22
+ })
23
+ .default({}),
24
+ curator: z
25
+ .object({
26
+ enabled: z.boolean().default(true),
27
+ onIdle: z.boolean().default(true),
28
+ afterTurns: z.number().min(1).default(10),
29
+ })
30
+ .default({}),
31
+ crossProject: z.boolean().default(true),
32
+ });
33
+
34
+ export type LoreConfig = z.infer<typeof LoreConfig>;
35
+
36
+ let current: LoreConfig = LoreConfig.parse({});
37
+
38
+ export function config(): LoreConfig {
39
+ return current;
40
+ }
41
+
42
+ export async function load(directory: string): Promise<LoreConfig> {
43
+ const paths = [`${directory}/.opencode/lore.json`, `${directory}/lore.json`];
44
+ for (const path of paths) {
45
+ const file = Bun.file(path);
46
+ if (await file.exists()) {
47
+ const raw = await file.json();
48
+ current = LoreConfig.parse(raw);
49
+ return current;
50
+ }
51
+ }
52
+ current = LoreConfig.parse({});
53
+ return current;
54
+ }
package/src/curator.ts ADDED
@@ -0,0 +1,154 @@
1
+ import type { createOpencodeClient } from "@opencode-ai/sdk";
2
+ import { config } from "./config";
3
+ import * as temporal from "./temporal";
4
+ import * as ltm from "./ltm";
5
+ import { CURATOR_SYSTEM, curatorUser } from "./prompt";
6
+ import { workerSessionIDs } from "./distillation";
7
+
8
+ type Client = ReturnType<typeof createOpencodeClient>;
9
+
10
+ const workerSessions = new Map<string, string>();
11
+
12
+ async function ensureWorkerSession(
13
+ client: Client,
14
+ parentID: string,
15
+ ): Promise<string> {
16
+ const existing = workerSessions.get(parentID);
17
+ if (existing) return existing;
18
+ const session = await client.session.create({
19
+ body: { parentID, title: "lore curator" },
20
+ });
21
+ const id = session.data!.id;
22
+ workerSessions.set(parentID, id);
23
+ workerSessionIDs.add(id);
24
+ return id;
25
+ }
26
+
27
+ type CuratorOp =
28
+ | {
29
+ op: "create";
30
+ category: string;
31
+ title: string;
32
+ content: string;
33
+ scope: "project" | "global";
34
+ crossProject?: boolean;
35
+ }
36
+ | { op: "update"; id: string; content?: string; confidence?: number }
37
+ | { op: "delete"; id: string; reason: string };
38
+
39
+ function parseOps(text: string): CuratorOp[] {
40
+ const cleaned = text
41
+ .trim()
42
+ .replace(/^```json?\s*/i, "")
43
+ .replace(/\s*```$/i, "");
44
+ try {
45
+ const parsed = JSON.parse(cleaned);
46
+ if (!Array.isArray(parsed)) return [];
47
+ return parsed.filter(
48
+ (op: unknown) =>
49
+ typeof op === "object" &&
50
+ op !== null &&
51
+ "op" in op &&
52
+ typeof (op as Record<string, unknown>).op === "string",
53
+ ) as CuratorOp[];
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ // Track which messages we've already curated
60
+ let lastCuratedAt = 0;
61
+
62
+ export async function run(input: {
63
+ client: Client;
64
+ projectPath: string;
65
+ sessionID: string;
66
+ model?: { providerID: string; modelID: string };
67
+ }): Promise<{ created: number; updated: number; deleted: number }> {
68
+ const cfg = config();
69
+ if (!cfg.curator.enabled) return { created: 0, updated: 0, deleted: 0 };
70
+
71
+ // Get recent messages since last curation
72
+ const all = temporal.bySession(input.projectPath, input.sessionID);
73
+ const recent = all.filter((m) => m.created_at > lastCuratedAt);
74
+ if (recent.length < 3) return { created: 0, updated: 0, deleted: 0 };
75
+
76
+ const text = recent.map((m) => `[${m.role}] ${m.content}`).join("\n\n");
77
+ const existing = ltm.forProject(input.projectPath, cfg.crossProject);
78
+ const existingForPrompt = existing.map((e) => ({
79
+ id: e.id,
80
+ category: e.category,
81
+ title: e.title,
82
+ content: e.content,
83
+ }));
84
+
85
+ const userContent = curatorUser({
86
+ messages: text,
87
+ existing: existingForPrompt,
88
+ });
89
+ const workerID = await ensureWorkerSession(input.client, input.sessionID);
90
+ const model = input.model ?? cfg.model;
91
+ const parts = [
92
+ { type: "text" as const, text: `${CURATOR_SYSTEM}\n\n${userContent}` },
93
+ ];
94
+
95
+ await input.client.session.prompt({
96
+ path: { id: workerID },
97
+ body: {
98
+ parts,
99
+ agent: "lore-curator",
100
+ ...(model ? { model } : {}),
101
+ },
102
+ });
103
+
104
+ const msgs = await input.client.session.messages({
105
+ path: { id: workerID },
106
+ query: { limit: 2 },
107
+ });
108
+ const last = msgs.data?.at(-1);
109
+ if (!last || last.info.role !== "assistant")
110
+ return { created: 0, updated: 0, deleted: 0 };
111
+
112
+ const responsePart = last.parts.find((p) => p.type === "text");
113
+ if (!responsePart || responsePart.type !== "text")
114
+ return { created: 0, updated: 0, deleted: 0 };
115
+
116
+ const ops = parseOps(responsePart.text);
117
+ let created = 0;
118
+ let updated = 0;
119
+ let deleted = 0;
120
+
121
+ for (const op of ops) {
122
+ if (op.op === "create") {
123
+ ltm.create({
124
+ projectPath: op.scope === "project" ? input.projectPath : undefined,
125
+ category: op.category,
126
+ title: op.title,
127
+ content: op.content,
128
+ session: input.sessionID,
129
+ scope: op.scope,
130
+ crossProject: op.crossProject ?? true,
131
+ });
132
+ created++;
133
+ } else if (op.op === "update") {
134
+ const entry = ltm.get(op.id);
135
+ if (entry) {
136
+ ltm.update(op.id, { content: op.content, confidence: op.confidence });
137
+ updated++;
138
+ }
139
+ } else if (op.op === "delete") {
140
+ const entry = ltm.get(op.id);
141
+ if (entry) {
142
+ ltm.remove(op.id);
143
+ deleted++;
144
+ }
145
+ }
146
+ }
147
+
148
+ lastCuratedAt = Date.now();
149
+ return { created, updated, deleted };
150
+ }
151
+
152
+ export function resetCurationTracker() {
153
+ lastCuratedAt = 0;
154
+ }
package/src/db.ts ADDED
@@ -0,0 +1,198 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { join } from "path";
3
+ import { mkdirSync } from "fs";
4
+
5
+ const SCHEMA_VERSION = 2;
6
+
7
+ const MIGRATIONS: string[] = [
8
+ `
9
+ -- Version 1: Initial schema
10
+
11
+ CREATE TABLE IF NOT EXISTS projects (
12
+ id TEXT PRIMARY KEY,
13
+ path TEXT NOT NULL UNIQUE,
14
+ name TEXT,
15
+ created_at INTEGER NOT NULL
16
+ );
17
+
18
+ CREATE TABLE IF NOT EXISTS temporal_messages (
19
+ id TEXT PRIMARY KEY,
20
+ project_id TEXT NOT NULL REFERENCES projects(id),
21
+ session_id TEXT NOT NULL,
22
+ role TEXT NOT NULL,
23
+ content TEXT NOT NULL,
24
+ tokens INTEGER DEFAULT 0,
25
+ distilled INTEGER DEFAULT 0,
26
+ created_at INTEGER NOT NULL,
27
+ metadata TEXT
28
+ );
29
+
30
+ CREATE VIRTUAL TABLE IF NOT EXISTS temporal_fts USING fts5(
31
+ content,
32
+ content=temporal_messages,
33
+ content_rowid=rowid,
34
+ tokenize='porter unicode61'
35
+ );
36
+
37
+ -- Triggers to keep FTS in sync
38
+ CREATE TRIGGER IF NOT EXISTS temporal_fts_insert AFTER INSERT ON temporal_messages BEGIN
39
+ INSERT INTO temporal_fts(rowid, content) VALUES (new.rowid, new.content);
40
+ END;
41
+
42
+ CREATE TRIGGER IF NOT EXISTS temporal_fts_delete AFTER DELETE ON temporal_messages BEGIN
43
+ INSERT INTO temporal_fts(temporal_fts, rowid, content) VALUES('delete', old.rowid, old.content);
44
+ END;
45
+
46
+ CREATE TRIGGER IF NOT EXISTS temporal_fts_update AFTER UPDATE ON temporal_messages BEGIN
47
+ INSERT INTO temporal_fts(temporal_fts, rowid, content) VALUES('delete', old.rowid, old.content);
48
+ INSERT INTO temporal_fts(rowid, content) VALUES (new.rowid, new.content);
49
+ END;
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_temporal_session ON temporal_messages(session_id);
52
+ CREATE INDEX IF NOT EXISTS idx_temporal_project ON temporal_messages(project_id);
53
+ CREATE INDEX IF NOT EXISTS idx_temporal_distilled ON temporal_messages(distilled);
54
+ CREATE INDEX IF NOT EXISTS idx_temporal_created ON temporal_messages(created_at);
55
+
56
+ CREATE TABLE IF NOT EXISTS distillations (
57
+ id TEXT PRIMARY KEY,
58
+ project_id TEXT NOT NULL REFERENCES projects(id),
59
+ session_id TEXT NOT NULL,
60
+ narrative TEXT NOT NULL,
61
+ facts TEXT NOT NULL,
62
+ source_ids TEXT NOT NULL,
63
+ generation INTEGER DEFAULT 0,
64
+ token_count INTEGER DEFAULT 0,
65
+ created_at INTEGER NOT NULL
66
+ );
67
+
68
+ CREATE INDEX IF NOT EXISTS idx_distillation_session ON distillations(session_id);
69
+ CREATE INDEX IF NOT EXISTS idx_distillation_project ON distillations(project_id);
70
+ CREATE INDEX IF NOT EXISTS idx_distillation_generation ON distillations(generation);
71
+ CREATE INDEX IF NOT EXISTS idx_distillation_created ON distillations(created_at);
72
+
73
+ CREATE TABLE IF NOT EXISTS knowledge (
74
+ id TEXT PRIMARY KEY,
75
+ project_id TEXT,
76
+ category TEXT NOT NULL,
77
+ title TEXT NOT NULL,
78
+ content TEXT NOT NULL,
79
+ source_session TEXT,
80
+ cross_project INTEGER DEFAULT 0,
81
+ confidence REAL DEFAULT 1.0,
82
+ created_at INTEGER NOT NULL,
83
+ updated_at INTEGER NOT NULL,
84
+ metadata TEXT
85
+ );
86
+
87
+ CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5(
88
+ title,
89
+ content,
90
+ category,
91
+ content=knowledge,
92
+ content_rowid=rowid,
93
+ tokenize='porter unicode61'
94
+ );
95
+
96
+ CREATE TRIGGER IF NOT EXISTS knowledge_fts_insert AFTER INSERT ON knowledge BEGIN
97
+ INSERT INTO knowledge_fts(rowid, title, content, category)
98
+ VALUES (new.rowid, new.title, new.content, new.category);
99
+ END;
100
+
101
+ CREATE TRIGGER IF NOT EXISTS knowledge_fts_delete AFTER DELETE ON knowledge BEGIN
102
+ INSERT INTO knowledge_fts(knowledge_fts, rowid, title, content, category)
103
+ VALUES('delete', old.rowid, old.title, old.content, old.category);
104
+ END;
105
+
106
+ CREATE TRIGGER IF NOT EXISTS knowledge_fts_update AFTER UPDATE ON knowledge BEGIN
107
+ INSERT INTO knowledge_fts(knowledge_fts, rowid, title, content, category)
108
+ VALUES('delete', old.rowid, old.title, old.content, old.category);
109
+ INSERT INTO knowledge_fts(rowid, title, content, category)
110
+ VALUES (new.rowid, new.title, new.content, new.category);
111
+ END;
112
+
113
+ CREATE INDEX IF NOT EXISTS idx_knowledge_project ON knowledge(project_id);
114
+ CREATE INDEX IF NOT EXISTS idx_knowledge_category ON knowledge(category);
115
+ CREATE INDEX IF NOT EXISTS idx_knowledge_cross ON knowledge(cross_project);
116
+
117
+ CREATE TABLE IF NOT EXISTS schema_version (
118
+ version INTEGER NOT NULL
119
+ );
120
+
121
+ INSERT INTO schema_version (version) VALUES (1);
122
+ `,
123
+ `
124
+ -- Version 2: Replace narrative+facts with observations text
125
+ ALTER TABLE distillations ADD COLUMN observations TEXT NOT NULL DEFAULT '';
126
+ `,
127
+ ];
128
+
129
+ function dataDir() {
130
+ const xdg = process.env.XDG_DATA_HOME;
131
+ const base = xdg || join(process.env.HOME || "~", ".local", "share");
132
+ return join(base, "opencode-lore");
133
+ }
134
+
135
+ let instance: Database | undefined;
136
+
137
+ export function db(): Database {
138
+ if (instance) return instance;
139
+ const dir = dataDir();
140
+ mkdirSync(dir, { recursive: true });
141
+ const path = join(dir, "lore.db");
142
+ instance = new Database(path, { create: true });
143
+ instance.exec("PRAGMA journal_mode = WAL");
144
+ instance.exec("PRAGMA foreign_keys = ON");
145
+ migrate(instance);
146
+ return instance;
147
+ }
148
+
149
+ function migrate(database: Database) {
150
+ const row = database
151
+ .query(
152
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'",
153
+ )
154
+ .get() as { name: string } | null;
155
+ const current = row
156
+ ? ((
157
+ database.query("SELECT version FROM schema_version").get() as {
158
+ version: number;
159
+ }
160
+ )?.version ?? 0)
161
+ : 0;
162
+ if (current >= MIGRATIONS.length) return;
163
+ for (let i = current; i < MIGRATIONS.length; i++) {
164
+ database.exec(MIGRATIONS[i]);
165
+ }
166
+ // Update version to latest. Migration 0 inserts version=1 via its own INSERT,
167
+ // but subsequent migrations don't update it, so always normalize to MIGRATIONS.length.
168
+ database.exec(`UPDATE schema_version SET version = ${MIGRATIONS.length}`);
169
+ }
170
+
171
+ export function close() {
172
+ if (instance) {
173
+ instance.close();
174
+ instance = undefined;
175
+ }
176
+ }
177
+
178
+ // Project management
179
+ export function ensureProject(path: string, name?: string): string {
180
+ const existing = db()
181
+ .query("SELECT id FROM projects WHERE path = ?")
182
+ .get(path) as { id: string } | null;
183
+ if (existing) return existing.id;
184
+ const id = crypto.randomUUID();
185
+ db()
186
+ .query(
187
+ "INSERT INTO projects (id, path, name, created_at) VALUES (?, ?, ?, ?)",
188
+ )
189
+ .run(id, path, name ?? path.split("/").pop() ?? "unknown", Date.now());
190
+ return id;
191
+ }
192
+
193
+ export function projectId(path: string): string | undefined {
194
+ const row = db()
195
+ .query("SELECT id FROM projects WHERE path = ?")
196
+ .get(path) as { id: string } | null;
197
+ return row?.id;
198
+ }