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,32 @@
|
|
|
1
|
+
// In-process "what is this session trying to do" cache. Read on the lock deny path,
|
|
2
|
+
// so it must stay a plain Map lookup: no I/O, no database.
|
|
3
|
+
const bySession = new Map();
|
|
4
|
+
const byAgentLabel = new Map();
|
|
5
|
+
const MAX = 2000;
|
|
6
|
+
|
|
7
|
+
function remember(info) {
|
|
8
|
+
if (!info?.sessionKey || !info.intent) return;
|
|
9
|
+
const prev = bySession.get(info.sessionKey);
|
|
10
|
+
if (prev && (prev.intentAt || 0) > (info.intentAt || 0)) return;
|
|
11
|
+
bySession.delete(info.sessionKey);
|
|
12
|
+
bySession.set(info.sessionKey, info);
|
|
13
|
+
if (bySession.size > MAX) bySession.delete(bySession.keys().next().value);
|
|
14
|
+
const latest = byAgentLabel.get(info.label);
|
|
15
|
+
if (!latest || (latest.intentAt || 0) <= (info.intentAt || 0)) byAgentLabel.set(info.label, info);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function forSession(sessionKey) {
|
|
19
|
+
return bySession.get(sessionKey) || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Fallback when a lock's session is unknown: the agent's most recent intent, low confidence. */
|
|
23
|
+
function latestForLabel(label) {
|
|
24
|
+
return byAgentLabel.get(label) || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function clear() {
|
|
28
|
+
bySession.clear();
|
|
29
|
+
byAgentLabel.clear();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { remember, forSession, latestForLabel, clear };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
|
|
3
|
+
// Fallback ids relay invents when an agent reports no session (unifyEvent → "session", lockAgentIdFor → "local").
|
|
4
|
+
const SESSION_SENTINELS = new Set(["", "session", "local", "null", "undefined"]);
|
|
5
|
+
const CONTENTION_BUCKET_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
function hash(parts, len = 20) {
|
|
8
|
+
return crypto.createHash("sha1").update(parts.map((p) => String(p ?? "")).join("␟")).digest("hex").slice(0, len);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function loginKey(login) {
|
|
12
|
+
const v = String(login || "").trim().toLowerCase();
|
|
13
|
+
return v || "local";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function agentKey(login, label) {
|
|
17
|
+
return `${loginKey(login)}::${String(label || "agent").trim()}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isRealSession(sessionId) {
|
|
21
|
+
return !SESSION_SENTINELS.has(String(sessionId ?? "").trim().toLowerCase());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sessionKey(label, sessionId) {
|
|
25
|
+
if (!isRealSession(sessionId)) return null;
|
|
26
|
+
return `${String(label || "agent").trim()}::${String(sessionId).trim()}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function day(ts) {
|
|
30
|
+
const d = new Date(Number(ts) || 0);
|
|
31
|
+
return Number.isNaN(d.getTime()) ? "unknown" : d.toISOString().slice(0, 10);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Groups session-less activity per agent, person and day so it never merges across people. */
|
|
35
|
+
function bucketSessionKey(label, login, ts) {
|
|
36
|
+
return `${String(label || "agent").trim()}::nosession::${loginKey(login)}::${day(ts)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `label:host:session` (or `label:host:session@login` from a remote room member). */
|
|
40
|
+
function parseLockAgentId(agentId) {
|
|
41
|
+
const parts = String(agentId || "").split(":");
|
|
42
|
+
const label = parts[0] || "";
|
|
43
|
+
const host = parts[1] || "";
|
|
44
|
+
const rawSession = parts.slice(2).join(":");
|
|
45
|
+
const atIdx = rawSession.lastIndexOf("@");
|
|
46
|
+
const session = atIdx > 0 ? rawSession.slice(0, atIdx) : rawSession;
|
|
47
|
+
return { label, host, session, sessionKnown: isRealSession(session) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fileKey(p) {
|
|
51
|
+
if (p == null) return null;
|
|
52
|
+
let s = String(p).trim().replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
|
53
|
+
if (!s || /^[a-zA-Z]:\//.test(s) || /^[a-zA-Z]:$/.test(s) || s.startsWith("/")) return null;
|
|
54
|
+
s = s.replace(/^(\.\/)+/, "").replace(/\/$/, "");
|
|
55
|
+
if (!s || s === "." || s === ".." || s.startsWith("../") || s.includes("/../")) return null;
|
|
56
|
+
return s.toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function displayPath(p) {
|
|
60
|
+
return String(p || "").trim().replace(/\\/g, "/").replace(/^(\.\/)+/, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Content-addressed so a local turn and the same turn clipped in a peer snapshot converge. */
|
|
64
|
+
function turnKey({ login, label, sessionId, ts, role, kind, text }) {
|
|
65
|
+
return `t_${hash([loginKey(login), label, sessionId, ts, role, kind, String(text || "").slice(0, 200)])}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function contentionKey({ kind, fileKey: fk, wanter, holder, ts }) {
|
|
69
|
+
const bucket = Math.floor((Number(ts) || 0) / CONTENTION_BUCKET_MS);
|
|
70
|
+
return `c_${hash([kind, fk, wanter, holder, bucket])}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function overlapKey(fk, editA, editB) {
|
|
74
|
+
const [a, b] = [String(editA), String(editB)].sort();
|
|
75
|
+
return `o_${hash([fk, a, b])}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parsers record whether each event's time was in the transcript (event.tsSynthetic).
|
|
80
|
+
* History stored before they did falls back to the old tell: `mtime + index*1000` is fractional.
|
|
81
|
+
*/
|
|
82
|
+
function tsSynthetic(ts, flag) {
|
|
83
|
+
if (typeof flag === "boolean") return flag;
|
|
84
|
+
return !Number.isInteger(Number(ts));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
hash,
|
|
89
|
+
loginKey,
|
|
90
|
+
agentKey,
|
|
91
|
+
isRealSession,
|
|
92
|
+
sessionKey,
|
|
93
|
+
bucketSessionKey,
|
|
94
|
+
parseLockAgentId,
|
|
95
|
+
fileKey,
|
|
96
|
+
displayPath,
|
|
97
|
+
turnKey,
|
|
98
|
+
contentionKey,
|
|
99
|
+
overlapKey,
|
|
100
|
+
tsSynthetic,
|
|
101
|
+
CONTENTION_BUCKET_MS,
|
|
102
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Versioned in-place graph migrations (#18): run at schema.ensure() and recorded on
|
|
2
|
+
// Meta.migrations so schema/ingest version bumps preserve history older than memory.json's cap
|
|
3
|
+
// instead of requiring a destructive drop-and-rebuild.
|
|
4
|
+
|
|
5
|
+
const MIGRATIONS = [
|
|
6
|
+
{
|
|
7
|
+
id: "001_meta_versions_v6",
|
|
8
|
+
description: "Ensure Session.stub defaults and stamp Meta to schemaVersion=1, ingestVersion=6 in place",
|
|
9
|
+
async up(client, projectId, { schemaVersion, ingestVersion }) {
|
|
10
|
+
await client.write(
|
|
11
|
+
projectId,
|
|
12
|
+
`MATCH (s:Session) WHERE s.stub IS NULL SET s.stub = false RETURN count(s) AS n`
|
|
13
|
+
);
|
|
14
|
+
await client.write(
|
|
15
|
+
projectId,
|
|
16
|
+
`MATCH (m:Meta {key: 'meta'})
|
|
17
|
+
SET m.schemaVersion = $schema, m.ingestVersion = $ingest, m.migratedAt = $now
|
|
18
|
+
RETURN 1 AS ok`,
|
|
19
|
+
{ schema: schemaVersion, ingest: ingestVersion, now: Date.now() }
|
|
20
|
+
);
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
async function runMigrations(client, projectId, { schemaVersion, ingestVersion }) {
|
|
26
|
+
const rows = await client.read(
|
|
27
|
+
projectId,
|
|
28
|
+
"MATCH (m:Meta {key: 'meta'}) RETURN coalesce(m.migrations, []) AS migrations, m.schemaVersion AS s, m.ingestVersion AS i"
|
|
29
|
+
);
|
|
30
|
+
const meta = rows[0] || {};
|
|
31
|
+
const applied = new Set(Array.isArray(meta.migrations) ? meta.migrations : []);
|
|
32
|
+
const ran = [];
|
|
33
|
+
|
|
34
|
+
for (const mig of MIGRATIONS) {
|
|
35
|
+
if (applied.has(mig.id)) continue;
|
|
36
|
+
await mig.up(client, projectId, { schemaVersion, ingestVersion });
|
|
37
|
+
applied.add(mig.id);
|
|
38
|
+
ran.push(mig.id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// If the graph had an older schemaVersion or ingestVersion, migrate it forward in place
|
|
42
|
+
// so existing Turn/Edit/Session/Decision history is preserved without dropping the graph.
|
|
43
|
+
if (ran.length || meta.s !== schemaVersion || meta.i !== ingestVersion) {
|
|
44
|
+
await client.write(
|
|
45
|
+
projectId,
|
|
46
|
+
`MATCH (m:Meta {key: 'meta'})
|
|
47
|
+
SET m.migrations = $migrations, m.schemaVersion = $schema, m.ingestVersion = $ingest
|
|
48
|
+
RETURN 1 AS ok`,
|
|
49
|
+
{ migrations: [...applied], schema: schemaVersion, ingest: ingestVersion }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { applied: [...applied], ran };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { MIGRATIONS, runMigrations };
|
|
@@ -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 };
|