nlm-memory 0.5.0 → 0.5.2

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 (257) hide show
  1. package/README.md +89 -34
  2. package/dist/cli/digest.d.ts +20 -0
  3. package/dist/cli/digest.js +142 -0
  4. package/dist/cli/digest.js.map +1 -0
  5. package/dist/cli/nlm.d.ts +1 -0
  6. package/dist/cli/nlm.js +25 -1
  7. package/dist/cli/nlm.js.map +1 -1
  8. package/dist/core/digest/compose.d.ts +38 -0
  9. package/dist/core/digest/compose.js +93 -0
  10. package/dist/core/digest/compose.js.map +1 -0
  11. package/dist/core/digest/hook-liveness.d.ts +32 -0
  12. package/dist/core/digest/hook-liveness.js +54 -0
  13. package/dist/core/digest/hook-liveness.js.map +1 -0
  14. package/dist/http/app.js +2 -1
  15. package/dist/http/app.js.map +1 -1
  16. package/dist/mcp/server.js +20 -1
  17. package/dist/mcp/server.js.map +1 -1
  18. package/dist/ui/assets/{index-C8cpwbYJ.css → index-Beo8psd-.css} +1 -1
  19. package/dist/ui/assets/{index-CB50QnL-.js → index-CSPTTeeM.js} +8 -8
  20. package/dist/ui/index.html +2 -2
  21. package/package.json +26 -1
  22. package/.agents/plugins/marketplace.json +0 -20
  23. package/.github/workflows/ci.yml +0 -30
  24. package/docs/methodology/re-derivation-rate.md +0 -112
  25. package/docs/methodology/useful-hit-rate.md +0 -79
  26. package/docs/plans/2026-05-20-fts5-lexical-recall.md +0 -1088
  27. package/docs/plans/2026-05-20-recall-daemon-wedge-fix.md +0 -662
  28. package/docs/plans/2026-05-20-recall-hook-design.md +0 -131
  29. package/docs/plans/2026-05-20-recall-hook-implementation.md +0 -1222
  30. package/docs/plans/desktop-product.md +0 -69
  31. package/docs/plans/factstore-design.md +0 -236
  32. package/logs/CHANGELOG/CHANGELOG-2026.md +0 -1575
  33. package/logs/CHANGELOG/CHANGELOG.md +0 -209
  34. package/migrations/000_initial_schema.sql +0 -174
  35. package/migrations/001_entity_type_rename.sql +0 -17
  36. package/migrations/002_adapter_state_extend.sql +0 -12
  37. package/migrations/003_session_embeddings.sql +0 -11
  38. package/migrations/004_facts.sql +0 -46
  39. package/migrations/005_sources.sql +0 -31
  40. package/migrations/006_providers.sql +0 -33
  41. package/migrations/007_source_tokens.sql +0 -17
  42. package/migrations/008_fts_rebuild.sql +0 -9
  43. package/migrations/009_session_embedding_chunks.sql +0 -46
  44. package/migrations/010_sources_opencode.sql +0 -30
  45. package/migrations/011_sources_hermes_agent.sql +0 -30
  46. package/migrations/012_sources_aider.sql +0 -30
  47. package/migrations/013_adapter_state_failure_count.sql +0 -12
  48. package/migrations/014_sources_cursor.sql +0 -30
  49. package/migrations/015_sources_windsurf.sql +0 -30
  50. package/plugin-hermes-agent/README.md +0 -49
  51. package/plugin-hermes-agent/__init__.py +0 -75
  52. package/plugin-hermes-agent/plugin.yaml +0 -15
  53. package/scripts/backfill-citations.mjs +0 -0
  54. package/scripts/build-codex-plugin.mjs +0 -61
  55. package/scripts/deepseek-probe.mjs +0 -67
  56. package/scripts/extract-triples.mjs +0 -207
  57. package/scripts/longmemeval/embedding-cache.ts +0 -77
  58. package/scripts/longmemeval/fetch-dataset.sh +0 -25
  59. package/scripts/longmemeval/run-harness.ts +0 -315
  60. package/scripts/longmemeval/scorer.ts +0 -99
  61. package/scripts/longmemeval/tsconfig.json +0 -9
  62. package/scripts/longmemeval/types.ts +0 -35
  63. package/scripts/nlm-daily-digest.py +0 -239
  64. package/scripts/nlm-daily-digest.sh +0 -28
  65. package/src/cli/classify-parity.ts +0 -257
  66. package/src/cli/launchctl-helpers.ts +0 -49
  67. package/src/cli/nlm.ts +0 -1078
  68. package/src/core/actions/actions-log.ts +0 -118
  69. package/src/core/actions/overlay.ts +0 -117
  70. package/src/core/adapters/aider.ts +0 -205
  71. package/src/core/adapters/claude-code.ts +0 -293
  72. package/src/core/adapters/common.ts +0 -54
  73. package/src/core/adapters/cursor.ts +0 -486
  74. package/src/core/adapters/from-source.ts +0 -67
  75. package/src/core/adapters/hermes-agent.ts +0 -240
  76. package/src/core/adapters/hermes.ts +0 -277
  77. package/src/core/adapters/jsonl-generic.ts +0 -208
  78. package/src/core/adapters/opencode.ts +0 -281
  79. package/src/core/adapters/pi.ts +0 -264
  80. package/src/core/adapters/windsurf.ts +0 -386
  81. package/src/core/classifier/prompt.ts +0 -200
  82. package/src/core/dataset/build-dataset.ts +0 -463
  83. package/src/core/embedding/chunk-body.ts +0 -76
  84. package/src/core/embedding/embed-backfill.ts +0 -210
  85. package/src/core/embedding/embed-normalize.ts +0 -135
  86. package/src/core/facts/backfill-facts.ts +0 -254
  87. package/src/core/facts/extract-facts.ts +0 -50
  88. package/src/core/hook/citation-detect.ts +0 -124
  89. package/src/core/hook/cite-memo.ts +0 -68
  90. package/src/core/hook/claude-settings.ts +0 -187
  91. package/src/core/hook/gate.ts +0 -25
  92. package/src/core/hook/hook-log.ts +0 -41
  93. package/src/core/hook/memo-sweep.ts +0 -164
  94. package/src/core/hook/memo.ts +0 -67
  95. package/src/core/hook/pointer-block.ts +0 -26
  96. package/src/core/hook/select.ts +0 -32
  97. package/src/core/hook/transcript.ts +0 -121
  98. package/src/core/ingest/ingest-session.ts +0 -111
  99. package/src/core/providers/provider-models.ts +0 -100
  100. package/src/core/providers/provider-registry.ts +0 -196
  101. package/src/core/recall/citation-log.ts +0 -108
  102. package/src/core/recall/filter.ts +0 -27
  103. package/src/core/recall/index.ts +0 -6
  104. package/src/core/recall/match-fields.ts +0 -40
  105. package/src/core/recall/query-log.ts +0 -149
  106. package/src/core/recall/query-shape.ts +0 -66
  107. package/src/core/recall/recall-service.ts +0 -320
  108. package/src/core/recall/recent-log.ts +0 -59
  109. package/src/core/recall/tokenize.ts +0 -18
  110. package/src/core/recall/useful-scan.ts +0 -336
  111. package/src/core/recall-facts/fact-query-log.ts +0 -150
  112. package/src/core/recall-facts/fact-recall-service.ts +0 -327
  113. package/src/core/scheduler/scan-once.ts +0 -142
  114. package/src/core/scheduler/scheduler.ts +0 -225
  115. package/src/core/sources/source-registry.ts +0 -278
  116. package/src/core/storage/db-restore.ts +0 -133
  117. package/src/core/storage/live-status.ts +0 -45
  118. package/src/core/storage/migrate.ts +0 -72
  119. package/src/core/storage/sqlite-fact-store.ts +0 -304
  120. package/src/core/storage/sqlite-session-store.ts +0 -810
  121. package/src/hook/hook-auth.ts +0 -18
  122. package/src/hook/prompt-recall-hook.ts +0 -180
  123. package/src/hook/session-end-hook.ts +0 -81
  124. package/src/hook/session-start-hook.ts +0 -168
  125. package/src/hook/stop-hook.ts +0 -239
  126. package/src/http/app.ts +0 -1215
  127. package/src/install/claude-code.ts +0 -128
  128. package/src/install/codex.ts +0 -367
  129. package/src/install/cursor.ts +0 -68
  130. package/src/install/hermes-agent.ts +0 -76
  131. package/src/install/hermes.ts +0 -78
  132. package/src/install/nlm-dir-perms.ts +0 -55
  133. package/src/install/ollama.ts +0 -284
  134. package/src/install/setup.ts +0 -489
  135. package/src/install/windsurf.ts +0 -68
  136. package/src/llm/classifier-box.ts +0 -64
  137. package/src/llm/deepseek-client.ts +0 -150
  138. package/src/llm/env-autoload.ts +0 -55
  139. package/src/llm/ollama-client.ts +0 -189
  140. package/src/mcp/server.ts +0 -534
  141. package/src/ports/fact-store.ts +0 -102
  142. package/src/ports/llm-client.ts +0 -52
  143. package/src/ports/logger.ts +0 -16
  144. package/src/ports/session-store.ts +0 -45
  145. package/src/ports/transcript-adapter.ts +0 -55
  146. package/src/shared/types.ts +0 -149
  147. package/src/ui/App.tsx +0 -58
  148. package/src/ui/components/PromoteOpenButton.tsx +0 -65
  149. package/src/ui/components/SessionDrawer.tsx +0 -199
  150. package/src/ui/components/SideNav.tsx +0 -162
  151. package/src/ui/components/Skeleton.tsx +0 -107
  152. package/src/ui/index.html +0 -13
  153. package/src/ui/lib/actions.ts +0 -30
  154. package/src/ui/lib/api.ts +0 -92
  155. package/src/ui/lib/dataset.ts +0 -141
  156. package/src/ui/lib/registries.ts +0 -155
  157. package/src/ui/lib/view-settings.ts +0 -41
  158. package/src/ui/main.tsx +0 -15
  159. package/src/ui/pages/Live.tsx +0 -229
  160. package/src/ui/pages/Pulse.tsx +0 -415
  161. package/src/ui/pages/Recall.tsx +0 -190
  162. package/src/ui/pages/River.tsx +0 -354
  163. package/src/ui/pages/Search.tsx +0 -386
  164. package/src/ui/pages/Stub.tsx +0 -9
  165. package/src/ui/pages/Thread.tsx +0 -473
  166. package/src/ui/pages/settings/Classifier.tsx +0 -227
  167. package/src/ui/pages/settings/Data.tsx +0 -190
  168. package/src/ui/pages/settings/Index.tsx +0 -65
  169. package/src/ui/pages/settings/Labels.tsx +0 -224
  170. package/src/ui/pages/settings/Providers.tsx +0 -305
  171. package/src/ui/pages/settings/SettingsSubnav.tsx +0 -28
  172. package/src/ui/pages/settings/Sources.tsx +0 -326
  173. package/src/ui/pages/settings/Views.tsx +0 -96
  174. package/src/ui/styles.css +0 -1890
  175. package/src/ui/tsconfig.json +0 -21
  176. package/src/ui/vite.config.ts +0 -19
  177. package/tests/fixtures/claude_code/short_session.jsonl +0 -2
  178. package/tests/fixtures/claude_code/standard_iso.jsonl +0 -4
  179. package/tests/fixtures/claude_code/tool_heavy.jsonl +0 -8
  180. package/tests/fixtures/claude_code/with_subagent.jsonl +0 -7
  181. package/tests/fixtures/facts.ts +0 -17
  182. package/tests/fixtures/golden-corpus.ts +0 -85
  183. package/tests/fixtures/hermes/paired_request_dump.json +0 -24
  184. package/tests/fixtures/hermes/paired_session.json +0 -23
  185. package/tests/fixtures/hermes/request_dump.json +0 -28
  186. package/tests/fixtures/hermes/session_iso.json +0 -38
  187. package/tests/fixtures/hermes/session_unix.json +0 -38
  188. package/tests/fixtures/hermes/system_only.json +0 -18
  189. package/tests/fixtures/pi/error-connection-abort.jsonl +0 -8
  190. package/tests/fixtures/pi/short-successful.jsonl +0 -5
  191. package/tests/fixtures/pi/with-custom-message.jsonl +0 -6
  192. package/tests/fixtures/sessions.ts +0 -22
  193. package/tests/integration/backfill-facts.test.ts +0 -362
  194. package/tests/integration/citation-explicit.test.ts +0 -111
  195. package/tests/integration/cite-event.test.ts +0 -169
  196. package/tests/integration/cite-memo.test.ts +0 -87
  197. package/tests/integration/db-restore.test.ts +0 -153
  198. package/tests/integration/embed-backfill.test.ts +0 -176
  199. package/tests/integration/fact-supersedence.test.ts +0 -313
  200. package/tests/integration/fts-index.test.ts +0 -60
  201. package/tests/integration/getbyids-sqlite.test.ts +0 -100
  202. package/tests/integration/hermes-agent-hooks.test.ts +0 -248
  203. package/tests/integration/hook-claude-settings.test.ts +0 -218
  204. package/tests/integration/hook-log.test.ts +0 -54
  205. package/tests/integration/hook-memo.test.ts +0 -68
  206. package/tests/integration/hook-pre-compact.test.ts +0 -105
  207. package/tests/integration/hook-subagent-start.test.ts +0 -102
  208. package/tests/integration/http.test.ts +0 -401
  209. package/tests/integration/keyword-search-fts.test.ts +0 -66
  210. package/tests/integration/mcp-recall-logging.test.ts +0 -88
  211. package/tests/integration/mcp.test.ts +0 -260
  212. package/tests/integration/memo-sweep.test.ts +0 -91
  213. package/tests/integration/prompt-recall-hook.test.ts +0 -88
  214. package/tests/integration/provider-registry.test.ts +0 -107
  215. package/tests/integration/recall-golden.test.ts +0 -59
  216. package/tests/integration/recall-sqlite.test.ts +0 -169
  217. package/tests/integration/scheduler.test.ts +0 -391
  218. package/tests/integration/session-end-hook.test.ts +0 -48
  219. package/tests/integration/session-start-hook.test.ts +0 -126
  220. package/tests/integration/source-registry.test.ts +0 -122
  221. package/tests/integration/sqlite-fact-store.test.ts +0 -346
  222. package/tests/integration/stop-hook.test.ts +0 -560
  223. package/tests/integration/wal-checkpoint.test.ts +0 -49
  224. package/tests/unit/cli/launchctl-helpers.test.ts +0 -60
  225. package/tests/unit/core/adapters/aider.test.ts +0 -230
  226. package/tests/unit/core/adapters/claude-code.test.ts +0 -118
  227. package/tests/unit/core/adapters/cursor.test.ts +0 -485
  228. package/tests/unit/core/adapters/hermes-agent.test.ts +0 -329
  229. package/tests/unit/core/adapters/hermes.test.ts +0 -81
  230. package/tests/unit/core/adapters/jsonl-generic.test.ts +0 -142
  231. package/tests/unit/core/adapters/opencode.test.ts +0 -354
  232. package/tests/unit/core/adapters/pi.test.ts +0 -110
  233. package/tests/unit/core/adapters/windsurf.test.ts +0 -416
  234. package/tests/unit/core/classifier/prompt.test.ts +0 -126
  235. package/tests/unit/core/embedding/chunk-body.test.ts +0 -100
  236. package/tests/unit/core/facts/extract-facts.test.ts +0 -117
  237. package/tests/unit/core/filter.test.ts +0 -40
  238. package/tests/unit/core/hook/citation-detect-cite-session.test.ts +0 -96
  239. package/tests/unit/core/hook/citation-detect.test.ts +0 -124
  240. package/tests/unit/core/hook/gate.test.ts +0 -29
  241. package/tests/unit/core/hook/pointer-block.test.ts +0 -22
  242. package/tests/unit/core/hook/select.test.ts +0 -66
  243. package/tests/unit/core/match-fields.test.ts +0 -39
  244. package/tests/unit/core/mcp-cite-session.test.ts +0 -51
  245. package/tests/unit/core/providers/provider-models.test.ts +0 -101
  246. package/tests/unit/core/query-shape.test.ts +0 -92
  247. package/tests/unit/core/recall-facts/fact-recall-service.test.ts +0 -258
  248. package/tests/unit/core/recall-service.test.ts +0 -200
  249. package/tests/unit/core/storage/live-status.test.ts +0 -54
  250. package/tests/unit/core/tokenize.test.ts +0 -32
  251. package/tests/unit/core/useful-scan.test.ts +0 -537
  252. package/tests/unit/llm/embed.test.ts +0 -93
  253. package/tests/unit/llm/ollama-client.test.ts +0 -124
  254. package/tests/unit/scripts/longmemeval-scorer.test.ts +0 -114
  255. package/tsconfig.json +0 -31
  256. package/tsconfig.test.json +0 -11
  257. package/vitest.config.ts +0 -22
package/src/cli/nlm.ts DELETED
@@ -1,1078 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * nlm — CLI entry point. Composition root for the whole stack.
4
- *
5
- * This is the one file that knows about every concrete implementation:
6
- * SqliteSessionStore (storage), OllamaClient (LLM), Hono (HTTP),
7
- * McpServer (MCP). Every other module depends on ports. Swapping a
8
- * backend means editing this file, not anything inside core/.
9
- *
10
- * Subcommands:
11
- * nlm start — boot HTTP server on $NLM_PORT (default 3940)
12
- * nlm migrate — run pending migrations against the canonical SQLite
13
- * nlm recall — one-shot recall query from the shell (debugging)
14
- * nlm mcp — run as an MCP stdio server (for ~/.mcp.json wiring)
15
- * nlm setup — interactive first-run wizard (recommended entry point)
16
- * nlm install — install the macOS LaunchAgent (auto-start on login)
17
- * nlm uninstall — remove the macOS LaunchAgent
18
- * nlm hook install — add the recall hook to Claude Code (shadow mode)
19
- * nlm hook uninstall — remove the recall hook from Claude Code
20
- * nlm connect claude-code — write MCP server block to ~/.mcp.json
21
- * nlm connect codex — install Codex marketplace plugin
22
- * nlm disconnect claude-code — remove MCP block from ~/.mcp.json
23
- * nlm disconnect codex — remove Codex plugin
24
- */
25
-
26
- import { fileURLToPath } from "node:url";
27
- import { dirname, resolve, join } from "node:path";
28
- import { homedir } from "node:os";
29
- import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
30
- import { execFileSync, spawnSync } from "node:child_process";
31
- import { Command } from "commander";
32
- import { serve } from "@hono/node-server";
33
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
34
- import { FactRecallService } from "../core/recall-facts/fact-recall-service.js";
35
- import { RecallService } from "../core/recall/recall-service.js";
36
- import { SqliteFactStore } from "../core/storage/sqlite-fact-store.js";
37
- import { ProviderRegistry } from "../core/providers/provider-registry.js";
38
- import { SourceRegistry } from "../core/sources/source-registry.js";
39
- import { SqliteSessionStore } from "../core/storage/sqlite-session-store.js";
40
- import { applyPendingRestore } from "../core/storage/db-restore.js";
41
- import { createApp } from "../http/app.js";
42
- import { createMcpServer } from "../mcp/server.js";
43
- import { ClassifierBox, type ClassifierProvider } from "../llm/classifier-box.js";
44
- import { DeepSeekClient } from "../llm/deepseek-client.js";
45
- import { OllamaClient } from "../llm/ollama-client.js";
46
- import { autoloadEnv } from "../llm/env-autoload.js";
47
- import { addHook, buildHookCommand, removeHook, smokeTestHookCommand } from "../core/hook/claude-settings.js";
48
- import {
49
- codexBinaryAvailable,
50
- connectCodex,
51
- disconnectCodex,
52
- pluginScriptsDir,
53
- } from "../install/codex.js";
54
- import { connectClaudeCode, disconnectClaudeCode, installClaudeCodeHooks, mcpConfigPath } from "../install/claude-code.js";
55
- import { hardenNlmDirPermissions } from "../install/nlm-dir-perms.js";
56
- import { ensureMcpToken } from "../install/ollama.js";
57
- import { connectCursor, disconnectCursor } from "../install/cursor.js";
58
- import { connectHermes, disconnectHermes, hermesConfigPath } from "../install/hermes.js";
59
- import { connectHermesAgent, disconnectHermesAgent, hermesAgentPluginDir } from "../install/hermes-agent.js";
60
- import { connectWindsurf, disconnectWindsurf } from "../install/windsurf.js";
61
- import { runSetup } from "../install/setup.js";
62
- import { runParity } from "./classify-parity.js";
63
- import { reembedCorpus } from "../core/embedding/embed-backfill.js";
64
- import { backfillFacts } from "../core/facts/backfill-facts.js";
65
- import { normalizeEmbeddings } from "../core/embedding/embed-normalize.js";
66
- import { ScanScheduler } from "../core/scheduler/scheduler.js";
67
- import { MemoSweepScheduler } from "../core/hook/memo-sweep.js";
68
- import { isAgentLoaded, isBenignBootoutError } from "./launchctl-helpers.js";
69
- import { adapterFromSource } from "../core/adapters/from-source.js";
70
- import type { TranscriptAdapter } from "../ports/transcript-adapter.js";
71
- import { scanUsefulHits } from "../core/recall/useful-scan.js";
72
-
73
- const __filename = fileURLToPath(import.meta.url);
74
- const __dirname = dirname(__filename);
75
- const MIGRATIONS_DIR = resolve(__dirname, "../../migrations");
76
- const UI_DIST = resolve(__dirname, "../../dist/ui");
77
- const DEFAULT_DB_PATH = resolve(homedir(), ".nlm/canonical.sqlite");
78
- const DEFAULT_PORT = 3940;
79
-
80
- function dbPath(): string {
81
- return process.env["NLM_DB_PATH"] ?? DEFAULT_DB_PATH;
82
- }
83
-
84
- function port(): number {
85
- const raw = process.env["NLM_PORT"];
86
- if (!raw) return DEFAULT_PORT;
87
- const n = Number.parseInt(raw, 10);
88
- if (!Number.isFinite(n) || n < 1 || n > 65_535) return DEFAULT_PORT;
89
- return n;
90
- }
91
-
92
- function ollamaUrl(): string {
93
- return process.env["NLM_OLLAMA_URL"] ?? "http://localhost:11434";
94
- }
95
-
96
- function buildClassifier(): ClassifierBox {
97
- // DeepSeek V4 Flash is the default for the ingest classifier per the
98
- // 2026-05-19 parity run: ~5s/session, 90% first-try success vs Ollama
99
- // phi4-mini's 0% on the same first three sessions. Override with
100
- // NLM_CLASSIFIER=ollama if you need offline-only operation.
101
- const provider = ((process.env["NLM_CLASSIFIER"] ?? "deepseek").toLowerCase() as ClassifierProvider);
102
- if (provider !== "ollama") autoloadEnv();
103
- const model = process.env["NLM_CLASSIFIER_MODEL"]
104
- ?? (provider === "ollama" ? "phi4-mini:latest" : "deepseek-v4-flash");
105
- return new ClassifierBox({ provider, model, ollamaUrl: ollamaUrl() });
106
- }
107
-
108
- function buildAdapters(sources: SourceRegistry): TranscriptAdapter[] {
109
- // Sources table is the source of truth. Each enabled row maps to one
110
- // adapter via adapterFromSource(). Detection still gates registration —
111
- // a row pointing at a missing dir won't poll. NLM_ADAPTERS keeps working
112
- // as a name-based filter for forcing a subset during dev.
113
- const explicit = process.env["NLM_ADAPTERS"];
114
- const allowed = explicit ? new Set(explicit.split(",").map((s) => s.trim())) : null;
115
- const out: TranscriptAdapter[] = [];
116
- for (const row of sources.list()) {
117
- if (!row.enabled) continue;
118
- const adapter = adapterFromSource(row);
119
- if (!adapter) continue;
120
- if (allowed && !allowed.has(adapter.name)) continue;
121
- if (!adapter.detect().enabled) continue;
122
- out.push(adapter);
123
- }
124
- return out;
125
- }
126
-
127
- function buildStack() {
128
- // Load .env before any registry seeds so secrets carried in env vars
129
- // (DEEPSEEK_API_KEY today; OPENAI_API_KEY etc. tomorrow) bridge into
130
- // the providers table on first boot under launchd.
131
- autoloadEnv();
132
- // A restore staged via POST /api/data/restore is promoted here, before
133
- // the store opens — the daemon can't swap a DB file it already holds.
134
- const restored = applyPendingRestore(dbPath());
135
- if (restored.applied) {
136
- console.error(`nlm-memory: restored database from staged backup`);
137
- if (restored.archivedTo) console.error(` previous db archived at ${restored.archivedTo}`);
138
- }
139
- const store = new SqliteSessionStore({
140
- dbPath: dbPath(),
141
- migrationsDir: MIGRATIONS_DIR,
142
- });
143
- // FactStore shares the SessionStore's connection so session+facts ingest
144
- // can commit in one transaction. Phase B.1 wires it in; no callers yet.
145
- const facts = new SqliteFactStore(store.rawDb());
146
- const sources = new SourceRegistry(store.rawDb());
147
- sources.seedDefaults();
148
- const providers = new ProviderRegistry(store.rawDb());
149
- providers.seedDefaults();
150
- // Recall only uses embed(). Embeddings live on Ollama; DeepSeek doesn't
151
- // expose them. Classifier is wired separately for Phase D ingest.
152
- const embedder = new OllamaClient({ baseUrl: ollamaUrl() });
153
- const classifier = buildClassifier();
154
- const recall = new RecallService({ store, llm: embedder });
155
- const factRecall = new FactRecallService({ factStore: facts, llm: embedder });
156
- return { store, facts, sources, providers, recall, factRecall, embedder, classifier };
157
- }
158
-
159
- const program = new Command();
160
- program
161
- .name("nlm")
162
- .description("Local-first memory operating system for AI operators")
163
- .version("0.3.0");
164
-
165
- program
166
- .command("start")
167
- .description("Boot the HTTP server + ingest scheduler")
168
- .option("--no-scheduler", "HTTP only; skip the ingest tick loop")
169
- .option("--interval-min <n>", "scheduler tick interval (min, default 30)", (v) => Number.parseInt(v, 10), 30)
170
- .action(async (opts) => {
171
- // Self-heal perms on every daemon start. Idempotent. Covers upgrade
172
- // path from pre-v0.4.2 installs where ~/.nlm contents were world-readable.
173
- hardenNlmDirPermissions();
174
- // Generate NLM_MCP_TOKEN if missing so /api/* gets Bearer-protected for
175
- // non-browser callers. Idempotent: re-reads persisted token first.
176
- autoloadEnv();
177
- ensureMcpToken();
178
- const { store, facts, sources, providers, recall, factRecall, embedder, classifier } = buildStack();
179
- const { existsSync } = await import("node:fs");
180
- const hasMcpToken = Boolean(process.env["NLM_MCP_TOKEN"]);
181
- const app = createApp({
182
- recall,
183
- store,
184
- liveStore: store,
185
- factRecall,
186
- factStore: facts,
187
- dbPath: dbPath(),
188
- classifier,
189
- sources,
190
- providers,
191
- ingest: { classifier, embedder, store, factStore: facts },
192
- embedderInfo: { provider: "ollama", model: "nomic-embed-text", dims: 768 },
193
- ...(existsSync(UI_DIST) ? { uiDist: UI_DIST } : {}),
194
- // Wire POST /mcp only when NLM_MCP_TOKEN is present. Absent = route never
195
- // mounts, zero attack surface. Present = token-gated Streamable-HTTP MCP
196
- // endpoint for container agents (e.g. Hermes WebUI).
197
- ...(hasMcpToken
198
- ? { mcpDeps: { recall, store, factRecall, factStore: facts } }
199
- : {}),
200
- });
201
- const p = port();
202
- serve({ fetch: app.fetch, port: p, hostname: "127.0.0.1" }, (info) => {
203
- console.error(`nlm-memory http listening on http://localhost:${info.port}`);
204
- if (hasMcpToken) {
205
- console.error(` mcp: http://localhost:${info.port}/mcp (token-gated)`);
206
- }
207
- console.error(` db: ${dbPath()}`);
208
- console.error(` ollama: ${ollamaUrl()}`);
209
- });
210
-
211
- // Keep the SQLite WAL bounded. WAL mode is on but nothing else
212
- // checkpoints it; under continuous readers it grows without limit
213
- // (it had reached 38 MB), which slows every read. Drain once at boot,
214
- // then every 5 minutes.
215
- const WAL_CHECKPOINT_INTERVAL_MS = 5 * 60_000;
216
- try {
217
- store.checkpoint();
218
- } catch {
219
- // Boot checkpoint can lose a race with readers — the interval retries.
220
- }
221
- const checkpointTimer = setInterval(() => {
222
- try {
223
- store.checkpoint();
224
- } catch {
225
- // Checkpoint contention — the next tick retries.
226
- }
227
- }, WAL_CHECKPOINT_INTERVAL_MS);
228
- checkpointTimer.unref();
229
-
230
- // Memo sweep runs independently of the transcript scheduler — it's the
231
- // backstop for SessionEnd hook unreliability (crashes, kill -9, IDE
232
- // force-close don't fire SessionEnd, so memo files would otherwise
233
- // accumulate forever). Always on, even when --no-scheduler.
234
- const memoSweep = new MemoSweepScheduler();
235
- memoSweep.start();
236
- console.error(" memo sweep: dormant cleanup every 5m (threshold 24h)");
237
-
238
- if (opts.scheduler !== false) {
239
- const adapters = buildAdapters(sources);
240
- if (adapters.length === 0) {
241
- console.error(" scheduler: no adapters detected (set NLM_ADAPTERS to force-enable)");
242
- } else {
243
- const scheduler = new ScanScheduler({
244
- store,
245
- adapters,
246
- classifier,
247
- embedder,
248
- factStore: facts,
249
- intervalMs: opts.intervalMin * 60_000,
250
- });
251
- scheduler.start();
252
- console.error(
253
- ` scheduler: ${adapters.map((a) => a.name).join(", ")} every ${opts.intervalMin}m`,
254
- );
255
- const shutdown = () => {
256
- clearInterval(checkpointTimer);
257
- scheduler.stop();
258
- memoSweep.stop();
259
- store.close();
260
- process.exit(0);
261
- };
262
- process.on("SIGINT", shutdown);
263
- process.on("SIGTERM", shutdown);
264
- }
265
- }
266
- });
267
-
268
- program
269
- .command("migrate")
270
- .description("Run pending migrations against the canonical SQLite")
271
- .action(() => {
272
- // SqliteSessionStore's constructor loads sqlite-vec and runs migrations.
273
- // Opening + closing is the whole operation.
274
- const store = new SqliteSessionStore({
275
- dbPath: dbPath(),
276
- migrationsDir: MIGRATIONS_DIR,
277
- });
278
- store.close();
279
- console.error(`nlm-memory: migrations applied at ${dbPath()}`);
280
- });
281
-
282
- program
283
- .command("recall")
284
- .description("One-shot recall query (for shell debugging)")
285
- .argument("<query>", "search query")
286
- .option("-e, --entity <name>", "filter by entity")
287
- .option("-k, --kind <kind>", "filter by marker kind (decision|open)")
288
- .option("-m, --mode <mode>", "keyword|semantic|hybrid", "keyword")
289
- .option("-l, --limit <n>", "max results", (v) => Number.parseInt(v, 10), 10)
290
- .action(async (query, opts) => {
291
- const { store, recall } = buildStack();
292
- try {
293
- const result = await recall.search({
294
- query,
295
- mode: opts.mode,
296
- limit: opts.limit,
297
- ...(opts.entity ? { entity: opts.entity } : {}),
298
- ...(opts.kind ? { kind: opts.kind } : {}),
299
- });
300
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
301
- } finally {
302
- store.close();
303
- }
304
- });
305
-
306
- program
307
- .command("classify-parity")
308
- .description("Run TS classifier against ~/.nlm/canonical.sqlite and diff vs persisted Python output")
309
- .option("-l, --limit <n>", "sessions to sample", (v) => Number.parseInt(v, 10), 10)
310
- .option("-p, --provider <name>", "deepseek | ollama", "deepseek")
311
- .option("-m, --model <name>", "model tag (default: deepseek-v4-flash for deepseek, phi4-mini:latest for ollama)")
312
- .option("-v, --verbose", "per-session diff lines on stderr")
313
- .action(async (opts) => {
314
- const provider = opts.provider === "ollama" ? "ollama" : "deepseek";
315
- const defaultModel = provider === "deepseek" ? "deepseek-v4-flash" : "phi4-mini:latest";
316
- const report = await runParity({
317
- limit: opts.limit,
318
- dbPath: dbPath(),
319
- ollamaUrl: ollamaUrl(),
320
- classifyModel: opts.model ?? defaultModel,
321
- provider,
322
- verbose: Boolean(opts.verbose),
323
- });
324
- process.stdout.write(JSON.stringify(report, null, 2) + "\n");
325
- });
326
-
327
- program
328
- .command("embed-backfill")
329
- .description("Re-embed every session into session_embedding_chunks (chunk + max-pool)")
330
- .option("-l, --limit <n>", "session cap (default: all)", (v) => Number.parseInt(v, 10))
331
- .option("--state <path>", "resume state file (default ~/.nlm/embed_reembed.state)")
332
- .option("-v, --verbose", "per-session progress on stderr")
333
- .action(async (opts) => {
334
- const embedder = new OllamaClient({ baseUrl: ollamaUrl() });
335
- const report = await reembedCorpus({
336
- dbPath: dbPath(),
337
- embedder,
338
- ...(opts.state ? { statePath: opts.state } : {}),
339
- ...(opts.limit ? { limit: opts.limit } : {}),
340
- ...(opts.verbose
341
- ? {
342
- onProgress: (i: number, n: number, sid: string, status: string) => {
343
- process.stderr.write(` [${i}/${n}] ${sid} ${status}\n`);
344
- },
345
- }
346
- : {}),
347
- });
348
- process.stdout.write(JSON.stringify(report, null, 2) + "\n");
349
- });
350
-
351
- program
352
- .command("backfill-facts")
353
- .description("One-shot: classify historical sessions and populate the FactStore (Phase B.5)")
354
- .option("-l, --limit <n>", "max sessions to process this run", (v) => Number.parseInt(v, 10))
355
- .option("--from <session-id>", "skip sessions with id <= this value (operator-resume)")
356
- .option("--state <path>", "resume state file (default ~/.nlm/backfill_facts.state)")
357
- .option("--dry-run", "count what would happen without writing facts")
358
- .option("--reprocess", "re-classify sessions that already have facts")
359
- .option("--no-embed", "skip per-fact embedding (faster but disables semantic recall)")
360
- .option("-v, --verbose", "per-session progress on stderr")
361
- .action(async (opts) => {
362
- const { store, facts, embedder, classifier } = buildStack();
363
- try {
364
- const report = await backfillFacts({
365
- store,
366
- factStore: facts,
367
- classifier,
368
- embedder: opts.embed === false ? null : embedder,
369
- ...(opts.state ? { statePath: opts.state } : {}),
370
- ...(opts.limit ? { limit: opts.limit } : {}),
371
- ...(opts.from ? { from: opts.from } : {}),
372
- dryRun: Boolean(opts.dryRun),
373
- reprocess: Boolean(opts.reprocess),
374
- ...(opts.verbose
375
- ? {
376
- onProgress: (i, n, sid, status, detail) => {
377
- const tail = detail ? ` ${detail}` : "";
378
- process.stderr.write(` [${i}/${n}] ${sid} ${status}${tail}\n`);
379
- },
380
- }
381
- : {}),
382
- });
383
- process.stdout.write(JSON.stringify(report, null, 2) + "\n");
384
- } finally {
385
- store.close();
386
- }
387
- });
388
-
389
- program
390
- .command("embed-normalize")
391
- .description("L2-normalize every row in session_embeddings (idempotent)")
392
- .option("--dim <n>", "vector dimension (default 768)", (v) => Number.parseInt(v, 10), 768)
393
- .option("--batch <n>", "rows per commit batch (default 100)", (v) => Number.parseInt(v, 10), 100)
394
- .option("--dry-run", "report what would change without writing")
395
- .action((opts) => {
396
- const report = normalizeEmbeddings({
397
- dbPath: dbPath(),
398
- dim: opts.dim,
399
- batchSize: opts.batch,
400
- dryRun: Boolean(opts.dryRun),
401
- });
402
- process.stdout.write(JSON.stringify(report, null, 2) + "\n");
403
- });
404
-
405
- program
406
- .command("mcp")
407
- .description("Run as an MCP stdio server (for ~/.mcp.json)")
408
- .action(async () => {
409
- const { recall, store, facts, factRecall } = buildStack();
410
- const server = createMcpServer({ recall, store, factStore: facts, factRecall });
411
- const transport = new StdioServerTransport();
412
- await server.connect(transport);
413
- });
414
-
415
- const LAUNCH_AGENT_LABEL = "com.github.pbmagnet4.nlm-memory";
416
- const LAUNCH_AGENT_PLIST = join(
417
- homedir(), "Library", "LaunchAgents", `${LAUNCH_AGENT_LABEL}.plist`,
418
- );
419
-
420
- const LINUX_SYSTEMD_UNIT_NAME = "nlm.service";
421
- const LINUX_SYSTEMD_UNIT_PATH = join(
422
- homedir(), ".config", "systemd", "user", LINUX_SYSTEMD_UNIT_NAME,
423
- );
424
-
425
- function buildSystemdUnit(nodeExec: string, nlmJs: string): string {
426
- const logDir = join(homedir(), ".nlm", "logs");
427
- return `[Unit]
428
- Description=NLM Memory — local AI session memory daemon
429
- After=network.target
430
-
431
- [Service]
432
- Type=simple
433
- ExecStart=${nodeExec} ${nlmJs} start
434
- WorkingDirectory=${homedir()}
435
- Restart=on-failure
436
- RestartSec=10
437
- StandardOutput=append:${logDir}/daemon-out.log
438
- StandardError=append:${logDir}/daemon-err.log
439
-
440
- [Install]
441
- WantedBy=default.target
442
- `;
443
- }
444
-
445
- // systemd user instance needs XDG_RUNTIME_DIR (a real user session) and
446
- // systemctl --user to respond. Both are missing on headless servers without
447
- // loginctl enable-linger and in many minimal containers.
448
- function linuxSystemdUserAvailable(): boolean {
449
- if (process.platform !== "linux") return false;
450
- if (!process.env["XDG_RUNTIME_DIR"]) return false;
451
- return spawnSync("systemctl", ["--user", "--version"], { encoding: "utf8" }).status === 0;
452
- }
453
-
454
- function buildPlist(nodeExec: string, nlmJs: string): string {
455
- const logDir = join(homedir(), ".nlm", "logs");
456
- return `<?xml version="1.0" encoding="UTF-8"?>
457
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
458
- <plist version="1.0">
459
- <dict>
460
- <key>Label</key>
461
- <string>${LAUNCH_AGENT_LABEL}</string>
462
- <key>ProgramArguments</key>
463
- <array>
464
- <string>${nodeExec}</string>
465
- <string>${nlmJs}</string>
466
- <string>start</string>
467
- </array>
468
- <key>WorkingDirectory</key>
469
- <string>${homedir()}</string>
470
- <key>RunAtLoad</key>
471
- <true/>
472
- <key>KeepAlive</key>
473
- <dict>
474
- <key>Crashed</key>
475
- <true/>
476
- <key>SuccessfulExit</key>
477
- <false/>
478
- </dict>
479
- <key>ThrottleInterval</key>
480
- <integer>10</integer>
481
- <key>StandardOutPath</key>
482
- <string>${logDir}/daemon-out.log</string>
483
- <key>StandardErrorPath</key>
484
- <string>${logDir}/daemon-err.log</string>
485
- </dict>
486
- </plist>
487
- `;
488
- }
489
-
490
- program
491
- .command("install")
492
- .description("Install the auto-start daemon (LaunchAgent on macOS, systemd user unit on Linux)")
493
- .action(() => {
494
- // Harden before installing the daemon so the persisted unit owner-
495
- // checks succeed against locked-down ~/.nlm logs.
496
- hardenNlmDirPermissions();
497
- if (process.platform === "darwin") {
498
- const uid = process.getuid?.();
499
- if (uid === undefined) {
500
- console.error("nlm install: could not determine UID");
501
- process.exit(1);
502
- }
503
- mkdirSync(join(homedir(), ".nlm", "logs"), { recursive: true });
504
- writeFileSync(LAUNCH_AGENT_PLIST, buildPlist(process.execPath, __filename), "utf8");
505
- console.error(`nlm: wrote ${LAUNCH_AGENT_PLIST}`);
506
- try {
507
- execFileSync("launchctl", ["bootout", `gui/${uid}`, LAUNCH_AGENT_LABEL], { stdio: "ignore" });
508
- } catch {
509
- // not loaded yet — expected on first install
510
- }
511
- execFileSync("launchctl", ["bootstrap", `gui/${uid}`, LAUNCH_AGENT_PLIST]);
512
- console.error("nlm: daemon installed and started.");
513
- console.error(` UI: http://localhost:${port()}/ui`);
514
- console.error(` To stop: launchctl stop ${LAUNCH_AGENT_LABEL}`);
515
- console.error(" To remove: nlm uninstall");
516
- return;
517
- }
518
-
519
- if (process.platform === "linux") {
520
- if (!linuxSystemdUserAvailable()) {
521
- console.error("nlm install: systemd user instance not available.");
522
- console.error(" XDG_RUNTIME_DIR missing or `systemctl --user` did not respond.");
523
- console.error(" Common on headless servers without an active user session.");
524
- console.error(" Start manually with: nlm start &");
525
- console.error(" Or enable lingering so user units run without login:");
526
- console.error(" sudo loginctl enable-linger $USER");
527
- console.error(" Then re-run: nlm install");
528
- process.exit(1);
529
- }
530
- mkdirSync(dirname(LINUX_SYSTEMD_UNIT_PATH), { recursive: true });
531
- mkdirSync(join(homedir(), ".nlm", "logs"), { recursive: true });
532
- writeFileSync(LINUX_SYSTEMD_UNIT_PATH, buildSystemdUnit(process.execPath, __filename), "utf8");
533
- console.error(`nlm: wrote ${LINUX_SYSTEMD_UNIT_PATH}`);
534
- execFileSync("systemctl", ["--user", "daemon-reload"]);
535
- execFileSync("systemctl", ["--user", "enable", "--now", LINUX_SYSTEMD_UNIT_NAME]);
536
- console.error("nlm: daemon installed and started.");
537
- console.error(` UI: http://localhost:${port()}/ui`);
538
- console.error(` Status: systemctl --user status ${LINUX_SYSTEMD_UNIT_NAME}`);
539
- console.error(` To stop: systemctl --user stop ${LINUX_SYSTEMD_UNIT_NAME}`);
540
- console.error(" To remove: nlm uninstall");
541
- console.error(" Headless? Run `sudo loginctl enable-linger $USER` so the daemon survives logout.");
542
- return;
543
- }
544
-
545
- console.error("nlm install: only macOS and Linux (systemd) are supported.");
546
- console.error(" On Windows, run `nlm start` manually or via Task Scheduler.");
547
- process.exit(1);
548
- });
549
-
550
- program
551
- .command("uninstall")
552
- .description("Remove the auto-start daemon (LaunchAgent on macOS, systemd user unit on Linux)")
553
- .action(() => {
554
- if (process.platform === "linux") {
555
- // Stop + disable, then remove the unit. Idempotent: ignore "not loaded"
556
- // errors so re-running uninstall on a half-removed state still finishes.
557
- try {
558
- execFileSync("systemctl", ["--user", "disable", "--now", LINUX_SYSTEMD_UNIT_NAME], { stdio: "pipe" });
559
- console.error(`nlm: stopped and disabled ${LINUX_SYSTEMD_UNIT_NAME}`);
560
- } catch {
561
- // Unit wasn't loaded — fine, proceed to file cleanup.
562
- }
563
- if (existsSync(LINUX_SYSTEMD_UNIT_PATH)) {
564
- rmSync(LINUX_SYSTEMD_UNIT_PATH);
565
- console.error(`nlm: removed ${LINUX_SYSTEMD_UNIT_PATH}`);
566
- }
567
- try {
568
- execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
569
- } catch {
570
- // systemd unavailable — file already removed, nothing more to do.
571
- }
572
- console.error("nlm: uninstalled. Run `nlm install` to reinstall.");
573
- return;
574
- }
575
-
576
- if (process.platform !== "darwin") {
577
- console.error("nlm uninstall: only macOS and Linux (systemd) are supported.");
578
- process.exit(1);
579
- }
580
- const uid = process.getuid?.();
581
- if (uid === undefined) {
582
- console.error("nlm uninstall: could not determine UID");
583
- process.exit(1);
584
- }
585
-
586
- let bootoutFailed = false;
587
- let bootoutStderr = "";
588
- try {
589
- execFileSync("launchctl", ["bootout", `gui/${uid}`, LAUNCH_AGENT_LABEL], { stdio: "pipe" });
590
- console.error("nlm: daemon stopped.");
591
- } catch (e) {
592
- const err = e as { stderr?: Buffer | string };
593
- bootoutStderr = err.stderr ? err.stderr.toString() : "";
594
- if (isBenignBootoutError(bootoutStderr)) {
595
- // Agent wasn't loaded — fine, proceed to plist cleanup.
596
- } else {
597
- bootoutFailed = true;
598
- }
599
- }
600
-
601
- // Source of truth: did launchd actually unload the agent? Same shape
602
- // of bug as #161 — silent partial success is worse than loud failure.
603
- if (isAgentLoaded(LAUNCH_AGENT_LABEL)) {
604
- console.error("nlm: uninstall FAILED — agent is still loaded after bootout.");
605
- if (bootoutStderr.trim()) {
606
- console.error(` launchctl stderr: ${bootoutStderr.trim()}`);
607
- }
608
- console.error(" Recovery options:");
609
- console.error(` 1. launchctl bootout gui/${uid}/${LAUNCH_AGENT_LABEL}`);
610
- console.error(" 2. If a stale process is holding the port, find it:");
611
- console.error(" ps aux | grep 'nlm.js start' | grep -v grep");
612
- console.error(" Then: kill <pid> (or kill -9 <pid> if it ignores TERM)");
613
- console.error(" Plist NOT removed — re-run `nlm uninstall` after the agent is gone.");
614
- process.exit(1);
615
- }
616
-
617
- if (bootoutFailed) {
618
- // launchctl errored AND the agent isn't loaded — odd but recoverable.
619
- // Flag it so the user knows something off-script happened.
620
- console.error(`nlm: bootout reported an error but agent is unloaded: ${bootoutStderr.trim()}`);
621
- }
622
-
623
- if (existsSync(LAUNCH_AGENT_PLIST)) {
624
- rmSync(LAUNCH_AGENT_PLIST);
625
- console.error(`nlm: removed ${LAUNCH_AGENT_PLIST}`);
626
- }
627
- console.error("nlm: uninstalled. Run `nlm install` to reinstall.");
628
- });
629
-
630
- const HOOK_JS = resolve(__dirname, "../hook/prompt-recall-hook.js");
631
- const SESSION_START_HOOK_JS = resolve(__dirname, "../hook/session-start-hook.js");
632
- const SESSION_END_HOOK_JS = resolve(__dirname, "../hook/session-end-hook.js");
633
- const STOP_HOOK_JS = resolve(__dirname, "../hook/stop-hook.js");
634
- const PRE_COMPACT_HOOK_JS = resolve(__dirname, "../hook/pre-compact-hook.js");
635
- const SUBAGENT_START_HOOK_JS = resolve(__dirname, "../hook/subagent-start-hook.js");
636
-
637
- interface HookSpec {
638
- readonly event: "UserPromptSubmit" | "SessionStart" | "SessionEnd" | "Stop" | "PreCompact" | "SubagentStart";
639
- readonly script: string;
640
- readonly label: string;
641
- }
642
-
643
- const ALL_HOOKS: ReadonlyArray<HookSpec> = [
644
- { event: "UserPromptSubmit", script: HOOK_JS, label: "recall" },
645
- { event: "SessionStart", script: SESSION_START_HOOK_JS, label: "session-start" },
646
- { event: "SessionEnd", script: SESSION_END_HOOK_JS, label: "session-end" },
647
- { event: "Stop", script: STOP_HOOK_JS, label: "stop" },
648
- { event: "PreCompact", script: PRE_COMPACT_HOOK_JS, label: "pre-compact" },
649
- { event: "SubagentStart", script: SUBAGENT_START_HOOK_JS, label: "subagent-start" },
650
- ];
651
-
652
- function claudeSettingsPath(): string {
653
- return process.env["NLM_CLAUDE_SETTINGS"] ?? join(homedir(), ".claude", "settings.json");
654
- }
655
-
656
- const hook = program
657
- .command("hook")
658
- .description("Manage the Claude Code NLM hooks");
659
-
660
- hook
661
- .command("install")
662
- .description("Add the NLM hooks (recall + session-end + stop) to ~/.claude/settings.json (live mode)")
663
- .action(() => {
664
- const path = claudeSettingsPath();
665
- const hookLogPath = process.env["NLM_HOOK_LOG"] ?? join(homedir(), ".nlm", "hook-log.jsonl");
666
-
667
- // Install + smoke each hook. If any fails, revert all and exit nonzero.
668
- // Atomic install matters because partial state ("recall installed but
669
- // session-end didn't smoke-test") is worse than no install — silent
670
- // partial failure is the bug class we shipped #161 to prevent.
671
- const installed: HookSpec[] = [];
672
- for (const spec of ALL_HOOKS) {
673
- const command = buildHookCommand(process.execPath, spec.script, "live");
674
- addHook(path, command, spec.event);
675
- const smoke = smokeTestHookCommand(command, hookLogPath);
676
- if (!smoke.ok) {
677
- // Revert every hook we installed this run (including the failing one).
678
- for (const prior of [...installed, spec]) {
679
- removeHook(path, prior.event);
680
- }
681
- console.error(`nlm: ${spec.label} hook (${spec.event}) FAILED smoke test — all NLM hooks reverted.`);
682
- console.error(` reason: ${smoke.reason}`);
683
- if (smoke.stderr) {
684
- const trimmed = smoke.stderr.trim();
685
- if (trimmed) console.error(` stderr: ${trimmed}`);
686
- }
687
- console.error(` command was: ${command}`);
688
- process.exit(1);
689
- }
690
- installed.push(spec);
691
- }
692
-
693
- console.error(`nlm: NLM hooks installed in ${path} (live mode):`);
694
- for (const spec of installed) {
695
- console.error(` - ${spec.event} → ${spec.label}-hook`);
696
- }
697
- console.error(" Smoke tests passed — all hooks appended synthetic entries to hook-log.jsonl.");
698
- console.error(" Recall hooks inject prior-session context on UserPromptSubmit and log to ~/.nlm/hook-log.jsonl.");
699
- console.error(" Session-end hook cleans up ~/.nlm/hook-state/<session>.json on session close.");
700
- console.error(" To run silently for calibration (no injection): set NLM_HOOK_MODE=shadow in the command.");
701
- console.error(" To remove: nlm hook uninstall");
702
- });
703
-
704
- hook
705
- .command("uninstall")
706
- .description("Remove all NLM hooks from ~/.claude/settings.json")
707
- .action(() => {
708
- const path = claudeSettingsPath();
709
- removeHook(path, "*");
710
- console.error(`nlm: all NLM hooks removed from ${path}.`);
711
- });
712
-
713
- // Repo root resolves to <pkg>/dist/cli/nlm.js → <pkg>/. The plugin tree is
714
- // shipped alongside dist/ so plugin/scripts/ is reachable from both local
715
- // dev and the globally-installed package.
716
- const REPO_ROOT = resolve(__dirname, "../..");
717
-
718
- const connect = program
719
- .command("connect")
720
- .description("Connect nlm-memory to an AI coding runtime");
721
-
722
- connect
723
- .command("codex")
724
- .description("Install nlm-memory as a Codex CLI plugin (marketplace + plugin add)")
725
- .option("--source <source>", "marketplace source (owner/repo, git URL, or local path)", "pbmagnet4/nlm-memory-ts")
726
- .option("--local", "shortcut for --source <repo-root>; use during dev")
727
- .option("--with-hooks", "additionally write absolute paths to ~/.codex/hooks.json (Codex Desktop fallback for openai/codex#16430)")
728
- .option("--dry-run", "print what would happen without invoking codex")
729
- .action((opts) => {
730
- if (!opts.dryRun && !codexBinaryAvailable()) {
731
- console.error("nlm connect codex: `codex` binary not on PATH. Install via `npm i -g @openai/codex` or `brew install codex`.");
732
- process.exit(1);
733
- }
734
- const source = opts.local ? REPO_ROOT : opts.source;
735
- const report = connectCodex(
736
- { source, withHooks: Boolean(opts.withHooks), dryRun: Boolean(opts.dryRun) },
737
- pluginScriptsDir(REPO_ROOT),
738
- );
739
-
740
- if (report.dryRun) {
741
- console.error("nlm connect codex (dry run):");
742
- console.error(` codex plugin marketplace add ${report.source}`);
743
- console.error(` codex plugin add ${report.pluginName}@${report.marketplaceName}`);
744
- console.error(` write [mcp_servers.nlm-memory] block to ${report.mcpServerWritten}`);
745
- if (report.legacyHooksWritten) {
746
- console.error(` write legacy fallback to ${report.legacyHooksWritten}`);
747
- }
748
- return;
749
- }
750
-
751
- if (report.marketplaceAdd && report.marketplaceAdd.status !== 0) {
752
- const stderr = report.marketplaceAdd.stderr.trim();
753
- console.error(`nlm connect codex: marketplace add failed (exit ${report.marketplaceAdd.status}).`);
754
- if (stderr) console.error(` codex stderr: ${stderr}`);
755
- process.exit(1);
756
- }
757
- if (report.pluginAdd && report.pluginAdd.status !== 0) {
758
- const stderr = report.pluginAdd.stderr.trim();
759
- console.error(`nlm connect codex: plugin add failed (exit ${report.pluginAdd.status}).`);
760
- if (stderr) console.error(` codex stderr: ${stderr}`);
761
- process.exit(1);
762
- }
763
-
764
- console.error(`nlm: connected to Codex via marketplace ${report.marketplaceName}, plugin ${report.pluginName}.`);
765
- if (report.mcpServerWritten) {
766
- console.error(` Wrote [mcp_servers.nlm-memory] to ${report.mcpServerWritten}`);
767
- }
768
- if (report.legacyHooksWritten) {
769
- console.error(` Wrote hooks.json fallback to ${report.legacyHooksWritten}`);
770
- }
771
- console.error(" Next: run `codex` interactively and approve the hook trust prompts. Then prompt — recall should fire.");
772
- });
773
-
774
- connect
775
- .command("claude-code")
776
- .description("Write the nlm-memory MCP server block into ~/.mcp.json")
777
- .option("--with-hooks", "also install Claude Code session hooks")
778
- .option("--dry-run", "print what would happen without changing files")
779
- .action((opts) => {
780
- if (opts.dryRun) {
781
- console.error("nlm connect claude-code (dry run):");
782
- console.error(` write [mcpServers.nlm-memory] to ${mcpConfigPath()}`);
783
- if (opts.withHooks) console.error(" install 6 Claude Code hooks");
784
- return;
785
- }
786
- const report = connectClaudeCode({ nlmBinPath: __filename, nodeExecPath: process.execPath });
787
- const action = report.alreadyPresent ? "updated" : "written";
788
- console.error(`nlm: [mcpServers.nlm-memory] ${action} → ${report.mcpConfigPath}`);
789
- console.error(" Restart Claude Code to activate the MCP server.");
790
- if (opts.withHooks) {
791
- const path = claudeSettingsPath();
792
- const hookLogPath = process.env["NLM_HOOK_LOG"] ?? join(homedir(), ".nlm", "hook-log.jsonl");
793
- const result = installClaudeCodeHooks({
794
- nodeExecPath: process.execPath,
795
- hooks: ALL_HOOKS,
796
- settingsPath: path,
797
- hookLogPath,
798
- addHook,
799
- removeHook,
800
- buildHookCommand,
801
- smokeTestHookCommand,
802
- });
803
- if (!result.ok) {
804
- console.error(`nlm: ${result.failedLabel ?? "hook"} smoke test failed — all hooks reverted. Run \`nlm hook install\` manually.`);
805
- process.exit(1);
806
- }
807
- console.error(`nlm: ${result.count} hooks installed → ${path}`);
808
- }
809
- });
810
-
811
- connect
812
- .command("hermes")
813
- .description("Write the nlm-memory MCP server entry into ~/.hermes/config.yaml")
814
- .option("--dry-run", "print what would happen without changing files")
815
- .action((opts) => {
816
- if (opts.dryRun) {
817
- console.error(`nlm connect hermes (dry run): write [mcp_servers.nlm-memory] to ${hermesConfigPath()}`);
818
- return;
819
- }
820
- const report = connectHermes({ nlmBinPath: __filename, nodeExecPath: process.execPath, dryRun: false });
821
- const action = report.alreadyPresent ? "updated" : "written";
822
- console.error(`nlm: [mcp_servers.nlm-memory] ${action} → ${report.configPath}`);
823
- console.error(" Restart Hermes to activate the MCP server.");
824
- });
825
-
826
- connect
827
- .command("hermes-agent")
828
- .description("Install the nlm-memory plugin into NousResearch Hermes Agent (~/.hermes/plugins/nlm-memory/)")
829
- .option("--dry-run", "print what would happen without changing files")
830
- .action((opts) => {
831
- const pluginSrcDir = join(REPO_ROOT, "plugin-hermes-agent");
832
- if (opts.dryRun) {
833
- console.error(`nlm connect hermes-agent (dry run): copy ${pluginSrcDir} → ${hermesAgentPluginDir()}`);
834
- console.error(" then: hermes plugins enable nlm-memory");
835
- return;
836
- }
837
- const report = connectHermesAgent({ pluginSrcDir, dryRun: false });
838
- const action = report.alreadyPresent ? "updated" : "installed";
839
- console.error(`nlm: nlm-memory plugin ${action} → ${report.destDir}`);
840
- if (report.enabledViaCli) {
841
- console.error(" Enabled via: hermes plugins enable nlm-memory");
842
- } else {
843
- console.error(" Run: hermes plugins enable nlm-memory (if hermes binary is on PATH)");
844
- }
845
- console.error(" Also run: nlm connect hermes (to wire the MCP server)");
846
- });
847
-
848
- connect
849
- .command("cursor")
850
- .description("Register Cursor as an nlm source (reads state.vscdb directly — no files installed)")
851
- .option("--db-path <path>", "override path to globalStorage/state.vscdb")
852
- .option("--dry-run", "print what would happen without changing files")
853
- .action((opts) => {
854
- const store = new SqliteSessionStore({ dbPath: dbPath(), migrationsDir: MIGRATIONS_DIR });
855
- try {
856
- const registry = new SourceRegistry(store.rawDb());
857
- const report = connectCursor(registry, {
858
- ...(opts.dbPath ? { dbPath: opts.dbPath as string } : {}),
859
- dryRun: Boolean(opts.dryRun),
860
- });
861
- if (opts.dryRun) {
862
- console.error(`nlm connect cursor (dry run): register source at ${report.adapterDbPath}${report.adapterExists ? "" : " (not found yet)"}`);
863
- return;
864
- }
865
- const suffix = report.adapterExists ? "" : " (DB not found — will activate when Cursor is installed)";
866
- console.error(`nlm: Cursor source ${report.action} → ${report.adapterDbPath}${suffix}`);
867
- } finally {
868
- store.close();
869
- }
870
- });
871
-
872
- connect
873
- .command("windsurf")
874
- .description("Register Windsurf as an nlm source (reads state.vscdb files directly — no files installed)")
875
- .option("--user-dir <path>", "override path to Windsurf User directory")
876
- .option("--dry-run", "print what would happen without changing files")
877
- .action((opts) => {
878
- const store = new SqliteSessionStore({ dbPath: dbPath(), migrationsDir: MIGRATIONS_DIR });
879
- try {
880
- const registry = new SourceRegistry(store.rawDb());
881
- const report = connectWindsurf(registry, {
882
- ...(opts.userDir ? { userDir: opts.userDir as string } : {}),
883
- dryRun: Boolean(opts.dryRun),
884
- });
885
- if (opts.dryRun) {
886
- console.error(`nlm connect windsurf (dry run): register source at ${report.userDir}${report.dirExists ? "" : " (not found yet)"}`);
887
- return;
888
- }
889
- const suffix = report.dirExists ? "" : " (User dir not found — will activate when Windsurf is installed)";
890
- console.error(`nlm: Windsurf source ${report.action} → ${report.userDir}${suffix}`);
891
- } finally {
892
- store.close();
893
- }
894
- });
895
-
896
- const disconnect = program
897
- .command("disconnect")
898
- .description("Disconnect nlm-memory from an AI coding runtime");
899
-
900
- disconnect
901
- .command("codex")
902
- .description("Remove the nlm-memory plugin + marketplace from Codex")
903
- .option("--with-hooks", "also strip our entries from ~/.codex/hooks.json")
904
- .option("--dry-run", "print what would happen without invoking codex")
905
- .action((opts) => {
906
- if (!opts.dryRun && !codexBinaryAvailable()) {
907
- console.error("nlm disconnect codex: `codex` binary not on PATH.");
908
- process.exit(1);
909
- }
910
- const report = disconnectCodex({
911
- withHooks: Boolean(opts.withHooks),
912
- dryRun: Boolean(opts.dryRun),
913
- });
914
-
915
- if (report.dryRun) {
916
- console.error("nlm disconnect codex (dry run):");
917
- console.error(` codex plugin remove ${report.pluginName}@${report.marketplaceName}`);
918
- console.error(` codex plugin marketplace remove ${report.marketplaceName}`);
919
- console.error(" strip [mcp_servers.nlm-memory] block from ~/.codex/config.toml");
920
- if (opts.withHooks) console.error(" strip our entries from ~/.codex/hooks.json");
921
- return;
922
- }
923
-
924
- // Best-effort removal — non-zero exits from codex are reported but
925
- // don't abort, because partial cleanup (plugin removed, marketplace
926
- // already gone) is the common case for repeat invocations.
927
- const pluginStderr = (report.pluginRemove?.stderr ?? "").trim();
928
- const marketStderr = (report.marketplaceRemove?.stderr ?? "").trim();
929
- if (report.pluginRemove?.status !== 0 && pluginStderr) {
930
- console.error(` plugin remove: ${pluginStderr}`);
931
- }
932
- if (report.marketplaceRemove?.status !== 0 && marketStderr) {
933
- console.error(` marketplace remove: ${marketStderr}`);
934
- }
935
- console.error("nlm: disconnected from Codex.");
936
- console.error(report.mcpServerRemoved
937
- ? " Stripped [mcp_servers.nlm-memory] block from ~/.codex/config.toml"
938
- : " No [mcp_servers.nlm-memory] block to remove from ~/.codex/config.toml");
939
- if (opts.withHooks) {
940
- console.error(report.legacyHooksRemoved
941
- ? " Stripped our entries from ~/.codex/hooks.json"
942
- : " No legacy hooks to remove from ~/.codex/hooks.json");
943
- }
944
- });
945
-
946
- disconnect
947
- .command("claude-code")
948
- .description("Remove the nlm-memory MCP server block from ~/.mcp.json")
949
- .option("--dry-run", "print what would happen without changing files")
950
- .action((opts) => {
951
- const report = disconnectClaudeCode({ dryRun: Boolean(opts.dryRun) });
952
- if (opts.dryRun) {
953
- console.error(`nlm disconnect claude-code (dry run): strip [mcpServers.nlm-memory] from ${report.mcpConfigPath}`);
954
- return;
955
- }
956
- console.error(report.removed
957
- ? `nlm: removed [mcpServers.nlm-memory] from ${report.mcpConfigPath}`
958
- : `nlm: no [mcpServers.nlm-memory] entry found in ${report.mcpConfigPath}`);
959
- });
960
-
961
- disconnect
962
- .command("hermes")
963
- .description("Remove the nlm-memory MCP server entry from ~/.hermes/config.yaml")
964
- .option("--dry-run", "print what would happen without changing files")
965
- .action((opts) => {
966
- const report = disconnectHermes({ dryRun: Boolean(opts.dryRun) });
967
- if (opts.dryRun) {
968
- console.error(`nlm disconnect hermes (dry run): strip [mcp_servers.nlm-memory] from ${report.configPath}`);
969
- return;
970
- }
971
- console.error(report.removed
972
- ? `nlm: removed [mcp_servers.nlm-memory] from ${report.configPath}`
973
- : `nlm: no [mcp_servers.nlm-memory] entry found in ${report.configPath}`);
974
- });
975
-
976
- disconnect
977
- .command("hermes-agent")
978
- .description("Remove the nlm-memory plugin from ~/.hermes/plugins/nlm-memory/")
979
- .option("--dry-run", "print what would happen without changing files")
980
- .action((opts) => {
981
- const report = disconnectHermesAgent({ dryRun: Boolean(opts.dryRun) });
982
- if (opts.dryRun) {
983
- console.error(`nlm disconnect hermes-agent (dry run): remove ${hermesAgentPluginDir()}`);
984
- return;
985
- }
986
- console.error(report.removed
987
- ? `nlm: removed plugin directory ${report.destDir}`
988
- : `nlm: no plugin directory found at ${report.destDir}`);
989
- });
990
-
991
- disconnect
992
- .command("cursor")
993
- .description("Disable the Cursor source in the nlm registry (leaves Cursor untouched)")
994
- .option("--dry-run", "print what would happen without changing files")
995
- .action((opts) => {
996
- const store = new SqliteSessionStore({ dbPath: dbPath(), migrationsDir: MIGRATIONS_DIR });
997
- try {
998
- const registry = new SourceRegistry(store.rawDb());
999
- const report = disconnectCursor(registry, { dryRun: Boolean(opts.dryRun) });
1000
- if (opts.dryRun) {
1001
- console.error("nlm disconnect cursor (dry run): disable Cursor source in registry");
1002
- return;
1003
- }
1004
- console.error(report.action === "disabled"
1005
- ? "nlm: Cursor source disabled"
1006
- : "nlm: no Cursor source found in registry");
1007
- } finally {
1008
- store.close();
1009
- }
1010
- });
1011
-
1012
- disconnect
1013
- .command("windsurf")
1014
- .description("Disable the Windsurf source in the nlm registry (leaves Windsurf untouched)")
1015
- .option("--dry-run", "print what would happen without changing files")
1016
- .action((opts) => {
1017
- const store = new SqliteSessionStore({ dbPath: dbPath(), migrationsDir: MIGRATIONS_DIR });
1018
- try {
1019
- const registry = new SourceRegistry(store.rawDb());
1020
- const report = disconnectWindsurf(registry, { dryRun: Boolean(opts.dryRun) });
1021
- if (opts.dryRun) {
1022
- console.error("nlm disconnect windsurf (dry run): disable Windsurf source in registry");
1023
- return;
1024
- }
1025
- console.error(report.action === "disabled"
1026
- ? "nlm: Windsurf source disabled"
1027
- : "nlm: no Windsurf source found in registry");
1028
- } finally {
1029
- store.close();
1030
- }
1031
- });
1032
-
1033
- program
1034
- .command("setup")
1035
- .description("Interactive first-run setup: detect runtimes, wire MCP + hooks, start daemon")
1036
- .action(async () => {
1037
- await runSetup({
1038
- nlmBinPath: __filename,
1039
- nodeExecPath: process.execPath,
1040
- migrationsDir: MIGRATIONS_DIR,
1041
- repoRoot: REPO_ROOT,
1042
- dbPath: dbPath(),
1043
- launchAgentLabel: LAUNCH_AGENT_LABEL,
1044
- launchAgentPlist: LAUNCH_AGENT_PLIST,
1045
- buildPlist,
1046
- linuxSystemdUnitName: LINUX_SYSTEMD_UNIT_NAME,
1047
- linuxSystemdUnitPath: LINUX_SYSTEMD_UNIT_PATH,
1048
- buildSystemdUnit,
1049
- linuxSystemdUserAvailable,
1050
- claudeSettingsPath: claudeSettingsPath(),
1051
- allHooks: ALL_HOOKS,
1052
- addHook,
1053
- removeHook,
1054
- buildHookCommand,
1055
- smokeTestHookCommand,
1056
- });
1057
- });
1058
-
1059
- program
1060
- .command("useful-scan")
1061
- .description("Scan hook log for useful recall hits; writes to ~/.nlm/useful-hit-log.jsonl")
1062
- .option("-d, --days <n>", "rolling window in days", (v) => Number.parseInt(v, 10), 1)
1063
- .option("--dry-run", "compute without writing to disk")
1064
- .action(async (opts) => {
1065
- const result = await scanUsefulHits({ days: opts.days as number, ...(opts.dryRun ? { dryRun: true } : {}) });
1066
- const rate = result.measurable === 0
1067
- ? "no measurable entries"
1068
- : `${result.useful}/${result.measurable} useful (${Math.round((result.useful / result.measurable) * 100)}%)`;
1069
- console.error(
1070
- `nlm useful-scan: scanned ${result.total} recalls in the last ${opts.days as number}d — ${rate}` +
1071
- (opts.dryRun ? " (dry-run)" : `, ${result.appended} appended`),
1072
- );
1073
- });
1074
-
1075
- program.parseAsync().catch((e) => {
1076
- console.error("nlm: fatal", e);
1077
- process.exit(1);
1078
- });