nlm-memory 0.5.0 → 0.5.2

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 (257) hide show
  1. package/README.md +89 -34
  2. package/dist/cli/digest.d.ts +20 -0
  3. package/dist/cli/digest.js +142 -0
  4. package/dist/cli/digest.js.map +1 -0
  5. package/dist/cli/nlm.d.ts +1 -0
  6. package/dist/cli/nlm.js +25 -1
  7. package/dist/cli/nlm.js.map +1 -1
  8. package/dist/core/digest/compose.d.ts +38 -0
  9. package/dist/core/digest/compose.js +93 -0
  10. package/dist/core/digest/compose.js.map +1 -0
  11. package/dist/core/digest/hook-liveness.d.ts +32 -0
  12. package/dist/core/digest/hook-liveness.js +54 -0
  13. package/dist/core/digest/hook-liveness.js.map +1 -0
  14. package/dist/http/app.js +2 -1
  15. package/dist/http/app.js.map +1 -1
  16. package/dist/mcp/server.js +20 -1
  17. package/dist/mcp/server.js.map +1 -1
  18. package/dist/ui/assets/{index-C8cpwbYJ.css → index-Beo8psd-.css} +1 -1
  19. package/dist/ui/assets/{index-CB50QnL-.js → index-CSPTTeeM.js} +8 -8
  20. package/dist/ui/index.html +2 -2
  21. package/package.json +26 -1
  22. package/.agents/plugins/marketplace.json +0 -20
  23. package/.github/workflows/ci.yml +0 -30
  24. package/docs/methodology/re-derivation-rate.md +0 -112
  25. package/docs/methodology/useful-hit-rate.md +0 -79
  26. package/docs/plans/2026-05-20-fts5-lexical-recall.md +0 -1088
  27. package/docs/plans/2026-05-20-recall-daemon-wedge-fix.md +0 -662
  28. package/docs/plans/2026-05-20-recall-hook-design.md +0 -131
  29. package/docs/plans/2026-05-20-recall-hook-implementation.md +0 -1222
  30. package/docs/plans/desktop-product.md +0 -69
  31. package/docs/plans/factstore-design.md +0 -236
  32. package/logs/CHANGELOG/CHANGELOG-2026.md +0 -1575
  33. package/logs/CHANGELOG/CHANGELOG.md +0 -209
  34. package/migrations/000_initial_schema.sql +0 -174
  35. package/migrations/001_entity_type_rename.sql +0 -17
  36. package/migrations/002_adapter_state_extend.sql +0 -12
  37. package/migrations/003_session_embeddings.sql +0 -11
  38. package/migrations/004_facts.sql +0 -46
  39. package/migrations/005_sources.sql +0 -31
  40. package/migrations/006_providers.sql +0 -33
  41. package/migrations/007_source_tokens.sql +0 -17
  42. package/migrations/008_fts_rebuild.sql +0 -9
  43. package/migrations/009_session_embedding_chunks.sql +0 -46
  44. package/migrations/010_sources_opencode.sql +0 -30
  45. package/migrations/011_sources_hermes_agent.sql +0 -30
  46. package/migrations/012_sources_aider.sql +0 -30
  47. package/migrations/013_adapter_state_failure_count.sql +0 -12
  48. package/migrations/014_sources_cursor.sql +0 -30
  49. package/migrations/015_sources_windsurf.sql +0 -30
  50. package/plugin-hermes-agent/README.md +0 -49
  51. package/plugin-hermes-agent/__init__.py +0 -75
  52. package/plugin-hermes-agent/plugin.yaml +0 -15
  53. package/scripts/backfill-citations.mjs +0 -0
  54. package/scripts/build-codex-plugin.mjs +0 -61
  55. package/scripts/deepseek-probe.mjs +0 -67
  56. package/scripts/extract-triples.mjs +0 -207
  57. package/scripts/longmemeval/embedding-cache.ts +0 -77
  58. package/scripts/longmemeval/fetch-dataset.sh +0 -25
  59. package/scripts/longmemeval/run-harness.ts +0 -315
  60. package/scripts/longmemeval/scorer.ts +0 -99
  61. package/scripts/longmemeval/tsconfig.json +0 -9
  62. package/scripts/longmemeval/types.ts +0 -35
  63. package/scripts/nlm-daily-digest.py +0 -239
  64. package/scripts/nlm-daily-digest.sh +0 -28
  65. package/src/cli/classify-parity.ts +0 -257
  66. package/src/cli/launchctl-helpers.ts +0 -49
  67. package/src/cli/nlm.ts +0 -1078
  68. package/src/core/actions/actions-log.ts +0 -118
  69. package/src/core/actions/overlay.ts +0 -117
  70. package/src/core/adapters/aider.ts +0 -205
  71. package/src/core/adapters/claude-code.ts +0 -293
  72. package/src/core/adapters/common.ts +0 -54
  73. package/src/core/adapters/cursor.ts +0 -486
  74. package/src/core/adapters/from-source.ts +0 -67
  75. package/src/core/adapters/hermes-agent.ts +0 -240
  76. package/src/core/adapters/hermes.ts +0 -277
  77. package/src/core/adapters/jsonl-generic.ts +0 -208
  78. package/src/core/adapters/opencode.ts +0 -281
  79. package/src/core/adapters/pi.ts +0 -264
  80. package/src/core/adapters/windsurf.ts +0 -386
  81. package/src/core/classifier/prompt.ts +0 -200
  82. package/src/core/dataset/build-dataset.ts +0 -463
  83. package/src/core/embedding/chunk-body.ts +0 -76
  84. package/src/core/embedding/embed-backfill.ts +0 -210
  85. package/src/core/embedding/embed-normalize.ts +0 -135
  86. package/src/core/facts/backfill-facts.ts +0 -254
  87. package/src/core/facts/extract-facts.ts +0 -50
  88. package/src/core/hook/citation-detect.ts +0 -124
  89. package/src/core/hook/cite-memo.ts +0 -68
  90. package/src/core/hook/claude-settings.ts +0 -187
  91. package/src/core/hook/gate.ts +0 -25
  92. package/src/core/hook/hook-log.ts +0 -41
  93. package/src/core/hook/memo-sweep.ts +0 -164
  94. package/src/core/hook/memo.ts +0 -67
  95. package/src/core/hook/pointer-block.ts +0 -26
  96. package/src/core/hook/select.ts +0 -32
  97. package/src/core/hook/transcript.ts +0 -121
  98. package/src/core/ingest/ingest-session.ts +0 -111
  99. package/src/core/providers/provider-models.ts +0 -100
  100. package/src/core/providers/provider-registry.ts +0 -196
  101. package/src/core/recall/citation-log.ts +0 -108
  102. package/src/core/recall/filter.ts +0 -27
  103. package/src/core/recall/index.ts +0 -6
  104. package/src/core/recall/match-fields.ts +0 -40
  105. package/src/core/recall/query-log.ts +0 -149
  106. package/src/core/recall/query-shape.ts +0 -66
  107. package/src/core/recall/recall-service.ts +0 -320
  108. package/src/core/recall/recent-log.ts +0 -59
  109. package/src/core/recall/tokenize.ts +0 -18
  110. package/src/core/recall/useful-scan.ts +0 -336
  111. package/src/core/recall-facts/fact-query-log.ts +0 -150
  112. package/src/core/recall-facts/fact-recall-service.ts +0 -327
  113. package/src/core/scheduler/scan-once.ts +0 -142
  114. package/src/core/scheduler/scheduler.ts +0 -225
  115. package/src/core/sources/source-registry.ts +0 -278
  116. package/src/core/storage/db-restore.ts +0 -133
  117. package/src/core/storage/live-status.ts +0 -45
  118. package/src/core/storage/migrate.ts +0 -72
  119. package/src/core/storage/sqlite-fact-store.ts +0 -304
  120. package/src/core/storage/sqlite-session-store.ts +0 -810
  121. package/src/hook/hook-auth.ts +0 -18
  122. package/src/hook/prompt-recall-hook.ts +0 -180
  123. package/src/hook/session-end-hook.ts +0 -81
  124. package/src/hook/session-start-hook.ts +0 -168
  125. package/src/hook/stop-hook.ts +0 -239
  126. package/src/http/app.ts +0 -1215
  127. package/src/install/claude-code.ts +0 -128
  128. package/src/install/codex.ts +0 -367
  129. package/src/install/cursor.ts +0 -68
  130. package/src/install/hermes-agent.ts +0 -76
  131. package/src/install/hermes.ts +0 -78
  132. package/src/install/nlm-dir-perms.ts +0 -55
  133. package/src/install/ollama.ts +0 -284
  134. package/src/install/setup.ts +0 -489
  135. package/src/install/windsurf.ts +0 -68
  136. package/src/llm/classifier-box.ts +0 -64
  137. package/src/llm/deepseek-client.ts +0 -150
  138. package/src/llm/env-autoload.ts +0 -55
  139. package/src/llm/ollama-client.ts +0 -189
  140. package/src/mcp/server.ts +0 -534
  141. package/src/ports/fact-store.ts +0 -102
  142. package/src/ports/llm-client.ts +0 -52
  143. package/src/ports/logger.ts +0 -16
  144. package/src/ports/session-store.ts +0 -45
  145. package/src/ports/transcript-adapter.ts +0 -55
  146. package/src/shared/types.ts +0 -149
  147. package/src/ui/App.tsx +0 -58
  148. package/src/ui/components/PromoteOpenButton.tsx +0 -65
  149. package/src/ui/components/SessionDrawer.tsx +0 -199
  150. package/src/ui/components/SideNav.tsx +0 -162
  151. package/src/ui/components/Skeleton.tsx +0 -107
  152. package/src/ui/index.html +0 -13
  153. package/src/ui/lib/actions.ts +0 -30
  154. package/src/ui/lib/api.ts +0 -92
  155. package/src/ui/lib/dataset.ts +0 -141
  156. package/src/ui/lib/registries.ts +0 -155
  157. package/src/ui/lib/view-settings.ts +0 -41
  158. package/src/ui/main.tsx +0 -15
  159. package/src/ui/pages/Live.tsx +0 -229
  160. package/src/ui/pages/Pulse.tsx +0 -415
  161. package/src/ui/pages/Recall.tsx +0 -190
  162. package/src/ui/pages/River.tsx +0 -354
  163. package/src/ui/pages/Search.tsx +0 -386
  164. package/src/ui/pages/Stub.tsx +0 -9
  165. package/src/ui/pages/Thread.tsx +0 -473
  166. package/src/ui/pages/settings/Classifier.tsx +0 -227
  167. package/src/ui/pages/settings/Data.tsx +0 -190
  168. package/src/ui/pages/settings/Index.tsx +0 -65
  169. package/src/ui/pages/settings/Labels.tsx +0 -224
  170. package/src/ui/pages/settings/Providers.tsx +0 -305
  171. package/src/ui/pages/settings/SettingsSubnav.tsx +0 -28
  172. package/src/ui/pages/settings/Sources.tsx +0 -326
  173. package/src/ui/pages/settings/Views.tsx +0 -96
  174. package/src/ui/styles.css +0 -1890
  175. package/src/ui/tsconfig.json +0 -21
  176. package/src/ui/vite.config.ts +0 -19
  177. package/tests/fixtures/claude_code/short_session.jsonl +0 -2
  178. package/tests/fixtures/claude_code/standard_iso.jsonl +0 -4
  179. package/tests/fixtures/claude_code/tool_heavy.jsonl +0 -8
  180. package/tests/fixtures/claude_code/with_subagent.jsonl +0 -7
  181. package/tests/fixtures/facts.ts +0 -17
  182. package/tests/fixtures/golden-corpus.ts +0 -85
  183. package/tests/fixtures/hermes/paired_request_dump.json +0 -24
  184. package/tests/fixtures/hermes/paired_session.json +0 -23
  185. package/tests/fixtures/hermes/request_dump.json +0 -28
  186. package/tests/fixtures/hermes/session_iso.json +0 -38
  187. package/tests/fixtures/hermes/session_unix.json +0 -38
  188. package/tests/fixtures/hermes/system_only.json +0 -18
  189. package/tests/fixtures/pi/error-connection-abort.jsonl +0 -8
  190. package/tests/fixtures/pi/short-successful.jsonl +0 -5
  191. package/tests/fixtures/pi/with-custom-message.jsonl +0 -6
  192. package/tests/fixtures/sessions.ts +0 -22
  193. package/tests/integration/backfill-facts.test.ts +0 -362
  194. package/tests/integration/citation-explicit.test.ts +0 -111
  195. package/tests/integration/cite-event.test.ts +0 -169
  196. package/tests/integration/cite-memo.test.ts +0 -87
  197. package/tests/integration/db-restore.test.ts +0 -153
  198. package/tests/integration/embed-backfill.test.ts +0 -176
  199. package/tests/integration/fact-supersedence.test.ts +0 -313
  200. package/tests/integration/fts-index.test.ts +0 -60
  201. package/tests/integration/getbyids-sqlite.test.ts +0 -100
  202. package/tests/integration/hermes-agent-hooks.test.ts +0 -248
  203. package/tests/integration/hook-claude-settings.test.ts +0 -218
  204. package/tests/integration/hook-log.test.ts +0 -54
  205. package/tests/integration/hook-memo.test.ts +0 -68
  206. package/tests/integration/hook-pre-compact.test.ts +0 -105
  207. package/tests/integration/hook-subagent-start.test.ts +0 -102
  208. package/tests/integration/http.test.ts +0 -401
  209. package/tests/integration/keyword-search-fts.test.ts +0 -66
  210. package/tests/integration/mcp-recall-logging.test.ts +0 -88
  211. package/tests/integration/mcp.test.ts +0 -260
  212. package/tests/integration/memo-sweep.test.ts +0 -91
  213. package/tests/integration/prompt-recall-hook.test.ts +0 -88
  214. package/tests/integration/provider-registry.test.ts +0 -107
  215. package/tests/integration/recall-golden.test.ts +0 -59
  216. package/tests/integration/recall-sqlite.test.ts +0 -169
  217. package/tests/integration/scheduler.test.ts +0 -391
  218. package/tests/integration/session-end-hook.test.ts +0 -48
  219. package/tests/integration/session-start-hook.test.ts +0 -126
  220. package/tests/integration/source-registry.test.ts +0 -122
  221. package/tests/integration/sqlite-fact-store.test.ts +0 -346
  222. package/tests/integration/stop-hook.test.ts +0 -560
  223. package/tests/integration/wal-checkpoint.test.ts +0 -49
  224. package/tests/unit/cli/launchctl-helpers.test.ts +0 -60
  225. package/tests/unit/core/adapters/aider.test.ts +0 -230
  226. package/tests/unit/core/adapters/claude-code.test.ts +0 -118
  227. package/tests/unit/core/adapters/cursor.test.ts +0 -485
  228. package/tests/unit/core/adapters/hermes-agent.test.ts +0 -329
  229. package/tests/unit/core/adapters/hermes.test.ts +0 -81
  230. package/tests/unit/core/adapters/jsonl-generic.test.ts +0 -142
  231. package/tests/unit/core/adapters/opencode.test.ts +0 -354
  232. package/tests/unit/core/adapters/pi.test.ts +0 -110
  233. package/tests/unit/core/adapters/windsurf.test.ts +0 -416
  234. package/tests/unit/core/classifier/prompt.test.ts +0 -126
  235. package/tests/unit/core/embedding/chunk-body.test.ts +0 -100
  236. package/tests/unit/core/facts/extract-facts.test.ts +0 -117
  237. package/tests/unit/core/filter.test.ts +0 -40
  238. package/tests/unit/core/hook/citation-detect-cite-session.test.ts +0 -96
  239. package/tests/unit/core/hook/citation-detect.test.ts +0 -124
  240. package/tests/unit/core/hook/gate.test.ts +0 -29
  241. package/tests/unit/core/hook/pointer-block.test.ts +0 -22
  242. package/tests/unit/core/hook/select.test.ts +0 -66
  243. package/tests/unit/core/match-fields.test.ts +0 -39
  244. package/tests/unit/core/mcp-cite-session.test.ts +0 -51
  245. package/tests/unit/core/providers/provider-models.test.ts +0 -101
  246. package/tests/unit/core/query-shape.test.ts +0 -92
  247. package/tests/unit/core/recall-facts/fact-recall-service.test.ts +0 -258
  248. package/tests/unit/core/recall-service.test.ts +0 -200
  249. package/tests/unit/core/storage/live-status.test.ts +0 -54
  250. package/tests/unit/core/tokenize.test.ts +0 -32
  251. package/tests/unit/core/useful-scan.test.ts +0 -537
  252. package/tests/unit/llm/embed.test.ts +0 -93
  253. package/tests/unit/llm/ollama-client.test.ts +0 -124
  254. package/tests/unit/scripts/longmemeval-scorer.test.ts +0 -114
  255. package/tsconfig.json +0 -31
  256. package/tsconfig.test.json +0 -11
  257. package/vitest.config.ts +0 -22
@@ -1,810 +0,0 @@
1
- /**
2
- * SqliteSessionStore — the canonical SessionStore implementation backed by
3
- * better-sqlite3 with the sqlite-vec extension loaded for KNN search.
4
- *
5
- * Layering note: core/ imports this concrete class only at the composition
6
- * root (CLI / server bootstrap). The recall use case and every other piece
7
- * of core depends on the SessionStore *port*, never on this file.
8
- *
9
- * Schema parity with the Python daemon: sessions row + session_entities +
10
- * markers + session_embeddings (vec0). Idle-status overlay (computed from
11
- * transcript mtime) is deferred to a later phase — A.2 returns the persisted
12
- * status verbatim.
13
- */
14
-
15
- import { existsSync, mkdirSync } from "node:fs";
16
- import { dirname, resolve } from "node:path";
17
- import Database from "better-sqlite3";
18
- import * as sqliteVec from "sqlite-vec";
19
- import type {
20
- KeywordNeighbor,
21
- SemanticNeighbor,
22
- SessionFilter,
23
- SessionStore,
24
- } from "@ports/session-store.js";
25
- import type {
26
- Session,
27
- SessionStatus,
28
- } from "@shared/types.js";
29
- import { liveSessionStatus } from "./live-status.js";
30
- import { loadActionOverlay, openQuestionId } from "@core/actions/overlay.js";
31
- import type { ActionOverlay } from "@core/actions/overlay.js";
32
- import type { Fact } from "@shared/types.js";
33
- import { runMigrations } from "./migrate.js";
34
- import type { SqliteFactStore } from "./sqlite-fact-store.js";
35
- import { tokenize } from "@core/recall/tokenize.js";
36
- import { chunkSessionText } from "@core/embedding/chunk-body.js";
37
-
38
- export interface SqliteSessionStoreOptions {
39
- readonly dbPath: string;
40
- readonly migrationsDir: string;
41
- readonly readonly?: boolean;
42
- }
43
-
44
- /** Full ingest payload for SqliteSessionStore.insertSession. */
45
- export interface IngestRecord {
46
- readonly id: string;
47
- readonly runtime: string;
48
- readonly runtimeSessionId: string | null;
49
- readonly startedAt: string;
50
- readonly endedAt: string | null;
51
- readonly durationMin: number | null;
52
- readonly label: string;
53
- readonly summary: string;
54
- readonly body: string | null;
55
- readonly status: SessionStatus;
56
- readonly transcriptKind: string | null;
57
- readonly transcriptPath: string | null;
58
- readonly transcriptOffset: number | null;
59
- readonly transcriptLength: number | null;
60
- readonly entities: ReadonlyArray<string>;
61
- readonly decisions: ReadonlyArray<string>;
62
- readonly openQuestions: ReadonlyArray<string>;
63
- }
64
-
65
- type SessionRow = {
66
- id: string;
67
- runtime: string;
68
- runtime_session_id: string | null;
69
- started_at: string;
70
- ended_at: string | null;
71
- duration_min: number | null;
72
- label: string;
73
- summary: string;
74
- status: "active" | "closed" | "superseded";
75
- transcript_kind: string | null;
76
- transcript_path: string | null;
77
- body: string | null;
78
- };
79
-
80
- type EntityRow = { session_id: string; entity_canonical: string };
81
- type MarkerRow = { session_id: string; kind: "decision" | "open"; text: string };
82
- type NeighborRow = { session_id: string; distance: number };
83
- type KeywordRow = { session_id: string; score: number };
84
-
85
- export interface RecentWrite {
86
- id: string;
87
- runtime: string;
88
- label: string;
89
- summary: string;
90
- createdAt: string;
91
- }
92
-
93
- export interface RecentMarker {
94
- sessionId: string;
95
- kind: "decision" | "open";
96
- text: string;
97
- label: string;
98
- createdAt: string;
99
- }
100
-
101
- export class SqliteSessionStore implements SessionStore {
102
- private readonly db: Database.Database;
103
-
104
- constructor(opts: SqliteSessionStoreOptions) {
105
- const dbPath = resolve(opts.dbPath);
106
- const parent = dirname(dbPath);
107
- if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
108
-
109
- this.db = new Database(dbPath, opts.readonly ? { readonly: true } : {});
110
- this.db.pragma("foreign_keys = ON");
111
- this.db.pragma("journal_mode = WAL");
112
-
113
- sqliteVec.load(this.db);
114
-
115
- if (!opts.readonly) {
116
- runMigrations(this.db, opts.migrationsDir);
117
- }
118
- }
119
-
120
- close(): void {
121
- this.db.close();
122
- }
123
-
124
- /**
125
- * Drains the WAL into the main database and truncates the -wal file.
126
- * WAL mode is on but nothing else checkpoints, so the file grows
127
- * unbounded under continuous readers. The daemon calls this on an
128
- * interval. Synchronous — keep the WAL small so each call is cheap.
129
- */
130
- checkpoint(): void {
131
- this.db.pragma("wal_checkpoint(TRUNCATE)");
132
- }
133
-
134
- /** Raw db handle for ingest helpers (Scheduler, scanOnce). Avoid using
135
- * directly from the recall path — it bypasses the SessionStore port. */
136
- rawDb(): Database.Database {
137
- return this.db;
138
- }
139
-
140
- /** Recently-written sessions ordered by created_at desc. Powers /live Writes column. */
141
- recentWrites(limit: number): RecentWrite[] {
142
- return this.db
143
- .prepare<[number], RecentWrite>(
144
- `SELECT id, runtime, label, summary, created_at AS createdAt
145
- FROM sessions
146
- ORDER BY created_at DESC
147
- LIMIT ?`,
148
- )
149
- .all(limit);
150
- }
151
-
152
- /** Recently-extracted markers ordered by session created_at desc. Powers /live Decisions column. */
153
- recentMarkers(limit: number): RecentMarker[] {
154
- return this.db
155
- .prepare<[number], RecentMarker>(
156
- `SELECT m.session_id AS sessionId, m.kind, m.text, s.label, s.created_at AS createdAt
157
- FROM markers m
158
- JOIN sessions s ON s.id = m.session_id
159
- ORDER BY s.created_at DESC, m.position ASC
160
- LIMIT ?`,
161
- )
162
- .all(limit);
163
- }
164
-
165
- /**
166
- * Atomic ingest: writes the session row, markers, entity rows + links,
167
- * supersedes edge (if any), and the embedding (best-effort) in one
168
- * transaction. Idempotent on re-ingest — ON CONFLICT updates the session
169
- * in place; markers are deleted and rewritten; entity links use INSERT OR
170
- * IGNORE; embedding row is DELETE+INSERT (vec0 doesn't UPDATE).
171
- *
172
- * Mirrors Python's SQLiteStore.insert_session. Markdown projection is not
173
- * yet ported and skipped here.
174
- */
175
- async insertSession(
176
- record: IngestRecord,
177
- embedder: import("@ports/llm-client.js").LLMClient | null = null,
178
- supersedes: string | null = null,
179
- factSink: { factStore: SqliteFactStore; facts: ReadonlyArray<Fact> } | null = null,
180
- ): Promise<void> {
181
- const db = this.db;
182
- const txn = db.transaction(() => {
183
- db.prepare(`
184
- INSERT INTO sessions (
185
- id, runtime, runtime_session_id, started_at, ended_at, duration_min,
186
- label, summary, body, status,
187
- transcript_kind, transcript_path, transcript_offset, transcript_length
188
- ) VALUES (@id, @runtime, @runtimeSessionId, @startedAt, @endedAt, @durationMin,
189
- @label, @summary, @body, @status,
190
- @transcriptKind, @transcriptPath, @transcriptOffset, @transcriptLength)
191
- ON CONFLICT(id) DO UPDATE SET
192
- ended_at = excluded.ended_at,
193
- duration_min = excluded.duration_min,
194
- label = excluded.label,
195
- summary = excluded.summary,
196
- body = excluded.body,
197
- status = excluded.status,
198
- updated_at = datetime('now')
199
- `).run({
200
- id: record.id,
201
- runtime: record.runtime,
202
- runtimeSessionId: record.runtimeSessionId,
203
- startedAt: record.startedAt,
204
- endedAt: record.endedAt,
205
- durationMin: record.durationMin,
206
- label: record.label,
207
- summary: record.summary,
208
- body: record.body,
209
- status: record.status === "idle" ? "active" : record.status,
210
- transcriptKind: record.transcriptKind,
211
- transcriptPath: record.transcriptPath,
212
- transcriptOffset: record.transcriptOffset,
213
- transcriptLength: record.transcriptLength,
214
- });
215
-
216
- db.prepare("DELETE FROM markers WHERE session_id = ?").run(record.id);
217
- const markerStmt = db.prepare(
218
- "INSERT INTO markers (session_id, kind, text, position) VALUES (?, ?, ?, ?)",
219
- );
220
- record.decisions.forEach((d, i) => markerStmt.run(record.id, "decision", d.trim(), i));
221
- record.openQuestions.forEach((q, i) => markerStmt.run(record.id, "open", q.trim(), i));
222
-
223
- const insertEnt = db.prepare(`
224
- INSERT OR IGNORE INTO entities
225
- (canonical, type, status, source, first_seen_session, last_seen_session, session_count)
226
- VALUES (?, 'candidate', 'candidate', 'auto-detected', ?, ?, 0)
227
- `);
228
- const touchEnt = db.prepare(`
229
- UPDATE entities
230
- SET last_seen_session = ?, session_count = session_count + 1, updated_at = datetime('now')
231
- WHERE canonical = ?
232
- `);
233
- const linkEnt = db.prepare(
234
- "INSERT OR IGNORE INTO session_entities (session_id, entity_canonical) VALUES (?, ?)",
235
- );
236
- for (const raw of record.entities) {
237
- const name = raw.trim();
238
- if (!name) continue;
239
- insertEnt.run(name, record.id, record.id);
240
- touchEnt.run(record.id, name);
241
- linkEnt.run(record.id, name);
242
- }
243
-
244
- if (supersedes) {
245
- db.prepare(
246
- `INSERT OR IGNORE INTO session_edges (from_session, to_session, kind)
247
- VALUES (?, ?, 'supersedes')`,
248
- ).run(record.id, supersedes);
249
- db.prepare(
250
- "UPDATE sessions SET status = 'superseded', updated_at = datetime('now') WHERE id = ?",
251
- ).run(supersedes);
252
- }
253
-
254
- // Facts ingest is part of the session txn — either both commit or both
255
- // roll back. On re-ingest (ON CONFLICT updates the session above), we
256
- // delete prior facts for this source_session_id before re-inserting so
257
- // the row count matches the latest classifier output. Without this,
258
- // re-ingest accumulates duplicates.
259
- //
260
- // Phase B.4 — deterministic supersedence on (subject, predicate)
261
- // collision. For each new fact, after insert, look up any OTHER
262
- // non-superseded fact with the same (subject, predicate). Mark the
263
- // older one as superseded by the new fact's id. Always-supersede
264
- // policy applies even when value is unchanged — same-value re-assertion
265
- // carries new provenance (new source_session_id) and is informative
266
- // history. See Section 2 of factstore-design.md.
267
- //
268
- // Ordering note: insertManyInTxn FIRST so the new fact id exists in
269
- // facts(id) before any UPDATE sets superseded_by = newId (the FK
270
- // would reject otherwise). The DELETE above plus the CASCADE-SET-NULL
271
- // on superseded_by means re-ingest naturally repairs chains: if an
272
- // earlier ingest of this session superseded a fact from another
273
- // session, deleting our prior fact unlinks the chain; the loop below
274
- // re-establishes it with the freshly-inserted row.
275
- if (factSink !== null) {
276
- this.applyFactsInTxn(record.id, factSink.factStore, factSink.facts);
277
- }
278
- });
279
- txn();
280
-
281
- // Embedding is best-effort and lives outside the txn so a slow Ollama
282
- // doesn't block the row commit. Body is chunked into ≤MAX_CHUNK_CHARS
283
- // windows (see chunk-body.ts) and each chunk embedded independently.
284
- // Per-chunk embedder failures are tolerated; the chunks that did embed
285
- // still contribute to recall.
286
- if (embedder) {
287
- const chunks = chunkSessionText({
288
- label: record.label,
289
- summary: record.summary,
290
- body: record.body,
291
- });
292
- this.deleteSessionChunks(record.id);
293
- for (let chunkIdx = 0; chunkIdx < chunks.length; chunkIdx++) {
294
- const text = chunks[chunkIdx]!;
295
- if (!text) continue;
296
- try {
297
- const { vector } = await embedder.embed(text, "document");
298
- this.insertChunkEmbedding(record.id, chunkIdx, vector);
299
- } catch {
300
- // Per-chunk embedder failure must not roll the ingest back or
301
- // abort subsequent chunks.
302
- }
303
- }
304
-
305
- if (factSink !== null) {
306
- await this.embedFacts(factSink.factStore, factSink.facts, embedder);
307
- }
308
- }
309
- }
310
-
311
- private deleteSessionChunks(sessionId: string): void {
312
- const db = this.db;
313
- const rows = db
314
- .prepare<[string], { chunk_id: number }>(
315
- "SELECT chunk_id FROM session_chunk_map WHERE session_id = ?",
316
- )
317
- .all(sessionId);
318
- if (rows.length === 0) return;
319
- const placeholders = rows.map(() => "?").join(",");
320
- const ids = rows.map((r) => r.chunk_id);
321
- db.prepare(
322
- `DELETE FROM session_embedding_chunks WHERE chunk_id IN (${placeholders})`,
323
- ).run(...ids);
324
- db.prepare("DELETE FROM session_chunk_map WHERE session_id = ?").run(sessionId);
325
- }
326
-
327
- private insertChunkEmbedding(
328
- sessionId: string,
329
- chunkIdx: number,
330
- vector: Float32Array,
331
- ): void {
332
- const db = this.db;
333
- const blob = Buffer.from(
334
- vector.buffer,
335
- vector.byteOffset,
336
- vector.byteLength,
337
- );
338
- // vec0 enforces strict integer typing on aux columns; better-sqlite3 binds
339
- // JS numbers as FLOAT, so cast chunk_idx via BigInt to bind as INTEGER.
340
- const idxInt = BigInt(chunkIdx);
341
- const info = db
342
- .prepare(
343
- "INSERT INTO session_embedding_chunks (embedding, session_id, chunk_idx) VALUES (?, ?, ?)",
344
- )
345
- .run(blob, sessionId, idxInt);
346
- const chunkId = Number(info.lastInsertRowid);
347
- db.prepare(
348
- "INSERT INTO session_chunk_map (chunk_id, session_id, chunk_idx) VALUES (?, ?, ?)",
349
- ).run(chunkId, sessionId, chunkIdx);
350
- }
351
-
352
- /**
353
- * Phase B.5 — backfill entry point. Writes facts (with deterministic
354
- * supersedence + best-effort embeddings) for an EXISTING session row
355
- * without touching it. Opens its own transaction; callers must not be
356
- * inside one. The session row must already exist in `sessions` or the
357
- * FK on facts.source_session_id rejects.
358
- *
359
- * Use this when ingesting facts after the fact — e.g. running the
360
- * classifier across a historical corpus that predates the B.2 ingest
361
- * write path. The live ingest path (`insertSession`) keeps using the
362
- * internal helpers directly so session+facts commit together.
363
- */
364
- async insertFactsForSession(
365
- sessionId: string,
366
- factStore: SqliteFactStore,
367
- facts: ReadonlyArray<Fact>,
368
- embedder: import("@ports/llm-client.js").LLMClient | null = null,
369
- ): Promise<void> {
370
- const db = this.db;
371
- const txn = db.transaction(() => {
372
- this.applyFactsInTxn(sessionId, factStore, facts);
373
- });
374
- txn();
375
- if (embedder) {
376
- await this.embedFacts(factStore, facts, embedder);
377
- }
378
- }
379
-
380
- /**
381
- * Sync core of the fact-ingest block. Runs inside an EXISTING transaction
382
- * — opens no txn of its own. Used by both `insertSession` (Phase B.2
383
- * atomic ingest) and `insertFactsForSession` (Phase B.5 backfill).
384
- *
385
- * Behavior (mirrored across both callers):
386
- * 1. DELETE prior facts attributed to this session (idempotent on
387
- * backfill, drops stale rows on re-ingest).
388
- * 2. Insert all new facts atomically.
389
- * 3. For each, mark the prior current (subject, predicate) fact as
390
- * superseded — Phase B.4 deterministic supersedence policy.
391
- *
392
- * Ordering: inserts before updates so the supersedence FK target exists.
393
- * CASCADE-SET-NULL on `superseded_by` handles chain repair on re-ingest.
394
- */
395
- private applyFactsInTxn(
396
- sessionId: string,
397
- factStore: SqliteFactStore,
398
- facts: ReadonlyArray<Fact>,
399
- ): void {
400
- const db = this.db;
401
- db.prepare("DELETE FROM facts WHERE source_session_id = ?").run(sessionId);
402
- factStore.insertManyInTxn(facts);
403
- if (facts.length === 0) return;
404
-
405
- const findCollisionStmt = db.prepare<
406
- [string, string, string],
407
- { id: string }
408
- >(`
409
- SELECT id
410
- FROM facts
411
- WHERE subject = ?
412
- AND predicate = ?
413
- AND superseded_by IS NULL
414
- AND id != ?
415
- ORDER BY created_at DESC
416
- LIMIT 1
417
- `);
418
- const markSupersededStmt = db.prepare(
419
- "UPDATE facts SET superseded_by = ? WHERE id = ?",
420
- );
421
- for (const fact of facts) {
422
- const prior = findCollisionStmt.get(fact.subject, fact.predicate, fact.id);
423
- if (prior) markSupersededStmt.run(fact.id, prior.id);
424
- }
425
- }
426
-
427
- /**
428
- * Best-effort per-fact embedding. Writes `${subject} ${predicate} ${value}`
429
- * embeddings to fact_embeddings via FactStore.upsertEmbedding. Per-fact
430
- * failures don't abort the batch, and never affect committed fact rows.
431
- */
432
- private async embedFacts(
433
- factStore: SqliteFactStore,
434
- facts: ReadonlyArray<Fact>,
435
- embedder: import("@ports/llm-client.js").LLMClient,
436
- ): Promise<void> {
437
- for (const fact of facts) {
438
- const factText = `${fact.subject} ${fact.predicate} ${fact.value}`.trim();
439
- if (!factText) continue;
440
- try {
441
- const { vector } = await embedder.embed(factText, "document");
442
- factStore.upsertEmbedding(fact.id, vector);
443
- } catch {
444
- // Per-fact embedding failure must not abort embedding of subsequent
445
- // facts. The fact row stays current; semantic recall just misses it
446
- // until a future re-ingest.
447
- }
448
- }
449
- }
450
-
451
- async list(filter?: SessionFilter): Promise<ReadonlyArray<Session>> {
452
- const rows = this.db
453
- .prepare<[], SessionRow>(`
454
- SELECT id, runtime, runtime_session_id, started_at, ended_at, duration_min,
455
- label, summary, status, transcript_kind, transcript_path, body
456
- FROM sessions
457
- ORDER BY started_at ASC
458
- `)
459
- .all();
460
-
461
- if (rows.length === 0) return [];
462
-
463
- const ids = rows.map((r) => r.id);
464
- const entitiesByIdMap = this.loadEntities(ids);
465
- const markersByIdMap = this.loadMarkers(ids);
466
- const overlay = loadActionOverlay(this.db);
467
-
468
- const sessions = rows.map((r) => this.rowToSession(r, entitiesByIdMap, markersByIdMap, overlay));
469
-
470
- if (!filter) return sessions;
471
- return sessions.filter((s) => {
472
- if (filter.entity !== undefined && !s.entities.includes(filter.entity)) {
473
- return false;
474
- }
475
- if (filter.hasDecisions === true && s.decisions.length === 0) return false;
476
- if (filter.hasOpenQuestions === true && s.open.length === 0) return false;
477
- return true;
478
- });
479
- }
480
-
481
- async getById(sessionId: string): Promise<Session | null> {
482
- const row = this.db
483
- .prepare<[string], SessionRow>(`
484
- SELECT id, runtime, runtime_session_id, started_at, ended_at, duration_min,
485
- label, summary, status, transcript_kind, transcript_path, body
486
- FROM sessions
487
- WHERE id = ?
488
- `)
489
- .get(sessionId);
490
-
491
- if (!row) return null;
492
- const entities = this.loadEntities([sessionId]);
493
- const markers = this.loadMarkers([sessionId]);
494
- const edges = this.loadSessionEdges([sessionId]);
495
- const overlay = loadActionOverlay(this.db);
496
- return this.rowToSession(row, entities, markers, overlay, edges);
497
- }
498
-
499
- /**
500
- * Batched session fetch for the recall path. Deliberately omits the
501
- * `body` column — body is ~48KB/row of session markdown that recall
502
- * never reads, and SELECTing it for the corpus is what wedged the
503
- * daemon. Resolved sessions carry `body: ""`.
504
- */
505
- async getByIds(ids: ReadonlyArray<string>): Promise<ReadonlyArray<Session>> {
506
- if (ids.length === 0) return [];
507
- const placeholders = ids.map(() => "?").join(",");
508
- const rows = this.db
509
- .prepare<string[], Omit<SessionRow, "body">>(`
510
- SELECT id, runtime, runtime_session_id, started_at, ended_at, duration_min,
511
- label, summary, status, transcript_kind, transcript_path
512
- FROM sessions
513
- WHERE id IN (${placeholders})
514
- `)
515
- .all(...ids);
516
-
517
- if (rows.length === 0) return [];
518
- const foundIds = rows.map((r) => r.id);
519
- const entitiesByIdMap = this.loadEntities(foundIds);
520
- const markersByIdMap = this.loadMarkers(foundIds);
521
- const overlay = loadActionOverlay(this.db);
522
- return rows.map((r) =>
523
- this.rowToSession({ ...r, body: null }, entitiesByIdMap, markersByIdMap, overlay),
524
- );
525
- }
526
-
527
- async semanticSearch(
528
- queryVector: Float32Array,
529
- limit: number,
530
- ): Promise<ReadonlyArray<SemanticNeighbor>> {
531
- const k = Math.max(1, Math.trunc(limit));
532
- const blob = Buffer.from(
533
- queryVector.buffer,
534
- queryVector.byteOffset,
535
- queryVector.byteLength,
536
- );
537
- // Overfetch chunks so the max-pool grouping has enough unique sessions
538
- // even when several top chunks come from the same session. Default 4
539
- // ≈ average chunks per session on the LongMemEval-S benchmark. Env-
540
- // tunable via NLM_CHUNK_OVERFETCH for per-type ablation against the
541
- // preference/assistant regressions where displacement is hypothesized.
542
- const envOverfetch = Number.parseInt(process.env["NLM_CHUNK_OVERFETCH"] ?? "", 10);
543
- const CHUNK_OVERFETCH = Number.isFinite(envOverfetch) && envOverfetch > 0 ? envOverfetch : 4;
544
- const chunkK = k * CHUNK_OVERFETCH;
545
- const rows = this.db
546
- .prepare<[Buffer, number], NeighborRow>(`
547
- SELECT session_id, distance
548
- FROM session_embedding_chunks
549
- WHERE embedding MATCH ?
550
- AND k = ?
551
- ORDER BY distance
552
- `)
553
- .all(blob, chunkK);
554
-
555
- // Max-pool: keep the smallest distance (highest cosine) per session.
556
- const best = new Map<string, number>();
557
- for (const r of rows) {
558
- const cur = best.get(r.session_id);
559
- if (cur === undefined || r.distance < cur) {
560
- best.set(r.session_id, r.distance);
561
- }
562
- }
563
- return [...best.entries()]
564
- .map(([sessionId, distance]) => ({ sessionId, distance }))
565
- .sort((a, b) => a.distance - b.distance)
566
- .slice(0, k);
567
- }
568
-
569
- /**
570
- * Lexical recall via the sessions_fts FTS5 index. BM25 column weights
571
- * favour label over summary over body. Returns sessions ranked best-first
572
- * with a positive score (the negated bm25() value — bm25 is more negative
573
- * for better matches). User input is tokenized and rebuilt into a quoted
574
- * OR query so FTS5 metacharacters cannot reach the MATCH parser.
575
- */
576
- async keywordSearch(
577
- query: string,
578
- limit: number,
579
- ): Promise<ReadonlyArray<KeywordNeighbor>> {
580
- const matchExpr = toMatchExpression(query);
581
- if (!matchExpr) return [];
582
- const k = Math.max(1, Math.trunc(limit));
583
- const rows = this.db
584
- .prepare<[string, number], KeywordRow>(`
585
- SELECT s.id AS session_id,
586
- -bm25(sessions_fts, 10.0, 4.0, 1.0) AS score
587
- FROM sessions_fts
588
- JOIN sessions s ON s.rowid = sessions_fts.rowid
589
- WHERE sessions_fts MATCH ?
590
- ORDER BY score DESC
591
- LIMIT ?
592
- `)
593
- .all(matchExpr, k);
594
- return rows.map((r) => ({ sessionId: r.session_id, score: r.score }));
595
- }
596
-
597
- async updateStatus(sessionId: string, status: SessionStatus): Promise<void> {
598
- if (status === "idle") {
599
- throw new Error("Cannot persist derived status 'idle' — only active/closed/superseded");
600
- }
601
- this.db
602
- .prepare(
603
- "UPDATE sessions SET status = ?, updated_at = datetime('now') WHERE id = ?",
604
- )
605
- .run(status, sessionId);
606
- }
607
-
608
- // ── insert helpers used by tests / future ingest path ─────────────────
609
- insertSessionForTest(session: Session): void {
610
- const stmt = this.db.prepare(`
611
- INSERT INTO sessions (
612
- id, runtime, runtime_session_id, started_at, ended_at, duration_min,
613
- label, summary, body, status, transcript_kind, transcript_path
614
- ) VALUES (
615
- @id, @runtime, @runtimeSessionId, @startedAt, @endedAt, @durationMin,
616
- @label, @summary, @body, @status, @transcriptKind, @transcriptPath
617
- )
618
- `);
619
- const status: SessionStatus = session.status === "idle" ? "active" : session.status;
620
- stmt.run({
621
- id: session.id,
622
- runtime: session.runtime,
623
- runtimeSessionId: session.runtimeSessionId,
624
- startedAt: session.startedAt,
625
- endedAt: session.endedAt,
626
- durationMin: session.durationMin,
627
- label: session.label,
628
- summary: session.summary,
629
- body: session.body,
630
- status,
631
- transcriptKind: session.transcriptKind,
632
- transcriptPath: session.transcriptPath,
633
- });
634
-
635
- const entStmt = this.db.prepare(`
636
- INSERT OR IGNORE INTO entities (canonical, type, status)
637
- VALUES (?, 'candidate', 'active')
638
- `);
639
- const linkStmt = this.db.prepare(
640
- "INSERT OR IGNORE INTO session_entities (session_id, entity_canonical) VALUES (?, ?)",
641
- );
642
- for (const e of session.entities) {
643
- entStmt.run(e);
644
- linkStmt.run(session.id, e);
645
- }
646
-
647
- const markerStmt = this.db.prepare(
648
- "INSERT INTO markers (session_id, kind, text, position) VALUES (?, ?, ?, ?)",
649
- );
650
- session.decisions.forEach((d, i) => markerStmt.run(session.id, "decision", d, i));
651
- session.open.forEach((q, i) => markerStmt.run(session.id, "open", q, i));
652
- }
653
-
654
- insertEdgeForTest(
655
- fromSession: string,
656
- toSession: string,
657
- kind: "supersedes" | "continues" = "supersedes",
658
- ): void {
659
- this.db
660
- .prepare(
661
- "INSERT OR IGNORE INTO session_edges (from_session, to_session, kind) VALUES (?, ?, ?)",
662
- )
663
- .run(fromSession, toSession, kind);
664
- }
665
-
666
- insertEmbeddingForTest(sessionId: string, vector: Float32Array): void {
667
- this.insertChunkEmbeddingForTest(sessionId, 0, vector);
668
- }
669
-
670
- insertChunkEmbeddingForTest(
671
- sessionId: string,
672
- chunkIdx: number,
673
- vector: Float32Array,
674
- ): void {
675
- this.insertChunkEmbedding(sessionId, chunkIdx, vector);
676
- }
677
-
678
- // ── internal ──────────────────────────────────────────────────────────
679
- private loadEntities(ids: ReadonlyArray<string>): Map<string, string[]> {
680
- if (ids.length === 0) return new Map();
681
- const placeholders = ids.map(() => "?").join(",");
682
- const rows = this.db
683
- .prepare<string[], EntityRow>(`
684
- SELECT session_id, entity_canonical
685
- FROM session_entities
686
- WHERE session_id IN (${placeholders})
687
- ORDER BY session_id
688
- `)
689
- .all(...ids);
690
-
691
- const out = new Map<string, string[]>();
692
- for (const r of rows) {
693
- const list = out.get(r.session_id);
694
- if (list) list.push(r.entity_canonical);
695
- else out.set(r.session_id, [r.entity_canonical]);
696
- }
697
- return out;
698
- }
699
-
700
- private loadSessionEdges(
701
- ids: ReadonlyArray<string>,
702
- ): Map<string, { supersededBy: string | null; supersedes: string[] }> {
703
- if (ids.length === 0) return new Map();
704
- const placeholders = ids.map(() => "?").join(",");
705
- const rows = this.db
706
- .prepare<string[], { from_session: string; to_session: string }>(`
707
- SELECT from_session, to_session
708
- FROM session_edges
709
- WHERE kind = 'supersedes'
710
- AND (from_session IN (${placeholders}) OR to_session IN (${placeholders}))
711
- `)
712
- .all(...ids, ...ids);
713
-
714
- const out = new Map<string, { supersededBy: string | null; supersedes: string[] }>();
715
- for (const id of ids) {
716
- out.set(id, { supersededBy: null, supersedes: [] });
717
- }
718
- for (const r of rows) {
719
- const fromEntry = out.get(r.from_session);
720
- if (fromEntry) fromEntry.supersedes.push(r.to_session);
721
- const toEntry = out.get(r.to_session);
722
- if (toEntry) toEntry.supersededBy = r.from_session;
723
- }
724
- return out;
725
- }
726
-
727
- private loadMarkers(
728
- ids: ReadonlyArray<string>,
729
- ): Map<string, { decisions: string[]; open: string[] }> {
730
- if (ids.length === 0) return new Map();
731
- const placeholders = ids.map(() => "?").join(",");
732
- const rows = this.db
733
- .prepare<string[], MarkerRow>(`
734
- SELECT session_id, kind, text
735
- FROM markers
736
- WHERE session_id IN (${placeholders})
737
- ORDER BY session_id, position
738
- `)
739
- .all(...ids);
740
-
741
- const out = new Map<string, { decisions: string[]; open: string[] }>();
742
- for (const r of rows) {
743
- let bucket = out.get(r.session_id);
744
- if (!bucket) {
745
- bucket = { decisions: [], open: [] };
746
- out.set(r.session_id, bucket);
747
- }
748
- if (r.kind === "decision") bucket.decisions.push(r.text);
749
- else bucket.open.push(r.text);
750
- }
751
- return out;
752
- }
753
-
754
- private rowToSession(
755
- row: SessionRow,
756
- entitiesById: Map<string, string[]>,
757
- markersById: Map<string, { decisions: string[]; open: string[] }>,
758
- overlay: ActionOverlay,
759
- edgesById?: Map<string, { supersededBy: string | null; supersedes: string[] }>,
760
- ): Session {
761
- const m = markersById.get(row.id);
762
- const rawDecisions = m?.decisions ?? [];
763
- const rawOpen = m?.open ?? [];
764
- const activeOpen: string[] = [];
765
- const promotedDecisions: string[] = [];
766
- for (const text of rawOpen) {
767
- const id = openQuestionId(row.id, text);
768
- if (overlay.resolvedOpens.has(id)) continue;
769
- const resolution = overlay.promotedOpens.get(id);
770
- if (resolution !== undefined) {
771
- promotedDecisions.push(resolution);
772
- continue;
773
- }
774
- activeOpen.push(text);
775
- }
776
- const edges = edgesById?.get(row.id);
777
- return {
778
- id: row.id,
779
- runtime: row.runtime,
780
- runtimeSessionId: row.runtime_session_id ?? "",
781
- startedAt: row.started_at,
782
- endedAt: row.ended_at,
783
- durationMin: row.duration_min,
784
- label: row.label,
785
- summary: row.summary,
786
- status: liveSessionStatus(row.transcript_path, row.status),
787
- transcriptKind: row.transcript_kind ?? "",
788
- transcriptPath: row.transcript_path,
789
- body: row.body ?? "",
790
- entities: entitiesById.get(row.id) ?? [],
791
- decisions: [...rawDecisions, ...promotedDecisions],
792
- open: activeOpen,
793
- ...(edges !== undefined
794
- ? { supersededBy: edges.supersededBy, supersedes: edges.supersedes }
795
- : {}),
796
- };
797
- }
798
- }
799
-
800
- /**
801
- * Builds a safe FTS5 MATCH expression from raw user input. Each indexable
802
- * token becomes a double-quoted string literal; literals are OR-joined.
803
- * Quoting neutralizes FTS5 operators (AND, OR, NEAR, *, parentheses, colon).
804
- * Returns null when the query has no indexable tokens.
805
- */
806
- function toMatchExpression(query: string): string | null {
807
- const terms = tokenize(query);
808
- if (terms.length === 0) return null;
809
- return terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
810
- }