relaybrain 0.0.0-stage → 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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +434 -3
  3. package/backend/coordinator/client.js +364 -0
  4. package/backend/coordinator/lockTable.js +649 -0
  5. package/backend/coordinator/server.js +179 -0
  6. package/backend/lib/centralAuth.js +62 -0
  7. package/backend/lib/centralStore.js +112 -0
  8. package/backend/lib/centralSync.js +88 -0
  9. package/backend/lib/cloneRepo.js +108 -0
  10. package/backend/lib/collisionMetrics.js +231 -0
  11. package/backend/lib/conflicts.js +35 -0
  12. package/backend/lib/dashboard.js +53 -0
  13. package/backend/lib/depGraph.js +393 -0
  14. package/backend/lib/deps.js +46 -0
  15. package/backend/lib/dirtyFiles.js +33 -0
  16. package/backend/lib/editMatchers.js +55 -0
  17. package/backend/lib/falkor/analytics.js +228 -0
  18. package/backend/lib/falkor/bus.js +16 -0
  19. package/backend/lib/falkor/central.js +281 -0
  20. package/backend/lib/falkor/client.js +165 -0
  21. package/backend/lib/falkor/config.js +73 -0
  22. package/backend/lib/falkor/coord.js +299 -0
  23. package/backend/lib/falkor/cursor.js +58 -0
  24. package/backend/lib/falkor/demo.js +221 -0
  25. package/backend/lib/falkor/fixtures.js +85 -0
  26. package/backend/lib/falkor/index.js +146 -0
  27. package/backend/lib/falkor/ingest.js +233 -0
  28. package/backend/lib/falkor/intents.js +32 -0
  29. package/backend/lib/falkor/keys.js +102 -0
  30. package/backend/lib/falkor/migrations.js +56 -0
  31. package/backend/lib/falkor/project.js +307 -0
  32. package/backend/lib/falkor/query.js +344 -0
  33. package/backend/lib/falkor/redact.js +30 -0
  34. package/backend/lib/falkor/routes.js +125 -0
  35. package/backend/lib/falkor/schema.js +108 -0
  36. package/backend/lib/falkor/write.js +281 -0
  37. package/backend/lib/githubLogin.js +108 -0
  38. package/backend/lib/githubOAuth.js +42 -0
  39. package/backend/lib/githubRepo.js +117 -0
  40. package/backend/lib/harvest.js +85 -0
  41. package/backend/lib/httpServer.js +27 -0
  42. package/backend/lib/installAgentRelay.js +235 -0
  43. package/backend/lib/installHooks.js +309 -0
  44. package/backend/lib/lamport.js +14 -0
  45. package/backend/lib/localBrowse.js +109 -0
  46. package/backend/lib/memorySync.js +63 -0
  47. package/backend/lib/notices.js +150 -0
  48. package/backend/lib/ot.js +74 -0
  49. package/backend/lib/patches.js +192 -0
  50. package/backend/lib/paths.js +85 -0
  51. package/backend/lib/peerClash.js +64 -0
  52. package/backend/lib/presence.js +35 -0
  53. package/backend/lib/relayOsBlock.js +84 -0
  54. package/backend/lib/repoSnapshot.js +125 -0
  55. package/backend/lib/room.js +638 -0
  56. package/backend/lib/roomAsk.js +148 -0
  57. package/backend/lib/roomAuth.js +215 -0
  58. package/backend/lib/roomControl.js +226 -0
  59. package/backend/lib/roomLocks.js +201 -0
  60. package/backend/lib/roomSignal.js +265 -0
  61. package/backend/lib/roomSync.js +194 -0
  62. package/backend/lib/seed.js +107 -0
  63. package/backend/lib/sseHub.js +40 -0
  64. package/backend/lib/store.js +79 -0
  65. package/backend/lib/supergraph.js +46 -0
  66. package/backend/lib/transcripts/discover.js +437 -0
  67. package/backend/lib/transcripts/parse.js +558 -0
  68. package/backend/lib/transcripts/sessionId.js +36 -0
  69. package/backend/lib/transcripts/sync.js +291 -0
  70. package/backend/lib/transcripts/util.js +299 -0
  71. package/backend/lib/treeSitterImports.js +219 -0
  72. package/backend/lib/watchWorkspace.js +42 -0
  73. package/backend/mcp/README.md +193 -0
  74. package/backend/mcp/server.js +42 -0
  75. package/backend/mcp/tools.js +484 -0
  76. package/backend/server.js +3894 -0
  77. package/cli/graph.js +286 -0
  78. package/cli/relay.js +475 -0
  79. package/docker-compose.falkor.yml +17 -0
  80. package/docs/MEMORY-GRAPH.md +314 -0
  81. package/hooks/relay-antigravity-post-tool.js +2 -0
  82. package/hooks/relay-antigravity-pre-read.js +2 -0
  83. package/hooks/relay-antigravity-pre-tool.js +2 -0
  84. package/hooks/relay-antigravity-stop.js +2 -0
  85. package/hooks/relay-claude-post-tool.js +2 -0
  86. package/hooks/relay-claude-pre-read.js +2 -0
  87. package/hooks/relay-claude-pre-tool.js +2 -0
  88. package/hooks/relay-claude-stop.js +2 -0
  89. package/hooks/relay-codex-post-tool.js +2 -0
  90. package/hooks/relay-codex-pre-read.js +2 -0
  91. package/hooks/relay-codex-pre-tool.js +2 -0
  92. package/hooks/relay-codex-stop.js +2 -0
  93. package/hooks/relay-copilot-post-tool.js +2 -0
  94. package/hooks/relay-copilot-pre-read.js +2 -0
  95. package/hooks/relay-copilot-pre-tool.js +2 -0
  96. package/hooks/relay-copilot-stop.js +2 -0
  97. package/hooks/relay-cursor-post-tool.js +2 -0
  98. package/hooks/relay-cursor-pre-read.js +2 -0
  99. package/hooks/relay-cursor-pre-tool.js +2 -0
  100. package/hooks/relay-cursor-stop.js +2 -0
  101. package/hooks/relay-hook-lib.js +621 -0
  102. package/mission-control/out/404.html +1 -0
  103. package/mission-control/out/__next.__PAGE__.txt +26 -0
  104. package/mission-control/out/__next._full.txt +21 -0
  105. package/mission-control/out/__next._tree.txt +4 -0
  106. package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_buildManifest.js +11 -0
  107. package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_clientMiddlewareManifest.js +1 -0
  108. package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_ssgManifest.js +1 -0
  109. package/mission-control/out/_next/static/chunks/0-3l04fnym95-.js +22 -0
  110. package/mission-control/out/_next/static/chunks/064ntycm-fx5v.js +1 -0
  111. package/mission-control/out/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  112. package/mission-control/out/_next/static/chunks/0ehjiuuxbbhq9.js +1 -0
  113. package/mission-control/out/_next/static/chunks/1-dy52ls2ntb_.css +4 -0
  114. package/mission-control/out/_next/static/chunks/1q0cmt0kqufow.js +2 -0
  115. package/mission-control/out/_next/static/chunks/1ya1u0izct4f6.js +1 -0
  116. package/mission-control/out/_next/static/chunks/2n66mlnb34vjq.js +1 -0
  117. package/mission-control/out/_next/static/chunks/2xar70jzx8f1y.css +1 -0
  118. package/mission-control/out/_next/static/chunks/31cgqj_-l15yt.js +31 -0
  119. package/mission-control/out/_next/static/chunks/31qgr-p-oulpb.js +1 -0
  120. package/mission-control/out/_next/static/chunks/3vq9c4hivvgn_.js +1 -0
  121. package/mission-control/out/_next/static/chunks/turbopack-3hsllzpnzz12p.js +1 -0
  122. package/mission-control/out/_next/static/media/icon.27otmh_fv3buh.svg +4 -0
  123. package/mission-control/out/_not-found/__next._full.txt +18 -0
  124. package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +24 -0
  125. package/mission-control/out/_not-found/__next._tree.txt +3 -0
  126. package/mission-control/out/_not-found.html +1 -0
  127. package/mission-control/out/_not-found.txt +18 -0
  128. package/mission-control/out/agents/antigravity.png +0 -0
  129. package/mission-control/out/agents/claude.png +0 -0
  130. package/mission-control/out/agents/codex.png +0 -0
  131. package/mission-control/out/agents/copilot.png +0 -0
  132. package/mission-control/out/agents/cursor.png +0 -0
  133. package/mission-control/out/icon.svg +4 -0
  134. package/mission-control/out/index.html +1 -0
  135. package/mission-control/out/index.txt +21 -0
  136. package/mission-control/out/logo.png +0 -0
  137. package/mission-control/out/logo_transparent.png +0 -0
  138. package/package.json +76 -6
  139. package/scripts/falkor-rebuild.js +38 -0
  140. package/scripts/falkor-seed-demo.js +29 -0
@@ -0,0 +1,437 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const {
4
+ HOME,
5
+ cursorSlug,
6
+ claudeSlug,
7
+ findJsonlFiles,
8
+ normalizeCompare,
9
+ readJsonl,
10
+ discoverWorkspaceStorageDir,
11
+ pathNeedle,
12
+ pathsMentionedIn,
13
+ pathsCoverWorkspace,
14
+ } = require("./util");
15
+
16
+ const AGENTS = ["Cursor", "Claude Code", "Codex", "Copilot", "Antigravity"];
17
+
18
+ function listCursorTranscriptFiles(transcriptDir) {
19
+ if (!transcriptDir || !fs.existsSync(transcriptDir)) return [];
20
+ const files = [];
21
+ for (const entry of fs.readdirSync(transcriptDir, { withFileTypes: true })) {
22
+ if (entry.isDirectory()) {
23
+ const jsonl = path.join(transcriptDir, entry.name, `${entry.name}.jsonl`);
24
+ if (fs.existsSync(jsonl)) files.push(jsonl);
25
+ } else if (entry.name.endsWith(".jsonl")) {
26
+ files.push(path.join(transcriptDir, entry.name));
27
+ }
28
+ }
29
+ return files.sort((a, b) => {
30
+ try {
31
+ return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
32
+ } catch {
33
+ return 0;
34
+ }
35
+ });
36
+ }
37
+
38
+ // Only the first bytes of a transcript are ever inspected here, but reading it
39
+ // with readFileSync pulled the whole file into memory first — and these files
40
+ // reach tens of megabytes. Done for several candidates per workspace, for every
41
+ // workspace, on a 3s sweep, that read alone was enough to stall the event loop.
42
+ function readHead(file, bytes) {
43
+ let fd;
44
+ try {
45
+ fd = fs.openSync(file, "r");
46
+ const buf = Buffer.allocUnsafe(bytes);
47
+ const read = fs.readSync(fd, buf, 0, bytes, 0);
48
+ return buf.slice(0, read).toString("utf8");
49
+ } catch {
50
+ return "";
51
+ } finally {
52
+ if (fd !== undefined) {
53
+ try {
54
+ fs.closeSync(fd);
55
+ } catch {
56
+ /* ignore */
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ // Resolving a workspace to its Cursor transcript directory means scanning every
63
+ // project Cursor knows about. The answer changes only when a directory appears,
64
+ // so a short cache keeps the sweep off that path entirely.
65
+ const CURSOR_DIR_TTL_MS = 30000;
66
+ const cursorDirCache = new Map();
67
+
68
+ function discoverCursorDir(workspacePath) {
69
+ const cached = cursorDirCache.get(workspacePath);
70
+ if (cached && Date.now() - cached.at < CURSOR_DIR_TTL_MS) {
71
+ // A cached directory that has since been removed must not be trusted.
72
+ if (!cached.dir || fs.existsSync(cached.dir)) return cached.dir;
73
+ }
74
+ const dir = findCursorDir(workspacePath);
75
+ cursorDirCache.set(workspacePath, { dir, at: Date.now() });
76
+ return dir;
77
+ }
78
+
79
+ function findCursorDir(workspacePath) {
80
+ const projectsRoot = path.join(HOME, ".cursor", "projects");
81
+ const direct = path.join(projectsRoot, cursorSlug(workspacePath), "agent-transcripts");
82
+ if (fs.existsSync(direct)) return direct;
83
+ if (!fs.existsSync(projectsRoot)) return null;
84
+ const target = normalizeCompare(workspacePath);
85
+ for (const entry of fs.readdirSync(projectsRoot, { withFileTypes: true })) {
86
+ if (!entry.isDirectory()) continue;
87
+ const candidate = path.join(projectsRoot, entry.name, "agent-transcripts");
88
+ if (!fs.existsSync(candidate)) continue;
89
+ for (const file of listCursorTranscriptFiles(candidate).slice(0, 4)) {
90
+ if (readHead(file, 12000).toLowerCase().includes(target)) return candidate;
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+
96
+ function discoverCursor(workspacePath) {
97
+ const dir = discoverCursorDir(workspacePath);
98
+ if (!dir) return [];
99
+ return listCursorTranscriptFiles(dir);
100
+ }
101
+
102
+ // The `cwd` recorded on a Claude transcript row is the authoritative workspace
103
+ // for that session. The directory slug alone is not: it is lossy (every
104
+ // non-alphanumeric character collapses to "-"), so sibling paths can land in the
105
+ // same folder, and the folder can hold unrelated .jsonl files (e.g. `memory/`).
106
+ // Checking cwd keeps one workspace's dashboard from showing another's sessions.
107
+ function claudeTranscriptCwd(file) {
108
+ // `cwd` is stamped on the session's opening rows, so the head is enough; the
109
+ // tail of a long session can be tens of megabytes of no interest here.
110
+ const lines = readHead(file, 64000).replace(/^\uFEFF/, "").split("\n");
111
+ for (const line of lines) {
112
+ if (!line) continue;
113
+ let row = null;
114
+ try {
115
+ row = JSON.parse(line);
116
+ } catch {
117
+ continue;
118
+ }
119
+ if (row?.cwd) return String(row.cwd);
120
+ }
121
+ return null;
122
+ }
123
+
124
+ function claudeJsonlFiles(dir) {
125
+ try {
126
+ return fs
127
+ .readdirSync(dir, { withFileTypes: true })
128
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
129
+ .map((entry) => path.join(dir, entry.name));
130
+ } catch {
131
+ return [];
132
+ }
133
+ }
134
+
135
+ // Claude project dirs for every folder above the workspace. Opening VS Code (or
136
+ // the CLI) one level up — a monorepo root, a hackathon folder holding the repo —
137
+ // files the session under the parent's slug, so the repo's own slug dir never
138
+ // sees it.
139
+ function claudeAncestorDirs(workspacePath) {
140
+ const root = path.join(HOME, ".claude", "projects");
141
+ const dirs = [];
142
+ let dir = path.resolve(workspacePath);
143
+ for (let parent = path.dirname(dir); parent !== dir; dir = parent, parent = path.dirname(dir)) {
144
+ const candidate = path.join(root, claudeSlug(parent));
145
+ if (fs.existsSync(candidate)) dirs.push({ dir: candidate, cwd: normalizeCompare(parent) });
146
+ }
147
+ return dirs;
148
+ }
149
+
150
+ // Transcripts are append-only, so once a session has mentioned a file inside the
151
+ // workspace it always will; only the bytes added since the last look need reading.
152
+ const touchCache = new Map();
153
+
154
+ // The workspace path as a transcript spells it: plain, JSON-escaped, raw
155
+ // backslashes, or a file:// URL. Matched literally rather than through
156
+ // pathsMentionedIn, whose regex stops at whitespace and so never sees a path
157
+ // under a home folder like "C:\Users\Jane Doe". The trailing separator keeps
158
+ // `.../relay` from matching `.../relay-old`.
159
+ function workspaceSpellings(needle) {
160
+ const fwd = needle.toLowerCase();
161
+ const url = fwd.replace(/ /g, "%20");
162
+ return [
163
+ `${fwd}/`,
164
+ `${fwd.replace(/\//g, "\\")}\\`,
165
+ `${fwd.replace(/\//g, "\\\\")}\\\\`,
166
+ `${url}/`,
167
+ `${url.replace(/^([a-z]):/, "$1%3a")}/`,
168
+ ];
169
+ }
170
+
171
+ function transcriptTouches(file, needle) {
172
+ let size;
173
+ try {
174
+ size = fs.statSync(file).size;
175
+ } catch {
176
+ return false;
177
+ }
178
+ const key = `${file}\n${needle}`;
179
+ const prev = touchCache.get(key);
180
+ if (prev?.matched) return true;
181
+ if (prev && prev.size === size) return false;
182
+ const from = Math.max(0, (prev?.size || 0) - 1024);
183
+ const spellings = workspaceSpellings(needle);
184
+ let matched = false;
185
+ try {
186
+ const fd = fs.openSync(file, "r");
187
+ try {
188
+ const chunk = 4 * 1024 * 1024;
189
+ const buf = Buffer.alloc(chunk);
190
+ for (let pos = from; pos < size && !matched; pos += chunk - 1024) {
191
+ const n = fs.readSync(fd, buf, 0, chunk, pos);
192
+ if (n <= 0) break;
193
+ const text = buf.toString("utf8", 0, n).toLowerCase();
194
+ matched = spellings.some((s) => text.includes(s));
195
+ if (n < chunk) break;
196
+ }
197
+ } finally {
198
+ fs.closeSync(fd);
199
+ }
200
+ } catch {
201
+ return false;
202
+ }
203
+ touchCache.set(key, { size, matched });
204
+ return matched;
205
+ }
206
+
207
+ const CLAUDE_ANCESTOR_MAX_FILES = 40;
208
+
209
+ function discoverClaude(workspacePath) {
210
+ const root = path.join(HOME, ".claude", "projects");
211
+ const projectDir = path.join(root, claudeSlug(workspacePath));
212
+ const target = normalizeCompare(workspacePath);
213
+ const own = claudeJsonlFiles(projectDir).filter((file) => {
214
+ const cwd = claudeTranscriptCwd(file);
215
+ return !cwd || normalizeCompare(cwd) === target;
216
+ });
217
+
218
+ // Sessions started in a parent folder count when they reach into this repo.
219
+ const needle = pathNeedle(workspacePath);
220
+ const inherited = [];
221
+ for (const { dir, cwd: dirCwd } of claudeAncestorDirs(workspacePath)) {
222
+ const recent = claudeJsonlFiles(dir)
223
+ .map((file) => {
224
+ try {
225
+ return { file, mtimeMs: fs.statSync(file).mtimeMs };
226
+ } catch {
227
+ return null;
228
+ }
229
+ })
230
+ .filter(Boolean)
231
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
232
+ .slice(0, CLAUDE_ANCESTOR_MAX_FILES);
233
+ for (const { file } of recent) {
234
+ const cwd = claudeTranscriptCwd(file);
235
+ if (cwd && normalizeCompare(cwd) !== dirCwd) continue;
236
+ if (transcriptTouches(file, needle)) inherited.push(file);
237
+ }
238
+ }
239
+ return [...own, ...inherited];
240
+ }
241
+
242
+ function discoverCodex(workspacePath) {
243
+ const root = path.join(HOME, ".codex", "sessions");
244
+ if (!fs.existsSync(root)) return [];
245
+ const target = normalizeCompare(workspacePath);
246
+ const files = findJsonlFiles(root);
247
+ const matched = [];
248
+ for (const file of files) {
249
+ try {
250
+ const first = JSON.parse(readHead(file, 32000).split("\n")[0] || "{}");
251
+ if (first.type === "session_meta" && first.payload?.cwd && normalizeCompare(first.payload.cwd) === target) {
252
+ matched.push(file);
253
+ }
254
+ } catch {
255
+ /* skip */
256
+ }
257
+ }
258
+ return matched;
259
+ }
260
+
261
+ function copilotWorkspaceStorageRoots() {
262
+ return [
263
+ path.join(HOME, "AppData", "Roaming", "Code", "User", "workspaceStorage"),
264
+ path.join(HOME, ".config", "Code", "User", "workspaceStorage"),
265
+ path.join(HOME, "Library", "Application Support", "Code", "User", "workspaceStorage"),
266
+ ];
267
+ }
268
+
269
+ function discoverCopilot(workspacePath) {
270
+ const storage = discoverWorkspaceStorageDir(workspacePath, copilotWorkspaceStorageRoots());
271
+ if (!storage) return [];
272
+ const sessions = [];
273
+ const chatDir = path.join(storage, "chatSessions");
274
+ if (fs.existsSync(chatDir)) {
275
+ for (const entry of fs.readdirSync(chatDir, { withFileTypes: true })) {
276
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) sessions.push(path.join(chatDir, entry.name));
277
+ }
278
+ }
279
+ const transcriptsDir = path.join(storage, "GitHub.copilot-chat", "transcripts");
280
+ if (fs.existsSync(transcriptsDir)) {
281
+ for (const entry of fs.readdirSync(transcriptsDir, { withFileTypes: true })) {
282
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) sessions.push(path.join(transcriptsDir, entry.name));
283
+ }
284
+ }
285
+ return sessions;
286
+ }
287
+
288
+ // Antigravity keeps one directory per conversation under `brain/<uuid>/`, with
289
+ // the chat log at `.system_generated/logs/transcript.jsonl`. There is no
290
+ // workspace slug anywhere in the layout, so the only way to tell which
291
+ // conversations belong to this repo is to read the paths each transcript talks
292
+ // about.
293
+ function antigravityRoots() {
294
+ return [
295
+ path.join(HOME, ".gemini", "antigravity-ide", "brain"),
296
+ path.join(HOME, ".antigravity", "brain"),
297
+ path.join(HOME, "AppData", "Roaming", "Antigravity IDE", "brain"),
298
+ path.join(HOME, "Library", "Application Support", "Antigravity IDE", "brain"),
299
+ ].filter((dir) => fs.existsSync(dir));
300
+ }
301
+
302
+ // Scanning every conversation on every dashboard poll would mean re-reading tens
303
+ // of megabytes every 8 seconds. The set of paths a transcript mentions can only
304
+ // change when the file does, so key the cache on size+mtime.
305
+ const MENTION_CACHE_MAX = 400;
306
+ const mentionCache = new Map();
307
+
308
+ function mentionedPaths(file) {
309
+ let stat;
310
+ try {
311
+ stat = fs.statSync(file);
312
+ } catch {
313
+ return null;
314
+ }
315
+ const stamp = `${stat.size}:${stat.mtimeMs}`;
316
+ const hit = mentionCache.get(file);
317
+ if (hit && hit.stamp === stamp) return hit.paths;
318
+ let paths;
319
+ try {
320
+ paths = pathsMentionedIn(fs.readFileSync(file, "utf8"));
321
+ } catch {
322
+ return null;
323
+ }
324
+ if (mentionCache.size >= MENTION_CACHE_MAX) mentionCache.delete(mentionCache.keys().next().value);
325
+ mentionCache.set(file, { stamp, paths });
326
+ return paths;
327
+ }
328
+
329
+ // Walking every conversation directory and stat-sorting the result costs ~100ms.
330
+ // The dashboard polls each workspace every 8s and the transcript watcher fires
331
+ // on every keystroke Antigravity persists, so without a short cache this alone
332
+ // would keep a core busy.
333
+ const LISTING_TTL_MS = 3000;
334
+ let listingCache = { at: 0, files: [] };
335
+
336
+ function antigravityTranscripts() {
337
+ if (Date.now() - listingCache.at < LISTING_TTL_MS) return listingCache.files;
338
+ const rows = [];
339
+ for (const root of antigravityRoots()) {
340
+ for (const file of findJsonlFiles(root)) {
341
+ // `transcript_full.jsonl` is the same conversation with tool payloads
342
+ // inlined; parsing both would double every message.
343
+ if (path.basename(file) !== "transcript.jsonl") continue;
344
+ let mtimeMs = 0;
345
+ try {
346
+ mtimeMs = fs.statSync(file).mtimeMs;
347
+ } catch {
348
+ continue;
349
+ }
350
+ rows.push({ file, mtimeMs });
351
+ }
352
+ }
353
+ rows.sort((a, b) => b.mtimeMs - a.mtimeMs);
354
+ listingCache = { at: Date.now(), files: rows.map((r) => r.file) };
355
+ return listingCache.files;
356
+ }
357
+
358
+ const ANTIGRAVITY_MAX_SESSIONS = 12;
359
+
360
+ function discoverAntigravity(workspacePath) {
361
+ const needle = pathNeedle(workspacePath);
362
+ if (!needle) return [];
363
+ const matched = [];
364
+ for (const file of antigravityTranscripts()) {
365
+ const paths = mentionedPaths(file);
366
+ if (!paths) continue;
367
+ if (pathsCoverWorkspace(paths, needle)) matched.push(file);
368
+ if (matched.length >= ANTIGRAVITY_MAX_SESSIONS) break;
369
+ }
370
+ return matched;
371
+ }
372
+
373
+ function discoverForAgent(agent, workspacePath) {
374
+ if (agent === "Cursor") return discoverCursor(workspacePath);
375
+ if (agent === "Claude Code") return discoverClaude(workspacePath);
376
+ if (agent === "Codex") return discoverCodex(workspacePath);
377
+ if (agent === "Copilot") return discoverCopilot(workspacePath);
378
+ if (agent === "Antigravity") return discoverAntigravity(workspacePath);
379
+ return [];
380
+ }
381
+
382
+ // A Stop hook's `transcript_path` is a hint about the session that just
383
+ // finished, not a filter. Returning only that file — which is what every
384
+ // discoverX used to do — meant an agent's other sessions vanished from the
385
+ // dashboard the moment one of its sessions ended. Treat the hint as one more
386
+ // file to be sure of, on top of everything normal discovery finds.
387
+ function discoverAgentFiles(agent, workspacePath, hint = {}) {
388
+ const files = discoverForAgent(agent, workspacePath);
389
+ const hinted = hint.transcript_path;
390
+ if (hinted && !files.some((f) => path.resolve(f) === path.resolve(hinted))) {
391
+ try {
392
+ if (fs.existsSync(hinted)) files.unshift(hinted);
393
+ } catch {
394
+ /* unreadable hint — normal discovery still stands */
395
+ }
396
+ }
397
+ return files;
398
+ }
399
+
400
+ function discoverAll(workspacePath, hint = {}) {
401
+ const found = [];
402
+ for (const agent of AGENTS) {
403
+ const agentHint = hint.agent === agent ? hint : {};
404
+ for (const file of discoverAgentFiles(agent, workspacePath, agentHint)) {
405
+ found.push({ agent, file });
406
+ }
407
+ }
408
+ return found;
409
+ }
410
+
411
+ // Every agent whose transcripts we can find needs a root here, otherwise its
412
+ // chats only refresh on the dashboard's slow poll — which is what made
413
+ // Antigravity and Copilot look frozen while Cursor and Claude updated live.
414
+ function transcriptWatchRoots(workspacePath) {
415
+ const roots = [];
416
+ const cursorDir = discoverCursorDir(workspacePath);
417
+ if (cursorDir) roots.push(cursorDir);
418
+ const claudeDir = path.join(HOME, ".claude", "projects", claudeSlug(workspacePath));
419
+ if (fs.existsSync(claudeDir)) roots.push(claudeDir);
420
+ for (const { dir } of claudeAncestorDirs(workspacePath)) roots.push(dir);
421
+ const codexDir = path.join(HOME, ".codex", "sessions");
422
+ if (fs.existsSync(codexDir)) roots.push(codexDir);
423
+ roots.push(...antigravityRoots());
424
+ const copilotStorage = discoverWorkspaceStorageDir(workspacePath, copilotWorkspaceStorageRoots());
425
+ if (copilotStorage) roots.push(copilotStorage);
426
+ return [...new Set(roots)];
427
+ }
428
+
429
+ module.exports = {
430
+ AGENTS,
431
+ discoverAll,
432
+ discoverAgentFiles,
433
+ listCursorTranscriptFiles,
434
+ discoverCursorDir,
435
+ transcriptWatchRoots,
436
+ copilotWorkspaceStorageRoots,
437
+ };