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
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
- });