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,165 @@
|
|
|
1
|
+
const { FalkorDB } = require("falkordb");
|
|
2
|
+
|
|
3
|
+
function sanitizeGraphId(projectId) {
|
|
4
|
+
return String(projectId || "default").replace(/[^A-Za-z0-9_]/g, "_");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function withTimeout(promise, ms, label) {
|
|
8
|
+
let timer;
|
|
9
|
+
return Promise.race([
|
|
10
|
+
promise,
|
|
11
|
+
new Promise((_, reject) => {
|
|
12
|
+
timer = setTimeout(() => reject(Object.assign(new Error(`${label} timed out after ${ms}ms`), { timeout: true })), ms);
|
|
13
|
+
}),
|
|
14
|
+
]).finally(() => clearTimeout(timer));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class FalkorClient {
|
|
18
|
+
constructor(cfg) {
|
|
19
|
+
this.cfg = cfg;
|
|
20
|
+
this.db = null;
|
|
21
|
+
this.connecting = null;
|
|
22
|
+
this.queues = new Map();
|
|
23
|
+
this.breaker = { state: "closed", until: 0, failures: 0, lastError: null, lastOkAt: 0 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
graphName(projectId) {
|
|
27
|
+
return `${this.cfg.prefix}${sanitizeGraphId(projectId)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
available() {
|
|
31
|
+
if (this.breaker.state !== "open") return true;
|
|
32
|
+
if (Date.now() >= this.breaker.until) {
|
|
33
|
+
this.breaker.state = "probing";
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async connection() {
|
|
40
|
+
if (this.closed) throw Object.assign(new Error("falkor client closed"), { breaker: true });
|
|
41
|
+
if (this.db) return this.db;
|
|
42
|
+
if (this.connecting) return this.connecting;
|
|
43
|
+
this.connecting = (async () => {
|
|
44
|
+
const db = await withTimeout(
|
|
45
|
+
FalkorDB.connect({
|
|
46
|
+
url: this.cfg.url,
|
|
47
|
+
disableOfflineQueue: true,
|
|
48
|
+
socket: {
|
|
49
|
+
connectTimeout: 5000,
|
|
50
|
+
reconnectStrategy: (retries) => Math.min(500 * (retries + 1), 5000),
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
7000,
|
|
54
|
+
"falkor connect"
|
|
55
|
+
);
|
|
56
|
+
// An EventEmitter "error" with no listener throws and would take relay serve down.
|
|
57
|
+
db.on("error", (err) => {
|
|
58
|
+
this.breaker.lastError = String(err?.message || err);
|
|
59
|
+
});
|
|
60
|
+
this.db = db;
|
|
61
|
+
return db;
|
|
62
|
+
})().finally(() => {
|
|
63
|
+
this.connecting = null;
|
|
64
|
+
});
|
|
65
|
+
return this.connecting;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ok() {
|
|
69
|
+
this.breaker.state = "closed";
|
|
70
|
+
this.breaker.failures = 0;
|
|
71
|
+
this.breaker.lastError = null;
|
|
72
|
+
this.breaker.lastOkAt = Date.now();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fail(err) {
|
|
76
|
+
const b = this.breaker;
|
|
77
|
+
const message = String(err?.message || err);
|
|
78
|
+
// Cypher errors are our bugs (or a graph that doesn't exist yet), not an outage:
|
|
79
|
+
// only transport failures trip the breaker or count as the connection's last error.
|
|
80
|
+
const transport = /ECONN|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|closed|offline|socket|connect/i.test(message);
|
|
81
|
+
if (!transport && !err?.timeout) {
|
|
82
|
+
b.lastQueryError = message;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
b.lastError = message;
|
|
86
|
+
b.failures += 1;
|
|
87
|
+
if (b.state === "probing" || transport || b.failures >= 3) {
|
|
88
|
+
b.state = "open";
|
|
89
|
+
b.until = Date.now() + this.cfg.breakerCooldownMs;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async run(kind, projectId, query, params) {
|
|
94
|
+
if (!this.available()) {
|
|
95
|
+
const err = new Error("falkor unavailable (breaker open)");
|
|
96
|
+
err.breaker = true;
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const db = await this.connection();
|
|
101
|
+
const graph = db.selectGraph(this.graphName(projectId));
|
|
102
|
+
const timeoutMs = kind === "read" ? this.cfg.readTimeoutMs : this.cfg.writeTimeoutMs;
|
|
103
|
+
const opts = { params: params || {}, TIMEOUT: timeoutMs };
|
|
104
|
+
const call = kind === "read" ? graph.roQuery(query, opts) : graph.query(query, opts);
|
|
105
|
+
const result = await withTimeout(call, timeoutMs + 500, `falkor ${kind}`);
|
|
106
|
+
this.ok();
|
|
107
|
+
return result.data || [];
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (!err.breaker) this.fail(err);
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// FalkorDB serializes writes per graph; chaining keeps our own batches from piling up.
|
|
115
|
+
write(projectId, query, params) {
|
|
116
|
+
const key = this.graphName(projectId);
|
|
117
|
+
const prev = this.queues.get(key) || Promise.resolve();
|
|
118
|
+
const next = prev.catch(() => undefined).then(() => this.run("write", projectId, query, params));
|
|
119
|
+
this.queues.set(
|
|
120
|
+
key,
|
|
121
|
+
next.catch(() => undefined)
|
|
122
|
+
);
|
|
123
|
+
return next;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
read(projectId, query, params) {
|
|
127
|
+
return this.run("read", projectId, query, params);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async graph(projectId) {
|
|
131
|
+
const db = await this.connection();
|
|
132
|
+
return db.selectGraph(this.graphName(projectId));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async deleteGraph(projectId) {
|
|
136
|
+
const g = await this.graph(projectId);
|
|
137
|
+
try {
|
|
138
|
+
await g.delete();
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (!/empty key|not exist|Invalid graph operation on empty key/i.test(String(err.message))) throw err;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
status() {
|
|
145
|
+
return {
|
|
146
|
+
state: this.breaker.state,
|
|
147
|
+
failures: this.breaker.failures,
|
|
148
|
+
lastError: this.breaker.lastError,
|
|
149
|
+
lastQueryError: this.breaker.lastQueryError || null,
|
|
150
|
+
lastOkAt: this.breaker.lastOkAt || null,
|
|
151
|
+
reopensAt: this.breaker.state === "open" ? this.breaker.until : null,
|
|
152
|
+
connected: Boolean(this.db),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async close() {
|
|
157
|
+
// A closed client stays closed: late timers must not reopen a socket that keeps the process alive.
|
|
158
|
+
this.closed = true;
|
|
159
|
+
const db = this.db;
|
|
160
|
+
this.db = null;
|
|
161
|
+
if (db) await db.close().catch(() => undefined);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { FalkorClient, sanitizeGraphId, withTimeout };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
function flag(value) {
|
|
5
|
+
return value === "1" || value === "true" || value === "yes";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// ~/.relay/falkor.json, written by `relay graph enable`. Resolved per call so tests and
|
|
9
|
+
// isolated runs that point USERPROFILE/HOME elsewhere get their own file.
|
|
10
|
+
function configPath() {
|
|
11
|
+
return path.join(require("os").homedir(), ".relay", "falkor.json");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function loadFileConfig() {
|
|
15
|
+
try {
|
|
16
|
+
const raw = JSON.parse(fs.readFileSync(configPath(), "utf8"));
|
|
17
|
+
return raw && typeof raw === "object" ? raw : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function saveFileConfig(cfg) {
|
|
24
|
+
const file = configPath();
|
|
25
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
26
|
+
// The URL can carry a password: keep the file private where the OS supports modes.
|
|
27
|
+
fs.writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function clearFileConfig() {
|
|
32
|
+
try {
|
|
33
|
+
fs.unlinkSync(configPath());
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validUrl(url) {
|
|
41
|
+
return /^(falkors?|rediss?):\/\/[^\s]+$/i.test(String(url || "").trim());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Env wins over the saved file, so a one-off `RELAY_FALKOR_URL=…` still works.
|
|
46
|
+
* Only the real process env reads the file; tests pass their own env object.
|
|
47
|
+
*/
|
|
48
|
+
function ipv4Localhost(url) {
|
|
49
|
+
return String(url || "").replace(/\/\/localhost(?=[:/]|$)/i, "//127.0.0.1");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readConfig(env = process.env, file = env === process.env ? loadFileConfig() : null) {
|
|
53
|
+
const envUrl = String(env.RELAY_FALKOR_URL || "").trim();
|
|
54
|
+
const rawUrl = envUrl || String(file?.url || "").trim();
|
|
55
|
+
const url = ipv4Localhost(rawUrl);
|
|
56
|
+
const disabled = flag(String(env.RELAY_FALKOR_DISABLED || "").toLowerCase()) || file?.disabled === true;
|
|
57
|
+
const storeText = env.RELAY_FALKOR_TEXT != null ? env.RELAY_FALKOR_TEXT !== "0" : file?.storeText !== false;
|
|
58
|
+
const rawDays = env.RELAY_FALKOR_TEXT_DAYS != null ? Number(env.RELAY_FALKOR_TEXT_DAYS) : file?.textDays != null ? Number(file.textDays) : 90;
|
|
59
|
+
const textRetentionDays = Number.isFinite(rawDays) && rawDays >= 0 ? rawDays : 90;
|
|
60
|
+
return {
|
|
61
|
+
url,
|
|
62
|
+
source: envUrl ? "env" : url ? "file" : null,
|
|
63
|
+
enabled: Boolean(url) && !disabled,
|
|
64
|
+
storeText,
|
|
65
|
+
textRetentionDays,
|
|
66
|
+
prefix: env.RELAY_FALKOR_GRAPH_PREFIX || file?.prefix || "relay_",
|
|
67
|
+
writeTimeoutMs: Number(env.RELAY_FALKOR_WRITE_TIMEOUT_MS) || 5000,
|
|
68
|
+
readTimeoutMs: Number(env.RELAY_FALKOR_READ_TIMEOUT_MS) || 2000,
|
|
69
|
+
breakerCooldownMs: 30_000,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { readConfig, loadFileConfig, saveFileConfig, clearFileConfig, configPath, validUrl };
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Coordination events → graph, plus graph context handed to agents at the moment of conflict.
|
|
2
|
+
// Everything on the claim path is synchronous Map/array work; writes happen on timers.
|
|
3
|
+
const { bus } = require("./bus");
|
|
4
|
+
const keys = require("./keys");
|
|
5
|
+
const intents = require("./intents");
|
|
6
|
+
const { each } = require("./write");
|
|
7
|
+
const { loadRegistry } = require("../store");
|
|
8
|
+
const { normalizeCompare } = require("../transcripts/util");
|
|
9
|
+
|
|
10
|
+
const HELD_FLUSH_MS = 10_000;
|
|
11
|
+
const CONTENTION_FLUSH_MS = 2_000;
|
|
12
|
+
const LABEL_FALLBACK_MAX_AGE_MS = 30 * 60 * 1000;
|
|
13
|
+
const MAX_ROWS = 5000;
|
|
14
|
+
|
|
15
|
+
let api = null;
|
|
16
|
+
const held = new Map();
|
|
17
|
+
const contentions = new Map();
|
|
18
|
+
let registryCache = { at: 0, projects: [] };
|
|
19
|
+
|
|
20
|
+
/** Lookup only: unlike server.js projectForPath, never registers a new workspace. */
|
|
21
|
+
function projectIdFor(workspaceId) {
|
|
22
|
+
if (!workspaceId) return null;
|
|
23
|
+
if (Date.now() - registryCache.at > 5000) {
|
|
24
|
+
try {
|
|
25
|
+
registryCache = { at: Date.now(), projects: loadRegistry().projects || [] };
|
|
26
|
+
} catch {
|
|
27
|
+
registryCache = { at: Date.now(), projects: [] };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const target = normalizeCompare(workspaceId);
|
|
31
|
+
const hit = registryCache.projects.find((p) => p.id === workspaceId || normalizeCompare(p.path) === target);
|
|
32
|
+
return hit?.id || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ago(ts) {
|
|
36
|
+
const mins = Math.max(0, Math.round((Date.now() - ts) / 60000));
|
|
37
|
+
if (mins < 1) return "just now";
|
|
38
|
+
if (mins < 60) return `${mins} min ago`;
|
|
39
|
+
if (mins < 48 * 60) return `${Math.round(mins / 60)} h ago`;
|
|
40
|
+
return `${Math.round(mins / 1440)} days ago`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What the holder is doing, from the in-process intent cache. Never touches the database. */
|
|
44
|
+
function holderIntent(holderAgentId) {
|
|
45
|
+
const h = keys.parseLockAgentId(holderAgentId);
|
|
46
|
+
if (!h.label) return null;
|
|
47
|
+
const sk = h.sessionKnown ? keys.sessionKey(h.label, h.session) : null;
|
|
48
|
+
const exact = sk ? intents.forSession(sk) : null;
|
|
49
|
+
if (exact?.intent) return { text: exact.intent, at: exact.intentAt, confidence: "high", sessionKey: sk, label: h.label };
|
|
50
|
+
const latest = intents.latestForLabel(h.label);
|
|
51
|
+
if (latest?.intent && Date.now() - (latest.intentAt || 0) < LABEL_FALLBACK_MAX_AGE_MS) {
|
|
52
|
+
return { text: latest.intent, at: latest.intentAt, confidence: "low", sessionKey: latest.sessionKey, label: h.label };
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function enrichDeny(result) {
|
|
58
|
+
if (!result || result.allowed || !result.holder || result.holderIntent) return result;
|
|
59
|
+
const intent = holderIntent(result.holder);
|
|
60
|
+
if (!intent) return result;
|
|
61
|
+
result.holderIntent = { text: intent.text, at: intent.at, confidence: intent.confidence };
|
|
62
|
+
const who = intent.confidence === "high" ? "They're" : "That agent was last";
|
|
63
|
+
result.reason = `${result.reason || "File is locked."} ${who} working on: "${intent.text}" (${intent.at ? ago(intent.at) : "recently"}).`;
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sideOf(agentId) {
|
|
68
|
+
const p = keys.parseLockAgentId(agentId);
|
|
69
|
+
const sk = p.sessionKnown ? keys.sessionKey(p.label, p.session) : null;
|
|
70
|
+
return { agentId: agentId || null, label: p.label || null, session: p.sessionKnown ? p.session : null, sessionKey: sk };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function recordContention(projectId, { kind, file, wanter, holder, reason, ts = Date.now(), holderIntentText = null }) {
|
|
74
|
+
const fk = keys.fileKey(file);
|
|
75
|
+
if (!projectId || !fk) return;
|
|
76
|
+
const key = keys.contentionKey({ kind, fileKey: fk, wanter: wanter || "", holder: holder || "", ts });
|
|
77
|
+
const existing = contentions.get(key);
|
|
78
|
+
if (existing) {
|
|
79
|
+
existing.attempts += 1;
|
|
80
|
+
existing.lastTs = Math.max(existing.lastTs, ts);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (contentions.size >= MAX_ROWS) {
|
|
84
|
+
api.projectStats(projectId).bufferDrops += 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const w = sideOf(wanter);
|
|
88
|
+
const h = sideOf(holder);
|
|
89
|
+
contentions.set(key, {
|
|
90
|
+
projectId,
|
|
91
|
+
key,
|
|
92
|
+
kind,
|
|
93
|
+
fileKey: fk,
|
|
94
|
+
path: keys.displayPath(file),
|
|
95
|
+
ts,
|
|
96
|
+
lastTs: ts,
|
|
97
|
+
attempts: 1,
|
|
98
|
+
reason: reason ? String(reason).slice(0, 500) : null,
|
|
99
|
+
wanter: w.agentId,
|
|
100
|
+
wanterLabel: w.label,
|
|
101
|
+
wanterSession: w.session,
|
|
102
|
+
wanterSessionKey: w.sessionKey,
|
|
103
|
+
holder: h.agentId,
|
|
104
|
+
holderLabel: h.label,
|
|
105
|
+
holderSession: h.session,
|
|
106
|
+
holderSessionKey: h.sessionKey,
|
|
107
|
+
holderIntent: holderIntentText,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function recordHeld(projectId, agentId, file, ts = Date.now()) {
|
|
112
|
+
const side = sideOf(agentId);
|
|
113
|
+
const fk = keys.fileKey(file);
|
|
114
|
+
if (!projectId || !side.sessionKey || !fk) return;
|
|
115
|
+
const key = `${projectId}|${side.sessionKey}|${fk}`;
|
|
116
|
+
const row = held.get(key);
|
|
117
|
+
if (row) {
|
|
118
|
+
row.count += 1;
|
|
119
|
+
row.lastAt = ts;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (held.size >= MAX_ROWS) return;
|
|
123
|
+
held.set(key, { projectId, sessionKey: side.sessionKey, label: side.label, sessionId: side.session, fileKey: fk, path: keys.displayPath(file), count: 1, firstAt: ts, lastAt: ts });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function observeClaim(args, result) {
|
|
127
|
+
if (!args || !result) return;
|
|
128
|
+
if (Array.isArray(args.files) && args.files.length) return; // the per-file recursion reports each file
|
|
129
|
+
if (args.mode === "read" || result.reading) return;
|
|
130
|
+
const projectId = projectIdFor(args.workspaceId);
|
|
131
|
+
if (result.allowed) {
|
|
132
|
+
recordHeld(projectId, args.agentId, args.file);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!result.holder) return;
|
|
136
|
+
enrichDeny(result);
|
|
137
|
+
// bumpCollision() in server.js emits a second "collision" event after claim() returns,
|
|
138
|
+
// with the already-enriched reason string, so holderIntentText must fall back to holderIntent(holder).
|
|
139
|
+
const intentText = result.holderIntent?.text || holderIntent(result.holder)?.text || null;
|
|
140
|
+
recordContention(projectId, {
|
|
141
|
+
kind: "claim_blocked",
|
|
142
|
+
file: args.file,
|
|
143
|
+
wanter: args.agentId,
|
|
144
|
+
holder: result.holder,
|
|
145
|
+
reason: result.reason,
|
|
146
|
+
holderIntentText: intentText,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function wrapClaim(lockTable) {
|
|
151
|
+
if (!lockTable || lockTable.__relayFalkorWrapped) return;
|
|
152
|
+
const original = lockTable.claim;
|
|
153
|
+
lockTable.claim = function claimWithGraph(args) {
|
|
154
|
+
const result = original.call(this, args);
|
|
155
|
+
try {
|
|
156
|
+
observeClaim(args, result);
|
|
157
|
+
} catch {
|
|
158
|
+
/* the graph must never change a lock decision */
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
};
|
|
162
|
+
lockTable.__relayFalkorWrapped = true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const SESSION_STUB = (side) => `
|
|
166
|
+
FOREACH (_ IN CASE WHEN r.${side}SessionKey IS NULL THEN [] ELSE [1] END |
|
|
167
|
+
MERGE (s:Session {key: r.${side}SessionKey})
|
|
168
|
+
ON CREATE SET s.label = r.${side}Label, s.sessionId = r.${side}Session, s.turnCount = 0, s.editCount = 0,
|
|
169
|
+
s.startedAt = r.ts, s.lastAt = r.ts, s.stub = true
|
|
170
|
+
MERGE (c)-[:${side === "wanter" ? "WANTED_BY" : "HELD_BY"}]->(s))`;
|
|
171
|
+
|
|
172
|
+
const CONTENTION = `
|
|
173
|
+
UNWIND $rows AS r
|
|
174
|
+
MERGE (c:Contention {key: r.key})
|
|
175
|
+
ON CREATE SET c.kind = r.kind, c.ts = r.ts, c.attempts = 0, c.reason = r.reason,
|
|
176
|
+
c.wanter = r.wanter, c.holder = r.holder, c.wanterLabel = r.wanterLabel,
|
|
177
|
+
c.holderLabel = r.holderLabel, c.holderIntent = r.holderIntent
|
|
178
|
+
SET c.attempts = c.attempts + r.attempts,
|
|
179
|
+
c.lastTs = CASE WHEN c.lastTs IS NULL OR r.lastTs > c.lastTs THEN r.lastTs ELSE c.lastTs END
|
|
180
|
+
MERGE (f:File {key: r.fileKey})
|
|
181
|
+
ON CREATE SET f.path = r.path, f.editCount = 0
|
|
182
|
+
MERGE (c)-[:ON]->(f)
|
|
183
|
+
${SESSION_STUB("wanter")}
|
|
184
|
+
${SESSION_STUB("holder")}
|
|
185
|
+
RETURN count(c) AS n`;
|
|
186
|
+
|
|
187
|
+
const HELD = `
|
|
188
|
+
UNWIND $rows AS r
|
|
189
|
+
MERGE (s:Session {key: r.sessionKey})
|
|
190
|
+
ON CREATE SET s.label = r.label, s.sessionId = r.sessionId, s.turnCount = 0, s.editCount = 0,
|
|
191
|
+
s.startedAt = r.firstAt, s.lastAt = r.lastAt, s.stub = true
|
|
192
|
+
MERGE (f:File {key: r.fileKey})
|
|
193
|
+
ON CREATE SET f.path = r.path, f.editCount = 0
|
|
194
|
+
MERGE (s)-[h:HELD]->(f)
|
|
195
|
+
ON CREATE SET h.count = 0, h.firstAt = r.firstAt, h.lastAt = r.lastAt
|
|
196
|
+
SET h.count = h.count + r.count,
|
|
197
|
+
h.lastAt = CASE WHEN r.lastAt > h.lastAt THEN r.lastAt ELSE h.lastAt END
|
|
198
|
+
RETURN count(h) AS n`;
|
|
199
|
+
|
|
200
|
+
function byProject(map) {
|
|
201
|
+
const out = new Map();
|
|
202
|
+
for (const row of map.values()) {
|
|
203
|
+
if (!out.has(row.projectId)) out.set(row.projectId, []);
|
|
204
|
+
out.get(row.projectId).push(row);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function flush(map, query, label) {
|
|
210
|
+
if (!map.size || !api.client.available()) return;
|
|
211
|
+
const groups = byProject(map);
|
|
212
|
+
map.clear();
|
|
213
|
+
for (const [projectId, rows] of groups) {
|
|
214
|
+
try {
|
|
215
|
+
await api.ensureProject(projectId);
|
|
216
|
+
await each(api.client, projectId, query, rows);
|
|
217
|
+
if (label === "contention") {
|
|
218
|
+
const attempts = rows.reduce((n, r) => n + r.attempts, 0);
|
|
219
|
+
console.log(`[relay-falkor] ${projectId}: +${rows.length} contention(s), ${attempts} blocked attempt(s)`);
|
|
220
|
+
api.notify?.(projectId, { contentions: rows.length });
|
|
221
|
+
}
|
|
222
|
+
} catch (err) {
|
|
223
|
+
api.projectStats(projectId).lastError = String(err.message || err);
|
|
224
|
+
// Only retry when the batch provably never reached the server; a re-sent batch would double the counters.
|
|
225
|
+
if (!err.breaker) continue;
|
|
226
|
+
for (const r of rows) if (map.size < MAX_ROWS) map.set(r.key || `${r.projectId}|${r.sessionKey}|${r.fileKey}`, r);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function every(ms, fn) {
|
|
232
|
+
const t = setInterval(() => fn().catch(() => undefined), ms);
|
|
233
|
+
if (t.unref) t.unref();
|
|
234
|
+
return t;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const WARM_INTENTS = `
|
|
238
|
+
MATCH (s:Session) WHERE s.intent IS NOT NULL
|
|
239
|
+
OPTIONAL MATCH (a:Agent)-[:RAN]->(s)
|
|
240
|
+
RETURN s.key AS sessionKey, s.label AS label, s.intent AS intent, s.intentAt AS intentAt, a.key AS agentKey
|
|
241
|
+
ORDER BY s.intentAt DESC LIMIT 1000`;
|
|
242
|
+
|
|
243
|
+
/** The intent cache is in-process; after a restart it is refilled from the graph, not from the next ingest. */
|
|
244
|
+
async function warmIntents() {
|
|
245
|
+
let projects = [];
|
|
246
|
+
try {
|
|
247
|
+
projects = loadRegistry().projects || [];
|
|
248
|
+
} catch {
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
let n = 0;
|
|
252
|
+
for (const p of projects) {
|
|
253
|
+
try {
|
|
254
|
+
const rows = await api.client.read(p.id, WARM_INTENTS);
|
|
255
|
+
for (const r of rows.reverse()) {
|
|
256
|
+
intents.remember(r);
|
|
257
|
+
n += 1;
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
/* project has no graph yet */
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return n;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function start(falkor) {
|
|
267
|
+
api = { ...falkor, client: falkor.state.client, state: falkor.state };
|
|
268
|
+
wrapClaim(falkor.state.deps.lockTable);
|
|
269
|
+
const warm = setTimeout(() => {
|
|
270
|
+
warmIntents()
|
|
271
|
+
.then((n) => n && console.log(`[relay-falkor] warmed ${n} session intent(s) from the graph`))
|
|
272
|
+
.catch(() => undefined);
|
|
273
|
+
}, 500);
|
|
274
|
+
if (warm.unref) warm.unref();
|
|
275
|
+
bus.on("collision", ({ projectId, kind, detail = {}, ts }) => {
|
|
276
|
+
if (kind === "claim_blocked") return; // already captured, with intent, by the claim wrapper
|
|
277
|
+
recordContention(projectId, {
|
|
278
|
+
kind,
|
|
279
|
+
file: detail.file,
|
|
280
|
+
wanter: detail.agentId || detail.agent || null,
|
|
281
|
+
holder: detail.holder || null,
|
|
282
|
+
reason: detail.reason || null,
|
|
283
|
+
ts: ts || Date.now(),
|
|
284
|
+
holderIntentText: detail.holder ? holderIntent(detail.holder)?.text || null : null,
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
every(CONTENTION_FLUSH_MS, () => flush(contentions, CONTENTION, "contention"));
|
|
288
|
+
every(HELD_FLUSH_MS, () => flush(held, HELD, "held"));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function flushAll() {
|
|
292
|
+
return Promise.all([flush(contentions, CONTENTION, "contention"), flush(held, HELD, "held")]);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function configure(falkor) {
|
|
296
|
+
api = { ...falkor, client: falkor.state.client, state: falkor.state };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = { start, configure, wrapClaim, enrichDeny, holderIntent, observeClaim, recordContention, recordHeld, flushAll, projectIdFor, warmIntents };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Per-project ingest cursor. Purely an optimization: MERGE + batch tokens keep the
|
|
2
|
+
// graph correct even if this file is lost; losing it only costs one full re-send.
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { projectDir } = require("../store");
|
|
6
|
+
const { INGEST_VERSION } = require("./schema");
|
|
7
|
+
|
|
8
|
+
const MAX_SEEN = 6000;
|
|
9
|
+
|
|
10
|
+
function cursorPath(projectId) {
|
|
11
|
+
return path.join(projectDir(projectId), "graph-cursor.json");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function blank() {
|
|
15
|
+
return { ingestVersion: INGEST_VERSION, seen: [], centralEventId: {}, updatedAt: 0 };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function load(projectId) {
|
|
19
|
+
try {
|
|
20
|
+
const raw = JSON.parse(fs.readFileSync(cursorPath(projectId), "utf8"));
|
|
21
|
+
if (raw.ingestVersion !== INGEST_VERSION) return blank();
|
|
22
|
+
return { ...blank(), ...raw, seen: Array.isArray(raw.seen) ? raw.seen : [] };
|
|
23
|
+
} catch {
|
|
24
|
+
return blank();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function save(projectId, cursor) {
|
|
29
|
+
const file = cursorPath(projectId);
|
|
30
|
+
const tmp = `${file}.tmp`;
|
|
31
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...cursor, updatedAt: Date.now() }));
|
|
32
|
+
fs.renameSync(tmp, file);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function seenSet(cursor) {
|
|
36
|
+
return new Set(cursor.seen);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function commit(projectId, cursor, ids) {
|
|
40
|
+
const set = new Set(cursor.seen);
|
|
41
|
+
for (const id of ids) {
|
|
42
|
+
set.delete(id);
|
|
43
|
+
set.add(id);
|
|
44
|
+
}
|
|
45
|
+
cursor.seen = [...set].slice(-MAX_SEEN);
|
|
46
|
+
save(projectId, cursor);
|
|
47
|
+
return cursor;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function reset(projectId) {
|
|
51
|
+
try {
|
|
52
|
+
fs.unlinkSync(cursorPath(projectId));
|
|
53
|
+
} catch {
|
|
54
|
+
/* already gone */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { load, save, commit, reset, seenSet, cursorPath };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Synthetic events shaped like real unifyEvent output (no user data).
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
// A native absolute workspace, so path handling is exercised the way it runs on each OS.
|
|
5
|
+
const WS = process.platform === "win32" ? "C:\\Users\\dev\\demo" : "/home/dev/demo";
|
|
6
|
+
const wsPath = (...parts) => path.join(WS, ...parts);
|
|
7
|
+
// Agents sometimes report Windows paths with forward slashes; on POSIX this is the same path.
|
|
8
|
+
const slashed = (p) => p.split(path.sep).join("/");
|
|
9
|
+
const CLAUDE_SID = "52b29a54-d8fb-4091-ab60-463ac73c9d6c";
|
|
10
|
+
const CURSOR_SID = "3e62675d-9b0f-4d99-b17d-c1d875fe98dc";
|
|
11
|
+
|
|
12
|
+
let n = 0;
|
|
13
|
+
function ev(fields) {
|
|
14
|
+
n += 1;
|
|
15
|
+
return {
|
|
16
|
+
id: `ev${String(n).padStart(4, "0")}`,
|
|
17
|
+
ownerLogin: "alice",
|
|
18
|
+
role: "assistant",
|
|
19
|
+
kind: "message",
|
|
20
|
+
text: "",
|
|
21
|
+
file: null,
|
|
22
|
+
path: null,
|
|
23
|
+
diff: "",
|
|
24
|
+
...fields,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function demoHistory() {
|
|
29
|
+
n = 0;
|
|
30
|
+
return [
|
|
31
|
+
ev({ agent: "Claude Code", sessionId: CLAUDE_SID, ts: 1000, role: "user", text: "switch the login flow to JWT tokens" }),
|
|
32
|
+
ev({ agent: "Cursor", sessionId: CURSOR_SID, ts: 1500.685, role: "user", text: "make the navbar sticky" }),
|
|
33
|
+
ev({ agent: "Claude Code", sessionId: CLAUDE_SID, ts: 2000, role: "assistant", kind: "thinking", text: "hmm, issuer first" }),
|
|
34
|
+
ev({ agent: "Claude Code", sessionId: CLAUDE_SID, ts: 2100, role: "assistant", text: "I'll update the token issuer first." }),
|
|
35
|
+
ev({
|
|
36
|
+
agent: "Claude Code",
|
|
37
|
+
sessionId: CLAUDE_SID,
|
|
38
|
+
ts: 3000,
|
|
39
|
+
role: "tool",
|
|
40
|
+
kind: "code_edit",
|
|
41
|
+
text: "Edit auth.ts",
|
|
42
|
+
file: "auth.ts",
|
|
43
|
+
path: slashed(wsPath("src", "Auth.ts")),
|
|
44
|
+
diff: "--- a/auth.ts\n+++ b/auth.ts\n@@ -1,2 +1,3 @@\n-old\n+new\n+newer\n",
|
|
45
|
+
}),
|
|
46
|
+
ev({
|
|
47
|
+
agent: "Cursor",
|
|
48
|
+
sessionId: CURSOR_SID,
|
|
49
|
+
ts: 3500.685,
|
|
50
|
+
role: "tool",
|
|
51
|
+
kind: "code_edit",
|
|
52
|
+
text: "Write navbar.css",
|
|
53
|
+
file: "navbar.css",
|
|
54
|
+
path: wsPath("styles", "navbar.css"),
|
|
55
|
+
diff: "+.nav{position:sticky}\n",
|
|
56
|
+
}),
|
|
57
|
+
ev({ agent: "Claude Code", sessionId: CLAUDE_SID, ts: 4000, role: "user", text: "also add refresh tokens, api_key=supersecretvalue123" }),
|
|
58
|
+
ev({
|
|
59
|
+
agent: "Claude Code",
|
|
60
|
+
sessionId: CLAUDE_SID,
|
|
61
|
+
ts: 5000,
|
|
62
|
+
role: "tool",
|
|
63
|
+
kind: "code_edit",
|
|
64
|
+
text: "Edit auth.ts",
|
|
65
|
+
file: "auth.ts",
|
|
66
|
+
path: wsPath("src", "auth.ts"),
|
|
67
|
+
diff: "+refresh\n",
|
|
68
|
+
}),
|
|
69
|
+
ev({
|
|
70
|
+
agent: "Claude Code",
|
|
71
|
+
sessionId: CLAUDE_SID,
|
|
72
|
+
ts: 5100,
|
|
73
|
+
role: "tool",
|
|
74
|
+
kind: "code_edit",
|
|
75
|
+
text: "Write notes.md",
|
|
76
|
+
file: "notes.md",
|
|
77
|
+
path: path.join(path.dirname(WS), "other-repo", "notes.md"),
|
|
78
|
+
diff: "+x\n",
|
|
79
|
+
}),
|
|
80
|
+
ev({ agent: "Antigravity", sessionId: "session", ts: 6000, role: "user", text: "what files do you see" }),
|
|
81
|
+
ev({ agent: "Codex", sessionId: "codex-1", ts: 7000, role: "tool", kind: "code_edit", text: "apply_patch", file: null, path: null, diff: "" }),
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { demoHistory, WS, wsPath, CLAUDE_SID, CURSOR_SID };
|