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,484 @@
1
+ /**
2
+ * The MCP tool surface, with no transport attached.
3
+ *
4
+ * `backend/mcp/server.js` feeds this over stdio (one process per agent, started
5
+ * by the agent itself); `POST /mcp` on the room host feeds it over HTTP. Both
6
+ * hand in a context object instead of reading process.env here, so the same
7
+ * fifteen tools can answer for "the agent running on this machine" and for
8
+ * "a room member asking through the tunnel" without either transport growing
9
+ * its own copy of the logic.
10
+ *
11
+ * When the user says `/relay ask`, agents call `relay_room_brief` here — not a CLI command.
12
+ */
13
+ const client = require("../coordinator/client");
14
+ const { loadMemory, loadRegistry } = require("../lib/store");
15
+ const { loadRoom } = require("../lib/room");
16
+ const { mergeRoomViews } = require("../lib/roomSync");
17
+ const { detectConflicts } = require("../lib/conflicts");
18
+ const { appendEvent, listEvents, jsonContext } = require("../lib/centralStore");
19
+ const { clientEventId } = require("../lib/memorySync");
20
+ const { dependsOn } = require("../lib/deps");
21
+ const { syncTranscriptsQueued } = require("../lib/transcripts/sync");
22
+ const { compileRoomAsk } = require("../lib/roomAsk");
23
+ const { getCollisionStats, mergeCollisionStats } = require("../lib/collisionMetrics");
24
+
25
+ const PROTOCOL_VERSION = "2024-11-05";
26
+ const DEFAULT_LIMIT = 50;
27
+ const MAX_LIMIT = 200;
28
+ // A read that has to cross the tunnel is answering a question the caller could
29
+ // also answer from its own last sync. Wait briefly, then do exactly that.
30
+ const HOST_TIMEOUT_MS = 1500;
31
+
32
+ const TOOLS = [
33
+ { name: "relay_get_chat_history", description: "Query unified agent chat history (time-ordered, filterable by agent and owner). In a room this spans every member, not just this machine.", inputSchema: { type: "object", properties: { agent: { type: "string" }, ownerLogin: { type: "string" }, limit: { type: "number" } } } },
34
+ { name: "relay_sync", description: "Re-read agent transcripts into unified memory.json history", inputSchema: { type: "object", properties: {} } },
35
+ { name: "relay_claim_file", description: "Acquire exclusive write lock before editing", inputSchema: { type: "object", properties: { file: { type: "string" }, ttl_ms: { type: "number" } }, required: ["file"] } },
36
+ { name: "relay_release_file", description: "Release a lock after editing", inputSchema: { type: "object", properties: { file: { type: "string" } }, required: ["file"] } },
37
+ { name: "relay_status", description: "View full lock table", inputSchema: { type: "object", properties: {} } },
38
+ { name: "relay_report_change", description: "Push a code-change event to Central (JSON, not markdown)", inputSchema: { type: "object", properties: { file: { type: "string" }, content: { type: "string" } } } },
39
+ { name: "relay_get_project_context", description: "Pull Central JSON context (changes, decisions, tasks, agents)", inputSchema: { type: "object", properties: {} } },
40
+ { name: "relay_get_recent_changes", description: "Recent code_edit events from memory.json + Central", inputSchema: { type: "object", properties: {} } },
41
+ { name: "relay_get_decisions", description: "Decision events from Central JSONL", inputSchema: { type: "object", properties: {} } },
42
+ { name: "relay_get_active_tasks", description: "Task events from Central JSONL", inputSchema: { type: "object", properties: {} } },
43
+ {
44
+ name: "relay_report_decision",
45
+ description:
46
+ "Record an architectural/design decision in the shared project memory graph. Pass `files` (repo-relative paths the decision affects) so teammates' agents see it before touching those files.",
47
+ inputSchema: {
48
+ type: "object",
49
+ properties: {
50
+ decision_id: { type: "string" },
51
+ decision: { type: "string" },
52
+ status: { type: "string" },
53
+ files: { type: "array", items: { type: "string" } },
54
+ },
55
+ },
56
+ },
57
+ {
58
+ name: "relay_update_task",
59
+ description: "Append a task event to the shared project memory graph. Pass `files` the task touches.",
60
+ inputSchema: {
61
+ type: "object",
62
+ properties: {
63
+ task_id: { type: "string" },
64
+ description: { type: "string" },
65
+ status: { type: "string" },
66
+ files: { type: "array", items: { type: "string" } },
67
+ },
68
+ },
69
+ },
70
+ { name: "relay_get_conflicts", description: "Overlapping edits in the last 5 minutes", inputSchema: { type: "object", properties: {} } },
71
+ {
72
+ name: "relay_room_brief",
73
+ description:
74
+ "Teammate activity brief for /relay ask — peer chat, code edits, tool activity, and live locks. Read-only; safe on the room MCP endpoint.",
75
+ inputSchema: { type: "object", properties: { limit: { type: "number" } } },
76
+ },
77
+ {
78
+ name: "relay_get_collision_stats",
79
+ description: "Lifetime counters for collisions Relay prevented (blocked claims, held-back patches, merge flags).",
80
+ inputSchema: { type: "object", properties: {} },
81
+ },
82
+ {
83
+ name: "relay_session_brief",
84
+ description:
85
+ "CALL THIS BEFORE you start editing files you haven't touched in this session. Returns the shared project memory for those files from the FalkorDB knowledge graph: who is editing them right now and why, past blocks/collisions, decisions, the prompts behind recent changes (across every agent and teammate), who to ask, and related files in the same work area.",
86
+ inputSchema: {
87
+ type: "object",
88
+ properties: { paths: { type: "array", items: { type: "string" }, description: "repo-relative file paths you are about to work on" } },
89
+ required: ["paths"],
90
+ },
91
+ },
92
+ {
93
+ name: "relay_graph_query",
94
+ description:
95
+ "Ask the shared project memory graph (FalkorDB) a question. Queries: file_story (why a file looks the way it does: every change + the prompt behind it), who_to_ask (people who know a file), contention_trace (why an agent was blocked, what the holder was doing), search_memory (full-text over every agent conversation, needs q), agent_activity (what an agent/person has been doing, optional agent/person), hotspots (PageRank over co-edited files: where coordination matters), overlaps (two agents changed the same file within minutes), decision_trace, session_brief (needs paths), overview.",
96
+ inputSchema: {
97
+ type: "object",
98
+ properties: {
99
+ query: {
100
+ type: "string",
101
+ enum: ["file_story", "who_to_ask", "contention_trace", "search_memory", "agent_activity", "hotspots", "overlaps", "decision_trace", "session_brief", "overview"],
102
+ },
103
+ path: { type: "string", description: "repo-relative file path" },
104
+ paths: { type: "array", items: { type: "string" } },
105
+ q: { type: "string", description: "search words for search_memory" },
106
+ agent: { type: "string", description: "agent label, e.g. Cursor" },
107
+ person: { type: "string", description: "GitHub login" },
108
+ limit: { type: "number" },
109
+ },
110
+ required: ["query"],
111
+ },
112
+ },
113
+ ];
114
+
115
+ const GRAPH_QUERIES = new Set(["file_story", "who_to_ask", "contention_trace", "search_memory", "agent_activity", "hotspots", "overlaps", "decision_trace", "session_brief", "overview"]);
116
+
117
+ /**
118
+ * Inside relay serve the graph module answers directly (this is also the path a
119
+ * room guest reaches through POST /mcp). A per-agent stdio server is a separate
120
+ * process, so it asks the local relay over HTTP, which forwards to the host when
121
+ * this machine is a room guest.
122
+ */
123
+ /** A few graph-derived lines for /relay ask; empty when the graph is off or slow. */
124
+ async function graphBriefSection(ctx) {
125
+ try {
126
+ const [blocks, hot] = await Promise.race([
127
+ Promise.all([graphCall("contention_trace", { limit: 3 }, ctx), graphCall("hotspots", { limit: 3 }, ctx)]),
128
+ new Promise((_, reject) => setTimeout(() => reject(new Error("slow")), 2500)),
129
+ ]);
130
+ const lines = [];
131
+ for (const b of blocks?.rows || []) {
132
+ lines.push(`- ${b.wanter || "an agent"} was blocked on \`${b.file}\` by ${b.holder || "another agent"}${b.holderWasDoing ? ` (working on: "${String(b.holderWasDoing).slice(0, 100)}")` : ""}`);
133
+ }
134
+ if (hot?.rows?.length) lines.push(`- Hotspots (PageRank over co-edits): ${hot.rows.map((h) => `\`${h.file}\``).join(", ")}`);
135
+ return lines.length ? `\n\n## Project memory graph\n${lines.join("\n")}\n` : "";
136
+ } catch {
137
+ return "";
138
+ }
139
+ }
140
+
141
+ async function graphCall(name, args, ctx) {
142
+ if (!GRAPH_QUERIES.has(name)) throw new Error(`unknown graph query "${name}"`);
143
+ const { path: p, paths, q, agent, person, limit } = args || {};
144
+ const clean = { path: p, paths, q, agent, person, limit };
145
+ const falkor = require("../lib/falkor");
146
+ if (falkor.state.cfg) {
147
+ return require("../lib/falkor/routes").query(name, { ...clean, projectId: ctx.projectId, workspace: ctx.workspace });
148
+ }
149
+ const port = client.apiPort(ctx.workspace);
150
+ const { status, body } = await client.postJson(port, `/api/graph/query/${name}`, { ...clean, workspace: ctx.workspace, projectId: ctx.projectId }, 6000);
151
+ if (status >= 400 && !body?.ok) throw new Error(body?.reason || `graph query failed (${status})`);
152
+ return body;
153
+ }
154
+
155
+ /**
156
+ * What a caller reached through the tunnel is allowed to do.
157
+ *
158
+ * Everything absent from this set writes somewhere — the lock table, Central,
159
+ * memory.json — and would write it as *this* machine: the host's workspace
160
+ * path, the host's RELAY_USER, an agentId the host cannot verify. A guest
161
+ * already has a local relay for those, on a transport that works when the
162
+ * tunnel does not, so the remote surface stays a read.
163
+ */
164
+ const READ_ONLY = new Set([
165
+ "relay_get_chat_history",
166
+ "relay_status",
167
+ "relay_get_project_context",
168
+ "relay_get_recent_changes",
169
+ "relay_get_decisions",
170
+ "relay_get_active_tasks",
171
+ "relay_get_conflicts",
172
+ "relay_room_brief",
173
+ "relay_get_collision_stats",
174
+ "relay_session_brief",
175
+ "relay_graph_query",
176
+ ]);
177
+
178
+ function toolsFor(ctx) {
179
+ return ctx?.readOnly ? TOOLS.filter((t) => READ_ONLY.has(t.name)) : TOOLS;
180
+ }
181
+
182
+ function resolveProjectId(workspace) {
183
+ const reg = loadRegistry();
184
+ return (
185
+ process.env.RELAY_PROJECT_ID ||
186
+ reg.projects.find((p) => p.path === workspace)?.id ||
187
+ reg.projects[0]?.id
188
+ );
189
+ }
190
+
191
+ /** The context a stdio server runs with: this machine, full access. */
192
+ function defaultCtx(overrides = {}) {
193
+ const workspace = process.env.RELAY_WORKSPACE_PATH || process.cwd();
194
+ return {
195
+ workspace,
196
+ projectId: resolveProjectId(workspace),
197
+ agentId: process.env.RELAY_AGENT_ID || "mcp:local:stdio",
198
+ agentLabel: process.env.RELAY_AGENT_LABEL || null,
199
+ user: process.env.RELAY_USER || "local",
200
+ owner: process.env.RELAY_OWNER || "local",
201
+ centralProjectId: process.env.RELAY_CENTRAL_PROJECT_ID || null,
202
+ readOnly: false,
203
+ viewer: null,
204
+ ...overrides,
205
+ };
206
+ }
207
+
208
+ function cleanFiles(files) {
209
+ if (!Array.isArray(files)) return undefined;
210
+ const out = files.filter((f) => typeof f === "string" && f.trim()).map((f) => f.trim().slice(0, 300));
211
+ return out.length ? out.slice(0, 50) : undefined;
212
+ }
213
+
214
+ function clampLimit(value) {
215
+ const n = Number(value) || DEFAULT_LIMIT;
216
+ return Math.max(1, Math.min(MAX_LIMIT, n));
217
+ }
218
+
219
+ function locksFromStatus(st) {
220
+ const map = st?.locks;
221
+ if (!map || typeof map !== "object") return [];
222
+ return Object.values(map).filter((l) => l && !l.released);
223
+ }
224
+
225
+ /**
226
+ * The most recent `limit` events, oldest first.
227
+ *
228
+ * memory.history is stored ascending, so the local-only path used to take the
229
+ * tail. mergeRoomViews hands back descending, and taking the tail of *that*
230
+ * would answer a question about right now with the oldest events in the room.
231
+ * Sorting first makes the tool independent of whichever source answered.
232
+ */
233
+ function newest(events, limit) {
234
+ return [...events].sort((a, b) => (a.ts || 0) - (b.ts || 0)).slice(-limit);
235
+ }
236
+
237
+ /** The host aggregate, shaped like the peer bucket roomSync already merges. */
238
+ function asPeer(body) {
239
+ const owner = body.hostLogin || "host";
240
+ const stamp = (rows) =>
241
+ (Array.isArray(rows) ? rows : []).map((row) => ({ ...row, ownerLogin: row.ownerLogin || owner, mine: false }));
242
+ return {
243
+ login: owner,
244
+ updatedAt: Date.now(),
245
+ lastTranscriptSyncAt: Number(body.lastTranscriptSyncAt) || 0,
246
+ chats: stamp(body.chats),
247
+ timeline: stamp(body.timeline),
248
+ activity: stamp(body.activity),
249
+ edits: stamp(body.edits),
250
+ agents: Array.isArray(body.agents) ? body.agents.map((a) => ({ ...a, ownerLogin: a.ownerLogin || owner })) : [],
251
+ stats: body.stats && typeof body.stats === "object" ? body.stats : {},
252
+ };
253
+ }
254
+
255
+ // An agent that calls three read tools in a row is asking one question, and
256
+ // three tunnel round trips would answer it three times. One second is short
257
+ // enough that nothing here is ever meaningfully staler than a single request.
258
+ const HOST_CACHE_MS = 1000;
259
+ let hostCache = null;
260
+
261
+ async function fetchHostState(room) {
262
+ const base = String(room.url).replace(/\/$/, "");
263
+ if (hostCache && hostCache.base === base && Date.now() - hostCache.at < HOST_CACHE_MS) return hostCache.body;
264
+ const query = room.hostProjectId ? `?projectId=${encodeURIComponent(room.hostProjectId)}` : "";
265
+ const res = await fetch(`${base}/api/room/state${query}`, {
266
+ signal: AbortSignal.timeout(HOST_TIMEOUT_MS),
267
+ headers: {
268
+ "ngrok-skip-browser-warning": "relay",
269
+ ...(room.memberToken ? { "x-relay-room-token": room.memberToken } : {}),
270
+ },
271
+ });
272
+ if (!res.ok) throw new Error(`room/state ${res.status}`);
273
+ const body = await res.json();
274
+ if (!body?.ok) throw new Error("room/state not ok");
275
+ hostCache = { base, at: Date.now(), body };
276
+ return body;
277
+ }
278
+
279
+ function peerAgeMs(memory) {
280
+ const peers = Object.values(memory?.roomPeers || {});
281
+ if (!peers.length) return 0;
282
+ const oldest = Math.min(...peers.map((p) => p?.updatedAt || 0));
283
+ return oldest ? Date.now() - oldest : 0;
284
+ }
285
+
286
+ /**
287
+ * What this machine can see of the room, best source first.
288
+ *
289
+ * A host already holds every peer snapshot locally, so it never leaves the
290
+ * process. A guest asks the host — and when the tunnel is down falls back to
291
+ * the snapshots roomSync parked in its own memory.json, which is stale but is
292
+ * the difference between a degraded answer and no answer. Solo, mergeRoomViews
293
+ * returns the local arrays untouched, so this costs nothing and changes
294
+ * nothing for anyone not in a room.
295
+ */
296
+ async function roomView(ctx, memory) {
297
+ const room = loadRoom(ctx.workspace);
298
+ if (room?.role === "guest" && room.url) {
299
+ try {
300
+ const peer = asPeer(await fetchHostState(room));
301
+ const merged = mergeRoomViews({ ...memory, roomPeers: { ...(memory.roomPeers || {}), host: peer } });
302
+ return {
303
+ ...merged,
304
+ room: {
305
+ source: "host",
306
+ peers: merged.peerLogins,
307
+ peer_snapshot_age_ms: Math.max(0, Date.now() - (hostCache?.at || Date.now())),
308
+ },
309
+ };
310
+ } catch {
311
+ /* tunnel down or host asleep — answer from the last sync instead */
312
+ }
313
+ }
314
+ const merged = mergeRoomViews(memory);
315
+ return {
316
+ ...merged,
317
+ room: {
318
+ source: merged.peerLogins.length ? "local_snapshot" : "solo",
319
+ peers: merged.peerLogins,
320
+ peer_snapshot_age_ms: peerAgeMs(memory),
321
+ },
322
+ };
323
+ }
324
+
325
+ /** Overrides win, but only where the caller actually supplied one. */
326
+ function withCtx(context = {}) {
327
+ const supplied = Object.fromEntries(Object.entries(context).filter(([, v]) => v !== undefined));
328
+ return { ...defaultCtx(), ...supplied };
329
+ }
330
+
331
+ async function callTool(name, args = {}, context = {}) {
332
+ const ctx = withCtx(context);
333
+ if (ctx.readOnly && !READ_ONLY.has(name)) {
334
+ throw new Error(`${name} is not available to remote callers - run it against your own relay.`);
335
+ }
336
+ const pid = ctx.projectId;
337
+ const memory = pid ? loadMemory(pid) : { chats: [], timeline: [], edits: [] };
338
+ const centralId = ctx.centralProjectId || pid;
339
+
340
+ switch (name) {
341
+ case "relay_get_chat_history": {
342
+ const limit = clampLimit(args.limit);
343
+ const view = await roomView(ctx, memory);
344
+ const history = (view.history || []).filter(
345
+ (e) => (!args.agent || e.agent === args.agent) && (!args.ownerLogin || e.ownerLogin === args.ownerLogin)
346
+ );
347
+ const chats = (view.chats || []).filter(
348
+ (c) => (!args.agent || c.agent === args.agent) && (!args.ownerLogin || c.ownerLogin === args.ownerLogin)
349
+ );
350
+ return {
351
+ history: newest(history, limit),
352
+ chats: chats.slice(0, limit),
353
+ timeline: (view.timeline || []).slice(0, limit),
354
+ room: view.room,
355
+ };
356
+ }
357
+ case "relay_sync": {
358
+ const reg = loadRegistry();
359
+ const project = reg.projects.find((p) => p.id === pid);
360
+ if (!project) return { ok: false, error: "no_project" };
361
+ const next = await syncTranscriptsQueued(project, { ownerLogin: ctx.owner });
362
+ return { ok: true, events: (next.history || []).length, chats: (next.chats || []).length };
363
+ }
364
+ case "relay_claim_file":
365
+ return client.claimFile(ctx.workspace, ctx.agentId, args.file, args.ttl_ms, {
366
+ dependsOn: dependsOn(ctx.workspace, args.file),
367
+ });
368
+ case "relay_release_file":
369
+ return client.releaseFile(ctx.workspace, ctx.agentId, args.file);
370
+ case "relay_status":
371
+ return client.status(ctx.workspace);
372
+ case "relay_get_recent_changes": {
373
+ const view = await roomView(ctx, memory);
374
+ return { edits: view.edits || [], room: view.room };
375
+ }
376
+ case "relay_get_conflicts": {
377
+ const view = await roomView(ctx, memory);
378
+ return { conflicts: detectConflicts({ ...memory, edits: view.edits || [] }), room: view.room };
379
+ }
380
+ case "relay_room_brief": {
381
+ const limit = Math.min(80, Math.max(5, clampLimit(args.limit || 30)));
382
+ const reg = loadRegistry();
383
+ const project = reg.projects.find((p) => p.id === pid);
384
+ if (project) {
385
+ try {
386
+ await syncTranscriptsQueued(project, { ownerLogin: ctx.owner || ctx.user || "local" });
387
+ } catch {
388
+ /* transcripts are best-effort */
389
+ }
390
+ }
391
+ const view = await roomView(ctx, memory);
392
+ const room = loadRoom(ctx.workspace);
393
+ const st = await client.status(ctx.workspace);
394
+ const brief = compileRoomAsk({
395
+ view,
396
+ room,
397
+ login: ctx.owner || ctx.user || "local",
398
+ locks: locksFromStatus(st),
399
+ limit,
400
+ });
401
+ return { markdown: brief.markdown + (await graphBriefSection(ctx)), data: brief.json, room: view.room };
402
+ }
403
+ case "relay_get_collision_stats": {
404
+ const login = ctx.owner || ctx.user || "local";
405
+ const merged = mergeCollisionStats(memory, { localLogin: login, projectId: pid });
406
+ return { collisions: merged, local: getCollisionStats(pid), room: (await roomView(ctx, memory)).room };
407
+ }
408
+ case "relay_session_brief":
409
+ return graphCall("session_brief", args, ctx);
410
+ case "relay_graph_query":
411
+ return graphCall(args.query, args, ctx);
412
+ case "relay_report_change":
413
+ return appendEvent(centralId, {
414
+ kind: "change",
415
+ client_event_id: clientEventId(Date.now(), args.file || "", 0),
416
+ user: ctx.user,
417
+ content: args.content,
418
+ file: args.file,
419
+ agent_source: ctx.agentId,
420
+ });
421
+ case "relay_get_project_context": {
422
+ const events = await listEvents(centralId, { limit: 500 });
423
+ return jsonContext(events);
424
+ }
425
+ case "relay_get_decisions":
426
+ return { decisions: jsonContext(await listEvents(centralId, { kind: "decision", limit: 200 })).decisions };
427
+ case "relay_get_active_tasks":
428
+ return { tasks: jsonContext(await listEvents(centralId, { kind: "task", limit: 200 })).tasks };
429
+ case "relay_report_decision":
430
+ return appendEvent(centralId, {
431
+ kind: "decision",
432
+ client_event_id: clientEventId(Date.now(), args.decision_id || "", 0),
433
+ user: ctx.user,
434
+ content: args.decision,
435
+ decision_id: args.decision_id,
436
+ status: args.status || "open",
437
+ agent_source: ctx.agentId,
438
+ agent_label: ctx.agentLabel,
439
+ files: cleanFiles(args.files),
440
+ });
441
+ case "relay_update_task":
442
+ return appendEvent(centralId, {
443
+ kind: "task",
444
+ client_event_id: clientEventId(Date.now(), args.task_id || "", 0),
445
+ user: ctx.user,
446
+ content: args.description,
447
+ task_id: args.task_id,
448
+ status: args.status || "open",
449
+ agent_source: ctx.agentId,
450
+ agent_label: ctx.agentLabel,
451
+ files: cleanFiles(args.files),
452
+ });
453
+ default:
454
+ throw new Error(`Unknown tool ${name}`);
455
+ }
456
+ }
457
+
458
+ /**
459
+ * One JSON-RPC message in, one reply out (or null for a notification, which
460
+ * takes no reply on either transport). Throws on tool failure; the transport
461
+ * decides how to word the error.
462
+ */
463
+ async function handleRpc(msg, ctx = {}) {
464
+ const { id, method, params } = msg || {};
465
+ if (method === "initialize") {
466
+ return {
467
+ id,
468
+ result: {
469
+ protocolVersion: params?.protocolVersion || PROTOCOL_VERSION,
470
+ serverInfo: { name: "relay", version: "0.1.0" },
471
+ capabilities: { tools: {} },
472
+ },
473
+ };
474
+ }
475
+ if (method === "tools/list") return { id, result: { tools: toolsFor(ctx) } };
476
+ if (method === "tools/call") {
477
+ const result = await callTool(params?.name, params?.arguments || {}, ctx);
478
+ return { id, result: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] } };
479
+ }
480
+ if (id != null) return { id, result: {} };
481
+ return null;
482
+ }
483
+
484
+ module.exports = { TOOLS, READ_ONLY, toolsFor, callTool, handleRpc, defaultCtx, PROTOCOL_VERSION };