nlm-memory 0.5.0 → 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 (247) hide show
  1. package/README.md +72 -34
  2. package/dist/cli/nlm.js +2 -1
  3. package/dist/cli/nlm.js.map +1 -1
  4. package/dist/http/app.js +2 -1
  5. package/dist/http/app.js.map +1 -1
  6. package/dist/mcp/server.js +20 -1
  7. package/dist/mcp/server.js.map +1 -1
  8. package/dist/ui/assets/{index-C8cpwbYJ.css → index-Beo8psd-.css} +1 -1
  9. package/dist/ui/assets/{index-CB50QnL-.js → index-CSPTTeeM.js} +8 -8
  10. package/dist/ui/index.html +2 -2
  11. package/package.json +26 -1
  12. package/.agents/plugins/marketplace.json +0 -20
  13. package/.github/workflows/ci.yml +0 -30
  14. package/docs/methodology/re-derivation-rate.md +0 -112
  15. package/docs/methodology/useful-hit-rate.md +0 -79
  16. package/docs/plans/2026-05-20-fts5-lexical-recall.md +0 -1088
  17. package/docs/plans/2026-05-20-recall-daemon-wedge-fix.md +0 -662
  18. package/docs/plans/2026-05-20-recall-hook-design.md +0 -131
  19. package/docs/plans/2026-05-20-recall-hook-implementation.md +0 -1222
  20. package/docs/plans/desktop-product.md +0 -69
  21. package/docs/plans/factstore-design.md +0 -236
  22. package/logs/CHANGELOG/CHANGELOG-2026.md +0 -1575
  23. package/logs/CHANGELOG/CHANGELOG.md +0 -209
  24. package/migrations/000_initial_schema.sql +0 -174
  25. package/migrations/001_entity_type_rename.sql +0 -17
  26. package/migrations/002_adapter_state_extend.sql +0 -12
  27. package/migrations/003_session_embeddings.sql +0 -11
  28. package/migrations/004_facts.sql +0 -46
  29. package/migrations/005_sources.sql +0 -31
  30. package/migrations/006_providers.sql +0 -33
  31. package/migrations/007_source_tokens.sql +0 -17
  32. package/migrations/008_fts_rebuild.sql +0 -9
  33. package/migrations/009_session_embedding_chunks.sql +0 -46
  34. package/migrations/010_sources_opencode.sql +0 -30
  35. package/migrations/011_sources_hermes_agent.sql +0 -30
  36. package/migrations/012_sources_aider.sql +0 -30
  37. package/migrations/013_adapter_state_failure_count.sql +0 -12
  38. package/migrations/014_sources_cursor.sql +0 -30
  39. package/migrations/015_sources_windsurf.sql +0 -30
  40. package/plugin-hermes-agent/README.md +0 -49
  41. package/plugin-hermes-agent/__init__.py +0 -75
  42. package/plugin-hermes-agent/plugin.yaml +0 -15
  43. package/scripts/backfill-citations.mjs +0 -0
  44. package/scripts/build-codex-plugin.mjs +0 -61
  45. package/scripts/deepseek-probe.mjs +0 -67
  46. package/scripts/extract-triples.mjs +0 -207
  47. package/scripts/longmemeval/embedding-cache.ts +0 -77
  48. package/scripts/longmemeval/fetch-dataset.sh +0 -25
  49. package/scripts/longmemeval/run-harness.ts +0 -315
  50. package/scripts/longmemeval/scorer.ts +0 -99
  51. package/scripts/longmemeval/tsconfig.json +0 -9
  52. package/scripts/longmemeval/types.ts +0 -35
  53. package/scripts/nlm-daily-digest.py +0 -239
  54. package/scripts/nlm-daily-digest.sh +0 -28
  55. package/src/cli/classify-parity.ts +0 -257
  56. package/src/cli/launchctl-helpers.ts +0 -49
  57. package/src/cli/nlm.ts +0 -1078
  58. package/src/core/actions/actions-log.ts +0 -118
  59. package/src/core/actions/overlay.ts +0 -117
  60. package/src/core/adapters/aider.ts +0 -205
  61. package/src/core/adapters/claude-code.ts +0 -293
  62. package/src/core/adapters/common.ts +0 -54
  63. package/src/core/adapters/cursor.ts +0 -486
  64. package/src/core/adapters/from-source.ts +0 -67
  65. package/src/core/adapters/hermes-agent.ts +0 -240
  66. package/src/core/adapters/hermes.ts +0 -277
  67. package/src/core/adapters/jsonl-generic.ts +0 -208
  68. package/src/core/adapters/opencode.ts +0 -281
  69. package/src/core/adapters/pi.ts +0 -264
  70. package/src/core/adapters/windsurf.ts +0 -386
  71. package/src/core/classifier/prompt.ts +0 -200
  72. package/src/core/dataset/build-dataset.ts +0 -463
  73. package/src/core/embedding/chunk-body.ts +0 -76
  74. package/src/core/embedding/embed-backfill.ts +0 -210
  75. package/src/core/embedding/embed-normalize.ts +0 -135
  76. package/src/core/facts/backfill-facts.ts +0 -254
  77. package/src/core/facts/extract-facts.ts +0 -50
  78. package/src/core/hook/citation-detect.ts +0 -124
  79. package/src/core/hook/cite-memo.ts +0 -68
  80. package/src/core/hook/claude-settings.ts +0 -187
  81. package/src/core/hook/gate.ts +0 -25
  82. package/src/core/hook/hook-log.ts +0 -41
  83. package/src/core/hook/memo-sweep.ts +0 -164
  84. package/src/core/hook/memo.ts +0 -67
  85. package/src/core/hook/pointer-block.ts +0 -26
  86. package/src/core/hook/select.ts +0 -32
  87. package/src/core/hook/transcript.ts +0 -121
  88. package/src/core/ingest/ingest-session.ts +0 -111
  89. package/src/core/providers/provider-models.ts +0 -100
  90. package/src/core/providers/provider-registry.ts +0 -196
  91. package/src/core/recall/citation-log.ts +0 -108
  92. package/src/core/recall/filter.ts +0 -27
  93. package/src/core/recall/index.ts +0 -6
  94. package/src/core/recall/match-fields.ts +0 -40
  95. package/src/core/recall/query-log.ts +0 -149
  96. package/src/core/recall/query-shape.ts +0 -66
  97. package/src/core/recall/recall-service.ts +0 -320
  98. package/src/core/recall/recent-log.ts +0 -59
  99. package/src/core/recall/tokenize.ts +0 -18
  100. package/src/core/recall/useful-scan.ts +0 -336
  101. package/src/core/recall-facts/fact-query-log.ts +0 -150
  102. package/src/core/recall-facts/fact-recall-service.ts +0 -327
  103. package/src/core/scheduler/scan-once.ts +0 -142
  104. package/src/core/scheduler/scheduler.ts +0 -225
  105. package/src/core/sources/source-registry.ts +0 -278
  106. package/src/core/storage/db-restore.ts +0 -133
  107. package/src/core/storage/live-status.ts +0 -45
  108. package/src/core/storage/migrate.ts +0 -72
  109. package/src/core/storage/sqlite-fact-store.ts +0 -304
  110. package/src/core/storage/sqlite-session-store.ts +0 -810
  111. package/src/hook/hook-auth.ts +0 -18
  112. package/src/hook/prompt-recall-hook.ts +0 -180
  113. package/src/hook/session-end-hook.ts +0 -81
  114. package/src/hook/session-start-hook.ts +0 -168
  115. package/src/hook/stop-hook.ts +0 -239
  116. package/src/http/app.ts +0 -1215
  117. package/src/install/claude-code.ts +0 -128
  118. package/src/install/codex.ts +0 -367
  119. package/src/install/cursor.ts +0 -68
  120. package/src/install/hermes-agent.ts +0 -76
  121. package/src/install/hermes.ts +0 -78
  122. package/src/install/nlm-dir-perms.ts +0 -55
  123. package/src/install/ollama.ts +0 -284
  124. package/src/install/setup.ts +0 -489
  125. package/src/install/windsurf.ts +0 -68
  126. package/src/llm/classifier-box.ts +0 -64
  127. package/src/llm/deepseek-client.ts +0 -150
  128. package/src/llm/env-autoload.ts +0 -55
  129. package/src/llm/ollama-client.ts +0 -189
  130. package/src/mcp/server.ts +0 -534
  131. package/src/ports/fact-store.ts +0 -102
  132. package/src/ports/llm-client.ts +0 -52
  133. package/src/ports/logger.ts +0 -16
  134. package/src/ports/session-store.ts +0 -45
  135. package/src/ports/transcript-adapter.ts +0 -55
  136. package/src/shared/types.ts +0 -149
  137. package/src/ui/App.tsx +0 -58
  138. package/src/ui/components/PromoteOpenButton.tsx +0 -65
  139. package/src/ui/components/SessionDrawer.tsx +0 -199
  140. package/src/ui/components/SideNav.tsx +0 -162
  141. package/src/ui/components/Skeleton.tsx +0 -107
  142. package/src/ui/index.html +0 -13
  143. package/src/ui/lib/actions.ts +0 -30
  144. package/src/ui/lib/api.ts +0 -92
  145. package/src/ui/lib/dataset.ts +0 -141
  146. package/src/ui/lib/registries.ts +0 -155
  147. package/src/ui/lib/view-settings.ts +0 -41
  148. package/src/ui/main.tsx +0 -15
  149. package/src/ui/pages/Live.tsx +0 -229
  150. package/src/ui/pages/Pulse.tsx +0 -415
  151. package/src/ui/pages/Recall.tsx +0 -190
  152. package/src/ui/pages/River.tsx +0 -354
  153. package/src/ui/pages/Search.tsx +0 -386
  154. package/src/ui/pages/Stub.tsx +0 -9
  155. package/src/ui/pages/Thread.tsx +0 -473
  156. package/src/ui/pages/settings/Classifier.tsx +0 -227
  157. package/src/ui/pages/settings/Data.tsx +0 -190
  158. package/src/ui/pages/settings/Index.tsx +0 -65
  159. package/src/ui/pages/settings/Labels.tsx +0 -224
  160. package/src/ui/pages/settings/Providers.tsx +0 -305
  161. package/src/ui/pages/settings/SettingsSubnav.tsx +0 -28
  162. package/src/ui/pages/settings/Sources.tsx +0 -326
  163. package/src/ui/pages/settings/Views.tsx +0 -96
  164. package/src/ui/styles.css +0 -1890
  165. package/src/ui/tsconfig.json +0 -21
  166. package/src/ui/vite.config.ts +0 -19
  167. package/tests/fixtures/claude_code/short_session.jsonl +0 -2
  168. package/tests/fixtures/claude_code/standard_iso.jsonl +0 -4
  169. package/tests/fixtures/claude_code/tool_heavy.jsonl +0 -8
  170. package/tests/fixtures/claude_code/with_subagent.jsonl +0 -7
  171. package/tests/fixtures/facts.ts +0 -17
  172. package/tests/fixtures/golden-corpus.ts +0 -85
  173. package/tests/fixtures/hermes/paired_request_dump.json +0 -24
  174. package/tests/fixtures/hermes/paired_session.json +0 -23
  175. package/tests/fixtures/hermes/request_dump.json +0 -28
  176. package/tests/fixtures/hermes/session_iso.json +0 -38
  177. package/tests/fixtures/hermes/session_unix.json +0 -38
  178. package/tests/fixtures/hermes/system_only.json +0 -18
  179. package/tests/fixtures/pi/error-connection-abort.jsonl +0 -8
  180. package/tests/fixtures/pi/short-successful.jsonl +0 -5
  181. package/tests/fixtures/pi/with-custom-message.jsonl +0 -6
  182. package/tests/fixtures/sessions.ts +0 -22
  183. package/tests/integration/backfill-facts.test.ts +0 -362
  184. package/tests/integration/citation-explicit.test.ts +0 -111
  185. package/tests/integration/cite-event.test.ts +0 -169
  186. package/tests/integration/cite-memo.test.ts +0 -87
  187. package/tests/integration/db-restore.test.ts +0 -153
  188. package/tests/integration/embed-backfill.test.ts +0 -176
  189. package/tests/integration/fact-supersedence.test.ts +0 -313
  190. package/tests/integration/fts-index.test.ts +0 -60
  191. package/tests/integration/getbyids-sqlite.test.ts +0 -100
  192. package/tests/integration/hermes-agent-hooks.test.ts +0 -248
  193. package/tests/integration/hook-claude-settings.test.ts +0 -218
  194. package/tests/integration/hook-log.test.ts +0 -54
  195. package/tests/integration/hook-memo.test.ts +0 -68
  196. package/tests/integration/hook-pre-compact.test.ts +0 -105
  197. package/tests/integration/hook-subagent-start.test.ts +0 -102
  198. package/tests/integration/http.test.ts +0 -401
  199. package/tests/integration/keyword-search-fts.test.ts +0 -66
  200. package/tests/integration/mcp-recall-logging.test.ts +0 -88
  201. package/tests/integration/mcp.test.ts +0 -260
  202. package/tests/integration/memo-sweep.test.ts +0 -91
  203. package/tests/integration/prompt-recall-hook.test.ts +0 -88
  204. package/tests/integration/provider-registry.test.ts +0 -107
  205. package/tests/integration/recall-golden.test.ts +0 -59
  206. package/tests/integration/recall-sqlite.test.ts +0 -169
  207. package/tests/integration/scheduler.test.ts +0 -391
  208. package/tests/integration/session-end-hook.test.ts +0 -48
  209. package/tests/integration/session-start-hook.test.ts +0 -126
  210. package/tests/integration/source-registry.test.ts +0 -122
  211. package/tests/integration/sqlite-fact-store.test.ts +0 -346
  212. package/tests/integration/stop-hook.test.ts +0 -560
  213. package/tests/integration/wal-checkpoint.test.ts +0 -49
  214. package/tests/unit/cli/launchctl-helpers.test.ts +0 -60
  215. package/tests/unit/core/adapters/aider.test.ts +0 -230
  216. package/tests/unit/core/adapters/claude-code.test.ts +0 -118
  217. package/tests/unit/core/adapters/cursor.test.ts +0 -485
  218. package/tests/unit/core/adapters/hermes-agent.test.ts +0 -329
  219. package/tests/unit/core/adapters/hermes.test.ts +0 -81
  220. package/tests/unit/core/adapters/jsonl-generic.test.ts +0 -142
  221. package/tests/unit/core/adapters/opencode.test.ts +0 -354
  222. package/tests/unit/core/adapters/pi.test.ts +0 -110
  223. package/tests/unit/core/adapters/windsurf.test.ts +0 -416
  224. package/tests/unit/core/classifier/prompt.test.ts +0 -126
  225. package/tests/unit/core/embedding/chunk-body.test.ts +0 -100
  226. package/tests/unit/core/facts/extract-facts.test.ts +0 -117
  227. package/tests/unit/core/filter.test.ts +0 -40
  228. package/tests/unit/core/hook/citation-detect-cite-session.test.ts +0 -96
  229. package/tests/unit/core/hook/citation-detect.test.ts +0 -124
  230. package/tests/unit/core/hook/gate.test.ts +0 -29
  231. package/tests/unit/core/hook/pointer-block.test.ts +0 -22
  232. package/tests/unit/core/hook/select.test.ts +0 -66
  233. package/tests/unit/core/match-fields.test.ts +0 -39
  234. package/tests/unit/core/mcp-cite-session.test.ts +0 -51
  235. package/tests/unit/core/providers/provider-models.test.ts +0 -101
  236. package/tests/unit/core/query-shape.test.ts +0 -92
  237. package/tests/unit/core/recall-facts/fact-recall-service.test.ts +0 -258
  238. package/tests/unit/core/recall-service.test.ts +0 -200
  239. package/tests/unit/core/storage/live-status.test.ts +0 -54
  240. package/tests/unit/core/tokenize.test.ts +0 -32
  241. package/tests/unit/core/useful-scan.test.ts +0 -537
  242. package/tests/unit/llm/embed.test.ts +0 -93
  243. package/tests/unit/llm/ollama-client.test.ts +0 -124
  244. package/tests/unit/scripts/longmemeval-scorer.test.ts +0 -114
  245. package/tsconfig.json +0 -31
  246. package/tsconfig.test.json +0 -11
  247. 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
- }