nlm-memory 0.4.2 → 0.5.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 (285) hide show
  1. package/README.md +72 -34
  2. package/dist/cli/nlm.js +223 -33
  3. package/dist/cli/nlm.js.map +1 -1
  4. package/dist/core/adapters/cursor.d.ts +45 -0
  5. package/dist/core/adapters/cursor.js +397 -0
  6. package/dist/core/adapters/cursor.js.map +1 -0
  7. package/dist/core/adapters/from-source.js +10 -0
  8. package/dist/core/adapters/from-source.js.map +1 -1
  9. package/dist/core/adapters/windsurf.d.ts +44 -0
  10. package/dist/core/adapters/windsurf.js +299 -0
  11. package/dist/core/adapters/windsurf.js.map +1 -0
  12. package/dist/core/hook/claude-settings.d.ts +12 -5
  13. package/dist/core/hook/claude-settings.js +21 -6
  14. package/dist/core/hook/claude-settings.js.map +1 -1
  15. package/dist/core/sources/source-registry.d.ts +1 -1
  16. package/dist/core/sources/source-registry.js +18 -0
  17. package/dist/core/sources/source-registry.js.map +1 -1
  18. package/dist/core/storage/sqlite-session-store.d.ts +2 -0
  19. package/dist/core/storage/sqlite-session-store.js +38 -2
  20. package/dist/core/storage/sqlite-session-store.js.map +1 -1
  21. package/dist/hook/hook-auth.d.ts +13 -0
  22. package/dist/hook/hook-auth.js +19 -0
  23. package/dist/hook/hook-auth.js.map +1 -0
  24. package/dist/hook/prompt-recall-hook.js +7 -1
  25. package/dist/hook/prompt-recall-hook.js.map +1 -1
  26. package/dist/hook/session-start-hook.js +4 -1
  27. package/dist/hook/session-start-hook.js.map +1 -1
  28. package/dist/hook/stop-hook.js +4 -1
  29. package/dist/hook/stop-hook.js.map +1 -1
  30. package/dist/http/app.d.ts +2 -0
  31. package/dist/http/app.js +76 -1
  32. package/dist/http/app.js.map +1 -1
  33. package/dist/install/claude-code.js +1 -1
  34. package/dist/install/claude-code.js.map +1 -1
  35. package/dist/install/cursor.d.ts +25 -0
  36. package/dist/install/cursor.js +43 -0
  37. package/dist/install/cursor.js.map +1 -0
  38. package/dist/install/nlm-dir-perms.d.ts +19 -0
  39. package/dist/install/nlm-dir-perms.js +43 -0
  40. package/dist/install/nlm-dir-perms.js.map +1 -0
  41. package/dist/install/ollama.d.ts +18 -1
  42. package/dist/install/ollama.js +62 -7
  43. package/dist/install/ollama.js.map +1 -1
  44. package/dist/install/setup.d.ts +4 -0
  45. package/dist/install/setup.js +141 -18
  46. package/dist/install/setup.js.map +1 -1
  47. package/dist/install/windsurf.d.ts +25 -0
  48. package/dist/install/windsurf.js +43 -0
  49. package/dist/install/windsurf.js.map +1 -0
  50. package/dist/mcp/server.js +20 -1
  51. package/dist/mcp/server.js.map +1 -1
  52. package/dist/shared/types.d.ts +4 -0
  53. package/dist/ui/assets/{index-BA6IpU8g.css → index-Beo8psd-.css} +1 -1
  54. package/dist/ui/assets/index-CSPTTeeM.js +69 -0
  55. package/dist/ui/index.html +2 -2
  56. package/package.json +26 -1
  57. package/plugin/scripts/prompt-recall-hook.mjs +55 -4
  58. package/plugin/scripts/stop-hook.mjs +57 -6
  59. package/.agents/plugins/marketplace.json +0 -20
  60. package/.github/workflows/ci.yml +0 -30
  61. package/dist/ui/assets/index-B_qIVV0k.js +0 -69
  62. package/docs/methodology/re-derivation-rate.md +0 -112
  63. package/docs/methodology/useful-hit-rate.md +0 -79
  64. package/docs/plans/2026-05-20-fts5-lexical-recall.md +0 -1088
  65. package/docs/plans/2026-05-20-recall-daemon-wedge-fix.md +0 -662
  66. package/docs/plans/2026-05-20-recall-hook-design.md +0 -131
  67. package/docs/plans/2026-05-20-recall-hook-implementation.md +0 -1222
  68. package/docs/plans/desktop-product.md +0 -69
  69. package/docs/plans/factstore-design.md +0 -236
  70. package/logs/CHANGELOG/CHANGELOG-2026.md +0 -1389
  71. package/logs/CHANGELOG/CHANGELOG.md +0 -337
  72. package/migrations/000_initial_schema.sql +0 -174
  73. package/migrations/001_entity_type_rename.sql +0 -17
  74. package/migrations/002_adapter_state_extend.sql +0 -12
  75. package/migrations/003_session_embeddings.sql +0 -11
  76. package/migrations/004_facts.sql +0 -46
  77. package/migrations/005_sources.sql +0 -31
  78. package/migrations/006_providers.sql +0 -33
  79. package/migrations/007_source_tokens.sql +0 -17
  80. package/migrations/008_fts_rebuild.sql +0 -9
  81. package/migrations/009_session_embedding_chunks.sql +0 -46
  82. package/migrations/010_sources_opencode.sql +0 -30
  83. package/migrations/011_sources_hermes_agent.sql +0 -30
  84. package/migrations/012_sources_aider.sql +0 -30
  85. package/migrations/013_adapter_state_failure_count.sql +0 -12
  86. package/plugin-hermes-agent/README.md +0 -49
  87. package/plugin-hermes-agent/__init__.py +0 -75
  88. package/plugin-hermes-agent/plugin.yaml +0 -15
  89. package/scripts/backfill-citations.mjs +0 -0
  90. package/scripts/build-codex-plugin.mjs +0 -61
  91. package/scripts/deepseek-probe.mjs +0 -67
  92. package/scripts/extract-triples.mjs +0 -207
  93. package/scripts/longmemeval/embedding-cache.ts +0 -77
  94. package/scripts/longmemeval/fetch-dataset.sh +0 -25
  95. package/scripts/longmemeval/run-harness.ts +0 -315
  96. package/scripts/longmemeval/scorer.ts +0 -99
  97. package/scripts/longmemeval/tsconfig.json +0 -9
  98. package/scripts/longmemeval/types.ts +0 -35
  99. package/scripts/nlm-daily-digest.py +0 -239
  100. package/scripts/nlm-daily-digest.sh +0 -28
  101. package/src/cli/classify-parity.ts +0 -257
  102. package/src/cli/launchctl-helpers.ts +0 -49
  103. package/src/cli/nlm.ts +0 -885
  104. package/src/core/actions/actions-log.ts +0 -118
  105. package/src/core/actions/overlay.ts +0 -117
  106. package/src/core/adapters/aider.ts +0 -205
  107. package/src/core/adapters/claude-code.ts +0 -293
  108. package/src/core/adapters/common.ts +0 -54
  109. package/src/core/adapters/from-source.ts +0 -57
  110. package/src/core/adapters/hermes-agent.ts +0 -240
  111. package/src/core/adapters/hermes.ts +0 -277
  112. package/src/core/adapters/jsonl-generic.ts +0 -208
  113. package/src/core/adapters/opencode.ts +0 -281
  114. package/src/core/adapters/pi.ts +0 -264
  115. package/src/core/classifier/prompt.ts +0 -200
  116. package/src/core/dataset/build-dataset.ts +0 -463
  117. package/src/core/embedding/chunk-body.ts +0 -76
  118. package/src/core/embedding/embed-backfill.ts +0 -210
  119. package/src/core/embedding/embed-normalize.ts +0 -135
  120. package/src/core/facts/backfill-facts.ts +0 -254
  121. package/src/core/facts/extract-facts.ts +0 -50
  122. package/src/core/hook/citation-detect.ts +0 -124
  123. package/src/core/hook/cite-memo.ts +0 -68
  124. package/src/core/hook/claude-settings.ts +0 -166
  125. package/src/core/hook/gate.ts +0 -25
  126. package/src/core/hook/hook-log.ts +0 -41
  127. package/src/core/hook/memo-sweep.ts +0 -164
  128. package/src/core/hook/memo.ts +0 -67
  129. package/src/core/hook/pointer-block.ts +0 -26
  130. package/src/core/hook/select.ts +0 -32
  131. package/src/core/hook/transcript.ts +0 -121
  132. package/src/core/ingest/ingest-session.ts +0 -111
  133. package/src/core/providers/provider-models.ts +0 -100
  134. package/src/core/providers/provider-registry.ts +0 -196
  135. package/src/core/recall/citation-log.ts +0 -108
  136. package/src/core/recall/filter.ts +0 -27
  137. package/src/core/recall/index.ts +0 -6
  138. package/src/core/recall/match-fields.ts +0 -40
  139. package/src/core/recall/query-log.ts +0 -149
  140. package/src/core/recall/query-shape.ts +0 -66
  141. package/src/core/recall/recall-service.ts +0 -320
  142. package/src/core/recall/recent-log.ts +0 -59
  143. package/src/core/recall/tokenize.ts +0 -18
  144. package/src/core/recall/useful-scan.ts +0 -336
  145. package/src/core/recall-facts/fact-query-log.ts +0 -150
  146. package/src/core/recall-facts/fact-recall-service.ts +0 -327
  147. package/src/core/scheduler/scan-once.ts +0 -142
  148. package/src/core/scheduler/scheduler.ts +0 -225
  149. package/src/core/sources/source-registry.ts +0 -260
  150. package/src/core/storage/db-restore.ts +0 -133
  151. package/src/core/storage/live-status.ts +0 -45
  152. package/src/core/storage/migrate.ts +0 -72
  153. package/src/core/storage/sqlite-fact-store.ts +0 -304
  154. package/src/core/storage/sqlite-session-store.ts +0 -765
  155. package/src/hook/prompt-recall-hook.ts +0 -174
  156. package/src/hook/session-end-hook.ts +0 -81
  157. package/src/hook/session-start-hook.ts +0 -165
  158. package/src/hook/stop-hook.ts +0 -236
  159. package/src/http/app.ts +0 -1137
  160. package/src/install/claude-code.ts +0 -128
  161. package/src/install/codex.ts +0 -367
  162. package/src/install/hermes-agent.ts +0 -76
  163. package/src/install/hermes.ts +0 -78
  164. package/src/install/ollama.ts +0 -211
  165. package/src/install/setup.ts +0 -368
  166. package/src/llm/classifier-box.ts +0 -64
  167. package/src/llm/deepseek-client.ts +0 -150
  168. package/src/llm/env-autoload.ts +0 -55
  169. package/src/llm/ollama-client.ts +0 -189
  170. package/src/mcp/server.ts +0 -534
  171. package/src/ports/fact-store.ts +0 -102
  172. package/src/ports/llm-client.ts +0 -52
  173. package/src/ports/logger.ts +0 -16
  174. package/src/ports/session-store.ts +0 -45
  175. package/src/ports/transcript-adapter.ts +0 -55
  176. package/src/shared/types.ts +0 -145
  177. package/src/ui/App.tsx +0 -58
  178. package/src/ui/components/PromoteOpenButton.tsx +0 -65
  179. package/src/ui/components/SessionDrawer.tsx +0 -136
  180. package/src/ui/components/SideNav.tsx +0 -162
  181. package/src/ui/components/Skeleton.tsx +0 -107
  182. package/src/ui/index.html +0 -13
  183. package/src/ui/lib/actions.ts +0 -30
  184. package/src/ui/lib/api.ts +0 -92
  185. package/src/ui/lib/dataset.ts +0 -141
  186. package/src/ui/lib/registries.ts +0 -155
  187. package/src/ui/lib/view-settings.ts +0 -41
  188. package/src/ui/main.tsx +0 -15
  189. package/src/ui/pages/Live.tsx +0 -229
  190. package/src/ui/pages/Pulse.tsx +0 -415
  191. package/src/ui/pages/Recall.tsx +0 -190
  192. package/src/ui/pages/River.tsx +0 -308
  193. package/src/ui/pages/Search.tsx +0 -93
  194. package/src/ui/pages/Stub.tsx +0 -9
  195. package/src/ui/pages/Thread.tsx +0 -262
  196. package/src/ui/pages/settings/Classifier.tsx +0 -227
  197. package/src/ui/pages/settings/Data.tsx +0 -190
  198. package/src/ui/pages/settings/Index.tsx +0 -65
  199. package/src/ui/pages/settings/Labels.tsx +0 -224
  200. package/src/ui/pages/settings/Providers.tsx +0 -305
  201. package/src/ui/pages/settings/SettingsSubnav.tsx +0 -28
  202. package/src/ui/pages/settings/Sources.tsx +0 -326
  203. package/src/ui/pages/settings/Views.tsx +0 -96
  204. package/src/ui/styles.css +0 -1766
  205. package/src/ui/tsconfig.json +0 -21
  206. package/src/ui/vite.config.ts +0 -19
  207. package/tests/fixtures/claude_code/short_session.jsonl +0 -2
  208. package/tests/fixtures/claude_code/standard_iso.jsonl +0 -4
  209. package/tests/fixtures/claude_code/tool_heavy.jsonl +0 -8
  210. package/tests/fixtures/claude_code/with_subagent.jsonl +0 -7
  211. package/tests/fixtures/facts.ts +0 -17
  212. package/tests/fixtures/golden-corpus.ts +0 -85
  213. package/tests/fixtures/hermes/paired_request_dump.json +0 -24
  214. package/tests/fixtures/hermes/paired_session.json +0 -23
  215. package/tests/fixtures/hermes/request_dump.json +0 -28
  216. package/tests/fixtures/hermes/session_iso.json +0 -38
  217. package/tests/fixtures/hermes/session_unix.json +0 -38
  218. package/tests/fixtures/hermes/system_only.json +0 -18
  219. package/tests/fixtures/pi/error-connection-abort.jsonl +0 -8
  220. package/tests/fixtures/pi/short-successful.jsonl +0 -5
  221. package/tests/fixtures/pi/with-custom-message.jsonl +0 -6
  222. package/tests/fixtures/sessions.ts +0 -22
  223. package/tests/integration/backfill-facts.test.ts +0 -362
  224. package/tests/integration/citation-explicit.test.ts +0 -111
  225. package/tests/integration/cite-event.test.ts +0 -169
  226. package/tests/integration/cite-memo.test.ts +0 -87
  227. package/tests/integration/db-restore.test.ts +0 -153
  228. package/tests/integration/embed-backfill.test.ts +0 -176
  229. package/tests/integration/fact-supersedence.test.ts +0 -313
  230. package/tests/integration/fts-index.test.ts +0 -60
  231. package/tests/integration/getbyids-sqlite.test.ts +0 -60
  232. package/tests/integration/hermes-agent-hooks.test.ts +0 -248
  233. package/tests/integration/hook-claude-settings.test.ts +0 -205
  234. package/tests/integration/hook-log.test.ts +0 -54
  235. package/tests/integration/hook-memo.test.ts +0 -68
  236. package/tests/integration/hook-pre-compact.test.ts +0 -105
  237. package/tests/integration/hook-subagent-start.test.ts +0 -102
  238. package/tests/integration/http.test.ts +0 -401
  239. package/tests/integration/keyword-search-fts.test.ts +0 -66
  240. package/tests/integration/mcp-recall-logging.test.ts +0 -88
  241. package/tests/integration/mcp.test.ts +0 -248
  242. package/tests/integration/memo-sweep.test.ts +0 -91
  243. package/tests/integration/prompt-recall-hook.test.ts +0 -88
  244. package/tests/integration/provider-registry.test.ts +0 -107
  245. package/tests/integration/recall-golden.test.ts +0 -59
  246. package/tests/integration/recall-sqlite.test.ts +0 -169
  247. package/tests/integration/scheduler.test.ts +0 -391
  248. package/tests/integration/session-end-hook.test.ts +0 -48
  249. package/tests/integration/session-start-hook.test.ts +0 -126
  250. package/tests/integration/source-registry.test.ts +0 -120
  251. package/tests/integration/sqlite-fact-store.test.ts +0 -346
  252. package/tests/integration/stop-hook.test.ts +0 -560
  253. package/tests/integration/wal-checkpoint.test.ts +0 -49
  254. package/tests/unit/cli/launchctl-helpers.test.ts +0 -60
  255. package/tests/unit/core/adapters/aider.test.ts +0 -230
  256. package/tests/unit/core/adapters/claude-code.test.ts +0 -118
  257. package/tests/unit/core/adapters/hermes-agent.test.ts +0 -329
  258. package/tests/unit/core/adapters/hermes.test.ts +0 -81
  259. package/tests/unit/core/adapters/jsonl-generic.test.ts +0 -142
  260. package/tests/unit/core/adapters/opencode.test.ts +0 -354
  261. package/tests/unit/core/adapters/pi.test.ts +0 -110
  262. package/tests/unit/core/classifier/prompt.test.ts +0 -126
  263. package/tests/unit/core/embedding/chunk-body.test.ts +0 -100
  264. package/tests/unit/core/facts/extract-facts.test.ts +0 -117
  265. package/tests/unit/core/filter.test.ts +0 -40
  266. package/tests/unit/core/hook/citation-detect-cite-session.test.ts +0 -96
  267. package/tests/unit/core/hook/citation-detect.test.ts +0 -124
  268. package/tests/unit/core/hook/gate.test.ts +0 -29
  269. package/tests/unit/core/hook/pointer-block.test.ts +0 -22
  270. package/tests/unit/core/hook/select.test.ts +0 -66
  271. package/tests/unit/core/match-fields.test.ts +0 -39
  272. package/tests/unit/core/mcp-cite-session.test.ts +0 -51
  273. package/tests/unit/core/providers/provider-models.test.ts +0 -101
  274. package/tests/unit/core/query-shape.test.ts +0 -92
  275. package/tests/unit/core/recall-facts/fact-recall-service.test.ts +0 -258
  276. package/tests/unit/core/recall-service.test.ts +0 -200
  277. package/tests/unit/core/storage/live-status.test.ts +0 -54
  278. package/tests/unit/core/tokenize.test.ts +0 -32
  279. package/tests/unit/core/useful-scan.test.ts +0 -537
  280. package/tests/unit/llm/embed.test.ts +0 -93
  281. package/tests/unit/llm/ollama-client.test.ts +0 -124
  282. package/tests/unit/scripts/longmemeval-scorer.test.ts +0 -114
  283. package/tsconfig.json +0 -31
  284. package/tsconfig.test.json +0 -11
  285. package/vitest.config.ts +0 -22
@@ -1,765 +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 overlay = loadActionOverlay(this.db);
495
- return this.rowToSession(row, entities, markers, overlay);
496
- }
497
-
498
- /**
499
- * Batched session fetch for the recall path. Deliberately omits the
500
- * `body` column — body is ~48KB/row of session markdown that recall
501
- * never reads, and SELECTing it for the corpus is what wedged the
502
- * daemon. Resolved sessions carry `body: ""`.
503
- */
504
- async getByIds(ids: ReadonlyArray<string>): Promise<ReadonlyArray<Session>> {
505
- if (ids.length === 0) return [];
506
- const placeholders = ids.map(() => "?").join(",");
507
- const rows = this.db
508
- .prepare<string[], Omit<SessionRow, "body">>(`
509
- SELECT id, runtime, runtime_session_id, started_at, ended_at, duration_min,
510
- label, summary, status, transcript_kind, transcript_path
511
- FROM sessions
512
- WHERE id IN (${placeholders})
513
- `)
514
- .all(...ids);
515
-
516
- if (rows.length === 0) return [];
517
- const foundIds = rows.map((r) => r.id);
518
- const entitiesByIdMap = this.loadEntities(foundIds);
519
- const markersByIdMap = this.loadMarkers(foundIds);
520
- const overlay = loadActionOverlay(this.db);
521
- return rows.map((r) =>
522
- this.rowToSession({ ...r, body: null }, entitiesByIdMap, markersByIdMap, overlay),
523
- );
524
- }
525
-
526
- async semanticSearch(
527
- queryVector: Float32Array,
528
- limit: number,
529
- ): Promise<ReadonlyArray<SemanticNeighbor>> {
530
- const k = Math.max(1, Math.trunc(limit));
531
- const blob = Buffer.from(
532
- queryVector.buffer,
533
- queryVector.byteOffset,
534
- queryVector.byteLength,
535
- );
536
- // Overfetch chunks so the max-pool grouping has enough unique sessions
537
- // even when several top chunks come from the same session. Default 4
538
- // ≈ average chunks per session on the LongMemEval-S benchmark. Env-
539
- // tunable via NLM_CHUNK_OVERFETCH for per-type ablation against the
540
- // preference/assistant regressions where displacement is hypothesized.
541
- const envOverfetch = Number.parseInt(process.env["NLM_CHUNK_OVERFETCH"] ?? "", 10);
542
- const CHUNK_OVERFETCH = Number.isFinite(envOverfetch) && envOverfetch > 0 ? envOverfetch : 4;
543
- const chunkK = k * CHUNK_OVERFETCH;
544
- const rows = this.db
545
- .prepare<[Buffer, number], NeighborRow>(`
546
- SELECT session_id, distance
547
- FROM session_embedding_chunks
548
- WHERE embedding MATCH ?
549
- AND k = ?
550
- ORDER BY distance
551
- `)
552
- .all(blob, chunkK);
553
-
554
- // Max-pool: keep the smallest distance (highest cosine) per session.
555
- const best = new Map<string, number>();
556
- for (const r of rows) {
557
- const cur = best.get(r.session_id);
558
- if (cur === undefined || r.distance < cur) {
559
- best.set(r.session_id, r.distance);
560
- }
561
- }
562
- return [...best.entries()]
563
- .map(([sessionId, distance]) => ({ sessionId, distance }))
564
- .sort((a, b) => a.distance - b.distance)
565
- .slice(0, k);
566
- }
567
-
568
- /**
569
- * Lexical recall via the sessions_fts FTS5 index. BM25 column weights
570
- * favour label over summary over body. Returns sessions ranked best-first
571
- * with a positive score (the negated bm25() value — bm25 is more negative
572
- * for better matches). User input is tokenized and rebuilt into a quoted
573
- * OR query so FTS5 metacharacters cannot reach the MATCH parser.
574
- */
575
- async keywordSearch(
576
- query: string,
577
- limit: number,
578
- ): Promise<ReadonlyArray<KeywordNeighbor>> {
579
- const matchExpr = toMatchExpression(query);
580
- if (!matchExpr) return [];
581
- const k = Math.max(1, Math.trunc(limit));
582
- const rows = this.db
583
- .prepare<[string, number], KeywordRow>(`
584
- SELECT s.id AS session_id,
585
- -bm25(sessions_fts, 10.0, 4.0, 1.0) AS score
586
- FROM sessions_fts
587
- JOIN sessions s ON s.rowid = sessions_fts.rowid
588
- WHERE sessions_fts MATCH ?
589
- ORDER BY score DESC
590
- LIMIT ?
591
- `)
592
- .all(matchExpr, k);
593
- return rows.map((r) => ({ sessionId: r.session_id, score: r.score }));
594
- }
595
-
596
- async updateStatus(sessionId: string, status: SessionStatus): Promise<void> {
597
- if (status === "idle") {
598
- throw new Error("Cannot persist derived status 'idle' — only active/closed/superseded");
599
- }
600
- this.db
601
- .prepare(
602
- "UPDATE sessions SET status = ?, updated_at = datetime('now') WHERE id = ?",
603
- )
604
- .run(status, sessionId);
605
- }
606
-
607
- // ── insert helpers used by tests / future ingest path ─────────────────
608
- insertSessionForTest(session: Session): void {
609
- const stmt = this.db.prepare(`
610
- INSERT INTO sessions (
611
- id, runtime, runtime_session_id, started_at, ended_at, duration_min,
612
- label, summary, body, status, transcript_kind, transcript_path
613
- ) VALUES (
614
- @id, @runtime, @runtimeSessionId, @startedAt, @endedAt, @durationMin,
615
- @label, @summary, @body, @status, @transcriptKind, @transcriptPath
616
- )
617
- `);
618
- const status: SessionStatus = session.status === "idle" ? "active" : session.status;
619
- stmt.run({
620
- id: session.id,
621
- runtime: session.runtime,
622
- runtimeSessionId: session.runtimeSessionId,
623
- startedAt: session.startedAt,
624
- endedAt: session.endedAt,
625
- durationMin: session.durationMin,
626
- label: session.label,
627
- summary: session.summary,
628
- body: session.body,
629
- status,
630
- transcriptKind: session.transcriptKind,
631
- transcriptPath: session.transcriptPath,
632
- });
633
-
634
- const entStmt = this.db.prepare(`
635
- INSERT OR IGNORE INTO entities (canonical, type, status)
636
- VALUES (?, 'candidate', 'active')
637
- `);
638
- const linkStmt = this.db.prepare(
639
- "INSERT OR IGNORE INTO session_entities (session_id, entity_canonical) VALUES (?, ?)",
640
- );
641
- for (const e of session.entities) {
642
- entStmt.run(e);
643
- linkStmt.run(session.id, e);
644
- }
645
-
646
- const markerStmt = this.db.prepare(
647
- "INSERT INTO markers (session_id, kind, text, position) VALUES (?, ?, ?, ?)",
648
- );
649
- session.decisions.forEach((d, i) => markerStmt.run(session.id, "decision", d, i));
650
- session.open.forEach((q, i) => markerStmt.run(session.id, "open", q, i));
651
- }
652
-
653
- insertEmbeddingForTest(sessionId: string, vector: Float32Array): void {
654
- this.insertChunkEmbeddingForTest(sessionId, 0, vector);
655
- }
656
-
657
- insertChunkEmbeddingForTest(
658
- sessionId: string,
659
- chunkIdx: number,
660
- vector: Float32Array,
661
- ): void {
662
- this.insertChunkEmbedding(sessionId, chunkIdx, vector);
663
- }
664
-
665
- // ── internal ──────────────────────────────────────────────────────────
666
- private loadEntities(ids: ReadonlyArray<string>): Map<string, string[]> {
667
- if (ids.length === 0) return new Map();
668
- const placeholders = ids.map(() => "?").join(",");
669
- const rows = this.db
670
- .prepare<string[], EntityRow>(`
671
- SELECT session_id, entity_canonical
672
- FROM session_entities
673
- WHERE session_id IN (${placeholders})
674
- ORDER BY session_id
675
- `)
676
- .all(...ids);
677
-
678
- const out = new Map<string, string[]>();
679
- for (const r of rows) {
680
- const list = out.get(r.session_id);
681
- if (list) list.push(r.entity_canonical);
682
- else out.set(r.session_id, [r.entity_canonical]);
683
- }
684
- return out;
685
- }
686
-
687
- private loadMarkers(
688
- ids: ReadonlyArray<string>,
689
- ): Map<string, { decisions: string[]; open: string[] }> {
690
- if (ids.length === 0) return new Map();
691
- const placeholders = ids.map(() => "?").join(",");
692
- const rows = this.db
693
- .prepare<string[], MarkerRow>(`
694
- SELECT session_id, kind, text
695
- FROM markers
696
- WHERE session_id IN (${placeholders})
697
- ORDER BY session_id, position
698
- `)
699
- .all(...ids);
700
-
701
- const out = new Map<string, { decisions: string[]; open: string[] }>();
702
- for (const r of rows) {
703
- let bucket = out.get(r.session_id);
704
- if (!bucket) {
705
- bucket = { decisions: [], open: [] };
706
- out.set(r.session_id, bucket);
707
- }
708
- if (r.kind === "decision") bucket.decisions.push(r.text);
709
- else bucket.open.push(r.text);
710
- }
711
- return out;
712
- }
713
-
714
- private rowToSession(
715
- row: SessionRow,
716
- entitiesById: Map<string, string[]>,
717
- markersById: Map<string, { decisions: string[]; open: string[] }>,
718
- overlay: ActionOverlay,
719
- ): Session {
720
- const m = markersById.get(row.id);
721
- const rawDecisions = m?.decisions ?? [];
722
- const rawOpen = m?.open ?? [];
723
- const activeOpen: string[] = [];
724
- const promotedDecisions: string[] = [];
725
- for (const text of rawOpen) {
726
- const id = openQuestionId(row.id, text);
727
- if (overlay.resolvedOpens.has(id)) continue;
728
- const resolution = overlay.promotedOpens.get(id);
729
- if (resolution !== undefined) {
730
- promotedDecisions.push(resolution);
731
- continue;
732
- }
733
- activeOpen.push(text);
734
- }
735
- return {
736
- id: row.id,
737
- runtime: row.runtime,
738
- runtimeSessionId: row.runtime_session_id ?? "",
739
- startedAt: row.started_at,
740
- endedAt: row.ended_at,
741
- durationMin: row.duration_min,
742
- label: row.label,
743
- summary: row.summary,
744
- status: liveSessionStatus(row.transcript_path, row.status),
745
- transcriptKind: row.transcript_kind ?? "",
746
- transcriptPath: row.transcript_path,
747
- body: row.body ?? "",
748
- entities: entitiesById.get(row.id) ?? [],
749
- decisions: [...rawDecisions, ...promotedDecisions],
750
- open: activeOpen,
751
- };
752
- }
753
- }
754
-
755
- /**
756
- * Builds a safe FTS5 MATCH expression from raw user input. Each indexable
757
- * token becomes a double-quoted string literal; literals are OR-joined.
758
- * Quoting neutralizes FTS5 operators (AND, OR, NEAR, *, parentheses, colon).
759
- * Returns null when the query has no indexable tokens.
760
- */
761
- function toMatchExpression(query: string): string | null {
762
- const terms = tokenize(query);
763
- if (terms.length === 0) return null;
764
- return terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
765
- }