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.
- package/dist/cli/nlm.js +221 -32
- package/dist/cli/nlm.js.map +1 -1
- package/dist/core/adapters/cursor.d.ts +45 -0
- package/dist/core/adapters/cursor.js +397 -0
- package/dist/core/adapters/cursor.js.map +1 -0
- package/dist/core/adapters/from-source.js +10 -0
- package/dist/core/adapters/from-source.js.map +1 -1
- package/dist/core/adapters/windsurf.d.ts +44 -0
- package/dist/core/adapters/windsurf.js +299 -0
- package/dist/core/adapters/windsurf.js.map +1 -0
- package/dist/core/hook/claude-settings.d.ts +12 -5
- package/dist/core/hook/claude-settings.js +21 -6
- package/dist/core/hook/claude-settings.js.map +1 -1
- package/dist/core/sources/source-registry.d.ts +1 -1
- package/dist/core/sources/source-registry.js +18 -0
- package/dist/core/sources/source-registry.js.map +1 -1
- package/dist/core/storage/sqlite-session-store.d.ts +2 -0
- package/dist/core/storage/sqlite-session-store.js +38 -2
- package/dist/core/storage/sqlite-session-store.js.map +1 -1
- package/dist/hook/hook-auth.d.ts +13 -0
- package/dist/hook/hook-auth.js +19 -0
- package/dist/hook/hook-auth.js.map +1 -0
- package/dist/hook/prompt-recall-hook.js +7 -1
- package/dist/hook/prompt-recall-hook.js.map +1 -1
- package/dist/hook/session-start-hook.js +4 -1
- package/dist/hook/session-start-hook.js.map +1 -1
- package/dist/hook/stop-hook.js +4 -1
- package/dist/hook/stop-hook.js.map +1 -1
- package/dist/http/app.d.ts +2 -0
- package/dist/http/app.js +74 -0
- package/dist/http/app.js.map +1 -1
- package/dist/install/claude-code.js +1 -1
- package/dist/install/claude-code.js.map +1 -1
- package/dist/install/cursor.d.ts +25 -0
- package/dist/install/cursor.js +43 -0
- package/dist/install/cursor.js.map +1 -0
- package/dist/install/nlm-dir-perms.d.ts +19 -0
- package/dist/install/nlm-dir-perms.js +43 -0
- package/dist/install/nlm-dir-perms.js.map +1 -0
- package/dist/install/ollama.d.ts +18 -1
- package/dist/install/ollama.js +68 -10
- package/dist/install/ollama.js.map +1 -1
- package/dist/install/setup.d.ts +4 -0
- package/dist/install/setup.js +141 -18
- package/dist/install/setup.js.map +1 -1
- package/dist/install/windsurf.d.ts +25 -0
- package/dist/install/windsurf.js +43 -0
- package/dist/install/windsurf.js.map +1 -0
- package/dist/shared/types.d.ts +4 -0
- package/dist/ui/assets/{index-BA6IpU8g.css → index-C8cpwbYJ.css} +1 -1
- package/dist/ui/assets/index-CB50QnL-.js +69 -0
- package/dist/ui/index.html +2 -2
- package/logs/CHANGELOG/CHANGELOG-2026.md +186 -0
- package/logs/CHANGELOG/CHANGELOG.md +107 -235
- package/migrations/014_sources_cursor.sql +30 -0
- package/migrations/015_sources_windsurf.sql +30 -0
- package/package.json +1 -1
- package/plugin/scripts/prompt-recall-hook.mjs +55 -4
- package/plugin/scripts/stop-hook.mjs +57 -6
- package/src/cli/nlm.ts +224 -31
- package/src/core/adapters/cursor.ts +486 -0
- package/src/core/adapters/from-source.ts +10 -0
- package/src/core/adapters/windsurf.ts +386 -0
- package/src/core/hook/claude-settings.ts +30 -9
- package/src/core/sources/source-registry.ts +19 -1
- package/src/core/storage/sqlite-session-store.ts +46 -1
- package/src/hook/hook-auth.ts +18 -0
- package/src/hook/prompt-recall-hook.ts +7 -1
- package/src/hook/session-start-hook.ts +4 -1
- package/src/hook/stop-hook.ts +4 -1
- package/src/http/app.ts +78 -0
- package/src/install/claude-code.ts +1 -1
- package/src/install/cursor.ts +68 -0
- package/src/install/nlm-dir-perms.ts +55 -0
- package/src/install/ollama.ts +86 -10
- package/src/install/setup.ts +138 -17
- package/src/install/windsurf.ts +68 -0
- package/src/shared/types.ts +4 -0
- package/src/ui/components/SessionDrawer.tsx +97 -34
- package/src/ui/pages/River.tsx +90 -44
- package/src/ui/pages/Search.tsx +357 -64
- package/src/ui/pages/Thread.tsx +267 -56
- package/src/ui/styles.css +129 -5
- package/tests/integration/getbyids-sqlite.test.ts +40 -0
- package/tests/integration/hook-claude-settings.test.ts +14 -1
- package/tests/integration/mcp.test.ts +12 -0
- package/tests/integration/source-registry.test.ts +5 -3
- package/tests/unit/core/adapters/cursor.test.ts +485 -0
- package/tests/unit/core/adapters/windsurf.test.ts +416 -0
- 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, "
|
|
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
|
+
}
|
package/src/install/ollama.ts
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
* Windows — winget install / detached spawn
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
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,29 +181,104 @@ 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(
|
|
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");
|
|
208
|
+
mkdirSync(nlmDir, { recursive: true, mode: 0o700 });
|
|
209
|
+
chmodSync(nlmDir, 0o700);
|
|
190
210
|
|
|
191
211
|
const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
192
212
|
const kept = existing
|
|
193
213
|
.split("\n")
|
|
194
|
-
.filter(
|
|
214
|
+
.filter(
|
|
215
|
+
(l) =>
|
|
216
|
+
!l.startsWith("DEEPSEEK_API_KEY=") &&
|
|
217
|
+
!l.startsWith("NLM_CLASSIFIER=") &&
|
|
218
|
+
!l.startsWith("NLM_CLASSIFIER_MODEL="),
|
|
219
|
+
)
|
|
195
220
|
.join("\n")
|
|
196
221
|
.replace(/\n{3,}/g, "\n\n")
|
|
197
222
|
.trim();
|
|
198
223
|
|
|
199
224
|
const additions: string[] = [];
|
|
200
|
-
if (choice === "deepseek"
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (input.choice === "ollama-offline") additions.push("NLM_CLASSIFIER=ollama");
|
|
234
|
+
if (input.model) additions.push(`NLM_CLASSIFIER_MODEL=${input.model}`);
|
|
235
|
+
|
|
236
|
+
writeFileSync(envPath, [kept, ...additions].filter(Boolean).join("\n") + "\n", { mode: 0o600 });
|
|
237
|
+
chmodSync(envPath, 0o600);
|
|
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
|
+
}
|
|
204
272
|
}
|
|
205
|
-
if (choice === "ollama-offline") additions.push("NLM_CLASSIFIER=ollama");
|
|
206
273
|
|
|
207
|
-
|
|
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;
|
|
208
284
|
}
|
package/src/install/setup.ts
CHANGED
|
@@ -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
|
|
198
|
-
const
|
|
199
|
-
|
|
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 (
|
|
233
|
+
if (wantConfigure) {
|
|
202
234
|
const classifierChoice = await select<ClassifierChoice>({
|
|
203
|
-
message: "Which classifier?",
|
|
235
|
+
message: "Which classifier provider?",
|
|
204
236
|
options: [
|
|
205
|
-
{
|
|
206
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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(
|
|
275
|
+
log.warn(`DeepSeek (${model as string}) configured — set DEEPSEEK_API_KEY in ~/.nlm/.env before running.`);
|
|
219
276
|
}
|
|
220
277
|
} else {
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
+
}
|
package/src/shared/types.ts
CHANGED
|
@@ -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";
|