nlm-memory 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. package/README.md +72 -34
  2. package/dist/cli/nlm.js +223 -33
  3. package/dist/cli/nlm.js.map +1 -1
  4. package/dist/core/adapters/cursor.d.ts +45 -0
  5. package/dist/core/adapters/cursor.js +397 -0
  6. package/dist/core/adapters/cursor.js.map +1 -0
  7. package/dist/core/adapters/from-source.js +10 -0
  8. package/dist/core/adapters/from-source.js.map +1 -1
  9. package/dist/core/adapters/windsurf.d.ts +44 -0
  10. package/dist/core/adapters/windsurf.js +299 -0
  11. package/dist/core/adapters/windsurf.js.map +1 -0
  12. package/dist/core/hook/claude-settings.d.ts +12 -5
  13. package/dist/core/hook/claude-settings.js +21 -6
  14. package/dist/core/hook/claude-settings.js.map +1 -1
  15. package/dist/core/sources/source-registry.d.ts +1 -1
  16. package/dist/core/sources/source-registry.js +18 -0
  17. package/dist/core/sources/source-registry.js.map +1 -1
  18. package/dist/core/storage/sqlite-session-store.d.ts +2 -0
  19. package/dist/core/storage/sqlite-session-store.js +38 -2
  20. package/dist/core/storage/sqlite-session-store.js.map +1 -1
  21. package/dist/hook/hook-auth.d.ts +13 -0
  22. package/dist/hook/hook-auth.js +19 -0
  23. package/dist/hook/hook-auth.js.map +1 -0
  24. package/dist/hook/prompt-recall-hook.js +7 -1
  25. package/dist/hook/prompt-recall-hook.js.map +1 -1
  26. package/dist/hook/session-start-hook.js +4 -1
  27. package/dist/hook/session-start-hook.js.map +1 -1
  28. package/dist/hook/stop-hook.js +4 -1
  29. package/dist/hook/stop-hook.js.map +1 -1
  30. package/dist/http/app.d.ts +2 -0
  31. package/dist/http/app.js +76 -1
  32. package/dist/http/app.js.map +1 -1
  33. package/dist/install/claude-code.js +1 -1
  34. package/dist/install/claude-code.js.map +1 -1
  35. package/dist/install/cursor.d.ts +25 -0
  36. package/dist/install/cursor.js +43 -0
  37. package/dist/install/cursor.js.map +1 -0
  38. package/dist/install/nlm-dir-perms.d.ts +19 -0
  39. package/dist/install/nlm-dir-perms.js +43 -0
  40. package/dist/install/nlm-dir-perms.js.map +1 -0
  41. package/dist/install/ollama.d.ts +18 -1
  42. package/dist/install/ollama.js +62 -7
  43. package/dist/install/ollama.js.map +1 -1
  44. package/dist/install/setup.d.ts +4 -0
  45. package/dist/install/setup.js +141 -18
  46. package/dist/install/setup.js.map +1 -1
  47. package/dist/install/windsurf.d.ts +25 -0
  48. package/dist/install/windsurf.js +43 -0
  49. package/dist/install/windsurf.js.map +1 -0
  50. package/dist/mcp/server.js +20 -1
  51. package/dist/mcp/server.js.map +1 -1
  52. package/dist/shared/types.d.ts +4 -0
  53. package/dist/ui/assets/{index-BA6IpU8g.css → index-Beo8psd-.css} +1 -1
  54. package/dist/ui/assets/index-CSPTTeeM.js +69 -0
  55. package/dist/ui/index.html +2 -2
  56. package/package.json +26 -1
  57. package/plugin/scripts/prompt-recall-hook.mjs +55 -4
  58. package/plugin/scripts/stop-hook.mjs +57 -6
  59. package/.agents/plugins/marketplace.json +0 -20
  60. package/.github/workflows/ci.yml +0 -30
  61. package/dist/ui/assets/index-B_qIVV0k.js +0 -69
  62. package/docs/methodology/re-derivation-rate.md +0 -112
  63. package/docs/methodology/useful-hit-rate.md +0 -79
  64. package/docs/plans/2026-05-20-fts5-lexical-recall.md +0 -1088
  65. package/docs/plans/2026-05-20-recall-daemon-wedge-fix.md +0 -662
  66. package/docs/plans/2026-05-20-recall-hook-design.md +0 -131
  67. package/docs/plans/2026-05-20-recall-hook-implementation.md +0 -1222
  68. package/docs/plans/desktop-product.md +0 -69
  69. package/docs/plans/factstore-design.md +0 -236
  70. package/logs/CHANGELOG/CHANGELOG-2026.md +0 -1389
  71. package/logs/CHANGELOG/CHANGELOG.md +0 -337
  72. package/migrations/000_initial_schema.sql +0 -174
  73. package/migrations/001_entity_type_rename.sql +0 -17
  74. package/migrations/002_adapter_state_extend.sql +0 -12
  75. package/migrations/003_session_embeddings.sql +0 -11
  76. package/migrations/004_facts.sql +0 -46
  77. package/migrations/005_sources.sql +0 -31
  78. package/migrations/006_providers.sql +0 -33
  79. package/migrations/007_source_tokens.sql +0 -17
  80. package/migrations/008_fts_rebuild.sql +0 -9
  81. package/migrations/009_session_embedding_chunks.sql +0 -46
  82. package/migrations/010_sources_opencode.sql +0 -30
  83. package/migrations/011_sources_hermes_agent.sql +0 -30
  84. package/migrations/012_sources_aider.sql +0 -30
  85. package/migrations/013_adapter_state_failure_count.sql +0 -12
  86. package/plugin-hermes-agent/README.md +0 -49
  87. package/plugin-hermes-agent/__init__.py +0 -75
  88. package/plugin-hermes-agent/plugin.yaml +0 -15
  89. package/scripts/backfill-citations.mjs +0 -0
  90. package/scripts/build-codex-plugin.mjs +0 -61
  91. package/scripts/deepseek-probe.mjs +0 -67
  92. package/scripts/extract-triples.mjs +0 -207
  93. package/scripts/longmemeval/embedding-cache.ts +0 -77
  94. package/scripts/longmemeval/fetch-dataset.sh +0 -25
  95. package/scripts/longmemeval/run-harness.ts +0 -315
  96. package/scripts/longmemeval/scorer.ts +0 -99
  97. package/scripts/longmemeval/tsconfig.json +0 -9
  98. package/scripts/longmemeval/types.ts +0 -35
  99. package/scripts/nlm-daily-digest.py +0 -239
  100. package/scripts/nlm-daily-digest.sh +0 -28
  101. package/src/cli/classify-parity.ts +0 -257
  102. package/src/cli/launchctl-helpers.ts +0 -49
  103. package/src/cli/nlm.ts +0 -885
  104. package/src/core/actions/actions-log.ts +0 -118
  105. package/src/core/actions/overlay.ts +0 -117
  106. package/src/core/adapters/aider.ts +0 -205
  107. package/src/core/adapters/claude-code.ts +0 -293
  108. package/src/core/adapters/common.ts +0 -54
  109. package/src/core/adapters/from-source.ts +0 -57
  110. package/src/core/adapters/hermes-agent.ts +0 -240
  111. package/src/core/adapters/hermes.ts +0 -277
  112. package/src/core/adapters/jsonl-generic.ts +0 -208
  113. package/src/core/adapters/opencode.ts +0 -281
  114. package/src/core/adapters/pi.ts +0 -264
  115. package/src/core/classifier/prompt.ts +0 -200
  116. package/src/core/dataset/build-dataset.ts +0 -463
  117. package/src/core/embedding/chunk-body.ts +0 -76
  118. package/src/core/embedding/embed-backfill.ts +0 -210
  119. package/src/core/embedding/embed-normalize.ts +0 -135
  120. package/src/core/facts/backfill-facts.ts +0 -254
  121. package/src/core/facts/extract-facts.ts +0 -50
  122. package/src/core/hook/citation-detect.ts +0 -124
  123. package/src/core/hook/cite-memo.ts +0 -68
  124. package/src/core/hook/claude-settings.ts +0 -166
  125. package/src/core/hook/gate.ts +0 -25
  126. package/src/core/hook/hook-log.ts +0 -41
  127. package/src/core/hook/memo-sweep.ts +0 -164
  128. package/src/core/hook/memo.ts +0 -67
  129. package/src/core/hook/pointer-block.ts +0 -26
  130. package/src/core/hook/select.ts +0 -32
  131. package/src/core/hook/transcript.ts +0 -121
  132. package/src/core/ingest/ingest-session.ts +0 -111
  133. package/src/core/providers/provider-models.ts +0 -100
  134. package/src/core/providers/provider-registry.ts +0 -196
  135. package/src/core/recall/citation-log.ts +0 -108
  136. package/src/core/recall/filter.ts +0 -27
  137. package/src/core/recall/index.ts +0 -6
  138. package/src/core/recall/match-fields.ts +0 -40
  139. package/src/core/recall/query-log.ts +0 -149
  140. package/src/core/recall/query-shape.ts +0 -66
  141. package/src/core/recall/recall-service.ts +0 -320
  142. package/src/core/recall/recent-log.ts +0 -59
  143. package/src/core/recall/tokenize.ts +0 -18
  144. package/src/core/recall/useful-scan.ts +0 -336
  145. package/src/core/recall-facts/fact-query-log.ts +0 -150
  146. package/src/core/recall-facts/fact-recall-service.ts +0 -327
  147. package/src/core/scheduler/scan-once.ts +0 -142
  148. package/src/core/scheduler/scheduler.ts +0 -225
  149. package/src/core/sources/source-registry.ts +0 -260
  150. package/src/core/storage/db-restore.ts +0 -133
  151. package/src/core/storage/live-status.ts +0 -45
  152. package/src/core/storage/migrate.ts +0 -72
  153. package/src/core/storage/sqlite-fact-store.ts +0 -304
  154. package/src/core/storage/sqlite-session-store.ts +0 -765
  155. package/src/hook/prompt-recall-hook.ts +0 -174
  156. package/src/hook/session-end-hook.ts +0 -81
  157. package/src/hook/session-start-hook.ts +0 -165
  158. package/src/hook/stop-hook.ts +0 -236
  159. package/src/http/app.ts +0 -1137
  160. package/src/install/claude-code.ts +0 -128
  161. package/src/install/codex.ts +0 -367
  162. package/src/install/hermes-agent.ts +0 -76
  163. package/src/install/hermes.ts +0 -78
  164. package/src/install/ollama.ts +0 -211
  165. package/src/install/setup.ts +0 -368
  166. package/src/llm/classifier-box.ts +0 -64
  167. package/src/llm/deepseek-client.ts +0 -150
  168. package/src/llm/env-autoload.ts +0 -55
  169. package/src/llm/ollama-client.ts +0 -189
  170. package/src/mcp/server.ts +0 -534
  171. package/src/ports/fact-store.ts +0 -102
  172. package/src/ports/llm-client.ts +0 -52
  173. package/src/ports/logger.ts +0 -16
  174. package/src/ports/session-store.ts +0 -45
  175. package/src/ports/transcript-adapter.ts +0 -55
  176. package/src/shared/types.ts +0 -145
  177. package/src/ui/App.tsx +0 -58
  178. package/src/ui/components/PromoteOpenButton.tsx +0 -65
  179. package/src/ui/components/SessionDrawer.tsx +0 -136
  180. package/src/ui/components/SideNav.tsx +0 -162
  181. package/src/ui/components/Skeleton.tsx +0 -107
  182. package/src/ui/index.html +0 -13
  183. package/src/ui/lib/actions.ts +0 -30
  184. package/src/ui/lib/api.ts +0 -92
  185. package/src/ui/lib/dataset.ts +0 -141
  186. package/src/ui/lib/registries.ts +0 -155
  187. package/src/ui/lib/view-settings.ts +0 -41
  188. package/src/ui/main.tsx +0 -15
  189. package/src/ui/pages/Live.tsx +0 -229
  190. package/src/ui/pages/Pulse.tsx +0 -415
  191. package/src/ui/pages/Recall.tsx +0 -190
  192. package/src/ui/pages/River.tsx +0 -308
  193. package/src/ui/pages/Search.tsx +0 -93
  194. package/src/ui/pages/Stub.tsx +0 -9
  195. package/src/ui/pages/Thread.tsx +0 -262
  196. package/src/ui/pages/settings/Classifier.tsx +0 -227
  197. package/src/ui/pages/settings/Data.tsx +0 -190
  198. package/src/ui/pages/settings/Index.tsx +0 -65
  199. package/src/ui/pages/settings/Labels.tsx +0 -224
  200. package/src/ui/pages/settings/Providers.tsx +0 -305
  201. package/src/ui/pages/settings/SettingsSubnav.tsx +0 -28
  202. package/src/ui/pages/settings/Sources.tsx +0 -326
  203. package/src/ui/pages/settings/Views.tsx +0 -96
  204. package/src/ui/styles.css +0 -1766
  205. package/src/ui/tsconfig.json +0 -21
  206. package/src/ui/vite.config.ts +0 -19
  207. package/tests/fixtures/claude_code/short_session.jsonl +0 -2
  208. package/tests/fixtures/claude_code/standard_iso.jsonl +0 -4
  209. package/tests/fixtures/claude_code/tool_heavy.jsonl +0 -8
  210. package/tests/fixtures/claude_code/with_subagent.jsonl +0 -7
  211. package/tests/fixtures/facts.ts +0 -17
  212. package/tests/fixtures/golden-corpus.ts +0 -85
  213. package/tests/fixtures/hermes/paired_request_dump.json +0 -24
  214. package/tests/fixtures/hermes/paired_session.json +0 -23
  215. package/tests/fixtures/hermes/request_dump.json +0 -28
  216. package/tests/fixtures/hermes/session_iso.json +0 -38
  217. package/tests/fixtures/hermes/session_unix.json +0 -38
  218. package/tests/fixtures/hermes/system_only.json +0 -18
  219. package/tests/fixtures/pi/error-connection-abort.jsonl +0 -8
  220. package/tests/fixtures/pi/short-successful.jsonl +0 -5
  221. package/tests/fixtures/pi/with-custom-message.jsonl +0 -6
  222. package/tests/fixtures/sessions.ts +0 -22
  223. package/tests/integration/backfill-facts.test.ts +0 -362
  224. package/tests/integration/citation-explicit.test.ts +0 -111
  225. package/tests/integration/cite-event.test.ts +0 -169
  226. package/tests/integration/cite-memo.test.ts +0 -87
  227. package/tests/integration/db-restore.test.ts +0 -153
  228. package/tests/integration/embed-backfill.test.ts +0 -176
  229. package/tests/integration/fact-supersedence.test.ts +0 -313
  230. package/tests/integration/fts-index.test.ts +0 -60
  231. package/tests/integration/getbyids-sqlite.test.ts +0 -60
  232. package/tests/integration/hermes-agent-hooks.test.ts +0 -248
  233. package/tests/integration/hook-claude-settings.test.ts +0 -205
  234. package/tests/integration/hook-log.test.ts +0 -54
  235. package/tests/integration/hook-memo.test.ts +0 -68
  236. package/tests/integration/hook-pre-compact.test.ts +0 -105
  237. package/tests/integration/hook-subagent-start.test.ts +0 -102
  238. package/tests/integration/http.test.ts +0 -401
  239. package/tests/integration/keyword-search-fts.test.ts +0 -66
  240. package/tests/integration/mcp-recall-logging.test.ts +0 -88
  241. package/tests/integration/mcp.test.ts +0 -248
  242. package/tests/integration/memo-sweep.test.ts +0 -91
  243. package/tests/integration/prompt-recall-hook.test.ts +0 -88
  244. package/tests/integration/provider-registry.test.ts +0 -107
  245. package/tests/integration/recall-golden.test.ts +0 -59
  246. package/tests/integration/recall-sqlite.test.ts +0 -169
  247. package/tests/integration/scheduler.test.ts +0 -391
  248. package/tests/integration/session-end-hook.test.ts +0 -48
  249. package/tests/integration/session-start-hook.test.ts +0 -126
  250. package/tests/integration/source-registry.test.ts +0 -120
  251. package/tests/integration/sqlite-fact-store.test.ts +0 -346
  252. package/tests/integration/stop-hook.test.ts +0 -560
  253. package/tests/integration/wal-checkpoint.test.ts +0 -49
  254. package/tests/unit/cli/launchctl-helpers.test.ts +0 -60
  255. package/tests/unit/core/adapters/aider.test.ts +0 -230
  256. package/tests/unit/core/adapters/claude-code.test.ts +0 -118
  257. package/tests/unit/core/adapters/hermes-agent.test.ts +0 -329
  258. package/tests/unit/core/adapters/hermes.test.ts +0 -81
  259. package/tests/unit/core/adapters/jsonl-generic.test.ts +0 -142
  260. package/tests/unit/core/adapters/opencode.test.ts +0 -354
  261. package/tests/unit/core/adapters/pi.test.ts +0 -110
  262. package/tests/unit/core/classifier/prompt.test.ts +0 -126
  263. package/tests/unit/core/embedding/chunk-body.test.ts +0 -100
  264. package/tests/unit/core/facts/extract-facts.test.ts +0 -117
  265. package/tests/unit/core/filter.test.ts +0 -40
  266. package/tests/unit/core/hook/citation-detect-cite-session.test.ts +0 -96
  267. package/tests/unit/core/hook/citation-detect.test.ts +0 -124
  268. package/tests/unit/core/hook/gate.test.ts +0 -29
  269. package/tests/unit/core/hook/pointer-block.test.ts +0 -22
  270. package/tests/unit/core/hook/select.test.ts +0 -66
  271. package/tests/unit/core/match-fields.test.ts +0 -39
  272. package/tests/unit/core/mcp-cite-session.test.ts +0 -51
  273. package/tests/unit/core/providers/provider-models.test.ts +0 -101
  274. package/tests/unit/core/query-shape.test.ts +0 -92
  275. package/tests/unit/core/recall-facts/fact-recall-service.test.ts +0 -258
  276. package/tests/unit/core/recall-service.test.ts +0 -200
  277. package/tests/unit/core/storage/live-status.test.ts +0 -54
  278. package/tests/unit/core/tokenize.test.ts +0 -32
  279. package/tests/unit/core/useful-scan.test.ts +0 -537
  280. package/tests/unit/llm/embed.test.ts +0 -93
  281. package/tests/unit/llm/ollama-client.test.ts +0 -124
  282. package/tests/unit/scripts/longmemeval-scorer.test.ts +0 -114
  283. package/tsconfig.json +0 -31
  284. package/tsconfig.test.json +0 -11
  285. package/vitest.config.ts +0 -22
@@ -1,1222 +0,0 @@
1
- # NLM Auto-Inject Recall Hook — Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Build a Claude Code `UserPromptSubmit` hook that automatically surfaces relevant prior NLM sessions as pointer blocks, gated for relevance, defaulting to a non-injecting shadow mode.
6
-
7
- **Architecture:** Small focused modules under `src/core/hook/` (a pure gate, pure selection + rendering, file-backed memo + shadow log, Claude settings editor) plus a thin orchestrator at `src/hook/prompt-recall-hook.ts` that Claude Code invokes per prompt. The orchestrator reads the prompt from stdin, runs the gate, queries the existing `/api/recall` HTTP endpoint, dedups against a per-conversation memo, and in `live` mode emits a capped pointer block — in `shadow` mode it only logs. Every error path is fail-open (exit 0, no output).
8
-
9
- **Tech Stack:** TypeScript (NodeNext, strict), better-sqlite3-based repo, vitest, Node 22 global `fetch`. Hexagonal — core modules are pure or file-I/O only; the orchestrator is the composition point.
10
-
11
- **Spec:** `docs/plans/2026-05-20-recall-hook-design.md` (read it for rationale).
12
-
13
- **Branch:** Create and work on `feat/recall-hook` off `main`.
14
-
15
- **Conventions to follow:**
16
- - File-path/env override pattern from `src/core/recall/query-log.ts`: a path defaults to `~/.nlm/...` but is overridable by an env var (used for testability). Mirror it for the hook log, memo dir, and Claude settings path.
17
- - Path aliases: `@core/*`, `@ports/*`, `@shared/*` (see `tsconfig.json`).
18
- - `dist/` is committed in this repo. Rebuild it in the final task.
19
- - Tests: pure modules → `tests/unit/core/hook/`; file-I/O modules → `tests/integration/`.
20
-
21
- **Out of scope:** Hermes/Codex hooks, local-LLM gating, content (non-pointer) injection, multi-machine. Do not build these.
22
-
23
- ---
24
-
25
- ## Task 1: Prompt gate (pure)
26
-
27
- A pure classifier: is a prompt obviously generative (skip recall) or should it be evaluated? Conservative generative *excluder* — default is `evaluate`; only high-precision generative openers short-circuit. Rationale: a false `generative` wrongly skips recall (the failure we are fixing); a false `evaluate` just wastes a cheap query. Bias toward `evaluate`.
28
-
29
- **Files:**
30
- - Create: `src/core/hook/gate.ts`
31
- - Test: `tests/unit/core/hook/gate.test.ts`
32
-
33
- - [ ] **Step 1: Write the failing test**
34
-
35
- Create `tests/unit/core/hook/gate.test.ts`:
36
-
37
- ```typescript
38
- import { describe, expect, it } from "vitest";
39
- import { classifyPrompt } from "../../../../src/core/hook/gate.js";
40
-
41
- describe("classifyPrompt", () => {
42
- it("classifies obvious generative openers as generative", () => {
43
- expect(classifyPrompt("draft a LinkedIn post about FTS5")).toBe("generative");
44
- expect(classifyPrompt("write the migration")).toBe("generative");
45
- expect(classifyPrompt("brainstorm names for the feature")).toBe("generative");
46
- expect(classifyPrompt("Create a test file")).toBe("generative");
47
- });
48
-
49
- it("classifies retrospective prompts as evaluate", () => {
50
- expect(classifyPrompt("what did we decide about pgvector")).toBe("evaluate");
51
- expect(classifyPrompt("have I worked with this client before")).toBe("evaluate");
52
- expect(classifyPrompt("why is the recall backend returning zero results")).toBe("evaluate");
53
- });
54
-
55
- it("strips leading filler before checking the opener", () => {
56
- expect(classifyPrompt("can you write a script")).toBe("generative");
57
- expect(classifyPrompt("please draft the email")).toBe("generative");
58
- expect(classifyPrompt("could you tell me what we decided")).toBe("evaluate");
59
- });
60
-
61
- it("defaults to evaluate for empty or ambiguous prompts", () => {
62
- expect(classifyPrompt("")).toBe("evaluate");
63
- expect(classifyPrompt("the FTS5 work")).toBe("evaluate");
64
- expect(classifyPrompt("fix the failing test")).toBe("evaluate");
65
- });
66
- });
67
- ```
68
-
69
- - [ ] **Step 2: Run the test to verify it fails**
70
-
71
- Run: `npm test -- tests/unit/core/hook/gate.test.ts`
72
- Expected: FAIL — module not found.
73
-
74
- - [ ] **Step 3: Implement the gate**
75
-
76
- Create `src/core/hook/gate.ts`:
77
-
78
- ```typescript
79
- /**
80
- * Prompt gate for the recall hook. Pure — no I/O.
81
- *
82
- * A conservative generative *excluder*: the default is "evaluate" (query
83
- * recall); only high-precision generative openers short-circuit to
84
- * "generative". A false "generative" wrongly skips recall — the exact
85
- * failure this feature fixes — so the generative set is deliberately tight.
86
- * It is calibrated further against shadow-mode logs.
87
- */
88
-
89
- export type PromptClass = "generative" | "evaluate";
90
-
91
- const LEADING_FILLER =
92
- /^(please|can you|could you|would you|will you|i need you to|i'd like you to|i want you to|i would like you to|help me|let's|lets|hey|ok|okay)\b[\s,]*/i;
93
-
94
- const GENERATIVE_OPENER =
95
- /^(write|draft|create|compose|generate|brainstorm|design|outline|sketch|invent|rename|come up with)\b/i;
96
-
97
- export function classifyPrompt(prompt: string): PromptClass {
98
- let p = prompt.trim();
99
- for (let i = 0; i < 3 && LEADING_FILLER.test(p); i++) {
100
- p = p.replace(LEADING_FILLER, "");
101
- }
102
- return GENERATIVE_OPENER.test(p) ? "generative" : "evaluate";
103
- }
104
- ```
105
-
106
- - [ ] **Step 4: Run the test to verify it passes**
107
-
108
- Run: `npm test -- tests/unit/core/hook/gate.test.ts`
109
- Expected: PASS — all 4 cases.
110
-
111
- - [ ] **Step 5: Commit**
112
-
113
- ```bash
114
- git checkout -b feat/recall-hook
115
- git add src/core/hook/gate.ts tests/unit/core/hook/gate.test.ts
116
- git commit -m "feat: add prompt gate for the recall hook"
117
- ```
118
-
119
- ---
120
-
121
- ## Task 2: Selection and pointer rendering (pure)
122
-
123
- Two pure modules: `select.ts` decides which recall hits to surface (score threshold, dedup against already-surfaced ids, per-fire and per-conversation caps); `pointer-block.ts` renders the chosen hits as the markdown pointer block.
124
-
125
- **Files:**
126
- - Create: `src/core/hook/select.ts`
127
- - Create: `src/core/hook/pointer-block.ts`
128
- - Test: `tests/unit/core/hook/select.test.ts`
129
- - Test: `tests/unit/core/hook/pointer-block.test.ts`
130
-
131
- - [ ] **Step 1: Write the failing test for selection**
132
-
133
- Create `tests/unit/core/hook/select.test.ts`:
134
-
135
- ```typescript
136
- import { describe, expect, it } from "vitest";
137
- import { selectHits, type RecallHitInput } from "../../../../src/core/hook/select.js";
138
-
139
- const hit = (id: string, matchScore: number): RecallHitInput => ({
140
- id,
141
- label: `label ${id}`,
142
- startedAt: "2026-05-15T10:00:00.000Z",
143
- matchScore,
144
- });
145
-
146
- describe("selectHits", () => {
147
- it("drops hits below the score threshold", () => {
148
- const out = selectHits({
149
- hits: [hit("a", 0.9), hit("b", 0.3)],
150
- surfaced: new Set(),
151
- scoreThreshold: 0.5,
152
- perFireCap: 3,
153
- perConversationCap: 10,
154
- });
155
- expect(out.map((h) => h.id)).toEqual(["a"]);
156
- });
157
-
158
- it("drops hits already surfaced in this conversation", () => {
159
- const out = selectHits({
160
- hits: [hit("a", 0.9), hit("b", 0.8)],
161
- surfaced: new Set(["a"]),
162
- scoreThreshold: 0.5,
163
- perFireCap: 3,
164
- perConversationCap: 10,
165
- });
166
- expect(out.map((h) => h.id)).toEqual(["b"]);
167
- });
168
-
169
- it("caps the number surfaced per fire", () => {
170
- const out = selectHits({
171
- hits: [hit("a", 0.9), hit("b", 0.8), hit("c", 0.7), hit("d", 0.6)],
172
- surfaced: new Set(),
173
- scoreThreshold: 0.5,
174
- perFireCap: 3,
175
- perConversationCap: 10,
176
- });
177
- expect(out.map((h) => h.id)).toEqual(["a", "b", "c"]);
178
- });
179
-
180
- it("respects the remaining per-conversation budget", () => {
181
- const out = selectHits({
182
- hits: [hit("a", 0.9), hit("b", 0.8), hit("c", 0.7)],
183
- surfaced: new Set(["x", "y", "z", "p", "q", "r", "s", "t", "u"]), // 9 surfaced
184
- scoreThreshold: 0.5,
185
- perFireCap: 3,
186
- perConversationCap: 10,
187
- });
188
- expect(out.map((h) => h.id)).toEqual(["a"]); // only 1 slot left
189
- });
190
-
191
- it("returns nothing when the per-conversation cap is already met", () => {
192
- const out = selectHits({
193
- hits: [hit("a", 0.9)],
194
- surfaced: new Set(Array.from({ length: 10 }, (_, i) => `s${i}`)),
195
- scoreThreshold: 0.5,
196
- perFireCap: 3,
197
- perConversationCap: 10,
198
- });
199
- expect(out).toEqual([]);
200
- });
201
- });
202
- ```
203
-
204
- - [ ] **Step 2: Run the test to verify it fails**
205
-
206
- Run: `npm test -- tests/unit/core/hook/select.test.ts`
207
- Expected: FAIL — module not found.
208
-
209
- - [ ] **Step 3: Implement selection**
210
-
211
- Create `src/core/hook/select.ts`:
212
-
213
- ```typescript
214
- /**
215
- * Selects which recall hits the hook surfaces. Pure — no I/O.
216
- *
217
- * Order of filtering: score threshold, then dedup against ids already
218
- * surfaced in this conversation, then the per-fire cap bounded by the
219
- * remaining per-conversation budget. Hits are assumed pre-ranked best-first.
220
- */
221
-
222
- export interface RecallHitInput {
223
- readonly id: string;
224
- readonly label: string;
225
- readonly startedAt: string;
226
- readonly matchScore: number;
227
- }
228
-
229
- export interface SelectParams {
230
- readonly hits: ReadonlyArray<RecallHitInput>;
231
- readonly surfaced: ReadonlySet<string>;
232
- readonly scoreThreshold: number;
233
- readonly perFireCap: number;
234
- readonly perConversationCap: number;
235
- }
236
-
237
- export function selectHits(params: SelectParams): ReadonlyArray<RecallHitInput> {
238
- const { hits, surfaced, scoreThreshold, perFireCap, perConversationCap } = params;
239
- const eligible = hits.filter(
240
- (h) => h.matchScore >= scoreThreshold && !surfaced.has(h.id),
241
- );
242
- const budget = Math.max(0, perConversationCap - surfaced.size);
243
- const limit = Math.min(perFireCap, budget);
244
- return eligible.slice(0, limit);
245
- }
246
- ```
247
-
248
- - [ ] **Step 4: Run the test to verify it passes**
249
-
250
- Run: `npm test -- tests/unit/core/hook/select.test.ts`
251
- Expected: PASS — all 5 cases.
252
-
253
- - [ ] **Step 5: Write the failing test for pointer-block**
254
-
255
- Create `tests/unit/core/hook/pointer-block.test.ts`:
256
-
257
- ```typescript
258
- import { describe, expect, it } from "vitest";
259
- import { formatPointerBlock } from "../../../../src/core/hook/pointer-block.js";
260
-
261
- describe("formatPointerBlock", () => {
262
- it("returns an empty string for no hits", () => {
263
- expect(formatPointerBlock([])).toBe("");
264
- });
265
-
266
- it("renders a header, one line per hit, and the tool footer", () => {
267
- const block = formatPointerBlock([
268
- { id: "sess_a", label: "FTS5 vs pgvector decision", startedAt: "2026-05-15T10:00:00.000Z" },
269
- { id: "sess_b", label: "Semantic recall via sqlite-vec", startedAt: "2026-05-17T09:30:00.000Z" },
270
- ]);
271
- expect(block).toContain("## Possibly-relevant prior sessions (nlm-memory)");
272
- expect(block).toContain("- sess_a · FTS5 vs pgvector decision (2026-05-15)");
273
- expect(block).toContain("- sess_b · Semantic recall via sqlite-vec (2026-05-17)");
274
- expect(block).toContain("recall_sessions");
275
- expect(block).toContain("get_session");
276
- });
277
- });
278
- ```
279
-
280
- - [ ] **Step 6: Run the test to verify it fails**
281
-
282
- Run: `npm test -- tests/unit/core/hook/pointer-block.test.ts`
283
- Expected: FAIL — module not found.
284
-
285
- - [ ] **Step 7: Implement pointer-block**
286
-
287
- Create `src/core/hook/pointer-block.ts`:
288
-
289
- ```typescript
290
- /**
291
- * Renders the pointer block injected by the recall hook in live mode. Pure.
292
- * Pointer-only by design: ids + labels, no session content — the agent
293
- * pulls detail via the recall_sessions / get_session MCP tools.
294
- */
295
-
296
- export interface PointerHit {
297
- readonly id: string;
298
- readonly label: string;
299
- readonly startedAt: string;
300
- }
301
-
302
- export function formatPointerBlock(hits: ReadonlyArray<PointerHit>): string {
303
- if (hits.length === 0) return "";
304
- const lines = hits.map(
305
- (h) => `- ${h.id} · ${h.label} (${h.startedAt.slice(0, 10)})`,
306
- );
307
- return [
308
- "## Possibly-relevant prior sessions (nlm-memory)",
309
- ...lines,
310
- "Pull detail with the recall_sessions / get_session MCP tools if relevant.",
311
- ].join("\n");
312
- }
313
- ```
314
-
315
- - [ ] **Step 8: Run both tests to verify they pass**
316
-
317
- Run: `npm test -- tests/unit/core/hook/select.test.ts tests/unit/core/hook/pointer-block.test.ts`
318
- Expected: PASS — 5 + 2 cases.
319
-
320
- - [ ] **Step 9: Commit**
321
-
322
- ```bash
323
- git add src/core/hook/select.ts src/core/hook/pointer-block.ts tests/unit/core/hook/select.test.ts tests/unit/core/hook/pointer-block.test.ts
324
- git commit -m "feat: add hit selection and pointer-block rendering for the recall hook"
325
- ```
326
-
327
- ---
328
-
329
- ## Task 3: Per-conversation dedup memo (file I/O)
330
-
331
- Tracks which session ids have already been surfaced in a conversation, so each is surfaced at most once. One JSON file per conversation under a state directory. The directory defaults to `~/.nlm/hook-state/` and is overridable via `NLM_HOOK_STATE_DIR` for testing (mirrors the `query-log.ts` env pattern). All functions are defensive — they never throw.
332
-
333
- **Files:**
334
- - Create: `src/core/hook/memo.ts`
335
- - Test: `tests/integration/hook-memo.test.ts`
336
-
337
- - [ ] **Step 1: Write the failing test**
338
-
339
- Create `tests/integration/hook-memo.test.ts`:
340
-
341
- ```typescript
342
- import { mkdtempSync, rmSync } from "node:fs";
343
- import { tmpdir } from "node:os";
344
- import { join } from "node:path";
345
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
346
- import { loadSurfaced, recordSurfaced } from "../../src/core/hook/memo.js";
347
-
348
- describe("hook memo", () => {
349
- let tmp: string;
350
-
351
- beforeEach(() => {
352
- tmp = mkdtempSync(join(tmpdir(), "nlm-memo-"));
353
- process.env["NLM_HOOK_STATE_DIR"] = tmp;
354
- });
355
-
356
- afterEach(() => {
357
- delete process.env["NLM_HOOK_STATE_DIR"];
358
- rmSync(tmp, { recursive: true, force: true });
359
- });
360
-
361
- it("returns an empty set for an unknown conversation", () => {
362
- expect(loadSurfaced("conv-1").size).toBe(0);
363
- });
364
-
365
- it("records and reloads surfaced ids", () => {
366
- recordSurfaced("conv-1", ["sess_a", "sess_b"]);
367
- const got = loadSurfaced("conv-1");
368
- expect([...got].sort()).toEqual(["sess_a", "sess_b"]);
369
- });
370
-
371
- it("accumulates across multiple records and dedups", () => {
372
- recordSurfaced("conv-1", ["sess_a"]);
373
- recordSurfaced("conv-1", ["sess_a", "sess_c"]);
374
- expect([...loadSurfaced("conv-1")].sort()).toEqual(["sess_a", "sess_c"]);
375
- });
376
-
377
- it("isolates conversations from each other", () => {
378
- recordSurfaced("conv-1", ["sess_a"]);
379
- recordSurfaced("conv-2", ["sess_z"]);
380
- expect([...loadSurfaced("conv-1")]).toEqual(["sess_a"]);
381
- expect([...loadSurfaced("conv-2")]).toEqual(["sess_z"]);
382
- });
383
-
384
- it("loadSurfaced returns empty on a corrupt memo file rather than throwing", () => {
385
- recordSurfaced("conv-1", ["sess_a"]);
386
- // overwrite with garbage
387
- const { writeFileSync } = require("node:fs");
388
- writeFileSync(join(tmp, "conv-1.json"), "{not json", "utf8");
389
- expect(loadSurfaced("conv-1").size).toBe(0);
390
- });
391
- });
392
- ```
393
-
394
- - [ ] **Step 2: Run the test to verify it fails**
395
-
396
- Run: `npm test -- tests/integration/hook-memo.test.ts`
397
- Expected: FAIL — module not found.
398
-
399
- - [ ] **Step 3: Implement the memo**
400
-
401
- Create `src/core/hook/memo.ts`:
402
-
403
- ```typescript
404
- /**
405
- * Per-conversation dedup memo for the recall hook. One JSON file per
406
- * conversation holds the set of session ids already surfaced, so each is
407
- * surfaced at most once per conversation.
408
- *
409
- * State dir defaults to ~/.nlm/hook-state/, overridable via
410
- * NLM_HOOK_STATE_DIR (testability — mirrors query-log.ts).
411
- *
412
- * Every function is defensive: a missing or corrupt file yields an empty
413
- * memo, and a write failure is swallowed. The hook must never break on memo
414
- * I/O.
415
- */
416
-
417
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
418
- import { homedir } from "node:os";
419
- import { join } from "node:path";
420
-
421
- function stateDir(): string {
422
- return process.env["NLM_HOOK_STATE_DIR"] ?? join(homedir(), ".nlm", "hook-state");
423
- }
424
-
425
- function memoPath(conversationId: string): string {
426
- const safe = conversationId.replace(/[^A-Za-z0-9_-]/g, "_") || "unknown";
427
- return join(stateDir(), `${safe}.json`);
428
- }
429
-
430
- export function loadSurfaced(conversationId: string): Set<string> {
431
- try {
432
- const path = memoPath(conversationId);
433
- if (!existsSync(path)) return new Set();
434
- const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
435
- if (!Array.isArray(parsed)) return new Set();
436
- return new Set(parsed.filter((x): x is string => typeof x === "string"));
437
- } catch {
438
- return new Set();
439
- }
440
- }
441
-
442
- export function recordSurfaced(
443
- conversationId: string,
444
- ids: ReadonlyArray<string>,
445
- ): void {
446
- try {
447
- const merged = loadSurfaced(conversationId);
448
- for (const id of ids) merged.add(id);
449
- mkdirSync(stateDir(), { recursive: true });
450
- writeFileSync(memoPath(conversationId), JSON.stringify([...merged]), "utf8");
451
- } catch {
452
- // Memo write failure must never break the hook.
453
- }
454
- }
455
- ```
456
-
457
- - [ ] **Step 4: Run the test to verify it passes**
458
-
459
- Run: `npm test -- tests/integration/hook-memo.test.ts`
460
- Expected: PASS — all 5 cases.
461
-
462
- - [ ] **Step 5: Commit**
463
-
464
- ```bash
465
- git add src/core/hook/memo.ts tests/integration/hook-memo.test.ts
466
- git commit -m "feat: add per-conversation dedup memo for the recall hook"
467
- ```
468
-
469
- ---
470
-
471
- ## Task 4: Shadow log (file I/O)
472
-
473
- An append-only JSONL log of every prompt the hook saw — the data the relevance gate is calibrated against during the shadow window. Path defaults to `~/.nlm/hook-log.jsonl`, overridable via `NLM_HOOK_LOG`. The append is defensive (swallows its own errors — telemetry must never break the hook).
474
-
475
- **Files:**
476
- - Create: `src/core/hook/hook-log.ts`
477
- - Test: `tests/integration/hook-log.test.ts`
478
-
479
- - [ ] **Step 1: Write the failing test**
480
-
481
- Create `tests/integration/hook-log.test.ts`:
482
-
483
- ```typescript
484
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
485
- import { tmpdir } from "node:os";
486
- import { join } from "node:path";
487
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
488
- import { appendHookLog, type HookLogEntry } from "../../src/core/hook/hook-log.js";
489
-
490
- const entry = (over: Partial<HookLogEntry> = {}): HookLogEntry => ({
491
- ts: "2026-05-20T12:00:00.000Z",
492
- conversationId: "conv-1",
493
- promptPreview: "what did we decide about pgvector",
494
- gate: "evaluate",
495
- hits: [{ id: "sess_a", score: 0.9 }],
496
- wouldInject: ["sess_a"],
497
- estTokens: 42,
498
- mode: "shadow",
499
- ...over,
500
- });
501
-
502
- describe("appendHookLog", () => {
503
- let tmp: string;
504
- let logPath: string;
505
-
506
- beforeEach(() => {
507
- tmp = mkdtempSync(join(tmpdir(), "nlm-hooklog-"));
508
- logPath = join(tmp, "hook-log.jsonl");
509
- process.env["NLM_HOOK_LOG"] = logPath;
510
- });
511
-
512
- afterEach(() => {
513
- delete process.env["NLM_HOOK_LOG"];
514
- rmSync(tmp, { recursive: true, force: true });
515
- });
516
-
517
- it("appends one JSON line per call", () => {
518
- appendHookLog(entry());
519
- appendHookLog(entry({ conversationId: "conv-2" }));
520
- const lines = readFileSync(logPath, "utf8").trim().split("\n");
521
- expect(lines).toHaveLength(2);
522
- const first = JSON.parse(lines[0] ?? "");
523
- expect(first.conversationId).toBe("conv-1");
524
- expect(first.wouldInject).toEqual(["sess_a"]);
525
- expect(first.estTokens).toBe(42);
526
- });
527
-
528
- it("creates the parent directory if missing", () => {
529
- process.env["NLM_HOOK_LOG"] = join(tmp, "nested", "deep", "hook-log.jsonl");
530
- appendHookLog(entry());
531
- const lines = readFileSync(
532
- join(tmp, "nested", "deep", "hook-log.jsonl"),
533
- "utf8",
534
- ).trim().split("\n");
535
- expect(lines).toHaveLength(1);
536
- });
537
- });
538
- ```
539
-
540
- - [ ] **Step 2: Run the test to verify it fails**
541
-
542
- Run: `npm test -- tests/integration/hook-log.test.ts`
543
- Expected: FAIL — module not found.
544
-
545
- - [ ] **Step 3: Implement the shadow log**
546
-
547
- Create `src/core/hook/hook-log.ts`:
548
-
549
- ```typescript
550
- /**
551
- * Append-only JSONL log for the recall hook. One line per prompt the hook
552
- * evaluated. This is the dataset the relevance gate (generative patterns +
553
- * score threshold) is calibrated against during the shadow window.
554
- *
555
- * Path defaults to ~/.nlm/hook-log.jsonl, overridable via NLM_HOOK_LOG.
556
- * appendHookLog swallows its own errors — telemetry must never break the hook.
557
- */
558
-
559
- import { appendFileSync, mkdirSync } from "node:fs";
560
- import { homedir } from "node:os";
561
- import { dirname, join } from "node:path";
562
- import type { PromptClass } from "./gate.js";
563
-
564
- export interface HookLogEntry {
565
- readonly ts: string;
566
- readonly conversationId: string;
567
- readonly promptPreview: string;
568
- readonly gate: PromptClass;
569
- readonly hits: ReadonlyArray<{ readonly id: string; readonly score: number }>;
570
- readonly wouldInject: ReadonlyArray<string>;
571
- readonly estTokens: number;
572
- readonly mode: "shadow" | "live";
573
- }
574
-
575
- function logPath(): string {
576
- return process.env["NLM_HOOK_LOG"] ?? join(homedir(), ".nlm", "hook-log.jsonl");
577
- }
578
-
579
- export function appendHookLog(entry: HookLogEntry): void {
580
- try {
581
- const path = logPath();
582
- mkdirSync(dirname(path), { recursive: true });
583
- appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8");
584
- } catch {
585
- // Telemetry failure must never break the hook.
586
- }
587
- }
588
- ```
589
-
590
- - [ ] **Step 4: Run the test to verify it passes**
591
-
592
- Run: `npm test -- tests/integration/hook-log.test.ts`
593
- Expected: PASS — both cases.
594
-
595
- - [ ] **Step 5: Commit**
596
-
597
- ```bash
598
- git add src/core/hook/hook-log.ts tests/integration/hook-log.test.ts
599
- git commit -m "feat: add shadow log for the recall hook"
600
- ```
601
-
602
- ---
603
-
604
- ## Task 5: Hook orchestrator
605
-
606
- The entrypoint Claude Code invokes per prompt. Split into a testable `runHook` (the orchestration: gate → recall → select → log → memo → return stdout text) and a thin `main` (stdin/stdout/fetch/env, fail-open). `runHook` takes the recall query as an injected dependency so it can be unit-tested with a fake.
607
-
608
- **Files:**
609
- - Create: `src/hook/prompt-recall-hook.ts`
610
- - Test: `tests/integration/prompt-recall-hook.test.ts`
611
-
612
- - [ ] **Step 1: Write the failing test**
613
-
614
- Create `tests/integration/prompt-recall-hook.test.ts`:
615
-
616
- ```typescript
617
- import { mkdtempSync, existsSync, readFileSync, rmSync } from "node:fs";
618
- import { tmpdir } from "node:os";
619
- import { join } from "node:path";
620
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
621
- import { runHook } from "../../src/hook/prompt-recall-hook.js";
622
- import type { RecallHitInput } from "../../src/core/hook/select.js";
623
-
624
- const hits = (...ids: string[]): ReadonlyArray<RecallHitInput> =>
625
- ids.map((id, i) => ({
626
- id,
627
- label: `Session ${id}`,
628
- startedAt: "2026-05-15T10:00:00.000Z",
629
- matchScore: 0.9 - i * 0.01,
630
- }));
631
-
632
- describe("runHook", () => {
633
- let tmp: string;
634
-
635
- beforeEach(() => {
636
- tmp = mkdtempSync(join(tmpdir(), "nlm-hook-"));
637
- process.env["NLM_HOOK_STATE_DIR"] = join(tmp, "state");
638
- process.env["NLM_HOOK_LOG"] = join(tmp, "hook-log.jsonl");
639
- });
640
-
641
- afterEach(() => {
642
- delete process.env["NLM_HOOK_STATE_DIR"];
643
- delete process.env["NLM_HOOK_LOG"];
644
- rmSync(tmp, { recursive: true, force: true });
645
- });
646
-
647
- it("shadow mode logs but returns no stdout", async () => {
648
- const out = await runHook(
649
- { prompt: "what did we decide about pgvector", conversationId: "c1" },
650
- { mode: "shadow", recall: async () => hits("sess_a") },
651
- );
652
- expect(out).toBe("");
653
- const log = readFileSync(join(tmp, "hook-log.jsonl"), "utf8").trim();
654
- expect(JSON.parse(log).wouldInject).toEqual(["sess_a"]);
655
- expect(JSON.parse(log).mode).toBe("shadow");
656
- });
657
-
658
- it("shadow mode does not write the memo", async () => {
659
- await runHook(
660
- { prompt: "what did we decide", conversationId: "c1" },
661
- { mode: "shadow", recall: async () => hits("sess_a") },
662
- );
663
- expect(existsSync(join(tmp, "state", "c1.json"))).toBe(false);
664
- });
665
-
666
- it("live mode returns the pointer block and records the memo", async () => {
667
- const out = await runHook(
668
- { prompt: "what did we decide about pgvector", conversationId: "c1" },
669
- { mode: "live", recall: async () => hits("sess_a", "sess_b") },
670
- );
671
- expect(out).toContain("## Possibly-relevant prior sessions (nlm-memory)");
672
- expect(out).toContain("sess_a");
673
- const memo = JSON.parse(readFileSync(join(tmp, "state", "c1.json"), "utf8"));
674
- expect(memo.sort()).toEqual(["sess_a", "sess_b"]);
675
- });
676
-
677
- it("live mode dedups: a second fire does not re-surface the same session", async () => {
678
- const deps = { mode: "live" as const, recall: async () => hits("sess_a") };
679
- const first = await runHook({ prompt: "what did we decide", conversationId: "c1" }, deps);
680
- expect(first).toContain("sess_a");
681
- const second = await runHook({ prompt: "and what else did we decide", conversationId: "c1" }, deps);
682
- expect(second).toBe("");
683
- });
684
-
685
- it("generative prompts skip recall entirely", async () => {
686
- let called = false;
687
- const out = await runHook(
688
- { prompt: "draft a blog post about FTS5", conversationId: "c1" },
689
- { mode: "live", recall: async () => { called = true; return hits("sess_a"); } },
690
- );
691
- expect(out).toBe("");
692
- expect(called).toBe(false);
693
- const log = readFileSync(join(tmp, "hook-log.jsonl"), "utf8").trim();
694
- expect(JSON.parse(log).gate).toBe("generative");
695
- });
696
-
697
- it("returns empty and does not throw when recall rejects", async () => {
698
- const out = await runHook(
699
- { prompt: "what did we decide", conversationId: "c1" },
700
- { mode: "live", recall: async () => { throw new Error("daemon down"); } },
701
- );
702
- expect(out).toBe("");
703
- });
704
- });
705
- ```
706
-
707
- - [ ] **Step 2: Run the test to verify it fails**
708
-
709
- Run: `npm test -- tests/integration/prompt-recall-hook.test.ts`
710
- Expected: FAIL — module not found.
711
-
712
- - [ ] **Step 3: Implement the orchestrator**
713
-
714
- Create `src/hook/prompt-recall-hook.ts`:
715
-
716
- ```typescript
717
- /**
718
- * Claude Code UserPromptSubmit hook entrypoint for NLM recall.
719
- *
720
- * runHook is the testable orchestration; main() is the thin process wrapper
721
- * (stdin / stdout / fetch / env). Every path is fail-open: any error yields
722
- * no output and a clean exit, so the hook can never block or fail a prompt.
723
- *
724
- * Mode is read from NLM_HOOK_MODE (default "shadow"). In shadow mode the
725
- * hook logs what it would inject and emits nothing; in live mode it emits a
726
- * pointer block and records the per-conversation memo.
727
- */
728
-
729
- import { classifyPrompt } from "@core/hook/gate.js";
730
- import { appendHookLog } from "@core/hook/hook-log.js";
731
- import { loadSurfaced, recordSurfaced } from "@core/hook/memo.js";
732
- import { formatPointerBlock } from "@core/hook/pointer-block.js";
733
- import { selectHits, type RecallHitInput } from "@core/hook/select.js";
734
-
735
- const SCORE_THRESHOLD = 0.5; // conservative start; calibrated in shadow mode
736
- const PER_FIRE_CAP = 3;
737
- const PER_CONVERSATION_CAP = 10;
738
- const RECALL_LIMIT = 5;
739
- const RECALL_TIMEOUT_MS = 1000;
740
- const PROMPT_PREVIEW_CHARS = 200;
741
-
742
- export type HookMode = "shadow" | "live";
743
-
744
- export interface HookInput {
745
- readonly prompt: string;
746
- readonly conversationId: string;
747
- }
748
-
749
- export interface RunHookDeps {
750
- readonly mode: HookMode;
751
- readonly recall: (prompt: string) => Promise<ReadonlyArray<RecallHitInput>>;
752
- }
753
-
754
- /** Orchestration. Returns the text to emit on stdout ("" for nothing). */
755
- export async function runHook(input: HookInput, deps: RunHookDeps): Promise<string> {
756
- const gate = classifyPrompt(input.prompt);
757
- const preview = input.prompt.slice(0, PROMPT_PREVIEW_CHARS);
758
-
759
- if (gate === "generative") {
760
- appendHookLog({
761
- ts: new Date().toISOString(),
762
- conversationId: input.conversationId,
763
- promptPreview: preview,
764
- gate,
765
- hits: [],
766
- wouldInject: [],
767
- estTokens: 0,
768
- mode: deps.mode,
769
- });
770
- return "";
771
- }
772
-
773
- let hits: ReadonlyArray<RecallHitInput> = [];
774
- try {
775
- hits = await deps.recall(input.prompt);
776
- } catch {
777
- hits = [];
778
- }
779
-
780
- const surfaced = loadSurfaced(input.conversationId);
781
- const selected = selectHits({
782
- hits,
783
- surfaced,
784
- scoreThreshold: SCORE_THRESHOLD,
785
- perFireCap: PER_FIRE_CAP,
786
- perConversationCap: PER_CONVERSATION_CAP,
787
- });
788
- const block = formatPointerBlock(selected);
789
- const estTokens = Math.ceil(block.length / 4);
790
-
791
- appendHookLog({
792
- ts: new Date().toISOString(),
793
- conversationId: input.conversationId,
794
- promptPreview: preview,
795
- gate,
796
- hits: hits.map((h) => ({ id: h.id, score: h.matchScore })),
797
- wouldInject: selected.map((h) => h.id),
798
- estTokens,
799
- mode: deps.mode,
800
- });
801
-
802
- if (deps.mode === "live" && selected.length > 0) {
803
- recordSurfaced(input.conversationId, selected.map((h) => h.id));
804
- return block;
805
- }
806
- return "";
807
- }
808
-
809
- function readStdin(): Promise<string> {
810
- return new Promise((resolve) => {
811
- let data = "";
812
- process.stdin.setEncoding("utf8");
813
- process.stdin.on("data", (chunk) => (data += chunk));
814
- process.stdin.on("end", () => resolve(data));
815
- process.stdin.on("error", () => resolve(data));
816
- });
817
- }
818
-
819
- async function recallOverHttp(prompt: string): Promise<ReadonlyArray<RecallHitInput>> {
820
- const portValue = process.env["NLM_PORT"] ?? "3940";
821
- const url =
822
- `http://localhost:${portValue}/api/recall` +
823
- `?q=${encodeURIComponent(prompt)}&mode=hybrid&limit=${RECALL_LIMIT}`;
824
- const controller = new AbortController();
825
- const timer = setTimeout(() => controller.abort(), RECALL_TIMEOUT_MS);
826
- try {
827
- const res = await fetch(url, {
828
- headers: { "x-recall-source": "hook" },
829
- signal: controller.signal,
830
- });
831
- if (!res.ok) return [];
832
- const body = (await res.json()) as {
833
- results?: ReadonlyArray<{
834
- id: string;
835
- label: string;
836
- startedAt: string;
837
- matchScore: number;
838
- }>;
839
- };
840
- return (body.results ?? []).map((r) => ({
841
- id: r.id,
842
- label: r.label,
843
- startedAt: r.startedAt,
844
- matchScore: r.matchScore,
845
- }));
846
- } finally {
847
- clearTimeout(timer);
848
- }
849
- }
850
-
851
- async function main(): Promise<void> {
852
- try {
853
- const raw = await readStdin();
854
- const payload = JSON.parse(raw) as {
855
- prompt?: unknown;
856
- session_id?: unknown;
857
- };
858
- const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
859
- const conversationId =
860
- typeof payload.session_id === "string" ? payload.session_id : "unknown";
861
- if (!prompt) return;
862
-
863
- const mode: HookMode = process.env["NLM_HOOK_MODE"] === "live" ? "live" : "shadow";
864
- const out = await runHook(
865
- { prompt, conversationId },
866
- { mode, recall: recallOverHttp },
867
- );
868
- if (out) process.stdout.write(out);
869
- } catch {
870
- // Fail open — never block or fail a prompt.
871
- }
872
- }
873
-
874
- // Run main() only when invoked as a script, not when imported by tests.
875
- if (process.argv[1] && process.argv[1].endsWith("prompt-recall-hook.js")) {
876
- void main();
877
- }
878
- ```
879
-
880
- - [ ] **Step 4: Run the test to verify it passes**
881
-
882
- Run: `npm test -- tests/integration/prompt-recall-hook.test.ts`
883
- Expected: PASS — all 6 cases.
884
-
885
- - [ ] **Step 5: Run the full suite and typecheck**
886
-
887
- Run: `npm test && npm run typecheck`
888
- Expected: PASS — whole suite green, typecheck clean.
889
-
890
- - [ ] **Step 6: Commit**
891
-
892
- ```bash
893
- git add src/hook/prompt-recall-hook.ts tests/integration/prompt-recall-hook.test.ts
894
- git commit -m "feat: add recall hook orchestrator (shadow/live, fail-open)"
895
- ```
896
-
897
- ---
898
-
899
- ## Task 6: Claude settings editor + `nlm hook` CLI
900
-
901
- A module that adds/removes the hook entry in `~/.claude/settings.json`, plus the `nlm hook install` / `nlm hook uninstall` CLI subcommands wired to it.
902
-
903
- **Files:**
904
- - Create: `src/core/hook/claude-settings.ts`
905
- - Test: `tests/integration/hook-claude-settings.test.ts`
906
- - Modify: `src/cli/nlm.ts`
907
-
908
- - [ ] **Step 1: Write the failing test**
909
-
910
- Create `tests/integration/hook-claude-settings.test.ts`:
911
-
912
- ```typescript
913
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
914
- import { tmpdir } from "node:os";
915
- import { join } from "node:path";
916
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
917
- import { addHook, removeHook } from "../../src/core/hook/claude-settings.js";
918
-
919
- interface Settings {
920
- hooks?: { UserPromptSubmit?: Array<{ hooks: Array<{ type: string; command: string }> }> };
921
- }
922
-
923
- describe("claude-settings hook editor", () => {
924
- let tmp: string;
925
- let settingsPath: string;
926
- const CMD = "NLM_HOOK_MODE=shadow node /abs/dist/hook/prompt-recall-hook.js";
927
-
928
- beforeEach(() => {
929
- tmp = mkdtempSync(join(tmpdir(), "nlm-settings-"));
930
- settingsPath = join(tmp, "settings.json");
931
- });
932
-
933
- afterEach(() => rmSync(tmp, { recursive: true, force: true }));
934
-
935
- it("creates settings.json with the hook entry when the file is absent", () => {
936
- addHook(settingsPath, CMD);
937
- const s = JSON.parse(readFileSync(settingsPath, "utf8")) as Settings;
938
- const entries = s.hooks?.UserPromptSubmit ?? [];
939
- expect(entries).toHaveLength(1);
940
- expect(entries[0]?.hooks[0]?.command).toBe(CMD);
941
- });
942
-
943
- it("preserves unrelated existing settings and hooks", () => {
944
- writeFileSync(
945
- settingsPath,
946
- JSON.stringify({
947
- model: "sonnet",
948
- hooks: { UserPromptSubmit: [{ hooks: [{ type: "command", command: "other-tool" }] }] },
949
- }),
950
- "utf8",
951
- );
952
- addHook(settingsPath, CMD);
953
- const s = JSON.parse(readFileSync(settingsPath, "utf8")) as Settings & { model?: string };
954
- expect(s.model).toBe("sonnet");
955
- const cmds = (s.hooks?.UserPromptSubmit ?? []).flatMap((e) => e.hooks.map((h) => h.command));
956
- expect(cmds).toContain("other-tool");
957
- expect(cmds).toContain(CMD);
958
- });
959
-
960
- it("is idempotent — re-adding does not duplicate the nlm entry", () => {
961
- addHook(settingsPath, CMD);
962
- addHook(settingsPath, "NLM_HOOK_MODE=live node /abs/dist/hook/prompt-recall-hook.js");
963
- const s = JSON.parse(readFileSync(settingsPath, "utf8")) as Settings;
964
- const cmds = (s.hooks?.UserPromptSubmit ?? []).flatMap((e) => e.hooks.map((h) => h.command));
965
- const nlmCmds = cmds.filter((c) => c.includes("prompt-recall-hook.js"));
966
- expect(nlmCmds).toHaveLength(1);
967
- expect(nlmCmds[0]).toContain("NLM_HOOK_MODE=live");
968
- });
969
-
970
- it("removeHook removes only the nlm entry and leaves others intact", () => {
971
- writeFileSync(
972
- settingsPath,
973
- JSON.stringify({
974
- hooks: { UserPromptSubmit: [{ hooks: [{ type: "command", command: "other-tool" }] }] },
975
- }),
976
- "utf8",
977
- );
978
- addHook(settingsPath, CMD);
979
- removeHook(settingsPath);
980
- const s = JSON.parse(readFileSync(settingsPath, "utf8")) as Settings;
981
- const cmds = (s.hooks?.UserPromptSubmit ?? []).flatMap((e) => e.hooks.map((h) => h.command));
982
- expect(cmds).toEqual(["other-tool"]);
983
- });
984
-
985
- it("removeHook is a no-op when settings.json does not exist", () => {
986
- expect(() => removeHook(settingsPath)).not.toThrow();
987
- });
988
- });
989
- ```
990
-
991
- - [ ] **Step 2: Run the test to verify it fails**
992
-
993
- Run: `npm test -- tests/integration/hook-claude-settings.test.ts`
994
- Expected: FAIL — module not found.
995
-
996
- - [ ] **Step 3: Implement the settings editor**
997
-
998
- Create `src/core/hook/claude-settings.ts`:
999
-
1000
- ```typescript
1001
- /**
1002
- * Adds/removes the NLM recall hook entry in a Claude Code settings.json.
1003
- *
1004
- * The nlm entry is identified by its command containing the marker
1005
- * "prompt-recall-hook.js". add is idempotent (it replaces any prior nlm
1006
- * entry); remove strips only the nlm entry and preserves everything else.
1007
- */
1008
-
1009
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1010
- import { dirname } from "node:path";
1011
-
1012
- const HOOK_MARKER = "prompt-recall-hook.js";
1013
-
1014
- interface HookCommand {
1015
- readonly type: string;
1016
- readonly command: string;
1017
- }
1018
- interface HookEntry {
1019
- readonly hooks: ReadonlyArray<HookCommand>;
1020
- }
1021
- interface ClaudeSettings {
1022
- hooks?: { UserPromptSubmit?: HookEntry[] } & Record<string, unknown>;
1023
- [key: string]: unknown;
1024
- }
1025
-
1026
- function read(path: string): ClaudeSettings {
1027
- if (!existsSync(path)) return {};
1028
- const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
1029
- if (typeof parsed !== "object" || parsed === null) {
1030
- throw new Error(`Claude settings at ${path} is not a JSON object`);
1031
- }
1032
- return parsed as ClaudeSettings;
1033
- }
1034
-
1035
- function write(path: string, settings: ClaudeSettings): void {
1036
- mkdirSync(dirname(path), { recursive: true });
1037
- writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
1038
- }
1039
-
1040
- function isNlmEntry(entry: HookEntry): boolean {
1041
- return entry.hooks.some((h) => h.command.includes(HOOK_MARKER));
1042
- }
1043
-
1044
- export function addHook(settingsPath: string, command: string): void {
1045
- const settings = read(settingsPath);
1046
- const hooks = settings.hooks ?? {};
1047
- const existing = hooks.UserPromptSubmit ?? [];
1048
- const others = existing.filter((e) => !isNlmEntry(e));
1049
- const next: HookEntry[] = [
1050
- ...others,
1051
- { hooks: [{ type: "command", command }] },
1052
- ];
1053
- write(settingsPath, { ...settings, hooks: { ...hooks, UserPromptSubmit: next } });
1054
- }
1055
-
1056
- export function removeHook(settingsPath: string): void {
1057
- if (!existsSync(settingsPath)) return;
1058
- const settings = read(settingsPath);
1059
- const existing = settings.hooks?.UserPromptSubmit;
1060
- if (!existing) return;
1061
- const kept = existing.filter((e) => !isNlmEntry(e));
1062
- const hooks = { ...settings.hooks, UserPromptSubmit: kept };
1063
- write(settingsPath, { ...settings, hooks });
1064
- }
1065
- ```
1066
-
1067
- - [ ] **Step 4: Run the test to verify it passes**
1068
-
1069
- Run: `npm test -- tests/integration/hook-claude-settings.test.ts`
1070
- Expected: PASS — all 5 cases.
1071
-
1072
- - [ ] **Step 5: Wire the `nlm hook` CLI subcommands**
1073
-
1074
- In `src/cli/nlm.ts`:
1075
-
1076
- (a) Add these imports alongside the existing imports near the top of the file:
1077
-
1078
- ```typescript
1079
- import { addHook, removeHook } from "../core/hook/claude-settings.js";
1080
- ```
1081
-
1082
- (b) Add the `hook` command group immediately before the final `program.parseAsync()` call at the end of the file (after the `uninstall` command block):
1083
-
1084
- ```typescript
1085
- const HOOK_JS = resolve(__dirname, "../hook/prompt-recall-hook.js");
1086
-
1087
- function claudeSettingsPath(): string {
1088
- return process.env["NLM_CLAUDE_SETTINGS"] ?? join(homedir(), ".claude", "settings.json");
1089
- }
1090
-
1091
- const hook = program
1092
- .command("hook")
1093
- .description("Manage the Claude Code recall hook");
1094
-
1095
- hook
1096
- .command("install")
1097
- .description("Add the recall hook to ~/.claude/settings.json (shadow mode)")
1098
- .action(() => {
1099
- const path = claudeSettingsPath();
1100
- const command = `NLM_HOOK_MODE=shadow node ${HOOK_JS}`;
1101
- addHook(path, command);
1102
- console.error(`nlm: recall hook installed in ${path} (shadow mode).`);
1103
- console.error(" It logs to ~/.nlm/hook-log.jsonl and injects nothing.");
1104
- console.error(" To go live later: change NLM_HOOK_MODE=shadow to live in that file.");
1105
- console.error(" To remove: nlm hook uninstall");
1106
- });
1107
-
1108
- hook
1109
- .command("uninstall")
1110
- .description("Remove the recall hook from ~/.claude/settings.json")
1111
- .action(() => {
1112
- const path = claudeSettingsPath();
1113
- removeHook(path);
1114
- console.error(`nlm: recall hook removed from ${path}.`);
1115
- });
1116
- ```
1117
-
1118
- Note: `resolve`, `join`, and `homedir` are already imported at the top of `nlm.ts` — do not re-import them. `__dirname` resolves to `dist/cli` at runtime, so `../hook/prompt-recall-hook.js` correctly points at `dist/hook/prompt-recall-hook.js`.
1119
-
1120
- (c) Update the subcommand list in the file's top doc comment — add these two lines after the `nlm uninstall` line:
1121
-
1122
- ```
1123
- * nlm hook install — add the recall hook to Claude Code (shadow mode)
1124
- * nlm hook uninstall — remove the recall hook from Claude Code
1125
- ```
1126
-
1127
- - [ ] **Step 6: Verify the build and full suite**
1128
-
1129
- Run: `npm run typecheck && npm test`
1130
- Expected: PASS — typecheck clean, whole suite green.
1131
-
1132
- - [ ] **Step 7: Commit**
1133
-
1134
- ```bash
1135
- git add src/core/hook/claude-settings.ts tests/integration/hook-claude-settings.test.ts src/cli/nlm.ts
1136
- git commit -m "feat: add nlm hook install/uninstall CLI and Claude settings editor"
1137
- ```
1138
-
1139
- ---
1140
-
1141
- ## Task 7: Rebuild `dist/` and update the CHANGELOG
1142
-
1143
- `dist/` is committed in this repo. Rebuild it so the hook ships, and append the CHANGELOG entry per repo protocol.
1144
-
1145
- **Files:**
1146
- - Modify: `dist/` (regenerated)
1147
- - Modify: `logs/CHANGELOG/CHANGELOG.md`
1148
-
1149
- - [ ] **Step 1: Rebuild `dist/`**
1150
-
1151
- Run: `npm run build`
1152
- Expected: `build:server` and `build:ui` both succeed. Confirm `dist/hook/prompt-recall-hook.js` and `dist/core/hook/` exist afterward. If the build fails, STOP and report the error.
1153
-
1154
- - [ ] **Step 2: Append the CHANGELOG entry**
1155
-
1156
- Insert this as the newest (first) dated entry in `logs/CHANGELOG/CHANGELOG.md`, immediately below the title/intro block:
1157
-
1158
- ```markdown
1159
- ## 2026-05-20 — Auto-inject recall hook (task #144, shadow mode)
1160
-
1161
- A Claude Code `UserPromptSubmit` hook that surfaces relevant prior sessions automatically, so read-side recall no longer depends on the agent choosing to call the MCP tool.
1162
-
1163
- **Changes**
1164
- - `src/core/hook/` — pure gate (`classifyPrompt`), selection (`selectHits`), pointer rendering (`formatPointerBlock`); file-backed per-conversation memo and JSONL shadow log; Claude `settings.json` editor.
1165
- - `src/hook/prompt-recall-hook.ts` — orchestrator. Reads the prompt from stdin, gates it, queries `/api/recall` (`x-recall-source: hook`), dedups against the memo, logs always; in live mode emits a capped pointer block. Every path is fail-open.
1166
- - `nlm hook install` / `nlm hook uninstall` — manage the `UserPromptSubmit` entry in `~/.claude/settings.json`. Separate from `nlm install`.
1167
-
1168
- **Decisions**
1169
- - Ships in shadow mode (`NLM_HOOK_MODE`, default `shadow`): logs what it would inject, injects nothing. Calibrate the gate against `~/.nlm/hook-log.jsonl` for 1-2 weeks, then flip to `live`.
1170
- - Pointer-only payload; each session surfaced at most once per conversation (dedup memo); caps of 3 per fire / 10 per conversation — keeps token cost minimal.
1171
- - Complements the MCP server (does not replace it): the hook is push/awareness, the MCP tools are pull/retrieval and the cross-runtime read path.
1172
-
1173
- **State:** v0.3.0. Hook installed in shadow mode; live activation pending the calibration window.
1174
- ```
1175
-
1176
- If `CHANGELOG.md` now exceeds 10 `## ` date headings, move the oldest beyond 10 into `logs/CHANGELOG/CHANGELOG-2026.md` (prepend) and ensure the `_Older entries archived in CHANGELOG-2026.md_` pointer line is present at the bottom of `CHANGELOG.md`.
1177
-
1178
- - [ ] **Step 3: Final verification**
1179
-
1180
- Run: `npm test && npm run typecheck`
1181
- Expected: PASS — full suite green, typecheck clean.
1182
-
1183
- - [ ] **Step 4: Commit**
1184
-
1185
- ```bash
1186
- git add dist logs/CHANGELOG/CHANGELOG.md
1187
- git commit -m "build: rebuild dist for the recall hook + CHANGELOG"
1188
- ```
1189
-
1190
- If Step 2 created/modified `CHANGELOG-2026.md`, `git add logs/CHANGELOG/CHANGELOG-2026.md` before committing.
1191
-
1192
- ---
1193
-
1194
- ## Self-Review
1195
-
1196
- **Spec coverage:**
1197
- - Gate (heuristic prefilter, generative excluder) → Task 1. ✓
1198
- - Recall-score threshold + dedup + caps → Task 2 (`selectHits`). ✓
1199
- - Pointer-only payload → Task 2 (`formatPointerBlock`). ✓
1200
- - Per-conversation dedup memo → Task 3. ✓
1201
- - Shadow log (JSONL, est. token cost) → Task 4. ✓
1202
- - Shadow/live modes, `NLM_HOOK_MODE` default shadow, fail-open, data flow → Task 5. ✓
1203
- - `nlm hook install`/`uninstall`, separate from `nlm install`, idempotent → Task 6. ✓
1204
- - Distribution via committed `dist/` + CHANGELOG → Task 7. ✓
1205
- - Token discipline (caps, dedup, suppress empty fires, est. tokens logged) → enforced in `selectHits` (Task 2) and `runHook` (Task 5). ✓
1206
- - Failure modes (daemon down, malformed stdin, timeout, corrupt memo) → Task 5 (`main` try/catch, recall try/catch, timeout) and Task 3 (defensive memo). ✓
1207
-
1208
- **Placeholder scan:** No TBDs; every code step has complete code; every command has an expected result. The `SCORE_THRESHOLD = 0.5` and the generative pattern set are intentional conservative starting values (the spec defers their calibration to the shadow window) — not placeholders.
1209
-
1210
- **Type consistency:** `RecallHitInput {id,label,startedAt,matchScore}` defined in Task 2, imported unchanged by Task 5. `PromptClass` from Task 1 imported by Task 4's `HookLogEntry` and Task 5. `HookLogEntry` defined in Task 4, constructed in Task 5 with matching fields. `selectHits`/`SelectParams`, `formatPointerBlock`/`PointerHit`, `addHook`/`removeHook`, `loadSurfaced`/`recordSurfaced`, `appendHookLog`, `runHook`/`RunHookDeps`/`HookInput` — all signatures consistent between definition and call sites. `PointerHit` (Task 2) is structurally satisfied by `RecallHitInput` (Task 5 passes `selected` to `formatPointerBlock`). ✓
1211
-
1212
- ---
1213
-
1214
- ## Execution Handoff
1215
-
1216
- **Plan complete and saved to `docs/plans/2026-05-20-recall-hook-implementation.md`. Two execution options:**
1217
-
1218
- **1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
1219
-
1220
- **2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints.
1221
-
1222
- **Which approach?**