@loreai/core 0.0.1 → 0.10.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.
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/search.ts ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Centralized FTS5 search utilities for Lore.
3
+ *
4
+ * Provides query building, stopword filtering, and (Phase 2+) score fusion.
5
+ * All FTS5 search callers (ltm, temporal, reflect) import from here.
6
+ */
7
+
8
+ /**
9
+ * Curated stopword set for FTS5 queries. These are common English words that
10
+ * match broadly and dilute search precision when used with OR semantics.
11
+ *
12
+ * CRITICAL: OR without stopword filtering is catastrophic — "the OR for OR and"
13
+ * matches every document in the corpus. Stopwords MUST be filtered before
14
+ * building OR queries.
15
+ *
16
+ * This list is intentionally conservative: only includes words that are
17
+ * genuinely content-free. Domain terms like "handle", "state", "type" are
18
+ * NOT stopwords — they carry meaning in code/technical contexts.
19
+ */
20
+ export const STOPWORDS: ReadonlySet<string> = new Set([
21
+ // Articles & determiners
22
+ "an",
23
+ "the",
24
+ "this",
25
+ "that",
26
+ "these",
27
+ "those",
28
+ "some",
29
+ "each",
30
+ "every",
31
+ // Pronouns
32
+ "he",
33
+ "it",
34
+ "me",
35
+ "my",
36
+ "we",
37
+ "us",
38
+ "or",
39
+ "am",
40
+ "they",
41
+ "them",
42
+ "their",
43
+ "there",
44
+ "here",
45
+ "what",
46
+ "which",
47
+ "where",
48
+ "when",
49
+ "whom",
50
+ // Common verbs (content-free)
51
+ "is",
52
+ "be",
53
+ "do",
54
+ "no",
55
+ "so",
56
+ "if",
57
+ "as",
58
+ "at",
59
+ "by",
60
+ "in",
61
+ "of",
62
+ "on",
63
+ "to",
64
+ "up",
65
+ "are",
66
+ "was",
67
+ "has",
68
+ "had",
69
+ "not",
70
+ "but",
71
+ "can",
72
+ "did",
73
+ "for",
74
+ "got",
75
+ "let",
76
+ "may",
77
+ "our",
78
+ "its",
79
+ "nor",
80
+ "yet",
81
+ "how",
82
+ "all",
83
+ "any",
84
+ "too",
85
+ "own",
86
+ "out",
87
+ "why",
88
+ "who",
89
+ "few",
90
+ "have",
91
+ "been",
92
+ "were",
93
+ "will",
94
+ "would",
95
+ "could",
96
+ "should",
97
+ "does",
98
+ "being",
99
+ "also",
100
+ // Prepositions & conjunctions
101
+ "with",
102
+ "from",
103
+ "into",
104
+ "about",
105
+ "than",
106
+ "over",
107
+ "such",
108
+ "after",
109
+ "before",
110
+ "between",
111
+ // Adverbs (content-free)
112
+ "just",
113
+ "only",
114
+ "very",
115
+ "more",
116
+ "most",
117
+ "really",
118
+ "already",
119
+ ]);
120
+
121
+ /**
122
+ * The sentinel value returned when a query contains no meaningful terms after
123
+ * filtering. Callers should check for this and return a "query too vague"
124
+ * message instead of executing an FTS5 MATCH against it.
125
+ */
126
+ export const EMPTY_QUERY = '""';
127
+
128
+ /**
129
+ * Filter raw query text into meaningful FTS5 tokens.
130
+ *
131
+ * Filtering (in order):
132
+ * 1. Strip non-word chars (punctuation, operators — prevents FTS5 injection)
133
+ * 2. Remove single-character tokens (contraction artifacts like "s", "t")
134
+ * 3. Remove stopwords
135
+ *
136
+ * If ALL words are filtered, returns an empty array. The caller decides
137
+ * what to do (typically returns a "query too vague" message).
138
+ *
139
+ * No general length filter — short but meaningful tokens like "DB", "CI",
140
+ * "IO", "PR" are preserved. Only single chars are dropped.
141
+ */
142
+ export function filterTerms(raw: string): string[] {
143
+ const words = raw
144
+ .replace(/[^\w\s]/g, " ")
145
+ .split(/\s+/)
146
+ .filter(Boolean);
147
+
148
+ return words.filter(
149
+ (w) => w.length > 1 && !STOPWORDS.has(w.toLowerCase()),
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Build an FTS5 MATCH expression using AND semantics (implicit AND via space).
155
+ *
156
+ * Returns `""` (match-nothing sentinel) when no meaningful terms remain after
157
+ * filtering. Callers should check `q === EMPTY_QUERY` and handle accordingly.
158
+ */
159
+ export function ftsQuery(raw: string): string {
160
+ const terms = filterTerms(raw);
161
+ if (!terms.length) return EMPTY_QUERY;
162
+ return terms.map((w) => `${w}*`).join(" ");
163
+ }
164
+
165
+ /**
166
+ * Build an FTS5 MATCH expression using OR semantics.
167
+ * Same filtering as ftsQuery(), but joins terms with OR.
168
+ * Used as fallback when AND returns zero results.
169
+ */
170
+ export function ftsQueryOr(raw: string): string {
171
+ const terms = filterTerms(raw);
172
+ if (!terms.length) return EMPTY_QUERY;
173
+ return terms.map((w) => `${w}*`).join(" OR ");
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Term extraction (Phase 3)
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Extract the top meaningful terms from text, sorted by frequency.
182
+ *
183
+ * Same filtering as ftsQuery: drops single chars + stopwords.
184
+ * No general length threshold — preserves short meaningful tokens like "DB", "CI".
185
+ *
186
+ * Used by forSession() to build session context queries for FTS5 scoring.
187
+ *
188
+ * @param text Raw text to extract terms from
189
+ * @param limit Max number of terms to return (default 40)
190
+ */
191
+ export function extractTopTerms(text: string, limit = 40): string[] {
192
+ const freq = text
193
+ .replace(/[^\w\s]/g, " ")
194
+ .toLowerCase()
195
+ .split(/\s+/)
196
+ .filter((w) => w.length > 1 && !STOPWORDS.has(w))
197
+ .reduce<Map<string, number>>((acc, w) => {
198
+ acc.set(w, (acc.get(w) ?? 0) + 1);
199
+ return acc;
200
+ }, new Map());
201
+
202
+ return [...freq.entries()]
203
+ .sort((a, b) => b[1] - a[1])
204
+ .slice(0, limit)
205
+ .map(([w]) => w);
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Score normalization & fusion (Phase 2)
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /**
213
+ * Normalize a raw FTS5 BM25 rank to a 0–1 range using min-max normalization.
214
+ *
215
+ * FTS5 rank/bm25() values are negative (more negative = better match).
216
+ * This converts them to 0–1 where 1 = best match in the result set.
217
+ *
218
+ * Used for display scores only — RRF fusion uses rank positions, not scores.
219
+ */
220
+ export function normalizeRank(
221
+ rank: number,
222
+ minRank: number,
223
+ maxRank: number,
224
+ ): number {
225
+ // All same rank → everything is equally relevant
226
+ if (minRank === maxRank) return 1;
227
+ // minRank is most negative (best), maxRank is least negative (worst)
228
+ // Invert: best match → 1.0, worst → 0.0
229
+ return (maxRank - rank) / (maxRank - minRank);
230
+ }
231
+
232
+ /**
233
+ * Reciprocal Rank Fusion: merge multiple ranked lists into a single ranked list.
234
+ *
235
+ * RRF score = Σ(1 / (k + rank_i)) for each list where the item appears.
236
+ * k = 60 is standard (from Cormack et al., 2009; also used by QMD).
237
+ *
238
+ * RRF is rank-based, not score-based — raw score magnitude differences across
239
+ * different FTS5 tables don't matter. Only relative ordering within each list.
240
+ *
241
+ * @param lists Each list provides items (in ranked order) and a key function
242
+ * for deduplication. Items at the front of the array are rank 0.
243
+ * @param k Smoothing constant. Default 60.
244
+ * @returns Fused list sorted by RRF score descending. When items appear
245
+ * in multiple lists, the first occurrence's item is kept.
246
+ */
247
+ export function reciprocalRankFusion<T>(
248
+ lists: Array<{ items: T[]; key: (item: T) => string }>,
249
+ k = 60,
250
+ ): Array<{ item: T; score: number }> {
251
+ const scores = new Map<string, { item: T; score: number }>();
252
+
253
+ for (const list of lists) {
254
+ for (let rank = 0; rank < list.items.length; rank++) {
255
+ const item = list.items[rank];
256
+ const id = list.key(item);
257
+ const rrfScore = 1 / (k + rank);
258
+ const existing = scores.get(id);
259
+ if (existing) {
260
+ existing.score += rrfScore;
261
+ } else {
262
+ scores.set(id, { item, score: rrfScore });
263
+ }
264
+ }
265
+ }
266
+
267
+ return [...scores.values()].sort((a, b) => b.score - a.score);
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // LLM query expansion (Phase 4)
272
+ // ---------------------------------------------------------------------------
273
+
274
+ import { QUERY_EXPANSION_SYSTEM } from "./prompt";
275
+ import * as log from "./log";
276
+ import type { LLMClient } from "./types";
277
+
278
+ /**
279
+ * Expand a user query into multiple search variants using the configured LLM.
280
+ * Returns `[original, ...expanded]`. The original is always first.
281
+ *
282
+ * Uses a 3-second timeout — if the LLM is slow, returns only the original query.
283
+ * Errors are caught silently (logged) and the original query is returned.
284
+ *
285
+ * @param llm LLM client for prompt calls
286
+ * @param query The original user query
287
+ * @param model Optional model override
288
+ */
289
+ export async function expandQuery(
290
+ llm: LLMClient,
291
+ query: string,
292
+ model?: { providerID: string; modelID: string },
293
+ ): Promise<string[]> {
294
+ const TIMEOUT_MS = 3000;
295
+
296
+ try {
297
+ // Race the LLM call against a timeout
298
+ const responseText = await Promise.race([
299
+ llm.prompt(
300
+ QUERY_EXPANSION_SYSTEM,
301
+ `Input: "${query}"`,
302
+ { model, workerID: "lore-query-expand" },
303
+ ),
304
+ new Promise<null>((resolve) => setTimeout(() => resolve(null), TIMEOUT_MS)),
305
+ ]);
306
+
307
+ if (!responseText) {
308
+ log.info("query expansion timed out or failed, using original query");
309
+ return [query];
310
+ }
311
+
312
+ // Parse JSON array from response
313
+ const cleaned = responseText
314
+ .trim()
315
+ .replace(/^```json?\s*/i, "")
316
+ .replace(/\s*```$/i, "");
317
+ const parsed = JSON.parse(cleaned);
318
+ if (!Array.isArray(parsed)) return [query];
319
+
320
+ const expanded = parsed.filter(
321
+ (q): q is string => typeof q === "string" && q.trim().length > 0,
322
+ );
323
+ if (!expanded.length) return [query];
324
+
325
+ return [query, ...expanded.slice(0, 3)]; // cap at 3 expansions
326
+ } catch (err) {
327
+ log.info("query expansion failed, using original query:", err);
328
+ return [query];
329
+ }
330
+ }
@@ -0,0 +1,379 @@
1
+ import { db, ensureProject } from "./db";
2
+ import { ftsQuery, ftsQueryOr, EMPTY_QUERY } from "./search";
3
+ import { sanitizeSurrogates } from "./markdown";
4
+ import type { LoreMessage, LorePart } from "./types";
5
+ import { isTextPart, isReasoningPart, isToolPart } from "./types";
6
+
7
+ // ~3 chars per token — validated as best heuristic against real API data.
8
+ function estimate(text: string): number {
9
+ return Math.ceil(text.length / 3);
10
+ }
11
+
12
+ function partsToText(parts: LorePart[]): string {
13
+ const chunks: string[] = [];
14
+ for (const part of parts) {
15
+ if (isTextPart(part)) chunks.push(part.text);
16
+ else if (isReasoningPart(part) && part.text)
17
+ chunks.push(`[reasoning] ${part.text}`);
18
+ else if (isToolPart(part) && part.state.status === "completed")
19
+ chunks.push(`[tool:${part.tool}] ${part.state.output}`);
20
+ }
21
+ // Sanitize unpaired surrogates from tool outputs and other raw text.
22
+ // Without this, surrogates survive into the DB and later break JSON
23
+ // serialization when included in recall tool responses.
24
+ return sanitizeSurrogates(chunks.join("\n"));
25
+ }
26
+
27
+ function messageMetadata(info: LoreMessage, parts: LorePart[]): string {
28
+ const meta: Record<string, unknown> = {};
29
+ if (info.role === "user") {
30
+ meta.agent = info.agent;
31
+ meta.model = info.model;
32
+ } else {
33
+ meta.modelID = info.modelID;
34
+ meta.providerID = info.providerID;
35
+ meta.mode = info.mode;
36
+ }
37
+ const tools = parts.filter(isToolPart).map((p) => p.tool);
38
+ if (tools.length) meta.tools = tools;
39
+ return JSON.stringify(meta);
40
+ }
41
+
42
+ export function store(input: {
43
+ projectPath: string;
44
+ info: LoreMessage;
45
+ parts: LorePart[];
46
+ }) {
47
+ const pid = ensureProject(input.projectPath);
48
+ const content = partsToText(input.parts);
49
+ if (!content.trim()) return;
50
+
51
+ const existing = db()
52
+ .query("SELECT id FROM temporal_messages WHERE id = ?")
53
+ .get(input.info.id);
54
+ if (existing) {
55
+ db()
56
+ .query(
57
+ "UPDATE temporal_messages SET content = ?, tokens = ?, metadata = ? WHERE id = ?",
58
+ )
59
+ .run(
60
+ content,
61
+ estimate(content),
62
+ messageMetadata(input.info, input.parts),
63
+ input.info.id,
64
+ );
65
+ return;
66
+ }
67
+
68
+ db()
69
+ .query(
70
+ `INSERT INTO temporal_messages (id, project_id, session_id, role, content, tokens, distilled, created_at, metadata)
71
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)`,
72
+ )
73
+ .run(
74
+ input.info.id,
75
+ pid,
76
+ input.info.sessionID,
77
+ input.info.role,
78
+ content,
79
+ estimate(content),
80
+ input.info.time.created,
81
+ messageMetadata(input.info, input.parts),
82
+ );
83
+ }
84
+
85
+ export type TemporalMessage = {
86
+ id: string;
87
+ project_id: string;
88
+ session_id: string;
89
+ role: string;
90
+ content: string;
91
+ tokens: number;
92
+ distilled: number;
93
+ created_at: number;
94
+ metadata: string;
95
+ };
96
+
97
+ export function undistilled(
98
+ projectPath: string,
99
+ sessionID?: string,
100
+ ): TemporalMessage[] {
101
+ const pid = ensureProject(projectPath);
102
+ const query = sessionID
103
+ ? "SELECT * FROM temporal_messages WHERE project_id = ? AND session_id = ? AND distilled = 0 ORDER BY created_at ASC"
104
+ : "SELECT * FROM temporal_messages WHERE project_id = ? AND distilled = 0 ORDER BY created_at ASC";
105
+ const params = sessionID ? [pid, sessionID] : [pid];
106
+ return db()
107
+ .query(query)
108
+ .all(...params) as TemporalMessage[];
109
+ }
110
+
111
+ export function bySession(
112
+ projectPath: string,
113
+ sessionID: string,
114
+ ): TemporalMessage[] {
115
+ const pid = ensureProject(projectPath);
116
+ return db()
117
+ .query(
118
+ "SELECT * FROM temporal_messages WHERE project_id = ? AND session_id = ? ORDER BY created_at ASC",
119
+ )
120
+ .all(pid, sessionID) as TemporalMessage[];
121
+ }
122
+
123
+ export function markDistilled(ids: string[]) {
124
+ if (!ids.length) return;
125
+ const placeholders = ids.map(() => "?").join(",");
126
+ db()
127
+ .query(
128
+ `UPDATE temporal_messages SET distilled = 1 WHERE id IN (${placeholders})`,
129
+ )
130
+ .run(...ids);
131
+ }
132
+
133
+ // LIKE-based fallback for when FTS5 fails unexpectedly.
134
+ function searchLike(input: {
135
+ pid: string;
136
+ query: string;
137
+ sessionID?: string;
138
+ limit: number;
139
+ }): TemporalMessage[] {
140
+ const terms = input.query
141
+ .toLowerCase()
142
+ .split(/\s+/)
143
+ .filter((t) => t.length > 2);
144
+ if (!terms.length) return [];
145
+ const conditions = terms.map(() => "LOWER(content) LIKE ?").join(" AND ");
146
+ const likeParams = terms.map((t) => `%${t}%`);
147
+ const query = input.sessionID
148
+ ? `SELECT * FROM temporal_messages WHERE project_id = ? AND session_id = ? AND ${conditions} ORDER BY created_at DESC LIMIT ?`
149
+ : `SELECT * FROM temporal_messages WHERE project_id = ? AND ${conditions} ORDER BY created_at DESC LIMIT ?`;
150
+ const params = input.sessionID
151
+ ? [input.pid, input.sessionID, ...likeParams, input.limit]
152
+ : [input.pid, ...likeParams, input.limit];
153
+ return db()
154
+ .query(query)
155
+ .all(...params) as TemporalMessage[];
156
+ }
157
+
158
+ export function search(input: {
159
+ projectPath: string;
160
+ query: string;
161
+ sessionID?: string;
162
+ limit?: number;
163
+ }): TemporalMessage[] {
164
+ const pid = ensureProject(input.projectPath);
165
+ const limit = input.limit ?? 20;
166
+ const q = ftsQuery(input.query);
167
+ if (q === EMPTY_QUERY) return [];
168
+
169
+ const ftsSQL = input.sessionID
170
+ ? `SELECT m.* FROM temporal_messages m
171
+ JOIN temporal_fts f ON m.rowid = f.rowid
172
+ WHERE f.content MATCH ? AND m.project_id = ? AND m.session_id = ?
173
+ ORDER BY rank LIMIT ?`
174
+ : `SELECT m.* FROM temporal_messages m
175
+ JOIN temporal_fts f ON m.rowid = f.rowid
176
+ WHERE f.content MATCH ? AND m.project_id = ?
177
+ ORDER BY rank LIMIT ?`;
178
+ const params = input.sessionID
179
+ ? [q, pid, input.sessionID, limit]
180
+ : [q, pid, limit];
181
+ try {
182
+ const results = db()
183
+ .query(ftsSQL)
184
+ .all(...params) as TemporalMessage[];
185
+ if (results.length) return results;
186
+
187
+ // AND returned nothing — try OR fallback for broader recall
188
+ const qOr = ftsQueryOr(input.query);
189
+ if (qOr === EMPTY_QUERY) return [];
190
+ const paramsOr = input.sessionID
191
+ ? [qOr, pid, input.sessionID, limit]
192
+ : [qOr, pid, limit];
193
+ return db()
194
+ .query(ftsSQL)
195
+ .all(...paramsOr) as TemporalMessage[];
196
+ } catch {
197
+ // FTS5 still choked (edge case) — fall back to LIKE search
198
+ return searchLike({
199
+ pid,
200
+ query: input.query,
201
+ sessionID: input.sessionID,
202
+ limit,
203
+ });
204
+ }
205
+ }
206
+
207
+ export type ScoredTemporalMessage = TemporalMessage & { rank: number };
208
+
209
+ /**
210
+ * Search with BM25 scores included. Returns results with raw FTS5 rank values
211
+ * for use in cross-source score fusion (RRF).
212
+ */
213
+ export function searchScored(input: {
214
+ projectPath: string;
215
+ query: string;
216
+ sessionID?: string;
217
+ limit?: number;
218
+ }): ScoredTemporalMessage[] {
219
+ const pid = ensureProject(input.projectPath);
220
+ const limit = input.limit ?? 20;
221
+ const q = ftsQuery(input.query);
222
+ if (q === EMPTY_QUERY) return [];
223
+
224
+ const ftsSQL = input.sessionID
225
+ ? `SELECT m.*, rank FROM temporal_messages m
226
+ JOIN temporal_fts f ON m.rowid = f.rowid
227
+ WHERE f.content MATCH ? AND m.project_id = ? AND m.session_id = ?
228
+ ORDER BY rank LIMIT ?`
229
+ : `SELECT m.*, rank FROM temporal_messages m
230
+ JOIN temporal_fts f ON m.rowid = f.rowid
231
+ WHERE f.content MATCH ? AND m.project_id = ?
232
+ ORDER BY rank LIMIT ?`;
233
+ const params = input.sessionID
234
+ ? [q, pid, input.sessionID, limit]
235
+ : [q, pid, limit];
236
+
237
+ try {
238
+ const results = db().query(ftsSQL).all(...params) as ScoredTemporalMessage[];
239
+ if (results.length) return results;
240
+
241
+ const qOr = ftsQueryOr(input.query);
242
+ if (qOr === EMPTY_QUERY) return [];
243
+ const paramsOr = input.sessionID
244
+ ? [qOr, pid, input.sessionID, limit]
245
+ : [qOr, pid, limit];
246
+ return db().query(ftsSQL).all(...paramsOr) as ScoredTemporalMessage[];
247
+ } catch {
248
+ return [];
249
+ }
250
+ }
251
+
252
+ export function count(projectPath: string, sessionID?: string): number {
253
+ const pid = ensureProject(projectPath);
254
+ const query = sessionID
255
+ ? "SELECT COUNT(*) as count FROM temporal_messages WHERE project_id = ? AND session_id = ?"
256
+ : "SELECT COUNT(*) as count FROM temporal_messages WHERE project_id = ?";
257
+ const params = sessionID ? [pid, sessionID] : [pid];
258
+ return (
259
+ db()
260
+ .query(query)
261
+ .get(...params) as { count: number }
262
+ ).count;
263
+ }
264
+
265
+ export function undistilledCount(
266
+ projectPath: string,
267
+ sessionID?: string,
268
+ ): number {
269
+ const pid = ensureProject(projectPath);
270
+ const query = sessionID
271
+ ? "SELECT COUNT(*) as count FROM temporal_messages WHERE project_id = ? AND session_id = ? AND distilled = 0"
272
+ : "SELECT COUNT(*) as count FROM temporal_messages WHERE project_id = ? AND distilled = 0";
273
+ const params = sessionID ? [pid, sessionID] : [pid];
274
+ return (
275
+ db()
276
+ .query(query)
277
+ .get(...params) as { count: number }
278
+ ).count;
279
+ }
280
+
281
+ export type PruneResult = {
282
+ /** Rows deleted by the TTL pass (distilled=1 AND older than retention period). */
283
+ ttlDeleted: number;
284
+ /** Rows deleted by the size-cap pass (distilled=1, oldest-first, to get under maxStorage). */
285
+ capDeleted: number;
286
+ };
287
+
288
+ /**
289
+ * Prune temporal messages for a project using a two-pass Hybrid C strategy:
290
+ *
291
+ * Pass 1 — TTL: delete messages where distilled=1 AND created_at is older than
292
+ * retentionDays. This covers normal operation — both distillation and curation
293
+ * have had ample time to process anything that old.
294
+ *
295
+ * Pass 2 — Size cap: if total temporal storage for the project still exceeds
296
+ * maxStorageMB, delete the oldest distilled=1 messages (regardless of age)
297
+ * until under the cap.
298
+ *
299
+ * Invariant: undistilled messages (distilled=0) are NEVER deleted by either pass.
300
+ */
301
+ export function prune(input: {
302
+ projectPath: string;
303
+ retentionDays: number;
304
+ maxStorageMB: number;
305
+ }): PruneResult {
306
+ const database = db();
307
+ const pid = ensureProject(input.projectPath);
308
+ const cutoff = Date.now() - input.retentionDays * 24 * 60 * 60 * 1000;
309
+
310
+ // Pass 1: TTL — delete distilled messages older than the retention window.
311
+ // Note: result.changes is inflated by FTS trigger side-effects, so we count
312
+ // eligible rows before deletion to get the accurate number deleted.
313
+ const ttlEligible = (
314
+ database
315
+ .query(
316
+ "SELECT COUNT(*) as c FROM temporal_messages WHERE project_id = ? AND distilled = 1 AND created_at < ?",
317
+ )
318
+ .get(pid, cutoff) as { c: number }
319
+ ).c;
320
+ if (ttlEligible > 0) {
321
+ database
322
+ .query(
323
+ "DELETE FROM temporal_messages WHERE project_id = ? AND distilled = 1 AND created_at < ?",
324
+ )
325
+ .run(pid, cutoff);
326
+ }
327
+ const ttlDeleted = ttlEligible;
328
+
329
+ // Pass 2: Size cap — check if total storage for this project exceeds the
330
+ // limit and if so, evict the oldest distilled messages until under the cap.
331
+ const maxBytes = input.maxStorageMB * 1024 * 1024;
332
+ const totalBytes = (
333
+ database
334
+ .query("SELECT SUM(LENGTH(content)) as b FROM temporal_messages WHERE project_id = ?")
335
+ .get(pid) as { b: number | null }
336
+ ).b ?? 0;
337
+
338
+ let capDeleted = 0;
339
+ if (totalBytes > maxBytes) {
340
+ // Collect oldest distilled messages until we've accounted for enough bytes
341
+ // to drop below the cap. Delete them in a single batch.
342
+ const candidates = database
343
+ .query(
344
+ "SELECT id, LENGTH(content) as size FROM temporal_messages WHERE project_id = ? AND distilled = 1 ORDER BY created_at ASC",
345
+ )
346
+ .all(pid) as { id: string; size: number }[];
347
+
348
+ const toDelete: string[] = [];
349
+ let freed = 0;
350
+ const excess = totalBytes - maxBytes;
351
+ for (const row of candidates) {
352
+ if (freed >= excess) break;
353
+ toDelete.push(row.id);
354
+ freed += row.size;
355
+ }
356
+
357
+ if (toDelete.length) {
358
+ const placeholders = toDelete.map(() => "?").join(",");
359
+ database
360
+ .query(
361
+ `DELETE FROM temporal_messages WHERE id IN (${placeholders})`,
362
+ )
363
+ .run(...toDelete);
364
+ // toDelete.length is the accurate count — result.changes is inflated by FTS triggers.
365
+ capDeleted = toDelete.length;
366
+ }
367
+ }
368
+
369
+ // Pass 3: Prune archived distillations older than the retention window.
370
+ // Archived gen-0 distillations are kept for recall search but don't need
371
+ // to live forever — they follow the same retention policy as temporal messages.
372
+ database
373
+ .query(
374
+ "DELETE FROM distillations WHERE project_id = ? AND archived = 1 AND created_at < ?",
375
+ )
376
+ .run(pid, cutoff);
377
+
378
+ return { ttlDeleted, capDeleted };
379
+ }