relaybrain 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +434 -3
  3. package/backend/coordinator/client.js +364 -0
  4. package/backend/coordinator/lockTable.js +649 -0
  5. package/backend/coordinator/server.js +179 -0
  6. package/backend/lib/centralAuth.js +62 -0
  7. package/backend/lib/centralStore.js +112 -0
  8. package/backend/lib/centralSync.js +88 -0
  9. package/backend/lib/cloneRepo.js +108 -0
  10. package/backend/lib/collisionMetrics.js +231 -0
  11. package/backend/lib/conflicts.js +35 -0
  12. package/backend/lib/dashboard.js +53 -0
  13. package/backend/lib/depGraph.js +393 -0
  14. package/backend/lib/deps.js +46 -0
  15. package/backend/lib/dirtyFiles.js +33 -0
  16. package/backend/lib/editMatchers.js +55 -0
  17. package/backend/lib/falkor/analytics.js +228 -0
  18. package/backend/lib/falkor/bus.js +16 -0
  19. package/backend/lib/falkor/central.js +281 -0
  20. package/backend/lib/falkor/client.js +165 -0
  21. package/backend/lib/falkor/config.js +73 -0
  22. package/backend/lib/falkor/coord.js +299 -0
  23. package/backend/lib/falkor/cursor.js +58 -0
  24. package/backend/lib/falkor/demo.js +221 -0
  25. package/backend/lib/falkor/fixtures.js +85 -0
  26. package/backend/lib/falkor/index.js +146 -0
  27. package/backend/lib/falkor/ingest.js +233 -0
  28. package/backend/lib/falkor/intents.js +32 -0
  29. package/backend/lib/falkor/keys.js +102 -0
  30. package/backend/lib/falkor/migrations.js +56 -0
  31. package/backend/lib/falkor/project.js +307 -0
  32. package/backend/lib/falkor/query.js +344 -0
  33. package/backend/lib/falkor/redact.js +30 -0
  34. package/backend/lib/falkor/routes.js +125 -0
  35. package/backend/lib/falkor/schema.js +108 -0
  36. package/backend/lib/falkor/write.js +281 -0
  37. package/backend/lib/githubLogin.js +108 -0
  38. package/backend/lib/githubOAuth.js +42 -0
  39. package/backend/lib/githubRepo.js +117 -0
  40. package/backend/lib/harvest.js +85 -0
  41. package/backend/lib/httpServer.js +27 -0
  42. package/backend/lib/installAgentRelay.js +235 -0
  43. package/backend/lib/installHooks.js +309 -0
  44. package/backend/lib/lamport.js +14 -0
  45. package/backend/lib/localBrowse.js +109 -0
  46. package/backend/lib/memorySync.js +63 -0
  47. package/backend/lib/notices.js +150 -0
  48. package/backend/lib/ot.js +74 -0
  49. package/backend/lib/patches.js +192 -0
  50. package/backend/lib/paths.js +85 -0
  51. package/backend/lib/peerClash.js +64 -0
  52. package/backend/lib/presence.js +35 -0
  53. package/backend/lib/relayOsBlock.js +84 -0
  54. package/backend/lib/repoSnapshot.js +125 -0
  55. package/backend/lib/room.js +638 -0
  56. package/backend/lib/roomAsk.js +148 -0
  57. package/backend/lib/roomAuth.js +215 -0
  58. package/backend/lib/roomControl.js +226 -0
  59. package/backend/lib/roomLocks.js +201 -0
  60. package/backend/lib/roomSignal.js +265 -0
  61. package/backend/lib/roomSync.js +194 -0
  62. package/backend/lib/seed.js +107 -0
  63. package/backend/lib/sseHub.js +40 -0
  64. package/backend/lib/store.js +79 -0
  65. package/backend/lib/supergraph.js +46 -0
  66. package/backend/lib/transcripts/discover.js +437 -0
  67. package/backend/lib/transcripts/parse.js +558 -0
  68. package/backend/lib/transcripts/sessionId.js +36 -0
  69. package/backend/lib/transcripts/sync.js +291 -0
  70. package/backend/lib/transcripts/util.js +299 -0
  71. package/backend/lib/treeSitterImports.js +219 -0
  72. package/backend/lib/watchWorkspace.js +42 -0
  73. package/backend/mcp/README.md +193 -0
  74. package/backend/mcp/server.js +42 -0
  75. package/backend/mcp/tools.js +484 -0
  76. package/backend/server.js +3894 -0
  77. package/cli/graph.js +286 -0
  78. package/cli/relay.js +475 -0
  79. package/docker-compose.falkor.yml +17 -0
  80. package/docs/MEMORY-GRAPH.md +314 -0
  81. package/hooks/relay-antigravity-post-tool.js +2 -0
  82. package/hooks/relay-antigravity-pre-read.js +2 -0
  83. package/hooks/relay-antigravity-pre-tool.js +2 -0
  84. package/hooks/relay-antigravity-stop.js +2 -0
  85. package/hooks/relay-claude-post-tool.js +2 -0
  86. package/hooks/relay-claude-pre-read.js +2 -0
  87. package/hooks/relay-claude-pre-tool.js +2 -0
  88. package/hooks/relay-claude-stop.js +2 -0
  89. package/hooks/relay-codex-post-tool.js +2 -0
  90. package/hooks/relay-codex-pre-read.js +2 -0
  91. package/hooks/relay-codex-pre-tool.js +2 -0
  92. package/hooks/relay-codex-stop.js +2 -0
  93. package/hooks/relay-copilot-post-tool.js +2 -0
  94. package/hooks/relay-copilot-pre-read.js +2 -0
  95. package/hooks/relay-copilot-pre-tool.js +2 -0
  96. package/hooks/relay-copilot-stop.js +2 -0
  97. package/hooks/relay-cursor-post-tool.js +2 -0
  98. package/hooks/relay-cursor-pre-read.js +2 -0
  99. package/hooks/relay-cursor-pre-tool.js +2 -0
  100. package/hooks/relay-cursor-stop.js +2 -0
  101. package/hooks/relay-hook-lib.js +621 -0
  102. package/mission-control/out/404.html +1 -0
  103. package/mission-control/out/__next.__PAGE__.txt +26 -0
  104. package/mission-control/out/__next._full.txt +21 -0
  105. package/mission-control/out/__next._tree.txt +4 -0
  106. package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_buildManifest.js +11 -0
  107. package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_clientMiddlewareManifest.js +1 -0
  108. package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_ssgManifest.js +1 -0
  109. package/mission-control/out/_next/static/chunks/0-3l04fnym95-.js +22 -0
  110. package/mission-control/out/_next/static/chunks/064ntycm-fx5v.js +1 -0
  111. package/mission-control/out/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  112. package/mission-control/out/_next/static/chunks/0ehjiuuxbbhq9.js +1 -0
  113. package/mission-control/out/_next/static/chunks/1-dy52ls2ntb_.css +4 -0
  114. package/mission-control/out/_next/static/chunks/1q0cmt0kqufow.js +2 -0
  115. package/mission-control/out/_next/static/chunks/1ya1u0izct4f6.js +1 -0
  116. package/mission-control/out/_next/static/chunks/2n66mlnb34vjq.js +1 -0
  117. package/mission-control/out/_next/static/chunks/2xar70jzx8f1y.css +1 -0
  118. package/mission-control/out/_next/static/chunks/31cgqj_-l15yt.js +31 -0
  119. package/mission-control/out/_next/static/chunks/31qgr-p-oulpb.js +1 -0
  120. package/mission-control/out/_next/static/chunks/3vq9c4hivvgn_.js +1 -0
  121. package/mission-control/out/_next/static/chunks/turbopack-3hsllzpnzz12p.js +1 -0
  122. package/mission-control/out/_next/static/media/icon.27otmh_fv3buh.svg +4 -0
  123. package/mission-control/out/_not-found/__next._full.txt +18 -0
  124. package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +24 -0
  125. package/mission-control/out/_not-found/__next._tree.txt +3 -0
  126. package/mission-control/out/_not-found.html +1 -0
  127. package/mission-control/out/_not-found.txt +18 -0
  128. package/mission-control/out/agents/antigravity.png +0 -0
  129. package/mission-control/out/agents/claude.png +0 -0
  130. package/mission-control/out/agents/codex.png +0 -0
  131. package/mission-control/out/agents/copilot.png +0 -0
  132. package/mission-control/out/agents/cursor.png +0 -0
  133. package/mission-control/out/icon.svg +4 -0
  134. package/mission-control/out/index.html +1 -0
  135. package/mission-control/out/index.txt +21 -0
  136. package/mission-control/out/logo.png +0 -0
  137. package/mission-control/out/logo_transparent.png +0 -0
  138. package/package.json +76 -6
  139. package/scripts/falkor-rebuild.js +38 -0
  140. package/scripts/falkor-seed-demo.js +29 -0
@@ -0,0 +1,201 @@
1
+ const { EventEmitter } = require("events");
2
+
3
+ const POLL_MS = 4000;
4
+ // How long a lock may sit past its own expiry before the mirror drops it. Locks
5
+ // carry a TTL of a few seconds and are renewed by heartbeat, so this is only
6
+ // what keeps a lock from flickering out between two polls.
7
+ const EXPIRY_GRACE_MS = 4000;
8
+ // Consecutive failed polls before the host counts as unreachable. One dropped
9
+ // tunnel request is normal; three in a row (~6s) is not.
10
+ const MAX_MISSES = 3;
11
+ // How long a push keeps the poller quiet. Long enough that a live socket does
12
+ // all the work, short enough that a socket which dies without closing cleanly
13
+ // (the normal way a tunnel disappears) is covered within one interval.
14
+ const PUSH_TRUST_MS = 8000;
15
+
16
+ /**
17
+ * A guest's own lock table is empty: its hooks claim against the host's
18
+ * coordinator (see coordinator/client.js), which is the whole point of a shared
19
+ * room. Without a mirror, every local read of the lock table — /api/locks, the
20
+ * SSE stream the Coordinator tab subscribes to, the dashboard — reports "no
21
+ * locks" and overwrites the host snapshot the dashboard just fetched.
22
+ *
23
+ * Two ways in, one authority. The host's lock table is the only writer; every
24
+ * change it makes is pushed down the room WebSocket and lands here via accept().
25
+ * That makes granting a lock and drawing it the SAME event rather than two
26
+ * subsystems that can disagree — which is exactly how a guest ended up with
27
+ * working locks and a permanently empty board. The poll below is no longer the
28
+ * delivery path, only reconciliation for whenever the socket is down.
29
+ *
30
+ * Only runs while a room is joined as a guest. Solo and host installs keep
31
+ * reading their own table exactly as before.
32
+ */
33
+ class RoomLockMirror extends EventEmitter {
34
+ constructor({ fetchLocks }) {
35
+ super();
36
+ this.fetchLocks = fetchLocks;
37
+ this.locks = [];
38
+ this.reads = [];
39
+ this.updatedAt = 0;
40
+ this.timer = null;
41
+ this.room = null;
42
+ this.failures = 0;
43
+ this.lastPushAt = 0;
44
+ }
45
+
46
+ /**
47
+ * Whether the host is answering. The board is otherwise indistinguishable
48
+ * between "nobody is editing" and "the tunnel is down", which is exactly the
49
+ * confusion a guest hits when their Coordinator screen stays empty.
50
+ */
51
+ get health() {
52
+ if (!this.active) return { joined: false, reachable: true, lastContactAt: null };
53
+ return {
54
+ joined: true,
55
+ reachable: this.updatedAt > 0 && this.failures < MAX_MISSES,
56
+ lastContactAt: this.updatedAt || null,
57
+ };
58
+ }
59
+
60
+ get active() {
61
+ return Boolean(this.room?.url && this.room.role === "guest" && this.room.hostProjectId);
62
+ }
63
+
64
+ /** Has the host ever answered? Until it has, there is nothing to prefer. */
65
+ get fresh() {
66
+ return this.active && this.updatedAt > 0;
67
+ }
68
+
69
+ start(room) {
70
+ const sameRoom =
71
+ this.room?.url === room?.url &&
72
+ this.room?.hostProjectId === room?.hostProjectId &&
73
+ this.room?.memberToken === room?.memberToken;
74
+ // Nothing below may touch this.room while it is still the same room.
75
+ //
76
+ // loadRoom() re-parses room.json and hands back a NEW object every call, and
77
+ // the dashboard calls syncRoomLocks() on every poll — so this used to swap
78
+ // this.room's identity every few seconds. poll() drops any response whose
79
+ // room is no longer current, which meant a poll still in flight at that
80
+ // moment was thrown away. Over loopback a poll finishes in milliseconds and
81
+ // nothing is lost; over an ngrok tunnel it takes long enough that every poll
82
+ // could land inside that window, leaving updatedAt at 0 forever — a guest
83
+ // whose locks worked fine but whose Coordinator board never filled in.
84
+ if (sameRoom && this.timer) return undefined;
85
+ const sameHost =
86
+ this.room?.role === "guest" &&
87
+ room?.role === "guest" &&
88
+ this.room?.hostProjectId &&
89
+ this.room.hostProjectId === room.hostProjectId;
90
+ if (sameHost && this.timer) {
91
+ // Tunnel URL changed (ngrok recycle). Keep the last snapshot so the
92
+ // Coordinator board does not flash empty while we retarget.
93
+ this.room = room;
94
+ this.failures = 0;
95
+ this.poll();
96
+ return undefined;
97
+ }
98
+ this.room = room || null;
99
+ if (!this.active) {
100
+ this.stop({ clear: true });
101
+ return undefined;
102
+ }
103
+ this.stop();
104
+ this.room = room;
105
+ this.timer = setInterval(() => this.poll(), POLL_MS);
106
+ if (this.timer.unref) this.timer.unref();
107
+ this.poll();
108
+ return undefined;
109
+ }
110
+
111
+ stop({ clear } = {}) {
112
+ if (this.timer) clearInterval(this.timer);
113
+ this.timer = null;
114
+ this.failures = 0;
115
+ this.lastPushAt = 0;
116
+ if (clear && (this.locks.length || this.reads.length)) {
117
+ this.locks = [];
118
+ this.reads = [];
119
+ this.updatedAt = 0;
120
+ this.emit("change", { locks: [], reads: [] });
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ /**
126
+ * A lock change pushed straight from the host's table over the room socket.
127
+ * Same commit path as a successful poll, so both routes leave the mirror in
128
+ * exactly one state.
129
+ */
130
+ accept(locks, extras = {}) {
131
+ if (!this.active || !Array.isArray(locks)) return false;
132
+ this.lastPushAt = Date.now();
133
+ const readsChanged =
134
+ Array.isArray(extras.reads) && JSON.stringify(extras.reads) !== JSON.stringify(this.reads);
135
+ if (Array.isArray(extras.reads)) this.reads = extras.reads;
136
+ return this.commit(locks, readsChanged);
137
+ }
138
+
139
+ commit(locks, readsChanged = false) {
140
+ const changed = JSON.stringify(locks) !== JSON.stringify(this.locks) || readsChanged;
141
+ const wasDown = this.failures >= MAX_MISSES;
142
+ this.locks = locks;
143
+ this.updatedAt = Date.now();
144
+ this.failures = 0;
145
+ if (changed || wasDown) this.emit("change", { locks, reads: this.reads || [] });
146
+ return changed;
147
+ }
148
+
149
+ async poll() {
150
+ if (!this.active) return;
151
+ // The socket is delivering; a poll would only re-fetch what we already have.
152
+ if (Date.now() - this.lastPushAt < PUSH_TRUST_MS) return;
153
+ const room = this.room;
154
+ try {
155
+ const snap = await this.fetchLocks(room);
156
+ if (!snap || this.room !== room) return;
157
+ if (Array.isArray(snap)) {
158
+ this.commit(snap);
159
+ return;
160
+ }
161
+ if (!Array.isArray(snap.locks)) return;
162
+ const readsChanged =
163
+ Array.isArray(snap.reads) && JSON.stringify(snap.reads) !== JSON.stringify(this.reads);
164
+ if (Array.isArray(snap.reads)) this.reads = snap.reads;
165
+ this.commit(snap.locks, readsChanged);
166
+ } catch {
167
+ // Host unreachable: keep the last snapshot and let each lock age out on
168
+ // its own TTL, rather than flashing "all locks released" on every dropped
169
+ // tunnel request.
170
+ this.failures += 1;
171
+ if (this.failures === MAX_MISSES) this.emit("change", { locks: this.list() || [] });
172
+ }
173
+ }
174
+
175
+ /**
176
+ * The host's locks, minus anything that has since run out of TTL.
177
+ *
178
+ * A dropped poll used to blank this after 15s, which took the guest's whole
179
+ * Coordinator board down with it — including locks the host was still
180
+ * heartbeating. Ageing entries out by their own expiry instead means an
181
+ * unreachable host decays the board file by file, exactly as a host that went
182
+ * quiet would, and one slow tunnel request changes nothing.
183
+ */
184
+ list() {
185
+ if (!this.fresh) return null;
186
+ const cutoff = Date.now() - EXPIRY_GRACE_MS;
187
+ return this.locks.filter((l) => !l.expiresAt || l.expiresAt > cutoff);
188
+ }
189
+
190
+ /** Host reads, minus anything that has aged out of its own TTL. */
191
+ listReads() {
192
+ if (!this.fresh) return null;
193
+ const now = Date.now();
194
+ return (this.reads || []).filter((r) => {
195
+ const exp = r.expiresAt || (r.at || 0) + 30000;
196
+ return exp > now;
197
+ });
198
+ }
199
+ }
200
+
201
+ module.exports = { RoomLockMirror };
@@ -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 };