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,179 @@
1
+ #!/usr/bin/env node
2
+ const http = require("http");
3
+ const express = require("express");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const { LockTable, sameOwner, normalizeFile } = require("./lockTable");
7
+ const { coordinatorStatePath, locksDirFor, homeRelayDir } = require("../lib/paths");
8
+ const { softBlock } = require("../lib/depGraph");
9
+ const { initTreeSitter } = require("../lib/treeSitterImports");
10
+ const { ingest: ingestSupergraph, snapshot: superSnapshot } = require("../lib/supergraph");
11
+ const { Presence } = require("../lib/presence");
12
+
13
+ function parseArgs(argv) {
14
+ const args = { port: process.env.RELAY_COORDINATOR_PORT || 0, workspacePath: null };
15
+ for (let i = 2; i < argv.length; i++) {
16
+ const a = argv[i];
17
+ if (a === "--port") args.port = Number(argv[++i]);
18
+ else if (!a.startsWith("-")) args.workspacePath = a;
19
+ }
20
+ return args;
21
+ }
22
+
23
+ function createCoordinator({ workspacePath, preferredPort } = {}) {
24
+ const locksDir = workspacePath
25
+ ? locksDirFor(workspacePath)
26
+ : path.join(homeRelayDir(), "locks");
27
+ const lockTable = new LockTable({ locksDir, workspacePath });
28
+ lockTable.loadFromDisk();
29
+ lockTable.startCleanup();
30
+ const presence = new Presence();
31
+
32
+ const app = express();
33
+ app.use(express.json({ limit: "1mb" }));
34
+ const startedAt = Date.now();
35
+
36
+ app.get("/health", (_req, res) => {
37
+ res.json({
38
+ ok: true,
39
+ port: app.get("port"),
40
+ uptime: (Date.now() - startedAt) / 1000,
41
+ workspacePath: workspacePath || null,
42
+ });
43
+ });
44
+
45
+ app.post("/claim", (req, res) => {
46
+ const body = req.body || {};
47
+ const files = Array.isArray(body.files) && body.files.length ? body.files : [body.file];
48
+ const warnings = [];
49
+ for (const file of files.filter(Boolean)) {
50
+ const blocked = softBlock(lockTable, body.workspaceId || workspacePath, body.agentId, file);
51
+ const held = blocked?.file ? lockTable.getLock(blocked.file, body.workspaceId || workspacePath) : null;
52
+ if (blocked && !(held && sameOwner(held.agentId, body.agentId))) {
53
+ warnings.push(blocked.reason || blocked.holder || String(blocked));
54
+ }
55
+ }
56
+ const result = lockTable.claim(body);
57
+ if (warnings.length && result.allowed) result.warning = result.warning || warnings[0];
58
+ res.status(result.allowed ? 200 : 409).json(result);
59
+ });
60
+
61
+ app.post("/heartbeat", (req, res) => {
62
+ res.json(lockTable.heartbeat(req.body || {}));
63
+ });
64
+
65
+ app.post("/escalate", (req, res) => {
66
+ const locks = req.body?.locks || [];
67
+ const results = locks.map((lock) => lockTable.escalateRegister(lock));
68
+ res.json({ results });
69
+ });
70
+
71
+ app.post("/presence", (req, res) => {
72
+ const { workspaceId, userId, action } = req.body || {};
73
+ const out =
74
+ action === "leave"
75
+ ? presence.leave(workspaceId, userId)
76
+ : presence.join(workspaceId, userId);
77
+ if (out.escalated) {
78
+ const snapshot = lockTable.list(workspaceId);
79
+ for (const lock of snapshot) lockTable.escalateRegister(lock);
80
+ }
81
+ res.json(out);
82
+ });
83
+
84
+ app.post("/patch", (req, res) => {
85
+ lockTable.emit("patch", req.body || {});
86
+ res.json({ ok: true });
87
+ });
88
+
89
+ app.post("/graph", (req, res) => {
90
+ res.json(ingestSupergraph(req.body?.workspaceId, req.body || {}));
91
+ });
92
+
93
+ app.get("/graph", (req, res) => {
94
+ res.json(superSnapshot(req.query.workspaceId));
95
+ });
96
+
97
+ app.post("/release", (req, res) => {
98
+ res.json(lockTable.release(req.body || {}));
99
+ });
100
+
101
+ app.post("/release-all", (req, res) => {
102
+ res.json(lockTable.releaseAll(req.body?.agentId, req.body?.workspaceId));
103
+ });
104
+
105
+ app.get("/status", (_req, res) => {
106
+ res.json({ ...lockTable.status(), uptime: (Date.now() - startedAt) / 1000 });
107
+ });
108
+
109
+ app.get("/stream", (req, res) => {
110
+ res.setHeader("Content-Type", "text/event-stream");
111
+ res.setHeader("Cache-Control", "no-cache");
112
+ res.setHeader("Connection", "keep-alive");
113
+ res.flushHeaders?.();
114
+ res.write(`data: ${JSON.stringify(lockTable.status())}\n\n`);
115
+ const listener = () => res.write(`data: ${JSON.stringify(lockTable.status())}\n\n`);
116
+ lockTable.on("change", listener);
117
+ req.on("close", () => lockTable.off("change", listener));
118
+ });
119
+
120
+ const server = http.createServer(app);
121
+ return { app, server, lockTable, presence, preferredPort: Number(preferredPort) || 0 };
122
+ }
123
+
124
+ function writeState(workspacePath, port, extra = {}) {
125
+ const state = { port, ...extra, startedAt: new Date().toISOString(), pid: process.pid };
126
+ const targets = [coordinatorStatePath(null)];
127
+ if (workspacePath) targets.push(coordinatorStatePath(workspacePath));
128
+ for (const file of targets) {
129
+ fs.mkdirSync(path.dirname(file), { recursive: true });
130
+ fs.writeFileSync(file, JSON.stringify(state, null, 2));
131
+ }
132
+ }
133
+
134
+ async function listen(server, preferredPort) {
135
+ const tryPort = async (port) =>
136
+ new Promise((resolve, reject) => {
137
+ const onError = (err) => {
138
+ server.off("listening", onListen);
139
+ reject(err);
140
+ };
141
+ const onListen = () => {
142
+ server.off("error", onError);
143
+ resolve(server.address().port);
144
+ };
145
+ server.once("error", onError);
146
+ server.once("listening", onListen);
147
+ server.listen(port, "127.0.0.1");
148
+ });
149
+
150
+ try {
151
+ return await tryPort(preferredPort || 0);
152
+ } catch (err) {
153
+ if (err.code === "EADDRINUSE") return tryPort(0);
154
+ throw err;
155
+ }
156
+ }
157
+
158
+ async function startStandalone() {
159
+ await initTreeSitter();
160
+ const args = parseArgs(process.argv);
161
+ const { app, server, lockTable } = createCoordinator({
162
+ workspacePath: args.workspacePath,
163
+ preferredPort: args.port,
164
+ });
165
+ const port = await listen(server, Number(args.port) || 0);
166
+ app.set("port", port);
167
+ writeState(args.workspacePath, port);
168
+ console.log(`[relay-coordinator] listening on 127.0.0.1:${port}`);
169
+ return { port, lockTable, server };
170
+ }
171
+
172
+ if (require.main === module) {
173
+ startStandalone().catch((err) => {
174
+ console.error(err);
175
+ process.exit(1);
176
+ });
177
+ }
178
+
179
+ module.exports = { createCoordinator, listen, writeState, startStandalone };
@@ -0,0 +1,62 @@
1
+ const crypto = require("crypto");
2
+ const os = require("os");
3
+ const path = require("path");
4
+ const { readJson, writeJson } = require("./store");
5
+
6
+ function hashKey(raw) {
7
+ return crypto.createHash("sha256").update(raw).digest("hex");
8
+ }
9
+
10
+ function adminKey() {
11
+ // No insecure fallback: Central admin access is disabled entirely unless the
12
+ // operator explicitly sets RELAY_CENTRAL_ADMIN_KEY. A hardcoded default here
13
+ // would be a publicly known admin credential for every install.
14
+ return process.env.RELAY_CENTRAL_ADMIN_KEY || null;
15
+ }
16
+
17
+ function sameSecret(a, b) {
18
+ const bufA = Buffer.from(String(a));
19
+ const bufB = Buffer.from(String(b));
20
+ if (bufA.length !== bufB.length) return false;
21
+ return crypto.timingSafeEqual(bufA, bufB);
22
+ }
23
+
24
+ function registryPath() {
25
+ return path.join(os.homedir(), ".relay-os", "central", "central-projects.json");
26
+ }
27
+
28
+ function loadProjects() {
29
+ return readJson(registryPath(), []);
30
+ }
31
+
32
+ function saveProjects(list) {
33
+ writeJson(registryPath(), list);
34
+ }
35
+
36
+ function createProject(name) {
37
+ const raw = `central_${crypto.randomBytes(24).toString("hex")}`;
38
+ const project = {
39
+ id: `p_${crypto.randomBytes(8).toString("hex")}`,
40
+ name: name || "relay-project",
41
+ apiKeyHash: hashKey(raw),
42
+ keyPrefix: raw.slice(0, 12),
43
+ createdAt: new Date().toISOString(),
44
+ };
45
+ const list = loadProjects();
46
+ list.push(project);
47
+ saveProjects(list);
48
+ return { project, apiKey: raw };
49
+ }
50
+
51
+ function resolveAuth(header) {
52
+ const token = String(header || "").replace(/^Bearer\s+/i, "").trim();
53
+ if (!token) return { ok: false };
54
+ const key = adminKey();
55
+ if (key && sameSecret(token, key)) return { ok: true, admin: true };
56
+ const hashed = hashKey(token);
57
+ const project = loadProjects().find((p) => p.apiKeyHash === hashed);
58
+ if (!project) return { ok: false };
59
+ return { ok: true, admin: false, project };
60
+ }
61
+
62
+ module.exports = { hashKey, adminKey, loadProjects, createProject, resolveAuth, registryPath };
@@ -0,0 +1,112 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+
5
+ const projectStates = new Map();
6
+ const initPromises = new Map();
7
+
8
+ function eventsPath(projectId) {
9
+ const dir = path.join(os.homedir(), ".relay-os", "central", "events");
10
+ fs.mkdirSync(dir, { recursive: true });
11
+ return path.join(dir, `${projectId}.jsonl`);
12
+ }
13
+
14
+ async function _initProjectState(projectId) {
15
+ const file = eventsPath(projectId);
16
+ let lastEventId = 0;
17
+ const clientEventIds = new Set();
18
+ if (fs.existsSync(file)) {
19
+ const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
20
+ for (const line of lines) {
21
+ try {
22
+ const ev = JSON.parse(line);
23
+ if (ev.event_id > lastEventId) lastEventId = ev.event_id;
24
+ if (ev.client_event_id) clientEventIds.add(ev.client_event_id);
25
+ } catch {
26
+ /* skip */
27
+ }
28
+ }
29
+ }
30
+ return { lastEventId, clientEventIds, queue: Promise.resolve() };
31
+ }
32
+
33
+ async function _getProjectState(projectId) {
34
+ if (projectStates.has(projectId)) return projectStates.get(projectId);
35
+ if (!initPromises.has(projectId)) {
36
+ initPromises.set(
37
+ projectId,
38
+ _initProjectState(projectId).then((state) => {
39
+ projectStates.set(projectId, state);
40
+ initPromises.delete(projectId);
41
+ return state;
42
+ })
43
+ );
44
+ }
45
+ return initPromises.get(projectId);
46
+ }
47
+
48
+ async function appendEvent(projectId, event) {
49
+ const state = await _getProjectState(projectId);
50
+ state.queue = state.queue
51
+ .then(async () => {
52
+ if (event.client_event_id && state.clientEventIds.has(event.client_event_id)) {
53
+ return { ...event, ignoredAsDuplicate: true };
54
+ }
55
+ state.lastEventId += 1;
56
+ const stored = {
57
+ ...event,
58
+ event_id: state.lastEventId,
59
+ project_id: projectId,
60
+ ts: event.ts || new Date().toISOString(),
61
+ };
62
+ fs.appendFileSync(eventsPath(projectId), JSON.stringify(stored) + "\n");
63
+ if (stored.client_event_id) state.clientEventIds.add(stored.client_event_id);
64
+ return stored;
65
+ })
66
+ .catch((err) => {
67
+ state.queue = Promise.resolve();
68
+ throw err;
69
+ });
70
+ return state.queue;
71
+ }
72
+
73
+ async function listEvents(projectId, { since = 0, limit = 200, kind } = {}) {
74
+ await _getProjectState(projectId);
75
+ const file = eventsPath(projectId);
76
+ if (!fs.existsSync(file)) return [];
77
+ const out = [];
78
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
79
+ if (!line) continue;
80
+ try {
81
+ const ev = JSON.parse(line);
82
+ if (ev.event_id <= Number(since || 0)) continue;
83
+ if (kind && ev.kind !== kind) continue;
84
+ out.push(ev);
85
+ if (out.length >= Number(limit)) break;
86
+ } catch {
87
+ /* skip */
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ function jsonContext(events) {
94
+ const changes = events.filter((e) => e.kind === "change").slice(-200).reverse();
95
+ const decisions = {};
96
+ const tasks = {};
97
+ const agents = {};
98
+ for (const e of events) {
99
+ if (e.kind === "decision" && e.decision_id) decisions[e.decision_id] = e;
100
+ if (e.kind === "task" && e.task_id) tasks[e.task_id] = e;
101
+ const key = `${e.user || "unknown"}@${e.agent_source || "agent"}`;
102
+ agents[key] = { key, lastSeen: e.ts };
103
+ }
104
+ return {
105
+ changes,
106
+ decisions: Object.values(decisions),
107
+ tasks: Object.values(tasks),
108
+ agents: Object.values(agents).sort((a, b) => String(b.lastSeen).localeCompare(String(a.lastSeen))),
109
+ };
110
+ }
111
+
112
+ module.exports = { appendEvent, listEvents, jsonContext, eventsPath };
@@ -0,0 +1,88 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { clientEventId } = require("./memorySync");
4
+
5
+ function configPath(workspacePath) {
6
+ return path.join(workspacePath, ".relay", "central-config.json");
7
+ }
8
+
9
+ function loadConfig(workspacePath) {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(configPath(workspacePath), "utf8"));
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ function saveConfig(workspacePath, cfg) {
18
+ fs.mkdirSync(path.dirname(configPath(workspacePath)), { recursive: true });
19
+ fs.writeFileSync(configPath(workspacePath), JSON.stringify(cfg, null, 2));
20
+ }
21
+
22
+ async function pushLocalEdits(workspacePath, projectId, memory) {
23
+ const cfg = loadConfig(workspacePath);
24
+ if (!cfg?.serverUrl || !cfg.apiKey || !cfg.projectId) return { pushed: 0, skipped: true };
25
+ const last = cfg.lastPushedEventTs || 0;
26
+ const edits = (memory.edits || []).filter((e) => e.ts > last);
27
+ let pushed = 0;
28
+ let latest = last;
29
+ for (let i = 0; i < edits.length; i++) {
30
+ const e = edits[i];
31
+ const body = {
32
+ kind: "change",
33
+ client_event_id: clientEventId(e.ts, e.file, i),
34
+ user: e.ownerLogin,
35
+ content: e.diff || e.file,
36
+ file: e.file,
37
+ agent_source: e.agent,
38
+ ts: new Date(e.ts).toISOString(),
39
+ };
40
+ const res = await fetch(`${cfg.serverUrl}/api/central/projects/${cfg.projectId}/events`, {
41
+ method: "POST",
42
+ headers: { Authorization: `Bearer ${cfg.apiKey}`, "Content-Type": "application/json" },
43
+ body: JSON.stringify(body),
44
+ });
45
+ if (!res.ok) break;
46
+ pushed += 1;
47
+ latest = Math.max(latest, e.ts);
48
+ }
49
+ cfg.lastPushedEventTs = latest;
50
+ saveConfig(workspacePath, cfg);
51
+ return { pushed };
52
+ }
53
+
54
+ async function pullCentralChanges(workspacePath) {
55
+ const cfg = loadConfig(workspacePath);
56
+ if (!cfg?.serverUrl || !cfg.apiKey || !cfg.projectId) return { pulled: 0, skipped: true };
57
+ const since = cfg.lastPulledCentralEventId || 0;
58
+ const res = await fetch(
59
+ `${cfg.serverUrl}/api/central/projects/${cfg.projectId}/events?since=${since}&limit=500`,
60
+ { headers: { Authorization: `Bearer ${cfg.apiKey}` } }
61
+ );
62
+ if (!res.ok) return { pulled: 0, error: res.status };
63
+ const body = await res.json();
64
+ const events = body.events || [];
65
+ if (!events.length) return { pulled: 0 };
66
+ const file = path.join(workspacePath, ".relay", "central_events.jsonl");
67
+ fs.mkdirSync(path.dirname(file), { recursive: true });
68
+ let last = since;
69
+ for (const ev of events) {
70
+ fs.appendFileSync(file, JSON.stringify(ev) + "\n");
71
+ if (ev.event_id > last) last = ev.event_id;
72
+ }
73
+ cfg.lastPulledCentralEventId = last;
74
+ saveConfig(workspacePath, cfg);
75
+ const ctx = jsonContextSafe(events);
76
+ fs.writeFileSync(path.join(workspacePath, ".relay", "central_context.json"), JSON.stringify(ctx, null, 2));
77
+ return { pulled: events.length, last };
78
+ }
79
+
80
+ function jsonContextSafe(events) {
81
+ try {
82
+ return require("./centralStore").jsonContext(events);
83
+ } catch {
84
+ return { changes: events.filter((e) => e.kind === "change") };
85
+ }
86
+ }
87
+
88
+ module.exports = { loadConfig, saveConfig, pushLocalEdits, pullCentralChanges };
@@ -0,0 +1,108 @@
1
+ const { spawn, execFileSync } = require("child_process");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const os = require("os");
5
+ const crypto = require("crypto");
6
+ const { loadRegistry, saveRegistry, saveMemory, saveSession, loadSession, id } = require("./store");
7
+ const { emptyMemory, initials } = require("./seed");
8
+ const { installProjectHooks } = require("./installHooks");
9
+ const { homeRelayDir } = require("./paths");
10
+
11
+ function runGit(args, cwd) {
12
+ return new Promise((resolve, reject) => {
13
+ const child = spawn("git", args, { cwd: cwd || process.cwd(), stdio: "inherit", shell: false, windowsHide: true });
14
+ child.on("error", reject);
15
+ child.on("exit", (code) => {
16
+ if (code === 0) resolve();
17
+ else reject(new Error(`git ${args[0]} exited ${code}`));
18
+ });
19
+ });
20
+ }
21
+
22
+ function defaultCloneDir(remoteUrl) {
23
+ const name = String(remoteUrl || "")
24
+ .replace(/\.git$/, "")
25
+ .split(/[/\\]/)
26
+ .filter(Boolean)
27
+ .pop() || "repo";
28
+ return path.join(os.homedir(), "Documents", "GitHub", name);
29
+ }
30
+
31
+ function registerProject({ name, workspacePath, remoteUrl, mode }) {
32
+ const reg = loadRegistry();
33
+ const existing = reg.projects.find((p) => p.path === workspacePath);
34
+ if (existing) return existing;
35
+ const project = {
36
+ id: id("proj"),
37
+ name: name || path.basename(workspacePath || "workspace"),
38
+ initials: initials(name || workspacePath || "WS"),
39
+ color: mode === "clone" ? "#a78bfa" : "#34d399",
40
+ path: workspacePath,
41
+ remoteUrl: remoteUrl || null,
42
+ apiKey: "rl_" + crypto.randomBytes(12).toString("hex"),
43
+ createdAt: Date.now(),
44
+ lastSyncAt: Date.now(),
45
+ };
46
+ fs.mkdirSync(path.join(workspacePath, ".relay"), { recursive: true });
47
+ try {
48
+ installProjectHooks(workspacePath);
49
+ } catch {
50
+ /* hooks are best-effort */
51
+ }
52
+ reg.projects.push(project);
53
+ saveRegistry(reg);
54
+ saveMemory(project.id, emptyMemory());
55
+ const session = loadSession();
56
+ saveSession({ ...session, projectId: project.id });
57
+ return project;
58
+ }
59
+
60
+ async function cloneRepo(remoteUrl, dest) {
61
+ if (!remoteUrl) throw new Error("usage: relay clone <git-url> [directory]");
62
+ const target = path.resolve(dest || defaultCloneDir(remoteUrl));
63
+ if (!fs.existsSync(target)) {
64
+ fs.mkdirSync(path.dirname(target), { recursive: true });
65
+ await runGit(["clone", remoteUrl, target]);
66
+ } else if (!fs.existsSync(path.join(target, ".git"))) {
67
+ throw new Error(`${target} exists and is not a git repo`);
68
+ }
69
+ const project = registerProject({
70
+ name: path.basename(target),
71
+ workspacePath: target,
72
+ remoteUrl,
73
+ mode: "clone",
74
+ });
75
+ return { project, path: target };
76
+ }
77
+
78
+ function detectRemoteUrl(target) {
79
+ if (!fs.existsSync(path.join(target, ".git"))) return null;
80
+ try {
81
+ return execFileSync("git", ["config", "--get", "remote.origin.url"], {
82
+ cwd: target,
83
+ encoding: "utf8",
84
+ stdio: ["ignore", "pipe", "ignore"],
85
+ windowsHide: true,
86
+ }).trim() || null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function addLocal(workspacePath, name) {
93
+ const target = path.resolve(workspacePath);
94
+ if (!fs.existsSync(target)) throw new Error(`path not found: ${target}`);
95
+ return registerProject({
96
+ name: name || path.basename(target),
97
+ workspacePath: target,
98
+ // A folder added via "Add local repository" is very often already a git
99
+ // repo with a real GitHub remote (e.g. `test` had `origin` set up
100
+ // manually) — read it instead of always registering remoteUrl: null,
101
+ // otherwise features that key off the repo (like the Team tab's GitHub
102
+ // collaborator list) silently have nothing to work with.
103
+ remoteUrl: detectRemoteUrl(target),
104
+ mode: "local",
105
+ });
106
+ }
107
+
108
+ module.exports = { cloneRepo, addLocal, registerProject, defaultCloneDir, homeRelayDir, detectRemoteUrl };