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,221 @@
1
+ // Shared helper for seeding the isolated 5-agent demo sandbox (`demo_<projectId>`)
2
+ // and rebuilding a real workspace graph (`<projectId>`) from real transcripts & history.
3
+ const path = require("path");
4
+ const schema = require("./schema");
5
+ const cursor = require("./cursor");
6
+ const { projectHistory, projectPeer } = require("./project");
7
+ const { writeProjection } = require("./write");
8
+ const { ingestHistory, ingestPeer } = require("./ingest");
9
+ const coord = require("./coord");
10
+ const central = require("./central");
11
+ const analytics = require("./analytics");
12
+ const intents = require("./intents");
13
+ const { loadRegistry, loadMemory } = require("../store");
14
+
15
+ const WS = process.platform === "win32" ? "C:\\demo\\relay-app" : "/workspace/relay-app";
16
+ const wsFile = (...parts) => path.join(WS, ...parts);
17
+
18
+ const S_CLAUDE = "52b29a54-d8fb-4091-ab60-463ac73c9d6c";
19
+ const S_CURSOR = "3e62675d-9b0f-4d99-b17d-c1d875fe98dc";
20
+ const S_CODEX = "019e1fdf-e54d-70e2-ae26-d0d7103a404f";
21
+ const S_COPILOT = "fcc51653-804f-4e4f-9330-347a42abd57c";
22
+ const S_ANTI = "46d9adf1-1b25-4cf7-bd41-da8309d19f08";
23
+
24
+ function demoGraphId(projectId) {
25
+ const base = String(projectId || "sandbox").replace(/^demo_/, "");
26
+ return `demo_${base}`;
27
+ }
28
+
29
+ function buildDemoHistory(now = Date.now()) {
30
+ const m = (minsAgo) => now - minsAgo * 60_000;
31
+ return [
32
+ // Session 1: Claude Code (@alice) switching login flow to JWT tokens
33
+ { id: "d01", agent: "Claude Code", ownerLogin: "alice", sessionId: S_CLAUDE, ts: m(25), role: "user", kind: "message", text: "switch the login flow to short-lived JWT tokens with refresh rotation" },
34
+ { id: "d02", agent: "Claude Code", ownerLogin: "alice", sessionId: S_CLAUDE, ts: m(24.5), role: "assistant", kind: "message", text: "I'll update src/auth.ts to issue 15-minute access JWTs and rotate refresh tokens in src/session.ts." },
35
+ { id: "d03", agent: "Claude Code", ownerLogin: "alice", sessionId: S_CLAUDE, ts: m(24), role: "tool", kind: "code_edit", text: "Edit auth.ts", file: "auth.ts", path: wsFile("src", "auth.ts"), diff: "--- a/src/auth.ts\n+++ b/src/auth.ts\n@@ -10,4 +10,8 @@\n-export function loginWithCookie(user) {\n+export function issueJwtPair(user) {\n+ return signJwt(user, { expiresIn: '15m' });\n" },
36
+ { id: "d04", agent: "Claude Code", ownerLogin: "alice", sessionId: S_CLAUDE, ts: m(23), role: "tool", kind: "code_edit", text: "Edit session.ts", file: "session.ts", path: wsFile("src", "session.ts"), diff: "--- a/src/session.ts\n+++ b/src/session.ts\n@@ -1,3 +1,6 @@\n+export function rotateRefreshToken(token) {\n+ return revokeAndMint(token);\n+}\n" },
37
+ { id: "d05", agent: "Claude Code", ownerLogin: "alice", sessionId: S_CLAUDE, ts: m(22), role: "user", kind: "message", text: "continue" },
38
+ { id: "d06", agent: "Claude Code", ownerLogin: "alice", sessionId: S_CLAUDE, ts: m(21.5), role: "tool", kind: "code_edit", text: "Edit middleware.ts", file: "middleware.ts", path: wsFile("src", "middleware.ts"), diff: "--- a/src/middleware.ts\n+++ b/src/middleware.ts\n@@ -4,2 +4,5 @@\n+export function verifyBearerJwt(req, res, next) {\n+ req.user = verifyJwt(req.headers.authorization);\n+}\n" },
39
+
40
+ // Session 2: Codex (@alice) hardening rate limits on auth & api routes
41
+ { id: "d07", agent: "Codex", ownerLogin: "alice", sessionId: S_CODEX, ts: m(18), role: "user", kind: "message", text: "add sliding-window rate limiting to login and token refresh endpoints" },
42
+ { id: "d08", agent: "Codex", ownerLogin: "alice", sessionId: S_CODEX, ts: m(17.5), role: "assistant", kind: "message", text: "Applying rate limiter middleware to src/routes.ts and src/auth.ts." },
43
+ { id: "d09", agent: "Codex", ownerLogin: "alice", sessionId: S_CODEX, ts: m(17), role: "tool", kind: "code_edit", text: "apply_patch auth.ts", file: "auth.ts", path: wsFile("src", "auth.ts"), diff: "+import { rateLimit } from './rateLimit';\n+export const authLimiter = rateLimit({ windowMs: 60000, max: 10 });\n" },
44
+ { id: "d10", agent: "Codex", ownerLogin: "alice", sessionId: S_CODEX, ts: m(16.8), role: "tool", kind: "code_edit", text: "apply_patch routes.ts", file: "routes.ts", path: wsFile("src", "routes.ts"), diff: "+app.post('/api/token/refresh', authLimiter, rotateRefreshToken);\n" },
45
+
46
+ // Session 3: Antigravity (@alice) updating UI auth client
47
+ { id: "d11", agent: "Antigravity", ownerLogin: "alice", sessionId: S_ANTI, ts: m(12), role: "user", kind: "message", text: "wire automatic silent JWT refresh into the frontend API client" },
48
+ { id: "d12", agent: "Antigravity", ownerLogin: "alice", sessionId: S_ANTI, ts: m(11.5), role: "assistant", kind: "message", text: "Updating src/client.ts and src/auth.ts types to retry 401 responses after refreshing the access token." },
49
+ { id: "d13", agent: "Antigravity", ownerLogin: "alice", sessionId: S_ANTI, ts: m(11), role: "tool", kind: "code_edit", text: "replace_file_content client.ts", file: "client.ts", path: wsFile("src", "client.ts"), diff: "+if (res.status === 401) await refreshAccessToken();\n" },
50
+ { id: "d14", agent: "Antigravity", ownerLogin: "alice", sessionId: S_ANTI, ts: m(10.5), role: "tool", kind: "code_edit", text: "replace_file_content auth.ts", file: "auth.ts", path: wsFile("src", "auth.ts"), diff: "+export type TokenPair = { accessToken: string; refreshToken: string; expiresIn: number };\n" },
51
+ ];
52
+ }
53
+
54
+ function buildPeerSnapshot(now = Date.now()) {
55
+ const m = (minsAgo) => now - minsAgo * 60_000;
56
+ return {
57
+ login: "bob",
58
+ chats: [
59
+ {
60
+ id: `chat_cursor_${S_CURSOR}`,
61
+ agent: "Cursor",
62
+ ownerLogin: "bob",
63
+ sessionId: S_CURSOR,
64
+ messages: [
65
+ { role: "user", kind: "message", text: "add logout revocation endpoint and clear refresh token store", ts: m(20) },
66
+ { role: "assistant", kind: "message", text: "Adding token revocation in src/auth.ts and src/session.ts.", ts: m(19.5) },
67
+ { role: "tool", kind: "code_edit", text: "StrReplace auth.ts", file: "auth.ts", ts: m(19) },
68
+ { role: "tool", kind: "code_edit", text: "StrReplace session.ts", file: "session.ts", ts: m(18.5) },
69
+ ],
70
+ },
71
+ {
72
+ id: `chat_copilot_${S_COPILOT}`,
73
+ agent: "Copilot",
74
+ ownerLogin: "bob",
75
+ sessionId: S_COPILOT,
76
+ messages: [
77
+ { role: "user", kind: "message", text: "add integration tests for JWT expiry and refresh rotation", ts: m(8) },
78
+ { role: "assistant", kind: "message", text: "Creating tests/auth.test.ts and exporting verify helpers from src/auth.ts.", ts: m(7.5) },
79
+ { role: "tool", kind: "code_edit", text: "replace_string_in_file auth.ts", file: "auth.ts", ts: m(7) },
80
+ { role: "tool", kind: "code_edit", text: "create_file auth.test.ts", file: "auth.test.ts", ts: m(6.5) },
81
+ ],
82
+ },
83
+ ],
84
+ edits: [
85
+ { id: "e_bob01", agent: "Cursor", ownerLogin: "bob", file: "src/auth.ts", ts: m(19), diff: "+export async function revokeSession(jti: string) { await denylist.add(jti); }\n" },
86
+ { id: "e_bob02", agent: "Cursor", ownerLogin: "bob", file: "src/session.ts", ts: m(18.5), diff: "+export const denylist = new Set<string>();\n" },
87
+ { id: "e_bob03", agent: "Copilot", ownerLogin: "bob", file: "src/auth.ts", ts: m(7), diff: "+export { verifyJwt } from './jwt';\n" },
88
+ { id: "e_bob04", agent: "Copilot", ownerLogin: "bob", file: "tests/auth.test.ts", ts: m(6.5), diff: "+test('rotates refresh token and rejects replay', async () => {});\n" },
89
+ ],
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Seeds the isolated demo graph (`demo_<projectId>`) without touching the real workspace graph.
95
+ * Note: does NOT pollute the live `intents` cache for real workspace sessions unless `updateIntents` is set.
96
+ */
97
+ async function seedDemoGraph(falkor, projectId, opts = {}) {
98
+ const targetId = opts.rawTarget ? String(projectId || "demo_sandbox") : demoGraphId(projectId);
99
+ const { client } = falkor.state;
100
+ const now = Date.now();
101
+ await client.deleteGraph(targetId).catch(() => undefined);
102
+ schema.forget(targetId);
103
+ await falkor.ensureProject(targetId);
104
+
105
+ const localProj = projectHistory(buildDemoHistory(now), { workspacePath: WS, storeText: true });
106
+ await writeProjection(client, targetId, localProj);
107
+
108
+ const peerProj = projectPeer(buildPeerSnapshot(now), { login: "bob", trustRowOwners: false, storeText: true });
109
+ await writeProjection(client, targetId, peerProj);
110
+
111
+ if (opts.updateIntents) {
112
+ for (const i of localProj.intents.values()) intents.remember(i);
113
+ for (const i of peerProj.intents.values()) intents.remember(i);
114
+ }
115
+
116
+ coord.configure(falkor);
117
+ coord.recordHeld(targetId, `Claude Code:ALICE-DESK:${S_CLAUDE}`, "src/auth.ts", now - 2 * 60_000);
118
+ coord.recordContention(targetId, {
119
+ kind: "claim_blocked",
120
+ file: "src/auth.ts",
121
+ wanter: `Cursor:BOB-LAPTOP:${S_CURSOR}`,
122
+ holder: `Claude Code:ALICE-DESK:${S_CLAUDE}`,
123
+ reason: `File \`src/auth.ts\` is locked by Claude Code. They're working on: "switch the login flow to short-lived JWT tokens with refresh rotation" (2 min ago).`,
124
+ ts: now - 20 * 60_000,
125
+ holderIntentText: "switch the login flow to short-lived JWT tokens with refresh rotation",
126
+ });
127
+ await coord.flushAll();
128
+
129
+ central.configure(falkor);
130
+ const decisions = [
131
+ central.toRow({
132
+ event_id: 101,
133
+ kind: "decision",
134
+ decision_id: "jwt-refresh-rotation",
135
+ content: "Use 15-minute stateless JWT access tokens paired with single-use rotating refresh tokens in src/auth.ts and src/session.ts.",
136
+ status: "accepted",
137
+ user: "alice",
138
+ agent_label: "Claude Code",
139
+ files: ["src/auth.ts", "src/session.ts", "src/middleware.ts"],
140
+ ts: new Date(now - 21 * 60_000).toISOString(),
141
+ }),
142
+ central.toRow({
143
+ event_id: 102,
144
+ kind: "decision",
145
+ decision_id: "auth-rate-limit",
146
+ content: "Enforce 10 req/min sliding-window rate limit on /api/token/refresh and login routes.",
147
+ status: "accepted",
148
+ user: "alice",
149
+ agent_label: "Codex",
150
+ files: ["src/auth.ts", "src/routes.ts"],
151
+ ts: new Date(now - 16 * 60_000).toISOString(),
152
+ }),
153
+ ].filter(Boolean);
154
+ await central.writeRows(targetId, decisions);
155
+
156
+ analytics.configure(falkor);
157
+ const summary = await analytics.run(targetId, { full: true });
158
+ falkor.notify?.(projectId, { demo: true, summary });
159
+ return { targetId, summary };
160
+ }
161
+
162
+ /**
163
+ * Drops and rebuilds a real workspace graph from its local `memory.json` + roomPeers + central JSONL.
164
+ */
165
+ async function rebuildProjectGraph(falkor, projectId, opts = {}) {
166
+ const projects = loadRegistry().projects || [];
167
+ const project = projects.find((p) => p.id === projectId || p.name === projectId);
168
+ if (!project) throw Object.assign(new Error(`Unknown project "${projectId}"`), { status: 404 });
169
+ const { client } = falkor.state;
170
+ if (!opts.keep) {
171
+ await client.deleteGraph(project.id);
172
+ schema.forget(project.id);
173
+ cursor.reset(project.id);
174
+ }
175
+ await falkor.ensureProject(project.id);
176
+ const memory = loadMemory(project.id);
177
+ const history = memory.history || [];
178
+ const t = Date.now();
179
+ const result = await ingestHistory(project.id, history, project.path, { full: true });
180
+ const peers = Object.entries(memory.roomPeers || {});
181
+ for (const [key, snapshot] of peers) {
182
+ await ingestPeer(project.id, { snapshot, login: snapshot.login || key, trustRowOwners: key === "host" });
183
+ }
184
+ // Re-ingest Central JSONL decisions/tasks if present
185
+ try {
186
+ central.configure(falkor);
187
+ const { eventsPath } = require("../centralStore");
188
+ const fs = require("fs");
189
+ const centralId = project.centralProjectId || project.id;
190
+ const file = eventsPath(centralId);
191
+ if (fs.existsSync(file)) {
192
+ const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
193
+ const rows = lines
194
+ .map((l) => {
195
+ try {
196
+ return central.toRow(JSON.parse(l));
197
+ } catch {
198
+ return null;
199
+ }
200
+ })
201
+ .filter(Boolean);
202
+ if (rows.length) await central.writeRows(project.id, rows);
203
+ }
204
+ } catch {
205
+ /* optional central events */
206
+ }
207
+ analytics.configure(falkor);
208
+ const stats = await analytics.run(project.id, { full: true });
209
+ falkor.notify?.(project.id, { rebuilt: true, stats });
210
+ return {
211
+ projectId: project.id,
212
+ path: project.path,
213
+ events: history.length,
214
+ peers: peers.length,
215
+ ingest: result,
216
+ analytics: stats,
217
+ ms: Date.now() - t,
218
+ };
219
+ }
220
+
221
+ module.exports = { demoGraphId, buildDemoHistory, buildPeerSnapshot, seedDemoGraph, rebuildProjectGraph };
@@ -0,0 +1,85 @@
1
+ // Synthetic events shaped like real unifyEvent output (no user data).
2
+ const path = require("path");
3
+
4
+ // A native absolute workspace, so path handling is exercised the way it runs on each OS.
5
+ const WS = process.platform === "win32" ? "C:\\Users\\dev\\demo" : "/home/dev/demo";
6
+ const wsPath = (...parts) => path.join(WS, ...parts);
7
+ // Agents sometimes report Windows paths with forward slashes; on POSIX this is the same path.
8
+ const slashed = (p) => p.split(path.sep).join("/");
9
+ const CLAUDE_SID = "52b29a54-d8fb-4091-ab60-463ac73c9d6c";
10
+ const CURSOR_SID = "3e62675d-9b0f-4d99-b17d-c1d875fe98dc";
11
+
12
+ let n = 0;
13
+ function ev(fields) {
14
+ n += 1;
15
+ return {
16
+ id: `ev${String(n).padStart(4, "0")}`,
17
+ ownerLogin: "alice",
18
+ role: "assistant",
19
+ kind: "message",
20
+ text: "",
21
+ file: null,
22
+ path: null,
23
+ diff: "",
24
+ ...fields,
25
+ };
26
+ }
27
+
28
+ function demoHistory() {
29
+ n = 0;
30
+ return [
31
+ ev({ agent: "Claude Code", sessionId: CLAUDE_SID, ts: 1000, role: "user", text: "switch the login flow to JWT tokens" }),
32
+ ev({ agent: "Cursor", sessionId: CURSOR_SID, ts: 1500.685, role: "user", text: "make the navbar sticky" }),
33
+ ev({ agent: "Claude Code", sessionId: CLAUDE_SID, ts: 2000, role: "assistant", kind: "thinking", text: "hmm, issuer first" }),
34
+ ev({ agent: "Claude Code", sessionId: CLAUDE_SID, ts: 2100, role: "assistant", text: "I'll update the token issuer first." }),
35
+ ev({
36
+ agent: "Claude Code",
37
+ sessionId: CLAUDE_SID,
38
+ ts: 3000,
39
+ role: "tool",
40
+ kind: "code_edit",
41
+ text: "Edit auth.ts",
42
+ file: "auth.ts",
43
+ path: slashed(wsPath("src", "Auth.ts")),
44
+ diff: "--- a/auth.ts\n+++ b/auth.ts\n@@ -1,2 +1,3 @@\n-old\n+new\n+newer\n",
45
+ }),
46
+ ev({
47
+ agent: "Cursor",
48
+ sessionId: CURSOR_SID,
49
+ ts: 3500.685,
50
+ role: "tool",
51
+ kind: "code_edit",
52
+ text: "Write navbar.css",
53
+ file: "navbar.css",
54
+ path: wsPath("styles", "navbar.css"),
55
+ diff: "+.nav{position:sticky}\n",
56
+ }),
57
+ ev({ agent: "Claude Code", sessionId: CLAUDE_SID, ts: 4000, role: "user", text: "also add refresh tokens, api_key=supersecretvalue123" }),
58
+ ev({
59
+ agent: "Claude Code",
60
+ sessionId: CLAUDE_SID,
61
+ ts: 5000,
62
+ role: "tool",
63
+ kind: "code_edit",
64
+ text: "Edit auth.ts",
65
+ file: "auth.ts",
66
+ path: wsPath("src", "auth.ts"),
67
+ diff: "+refresh\n",
68
+ }),
69
+ ev({
70
+ agent: "Claude Code",
71
+ sessionId: CLAUDE_SID,
72
+ ts: 5100,
73
+ role: "tool",
74
+ kind: "code_edit",
75
+ text: "Write notes.md",
76
+ file: "notes.md",
77
+ path: path.join(path.dirname(WS), "other-repo", "notes.md"),
78
+ diff: "+x\n",
79
+ }),
80
+ ev({ agent: "Antigravity", sessionId: "session", ts: 6000, role: "user", text: "what files do you see" }),
81
+ ev({ agent: "Codex", sessionId: "codex-1", ts: 7000, role: "tool", kind: "code_edit", text: "apply_patch", file: null, path: null, diff: "" }),
82
+ ];
83
+ }
84
+
85
+ module.exports = { demoHistory, WS, wsPath, CLAUDE_SID, CURSOR_SID };
@@ -0,0 +1,146 @@
1
+ const { readConfig, loadFileConfig, saveFileConfig, configPath, validUrl } = require("./config");
2
+ const { FalkorClient } = require("./client");
3
+ const schema = require("./schema");
4
+
5
+ const state = {
6
+ cfg: null,
7
+ client: null,
8
+ deps: {},
9
+ projects: new Map(),
10
+ started: false,
11
+ };
12
+
13
+ function projectStats(projectId) {
14
+ if (!state.projects.has(projectId)) {
15
+ state.projects.set(projectId, {
16
+ schemaVersion: null,
17
+ needsRebuild: false,
18
+ lastIngestAt: null,
19
+ lastIngestCount: 0,
20
+ bufferDrops: 0,
21
+ droppedRows: 0,
22
+ pendingLinks: 0,
23
+ lastError: null,
24
+ });
25
+ }
26
+ return state.projects.get(projectId);
27
+ }
28
+
29
+ function enabled() {
30
+ return Boolean(state.started && state.cfg?.enabled);
31
+ }
32
+
33
+ async function ensureProject(projectId) {
34
+ const info = await schema.ensure(state.client, projectId);
35
+ const stats = projectStats(projectId);
36
+ stats.schemaVersion = info.schemaVersion;
37
+ stats.needsRebuild = info.needsRebuild;
38
+ return info;
39
+ }
40
+
41
+ /** After any write: refresh the UI and let the analytics job know there's new structure. */
42
+ function notify(projectId, payload = {}) {
43
+ try {
44
+ // Not "graph": that event name already carries the dependency graph to the dashboard.
45
+ state.deps.sse?.emit?.("memory-graph", { workspaceId: projectId, at: Date.now(), ...payload });
46
+ } catch {
47
+ /* UI refresh is best-effort */
48
+ }
49
+ require("./bus").emit("graph-written", { projectId });
50
+ }
51
+
52
+ function maskUrl(url) {
53
+ return url ? url.replace(/\/\/[^@/]*@/, "//***@") : null;
54
+ }
55
+
56
+ function health() {
57
+ return {
58
+ enabled: Boolean(state.cfg?.enabled),
59
+ running: state.started,
60
+ url: maskUrl(state.cfg?.url),
61
+ source: state.cfg?.source || null,
62
+ configFile: configPath(),
63
+ storeText: state.cfg?.storeText ?? null,
64
+ breaker: state.client ? state.client.status() : null,
65
+ projects: Object.fromEntries(state.projects),
66
+ };
67
+ }
68
+
69
+ /** Starts the client and every module once; used at boot and by a runtime enable. */
70
+ function startGraph() {
71
+ if (state.started) return;
72
+ state.client = new FalkorClient(state.cfg);
73
+ state.started = true;
74
+ for (const name of MODULES) {
75
+ try {
76
+ require(name).start(module.exports);
77
+ } catch (err) {
78
+ console.warn(`[relay-falkor] ${name} failed to start: ${err.message || err}`);
79
+ }
80
+ }
81
+ state.client
82
+ .connection()
83
+ .then(() => console.log(`[relay-falkor] connected ${maskUrl(state.cfg.url)}`))
84
+ .catch((err) => console.warn(`[relay-falkor] not reachable yet (${err.message || err}); relay continues without it`));
85
+ }
86
+
87
+ /**
88
+ * Turns the graph on in a running relay serve (`relay graph enable`, Mission Control's
89
+ * Enable button). Saves the URL so the next start remembers it.
90
+ */
91
+ async function enableAtRuntime(url) {
92
+ const target = String(url || "").trim() || "falkor://127.0.0.1:6379";
93
+ if (!validUrl(target)) throw Object.assign(new Error("URL must look like falkor://host:port"), { status: 400 });
94
+ const saved = { ...(loadFileConfig() || {}), url: target };
95
+ delete saved.disabled;
96
+ saveFileConfig(saved);
97
+ if (state.started) return { ...health(), note: "already running; restart relay serve to switch databases" };
98
+ state.cfg = readConfig();
99
+ if (!state.cfg.enabled) throw Object.assign(new Error("RELAY_FALKOR_DISABLED is set in this process"), { status: 409 });
100
+ startGraph();
101
+ try {
102
+ await state.client.connection();
103
+ } catch (err) {
104
+ return { ...health(), warning: `saved, but FalkorDB is not reachable yet: ${err.message || err}` };
105
+ }
106
+ return health();
107
+ }
108
+
109
+ function registerRoutes(app) {
110
+ app.get("/api/graph/health", (_req, res) => res.json(health()));
111
+ app.post("/api/graph/enable", async (req, res) => {
112
+ // Writes this machine's config: never through the room tunnel.
113
+ if (require("../roomAuth").isRemoteRequest(req)) return res.status(403).json({ ok: false, reason: "local requests only" });
114
+ try {
115
+ res.json({ ok: true, ...(await enableAtRuntime(req.body?.url)) });
116
+ } catch (err) {
117
+ res.status(err.status || 500).json({ ok: false, reason: String(err.message || err) });
118
+ }
119
+ });
120
+ // Registered even when disabled here: a room guest without FalkorDB still reads the host's graph.
121
+ require("./routes").register(app, module.exports);
122
+ }
123
+
124
+ /**
125
+ * Wires the memory graph into relay serve. Never throws: a broken or absent
126
+ * FalkorDB must leave every existing relay behavior untouched.
127
+ */
128
+ function init(app, deps = {}) {
129
+ try {
130
+ state.cfg = readConfig();
131
+ state.deps = deps;
132
+ registerRoutes(app);
133
+ if (!state.cfg.enabled) {
134
+ console.log("[relay-falkor] memory graph off (run `relay graph enable`, or set RELAY_FALKOR_URL)");
135
+ return health();
136
+ }
137
+ startGraph();
138
+ } catch (err) {
139
+ console.warn(`[relay-falkor] init failed: ${err.message || err}`);
140
+ }
141
+ return health();
142
+ }
143
+
144
+ const MODULES = ["./ingest", "./coord", "./central", "./analytics"];
145
+
146
+ module.exports = { init, state, enabled, ensureProject, projectStats, health, notify, enableAtRuntime };
@@ -0,0 +1,233 @@
1
+ const { bus } = require("./bus");
2
+ const cursor = require("./cursor");
3
+ const intents = require("./intents");
4
+ const { projectHistory, projectPatches, projectPeer } = require("./project");
5
+ const { writeProjection } = require("./write");
6
+
7
+ const DEBOUNCE_MS = 2000;
8
+ const pending = new Map();
9
+ const running = new Map();
10
+ let api = null;
11
+
12
+ function log(projectId, msg) {
13
+ console.log(`[relay-falkor] ${projectId}: ${msg}`);
14
+ }
15
+
16
+ // The cursor says what we sent; the graph's Meta.lastIngestAt says what it kept. RDB
17
+ // snapshots can lose the last few seconds, and a FalkorDB can be wiped or swapped,
18
+ // so a cursor newer than the graph means re-send everything (MERGE keeps it idempotent).
19
+ const SNAPSHOT_GRACE_MS = 5000;
20
+ const reconciled = new Set();
21
+
22
+ function cursorFor(projectId, info, opts) {
23
+ const cur = cursor.load(projectId);
24
+ if (opts.full) return { ...cur, seen: [] };
25
+ if (!reconciled.has(projectId)) {
26
+ reconciled.add(projectId);
27
+ if (cur.seen.length && cur.updatedAt > (info?.lastIngestAt || 0) + SNAPSHOT_GRACE_MS) {
28
+ log(projectId, "graph is behind the ingest cursor (lost snapshot or new database); re-sending history");
29
+ return { ...cur, seen: [] };
30
+ }
31
+ }
32
+ return cur;
33
+ }
34
+
35
+ /** Projects + writes everything in `history` the cursor hasn't confirmed yet. */
36
+ async function ingestHistory(projectId, history, workspacePath, opts = {}) {
37
+ const { client, state } = api;
38
+ const stats = api.projectStats(projectId);
39
+ const info = await api.ensureProject(projectId);
40
+ const cur = cursorFor(projectId, info, opts);
41
+ const seen = cursor.seenSet(cur);
42
+ const projection = projectHistory(history, {
43
+ workspacePath,
44
+ isNew: (id) => !seen.has(id),
45
+ storeText: state.cfg.storeText,
46
+ });
47
+ for (const info of projection.intents.values()) intents.remember(info);
48
+ if (!projection.sessions.length && !projection.turns.length && !projection.edits.length) return { skipped: true };
49
+
50
+ const t = Date.now();
51
+ const result = await writeProjection(client, projectId, projection);
52
+ // Stamp the graph before the cursor, so the cursor can never look older than what the graph holds.
53
+ await client.write(projectId, "MERGE (m:Meta {key:'meta'}) SET m.lastIngestAt = $now RETURN 1", { now: Date.now() });
54
+ cursor.commit(projectId, cur, (history || []).map((e) => e.id).filter(Boolean));
55
+ stats.lastIngestAt = Date.now();
56
+ stats.lastIngestCount = result.turnsCreated + result.editsCreated;
57
+ stats.droppedRows += projection.dropped;
58
+ stats.lastError = null;
59
+ log(projectId, `+${result.turnsCreated} turns, +${result.editsCreated} edits, ${result.sessions} sessions (${Date.now() - t}ms)`);
60
+ api.notify?.(projectId, result);
61
+ return result;
62
+ }
63
+
64
+ function schedule(projectId, payload) {
65
+ try {
66
+ const projected = projectHistory(payload.history, {
67
+ workspacePath: payload.workspacePath,
68
+ isNew: () => false,
69
+ storeText: false,
70
+ });
71
+ for (const info of projected.intents.values()) intents.remember(info);
72
+ } catch {
73
+ /* best-effort immediate intent cache update */
74
+ }
75
+ const entry = pending.get(projectId) || {};
76
+ entry.payload = payload;
77
+ clearTimeout(entry.timer);
78
+ entry.timer = setTimeout(() => flush(projectId), DEBOUNCE_MS);
79
+ if (entry.timer.unref) entry.timer.unref();
80
+ pending.set(projectId, entry);
81
+ }
82
+
83
+ async function flush(projectId) {
84
+ const entry = pending.get(projectId);
85
+ if (!entry) return;
86
+ if (running.get(projectId)) {
87
+ schedule(projectId, entry.payload);
88
+ return;
89
+ }
90
+ pending.delete(projectId);
91
+ if (!api.client.available()) return;
92
+ running.set(projectId, true);
93
+ try {
94
+ const { history, workspacePath } = entry.payload;
95
+ await ingestHistory(projectId, history, workspacePath);
96
+ } catch (err) {
97
+ // Replayable: the cursor wasn't committed, so the next sync re-sends these events.
98
+ api.projectStats(projectId).lastError = String(err.message || err);
99
+ if (!err.breaker) log(projectId, `ingest failed: ${err.message || err}`);
100
+ } finally {
101
+ running.delete(projectId);
102
+ }
103
+ }
104
+
105
+ // ---- patches: not replayable from history, so failed batches go back into a bounded buffer
106
+ const PATCH_BUFFER_MAX = 2000;
107
+ const patchQueues = new Map();
108
+
109
+ function queuePatch(projectId, patch) {
110
+ const q = patchQueues.get(projectId) || { items: [], timer: null };
111
+ q.items.push(patch);
112
+ if (q.items.length > PATCH_BUFFER_MAX) {
113
+ q.items.splice(0, q.items.length - PATCH_BUFFER_MAX);
114
+ api.projectStats(projectId).bufferDrops += 1;
115
+ }
116
+ if (!q.timer) {
117
+ q.timer = setTimeout(() => flushPatches(projectId), 1000);
118
+ if (q.timer.unref) q.timer.unref();
119
+ }
120
+ patchQueues.set(projectId, q);
121
+ }
122
+
123
+ async function flushPatches(projectId) {
124
+ const q = patchQueues.get(projectId);
125
+ if (!q) return;
126
+ q.timer = null;
127
+ const items = q.items.splice(0);
128
+ if (!items.length) return;
129
+ const retry = () => {
130
+ q.items.unshift(...items);
131
+ q.timer = setTimeout(() => flushPatches(projectId), api.state.cfg.breakerCooldownMs);
132
+ if (q.timer.unref) q.timer.unref();
133
+ };
134
+ if (!api.client.available()) return retry();
135
+ try {
136
+ await api.ensureProject(projectId);
137
+ const result = await writeProjection(api.client, projectId, projectPatches(items, { storeText: api.state.cfg.storeText }));
138
+ if (result.editsCreated) log(projectId, `+${result.editsCreated} patch edits`);
139
+ api.notify?.(projectId, result);
140
+ } catch (err) {
141
+ api.projectStats(projectId).lastError = String(err.message || err);
142
+ retry();
143
+ }
144
+ }
145
+
146
+ // ---- room peers: debounced per (project, peer); content-addressed keys make re-pushes no-ops
147
+ const peerSeen = new Map();
148
+ const PEER_SEEN_MAX = 20000;
149
+
150
+ function seenFor(projectId) {
151
+ if (!peerSeen.has(projectId)) peerSeen.set(projectId, new Set());
152
+ return peerSeen.get(projectId);
153
+ }
154
+
155
+ async function ingestPeer(projectId, { snapshot, login, trustRowOwners }) {
156
+ await api.ensureProject(projectId);
157
+ const seen = seenFor(projectId);
158
+ const projection = projectPeer(snapshot, {
159
+ login,
160
+ trustRowOwners,
161
+ isNew: (id) => !seen.has(id),
162
+ storeText: api.state.cfg.storeText,
163
+ });
164
+ for (const info of projection.intents.values()) intents.remember(info);
165
+ if (!projection.turns.length && !projection.edits.length) return { skipped: true };
166
+ const result = await writeProjection(api.client, projectId, projection);
167
+ for (const t of projection.turns) seen.add(`peer_${t.turnKey}`);
168
+ for (const e of projection.edits) seen.add(String(e.editKey).replace(/^e_/, ""));
169
+ for (const e of projection.edits) seen.add(e.editKey);
170
+ if (seen.size > PEER_SEEN_MAX) peerSeen.set(projectId, new Set([...seen].slice(-PEER_SEEN_MAX / 2)));
171
+ log(projectId, `peer ${login}: +${result.turnsCreated} turns, +${result.editsCreated} edits`);
172
+ api.notify?.(projectId, { peer: login, ...result });
173
+ return result;
174
+ }
175
+
176
+ const peerTimers = new Map();
177
+ function schedulePeer(projectId, payload) {
178
+ try {
179
+ const projected = projectPeer(payload.snapshot, {
180
+ login: payload.login,
181
+ trustRowOwners: payload.trustRowOwners,
182
+ isNew: () => false,
183
+ storeText: false,
184
+ });
185
+ for (const info of projected.intents.values()) intents.remember(info);
186
+ } catch {
187
+ /* best-effort immediate intent cache update */
188
+ }
189
+ const key = `${projectId}|${payload.login}`;
190
+ const entry = peerTimers.get(key) || {};
191
+ entry.payload = payload;
192
+ clearTimeout(entry.timer);
193
+ entry.timer = setTimeout(async () => {
194
+ peerTimers.delete(key);
195
+ if (!api.client.available()) return;
196
+ try {
197
+ await ingestPeer(projectId, entry.payload);
198
+ } catch (err) {
199
+ api.projectStats(projectId).lastError = String(err.message || err);
200
+ }
201
+ }, DEBOUNCE_MS);
202
+ if (entry.timer.unref) entry.timer.unref();
203
+ peerTimers.set(key, entry);
204
+ }
205
+
206
+ function start(falkor) {
207
+ api = { ...falkor, client: falkor.state.client, state: falkor.state };
208
+ bus.on("history", ({ project, workspacePath, memory }) => {
209
+ if (!project?.id || !Array.isArray(memory?.history)) return;
210
+ schedule(project.id, { history: memory.history, workspacePath: workspacePath || project.path });
211
+ });
212
+ bus.on("patch", ({ projectId, patch }) => {
213
+ if (projectId && patch) queuePatch(projectId, patch);
214
+ });
215
+ bus.on("peer", ({ projectId, key, login, snapshot }) => {
216
+ if (!projectId || !snapshot) return;
217
+ // A guest's "host" bucket aggregates several people, so its row owners are meaningful;
218
+ // a single peer's push is forced to that peer's authenticated login.
219
+ const trustRowOwners = key === "host";
220
+ schedulePeer(projectId, { snapshot, login: login || key, trustRowOwners });
221
+ });
222
+ }
223
+
224
+ function configure(falkor) {
225
+ api = { ...falkor, client: falkor.state.client, state: falkor.state };
226
+ }
227
+
228
+ /** Forget per-process state, as a restart would. */
229
+ function _resetForTests() {
230
+ reconciled.clear();
231
+ }
232
+
233
+ module.exports = { start, configure, ingestHistory, ingestPeer, flush, flushPatches, _resetForTests };