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.
- package/LICENSE +21 -0
- package/README.md +290 -2
- 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/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 +81 -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 +143 -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 +498 -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 +338 -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 +3839 -0
- package/cli/graph.js +278 -0
- package/cli/relay.js +419 -0
- package/docker-compose.falkor.yml +17 -0
- package/docs/MEMORY-GRAPH.md +269 -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/chunks/0-3l04fnym95-.js +22 -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/1q0cmt0kqufow.js +2 -0
- package/mission-control/out/_next/static/chunks/1ya1u0izct4f6.js +1 -0
- package/mission-control/out/_next/static/chunks/25rvg4bnew99f.css +4 -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/3_5x4qn1_b4al.js +1 -0
- package/mission-control/out/_next/static/chunks/3o1tiwncjss_k.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/dglM39XRu0cmneClUkGYd/_buildManifest.js +11 -0
- package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_clientMiddlewareManifest.js +1 -0
- package/mission-control/out/_next/static/dglM39XRu0cmneClUkGYd/_ssgManifest.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 +74 -4
- package/scripts/falkor-rebuild.js +57 -0
- package/scripts/falkor-seed-demo.js +157 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Pure: memory.history → row batches for write.js. No I/O, no database.
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { relativeEditPath } = require("../transcripts/sync");
|
|
4
|
+
const keys = require("./keys");
|
|
5
|
+
const { storedText, clip, redact } = require("./redact");
|
|
6
|
+
|
|
7
|
+
const TURN_TEXT_MAX = 2000;
|
|
8
|
+
const DIFF_HEAD_MAX = 1000;
|
|
9
|
+
const INTENT_MAX = 280;
|
|
10
|
+
const TITLE_MAX = 120;
|
|
11
|
+
const STALE_PROMPT_MS = 30 * 60 * 1000;
|
|
12
|
+
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000;
|
|
13
|
+
// "continue"-style nudges say nothing about intent; the request they continue does.
|
|
14
|
+
const FILLER = /^(please\s+)?(continue|go(\s+on|\s+ahead)?|yes|yep|yeah|y|ok(ay)?|sure|proceed|do\s+it|keep\s+going|carry\s+on|next|try\s+again|retry|go\s+for\s+it|sounds\s+good|lgtm|done\??)[\s.!]*$/i;
|
|
15
|
+
|
|
16
|
+
const GREETING = /^(hi+|hello|hey+|yo|sup|wassup|hola|thanks|thank\s+you|thx|ty)(\s+(there|wassup|sup|man|bro|all|claude|cursor|again|so\s+much))*[\s.!?,]*$/i;
|
|
17
|
+
|
|
18
|
+
function isFiller(text) {
|
|
19
|
+
const s = String(text || "").trim();
|
|
20
|
+
return s.length <= 30 && (FILLER.test(s) || GREETING.test(s));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function diffStats(diff) {
|
|
24
|
+
let added = 0;
|
|
25
|
+
let removed = 0;
|
|
26
|
+
for (const line of String(diff || "").split("\n")) {
|
|
27
|
+
if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
|
|
28
|
+
else if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
|
|
29
|
+
}
|
|
30
|
+
return { added, removed };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toolOf(text) {
|
|
34
|
+
const word = String(text || "").trim().split(/\s+/)[0] || "";
|
|
35
|
+
return word.slice(0, 40) || null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sessionOf(event) {
|
|
39
|
+
const real = keys.sessionKey(event.agent, event.sessionId);
|
|
40
|
+
if (real) return { key: real, bucket: false };
|
|
41
|
+
return { key: keys.bucketSessionKey(event.agent, event.ownerLogin, event.ts), bucket: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function oneLine(text, max) {
|
|
45
|
+
return clip(redact(String(text || "").replace(/\s+/g, " ").trim()), max);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {object[]} history unified events (sync.js unifyEvent), ordered by compareEvents
|
|
50
|
+
* @param {object} opts
|
|
51
|
+
* @param {string} opts.workspacePath used to make edit paths repo-relative
|
|
52
|
+
* @param {(id: string) => boolean} [opts.isNew] events not yet ingested; default: all
|
|
53
|
+
* @param {boolean} [opts.storeText]
|
|
54
|
+
*/
|
|
55
|
+
function projectHistory(history, opts = {}) {
|
|
56
|
+
const { workspacePath, isNew = () => true, storeText = true } = opts;
|
|
57
|
+
const groups = new Map();
|
|
58
|
+
let dropped = 0;
|
|
59
|
+
|
|
60
|
+
for (const event of history || []) {
|
|
61
|
+
if (!event || !event.id || !event.agent) {
|
|
62
|
+
dropped += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const { key, bucket } = sessionOf(event);
|
|
66
|
+
if (!groups.has(key)) groups.set(key, { key, bucket, events: [] });
|
|
67
|
+
groups.get(key).events.push(event);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sessions = [];
|
|
71
|
+
const turns = [];
|
|
72
|
+
const edits = [];
|
|
73
|
+
const intents = new Map();
|
|
74
|
+
|
|
75
|
+
for (const group of groups.values()) {
|
|
76
|
+
const first = group.events[0];
|
|
77
|
+
const login = keys.loginKey(first.ownerLogin);
|
|
78
|
+
const agentKey = keys.agentKey(first.ownerLogin, first.agent);
|
|
79
|
+
let lastUser = null;
|
|
80
|
+
let lastMeaningful = null;
|
|
81
|
+
let lastAssistant = null;
|
|
82
|
+
let firstUser = null;
|
|
83
|
+
let thinking = 0;
|
|
84
|
+
let fresh = false;
|
|
85
|
+
let seq = 0;
|
|
86
|
+
const recent = new Map();
|
|
87
|
+
let startedAt = Infinity;
|
|
88
|
+
let lastAt = 0;
|
|
89
|
+
|
|
90
|
+
for (const event of group.events) {
|
|
91
|
+
const ts = Number(event.ts) || 0;
|
|
92
|
+
startedAt = Math.min(startedAt, ts);
|
|
93
|
+
lastAt = Math.max(lastAt, ts);
|
|
94
|
+
const isFresh = isNew(event.id);
|
|
95
|
+
if (isFresh) fresh = true;
|
|
96
|
+
|
|
97
|
+
if (event.kind === "thinking") {
|
|
98
|
+
thinking += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (event.kind === "message" && (event.role === "user" || event.role === "assistant")) {
|
|
103
|
+
if (!String(event.text || "").trim()) continue;
|
|
104
|
+
// One conversation can be recorded twice (Copilot writes chatSessions and a transcript
|
|
105
|
+
// for the same session); a repeat of the same message moments later is the same turn.
|
|
106
|
+
const dupKey = `${event.role}|${String(event.text).replace(/\s+/g, " ").trim().slice(0, 200)}`;
|
|
107
|
+
const prevTs = recent.get(dupKey);
|
|
108
|
+
if (prevTs != null && Math.abs(ts - prevTs) <= DUPLICATE_WINDOW_MS) continue;
|
|
109
|
+
recent.set(dupKey, ts);
|
|
110
|
+
const turn = {
|
|
111
|
+
sessionKey: group.key,
|
|
112
|
+
turnKey: keys.turnKey({
|
|
113
|
+
login: event.ownerLogin,
|
|
114
|
+
label: event.agent,
|
|
115
|
+
sessionId: event.sessionId,
|
|
116
|
+
ts: event.ts,
|
|
117
|
+
role: event.role,
|
|
118
|
+
kind: event.kind,
|
|
119
|
+
text: event.text,
|
|
120
|
+
}),
|
|
121
|
+
ts,
|
|
122
|
+
role: event.role,
|
|
123
|
+
kind: "message",
|
|
124
|
+
text: storedText(event.text, TURN_TEXT_MAX, storeText),
|
|
125
|
+
seq: seq++,
|
|
126
|
+
tsSynthetic: keys.tsSynthetic(event.ts, event.tsSynthetic),
|
|
127
|
+
};
|
|
128
|
+
if (event.role === "user") {
|
|
129
|
+
lastUser = { ...turn, raw: event.text };
|
|
130
|
+
if (!isFiller(event.text)) {
|
|
131
|
+
lastMeaningful = lastUser;
|
|
132
|
+
if (!firstUser) firstUser = lastUser;
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
lastAssistant = turn;
|
|
136
|
+
}
|
|
137
|
+
if (isFresh) turns.push(turn);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (event.kind === "code_edit") {
|
|
142
|
+
const hasPath = Boolean(event.path || event.file);
|
|
143
|
+
const rel = hasPath ? relativeEditPath(event, workspacePath) : null;
|
|
144
|
+
// Matches sync.js buildViews: an edit outside the workspace is not this project's activity.
|
|
145
|
+
if (hasPath && !rel) continue;
|
|
146
|
+
if (!isFresh) continue;
|
|
147
|
+
const fk = keys.fileKey(rel);
|
|
148
|
+
const stats = diffStats(event.diff);
|
|
149
|
+
const prompt = lastMeaningful || lastUser;
|
|
150
|
+
const promptGap = prompt ? ts - prompt.ts : null;
|
|
151
|
+
edits.push({
|
|
152
|
+
sessionKey: group.key,
|
|
153
|
+
agentKey,
|
|
154
|
+
login,
|
|
155
|
+
loginRaw: String(first.ownerLogin || "local"),
|
|
156
|
+
label: first.agent,
|
|
157
|
+
editKey: `e_${event.id}`,
|
|
158
|
+
fileKey: fk,
|
|
159
|
+
path: fk ? keys.displayPath(rel) : null,
|
|
160
|
+
ext: fk ? path.posix.extname(fk).slice(1) || null : null,
|
|
161
|
+
orphan: !fk,
|
|
162
|
+
ts,
|
|
163
|
+
tool: toolOf(event.text),
|
|
164
|
+
diffHead: storeText ? clip(redact(event.diff || ""), DIFF_HEAD_MAX) : null,
|
|
165
|
+
added: stats.added,
|
|
166
|
+
removed: stats.removed,
|
|
167
|
+
tsSynthetic: keys.tsSynthetic(event.ts, event.tsSynthetic),
|
|
168
|
+
promptKey: prompt ? prompt.turnKey : null,
|
|
169
|
+
promptGap,
|
|
170
|
+
promptStale: promptGap != null && promptGap > STALE_PROMPT_MS,
|
|
171
|
+
promptConfidence: group.bucket ? "low" : "high",
|
|
172
|
+
promptVia: lastUser && prompt !== lastUser ? oneLine(lastUser.raw, 40) : null,
|
|
173
|
+
explainKey: lastAssistant ? lastAssistant.turnKey : null,
|
|
174
|
+
explainGap: lastAssistant ? ts - lastAssistant.ts : null,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const intentSource = lastMeaningful || lastUser;
|
|
180
|
+
const intentText = intentSource ? oneLine(intentSource.raw, INTENT_MAX) : null;
|
|
181
|
+
const info = {
|
|
182
|
+
sessionKey: group.key,
|
|
183
|
+
agentKey,
|
|
184
|
+
label: first.agent,
|
|
185
|
+
login,
|
|
186
|
+
loginRaw: String(first.ownerLogin || "local"),
|
|
187
|
+
sessionId: group.bucket ? null : String(first.sessionId),
|
|
188
|
+
bucket: group.bucket,
|
|
189
|
+
startedAt: Number.isFinite(startedAt) ? startedAt : 0,
|
|
190
|
+
lastAt,
|
|
191
|
+
thinkingCount: thinking,
|
|
192
|
+
title: storeText && firstUser ? oneLine(firstUser.raw, TITLE_MAX) : null,
|
|
193
|
+
intent: storeText ? intentText : null,
|
|
194
|
+
intentAt: intentSource ? intentSource.ts : null,
|
|
195
|
+
};
|
|
196
|
+
// The cache only lives in this process (deny-reason hints), so it keeps text even when the graph doesn't.
|
|
197
|
+
intents.set(group.key, { ...info, intent: intentText });
|
|
198
|
+
if (fresh) sessions.push(info);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { sessions, turns, edits, intents, dropped };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const AGENT_LABELS = new Set(["Claude Code", "Cursor", "Codex", "Copilot", "Antigravity"]);
|
|
205
|
+
// Watcher patches from bulk tree copies are not anyone's edit.
|
|
206
|
+
const BULK_PATCH_AGENTS = new Set(["seed", "snapshot"]);
|
|
207
|
+
const UNATTRIBUTED = "Editor";
|
|
208
|
+
// Files `relay init/clone/add` writes itself; an unattributed change to them is relay, not a person.
|
|
209
|
+
const RELAY_MANAGED = /^(\.claude\/|\.cursor\/|\.codex\/|\.agents\/|\.relay\/|\.github\/hooks\/|\.github\/mcp\.json$|\.github\/copilot-instructions\.md$|\.cursorrules$|claude\.md$|agents\.md$)/;
|
|
210
|
+
|
|
211
|
+
function patchRow(patch, { storeText = true } = {}) {
|
|
212
|
+
if (!patch?.id || !patch.file || BULK_PATCH_AGENTS.has(patch.agent)) return null;
|
|
213
|
+
const fk = keys.fileKey(patch.file);
|
|
214
|
+
if (!fk) return null;
|
|
215
|
+
// patch.agent is just "fs.watch"; the lock holder that triggered the flush is the real author.
|
|
216
|
+
const origin = keys.parseLockAgentId(patch.originAgentId);
|
|
217
|
+
const labelFromAgent = AGENT_LABELS.has(patch.agent) ? patch.agent : null;
|
|
218
|
+
const label = AGENT_LABELS.has(origin.label) ? origin.label : labelFromAgent || UNATTRIBUTED;
|
|
219
|
+
if (label === UNATTRIBUTED && RELAY_MANAGED.test(fk)) return null;
|
|
220
|
+
const sk = label === origin.label && origin.sessionKnown ? keys.sessionKey(label, origin.session) : null;
|
|
221
|
+
const diff = String(patch.diff || "");
|
|
222
|
+
const stats = diffStats(diff);
|
|
223
|
+
return {
|
|
224
|
+
sessionKey: sk,
|
|
225
|
+
agentKey: keys.agentKey(patch.ownerLogin, label),
|
|
226
|
+
login: keys.loginKey(patch.ownerLogin),
|
|
227
|
+
loginRaw: String(patch.ownerLogin || "local"),
|
|
228
|
+
label,
|
|
229
|
+
editKey: String(patch.id),
|
|
230
|
+
source: "patch",
|
|
231
|
+
fileKey: fk,
|
|
232
|
+
path: keys.displayPath(patch.file),
|
|
233
|
+
ext: path.posix.extname(fk).slice(1) || null,
|
|
234
|
+
orphan: false,
|
|
235
|
+
ts: Number(patch.ts) || Date.now(),
|
|
236
|
+
tool: "patch",
|
|
237
|
+
diffHead: storeText && !diff.startsWith("sha256:") ? clip(redact(diff), DIFF_HEAD_MAX) : null,
|
|
238
|
+
added: stats.added,
|
|
239
|
+
removed: stats.removed,
|
|
240
|
+
tsSynthetic: false,
|
|
241
|
+
lamport: Number.isFinite(Number(patch.lamport)) ? Number(patch.lamport) : null,
|
|
242
|
+
sha256: patch.sha256 || null,
|
|
243
|
+
promptKey: null,
|
|
244
|
+
explainKey: null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function projectPatches(patches, opts = {}) {
|
|
249
|
+
return { sessions: [], turns: [], edits: patches.map((p) => patchRow(p, opts)).filter(Boolean), intents: new Map(), dropped: 0 };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A room peer's snapshot (roomSync.sanitize shape) → the same rows as local history.
|
|
254
|
+
* Peers ship threads + edit rows but no raw history, and their edit rows carry no
|
|
255
|
+
* session, so each edit is re-attached to its thread's code_edit message.
|
|
256
|
+
*/
|
|
257
|
+
function projectPeer(snapshot, { login, trustRowOwners = false, isNew = () => true, storeText = true } = {}) {
|
|
258
|
+
const events = [];
|
|
259
|
+
const edits = [...(snapshot?.edits || [])];
|
|
260
|
+
const used = new Set();
|
|
261
|
+
const ownerOf = (row) => (trustRowOwners ? row?.ownerLogin || login : login);
|
|
262
|
+
|
|
263
|
+
for (const thread of snapshot?.chats || []) {
|
|
264
|
+
if (!thread?.agent) continue;
|
|
265
|
+
const owner = ownerOf(thread);
|
|
266
|
+
for (const m of thread.messages || []) {
|
|
267
|
+
if (!m) continue;
|
|
268
|
+
const base = { agent: thread.agent, ownerLogin: owner, sessionId: thread.sessionId, ts: Number(m.ts) || 0 };
|
|
269
|
+
if (m.kind === "code_edit") {
|
|
270
|
+
const base2 = path.posix.basename(String(m.file || "").replace(/\\/g, "/")).toLowerCase();
|
|
271
|
+
const idx = edits.findIndex(
|
|
272
|
+
(e, i) =>
|
|
273
|
+
!used.has(i) &&
|
|
274
|
+
e.agent === thread.agent &&
|
|
275
|
+
Number(e.ts) === base.ts &&
|
|
276
|
+
path.posix.basename(String(e.file || "").replace(/\\/g, "/")).toLowerCase() === base2
|
|
277
|
+
);
|
|
278
|
+
if (idx < 0) continue;
|
|
279
|
+
used.add(idx);
|
|
280
|
+
const edit = edits[idx];
|
|
281
|
+
events.push({ ...base, id: String(edit.id).replace(/^e_/, ""), role: "tool", kind: "code_edit", text: m.text, file: m.file, path: edit.file, diff: edit.diff });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (m.role !== "user" && m.role !== "assistant") continue;
|
|
285
|
+
const tk = keys.turnKey({ login: owner, label: thread.agent, sessionId: thread.sessionId, ts: m.ts, role: m.role, kind: "message", text: m.text });
|
|
286
|
+
events.push({ ...base, id: `peer_${tk}`, role: m.role, kind: "message", text: m.text });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
events.sort((a, b) => a.ts - b.ts);
|
|
290
|
+
const projection = projectHistory(events, { workspacePath: null, isNew, storeText });
|
|
291
|
+
|
|
292
|
+
// Edits whose thread message was trimmed away still count; they just lose their session.
|
|
293
|
+
for (let i = 0; i < edits.length; i++) {
|
|
294
|
+
if (used.has(i)) continue;
|
|
295
|
+
const e = edits[i];
|
|
296
|
+
const owner = ownerOf(e);
|
|
297
|
+
const row = String(e.id || "").startsWith("patch_")
|
|
298
|
+
? patchRow({ ...e, ownerLogin: owner }, { storeText })
|
|
299
|
+
: patchRow({ ...e, id: e.id, ownerLogin: owner, originAgentId: null }, { storeText });
|
|
300
|
+
if (!row || !isNew(row.editKey)) continue;
|
|
301
|
+
if (!String(e.id || "").startsWith("patch_")) row.source = "transcript";
|
|
302
|
+
projection.edits.push(row);
|
|
303
|
+
}
|
|
304
|
+
return projection;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
module.exports = { projectHistory, projectPatches, projectPeer, patchRow, diffStats, TURN_TEXT_MAX, INTENT_MAX, UNATTRIBUTED };
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// Named, parameterized, read-only queries: the only way anything reads the graph.
|
|
2
|
+
// No caller-supplied Cypher, so the surface is safe for room guests over the tunnel.
|
|
3
|
+
const keys = require("./keys");
|
|
4
|
+
|
|
5
|
+
const MAX_LIMIT = 50;
|
|
6
|
+
const ACTIVE_MS = 15 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
function limitOf(v, d = 10) {
|
|
9
|
+
const n = Number(v) || d;
|
|
10
|
+
return Math.max(1, Math.min(MAX_LIMIT, Math.floor(n)));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function requireFile(args) {
|
|
14
|
+
const fk = keys.fileKey(args.path);
|
|
15
|
+
if (!fk) throw Object.assign(new Error("`path` must be a repo-relative file path"), { status: 400 });
|
|
16
|
+
return fk;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fileList(args) {
|
|
20
|
+
const raw = Array.isArray(args.paths) ? args.paths : String(args.paths || args.path || "").split(",");
|
|
21
|
+
const list = [...new Set(raw.map((p) => keys.fileKey(p)).filter(Boolean))].slice(0, 10);
|
|
22
|
+
if (!list.length) throw Object.assign(new Error("`paths` must list repo-relative files"), { status: 400 });
|
|
23
|
+
return list;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** RediSearch syntax characters would turn user text into a query language; keep words only. */
|
|
27
|
+
function searchTerms(q) {
|
|
28
|
+
const words = String(q || "")
|
|
29
|
+
.replace(/[^\p{L}\p{N}_\s]/gu, " ")
|
|
30
|
+
.split(/\s+/)
|
|
31
|
+
.filter((w) => w.length > 1)
|
|
32
|
+
.slice(0, 8);
|
|
33
|
+
return words.join(" ");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const Q = {
|
|
37
|
+
file_story: {
|
|
38
|
+
summary: "Every change to a file, newest first, with the prompt that caused it.",
|
|
39
|
+
cypher: `
|
|
40
|
+
MATCH (f:File {key: $fileKey})<-[:MODIFIED]-(e:Edit)-[:BY]->(a:Agent)
|
|
41
|
+
WHERE NOT (e)-[:SAME_CHANGE]->()
|
|
42
|
+
OPTIONAL MATCH (e)-[p:PROMPTED_BY]->(u:Turn)
|
|
43
|
+
OPTIONAL MATCH (e)-[:EXPLAINED_BY]->(x:Turn)
|
|
44
|
+
OPTIONAL MATCH (s:Session)-[:MADE]->(e)
|
|
45
|
+
RETURN e.ts AS ts, a.label AS agent, a.login AS person, e.source AS source, s.title AS session,
|
|
46
|
+
u.text AS prompt, p.via AS via, x.text AS agentSaid, e.added AS added, e.removed AS removed, e.diffHead AS diff
|
|
47
|
+
ORDER BY ts DESC LIMIT $limit`,
|
|
48
|
+
params: (a) => ({ fileKey: requireFile(a), limit: limitOf(a.limit) }),
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
who_to_ask: {
|
|
52
|
+
summary: "People (and their agents) who know a file best, by recency and volume.",
|
|
53
|
+
cypher: `
|
|
54
|
+
MATCH (p:Person)-[:RUNS]->(a:Agent)-[t:TOUCHED]->(f:File {key: $fileKey})
|
|
55
|
+
WITH p, sum(t.count) AS edits, max(t.lastAt) AS lastAt, collect(DISTINCT a.label) AS via
|
|
56
|
+
RETURN p.login AS person, edits, lastAt, via
|
|
57
|
+
ORDER BY lastAt DESC, edits DESC LIMIT $limit`,
|
|
58
|
+
params: (a) => ({ fileKey: requireFile(a), limit: limitOf(a.limit, 5) }),
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
contention_trace: {
|
|
62
|
+
summary: "Why agents were blocked: who held the file, what they were doing, who wanted it.",
|
|
63
|
+
cypher: `
|
|
64
|
+
MATCH (c:Contention)-[:ON]->(f:File)
|
|
65
|
+
WHERE $fileKey IS NULL OR f.key = $fileKey
|
|
66
|
+
OPTIONAL MATCH (c)-[:HELD_BY]->(hs:Session)
|
|
67
|
+
OPTIONAL MATCH (ha:Agent)-[:RAN]->(hs)
|
|
68
|
+
OPTIONAL MATCH (c)-[:WANTED_BY]->(ws:Session)
|
|
69
|
+
OPTIONAL MATCH (wa:Agent)-[:RAN]->(ws)
|
|
70
|
+
RETURN c.ts AS ts, c.lastTs AS lastTs, c.kind AS kind, c.attempts AS attempts, f.path AS file,
|
|
71
|
+
coalesce(ha.label, c.holderLabel) AS holder, ha.login AS holderPerson,
|
|
72
|
+
coalesce(c.holderIntent, hs.intent) AS holderWasDoing,
|
|
73
|
+
coalesce(wa.label, c.wanterLabel) AS wanter, wa.login AS wanterPerson, ws.intent AS wanterWasDoing,
|
|
74
|
+
c.reason AS reason
|
|
75
|
+
ORDER BY ts DESC LIMIT $limit`,
|
|
76
|
+
params: (a) => ({ fileKey: a.path ? requireFile(a) : null, limit: limitOf(a.limit) }),
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
search_memory: {
|
|
80
|
+
summary: "Full-text search across every agent conversation in the project.",
|
|
81
|
+
cypher: `
|
|
82
|
+
CALL db.idx.fulltext.queryNodes('Turn', $q) YIELD node, score
|
|
83
|
+
MATCH (s:Session)-[:HAS_TURN]->(node)
|
|
84
|
+
OPTIONAL MATCH (a:Agent)-[:RAN]->(s)
|
|
85
|
+
RETURN node.text AS text, node.ts AS ts, node.role AS role, a.label AS agent, a.login AS person,
|
|
86
|
+
s.title AS session, s.key AS sessionKey, score
|
|
87
|
+
ORDER BY score DESC LIMIT $limit`,
|
|
88
|
+
params: (a) => {
|
|
89
|
+
const q = searchTerms(a.q);
|
|
90
|
+
if (!q) throw Object.assign(new Error("`q` must contain words to search for"), { status: 400 });
|
|
91
|
+
return { q, limit: limitOf(a.limit) };
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
agent_activity: {
|
|
96
|
+
summary: "What an agent or person has been working on, session by session.",
|
|
97
|
+
cypher: `
|
|
98
|
+
MATCH (a:Agent)-[:RAN]->(s:Session)
|
|
99
|
+
WHERE ($label IS NULL OR a.label = $label) AND ($login IS NULL OR a.login = $login) AND s.lastAt >= $since
|
|
100
|
+
OPTIONAL MATCH (s)-[:MADE]->(e:Edit)-[:MODIFIED]->(f:File)
|
|
101
|
+
RETURN a.label AS agent, a.login AS person, s.title AS title, s.intent AS intent, s.lastAt AS lastAt,
|
|
102
|
+
s.turnCount AS turns, s.editCount AS edits, collect(DISTINCT f.path)[..15] AS files
|
|
103
|
+
ORDER BY lastAt DESC LIMIT $limit`,
|
|
104
|
+
params: (a) => ({
|
|
105
|
+
label: a.agent ? String(a.agent) : null,
|
|
106
|
+
login: a.person ? keys.loginKey(a.person) : null,
|
|
107
|
+
since: Number(a.since) || 0,
|
|
108
|
+
limit: limitOf(a.limit),
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
hotspots: {
|
|
113
|
+
summary: "Files where coordination matters most: PageRank over co-edits, plus overlaps and blocks.",
|
|
114
|
+
cypher: `
|
|
115
|
+
MATCH (f:File) WHERE f.rank IS NOT NULL
|
|
116
|
+
OPTIONAL MATCH (f)<-[:ON]-(c:Contention)
|
|
117
|
+
OPTIONAL MATCH (f)<-[:ON]-(o:Overlap)
|
|
118
|
+
OPTIONAL MATCH (a:Agent)-[:TOUCHED]->(f)
|
|
119
|
+
RETURN f.path AS file, f.rank AS rank, f.community AS community, f.editCount AS edits,
|
|
120
|
+
count(DISTINCT c) AS contentions, count(DISTINCT o) AS overlaps, count(DISTINCT a) AS agents
|
|
121
|
+
ORDER BY rank DESC LIMIT $limit`,
|
|
122
|
+
params: (a) => ({ limit: limitOf(a.limit) }),
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
decision_trace: {
|
|
126
|
+
summary: "Decisions about a file (or all), who made them and in which session.",
|
|
127
|
+
cypher: `
|
|
128
|
+
MATCH (d:Decision)
|
|
129
|
+
WHERE $fileKey IS NULL OR (d)-[:ABOUT]->(:File {key: $fileKey})
|
|
130
|
+
OPTIONAL MATCH (a:Agent)-[:DECIDED]->(d)
|
|
131
|
+
OPTIONAL MATCH (d)-[r:DURING]->(s:Session)
|
|
132
|
+
OPTIONAL MATCH (d)-[:ABOUT]->(f:File)
|
|
133
|
+
RETURN d.key AS id, d.content AS decision, d.status AS status, d.updatedAt AS updatedAt, d.revisions AS revisions,
|
|
134
|
+
a.label AS agent, a.login AS person, s.title AS session, r.confidence AS sessionConfidence,
|
|
135
|
+
collect(DISTINCT f.path) AS files
|
|
136
|
+
ORDER BY updatedAt DESC LIMIT $limit`,
|
|
137
|
+
params: (a) => ({ fileKey: a.path ? requireFile(a) : null, limit: limitOf(a.limit) }),
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
overlaps: {
|
|
141
|
+
summary: "Places where two different agents changed the same file within minutes.",
|
|
142
|
+
cypher: `
|
|
143
|
+
MATCH (o:Overlap)-[:ON]->(f:File)
|
|
144
|
+
WHERE $fileKey IS NULL OR f.key = $fileKey
|
|
145
|
+
MATCH (o)-[:BETWEEN]->(e:Edit)-[:BY]->(a:Agent)
|
|
146
|
+
OPTIONAL MATCH (e)-[:PROMPTED_BY]->(u:Turn)
|
|
147
|
+
WITH o, f, collect({agent: a.label, person: a.login, ts: e.ts, prompt: u.text}) AS sides
|
|
148
|
+
RETURN o.ts AS ts, f.path AS file, o.gapMs AS gapMs, o.confidence AS confidence, sides
|
|
149
|
+
ORDER BY ts DESC LIMIT $limit`,
|
|
150
|
+
params: (a) => ({ fileKey: a.path ? requireFile(a) : null, limit: limitOf(a.limit) }),
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
overview: {
|
|
154
|
+
summary: "Counts by node label and relationship type.",
|
|
155
|
+
cypher: `
|
|
156
|
+
MATCH (n) WHERE NOT n:Meta
|
|
157
|
+
RETURN labels(n)[0] AS label, count(n) AS count ORDER BY count DESC`,
|
|
158
|
+
params: () => ({}),
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const NEIGHBORHOOD = `
|
|
163
|
+
MATCH (f:File {key: $fileKey})
|
|
164
|
+
OPTIONAL MATCH (f)<-[:MODIFIED]-(e:Edit)-[:BY]->(a:Agent)
|
|
165
|
+
WHERE NOT (e)-[:SAME_CHANGE]->()
|
|
166
|
+
OPTIONAL MATCH (s:Session)-[:MADE]->(e)
|
|
167
|
+
OPTIONAL MATCH (e)-[:PROMPTED_BY]->(u:Turn)
|
|
168
|
+
WITH f, e, a, s, u ORDER BY e.ts DESC LIMIT 25
|
|
169
|
+
RETURN f.key AS fileKey, f.path AS filePath, f.rank AS rank,
|
|
170
|
+
collect({edit: e.key, ts: e.ts, agent: a.key, agentLabel: a.label, person: a.login,
|
|
171
|
+
session: s.key, sessionTitle: s.title, sessionIntent: s.intent, prompt: u.key, promptText: u.text}) AS rows`;
|
|
172
|
+
|
|
173
|
+
const NEIGHBOR_EXTRAS = `
|
|
174
|
+
MATCH (f:File {key: $fileKey})
|
|
175
|
+
OPTIONAL MATCH (f)<-[:ON]-(c:Contention)
|
|
176
|
+
OPTIONAL MATCH (c)-[:HELD_BY]->(hs:Session)
|
|
177
|
+
OPTIONAL MATCH (c)-[:WANTED_BY]->(ws:Session)
|
|
178
|
+
WITH f, collect(DISTINCT {key: c.key, kind: c.kind, attempts: c.attempts, ts: c.ts, holder: hs.key, holderLabel: hs.label,
|
|
179
|
+
holderIntent: coalesce(c.holderIntent, hs.intent), wanter: ws.key, wanterLabel: ws.label}) AS contentions
|
|
180
|
+
OPTIONAL MATCH (f)<-[:ABOUT]-(d:Decision)
|
|
181
|
+
WITH f, contentions, collect(DISTINCT {key: d.key, content: d.content, status: d.status}) AS decisions
|
|
182
|
+
OPTIONAL MATCH (f)-[x:CO_EDITED]->(g:File)
|
|
183
|
+
WITH f, contentions, decisions, g, x ORDER BY x.weight DESC
|
|
184
|
+
RETURN contentions, decisions, collect({key: g.key, path: g.path, weight: x.weight})[..8] AS coEdited`;
|
|
185
|
+
|
|
186
|
+
const HOTSPOT_SEEDS = `MATCH (f:File) WHERE f.rank IS NOT NULL RETURN f.key AS k ORDER BY f.rank DESC LIMIT 1`;
|
|
187
|
+
|
|
188
|
+
/** Small node/edge set around one file, shaped for the Mission Control canvas. */
|
|
189
|
+
async function neighborhood(client, projectId, args) {
|
|
190
|
+
let fk = args.path ? requireFile(args) : null;
|
|
191
|
+
if (!fk) fk = (await client.read(projectId, HOTSPOT_SEEDS))[0]?.k || null;
|
|
192
|
+
if (!fk) return { nodes: [], edges: [], focus: null };
|
|
193
|
+
const [main] = await client.read(projectId, NEIGHBORHOOD, { fileKey: fk });
|
|
194
|
+
const [extra] = await client.read(projectId, NEIGHBOR_EXTRAS, { fileKey: fk });
|
|
195
|
+
if (!main) return { nodes: [], edges: [], focus: null };
|
|
196
|
+
const nodes = new Map();
|
|
197
|
+
const edges = [];
|
|
198
|
+
const node = (id, kind, label, props = {}) => {
|
|
199
|
+
if (id && !nodes.has(id)) nodes.set(id, { id, kind, label, props });
|
|
200
|
+
};
|
|
201
|
+
const seenEdges = new Set();
|
|
202
|
+
const edge = (from, to, type) => {
|
|
203
|
+
const k = `${from}|${to}|${type}`;
|
|
204
|
+
if (!from || !to || seenEdges.has(k)) return;
|
|
205
|
+
seenEdges.add(k);
|
|
206
|
+
edges.push({ from, to, type });
|
|
207
|
+
};
|
|
208
|
+
node(`file:${main.fileKey}`, "file", main.filePath, { rank: main.rank, focus: true });
|
|
209
|
+
for (const r of main.rows || []) {
|
|
210
|
+
if (!r.edit) continue;
|
|
211
|
+
node(`agent:${r.agent}`, "agent", `${r.agentLabel} · ${r.person}`, { person: r.person, label: r.agentLabel });
|
|
212
|
+
if (r.session) {
|
|
213
|
+
node(`session:${r.session}`, "session", r.sessionTitle || r.session.split("::")[1]?.slice(0, 8) || "session", { intent: r.sessionIntent });
|
|
214
|
+
edge(`agent:${r.agent}`, `session:${r.session}`, "RAN");
|
|
215
|
+
edge(`session:${r.session}`, `file:${main.fileKey}`, "EDITED");
|
|
216
|
+
} else {
|
|
217
|
+
edge(`agent:${r.agent}`, `file:${main.fileKey}`, "EDITED");
|
|
218
|
+
}
|
|
219
|
+
if (r.prompt && r.session) {
|
|
220
|
+
node(`turn:${r.prompt}`, "prompt", r.promptText ? String(r.promptText).slice(0, 80) : "prompt", { text: r.promptText });
|
|
221
|
+
edge(`turn:${r.prompt}`, `session:${r.session}`, "PROMPTED");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const c of extra?.contentions || []) {
|
|
225
|
+
if (!c.key) continue;
|
|
226
|
+
node(`contention:${c.key}`, "contention", `${c.kind === "claim_blocked" ? "blocked" : c.kind} ×${c.attempts || 1}`, c);
|
|
227
|
+
edge(`contention:${c.key}`, `file:${main.fileKey}`, "ON");
|
|
228
|
+
if (c.holder) {
|
|
229
|
+
node(`session:${c.holder}`, "session", c.holderLabel || "holder", { intent: c.holderIntent });
|
|
230
|
+
edge(`contention:${c.key}`, `session:${c.holder}`, "HELD_BY");
|
|
231
|
+
}
|
|
232
|
+
if (c.wanter) {
|
|
233
|
+
node(`session:${c.wanter}`, "session", c.wanterLabel || "wanter", {});
|
|
234
|
+
edge(`contention:${c.key}`, `session:${c.wanter}`, "WANTED_BY");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const d of extra?.decisions || []) {
|
|
238
|
+
if (!d.key) continue;
|
|
239
|
+
node(`decision:${d.key}`, "decision", String(d.content || "decision").slice(0, 60), d);
|
|
240
|
+
edge(`decision:${d.key}`, `file:${main.fileKey}`, "ABOUT");
|
|
241
|
+
}
|
|
242
|
+
for (const g of extra?.coEdited || []) {
|
|
243
|
+
if (!g.key) continue;
|
|
244
|
+
node(`file:${g.key}`, "file", g.path, { weight: g.weight });
|
|
245
|
+
edge(`file:${main.fileKey}`, `file:${g.key}`, "CO_EDITED");
|
|
246
|
+
}
|
|
247
|
+
return { focus: main.fileKey, nodes: [...nodes.values()].slice(0, 150), edges };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const BRIEF_EDITS = `
|
|
251
|
+
MATCH (f:File {key: $fileKey})<-[:MODIFIED]-(e:Edit)-[:BY]->(a:Agent)
|
|
252
|
+
WHERE NOT (e)-[:SAME_CHANGE]->()
|
|
253
|
+
OPTIONAL MATCH (e)-[:PROMPTED_BY]->(u:Turn)
|
|
254
|
+
RETURN e.ts AS ts, a.label AS agent, a.login AS person, u.text AS prompt
|
|
255
|
+
ORDER BY ts DESC LIMIT 8`;
|
|
256
|
+
const BRIEF_FILE = `MATCH (f:File {key: $fileKey}) RETURN f.path AS path, f.rank AS rank, f.community AS community, f.editCount AS edits`;
|
|
257
|
+
const BRIEF_ACTIVE = `
|
|
258
|
+
MATCH (s:Session)-[h:HELD]->(f:File {key: $fileKey})
|
|
259
|
+
WHERE h.lastAt >= $activeSince
|
|
260
|
+
OPTIONAL MATCH (a:Agent)-[:RAN]->(s)
|
|
261
|
+
RETURN coalesce(a.label, s.label) AS agent, a.login AS person, s.intent AS intent, h.lastAt AS lastAt
|
|
262
|
+
ORDER BY lastAt DESC LIMIT 3`;
|
|
263
|
+
const BRIEF_NEIGHBORS = `
|
|
264
|
+
MATCH (f:File {key: $fileKey})
|
|
265
|
+
WHERE f.community IS NOT NULL
|
|
266
|
+
MATCH (g:File) WHERE g.community = f.community AND g.key <> f.key
|
|
267
|
+
MATCH (a:Agent)-[t:TOUCHED]->(g) WHERE t.lastAt >= $activeSince
|
|
268
|
+
RETURN g.path AS file, a.label AS agent, a.login AS person, t.lastAt AS lastAt
|
|
269
|
+
ORDER BY lastAt DESC LIMIT 3`;
|
|
270
|
+
|
|
271
|
+
function ago(ts, now) {
|
|
272
|
+
if (!ts) return "";
|
|
273
|
+
const m = Math.round((now - ts) / 60000);
|
|
274
|
+
if (m < 60) return `${m}m ago`;
|
|
275
|
+
if (m < 48 * 60) return `${Math.round(m / 60)}h ago`;
|
|
276
|
+
return `${Math.round(m / 1440)}d ago`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function clipLine(s, n = 140) {
|
|
280
|
+
const t = String(s || "").replace(/\s+/g, " ").trim();
|
|
281
|
+
return t.length > n ? `${t.slice(0, n)}…` : t;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Everything an agent should know before touching these files, as data and as markdown. */
|
|
285
|
+
async function sessionBrief(client, projectId, args, run) {
|
|
286
|
+
const files = fileList(args);
|
|
287
|
+
const now = Date.now();
|
|
288
|
+
const activeSince = now - ACTIVE_MS;
|
|
289
|
+
const out = [];
|
|
290
|
+
for (const fileKey of files) {
|
|
291
|
+
const [info] = await client.read(projectId, BRIEF_FILE, { fileKey });
|
|
292
|
+
const recent = await client.read(projectId, BRIEF_EDITS, { fileKey });
|
|
293
|
+
const people = (await run("who_to_ask", { path: fileKey, limit: 3 })).rows;
|
|
294
|
+
const decisions = (await run("decision_trace", { path: fileKey, limit: 3 })).rows;
|
|
295
|
+
const blocks = (await run("contention_trace", { path: fileKey, limit: 3 })).rows;
|
|
296
|
+
const active = await client.read(projectId, BRIEF_ACTIVE, { fileKey, activeSince });
|
|
297
|
+
const nearby = await client.read(projectId, BRIEF_NEIGHBORS, { fileKey, activeSince });
|
|
298
|
+
out.push({ file: info?.path || fileKey, known: Boolean(info), rank: info?.rank ?? null, community: info?.community ?? null, edits: info?.edits || 0, recent, people, decisions, blocks, active, nearby });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const md = ["# Relay memory brief", ""];
|
|
302
|
+
for (const f of out) {
|
|
303
|
+
md.push(`## ${f.file}`);
|
|
304
|
+
if (!f.known) {
|
|
305
|
+
md.push("- No history in the project memory graph yet.", "");
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (f.active.length) for (const a of f.active) md.push(`- ⚠️ **In use right now** by ${a.agent}${a.person ? ` (${a.person})` : ""}${a.intent ? `: "${clipLine(a.intent)}"` : ""}`);
|
|
309
|
+
for (const b of f.blocks) md.push(`- Contention ${ago(b.ts, now)}: ${b.wanter} was blocked by ${b.holder}${b.holderWasDoing ? ` while it worked on "${clipLine(b.holderWasDoing, 90)}"` : ""}`);
|
|
310
|
+
for (const d of f.decisions) md.push(`- Decision (${d.status}): ${clipLine(d.decision)}${d.agent ? `, by ${d.agent}` : ""}`);
|
|
311
|
+
const why = new Map();
|
|
312
|
+
for (const r of f.recent) {
|
|
313
|
+
const k = `${r.agent}|${r.person}|${r.prompt || ""}`;
|
|
314
|
+
if (why.has(k)) why.get(k).n += 1;
|
|
315
|
+
else why.set(k, { ...r, n: 1 });
|
|
316
|
+
}
|
|
317
|
+
for (const r of why.values()) {
|
|
318
|
+
md.push(`- ${ago(r.ts, now)} ${r.agent} (${r.person}) changed it${r.n > 1 ? ` ${r.n}×` : ""} because: "${clipLine(r.prompt || "unknown")}"`);
|
|
319
|
+
}
|
|
320
|
+
if (f.people.length) md.push(`- Ask: ${f.people.map((p) => `${p.person} (${p.edits} edits via ${p.via.join("/")})`).join(", ")}`);
|
|
321
|
+
for (const n of f.nearby) md.push(`- Same work area: ${n.file} touched ${ago(n.lastAt, now)} by ${n.agent} (${n.person})`);
|
|
322
|
+
md.push("");
|
|
323
|
+
}
|
|
324
|
+
return { files: out, markdown: md.join("\n") };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function names() {
|
|
328
|
+
return [...Object.keys(Q), "session_brief", "neighborhood"];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Runs one named query. Throws a 400-style error on bad input. */
|
|
332
|
+
async function runNamed(client, projectId, name, args = {}) {
|
|
333
|
+
if (!projectId) throw Object.assign(new Error("no project"), { status: 404 });
|
|
334
|
+
const run = (n, a) => runNamed(client, projectId, n, a);
|
|
335
|
+
if (name === "session_brief") return { query: name, ...(await sessionBrief(client, projectId, args, run)) };
|
|
336
|
+
if (name === "neighborhood") return { query: name, ...(await neighborhood(client, projectId, args)) };
|
|
337
|
+
const def = Q[name];
|
|
338
|
+
if (!def) throw Object.assign(new Error(`unknown query "${name}". Try: ${names().join(", ")}`), { status: 400 });
|
|
339
|
+
const params = def.params(args);
|
|
340
|
+
const rows = await client.read(projectId, def.cypher, params);
|
|
341
|
+
return { query: name, summary: def.summary, cypher: def.cypher.trim(), params, rows };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = { runNamed, names, searchTerms, Q };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const PATTERNS = [
|
|
2
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
3
|
+
/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}/g,
|
|
4
|
+
/\bgh[pousr]_[A-Za-z0-9]{30,}/g,
|
|
5
|
+
/\bgithub_pat_[A-Za-z0-9_]{30,}/g,
|
|
6
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
7
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
|
|
8
|
+
/\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
9
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|
10
|
+
];
|
|
11
|
+
const ASSIGNMENT = /\b(password|passwd|secret|api[_-]?key|token)(\s*[:=]\s*)(["']?)[^\s"']{6,}\3/gi;
|
|
12
|
+
|
|
13
|
+
function redact(text) {
|
|
14
|
+
let s = String(text ?? "");
|
|
15
|
+
for (const re of PATTERNS) s = s.replace(re, "[redacted]");
|
|
16
|
+
return s.replace(ASSIGNMENT, (_m, name, sep) => `${name}${sep}[redacted]`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function clip(text, max) {
|
|
20
|
+
const s = String(text ?? "");
|
|
21
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Text as stored in the graph: redacted, clipped, or dropped entirely when text storage is off. */
|
|
25
|
+
function storedText(text, max, storeText = true) {
|
|
26
|
+
if (!storeText) return null;
|
|
27
|
+
return clip(redact(text), max);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { redact, clip, storedText };
|