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
@@ -0,0 +1,386 @@
1
+ /**
2
+ * WindsurfAdapter — reads Windsurf (Codeium Cascade) sessions.
3
+ *
4
+ * ## Storage locations (macOS; Linux uses ~/.config/)
5
+ *
6
+ * Workspace DBs ~/Library/Application Support/Windsurf/User/workspaceStorage/<hash>/state.vscdb
7
+ * Table: ItemTable
8
+ * Key: workbench.panel.aichat.view.aichat.chatdata — chat tabs
9
+ * Bubble role: type 'user' → user, type 'ai' → assistant
10
+ *
11
+ * Global DB ~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb
12
+ * Table: cursorDiskKV (if present) — composerData:*, agentData:*, flowData:*
13
+ * Table: ItemTable (fallback) — keys matching %agent%, %flow%, %cascade%
14
+ * Conversation format: type 1/2 (user/assistant) or role: user/assistant
15
+ *
16
+ * ## Session ID prefixes
17
+ *
18
+ * ws_ — workspace chat tab (ItemTable chatdata)
19
+ * wsg_ — global DB agent/flow session (cursorDiskKV or ItemTable)
20
+ *
21
+ * ## pathOrUrl in source registry
22
+ * Path to the Windsurf User directory. The adapter discovers:
23
+ * <userDir>/workspaceStorage/<hash>/state.vscdb (workspace)
24
+ * <userDir>/globalStorage/state.vscdb (global)
25
+ *
26
+ * Env override: NLM_WINDSURF_USER_DIR
27
+ */
28
+
29
+ import { existsSync, readdirSync } from "node:fs";
30
+ import { homedir } from "node:os";
31
+ import { join } from "node:path";
32
+ import Database from "better-sqlite3";
33
+ import type {
34
+ DetectionResult,
35
+ DiscoverOptions,
36
+ SessionChunk,
37
+ TranscriptAdapter,
38
+ } from "@ports/transcript-adapter.js";
39
+ import { durationMinutes, normalizeTimestamp, safeSessionId } from "./common.js";
40
+
41
+ export interface WindsurfAdapterOptions {
42
+ readonly userDir?: string;
43
+ }
44
+
45
+ // ── Types ─────────────────────────────────────────────────────────────────────
46
+
47
+ interface ItemTableRow {
48
+ readonly key: string;
49
+ readonly value: string | null;
50
+ }
51
+
52
+ interface KVRow {
53
+ readonly key: string;
54
+ readonly value: string | null;
55
+ }
56
+
57
+ interface ChatTab {
58
+ readonly tabId?: string;
59
+ readonly chatTitle?: string;
60
+ readonly lastSendTime?: number;
61
+ readonly bubbles?: ChatBubble[];
62
+ }
63
+
64
+ interface ChatBubble {
65
+ readonly type?: "user" | "ai" | string;
66
+ readonly text?: string;
67
+ readonly rawText?: string;
68
+ }
69
+
70
+ interface AgentComposer {
71
+ readonly composerId?: string;
72
+ readonly name?: string;
73
+ readonly createdAt?: unknown;
74
+ readonly lastUpdatedAt?: unknown;
75
+ readonly conversation?: AgentBubble[];
76
+ readonly status?: string;
77
+ }
78
+
79
+ interface AgentBubble {
80
+ readonly type?: number | string;
81
+ readonly role?: string;
82
+ readonly text?: string;
83
+ }
84
+
85
+ type Turn = { role: "user" | "assistant"; text: string };
86
+
87
+ const CHAT_KEY = "workbench.panel.aichat.view.aichat.chatdata";
88
+
89
+ // ── Path helpers ──────────────────────────────────────────────────────────────
90
+
91
+ export function defaultUserDir(): string {
92
+ if (process.env["NLM_WINDSURF_USER_DIR"]) return process.env["NLM_WINDSURF_USER_DIR"];
93
+ const home = homedir();
94
+ if (process.platform === "darwin") {
95
+ return join(home, "Library/Application Support/Windsurf/User");
96
+ }
97
+ return join(home, ".config/Windsurf/User");
98
+ }
99
+
100
+ function workspaceStorageDir(userDir: string): string {
101
+ return join(userDir, "workspaceStorage");
102
+ }
103
+
104
+ function globalDbPath(userDir: string): string {
105
+ return join(userDir, "globalStorage", "state.vscdb");
106
+ }
107
+
108
+ function listWorkspaceDbs(userDir: string): string[] {
109
+ const wsDir = workspaceStorageDir(userDir);
110
+ if (!existsSync(wsDir)) return [];
111
+ try {
112
+ return readdirSync(wsDir, { withFileTypes: true })
113
+ .filter((e) => e.isDirectory())
114
+ .map((e) => join(wsDir, e.name, "state.vscdb"))
115
+ .filter((p) => existsSync(p));
116
+ } catch {
117
+ return [];
118
+ }
119
+ }
120
+
121
+ // ── Turn extraction ───────────────────────────────────────────────────────────
122
+
123
+ function extractChatTurns(bubbles: ChatBubble[]): Turn[] {
124
+ const turns: Turn[] = [];
125
+ for (const b of bubbles) {
126
+ const text = (b.rawText ?? b.text ?? "").trim();
127
+ if (!text) continue;
128
+ turns.push({ role: b.type === "user" ? "user" : "assistant", text });
129
+ }
130
+ return turns;
131
+ }
132
+
133
+ function extractAgentTurns(bubbles: AgentBubble[]): Turn[] {
134
+ const turns: Turn[] = [];
135
+ for (const b of bubbles) {
136
+ const text = (b.text ?? "").trim();
137
+ if (!text) continue;
138
+ // Accept numeric type (1/2) or string role
139
+ const isUser = b.type === 1 || b.role === "user";
140
+ const isAssistant = b.type === 2 || b.role === "assistant";
141
+ if (!isUser && !isAssistant) continue;
142
+ turns.push({ role: isUser ? "user" : "assistant", text });
143
+ }
144
+ return turns;
145
+ }
146
+
147
+ function provisionalLabel(turns: ReadonlyArray<Turn>): string {
148
+ for (const t of turns) {
149
+ if (t.role !== "user") continue;
150
+ const first = t.text.split("\n", 1)[0]?.trim();
151
+ if (first) return first.slice(0, 80);
152
+ }
153
+ return "Untitled session";
154
+ }
155
+
156
+ function buildChunk(
157
+ id: string,
158
+ runtimeSessionId: string,
159
+ sourcePath: string,
160
+ turns: Turn[],
161
+ startedAt: string,
162
+ endedAt: string,
163
+ label: string,
164
+ ): SessionChunk {
165
+ const text = turns.map((t) => `${t.role}: ${t.text}`).join("\n\n");
166
+ return {
167
+ id,
168
+ runtime: "windsurf/1.0",
169
+ runtimeSessionId,
170
+ sourcePath,
171
+ startedAt,
172
+ endedAt,
173
+ durationMin: durationMinutes(startedAt, endedAt),
174
+ turnCount: turns.length,
175
+ byteRange: [0, Buffer.byteLength(text, "utf8")],
176
+ projectDir: "",
177
+ gitBranch: "",
178
+ text,
179
+ label,
180
+ };
181
+ }
182
+
183
+ // ── Workspace chat helpers ────────────────────────────────────────────────────
184
+
185
+ function parseTabsFromDb(dbPath: string): ChatTab[] {
186
+ let db: Database.Database | undefined;
187
+ try {
188
+ db = new Database(dbPath, { readonly: true });
189
+ const row = db
190
+ .prepare<[string], ItemTableRow>(`SELECT key, value FROM ItemTable WHERE key = ?`)
191
+ .get(CHAT_KEY);
192
+ if (!row?.value) return [];
193
+ const data = JSON.parse(row.value) as { tabs?: ChatTab[] };
194
+ return Array.isArray(data.tabs) ? data.tabs : [];
195
+ } catch {
196
+ return [];
197
+ } finally {
198
+ db?.close();
199
+ }
200
+ }
201
+
202
+ // ── Global DB helpers ─────────────────────────────────────────────────────────
203
+
204
+ interface GlobalSession {
205
+ readonly id: string; // raw composerId/session key
206
+ readonly meta: AgentComposer;
207
+ readonly dbPath: string;
208
+ }
209
+
210
+ function parseGlobalSessions(globalPath: string): GlobalSession[] {
211
+ if (!existsSync(globalPath)) return [];
212
+ let db: Database.Database | undefined;
213
+ const results: GlobalSession[] = [];
214
+ try {
215
+ db = new Database(globalPath, { readonly: true });
216
+ const tables = db
217
+ .prepare<[], { name: string }>(`SELECT name FROM sqlite_master WHERE type='table'`)
218
+ .all()
219
+ .map((r) => r.name);
220
+
221
+ if (tables.includes("cursorDiskKV")) {
222
+ const rows = db
223
+ .prepare<[], KVRow>(
224
+ `SELECT key, value FROM cursorDiskKV
225
+ WHERE key LIKE 'composerData:%' OR key LIKE 'agentData:%' OR key LIKE 'flowData:%'
226
+ ORDER BY rowid ASC`,
227
+ )
228
+ .all();
229
+ for (const row of rows) {
230
+ if (!row.value) continue;
231
+ try {
232
+ const meta = JSON.parse(row.value) as AgentComposer;
233
+ const rawId = meta.composerId ?? row.key.split(":").slice(1).join(":");
234
+ if (rawId) results.push({ id: rawId, meta, dbPath: globalPath });
235
+ } catch { /* skip */ }
236
+ }
237
+ } else if (tables.includes("ItemTable")) {
238
+ // Fallback: probe for agent/flow/cascade keys
239
+ const rows = db
240
+ .prepare<[], ItemTableRow>(
241
+ `SELECT key, value FROM ItemTable
242
+ WHERE key LIKE '%agent%' OR key LIKE '%flow%' OR key LIKE '%cascade%'`,
243
+ )
244
+ .all();
245
+ for (const row of rows) {
246
+ if (!row.value) continue;
247
+ try {
248
+ const data = JSON.parse(row.value);
249
+ if (typeof data !== "object" || !data) continue;
250
+ // Accept any object with a conversation array
251
+ const conv = (data as AgentComposer).conversation;
252
+ if (!Array.isArray(conv) || conv.length === 0) continue;
253
+ const id = (data as AgentComposer).composerId ?? row.key;
254
+ results.push({ id, meta: data as AgentComposer, dbPath: globalPath });
255
+ } catch { /* skip */ }
256
+ }
257
+ }
258
+ } catch { /* inaccessible */ } finally {
259
+ db?.close();
260
+ }
261
+ return results;
262
+ }
263
+
264
+ // ── Adapter ───────────────────────────────────────────────────────────────────
265
+
266
+ export class WindsurfAdapter implements TranscriptAdapter {
267
+ readonly name = "windsurf";
268
+ readonly runtimeVersion = "windsurf/1.0";
269
+ readonly transcriptKind = "windsurf-sqlite";
270
+
271
+ private readonly userDir: string;
272
+
273
+ constructor(opts: WindsurfAdapterOptions = {}) {
274
+ this.userDir = opts.userDir ?? defaultUserDir();
275
+ }
276
+
277
+ detect(): DetectionResult {
278
+ if (existsSync(this.userDir)) {
279
+ return { adapterName: this.name, enabled: true, path: this.userDir, hint: null };
280
+ }
281
+ return {
282
+ adapterName: this.name,
283
+ enabled: false,
284
+ path: null,
285
+ hint: "Windsurf User directory not found — install Windsurf or set NLM_WINDSURF_USER_DIR.",
286
+ };
287
+ }
288
+
289
+ async discover(options?: DiscoverOptions): Promise<ReadonlyArray<string>> {
290
+ const seen = new Set<string>();
291
+ const ids: string[] = [];
292
+ const add = (id: string) => { if (!seen.has(id)) { seen.add(id); ids.push(id); } };
293
+ const cutoff = options?.since?.getTime();
294
+
295
+ // ── Workspace chat tabs ───────────────────────────────────────────────
296
+ for (const dbPath of listWorkspaceDbs(this.userDir)) {
297
+ for (const tab of parseTabsFromDb(dbPath)) {
298
+ if (!tab.tabId) continue;
299
+ if (!(tab.bubbles && tab.bubbles.length > 0)) continue;
300
+ if (cutoff !== undefined) {
301
+ const ts = tab.lastSendTime;
302
+ // Only skip when we have a real non-zero timestamp older than the cutoff.
303
+ // Missing (undefined) or zero timestamps are treated as "unknown age" — include.
304
+ if (ts !== undefined && ts > 0 && ts < cutoff) continue;
305
+ }
306
+ add(`ws_${tab.tabId}`);
307
+ }
308
+ }
309
+
310
+ // ── Global agent/flow sessions ────────────────────────────────────────
311
+ for (const gs of parseGlobalSessions(globalDbPath(this.userDir))) {
312
+ if (cutoff !== undefined) {
313
+ const ts = gs.meta.lastUpdatedAt ?? gs.meta.createdAt;
314
+ if (ts !== undefined && ts !== null) {
315
+ const normalized = normalizeTimestamp(ts);
316
+ if (normalized && Date.parse(normalized) < cutoff) continue;
317
+ }
318
+ }
319
+ add(`wsg_${gs.id}`);
320
+ }
321
+
322
+ return ids;
323
+ }
324
+
325
+ async parseSession(id: string): Promise<SessionChunk | null> {
326
+ if (id.startsWith("wsg_")) {
327
+ return this._parseGlobalSession(id.slice("wsg_".length));
328
+ }
329
+ // ws_ prefix or legacy plain tabId
330
+ const tabId = id.startsWith("ws_") ? id.slice("ws_".length) : id;
331
+ return this._parseWorkspaceChatTab(tabId);
332
+ }
333
+
334
+ private _parseWorkspaceChatTab(tabId: string): SessionChunk | null {
335
+ for (const dbPath of listWorkspaceDbs(this.userDir)) {
336
+ const tabs = parseTabsFromDb(dbPath);
337
+ const tab = tabs.find((t) => t.tabId === tabId);
338
+ if (!tab) continue;
339
+
340
+ const turns = extractChatTurns(tab.bubbles ?? []);
341
+ if (turns.length === 0) return null;
342
+
343
+ const endedAtMs = tab.lastSendTime ?? 0;
344
+ const endedAt = endedAtMs > 0 ? new Date(endedAtMs).toISOString() : "";
345
+ const label = tab.chatTitle?.trim()
346
+ ? tab.chatTitle.trim().slice(0, 80)
347
+ : provisionalLabel(turns);
348
+
349
+ return buildChunk(
350
+ safeSessionId("ws", tabId),
351
+ tabId,
352
+ `${dbPath}::${tabId}`,
353
+ turns,
354
+ endedAt,
355
+ endedAt,
356
+ label,
357
+ );
358
+ }
359
+ return null;
360
+ }
361
+
362
+ private _parseGlobalSession(rawId: string): SessionChunk | null {
363
+ for (const gs of parseGlobalSessions(globalDbPath(this.userDir))) {
364
+ if (gs.id !== rawId) continue;
365
+ const turns = extractAgentTurns(gs.meta.conversation ?? []);
366
+ if (turns.length === 0) return null;
367
+
368
+ const startedAt = normalizeTimestamp(gs.meta.createdAt ?? gs.meta.lastUpdatedAt ?? "");
369
+ const endedAt = normalizeTimestamp(gs.meta.lastUpdatedAt ?? gs.meta.createdAt ?? "");
370
+ const label = gs.meta.name?.trim()
371
+ ? gs.meta.name.trim().slice(0, 80)
372
+ : provisionalLabel(turns);
373
+
374
+ return buildChunk(
375
+ safeSessionId("wsg", rawId),
376
+ rawId,
377
+ `${gs.dbPath}::${rawId}`,
378
+ turns,
379
+ startedAt,
380
+ endedAt,
381
+ label,
382
+ );
383
+ }
384
+ return null;
385
+ }
386
+ }
@@ -32,11 +32,26 @@ export function shellQuote(arg: string): string {
32
32
  return `'${arg.replace(/'/g, "'\\''")}'`;
33
33
  }
34
34
 
35
+ /**
36
+ * Double-quote a cmd.exe argument. Embedded double quotes are doubled per
37
+ * cmd.exe parsing rules. Used for hook commands on Windows where Claude
38
+ * Code dispatches via cmd.exe /c rather than sh -c.
39
+ */
40
+ export function cmdQuote(arg: string): string {
41
+ return `"${arg.replace(/"/g, '""')}"`;
42
+ }
43
+
35
44
  export function buildHookCommand(
36
45
  execPath: string,
37
46
  hookJs: string,
38
47
  mode: "shadow" | "live",
48
+ targetPlatform: NodeJS.Platform = process.platform,
39
49
  ): string {
50
+ if (targetPlatform === "win32") {
51
+ // cmd.exe: `set VAR=val && "exec" "script"`. The set is scoped to the
52
+ // cmd /c invocation so the env var is visible to the chained child.
53
+ return `set NLM_HOOK_MODE=${mode} && ${cmdQuote(execPath)} ${cmdQuote(hookJs)}`;
54
+ }
40
55
  return `NLM_HOOK_MODE=${mode} ${shellQuote(execPath)} ${shellQuote(hookJs)}`;
41
56
  }
42
57
 
@@ -47,10 +62,11 @@ export interface SmokeTestResult {
47
62
  }
48
63
 
49
64
  /**
50
- * Invoke the wired command exactly the way Claude Code does (sh -c with
51
- * JSON on stdin) and confirm the hook log gained an entry. Catches the
52
- * class of failures where settings.json looks valid but the hook fails
53
- * at startup (path tokenization, missing modules, etc.).
65
+ * Invoke the wired command exactly the way Claude Code does (sh -c on
66
+ * POSIX, cmd.exe /c on Windows) with JSON on stdin and confirm the hook
67
+ * log gained an entry. Catches the class of failures where settings.json
68
+ * looks valid but the hook fails at startup (path tokenization, missing
69
+ * modules, missing shell, etc.).
54
70
  */
55
71
  export function smokeTestHookCommand(
56
72
  command: string,
@@ -58,11 +74,16 @@ export function smokeTestHookCommand(
58
74
  timeoutMs = 5000,
59
75
  ): SmokeTestResult {
60
76
  const sizeBefore = existsSync(hookLogPath) ? statSync(hookLogPath).size : 0;
61
- const result = spawnSync("sh", ["-c", command], {
62
- input: JSON.stringify({ prompt: "smoke test", session_id: "install-smoke" }),
63
- timeout: timeoutMs,
64
- encoding: "utf8",
65
- });
77
+ const isWin = process.platform === "win32";
78
+ const result = spawnSync(
79
+ isWin ? "cmd.exe" : "sh",
80
+ [isWin ? "/c" : "-c", command],
81
+ {
82
+ input: JSON.stringify({ prompt: "smoke test", session_id: "install-smoke" }),
83
+ timeout: timeoutMs,
84
+ encoding: "utf8",
85
+ },
86
+ );
66
87
  if (result.error) {
67
88
  return { ok: false, reason: `spawn failed: ${result.error.message}` };
68
89
  }
@@ -19,10 +19,12 @@ import { homedir } from "node:os";
19
19
  import { join } from "node:path";
20
20
  import type Database from "better-sqlite3";
21
21
  import { defaultHistoryFile as defaultAiderHistoryFile } from "../adapters/aider.js";
22
+ import { defaultDbPath as defaultCursorDbPath } from "../adapters/cursor.js";
22
23
  import { defaultDbPath as defaultHermesAgentDbPath } from "../adapters/hermes-agent.js";
23
24
  import { defaultDbPath as defaultOpenCodeDbPath } from "../adapters/opencode.js";
25
+ import { defaultUserDir as defaultWindsurfUserDir } from "../adapters/windsurf.js";
24
26
 
25
- export type SourceKind = "claude-code" | "hermes" | "hermes-agent" | "aider" | "opencode" | "pi" | "jsonl-generic" | "webhook";
27
+ export type SourceKind = "claude-code" | "hermes" | "hermes-agent" | "aider" | "cursor" | "windsurf" | "opencode" | "pi" | "jsonl-generic" | "webhook";
26
28
 
27
29
  export interface SourceRow {
28
30
  readonly id: number;
@@ -210,6 +212,8 @@ export class SourceRegistry {
210
212
  const openCodeDbPath = defaultOpenCodeDbPath();
211
213
  const hermesAgentDbPath = defaultHermesAgentDbPath();
212
214
  const aiderHistoryFile = defaultAiderHistoryFile();
215
+ const cursorDbPath = defaultCursorDbPath();
216
+ const windsurfUserDir = defaultWindsurfUserDir();
213
217
 
214
218
  const presets: SourceInsert[] = [
215
219
  {
@@ -240,6 +244,20 @@ export class SourceRegistry {
240
244
  runtimeLabel: "aider/1.0",
241
245
  enabled: existsSync(aiderHistoryFile),
242
246
  },
247
+ {
248
+ kind: "cursor",
249
+ name: "Cursor",
250
+ pathOrUrl: cursorDbPath,
251
+ runtimeLabel: "cursor/1.0",
252
+ enabled: existsSync(cursorDbPath),
253
+ },
254
+ {
255
+ kind: "windsurf",
256
+ name: "Windsurf",
257
+ pathOrUrl: windsurfUserDir,
258
+ runtimeLabel: "windsurf/1.0",
259
+ enabled: existsSync(windsurfUserDir),
260
+ },
243
261
  {
244
262
  kind: "opencode",
245
263
  name: "OpenCode",
@@ -491,8 +491,9 @@ export class SqliteSessionStore implements SessionStore {
491
491
  if (!row) return null;
492
492
  const entities = this.loadEntities([sessionId]);
493
493
  const markers = this.loadMarkers([sessionId]);
494
+ const edges = this.loadSessionEdges([sessionId]);
494
495
  const overlay = loadActionOverlay(this.db);
495
- return this.rowToSession(row, entities, markers, overlay);
496
+ return this.rowToSession(row, entities, markers, overlay, edges);
496
497
  }
497
498
 
498
499
  /**
@@ -650,6 +651,18 @@ export class SqliteSessionStore implements SessionStore {
650
651
  session.open.forEach((q, i) => markerStmt.run(session.id, "open", q, i));
651
652
  }
652
653
 
654
+ insertEdgeForTest(
655
+ fromSession: string,
656
+ toSession: string,
657
+ kind: "supersedes" | "continues" = "supersedes",
658
+ ): void {
659
+ this.db
660
+ .prepare(
661
+ "INSERT OR IGNORE INTO session_edges (from_session, to_session, kind) VALUES (?, ?, ?)",
662
+ )
663
+ .run(fromSession, toSession, kind);
664
+ }
665
+
653
666
  insertEmbeddingForTest(sessionId: string, vector: Float32Array): void {
654
667
  this.insertChunkEmbeddingForTest(sessionId, 0, vector);
655
668
  }
@@ -684,6 +697,33 @@ export class SqliteSessionStore implements SessionStore {
684
697
  return out;
685
698
  }
686
699
 
700
+ private loadSessionEdges(
701
+ ids: ReadonlyArray<string>,
702
+ ): Map<string, { supersededBy: string | null; supersedes: string[] }> {
703
+ if (ids.length === 0) return new Map();
704
+ const placeholders = ids.map(() => "?").join(",");
705
+ const rows = this.db
706
+ .prepare<string[], { from_session: string; to_session: string }>(`
707
+ SELECT from_session, to_session
708
+ FROM session_edges
709
+ WHERE kind = 'supersedes'
710
+ AND (from_session IN (${placeholders}) OR to_session IN (${placeholders}))
711
+ `)
712
+ .all(...ids, ...ids);
713
+
714
+ const out = new Map<string, { supersededBy: string | null; supersedes: string[] }>();
715
+ for (const id of ids) {
716
+ out.set(id, { supersededBy: null, supersedes: [] });
717
+ }
718
+ for (const r of rows) {
719
+ const fromEntry = out.get(r.from_session);
720
+ if (fromEntry) fromEntry.supersedes.push(r.to_session);
721
+ const toEntry = out.get(r.to_session);
722
+ if (toEntry) toEntry.supersededBy = r.from_session;
723
+ }
724
+ return out;
725
+ }
726
+
687
727
  private loadMarkers(
688
728
  ids: ReadonlyArray<string>,
689
729
  ): Map<string, { decisions: string[]; open: string[] }> {
@@ -716,6 +756,7 @@ export class SqliteSessionStore implements SessionStore {
716
756
  entitiesById: Map<string, string[]>,
717
757
  markersById: Map<string, { decisions: string[]; open: string[] }>,
718
758
  overlay: ActionOverlay,
759
+ edgesById?: Map<string, { supersededBy: string | null; supersedes: string[] }>,
719
760
  ): Session {
720
761
  const m = markersById.get(row.id);
721
762
  const rawDecisions = m?.decisions ?? [];
@@ -732,6 +773,7 @@ export class SqliteSessionStore implements SessionStore {
732
773
  }
733
774
  activeOpen.push(text);
734
775
  }
776
+ const edges = edgesById?.get(row.id);
735
777
  return {
736
778
  id: row.id,
737
779
  runtime: row.runtime,
@@ -748,6 +790,9 @@ export class SqliteSessionStore implements SessionStore {
748
790
  entities: entitiesById.get(row.id) ?? [],
749
791
  decisions: [...rawDecisions, ...promotedDecisions],
750
792
  open: activeOpen,
793
+ ...(edges !== undefined
794
+ ? { supersededBy: edges.supersededBy, supersedes: edges.supersedes }
795
+ : {}),
751
796
  };
752
797
  }
753
798
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared helper: Bearer token for hook → /api/* HTTP calls.
3
+ *
4
+ * The HTTP daemon's /api/* gate requires either a same-origin browser
5
+ * request (Origin header set by the browser) or a Bearer token. Hooks
6
+ * are CLI processes with no Origin, so they need the token.
7
+ *
8
+ * Reads NLM_MCP_TOKEN from process.env (assumes autoloadEnv() has been
9
+ * called first). Returns an empty headers object when no token is set
10
+ * so legacy installs without a token still work — the daemon's gate
11
+ * also accepts unauthenticated loopback requests when no token is set.
12
+ */
13
+
14
+ export function hookAuthHeaders(extra: Record<string, string> = {}): Record<string, string> {
15
+ const token = process.env["NLM_MCP_TOKEN"];
16
+ if (!token) return { ...extra };
17
+ return { ...extra, authorization: `Bearer ${token}` };
18
+ }
@@ -16,6 +16,8 @@ import { appendHookLog } from "@core/hook/hook-log.js";
16
16
  import { loadSurfaced, recordSurfaced } from "@core/hook/memo.js";
17
17
  import { formatPointerBlock } from "@core/hook/pointer-block.js";
18
18
  import { selectHits, type RecallHitInput } from "@core/hook/select.js";
19
+ import { autoloadEnv } from "../llm/env-autoload.js";
20
+ import { hookAuthHeaders } from "./hook-auth.js";
19
21
 
20
22
  // Keyword recall returns raw BM25 scores (unbounded, not the 0..1 hybrid
21
23
  // scale). FTS5 MATCH already gates relevance — only lexically-matching
@@ -116,7 +118,7 @@ async function recallOverHttp(prompt: string): Promise<ReadonlyArray<RecallHitIn
116
118
  const timer = setTimeout(() => controller.abort(), RECALL_TIMEOUT_MS);
117
119
  try {
118
120
  const res = await fetch(url, {
119
- headers: { "x-recall-source": "hook" },
121
+ headers: hookAuthHeaders({ "x-recall-source": "hook" }),
120
122
  signal: controller.signal,
121
123
  });
122
124
  if (!res.ok) return [];
@@ -147,6 +149,10 @@ async function recallOverHttp(prompt: string): Promise<ReadonlyArray<RecallHitIn
147
149
 
148
150
  async function main(): Promise<void> {
149
151
  try {
152
+ // Load ~/.nlm/.env so NLM_MCP_TOKEN is available before we hit /api/recall.
153
+ // Hooks run as short-lived processes spawned by Claude Code with no shell
154
+ // env beyond what the parent passed — explicit .env load is required.
155
+ autoloadEnv();
150
156
  const raw = await readStdin();
151
157
  const payload = JSON.parse(raw) as {
152
158
  prompt?: unknown;
@@ -17,6 +17,8 @@ import { appendHookLog } from "@core/hook/hook-log.js";
17
17
  import { loadSurfaced, recordSurfaced } from "@core/hook/memo.js";
18
18
  import { formatPointerBlock } from "@core/hook/pointer-block.js";
19
19
  import { selectHits, type RecallHitInput } from "@core/hook/select.js";
20
+ import { autoloadEnv } from "../llm/env-autoload.js";
21
+ import { hookAuthHeaders } from "./hook-auth.js";
20
22
 
21
23
  const SCORE_THRESHOLD = 0;
22
24
  const PER_FIRE_CAP = 3;
@@ -103,7 +105,7 @@ async function recallOverHttp(query: string): Promise<ReadonlyArray<RecallHitInp
103
105
  const timer = setTimeout(() => controller.abort(), RECALL_TIMEOUT_MS);
104
106
  try {
105
107
  const res = await fetch(url, {
106
- headers: { "x-recall-source": "session-start-hook" },
108
+ headers: hookAuthHeaders({ "x-recall-source": "session-start-hook" }),
107
109
  signal: controller.signal,
108
110
  });
109
111
  if (!res.ok) return [];
@@ -134,6 +136,7 @@ async function recallOverHttp(query: string): Promise<ReadonlyArray<RecallHitInp
134
136
 
135
137
  async function main(): Promise<void> {
136
138
  try {
139
+ autoloadEnv();
137
140
  const raw = await readStdin();
138
141
  const payload = JSON.parse(raw) as {
139
142
  session_id?: unknown;
@@ -30,6 +30,8 @@ import {
30
30
  readAllAssistantTurns,
31
31
  type ToolUseBlock,
32
32
  } from "@core/hook/transcript.js";
33
+ import { autoloadEnv } from "../llm/env-autoload.js";
34
+ import { hookAuthHeaders } from "./hook-auth.js";
33
35
 
34
36
  const RESPONSE_PREVIEW_CHARS = 200;
35
37
  const POST_TIMEOUT_MS = 1500;
@@ -193,7 +195,7 @@ async function postCitationOverHttp(
193
195
  try {
194
196
  await fetch(url, {
195
197
  method: "POST",
196
- headers: { "content-type": "application/json" },
198
+ headers: hookAuthHeaders({ "content-type": "application/json" }),
197
199
  body: JSON.stringify({
198
200
  conversation_id: conversationId,
199
201
  cited_id: citedId,
@@ -209,6 +211,7 @@ async function postCitationOverHttp(
209
211
 
210
212
  async function main(): Promise<void> {
211
213
  try {
214
+ autoloadEnv();
212
215
  const raw = await readStdin();
213
216
  const payload = JSON.parse(raw) as {
214
217
  session_id?: unknown;