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,560 +0,0 @@
1
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
- import { runStopHook } from "../../src/hook/stop-hook.js";
6
- import { recordSurfaced } from "../../src/core/hook/memo.js";
7
- import { loadCited } from "../../src/core/hook/cite-memo.js";
8
- import {
9
- readAllAssistantTurns,
10
- readLastAssistantText,
11
- readLastAssistantTurn,
12
- } from "../../src/core/hook/transcript.js";
13
-
14
- function writeTranscript(path: string, lines: object[]): void {
15
- writeFileSync(path, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
16
- }
17
-
18
- describe("readLastAssistantText", () => {
19
- let tmp: string;
20
- beforeEach(() => {
21
- tmp = mkdtempSync(join(tmpdir(), "nlm-transcript-"));
22
- });
23
- afterEach(() => {
24
- rmSync(tmp, { recursive: true, force: true });
25
- });
26
-
27
- it("returns the text of the last assistant turn with array content", () => {
28
- const path = join(tmp, "t.jsonl");
29
- writeTranscript(path, [
30
- { type: "user", message: { content: "hi" } },
31
- {
32
- type: "assistant",
33
- message: {
34
- content: [
35
- { type: "text", text: "first reply" },
36
- { type: "tool_use", name: "x" },
37
- ],
38
- },
39
- },
40
- { type: "user", message: { content: "more" } },
41
- {
42
- type: "assistant",
43
- message: { content: [{ type: "text", text: "second reply" }] },
44
- },
45
- ]);
46
- expect(readLastAssistantText(path)).toBe("second reply");
47
- });
48
-
49
- it("returns null when no assistant turn is present", () => {
50
- const path = join(tmp, "t.jsonl");
51
- writeTranscript(path, [{ type: "user", message: { content: "hi" } }]);
52
- expect(readLastAssistantText(path)).toBeNull();
53
- });
54
-
55
- it("returns null when path is missing", () => {
56
- expect(readLastAssistantText(join(tmp, "nonexistent.jsonl"))).toBeNull();
57
- });
58
-
59
- it("readLastAssistantTurn extracts text + tool_use blocks together", () => {
60
- const path = join(tmp, "t.jsonl");
61
- writeTranscript(path, [
62
- {
63
- type: "assistant",
64
- message: {
65
- content: [
66
- { type: "text", text: "Searching..." },
67
- {
68
- type: "tool_use",
69
- name: "mcp__nlm-memory__get_session",
70
- input: { id: "cc_sub_abc123" },
71
- },
72
- { type: "text", text: "second prose chunk" },
73
- ],
74
- },
75
- },
76
- ]);
77
- const turn = readLastAssistantTurn(path);
78
- expect(turn.text).toBe("Searching...\nsecond prose chunk");
79
- expect(turn.toolUses).toEqual([
80
- {
81
- name: "mcp__nlm-memory__get_session",
82
- input: { id: "cc_sub_abc123" },
83
- },
84
- ]);
85
- });
86
-
87
- it("skips malformed JSON lines", () => {
88
- const path = join(tmp, "t.jsonl");
89
- writeFileSync(
90
- path,
91
- "not json\n" +
92
- JSON.stringify({
93
- type: "assistant",
94
- message: { content: [{ type: "text", text: "real reply" }] },
95
- }) +
96
- "\n",
97
- );
98
- expect(readLastAssistantText(path)).toBe("real reply");
99
- });
100
- });
101
-
102
- describe("runStopHook", () => {
103
- let tmp: string;
104
- beforeEach(() => {
105
- tmp = mkdtempSync(join(tmpdir(), "nlm-stop-"));
106
- process.env["NLM_HOOK_STATE_DIR"] = join(tmp, "state");
107
- });
108
- afterEach(() => {
109
- delete process.env["NLM_HOOK_STATE_DIR"];
110
- rmSync(tmp, { recursive: true, force: true });
111
- });
112
-
113
- it("posts a citation for each surfaced ID found in the last assistant message", async () => {
114
- recordSurfaced("conv-1", [
115
- "cc_sub_a139f4ab7ca5aa909",
116
- "cc_ff88cd96-d1f9-428c-8a97-2e4ca431acbe",
117
- "hm_20260427_6ff562",
118
- ]);
119
- const transcript = join(tmp, "t.jsonl");
120
- writeTranscript(transcript, [
121
- {
122
- type: "assistant",
123
- message: {
124
- content: [
125
- {
126
- type: "text",
127
- text: "Per cc_sub_a139f4ab7ca5aa909 and hm_20260427_6ff562 we chose FTS5.",
128
- },
129
- ],
130
- },
131
- },
132
- ]);
133
- const postCitation = vi.fn().mockResolvedValue(undefined);
134
- const result = await runStopHook(
135
- {
136
- conversationId: "conv-1",
137
- transcriptPath: transcript,
138
- stopHookActive: false,
139
- },
140
- { postCitation },
141
- );
142
- expect(result.skipped).toBe(false);
143
- expect(result.surfacedCount).toBe(3);
144
- expect(result.citations.map((c) => c.id).sort()).toEqual(
145
- ["cc_sub_a139f4ab7ca5aa909", "hm_20260427_6ff562"].sort(),
146
- );
147
- expect(result.citations.every((c) => c.kind === "prose")).toBe(true);
148
- expect(postCitation).toHaveBeenCalledTimes(2);
149
- expect(postCitation).toHaveBeenCalledWith(
150
- "conv-1",
151
- "cc_sub_a139f4ab7ca5aa909",
152
- "prose",
153
- expect.stringContaining("cc_sub_a139f4ab7ca5aa909"),
154
- );
155
- });
156
-
157
- it("posts a tool_use citation when the model invokes an NLM MCP tool referencing a surfaced ID", async () => {
158
- recordSurfaced("conv-mcp", [
159
- "cc_sub_a139f4ab7ca5aa909",
160
- "cc_ff88cd96-d1f9-428c-8a97-2e4ca431acbe",
161
- ]);
162
- const transcript = join(tmp, "t.jsonl");
163
- writeTranscript(transcript, [
164
- {
165
- type: "assistant",
166
- message: {
167
- content: [
168
- { type: "text", text: "Let me look at that prior session." },
169
- {
170
- type: "tool_use",
171
- name: "mcp__nlm-memory__get_session",
172
- input: { id: "cc_sub_a139f4ab7ca5aa909" },
173
- },
174
- ],
175
- },
176
- },
177
- ]);
178
- const postCitation = vi.fn().mockResolvedValue(undefined);
179
- const result = await runStopHook(
180
- {
181
- conversationId: "conv-mcp",
182
- transcriptPath: transcript,
183
- stopHookActive: false,
184
- },
185
- { postCitation },
186
- );
187
- expect(result.citations).toEqual([
188
- { id: "cc_sub_a139f4ab7ca5aa909", kind: "tool_use" },
189
- ]);
190
- expect(postCitation).toHaveBeenCalledWith(
191
- "conv-mcp",
192
- "cc_sub_a139f4ab7ca5aa909",
193
- "tool_use",
194
- expect.any(String),
195
- );
196
- });
197
-
198
- it("ignores tool_use blocks for non-NLM tools", async () => {
199
- recordSurfaced("conv-other", ["cc_sub_a139f4ab7ca5aa909"]);
200
- const transcript = join(tmp, "t.jsonl");
201
- writeTranscript(transcript, [
202
- {
203
- type: "assistant",
204
- message: {
205
- content: [
206
- {
207
- type: "tool_use",
208
- name: "Bash",
209
- input: { command: "grep cc_sub_a139f4ab7ca5aa909 /tmp/log" },
210
- },
211
- ],
212
- },
213
- },
214
- ]);
215
- const postCitation = vi.fn();
216
- const result = await runStopHook(
217
- {
218
- conversationId: "conv-other",
219
- transcriptPath: transcript,
220
- stopHookActive: false,
221
- },
222
- { postCitation },
223
- );
224
- expect(result.citations).toEqual([]);
225
- expect(postCitation).not.toHaveBeenCalled();
226
- });
227
-
228
- it("skips when stop_hook_active is true", async () => {
229
- recordSurfaced("conv-2", ["cc_sub_a139f4ab7ca5aa909"]);
230
- const postCitation = vi.fn();
231
- const result = await runStopHook(
232
- {
233
- conversationId: "conv-2",
234
- transcriptPath: "ignored",
235
- stopHookActive: true,
236
- },
237
- { postCitation },
238
- );
239
- expect(result.skipped).toBe(true);
240
- expect(postCitation).not.toHaveBeenCalled();
241
- });
242
-
243
- it("returns empty cited when no IDs were surfaced for this conversation", async () => {
244
- const transcript = join(tmp, "t.jsonl");
245
- writeTranscript(transcript, [
246
- {
247
- type: "assistant",
248
- message: { content: [{ type: "text", text: "anything" }] },
249
- },
250
- ]);
251
- const postCitation = vi.fn();
252
- const result = await runStopHook(
253
- {
254
- conversationId: "conv-no-recall",
255
- transcriptPath: transcript,
256
- stopHookActive: false,
257
- },
258
- { postCitation },
259
- );
260
- expect(result.surfacedCount).toBe(0);
261
- expect(result.citations).toEqual([]);
262
- expect(postCitation).not.toHaveBeenCalled();
263
- });
264
-
265
- it("does not throw when postCitation rejects (daemon down)", async () => {
266
- recordSurfaced("conv-3", ["cc_sub_a139f4ab7ca5aa909"]);
267
- const transcript = join(tmp, "t.jsonl");
268
- writeTranscript(transcript, [
269
- {
270
- type: "assistant",
271
- message: {
272
- content: [{ type: "text", text: "cc_sub_a139f4ab7ca5aa909 cited" }],
273
- },
274
- },
275
- ]);
276
- const postCitation = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
277
- await expect(
278
- runStopHook(
279
- {
280
- conversationId: "conv-3",
281
- transcriptPath: transcript,
282
- stopHookActive: false,
283
- },
284
- { postCitation },
285
- ),
286
- ).resolves.toBeDefined();
287
- });
288
-
289
- it("handles missing transcript path by returning no citations", async () => {
290
- recordSurfaced("conv-4", ["cc_sub_a139f4ab7ca5aa909"]);
291
- const postCitation = vi.fn();
292
- const result = await runStopHook(
293
- {
294
- conversationId: "conv-4",
295
- transcriptPath: join(tmp, "nonexistent.jsonl"),
296
- stopHookActive: false,
297
- },
298
- { postCitation },
299
- );
300
- expect(result.surfacedCount).toBe(1);
301
- expect(result.citations).toEqual([]);
302
- expect(postCitation).not.toHaveBeenCalled();
303
- });
304
-
305
- it("detects a tool_use citation when the model invoked the tool in an EARLIER turn and the last turn is prose-only", async () => {
306
- // Real-world pattern: model calls get_session → reads tool_result →
307
- // writes prose summary in a separate assistant turn. Stop fires after
308
- // the summary. The pre-fix detector scanned only the summary turn and
309
- // missed the get_session call entirely (348 stop firings in production
310
- // logged 0 citations despite 23 NLM tool_uses in transcripts).
311
- recordSurfaced("conv-multi", [
312
- "cc_7ff73609-9ac8-4851-891c-e958915bb7fa",
313
- ]);
314
- const transcript = join(tmp, "t.jsonl");
315
- writeTranscript(transcript, [
316
- { type: "user", message: { content: "what did we decide about FTS5?" } },
317
- {
318
- type: "assistant",
319
- message: {
320
- content: [
321
- { type: "text", text: "Let me check the prior session." },
322
- {
323
- type: "tool_use",
324
- name: "mcp__nlm-memory__get_session",
325
- input: { id: "cc_7ff73609-9ac8-4851-891c-e958915bb7fa" },
326
- },
327
- ],
328
- },
329
- },
330
- {
331
- type: "user",
332
- message: {
333
- content: [{ type: "tool_result", content: "session body..." }],
334
- },
335
- },
336
- {
337
- type: "assistant",
338
- message: {
339
- content: [
340
- {
341
- type: "text",
342
- text: "FTS5 was chosen because the keyword leg already ranks high.",
343
- },
344
- ],
345
- },
346
- },
347
- ]);
348
- const postCitation = vi.fn().mockResolvedValue(undefined);
349
- const result = await runStopHook(
350
- {
351
- conversationId: "conv-multi",
352
- transcriptPath: transcript,
353
- stopHookActive: false,
354
- },
355
- { postCitation },
356
- );
357
- expect(result.citations).toEqual([
358
- {
359
- id: "cc_7ff73609-9ac8-4851-891c-e958915bb7fa",
360
- kind: "tool_use",
361
- },
362
- ]);
363
- expect(postCitation).toHaveBeenCalledTimes(1);
364
- expect(postCitation).toHaveBeenCalledWith(
365
- "conv-multi",
366
- "cc_7ff73609-9ac8-4851-891c-e958915bb7fa",
367
- "tool_use",
368
- // preview is the LAST turn's prose, not the earlier prose.
369
- expect.stringContaining("FTS5 was chosen"),
370
- );
371
- });
372
-
373
- it("dedupes across repeated Stop firings — same tool_use citation is posted exactly once", async () => {
374
- recordSurfaced("conv-dedup", [
375
- "cc_sub_a139f4ab7ca5aa909",
376
- ]);
377
- const transcript = join(tmp, "t.jsonl");
378
- writeTranscript(transcript, [
379
- {
380
- type: "assistant",
381
- message: {
382
- content: [
383
- {
384
- type: "tool_use",
385
- name: "mcp__nlm-memory__get_session",
386
- input: { id: "cc_sub_a139f4ab7ca5aa909" },
387
- },
388
- ],
389
- },
390
- },
391
- {
392
- type: "assistant",
393
- message: { content: [{ type: "text", text: "first response" }] },
394
- },
395
- ]);
396
- const postCitation = vi.fn().mockResolvedValue(undefined);
397
-
398
- // First fire — citation detected and posted.
399
- const first = await runStopHook(
400
- {
401
- conversationId: "conv-dedup",
402
- transcriptPath: transcript,
403
- stopHookActive: false,
404
- },
405
- { postCitation },
406
- );
407
- expect(first.citations).toHaveLength(1);
408
- expect(postCitation).toHaveBeenCalledTimes(1);
409
-
410
- // Transcript grows with another assistant turn (typical conversation
411
- // continuation). The earlier tool_use is still in the file.
412
- writeTranscript(transcript, [
413
- {
414
- type: "assistant",
415
- message: {
416
- content: [
417
- {
418
- type: "tool_use",
419
- name: "mcp__nlm-memory__get_session",
420
- input: { id: "cc_sub_a139f4ab7ca5aa909" },
421
- },
422
- ],
423
- },
424
- },
425
- {
426
- type: "assistant",
427
- message: { content: [{ type: "text", text: "first response" }] },
428
- },
429
- {
430
- type: "user",
431
- message: { content: "follow up" },
432
- },
433
- {
434
- type: "assistant",
435
- message: { content: [{ type: "text", text: "second response" }] },
436
- },
437
- ]);
438
-
439
- // Second fire — same id, must not post again.
440
- const second = await runStopHook(
441
- {
442
- conversationId: "conv-dedup",
443
- transcriptPath: transcript,
444
- stopHookActive: false,
445
- },
446
- { postCitation },
447
- );
448
- expect(second.citations).toEqual([]);
449
- expect(postCitation).toHaveBeenCalledTimes(1);
450
-
451
- // Cite-memo persisted the dedup state.
452
- expect(loadCited("conv-dedup")).toEqual(
453
- new Set(["cc_sub_a139f4ab7ca5aa909"]),
454
- );
455
- });
456
-
457
- it("records a citation locally even if postCitation fails — prevents reposting on next fire", async () => {
458
- recordSurfaced("conv-failopen", ["cc_sub_a139f4ab7ca5aa909"]);
459
- const transcript = join(tmp, "t.jsonl");
460
- writeTranscript(transcript, [
461
- {
462
- type: "assistant",
463
- message: {
464
- content: [
465
- {
466
- type: "tool_use",
467
- name: "mcp__nlm-memory__get_session",
468
- input: { id: "cc_sub_a139f4ab7ca5aa909" },
469
- },
470
- ],
471
- },
472
- },
473
- ]);
474
- const postCitation = vi
475
- .fn()
476
- .mockRejectedValueOnce(new Error("ECONNREFUSED"))
477
- .mockResolvedValue(undefined);
478
- await runStopHook(
479
- {
480
- conversationId: "conv-failopen",
481
- transcriptPath: transcript,
482
- stopHookActive: false,
483
- },
484
- { postCitation },
485
- );
486
- // Second fire must NOT retry — the citation log carries an at-most-once
487
- // contract on the hook side; a missed daemon write is a known telemetry
488
- // gap, not a reason to double-count.
489
- await runStopHook(
490
- {
491
- conversationId: "conv-failopen",
492
- transcriptPath: transcript,
493
- stopHookActive: false,
494
- },
495
- { postCitation },
496
- );
497
- expect(postCitation).toHaveBeenCalledTimes(1);
498
- });
499
- });
500
-
501
- describe("readAllAssistantTurns", () => {
502
- let tmp: string;
503
- beforeEach(() => {
504
- tmp = mkdtempSync(join(tmpdir(), "nlm-transcript-all-"));
505
- });
506
- afterEach(() => {
507
- rmSync(tmp, { recursive: true, force: true });
508
- });
509
-
510
- it("returns every assistant turn in order", () => {
511
- const path = join(tmp, "t.jsonl");
512
- writeTranscript(path, [
513
- { type: "user", message: { content: "hi" } },
514
- {
515
- type: "assistant",
516
- message: {
517
- content: [
518
- { type: "text", text: "first" },
519
- {
520
- type: "tool_use",
521
- name: "mcp__nlm-memory__get_session",
522
- input: { id: "cc_x" },
523
- },
524
- ],
525
- },
526
- },
527
- { type: "user", message: { content: "more" } },
528
- {
529
- type: "assistant",
530
- message: { content: [{ type: "text", text: "second" }] },
531
- },
532
- ]);
533
- const turns = readAllAssistantTurns(path);
534
- expect(turns).toHaveLength(2);
535
- expect(turns[0]?.text).toBe("first");
536
- expect(turns[0]?.toolUses[0]?.name).toBe("mcp__nlm-memory__get_session");
537
- expect(turns[1]?.text).toBe("second");
538
- expect(turns[1]?.toolUses).toEqual([]);
539
- });
540
-
541
- it("returns empty array for missing path", () => {
542
- expect(readAllAssistantTurns(join(tmp, "missing.jsonl"))).toEqual([]);
543
- });
544
-
545
- it("skips malformed lines without throwing", () => {
546
- const path = join(tmp, "t.jsonl");
547
- writeFileSync(
548
- path,
549
- "not json\n" +
550
- JSON.stringify({
551
- type: "assistant",
552
- message: { content: [{ type: "text", text: "real" }] },
553
- }) +
554
- "\n",
555
- );
556
- const turns = readAllAssistantTurns(path);
557
- expect(turns).toHaveLength(1);
558
- expect(turns[0]?.text).toBe("real");
559
- });
560
- });
@@ -1,49 +0,0 @@
1
- /**
2
- * SqliteSessionStore.checkpoint — drains the WAL into the main DB and
3
- * truncates the -wal file, so it cannot grow unbounded.
4
- */
5
-
6
- import { mkdtempSync, rmSync, statSync } from "node:fs";
7
- import { tmpdir } from "node:os";
8
- import { join, resolve } from "node:path";
9
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
10
- import { SqliteSessionStore } from "../../src/core/storage/sqlite-session-store.js";
11
- import { makeSession } from "../fixtures/sessions.js";
12
-
13
- const MIGRATIONS_DIR = resolve(__dirname, "../../migrations");
14
-
15
- describe("SqliteSessionStore.checkpoint", () => {
16
- let tmp: string;
17
- let dbPath: string;
18
- let store: SqliteSessionStore;
19
-
20
- beforeEach(() => {
21
- tmp = mkdtempSync(join(tmpdir(), "nlm-wal-"));
22
- dbPath = join(tmp, "canonical.sqlite");
23
- store = new SqliteSessionStore({ dbPath, migrationsDir: MIGRATIONS_DIR });
24
- });
25
-
26
- afterEach(() => {
27
- store.close();
28
- rmSync(tmp, { recursive: true, force: true });
29
- });
30
-
31
- it("truncates the -wal file after checkpoint", () => {
32
- for (let i = 0; i < 30; i++) {
33
- store.insertSessionForTest(
34
- makeSession({ id: `s${i}`, label: `session ${i}`, body: "x".repeat(5000) }),
35
- );
36
- }
37
- const walBefore = statSync(`${dbPath}-wal`).size;
38
- expect(walBefore).toBeGreaterThan(0);
39
-
40
- store.checkpoint();
41
-
42
- const walAfter = statSync(`${dbPath}-wal`).size;
43
- expect(walAfter).toBe(0);
44
- });
45
-
46
- it("is safe to call when the WAL is already empty", () => {
47
- expect(() => store.checkpoint()).not.toThrow();
48
- });
49
- });
@@ -1,60 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { isAgentLoaded, isBenignBootoutError } from "../../../src/cli/launchctl-helpers.js";
3
-
4
- describe("isBenignBootoutError", () => {
5
- it("recognizes the 'Could not find service' message", () => {
6
- expect(isBenignBootoutError("Could not find service \"foo\" in domain for port: 0\n")).toBe(true);
7
- });
8
-
9
- it("recognizes 'No such process'", () => {
10
- expect(isBenignBootoutError("Boot-out failed: No such process")).toBe(true);
11
- });
12
-
13
- it("recognizes 'not currently loaded' phrasing", () => {
14
- expect(isBenignBootoutError("Service is not currently loaded.")).toBe(true);
15
- });
16
-
17
- it("treats unrecognized stderr as a real failure", () => {
18
- expect(isBenignBootoutError("Bootstrap failed: 5: Input/output error")).toBe(false);
19
- });
20
-
21
- it("treats empty stderr as a real failure (no excuse to be silent)", () => {
22
- expect(isBenignBootoutError("")).toBe(false);
23
- });
24
-
25
- it("is case-insensitive on the match", () => {
26
- expect(isBenignBootoutError("COULD NOT FIND SERVICE")).toBe(true);
27
- });
28
- });
29
-
30
- describe("isAgentLoaded", () => {
31
- const label = "com.example.test-agent";
32
-
33
- it("returns true when the label appears in the launchctl list output", () => {
34
- const stub = () => `12345\t0\tcom.apple.WebKit.GPU\n87530\t0\t${label}\n22222\t0\tsomething.else\n`;
35
- expect(isAgentLoaded(label, stub)).toBe(true);
36
- });
37
-
38
- it("returns false when the label is absent", () => {
39
- const stub = () => `12345\t0\tcom.apple.WebKit.GPU\n22222\t0\tsomething.else\n`;
40
- expect(isAgentLoaded(label, stub)).toBe(false);
41
- });
42
-
43
- it("returns false when the runner throws (launchctl unreachable)", () => {
44
- const stub = (): string => {
45
- throw new Error("launchctl not found");
46
- };
47
- expect(isAgentLoaded(label, stub)).toBe(false);
48
- });
49
-
50
- it("requires substring match — partial label collision is fine, full label wins", () => {
51
- // A different agent that happens to share a prefix should not falsely match
52
- // a query for the full label. (We match by includes(), so this is a guard:
53
- // make sure the label is specific enough.)
54
- const stub = () => `12345\t0\tcom.example.test-agent-helper\n`;
55
- // includes() will return true here — documents the limitation. If we ever
56
- // want exact-match, change the helper. For now, the actual NLM label is
57
- // distinctive enough that this isn't a concern.
58
- expect(isAgentLoaded(label, stub)).toBe(true);
59
- });
60
- });