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,146 @@
|
|
|
1
|
+
const { readConfig, loadFileConfig, saveFileConfig, configPath, validUrl } = require("./config");
|
|
2
|
+
const { FalkorClient } = require("./client");
|
|
3
|
+
const schema = require("./schema");
|
|
4
|
+
|
|
5
|
+
const state = {
|
|
6
|
+
cfg: null,
|
|
7
|
+
client: null,
|
|
8
|
+
deps: {},
|
|
9
|
+
projects: new Map(),
|
|
10
|
+
started: false,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function projectStats(projectId) {
|
|
14
|
+
if (!state.projects.has(projectId)) {
|
|
15
|
+
state.projects.set(projectId, {
|
|
16
|
+
schemaVersion: null,
|
|
17
|
+
needsRebuild: false,
|
|
18
|
+
lastIngestAt: null,
|
|
19
|
+
lastIngestCount: 0,
|
|
20
|
+
bufferDrops: 0,
|
|
21
|
+
droppedRows: 0,
|
|
22
|
+
pendingLinks: 0,
|
|
23
|
+
lastError: null,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return state.projects.get(projectId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function enabled() {
|
|
30
|
+
return Boolean(state.started && state.cfg?.enabled);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function ensureProject(projectId) {
|
|
34
|
+
const info = await schema.ensure(state.client, projectId);
|
|
35
|
+
const stats = projectStats(projectId);
|
|
36
|
+
stats.schemaVersion = info.schemaVersion;
|
|
37
|
+
stats.needsRebuild = info.needsRebuild;
|
|
38
|
+
return info;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** After any write: refresh the UI and let the analytics job know there's new structure. */
|
|
42
|
+
function notify(projectId, payload = {}) {
|
|
43
|
+
try {
|
|
44
|
+
// Not "graph": that event name already carries the dependency graph to the dashboard.
|
|
45
|
+
state.deps.sse?.emit?.("memory-graph", { workspaceId: projectId, at: Date.now(), ...payload });
|
|
46
|
+
} catch {
|
|
47
|
+
/* UI refresh is best-effort */
|
|
48
|
+
}
|
|
49
|
+
require("./bus").emit("graph-written", { projectId });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function maskUrl(url) {
|
|
53
|
+
return url ? url.replace(/\/\/[^@/]*@/, "//***@") : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function health() {
|
|
57
|
+
return {
|
|
58
|
+
enabled: Boolean(state.cfg?.enabled),
|
|
59
|
+
running: state.started,
|
|
60
|
+
url: maskUrl(state.cfg?.url),
|
|
61
|
+
source: state.cfg?.source || null,
|
|
62
|
+
configFile: configPath(),
|
|
63
|
+
storeText: state.cfg?.storeText ?? null,
|
|
64
|
+
breaker: state.client ? state.client.status() : null,
|
|
65
|
+
projects: Object.fromEntries(state.projects),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Starts the client and every module once; used at boot and by a runtime enable. */
|
|
70
|
+
function startGraph() {
|
|
71
|
+
if (state.started) return;
|
|
72
|
+
state.client = new FalkorClient(state.cfg);
|
|
73
|
+
state.started = true;
|
|
74
|
+
for (const name of MODULES) {
|
|
75
|
+
try {
|
|
76
|
+
require(name).start(module.exports);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.warn(`[relay-falkor] ${name} failed to start: ${err.message || err}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
state.client
|
|
82
|
+
.connection()
|
|
83
|
+
.then(() => console.log(`[relay-falkor] connected ${maskUrl(state.cfg.url)}`))
|
|
84
|
+
.catch((err) => console.warn(`[relay-falkor] not reachable yet (${err.message || err}); relay continues without it`));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Turns the graph on in a running relay serve (`relay graph enable`, Mission Control's
|
|
89
|
+
* Enable button). Saves the URL so the next start remembers it.
|
|
90
|
+
*/
|
|
91
|
+
async function enableAtRuntime(url) {
|
|
92
|
+
const target = String(url || "").trim() || "falkor://127.0.0.1:6379";
|
|
93
|
+
if (!validUrl(target)) throw Object.assign(new Error("URL must look like falkor://host:port"), { status: 400 });
|
|
94
|
+
const saved = { ...(loadFileConfig() || {}), url: target };
|
|
95
|
+
delete saved.disabled;
|
|
96
|
+
saveFileConfig(saved);
|
|
97
|
+
if (state.started) return { ...health(), note: "already running; restart relay serve to switch databases" };
|
|
98
|
+
state.cfg = readConfig();
|
|
99
|
+
if (!state.cfg.enabled) throw Object.assign(new Error("RELAY_FALKOR_DISABLED is set in this process"), { status: 409 });
|
|
100
|
+
startGraph();
|
|
101
|
+
try {
|
|
102
|
+
await state.client.connection();
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return { ...health(), warning: `saved, but FalkorDB is not reachable yet: ${err.message || err}` };
|
|
105
|
+
}
|
|
106
|
+
return health();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function registerRoutes(app) {
|
|
110
|
+
app.get("/api/graph/health", (_req, res) => res.json(health()));
|
|
111
|
+
app.post("/api/graph/enable", async (req, res) => {
|
|
112
|
+
// Writes this machine's config: never through the room tunnel.
|
|
113
|
+
if (require("../roomAuth").isRemoteRequest(req)) return res.status(403).json({ ok: false, reason: "local requests only" });
|
|
114
|
+
try {
|
|
115
|
+
res.json({ ok: true, ...(await enableAtRuntime(req.body?.url)) });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
res.status(err.status || 500).json({ ok: false, reason: String(err.message || err) });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
// Registered even when disabled here: a room guest without FalkorDB still reads the host's graph.
|
|
121
|
+
require("./routes").register(app, module.exports);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Wires the memory graph into relay serve. Never throws: a broken or absent
|
|
126
|
+
* FalkorDB must leave every existing relay behavior untouched.
|
|
127
|
+
*/
|
|
128
|
+
function init(app, deps = {}) {
|
|
129
|
+
try {
|
|
130
|
+
state.cfg = readConfig();
|
|
131
|
+
state.deps = deps;
|
|
132
|
+
registerRoutes(app);
|
|
133
|
+
if (!state.cfg.enabled) {
|
|
134
|
+
console.log("[relay-falkor] memory graph off (run `relay graph enable`, or set RELAY_FALKOR_URL)");
|
|
135
|
+
return health();
|
|
136
|
+
}
|
|
137
|
+
startGraph();
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.warn(`[relay-falkor] init failed: ${err.message || err}`);
|
|
140
|
+
}
|
|
141
|
+
return health();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const MODULES = ["./ingest", "./coord", "./central", "./analytics"];
|
|
145
|
+
|
|
146
|
+
module.exports = { init, state, enabled, ensureProject, projectStats, health, notify, enableAtRuntime };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
const { bus } = require("./bus");
|
|
2
|
+
const cursor = require("./cursor");
|
|
3
|
+
const intents = require("./intents");
|
|
4
|
+
const { projectHistory, projectPatches, projectPeer } = require("./project");
|
|
5
|
+
const { writeProjection } = require("./write");
|
|
6
|
+
|
|
7
|
+
const DEBOUNCE_MS = 2000;
|
|
8
|
+
const pending = new Map();
|
|
9
|
+
const running = new Map();
|
|
10
|
+
let api = null;
|
|
11
|
+
|
|
12
|
+
function log(projectId, msg) {
|
|
13
|
+
console.log(`[relay-falkor] ${projectId}: ${msg}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The cursor says what we sent; the graph's Meta.lastIngestAt says what it kept. RDB
|
|
17
|
+
// snapshots can lose the last few seconds, and a FalkorDB can be wiped or swapped,
|
|
18
|
+
// so a cursor newer than the graph means re-send everything (MERGE keeps it idempotent).
|
|
19
|
+
const SNAPSHOT_GRACE_MS = 5000;
|
|
20
|
+
const reconciled = new Set();
|
|
21
|
+
|
|
22
|
+
function cursorFor(projectId, info, opts) {
|
|
23
|
+
const cur = cursor.load(projectId);
|
|
24
|
+
if (opts.full) return { ...cur, seen: [] };
|
|
25
|
+
if (!reconciled.has(projectId)) {
|
|
26
|
+
reconciled.add(projectId);
|
|
27
|
+
if (cur.seen.length && cur.updatedAt > (info?.lastIngestAt || 0) + SNAPSHOT_GRACE_MS) {
|
|
28
|
+
log(projectId, "graph is behind the ingest cursor (lost snapshot or new database); re-sending history");
|
|
29
|
+
return { ...cur, seen: [] };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return cur;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Projects + writes everything in `history` the cursor hasn't confirmed yet. */
|
|
36
|
+
async function ingestHistory(projectId, history, workspacePath, opts = {}) {
|
|
37
|
+
const { client, state } = api;
|
|
38
|
+
const stats = api.projectStats(projectId);
|
|
39
|
+
const info = await api.ensureProject(projectId);
|
|
40
|
+
const cur = cursorFor(projectId, info, opts);
|
|
41
|
+
const seen = cursor.seenSet(cur);
|
|
42
|
+
const projection = projectHistory(history, {
|
|
43
|
+
workspacePath,
|
|
44
|
+
isNew: (id) => !seen.has(id),
|
|
45
|
+
storeText: state.cfg.storeText,
|
|
46
|
+
});
|
|
47
|
+
for (const info of projection.intents.values()) intents.remember(info);
|
|
48
|
+
if (!projection.sessions.length && !projection.turns.length && !projection.edits.length) return { skipped: true };
|
|
49
|
+
|
|
50
|
+
const t = Date.now();
|
|
51
|
+
const result = await writeProjection(client, projectId, projection);
|
|
52
|
+
// Stamp the graph before the cursor, so the cursor can never look older than what the graph holds.
|
|
53
|
+
await client.write(projectId, "MERGE (m:Meta {key:'meta'}) SET m.lastIngestAt = $now RETURN 1", { now: Date.now() });
|
|
54
|
+
cursor.commit(projectId, cur, (history || []).map((e) => e.id).filter(Boolean));
|
|
55
|
+
stats.lastIngestAt = Date.now();
|
|
56
|
+
stats.lastIngestCount = result.turnsCreated + result.editsCreated;
|
|
57
|
+
stats.droppedRows += projection.dropped;
|
|
58
|
+
stats.lastError = null;
|
|
59
|
+
log(projectId, `+${result.turnsCreated} turns, +${result.editsCreated} edits, ${result.sessions} sessions (${Date.now() - t}ms)`);
|
|
60
|
+
api.notify?.(projectId, result);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function schedule(projectId, payload) {
|
|
65
|
+
try {
|
|
66
|
+
const projected = projectHistory(payload.history, {
|
|
67
|
+
workspacePath: payload.workspacePath,
|
|
68
|
+
isNew: () => false,
|
|
69
|
+
storeText: false,
|
|
70
|
+
});
|
|
71
|
+
for (const info of projected.intents.values()) intents.remember(info);
|
|
72
|
+
} catch {
|
|
73
|
+
/* best-effort immediate intent cache update */
|
|
74
|
+
}
|
|
75
|
+
const entry = pending.get(projectId) || {};
|
|
76
|
+
entry.payload = payload;
|
|
77
|
+
clearTimeout(entry.timer);
|
|
78
|
+
entry.timer = setTimeout(() => flush(projectId), DEBOUNCE_MS);
|
|
79
|
+
if (entry.timer.unref) entry.timer.unref();
|
|
80
|
+
pending.set(projectId, entry);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function flush(projectId) {
|
|
84
|
+
const entry = pending.get(projectId);
|
|
85
|
+
if (!entry) return;
|
|
86
|
+
if (running.get(projectId)) {
|
|
87
|
+
schedule(projectId, entry.payload);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
pending.delete(projectId);
|
|
91
|
+
if (!api.client.available()) return;
|
|
92
|
+
running.set(projectId, true);
|
|
93
|
+
try {
|
|
94
|
+
const { history, workspacePath } = entry.payload;
|
|
95
|
+
await ingestHistory(projectId, history, workspacePath);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// Replayable: the cursor wasn't committed, so the next sync re-sends these events.
|
|
98
|
+
api.projectStats(projectId).lastError = String(err.message || err);
|
|
99
|
+
if (!err.breaker) log(projectId, `ingest failed: ${err.message || err}`);
|
|
100
|
+
} finally {
|
|
101
|
+
running.delete(projectId);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- patches: not replayable from history, so failed batches go back into a bounded buffer
|
|
106
|
+
const PATCH_BUFFER_MAX = 2000;
|
|
107
|
+
const patchQueues = new Map();
|
|
108
|
+
|
|
109
|
+
function queuePatch(projectId, patch) {
|
|
110
|
+
const q = patchQueues.get(projectId) || { items: [], timer: null };
|
|
111
|
+
q.items.push(patch);
|
|
112
|
+
if (q.items.length > PATCH_BUFFER_MAX) {
|
|
113
|
+
q.items.splice(0, q.items.length - PATCH_BUFFER_MAX);
|
|
114
|
+
api.projectStats(projectId).bufferDrops += 1;
|
|
115
|
+
}
|
|
116
|
+
if (!q.timer) {
|
|
117
|
+
q.timer = setTimeout(() => flushPatches(projectId), 1000);
|
|
118
|
+
if (q.timer.unref) q.timer.unref();
|
|
119
|
+
}
|
|
120
|
+
patchQueues.set(projectId, q);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function flushPatches(projectId) {
|
|
124
|
+
const q = patchQueues.get(projectId);
|
|
125
|
+
if (!q) return;
|
|
126
|
+
q.timer = null;
|
|
127
|
+
const items = q.items.splice(0);
|
|
128
|
+
if (!items.length) return;
|
|
129
|
+
const retry = () => {
|
|
130
|
+
q.items.unshift(...items);
|
|
131
|
+
q.timer = setTimeout(() => flushPatches(projectId), api.state.cfg.breakerCooldownMs);
|
|
132
|
+
if (q.timer.unref) q.timer.unref();
|
|
133
|
+
};
|
|
134
|
+
if (!api.client.available()) return retry();
|
|
135
|
+
try {
|
|
136
|
+
await api.ensureProject(projectId);
|
|
137
|
+
const result = await writeProjection(api.client, projectId, projectPatches(items, { storeText: api.state.cfg.storeText }));
|
|
138
|
+
if (result.editsCreated) log(projectId, `+${result.editsCreated} patch edits`);
|
|
139
|
+
api.notify?.(projectId, result);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
api.projectStats(projectId).lastError = String(err.message || err);
|
|
142
|
+
retry();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- room peers: debounced per (project, peer); content-addressed keys make re-pushes no-ops
|
|
147
|
+
const peerSeen = new Map();
|
|
148
|
+
const PEER_SEEN_MAX = 20000;
|
|
149
|
+
|
|
150
|
+
function seenFor(projectId) {
|
|
151
|
+
if (!peerSeen.has(projectId)) peerSeen.set(projectId, new Set());
|
|
152
|
+
return peerSeen.get(projectId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function ingestPeer(projectId, { snapshot, login, trustRowOwners }) {
|
|
156
|
+
await api.ensureProject(projectId);
|
|
157
|
+
const seen = seenFor(projectId);
|
|
158
|
+
const projection = projectPeer(snapshot, {
|
|
159
|
+
login,
|
|
160
|
+
trustRowOwners,
|
|
161
|
+
isNew: (id) => !seen.has(id),
|
|
162
|
+
storeText: api.state.cfg.storeText,
|
|
163
|
+
});
|
|
164
|
+
for (const info of projection.intents.values()) intents.remember(info);
|
|
165
|
+
if (!projection.turns.length && !projection.edits.length) return { skipped: true };
|
|
166
|
+
const result = await writeProjection(api.client, projectId, projection);
|
|
167
|
+
for (const t of projection.turns) seen.add(`peer_${t.turnKey}`);
|
|
168
|
+
for (const e of projection.edits) seen.add(String(e.editKey).replace(/^e_/, ""));
|
|
169
|
+
for (const e of projection.edits) seen.add(e.editKey);
|
|
170
|
+
if (seen.size > PEER_SEEN_MAX) peerSeen.set(projectId, new Set([...seen].slice(-PEER_SEEN_MAX / 2)));
|
|
171
|
+
log(projectId, `peer ${login}: +${result.turnsCreated} turns, +${result.editsCreated} edits`);
|
|
172
|
+
api.notify?.(projectId, { peer: login, ...result });
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const peerTimers = new Map();
|
|
177
|
+
function schedulePeer(projectId, payload) {
|
|
178
|
+
try {
|
|
179
|
+
const projected = projectPeer(payload.snapshot, {
|
|
180
|
+
login: payload.login,
|
|
181
|
+
trustRowOwners: payload.trustRowOwners,
|
|
182
|
+
isNew: () => false,
|
|
183
|
+
storeText: false,
|
|
184
|
+
});
|
|
185
|
+
for (const info of projected.intents.values()) intents.remember(info);
|
|
186
|
+
} catch {
|
|
187
|
+
/* best-effort immediate intent cache update */
|
|
188
|
+
}
|
|
189
|
+
const key = `${projectId}|${payload.login}`;
|
|
190
|
+
const entry = peerTimers.get(key) || {};
|
|
191
|
+
entry.payload = payload;
|
|
192
|
+
clearTimeout(entry.timer);
|
|
193
|
+
entry.timer = setTimeout(async () => {
|
|
194
|
+
peerTimers.delete(key);
|
|
195
|
+
if (!api.client.available()) return;
|
|
196
|
+
try {
|
|
197
|
+
await ingestPeer(projectId, entry.payload);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
api.projectStats(projectId).lastError = String(err.message || err);
|
|
200
|
+
}
|
|
201
|
+
}, DEBOUNCE_MS);
|
|
202
|
+
if (entry.timer.unref) entry.timer.unref();
|
|
203
|
+
peerTimers.set(key, entry);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function start(falkor) {
|
|
207
|
+
api = { ...falkor, client: falkor.state.client, state: falkor.state };
|
|
208
|
+
bus.on("history", ({ project, workspacePath, memory }) => {
|
|
209
|
+
if (!project?.id || !Array.isArray(memory?.history)) return;
|
|
210
|
+
schedule(project.id, { history: memory.history, workspacePath: workspacePath || project.path });
|
|
211
|
+
});
|
|
212
|
+
bus.on("patch", ({ projectId, patch }) => {
|
|
213
|
+
if (projectId && patch) queuePatch(projectId, patch);
|
|
214
|
+
});
|
|
215
|
+
bus.on("peer", ({ projectId, key, login, snapshot }) => {
|
|
216
|
+
if (!projectId || !snapshot) return;
|
|
217
|
+
// A guest's "host" bucket aggregates several people, so its row owners are meaningful;
|
|
218
|
+
// a single peer's push is forced to that peer's authenticated login.
|
|
219
|
+
const trustRowOwners = key === "host";
|
|
220
|
+
schedulePeer(projectId, { snapshot, login: login || key, trustRowOwners });
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function configure(falkor) {
|
|
225
|
+
api = { ...falkor, client: falkor.state.client, state: falkor.state };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Forget per-process state, as a restart would. */
|
|
229
|
+
function _resetForTests() {
|
|
230
|
+
reconciled.clear();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = { start, configure, ingestHistory, ingestPeer, flush, flushPatches, _resetForTests };
|
|
@@ -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 };
|