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.
- package/README.md +72 -34
- package/dist/cli/nlm.js +223 -33
- package/dist/cli/nlm.js.map +1 -1
- package/dist/core/adapters/cursor.d.ts +45 -0
- package/dist/core/adapters/cursor.js +397 -0
- package/dist/core/adapters/cursor.js.map +1 -0
- package/dist/core/adapters/from-source.js +10 -0
- package/dist/core/adapters/from-source.js.map +1 -1
- package/dist/core/adapters/windsurf.d.ts +44 -0
- package/dist/core/adapters/windsurf.js +299 -0
- package/dist/core/adapters/windsurf.js.map +1 -0
- package/dist/core/hook/claude-settings.d.ts +12 -5
- package/dist/core/hook/claude-settings.js +21 -6
- package/dist/core/hook/claude-settings.js.map +1 -1
- package/dist/core/sources/source-registry.d.ts +1 -1
- package/dist/core/sources/source-registry.js +18 -0
- package/dist/core/sources/source-registry.js.map +1 -1
- package/dist/core/storage/sqlite-session-store.d.ts +2 -0
- package/dist/core/storage/sqlite-session-store.js +38 -2
- package/dist/core/storage/sqlite-session-store.js.map +1 -1
- package/dist/hook/hook-auth.d.ts +13 -0
- package/dist/hook/hook-auth.js +19 -0
- package/dist/hook/hook-auth.js.map +1 -0
- package/dist/hook/prompt-recall-hook.js +7 -1
- package/dist/hook/prompt-recall-hook.js.map +1 -1
- package/dist/hook/session-start-hook.js +4 -1
- package/dist/hook/session-start-hook.js.map +1 -1
- package/dist/hook/stop-hook.js +4 -1
- package/dist/hook/stop-hook.js.map +1 -1
- package/dist/http/app.d.ts +2 -0
- package/dist/http/app.js +76 -1
- package/dist/http/app.js.map +1 -1
- package/dist/install/claude-code.js +1 -1
- package/dist/install/claude-code.js.map +1 -1
- package/dist/install/cursor.d.ts +25 -0
- package/dist/install/cursor.js +43 -0
- package/dist/install/cursor.js.map +1 -0
- package/dist/install/nlm-dir-perms.d.ts +19 -0
- package/dist/install/nlm-dir-perms.js +43 -0
- package/dist/install/nlm-dir-perms.js.map +1 -0
- package/dist/install/ollama.d.ts +18 -1
- package/dist/install/ollama.js +62 -7
- package/dist/install/ollama.js.map +1 -1
- package/dist/install/setup.d.ts +4 -0
- package/dist/install/setup.js +141 -18
- package/dist/install/setup.js.map +1 -1
- package/dist/install/windsurf.d.ts +25 -0
- package/dist/install/windsurf.js +43 -0
- package/dist/install/windsurf.js.map +1 -0
- package/dist/mcp/server.js +20 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/types.d.ts +4 -0
- package/dist/ui/assets/{index-BA6IpU8g.css → index-Beo8psd-.css} +1 -1
- package/dist/ui/assets/index-CSPTTeeM.js +69 -0
- package/dist/ui/index.html +2 -2
- package/package.json +26 -1
- package/plugin/scripts/prompt-recall-hook.mjs +55 -4
- package/plugin/scripts/stop-hook.mjs +57 -6
- package/.agents/plugins/marketplace.json +0 -20
- package/.github/workflows/ci.yml +0 -30
- package/dist/ui/assets/index-B_qIVV0k.js +0 -69
- package/docs/methodology/re-derivation-rate.md +0 -112
- package/docs/methodology/useful-hit-rate.md +0 -79
- package/docs/plans/2026-05-20-fts5-lexical-recall.md +0 -1088
- package/docs/plans/2026-05-20-recall-daemon-wedge-fix.md +0 -662
- package/docs/plans/2026-05-20-recall-hook-design.md +0 -131
- package/docs/plans/2026-05-20-recall-hook-implementation.md +0 -1222
- package/docs/plans/desktop-product.md +0 -69
- package/docs/plans/factstore-design.md +0 -236
- package/logs/CHANGELOG/CHANGELOG-2026.md +0 -1389
- package/logs/CHANGELOG/CHANGELOG.md +0 -337
- package/migrations/000_initial_schema.sql +0 -174
- package/migrations/001_entity_type_rename.sql +0 -17
- package/migrations/002_adapter_state_extend.sql +0 -12
- package/migrations/003_session_embeddings.sql +0 -11
- package/migrations/004_facts.sql +0 -46
- package/migrations/005_sources.sql +0 -31
- package/migrations/006_providers.sql +0 -33
- package/migrations/007_source_tokens.sql +0 -17
- package/migrations/008_fts_rebuild.sql +0 -9
- package/migrations/009_session_embedding_chunks.sql +0 -46
- package/migrations/010_sources_opencode.sql +0 -30
- package/migrations/011_sources_hermes_agent.sql +0 -30
- package/migrations/012_sources_aider.sql +0 -30
- package/migrations/013_adapter_state_failure_count.sql +0 -12
- package/plugin-hermes-agent/README.md +0 -49
- package/plugin-hermes-agent/__init__.py +0 -75
- package/plugin-hermes-agent/plugin.yaml +0 -15
- package/scripts/backfill-citations.mjs +0 -0
- package/scripts/build-codex-plugin.mjs +0 -61
- package/scripts/deepseek-probe.mjs +0 -67
- package/scripts/extract-triples.mjs +0 -207
- package/scripts/longmemeval/embedding-cache.ts +0 -77
- package/scripts/longmemeval/fetch-dataset.sh +0 -25
- package/scripts/longmemeval/run-harness.ts +0 -315
- package/scripts/longmemeval/scorer.ts +0 -99
- package/scripts/longmemeval/tsconfig.json +0 -9
- package/scripts/longmemeval/types.ts +0 -35
- package/scripts/nlm-daily-digest.py +0 -239
- package/scripts/nlm-daily-digest.sh +0 -28
- package/src/cli/classify-parity.ts +0 -257
- package/src/cli/launchctl-helpers.ts +0 -49
- package/src/cli/nlm.ts +0 -885
- package/src/core/actions/actions-log.ts +0 -118
- package/src/core/actions/overlay.ts +0 -117
- package/src/core/adapters/aider.ts +0 -205
- package/src/core/adapters/claude-code.ts +0 -293
- package/src/core/adapters/common.ts +0 -54
- package/src/core/adapters/from-source.ts +0 -57
- package/src/core/adapters/hermes-agent.ts +0 -240
- package/src/core/adapters/hermes.ts +0 -277
- package/src/core/adapters/jsonl-generic.ts +0 -208
- package/src/core/adapters/opencode.ts +0 -281
- package/src/core/adapters/pi.ts +0 -264
- package/src/core/classifier/prompt.ts +0 -200
- package/src/core/dataset/build-dataset.ts +0 -463
- package/src/core/embedding/chunk-body.ts +0 -76
- package/src/core/embedding/embed-backfill.ts +0 -210
- package/src/core/embedding/embed-normalize.ts +0 -135
- package/src/core/facts/backfill-facts.ts +0 -254
- package/src/core/facts/extract-facts.ts +0 -50
- package/src/core/hook/citation-detect.ts +0 -124
- package/src/core/hook/cite-memo.ts +0 -68
- package/src/core/hook/claude-settings.ts +0 -166
- package/src/core/hook/gate.ts +0 -25
- package/src/core/hook/hook-log.ts +0 -41
- package/src/core/hook/memo-sweep.ts +0 -164
- package/src/core/hook/memo.ts +0 -67
- package/src/core/hook/pointer-block.ts +0 -26
- package/src/core/hook/select.ts +0 -32
- package/src/core/hook/transcript.ts +0 -121
- package/src/core/ingest/ingest-session.ts +0 -111
- package/src/core/providers/provider-models.ts +0 -100
- package/src/core/providers/provider-registry.ts +0 -196
- package/src/core/recall/citation-log.ts +0 -108
- package/src/core/recall/filter.ts +0 -27
- package/src/core/recall/index.ts +0 -6
- package/src/core/recall/match-fields.ts +0 -40
- package/src/core/recall/query-log.ts +0 -149
- package/src/core/recall/query-shape.ts +0 -66
- package/src/core/recall/recall-service.ts +0 -320
- package/src/core/recall/recent-log.ts +0 -59
- package/src/core/recall/tokenize.ts +0 -18
- package/src/core/recall/useful-scan.ts +0 -336
- package/src/core/recall-facts/fact-query-log.ts +0 -150
- package/src/core/recall-facts/fact-recall-service.ts +0 -327
- package/src/core/scheduler/scan-once.ts +0 -142
- package/src/core/scheduler/scheduler.ts +0 -225
- package/src/core/sources/source-registry.ts +0 -260
- package/src/core/storage/db-restore.ts +0 -133
- package/src/core/storage/live-status.ts +0 -45
- package/src/core/storage/migrate.ts +0 -72
- package/src/core/storage/sqlite-fact-store.ts +0 -304
- package/src/core/storage/sqlite-session-store.ts +0 -765
- package/src/hook/prompt-recall-hook.ts +0 -174
- package/src/hook/session-end-hook.ts +0 -81
- package/src/hook/session-start-hook.ts +0 -165
- package/src/hook/stop-hook.ts +0 -236
- package/src/http/app.ts +0 -1137
- package/src/install/claude-code.ts +0 -128
- package/src/install/codex.ts +0 -367
- package/src/install/hermes-agent.ts +0 -76
- package/src/install/hermes.ts +0 -78
- package/src/install/ollama.ts +0 -211
- package/src/install/setup.ts +0 -368
- package/src/llm/classifier-box.ts +0 -64
- package/src/llm/deepseek-client.ts +0 -150
- package/src/llm/env-autoload.ts +0 -55
- package/src/llm/ollama-client.ts +0 -189
- package/src/mcp/server.ts +0 -534
- package/src/ports/fact-store.ts +0 -102
- package/src/ports/llm-client.ts +0 -52
- package/src/ports/logger.ts +0 -16
- package/src/ports/session-store.ts +0 -45
- package/src/ports/transcript-adapter.ts +0 -55
- package/src/shared/types.ts +0 -145
- package/src/ui/App.tsx +0 -58
- package/src/ui/components/PromoteOpenButton.tsx +0 -65
- package/src/ui/components/SessionDrawer.tsx +0 -136
- package/src/ui/components/SideNav.tsx +0 -162
- package/src/ui/components/Skeleton.tsx +0 -107
- package/src/ui/index.html +0 -13
- package/src/ui/lib/actions.ts +0 -30
- package/src/ui/lib/api.ts +0 -92
- package/src/ui/lib/dataset.ts +0 -141
- package/src/ui/lib/registries.ts +0 -155
- package/src/ui/lib/view-settings.ts +0 -41
- package/src/ui/main.tsx +0 -15
- package/src/ui/pages/Live.tsx +0 -229
- package/src/ui/pages/Pulse.tsx +0 -415
- package/src/ui/pages/Recall.tsx +0 -190
- package/src/ui/pages/River.tsx +0 -308
- package/src/ui/pages/Search.tsx +0 -93
- package/src/ui/pages/Stub.tsx +0 -9
- package/src/ui/pages/Thread.tsx +0 -262
- package/src/ui/pages/settings/Classifier.tsx +0 -227
- package/src/ui/pages/settings/Data.tsx +0 -190
- package/src/ui/pages/settings/Index.tsx +0 -65
- package/src/ui/pages/settings/Labels.tsx +0 -224
- package/src/ui/pages/settings/Providers.tsx +0 -305
- package/src/ui/pages/settings/SettingsSubnav.tsx +0 -28
- package/src/ui/pages/settings/Sources.tsx +0 -326
- package/src/ui/pages/settings/Views.tsx +0 -96
- package/src/ui/styles.css +0 -1766
- package/src/ui/tsconfig.json +0 -21
- package/src/ui/vite.config.ts +0 -19
- package/tests/fixtures/claude_code/short_session.jsonl +0 -2
- package/tests/fixtures/claude_code/standard_iso.jsonl +0 -4
- package/tests/fixtures/claude_code/tool_heavy.jsonl +0 -8
- package/tests/fixtures/claude_code/with_subagent.jsonl +0 -7
- package/tests/fixtures/facts.ts +0 -17
- package/tests/fixtures/golden-corpus.ts +0 -85
- package/tests/fixtures/hermes/paired_request_dump.json +0 -24
- package/tests/fixtures/hermes/paired_session.json +0 -23
- package/tests/fixtures/hermes/request_dump.json +0 -28
- package/tests/fixtures/hermes/session_iso.json +0 -38
- package/tests/fixtures/hermes/session_unix.json +0 -38
- package/tests/fixtures/hermes/system_only.json +0 -18
- package/tests/fixtures/pi/error-connection-abort.jsonl +0 -8
- package/tests/fixtures/pi/short-successful.jsonl +0 -5
- package/tests/fixtures/pi/with-custom-message.jsonl +0 -6
- package/tests/fixtures/sessions.ts +0 -22
- package/tests/integration/backfill-facts.test.ts +0 -362
- package/tests/integration/citation-explicit.test.ts +0 -111
- package/tests/integration/cite-event.test.ts +0 -169
- package/tests/integration/cite-memo.test.ts +0 -87
- package/tests/integration/db-restore.test.ts +0 -153
- package/tests/integration/embed-backfill.test.ts +0 -176
- package/tests/integration/fact-supersedence.test.ts +0 -313
- package/tests/integration/fts-index.test.ts +0 -60
- package/tests/integration/getbyids-sqlite.test.ts +0 -60
- package/tests/integration/hermes-agent-hooks.test.ts +0 -248
- package/tests/integration/hook-claude-settings.test.ts +0 -205
- package/tests/integration/hook-log.test.ts +0 -54
- package/tests/integration/hook-memo.test.ts +0 -68
- package/tests/integration/hook-pre-compact.test.ts +0 -105
- package/tests/integration/hook-subagent-start.test.ts +0 -102
- package/tests/integration/http.test.ts +0 -401
- package/tests/integration/keyword-search-fts.test.ts +0 -66
- package/tests/integration/mcp-recall-logging.test.ts +0 -88
- package/tests/integration/mcp.test.ts +0 -248
- package/tests/integration/memo-sweep.test.ts +0 -91
- package/tests/integration/prompt-recall-hook.test.ts +0 -88
- package/tests/integration/provider-registry.test.ts +0 -107
- package/tests/integration/recall-golden.test.ts +0 -59
- package/tests/integration/recall-sqlite.test.ts +0 -169
- package/tests/integration/scheduler.test.ts +0 -391
- package/tests/integration/session-end-hook.test.ts +0 -48
- package/tests/integration/session-start-hook.test.ts +0 -126
- package/tests/integration/source-registry.test.ts +0 -120
- package/tests/integration/sqlite-fact-store.test.ts +0 -346
- package/tests/integration/stop-hook.test.ts +0 -560
- package/tests/integration/wal-checkpoint.test.ts +0 -49
- package/tests/unit/cli/launchctl-helpers.test.ts +0 -60
- package/tests/unit/core/adapters/aider.test.ts +0 -230
- package/tests/unit/core/adapters/claude-code.test.ts +0 -118
- package/tests/unit/core/adapters/hermes-agent.test.ts +0 -329
- package/tests/unit/core/adapters/hermes.test.ts +0 -81
- package/tests/unit/core/adapters/jsonl-generic.test.ts +0 -142
- package/tests/unit/core/adapters/opencode.test.ts +0 -354
- package/tests/unit/core/adapters/pi.test.ts +0 -110
- package/tests/unit/core/classifier/prompt.test.ts +0 -126
- package/tests/unit/core/embedding/chunk-body.test.ts +0 -100
- package/tests/unit/core/facts/extract-facts.test.ts +0 -117
- package/tests/unit/core/filter.test.ts +0 -40
- package/tests/unit/core/hook/citation-detect-cite-session.test.ts +0 -96
- package/tests/unit/core/hook/citation-detect.test.ts +0 -124
- package/tests/unit/core/hook/gate.test.ts +0 -29
- package/tests/unit/core/hook/pointer-block.test.ts +0 -22
- package/tests/unit/core/hook/select.test.ts +0 -66
- package/tests/unit/core/match-fields.test.ts +0 -39
- package/tests/unit/core/mcp-cite-session.test.ts +0 -51
- package/tests/unit/core/providers/provider-models.test.ts +0 -101
- package/tests/unit/core/query-shape.test.ts +0 -92
- package/tests/unit/core/recall-facts/fact-recall-service.test.ts +0 -258
- package/tests/unit/core/recall-service.test.ts +0 -200
- package/tests/unit/core/storage/live-status.test.ts +0 -54
- package/tests/unit/core/tokenize.test.ts +0 -32
- package/tests/unit/core/useful-scan.test.ts +0 -537
- package/tests/unit/llm/embed.test.ts +0 -93
- package/tests/unit/llm/ollama-client.test.ts +0 -124
- package/tests/unit/scripts/longmemeval-scorer.test.ts +0 -114
- package/tsconfig.json +0 -31
- package/tsconfig.test.json +0 -11
- 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?**
|