nlm-memory 0.4.2 → 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 +62 -7
  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 +80 -7
  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
package/src/http/app.ts CHANGED
@@ -139,12 +139,90 @@ function parseLimit(raw: string | undefined, fallback: number, max: number): num
139
139
  return Math.min(max, n);
140
140
  }
141
141
 
142
+ // Accept Host headers that point to loopback, with or without the bound port.
143
+ // Rejecting non-loopback Hosts closes the DNS-rebinding hole: a malicious
144
+ // site can resolve attacker.com to 127.0.0.1 in the browser but cannot
145
+ // forge a Host header browsers send automatically.
146
+ export function isLoopbackHost(host: string | undefined, port: number): boolean {
147
+ if (!host) return false;
148
+ const lower = host.toLowerCase();
149
+ return (
150
+ lower === "localhost" ||
151
+ lower === `localhost:${port}` ||
152
+ lower === "127.0.0.1" ||
153
+ lower === `127.0.0.1:${port}` ||
154
+ lower === "[::1]" ||
155
+ lower === `[::1]:${port}`
156
+ );
157
+ }
158
+
159
+ // Browser Origin headers are set automatically and cannot be spoofed by
160
+ // page-level JS. A request with a non-loopback Origin reaching loopback
161
+ // means the user is on attacker.com — the page is trying to read our data.
162
+ export function isLoopbackOrigin(origin: string | undefined, port: number): boolean {
163
+ if (!origin) return false;
164
+ const lower = origin.toLowerCase();
165
+ return (
166
+ lower === `http://localhost:${port}` ||
167
+ lower === `http://127.0.0.1:${port}` ||
168
+ lower === `http://[::1]:${port}`
169
+ );
170
+ }
171
+
142
172
  const VALID_MODES: ReadonlyArray<RecallMode> = ["keyword", "semantic", "hybrid"];
143
173
  const VALID_KINDS: ReadonlyArray<RecallKindFilter> = ["decision", "open"];
144
174
  const VALID_FACT_KINDS: ReadonlyArray<FactKind> = ["decision", "open", "attribute"];
145
175
 
146
176
  export function createApp(deps: HttpDeps): Hono {
147
177
  const app = new Hono();
178
+ const boundPort = process.env["NLM_PORT"] ? Number.parseInt(process.env["NLM_PORT"], 10) : 3940;
179
+
180
+ // ── Local-only access middleware (defense in depth on top of 127.0.0.1 bind) ──
181
+ //
182
+ // Threat model: server binds to loopback so external network is blocked.
183
+ // What's left:
184
+ // 1. DNS rebinding from a malicious tab — Host check blocks it
185
+ // 2. Browser drive-by from a cross-origin tab — Origin check blocks it
186
+ // 3. Port forwarding (ssh -L, ngrok) reaching another machine — Bearer blocks it
187
+ //
188
+ // Applied to /api/* and /mcp. Static UI (/ui/*) and /api/health pass through
189
+ // the host check but skip Origin/Bearer so SPAs and liveness probes work.
190
+ // Skip entirely under Vitest — in-process app.request() calls have no real
191
+ // network surface and synthesize requests without a Host header.
192
+ const skipLocalGate = !!process.env["VITEST"] || process.env["NODE_ENV"] === "test";
193
+ app.use("/api/*", async (c, next) => {
194
+ if (skipLocalGate) return next();
195
+ const host = c.req.header("host");
196
+ if (!isLoopbackHost(host, boundPort)) {
197
+ return c.json({ error: "host header not allowed" }, 403);
198
+ }
199
+ if (c.req.path === "/api/health") {
200
+ return next();
201
+ }
202
+ const origin = c.req.header("origin");
203
+ if (origin !== undefined) {
204
+ if (!isLoopbackOrigin(origin, boundPort)) {
205
+ return c.json({ error: "origin not allowed" }, 403);
206
+ }
207
+ // Loopback origin → same-origin UI request. Allow.
208
+ return next();
209
+ }
210
+ // No Origin → not a browser fetch. Require Bearer if a token is configured.
211
+ const token = process.env["NLM_MCP_TOKEN"];
212
+ if (!token) {
213
+ // No token configured → local-only daemon with loopback Host already verified.
214
+ // Acceptable for single-user dev installs; production users should set the token.
215
+ return next();
216
+ }
217
+ const auth = c.req.header("authorization") ?? "";
218
+ const match = /^Bearer\s+(\S+)$/i.exec(auth);
219
+ const given = Buffer.from(match?.[1] ?? "", "utf8");
220
+ const want = Buffer.from(token, "utf8");
221
+ if (!match || given.length !== want.length || !timingSafeEqual(given, want)) {
222
+ return c.json({ error: "unauthorized" }, 401);
223
+ }
224
+ return next();
225
+ });
148
226
 
149
227
  app.get("/api/health", (c) =>
150
228
  c.json({ status: "ok", service: "nlm-memory", version: "0.2.0-dev" }),
@@ -94,7 +94,7 @@ export function installClaudeCodeHooks(opts: HookInstallOptions): HookInstallRes
94
94
  const installed: HookSpec[] = [];
95
95
  for (const spec of opts.hooks) {
96
96
  try {
97
- const command = opts.buildHookCommand(opts.nodeExecPath, spec.script, "shadow");
97
+ const command = opts.buildHookCommand(opts.nodeExecPath, spec.script, "live");
98
98
  opts.addHook(opts.settingsPath, command, spec.event);
99
99
  const smoke = opts.smokeTestHookCommand(command, opts.hookLogPath);
100
100
  if (!smoke.ok) {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `nlm connect cursor` / `nlm disconnect cursor` — registers or removes the
3
+ * Cursor adapter source in the NLM source registry.
4
+ *
5
+ * Unlike plugin-based runtimes (hermes-agent, codex), Cursor needs no file
6
+ * to be installed. NLM reads Cursor's existing state.vscdb directly. The
7
+ * connect operation only registers the source row so the daemon scans it.
8
+ */
9
+
10
+ import { existsSync } from "node:fs";
11
+ import { defaultDbPath } from "../core/adapters/cursor.js";
12
+ import type { SourceRegistry } from "../core/sources/source-registry.js";
13
+
14
+ export interface ConnectCursorOptions {
15
+ readonly dbPath?: string;
16
+ readonly dryRun?: boolean;
17
+ }
18
+
19
+ export interface ConnectCursorReport {
20
+ readonly adapterDbPath: string;
21
+ readonly adapterExists: boolean;
22
+ readonly action: "created" | "enabled" | "already-active" | "dry-run";
23
+ }
24
+
25
+ export interface DisconnectCursorReport {
26
+ readonly action: "disabled" | "not-found" | "dry-run";
27
+ }
28
+
29
+ export function connectCursor(
30
+ registry: SourceRegistry,
31
+ opts: ConnectCursorOptions = {},
32
+ ): ConnectCursorReport {
33
+ const adapterDbPath = opts.dbPath ?? defaultDbPath();
34
+ const adapterExists = existsSync(adapterDbPath);
35
+
36
+ if (opts.dryRun) {
37
+ return { adapterDbPath, adapterExists, action: "dry-run" };
38
+ }
39
+
40
+ const existing = registry.getByName("Cursor");
41
+ if (existing) {
42
+ if (existing.enabled && existing.pathOrUrl === adapterDbPath) {
43
+ return { adapterDbPath, adapterExists, action: "already-active" };
44
+ }
45
+ registry.update(existing.id, { enabled: true, pathOrUrl: adapterDbPath });
46
+ return { adapterDbPath, adapterExists, action: "enabled" };
47
+ }
48
+
49
+ registry.insert({
50
+ kind: "cursor",
51
+ name: "Cursor",
52
+ pathOrUrl: adapterDbPath,
53
+ runtimeLabel: "cursor/1.0",
54
+ enabled: adapterExists,
55
+ });
56
+ return { adapterDbPath, adapterExists, action: "created" };
57
+ }
58
+
59
+ export function disconnectCursor(
60
+ registry: SourceRegistry,
61
+ opts: { dryRun?: boolean } = {},
62
+ ): DisconnectCursorReport {
63
+ if (opts.dryRun) return { action: "dry-run" };
64
+ const existing = registry.getByName("Cursor");
65
+ if (!existing) return { action: "not-found" };
66
+ registry.update(existing.id, { enabled: false });
67
+ return { action: "disabled" };
68
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Idempotent permission hardening for ~/.nlm/.
3
+ *
4
+ * Recursively sets owner-only perms on the daemon's working directory:
5
+ * directories → 0o700
6
+ * files → 0o600
7
+ *
8
+ * Run at every `nlm setup`, `nlm install`, and `nlm start` so installs
9
+ * predating v0.4.2 (when explicit chmod was added) self-heal on next
10
+ * launch. No-op on Windows — ACLs are the POSIX equivalent and out of
11
+ * scope here.
12
+ */
13
+
14
+ import { chmodSync, existsSync, readdirSync, statSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ export interface PermsHardenResult {
19
+ readonly nlmDir: string;
20
+ readonly filesHardened: number;
21
+ readonly dirsHardened: number;
22
+ readonly skipped: number;
23
+ }
24
+
25
+ export function hardenNlmDirPermissions(
26
+ nlmDir: string = join(homedir(), ".nlm"),
27
+ ): PermsHardenResult {
28
+ const result = { nlmDir, filesHardened: 0, dirsHardened: 0, skipped: 0 };
29
+ if (process.platform === "win32") return result;
30
+ if (!existsSync(nlmDir)) return result;
31
+ walk(nlmDir, result);
32
+ return result;
33
+ }
34
+
35
+ interface MutableResult {
36
+ filesHardened: number;
37
+ dirsHardened: number;
38
+ skipped: number;
39
+ }
40
+
41
+ function walk(path: string, r: MutableResult): void {
42
+ try {
43
+ const s = statSync(path);
44
+ if (s.isDirectory()) {
45
+ chmodSync(path, 0o700);
46
+ r.dirsHardened += 1;
47
+ for (const name of readdirSync(path)) walk(join(path, name), r);
48
+ } else if (s.isFile()) {
49
+ chmodSync(path, 0o600);
50
+ r.filesHardened += 1;
51
+ }
52
+ } catch {
53
+ r.skipped += 1;
54
+ }
55
+ }
@@ -16,6 +16,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
16
16
  import { homedir, platform } from "node:os";
17
17
  import { join } from "node:path";
18
18
  import { spawnSync, spawn } from "node:child_process";
19
+ import { randomBytes } from "node:crypto";
19
20
 
20
21
  export const EMBEDDING_MODEL = "nomic-embed-text";
21
22
  const OS = platform();
@@ -180,11 +181,28 @@ export function pullEmbeddingModel(): OllamaResult {
180
181
 
181
182
  export type ClassifierChoice = "deepseek" | "ollama-offline";
182
183
 
184
+ export interface ClassifierConfigInput {
185
+ readonly choice: ClassifierChoice;
186
+ readonly model?: string;
187
+ readonly apiKey?: string;
188
+ }
189
+
183
190
  /**
184
191
  * Write classifier config to ~/.nlm/.env. Merges into the existing file —
185
192
  * only the lines we manage are updated; anything the user added by hand stays.
193
+ *
194
+ * Manages three keys: DEEPSEEK_API_KEY, NLM_CLASSIFIER, NLM_CLASSIFIER_MODEL.
195
+ * Backwards-compatible: passing positional (choice, apiKey) still works.
186
196
  */
187
- export function writeClassifierConfig(choice: ClassifierChoice, apiKey?: string): void {
197
+ export function writeClassifierConfig(
198
+ choiceOrInput: ClassifierChoice | ClassifierConfigInput,
199
+ apiKey?: string,
200
+ ): void {
201
+ const input: ClassifierConfigInput =
202
+ typeof choiceOrInput === "string"
203
+ ? { choice: choiceOrInput, ...(apiKey !== undefined ? { apiKey } : {}) }
204
+ : choiceOrInput;
205
+
188
206
  const envPath = join(homedir(), ".nlm", ".env");
189
207
  const nlmDir = join(homedir(), ".nlm");
190
208
  mkdirSync(nlmDir, { recursive: true, mode: 0o700 });
@@ -193,19 +211,74 @@ export function writeClassifierConfig(choice: ClassifierChoice, apiKey?: string)
193
211
  const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
194
212
  const kept = existing
195
213
  .split("\n")
196
- .filter((l) => !l.startsWith("DEEPSEEK_API_KEY=") && !l.startsWith("NLM_CLASSIFIER="))
214
+ .filter(
215
+ (l) =>
216
+ !l.startsWith("DEEPSEEK_API_KEY=") &&
217
+ !l.startsWith("NLM_CLASSIFIER=") &&
218
+ !l.startsWith("NLM_CLASSIFIER_MODEL="),
219
+ )
197
220
  .join("\n")
198
221
  .replace(/\n{3,}/g, "\n\n")
199
222
  .trim();
200
223
 
201
224
  const additions: string[] = [];
202
- if (choice === "deepseek" && apiKey) {
203
- // Strip newlines that clipboard paste can introduce.
204
- const sanitized = apiKey.replace(/[\r\n]/g, "").trim();
205
- additions.push(`DEEPSEEK_API_KEY=${sanitized}`);
225
+ if (input.choice === "deepseek") {
226
+ additions.push("NLM_CLASSIFIER=deepseek");
227
+ if (input.apiKey) {
228
+ // Strip newlines that clipboard paste can introduce.
229
+ const sanitized = input.apiKey.replace(/[\r\n]/g, "").trim();
230
+ additions.push(`DEEPSEEK_API_KEY=${sanitized}`);
231
+ }
206
232
  }
207
- if (choice === "ollama-offline") additions.push("NLM_CLASSIFIER=ollama");
233
+ if (input.choice === "ollama-offline") additions.push("NLM_CLASSIFIER=ollama");
234
+ if (input.model) additions.push(`NLM_CLASSIFIER_MODEL=${input.model}`);
208
235
 
209
236
  writeFileSync(envPath, [kept, ...additions].filter(Boolean).join("\n") + "\n", { mode: 0o600 });
210
237
  chmodSync(envPath, 0o600);
211
238
  }
239
+
240
+ const TOKEN_BYTES = 32;
241
+
242
+ /**
243
+ * Generate and persist an NLM_MCP_TOKEN if one isn't already set. Returns
244
+ * the token that's active for this process. Called during setup and on
245
+ * `nlm start` so installs that pre-date token-gated /api/* still get
246
+ * Bearer-protected without operator intervention.
247
+ *
248
+ * Token is hex-encoded crypto.randomBytes — 64 chars, 256 bits of entropy.
249
+ */
250
+ export function ensureMcpToken(): string {
251
+ const existing = process.env["NLM_MCP_TOKEN"];
252
+ if (existing) return existing;
253
+
254
+ const token = randomBytes(TOKEN_BYTES).toString("hex");
255
+
256
+ const envPath = join(homedir(), ".nlm", ".env");
257
+ const nlmDir = join(homedir(), ".nlm");
258
+ mkdirSync(nlmDir, { recursive: true, mode: 0o700 });
259
+ chmodSync(nlmDir, 0o700);
260
+
261
+ const fileExisting = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
262
+ // Idempotent re-read: another setup run could have written the token
263
+ // between our env check and now. Prefer the persisted value.
264
+ for (const line of fileExisting.split("\n")) {
265
+ if (line.startsWith("NLM_MCP_TOKEN=")) {
266
+ const persisted = line.slice("NLM_MCP_TOKEN=".length).trim();
267
+ if (persisted) {
268
+ process.env["NLM_MCP_TOKEN"] = persisted;
269
+ return persisted;
270
+ }
271
+ }
272
+ }
273
+
274
+ const kept = fileExisting
275
+ .split("\n")
276
+ .filter((l) => !l.startsWith("NLM_MCP_TOKEN="))
277
+ .join("\n")
278
+ .replace(/\n{3,}/g, "\n\n")
279
+ .trim();
280
+ writeFileSync(envPath, [kept, `NLM_MCP_TOKEN=${token}`].filter(Boolean).join("\n") + "\n", { mode: 0o600 });
281
+ chmodSync(envPath, 0o600);
282
+ process.env["NLM_MCP_TOKEN"] = token;
283
+ return token;
284
+ }
@@ -13,7 +13,7 @@
13
13
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
14
14
  import { execFileSync } from "node:child_process";
15
15
  import { homedir, platform } from "node:os";
16
- import { join } from "node:path";
16
+ import { dirname, join } from "node:path";
17
17
  import {
18
18
  cancel, confirm, intro, isCancel, log, multiselect, outro, password, select, spinner,
19
19
  } from "@clack/prompts";
@@ -26,6 +26,7 @@ import {
26
26
  type ClassifierChoice,
27
27
  EMBEDDING_MODEL,
28
28
  embeddingModelPresent,
29
+ ensureMcpToken,
29
30
  installOllama,
30
31
  ollamaBinaryAvailable,
31
32
  ollamaServerRunning,
@@ -35,6 +36,7 @@ import {
35
36
  writeClassifierConfig,
36
37
  } from "./ollama.js";
37
38
  import { installClaudeCodeHooks } from "./claude-code.js";
39
+ import { hardenNlmDirPermissions } from "./nlm-dir-perms.js";
38
40
 
39
41
  const OS = platform();
40
42
 
@@ -47,6 +49,10 @@ export interface SetupOptions {
47
49
  readonly launchAgentLabel: string;
48
50
  readonly launchAgentPlist: string;
49
51
  readonly buildPlist: (nodeExec: string, binPath: string) => string;
52
+ readonly linuxSystemdUnitName: string;
53
+ readonly linuxSystemdUnitPath: string;
54
+ readonly buildSystemdUnit: (nodeExec: string, binPath: string) => string;
55
+ readonly linuxSystemdUserAvailable: () => boolean;
50
56
  readonly claudeSettingsPath: string;
51
57
  readonly allHooks: ReadonlyArray<{
52
58
  event: ClaudeHookEvent;
@@ -59,6 +65,30 @@ export interface SetupOptions {
59
65
  readonly smokeTestHookCommand: (command: string, logPath: string) => { ok: boolean; reason?: string; stderr?: string };
60
66
  }
61
67
 
68
+ // Embedding-only tags shouldn't be offered as classifier models — they
69
+ // can't run chat completions and the call would fail at first ingest.
70
+ const EMBEDDING_MODEL_PREFIXES = ["nomic-embed", "mxbai-embed", "snowflake-arctic-embed", "bge-"] as const;
71
+
72
+ async function fetchOllamaChatModels(timeoutMs = 5000): Promise<string[]> {
73
+ const baseUrl = process.env["NLM_OLLAMA_URL"] ?? "http://localhost:11434";
74
+ const controller = new AbortController();
75
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
76
+ try {
77
+ const res = await fetch(`${baseUrl}/api/tags`, { signal: controller.signal });
78
+ if (!res.ok) return [];
79
+ const data = (await res.json()) as { models?: Array<{ name?: string }> };
80
+ return (data.models ?? [])
81
+ .map((m) => m.name)
82
+ .filter((n): n is string => typeof n === "string")
83
+ .filter((n) => !EMBEDDING_MODEL_PREFIXES.some((p) => n.startsWith(p)))
84
+ .sort();
85
+ } catch {
86
+ return [];
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+
62
92
  type RuntimeId = "claude-code" | "codex" | "opencode" | "hermes" | "pi";
63
93
 
64
94
  interface RuntimeOption {
@@ -194,35 +224,88 @@ export async function runSetup(opts: SetupOptions): Promise<void> {
194
224
  log.success(`Ollama ready — ${EMBEDDING_MODEL} present`);
195
225
  }
196
226
 
197
- // ── Step 3: classifier API key ────────────────────────────────────────
198
- const wantKey = await confirm({ message: "Add a classifier API key? (enables accurate session tagging; DeepSeek is ~$0.002/session)" });
199
- if (isCancel(wantKey)) { cancel("Setup cancelled."); process.exit(0); }
227
+ // ── Step 3: classifier (provider + model + key) ───────────────────────
228
+ const wantConfigure = await confirm({
229
+ message: "Configure the session classifier? (controls how new sessions are tagged)",
230
+ });
231
+ if (isCancel(wantConfigure)) { cancel("Setup cancelled."); process.exit(0); }
200
232
 
201
- if (wantKey) {
233
+ if (wantConfigure) {
202
234
  const classifierChoice = await select<ClassifierChoice>({
203
- message: "Which classifier?",
235
+ message: "Which classifier provider?",
204
236
  options: [
205
- { value: "deepseek", label: "DeepSeek", hint: "recommended — fast, cheap, needs DEEPSEEK_API_KEY" },
206
- { value: "ollama-offline", label: "Ollama (offline)", hint: "free, no API key, slower and less accurate" },
237
+ {
238
+ value: "deepseek",
239
+ label: "DeepSeek (cloud)",
240
+ hint: "fast, cheap (~$0.002/session). Transcripts are sent to api.deepseek.com.",
241
+ },
242
+ {
243
+ value: "ollama-offline",
244
+ label: "Ollama (local)",
245
+ hint: "private — runs on this machine via your local Ollama. Slower; needs a chat model pulled.",
246
+ },
207
247
  ],
208
248
  });
209
249
  if (isCancel(classifierChoice)) { cancel("Setup cancelled."); process.exit(0); }
210
250
 
211
251
  if (classifierChoice === "deepseek") {
252
+ log.info("Heads up: DeepSeek classification sends up to 30K chars of each session transcript to api.deepseek.com.");
253
+ log.info(" Anything in a transcript (pasted keys, client names, internal URLs) leaves this machine.");
254
+ log.info(" Pick Ollama (local) above if that's not acceptable.");
255
+
256
+ const model = await select<string>({
257
+ message: "Which DeepSeek model?",
258
+ options: [
259
+ { value: "deepseek-v4-flash", label: "deepseek-v4-flash", hint: "recommended — fast + cheap, ~$0.002/session" },
260
+ { value: "deepseek-v4-pro", label: "deepseek-v4-pro", hint: "higher quality, ~10× cost" },
261
+ { value: "deepseek-chat", label: "deepseek-chat", hint: "legacy chat model" },
262
+ ],
263
+ });
264
+ if (isCancel(model)) { cancel("Setup cancelled."); process.exit(0); }
265
+
212
266
  const key = await password({ message: "DeepSeek API key (get one at platform.deepseek.com):" });
213
267
  if (isCancel(key)) { cancel("Setup cancelled."); process.exit(0); }
214
- if (key && (key as string).trim()) {
215
- writeClassifierConfig("deepseek", (key as string).trim());
216
- log.success("DeepSeek API key saved to ~/.nlm/.env");
268
+ const apiKey = key && (key as string).trim() ? (key as string).trim() : undefined;
269
+ writeClassifierConfig(apiKey !== undefined
270
+ ? { choice: "deepseek", model: model as string, apiKey }
271
+ : { choice: "deepseek", model: model as string });
272
+ if (apiKey) {
273
+ log.success(`DeepSeek (${model as string}) configured — credentials saved to ~/.nlm/.env`);
217
274
  } else {
218
- log.warn("No key entered — set DEEPSEEK_API_KEY in ~/.nlm/.env later.");
275
+ log.warn(`DeepSeek (${model as string}) configured — set DEEPSEEK_API_KEY in ~/.nlm/.env before running.`);
219
276
  }
220
277
  } else {
221
- writeClassifierConfig("ollama-offline");
222
- log.success("Classifier set to Ollama offline (saved to ~/.nlm/.env)");
278
+ const ollamaModels = await fetchOllamaChatModels();
279
+ let modelValue = "phi4-mini:latest";
280
+ if (ollamaModels.length > 0) {
281
+ const model = await select<string>({
282
+ message: "Which Ollama chat model?",
283
+ options: ollamaModels.map((m) => ({
284
+ value: m,
285
+ label: m,
286
+ hint: m === "phi4-mini:latest" ? "recommended default — small, fast" : undefined,
287
+ })) as { value: string; label: string; hint?: string }[],
288
+ });
289
+ if (isCancel(model)) { cancel("Setup cancelled."); process.exit(0); }
290
+ modelValue = model as string;
291
+ } else {
292
+ log.warn("No Ollama chat models detected. Defaulting to phi4-mini:latest.");
293
+ log.warn(" Pull a model with: ollama pull phi4-mini (or any chat model you prefer)");
294
+ }
295
+ writeClassifierConfig({ choice: "ollama-offline", model: modelValue });
296
+ log.success(`Ollama classifier (${modelValue}) saved to ~/.nlm/.env`);
223
297
  }
224
298
  }
225
299
 
300
+ // ── Step 3.5: HTTP API auth token ─────────────────────────────────────
301
+ // Generate a token if one isn't set so /api/* gets Bearer-protected for
302
+ // non-browser callers (curl, port-forwarded clients). The UI still works
303
+ // because browsers send Origin and we exempt loopback origins.
304
+ const token = ensureMcpToken();
305
+ if (token === process.env["NLM_MCP_TOKEN"] && token.length === 64) {
306
+ log.success("HTTP API auth token saved to ~/.nlm/.env (NLM_MCP_TOKEN)");
307
+ }
308
+
226
309
  // ── Step 4: migrations ────────────────────────────────────────────────
227
310
  const ms = spinner();
228
311
  ms.start("Running database migrations");
@@ -237,6 +320,14 @@ export async function runSetup(opts: SetupOptions): Promise<void> {
237
320
  process.exit(1);
238
321
  }
239
322
 
323
+ // ── Step 4.5: harden ~/.nlm permissions ────────────────────────────────
324
+ // Idempotent. Covers upgrade from pre-v0.4.2 installs where files were
325
+ // written without explicit chmod, leaving secrets world-readable.
326
+ const perms = hardenNlmDirPermissions();
327
+ if (perms.filesHardened + perms.dirsHardened > 0) {
328
+ log.success(`Hardened perms on ${perms.dirsHardened} dirs and ${perms.filesHardened} files in ${perms.nlmDir}`);
329
+ }
330
+
240
331
  // ── Step 5: daemon ────────────────────────────────────────────────────
241
332
  if (OS === "darwin") {
242
333
  const installDaemon = await confirm({ message: "Install macOS LaunchAgent (auto-start on login)?" });
@@ -262,9 +353,34 @@ export async function runSetup(opts: SetupOptions): Promise<void> {
262
353
  }
263
354
  }
264
355
  } else if (OS === "linux") {
265
- log.info("Linux daemon: add `nlm start` to your init system to auto-start on boot.");
266
- log.info(" systemd example: sudo systemctl enable --now nlm (after creating a unit file)");
267
- log.info(" Quick start now: nlm start &");
356
+ if (opts.linuxSystemdUserAvailable()) {
357
+ const installDaemon = await confirm({ message: "Install systemd user unit (auto-start on login)?" });
358
+ if (isCancel(installDaemon)) { cancel("Setup cancelled."); process.exit(0); }
359
+
360
+ if (installDaemon) {
361
+ const ds = spinner();
362
+ ds.start("Installing systemd user unit");
363
+ try {
364
+ mkdirSync(dirname(opts.linuxSystemdUnitPath), { recursive: true });
365
+ mkdirSync(join(homedir(), ".nlm", "logs"), { recursive: true });
366
+ writeFileSync(opts.linuxSystemdUnitPath, opts.buildSystemdUnit(opts.nodeExecPath, opts.nlmBinPath), "utf8");
367
+ execFileSync("systemctl", ["--user", "daemon-reload"]);
368
+ execFileSync("systemctl", ["--user", "enable", "--now", opts.linuxSystemdUnitName]);
369
+ ds.stop("systemd user unit installed — daemon running");
370
+ log.info(` Status: systemctl --user status ${opts.linuxSystemdUnitName}`);
371
+ log.info(" Headless? Run `sudo loginctl enable-linger $USER` so the daemon survives logout.");
372
+ } catch (e) {
373
+ ds.stop("systemd install failed");
374
+ log.error(`${e instanceof Error ? e.message : String(e)}`);
375
+ log.warn("Run `nlm install` manually later, or start now with: nlm start &");
376
+ }
377
+ }
378
+ } else {
379
+ log.info("systemd user instance not available (no XDG_RUNTIME_DIR or `systemctl --user`).");
380
+ log.info(" Common on headless servers — start manually with: nlm start &");
381
+ log.info(" Or enable lingering, then re-run `nlm install`:");
382
+ log.info(" sudo loginctl enable-linger $USER");
383
+ }
268
384
  } else if (OS === "win32") {
269
385
  log.info("Windows daemon: run `nlm start` at login via Task Scheduler.");
270
386
  log.info(" Or start manually: nlm start");
@@ -352,6 +468,11 @@ export async function runSetup(opts: SetupOptions): Promise<void> {
352
468
  case "pi":
353
469
  log.success("pi.dev: session scanning enabled (passive — no extra config needed)");
354
470
  break;
471
+
472
+ default: {
473
+ const _: never = id;
474
+ log.warn(`Unknown runtime: ${_ as string} — skipping.`);
475
+ }
355
476
  }
356
477
  }
357
478
 
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `nlm connect windsurf` / `nlm disconnect windsurf` — registers or removes the
3
+ * Windsurf adapter source in the NLM source registry.
4
+ *
5
+ * NLM reads Windsurf's existing workspace SQLite DBs directly from the User
6
+ * directory. The connect operation only registers the source row so the daemon
7
+ * scans it.
8
+ */
9
+
10
+ import { existsSync } from "node:fs";
11
+ import { defaultUserDir } from "../core/adapters/windsurf.js";
12
+ import type { SourceRegistry } from "../core/sources/source-registry.js";
13
+
14
+ export interface ConnectWindsurfOptions {
15
+ readonly userDir?: string;
16
+ readonly dryRun?: boolean;
17
+ }
18
+
19
+ export interface ConnectWindsurfReport {
20
+ readonly userDir: string;
21
+ readonly dirExists: boolean;
22
+ readonly action: "created" | "enabled" | "already-active" | "dry-run";
23
+ }
24
+
25
+ export interface DisconnectWindsurfReport {
26
+ readonly action: "disabled" | "not-found" | "dry-run";
27
+ }
28
+
29
+ export function connectWindsurf(
30
+ registry: SourceRegistry,
31
+ opts: ConnectWindsurfOptions = {},
32
+ ): ConnectWindsurfReport {
33
+ const userDir = opts.userDir ?? defaultUserDir();
34
+ const dirExists = existsSync(userDir);
35
+
36
+ if (opts.dryRun) {
37
+ return { userDir, dirExists, action: "dry-run" };
38
+ }
39
+
40
+ const existing = registry.getByName("Windsurf");
41
+ if (existing) {
42
+ if (existing.enabled && existing.pathOrUrl === userDir) {
43
+ return { userDir, dirExists, action: "already-active" };
44
+ }
45
+ registry.update(existing.id, { enabled: true, pathOrUrl: userDir });
46
+ return { userDir, dirExists, action: "enabled" };
47
+ }
48
+
49
+ registry.insert({
50
+ kind: "windsurf",
51
+ name: "Windsurf",
52
+ pathOrUrl: userDir,
53
+ runtimeLabel: "windsurf/1.0",
54
+ enabled: dirExists,
55
+ });
56
+ return { userDir, dirExists, action: "created" };
57
+ }
58
+
59
+ export function disconnectWindsurf(
60
+ registry: SourceRegistry,
61
+ opts: { dryRun?: boolean } = {},
62
+ ): DisconnectWindsurfReport {
63
+ if (opts.dryRun) return { action: "dry-run" };
64
+ const existing = registry.getByName("Windsurf");
65
+ if (!existing) return { action: "not-found" };
66
+ registry.update(existing.id, { enabled: false });
67
+ return { action: "disabled" };
68
+ }
@@ -31,6 +31,10 @@ export interface Session {
31
31
  readonly entities: ReadonlyArray<string>;
32
32
  readonly decisions: ReadonlyArray<string>;
33
33
  readonly open: ReadonlyArray<string>;
34
+ /** IDs of sessions this session supersedes (newer → older). Populated by getById; absent on bulk reads. */
35
+ readonly supersedes?: ReadonlyArray<string>;
36
+ /** ID of the session that superseded this one, if any. Populated by getById; absent on bulk reads. */
37
+ readonly supersededBy?: string | null;
34
38
  }
35
39
 
36
40
  export type RecallMode = "keyword" | "semantic" | "hybrid";