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,88 +0,0 @@
1
- /**
2
- * The MCP recall handlers must write to the recall telemetry, the same way
3
- * the HTTP /api/recall path does. Without this, every agent recall via MCP
4
- * is invisible to query_log.jsonl / fact_query_log.jsonl and the Recall
5
- * page — which is the path that actually matters for adoption telemetry.
6
- */
7
-
8
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
9
- import { tmpdir } from "node:os";
10
- import { join } from "node:path";
11
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
12
- import {
13
- recallFactsHandler,
14
- recallSessionsHandler,
15
- type McpDeps,
16
- } from "../../src/mcp/server.js";
17
-
18
- // logQuery is fire-and-forget (void) in the handler — poll for the line.
19
- async function waitForLine(path: string): Promise<string> {
20
- for (let i = 0; i < 60; i++) {
21
- if (existsSync(path)) {
22
- const txt = readFileSync(path, "utf8").trim();
23
- if (txt) return txt;
24
- }
25
- await new Promise((r) => setTimeout(r, 25));
26
- }
27
- throw new Error(`no log line written to ${path} within timeout`);
28
- }
29
-
30
- describe("MCP recall handlers write telemetry", () => {
31
- let tmp: string;
32
-
33
- beforeEach(() => {
34
- tmp = mkdtempSync(join(tmpdir(), "nlm-mcplog-"));
35
- process.env["NLM_QUERY_LOG"] = join(tmp, "query_log.jsonl");
36
- process.env["NLM_FACT_QUERY_LOG"] = join(tmp, "fact_query_log.jsonl");
37
- });
38
-
39
- afterEach(() => {
40
- delete process.env["NLM_QUERY_LOG"];
41
- delete process.env["NLM_FACT_QUERY_LOG"];
42
- rmSync(tmp, { recursive: true, force: true });
43
- });
44
-
45
- it("recall_sessions logs an mcp-source query", async () => {
46
- const deps = {
47
- recall: {
48
- search: async () => ({
49
- query: "pgvector",
50
- entity: null,
51
- kind: null,
52
- mode: "keyword",
53
- limit: 10,
54
- total: 2,
55
- results: [{ id: "s1" }, { id: "s2" }],
56
- }),
57
- },
58
- } as unknown as McpDeps;
59
-
60
- await recallSessionsHandler(deps, { query: "pgvector", mode: "keyword", limit: 10 });
61
-
62
- const entry = JSON.parse(await waitForLine(process.env["NLM_QUERY_LOG"] as string));
63
- expect(entry.source).toBe("mcp");
64
- expect(entry.query).toBe("pgvector");
65
- expect(entry.n_results).toBe(2);
66
- expect(entry.returned_ids).toEqual(["s1", "s2"]);
67
- });
68
-
69
- it("recall_facts logs an mcp-source query", async () => {
70
- const deps = {
71
- factRecall: {
72
- search: async () => ({
73
- query: "routing",
74
- total: 1,
75
- results: [{ id: "fact_x" }],
76
- }),
77
- },
78
- } as unknown as McpDeps;
79
-
80
- await recallFactsHandler(deps, { query: "routing", mode: "keyword", limit: 10 });
81
-
82
- const entry = JSON.parse(await waitForLine(process.env["NLM_FACT_QUERY_LOG"] as string));
83
- expect(entry.source).toBe("mcp");
84
- expect(entry.query).toBe("routing");
85
- expect(entry.n_results).toBe(1);
86
- expect(entry.returned_ids).toEqual(["fact_x"]);
87
- });
88
- });
@@ -1,260 +0,0 @@
1
- /**
2
- * MCP adapter integration. Exercises the tool handlers directly (no stdio
3
- * transport) to prove the in-process binding to RecallService + SessionStore
4
- * works end-to-end.
5
- */
6
-
7
- import { mkdtempSync, rmSync } from "node:fs";
8
- import { tmpdir } from "node:os";
9
- import { join, resolve } from "node:path";
10
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
11
- import { FactRecallService } from "../../src/core/recall-facts/fact-recall-service.js";
12
- import { RecallService } from "../../src/core/recall/recall-service.js";
13
- import { SqliteFactStore } from "../../src/core/storage/sqlite-fact-store.js";
14
- import { SqliteSessionStore } from "../../src/core/storage/sqlite-session-store.js";
15
- import {
16
- createMcpServer,
17
- getFactHistoryHandler,
18
- getSessionHandler,
19
- recallFactsHandler,
20
- recallSessionsHandler,
21
- } from "../../src/mcp/server.js";
22
- import type { EmbedResult, LLMClient } from "../../src/ports/llm-client.js";
23
- import type { Session } from "../../src/shared/types.js";
24
- import { makeFact } from "../fixtures/facts.js";
25
- import { makeSession } from "../fixtures/sessions.js";
26
-
27
- const MIGRATIONS_DIR = resolve(__dirname, "../../migrations");
28
-
29
- function unit(values: number[]): Float32Array {
30
- const padded = new Float32Array(768);
31
- values.forEach((v, i) => {
32
- padded[i] = v;
33
- });
34
- let sum = 0;
35
- for (const v of padded) sum += v * v;
36
- const norm = Math.sqrt(sum) || 1;
37
- for (let i = 0; i < padded.length; i++) padded[i] = (padded[i] ?? 0) / norm;
38
- return padded;
39
- }
40
-
41
- class FixedEmbedder implements LLMClient {
42
- constructor(private readonly vector: Float32Array) {}
43
- async embed(): Promise<EmbedResult> {
44
- return { vector: this.vector, model: "fixed-test" };
45
- }
46
- async classify(): Promise<never> {
47
- throw new Error("not used");
48
- }
49
- }
50
-
51
- const seed: ReadonlyArray<{ session: Session; embedding: Float32Array }> = [
52
- {
53
- session: makeSession({
54
- id: "sess_a",
55
- label: "Hono router setup",
56
- entities: ["NLM"],
57
- decisions: ["chose Hono"],
58
- }),
59
- embedding: unit([1, 0, 0]),
60
- },
61
- {
62
- session: makeSession({
63
- id: "sess_b",
64
- label: "pgvector migration plan",
65
- entities: ["NLM", "Postgres"],
66
- open: ["cutover timing"],
67
- }),
68
- embedding: unit([0, 1, 0]),
69
- },
70
- ];
71
-
72
- interface ParsedTool {
73
- content: { type: string; text: string }[];
74
- isError?: boolean;
75
- }
76
-
77
- function parsePayload(result: ParsedTool): unknown {
78
- const first = result.content[0];
79
- if (!first) throw new Error("empty tool result");
80
- return JSON.parse(first.text);
81
- }
82
-
83
- describe("MCP adapter", () => {
84
- let tmp: string;
85
- let store: SqliteSessionStore;
86
- let recall: RecallService;
87
-
88
- beforeEach(() => {
89
- tmp = mkdtempSync(join(tmpdir(), "nlm-mcp-"));
90
- store = new SqliteSessionStore({
91
- dbPath: join(tmp, "canonical.sqlite"),
92
- migrationsDir: MIGRATIONS_DIR,
93
- });
94
- for (const { session, embedding } of seed) {
95
- store.insertSessionForTest(session);
96
- store.insertEmbeddingForTest(session.id, embedding);
97
- }
98
- recall = new RecallService({
99
- store,
100
- llm: new FixedEmbedder(unit([0, 1, 0])),
101
- });
102
- });
103
-
104
- afterEach(() => {
105
- store.close();
106
- rmSync(tmp, { recursive: true, force: true });
107
- });
108
-
109
- it("recall_sessions returns the keyword hit", async () => {
110
- const result = await recallSessionsHandler(
111
- { recall, store },
112
- { query: "pgvector", mode: "keyword" },
113
- );
114
- expect(result.isError).toBeUndefined();
115
- const body = parsePayload(result) as { total: number; results: { id: string }[] };
116
- expect(body.total).toBe(1);
117
- expect(body.results[0]?.id).toBe("sess_b");
118
- });
119
-
120
- it("recall_sessions defaults to hybrid mode + 10-item limit", async () => {
121
- const result = await recallSessionsHandler({ recall, store }, { query: "hono" });
122
- const body = parsePayload(result) as { mode: string; limit: number };
123
- expect(body.mode).toBe("hybrid");
124
- expect(body.limit).toBe(10);
125
- });
126
-
127
- it("recall_sessions threads entity + kind filters into RecallService", async () => {
128
- const result = await recallSessionsHandler(
129
- { recall, store },
130
- { query: "pgvector", entity: "NLM", kind: "open" },
131
- );
132
- const body = parsePayload(result) as {
133
- entity: string;
134
- kind: string;
135
- results: { id: string }[];
136
- };
137
- expect(body.entity).toBe("NLM");
138
- expect(body.kind).toBe("open");
139
- expect(body.results.every((r) => r.id === "sess_b")).toBe(true);
140
- });
141
-
142
- it("get_session returns the full session for a known id", async () => {
143
- const result = await getSessionHandler({ recall, store }, { id: "sess_a" });
144
- expect(result.isError).toBeUndefined();
145
- const body = parsePayload(result) as { id: string; entities: string[] };
146
- expect(body.id).toBe("sess_a");
147
- expect(body.entities).toContain("NLM");
148
- });
149
-
150
- it("get_session includes supersedence links when an edge exists", async () => {
151
- store.insertEdgeForTest("sess_a", "sess_b", "supersedes");
152
- const newer = await getSessionHandler({ recall, store }, { id: "sess_a" });
153
- const older = await getSessionHandler({ recall, store }, { id: "sess_b" });
154
- const newerBody = parsePayload(newer) as { supersedes: string[]; supersededBy: string | null };
155
- const olderBody = parsePayload(older) as { supersedes: string[]; supersededBy: string | null };
156
- expect(newerBody.supersedes).toEqual(["sess_b"]);
157
- expect(newerBody.supersededBy).toBeNull();
158
- expect(olderBody.supersededBy).toBe("sess_a");
159
- expect(olderBody.supersedes).toEqual([]);
160
- });
161
-
162
- it("get_session returns an error tool result on missing id", async () => {
163
- const result = await getSessionHandler(
164
- { recall, store },
165
- { id: "does_not_exist" },
166
- );
167
- expect(result.isError).toBe(true);
168
- expect(result.content[0]?.text).toContain("not found");
169
- });
170
-
171
- it("createMcpServer registers both tools without throwing", () => {
172
- const server = createMcpServer({ recall, store });
173
- expect(server).toBeDefined();
174
- });
175
-
176
- describe("fact tools (B.3)", () => {
177
- let factStore: SqliteFactStore;
178
- let factRecall: FactRecallService;
179
-
180
- beforeEach(async () => {
181
- factStore = new SqliteFactStore(store.rawDb());
182
- factRecall = new FactRecallService({
183
- factStore,
184
- llm: new FixedEmbedder(unit([1, 0, 0])),
185
- });
186
- await factStore.insertMany([
187
- makeFact({
188
- id: "f_hono",
189
- subject: "nlm-memory-ts",
190
- predicate: "framework",
191
- value: "Hono",
192
- confidence: 0.9,
193
- sourceSessionId: "sess_a",
194
- }),
195
- makeFact({
196
- id: "f_endpoint",
197
- kind: "attribute",
198
- subject: "mac-pro-llm-host",
199
- predicate: "endpoint",
200
- value: "http://macpro:8080/v1",
201
- confidence: 0.85,
202
- sourceSessionId: "sess_b",
203
- }),
204
- ]);
205
- });
206
-
207
- it("recall_facts returns the current fact for an exact subject+predicate", async () => {
208
- const result = await recallFactsHandler(
209
- { recall, store, factRecall, factStore },
210
- { subject: "nlm-memory-ts", predicate: "framework" },
211
- );
212
- expect(result.isError).toBeUndefined();
213
- const body = parsePayload(result) as {
214
- total: number;
215
- results: { id: string; value: string }[];
216
- };
217
- expect(body.total).toBe(1);
218
- expect(body.results[0]?.id).toBe("f_hono");
219
- expect(body.results[0]?.value).toBe("Hono");
220
- });
221
-
222
- it("recall_facts returns an error tool result when factRecall is missing", async () => {
223
- const result = await recallFactsHandler(
224
- { recall, store },
225
- { subject: "x" },
226
- );
227
- expect(result.isError).toBe(true);
228
- });
229
-
230
- it("get_fact_history returns chains ordered newest → oldest", async () => {
231
- await factStore.insertMany([
232
- makeFact({
233
- id: "f_old",
234
- subject: "nlm-memory-ts",
235
- predicate: "framework",
236
- value: "Fastify",
237
- createdAt: "2026-05-18T00:00:00Z",
238
- confidence: 0.9,
239
- sourceSessionId: "sess_a",
240
- }),
241
- ]);
242
- await factStore.markSuperseded("f_old", "f_hono");
243
-
244
- const result = await getFactHistoryHandler(
245
- { recall, store, factRecall, factStore },
246
- { subject: "nlm-memory-ts", predicate: "framework" },
247
- );
248
- const body = parsePayload(result) as {
249
- chains: { history: { id: string }[] }[];
250
- };
251
- expect(body.chains).toHaveLength(1);
252
- expect(body.chains[0]?.history.map((f) => f.id)).toEqual(["f_hono", "f_old"]);
253
- });
254
-
255
- it("createMcpServer registers fact tools when factRecall + factStore wired", () => {
256
- const server = createMcpServer({ recall, store, factRecall, factStore });
257
- expect(server).toBeDefined();
258
- });
259
- });
260
- });
@@ -1,91 +0,0 @@
1
- import { existsSync, mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
- import { MemoSweepScheduler, sweepMemoDir } from "../../src/core/hook/memo-sweep.js";
6
-
7
- describe("memo sweep", () => {
8
- let tmp: string;
9
-
10
- beforeEach(() => {
11
- tmp = mkdtempSync(join(tmpdir(), "nlm-memo-sweep-"));
12
- });
13
-
14
- afterEach(() => rmSync(tmp, { recursive: true, force: true }));
15
-
16
- function plantMemo(name: string, ageMs: number): string {
17
- const path = join(tmp, name);
18
- writeFileSync(path, "[]", "utf8");
19
- if (ageMs > 0) {
20
- const past = (Date.now() - ageMs) / 1000;
21
- utimesSync(path, past, past);
22
- }
23
- return path;
24
- }
25
-
26
- it("returns a zero-count report when state dir does not exist", () => {
27
- const report = sweepMemoDir({ stateDir: join(tmp, "nope") });
28
- expect(report).toEqual({ scanned: 0, deleted: 0, kept: 0, errors: 0 });
29
- });
30
-
31
- it("deletes memos older than the dormant threshold and keeps fresh ones", () => {
32
- plantMemo("fresh.json", 30 * 60 * 1000); // 30 min old — active
33
- plantMemo("idle.json", 6 * 60 * 60 * 1000); // 6 hours — idle
34
- plantMemo("dormant-a.json", 25 * 60 * 60 * 1000); // 25h — dormant
35
- plantMemo("dormant-b.json", 30 * 24 * 60 * 60 * 1000); // 30 days — very dormant
36
-
37
- const report = sweepMemoDir({ stateDir: tmp });
38
- expect(report).toMatchObject({ scanned: 4, deleted: 2, kept: 2, errors: 0 });
39
-
40
- const remaining = readdirSync(tmp).sort();
41
- expect(remaining).toEqual(["fresh.json", "idle.json"]);
42
- });
43
-
44
- it("ignores non-json files in the state dir", () => {
45
- plantMemo("dormant.json", 25 * 60 * 60 * 1000);
46
- writeFileSync(join(tmp, "README.txt"), "not a memo");
47
- writeFileSync(join(tmp, ".DS_Store"), "");
48
-
49
- const report = sweepMemoDir({ stateDir: tmp });
50
- expect(report.deleted).toBe(1);
51
- expect(existsSync(join(tmp, "README.txt"))).toBe(true);
52
- expect(existsSync(join(tmp, ".DS_Store"))).toBe(true);
53
- });
54
-
55
- it("honors a custom dormantMs threshold", () => {
56
- plantMemo("two-hour-old.json", 2 * 60 * 60 * 1000);
57
- // Threshold of 1 hour — anything older than 1h is dormant.
58
- const report = sweepMemoDir({ stateDir: tmp, dormantMs: 60 * 60 * 1000 });
59
- expect(report.deleted).toBe(1);
60
- });
61
-
62
- it("uses an injected `now` for deterministic time-window tests", () => {
63
- const path = plantMemo("memo.json", 0);
64
- // memo was just touched. Pretend "now" is 2 days in the future — it's dormant.
65
- const future = Date.now() + 2 * 24 * 60 * 60 * 1000;
66
- const report = sweepMemoDir({ stateDir: tmp, now: () => future });
67
- expect(report.deleted).toBe(1);
68
- expect(existsSync(path)).toBe(false);
69
- });
70
-
71
- it("MemoSweepScheduler.tick performs the sweep without scheduling", () => {
72
- plantMemo("dormant.json", 25 * 60 * 60 * 1000);
73
- plantMemo("fresh.json", 60_000);
74
- const sweeper = new MemoSweepScheduler({ stateDir: tmp, logger: () => {} });
75
- const report = sweeper.tick();
76
- expect(report.deleted).toBe(1);
77
- expect(report.kept).toBe(1);
78
- });
79
-
80
- it("MemoSweepScheduler.start does not throw and stop cleans up the timer", () => {
81
- const sweeper = new MemoSweepScheduler({
82
- stateDir: tmp,
83
- intervalMs: 60_000,
84
- logger: () => {},
85
- });
86
- expect(() => sweeper.start()).not.toThrow();
87
- expect(() => sweeper.stop()).not.toThrow();
88
- // Idempotent — double-stop is a no-op.
89
- expect(() => sweeper.stop()).not.toThrow();
90
- });
91
- });
@@ -1,88 +0,0 @@
1
- import { mkdtempSync, existsSync, readFileSync, rmSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
- import { runHook } from "../../src/hook/prompt-recall-hook.js";
6
- import type { RecallHitInput } from "../../src/core/hook/select.js";
7
-
8
- const hits = (...ids: string[]): ReadonlyArray<RecallHitInput> =>
9
- ids.map((id, i) => ({
10
- id,
11
- label: `Session ${id}`,
12
- startedAt: "2026-05-15T10:00:00.000Z",
13
- matchScore: 0.9 - i * 0.01,
14
- }));
15
-
16
- describe("runHook", () => {
17
- let tmp: string;
18
-
19
- beforeEach(() => {
20
- tmp = mkdtempSync(join(tmpdir(), "nlm-hook-"));
21
- process.env["NLM_HOOK_STATE_DIR"] = join(tmp, "state");
22
- process.env["NLM_HOOK_LOG"] = join(tmp, "hook-log.jsonl");
23
- });
24
-
25
- afterEach(() => {
26
- delete process.env["NLM_HOOK_STATE_DIR"];
27
- delete process.env["NLM_HOOK_LOG"];
28
- rmSync(tmp, { recursive: true, force: true });
29
- });
30
-
31
- it("shadow mode logs but returns no stdout", async () => {
32
- const out = await runHook(
33
- { prompt: "what did we decide about pgvector", conversationId: "c1" },
34
- { mode: "shadow", recall: async () => hits("sess_a") },
35
- );
36
- expect(out).toBe("");
37
- const log = readFileSync(join(tmp, "hook-log.jsonl"), "utf8").trim();
38
- expect(JSON.parse(log).wouldInject).toEqual(["sess_a"]);
39
- expect(JSON.parse(log).mode).toBe("shadow");
40
- });
41
-
42
- it("shadow mode does not write the memo", async () => {
43
- await runHook(
44
- { prompt: "what did we decide", conversationId: "c1" },
45
- { mode: "shadow", recall: async () => hits("sess_a") },
46
- );
47
- expect(existsSync(join(tmp, "state", "c1.json"))).toBe(false);
48
- });
49
-
50
- it("live mode returns the pointer block and records the memo", async () => {
51
- const out = await runHook(
52
- { prompt: "what did we decide about pgvector", conversationId: "c1" },
53
- { mode: "live", recall: async () => hits("sess_a", "sess_b") },
54
- );
55
- expect(out).toContain("## Possibly-relevant prior sessions (nlm-memory)");
56
- expect(out).toContain("sess_a");
57
- const memo = JSON.parse(readFileSync(join(tmp, "state", "c1.json"), "utf8"));
58
- expect([...memo].sort()).toEqual(["sess_a", "sess_b"]);
59
- });
60
-
61
- it("live mode dedups: a second fire does not re-surface the same session", async () => {
62
- const deps = { mode: "live" as const, recall: async () => hits("sess_a") };
63
- const first = await runHook({ prompt: "what did we decide", conversationId: "c1" }, deps);
64
- expect(first).toContain("sess_a");
65
- const second = await runHook({ prompt: "and what else did we decide", conversationId: "c1" }, deps);
66
- expect(second).toBe("");
67
- });
68
-
69
- it("generative prompts skip recall entirely", async () => {
70
- let called = false;
71
- const out = await runHook(
72
- { prompt: "draft a blog post about FTS5", conversationId: "c1" },
73
- { mode: "live", recall: async () => { called = true; return hits("sess_a"); } },
74
- );
75
- expect(out).toBe("");
76
- expect(called).toBe(false);
77
- const log = readFileSync(join(tmp, "hook-log.jsonl"), "utf8").trim();
78
- expect(JSON.parse(log).gate).toBe("generative");
79
- });
80
-
81
- it("returns empty and does not throw when recall rejects", async () => {
82
- const out = await runHook(
83
- { prompt: "what did we decide", conversationId: "c1" },
84
- { mode: "live", recall: async () => { throw new Error("daemon down"); } },
85
- );
86
- expect(out).toBe("");
87
- });
88
- });
@@ -1,107 +0,0 @@
1
- /**
2
- * Phase 0 task 3 — ProviderRegistry integration. Real SQLite, seed
3
- * defaults bridge from env, CRUD, secret redaction on list/get.
4
- */
5
-
6
- import { mkdtempSync, rmSync } from "node:fs";
7
- import { tmpdir } from "node:os";
8
- import { join, resolve } from "node:path";
9
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
10
- import { SqliteSessionStore } from "../../src/core/storage/sqlite-session-store.js";
11
- import { ProviderRegistry } from "../../src/core/providers/provider-registry.js";
12
-
13
- const MIGRATIONS_DIR = resolve(__dirname, "../../migrations");
14
-
15
- describe("ProviderRegistry", () => {
16
- let tmp: string;
17
- let store: SqliteSessionStore;
18
- let registry: ProviderRegistry;
19
- let savedKey: string | undefined;
20
-
21
- beforeEach(() => {
22
- tmp = mkdtempSync(join(tmpdir(), "nlm-providers-"));
23
- store = new SqliteSessionStore({
24
- dbPath: join(tmp, "canonical.sqlite"),
25
- migrationsDir: MIGRATIONS_DIR,
26
- });
27
- registry = new ProviderRegistry(store.rawDb());
28
- savedKey = process.env["DEEPSEEK_API_KEY"];
29
- });
30
-
31
- afterEach(() => {
32
- if (savedKey === undefined) delete process.env["DEEPSEEK_API_KEY"];
33
- else process.env["DEEPSEEK_API_KEY"] = savedKey;
34
- store.close();
35
- rmSync(tmp, { recursive: true, force: true });
36
- });
37
-
38
- it("seedDefaults inserts Ollama always, DeepSeek with key when present", () => {
39
- process.env["DEEPSEEK_API_KEY"] = "sk-test-abc";
40
- registry.seedDefaults();
41
- const rows = registry.list();
42
- expect(rows.map((r) => r.kind)).toEqual(["ollama", "deepseek"]);
43
- const deepseek = rows.find((r) => r.kind === "deepseek");
44
- expect(deepseek?.enabled).toBe(true);
45
- expect(deepseek?.hasApiKey).toBe(true);
46
- expect(deepseek?.apiKey).toBeNull(); // redacted
47
- });
48
-
49
- it("seedDefaults disables DeepSeek when key is absent", () => {
50
- delete process.env["DEEPSEEK_API_KEY"];
51
- registry.seedDefaults();
52
- const deepseek = registry.getByName("DeepSeek");
53
- expect(deepseek?.enabled).toBe(false);
54
- expect(deepseek?.hasApiKey).toBe(false);
55
- });
56
-
57
- it("seedDefaults is idempotent", () => {
58
- registry.seedDefaults();
59
- registry.seedDefaults();
60
- expect(registry.list().length).toBe(2);
61
- });
62
-
63
- it("inserts a custom provider with explicit base URL", () => {
64
- const row = registry.insert({
65
- kind: "openai-compatible",
66
- name: "vLLM box",
67
- baseUrl: "http://192.168.1.50:8000/v1",
68
- defaultModel: "llama-3.1-70b",
69
- apiKey: "secret-token",
70
- });
71
- expect(row.baseUrl).toBe("http://192.168.1.50:8000/v1");
72
- expect(row.hasApiKey).toBe(true);
73
- expect(row.apiKey).toBeNull();
74
- });
75
-
76
- it("getSecret returns the unredacted key", () => {
77
- const row = registry.insert({
78
- kind: "openai", name: "OpenAI prod", apiKey: "sk-real",
79
- });
80
- expect(registry.getSecret(row.id)).toBe("sk-real");
81
- });
82
-
83
- it("rejects duplicate names", () => {
84
- registry.insert({ kind: "openai", name: "OpenAI", apiKey: "k" });
85
- expect(() => registry.insert({ kind: "openai", name: "OpenAI", apiKey: "k2" }))
86
- .toThrow();
87
- });
88
-
89
- it("update patches only supplied fields", () => {
90
- const row = registry.insert({ kind: "openai", name: "OAI", apiKey: "k1" });
91
- const updated = registry.update(row.id, { apiKey: "k2", enabled: false });
92
- expect(updated?.enabled).toBe(false);
93
- expect(registry.getSecret(row.id)).toBe("k2");
94
- });
95
-
96
- it("delete removes the row", () => {
97
- const row = registry.insert({ kind: "openai", name: "Tmp", apiKey: "k" });
98
- expect(registry.delete(row.id)).toBe(true);
99
- expect(registry.get(row.id)).toBeNull();
100
- });
101
-
102
- it("fills in default base URL and model when omitted", () => {
103
- const row = registry.insert({ kind: "anthropic", name: "Claude", apiKey: "k" });
104
- expect(row.baseUrl).toBe("https://api.anthropic.com");
105
- expect(row.defaultModel).toBe("claude-haiku-4-5-20251001");
106
- });
107
- });
@@ -1,59 +0,0 @@
1
- /**
2
- * Recall-quality regression gate. A fixed corpus + query/expectation pairs,
3
- * run through RecallService against a real SqliteSessionStore. Assertions are
4
- * tolerant (expected session within top 3) so they survive the swap from the
5
- * token-overlap scorer to FTS5 BM25 ranking. This test must stay green from
6
- * the current code through every task in this plan.
7
- */
8
-
9
- import { mkdtempSync, rmSync } from "node:fs";
10
- import { tmpdir } from "node:os";
11
- import { join, resolve } from "node:path";
12
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
- import { RecallService } from "../../src/core/recall/recall-service.js";
14
- import { SqliteSessionStore } from "../../src/core/storage/sqlite-session-store.js";
15
- import type { EmbedResult, LLMClient } from "../../src/ports/llm-client.js";
16
- import { LLMUnreachableError } from "../../src/ports/llm-client.js";
17
- import { GOLDEN_CORPUS, GOLDEN_QUERIES } from "../fixtures/golden-corpus.js";
18
-
19
- const MIGRATIONS_DIR = resolve(__dirname, "../../migrations");
20
-
21
- // Keyword-only recall must never touch the embedder; this stub proves it.
22
- class UnreachableEmbedder implements LLMClient {
23
- async embed(): Promise<EmbedResult> {
24
- throw new LLMUnreachableError("ollama");
25
- }
26
- async classify(): Promise<never> {
27
- throw new Error("not used");
28
- }
29
- }
30
-
31
- describe("golden recall regression gate", () => {
32
- let tmp: string;
33
- let store: SqliteSessionStore;
34
-
35
- beforeEach(() => {
36
- tmp = mkdtempSync(join(tmpdir(), "nlm-golden-"));
37
- store = new SqliteSessionStore({
38
- dbPath: join(tmp, "canonical.sqlite"),
39
- migrationsDir: MIGRATIONS_DIR,
40
- });
41
- for (const session of GOLDEN_CORPUS) {
42
- store.insertSessionForTest(session);
43
- }
44
- });
45
-
46
- afterEach(() => {
47
- store.close();
48
- rmSync(tmp, { recursive: true, force: true });
49
- });
50
-
51
- for (const { query, expectTop3 } of GOLDEN_QUERIES) {
52
- it(`keyword recall surfaces "${expectTop3}" in the top 3 for "${query}"`, async () => {
53
- const svc = new RecallService({ store, llm: new UnreachableEmbedder() });
54
- const result = await svc.search({ query, mode: "keyword", limit: 10 });
55
- const top3 = result.results.slice(0, 3).map((r) => r.id);
56
- expect(top3).toContain(expectTop3);
57
- });
58
- }
59
- });