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,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
- });