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,265 @@
1
+ const { spawn } = require("child_process");
2
+ const { parseGithubRepo } = require("./githubRepo");
3
+ const { inviteState, invitesOf, normLogin } = require("./roomAuth");
4
+
5
+ const SIGNAL_PREFIX = "relay-room:";
6
+ const SIGNAL_FILE = "relay-signal.json";
7
+ const PROOF_FILE = "relay-proof.json";
8
+
9
+ function repoKeyFromRemote(remoteUrl) {
10
+ const parsed = parseGithubRepo(remoteUrl);
11
+ return parsed ? `${parsed.owner}/${parsed.repo}` : null;
12
+ }
13
+
14
+ function signalDescription(repoKey) {
15
+ return `${SIGNAL_PREFIX}${repoKey}`;
16
+ }
17
+
18
+ function invitedLogins(room) {
19
+ return invitesOf(room)
20
+ .filter((i) => {
21
+ const state = inviteState(i);
22
+ return state === "pending" || state === "accepted";
23
+ })
24
+ .map((i) => normLogin(i.login))
25
+ .filter(Boolean);
26
+ }
27
+
28
+ function ghApi(pathname, { method = "GET", body } = {}) {
29
+ const args = ["api", pathname, "-H", "Accept: application/vnd.github+json"];
30
+ if (method && method !== "GET") args.push("-X", method);
31
+ if (body !== undefined) args.push("--input", "-");
32
+ return new Promise((resolve, reject) => {
33
+ const child = spawn("gh", args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, shell: false });
34
+ let out = "";
35
+ let err = "";
36
+ child.stdout.on("data", (c) => (out += c));
37
+ child.stderr.on("data", (c) => (err += c));
38
+ child.on("error", () => reject(new Error("gh_not_installed")));
39
+ child.on("exit", (code) => {
40
+ if (code !== 0) return reject(new Error(err.trim() || `gh exited ${code}`));
41
+ const text = out.trim();
42
+ if (!text) return resolve({});
43
+ try {
44
+ resolve(JSON.parse(text));
45
+ } catch {
46
+ resolve(text);
47
+ }
48
+ });
49
+ if (body !== undefined) child.stdin.write(JSON.stringify(body));
50
+ child.stdin.end();
51
+ });
52
+ }
53
+
54
+ async function readGist(id) {
55
+ if (!id) return null;
56
+ try {
57
+ return await ghApi(`gists/${id}`);
58
+ } catch {
59
+ try {
60
+ const res = await fetch(`https://api.github.com/gists/${id}`, {
61
+ headers: { "User-Agent": "relay-it", Accept: "application/vnd.github+json" },
62
+ signal: AbortSignal.timeout(5000),
63
+ });
64
+ if (!res.ok) return null;
65
+ return res.json();
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+ }
71
+
72
+ function parseSignal(gist) {
73
+ if (!gist?.id) return null;
74
+ const desc = String(gist.description || "");
75
+ if (!desc.startsWith(SIGNAL_PREFIX)) return null;
76
+ const raw = gist.files?.[SIGNAL_FILE]?.content;
77
+ let body = {};
78
+ if (raw) {
79
+ try {
80
+ body = JSON.parse(raw);
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+ return {
86
+ v: body.v || 1,
87
+ live: body.live !== false,
88
+ roomId: body.roomId || null,
89
+ url: body.url || null,
90
+ hostLogin: body.hostLogin || gist.owner?.login || null,
91
+ repoKey: body.repo || desc.slice(SIGNAL_PREFIX.length),
92
+ projectName: body.projectName || null,
93
+ nonce: body.nonce || null,
94
+ invited: Array.isArray(body.invited) ? body.invited.map(normLogin) : [],
95
+ updatedAt: body.updatedAt || null,
96
+ gistId: gist.id,
97
+ };
98
+ }
99
+
100
+ function signalPayload(room, { repoKey, hostLogin }) {
101
+ return {
102
+ v: 1,
103
+ live: room.live !== false,
104
+ roomId: room.roomId,
105
+ url: room.url || null,
106
+ hostLogin,
107
+ repo: repoKey,
108
+ projectName: room.hostProjectName || null,
109
+ nonce: room.nonce,
110
+ invited: invitedLogins(room),
111
+ updatedAt: new Date().toISOString(),
112
+ };
113
+ }
114
+
115
+ async function findOwnSignalGist(repoKey) {
116
+ try {
117
+ const mine = await ghApi("gists");
118
+ const description = signalDescription(repoKey);
119
+ return (Array.isArray(mine) ? mine : []).find((g) => g.description === description) || null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Publishes the current tunnel URL under a well-known public gist so a guest
127
+ * can find the host again after ngrok recycles the URL. Membership credentials
128
+ * stay in room.json — the gist is only a pointer.
129
+ */
130
+ async function publishSignal(room, { repoKey, hostLogin }) {
131
+ if (!room?.roomId || !repoKey || !hostLogin) {
132
+ return { ok: false, error: "signal_incomplete" };
133
+ }
134
+ const description = signalDescription(repoKey);
135
+ const files = { [SIGNAL_FILE]: { content: JSON.stringify(signalPayload(room, { repoKey, hostLogin }), null, 2) } };
136
+ const patch = async (id) => {
137
+ await ghApi(`gists/${id}`, { method: "PATCH", body: { description, files } });
138
+ return { ok: true, gistId: id };
139
+ };
140
+ if (room.signalGistId) {
141
+ try {
142
+ return await patch(room.signalGistId);
143
+ } catch {
144
+ /* gist was deleted — fall through and recreate */
145
+ }
146
+ }
147
+ const existing = await findOwnSignalGist(repoKey);
148
+ if (existing?.id) return patch(existing.id);
149
+ const created = await ghApi("gists", {
150
+ method: "POST",
151
+ body: { description, public: true, files },
152
+ });
153
+ if (!created?.id) return { ok: false, error: "gist_create_failed" };
154
+ return { ok: true, gistId: created.id };
155
+ }
156
+
157
+ async function findSignal({ gistId, hostLogin, repoKey, roomId } = {}) {
158
+ if (gistId) {
159
+ const signal = parseSignal(await readGist(gistId));
160
+ if (signal && (!roomId || signal.roomId === roomId) && (!repoKey || signal.repoKey === repoKey)) return signal;
161
+ }
162
+ if (!hostLogin || !repoKey) return null;
163
+ let gists = [];
164
+ try {
165
+ gists = await ghApi(`users/${encodeURIComponent(hostLogin)}/gists`);
166
+ } catch {
167
+ try {
168
+ const res = await fetch(`https://api.github.com/users/${encodeURIComponent(hostLogin)}/gists`, {
169
+ headers: { "User-Agent": "relay-it", Accept: "application/vnd.github+json" },
170
+ signal: AbortSignal.timeout(6000),
171
+ });
172
+ if (res.ok) gists = await res.json();
173
+ } catch {
174
+ gists = [];
175
+ }
176
+ }
177
+ const description = signalDescription(repoKey);
178
+ const match = (Array.isArray(gists) ? gists : []).find((g) => g.description === description);
179
+ if (!match?.id) return null;
180
+ const signal = parseSignal(await readGist(match.id));
181
+ if (!signal) return null;
182
+ if (roomId && signal.roomId !== roomId) return null;
183
+ return signal;
184
+ }
185
+
186
+ async function discoverRooms({ repoKey, logins, myLogin }) {
187
+ const me = normLogin(myLogin);
188
+ const hosts = [...new Set((logins || []).map(normLogin).filter((l) => l && l !== me))];
189
+ const rooms = [];
190
+ for (const hostLogin of hosts) {
191
+ try {
192
+ const signal = await findSignal({ hostLogin, repoKey });
193
+ if (!signal?.live || !signal.url) continue;
194
+ if (!signal.invited.includes(me)) continue;
195
+ rooms.push({
196
+ hostLogin: signal.hostLogin,
197
+ url: signal.url,
198
+ roomId: signal.roomId,
199
+ projectName: signal.projectName,
200
+ invited: true,
201
+ updatedAt: signal.updatedAt,
202
+ gistId: signal.gistId,
203
+ });
204
+ } catch {
205
+ /* one collaborator failing must not hide the others */
206
+ }
207
+ }
208
+ return rooms;
209
+ }
210
+
211
+ async function createProof({ roomId, nonce, login }) {
212
+ const gist = await ghApi("gists", {
213
+ method: "POST",
214
+ body: {
215
+ description: `relay-proof:${roomId}`,
216
+ public: false,
217
+ files: {
218
+ [PROOF_FILE]: {
219
+ content: JSON.stringify({ v: 1, roomId, nonce, login: normLogin(login), ts: Date.now() }),
220
+ },
221
+ },
222
+ },
223
+ });
224
+ if (!gist?.id) throw new Error("proof_create_failed");
225
+ return gist.id;
226
+ }
227
+
228
+ async function deleteGist(id) {
229
+ if (!id) return;
230
+ try {
231
+ await ghApi(`gists/${id}`, { method: "DELETE" });
232
+ } catch {
233
+ /* leftover proof gists are secret and expire from usefulness with the nonce */
234
+ }
235
+ }
236
+
237
+ async function verifyProof({ gistId, roomId, nonce, login }) {
238
+ const gist = await readGist(gistId);
239
+ if (!gist?.id) return { ok: false, reason: "proof_missing" };
240
+ if (normLogin(gist.owner?.login) !== normLogin(login)) return { ok: false, reason: "proof_wrong_user" };
241
+ const raw = gist.files?.[PROOF_FILE]?.content;
242
+ let body;
243
+ try {
244
+ body = JSON.parse(raw || "");
245
+ } catch {
246
+ return { ok: false, reason: "proof_invalid" };
247
+ }
248
+ if (!body || body.roomId !== roomId || body.nonce !== nonce) return { ok: false, reason: "proof_mismatch" };
249
+ return { ok: true };
250
+ }
251
+
252
+ const GIST_SCOPE_HINT =
253
+ "Sharing is up, but teammates cannot auto-find you yet. Run `gh auth refresh -s gist` once so Relay can publish a join signal. Until then, send them the invite link.";
254
+
255
+ module.exports = {
256
+ repoKeyFromRemote,
257
+ invitedLogins,
258
+ publishSignal,
259
+ findSignal,
260
+ discoverRooms,
261
+ createProof,
262
+ deleteGist,
263
+ verifyProof,
264
+ GIST_SCOPE_HINT,
265
+ };
@@ -0,0 +1,194 @@
1
+ const { loadMemory, saveMemory } = require("./store");
2
+ const { loadLocalCollisions } = require("./collisionMetrics");
3
+
4
+ // What one machine publishes to the room, and what it stores for every other
5
+ // machine in the room. Kept in the same memory.json as local history so the
6
+ // dashboard can merge both without a second store — but in a separate
7
+ // `roomPeers` bucket, so a peer's history is never re-published as our own
8
+ // (which would echo back and forth and grow without bound).
9
+ const MAX_THREADS = 24;
10
+ const MAX_MESSAGES = 80;
11
+ const MAX_EDITS = 200;
12
+ const MAX_ACTIVITY = 60;
13
+ const MAX_TIMELINE = 40;
14
+ // Transcript text is clipped to 8000 chars locally; at 24 threads x 80 messages
15
+ // that is a 15MB request per poll over a home-broadband tunnel. Peers get a
16
+ // shorter clip — enough to read the conversation, small enough to send often.
17
+ const MAX_MESSAGE_CHARS = 3000;
18
+ const PEER_TTL_MS = 6 * 60 * 60 * 1000;
19
+
20
+ function trimThread(thread) {
21
+ return {
22
+ ...thread,
23
+ messages: (thread.messages || []).slice(-MAX_MESSAGES).map((m) => ({
24
+ ...m,
25
+ text: typeof m.text === "string" && m.text.length > MAX_MESSAGE_CHARS ? `${m.text.slice(0, MAX_MESSAGE_CHARS)}\n…` : m.text,
26
+ })),
27
+ };
28
+ }
29
+
30
+ /** The locally-owned slice of a workspace's memory, sized for the wire. */
31
+ function localSnapshot(memory, login, projectId) {
32
+ const stats = { ...(memory.stats || {}) };
33
+ // Prefer file-backed collision counters so room peers see the durable totals.
34
+ if (projectId) stats.collisions = loadLocalCollisions(projectId);
35
+ return {
36
+ login,
37
+ updatedAt: Date.now(),
38
+ lastTranscriptSyncAt: memory.lastTranscriptSyncAt || 0,
39
+ chats: (memory.chats || []).slice(0, MAX_THREADS).map(trimThread),
40
+ timeline: (memory.timeline || []).slice(0, MAX_TIMELINE),
41
+ activity: (memory.activity || []).slice(0, MAX_ACTIVITY),
42
+ edits: (memory.edits || []).slice(0, MAX_EDITS),
43
+ agents: (memory.agents || []).filter((a) => a.status === "connected"),
44
+ stats,
45
+ };
46
+ }
47
+
48
+ function sanitize(snapshot, login) {
49
+ if (!snapshot || typeof snapshot !== "object") return null;
50
+ const owner = login || snapshot.login || "peer";
51
+ const stamp = (rows, cap) =>
52
+ (Array.isArray(rows) ? rows : []).slice(0, cap).map((row) => ({ ...row, ownerLogin: row.ownerLogin || owner, mine: false }));
53
+ return {
54
+ login: owner,
55
+ updatedAt: Date.now(),
56
+ lastTranscriptSyncAt: Number(snapshot.lastTranscriptSyncAt) || 0,
57
+ chats: stamp(snapshot.chats, MAX_THREADS).map(trimThread),
58
+ timeline: stamp(snapshot.timeline, MAX_TIMELINE),
59
+ activity: stamp(snapshot.activity, MAX_ACTIVITY),
60
+ edits: stamp(snapshot.edits, MAX_EDITS),
61
+ agents: Array.isArray(snapshot.agents) ? snapshot.agents.slice(0, 20).map((a) => ({ ...a, ownerLogin: owner })) : [],
62
+ stats: snapshot.stats && typeof snapshot.stats === "object" ? snapshot.stats : {},
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Records what a peer reported. `key` is the peer's login on the host side and
68
+ * the literal "host" on a guest, so a guest holds exactly one aggregated bucket
69
+ * instead of one per room member.
70
+ */
71
+ function savePeerSnapshot(projectId, key, snapshot, login) {
72
+ const clean = sanitize(snapshot, login || key);
73
+ if (!clean) return null;
74
+ const memory = loadMemory(projectId);
75
+ const peers = memory.roomPeers && typeof memory.roomPeers === "object" ? memory.roomPeers : {};
76
+ const now = Date.now();
77
+ for (const [name, row] of Object.entries(peers)) {
78
+ if (now - (row?.updatedAt || 0) > PEER_TTL_MS) delete peers[name];
79
+ }
80
+ peers[key] = clean;
81
+ memory.roomPeers = peers;
82
+ saveMemory(projectId, memory);
83
+ require("./falkor/bus").emit("peer", { projectId, key, login: clean.login, snapshot: clean });
84
+ return clean;
85
+ }
86
+
87
+ function clearPeers(projectId) {
88
+ const memory = loadMemory(projectId);
89
+ if (!memory.roomPeers) return;
90
+ delete memory.roomPeers;
91
+ saveMemory(projectId, memory);
92
+ }
93
+
94
+ function peerSnapshots(memory, { exclude } = {}) {
95
+ const peers = memory?.roomPeers && typeof memory.roomPeers === "object" ? memory.roomPeers : {};
96
+ return Object.entries(peers)
97
+ .filter(([key, row]) => row && key !== exclude && row.login !== exclude)
98
+ .map(([, row]) => row);
99
+ }
100
+
101
+ function dedupeById(rows, sortKey) {
102
+ const byId = new Map();
103
+ for (const row of rows) {
104
+ if (!row) continue;
105
+ const key = row.id || `${row.ownerLogin}:${row.ts || row.updatedAt}`;
106
+ const existing = byId.get(key);
107
+ // Two sides of a room can both hold a thread (the guest's own chats come
108
+ // back inside the host's aggregate). Keep the fuller copy rather than
109
+ // whichever arrived last, so merging never truncates a conversation.
110
+ if (!existing || (row.messages?.length || 0) > (existing.messages?.length || 0)) byId.set(key, row);
111
+ }
112
+ return [...byId.values()].sort((a, b) => (b[sortKey] || 0) - (a[sortKey] || 0));
113
+ }
114
+
115
+ // Room snapshots do not ship the raw history array (too large for the tunnel).
116
+ // The Activity tab still reads history, so rebuild peer events from the chats
117
+ // that did cross the wire — otherwise the joiner's feed is empty while Chat
118
+ // and Code edits look fully synced.
119
+ function historyFromChats(chats) {
120
+ const events = [];
121
+ for (const thread of chats || []) {
122
+ (thread.messages || []).forEach((m, i) => {
123
+ events.push({
124
+ id: `peer_${thread.ownerLogin || "peer"}_${thread.id}_${m.ts || 0}_${i}`,
125
+ ts: m.ts || thread.updatedAt || 0,
126
+ agent: thread.agent,
127
+ ownerLogin: thread.ownerLogin,
128
+ sessionId: thread.sessionId,
129
+ role: m.role,
130
+ kind: m.kind || "message",
131
+ text: m.text || "",
132
+ file: m.file || null,
133
+ path: m.file || null,
134
+ diff: "",
135
+ });
136
+ });
137
+ }
138
+ return events;
139
+ }
140
+
141
+ function mergeAgents(local, peers) {
142
+ const agents = [];
143
+ const seen = new Set();
144
+ const add = (agent, fallbackOwner) => {
145
+ if (!agent) return;
146
+ const ownerLogin = agent.ownerLogin || fallbackOwner || "";
147
+ const base = String(agent.id || agent.label || "agent")
148
+ .toLowerCase()
149
+ .replace(/\s+/g, "-");
150
+ const key = `${ownerLogin}::${agent.label || base}`;
151
+ if (seen.has(key)) return;
152
+ seen.add(key);
153
+ agents.push({
154
+ ...agent,
155
+ ownerLogin,
156
+ id: ownerLogin ? `${ownerLogin}:${base}` : base,
157
+ });
158
+ };
159
+ for (const agent of local || []) add(agent, agent.ownerLogin);
160
+ for (const peer of peers) {
161
+ for (const agent of peer.agents || []) add(agent, peer.login);
162
+ }
163
+ return agents;
164
+ }
165
+
166
+ /** Local view + every peer's view, deduped. Used for both the UI and the wire. */
167
+ function mergeRoomViews(memory, opts = {}) {
168
+ const peers = peerSnapshots(memory, opts);
169
+ if (!peers.length) {
170
+ return {
171
+ chats: memory.chats || [],
172
+ timeline: memory.timeline || [],
173
+ activity: memory.activity || [],
174
+ edits: memory.edits || [],
175
+ history: memory.history || [],
176
+ agents: memory.agents || [],
177
+ peerLogins: [],
178
+ };
179
+ }
180
+ return {
181
+ chats: dedupeById([...(memory.chats || []), ...peers.flatMap((p) => p.chats || [])], "updatedAt"),
182
+ timeline: dedupeById([...(memory.timeline || []), ...peers.flatMap((p) => p.timeline || [])], "ts"),
183
+ activity: dedupeById([...(memory.activity || []), ...peers.flatMap((p) => p.activity || [])], "ts").slice(0, 120),
184
+ edits: dedupeById([...(memory.edits || []), ...peers.flatMap((p) => p.edits || [])], "ts").slice(0, 400),
185
+ history: dedupeById(
186
+ [...(memory.history || []), ...peers.flatMap((p) => historyFromChats(p.chats))],
187
+ "ts"
188
+ ).slice(0, 2000),
189
+ agents: mergeAgents(memory.agents, peers),
190
+ peerLogins: peers.map((p) => p.login).filter(Boolean),
191
+ };
192
+ }
193
+
194
+ module.exports = { localSnapshot, savePeerSnapshot, peerSnapshots, mergeRoomViews, clearPeers };
@@ -0,0 +1,107 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const crypto = require("crypto");
4
+ const { loadRegistry, saveRegistry, saveMemory, loadMemory, id } = require("./store");
5
+
6
+ function avatar(seed, size = 80) {
7
+ const hash = crypto.createHash("md5").update(seed).digest("hex");
8
+ return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=${size}`;
9
+ }
10
+
11
+ function initials(name) {
12
+ const parts = String(name).replace(/[./]/g, " ").trim().split(/\s+/);
13
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
14
+ return (parts[0][0] + parts[1][0]).toUpperCase();
15
+ }
16
+
17
+ function agent(label, status, extra = {}) {
18
+ return {
19
+ id: label.toLowerCase().replace(/\s+/g, "-"),
20
+ label,
21
+ status,
22
+ lastActiveAt: Date.now() - (extra.agoMs || 0),
23
+ sessionId: extra.sessionId,
24
+ ownerId: extra.ownerId,
25
+ ownerLogin: extra.ownerLogin,
26
+ ...extra,
27
+ };
28
+ }
29
+
30
+ function emptyMemory() {
31
+ return {
32
+ stats: { events: 0, agents: 0, patches: 0 },
33
+ agents: [
34
+ agent("Claude Code", "idle"),
35
+ agent("Cursor", "idle"),
36
+ agent("Codex", "idle"),
37
+ agent("Copilot", "idle"),
38
+ agent("Antigravity", "idle"),
39
+ ],
40
+ collaborators: [],
41
+ activity: [],
42
+ history: [],
43
+ timeline: [],
44
+ chats: [],
45
+ edits: [],
46
+ };
47
+ }
48
+
49
+ function looksLikeDemo(mem) {
50
+ if (!mem) return false;
51
+ if (mem.stats?.events === 186 || mem.stats?.events === 64) return true;
52
+ const ids = (mem.activity || []).map((a) => a.id);
53
+ return ids.includes("a1") || ids.includes("u1");
54
+ }
55
+
56
+ // Demo accounts used to be seeded here with `user_<login>` ids. Real accounts
57
+ // come from GitHub and are `gh_<id>`, so anything still carrying the old id
58
+ // scheme is a placeholder — drop it, unless it is the signed-in session.
59
+ function dropDemoUsers() {
60
+ const reg = loadRegistry();
61
+ const users = reg.users || [];
62
+ let activeId = null;
63
+ try {
64
+ activeId = require("./store").loadSession?.().userId || null;
65
+ } catch {
66
+ /* no session helper: keep every account */
67
+ }
68
+ const kept = users.filter((u) => !String(u.id || "").startsWith("user_") || u.id === activeId);
69
+ if (kept.length !== users.length) {
70
+ reg.users = kept;
71
+ saveRegistry(reg);
72
+ }
73
+ }
74
+
75
+ function seedIfEmpty() {
76
+ dropDemoUsers();
77
+ const current = loadRegistry();
78
+ if (current.projects?.length) {
79
+ for (const p of current.projects) {
80
+ const mem = loadMemory(p.id);
81
+ if (looksLikeDemo(mem) || mem.ir) saveMemory(p.id, emptyMemory());
82
+ }
83
+ return loadRegistry();
84
+ }
85
+ const cwd = process.cwd();
86
+ const project = {
87
+ id: "proj_relay",
88
+ name: path.basename(cwd),
89
+ initials: initials(cwd),
90
+ color: "#f59e0b",
91
+ path: cwd,
92
+ remoteUrl: null,
93
+ apiKey: "rl_" + crypto.randomBytes(12).toString("hex"),
94
+ createdAt: Date.now(),
95
+ lastSyncAt: Date.now(),
96
+ };
97
+ try {
98
+ fs.mkdirSync(path.join(cwd, ".relay"), { recursive: true });
99
+ } catch {
100
+ /* ignore */
101
+ }
102
+ saveMemory(project.id, emptyMemory());
103
+ saveRegistry({ users: loadRegistry().users, projects: [project] });
104
+ return loadRegistry();
105
+ }
106
+
107
+ module.exports = { seedIfEmpty, avatar, initials, emptyMemory, id };
@@ -0,0 +1,40 @@
1
+ class SseHub {
2
+ constructor() {
3
+ this.clients = new Set();
4
+ }
5
+
6
+ subscribe(res, filter = {}) {
7
+ res.setHeader("Content-Type", "text/event-stream");
8
+ res.setHeader("Cache-Control", "no-cache, no-transform");
9
+ res.setHeader("Connection", "keep-alive");
10
+ res.setHeader("X-Accel-Buffering", "no");
11
+ res.flushHeaders?.();
12
+ const client = { res, filter };
13
+ this.clients.add(client);
14
+ res.write(`event: hello\ndata: ${JSON.stringify({ ok: true, ts: Date.now() })}\n\n`);
15
+ res.on("close", () => this.clients.delete(client));
16
+ return client;
17
+ }
18
+
19
+ emit(event, payload) {
20
+ const data = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
21
+ for (const client of this.clients) {
22
+ const { workspaceId } = client.filter;
23
+ if (
24
+ event !== "notice" &&
25
+ workspaceId &&
26
+ payload.workspaceId &&
27
+ payload.workspaceId !== workspaceId
28
+ ) {
29
+ continue;
30
+ }
31
+ try {
32
+ client.res.write(data);
33
+ } catch {
34
+ this.clients.delete(client);
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ module.exports = { SseHub };
@@ -0,0 +1,79 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const crypto = require("crypto");
4
+ const { dataDir, registryPath, sessionPath } = require("./paths");
5
+
6
+ function readJson(file, fallback) {
7
+ try {
8
+ return JSON.parse(fs.readFileSync(file, "utf8"));
9
+ } catch {
10
+ return fallback;
11
+ }
12
+ }
13
+
14
+ function writeJson(file, value) {
15
+ fs.mkdirSync(path.dirname(file), { recursive: true });
16
+ fs.writeFileSync(file, JSON.stringify(value, null, 2));
17
+ }
18
+
19
+ function id(prefix = "id") {
20
+ return `${prefix}_${crypto.randomBytes(6).toString("hex")}`;
21
+ }
22
+
23
+ function loadRegistry() {
24
+ return readJson(registryPath(), { projects: [], users: [] });
25
+ }
26
+
27
+ function saveRegistry(reg) {
28
+ writeJson(registryPath(), reg);
29
+ }
30
+
31
+ function loadSession() {
32
+ return readJson(sessionPath(), { userId: null });
33
+ }
34
+
35
+ function saveSession(session) {
36
+ writeJson(sessionPath(), session);
37
+ }
38
+
39
+ function projectDir(projectId) {
40
+ const dir = path.join(dataDir(), "projects", projectId);
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ return dir;
43
+ }
44
+
45
+ function memoryPath(projectId) {
46
+ return path.join(projectDir(projectId), "memory.json");
47
+ }
48
+
49
+ function loadMemory(projectId) {
50
+ return readJson(memoryPath(projectId), {
51
+ history: [],
52
+ timeline: [],
53
+ chats: [],
54
+ edits: [],
55
+ ir: {},
56
+ agents: [],
57
+ collaborators: [],
58
+ activity: [],
59
+ });
60
+ }
61
+
62
+ function saveMemory(projectId, memory) {
63
+ writeJson(memoryPath(projectId), memory);
64
+ }
65
+
66
+ module.exports = {
67
+ readJson,
68
+ writeJson,
69
+ id,
70
+ loadRegistry,
71
+ saveRegistry,
72
+ loadSession,
73
+ saveSession,
74
+ projectDir,
75
+ memoryPath,
76
+ loadMemory,
77
+ saveMemory,
78
+ dataDir,
79
+ };