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
|
@@ -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
|
|
51
|
-
* JSON on stdin
|
|
52
|
-
* class of failures where settings.json
|
|
53
|
-
* at startup (path tokenization, missing
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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;
|
package/src/hook/stop-hook.ts
CHANGED
|
@@ -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;
|