@getbourdon/participants 0.2.0 → 0.2.1

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.
@@ -1,670 +0,0 @@
1
- /**
2
- * Cursor participant — reads Cursor's (the AI-first IDE) SQLite-backed
3
- * workspace/composer state and normalizes it into L5. Port of
4
- * `participants/cursor.py` + the extraction helper `participants/_cursor_sqlite.py`.
5
- *
6
- * Cursor stores workspace state in `state.vscdb` SQLite DBs at platform-specific
7
- * paths (`~/.config/Cursor/`, `~/Library/Application Support/Cursor/`,
8
- * `%APPDATA%/Cursor/`). This reader discovers those stores, reads the `ItemTable`
9
- * key/value rows read-only, parses the composer/chat records into sessions +
10
- * project/topic entities, and merges curated `.cursor/memory/short-index.json`
11
- * entities alongside them.
12
- *
13
- * SQLite access: the Python oracle copies each DB (+ its `-wal`/`-shm`
14
- * companions) to a tmp file and opens it read-write so SQLite folds the WAL into
15
- * a consistent snapshot. The TS mirror instead leans on {@link tryOpenReadonly}
16
- * (`mode=ro`), which respects SQLite locking and reads the `-wal` sidecar of a
17
- * live writer — the same consistent view without the copy. Read-only,
18
- * deterministic, visibility-enforced-before-emission, redaction on every native
19
- * string; `healthCheck` never throws.
20
- */
21
- import { readFileSync, readdirSync, statSync } from "node:fs";
22
- import { homedir } from "node:os";
23
- import { basename, join } from "node:path";
24
- import { Visibility, filterForFederation, makeAgentInfo, makeEntity, makeManifest, makeSession, makeVisibilityPolicy, } from "@getbourdon/l5";
25
- import { redactText } from "@getbourdon/redaction";
26
- import { ParticipantDiscoveryError, SPEC_VERSION, } from "../base.js";
27
- import { tableExists, tryOpenReadonly } from "../sqlite-base.js";
28
- const AGENT_ID = "cursor";
29
- const AGENT_TYPE = "code-assistant";
30
- const ROLE_NARRATIVE = "AI-first IDE. Bourdon reads the SQLite-backed composer/workspace " +
31
- "state to surface recent sessions and project entities to other agents.";
32
- const DEFAULT_POLICY = makeVisibilityPolicy({
33
- default: Visibility.TEAM,
34
- private_tags: ["personal", "credential", "secret"],
35
- team_tags: ["cursor", "workspace", "sqlite"],
36
- });
37
- const TOPIC_MIN_MENTIONS = 3;
38
- // -- Defensive fs helpers -----------------------------------------------------
39
- function homeDir() {
40
- try {
41
- return homedir();
42
- }
43
- catch {
44
- return process.env.HOME || process.env.USERPROFILE || ".";
45
- }
46
- }
47
- function isDir(p) {
48
- try {
49
- return statSync(p).isDirectory();
50
- }
51
- catch {
52
- return false;
53
- }
54
- }
55
- function isFile(p) {
56
- try {
57
- return statSync(p).isFile();
58
- }
59
- catch {
60
- return false;
61
- }
62
- }
63
- /** Redact credential-like content and cap length (redaction SSOT). */
64
- function scrubText(value, limit = 256) {
65
- return redactText(value, limit);
66
- }
67
- // -- Path resolution ----------------------------------------------------------
68
- /**
69
- * Platform-specific Cursor data dir (the SQLite workspace state lives here),
70
- * `$CURSOR_DIR` override first. Returns null on an unrecognized platform (or
71
- * Windows without `%APPDATA%`). Mirrors `default_cursor_dir`.
72
- */
73
- export function defaultCursorDir() {
74
- const env = process.env.CURSOR_DIR;
75
- if (env)
76
- return env;
77
- switch (process.platform) {
78
- case "darwin":
79
- return join(homeDir(), "Library", "Application Support", "Cursor");
80
- case "linux":
81
- return join(homeDir(), ".config", "Cursor");
82
- case "win32": {
83
- const appdata = process.env.APPDATA;
84
- return appdata ? join(appdata, "Cursor") : null;
85
- }
86
- default:
87
- return null;
88
- }
89
- }
90
- /** Candidate `state.vscdb` paths under a Cursor data dir that actually exist. */
91
- function iterStateDbs(root) {
92
- const candidates = [
93
- join(root, "state.vscdb"),
94
- join(root, "User", "globalStorage", "state.vscdb"),
95
- ];
96
- const workspaceStorage = join(root, "User", "workspaceStorage");
97
- if (isDir(workspaceStorage)) {
98
- let children = [];
99
- try {
100
- children = readdirSync(workspaceStorage).sort();
101
- }
102
- catch {
103
- children = [];
104
- }
105
- for (const child of children) {
106
- candidates.push(join(workspaceStorage, child, "state.vscdb"));
107
- }
108
- }
109
- return candidates.filter((p) => isFile(p));
110
- }
111
- // -- SQLite ItemTable read (read-only) ----------------------------------------
112
- /**
113
- * Read the `(key, value)` rows of the `ItemTable`, JSON-decoding each value.
114
- * Returns the decoded records plus a count of values that failed to parse.
115
- * Never raises — a locked/corrupt/unreadable DB degrades to "no rows".
116
- */
117
- function readItemTable(dbPath) {
118
- const records = [];
119
- let malformed = 0;
120
- const conn = tryOpenReadonly(dbPath);
121
- if (!conn)
122
- return { records, malformed };
123
- let rows;
124
- try {
125
- if (!tableExists(conn, "ItemTable"))
126
- return { records, malformed };
127
- rows = conn.prepare("SELECT key, value FROM ItemTable").all();
128
- }
129
- catch {
130
- // Locked (Windows holds a write lock on a live DB), corrupt, or otherwise
131
- // unreadable — skip this DB, never crash the whole export. The caller still
132
- // records it as scanned.
133
- return { records, malformed };
134
- }
135
- finally {
136
- conn.close();
137
- }
138
- for (const row of rows) {
139
- const raw = row.value;
140
- let text = null;
141
- if (typeof raw === "string")
142
- text = raw;
143
- else if (raw instanceof Uint8Array)
144
- text = Buffer.from(raw).toString("utf8");
145
- if (text === null) {
146
- malformed += 1;
147
- continue;
148
- }
149
- let value;
150
- try {
151
- value = JSON.parse(text);
152
- }
153
- catch {
154
- malformed += 1;
155
- continue;
156
- }
157
- records.push([String(row.key), value]);
158
- }
159
- return { records, malformed };
160
- }
161
- // -- Record parsing -----------------------------------------------------------
162
- function isRecord(value) {
163
- return typeof value === "object" && value !== null && !Array.isArray(value);
164
- }
165
- function firstString(value, keys) {
166
- for (const key of keys) {
167
- const candidate = value[key];
168
- if (typeof candidate === "string" && candidate.trim())
169
- return candidate.trim();
170
- }
171
- return "";
172
- }
173
- function firstMessageText(messages) {
174
- if (!Array.isArray(messages))
175
- return "";
176
- for (const message of messages) {
177
- if (!isRecord(message))
178
- continue;
179
- // Python uses `content or text` — fall back whenever content is falsy
180
- // (incl. ""), not just null/undefined. `||` matches; `??` would drop a
181
- // {content:"", text:"..."} record and silently lose the session.
182
- const content = message["content"] || message["text"];
183
- if (typeof content === "string" && content.trim())
184
- return content.trim();
185
- }
186
- return "";
187
- }
188
- function looksLikeComposerRecord(key, value) {
189
- const loweredKey = key.toLowerCase();
190
- if (loweredKey.includes("composer") || loweredKey.includes("aichat") || loweredKey.includes("chat")) {
191
- return true;
192
- }
193
- if (isRecord(value)) {
194
- const joinedKeys = Object.keys(value).join(" ").toLowerCase();
195
- return joinedKeys.includes("message") && (joinedKeys.includes("workspace") || joinedKeys.includes("file"));
196
- }
197
- return false;
198
- }
199
- /**
200
- * Parse a calendar date as written from an ISO-ish string (no tz conversion,
201
- * mirroring `datetime.fromisoformat(...).date()`) or an epoch number.
202
- */
203
- function parseDate(value) {
204
- if (typeof value === "string") {
205
- const normalized = value.replaceAll("Z", "+00:00");
206
- const m = /^(\d{4}-\d{2}-\d{2})([T ].*)?$/.exec(normalized);
207
- if (!m)
208
- return "";
209
- const datePart = m[1] ?? "";
210
- // Reject calendar-impossible days (e.g. 2024-02-30): Python's
211
- // `datetime.fromisoformat` raises on these, but `new Date` silently rolls
212
- // the month over. Round-trip the date and require it to survive unchanged.
213
- const dayProbe = new Date(`${datePart}T00:00:00Z`);
214
- if (Number.isNaN(dayProbe.getTime()) || dayProbe.toISOString().slice(0, 10) !== datePart) {
215
- return "";
216
- }
217
- // Validate the full value parses as a real datetime; take the date as written.
218
- const probe = new Date(m[2] ? normalized.replace(" ", "T") : `${datePart}T00:00:00`);
219
- if (Number.isNaN(probe.getTime()))
220
- return "";
221
- return datePart;
222
- }
223
- if (typeof value === "number" && Number.isFinite(value)) {
224
- const timestamp = value > 10_000_000_000 ? value / 1000 : value;
225
- const dt = new Date(timestamp * 1000);
226
- if (Number.isNaN(dt.getTime()))
227
- return "";
228
- return dt.toISOString().slice(0, 10);
229
- }
230
- return "";
231
- }
232
- function dateFromRecord(value) {
233
- for (const key of ["createdAt", "timestamp", "updatedAt", "lastUpdatedAt", "time"]) {
234
- const parsed = parseDate(value[key]);
235
- if (parsed)
236
- return parsed;
237
- }
238
- return new Date().toISOString().slice(0, 10);
239
- }
240
- function stringsFromList(values) {
241
- const strings = [];
242
- for (const item of values) {
243
- if (typeof item === "string" && item.trim()) {
244
- strings.push(item.trim());
245
- }
246
- else if (isRecord(item)) {
247
- const path = firstString(item, ["path", "file", "uri", "relativePath"]);
248
- if (path)
249
- strings.push(path);
250
- }
251
- }
252
- return strings;
253
- }
254
- function dedupe(values) {
255
- const seen = new Set();
256
- const out = [];
257
- for (const value of values) {
258
- if (!seen.has(value)) {
259
- seen.add(value);
260
- out.push(value);
261
- }
262
- }
263
- return out;
264
- }
265
- function extractFiles(value) {
266
- const files = [];
267
- for (const key of ["files", "filePaths", "filesTouched", "references"]) {
268
- const candidate = value[key];
269
- if (Array.isArray(candidate))
270
- files.push(...stringsFromList(candidate));
271
- }
272
- const messages = value["messages"];
273
- if (Array.isArray(messages)) {
274
- for (const message of messages) {
275
- if (isRecord(message))
276
- files.push(...extractFiles(message));
277
- }
278
- }
279
- return dedupe(files);
280
- }
281
- function recordToSession(key, value) {
282
- if (!looksLikeComposerRecord(key, value))
283
- return null;
284
- if (!isRecord(value))
285
- return null;
286
- let cwd = firstString(value, ["workspacePath", "workspace", "cwd", "folder", "rootPath"]);
287
- if (!cwd && isRecord(value["workspace"])) {
288
- cwd = firstString(value["workspace"], ["path", "folder", "cwd"]);
289
- }
290
- if (!cwd)
291
- cwd = "";
292
- let action = firstString(value, ["title", "text", "prompt", "summary", "name"]);
293
- if (!action)
294
- action = firstMessageText(value["messages"]);
295
- if (!action)
296
- return null;
297
- return {
298
- date: dateFromRecord(value),
299
- cwd,
300
- keyActions: [scrubText(action)],
301
- filesTouched: extractFiles(value),
302
- };
303
- }
304
- function projectName(cwd) {
305
- if (!cwd)
306
- return "";
307
- const name = basename(cwd).trim();
308
- return name && name !== "." && name !== "/" ? name : "";
309
- }
310
- // -- Topic-entity inference ---------------------------------------------------
311
- const STRIP_CHARS = new Set([
312
- ".", ",", ";", ":", "!", "?", '"', "'", "`", "(", ")", "[", "]", "{", "}", "#", "-", "/",
313
- ]);
314
- function stripChars(token) {
315
- let start = 0;
316
- let end = token.length;
317
- while (start < end && STRIP_CHARS.has(token[start]))
318
- start += 1;
319
- while (end > start && STRIP_CHARS.has(token[end - 1]))
320
- end -= 1;
321
- return token.slice(start, end);
322
- }
323
- const TOPIC_STOPWORDS = new Set([
324
- "the", "and", "for", "with", "this", "that", "from", "have", "will",
325
- "are", "was", "been", "not", "but", "all", "can", "has", "its",
326
- "add", "use", "set", "get", "new", "fix", "run", "now", "also",
327
- "into", "make", "just", "like", "need", "want", "work", "code",
328
- "file", "test", "data", "type", "should", "would", "could",
329
- ]);
330
- /** Extract recurring topic entities from session key_actions (in place). */
331
- function addTopicEntities(sessions, entities) {
332
- const wordCounts = new Map();
333
- const wordLatest = new Map();
334
- for (const session of sessions) {
335
- for (const action of session.keyActions) {
336
- const words = new Set();
337
- for (const token of action.toLowerCase().split(/\s+/)) {
338
- const cleaned = stripChars(token);
339
- if (cleaned.length >= 4 && !TOPIC_STOPWORDS.has(cleaned) && /^\p{L}+$/u.test(cleaned)) {
340
- words.add(cleaned);
341
- }
342
- }
343
- for (const word of words) {
344
- wordCounts.set(word, (wordCounts.get(word) ?? 0) + 1);
345
- if (session.date) {
346
- const prev = wordLatest.get(word);
347
- if (prev === undefined || session.date > prev)
348
- wordLatest.set(word, session.date);
349
- }
350
- }
351
- }
352
- }
353
- for (const [word, count] of wordCounts) {
354
- if (count >= TOPIC_MIN_MENTIONS && !entities.has(word)) {
355
- entities.set(word, {
356
- name: word,
357
- entityType: "topic",
358
- aliases: [],
359
- summary: `Recurring topic across ${count} Cursor sessions.`,
360
- tags: ["cursor", "topic", "inferred"],
361
- lastUpdated: wordLatest.get(word) ?? "",
362
- });
363
- }
364
- }
365
- }
366
- // -- Top-level extraction -----------------------------------------------------
367
- function extractCursorMemories(cursorDir = null) {
368
- const root = cursorDir ?? defaultCursorDir();
369
- if (root === null || !isDir(root)) {
370
- return { sessions: [], entities: [], databasesScanned: [], malformedRecords: 0 };
371
- }
372
- const sessions = [];
373
- const entitiesByName = new Map();
374
- const databasesScanned = [];
375
- let malformedRecords = 0;
376
- const projectLatest = new Map();
377
- for (const dbPath of iterStateDbs(root)) {
378
- databasesScanned.push(dbPath);
379
- const { records, malformed } = readItemTable(dbPath);
380
- malformedRecords += malformed;
381
- for (const [key, value] of records) {
382
- const parsedSession = recordToSession(key, value);
383
- if (parsedSession === null)
384
- continue;
385
- sessions.push(parsedSession);
386
- const name = projectName(parsedSession.cwd);
387
- if (!name)
388
- continue;
389
- const prev = projectLatest.get(name) ?? "";
390
- if (!projectLatest.has(name) || (parsedSession.date && parsedSession.date > prev)) {
391
- projectLatest.set(name, parsedSession.date);
392
- }
393
- const existing = entitiesByName.get(name);
394
- const aliasSet = new Set(parsedSession.cwd ? [parsedSession.cwd] : []);
395
- if (existing) {
396
- for (const a of existing.aliases)
397
- aliasSet.add(a);
398
- }
399
- entitiesByName.set(name, {
400
- name: scrubText(name, 120),
401
- entityType: "project",
402
- aliases: [...aliasSet].sort(),
403
- summary: scrubText(`Cursor workspace inferred from ${parsedSession.cwd}.`),
404
- tags: ["cursor", "workspace", "sqlite"],
405
- lastUpdated: projectLatest.get(name) ?? "",
406
- });
407
- }
408
- }
409
- addTopicEntities(sessions, entitiesByName);
410
- sessions.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
411
- return {
412
- sessions,
413
- entities: [...entitiesByName.values()],
414
- databasesScanned,
415
- malformedRecords,
416
- };
417
- }
418
- // -- Short-index integration --------------------------------------------------
419
- /** Read a short-index.json and return its normalized entry dicts. */
420
- function readShortIndex(path) {
421
- if (!isFile(path))
422
- return [];
423
- let payload;
424
- try {
425
- const raw = readFileSync(path, "utf8").replace(/^\uFEFF/, ""); // utf-8-sig: strip BOM
426
- payload = JSON.parse(raw);
427
- }
428
- catch {
429
- // Python logs a warning here; swallow + skip so a bad file never crashes export.
430
- return [];
431
- }
432
- if (!isRecord(payload))
433
- return [];
434
- const entries = payload["entries"];
435
- if (!Array.isArray(entries))
436
- return [];
437
- return entries.filter((e) => isRecord(e) && Boolean(e["topic_key"]));
438
- }
439
- /** Merge workspace + global short-index files into Entity objects. */
440
- function shortIndexToEntities(workspaceRoot, _cursorDir) {
441
- const paths = [];
442
- if (workspaceRoot) {
443
- paths.push(join(workspaceRoot, ".cursor", "memory", "short-index.json"));
444
- }
445
- const cursorHome = process.env.CURSOR_DIR ? process.env.CURSOR_DIR : null;
446
- const homeBase = cursorHome ?? join(homeDir(), ".cursor");
447
- paths.push(join(homeBase, "memory", "short-index.json"));
448
- const merged = new Map();
449
- for (const path of paths) {
450
- for (const entry of readShortIndex(path)) {
451
- const key = String(entry["topic_key"]).trim().toLowerCase();
452
- merged.set(key, entry);
453
- }
454
- }
455
- const entities = [];
456
- for (const entry of merged.values()) {
457
- const nameRaw = entry["topic_name"] !== undefined ? entry["topic_name"] : (entry["topic_key"] ?? "");
458
- const name = String(nameRaw).trim();
459
- if (!name)
460
- continue;
461
- const aliasesRaw = "triggers" in entry ? entry["triggers"] : "aliases" in entry ? entry["aliases"] : [];
462
- const aliases = Array.isArray(aliasesRaw)
463
- ? aliasesRaw.map((a) => String(a).trim()).filter((s) => s.length > 0)
464
- : [];
465
- const summary = String(entry["summary"] ?? "").trim();
466
- const tagsRaw = entry["tags"] ?? [];
467
- const tags = Array.isArray(tagsRaw)
468
- ? tagsRaw.map((t) => String(t).trim()).filter((s) => s.length > 0)
469
- : [];
470
- tags.push("short-index");
471
- const access = String(entry["access_level"] ?? "team").trim().toLowerCase();
472
- const visibility = access === "private"
473
- ? Visibility.PRIVATE
474
- : access === "public"
475
- ? Visibility.PUBLIC
476
- : Visibility.TEAM;
477
- const lastTouched = String(entry["last_updated"] ?? "");
478
- entities.push(makeEntity({
479
- name,
480
- type: "topic",
481
- aliases,
482
- summary: summary || undefined,
483
- tags,
484
- last_touched: lastTouched || undefined,
485
- visibility,
486
- }));
487
- }
488
- return entities;
489
- }
490
- // -- Conversion helpers -------------------------------------------------------
491
- function toSession(raw) {
492
- return makeSession({
493
- date: raw.date || "",
494
- cwd: raw.cwd || undefined,
495
- project_focus: [],
496
- key_actions: [...raw.keyActions],
497
- files_touched: [...raw.filesTouched],
498
- });
499
- }
500
- function toEntity(raw) {
501
- return makeEntity({
502
- name: raw.name,
503
- type: raw.entityType || undefined,
504
- aliases: [...raw.aliases],
505
- summary: raw.summary || undefined,
506
- tags: [...raw.tags],
507
- last_touched: raw.lastUpdated || undefined,
508
- });
509
- }
510
- /** Current UTC time, seconds precision + `+00:00` (Python `isoformat(timespec="seconds")`). */
511
- function nowIsoSeconds() {
512
- return new Date().toISOString().replace(/\.\d{3}Z$/, "+00:00");
513
- }
514
- // -- Participant --------------------------------------------------------------
515
- /** External participant for Cursor's SQLite workspace state. */
516
- export class CursorParticipant {
517
- agentId = AGENT_ID;
518
- agentType = AGENT_TYPE;
519
- cursorDir;
520
- workspaceRoot;
521
- policy = DEFAULT_POLICY;
522
- /**
523
- * Conventional Cursor config dir (`~/.cursor`) used by the setup wizard. The
524
- * wizard probes `~/.cursor` for presence; the SQLite workspace state that
525
- * `exportL5` actually reads lives at {@link defaultCursorDir}. Detection and
526
- * extraction intentionally use different anchors.
527
- */
528
- static defaultNativePath(home) {
529
- return join(home ?? homeDir(), ".cursor");
530
- }
531
- constructor(cursorDir, workspaceRoot) {
532
- this.cursorDir = cursorDir ?? null;
533
- this.workspaceRoot = workspaceRoot ?? null;
534
- }
535
- get nativePath() {
536
- const path = this.cursorDir ?? defaultCursorDir();
537
- return path ?? "";
538
- }
539
- discover() {
540
- const path = this.cursorDir ?? defaultCursorDir();
541
- if (path === null || !isDir(path)) {
542
- throw new ParticipantDiscoveryError(`Cursor data directory not found at ${path === null ? "None" : `'${path}'`}. ` +
543
- "Pass an explicit cursor_dir to CursorParticipant() if Cursor stores its " +
544
- "state somewhere non-standard.");
545
- }
546
- const dbs = iterStateDbs(path);
547
- const dbDetails = dbs.map((db) => {
548
- let size;
549
- try {
550
- size = statSync(db).size;
551
- }
552
- catch {
553
- size = -1;
554
- }
555
- return { path: db, size_bytes: size };
556
- });
557
- return {
558
- path,
559
- version: "unknown",
560
- metadata: {
561
- platform_default: defaultCursorDir() ?? "None",
562
- databases_found: dbs.length,
563
- databases: dbDetails,
564
- },
565
- };
566
- }
567
- exportSessions(since, limit = 100) {
568
- const memories = this.extract();
569
- const out = [];
570
- const sinceIso = since ? since.toISOString().slice(0, 10) : null;
571
- for (const raw of memories.sessions) {
572
- if (sinceIso && raw.date && raw.date < sinceIso)
573
- continue;
574
- out.push(toSession(raw));
575
- if (out.length >= limit)
576
- break;
577
- }
578
- return out;
579
- }
580
- exportL5(since) {
581
- const memories = this.extract();
582
- let sessions = memories.sessions.map(toSession);
583
- if (since) {
584
- const sinceIso = since.toISOString().slice(0, 10);
585
- sessions = sessions.filter((s) => !s.date || s.date >= sinceIso);
586
- }
587
- const entities = memories.entities.map(toEntity);
588
- const shortIndexEntities = shortIndexToEntities(this.workspaceRoot, this.cursorDir);
589
- const seenNames = new Set(entities.map((e) => e.name.toLowerCase()));
590
- for (const si of shortIndexEntities) {
591
- const key = si.name.toLowerCase();
592
- if (!seenNames.has(key)) {
593
- entities.push(si);
594
- seenNames.add(key);
595
- }
596
- }
597
- const visibleEntities = filterForFederation(entities, this.policy);
598
- return makeManifest({
599
- spec_version: SPEC_VERSION,
600
- agent: makeAgentInfo({
601
- id: AGENT_ID,
602
- type: AGENT_TYPE,
603
- role_narrative: ROLE_NARRATIVE,
604
- spec_version_compat: SPEC_VERSION,
605
- }),
606
- last_updated: nowIsoSeconds(),
607
- capabilities: ["composer-history", "workspace-state"],
608
- recent_sessions: sessions,
609
- known_entities: visibleEntities,
610
- visibility_policy: this.policy,
611
- });
612
- }
613
- healthCheck() {
614
- const path = this.cursorDir ?? defaultCursorDir();
615
- if (path === null) {
616
- return {
617
- status: "blocked",
618
- reason: "Cursor data directory not resolvable on this platform.",
619
- details: {},
620
- proposedFix: "Install Cursor (https://cursor.sh) and open it once. " +
621
- "Set CURSOR_DIR if Cursor stores state outside the default location.",
622
- };
623
- }
624
- if (!isDir(path)) {
625
- return {
626
- status: "blocked",
627
- reason: `Cursor data directory not present at ${path}.`,
628
- details: { expected_path: path },
629
- proposedFix: "Install Cursor and open it once to create the data directory, " +
630
- "or set CURSOR_DIR to the actual location.",
631
- };
632
- }
633
- let memories;
634
- try {
635
- memories = this.extract();
636
- }
637
- catch (err) {
638
- // health check must not raise.
639
- return {
640
- status: "degraded",
641
- reason: "Cursor data directory present but extraction failed.",
642
- details: { error: String(err) },
643
- proposedFix: "Close Cursor (its SQLite stores may be locked) then re-run " +
644
- "`bourdon cursor export`. If extraction still fails, file an " +
645
- "issue with the error above.",
646
- };
647
- }
648
- if (memories.databasesScanned.length === 0) {
649
- return {
650
- status: "degraded",
651
- reason: "No Cursor SQLite stores found under the data directory.",
652
- details: { path },
653
- proposedFix: "Open Cursor and use it for at least one chat session, then " +
654
- "re-run `bourdon cursor export`.",
655
- };
656
- }
657
- return {
658
- status: "ok",
659
- details: {
660
- databases_scanned: memories.databasesScanned.length,
661
- sessions_extracted: memories.sessions.length,
662
- entities_extracted: memories.entities.length,
663
- malformed_records: memories.malformedRecords,
664
- },
665
- };
666
- }
667
- extract() {
668
- return extractCursorMemories(this.cursorDir);
669
- }
670
- }