relaybrain 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +290 -2
  3. package/backend/coordinator/client.js +364 -0
  4. package/backend/coordinator/lockTable.js +649 -0
  5. package/backend/coordinator/server.js +179 -0
  6. package/backend/lib/centralAuth.js +62 -0
  7. package/backend/lib/centralStore.js +112 -0
  8. package/backend/lib/centralSync.js +88 -0
  9. package/backend/lib/cloneRepo.js +108 -0
  10. package/backend/lib/collisionMetrics.js +231 -0
  11. package/backend/lib/conflicts.js +35 -0
  12. package/backend/lib/dashboard.js +53 -0
  13. package/backend/lib/depGraph.js +393 -0
  14. package/backend/lib/deps.js +46 -0
  15. package/backend/lib/dirtyFiles.js +33 -0
  16. package/backend/lib/editMatchers.js +55 -0
  17. package/backend/lib/falkor/analytics.js +228 -0
  18. package/backend/lib/falkor/bus.js +16 -0
  19. package/backend/lib/falkor/central.js +281 -0
  20. package/backend/lib/falkor/client.js +165 -0
  21. package/backend/lib/falkor/config.js +73 -0
  22. package/backend/lib/falkor/coord.js +299 -0
  23. package/backend/lib/falkor/cursor.js +58 -0
  24. package/backend/lib/falkor/fixtures.js +85 -0
  25. package/backend/lib/falkor/index.js +146 -0
  26. package/backend/lib/falkor/ingest.js +233 -0
  27. package/backend/lib/falkor/intents.js +32 -0
  28. package/backend/lib/falkor/keys.js +102 -0
  29. package/backend/lib/falkor/migrations.js +56 -0
  30. package/backend/lib/falkor/project.js +307 -0
  31. package/backend/lib/falkor/query.js +344 -0
  32. package/backend/lib/falkor/redact.js +30 -0
  33. package/backend/lib/falkor/routes.js +81 -0
  34. package/backend/lib/falkor/schema.js +108 -0
  35. package/backend/lib/falkor/write.js +281 -0
  36. package/backend/lib/githubLogin.js +108 -0
  37. package/backend/lib/githubOAuth.js +42 -0
  38. package/backend/lib/githubRepo.js +117 -0
  39. package/backend/lib/harvest.js +85 -0
  40. package/backend/lib/httpServer.js +27 -0
  41. package/backend/lib/installAgentRelay.js +235 -0
  42. package/backend/lib/installHooks.js +309 -0
  43. package/backend/lib/lamport.js +14 -0
  44. package/backend/lib/localBrowse.js +109 -0
  45. package/backend/lib/memorySync.js +63 -0
  46. package/backend/lib/notices.js +143 -0
  47. package/backend/lib/ot.js +74 -0
  48. package/backend/lib/patches.js +192 -0
  49. package/backend/lib/paths.js +85 -0
  50. package/backend/lib/peerClash.js +64 -0
  51. package/backend/lib/presence.js +35 -0
  52. package/backend/lib/relayOsBlock.js +84 -0
  53. package/backend/lib/repoSnapshot.js +125 -0
  54. package/backend/lib/room.js +498 -0
  55. package/backend/lib/roomAsk.js +148 -0
  56. package/backend/lib/roomAuth.js +215 -0
  57. package/backend/lib/roomControl.js +226 -0
  58. package/backend/lib/roomLocks.js +201 -0
  59. package/backend/lib/roomSignal.js +265 -0
  60. package/backend/lib/roomSync.js +194 -0
  61. package/backend/lib/seed.js +107 -0
  62. package/backend/lib/sseHub.js +40 -0
  63. package/backend/lib/store.js +79 -0
  64. package/backend/lib/supergraph.js +46 -0
  65. package/backend/lib/transcripts/discover.js +338 -0
  66. package/backend/lib/transcripts/parse.js +558 -0
  67. package/backend/lib/transcripts/sessionId.js +36 -0
  68. package/backend/lib/transcripts/sync.js +291 -0
  69. package/backend/lib/transcripts/util.js +299 -0
  70. package/backend/lib/treeSitterImports.js +219 -0
  71. package/backend/lib/watchWorkspace.js +42 -0
  72. package/backend/mcp/README.md +193 -0
  73. package/backend/mcp/server.js +42 -0
  74. package/backend/mcp/tools.js +484 -0
  75. package/backend/server.js +3839 -0
  76. package/cli/graph.js +278 -0
  77. package/cli/relay.js +419 -0
  78. package/docker-compose.falkor.yml +17 -0
  79. package/docs/MEMORY-GRAPH.md +269 -0
  80. package/hooks/relay-antigravity-post-tool.js +2 -0
  81. package/hooks/relay-antigravity-pre-read.js +2 -0
  82. package/hooks/relay-antigravity-pre-tool.js +2 -0
  83. package/hooks/relay-antigravity-stop.js +2 -0
  84. package/hooks/relay-claude-post-tool.js +2 -0
  85. package/hooks/relay-claude-pre-read.js +2 -0
  86. package/hooks/relay-claude-pre-tool.js +2 -0
  87. package/hooks/relay-claude-stop.js +2 -0
  88. package/hooks/relay-codex-post-tool.js +2 -0
  89. package/hooks/relay-codex-pre-read.js +2 -0
  90. package/hooks/relay-codex-pre-tool.js +2 -0
  91. package/hooks/relay-codex-stop.js +2 -0
  92. package/hooks/relay-copilot-post-tool.js +2 -0
  93. package/hooks/relay-copilot-pre-read.js +2 -0
  94. package/hooks/relay-copilot-pre-tool.js +2 -0
  95. package/hooks/relay-copilot-stop.js +2 -0
  96. package/hooks/relay-cursor-post-tool.js +2 -0
  97. package/hooks/relay-cursor-pre-read.js +2 -0
  98. package/hooks/relay-cursor-pre-tool.js +2 -0
  99. package/hooks/relay-cursor-stop.js +2 -0
  100. package/hooks/relay-hook-lib.js +621 -0
  101. package/mission-control/out/404.html +1 -0
  102. package/mission-control/out/__next.__PAGE__.txt +26 -0
  103. package/mission-control/out/__next._full.txt +21 -0
  104. package/mission-control/out/__next._tree.txt +4 -0
  105. package/mission-control/out/_next/static/chunks/0-3l04fnym95-.js +22 -0
  106. package/mission-control/out/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  107. package/mission-control/out/_next/static/chunks/0ehjiuuxbbhq9.js +1 -0
  108. package/mission-control/out/_next/static/chunks/1q0cmt0kqufow.js +2 -0
  109. package/mission-control/out/_next/static/chunks/1ya1u0izct4f6.js +1 -0
  110. package/mission-control/out/_next/static/chunks/25rvg4bnew99f.css +4 -0
  111. package/mission-control/out/_next/static/chunks/2n66mlnb34vjq.js +1 -0
  112. package/mission-control/out/_next/static/chunks/2xar70jzx8f1y.css +1 -0
  113. package/mission-control/out/_next/static/chunks/31cgqj_-l15yt.js +31 -0
  114. package/mission-control/out/_next/static/chunks/3_5x4qn1_b4al.js +1 -0
  115. package/mission-control/out/_next/static/chunks/3o1tiwncjss_k.js +1 -0
  116. package/mission-control/out/_next/static/chunks/3vq9c4hivvgn_.js +1 -0
  117. package/mission-control/out/_next/static/chunks/turbopack-3hsllzpnzz12p.js +1 -0
  118. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_buildManifest.js +11 -0
  119. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_clientMiddlewareManifest.js +1 -0
  120. package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_ssgManifest.js +1 -0
  121. package/mission-control/out/_next/static/media/icon.27otmh_fv3buh.svg +4 -0
  122. package/mission-control/out/_not-found/__next._full.txt +18 -0
  123. package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +24 -0
  124. package/mission-control/out/_not-found/__next._tree.txt +3 -0
  125. package/mission-control/out/_not-found.html +1 -0
  126. package/mission-control/out/_not-found.txt +18 -0
  127. package/mission-control/out/agents/antigravity.png +0 -0
  128. package/mission-control/out/agents/claude.png +0 -0
  129. package/mission-control/out/agents/codex.png +0 -0
  130. package/mission-control/out/agents/copilot.png +0 -0
  131. package/mission-control/out/agents/cursor.png +0 -0
  132. package/mission-control/out/icon.svg +4 -0
  133. package/mission-control/out/index.html +1 -0
  134. package/mission-control/out/index.txt +21 -0
  135. package/mission-control/out/logo.png +0 -0
  136. package/mission-control/out/logo_transparent.png +0 -0
  137. package/package.json +74 -4
  138. package/scripts/falkor-rebuild.js +57 -0
  139. package/scripts/falkor-seed-demo.js +157 -0
@@ -0,0 +1,81 @@
1
+ const { runNamed, names } = require("./query");
2
+ const { projectIdFor } = require("./coord");
3
+ const { loadRoom } = require("../room");
4
+ const { loadRegistry, loadSession } = require("../store");
5
+
6
+ let api = null;
7
+
8
+ function argsFrom(req) {
9
+ const q = { ...(req.query || {}), ...(req.body || {}) };
10
+ if (typeof q.paths === "string") q.paths = q.paths.split(",").filter(Boolean);
11
+ return q;
12
+ }
13
+
14
+ function resolveProjectId(args) {
15
+ if (args.projectId) return String(args.projectId);
16
+ const byPath = projectIdFor(args.workspace);
17
+ if (byPath) return byPath;
18
+ const reg = loadRegistry();
19
+ const session = loadSession();
20
+ return reg.projects.find((p) => p.id === session.projectId)?.id || reg.projects[0]?.id || null;
21
+ }
22
+
23
+ function projectPath(projectId) {
24
+ return loadRegistry().projects.find((p) => p.id === projectId)?.path || null;
25
+ }
26
+
27
+ /** A guest's graph lives on the host; ask it through the room's existing read-only MCP endpoint. */
28
+ async function askHost(room, name, args) {
29
+ const body = {
30
+ jsonrpc: "2.0",
31
+ id: 1,
32
+ method: "tools/call",
33
+ params: { name: "relay_graph_query", arguments: { ...args, query: name, projectId: undefined, workspace: undefined } },
34
+ };
35
+ const res = await api.state.deps.fetchHost(room.url, "/mcp", { method: "POST", body: JSON.stringify(body), timeoutMs: 4000 });
36
+ const json = await res.json();
37
+ if (json.error) throw Object.assign(new Error(json.error.message || "host query failed"), { status: 502 });
38
+ const text = json.result?.content?.[0]?.text;
39
+ return { ...JSON.parse(text || "{}"), source: "host" };
40
+ }
41
+
42
+ /** Local graph first unless this machine is a room guest, in which case the host's graph is the room brain. */
43
+ async function query(name, args) {
44
+ const projectId = resolveProjectId(args);
45
+ const room = loadRoom(args.workspace || projectPath(projectId) || undefined);
46
+ if (room?.role === "guest" && room.url && api.state.deps.fetchHost && !args.local) {
47
+ try {
48
+ return await askHost(room, name, args);
49
+ } catch (err) {
50
+ if (!api.state.started) {
51
+ throw Object.assign(new Error(`room host unreachable (${err.message || err}) and this machine has no memory graph of its own`), {
52
+ status: 503,
53
+ });
54
+ }
55
+ /* tunnel down: fall back to whatever this machine's own graph knows */
56
+ }
57
+ }
58
+ if (!api.state.started) return { ok: false, reason: "falkor_disabled", hint: "set RELAY_FALKOR_URL and restart relay serve" };
59
+ if (!api.client.available()) return { ok: false, reason: "falkor_unavailable", breaker: api.client.status() };
60
+ await api.ensureProject(projectId);
61
+ return { ok: true, projectId, source: "local", ...(await runNamed(api.client, projectId, name, args)) };
62
+ }
63
+
64
+ function register(app, falkor) {
65
+ api = { ...falkor, get client() {
66
+ return falkor.state.client;
67
+ } };
68
+ const guard = falkor.state.deps.requireRoomMember || ((_req, _res, next) => next());
69
+ const handler = async (req, res) => {
70
+ try {
71
+ res.json(await query(req.params.name, argsFrom(req)));
72
+ } catch (err) {
73
+ res.status(err.status || 500).json({ ok: false, reason: String(err.message || err), queries: names() });
74
+ }
75
+ };
76
+ app.get("/api/graph/queries", (_req, res) => res.json({ queries: names() }));
77
+ app.get("/api/graph/query/:name", guard, handler);
78
+ app.post("/api/graph/query/:name", guard, handler);
79
+ }
80
+
81
+ module.exports = { register, query };
@@ -0,0 +1,108 @@
1
+ const { ConstraintType, EntityType } = require("falkordb");
2
+
3
+ const SCHEMA_VERSION = 1;
4
+ // Bump when projector/key logic changes in a way that would re-key existing nodes.
5
+ const INGEST_VERSION = 6;
6
+
7
+ const KEYED_LABELS = ["Person", "Agent", "Session", "Turn", "Edit", "File", "Decision", "Task", "Contention", "Overlap", "Meta"];
8
+
9
+ const RANGE_INDEXES = [
10
+ ...KEYED_LABELS.map((label) => [label, "key"]),
11
+ ["Edit", "ts"],
12
+ ["Turn", "ts"],
13
+ ["Contention", "ts"],
14
+ ["Session", "lastAt"],
15
+ ["Session", "label"],
16
+ ];
17
+
18
+ const FULLTEXT_INDEXES = [
19
+ ["Turn", "text"],
20
+ ["Decision", "content"],
21
+ ["Task", "description"],
22
+ ];
23
+
24
+ const ensured = new Map();
25
+
26
+ function has(existing, label, prop, type) {
27
+ return existing.some((row) => row.label === label && (row.types?.[prop] || []).includes(type));
28
+ }
29
+
30
+ async function bootstrap(client, projectId) {
31
+ // Writing Meta first creates the graph; read-only calls on a missing graph error.
32
+ const meta = await client.write(
33
+ projectId,
34
+ `MERGE (m:Meta {key: 'meta'})
35
+ ON CREATE SET m.schemaVersion = $schema, m.ingestVersion = $ingest, m.createdAt = $now
36
+ RETURN m.schemaVersion AS schemaVersion, m.ingestVersion AS ingestVersion, m.createdAt AS createdAt,
37
+ m.lastIngestAt AS lastIngestAt`,
38
+ { schema: SCHEMA_VERSION, ingest: INGEST_VERSION, now: Date.now() }
39
+ );
40
+
41
+ const existing = await client.read(projectId, "CALL db.indexes() YIELD label, properties, types RETURN label, properties, types");
42
+ for (const [label, prop] of RANGE_INDEXES) {
43
+ if (has(existing, label, prop, "RANGE")) continue;
44
+ await client.write(projectId, `CREATE INDEX FOR (n:${label}) ON (n.${prop})`);
45
+ }
46
+ for (const [label, prop] of FULLTEXT_INDEXES) {
47
+ if (has(existing, label, prop, "FULLTEXT")) continue;
48
+ await client.write(projectId, `CREATE FULLTEXT INDEX FOR (n:${label}) ON (n.${prop})`);
49
+ }
50
+
51
+ // Unique constraints are a backstop (MERGE already guarantees one node per key). Awaited
52
+ // on purpose: CONSTRAINT CREATE returns PENDING at once, and a fire-and-forget call that
53
+ // lands after the graph was deleted silently recreates it.
54
+ try {
55
+ const graph = await client.graph(projectId);
56
+ const current = await client.read(projectId, "CALL db.constraints() YIELD label, properties RETURN label, properties");
57
+ for (const label of KEYED_LABELS) {
58
+ if (current.some((c) => c.label === label && (c.properties || []).includes("key"))) continue;
59
+ await graph.constraintCreate(ConstraintType.UNIQUE, EntityType.NODE, label, "key").catch(() => undefined);
60
+ }
61
+ } catch {
62
+ /* constraints are optional */
63
+ }
64
+
65
+ const { runMigrations } = require("./migrations");
66
+ let migResult = { applied: [], ran: [] };
67
+ try {
68
+ migResult = await runMigrations(client, projectId, {
69
+ schemaVersion: SCHEMA_VERSION,
70
+ ingestVersion: INGEST_VERSION,
71
+ });
72
+ } catch {
73
+ /* keep existing meta row if migrations cannot run */
74
+ }
75
+
76
+ const refreshed = await client.read(
77
+ projectId,
78
+ "MATCH (m:Meta {key: 'meta'}) RETURN m.schemaVersion AS schemaVersion, m.ingestVersion AS ingestVersion, m.createdAt AS createdAt, m.lastIngestAt AS lastIngestAt"
79
+ );
80
+ const row = refreshed[0] || meta[0] || {};
81
+ return {
82
+ schemaVersion: row.schemaVersion,
83
+ ingestVersion: row.ingestVersion,
84
+ createdAt: row.createdAt,
85
+ lastIngestAt: row.lastIngestAt || 0,
86
+ migrations: migResult.applied,
87
+ migrated: migResult.ran,
88
+ needsRebuild: row.schemaVersion !== SCHEMA_VERSION || row.ingestVersion !== INGEST_VERSION,
89
+ };
90
+ }
91
+
92
+ /** Idempotent, once per process per project; a failed attempt is retried on the next call. */
93
+ function ensure(client, projectId) {
94
+ if (!ensured.has(projectId)) {
95
+ const pending = bootstrap(client, projectId).catch((err) => {
96
+ ensured.delete(projectId);
97
+ throw err;
98
+ });
99
+ ensured.set(projectId, pending);
100
+ }
101
+ return ensured.get(projectId);
102
+ }
103
+
104
+ function forget(projectId) {
105
+ ensured.delete(projectId);
106
+ }
107
+
108
+ module.exports = { ensure, forget, bootstrap, SCHEMA_VERSION, INGEST_VERSION, KEYED_LABELS };
@@ -0,0 +1,281 @@
1
+ // Parameterized batch writers. Labels/relationship types are constants here; every
2
+ // value travels as a parameter. Counters are only bumped for nodes this batch created
3
+ // (batch token), so replaying any batch is a no-op.
4
+ const crypto = require("crypto");
5
+
6
+ const CHUNK = 400;
7
+ const TRANSCRIPT_EDIT_AGENTS = new Set(["Claude Code", "Cursor", "Codex", "Antigravity", "Copilot"]);
8
+
9
+ function token() {
10
+ return crypto.randomBytes(8).toString("hex");
11
+ }
12
+
13
+ function chunks(rows, size = CHUNK) {
14
+ const out = [];
15
+ for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size));
16
+ return out;
17
+ }
18
+
19
+ function dedupe(rows, key) {
20
+ const seen = new Map();
21
+ for (const row of rows) if (row && row[key] != null) seen.set(row[key], row);
22
+ return [...seen.values()];
23
+ }
24
+
25
+ async function each(client, projectId, query, rows, extra = {}) {
26
+ const results = [];
27
+ for (const part of chunks(rows)) {
28
+ const data = await client.write(projectId, query, { rows: part, ...extra });
29
+ results.push(...data);
30
+ }
31
+ return results;
32
+ }
33
+
34
+ const SESSIONS = `
35
+ UNWIND $rows AS r
36
+ MERGE (p:Person {key: r.login})
37
+ ON CREATE SET p.login = r.loginRaw, p.firstSeen = r.startedAt
38
+ SET p.lastSeen = CASE WHEN p.lastSeen IS NULL OR r.lastAt > p.lastSeen THEN r.lastAt ELSE p.lastSeen END
39
+ MERGE (a:Agent {key: r.agentKey})
40
+ ON CREATE SET a.label = r.label, a.login = r.login, a.firstSeen = r.startedAt
41
+ SET a.lastSeen = CASE WHEN a.lastSeen IS NULL OR r.lastAt > a.lastSeen THEN r.lastAt ELSE a.lastSeen END
42
+ MERGE (p)-[:RUNS]->(a)
43
+ MERGE (s:Session {key: r.sessionKey})
44
+ ON CREATE SET s.sessionId = r.sessionId, s.label = r.label, s.bucket = r.bucket,
45
+ s.turnCount = 0, s.editCount = 0, s.startedAt = r.startedAt
46
+ SET s.login = r.login, s.stub = false,
47
+ s.startedAt = CASE WHEN s.startedAt IS NULL OR r.startedAt < s.startedAt THEN r.startedAt ELSE s.startedAt END,
48
+ s.lastAt = CASE WHEN s.lastAt IS NULL OR r.lastAt > s.lastAt THEN r.lastAt ELSE s.lastAt END,
49
+ s.thinkingCount = CASE WHEN s.thinkingCount IS NULL OR r.thinkingCount > s.thinkingCount THEN r.thinkingCount ELSE s.thinkingCount END,
50
+ s.title = coalesce(s.title, r.title)
51
+ FOREACH (_ IN CASE WHEN r.intentAt IS NOT NULL AND (s.intentAt IS NULL OR r.intentAt >= s.intentAt) THEN [1] ELSE [] END |
52
+ SET s.intent = r.intent, s.intentAt = r.intentAt)
53
+ MERGE (a)-[:RAN]->(s)
54
+ RETURN count(s) AS n`;
55
+
56
+ const TURNS = `
57
+ UNWIND $rows AS r
58
+ MATCH (s:Session {key: r.sessionKey})
59
+ MERGE (t:Turn {key: r.turnKey})
60
+ ON CREATE SET t.ts = r.ts, t.role = r.role, t.kind = r.kind, t.text = r.text,
61
+ t.seq = r.seq, t.tsSynthetic = r.tsSynthetic, t._b = $batch
62
+ MERGE (s)-[:HAS_TURN]->(t)
63
+ RETURN r.sessionKey AS sessionKey, coalesce(t._b = $batch, false) AS created`;
64
+
65
+ const EDITS = `
66
+ UNWIND $rows AS r
67
+ MERGE (p:Person {key: r.login})
68
+ ON CREATE SET p.login = r.loginRaw, p.firstSeen = r.ts
69
+ MERGE (a:Agent {key: r.agentKey})
70
+ ON CREATE SET a.label = r.label, a.login = r.login, a.firstSeen = r.ts
71
+ MERGE (p)-[:RUNS]->(a)
72
+ MERGE (e:Edit {key: r.editKey})
73
+ ON CREATE SET e.source = r.source, e.ts = r.ts, e.tool = r.tool, e.diffHead = r.diffHead,
74
+ e.added = r.added, e.removed = r.removed, e.tsSynthetic = r.tsSynthetic,
75
+ e.orphan = r.orphan, e.lamport = r.lamport, e.sha256 = r.sha256, e._b = $batch
76
+ MERGE (e)-[:BY]->(a)
77
+ WITH r, e
78
+ OPTIONAL MATCH (s:Session {key: r.sessionKey})
79
+ FOREACH (_ IN CASE WHEN s IS NULL THEN [] ELSE [1] END | MERGE (s)-[:MADE]->(e))
80
+ RETURN r.editKey AS editKey, coalesce(e._b = $batch, false) AS created`;
81
+
82
+ // One real change often arrives twice: transcript code_edit (intent) and watcher patch (disk truth).
83
+ const SAME_CHANGE = `
84
+ UNWIND $rows AS r
85
+ MATCH (p:Edit {source: 'patch'})-[:MODIFIED]->(f:File {key: r.fileKey})
86
+ MATCH (p)-[:BY]->(a:Agent {key: r.agentKey})
87
+ WHERE NOT (p)-[:SAME_CHANGE]->()
88
+ MATCH (t:Edit {source: 'transcript'})-[:MODIFIED]->(f)
89
+ MATCH (t)-[:BY]->(a)
90
+ WHERE abs(t.ts - p.ts) <= $windowMs
91
+ RETURN p.key AS patchKey, t.key AS editKey, abs(t.ts - p.ts) AS gap,
92
+ p.diffHead AS patchDiff, t.diffHead AS editDiff, coalesce(t.tsSynthetic, false) AS synthetic`;
93
+
94
+ const LINK_SAME_CHANGE = `
95
+ UNWIND $rows AS r
96
+ MATCH (p:Edit {key: r.patchKey})
97
+ MATCH (t:Edit {key: r.editKey})
98
+ MERGE (p)-[s:SAME_CHANGE]->(t)
99
+ SET s.gapMs = r.gap, s.method = r.method
100
+ RETURN count(s) AS n`;
101
+
102
+ const SAME_CHANGE_EXACT_MS = 15_000;
103
+ const SAME_CHANGE_CONTENT_MS = 10 * 60 * 1000;
104
+
105
+ function addedLines(diff) {
106
+ const out = new Set();
107
+ for (const line of String(diff || "").split("\n")) {
108
+ if (!line.startsWith("+") || line.startsWith("+++")) continue;
109
+ const s = line.slice(1).trim();
110
+ if (s.length >= 8) out.add(s);
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * Picks the transcript edit each watcher patch duplicates: the nearest one within 15 s, or,
117
+ * when transcript times are approximate (Cursor's minute-precision stamps), one within 10 min
118
+ * that added the same lines.
119
+ */
120
+ function matchSameChange(candidates) {
121
+ const byPatch = new Map();
122
+ for (const c of candidates) {
123
+ if (!byPatch.has(c.patchKey)) byPatch.set(c.patchKey, []);
124
+ byPatch.get(c.patchKey).push(c);
125
+ }
126
+ const links = [];
127
+ for (const list of byPatch.values()) {
128
+ const timed = list.filter((c) => c.gap <= SAME_CHANGE_EXACT_MS).sort((a, b) => a.gap - b.gap)[0];
129
+ if (timed) {
130
+ links.push({ patchKey: timed.patchKey, editKey: timed.editKey, gap: timed.gap, method: "time" });
131
+ continue;
132
+ }
133
+ const patchLines = addedLines(list[0].patchDiff);
134
+ if (!patchLines.size) continue;
135
+ let best = null;
136
+ for (const c of list) {
137
+ let shared = 0;
138
+ for (const l of addedLines(c.editDiff)) if (patchLines.has(l)) shared += 1;
139
+ if (shared && (!best || shared > best.shared || (shared === best.shared && c.gap < best.gap))) best = { ...c, shared };
140
+ }
141
+ if (best) links.push({ patchKey: best.patchKey, editKey: best.editKey, gap: best.gap, method: "content" });
142
+ }
143
+ return links;
144
+ }
145
+
146
+ const EDIT_FILES = `
147
+ UNWIND $rows AS r
148
+ MATCH (e:Edit {key: r.editKey})
149
+ MERGE (f:File {key: r.fileKey})
150
+ ON CREATE SET f.editCount = 0
151
+ SET f.path = r.path, f.ext = r.ext
152
+ MERGE (e)-[:MODIFIED]->(f)
153
+ RETURN count(e) AS n`;
154
+
155
+ const FILE_COUNTS = `
156
+ UNWIND $rows AS r
157
+ MATCH (f:File {key: r.fileKey})
158
+ SET f.editCount = coalesce(f.editCount, 0) + r.n,
159
+ f.lastEditAt = CASE WHEN f.lastEditAt IS NULL OR r.lastAt > f.lastEditAt THEN r.lastAt ELSE f.lastEditAt END
160
+ RETURN count(f) AS n`;
161
+
162
+ const TOUCHED = `
163
+ UNWIND $rows AS r
164
+ MATCH (a:Agent {key: r.agentKey})
165
+ MATCH (f:File {key: r.fileKey})
166
+ MERGE (a)-[t:TOUCHED]->(f)
167
+ ON CREATE SET t.count = 0, t.firstAt = r.firstAt, t.lastAt = r.lastAt
168
+ SET t.count = t.count + r.n,
169
+ t.firstAt = CASE WHEN r.firstAt < t.firstAt THEN r.firstAt ELSE t.firstAt END,
170
+ t.lastAt = CASE WHEN r.lastAt > t.lastAt THEN r.lastAt ELSE t.lastAt END
171
+ RETURN count(t) AS n`;
172
+
173
+ const SESSION_COUNTS = `
174
+ UNWIND $rows AS r
175
+ MATCH (s:Session {key: r.sessionKey})
176
+ SET s.turnCount = coalesce(s.turnCount, 0) + r.turns,
177
+ s.editCount = coalesce(s.editCount, 0) + r.edits
178
+ RETURN count(s) AS n`;
179
+
180
+ const PROMPTS = `
181
+ UNWIND $rows AS r
182
+ MATCH (e:Edit {key: r.editKey})
183
+ OPTIONAL MATCH (u:Turn {key: r.promptKey})
184
+ FOREACH (_ IN CASE WHEN u IS NULL THEN [] ELSE [1] END |
185
+ MERGE (e)-[p:PROMPTED_BY]->(u)
186
+ SET p.gapMs = r.promptGap, p.stale = r.promptStale, p.confidence = r.promptConfidence, p.via = r.promptVia)
187
+ WITH e, r
188
+ OPTIONAL MATCH (x:Turn {key: r.explainKey})
189
+ FOREACH (_ IN CASE WHEN x IS NULL THEN [] ELSE [1] END |
190
+ MERGE (e)-[q:EXPLAINED_BY]->(x)
191
+ SET q.gapMs = r.explainGap)
192
+ RETURN count(e) AS n`;
193
+
194
+ function tally(rows, keyOf, init, add) {
195
+ const map = new Map();
196
+ for (const row of rows) {
197
+ const k = keyOf(row);
198
+ if (k == null) continue;
199
+ if (!map.has(k)) map.set(k, init(row));
200
+ add(map.get(k), row);
201
+ }
202
+ return [...map.values()];
203
+ }
204
+
205
+ /**
206
+ * Writes one projected history batch in dependency order:
207
+ * people/agents/sessions → turns → edits → files/touches → counters → prompt links.
208
+ */
209
+ async function writeProjection(client, projectId, projection) {
210
+ const batch = token();
211
+ const sessions = dedupe(projection.sessions, "sessionKey");
212
+ const turns = dedupe(projection.turns, "turnKey");
213
+ const edits = dedupe(projection.edits, "editKey").map((e) => ({ source: "transcript", lamport: null, sha256: null, ...e }));
214
+
215
+ if (sessions.length) await each(client, projectId, SESSIONS, sessions);
216
+
217
+ const turnResults = turns.length ? await each(client, projectId, TURNS, turns, { batch }) : [];
218
+ const editResults = edits.length ? await each(client, projectId, EDITS, edits, { batch }) : [];
219
+
220
+ const createdEdit = new Set(editResults.filter((r) => r.created).map((r) => r.editKey));
221
+ const fileRows = edits.filter((e) => e.fileKey);
222
+ if (fileRows.length) await each(client, projectId, EDIT_FILES, fileRows);
223
+
224
+ // These agents' transcripts already record each edit, so their watcher patch is the same change twice.
225
+ const counted = (e) => e.source !== "patch" || !TRANSCRIPT_EDIT_AGENTS.has(e.label);
226
+ const freshFileRows = fileRows.filter((e) => createdEdit.has(e.editKey) && counted(e));
227
+ const fileCounts = tally(
228
+ freshFileRows,
229
+ (e) => e.fileKey,
230
+ (e) => ({ fileKey: e.fileKey, n: 0, lastAt: e.ts }),
231
+ (acc, e) => {
232
+ acc.n += 1;
233
+ acc.lastAt = Math.max(acc.lastAt, e.ts);
234
+ }
235
+ );
236
+ if (fileCounts.length) await each(client, projectId, FILE_COUNTS, fileCounts);
237
+
238
+ const touches = tally(
239
+ freshFileRows,
240
+ (e) => `${e.agentKey}|${e.fileKey}`,
241
+ (e) => ({ agentKey: e.agentKey, fileKey: e.fileKey, n: 0, firstAt: e.ts, lastAt: e.ts }),
242
+ (acc, e) => {
243
+ acc.n += 1;
244
+ acc.firstAt = Math.min(acc.firstAt, e.ts);
245
+ acc.lastAt = Math.max(acc.lastAt, e.ts);
246
+ }
247
+ );
248
+ if (touches.length) await each(client, projectId, TOUCHED, touches);
249
+
250
+ const counters = new Map();
251
+ const bump = (sessionKey, field) => {
252
+ if (!counters.has(sessionKey)) counters.set(sessionKey, { sessionKey, turns: 0, edits: 0 });
253
+ counters.get(sessionKey)[field] += 1;
254
+ };
255
+ for (const r of turnResults) if (r.created) bump(r.sessionKey, "turns");
256
+ for (const e of edits) if (e.sessionKey && createdEdit.has(e.editKey) && counted(e)) bump(e.sessionKey, "edits");
257
+ if (counters.size) await each(client, projectId, SESSION_COUNTS, [...counters.values()]);
258
+
259
+ const linked = edits.filter((e) => e.promptKey || e.explainKey);
260
+ if (linked.length) await each(client, projectId, PROMPTS, linked);
261
+
262
+ const pairs = dedupe(
263
+ fileRows.map((e) => ({ k: `${e.agentKey}|${e.fileKey}`, agentKey: e.agentKey, fileKey: e.fileKey })),
264
+ "k"
265
+ );
266
+ if (pairs.length) {
267
+ const candidates = await each(client, projectId, SAME_CHANGE, pairs, { windowMs: SAME_CHANGE_CONTENT_MS });
268
+ const links = matchSameChange(candidates);
269
+ if (links.length) await each(client, projectId, LINK_SAME_CHANGE, links);
270
+ }
271
+
272
+ return {
273
+ sessions: sessions.length,
274
+ turns: turns.length,
275
+ turnsCreated: turnResults.filter((r) => r.created).length,
276
+ edits: edits.length,
277
+ editsCreated: createdEdit.size,
278
+ };
279
+ }
280
+
281
+ module.exports = { writeProjection, each, chunks, dedupe, token, matchSameChange };
@@ -0,0 +1,108 @@
1
+ const { spawn } = require("child_process");
2
+ const { loadRegistry, saveRegistry, saveSession, loadSession } = require("./store");
3
+ const { authorizeUrl, exchangeCode } = require("./githubOAuth");
4
+
5
+ function runGh(args) {
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn("gh", args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false });
8
+ let out = "";
9
+ let err = "";
10
+ child.stdout.on("data", (c) => (out += c));
11
+ child.stderr.on("data", (c) => (err += c));
12
+ child.on("error", () => reject(new Error("GitHub CLI (gh) is not installed")));
13
+ child.on("exit", (code) => {
14
+ if (code === 0) resolve(out.trim());
15
+ else reject(new Error(err.trim() || `gh exited ${code}`));
16
+ });
17
+ });
18
+ }
19
+
20
+ function upsertUser(user) {
21
+ const reg = loadRegistry();
22
+ const idx = reg.users.findIndex((u) => u.login === user.login || u.id === user.id);
23
+ if (idx >= 0) reg.users[idx] = { ...reg.users[idx], ...user };
24
+ else reg.users.unshift(user);
25
+ saveRegistry(reg);
26
+ saveSession({ ...loadSession(), userId: user.id, login: user.login });
27
+ return user;
28
+ }
29
+
30
+ async function loginFromGh() {
31
+ const raw = await runGh(["api", "user"]);
32
+ const gh = JSON.parse(raw);
33
+ return upsertUser({
34
+ id: `gh_${gh.id}`,
35
+ login: gh.login,
36
+ name: gh.name || gh.login,
37
+ avatarUrl: gh.avatar_url,
38
+ });
39
+ }
40
+
41
+ async function loginDeviceFlow() {
42
+ const clientId = process.env.GITHUB_CLIENT_ID;
43
+ if (!clientId) throw new Error("no_device_flow");
44
+ const res = await fetch("https://github.com/login/device/code", {
45
+ method: "POST",
46
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
47
+ body: JSON.stringify({ client_id: clientId, scope: "read:user repo" }),
48
+ });
49
+ const device = await res.json();
50
+ if (!device.device_code) throw new Error(device.error || "device_code_failed");
51
+ return device;
52
+ }
53
+
54
+ async function pollDevice(device) {
55
+ const clientId = process.env.GITHUB_CLIENT_ID;
56
+ const started = Date.now();
57
+ const interval = (device.interval || 5) * 1000;
58
+ while (Date.now() - started < (device.expires_in || 900) * 1000) {
59
+ await new Promise((r) => setTimeout(r, interval));
60
+ const res = await fetch("https://github.com/login/oauth/access_token", {
61
+ method: "POST",
62
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
63
+ body: JSON.stringify({
64
+ client_id: clientId,
65
+ device_code: device.device_code,
66
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
67
+ }),
68
+ });
69
+ const body = await res.json();
70
+ if (body.access_token) {
71
+ const userRes = await fetch("https://api.github.com/user", {
72
+ headers: { Authorization: `Bearer ${body.access_token}`, "User-Agent": "relay-it" },
73
+ });
74
+ const gh = await userRes.json();
75
+ return upsertUser({
76
+ id: `gh_${gh.id}`,
77
+ login: gh.login,
78
+ name: gh.name || gh.login,
79
+ avatarUrl: gh.avatar_url,
80
+ });
81
+ }
82
+ if (body.error === "authorization_pending") continue;
83
+ if (body.error === "slow_down") continue;
84
+ throw new Error(body.error_description || body.error || "device_denied");
85
+ }
86
+ throw new Error("device_expired");
87
+ }
88
+
89
+ function logout() {
90
+ saveSession({ userId: null, login: null, projectId: loadSession().projectId });
91
+ }
92
+
93
+ function whoami() {
94
+ const session = loadSession();
95
+ const user = loadRegistry().users.find((u) => u.id === session.userId || u.login === session.login);
96
+ return { session, user: user || null };
97
+ }
98
+
99
+ module.exports = {
100
+ loginFromGh,
101
+ loginDeviceFlow,
102
+ pollDevice,
103
+ upsertUser,
104
+ logout,
105
+ whoami,
106
+ authorizeUrl,
107
+ exchangeCode,
108
+ };
@@ -0,0 +1,42 @@
1
+ async function exchangeCode(code, redirectUri) {
2
+ const clientId = process.env.GITHUB_CLIENT_ID;
3
+ const secret = process.env.GITHUB_CLIENT_SECRET;
4
+ if (!clientId || !secret) return { error: "oauth_not_configured" };
5
+ const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
6
+ method: "POST",
7
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
8
+ body: JSON.stringify({
9
+ client_id: clientId,
10
+ client_secret: secret,
11
+ code,
12
+ redirect_uri: redirectUri,
13
+ }),
14
+ });
15
+ const token = await tokenRes.json();
16
+ if (!token.access_token) return { error: "token_failed", detail: token };
17
+ const userRes = await fetch("https://api.github.com/user", {
18
+ headers: { Authorization: `Bearer ${token.access_token}`, "User-Agent": "relay-it" },
19
+ });
20
+ const gh = await userRes.json();
21
+ return {
22
+ user: {
23
+ id: `gh_${gh.id}`,
24
+ login: gh.login,
25
+ name: gh.name || gh.login,
26
+ avatarUrl: gh.avatar_url,
27
+ },
28
+ };
29
+ }
30
+
31
+ function authorizeUrl(redirectUri) {
32
+ const clientId = process.env.GITHUB_CLIENT_ID;
33
+ if (!clientId) return null;
34
+ const q = new URLSearchParams({
35
+ client_id: clientId,
36
+ redirect_uri: redirectUri,
37
+ scope: "read:user",
38
+ });
39
+ return `https://github.com/login/oauth/authorize?${q}`;
40
+ }
41
+
42
+ module.exports = { exchangeCode, authorizeUrl };