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,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?**