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,558 @@
1
+ const path = require("path");
2
+ const {
3
+ readJsonl,
4
+ extractText,
5
+ isRedacted,
6
+ clip,
7
+ toTs,
8
+ realTs,
9
+ cursorStampTs,
10
+ fileMtime,
11
+ makeUnifiedDiff,
12
+ } = require("./util");
13
+
14
+ const { EDIT_TOOL_SETS } = require("../editMatchers");
15
+ const { sessionIdFromTranscript } = require("./sessionId");
16
+ const CURSOR_EDIT_TOOLS = EDIT_TOOL_SETS.cursor;
17
+ const CLAUDE_EDIT_TOOLS = EDIT_TOOL_SETS.claude;
18
+ const CODEX_EDIT_TOOLS = EDIT_TOOL_SETS.codex;
19
+
20
+ function userQuery(text) {
21
+ const match = String(text || "").match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/i);
22
+ return match ? match[1].trim() : text;
23
+ }
24
+
25
+ function userRequest(text) {
26
+ const match = String(text || "").match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/i)
27
+ || String(text || "").match(/<userRequest>\s*([\s\S]*?)\s*<\/userRequest>/i);
28
+ return match ? match[1].trim() : text;
29
+ }
30
+
31
+ function splitThinking(content) {
32
+ const thinkingBits = [];
33
+ const textBits = [];
34
+ const parts = Array.isArray(content) ? content : content ? [{ type: "text", text: extractText(content) }] : [];
35
+ for (const part of parts) {
36
+ if (!part || typeof part !== "object") continue;
37
+ if (part.type === "thinking" || part.type === "reasoning" || part.type === "thought") {
38
+ const bit = part.thinking || part.text || part.content || "";
39
+ if (bit) thinkingBits.push(String(bit));
40
+ continue;
41
+ }
42
+ if (part.type === "text" && part.text) textBits.push(part.text);
43
+ }
44
+ let text = textBits.join("\n").trim();
45
+ text = text.replace(/<(thinking|think)>[\s\S]*?<\/\1>/gi, (block, tag) => {
46
+ thinkingBits.push(block.replace(new RegExp(`</?${tag}>`, "gi"), "").trim());
47
+ return "";
48
+ }).trim();
49
+ return { text, thinking: thinkingBits.filter(Boolean).join("\n\n") };
50
+ }
51
+
52
+ function sessionIdFromFile(file) {
53
+ return path.basename(file, ".jsonl");
54
+ }
55
+
56
+ // Claude Code replays slash commands and their output back through the user
57
+ // role (`/login`, its stdout, hook feedback). They are CLI plumbing, not
58
+ // something the human said, so they should not appear in a chat transcript.
59
+ const LOCAL_COMMAND_TAGS = /^<\/?(command-name|command-message|command-args|local-command-stdout|local-command-stderr|command-contents)>/i;
60
+
61
+ function isLocalCommandEcho(text) {
62
+ const trimmed = String(text || "").trim();
63
+ if (!trimmed) return false;
64
+ if (!trimmed.startsWith("<")) return false;
65
+ return LOCAL_COMMAND_TAGS.test(trimmed);
66
+ }
67
+
68
+ function parseCursor(file) {
69
+ const rows = readJsonl(file);
70
+ const base = fileMtime(file);
71
+ const sessionId = sessionIdFromFile(file);
72
+ const events = [];
73
+ const real = new Map();
74
+ // Rows have no timestamps; a user turn's <timestamp> anchors it and the rows that follow.
75
+ let anchor = null;
76
+ rows.forEach((step, index) => {
77
+ const role = step.role || step.message?.role;
78
+ const content = step.message?.content;
79
+ const own = realTs(step.timestamp || step.ts);
80
+ const stamp = role === "user" ? cursorStampTs(extractText(content)) : null;
81
+ if (stamp != null) anchor = { ts: stamp, index };
82
+ const ts = own ?? (anchor ? anchor.ts + (index - anchor.index) * 1000 : base + index * 1000);
83
+ real.set(index, own != null || anchor != null);
84
+ if (role === "user") {
85
+ const text = userQuery(extractText(content));
86
+ if (!isRedacted(text)) events.push({ ts, role: "user", kind: "message", text, sessionId, index });
87
+ return;
88
+ }
89
+ if (role === "assistant") {
90
+ const { text, thinking } = splitThinking(content);
91
+ if (thinking && !isRedacted(thinking)) {
92
+ events.push({ ts, role: "assistant", kind: "thinking", text: thinking, sessionId, index });
93
+ }
94
+ if (!isRedacted(text)) events.push({ ts, role: "assistant", kind: "message", text, sessionId, index });
95
+ const tools = Array.isArray(content) ? content.filter((p) => p?.type === "tool_use") : [];
96
+ for (const tool of tools) {
97
+ if (!CURSOR_EDIT_TOOLS.has(tool.name)) continue;
98
+ const input = tool.input || {};
99
+ const filePath = input.path || input.file_path || input.target_notebook || input.target_file;
100
+ if (!filePath) continue;
101
+ let diff = "";
102
+ if (input.old_string != null && input.new_string != null) {
103
+ diff = makeUnifiedDiff(input.old_string, input.new_string, path.basename(filePath));
104
+ } else if (input.contents != null) {
105
+ diff = makeUnifiedDiff("", input.contents, path.basename(filePath));
106
+ }
107
+ events.push({
108
+ ts,
109
+ role: "tool",
110
+ kind: "code_edit",
111
+ text: `${tool.name} ${path.basename(filePath)}`,
112
+ file: path.basename(filePath),
113
+ path: filePath,
114
+ diff,
115
+ sessionId,
116
+ index,
117
+ });
118
+ }
119
+ }
120
+ });
121
+ return markReal(events, real);
122
+ }
123
+
124
+ /** Tags each event with whether its row's time was recorded (true) or estimated (false). */
125
+ function markReal(events, real) {
126
+ for (const e of events) e.tsSynthetic = !real.get(e.index);
127
+ return events;
128
+ }
129
+
130
+ // A Claude Code `Edit`/`Write`/`MultiEdit` tool_use carries its change in one of
131
+ // three shapes. Flatten them into one diff per touched file so the timeline does
132
+ // not silently drop MultiEdit (an `edits` array) or Write (`content`, not
133
+ // `contents` — the key the Cursor payload uses).
134
+ function claudeEditDiffs(name, input) {
135
+ const filePath = input.file_path || input.path || input.target_file;
136
+ if (!filePath) return [];
137
+ const label = path.basename(filePath);
138
+ if (Array.isArray(input.edits) && input.edits.length) {
139
+ const diff = input.edits
140
+ .map((edit) => makeUnifiedDiff(edit.old_string ?? "", edit.new_string ?? "", label))
141
+ .join("\n");
142
+ return [{ filePath, label, diff }];
143
+ }
144
+ if (input.old_string != null || input.new_string != null) {
145
+ return [{ filePath, label, diff: makeUnifiedDiff(input.old_string ?? "", input.new_string ?? "", label) }];
146
+ }
147
+ const written = input.content ?? input.contents;
148
+ if (written != null) {
149
+ return [{ filePath, label, diff: makeUnifiedDiff("", written, label) }];
150
+ }
151
+ return [{ filePath, label, diff: "" }];
152
+ }
153
+
154
+ // Claude Code writes one JSONL row per transcript entry, but only `user` and
155
+ // `assistant` rows are conversation. The rest (`attachment`, `system`, `mode`,
156
+ // `file-history-snapshot`, `ai-title`, …) are UI bookkeeping, and `isMeta` user
157
+ // rows are the local-command caveat the CLI injects — none of it belongs in a
158
+ // chat view. Tool output also arrives as a `user` row whose content is a
159
+ // `tool_result` block; without the filter below it renders as if the human
160
+ // typed the contents of stdout.
161
+ function parseClaude(file) {
162
+ const rows = readJsonl(file);
163
+ const fallbackSession = sessionIdFromFile(file);
164
+ const events = [];
165
+ const real = new Map();
166
+ rows.forEach((step, index) => {
167
+ if (step.type !== "user" && step.type !== "assistant") return;
168
+ if (step.isMeta) return;
169
+ const sessionId = step.sessionId || fallbackSession;
170
+ const ts = toTs(step.timestamp, fileMtime(file) + index * 1000);
171
+ real.set(index, realTs(step.timestamp) != null);
172
+ const content = step.message?.content;
173
+ const parts = Array.isArray(content) ? content : [];
174
+
175
+ if (step.type === "user") {
176
+ if (parts.some((part) => part?.type === "tool_result")) return;
177
+ const text = extractText(content);
178
+ if (isLocalCommandEcho(text)) return;
179
+ if (!isRedacted(text)) events.push({ ts, role: "user", kind: "message", text, sessionId, index });
180
+ return;
181
+ }
182
+
183
+ const { text, thinking } = splitThinking(content);
184
+ if (thinking && !isRedacted(thinking)) {
185
+ events.push({ ts, role: "assistant", kind: "thinking", text: thinking, sessionId, index });
186
+ }
187
+ if (!isRedacted(text)) events.push({ ts, role: "assistant", kind: "message", text, sessionId, index });
188
+ for (const part of parts) {
189
+ if (!part || part.type !== "tool_use" || !CLAUDE_EDIT_TOOLS.has(part.name)) continue;
190
+ for (const edit of claudeEditDiffs(part.name, part.input || {})) {
191
+ events.push({
192
+ ts,
193
+ role: "tool",
194
+ kind: "code_edit",
195
+ text: `${part.name} ${edit.label}`,
196
+ file: edit.label,
197
+ path: edit.filePath,
198
+ diff: edit.diff,
199
+ sessionId,
200
+ index,
201
+ });
202
+ }
203
+ }
204
+ });
205
+ return markReal(events, real);
206
+ }
207
+
208
+ function parseCodex(file) {
209
+ const rows = readJsonl(file);
210
+ // Hooks send the thread id as session_id; the rollout filename only ends with it.
211
+ const meta = rows.find((r) => r?.type === "session_meta")?.payload?.id;
212
+ const sessionId = typeof meta === "string" && meta ? meta : sessionIdFromTranscript("Codex", file);
213
+ const events = [];
214
+ const real = new Map();
215
+ rows.forEach((step, index) => {
216
+ const ts = toTs(step.timestamp, fileMtime(file) + index * 1000);
217
+ real.set(index, realTs(step.timestamp) != null);
218
+ if (step.type === "event_msg") {
219
+ const p = step.payload || {};
220
+ if (p.type === "user_message" && !isRedacted(p.message)) {
221
+ events.push({ ts, role: "user", kind: "message", text: p.message, sessionId, index });
222
+ } else if (p.type === "agent_message" && !isRedacted(p.message)) {
223
+ events.push({ ts, role: "assistant", kind: "message", text: p.message, sessionId, index });
224
+ }
225
+ return;
226
+ }
227
+ // Codex sends apply_patch as a custom tool call whose `input` is the raw patch;
228
+ // older builds used a function_call. Either way the files are inside the patch text.
229
+ const call = step.type === "response_item" ? step.payload : null;
230
+ if (call && (call.type === "function_call" || call.type === "custom_tool_call")) {
231
+ const name = call.name || "";
232
+ if (!CODEX_EDIT_TOOLS.has(name)) return;
233
+ let args = {};
234
+ if (call.type === "custom_tool_call") {
235
+ args = { input: typeof call.input === "string" ? call.input : "" };
236
+ } else {
237
+ try {
238
+ args = JSON.parse(call.arguments || "{}");
239
+ } catch {
240
+ args = {};
241
+ }
242
+ }
243
+ const patchText = [args.input, args.patch, Array.isArray(args.command) ? args.command.join("\n") : args.command].find(
244
+ (v) => typeof v === "string" && v.includes("*** ")
245
+ );
246
+ const sections = patchText ? patchSections(patchText) : [];
247
+ if (sections.length) {
248
+ for (const s of sections) {
249
+ events.push({
250
+ ts,
251
+ role: "tool",
252
+ kind: "code_edit",
253
+ text: `${name} ${path.basename(s.filePath)}`,
254
+ file: path.basename(s.filePath),
255
+ path: s.filePath,
256
+ diff: s.diff,
257
+ sessionId,
258
+ index,
259
+ });
260
+ }
261
+ return;
262
+ }
263
+ const filePath = args.path || args.file_path || args.file || null;
264
+ events.push({
265
+ ts,
266
+ role: "tool",
267
+ kind: "code_edit",
268
+ text: `${name}${filePath ? ` ${path.basename(filePath)}` : ""}`,
269
+ file: filePath ? path.basename(filePath) : null,
270
+ path: filePath,
271
+ diff: typeof args.patch === "string" ? args.patch : "",
272
+ sessionId,
273
+ index,
274
+ });
275
+ }
276
+ });
277
+ return markReal(events, real);
278
+ }
279
+
280
+ // VS Code chatSessions .jsonl is a patch log: kind 0 = initial state, kind 1 = set the value
281
+ // at path k, kind 2 = append v's items to the array at path k. New requests (with their
282
+ // timestamp and message) and response parts arrive as kind 2, so it must not be skipped.
283
+ function foldChatSessionPatches(rows) {
284
+ let state = null;
285
+ for (const step of rows) {
286
+ if (step.kind === 0) {
287
+ state = step.v;
288
+ continue;
289
+ }
290
+ if (!state || (step.kind !== 1 && step.kind !== 2) || !Array.isArray(step.k) || !step.k.length) continue;
291
+ let current = state;
292
+ for (let i = 0; i < step.k.length - 1; i += 1) {
293
+ const key = step.k[i];
294
+ if (current[key] === undefined || current[key] === null) current[key] = typeof step.k[i + 1] === "number" ? [] : {};
295
+ current = current[key];
296
+ }
297
+ const last = step.k[step.k.length - 1];
298
+ if (step.kind === 1) {
299
+ current[last] = step.v;
300
+ continue;
301
+ }
302
+ if (!Array.isArray(current[last])) current[last] = [];
303
+ if (Array.isArray(step.v)) current[last].push(...step.v);
304
+ else if (step.v !== undefined) current[last].push(step.v);
305
+ }
306
+ return state;
307
+ }
308
+
309
+ function parseCopilot(file) {
310
+ const rows = readJsonl(file);
311
+ if (!rows.length) return [];
312
+ if (rows[0].kind === 0) {
313
+ const state = foldChatSessionPatches(rows);
314
+ if (!state) return [];
315
+ const sessionId = state.sessionId || sessionIdFromFile(file);
316
+ const start = toTs(state.creationDate, fileMtime(file));
317
+ // VS Code also writes GitHub.copilot-chat/transcripts/<same id>.jsonl, with real timestamps
318
+ // and tool calls, but it only starts partway through the session. This file then only
319
+ // contributes what came before it, or every prompt would appear twice.
320
+ const transcriptStart = copilotTranscriptStart(file);
321
+ const events = [];
322
+ const real = new Map();
323
+ (state.requests || []).forEach((request, index) => {
324
+ real.set(index, realTs(request?.timestamp) != null);
325
+ const rendered = request?.result?.metadata?.renderedUserMessage;
326
+ const text =
327
+ String(request?.message?.text || "").trim() ||
328
+ userRequest(extractText(rendered)) ||
329
+ (index === 0 ? String(state.customTitle || "").trim() : "");
330
+ // Requests carry their own time; creationDate + index is only a last resort.
331
+ const ts = toTs(request?.timestamp, start + index * 1000);
332
+ if (!isRedacted(text)) events.push({ ts, role: "user", kind: "message", text, sessionId, index });
333
+ const assistant = extractText(request?.response || request?.result?.message);
334
+ if (!isRedacted(assistant)) {
335
+ events.push({ ts: toTs(request?.responseTimestamp, ts + 1), role: "assistant", kind: "message", text: assistant, sessionId, index });
336
+ }
337
+ });
338
+ markReal(events, real);
339
+ return transcriptStart ? events.filter((e) => e.ts < transcriptStart) : events;
340
+ }
341
+ const started = rows.find((r) => r?.type === "session.start")?.data?.sessionId;
342
+ const sessionId = typeof started === "string" && started ? started : sessionIdFromFile(file);
343
+ const events = [];
344
+ const real = new Map();
345
+ rows.forEach((step, index) => {
346
+ const ts = toTs(step.timestamp, fileMtime(file) + index * 1000);
347
+ real.set(index, realTs(step.timestamp) != null);
348
+ if (step.type === "user.message" && step.data?.content) {
349
+ const text = extractText(step.data.content);
350
+ // Copilot files terminal callbacks as user messages; nobody typed them.
351
+ if (!isRedacted(text) && !COPILOT_INJECTED.test(text)) events.push({ ts, role: "user", kind: "message", text, sessionId, index });
352
+ } else if (step.type === "assistant.message" && step.data?.content) {
353
+ const text = extractText(step.data.content);
354
+ if (!isRedacted(text)) events.push({ ts, role: "assistant", kind: "message", text, sessionId, index });
355
+ } else if (step.type === "tool.execution_start" && COPILOT_EDIT_TOOLS.has(step.data?.toolName)) {
356
+ for (const edit of copilotEditDiffs(step.data.toolName, toolArgs(step.data.arguments))) {
357
+ events.push({
358
+ ts,
359
+ role: "tool",
360
+ kind: "code_edit",
361
+ text: `${step.data.toolName} ${path.basename(edit.filePath)}`,
362
+ file: path.basename(edit.filePath),
363
+ path: edit.filePath,
364
+ diff: edit.diff,
365
+ sessionId,
366
+ index,
367
+ });
368
+ }
369
+ }
370
+ });
371
+ return markReal(events, real);
372
+ }
373
+
374
+ const COPILOT_INJECTED = /^\s*\[Terminal [0-9a-f-]+ notification:/i;
375
+
376
+ /** Start time of the transcript twin of a chatSessions file, or null when there is none. */
377
+ function copilotTranscriptStart(chatSessionFile) {
378
+ const twin = path.join(path.dirname(path.dirname(chatSessionFile)), "GitHub.copilot-chat", "transcripts", `${sessionIdFromFile(chatSessionFile)}.jsonl`);
379
+ const first = readJsonl(twin).find((r) => r?.type === "session.start");
380
+ if (!first) return null;
381
+ return toTs(first.data?.startTime || first.timestamp, null) || null;
382
+ }
383
+
384
+ // Copilot agent edit tools as named in GitHub.copilot-chat transcripts (VS Code), plus
385
+ // the CLI's edit/create. Reads (read_file, grep_search, …) are deliberately absent.
386
+ const COPILOT_EDIT_TOOLS = new Set([
387
+ "create_file",
388
+ "replace_string_in_file",
389
+ "multi_replace_string_in_file",
390
+ "insert_edit_into_file",
391
+ "apply_patch",
392
+ "edit_file",
393
+ "edit",
394
+ "create",
395
+ ]);
396
+
397
+ function toolArgs(value) {
398
+ if (value && typeof value === "object") return value;
399
+ try {
400
+ return JSON.parse(String(value || "{}"));
401
+ } catch {
402
+ return {};
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Splits an apply_patch payload ("*** Begin Patch / *** Update File: <path> / … / *** End Patch")
408
+ * into one section per file. Shared by Copilot and Codex, which use the same format.
409
+ */
410
+ function patchSections(input) {
411
+ const sections = [];
412
+ let current = null;
413
+ for (const line of String(input || "").split(/\r?\n/)) {
414
+ const header = line.match(/^\*\*\* (Add|Update|Delete) File: (.+?)\s*$/);
415
+ if (header) {
416
+ current = { op: header[1].toLowerCase(), filePath: header[2], lines: [] };
417
+ sections.push(current);
418
+ continue;
419
+ }
420
+ if (/^\*\*\* (Begin|End) Patch/.test(line) || /^\*\*\* End of File/.test(line)) continue;
421
+ if (current && !/^\*\*\* Move to:/.test(line)) current.lines.push(line);
422
+ }
423
+ return sections.map((s) => ({ op: s.op, filePath: s.filePath, diff: s.lines.join("\n") }));
424
+ }
425
+
426
+ function copilotEditDiffs(name, args) {
427
+ if (name === "apply_patch") return patchSections(args.input || args.patch || "").map(({ filePath, diff }) => ({ filePath, diff }));
428
+ if (name === "multi_replace_string_in_file") {
429
+ const byFile = new Map();
430
+ for (const r of Array.isArray(args.replacements) ? args.replacements : []) {
431
+ const filePath = r?.filePath || r?.path;
432
+ if (!filePath) continue;
433
+ const diff = makeUnifiedDiff(r.oldString ?? "", r.newString ?? "", path.basename(filePath));
434
+ byFile.set(filePath, byFile.has(filePath) ? `${byFile.get(filePath)}\n${diff}` : diff);
435
+ }
436
+ return [...byFile].map(([filePath, diff]) => ({ filePath, diff }));
437
+ }
438
+ const filePath = args.filePath || args.path || args.file_path;
439
+ if (!filePath) return [];
440
+ const label = path.basename(filePath);
441
+ if (args.oldString != null || args.newString != null || args.old_string != null || args.new_string != null) {
442
+ return [{ filePath, diff: makeUnifiedDiff(args.oldString ?? args.old_string ?? "", args.newString ?? args.new_string ?? "", label) }];
443
+ }
444
+ const written = args.content ?? args.code ?? args.file_text;
445
+ return [{ filePath, diff: written != null ? makeUnifiedDiff("", written, label) : "" }];
446
+ }
447
+
448
+ const ANTIGRAVITY_EDIT_TOOLS = EDIT_TOOL_SETS.antigravity;
449
+
450
+ // Antigravity quotes every tool argument, so `args.TargetFile` arrives as the
451
+ // literal string `"c:\\Users\\me\\app.ts"` — surrounding quotes included.
452
+ function antigravityArg(value) {
453
+ if (typeof value !== "string") return null;
454
+ const trimmed = value.trim().replace(/^"(.*)"$/s, "$1");
455
+ return trimmed || null;
456
+ }
457
+
458
+ // A CODE_ACTION names the file it touched in one of two shapes:
459
+ // Created file file:///c:/Users/me%20x/app.ts with requested content.
460
+ // The following changes were made by the write_to_file tool to: c:\Users\me\app.ts.
461
+ function antigravityEditPath(content) {
462
+ const text = String(content || "");
463
+ const url = text.match(/file:\/\/\/[^\s\n`"']+/);
464
+ if (url) {
465
+ try {
466
+ return decodeURIComponent(url[0].replace(/^file:\/+/, "")).replace(/\//g, path.sep);
467
+ } catch {
468
+ return url[0];
469
+ }
470
+ }
471
+ const named = text.match(/\btool to:\s*(.+?)\.\s*(?:If relevant|[\r\n]|$)/);
472
+ if (named) return named[1].trim();
473
+ return null;
474
+ }
475
+
476
+ function antigravityDiff(content) {
477
+ const match = String(content || "").match(/\[diff_block_start\]\r?\n([\s\S]*?)\r?\n\[diff_block_end\]/);
478
+ return match ? match[1] : "";
479
+ }
480
+
481
+ function antigravitySummary(content) {
482
+ // The first two lines are always `Created At:` / `Completed At:` bookkeeping.
483
+ const lines = String(content || "")
484
+ .split("\n")
485
+ .map((line) => line.trim())
486
+ .filter((line) => line && !/^(Created|Completed) At:/.test(line));
487
+ return (lines[0] || "code action").slice(0, 240);
488
+ }
489
+
490
+ function parseAntigravity(file) {
491
+ const rows = readJsonl(file);
492
+ const sessionId = sessionIdFromTranscript("Antigravity", file) || sessionIdFromFile(file);
493
+ const events = [];
494
+ // CODE_ACTION carries the diff but sometimes not a resolvable path; the
495
+ // PLANNER_RESPONSE that requested it always carries `TargetFile`. Remembering
496
+ // the last requested target lets the two halves be stitched back together.
497
+ let pendingTarget = null;
498
+ const real = new Map();
499
+ rows.forEach((step, index) => {
500
+ const ts = toTs(step.created_at || step.timestamp, fileMtime(file) + index * 1000);
501
+ real.set(index, realTs(step.created_at || step.timestamp) != null);
502
+ if (step.type === "USER_INPUT" && step.content) {
503
+ const text = userRequest(step.content);
504
+ if (!isRedacted(text)) events.push({ ts, role: "user", kind: "message", text, sessionId, index });
505
+ return;
506
+ }
507
+ if (step.type === "PLANNER_RESPONSE") {
508
+ if (step.thinking && !isRedacted(step.thinking)) {
509
+ events.push({ ts, role: "assistant", kind: "thinking", text: String(step.thinking), sessionId, index });
510
+ }
511
+ if (step.content && !isRedacted(step.content)) {
512
+ events.push({ ts, role: "assistant", kind: "message", text: String(step.content), sessionId, index });
513
+ }
514
+ for (const call of step.tool_calls || []) {
515
+ if (!ANTIGRAVITY_EDIT_TOOLS.has(call?.name)) continue;
516
+ const target = antigravityArg(call.args?.TargetFile);
517
+ if (target) pendingTarget = target;
518
+ }
519
+ return;
520
+ }
521
+ if (step.type === "CODE_ACTION") {
522
+ const filePath = antigravityEditPath(step.content) || pendingTarget;
523
+ pendingTarget = null;
524
+ events.push({
525
+ ts,
526
+ role: "tool",
527
+ kind: "code_edit",
528
+ text: antigravitySummary(step.content),
529
+ file: filePath ? path.basename(filePath) : null,
530
+ path: filePath,
531
+ diff: antigravityDiff(step.content),
532
+ sessionId,
533
+ index,
534
+ });
535
+ }
536
+ // VIEW_FILE / LIST_DIRECTORY / EPHEMERAL_MESSAGE are reads and status
537
+ // chatter. Filing them as code_edit is what put files the agent merely
538
+ // looked at into the Code Edits panel.
539
+ });
540
+ return markReal(events, real);
541
+ }
542
+
543
+ function parseFile(agent, file) {
544
+ let events = [];
545
+ if (agent === "Cursor") events = parseCursor(file);
546
+ else if (agent === "Claude Code") events = parseClaude(file);
547
+ else if (agent === "Codex") events = parseCodex(file);
548
+ else if (agent === "Copilot") events = parseCopilot(file);
549
+ else if (agent === "Antigravity") events = parseAntigravity(file);
550
+ return events.map((event) => ({
551
+ ...event,
552
+ text: clip(event.text),
553
+ diff: event.diff ? clip(event.diff, 4000) : event.diff,
554
+ transcriptPath: file,
555
+ }));
556
+ }
557
+
558
+ module.exports = { parseFile, patchSections };
@@ -0,0 +1,36 @@
1
+ // One definition of "which session is this transcript", shared by the transcript
2
+ // parser and the lock hooks. A lock id and a transcript must name the same session
3
+ // or the memory graph cannot say what a lock holder was doing. Dependency-free:
4
+ // hooks load this on every claim.
5
+ const path = require("path");
6
+
7
+ const UUID_TAIL = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
8
+
9
+ function base(file) {
10
+ return path.basename(String(file), path.extname(String(file)));
11
+ }
12
+
13
+ /**
14
+ * @param {string} agent "Claude Code" | "Cursor" | "Codex" | "Copilot" | "Antigravity"
15
+ * @param {string} file transcript path as discovered on disk or reported by a hook
16
+ */
17
+ function sessionIdFromTranscript(agent, file) {
18
+ if (!file || typeof file !== "string") return null;
19
+ const normalized = file.replace(/\\/g, "/");
20
+ if (agent === "Codex") {
21
+ // rollout-2026-05-13T11-17-07-<thread uuid>.jsonl; hooks send the thread uuid as session_id.
22
+ const m = base(normalized).match(UUID_TAIL);
23
+ return m ? m[1] : base(normalized);
24
+ }
25
+ if (agent === "Antigravity") {
26
+ // brain/<conversation uuid>/.system_generated/logs/transcript.jsonl
27
+ const parts = normalized.split("/");
28
+ const i = parts.lastIndexOf("brain");
29
+ if (i >= 0 && parts[i + 1]) return parts[i + 1];
30
+ return path.posix.basename(path.posix.dirname(path.posix.dirname(path.posix.dirname(normalized)))) || base(normalized);
31
+ }
32
+ // Claude Code <session>.jsonl, Cursor agent-transcripts/<id>/<id>.jsonl, Copilot chatSessions/<id>.json
33
+ return base(normalized);
34
+ }
35
+
36
+ module.exports = { sessionIdFromTranscript };