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,46 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
const IMPORT_RE =
|
|
5
|
+
/(?:import\s+(?:[\s\S]*?)\s+from\s+|require\s*\(|from\s+)['"]([^'"]+)['"]/g;
|
|
6
|
+
|
|
7
|
+
function normalizeRel(fromFile, spec) {
|
|
8
|
+
if (!spec.startsWith(".")) return null;
|
|
9
|
+
const dir = path.posix.dirname(fromFile.replace(/\\/g, "/"));
|
|
10
|
+
let resolved = path.posix.normalize(path.posix.join(dir, spec)).replace(/^\.\//, "");
|
|
11
|
+
return resolved.replace(/\\/g, "/");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseImports(source, fromFile) {
|
|
15
|
+
const out = [];
|
|
16
|
+
let m;
|
|
17
|
+
const re = new RegExp(IMPORT_RE);
|
|
18
|
+
while ((m = re.exec(source))) {
|
|
19
|
+
const rel = normalizeRel(fromFile, m[1]);
|
|
20
|
+
if (rel) out.push(rel);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveExisting(workspacePath, rel) {
|
|
26
|
+
const bases = [rel, `${rel}.ts`, `${rel}.tsx`, `${rel}.js`, `${rel}.jsx`, path.posix.join(rel, "index.ts")];
|
|
27
|
+
for (const b of bases) {
|
|
28
|
+
const abs = path.join(workspacePath, b);
|
|
29
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return b.replace(/\\/g, "/");
|
|
30
|
+
}
|
|
31
|
+
return rel;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function dependsOn(workspacePath, filePath) {
|
|
35
|
+
try {
|
|
36
|
+
const rel = filePath.replace(/\\/g, "/").replace(/^\.?\//, "");
|
|
37
|
+
const abs = path.join(workspacePath, rel);
|
|
38
|
+
if (!fs.existsSync(abs)) return [];
|
|
39
|
+
const src = fs.readFileSync(abs, "utf8");
|
|
40
|
+
return parseImports(src, rel).map((d) => resolveExisting(workspacePath, d));
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { dependsOn, parseImports };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const { execFileSync } = require("child_process");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
const SKIP = /(^|\/)(\.git|\.relay|node_modules|\.next|dist|out)(\/|$)/i;
|
|
5
|
+
|
|
6
|
+
function dirtyFiles(workspacePath) {
|
|
7
|
+
if (!workspacePath) return [];
|
|
8
|
+
let out = "";
|
|
9
|
+
try {
|
|
10
|
+
out = execFileSync("git", ["-C", workspacePath, "status", "--porcelain", "-uall"], {
|
|
11
|
+
encoding: "utf8",
|
|
12
|
+
timeout: 4000,
|
|
13
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
14
|
+
windowsHide: true,
|
|
15
|
+
});
|
|
16
|
+
} catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
const files = [];
|
|
20
|
+
for (const line of out.split("\n")) {
|
|
21
|
+
if (!line.trim()) continue;
|
|
22
|
+
const raw = line.slice(3).replace(/.* -> /, "").trim().replace(/\\/g, "/");
|
|
23
|
+
if (!raw || SKIP.test(raw)) continue;
|
|
24
|
+
files.push(raw);
|
|
25
|
+
}
|
|
26
|
+
return [...new Set(files)];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveRel(workspacePath, file) {
|
|
30
|
+
return path.join(workspacePath, file);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { dirtyFiles, resolveRel };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for write-tool hook matchers (PreToolUse + PostToolUse)
|
|
3
|
+
* and transcript edit detection. Keep pre/post identical per product or flush
|
|
4
|
+
* never runs after a successful claim.
|
|
5
|
+
*
|
|
6
|
+
* Sources (2025–2026):
|
|
7
|
+
* - Cursor hooks: preToolUse/postToolUse match tool_name (Write, Edit, Delete, …)
|
|
8
|
+
* - Claude Code hooks: PostToolUse matcher = tool name (Edit|Write|NotebookEdit, …)
|
|
9
|
+
* - Codex hooks: apply_patch|Edit|Write (+ runtime names in transcripts)
|
|
10
|
+
* - Copilot CLI: postToolUse matcher = toolName regex (edit|create|apply_patch)
|
|
11
|
+
* - Antigravity: PreToolUse/PostToolUse matcher on toolCall.name
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function matcherToSet(matcher) {
|
|
15
|
+
return new Set(
|
|
16
|
+
String(matcher || "")
|
|
17
|
+
.split("|")
|
|
18
|
+
.map((s) => s.trim())
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const EDIT = {
|
|
24
|
+
// code.claude.com/docs/en/hooks — PostToolUse matcher on tool name
|
|
25
|
+
claude: "Edit|Write|NotebookEdit|MultiEdit|Replace",
|
|
26
|
+
// cursor.com/docs/hooks — postToolUse matcher on tool_name; StrReplace/ApplyPatch
|
|
27
|
+
// appear in agent transcripts but are under-documented in the matcher list.
|
|
28
|
+
cursor: "Write|Edit|Delete|StrReplace|ApplyPatch|EditNotebook|search_replace",
|
|
29
|
+
// Codex hook config + session transcript tool names
|
|
30
|
+
codex: "apply_patch|Edit|Write|write|edit_file|create_file|patch_file",
|
|
31
|
+
// docs.github.com/copilot/reference/hooks-reference — CLI toolName edit|create, plus the
|
|
32
|
+
// VS Code Copilot agent's names. replace_string_in_file is its most common edit, and
|
|
33
|
+
// "edit|create" alone never matched it.
|
|
34
|
+
copilot: "edit|create|apply_patch|create_file|replace_string_in_file|multi_replace_string_in_file|insert_edit_into_file|edit_file",
|
|
35
|
+
// Antigravity write tools (TargetFile on toolCall.args)
|
|
36
|
+
antigravity: "write_to_file|replace_file_content|multi_replace_file_content|edit_file|create_file",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const READ = {
|
|
40
|
+
claude: "Read|Grep|Glob",
|
|
41
|
+
cursor: "Read|Grep",
|
|
42
|
+
codex: null,
|
|
43
|
+
copilot: "view|grep|glob",
|
|
44
|
+
antigravity: "view_file|grep_search|list_dir|find_by_name",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const EDIT_TOOL_SETS = {
|
|
48
|
+
cursor: matcherToSet(EDIT.cursor),
|
|
49
|
+
claude: matcherToSet(EDIT.claude),
|
|
50
|
+
codex: matcherToSet(EDIT.codex),
|
|
51
|
+
copilot: matcherToSet(EDIT.copilot),
|
|
52
|
+
antigravity: matcherToSet(EDIT.antigravity),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
module.exports = { EDIT, READ, EDIT_TOOL_SETS, matcherToSet };
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Debounced per-project job: overlaps, projections, FalkorDB graph algorithms.
|
|
2
|
+
// Never inline with ingest; results land as properties so reads stay one hop.
|
|
3
|
+
const { bus } = require("./bus");
|
|
4
|
+
const keys = require("./keys");
|
|
5
|
+
const { each } = require("./write");
|
|
6
|
+
|
|
7
|
+
const DEBOUNCE_MS = 15_000;
|
|
8
|
+
const MIN_INTERVAL_MS = 30_000;
|
|
9
|
+
const OVERLAP_WINDOW_MS = 5 * 60 * 1000;
|
|
10
|
+
const MAX_FILES_PER_SESSION = 50;
|
|
11
|
+
|
|
12
|
+
let api = null;
|
|
13
|
+
const timers = new Map();
|
|
14
|
+
const lastRun = new Map();
|
|
15
|
+
const running = new Set();
|
|
16
|
+
|
|
17
|
+
const OVERLAP_CANDIDATES = `
|
|
18
|
+
MATCH (e1:Edit)-[:MODIFIED]->(f:File)<-[:MODIFIED]-(e2:Edit)
|
|
19
|
+
WHERE e1.key < e2.key AND abs(e1.ts - e2.ts) <= $window
|
|
20
|
+
AND NOT (e1)-[:SAME_CHANGE]->() AND NOT (e2)-[:SAME_CHANGE]->()
|
|
21
|
+
MATCH (e1)-[:BY]->(a1:Agent), (e2)-[:BY]->(a2:Agent)
|
|
22
|
+
WHERE a1.key <> a2.key
|
|
23
|
+
RETURN f.key AS fileKey, e1.key AS k1, e2.key AS k2, e1.ts AS t1, e2.ts AS t2,
|
|
24
|
+
coalesce(e1.tsSynthetic, false) OR coalesce(e2.tsSynthetic, false) AS synthetic`;
|
|
25
|
+
|
|
26
|
+
const OVERLAP_WRITE = `
|
|
27
|
+
UNWIND $rows AS r
|
|
28
|
+
MATCH (e1:Edit {key: r.k1})
|
|
29
|
+
MATCH (e2:Edit {key: r.k2})
|
|
30
|
+
MATCH (f:File {key: r.fileKey})
|
|
31
|
+
MERGE (o:Overlap {key: r.key})
|
|
32
|
+
ON CREATE SET o.ts = r.ts, o.gapMs = r.gap, o.windowMs = $window, o.confidence = r.confidence
|
|
33
|
+
MERGE (o)-[:ON]->(f)
|
|
34
|
+
MERGE (o)-[:BETWEEN]->(e1)
|
|
35
|
+
MERGE (o)-[:BETWEEN]->(e2)
|
|
36
|
+
RETURN count(o) AS n`;
|
|
37
|
+
|
|
38
|
+
const SESSION_FILES = `
|
|
39
|
+
MATCH (s:Session)-[:MADE]->(:Edit)-[:MODIFIED]->(f:File)
|
|
40
|
+
WITH s, collect(DISTINCT f.key) AS files
|
|
41
|
+
WHERE size(files) > 1 AND size(files) <= $max
|
|
42
|
+
RETURN files`;
|
|
43
|
+
|
|
44
|
+
const SESSION_FILES_INCREMENTAL = `
|
|
45
|
+
MATCH (s:Session)-[:MADE]->(e:Edit)
|
|
46
|
+
WHERE e.ts >= $since
|
|
47
|
+
WITH DISTINCT s
|
|
48
|
+
MATCH (s)-[:MADE]->(:Edit)-[:MODIFIED]->(f:File)
|
|
49
|
+
WITH s, collect(DISTINCT f.key) AS files
|
|
50
|
+
WHERE size(files) > 1 AND size(files) <= $max
|
|
51
|
+
RETURN s.key AS sessionKey, files`;
|
|
52
|
+
|
|
53
|
+
const AGENT_OVERLAPS = `
|
|
54
|
+
MATCH (o:Overlap)-[:BETWEEN]->(e:Edit)-[:BY]->(a:Agent)
|
|
55
|
+
WITH o, collect(DISTINCT a.key) AS agents, max(o.ts) AS ts
|
|
56
|
+
WHERE size(agents) = 2
|
|
57
|
+
RETURN agents, ts`;
|
|
58
|
+
|
|
59
|
+
const CLEAR = (rel) => `MATCH ()-[r:${rel}]->() DELETE r RETURN count(r) AS n`;
|
|
60
|
+
|
|
61
|
+
const CO_EDITED_WRITE = `
|
|
62
|
+
UNWIND $rows AS r
|
|
63
|
+
MATCH (a:File {key: r.a})
|
|
64
|
+
MATCH (b:File {key: r.b})
|
|
65
|
+
MERGE (a)-[x:CO_EDITED]->(b) SET x.weight = r.w
|
|
66
|
+
MERGE (b)-[y:CO_EDITED]->(a) SET y.weight = r.w
|
|
67
|
+
RETURN count(x) AS n`;
|
|
68
|
+
|
|
69
|
+
const CO_EDITED_SESSION_INCREMENTAL = `
|
|
70
|
+
UNWIND $rows AS r
|
|
71
|
+
MATCH (a:File {key: r.a})
|
|
72
|
+
MATCH (b:File {key: r.b})
|
|
73
|
+
MERGE (a)-[x:CO_EDITED]->(b)
|
|
74
|
+
ON CREATE SET x.sessions = []
|
|
75
|
+
FOREACH (_ IN CASE WHEN r.s IN x.sessions THEN [] ELSE [1] END |
|
|
76
|
+
SET x.sessions = x.sessions + r.s)
|
|
77
|
+
SET x.weight = size(x.sessions)
|
|
78
|
+
MERGE (b)-[y:CO_EDITED]->(a)
|
|
79
|
+
ON CREATE SET y.sessions = []
|
|
80
|
+
FOREACH (_ IN CASE WHEN r.s IN y.sessions THEN [] ELSE [1] END |
|
|
81
|
+
SET y.sessions = y.sessions + r.s)
|
|
82
|
+
SET y.weight = size(y.sessions)
|
|
83
|
+
RETURN count(x) AS n`;
|
|
84
|
+
|
|
85
|
+
const PRUNE_TURN_TEXT = `
|
|
86
|
+
MATCH (t:Turn)
|
|
87
|
+
WHERE t.ts < $cutoff AND t.text IS NOT NULL
|
|
88
|
+
SET t.text = NULL, t.prunedAt = $now
|
|
89
|
+
RETURN count(t) AS n`;
|
|
90
|
+
|
|
91
|
+
const PRUNE_EDIT_DIFF = `
|
|
92
|
+
MATCH (e:Edit)
|
|
93
|
+
WHERE e.ts < $cutoff AND e.diffHead IS NOT NULL
|
|
94
|
+
SET e.diffHead = NULL, e.prunedAt = $now
|
|
95
|
+
RETURN count(e) AS n`;
|
|
96
|
+
|
|
97
|
+
const OVERLAPS_WITH_WRITE = `
|
|
98
|
+
UNWIND $rows AS r
|
|
99
|
+
MATCH (a:Agent {key: r.a})
|
|
100
|
+
MATCH (b:Agent {key: r.b})
|
|
101
|
+
MERGE (a)-[x:OVERLAPS_WITH]->(b) SET x.weight = r.w, x.lastAt = r.lastAt
|
|
102
|
+
MERGE (b)-[y:OVERLAPS_WITH]->(a) SET y.weight = r.w, y.lastAt = r.lastAt
|
|
103
|
+
RETURN count(x) AS n`;
|
|
104
|
+
|
|
105
|
+
const FILE_RANK = `CALL algo.pageRank('File', 'CO_EDITED') YIELD node, score SET node.rank = score RETURN count(node) AS n`;
|
|
106
|
+
const FILE_COMMUNITY = `CALL algo.labelPropagation({nodeLabels: ['File'], relationshipTypes: ['CO_EDITED']}) YIELD node, communityId
|
|
107
|
+
SET node.community = communityId RETURN count(node) AS n`;
|
|
108
|
+
const AGENT_RANK = `CALL algo.pageRank('Agent', 'OVERLAPS_WITH') YIELD node, score SET node.entanglement = score RETURN count(node) AS n`;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Prunes Turn.text and Edit.diffHead older than `days` while preserving all nodes,
|
|
112
|
+
* edges, timestamps, and metadata (#18).
|
|
113
|
+
*/
|
|
114
|
+
async function pruneText(client, projectId, days = 90, now = Date.now()) {
|
|
115
|
+
if (!Number.isFinite(days) || days <= 0) return { turnsPruned: 0, editsPruned: 0 };
|
|
116
|
+
const cutoff = now - days * 24 * 60 * 60 * 1000;
|
|
117
|
+
const tRes = await client.write(projectId, PRUNE_TURN_TEXT, { cutoff, now });
|
|
118
|
+
const eRes = await client.write(projectId, PRUNE_EDIT_DIFF, { cutoff, now });
|
|
119
|
+
return {
|
|
120
|
+
turnsPruned: Number(tRes[0]?.n || 0),
|
|
121
|
+
editsPruned: Number(eRes[0]?.n || 0),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function run(projectId, opts = {}) {
|
|
126
|
+
const { client } = api;
|
|
127
|
+
const t = Date.now();
|
|
128
|
+
await api.ensureProject(projectId);
|
|
129
|
+
|
|
130
|
+
const metaRows = await client.read(projectId, "MATCH (m:Meta {key:'meta'}) RETURN coalesce(m.lastAnalyticsAt, 0) AS since");
|
|
131
|
+
const since = opts.full ? 0 : Number(metaRows[0]?.since || 0);
|
|
132
|
+
|
|
133
|
+
const candidates = await client.read(projectId, OVERLAP_CANDIDATES, { window: OVERLAP_WINDOW_MS });
|
|
134
|
+
const overlaps = candidates.map((c) => ({
|
|
135
|
+
key: keys.overlapKey(c.fileKey, c.k1, c.k2),
|
|
136
|
+
k1: c.k1,
|
|
137
|
+
k2: c.k2,
|
|
138
|
+
fileKey: c.fileKey,
|
|
139
|
+
ts: Math.max(c.t1, c.t2),
|
|
140
|
+
gap: Math.abs(c.t1 - c.t2),
|
|
141
|
+
confidence: c.synthetic ? "low" : "high",
|
|
142
|
+
}));
|
|
143
|
+
if (overlaps.length) await each(client, projectId, OVERLAP_WRITE, overlaps, { window: OVERLAP_WINDOW_MS });
|
|
144
|
+
|
|
145
|
+
// Incremental CO_EDITED projection (#18): only scan sessions that received edits since
|
|
146
|
+
// Meta.lastAnalyticsAt (or all sessions on cold start / opts.full). Each CO_EDITED edge
|
|
147
|
+
// tracks the set of session keys that co-edited the pair so weight = size(sessions) is
|
|
148
|
+
// idempotent across incremental runs.
|
|
149
|
+
const touchedSessions = await client.read(projectId, SESSION_FILES_INCREMENTAL, { since, max: MAX_FILES_PER_SESSION });
|
|
150
|
+
if (since === 0) await client.write(projectId, CLEAR("CO_EDITED"));
|
|
151
|
+
const incRows = [];
|
|
152
|
+
for (const { sessionKey, files } of touchedSessions) {
|
|
153
|
+
const sorted = [...files].sort();
|
|
154
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
155
|
+
for (let j = i + 1; j < sorted.length; j++) {
|
|
156
|
+
incRows.push({ a: sorted[i], b: sorted[j], s: sessionKey });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (incRows.length) await each(client, projectId, CO_EDITED_SESSION_INCREMENTAL, incRows);
|
|
161
|
+
const coCountRows = await client.read(projectId, "MATCH ()-[r:CO_EDITED]->() RETURN count(r) / 2 AS n");
|
|
162
|
+
const coEditedCount = Number(coCountRows[0]?.n || 0);
|
|
163
|
+
|
|
164
|
+
const agentPairs = new Map();
|
|
165
|
+
for (const { agents, ts } of await client.read(projectId, AGENT_OVERLAPS)) {
|
|
166
|
+
const [a, b] = [...agents].sort();
|
|
167
|
+
const row = agentPairs.get(`${a}|${b}`) || { a, b, w: 0, lastAt: 0 };
|
|
168
|
+
row.w += 1;
|
|
169
|
+
row.lastAt = Math.max(row.lastAt, ts);
|
|
170
|
+
agentPairs.set(`${a}|${b}`, row);
|
|
171
|
+
}
|
|
172
|
+
await client.write(projectId, CLEAR("OVERLAPS_WITH"));
|
|
173
|
+
if (agentPairs.size) await each(client, projectId, OVERLAPS_WITH_WRITE, [...agentPairs.values()]);
|
|
174
|
+
|
|
175
|
+
await client.write(projectId, FILE_RANK);
|
|
176
|
+
await client.write(projectId, FILE_COMMUNITY).catch(() => undefined);
|
|
177
|
+
await client.write(projectId, AGENT_RANK).catch(() => undefined);
|
|
178
|
+
|
|
179
|
+
const retentionDays = api.state?.cfg?.textRetentionDays ?? 90;
|
|
180
|
+
const pruned = await pruneText(client, projectId, retentionDays, t).catch(() => ({ turnsPruned: 0, editsPruned: 0 }));
|
|
181
|
+
|
|
182
|
+
await client.write(projectId, "MATCH (m:Meta {key:'meta'}) SET m.lastAnalyticsAt = $now RETURN 1", { now: t });
|
|
183
|
+
|
|
184
|
+
const summary = {
|
|
185
|
+
overlaps: overlaps.length,
|
|
186
|
+
coEdited: coEditedCount,
|
|
187
|
+
incrementalSessions: touchedSessions.length,
|
|
188
|
+
agentPairs: agentPairs.size,
|
|
189
|
+
turnsPruned: pruned.turnsPruned,
|
|
190
|
+
editsPruned: pruned.editsPruned,
|
|
191
|
+
ms: Date.now() - t,
|
|
192
|
+
};
|
|
193
|
+
api.projectStats(projectId).lastAnalytics = summary;
|
|
194
|
+
console.log(`[relay-falkor] ${projectId}: analytics ${JSON.stringify(summary)}`);
|
|
195
|
+
api.state.deps.sse?.emit?.("graph", { workspaceId: projectId, at: Date.now(), analytics: summary });
|
|
196
|
+
return summary;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function schedule(projectId) {
|
|
200
|
+
clearTimeout(timers.get(projectId));
|
|
201
|
+
const wait = Math.max(DEBOUNCE_MS, MIN_INTERVAL_MS - (Date.now() - (lastRun.get(projectId) || 0)));
|
|
202
|
+
const timer = setTimeout(async () => {
|
|
203
|
+
timers.delete(projectId);
|
|
204
|
+
if (running.has(projectId) || !api.client.available()) return;
|
|
205
|
+
running.add(projectId);
|
|
206
|
+
lastRun.set(projectId, Date.now());
|
|
207
|
+
try {
|
|
208
|
+
await run(projectId);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
api.projectStats(projectId).lastError = `analytics: ${err.message || err}`;
|
|
211
|
+
} finally {
|
|
212
|
+
running.delete(projectId);
|
|
213
|
+
}
|
|
214
|
+
}, wait);
|
|
215
|
+
if (timer.unref) timer.unref();
|
|
216
|
+
timers.set(projectId, timer);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function start(falkor) {
|
|
220
|
+
api = { ...falkor, client: falkor.state.client, state: falkor.state };
|
|
221
|
+
bus.on("graph-written", ({ projectId }) => projectId && schedule(projectId));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function configure(falkor) {
|
|
225
|
+
api = { ...falkor, client: falkor.state.client, state: falkor.state };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = { start, configure, run, schedule, pruneText };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const { EventEmitter } = require("events");
|
|
2
|
+
|
|
3
|
+
// Taps in existing modules emit here; the falkor module subscribes only when enabled.
|
|
4
|
+
const bus = new EventEmitter();
|
|
5
|
+
bus.setMaxListeners(50);
|
|
6
|
+
|
|
7
|
+
function emit(type, payload) {
|
|
8
|
+
if (!bus.listenerCount(type)) return;
|
|
9
|
+
try {
|
|
10
|
+
bus.emit(type, payload);
|
|
11
|
+
} catch (err) {
|
|
12
|
+
console.warn(`[relay-falkor] ${type} listener failed: ${err.message || err}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { bus, emit };
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Decisions & tasks. MCP stdio processes append to the Central JSONL log themselves;
|
|
2
|
+
// relay serve (the only graph writer) tails those files into the graph.
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const keys = require("./keys");
|
|
6
|
+
const { storedText } = require("./redact");
|
|
7
|
+
const { each } = require("./write");
|
|
8
|
+
const { eventsPath } = require("../centralStore");
|
|
9
|
+
const { loadRegistry, loadSession, projectDir } = require("../store");
|
|
10
|
+
|
|
11
|
+
const POLL_MS = 5000;
|
|
12
|
+
const DURING_WINDOW_MS = 10 * 60 * 1000;
|
|
13
|
+
const MENTION = /(?:[\w.-]+[\\/])*[\w-]+\.[A-Za-z][A-Za-z0-9]{0,5}\b/g;
|
|
14
|
+
|
|
15
|
+
let api = null;
|
|
16
|
+
const fileState = new Map();
|
|
17
|
+
|
|
18
|
+
function cursorFile(projectId) {
|
|
19
|
+
return path.join(projectDir(projectId), "graph-central-cursor.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function loadCursor(projectId) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(cursorFile(projectId), "utf8"));
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function saveCursor(projectId, cur) {
|
|
31
|
+
const file = cursorFile(projectId);
|
|
32
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify(cur));
|
|
33
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function eventsDir() {
|
|
37
|
+
return path.dirname(eventsPath("_probe"));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A Central id is usually the relay project id; otherwise a project that names it as its centralProjectId. */
|
|
41
|
+
function relayProjectFor(centralId) {
|
|
42
|
+
const projects = loadRegistry().projects || [];
|
|
43
|
+
return projects.find((p) => p.id === centralId) || projects.find((p) => p.centralProjectId === centralId) || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mentions(text) {
|
|
47
|
+
const out = new Set();
|
|
48
|
+
const s = String(text || "");
|
|
49
|
+
let m;
|
|
50
|
+
MENTION.lastIndex = 0;
|
|
51
|
+
while ((m = MENTION.exec(s))) {
|
|
52
|
+
if (s.slice(Math.max(0, m.index - 3), m.index).includes("://")) continue;
|
|
53
|
+
if (/^\d+(\.\d+)+$/.test(m[0])) continue;
|
|
54
|
+
out.add(m[0].replace(/\\/g, "/"));
|
|
55
|
+
}
|
|
56
|
+
return [...out].slice(0, 20);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Same fallback /api/ingest-stop uses for transcripts, so decisions and sessions agree on who "local" is.
|
|
60
|
+
function loginFor(event) {
|
|
61
|
+
const user = String(event.user || "").trim();
|
|
62
|
+
if (user && user.toLowerCase() !== "local") return user;
|
|
63
|
+
try {
|
|
64
|
+
return loadSession().login || "local";
|
|
65
|
+
} catch {
|
|
66
|
+
return "local";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function labelFor(event) {
|
|
71
|
+
if (event.agent_label) return String(event.agent_label);
|
|
72
|
+
const parsed = keys.parseLockAgentId(event.agent_source);
|
|
73
|
+
return ["Claude Code", "Cursor", "Codex", "Copilot", "Antigravity"].includes(parsed.label) ? parsed.label : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toRow(event) {
|
|
77
|
+
const kind = event.kind === "decision" ? "Decision" : event.kind === "task" ? "Task" : null;
|
|
78
|
+
if (!kind) return null;
|
|
79
|
+
const idField = kind === "Decision" ? event.decision_id : event.task_id;
|
|
80
|
+
const ts = Date.parse(event.ts) || Date.now();
|
|
81
|
+
const login = loginFor(event);
|
|
82
|
+
const label = labelFor(event);
|
|
83
|
+
const explicit = (Array.isArray(event.files) ? event.files : []).map((f) => ({ fileKey: keys.fileKey(f), path: keys.displayPath(f) })).filter((f) => f.fileKey);
|
|
84
|
+
return {
|
|
85
|
+
kind,
|
|
86
|
+
key: idField ? `${kind === "Decision" ? "d" : "k"}_${String(idField).slice(0, 120)}` : `evt_${event.event_id}`,
|
|
87
|
+
eventId: Number(event.event_id) || 0,
|
|
88
|
+
content: storedText(event.content || "", 2000, api.state.cfg.storeText) ?? "",
|
|
89
|
+
status: String(event.status || "open").slice(0, 40),
|
|
90
|
+
ts,
|
|
91
|
+
login: keys.loginKey(login),
|
|
92
|
+
loginRaw: login,
|
|
93
|
+
label,
|
|
94
|
+
agentKey: label ? keys.agentKey(login, label) : null,
|
|
95
|
+
attribution: label ? (event.agent_label ? "explicit" : "inferred") : "unknown",
|
|
96
|
+
explicit,
|
|
97
|
+
mentioned: explicit.length ? [] : mentions(event.content),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const UPSERT = (label, textProp, rel) => `
|
|
102
|
+
UNWIND $rows AS r
|
|
103
|
+
MERGE (d:${label} {key: r.key})
|
|
104
|
+
ON CREATE SET d.createdAt = r.ts, d.updatedAt = r.ts, d.${textProp} = r.content, d.status = r.status,
|
|
105
|
+
d.eventIds = [], d.attribution = r.attribution
|
|
106
|
+
FOREACH (_ IN CASE WHEN r.ts >= d.updatedAt THEN [1] ELSE [] END |
|
|
107
|
+
SET d.${textProp} = r.content, d.status = r.status, d.updatedAt = r.ts)
|
|
108
|
+
SET d.eventIds = CASE WHEN r.eventId IN d.eventIds THEN d.eventIds ELSE d.eventIds + r.eventId END
|
|
109
|
+
SET d.revisions = size(d.eventIds)
|
|
110
|
+
FOREACH (_ IN CASE WHEN r.agentKey IS NULL THEN [] ELSE [1] END |
|
|
111
|
+
MERGE (p:Person {key: r.login}) ON CREATE SET p.login = r.loginRaw, p.firstSeen = r.ts
|
|
112
|
+
MERGE (a:Agent {key: r.agentKey}) ON CREATE SET a.label = r.label, a.login = r.login, a.firstSeen = r.ts
|
|
113
|
+
MERGE (p)-[:RUNS]->(a)
|
|
114
|
+
MERGE (a)-[x:${rel}]->(d)
|
|
115
|
+
SET x.ts = r.ts)
|
|
116
|
+
RETURN count(d) AS n`;
|
|
117
|
+
|
|
118
|
+
const ABOUT_EXPLICIT = (label) => `
|
|
119
|
+
UNWIND $rows AS r
|
|
120
|
+
MATCH (d:${label} {key: r.key})
|
|
121
|
+
MERGE (f:File {key: r.fileKey}) ON CREATE SET f.path = r.path, f.editCount = 0
|
|
122
|
+
MERGE (d)-[a:ABOUT]->(f)
|
|
123
|
+
SET a.how = 'explicit'
|
|
124
|
+
RETURN count(a) AS n`;
|
|
125
|
+
|
|
126
|
+
const FILE_CANDIDATES = `
|
|
127
|
+
UNWIND $tokens AS tok
|
|
128
|
+
MATCH (f:File)
|
|
129
|
+
WHERE f.key = tok OR f.key ENDS WITH ('/' + tok)
|
|
130
|
+
RETURN tok, collect(f.key) AS keys`;
|
|
131
|
+
|
|
132
|
+
const ABOUT_MENTIONED = (label) => `
|
|
133
|
+
UNWIND $rows AS r
|
|
134
|
+
MATCH (d:${label} {key: r.key})
|
|
135
|
+
MATCH (f:File {key: r.fileKey})
|
|
136
|
+
MERGE (d)-[a:ABOUT]->(f)
|
|
137
|
+
ON CREATE SET a.how = 'mentioned'
|
|
138
|
+
RETURN count(a) AS n`;
|
|
139
|
+
|
|
140
|
+
// Prefer the same person's session; any session of that agent is the fallback.
|
|
141
|
+
const SESSION_NEAR = `
|
|
142
|
+
MATCH (s:Session)
|
|
143
|
+
WHERE s.label = $label AND s.lastAt >= $from AND s.lastAt <= $to
|
|
144
|
+
RETURN s.key AS key, s.login = $login AS samePerson
|
|
145
|
+
ORDER BY samePerson DESC, s.lastAt DESC LIMIT 1`;
|
|
146
|
+
|
|
147
|
+
const DURING = (label) => `
|
|
148
|
+
UNWIND $rows AS r
|
|
149
|
+
MATCH (d:${label} {key: r.key})
|
|
150
|
+
MATCH (s:Session {key: r.sessionKey})
|
|
151
|
+
MERGE (d)-[x:DURING]->(s)
|
|
152
|
+
SET x.confidence = 'inferred'
|
|
153
|
+
RETURN count(x) AS n`;
|
|
154
|
+
|
|
155
|
+
async function writeRows(projectId, rows) {
|
|
156
|
+
const { client } = api;
|
|
157
|
+
for (const label of ["Decision", "Task"]) {
|
|
158
|
+
const group = rows.filter((r) => r.kind === label);
|
|
159
|
+
if (!group.length) continue;
|
|
160
|
+
await each(client, projectId, UPSERT(label, label === "Decision" ? "content" : "description", label === "Decision" ? "DECIDED" : "UPDATED"), group);
|
|
161
|
+
|
|
162
|
+
const explicit = group.flatMap((r) => r.explicit.map((f) => ({ key: r.key, ...f })));
|
|
163
|
+
if (explicit.length) await each(client, projectId, ABOUT_EXPLICIT(label), explicit);
|
|
164
|
+
|
|
165
|
+
const tokens = [...new Set(group.flatMap((r) => r.mentioned.map((m) => keys.fileKey(m)).filter(Boolean)))];
|
|
166
|
+
if (tokens.length) {
|
|
167
|
+
const found = await client.read(projectId, FILE_CANDIDATES, { tokens });
|
|
168
|
+
// A bare "index.ts" matching five files means nothing; only link unambiguous mentions.
|
|
169
|
+
const resolved = new Map(found.filter((row) => row.keys.length === 1).map((row) => [row.tok, row.keys[0]]));
|
|
170
|
+
const links = group.flatMap((r) =>
|
|
171
|
+
r.mentioned.map((m) => resolved.get(keys.fileKey(m))).filter(Boolean).map((fileKey) => ({ key: r.key, fileKey }))
|
|
172
|
+
);
|
|
173
|
+
if (links.length) await each(client, projectId, ABOUT_MENTIONED(label), links);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const during = [];
|
|
177
|
+
for (const r of group) {
|
|
178
|
+
if (!r.label) continue;
|
|
179
|
+
const near = await client.read(projectId, SESSION_NEAR, { label: r.label, login: r.login, from: r.ts - DURING_WINDOW_MS, to: r.ts + DURING_WINDOW_MS });
|
|
180
|
+
if (near[0]?.key) during.push({ key: r.key, sessionKey: near[0].key });
|
|
181
|
+
}
|
|
182
|
+
if (during.length) await each(client, projectId, DURING(label), during);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function readNewEvents(file, after) {
|
|
187
|
+
const out = [];
|
|
188
|
+
let text = "";
|
|
189
|
+
try {
|
|
190
|
+
text = fs.readFileSync(file, "utf8");
|
|
191
|
+
} catch {
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
for (const line of text.split("\n")) {
|
|
195
|
+
if (!line) continue;
|
|
196
|
+
try {
|
|
197
|
+
const ev = JSON.parse(line);
|
|
198
|
+
if (Number(ev.event_id) > after) out.push(ev);
|
|
199
|
+
} catch {
|
|
200
|
+
/* partial line; picked up next poll */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let scanning = false;
|
|
207
|
+
|
|
208
|
+
async function scan() {
|
|
209
|
+
if (scanning) return;
|
|
210
|
+
scanning = true;
|
|
211
|
+
try {
|
|
212
|
+
await scanOnce();
|
|
213
|
+
} finally {
|
|
214
|
+
scanning = false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function scanOnce() {
|
|
219
|
+
if (!api.client.available()) return;
|
|
220
|
+
let names = [];
|
|
221
|
+
try {
|
|
222
|
+
names = fs.readdirSync(eventsDir()).filter((n) => n.endsWith(".jsonl"));
|
|
223
|
+
} catch {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
for (const name of names) {
|
|
227
|
+
const file = path.join(eventsDir(), name);
|
|
228
|
+
let mtime = 0;
|
|
229
|
+
try {
|
|
230
|
+
mtime = fs.statSync(file).mtimeMs;
|
|
231
|
+
} catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (fileState.get(file) === mtime) continue;
|
|
235
|
+
const centralId = name.replace(/\.jsonl$/, "");
|
|
236
|
+
const project = relayProjectFor(centralId);
|
|
237
|
+
if (!project) {
|
|
238
|
+
fileState.set(file, mtime);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const cur = loadCursor(project.id);
|
|
242
|
+
const after = Number(cur[centralId] || 0);
|
|
243
|
+
const events = readNewEvents(file, after);
|
|
244
|
+
const rows = events.map(toRow).filter(Boolean);
|
|
245
|
+
try {
|
|
246
|
+
if (rows.length) {
|
|
247
|
+
await api.ensureProject(project.id);
|
|
248
|
+
await writeRows(project.id, rows);
|
|
249
|
+
console.log(`[relay-falkor] ${project.id}: +${rows.length} decision/task event(s)`);
|
|
250
|
+
api.notify?.(project.id, { decisions: rows.length });
|
|
251
|
+
}
|
|
252
|
+
if (events.length) {
|
|
253
|
+
cur[centralId] = Math.max(after, ...events.map((e) => Number(e.event_id) || 0));
|
|
254
|
+
saveCursor(project.id, cur);
|
|
255
|
+
}
|
|
256
|
+
fileState.set(file, mtime);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
api.projectStats(project.id).lastError = String(err.message || err);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function start(falkor) {
|
|
264
|
+
api = { ...falkor, client: falkor.state.client, state: falkor.state };
|
|
265
|
+
const tick = () => scan().catch(() => undefined);
|
|
266
|
+
const t = setInterval(tick, POLL_MS);
|
|
267
|
+
if (t.unref) t.unref();
|
|
268
|
+
try {
|
|
269
|
+
fs.mkdirSync(eventsDir(), { recursive: true });
|
|
270
|
+
fs.watch(eventsDir(), { persistent: false }, () => setTimeout(tick, 200));
|
|
271
|
+
} catch {
|
|
272
|
+
/* polling covers it */
|
|
273
|
+
}
|
|
274
|
+
setTimeout(tick, 1000);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function configure(falkor) {
|
|
278
|
+
api = { ...falkor, client: falkor.state.client, state: falkor.state };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
module.exports = { start, configure, scan, toRow, mentions, writeRows };
|