nlm-memory 0.4.1 → 0.5.0

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 (90) hide show
  1. package/dist/cli/nlm.js +221 -32
  2. package/dist/cli/nlm.js.map +1 -1
  3. package/dist/core/adapters/cursor.d.ts +45 -0
  4. package/dist/core/adapters/cursor.js +397 -0
  5. package/dist/core/adapters/cursor.js.map +1 -0
  6. package/dist/core/adapters/from-source.js +10 -0
  7. package/dist/core/adapters/from-source.js.map +1 -1
  8. package/dist/core/adapters/windsurf.d.ts +44 -0
  9. package/dist/core/adapters/windsurf.js +299 -0
  10. package/dist/core/adapters/windsurf.js.map +1 -0
  11. package/dist/core/hook/claude-settings.d.ts +12 -5
  12. package/dist/core/hook/claude-settings.js +21 -6
  13. package/dist/core/hook/claude-settings.js.map +1 -1
  14. package/dist/core/sources/source-registry.d.ts +1 -1
  15. package/dist/core/sources/source-registry.js +18 -0
  16. package/dist/core/sources/source-registry.js.map +1 -1
  17. package/dist/core/storage/sqlite-session-store.d.ts +2 -0
  18. package/dist/core/storage/sqlite-session-store.js +38 -2
  19. package/dist/core/storage/sqlite-session-store.js.map +1 -1
  20. package/dist/hook/hook-auth.d.ts +13 -0
  21. package/dist/hook/hook-auth.js +19 -0
  22. package/dist/hook/hook-auth.js.map +1 -0
  23. package/dist/hook/prompt-recall-hook.js +7 -1
  24. package/dist/hook/prompt-recall-hook.js.map +1 -1
  25. package/dist/hook/session-start-hook.js +4 -1
  26. package/dist/hook/session-start-hook.js.map +1 -1
  27. package/dist/hook/stop-hook.js +4 -1
  28. package/dist/hook/stop-hook.js.map +1 -1
  29. package/dist/http/app.d.ts +2 -0
  30. package/dist/http/app.js +74 -0
  31. package/dist/http/app.js.map +1 -1
  32. package/dist/install/claude-code.js +1 -1
  33. package/dist/install/claude-code.js.map +1 -1
  34. package/dist/install/cursor.d.ts +25 -0
  35. package/dist/install/cursor.js +43 -0
  36. package/dist/install/cursor.js.map +1 -0
  37. package/dist/install/nlm-dir-perms.d.ts +19 -0
  38. package/dist/install/nlm-dir-perms.js +43 -0
  39. package/dist/install/nlm-dir-perms.js.map +1 -0
  40. package/dist/install/ollama.d.ts +18 -1
  41. package/dist/install/ollama.js +68 -10
  42. package/dist/install/ollama.js.map +1 -1
  43. package/dist/install/setup.d.ts +4 -0
  44. package/dist/install/setup.js +141 -18
  45. package/dist/install/setup.js.map +1 -1
  46. package/dist/install/windsurf.d.ts +25 -0
  47. package/dist/install/windsurf.js +43 -0
  48. package/dist/install/windsurf.js.map +1 -0
  49. package/dist/shared/types.d.ts +4 -0
  50. package/dist/ui/assets/{index-BA6IpU8g.css → index-C8cpwbYJ.css} +1 -1
  51. package/dist/ui/assets/index-CB50QnL-.js +69 -0
  52. package/dist/ui/index.html +2 -2
  53. package/logs/CHANGELOG/CHANGELOG-2026.md +186 -0
  54. package/logs/CHANGELOG/CHANGELOG.md +107 -235
  55. package/migrations/014_sources_cursor.sql +30 -0
  56. package/migrations/015_sources_windsurf.sql +30 -0
  57. package/package.json +1 -1
  58. package/plugin/scripts/prompt-recall-hook.mjs +55 -4
  59. package/plugin/scripts/stop-hook.mjs +57 -6
  60. package/src/cli/nlm.ts +224 -31
  61. package/src/core/adapters/cursor.ts +486 -0
  62. package/src/core/adapters/from-source.ts +10 -0
  63. package/src/core/adapters/windsurf.ts +386 -0
  64. package/src/core/hook/claude-settings.ts +30 -9
  65. package/src/core/sources/source-registry.ts +19 -1
  66. package/src/core/storage/sqlite-session-store.ts +46 -1
  67. package/src/hook/hook-auth.ts +18 -0
  68. package/src/hook/prompt-recall-hook.ts +7 -1
  69. package/src/hook/session-start-hook.ts +4 -1
  70. package/src/hook/stop-hook.ts +4 -1
  71. package/src/http/app.ts +78 -0
  72. package/src/install/claude-code.ts +1 -1
  73. package/src/install/cursor.ts +68 -0
  74. package/src/install/nlm-dir-perms.ts +55 -0
  75. package/src/install/ollama.ts +86 -10
  76. package/src/install/setup.ts +138 -17
  77. package/src/install/windsurf.ts +68 -0
  78. package/src/shared/types.ts +4 -0
  79. package/src/ui/components/SessionDrawer.tsx +97 -34
  80. package/src/ui/pages/River.tsx +90 -44
  81. package/src/ui/pages/Search.tsx +357 -64
  82. package/src/ui/pages/Thread.tsx +267 -56
  83. package/src/ui/styles.css +129 -5
  84. package/tests/integration/getbyids-sqlite.test.ts +40 -0
  85. package/tests/integration/hook-claude-settings.test.ts +14 -1
  86. package/tests/integration/mcp.test.ts +12 -0
  87. package/tests/integration/source-registry.test.ts +5 -3
  88. package/tests/unit/core/adapters/cursor.test.ts +485 -0
  89. package/tests/unit/core/adapters/windsurf.test.ts +416 -0
  90. package/dist/ui/assets/index-B_qIVV0k.js +0 -69
@@ -3,7 +3,7 @@
3
3
  // src/hook/stop-hook.ts
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { appendFileSync, mkdirSync as mkdirSync3 } from "node:fs";
6
- import { homedir as homedir3 } from "node:os";
6
+ import { homedir as homedir4 } from "node:os";
7
7
  import { dirname, join as join3 } from "node:path";
8
8
 
9
9
  // src/core/hook/citation-detect.ts
@@ -162,6 +162,56 @@ function readAllAssistantTurns(transcriptPath) {
162
162
  return turns;
163
163
  }
164
164
 
165
+ // src/llm/env-autoload.ts
166
+ import { readFileSync as readFileSync4, existsSync as existsSync4 } from "node:fs";
167
+ import { homedir as homedir3 } from "node:os";
168
+ import { resolve } from "node:path";
169
+ var DEFAULT_SEARCH_PATHS = [
170
+ "~/.nlm/.env",
171
+ "./.env",
172
+ "../.env",
173
+ "../../.env"
174
+ ];
175
+ function expandHome(p) {
176
+ if (p.startsWith("~/")) return resolve(homedir3(), p.slice(2));
177
+ return p;
178
+ }
179
+ function autoloadEnv(extraPaths = []) {
180
+ const loaded = [];
181
+ const paths = [...DEFAULT_SEARCH_PATHS, ...extraPaths];
182
+ for (const raw of paths) {
183
+ const path = expandHome(raw);
184
+ if (!existsSync4(path)) continue;
185
+ try {
186
+ const content = readFileSync4(path, "utf8");
187
+ for (const line of content.split("\n")) {
188
+ const trimmed = line.trim();
189
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue;
190
+ const eq = trimmed.indexOf("=");
191
+ const key = trimmed.slice(0, eq).trim();
192
+ let value = trimmed.slice(eq + 1).trim();
193
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
194
+ value = value.slice(1, -1);
195
+ }
196
+ if (key && process.env[key] === void 0) {
197
+ process.env[key] = value;
198
+ }
199
+ }
200
+ loaded.push(path);
201
+ } catch {
202
+ continue;
203
+ }
204
+ }
205
+ return loaded;
206
+ }
207
+
208
+ // src/hook/hook-auth.ts
209
+ function hookAuthHeaders(extra = {}) {
210
+ const token = process.env["NLM_MCP_TOKEN"];
211
+ if (!token) return { ...extra };
212
+ return { ...extra, authorization: `Bearer ${token}` };
213
+ }
214
+
165
215
  // src/hook/stop-hook.ts
166
216
  var RESPONSE_PREVIEW_CHARS = 200;
167
217
  var POST_TIMEOUT_MS = 1500;
@@ -229,7 +279,7 @@ async function runStopHook(input, deps) {
229
279
  };
230
280
  }
231
281
  function logPath() {
232
- return process.env["NLM_HOOK_LOG"] ?? join3(homedir3(), ".nlm", "hook-log.jsonl");
282
+ return process.env["NLM_HOOK_LOG"] ?? join3(homedir4(), ".nlm", "hook-log.jsonl");
233
283
  }
234
284
  function logStopResult(result) {
235
285
  try {
@@ -254,12 +304,12 @@ function logStopResult(result) {
254
304
  }
255
305
  }
256
306
  function readStdin() {
257
- return new Promise((resolve) => {
307
+ return new Promise((resolve2) => {
258
308
  let data = "";
259
309
  process.stdin.setEncoding("utf8");
260
310
  process.stdin.on("data", (chunk) => data += chunk);
261
- process.stdin.on("end", () => resolve(data));
262
- process.stdin.on("error", () => resolve(data));
311
+ process.stdin.on("end", () => resolve2(data));
312
+ process.stdin.on("error", () => resolve2(data));
263
313
  });
264
314
  }
265
315
  async function postCitationOverHttp(conversationId, citedId, kind, responsePreview) {
@@ -270,7 +320,7 @@ async function postCitationOverHttp(conversationId, citedId, kind, responsePrevi
270
320
  try {
271
321
  await fetch(url, {
272
322
  method: "POST",
273
- headers: { "content-type": "application/json" },
323
+ headers: hookAuthHeaders({ "content-type": "application/json" }),
274
324
  body: JSON.stringify({
275
325
  conversation_id: conversationId,
276
326
  cited_id: citedId,
@@ -285,6 +335,7 @@ async function postCitationOverHttp(conversationId, citedId, kind, responsePrevi
285
335
  }
286
336
  async function main() {
287
337
  try {
338
+ autoloadEnv();
288
339
  const raw = await readStdin();
289
340
  const payload = JSON.parse(raw);
290
341
  const conversationId = typeof payload.session_id === "string" ? payload.session_id : "unknown";
package/src/cli/nlm.ts CHANGED
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
27
27
  import { dirname, resolve, join } from "node:path";
28
28
  import { homedir } from "node:os";
29
29
  import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
30
- import { execFileSync } from "node:child_process";
30
+ import { execFileSync, spawnSync } from "node:child_process";
31
31
  import { Command } from "commander";
32
32
  import { serve } from "@hono/node-server";
33
33
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -51,9 +51,13 @@ import {
51
51
  disconnectCodex,
52
52
  pluginScriptsDir,
53
53
  } from "../install/codex.js";
54
- import { connectClaudeCode, disconnectClaudeCode, installClaudeCodeHooks } from "../install/claude-code.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";
55
58
  import { connectHermes, disconnectHermes, hermesConfigPath } from "../install/hermes.js";
56
59
  import { connectHermesAgent, disconnectHermesAgent, hermesAgentPluginDir } from "../install/hermes-agent.js";
60
+ import { connectWindsurf, disconnectWindsurf } from "../install/windsurf.js";
57
61
  import { runSetup } from "../install/setup.js";
58
62
  import { runParity } from "./classify-parity.js";
59
63
  import { reembedCorpus } from "../core/embedding/embed-backfill.js";
@@ -164,6 +168,13 @@ program
164
168
  .option("--no-scheduler", "HTTP only; skip the ingest tick loop")
165
169
  .option("--interval-min <n>", "scheduler tick interval (min, default 30)", (v) => Number.parseInt(v, 10), 30)
166
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();
167
178
  const { store, facts, sources, providers, recall, factRecall, embedder, classifier } = buildStack();
168
179
  const { existsSync } = await import("node:fs");
169
180
  const hasMcpToken = Boolean(process.env["NLM_MCP_TOKEN"]);
@@ -406,6 +417,40 @@ const LAUNCH_AGENT_PLIST = join(
406
417
  homedir(), "Library", "LaunchAgents", `${LAUNCH_AGENT_LABEL}.plist`,
407
418
  );
408
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
+
409
454
  function buildPlist(nodeExec: string, nlmJs: string): string {
410
455
  const logDir = join(homedir(), ".nlm", "logs");
411
456
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -444,38 +489,92 @@ function buildPlist(nodeExec: string, nlmJs: string): string {
444
489
 
445
490
  program
446
491
  .command("install")
447
- .description("Install the macOS LaunchAgent so nlm-memory auto-starts on login")
492
+ .description("Install the auto-start daemon (LaunchAgent on macOS, systemd user unit on Linux)")
448
493
  .action(() => {
449
- if (process.platform !== "darwin") {
450
- console.error("nlm install: only macOS is supported. On Linux, add `nlm start` to your init system manually.");
451
- process.exit(1);
452
- }
453
- const uid = process.getuid?.();
454
- if (uid === undefined) {
455
- console.error("nlm install: could not determine UID");
456
- process.exit(1);
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;
457
517
  }
458
- mkdirSync(join(homedir(), ".nlm", "logs"), { recursive: true });
459
- writeFileSync(LAUNCH_AGENT_PLIST, buildPlist(process.execPath, __filename), "utf8");
460
- console.error(`nlm: wrote ${LAUNCH_AGENT_PLIST}`);
461
- try {
462
- execFileSync("launchctl", ["bootout", `gui/${uid}`, LAUNCH_AGENT_LABEL], { stdio: "ignore" });
463
- } catch {
464
- // not loaded yet expected on first install
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;
465
543
  }
466
- execFileSync("launchctl", ["bootstrap", `gui/${uid}`, LAUNCH_AGENT_PLIST]);
467
- console.error("nlm: daemon installed and started.");
468
- console.error(` UI: http://localhost:${port()}/ui`);
469
- console.error(` To stop: launchctl stop ${LAUNCH_AGENT_LABEL}`);
470
- console.error(" To remove: nlm uninstall");
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);
471
548
  });
472
549
 
473
550
  program
474
551
  .command("uninstall")
475
- .description("Remove the macOS LaunchAgent")
552
+ .description("Remove the auto-start daemon (LaunchAgent on macOS, systemd user unit on Linux)")
476
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
+
477
576
  if (process.platform !== "darwin") {
478
- console.error("nlm uninstall: only macOS is supported.");
577
+ console.error("nlm uninstall: only macOS and Linux (systemd) are supported.");
479
578
  process.exit(1);
480
579
  }
481
580
  const uid = process.getuid?.();
@@ -560,7 +659,7 @@ const hook = program
560
659
 
561
660
  hook
562
661
  .command("install")
563
- .description("Add the NLM hooks (recall + session-end + stop) to ~/.claude/settings.json (shadow mode)")
662
+ .description("Add the NLM hooks (recall + session-end + stop) to ~/.claude/settings.json (live mode)")
564
663
  .action(() => {
565
664
  const path = claudeSettingsPath();
566
665
  const hookLogPath = process.env["NLM_HOOK_LOG"] ?? join(homedir(), ".nlm", "hook-log.jsonl");
@@ -571,7 +670,7 @@ hook
571
670
  // partial failure is the bug class we shipped #161 to prevent.
572
671
  const installed: HookSpec[] = [];
573
672
  for (const spec of ALL_HOOKS) {
574
- const command = buildHookCommand(process.execPath, spec.script, "shadow");
673
+ const command = buildHookCommand(process.execPath, spec.script, "live");
575
674
  addHook(path, command, spec.event);
576
675
  const smoke = smokeTestHookCommand(command, hookLogPath);
577
676
  if (!smoke.ok) {
@@ -591,14 +690,14 @@ hook
591
690
  installed.push(spec);
592
691
  }
593
692
 
594
- console.error(`nlm: NLM hooks installed in ${path} (shadow mode):`);
693
+ console.error(`nlm: NLM hooks installed in ${path} (live mode):`);
595
694
  for (const spec of installed) {
596
695
  console.error(` - ${spec.event} → ${spec.label}-hook`);
597
696
  }
598
697
  console.error(" Smoke tests passed — all hooks appended synthetic entries to hook-log.jsonl.");
599
- console.error(" Recall hooks log to ~/.nlm/hook-log.jsonl and inject nothing in shadow mode.");
698
+ console.error(" Recall hooks inject prior-session context on UserPromptSubmit and log to ~/.nlm/hook-log.jsonl.");
600
699
  console.error(" Session-end hook cleans up ~/.nlm/hook-state/<session>.json on session close.");
601
- console.error(" To go live later: change NLM_HOOK_MODE=shadow to live for the recall hook.");
700
+ console.error(" To run silently for calibration (no injection): set NLM_HOOK_MODE=shadow in the command.");
602
701
  console.error(" To remove: nlm hook uninstall");
603
702
  });
604
703
 
@@ -680,7 +779,7 @@ connect
680
779
  .action((opts) => {
681
780
  if (opts.dryRun) {
682
781
  console.error("nlm connect claude-code (dry run):");
683
- console.error(` write [mcpServers.nlm-memory] to ${join(homedir(), ".mcp.json")}`);
782
+ console.error(` write [mcpServers.nlm-memory] to ${mcpConfigPath()}`);
684
783
  if (opts.withHooks) console.error(" install 6 Claude Code hooks");
685
784
  return;
686
785
  }
@@ -746,6 +845,54 @@ connect
746
845
  console.error(" Also run: nlm connect hermes (to wire the MCP server)");
747
846
  });
748
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
+
749
896
  const disconnect = program
750
897
  .command("disconnect")
751
898
  .description("Disconnect nlm-memory from an AI coding runtime");
@@ -841,6 +988,48 @@ disconnect
841
988
  : `nlm: no plugin directory found at ${report.destDir}`);
842
989
  });
843
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
+
844
1033
  program
845
1034
  .command("setup")
846
1035
  .description("Interactive first-run setup: detect runtimes, wire MCP + hooks, start daemon")
@@ -854,6 +1043,10 @@ program
854
1043
  launchAgentLabel: LAUNCH_AGENT_LABEL,
855
1044
  launchAgentPlist: LAUNCH_AGENT_PLIST,
856
1045
  buildPlist,
1046
+ linuxSystemdUnitName: LINUX_SYSTEMD_UNIT_NAME,
1047
+ linuxSystemdUnitPath: LINUX_SYSTEMD_UNIT_PATH,
1048
+ buildSystemdUnit,
1049
+ linuxSystemdUserAvailable,
857
1050
  claudeSettingsPath: claudeSettingsPath(),
858
1051
  allHooks: ALL_HOOKS,
859
1052
  addHook,