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.
- package/LICENSE +21 -0
- package/README.md +434 -3
- package/backend/coordinator/client.js +364 -0
- package/backend/coordinator/lockTable.js +649 -0
- package/backend/coordinator/server.js +179 -0
- package/backend/lib/centralAuth.js +62 -0
- package/backend/lib/centralStore.js +112 -0
- package/backend/lib/centralSync.js +88 -0
- package/backend/lib/cloneRepo.js +108 -0
- package/backend/lib/collisionMetrics.js +231 -0
- package/backend/lib/conflicts.js +35 -0
- package/backend/lib/dashboard.js +53 -0
- package/backend/lib/depGraph.js +393 -0
- package/backend/lib/deps.js +46 -0
- package/backend/lib/dirtyFiles.js +33 -0
- package/backend/lib/editMatchers.js +55 -0
- package/backend/lib/falkor/analytics.js +228 -0
- package/backend/lib/falkor/bus.js +16 -0
- package/backend/lib/falkor/central.js +281 -0
- package/backend/lib/falkor/client.js +165 -0
- package/backend/lib/falkor/config.js +73 -0
- package/backend/lib/falkor/coord.js +299 -0
- package/backend/lib/falkor/cursor.js +58 -0
- package/backend/lib/falkor/demo.js +221 -0
- package/backend/lib/falkor/fixtures.js +85 -0
- package/backend/lib/falkor/index.js +146 -0
- package/backend/lib/falkor/ingest.js +233 -0
- package/backend/lib/falkor/intents.js +32 -0
- package/backend/lib/falkor/keys.js +102 -0
- package/backend/lib/falkor/migrations.js +56 -0
- package/backend/lib/falkor/project.js +307 -0
- package/backend/lib/falkor/query.js +344 -0
- package/backend/lib/falkor/redact.js +30 -0
- package/backend/lib/falkor/routes.js +125 -0
- package/backend/lib/falkor/schema.js +108 -0
- package/backend/lib/falkor/write.js +281 -0
- package/backend/lib/githubLogin.js +108 -0
- package/backend/lib/githubOAuth.js +42 -0
- package/backend/lib/githubRepo.js +117 -0
- package/backend/lib/harvest.js +85 -0
- package/backend/lib/httpServer.js +27 -0
- package/backend/lib/installAgentRelay.js +235 -0
- package/backend/lib/installHooks.js +309 -0
- package/backend/lib/lamport.js +14 -0
- package/backend/lib/localBrowse.js +109 -0
- package/backend/lib/memorySync.js +63 -0
- package/backend/lib/notices.js +150 -0
- package/backend/lib/ot.js +74 -0
- package/backend/lib/patches.js +192 -0
- package/backend/lib/paths.js +85 -0
- package/backend/lib/peerClash.js +64 -0
- package/backend/lib/presence.js +35 -0
- package/backend/lib/relayOsBlock.js +84 -0
- package/backend/lib/repoSnapshot.js +125 -0
- package/backend/lib/room.js +638 -0
- package/backend/lib/roomAsk.js +148 -0
- package/backend/lib/roomAuth.js +215 -0
- package/backend/lib/roomControl.js +226 -0
- package/backend/lib/roomLocks.js +201 -0
- package/backend/lib/roomSignal.js +265 -0
- package/backend/lib/roomSync.js +194 -0
- package/backend/lib/seed.js +107 -0
- package/backend/lib/sseHub.js +40 -0
- package/backend/lib/store.js +79 -0
- package/backend/lib/supergraph.js +46 -0
- package/backend/lib/transcripts/discover.js +437 -0
- package/backend/lib/transcripts/parse.js +558 -0
- package/backend/lib/transcripts/sessionId.js +36 -0
- package/backend/lib/transcripts/sync.js +291 -0
- package/backend/lib/transcripts/util.js +299 -0
- package/backend/lib/treeSitterImports.js +219 -0
- package/backend/lib/watchWorkspace.js +42 -0
- package/backend/mcp/README.md +193 -0
- package/backend/mcp/server.js +42 -0
- package/backend/mcp/tools.js +484 -0
- package/backend/server.js +3894 -0
- package/cli/graph.js +286 -0
- package/cli/relay.js +475 -0
- package/docker-compose.falkor.yml +17 -0
- package/docs/MEMORY-GRAPH.md +314 -0
- package/hooks/relay-antigravity-post-tool.js +2 -0
- package/hooks/relay-antigravity-pre-read.js +2 -0
- package/hooks/relay-antigravity-pre-tool.js +2 -0
- package/hooks/relay-antigravity-stop.js +2 -0
- package/hooks/relay-claude-post-tool.js +2 -0
- package/hooks/relay-claude-pre-read.js +2 -0
- package/hooks/relay-claude-pre-tool.js +2 -0
- package/hooks/relay-claude-stop.js +2 -0
- package/hooks/relay-codex-post-tool.js +2 -0
- package/hooks/relay-codex-pre-read.js +2 -0
- package/hooks/relay-codex-pre-tool.js +2 -0
- package/hooks/relay-codex-stop.js +2 -0
- package/hooks/relay-copilot-post-tool.js +2 -0
- package/hooks/relay-copilot-pre-read.js +2 -0
- package/hooks/relay-copilot-pre-tool.js +2 -0
- package/hooks/relay-copilot-stop.js +2 -0
- package/hooks/relay-cursor-post-tool.js +2 -0
- package/hooks/relay-cursor-pre-read.js +2 -0
- package/hooks/relay-cursor-pre-tool.js +2 -0
- package/hooks/relay-cursor-stop.js +2 -0
- package/hooks/relay-hook-lib.js +621 -0
- package/mission-control/out/404.html +1 -0
- package/mission-control/out/__next.__PAGE__.txt +26 -0
- package/mission-control/out/__next._full.txt +21 -0
- package/mission-control/out/__next._tree.txt +4 -0
- package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_buildManifest.js +11 -0
- package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_clientMiddlewareManifest.js +1 -0
- package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_ssgManifest.js +1 -0
- package/mission-control/out/_next/static/chunks/0-3l04fnym95-.js +22 -0
- package/mission-control/out/_next/static/chunks/064ntycm-fx5v.js +1 -0
- package/mission-control/out/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- package/mission-control/out/_next/static/chunks/0ehjiuuxbbhq9.js +1 -0
- package/mission-control/out/_next/static/chunks/1-dy52ls2ntb_.css +4 -0
- package/mission-control/out/_next/static/chunks/1q0cmt0kqufow.js +2 -0
- package/mission-control/out/_next/static/chunks/1ya1u0izct4f6.js +1 -0
- package/mission-control/out/_next/static/chunks/2n66mlnb34vjq.js +1 -0
- package/mission-control/out/_next/static/chunks/2xar70jzx8f1y.css +1 -0
- package/mission-control/out/_next/static/chunks/31cgqj_-l15yt.js +31 -0
- package/mission-control/out/_next/static/chunks/31qgr-p-oulpb.js +1 -0
- package/mission-control/out/_next/static/chunks/3vq9c4hivvgn_.js +1 -0
- package/mission-control/out/_next/static/chunks/turbopack-3hsllzpnzz12p.js +1 -0
- package/mission-control/out/_next/static/media/icon.27otmh_fv3buh.svg +4 -0
- package/mission-control/out/_not-found/__next._full.txt +18 -0
- package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +24 -0
- package/mission-control/out/_not-found/__next._tree.txt +3 -0
- package/mission-control/out/_not-found.html +1 -0
- package/mission-control/out/_not-found.txt +18 -0
- package/mission-control/out/agents/antigravity.png +0 -0
- package/mission-control/out/agents/claude.png +0 -0
- package/mission-control/out/agents/codex.png +0 -0
- package/mission-control/out/agents/copilot.png +0 -0
- package/mission-control/out/agents/cursor.png +0 -0
- package/mission-control/out/icon.svg +4 -0
- package/mission-control/out/index.html +1 -0
- package/mission-control/out/index.txt +21 -0
- package/mission-control/out/logo.png +0 -0
- package/mission-control/out/logo_transparent.png +0 -0
- package/package.json +76 -6
- package/scripts/falkor-rebuild.js +38 -0
- package/scripts/falkor-seed-demo.js +29 -0
|
@@ -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 };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const { spawn } = require("child_process");
|
|
2
|
+
|
|
3
|
+
// In-memory only — a per-repo cache is enough to stop every dashboard poll
|
|
4
|
+
// (RelayContext polls on an interval) from re-hitting GitHub's API/gh CLI.
|
|
5
|
+
const CACHE_MS = 60_000;
|
|
6
|
+
const cache = new Map(); // "owner/repo" -> { at, data }
|
|
7
|
+
|
|
8
|
+
function runGh(args) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const child = spawn("gh", args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false });
|
|
11
|
+
let out = "";
|
|
12
|
+
let err = "";
|
|
13
|
+
child.stdout.on("data", (c) => (out += c));
|
|
14
|
+
child.stderr.on("data", (c) => (err += c));
|
|
15
|
+
child.on("error", () => reject(new Error("gh_not_installed")));
|
|
16
|
+
child.on("exit", (code) => {
|
|
17
|
+
if (code === 0) resolve(out.trim());
|
|
18
|
+
else reject(new Error(err.trim() || `gh exited ${code}`));
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Matches both https://github.com/owner/repo(.git) and git@github.com:owner/repo(.git)
|
|
24
|
+
function parseGithubRepo(remoteUrl) {
|
|
25
|
+
if (!remoteUrl) return null;
|
|
26
|
+
const m = String(remoteUrl).match(/github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?\/?$/i);
|
|
27
|
+
if (!m) return null;
|
|
28
|
+
return { owner: m[1], repo: m[2] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function permissionOf(u) {
|
|
32
|
+
if (u.role_name) return u.role_name;
|
|
33
|
+
if (u.permissions?.admin) return "admin";
|
|
34
|
+
if (u.permissions?.push) return "write";
|
|
35
|
+
if (u.permissions?.pull) return "read";
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function fetchCollaboratorsRaw(owner, repo) {
|
|
40
|
+
try {
|
|
41
|
+
// Requires push access on the repo (via `gh auth login`) — this is the
|
|
42
|
+
// real "who can push here" collaborator list, avatars included.
|
|
43
|
+
const raw = await runGh(["api", `repos/${owner}/${repo}/collaborators`, "--paginate"]);
|
|
44
|
+
const list = JSON.parse(raw);
|
|
45
|
+
return list.map((u) => ({
|
|
46
|
+
id: `gh_${u.id}`,
|
|
47
|
+
login: u.login,
|
|
48
|
+
name: u.login,
|
|
49
|
+
avatarUrl: u.avatar_url,
|
|
50
|
+
permission: permissionOf(u),
|
|
51
|
+
source: "collaborators",
|
|
52
|
+
}));
|
|
53
|
+
} catch {
|
|
54
|
+
// Fallback for when `gh` isn't installed/authed, or the caller lacks push
|
|
55
|
+
// access: public contributor list needs no auth at all. Not the same set
|
|
56
|
+
// as real collaborators (it's activity-based, no pending/read-only
|
|
57
|
+
// invites) but better than an empty panel.
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/contributors`, {
|
|
60
|
+
headers: { "User-Agent": "relay-it" },
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) throw new Error(`http_${res.status}`);
|
|
63
|
+
const list = await res.json();
|
|
64
|
+
return list.map((u) => ({
|
|
65
|
+
id: `gh_${u.id}`,
|
|
66
|
+
login: u.login,
|
|
67
|
+
name: u.login,
|
|
68
|
+
avatarUrl: u.avatar_url,
|
|
69
|
+
permission: null,
|
|
70
|
+
source: "contributors-fallback",
|
|
71
|
+
}));
|
|
72
|
+
} catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fetchCollaborators(remoteUrl) {
|
|
79
|
+
const parsed = parseGithubRepo(remoteUrl);
|
|
80
|
+
if (!parsed) return Promise.resolve([]);
|
|
81
|
+
const key = `${parsed.owner}/${parsed.repo}`;
|
|
82
|
+
const hit = cache.get(key);
|
|
83
|
+
if (hit && Date.now() - hit.at < CACHE_MS && hit.data.length) return Promise.resolve(hit.data);
|
|
84
|
+
return fetchCollaboratorsRaw(parsed.owner, parsed.repo).then((data) => {
|
|
85
|
+
const prev = cache.get(key);
|
|
86
|
+
const prevAuth = (prev?.data || []).filter((c) => c.source === "collaborators");
|
|
87
|
+
const nextAuth = (data || []).filter((c) => c.source === "collaborators");
|
|
88
|
+
// Prefer a real collaborator list. A later contributors-fallback (gh
|
|
89
|
+
// flaked, no push access this poll) must not shrink a roster we already had.
|
|
90
|
+
if (nextAuth.length) {
|
|
91
|
+
cache.set(key, { at: Date.now(), data });
|
|
92
|
+
return data;
|
|
93
|
+
}
|
|
94
|
+
if (prevAuth.length) {
|
|
95
|
+
cache.set(key, { at: Date.now(), data: prev.data });
|
|
96
|
+
return prev.data;
|
|
97
|
+
}
|
|
98
|
+
if (data.length) {
|
|
99
|
+
cache.set(key, { at: Date.now(), data });
|
|
100
|
+
return data;
|
|
101
|
+
}
|
|
102
|
+
if (prev?.data?.length) {
|
|
103
|
+
cache.set(key, { at: Date.now(), data: prev.data });
|
|
104
|
+
return prev.data;
|
|
105
|
+
}
|
|
106
|
+
cache.set(key, { at: Date.now(), data: [] });
|
|
107
|
+
return [];
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function peekCollaborators(remoteUrl) {
|
|
112
|
+
const parsed = parseGithubRepo(remoteUrl);
|
|
113
|
+
if (!parsed) return [];
|
|
114
|
+
return cache.get(`${parsed.owner}/${parsed.repo}`)?.data || [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { fetchCollaborators, peekCollaborators, parseGithubRepo };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
|
|
5
|
+
function readJsonl(file) {
|
|
6
|
+
if (!file || !fs.existsSync(file)) return [];
|
|
7
|
+
return fs
|
|
8
|
+
.readFileSync(file, "utf8")
|
|
9
|
+
.split("\n")
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.map((line) => {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(line);
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeMessages(rows) {
|
|
22
|
+
const messages = [];
|
|
23
|
+
const edits = [];
|
|
24
|
+
for (const row of rows) {
|
|
25
|
+
const role = row.role || row.type || row.message?.role;
|
|
26
|
+
const text =
|
|
27
|
+
row.text ||
|
|
28
|
+
row.content ||
|
|
29
|
+
row.message?.content ||
|
|
30
|
+
(Array.isArray(row.message?.content) ? row.message.content.map((c) => c.text || "").join("\n") : "");
|
|
31
|
+
if (role === "user" || role === "assistant" || role === "human") {
|
|
32
|
+
messages.push({ role: role === "human" ? "user" : role, text: String(text || "").slice(0, 4000) });
|
|
33
|
+
}
|
|
34
|
+
const file =
|
|
35
|
+
row.tool_input?.file_path ||
|
|
36
|
+
row.tool_input?.target_file ||
|
|
37
|
+
row.message?.tool_use?.input?.file_path ||
|
|
38
|
+
null;
|
|
39
|
+
if (file && (role === "tool" || row.type === "tool_use" || row.tool_name)) {
|
|
40
|
+
edits.push({ file: String(file).replace(/\\/g, "/"), diff: "" });
|
|
41
|
+
messages.push({ role: "tool", text: `Edit ${file}`, file });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { messages, edits };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function findCursorTranscript(sessionId) {
|
|
48
|
+
if (!sessionId) return null;
|
|
49
|
+
const roots = [
|
|
50
|
+
path.join(os.homedir(), ".cursor", "projects"),
|
|
51
|
+
path.join(os.homedir(), ".cursor-tutor"),
|
|
52
|
+
];
|
|
53
|
+
for (const root of roots) {
|
|
54
|
+
if (!fs.existsSync(root)) continue;
|
|
55
|
+
const stack = [root];
|
|
56
|
+
let walked = 0;
|
|
57
|
+
while (stack.length && walked < 400) {
|
|
58
|
+
walked += 1;
|
|
59
|
+
const dir = stack.pop();
|
|
60
|
+
let entries = [];
|
|
61
|
+
try {
|
|
62
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
63
|
+
} catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
const full = path.join(dir, e.name);
|
|
68
|
+
if (e.isDirectory() && e.name !== "node_modules") stack.push(full);
|
|
69
|
+
else if (e.name.includes(sessionId) && e.name.endsWith(".jsonl")) return full;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function harvest({ transcript_path, sessionId, conversationId }) {
|
|
77
|
+
const file = transcript_path || findCursorTranscript(sessionId || conversationId);
|
|
78
|
+
if (!file) return { messages: [], edits: [], source: null };
|
|
79
|
+
const rows = readJsonl(file);
|
|
80
|
+
const parsed = normalizeMessages(rows);
|
|
81
|
+
parsed.source = file;
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { harvest, readJsonl, normalizeMessages };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Next's rewrite proxy reuses sockets; Node's 5s keep-alive closes them first → ECONNRESET. */
|
|
2
|
+
function hardenHttpServer(server) {
|
|
3
|
+
server.keepAliveTimeout = 65_000;
|
|
4
|
+
server.headersTimeout = 66_000;
|
|
5
|
+
server.requestTimeout = 0;
|
|
6
|
+
server.timeout = 0;
|
|
7
|
+
server.on("error", (err) => {
|
|
8
|
+
console.error("[relay-api]", err.message);
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
});
|
|
11
|
+
server.on("clientError", (err, socket) => {
|
|
12
|
+
if (err?.code === "ECONNRESET" || !socket || socket.destroyed) return;
|
|
13
|
+
try {
|
|
14
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
15
|
+
} catch {
|
|
16
|
+
/* already closed */
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
return server;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function apiErrorHandler(err, _req, res, _next) {
|
|
23
|
+
console.error("[relay-api]", err);
|
|
24
|
+
if (!res.headersSent) res.status(500).json({ error: String(err.message || err) });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { hardenHttpServer, apiErrorHandler };
|