@loreai/core 0.16.0 → 0.17.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 (155) hide show
  1. package/README.md +11 -0
  2. package/dist/bun/agents-file.d.ts +13 -1
  3. package/dist/bun/agents-file.d.ts.map +1 -1
  4. package/dist/bun/config.d.ts +20 -1
  5. package/dist/bun/config.d.ts.map +1 -1
  6. package/dist/bun/data.d.ts +174 -0
  7. package/dist/bun/data.d.ts.map +1 -0
  8. package/dist/bun/db.d.ts +65 -0
  9. package/dist/bun/db.d.ts.map +1 -1
  10. package/dist/bun/distillation.d.ts +49 -6
  11. package/dist/bun/distillation.d.ts.map +1 -1
  12. package/dist/bun/embedding-vendor.d.ts +66 -0
  13. package/dist/bun/embedding-vendor.d.ts.map +1 -0
  14. package/dist/bun/embedding-worker-types.d.ts +66 -0
  15. package/dist/bun/embedding-worker-types.d.ts.map +1 -0
  16. package/dist/bun/embedding-worker.d.ts +16 -0
  17. package/dist/bun/embedding-worker.d.ts.map +1 -0
  18. package/dist/bun/embedding-worker.js +100 -0
  19. package/dist/bun/embedding-worker.js.map +7 -0
  20. package/dist/bun/embedding.d.ts +91 -8
  21. package/dist/bun/embedding.d.ts.map +1 -1
  22. package/dist/bun/git.d.ts +47 -0
  23. package/dist/bun/git.d.ts.map +1 -0
  24. package/dist/bun/gradient.d.ts +19 -1
  25. package/dist/bun/gradient.d.ts.map +1 -1
  26. package/dist/bun/index.d.ts +9 -6
  27. package/dist/bun/index.d.ts.map +1 -1
  28. package/dist/bun/index.js +13029 -10885
  29. package/dist/bun/index.js.map +4 -4
  30. package/dist/bun/lat-reader.d.ts +1 -1
  31. package/dist/bun/lat-reader.d.ts.map +1 -1
  32. package/dist/bun/ltm.d.ts.map +1 -1
  33. package/dist/bun/markdown.d.ts +11 -0
  34. package/dist/bun/markdown.d.ts.map +1 -1
  35. package/dist/bun/prompt.d.ts +1 -1
  36. package/dist/bun/prompt.d.ts.map +1 -1
  37. package/dist/bun/recall.d.ts +53 -0
  38. package/dist/bun/recall.d.ts.map +1 -1
  39. package/dist/bun/search.d.ts +29 -0
  40. package/dist/bun/search.d.ts.map +1 -1
  41. package/dist/bun/temporal.d.ts +2 -0
  42. package/dist/bun/temporal.d.ts.map +1 -1
  43. package/dist/bun/types.d.ts +15 -0
  44. package/dist/bun/types.d.ts.map +1 -1
  45. package/dist/bun/worker-model.d.ts +12 -9
  46. package/dist/bun/worker-model.d.ts.map +1 -1
  47. package/dist/node/agents-file.d.ts +13 -1
  48. package/dist/node/agents-file.d.ts.map +1 -1
  49. package/dist/node/config.d.ts +20 -1
  50. package/dist/node/config.d.ts.map +1 -1
  51. package/dist/node/data.d.ts +174 -0
  52. package/dist/node/data.d.ts.map +1 -0
  53. package/dist/node/db.d.ts +65 -0
  54. package/dist/node/db.d.ts.map +1 -1
  55. package/dist/node/distillation.d.ts +49 -6
  56. package/dist/node/distillation.d.ts.map +1 -1
  57. package/dist/node/embedding-vendor.d.ts +66 -0
  58. package/dist/node/embedding-vendor.d.ts.map +1 -0
  59. package/dist/node/embedding-worker-types.d.ts +66 -0
  60. package/dist/node/embedding-worker-types.d.ts.map +1 -0
  61. package/dist/node/embedding-worker.d.ts +16 -0
  62. package/dist/node/embedding-worker.d.ts.map +1 -0
  63. package/dist/node/embedding-worker.js +100 -0
  64. package/dist/node/embedding-worker.js.map +7 -0
  65. package/dist/node/embedding.d.ts +91 -8
  66. package/dist/node/embedding.d.ts.map +1 -1
  67. package/dist/node/git.d.ts +47 -0
  68. package/dist/node/git.d.ts.map +1 -0
  69. package/dist/node/gradient.d.ts +19 -1
  70. package/dist/node/gradient.d.ts.map +1 -1
  71. package/dist/node/index.d.ts +9 -6
  72. package/dist/node/index.d.ts.map +1 -1
  73. package/dist/node/index.js +13029 -10885
  74. package/dist/node/index.js.map +4 -4
  75. package/dist/node/lat-reader.d.ts +1 -1
  76. package/dist/node/lat-reader.d.ts.map +1 -1
  77. package/dist/node/ltm.d.ts.map +1 -1
  78. package/dist/node/markdown.d.ts +11 -0
  79. package/dist/node/markdown.d.ts.map +1 -1
  80. package/dist/node/prompt.d.ts +1 -1
  81. package/dist/node/prompt.d.ts.map +1 -1
  82. package/dist/node/recall.d.ts +53 -0
  83. package/dist/node/recall.d.ts.map +1 -1
  84. package/dist/node/search.d.ts +29 -0
  85. package/dist/node/search.d.ts.map +1 -1
  86. package/dist/node/temporal.d.ts +2 -0
  87. package/dist/node/temporal.d.ts.map +1 -1
  88. package/dist/node/types.d.ts +15 -0
  89. package/dist/node/types.d.ts.map +1 -1
  90. package/dist/node/worker-model.d.ts +12 -9
  91. package/dist/node/worker-model.d.ts.map +1 -1
  92. package/dist/types/agents-file.d.ts +13 -1
  93. package/dist/types/agents-file.d.ts.map +1 -1
  94. package/dist/types/config.d.ts +20 -1
  95. package/dist/types/config.d.ts.map +1 -1
  96. package/dist/types/data.d.ts +174 -0
  97. package/dist/types/data.d.ts.map +1 -0
  98. package/dist/types/db.d.ts +65 -0
  99. package/dist/types/db.d.ts.map +1 -1
  100. package/dist/types/distillation.d.ts +49 -6
  101. package/dist/types/distillation.d.ts.map +1 -1
  102. package/dist/types/embedding-vendor.d.ts +66 -0
  103. package/dist/types/embedding-vendor.d.ts.map +1 -0
  104. package/dist/types/embedding-worker-types.d.ts +66 -0
  105. package/dist/types/embedding-worker-types.d.ts.map +1 -0
  106. package/dist/types/embedding-worker.d.ts +16 -0
  107. package/dist/types/embedding-worker.d.ts.map +1 -0
  108. package/dist/types/embedding.d.ts +91 -8
  109. package/dist/types/embedding.d.ts.map +1 -1
  110. package/dist/types/git.d.ts +47 -0
  111. package/dist/types/git.d.ts.map +1 -0
  112. package/dist/types/gradient.d.ts +19 -1
  113. package/dist/types/gradient.d.ts.map +1 -1
  114. package/dist/types/index.d.ts +9 -6
  115. package/dist/types/index.d.ts.map +1 -1
  116. package/dist/types/lat-reader.d.ts +1 -1
  117. package/dist/types/lat-reader.d.ts.map +1 -1
  118. package/dist/types/ltm.d.ts.map +1 -1
  119. package/dist/types/markdown.d.ts +11 -0
  120. package/dist/types/markdown.d.ts.map +1 -1
  121. package/dist/types/prompt.d.ts +1 -1
  122. package/dist/types/prompt.d.ts.map +1 -1
  123. package/dist/types/recall.d.ts +53 -0
  124. package/dist/types/recall.d.ts.map +1 -1
  125. package/dist/types/search.d.ts +29 -0
  126. package/dist/types/search.d.ts.map +1 -1
  127. package/dist/types/temporal.d.ts +2 -0
  128. package/dist/types/temporal.d.ts.map +1 -1
  129. package/dist/types/types.d.ts +15 -0
  130. package/dist/types/types.d.ts.map +1 -1
  131. package/dist/types/worker-model.d.ts +12 -9
  132. package/dist/types/worker-model.d.ts.map +1 -1
  133. package/package.json +5 -2
  134. package/src/agents-file.ts +87 -4
  135. package/src/config.ts +68 -5
  136. package/src/curator.ts +2 -2
  137. package/src/data.ts +768 -0
  138. package/src/db.ts +386 -7
  139. package/src/distillation.ts +178 -35
  140. package/src/embedding-vendor.ts +102 -0
  141. package/src/embedding-worker-types.ts +82 -0
  142. package/src/embedding-worker.ts +185 -0
  143. package/src/embedding.ts +607 -61
  144. package/src/git.ts +144 -0
  145. package/src/gradient.ts +174 -17
  146. package/src/index.ts +20 -0
  147. package/src/lat-reader.ts +5 -11
  148. package/src/ltm.ts +17 -44
  149. package/src/markdown.ts +15 -0
  150. package/src/prompt.ts +1 -2
  151. package/src/recall.ts +401 -70
  152. package/src/search.ts +71 -1
  153. package/src/temporal.ts +42 -35
  154. package/src/types.ts +15 -0
  155. package/src/worker-model.ts +14 -9
package/src/data.ts ADDED
@@ -0,0 +1,768 @@
1
+ /**
2
+ * data.ts — Data listing, inspection, and deletion for Lore.
3
+ *
4
+ * Provides a unified API for both the CLI (`lore data`) and the web UI
5
+ * (`/ui/`) to browse, search, and delete stored data across all tables.
6
+ *
7
+ * Cross-cutting concerns (e.g. `clearProject` touches knowledge, temporal,
8
+ * distillations, and session_state in one transaction) live here instead of
9
+ * being spread across ltm/temporal/distillation modules.
10
+ */
11
+
12
+ import { statSync, unlinkSync, existsSync } from "fs";
13
+ import {
14
+ db,
15
+ ensureProject,
16
+ projectId,
17
+ close,
18
+ dbPath,
19
+ mergeProjectInternal,
20
+ repoNameFromRemote,
21
+ } from "./db";
22
+ import { getGitRemote } from "./git";
23
+ import * as ltm from "./ltm";
24
+ import * as agentsFile from "./agents-file";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export type ProjectSummary = {
31
+ id: string;
32
+ path: string;
33
+ name: string | null;
34
+ git_remote: string | null;
35
+ created_at: number;
36
+ knowledge_count: number;
37
+ session_count: number;
38
+ message_count: number;
39
+ distillation_count: number;
40
+ };
41
+
42
+ export type SessionSummary = {
43
+ session_id: string;
44
+ message_count: number;
45
+ first_message_at: number;
46
+ last_message_at: number;
47
+ distilled_count: number;
48
+ undistilled_count: number;
49
+ distillation_count: number;
50
+ };
51
+
52
+ export type DistillationSummary = {
53
+ id: string;
54
+ session_id: string;
55
+ generation: number;
56
+ token_count: number;
57
+ r_compression: number | null;
58
+ c_norm: number | null;
59
+ archived: number;
60
+ created_at: number;
61
+ call_type: string | null;
62
+ };
63
+
64
+ export type DistillationDetail = DistillationSummary & {
65
+ project_id: string;
66
+ observations: string;
67
+ source_ids: string;
68
+ };
69
+
70
+ export type ClearResult = {
71
+ knowledge_deleted: number;
72
+ temporal_deleted: number;
73
+ distillations_deleted: number;
74
+ sessions_cleared: number;
75
+ };
76
+
77
+ export type GlobalStats = {
78
+ project_count: number;
79
+ knowledge_count: number;
80
+ session_count: number;
81
+ message_count: number;
82
+ distillation_count: number;
83
+ db_size_bytes: number;
84
+ };
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Listing functions
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /** List all projects with summary counts. */
91
+ export function listProjects(): ProjectSummary[] {
92
+ return db()
93
+ .query(
94
+ `SELECT p.id, p.path, p.name, p.git_remote, p.created_at,
95
+ (SELECT COUNT(*) FROM knowledge WHERE project_id = p.id AND confidence > 0.2) as knowledge_count,
96
+ (SELECT COUNT(DISTINCT session_id) FROM temporal_messages WHERE project_id = p.id) as session_count,
97
+ (SELECT COUNT(*) FROM temporal_messages WHERE project_id = p.id) as message_count,
98
+ (SELECT COUNT(*) FROM distillations WHERE project_id = p.id) as distillation_count
99
+ FROM projects p ORDER BY p.created_at DESC`,
100
+ )
101
+ .all() as ProjectSummary[];
102
+ }
103
+
104
+ /** List distinct sessions for a project, with message/distillation counts. */
105
+ export function listSessions(
106
+ projectPath: string,
107
+ limit = 50,
108
+ ): SessionSummary[] {
109
+ const pid = ensureProject(projectPath);
110
+ return db()
111
+ .query(
112
+ `SELECT
113
+ session_id,
114
+ COUNT(*) as message_count,
115
+ MIN(created_at) as first_message_at,
116
+ MAX(created_at) as last_message_at,
117
+ SUM(CASE WHEN distilled = 1 THEN 1 ELSE 0 END) as distilled_count,
118
+ SUM(CASE WHEN distilled = 0 THEN 1 ELSE 0 END) as undistilled_count,
119
+ (SELECT COUNT(*) FROM distillations d
120
+ WHERE d.project_id = temporal_messages.project_id
121
+ AND d.session_id = temporal_messages.session_id) as distillation_count
122
+ FROM temporal_messages
123
+ WHERE project_id = ?
124
+ GROUP BY session_id
125
+ ORDER BY MAX(created_at) DESC
126
+ LIMIT ?`,
127
+ )
128
+ .all(pid, limit) as SessionSummary[];
129
+ }
130
+
131
+ /** List distillations for a project (optionally filtered by session). */
132
+ export function listDistillations(
133
+ projectPath: string,
134
+ opts?: { sessionId?: string; limit?: number },
135
+ ): DistillationSummary[] {
136
+ const pid = ensureProject(projectPath);
137
+ const limit = opts?.limit ?? 50;
138
+
139
+ if (opts?.sessionId) {
140
+ return db()
141
+ .query(
142
+ `SELECT id, session_id, generation, token_count, r_compression, c_norm, archived, created_at, call_type
143
+ FROM distillations
144
+ WHERE project_id = ? AND session_id = ?
145
+ ORDER BY created_at DESC LIMIT ?`,
146
+ )
147
+ .all(pid, opts.sessionId, limit) as DistillationSummary[];
148
+ }
149
+
150
+ return db()
151
+ .query(
152
+ `SELECT id, session_id, generation, token_count, r_compression, c_norm, archived, created_at, call_type
153
+ FROM distillations
154
+ WHERE project_id = ?
155
+ ORDER BY created_at DESC LIMIT ?`,
156
+ )
157
+ .all(pid, limit) as DistillationSummary[];
158
+ }
159
+
160
+ /** Get a single distillation by ID (or resolved prefix). */
161
+ export function getDistillation(id: string): DistillationDetail | null {
162
+ return db()
163
+ .query(
164
+ `SELECT id, project_id, session_id, observations, source_ids, generation,
165
+ token_count, r_compression, c_norm, archived, created_at
166
+ FROM distillations WHERE id = ?`,
167
+ )
168
+ .get(id) as DistillationDetail | null;
169
+ }
170
+
171
+ /**
172
+ * Resolve a partial ID prefix to a full ID for a given table.
173
+ * Returns null if 0 or 2+ matches (ambiguous prefix).
174
+ */
175
+ export function resolveId(
176
+ table: "knowledge" | "distillations",
177
+ prefix: string,
178
+ ): string | null {
179
+ const results = db()
180
+ .query(`SELECT id FROM ${table} WHERE id LIKE ? LIMIT 2`)
181
+ .all(prefix + "%") as Array<{ id: string }>;
182
+ return results.length === 1 ? results[0].id : null;
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Stats
187
+ // ---------------------------------------------------------------------------
188
+
189
+ /** Global stats for the dashboard. */
190
+ export function globalStats(): GlobalStats {
191
+ const row = db()
192
+ .query(
193
+ `SELECT
194
+ (SELECT COUNT(*) FROM projects) as project_count,
195
+ (SELECT COUNT(*) FROM knowledge WHERE confidence > 0.2) as knowledge_count,
196
+ (SELECT COUNT(DISTINCT session_id) FROM temporal_messages) as session_count,
197
+ (SELECT COUNT(*) FROM temporal_messages) as message_count,
198
+ (SELECT COUNT(*) FROM distillations) as distillation_count`,
199
+ )
200
+ .get() as Omit<GlobalStats, "db_size_bytes">;
201
+
202
+ let db_size_bytes = 0;
203
+ try {
204
+ const p = dbPath();
205
+ db_size_bytes = statSync(p).size;
206
+ // Add WAL file size if present
207
+ const walPath = p + "-wal";
208
+ if (existsSync(walPath)) {
209
+ db_size_bytes += statSync(walPath).size;
210
+ }
211
+ } catch {
212
+ // File may not exist yet or stat fails
213
+ }
214
+
215
+ return { ...row, db_size_bytes };
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Deletion functions
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /**
223
+ * Count rows that will be affected, for confirmation prompts.
224
+ */
225
+ export function countForProject(projectPath: string): {
226
+ knowledge: number;
227
+ messages: number;
228
+ distillations: number;
229
+ sessions: number;
230
+ } {
231
+ const pid = projectId(projectPath);
232
+ if (!pid)
233
+ return { knowledge: 0, messages: 0, distillations: 0, sessions: 0 };
234
+
235
+ const row = db()
236
+ .query(
237
+ `SELECT
238
+ (SELECT COUNT(*) FROM knowledge WHERE project_id = ? AND confidence > 0.2) as knowledge,
239
+ (SELECT COUNT(*) FROM temporal_messages WHERE project_id = ?) as messages,
240
+ (SELECT COUNT(*) FROM distillations WHERE project_id = ?) as distillations,
241
+ (SELECT COUNT(DISTINCT session_id) FROM temporal_messages WHERE project_id = ?) as sessions`,
242
+ )
243
+ .get(pid, pid, pid, pid) as {
244
+ knowledge: number;
245
+ messages: number;
246
+ distillations: number;
247
+ sessions: number;
248
+ };
249
+
250
+ return row;
251
+ }
252
+
253
+ /**
254
+ * Clear all data for a project.
255
+ * Deletes: knowledge, temporal_messages, distillations, session_state.
256
+ * Does NOT delete the project row itself (preserves path->id mapping).
257
+ * Regenerates `.lore.md` if the project path exists on disk.
258
+ */
259
+ export function clearProject(projectPath: string): ClearResult {
260
+ const pid = ensureProject(projectPath);
261
+ const database = db();
262
+
263
+ // Count before deleting (result.changes is inflated by FTS triggers)
264
+ const counts = {
265
+ knowledge: (
266
+ database
267
+ .query(
268
+ "SELECT COUNT(*) as c FROM knowledge WHERE project_id = ?",
269
+ )
270
+ .get(pid) as { c: number }
271
+ ).c,
272
+ temporal: (
273
+ database
274
+ .query(
275
+ "SELECT COUNT(*) as c FROM temporal_messages WHERE project_id = ?",
276
+ )
277
+ .get(pid) as { c: number }
278
+ ).c,
279
+ distillations: (
280
+ database
281
+ .query(
282
+ "SELECT COUNT(*) as c FROM distillations WHERE project_id = ?",
283
+ )
284
+ .get(pid) as { c: number }
285
+ ).c,
286
+ sessions: (
287
+ database
288
+ .query(
289
+ "SELECT COUNT(DISTINCT session_id) as c FROM temporal_messages WHERE project_id = ?",
290
+ )
291
+ .get(pid) as { c: number }
292
+ ).c,
293
+ };
294
+
295
+ // Delete in dependency order
296
+ database.exec("BEGIN IMMEDIATE");
297
+ try {
298
+ // Delete session_state BEFORE temporal_messages (subquery needs the rows)
299
+ database
300
+ .query(
301
+ `DELETE FROM session_state WHERE session_id IN
302
+ (SELECT DISTINCT session_id FROM temporal_messages WHERE project_id = ?)`,
303
+ )
304
+ .run(pid);
305
+ database
306
+ .query("DELETE FROM knowledge WHERE project_id = ?")
307
+ .run(pid);
308
+ database
309
+ .query("DELETE FROM temporal_messages WHERE project_id = ?")
310
+ .run(pid);
311
+ database
312
+ .query("DELETE FROM distillations WHERE project_id = ?")
313
+ .run(pid);
314
+ database
315
+ .query("DELETE FROM lat_sections WHERE project_id = ?")
316
+ .run(pid);
317
+ database.exec("COMMIT");
318
+ } catch (e) {
319
+ database.exec("ROLLBACK");
320
+ throw e;
321
+ }
322
+
323
+ // Regenerate .lore.md (will be empty/minimal after clearing knowledge)
324
+ if (existsSync(projectPath)) {
325
+ try {
326
+ agentsFile.exportLoreFile(projectPath);
327
+ } catch {
328
+ // Non-fatal: project dir may not be writable
329
+ }
330
+ }
331
+
332
+ return {
333
+ knowledge_deleted: counts.knowledge,
334
+ temporal_deleted: counts.temporal,
335
+ distillations_deleted: counts.distillations,
336
+ sessions_cleared: counts.sessions,
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Fully delete a project: all associated data AND the project row itself.
342
+ * Also removes path aliases pointing to this project.
343
+ *
344
+ * Unlike clearProject(), this does NOT call ensureProject() (avoids
345
+ * re-creating the project) and does NOT regenerate .lore.md.
346
+ *
347
+ * Returns deletion counts, or null if the project ID doesn't exist.
348
+ */
349
+ export function deleteProject(projectId: string): ClearResult | null {
350
+ const database = db();
351
+
352
+ // Verify the project exists and collect all paths BEFORE deleting.
353
+ // We need these to invalidate the .lore.md file cache (kv_meta) after
354
+ // deletion — otherwise shouldImportLoreFile() sees the stale cache,
355
+ // skips re-import, and the curator overwrites .lore.md with junk.
356
+ const project = database
357
+ .query("SELECT id, path FROM projects WHERE id = ?")
358
+ .get(projectId) as { id: string; path: string } | null;
359
+ if (!project) return null;
360
+
361
+ const aliasPaths = database
362
+ .query("SELECT path FROM project_path_aliases WHERE project_id = ?")
363
+ .all(projectId) as { path: string }[];
364
+ const allPaths = [project.path, ...aliasPaths.map((r) => r.path)];
365
+
366
+ // Count before deleting
367
+ const counts = {
368
+ knowledge: (
369
+ database
370
+ .query(
371
+ "SELECT COUNT(*) as c FROM knowledge WHERE project_id = ?",
372
+ )
373
+ .get(projectId) as { c: number }
374
+ ).c,
375
+ temporal: (
376
+ database
377
+ .query(
378
+ "SELECT COUNT(*) as c FROM temporal_messages WHERE project_id = ?",
379
+ )
380
+ .get(projectId) as { c: number }
381
+ ).c,
382
+ distillations: (
383
+ database
384
+ .query(
385
+ "SELECT COUNT(*) as c FROM distillations WHERE project_id = ?",
386
+ )
387
+ .get(projectId) as { c: number }
388
+ ).c,
389
+ sessions: (
390
+ database
391
+ .query(
392
+ "SELECT COUNT(DISTINCT session_id) as c FROM temporal_messages WHERE project_id = ?",
393
+ )
394
+ .get(projectId) as { c: number }
395
+ ).c,
396
+ };
397
+
398
+ database.exec("BEGIN IMMEDIATE");
399
+ try {
400
+ // Delete session_state BEFORE temporal_messages (subquery needs the rows)
401
+ database
402
+ .query(
403
+ `DELETE FROM session_state WHERE session_id IN
404
+ (SELECT DISTINCT session_id FROM temporal_messages WHERE project_id = ?)`,
405
+ )
406
+ .run(projectId);
407
+ database
408
+ .query("DELETE FROM knowledge WHERE project_id = ?")
409
+ .run(projectId);
410
+ database
411
+ .query("DELETE FROM temporal_messages WHERE project_id = ?")
412
+ .run(projectId);
413
+ database
414
+ .query("DELETE FROM distillations WHERE project_id = ?")
415
+ .run(projectId);
416
+ database
417
+ .query("DELETE FROM lat_sections WHERE project_id = ?")
418
+ .run(projectId);
419
+ // Explicit delete for safety (FK CASCADE depends on PRAGMA foreign_keys)
420
+ database
421
+ .query("DELETE FROM project_path_aliases WHERE project_id = ?")
422
+ .run(projectId);
423
+ database
424
+ .query("DELETE FROM warmup_histograms WHERE project_id = ?")
425
+ .run(projectId);
426
+ // Finally, delete the project row itself
427
+ database
428
+ .query("DELETE FROM projects WHERE id = ?")
429
+ .run(projectId);
430
+ database.exec("COMMIT");
431
+ } catch (e) {
432
+ database.exec("ROLLBACK");
433
+ throw e;
434
+ }
435
+
436
+ // Invalidate the .lore.md file cache for all known paths so that
437
+ // shouldImportLoreFile() re-checks the file if this project path
438
+ // is reused. Without this, the stale cache causes the import to be
439
+ // skipped, the curator creates junk entries, and exportLoreFile()
440
+ // overwrites the good .lore.md with garbage.
441
+ for (const p of allPaths) {
442
+ agentsFile.clearLoreFileCache(p);
443
+ }
444
+
445
+ return {
446
+ knowledge_deleted: counts.knowledge,
447
+ temporal_deleted: counts.temporal,
448
+ distillations_deleted: counts.distillations,
449
+ sessions_cleared: counts.sessions,
450
+ };
451
+ }
452
+
453
+ /** Rename a project. Returns true if the project exists and was renamed. */
454
+ export function renameProject(projectId: string, newName: string): boolean {
455
+ const result = db()
456
+ .query("UPDATE projects SET name = ? WHERE id = ?")
457
+ .run(newName.trim(), projectId);
458
+ return result.changes > 0;
459
+ }
460
+
461
+ /** Clear only knowledge entries for a project. Regenerates .lore.md. */
462
+ export function clearKnowledge(projectPath: string): number {
463
+ const pid = ensureProject(projectPath);
464
+ const count = (
465
+ db()
466
+ .query(
467
+ "SELECT COUNT(*) as c FROM knowledge WHERE project_id = ?",
468
+ )
469
+ .get(pid) as { c: number }
470
+ ).c;
471
+
472
+ db().query("DELETE FROM knowledge WHERE project_id = ?").run(pid);
473
+
474
+ // Regenerate .lore.md
475
+ if (existsSync(projectPath)) {
476
+ try {
477
+ agentsFile.exportLoreFile(projectPath);
478
+ } catch {
479
+ // Non-fatal
480
+ }
481
+ }
482
+
483
+ return count;
484
+ }
485
+
486
+ /** Clear only temporal messages for a project. */
487
+ export function clearTemporal(projectPath: string): number {
488
+ const pid = ensureProject(projectPath);
489
+ const count = (
490
+ db()
491
+ .query(
492
+ "SELECT COUNT(*) as c FROM temporal_messages WHERE project_id = ?",
493
+ )
494
+ .get(pid) as { c: number }
495
+ ).c;
496
+
497
+ db()
498
+ .query("DELETE FROM temporal_messages WHERE project_id = ?")
499
+ .run(pid);
500
+
501
+ return count;
502
+ }
503
+
504
+ /** Clear only distillations for a project. */
505
+ export function clearDistillations(projectPath: string): number {
506
+ const pid = ensureProject(projectPath);
507
+ const count = (
508
+ db()
509
+ .query(
510
+ "SELECT COUNT(*) as c FROM distillations WHERE project_id = ?",
511
+ )
512
+ .get(pid) as { c: number }
513
+ ).c;
514
+
515
+ db()
516
+ .query("DELETE FROM distillations WHERE project_id = ?")
517
+ .run(pid);
518
+
519
+ return count;
520
+ }
521
+
522
+ /** Delete a single knowledge entry. Returns true if found and deleted. */
523
+ export function deleteKnowledge(id: string): boolean {
524
+ const entry = ltm.get(id);
525
+ if (!entry) return false;
526
+ ltm.remove(id);
527
+ return true;
528
+ }
529
+
530
+ /** Delete a single distillation. Returns true if found and deleted. */
531
+ export function deleteDistillation(id: string): boolean {
532
+ const existing = getDistillation(id);
533
+ if (!existing) return false;
534
+ db().query("DELETE FROM distillations WHERE id = ?").run(id);
535
+ return true;
536
+ }
537
+
538
+ /**
539
+ * Delete all data for a specific session (messages + distillations + session_state).
540
+ */
541
+ export function deleteSession(
542
+ projectPath: string,
543
+ sessionId: string,
544
+ ): { messages_deleted: number; distillations_deleted: number } {
545
+ const pid = ensureProject(projectPath);
546
+ const database = db();
547
+
548
+ const msgCount = (
549
+ database
550
+ .query(
551
+ "SELECT COUNT(*) as c FROM temporal_messages WHERE project_id = ? AND session_id = ?",
552
+ )
553
+ .get(pid, sessionId) as { c: number }
554
+ ).c;
555
+
556
+ const distCount = (
557
+ database
558
+ .query(
559
+ "SELECT COUNT(*) as c FROM distillations WHERE project_id = ? AND session_id = ?",
560
+ )
561
+ .get(pid, sessionId) as { c: number }
562
+ ).c;
563
+
564
+ database
565
+ .query(
566
+ "DELETE FROM temporal_messages WHERE project_id = ? AND session_id = ?",
567
+ )
568
+ .run(pid, sessionId);
569
+ database
570
+ .query(
571
+ "DELETE FROM distillations WHERE project_id = ? AND session_id = ?",
572
+ )
573
+ .run(pid, sessionId);
574
+ database
575
+ .query("DELETE FROM session_state WHERE session_id = ?")
576
+ .run(sessionId);
577
+
578
+ return { messages_deleted: msgCount, distillations_deleted: distCount };
579
+ }
580
+
581
+ /**
582
+ * Nuclear option: close the DB, delete the file, re-initialize.
583
+ * Returns the path of the deleted DB file.
584
+ */
585
+ export function wipeDatabase(): string {
586
+ const p = dbPath();
587
+ close();
588
+
589
+ // Delete DB and associated WAL/SHM files
590
+ for (const suffix of ["", "-wal", "-shm"]) {
591
+ const fp = p + suffix;
592
+ if (existsSync(fp)) {
593
+ try {
594
+ unlinkSync(fp);
595
+ } catch {
596
+ // Best-effort
597
+ }
598
+ }
599
+ }
600
+
601
+ // Re-initialize with fresh schema
602
+ db();
603
+ return p;
604
+ }
605
+
606
+ // ---------------------------------------------------------------------------
607
+ // Project merging & git remote backfill
608
+ // ---------------------------------------------------------------------------
609
+
610
+ export type MergeResult = {
611
+ knowledge_moved: number;
612
+ messages_moved: number;
613
+ distillations_moved: number;
614
+ };
615
+
616
+ /**
617
+ * Merge a source project into a target project.
618
+ *
619
+ * Moves all data (knowledge, messages, distillations, LAT sections, path
620
+ * aliases) from source to target, then deletes the source project row.
621
+ * The source project's path is registered as an alias of the target.
622
+ *
623
+ * Returns counts of moved rows for reporting.
624
+ */
625
+ export function mergeProjects(sourceId: string, targetId: string): MergeResult {
626
+ const database = db();
627
+
628
+ // Count before merging (result.changes is inflated by FTS triggers)
629
+ const counts = {
630
+ knowledge: (
631
+ database
632
+ .query(
633
+ "SELECT COUNT(*) as c FROM knowledge WHERE project_id = ?",
634
+ )
635
+ .get(sourceId) as { c: number }
636
+ ).c,
637
+ messages: (
638
+ database
639
+ .query(
640
+ "SELECT COUNT(*) as c FROM temporal_messages WHERE project_id = ?",
641
+ )
642
+ .get(sourceId) as { c: number }
643
+ ).c,
644
+ distillations: (
645
+ database
646
+ .query(
647
+ "SELECT COUNT(*) as c FROM distillations WHERE project_id = ?",
648
+ )
649
+ .get(sourceId) as { c: number }
650
+ ).c,
651
+ };
652
+
653
+ mergeProjectInternal(sourceId, targetId);
654
+
655
+ return {
656
+ knowledge_moved: counts.knowledge,
657
+ messages_moved: counts.messages,
658
+ distillations_moved: counts.distillations,
659
+ };
660
+ }
661
+
662
+ /**
663
+ * Backfill git_remote for existing projects, merge duplicates, and
664
+ * update project names from git remote repo names where still using
665
+ * the directory-basename default.
666
+ *
667
+ * Iterates all projects that lack a git_remote value, runs `git remote -v`
668
+ * on their stored path, and:
669
+ * - If no other project shares that remote: sets git_remote on the row.
670
+ * - If another project already has that remote: merges this project into
671
+ * the existing one (consolidating fragmented data).
672
+ *
673
+ * Also backfills project names: if a project's name matches the directory
674
+ * basename (the old default) or is null, and a git remote is available,
675
+ * the name is updated to the repo name from the remote URL.
676
+ *
677
+ * Skips projects whose path no longer exists on disk or is not a git repo.
678
+ *
679
+ * Returns counts for reporting.
680
+ */
681
+ export function backfillGitRemotes(): {
682
+ updated: number;
683
+ merged: number;
684
+ namesBackfilled: number;
685
+ mergeDetails: Array<{
686
+ sourcePath: string;
687
+ targetPath: string;
688
+ gitRemote: string;
689
+ result: MergeResult;
690
+ }>;
691
+ } {
692
+ const projects = db()
693
+ .query(
694
+ "SELECT id, path, name, git_remote FROM projects ORDER BY created_at ASC",
695
+ )
696
+ .all() as Array<{
697
+ id: string;
698
+ path: string;
699
+ name: string | null;
700
+ git_remote: string | null;
701
+ }>;
702
+
703
+ let updated = 0;
704
+ let merged = 0;
705
+ let namesBackfilled = 0;
706
+ const mergeDetails: Array<{
707
+ sourcePath: string;
708
+ targetPath: string;
709
+ gitRemote: string;
710
+ result: MergeResult;
711
+ }> = [];
712
+
713
+ for (const project of projects) {
714
+ let gitRemote = project.git_remote;
715
+
716
+ if (!gitRemote) {
717
+ // Skip if path doesn't exist
718
+ if (!existsSync(project.path)) continue;
719
+
720
+ // Try to get git remote
721
+ gitRemote = getGitRemote(project.path);
722
+ if (!gitRemote) continue;
723
+
724
+ // Check if another project already has this git_remote
725
+ const existing = db()
726
+ .query(
727
+ "SELECT id, path FROM projects WHERE git_remote = ? AND id != ? LIMIT 1",
728
+ )
729
+ .get(gitRemote, project.id) as {
730
+ id: string;
731
+ path: string;
732
+ } | null;
733
+
734
+ if (existing) {
735
+ // Merge this project into the existing one
736
+ const result = mergeProjects(project.id, existing.id);
737
+ mergeDetails.push({
738
+ sourcePath: project.path,
739
+ targetPath: existing.path,
740
+ gitRemote,
741
+ result,
742
+ });
743
+ merged++;
744
+ continue; // project was merged away, skip name backfill
745
+ }
746
+
747
+ // Set the git_remote
748
+ db()
749
+ .query("UPDATE projects SET git_remote = ? WHERE id = ?")
750
+ .run(gitRemote, project.id);
751
+ updated++;
752
+ }
753
+
754
+ // Backfill name from git remote if still using directory basename default
755
+ const dirBasename = project.path.split("/").pop();
756
+ if (project.name === dirBasename || !project.name) {
757
+ const repoName = repoNameFromRemote(gitRemote);
758
+ if (repoName && repoName !== project.name) {
759
+ db()
760
+ .query("UPDATE projects SET name = ? WHERE id = ?")
761
+ .run(repoName, project.id);
762
+ namesBackfilled++;
763
+ }
764
+ }
765
+ }
766
+
767
+ return { updated, merged, namesBackfilled, mergeDetails };
768
+ }