@loreai/core 0.0.1 → 0.10.1

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.
Files changed (147) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -5
  3. package/dist/bun/agents-file.d.ts +59 -0
  4. package/dist/bun/agents-file.d.ts.map +1 -0
  5. package/dist/bun/config.d.ts +58 -0
  6. package/dist/bun/config.d.ts.map +1 -0
  7. package/dist/bun/curator.d.ts +35 -0
  8. package/dist/bun/curator.d.ts.map +1 -0
  9. package/dist/bun/db/driver.bun.d.ts +5 -0
  10. package/dist/bun/db/driver.bun.d.ts.map +1 -0
  11. package/dist/bun/db/driver.node.d.ts +15 -0
  12. package/dist/bun/db/driver.node.d.ts.map +1 -0
  13. package/dist/bun/db.d.ts +22 -0
  14. package/dist/bun/db.d.ts.map +1 -0
  15. package/dist/bun/distillation.d.ts +32 -0
  16. package/dist/bun/distillation.d.ts.map +1 -0
  17. package/dist/bun/embedding.d.ts +90 -0
  18. package/dist/bun/embedding.d.ts.map +1 -0
  19. package/dist/bun/gradient.d.ts +73 -0
  20. package/dist/bun/gradient.d.ts.map +1 -0
  21. package/dist/bun/index.d.ts +19 -0
  22. package/dist/bun/index.d.ts.map +1 -0
  23. package/dist/bun/index.js +28236 -0
  24. package/dist/bun/index.js.map +7 -0
  25. package/dist/bun/lat-reader.d.ts +69 -0
  26. package/dist/bun/lat-reader.d.ts.map +1 -0
  27. package/dist/bun/log.d.ts +17 -0
  28. package/dist/bun/log.d.ts.map +1 -0
  29. package/dist/bun/ltm.d.ts +138 -0
  30. package/dist/bun/ltm.d.ts.map +1 -0
  31. package/dist/bun/markdown.d.ts +37 -0
  32. package/dist/bun/markdown.d.ts.map +1 -0
  33. package/dist/bun/prompt.d.ts +47 -0
  34. package/dist/bun/prompt.d.ts.map +1 -0
  35. package/dist/bun/recall.d.ts +41 -0
  36. package/dist/bun/recall.d.ts.map +1 -0
  37. package/dist/bun/search.d.ts +113 -0
  38. package/dist/bun/search.d.ts.map +1 -0
  39. package/dist/bun/temporal.d.ts +66 -0
  40. package/dist/bun/temporal.d.ts.map +1 -0
  41. package/dist/bun/types.d.ts +180 -0
  42. package/dist/bun/types.d.ts.map +1 -0
  43. package/dist/bun/worker.d.ts +6 -0
  44. package/dist/bun/worker.d.ts.map +1 -0
  45. package/dist/node/agents-file.d.ts +59 -0
  46. package/dist/node/agents-file.d.ts.map +1 -0
  47. package/dist/node/config.d.ts +58 -0
  48. package/dist/node/config.d.ts.map +1 -0
  49. package/dist/node/curator.d.ts +35 -0
  50. package/dist/node/curator.d.ts.map +1 -0
  51. package/dist/node/db/driver.bun.d.ts +5 -0
  52. package/dist/node/db/driver.bun.d.ts.map +1 -0
  53. package/dist/node/db/driver.node.d.ts +15 -0
  54. package/dist/node/db/driver.node.d.ts.map +1 -0
  55. package/dist/node/db.d.ts +22 -0
  56. package/dist/node/db.d.ts.map +1 -0
  57. package/dist/node/distillation.d.ts +32 -0
  58. package/dist/node/distillation.d.ts.map +1 -0
  59. package/dist/node/embedding.d.ts +90 -0
  60. package/dist/node/embedding.d.ts.map +1 -0
  61. package/dist/node/gradient.d.ts +73 -0
  62. package/dist/node/gradient.d.ts.map +1 -0
  63. package/dist/node/index.d.ts +19 -0
  64. package/dist/node/index.d.ts.map +1 -0
  65. package/dist/node/index.js +28253 -0
  66. package/dist/node/index.js.map +7 -0
  67. package/dist/node/lat-reader.d.ts +69 -0
  68. package/dist/node/lat-reader.d.ts.map +1 -0
  69. package/dist/node/log.d.ts +17 -0
  70. package/dist/node/log.d.ts.map +1 -0
  71. package/dist/node/ltm.d.ts +138 -0
  72. package/dist/node/ltm.d.ts.map +1 -0
  73. package/dist/node/markdown.d.ts +37 -0
  74. package/dist/node/markdown.d.ts.map +1 -0
  75. package/dist/node/prompt.d.ts +47 -0
  76. package/dist/node/prompt.d.ts.map +1 -0
  77. package/dist/node/recall.d.ts +41 -0
  78. package/dist/node/recall.d.ts.map +1 -0
  79. package/dist/node/search.d.ts +113 -0
  80. package/dist/node/search.d.ts.map +1 -0
  81. package/dist/node/temporal.d.ts +66 -0
  82. package/dist/node/temporal.d.ts.map +1 -0
  83. package/dist/node/types.d.ts +180 -0
  84. package/dist/node/types.d.ts.map +1 -0
  85. package/dist/node/worker.d.ts +6 -0
  86. package/dist/node/worker.d.ts.map +1 -0
  87. package/dist/types/agents-file.d.ts +59 -0
  88. package/dist/types/agents-file.d.ts.map +1 -0
  89. package/dist/types/config.d.ts +58 -0
  90. package/dist/types/config.d.ts.map +1 -0
  91. package/dist/types/curator.d.ts +35 -0
  92. package/dist/types/curator.d.ts.map +1 -0
  93. package/dist/types/db/driver.bun.d.ts +5 -0
  94. package/dist/types/db/driver.bun.d.ts.map +1 -0
  95. package/dist/types/db/driver.node.d.ts +15 -0
  96. package/dist/types/db/driver.node.d.ts.map +1 -0
  97. package/dist/types/db.d.ts +22 -0
  98. package/dist/types/db.d.ts.map +1 -0
  99. package/dist/types/distillation.d.ts +32 -0
  100. package/dist/types/distillation.d.ts.map +1 -0
  101. package/dist/types/embedding.d.ts +90 -0
  102. package/dist/types/embedding.d.ts.map +1 -0
  103. package/dist/types/gradient.d.ts +73 -0
  104. package/dist/types/gradient.d.ts.map +1 -0
  105. package/dist/types/index.d.ts +19 -0
  106. package/dist/types/index.d.ts.map +1 -0
  107. package/dist/types/lat-reader.d.ts +69 -0
  108. package/dist/types/lat-reader.d.ts.map +1 -0
  109. package/dist/types/log.d.ts +17 -0
  110. package/dist/types/log.d.ts.map +1 -0
  111. package/dist/types/ltm.d.ts +138 -0
  112. package/dist/types/ltm.d.ts.map +1 -0
  113. package/dist/types/markdown.d.ts +37 -0
  114. package/dist/types/markdown.d.ts.map +1 -0
  115. package/dist/types/prompt.d.ts +47 -0
  116. package/dist/types/prompt.d.ts.map +1 -0
  117. package/dist/types/recall.d.ts +41 -0
  118. package/dist/types/recall.d.ts.map +1 -0
  119. package/dist/types/search.d.ts +113 -0
  120. package/dist/types/search.d.ts.map +1 -0
  121. package/dist/types/temporal.d.ts +66 -0
  122. package/dist/types/temporal.d.ts.map +1 -0
  123. package/dist/types/types.d.ts +180 -0
  124. package/dist/types/types.d.ts.map +1 -0
  125. package/dist/types/worker.d.ts +6 -0
  126. package/dist/types/worker.d.ts.map +1 -0
  127. package/package.json +48 -5
  128. package/src/agents-file.ts +406 -0
  129. package/src/config.ts +132 -0
  130. package/src/curator.ts +220 -0
  131. package/src/db/driver.bun.ts +18 -0
  132. package/src/db/driver.node.ts +54 -0
  133. package/src/db.ts +433 -0
  134. package/src/distillation.ts +433 -0
  135. package/src/embedding.ts +528 -0
  136. package/src/gradient.ts +1387 -0
  137. package/src/index.ts +109 -0
  138. package/src/lat-reader.ts +374 -0
  139. package/src/log.ts +27 -0
  140. package/src/ltm.ts +861 -0
  141. package/src/markdown.ts +129 -0
  142. package/src/prompt.ts +454 -0
  143. package/src/recall.ts +446 -0
  144. package/src/search.ts +330 -0
  145. package/src/temporal.ts +379 -0
  146. package/src/types.ts +199 -0
  147. package/src/worker.ts +26 -0
package/src/db.ts ADDED
@@ -0,0 +1,433 @@
1
+ import { Database } from "#db/driver";
2
+ import { join, dirname } from "path";
3
+ import { mkdirSync } from "fs";
4
+
5
+ const SCHEMA_VERSION = 10;
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
+ -- Version 3: One-time vacuum to reclaim accumulated free pages, and enable
129
+ -- incremental auto-vacuum so future deletes return pages to the OS.
130
+ -- VACUUM must run outside a transaction and cannot be in a multi-statement
131
+ -- exec, so it is handled specially in the migrate() function.
132
+ `,
133
+ `
134
+ -- Version 4: Persistent session state for error recovery.
135
+ -- Stores forceMinLayer so it survives OpenCode restarts. Without this,
136
+ -- a "prompt too long" error recovery (escalate to layer 2) is lost if
137
+ -- the process restarts before the next turn.
138
+ CREATE TABLE IF NOT EXISTS session_state (
139
+ session_id TEXT PRIMARY KEY,
140
+ force_min_layer INTEGER NOT NULL DEFAULT 0,
141
+ updated_at INTEGER NOT NULL
142
+ );
143
+ `,
144
+ `
145
+ -- Version 5: Multi-resolution composable distillations.
146
+ -- Instead of deleting gen-0 distillations during meta-distillation,
147
+ -- mark them as archived. Archived entries are excluded from the in-context
148
+ -- prefix but remain searchable via the recall tool, providing a detailed
149
+ -- "zoom-in" layer beneath the compressed gen-1 summary.
150
+ -- Inspired by Cartridges (Eyuboglu et al., 2025) composability: independently
151
+ -- compressed representations can be concatenated and queried without retraining.
152
+ -- Reference: https://arxiv.org/abs/2501.17390
153
+ ALTER TABLE distillations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
154
+ CREATE INDEX IF NOT EXISTS idx_distillation_archived ON distillations(archived);
155
+ `,
156
+ `
157
+ -- Version 6: Compound indexes for common multi-column query patterns.
158
+ -- Almost every query filters on (project_id, session_id) but only single-column
159
+ -- indexes existed, forcing SQLite to pick one and scan for the rest.
160
+
161
+ -- temporal_messages: covers bySession, search-LIKE fallback, count, undistilledCount
162
+ CREATE INDEX IF NOT EXISTS idx_temporal_project_session ON temporal_messages(project_id, session_id);
163
+ -- temporal_messages: covers undistilled() and undistilledCount() with distilled filter
164
+ CREATE INDEX IF NOT EXISTS idx_temporal_project_session_distilled ON temporal_messages(project_id, session_id, distilled);
165
+ -- temporal_messages: covers pruning TTL pass and size-cap pass (distilled=1 ordered by created_at)
166
+ CREATE INDEX IF NOT EXISTS idx_temporal_project_distilled_created ON temporal_messages(project_id, distilled, created_at);
167
+
168
+ -- distillations: covers loadForSession, latestObservations, searchDistillations, resetOrphans
169
+ CREATE INDEX IF NOT EXISTS idx_distillation_project_session ON distillations(project_id, session_id);
170
+ -- distillations: covers gen0Count, loadGen0, gradient prefix loading (archived filter)
171
+ CREATE INDEX IF NOT EXISTS idx_distillation_project_session_gen_archived ON distillations(project_id, session_id, generation, archived);
172
+
173
+ -- Drop redundant single-column indexes that are now left-prefixes of compound indexes.
174
+ -- idx_temporal_project is a prefix of idx_temporal_project_session.
175
+ -- idx_distillation_project is a prefix of idx_distillation_project_session.
176
+ -- idx_temporal_distilled is a prefix of no compound index but is low-selectivity (0/1)
177
+ -- and all queries that use it also filter on project_id — covered by the new compounds.
178
+ DROP INDEX IF EXISTS idx_temporal_project;
179
+ DROP INDEX IF EXISTS idx_temporal_distilled;
180
+ DROP INDEX IF EXISTS idx_distillation_project;
181
+ `,
182
+ `
183
+ -- Version 7: FTS5 for distillations — enables ranked search instead of LIKE.
184
+ CREATE VIRTUAL TABLE IF NOT EXISTS distillation_fts USING fts5(
185
+ observations,
186
+ content=distillations,
187
+ content_rowid=rowid,
188
+ tokenize='porter unicode61'
189
+ );
190
+
191
+ -- Backfill existing data (skip empty observations from schema v1→v2 migration)
192
+ INSERT INTO distillation_fts(rowid, observations)
193
+ SELECT rowid, observations FROM distillations WHERE observations != '';
194
+
195
+ -- Sync triggers
196
+ CREATE TRIGGER IF NOT EXISTS distillation_fts_insert AFTER INSERT ON distillations BEGIN
197
+ INSERT INTO distillation_fts(rowid, observations) VALUES (new.rowid, new.observations);
198
+ END;
199
+
200
+ CREATE TRIGGER IF NOT EXISTS distillation_fts_delete AFTER DELETE ON distillations BEGIN
201
+ INSERT INTO distillation_fts(distillation_fts, rowid, observations)
202
+ VALUES('delete', old.rowid, old.observations);
203
+ END;
204
+
205
+ CREATE TRIGGER IF NOT EXISTS distillation_fts_update AFTER UPDATE ON distillations BEGIN
206
+ INSERT INTO distillation_fts(distillation_fts, rowid, observations)
207
+ VALUES('delete', old.rowid, old.observations);
208
+ INSERT INTO distillation_fts(rowid, observations) VALUES (new.rowid, new.observations);
209
+ END;
210
+ `,
211
+ `
212
+ -- Version 8: Embedding BLOB column for vector search (Voyage AI).
213
+ -- No backfill — entries get embedded lazily on next create/update
214
+ -- or via explicit backfill when embeddings are first enabled.
215
+ ALTER TABLE knowledge ADD COLUMN embedding BLOB;
216
+
217
+ -- Key-value metadata table for plugin state (e.g. embedding config fingerprint).
218
+ CREATE TABLE IF NOT EXISTS kv_meta (
219
+ key TEXT PRIMARY KEY,
220
+ value TEXT NOT NULL
221
+ );
222
+ `,
223
+ `
224
+ -- Version 9: Embedding BLOB column for distillation vector search.
225
+ -- Same pattern as knowledge embeddings (version 8). Enables semantic
226
+ -- search over distilled session summaries via cosine similarity.
227
+ -- No backfill — entries get embedded lazily on next distillation
228
+ -- or via explicit backfill when embeddings are first enabled.
229
+ ALTER TABLE distillations ADD COLUMN embedding BLOB;
230
+ `,
231
+ `
232
+ -- Version 10: lat.md section cache + knowledge cross-references.
233
+
234
+ -- lat.md section cache for recall integration.
235
+ -- Parsed from lat.md/ directory markdown files, FTS5-indexed for search.
236
+ CREATE TABLE IF NOT EXISTS lat_sections (
237
+ id TEXT PRIMARY KEY,
238
+ project_id TEXT NOT NULL REFERENCES projects(id),
239
+ file TEXT NOT NULL,
240
+ heading TEXT NOT NULL,
241
+ depth INTEGER NOT NULL,
242
+ content TEXT NOT NULL,
243
+ content_hash TEXT NOT NULL,
244
+ first_paragraph TEXT,
245
+ updated_at INTEGER NOT NULL
246
+ );
247
+
248
+ CREATE VIRTUAL TABLE IF NOT EXISTS lat_sections_fts USING fts5(
249
+ heading,
250
+ content,
251
+ content=lat_sections,
252
+ content_rowid=rowid,
253
+ tokenize='porter unicode61'
254
+ );
255
+
256
+ CREATE TRIGGER IF NOT EXISTS lat_fts_insert AFTER INSERT ON lat_sections BEGIN
257
+ INSERT INTO lat_sections_fts(rowid, heading, content)
258
+ VALUES (new.rowid, new.heading, new.content);
259
+ END;
260
+
261
+ CREATE TRIGGER IF NOT EXISTS lat_fts_delete AFTER DELETE ON lat_sections BEGIN
262
+ INSERT INTO lat_sections_fts(lat_sections_fts, rowid, heading, content)
263
+ VALUES('delete', old.rowid, old.heading, old.content);
264
+ END;
265
+
266
+ CREATE TRIGGER IF NOT EXISTS lat_fts_update AFTER UPDATE ON lat_sections BEGIN
267
+ INSERT INTO lat_sections_fts(lat_sections_fts, rowid, heading, content)
268
+ VALUES('delete', old.rowid, old.heading, old.content);
269
+ INSERT INTO lat_sections_fts(rowid, heading, content)
270
+ VALUES (new.rowid, new.heading, new.content);
271
+ END;
272
+
273
+ CREATE INDEX IF NOT EXISTS idx_lat_sections_project ON lat_sections(project_id);
274
+ CREATE INDEX IF NOT EXISTS idx_lat_sections_file ON lat_sections(project_id, file);
275
+
276
+ -- Knowledge cross-references via [[entry-id]] wiki links.
277
+ -- ON DELETE CASCADE: when either entry is deleted, the ref row is auto-removed.
278
+ CREATE TABLE IF NOT EXISTS knowledge_refs (
279
+ from_id TEXT NOT NULL REFERENCES knowledge(id) ON DELETE CASCADE,
280
+ to_id TEXT NOT NULL REFERENCES knowledge(id) ON DELETE CASCADE,
281
+ PRIMARY KEY (from_id, to_id)
282
+ );
283
+ `,
284
+ ];
285
+
286
+ function dataDir() {
287
+ const xdg = process.env.XDG_DATA_HOME;
288
+ const base = xdg || join(process.env.HOME || "~", ".local", "share");
289
+ return join(base, "opencode-lore");
290
+ }
291
+
292
+ let instance: Database | undefined;
293
+
294
+ export function db(): Database {
295
+ if (instance) return instance;
296
+ const envPath = process.env.LORE_DB_PATH;
297
+ let path: string;
298
+ if (envPath) {
299
+ mkdirSync(dirname(envPath), { recursive: true });
300
+ path = envPath;
301
+ } else {
302
+ const dir = dataDir();
303
+ mkdirSync(dir, { recursive: true });
304
+ path = join(dir, "lore.db");
305
+ }
306
+ // Both `bun:sqlite` and `node:sqlite` create the file by default if it doesn't
307
+ // exist, so no special option is needed. (bun:sqlite's `{ create: true }`
308
+ // exists only to opt INTO creation when you want readonly=false — which is
309
+ // already the default for our case.)
310
+ instance = new Database(path);
311
+ instance.exec("PRAGMA journal_mode = WAL");
312
+ instance.exec("PRAGMA foreign_keys = ON");
313
+ // Return freed pages to the OS incrementally on each transaction commit
314
+ // instead of accumulating a free-page list that bloats the file.
315
+ instance.exec("PRAGMA auto_vacuum = INCREMENTAL");
316
+ migrate(instance);
317
+ return instance;
318
+ }
319
+
320
+ // Index of the migration that performs a one-time VACUUM.
321
+ // VACUUM cannot run inside a transaction, so migrate() handles it specially.
322
+ const VACUUM_MIGRATION_INDEX = 2; // 0-based index of version-3 migration
323
+
324
+ function migrate(database: Database) {
325
+ const row = database
326
+ .query(
327
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'",
328
+ )
329
+ .get() as { name: string } | null;
330
+ const current = row
331
+ ? ((
332
+ database.query("SELECT version FROM schema_version").get() as {
333
+ version: number;
334
+ }
335
+ )?.version ?? 0)
336
+ : 0;
337
+ if (current >= MIGRATIONS.length) return;
338
+ for (let i = current; i < MIGRATIONS.length; i++) {
339
+ if (i === VACUUM_MIGRATION_INDEX) {
340
+ // VACUUM cannot run inside a transaction. Run it directly.
341
+ // auto_vacuum mode must be set *before* VACUUM — SQLite bakes it into
342
+ // the file header during the rebuild. After this, every subsequent
343
+ // startup's "PRAGMA auto_vacuum = INCREMENTAL" is a no-op (already set).
344
+ database.exec("PRAGMA auto_vacuum = INCREMENTAL");
345
+ database.exec("VACUUM");
346
+ } else {
347
+ database.exec(MIGRATIONS[i]);
348
+ }
349
+ }
350
+ // Update version to latest. Migration 0 inserts version=1 via its own INSERT,
351
+ // but subsequent migrations don't update it, so always normalize to MIGRATIONS.length.
352
+ database.exec(`UPDATE schema_version SET version = ${MIGRATIONS.length}`);
353
+ }
354
+
355
+ export function close() {
356
+ if (instance) {
357
+ instance.close();
358
+ instance = undefined;
359
+ }
360
+ }
361
+
362
+ // Project management
363
+ export function ensureProject(path: string, name?: string): string {
364
+ const existing = db()
365
+ .query("SELECT id FROM projects WHERE path = ?")
366
+ .get(path) as { id: string } | null;
367
+ if (existing) return existing.id;
368
+ const id = crypto.randomUUID();
369
+ db()
370
+ .query(
371
+ "INSERT INTO projects (id, path, name, created_at) VALUES (?, ?, ?, ?)",
372
+ )
373
+ .run(id, path, name ?? path.split("/").pop() ?? "unknown", Date.now());
374
+ return id;
375
+ }
376
+
377
+ export function projectId(path: string): string | undefined {
378
+ const row = db()
379
+ .query("SELECT id FROM projects WHERE path = ?")
380
+ .get(path) as { id: string } | null;
381
+ return row?.id;
382
+ }
383
+
384
+ /** Look up a project's display name by its internal ID. */
385
+ export function projectName(id: string): string | null {
386
+ const row = db()
387
+ .query("SELECT name FROM projects WHERE id = ?")
388
+ .get(id) as { name: string } | null;
389
+ return row?.name ?? null;
390
+ }
391
+
392
+ /**
393
+ * Returns true if Lore has never been used before (no projects in the DB).
394
+ * Must be called before ensureProject() to get an accurate result.
395
+ */
396
+ export function isFirstRun(): boolean {
397
+ const row = db()
398
+ .query("SELECT COUNT(*) as count FROM projects")
399
+ .get() as { count: number };
400
+ return row.count === 0;
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Persistent session state (error recovery)
405
+ // ---------------------------------------------------------------------------
406
+
407
+ /**
408
+ * Load persisted forceMinLayer for a session. Returns 0 if none stored.
409
+ */
410
+ export function loadForceMinLayer(sessionID: string): number {
411
+ const row = db()
412
+ .query("SELECT force_min_layer FROM session_state WHERE session_id = ?")
413
+ .get(sessionID) as { force_min_layer: number } | null;
414
+ return row?.force_min_layer ?? 0;
415
+ }
416
+
417
+ /**
418
+ * Persist forceMinLayer for a session. Deletes the row when layer is 0
419
+ * (consumed) to avoid unbounded growth.
420
+ */
421
+ export function saveForceMinLayer(sessionID: string, layer: number): void {
422
+ if (layer === 0) {
423
+ db()
424
+ .query("DELETE FROM session_state WHERE session_id = ?")
425
+ .run(sessionID);
426
+ } else {
427
+ db()
428
+ .query(
429
+ "INSERT OR REPLACE INTO session_state (session_id, force_min_layer, updated_at) VALUES (?, ?, ?)",
430
+ )
431
+ .run(sessionID, layer, Date.now());
432
+ }
433
+ }