relaybrain 0.0.0-stage → 0.2.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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +290 -2
  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/fixtures.js +85 -0
  25. package/backend/lib/falkor/index.js +146 -0
  26. package/backend/lib/falkor/ingest.js +233 -0
  27. package/backend/lib/falkor/intents.js +32 -0
  28. package/backend/lib/falkor/keys.js +102 -0
  29. package/backend/lib/falkor/migrations.js +56 -0
  30. package/backend/lib/falkor/project.js +307 -0
  31. package/backend/lib/falkor/query.js +344 -0
  32. package/backend/lib/falkor/redact.js +30 -0
  33. package/backend/lib/falkor/routes.js +81 -0
  34. package/backend/lib/falkor/schema.js +108 -0
  35. package/backend/lib/falkor/write.js +281 -0
  36. package/backend/lib/githubLogin.js +108 -0
  37. package/backend/lib/githubOAuth.js +42 -0
  38. package/backend/lib/githubRepo.js +117 -0
  39. package/backend/lib/harvest.js +85 -0
  40. package/backend/lib/httpServer.js +27 -0
  41. package/backend/lib/installAgentRelay.js +235 -0
  42. package/backend/lib/installHooks.js +309 -0
  43. package/backend/lib/lamport.js +14 -0
  44. package/backend/lib/localBrowse.js +109 -0
  45. package/backend/lib/memorySync.js +63 -0
  46. package/backend/lib/notices.js +143 -0
  47. package/backend/lib/ot.js +74 -0
  48. package/backend/lib/patches.js +192 -0
  49. package/backend/lib/paths.js +85 -0
  50. package/backend/lib/peerClash.js +64 -0
  51. package/backend/lib/presence.js +35 -0
  52. package/backend/lib/relayOsBlock.js +84 -0
  53. package/backend/lib/repoSnapshot.js +125 -0
  54. package/backend/lib/room.js +498 -0
  55. package/backend/lib/roomAsk.js +148 -0
  56. package/backend/lib/roomAuth.js +215 -0
  57. package/backend/lib/roomControl.js +226 -0
  58. package/backend/lib/roomLocks.js +201 -0
  59. package/backend/lib/roomSignal.js +265 -0
  60. package/backend/lib/roomSync.js +194 -0
  61. package/backend/lib/seed.js +107 -0
  62. package/backend/lib/sseHub.js +40 -0
  63. package/backend/lib/store.js +79 -0
  64. package/backend/lib/supergraph.js +46 -0
  65. package/backend/lib/transcripts/discover.js +338 -0
  66. package/backend/lib/transcripts/parse.js +558 -0
  67. package/backend/lib/transcripts/sessionId.js +36 -0
  68. package/backend/lib/transcripts/sync.js +291 -0
  69. package/backend/lib/transcripts/util.js +299 -0
  70. package/backend/lib/treeSitterImports.js +219 -0
  71. package/backend/lib/watchWorkspace.js +42 -0
  72. package/backend/mcp/README.md +193 -0
  73. package/backend/mcp/server.js +42 -0
  74. package/backend/mcp/tools.js +484 -0
  75. package/backend/server.js +3839 -0
  76. package/cli/graph.js +278 -0
  77. package/cli/relay.js +419 -0
  78. package/docker-compose.falkor.yml +17 -0
  79. package/docs/MEMORY-GRAPH.md +269 -0
  80. package/hooks/relay-antigravity-post-tool.js +2 -0
  81. package/hooks/relay-antigravity-pre-read.js +2 -0
  82. package/hooks/relay-antigravity-pre-tool.js +2 -0
  83. package/hooks/relay-antigravity-stop.js +2 -0
  84. package/hooks/relay-claude-post-tool.js +2 -0
  85. package/hooks/relay-claude-pre-read.js +2 -0
  86. package/hooks/relay-claude-pre-tool.js +2 -0
  87. package/hooks/relay-claude-stop.js +2 -0
  88. package/hooks/relay-codex-post-tool.js +2 -0
  89. package/hooks/relay-codex-pre-read.js +2 -0
  90. package/hooks/relay-codex-pre-tool.js +2 -0
  91. package/hooks/relay-codex-stop.js +2 -0
  92. package/hooks/relay-copilot-post-tool.js +2 -0
  93. package/hooks/relay-copilot-pre-read.js +2 -0
  94. package/hooks/relay-copilot-pre-tool.js +2 -0
  95. package/hooks/relay-copilot-stop.js +2 -0
  96. package/hooks/relay-cursor-post-tool.js +2 -0
  97. package/hooks/relay-cursor-pre-read.js +2 -0
  98. package/hooks/relay-cursor-pre-tool.js +2 -0
  99. package/hooks/relay-cursor-stop.js +2 -0
  100. package/hooks/relay-hook-lib.js +621 -0
  101. package/mission-control/out/404.html +1 -0
  102. package/mission-control/out/__next.__PAGE__.txt +26 -0
  103. package/mission-control/out/__next._full.txt +21 -0
  104. package/mission-control/out/__next._tree.txt +4 -0
  105. package/mission-control/out/_next/static/chunks/0-3l04fnym95-.js +22 -0
  106. package/mission-control/out/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  107. package/mission-control/out/_next/static/chunks/0ehjiuuxbbhq9.js +1 -0
  108. package/mission-control/out/_next/static/chunks/1q0cmt0kqufow.js +2 -0
  109. package/mission-control/out/_next/static/chunks/1ya1u0izct4f6.js +1 -0
  110. package/mission-control/out/_next/static/chunks/25rvg4bnew99f.css +4 -0
  111. package/mission-control/out/_next/static/chunks/2n66mlnb34vjq.js +1 -0
  112. package/mission-control/out/_next/static/chunks/2xar70jzx8f1y.css +1 -0
  113. package/mission-control/out/_next/static/chunks/31cgqj_-l15yt.js +31 -0
  114. package/mission-control/out/_next/static/chunks/3_5x4qn1_b4al.js +1 -0
  115. package/mission-control/out/_next/static/chunks/3o1tiwncjss_k.js +1 -0
  116. package/mission-control/out/_next/static/chunks/3vq9c4hivvgn_.js +1 -0
  117. package/mission-control/out/_next/static/chunks/turbopack-3hsllzpnzz12p.js +1 -0
  118. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_buildManifest.js +11 -0
  119. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_clientMiddlewareManifest.js +1 -0
  120. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_ssgManifest.js +1 -0
  121. package/mission-control/out/_next/static/media/icon.27otmh_fv3buh.svg +4 -0
  122. package/mission-control/out/_not-found/__next._full.txt +18 -0
  123. package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +24 -0
  124. package/mission-control/out/_not-found/__next._tree.txt +3 -0
  125. package/mission-control/out/_not-found.html +1 -0
  126. package/mission-control/out/_not-found.txt +18 -0
  127. package/mission-control/out/agents/antigravity.png +0 -0
  128. package/mission-control/out/agents/claude.png +0 -0
  129. package/mission-control/out/agents/codex.png +0 -0
  130. package/mission-control/out/agents/copilot.png +0 -0
  131. package/mission-control/out/agents/cursor.png +0 -0
  132. package/mission-control/out/icon.svg +4 -0
  133. package/mission-control/out/index.html +1 -0
  134. package/mission-control/out/index.txt +21 -0
  135. package/mission-control/out/logo.png +0 -0
  136. package/mission-control/out/logo_transparent.png +0 -0
  137. package/package.json +74 -4
  138. package/scripts/falkor-rebuild.js +57 -0
  139. package/scripts/falkor-seed-demo.js +157 -0
@@ -0,0 +1,291 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { loadMemory, saveMemory, id } = require("../store");
4
+ const { discoverAll, AGENTS } = require("./discover");
5
+ const { parseFile } = require("./parse");
6
+ const { eventId, normalizeWorkspaceRoot } = require("./util");
7
+
8
+ const HISTORY_CAP = 2000;
9
+ const MESSAGES_PER_THREAD = 300;
10
+ const inflight = new Map();
11
+
12
+ function unifyEvent(raw, agent, ownerLogin) {
13
+ const sessionId = raw.sessionId || "session";
14
+ return {
15
+ id: eventId([agent, ownerLogin, raw.transcriptPath, sessionId, raw.index, raw.role, raw.kind, raw.text]),
16
+ ts: raw.ts,
17
+ tsIso: new Date(raw.ts).toISOString(),
18
+ agent,
19
+ ownerLogin,
20
+ sessionId,
21
+ role: raw.role,
22
+ kind: raw.kind,
23
+ text: raw.text || "",
24
+ file: raw.file || null,
25
+ path: raw.path || null,
26
+ diff: raw.diff || "",
27
+ transcriptPath: raw.transcriptPath || null,
28
+ tsSynthetic: raw.tsSynthetic === true,
29
+ };
30
+ }
31
+
32
+ function compareEvents(a, b) {
33
+ if (a.ts !== b.ts) return a.ts - b.ts;
34
+ if (a.kind !== b.kind) return a.kind === "message" ? -1 : 1;
35
+ if (a.role === "user" && b.role !== "user") return -1;
36
+ if (a.role !== "user" && b.role === "user") return 1;
37
+ return String(a.agent).localeCompare(String(b.agent));
38
+ }
39
+
40
+ function threadKey(event) {
41
+ return `${event.ownerLogin}::${event.agent}::${event.sessionId}`;
42
+ }
43
+
44
+ // Edits carry the absolute path the agent used. Returns the workspace-relative
45
+ // path, or null when the edit landed outside this workspace entirely — an agent
46
+ // touching a temp file or a sibling repo is not this workspace's activity, and
47
+ // showing it as such is what makes one project's feed look like another's.
48
+ function relativeEditPath(event, workspacePath) {
49
+ const abs = event.path || event.file || "";
50
+ if (!abs) return null;
51
+ if (!workspacePath || !path.isAbsolute(abs)) return abs.replace(/\\/g, "/");
52
+ const rel = path.relative(workspacePath, abs).replace(/\\/g, "/");
53
+ if (!rel || rel.startsWith("../") || rel === ".." || path.isAbsolute(rel)) return null;
54
+ return rel;
55
+ }
56
+
57
+ function buildViews(history, ownerLogin, workspacePath) {
58
+ const threads = new Map();
59
+ const edits = [];
60
+ const activity = [];
61
+ const timeline = [];
62
+
63
+ for (const event of history) {
64
+ const editPath = event.kind === "code_edit" ? relativeEditPath(event, workspacePath) : null;
65
+ if (event.kind === "code_edit" && !editPath) continue;
66
+ const key = threadKey(event);
67
+ if (!threads.has(key)) {
68
+ threads.set(key, {
69
+ id: `chat_${event.agent.replace(/\s+/g, "-").toLowerCase()}_${event.sessionId}`.slice(0, 80),
70
+ agent: event.agent,
71
+ ownerLogin: event.ownerLogin,
72
+ mine: event.ownerLogin === ownerLogin,
73
+ sessionId: event.sessionId,
74
+ updatedAt: event.ts,
75
+ messages: [],
76
+ });
77
+ }
78
+ const thread = threads.get(key);
79
+ thread.updatedAt = Math.max(thread.updatedAt, event.ts);
80
+ if (event.kind === "message" || event.kind === "code_edit") {
81
+ thread.messages.push({
82
+ role: event.role,
83
+ text: event.text,
84
+ ts: event.ts,
85
+ kind: event.kind,
86
+ file: event.file || undefined,
87
+ });
88
+ }
89
+ if (event.kind === "code_edit") {
90
+ edits.push({
91
+ id: `e_${event.id}`,
92
+ agent: event.agent,
93
+ ownerLogin: event.ownerLogin,
94
+ mine: event.ownerLogin === ownerLogin,
95
+ file: editPath,
96
+ ts: event.ts,
97
+ diff: event.diff || "",
98
+ });
99
+ activity.push({
100
+ id: `a_${event.id}`,
101
+ kind: "edit",
102
+ agent: event.agent,
103
+ ownerLogin: event.ownerLogin,
104
+ mine: event.ownerLogin === ownerLogin,
105
+ text: event.text || `Edited ${event.file}`,
106
+ ts: event.ts,
107
+ });
108
+ }
109
+ }
110
+
111
+ const chats = [...threads.values()]
112
+ .map((thread) => ({
113
+ ...thread,
114
+ messages: thread.messages.slice(-MESSAGES_PER_THREAD),
115
+ }))
116
+ .sort((a, b) => b.updatedAt - a.updatedAt);
117
+
118
+ for (const thread of chats) {
119
+ timeline.push({
120
+ id: `seg_${thread.id}`,
121
+ agent: thread.agent,
122
+ ownerLogin: thread.ownerLogin,
123
+ mine: thread.mine,
124
+ ts: thread.updatedAt,
125
+ title: `${thread.agent} · ${thread.messages.length} events`,
126
+ events: thread.messages.map((m) => ({
127
+ type: m.kind === "code_edit" ? "edit" : m.role,
128
+ text: m.text,
129
+ file: m.file,
130
+ })),
131
+ });
132
+ }
133
+ timeline.sort((a, b) => b.ts - a.ts);
134
+
135
+ edits.sort((a, b) => b.ts - a.ts);
136
+ activity.sort((a, b) => b.ts - a.ts);
137
+
138
+ return { chats, timeline, edits: edits.slice(0, 400), activity: activity.slice(0, 80) };
139
+ }
140
+
141
+ function markAgents(memory, history, ownerLogin) {
142
+ const latest = new Map();
143
+ for (const event of history) {
144
+ const prev = latest.get(event.agent);
145
+ if (!prev || event.ts > prev) latest.set(event.agent, event.ts);
146
+ }
147
+ const agents = (memory.agents || []).map((row) => {
148
+ const ts = latest.get(row.label);
149
+ const stamped = ownerLogin && !row.ownerLogin ? { ...row, ownerLogin } : row;
150
+ if (!ts) return stamped;
151
+ return { ...stamped, status: "connected", lastActiveAt: ts };
152
+ });
153
+ for (const label of AGENTS) {
154
+ if (!agents.some((a) => a.label === label && (!a.ownerLogin || a.ownerLogin === ownerLogin)) && latest.has(label)) {
155
+ agents.push({
156
+ id: label.toLowerCase().replace(/\s+/g, "-"),
157
+ label,
158
+ status: "connected",
159
+ lastActiveAt: latest.get(label),
160
+ ownerLogin: ownerLogin || undefined,
161
+ });
162
+ }
163
+ }
164
+ return agents;
165
+ }
166
+
167
+ function syncTranscripts(project, opts = {}) {
168
+ const ownerLogin = opts.ownerLogin || "local";
169
+ const workspacePath = normalizeWorkspaceRoot(project.path) || project.path;
170
+ const found = discoverAll(workspacePath, opts.hint || {});
171
+ const existing = loadMemory(project.id);
172
+ if (!found.length && ((existing.history || []).length || (existing.chats || []).length)) {
173
+ return existing;
174
+ }
175
+ const history = [];
176
+ const seen = new Set();
177
+ // Which agents actually produced readable transcripts this pass. An agent
178
+ // missing from this set was not refreshed — that is very different from
179
+ // "this agent has nothing to say", and the two must not be conflated below.
180
+ const refreshed = new Set();
181
+ let failed = 0;
182
+
183
+ for (const { agent, file } of found) {
184
+ let parsed = [];
185
+ try {
186
+ parsed = parseFile(agent, file);
187
+ } catch (err) {
188
+ // Swallowing this silently is how a parser crash turns into an empty
189
+ // Activity/Chats panel with no way to tell that from "nothing happened".
190
+ failed += 1;
191
+ console.warn(`[relay-transcripts] ${agent} parse failed for ${file}: ${err.message || err}`);
192
+ continue;
193
+ }
194
+ refreshed.add(agent);
195
+ for (const raw of parsed) {
196
+ const event = unifyEvent(raw, agent, ownerLogin);
197
+ if (seen.has(event.id)) continue;
198
+ seen.add(event.id);
199
+ history.push(event);
200
+ }
201
+ }
202
+
203
+ // Every discovered transcript blew up: keep whatever was ingested before and
204
+ // leave lastTranscriptSyncAt alone so the next poll retries instead of
205
+ // treating the empty result as the current truth.
206
+ if (failed && failed === found.length) {
207
+ console.warn(`[relay-transcripts] ${project.id}: all ${failed} transcript(s) failed to parse — keeping previous history`);
208
+ return existing;
209
+ }
210
+
211
+ // History used to be rebuilt from scratch on every pass, so an agent that was
212
+ // momentarily undiscoverable — its session directory not created yet, a
213
+ // rotated transcript, a hint that narrowed discovery to one other agent's
214
+ // file, a parse error — was erased from the dashboard even though nothing had
215
+ // happened to its conversation. That is what made a finished Claude session
216
+ // disappear the moment Cursor's transcript was re-read. Carry forward every
217
+ // event belonging to an agent we did not successfully re-read this pass.
218
+ let carried = 0;
219
+ for (const event of existing.history || []) {
220
+ if (refreshed.has(event.agent)) continue;
221
+ if (seen.has(event.id)) continue;
222
+ seen.add(event.id);
223
+ history.push(event);
224
+ carried += 1;
225
+ }
226
+ if (carried) {
227
+ const kept = [...new Set((existing.history || []).filter((e) => !refreshed.has(e.agent)).map((e) => e.agent))];
228
+ console.log(`[relay-transcripts] ${project.id}: kept ${carried} event(s) from ${kept.join(", ")} (not re-read this pass)`);
229
+ }
230
+
231
+ history.sort(compareEvents);
232
+ const capped = history.slice(-HISTORY_CAP);
233
+ const views = buildViews(capped, ownerLogin, workspacePath);
234
+ const memory = existing;
235
+ memory.history = capped;
236
+ memory.chats = views.chats;
237
+ memory.timeline = views.timeline;
238
+ memory.edits = views.edits;
239
+ memory.activity = views.activity;
240
+ memory.agents = markAgents(memory, capped, ownerLogin);
241
+ memory.stats = memory.stats || {};
242
+ memory.stats.events = capped.length;
243
+ memory.stats.patches = views.edits.length;
244
+ memory.stats.agents = new Set(capped.map((e) => e.agent)).size;
245
+ memory.lastTranscriptSyncAt = Date.now();
246
+ if (memory.ir) delete memory.ir;
247
+ saveMemory(project.id, memory);
248
+ require("../falkor/bus").emit("history", { project, workspacePath, memory });
249
+ return memory;
250
+ }
251
+
252
+ function syncTranscriptsQueued(project, opts = {}) {
253
+ const key = project.id;
254
+ if (inflight.has(key)) return inflight.get(key);
255
+ const pending = Promise.resolve()
256
+ .then(() => syncTranscripts(project, opts))
257
+ .finally(() => inflight.delete(key));
258
+ inflight.set(key, pending);
259
+ return pending;
260
+ }
261
+
262
+ function latestTranscriptMtime(workspacePath) {
263
+ let max = 0;
264
+ for (const { file } of discoverAll(workspacePath)) {
265
+ try {
266
+ max = Math.max(max, fs.statSync(file).mtimeMs);
267
+ } catch {
268
+ /* skip */
269
+ }
270
+ }
271
+ return max;
272
+ }
273
+
274
+ function syncIfStale(project, opts = {}) {
275
+ const workspacePath = normalizeWorkspaceRoot(project.path) || project.path;
276
+ const existing = loadMemory(project.id);
277
+ const mtime = latestTranscriptMtime(workspacePath);
278
+ if (!mtime || mtime <= (existing.lastTranscriptSyncAt || 0)) return Promise.resolve(existing);
279
+ return syncTranscriptsQueued(project, opts);
280
+ }
281
+
282
+ module.exports = {
283
+ syncTranscripts,
284
+ syncTranscriptsQueued,
285
+ syncIfStale,
286
+ latestTranscriptMtime,
287
+ unifyEvent,
288
+ compareEvents,
289
+ relativeEditPath,
290
+ id,
291
+ };
@@ -0,0 +1,299 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+ const crypto = require("crypto");
5
+
6
+ const HOME = os.homedir();
7
+
8
+ function normalizeWorkspaceRoot(root) {
9
+ if (!root || typeof root !== "string") return null;
10
+ let trimmed = root.trim();
11
+ if (!trimmed) return null;
12
+ // file:///C:/Users/... and Git Bash /C:/Users or /C:\Users become a real
13
+ // Windows path. path.resolve("/C:/Users") on win32 is C:\C:\Users, which
14
+ // then fails mkdir with ENOENT.
15
+ trimmed = trimmed.replace(/^file:\/+/i, "/");
16
+ const drive = trimmed.match(/^\/?([a-zA-Z]):([\\/].*)$/);
17
+ if (drive) trimmed = `${drive[1]}:${drive[2]}`;
18
+ const doubled = trimmed.match(/^([a-zA-Z]):[\\/]+\1:[\\/]+(.*)$/i);
19
+ if (doubled) trimmed = `${doubled[1]}:\\${doubled[2]}`;
20
+ try {
21
+ return path.resolve(trimmed);
22
+ } catch {
23
+ return trimmed;
24
+ }
25
+ }
26
+
27
+ function normalizeCompare(p) {
28
+ return String(p || "")
29
+ .toLowerCase()
30
+ .replace(/\\/g, "/")
31
+ .replace(/\/$/, "");
32
+ }
33
+
34
+ // A transcript embeds the same workspace path in three encodings, often within
35
+ // one file: JSON-escaped (`c:\\Users\\me`), URL form (`file:///c:/Users/me%20x`),
36
+ // and plain. normalizeCompare handles only the last — it turns `\\` into `//`
37
+ // and never decodes `%20` — so a substring search for the workspace root misses
38
+ // every Antigravity transcript and the agent looks like it has no history at all.
39
+ function pathHaystack(text) {
40
+ let s = String(text || "");
41
+ if (s.includes("%")) {
42
+ s = s.replace(/%[0-9a-fA-F]{2}/g, (m) => {
43
+ try {
44
+ return decodeURIComponent(m);
45
+ } catch {
46
+ return m;
47
+ }
48
+ });
49
+ }
50
+ return s.toLowerCase().replace(/\\/g, "/").replace(/\/{2,}/g, "/");
51
+ }
52
+
53
+ function pathNeedle(p) {
54
+ return pathHaystack(p).replace(/\/+$/, "");
55
+ }
56
+
57
+ const WIN_PATH_RE = /(?:file:\/\/\/)?[a-zA-Z]:(?:\\{1,2}|\/)[^\s"'`,;)\]}<>|*?]*/g;
58
+ const POSIX_PATH_RE = /(?:file:\/\/)?\/(?:users|home|workspace|repos|projects|var|opt|srv)\/[^\s"'`,;)\]}<>|*?]*/gi;
59
+
60
+ // Collects every absolute path the text mentions, normalized. Matching on these
61
+ // rather than a raw `includes(root)` keeps `.../relay/test` from claiming the
62
+ // transcripts of `.../relay/test-harness`.
63
+ function pathsMentionedIn(text) {
64
+ const out = new Set();
65
+ const source = String(text || "");
66
+ for (const re of [WIN_PATH_RE, POSIX_PATH_RE]) {
67
+ re.lastIndex = 0;
68
+ let match;
69
+ while ((match = re.exec(source))) {
70
+ const norm = pathNeedle(match[0].replace(/^file:\/+/i, "/").replace(/^\/([a-zA-Z]:)/, "$1"));
71
+ if (norm.length > 3) out.add(norm);
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ function pathsCoverWorkspace(paths, needle) {
78
+ if (!needle) return false;
79
+ for (const candidate of paths) {
80
+ if (candidate === needle || candidate.startsWith(`${needle}/`)) return true;
81
+ }
82
+ return false;
83
+ }
84
+
85
+ function cursorSlug(workspacePath) {
86
+ const norm = String(workspacePath || "")
87
+ .replace(/\\/g, "/")
88
+ .replace(/\/$/, "");
89
+ const driveMatch = norm.match(/^([a-zA-Z]):\/?(.*)$/);
90
+ if (driveMatch) {
91
+ const tail = driveMatch[2]
92
+ .replace(/[^a-zA-Z0-9]/g, "-")
93
+ .replace(/-+/g, "-")
94
+ .replace(/^-|-$/g, "");
95
+ return `${driveMatch[1].toLowerCase()}-${tail}`;
96
+ }
97
+ let slug = norm.replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
98
+ if (slug.charAt(1) === "-") slug = slug.charAt(0).toLowerCase() + slug.slice(1);
99
+ return slug;
100
+ }
101
+
102
+ function claudeSlug(workspacePath) {
103
+ let slug = String(workspacePath || "").replace(/[^a-zA-Z0-9]/g, "-");
104
+ if (slug.charAt(1) === "-") slug = slug.charAt(0).toLowerCase() + slug.slice(1);
105
+ return slug;
106
+ }
107
+
108
+ function readJsonl(file) {
109
+ if (!file || !fs.existsSync(file)) return [];
110
+ return fs
111
+ .readFileSync(file, "utf8")
112
+ .replace(/^\uFEFF/, "")
113
+ .split("\n")
114
+ .filter(Boolean)
115
+ .map((line) => {
116
+ try {
117
+ return JSON.parse(line);
118
+ } catch {
119
+ return null;
120
+ }
121
+ })
122
+ .filter(Boolean);
123
+ }
124
+
125
+ function findJsonlFiles(dir, results = [], depth = 0) {
126
+ if (!dir || depth > 8 || !fs.existsSync(dir)) return results;
127
+ let entries = [];
128
+ try {
129
+ entries = fs.readdirSync(dir, { withFileTypes: true });
130
+ } catch {
131
+ return results;
132
+ }
133
+ for (const entry of entries) {
134
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
135
+ const full = path.join(dir, entry.name);
136
+ if (entry.isDirectory()) findJsonlFiles(full, results, depth + 1);
137
+ else if (entry.name.endsWith(".jsonl")) results.push(full);
138
+ }
139
+ return results;
140
+ }
141
+
142
+ function mostRecentFile(files) {
143
+ if (!files.length) return null;
144
+ return [...files].sort((a, b) => {
145
+ try {
146
+ return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
147
+ } catch {
148
+ return 0;
149
+ }
150
+ })[0];
151
+ }
152
+
153
+ function fileMtime(file) {
154
+ try {
155
+ return fs.statSync(file).mtimeMs;
156
+ } catch {
157
+ return Date.now();
158
+ }
159
+ }
160
+
161
+ /** A timestamp the transcript actually recorded, or null (so callers can say theirs is a guess). */
162
+ function realTs(value) {
163
+ if (value == null || value === "") return null;
164
+ if (typeof value === "number") return Number.isFinite(value) ? (value < 1e12 ? value * 1000 : value) : null;
165
+ const parsed = Date.parse(String(value));
166
+ return Number.isNaN(parsed) ? null : parsed;
167
+ }
168
+
169
+ /**
170
+ * Cursor rows carry no timestamps, but newer Cursor builds prefix each user message with
171
+ * <timestamp>Saturday, Aug 22, 2026, 3:41 PM (UTC+5:30)</timestamp>. Minute precision.
172
+ */
173
+ function cursorStampTs(text) {
174
+ const m = String(text || "").match(/<timestamp>\s*([^<]+?)\s*<\/timestamp>/i);
175
+ if (!m) return null;
176
+ const cleaned = m[1]
177
+ .replace(/^[A-Za-z]+,\s*/, "")
178
+ .replace(/\s*\(UTC\s*([+-])?(\d{1,2})(?::?(\d{2}))?\)\s*$/i, (_, sign, h, mm) => ` GMT${sign || "+"}${h.padStart(2, "0")}${mm || "00"}`)
179
+ .replace(/\s*\(UTC\)\s*$/i, " GMT");
180
+ const parsed = Date.parse(cleaned);
181
+ return Number.isNaN(parsed) ? null : parsed;
182
+ }
183
+
184
+ function toTs(value, fallback = Date.now()) {
185
+ if (value == null || value === "") return fallback;
186
+ if (typeof value === "number" && Number.isFinite(value)) {
187
+ return value < 1e12 ? value * 1000 : value;
188
+ }
189
+ const parsed = Date.parse(String(value));
190
+ return Number.isNaN(parsed) ? fallback : parsed;
191
+ }
192
+
193
+ function eventId(parts) {
194
+ return crypto.createHash("sha1").update(parts.filter(Boolean).join("|")).digest("hex").slice(0, 16);
195
+ }
196
+
197
+ function clip(text, max = 8000) {
198
+ const s = String(text || "");
199
+ return s.length > max ? `${s.slice(0, max)}\n…` : s;
200
+ }
201
+
202
+ function isRedacted(text) {
203
+ const s = String(text || "").trim();
204
+ if (!s) return true;
205
+ if (s === "[REDACTED]") return true;
206
+ return !s.replace(/\[REDACTED\]/gi, "").trim() && /\[REDACTED\]/i.test(s);
207
+ }
208
+
209
+ function extractText(value) {
210
+ if (value == null) return "";
211
+ if (typeof value === "string") return value.trim();
212
+ if (Array.isArray(value)) {
213
+ return value
214
+ .map((part) => {
215
+ if (typeof part === "string") return part;
216
+ // tool_result / tool_use blocks carry a `content` field holding raw tool
217
+ // output. Reading it as message text makes stdout show up in the chat
218
+ // attributed to whoever sent the enclosing turn.
219
+ if (part?.type === "tool_result" || part?.type === "tool_use") return "";
220
+ if (part?.type === "text" && typeof part.text === "string") return part.text;
221
+ if (typeof part?.text === "string") return part.text;
222
+ if (typeof part?.content === "string") return part.content;
223
+ return "";
224
+ })
225
+ .filter(Boolean)
226
+ .join("\n")
227
+ .trim();
228
+ }
229
+ if (typeof value === "object") {
230
+ if (typeof value.text === "string") return value.text.trim();
231
+ if (typeof value.content === "string") return value.content.trim();
232
+ }
233
+ return String(value).trim();
234
+ }
235
+
236
+ function makeUnifiedDiff(oldStr, newStr, filePath) {
237
+ const oldLines = String(oldStr ?? "").split("\n");
238
+ const newLines = String(newStr ?? "").split("\n");
239
+ const name = filePath || "file";
240
+ return [
241
+ `--- a/${name}`,
242
+ `+++ b/${name}`,
243
+ `@@ -1,${oldLines.length} +1,${newLines.length} @@`,
244
+ ...oldLines.map((line) => `-${line}`),
245
+ ...newLines.map((line) => `+${line}`),
246
+ ].join("\n");
247
+ }
248
+
249
+ function discoverWorkspaceStorageDir(workspacePath, roots) {
250
+ const target = normalizeCompare(workspacePath);
251
+ for (const root of roots) {
252
+ if (!fs.existsSync(root)) continue;
253
+ let entries = [];
254
+ try {
255
+ entries = fs.readdirSync(root, { withFileTypes: true });
256
+ } catch {
257
+ continue;
258
+ }
259
+ for (const entry of entries) {
260
+ if (!entry.isDirectory()) continue;
261
+ const workspaceJson = path.join(root, entry.name, "workspace.json");
262
+ if (!fs.existsSync(workspaceJson)) continue;
263
+ try {
264
+ const { folder } = JSON.parse(fs.readFileSync(workspaceJson, "utf8"));
265
+ if (!folder || !String(folder).startsWith("file:///")) continue;
266
+ const decoded = decodeURIComponent(String(folder).replace("file:///", "")).replace(/\//g, path.sep);
267
+ if (normalizeCompare(decoded) === target) return path.join(root, entry.name);
268
+ } catch {
269
+ /* skip */
270
+ }
271
+ }
272
+ }
273
+ return null;
274
+ }
275
+
276
+ module.exports = {
277
+ HOME,
278
+ normalizeWorkspaceRoot,
279
+ normalizeCompare,
280
+ pathHaystack,
281
+ pathNeedle,
282
+ pathsMentionedIn,
283
+ pathsCoverWorkspace,
284
+ cursorSlug,
285
+ claudeSlug,
286
+ readJsonl,
287
+ findJsonlFiles,
288
+ mostRecentFile,
289
+ fileMtime,
290
+ toTs,
291
+ realTs,
292
+ cursorStampTs,
293
+ eventId,
294
+ clip,
295
+ isRedacted,
296
+ extractText,
297
+ makeUnifiedDiff,
298
+ discoverWorkspaceStorageDir,
299
+ };