syncstaff-mcp 0.2.3
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/README.md +86 -0
- package/dist/lib/agent-state.js +119 -0
- package/dist/lib/blast.js +462 -0
- package/dist/lib/client-config.js +81 -0
- package/dist/lib/env-compat.js +66 -0
- package/dist/lib/globs.js +0 -0
- package/dist/lib/ids.js +24 -0
- package/dist/lib/index/aliases.js +244 -0
- package/dist/lib/index/call-sites.js +178 -0
- package/dist/lib/index/checker-resolver.js +257 -0
- package/dist/lib/index/context-card.js +140 -0
- package/dist/lib/index/coverage.js +218 -0
- package/dist/lib/index/delivery.js +66 -0
- package/dist/lib/index/discovery.js +90 -0
- package/dist/lib/index/embedding.js +110 -0
- package/dist/lib/index/file-index.js +222 -0
- package/dist/lib/index/fingerprint.js +0 -0
- package/dist/lib/index/git-history.js +136 -0
- package/dist/lib/index/graph.js +234 -0
- package/dist/lib/index/impact.js +174 -0
- package/dist/lib/index/incremental.js +332 -0
- package/dist/lib/index/lexical.js +462 -0
- package/dist/lib/index/order.js +43 -0
- package/dist/lib/index/pages.js +357 -0
- package/dist/lib/index/persistence.js +233 -0
- package/dist/lib/index/pipeline.js +527 -0
- package/dist/lib/index/registry.js +106 -0
- package/dist/lib/index/resolve.js +280 -0
- package/dist/lib/index/semantic.js +381 -0
- package/dist/lib/index/surfaces.js +27 -0
- package/dist/lib/index/symbols.js +426 -0
- package/dist/lib/index/transformers-embedder.js +73 -0
- package/dist/lib/index/typescript-parser.js +532 -0
- package/dist/lib/index/vector-cache.js +176 -0
- package/dist/lib/index/verification.js +58 -0
- package/dist/lib/mcp-compaction.js +241 -0
- package/dist/lib/model-roles.js +206 -0
- package/dist/lib/path-warnings.js +90 -0
- package/dist/lib/protocol.js +95 -0
- package/dist/lib/types.js +69 -0
- package/dist/lib/version.js +21 -0
- package/dist/lib/worktree.js +211 -0
- package/dist/mcp/approval.js +0 -0
- package/dist/mcp/cloud-connector.js +99 -0
- package/dist/mcp/daemon-client.js +156 -0
- package/dist/mcp/daemon-protocol.js +100 -0
- package/dist/mcp/escalation-waiter.js +183 -0
- package/dist/mcp/graph-ops.js +169 -0
- package/dist/mcp/index.js +1151 -0
- package/dist/mcp/login.js +169 -0
- package/dist/mcp/setup.js +90 -0
- package/package.json +42 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin client for the optional local-graph daemon (daemon.ts).
|
|
3
|
+
*
|
|
4
|
+
* Every function here is fail-soft by construction: a daemon that is not
|
|
5
|
+
* running, not reachable, on a mismatched protocol version, or that errors
|
|
6
|
+
* mid-request never throws out of this module. The caller (index.ts) always
|
|
7
|
+
* gets either a real result or `null`, and on `null` falls back to building
|
|
8
|
+
* its own local graph exactly as it did before this module existed. Nothing
|
|
9
|
+
* here may become a new way for `sync_impact` / `sync_context_card` /
|
|
10
|
+
* `sync_retrieve` to fail that didn't already exist without a daemon.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { connect } from "node:net";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { DAEMON_PROTOCOL_VERSION, socketPathFor, tcpPortFileFor, } from "./daemon-protocol.js";
|
|
17
|
+
function envSeconds(name, fallback) {
|
|
18
|
+
const value = Number(process.env[name]);
|
|
19
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
20
|
+
}
|
|
21
|
+
/** 0 disables the daemon path entirely — every call falls back to local computation, same as before this module existed. */
|
|
22
|
+
const REQUEST_TIMEOUT_MS = envSeconds("KEEL_MCP_DAEMON_TIMEOUT_SECONDS", 20) * 1000;
|
|
23
|
+
const CONNECT_TIMEOUT_MS = 500;
|
|
24
|
+
/** Opt-in: off by default, so existing behavior is unchanged unless a caller sets this. */
|
|
25
|
+
export function daemonEnabled() {
|
|
26
|
+
const v = process.env.KEEL_MCP_DAEMON?.trim().toLowerCase();
|
|
27
|
+
return v === "1" || v === "true" || v === "on";
|
|
28
|
+
}
|
|
29
|
+
function connectTo(target) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const socket = "path" in target ? connect(target.path) : connect(target.port, "127.0.0.1");
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
socket.destroy();
|
|
34
|
+
reject(new Error("connect timeout"));
|
|
35
|
+
}, CONNECT_TIMEOUT_MS);
|
|
36
|
+
socket.once("connect", () => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
resolve(socket);
|
|
39
|
+
});
|
|
40
|
+
socket.once("error", (err) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
reject(err);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async function connectToDaemon(root) {
|
|
47
|
+
const socketPath = socketPathFor(root);
|
|
48
|
+
if (existsSync(socketPath)) {
|
|
49
|
+
try {
|
|
50
|
+
return await connectTo({ path: socketPath });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* stale socket file, or a daemon mid-shutdown; fall through to the TCP fallback check below */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const portFile = tcpPortFileFor(root);
|
|
57
|
+
if (existsSync(portFile)) {
|
|
58
|
+
const port = Number(readFileSync(portFile, "utf8").trim());
|
|
59
|
+
if (Number.isInteger(port) && port > 0) {
|
|
60
|
+
try {
|
|
61
|
+
return await connectTo({ port });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* stale port file */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
function sendRequest(socket, req) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
let buffer = "";
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
socket.destroy();
|
|
75
|
+
reject(new Error("daemon request timed out"));
|
|
76
|
+
}, REQUEST_TIMEOUT_MS);
|
|
77
|
+
socket.setEncoding("utf8");
|
|
78
|
+
socket.on("data", (chunk) => {
|
|
79
|
+
buffer += chunk;
|
|
80
|
+
const newline = buffer.indexOf("\n");
|
|
81
|
+
if (newline === -1)
|
|
82
|
+
return;
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
const line = buffer.slice(0, newline);
|
|
85
|
+
socket.end();
|
|
86
|
+
try {
|
|
87
|
+
resolve(JSON.parse(line));
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
reject(e);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
socket.once("error", (err) => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
reject(err);
|
|
96
|
+
});
|
|
97
|
+
socket.write(`${JSON.stringify(req)}\n`);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Try answering `op` from the daemon; `null` means "no usable daemon right
|
|
102
|
+
* now", never a thrown error — see the module comment for why that is load
|
|
103
|
+
* bearing rather than incidental.
|
|
104
|
+
*/
|
|
105
|
+
export async function tryDaemon(root, op, args) {
|
|
106
|
+
if (!daemonEnabled())
|
|
107
|
+
return null;
|
|
108
|
+
let socket = null;
|
|
109
|
+
try {
|
|
110
|
+
socket = await connectToDaemon(root);
|
|
111
|
+
if (!socket)
|
|
112
|
+
return null;
|
|
113
|
+
const res = await sendRequest(socket, { v: DAEMON_PROTOCOL_VERSION, op, root, args });
|
|
114
|
+
if (!res.ok)
|
|
115
|
+
return null; // version mismatch, bad request, or a build failure — fall back silently
|
|
116
|
+
return res.result;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
socket?.destroy();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
let spawnAttempted = false;
|
|
126
|
+
/**
|
|
127
|
+
* Fire-and-forget: start the daemon in the background for NEXT call. Never
|
|
128
|
+
* awaited by a tool call — the point is that today's call still completes
|
|
129
|
+
* at today's speed via the local fallback, and only a later call (this
|
|
130
|
+
* agent's or another's) benefits once the daemon is warm.
|
|
131
|
+
*/
|
|
132
|
+
export function ensureDaemonSpawned(root) {
|
|
133
|
+
// Once per process, not once per root: a second attempt for a root whose
|
|
134
|
+
// first daemon failed to start would just fail the same way again, and
|
|
135
|
+
// daemon.ts's own EADDRINUSE handling already makes a redundant spawn for
|
|
136
|
+
// a root that DOES have one running cheap and safe (see "a second daemon
|
|
137
|
+
// spawned for the same root exits quietly" in mcp-daemon.test.ts) — so
|
|
138
|
+
// there is no correctness reason to gate on `existsSync(pidFileFor(root))`
|
|
139
|
+
// first, and a real cost to doing so: a stale pidfile left by a daemon
|
|
140
|
+
// that crashed before cleaning up would make that check true forever,
|
|
141
|
+
// permanently skipping every future spawn attempt for this root.
|
|
142
|
+
if (!daemonEnabled() || spawnAttempted)
|
|
143
|
+
return;
|
|
144
|
+
spawnAttempted = true;
|
|
145
|
+
try {
|
|
146
|
+
const daemonScript = fileURLToPath(new URL("./daemon.js", import.meta.url));
|
|
147
|
+
const child = spawn(process.execPath, [daemonScript, root], {
|
|
148
|
+
detached: true,
|
|
149
|
+
stdio: "ignore",
|
|
150
|
+
});
|
|
151
|
+
child.unref();
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
/* best-effort — the in-process fallback covers this call and every call after it if spawning never works here */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire contract between the MCP adapter and its optional local graph daemon.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately tiny and framing-free beyond newline-delimited JSON: one
|
|
5
|
+
* request per line in, one response per line out, over a long-lived
|
|
6
|
+
* connection. Anything richer (length-prefixing, multiplexed request ids)
|
|
7
|
+
* is complexity this doesn't need yet — today's local graph tools are one
|
|
8
|
+
* request awaiting one response, never concurrent on one connection.
|
|
9
|
+
*
|
|
10
|
+
* Both `daemon.ts` (the server) and `daemon-client.ts` (the caller) import
|
|
11
|
+
* this rather than each defining their own copy, because two independently
|
|
12
|
+
* maintained copies of a socket path formula are how a client silently stops
|
|
13
|
+
* finding a daemon that is, in fact, running.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { realpathSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
/**
|
|
20
|
+
* NOT `os.tmpdir()` on POSIX, deliberately.
|
|
21
|
+
*
|
|
22
|
+
* `os.tmpdir()` reads `TMPDIR`, and the MCP SDK's `getDefaultEnvironment()`
|
|
23
|
+
* — what a host CLI hands a spawned stdio adapter when it does not pass its
|
|
24
|
+
* own full environment — inherits `HOME`/`LOGNAME`/`PATH`/`SHELL`/`TERM`/
|
|
25
|
+
* `USER` and nothing else; `TMPDIR` is not on that list. Two adapter
|
|
26
|
+
* processes for the same repo, launched by two different host CLIs (or the
|
|
27
|
+
* same CLI with and without a custom env), can then disagree on where
|
|
28
|
+
* `os.tmpdir()` even points — one at `/var/folders/.../T` from an inherited
|
|
29
|
+
* `TMPDIR`, the other falling back to `/tmp` because it never saw one. Two
|
|
30
|
+
* clients that cannot agree on a path can never find the same daemon, which
|
|
31
|
+
* defeats the one thing this module exists for. Found the hard way: a daemon
|
|
32
|
+
* spawned from inside an adapter process bound and ran correctly, just at a
|
|
33
|
+
* path the test (and every other real client) never thought to look.
|
|
34
|
+
*
|
|
35
|
+
* `/tmp` itself needs no environment variable on POSIX and is standard
|
|
36
|
+
* across macOS and Linux. Windows keeps `os.tmpdir()`, because `TEMP` (and
|
|
37
|
+
* `TMP`) ARE in `getDefaultEnvironment()`'s Windows list — this is a POSIX-
|
|
38
|
+
* specific gap, not a general one.
|
|
39
|
+
*/
|
|
40
|
+
function baseDir() {
|
|
41
|
+
return process.platform === "win32" ? tmpdir() : "/tmp";
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Bumped whenever the request/response shape changes in a way an older peer
|
|
45
|
+
* cannot safely parse. A version mismatch is handled by the CLIENT refusing
|
|
46
|
+
* to use the daemon for that call (falling back to local computation) rather
|
|
47
|
+
* than the daemon attempting a live self-restart — see daemon.ts's module
|
|
48
|
+
* comment for why an in-place upgrade is out of scope for now.
|
|
49
|
+
*/
|
|
50
|
+
export const DAEMON_PROTOCOL_VERSION = 1;
|
|
51
|
+
/**
|
|
52
|
+
* A stable, filesystem-safe name for this repo root's daemon socket (or, on
|
|
53
|
+
* the TCP fallback, the file that records which port it bound).
|
|
54
|
+
*
|
|
55
|
+
* Hashed rather than a sanitized path: two repos differing only in
|
|
56
|
+
* characters `net.createServer().listen(path)` treats specially, or a path
|
|
57
|
+
* long enough to exceed a Unix socket's ~104-byte limit, would otherwise
|
|
58
|
+
* collide or fail to bind for reasons that have nothing to do with two
|
|
59
|
+
* repos actually being the same one.
|
|
60
|
+
*
|
|
61
|
+
* Resolved through `realpathSync` before hashing, because "the same root"
|
|
62
|
+
* is not always spelled the same way by every caller. `repoRoot()`
|
|
63
|
+
* (agent-state.ts) shells out to `git rev-parse --show-toplevel`, which
|
|
64
|
+
* resolves symlinks — on macOS that turns `/var/folders/...` (what
|
|
65
|
+
* `os.tmpdir()` returns) into `/private/var/folders/...`. Two processes
|
|
66
|
+
* that agree on the actual directory but disagree on which of those two
|
|
67
|
+
* spellings names it would hash to two different sockets and never find
|
|
68
|
+
* each other — found via exactly that mismatch between a test's own
|
|
69
|
+
* `tmpdir()`-built fixture path and the real adapter's `repoRoot()`. A
|
|
70
|
+
* root that does not exist yet (or a transient FS error) falls back to
|
|
71
|
+
* hashing what was given rather than failing the caller over it.
|
|
72
|
+
*/
|
|
73
|
+
export function daemonInstanceId(root) {
|
|
74
|
+
let canonical = root;
|
|
75
|
+
try {
|
|
76
|
+
canonical = realpathSync(root);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* root not on disk from this vantage point yet, or unreadable — hash the literal string rather than throw */
|
|
80
|
+
}
|
|
81
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Unix domain socket path for this root. Not `/tmp/sync.sock` (a name
|
|
85
|
+
* shared across every repo and every user on the box) — one fixed path
|
|
86
|
+
* would make a second project's daemon either refuse to start or, worse,
|
|
87
|
+
* silently answer questions about the wrong checkout the moment the first
|
|
88
|
+
* daemon's socket happened to still exist.
|
|
89
|
+
*/
|
|
90
|
+
export function socketPathFor(root) {
|
|
91
|
+
return join(baseDir(), `keel-daemon-${daemonInstanceId(root)}.sock`);
|
|
92
|
+
}
|
|
93
|
+
/** Where the TCP fallback (used when the socket path can't be bound — see daemon.ts) records its port. */
|
|
94
|
+
export function tcpPortFileFor(root) {
|
|
95
|
+
return join(baseDir(), `keel-daemon-${daemonInstanceId(root)}.port`);
|
|
96
|
+
}
|
|
97
|
+
/** The pidfile a running daemon maintains so a stale one can be told apart from a live one before connecting. */
|
|
98
|
+
export function pidFileFor(root) {
|
|
99
|
+
return join(baseDir(), `keel-daemon-${daemonInstanceId(root)}.pid`);
|
|
100
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep an MCP tool call alive until a durable human escalation is resolved.
|
|
3
|
+
*
|
|
4
|
+
* SSE is the fast path. The coordinator's event stream is replayable, but a
|
|
5
|
+
* client still needs to reconnect when a proxy or server restarts. A small
|
|
6
|
+
* polling loop runs alongside it as a safety net: an approval must never be
|
|
7
|
+
* lost merely because the stream was unavailable at the instant it arrived.
|
|
8
|
+
*/
|
|
9
|
+
/** Parse complete SSE frames. The caller may retain an incomplete final frame. */
|
|
10
|
+
export function parseSseFrames(input) {
|
|
11
|
+
const frames = [];
|
|
12
|
+
for (const block of input.replace(/\r\n/g, "\n").split("\n\n")) {
|
|
13
|
+
if (!block.trim() || block.trimStart().startsWith(":"))
|
|
14
|
+
continue;
|
|
15
|
+
let id;
|
|
16
|
+
let event;
|
|
17
|
+
const data = [];
|
|
18
|
+
for (const line of block.split("\n")) {
|
|
19
|
+
if (!line || line.startsWith(":"))
|
|
20
|
+
continue;
|
|
21
|
+
const separator = line.indexOf(":");
|
|
22
|
+
const field = separator < 0 ? line : line.slice(0, separator);
|
|
23
|
+
const value = separator < 0 ? "" : line.slice(separator + 1).replace(/^ /, "");
|
|
24
|
+
if (field === "id")
|
|
25
|
+
id = value;
|
|
26
|
+
else if (field === "event")
|
|
27
|
+
event = value;
|
|
28
|
+
else if (field === "data")
|
|
29
|
+
data.push(value);
|
|
30
|
+
}
|
|
31
|
+
if (data.length > 0 || event || id)
|
|
32
|
+
frames.push({ id, event, data: data.join("\n") });
|
|
33
|
+
}
|
|
34
|
+
return frames;
|
|
35
|
+
}
|
|
36
|
+
function resolved(record) {
|
|
37
|
+
return Boolean(record?.resolved_at && record.resolution);
|
|
38
|
+
}
|
|
39
|
+
function result(escalationId, record, status) {
|
|
40
|
+
const resolution = record?.resolution ?? null;
|
|
41
|
+
const applied = resolution?.applied ?? null;
|
|
42
|
+
return {
|
|
43
|
+
status,
|
|
44
|
+
escalation_id: escalationId,
|
|
45
|
+
record,
|
|
46
|
+
resolution,
|
|
47
|
+
chosen_option: resolution?.chosen_option ?? null,
|
|
48
|
+
effect: applied?.effect ?? null,
|
|
49
|
+
changes: applied?.changes ?? [],
|
|
50
|
+
resolved_by: record?.resolved_by ?? null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function sleep(ms, signal) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
if (signal.aborted)
|
|
56
|
+
return resolve();
|
|
57
|
+
const timer = setTimeout(resolve, ms);
|
|
58
|
+
signal.addEventListener("abort", () => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
resolve();
|
|
61
|
+
}, { once: true });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async function listenToSse(options, signal) {
|
|
65
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
66
|
+
const url = `${options.server.replace(/\/+$/, "")}/events?project_id=${encodeURIComponent(options.projectId)}`;
|
|
67
|
+
const response = await fetchImpl(url, { headers: options.headers, signal });
|
|
68
|
+
if (!response.ok || !response.body)
|
|
69
|
+
throw new Error(`SSE connection failed: ${response.status}`);
|
|
70
|
+
const reader = response.body.getReader();
|
|
71
|
+
const decoder = new TextDecoder();
|
|
72
|
+
let pending = "";
|
|
73
|
+
try {
|
|
74
|
+
while (!signal.aborted) {
|
|
75
|
+
const read = await reader.read();
|
|
76
|
+
if (read.done)
|
|
77
|
+
break;
|
|
78
|
+
pending += decoder.decode(read.value, { stream: true });
|
|
79
|
+
const boundary = pending.lastIndexOf("\n\n");
|
|
80
|
+
if (boundary < 0)
|
|
81
|
+
continue;
|
|
82
|
+
const complete = pending.slice(0, boundary + 2);
|
|
83
|
+
pending = pending.slice(boundary + 2);
|
|
84
|
+
for (const frame of parseSseFrames(complete)) {
|
|
85
|
+
if (frame.event === "escalation.resolved") {
|
|
86
|
+
try {
|
|
87
|
+
const payload = JSON.parse(frame.data);
|
|
88
|
+
if (payload.escalation_id === options.escalationId)
|
|
89
|
+
return frame;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// A malformed unrelated event must not kill the wait loop.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
reader.releaseLock();
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
async function streamLoop(options, signal, deadline) {
|
|
104
|
+
while (!signal.aborted && Date.now() < deadline) {
|
|
105
|
+
const attempt = new AbortController();
|
|
106
|
+
const relay = () => attempt.abort();
|
|
107
|
+
signal.addEventListener("abort", relay, { once: true });
|
|
108
|
+
const timer = setTimeout(() => attempt.abort(), Math.min(options.streamAttemptMs ?? 15_000, Math.max(1, deadline - Date.now())));
|
|
109
|
+
try {
|
|
110
|
+
const frame = await (options.connectSse
|
|
111
|
+
? options.connectSse(attempt.signal)
|
|
112
|
+
: listenToSse(options, attempt.signal));
|
|
113
|
+
if (frame) {
|
|
114
|
+
const payload = JSON.parse(frame.data);
|
|
115
|
+
// The event is a wake-up, not the durable decision record. Some
|
|
116
|
+
// resolvers intentionally publish only id/resolver/effect/reason; if
|
|
117
|
+
// we synthesize a record from that abbreviated event, callers see a
|
|
118
|
+
// resolved escalation with a null chosen option and no applied
|
|
119
|
+
// changes even though the database contains both. Re-read after the
|
|
120
|
+
// event so the fast SSE path and the polling path return one shape.
|
|
121
|
+
const durable = (await options.fetchEscalations()).find((row) => row.id === options.escalationId);
|
|
122
|
+
if (resolved(durable))
|
|
123
|
+
return durable;
|
|
124
|
+
// Retain compatibility with complete event producers. An abbreviated
|
|
125
|
+
// event falls through and reconnects/polls until the durable row is
|
|
126
|
+
// visible instead of claiming an incomplete resolution.
|
|
127
|
+
if (payload.chosen_option == null || payload.applied == null)
|
|
128
|
+
continue;
|
|
129
|
+
return {
|
|
130
|
+
id: payload.escalation_id,
|
|
131
|
+
resolved_at: new Date().toISOString(),
|
|
132
|
+
resolved_by: payload.resolved_by ?? null,
|
|
133
|
+
resolution: {
|
|
134
|
+
chosen_option: payload.chosen_option ?? null,
|
|
135
|
+
applied: payload.applied ?? null,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Reconnect below. Polling remains active throughout this loop.
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
signal.removeEventListener("abort", relay);
|
|
146
|
+
}
|
|
147
|
+
await sleep(Math.min(250, Math.max(1, deadline - Date.now())), signal);
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
async function pollLoop(options, signal, deadline) {
|
|
152
|
+
const interval = Math.max(100, options.pollIntervalMs ?? 5_000);
|
|
153
|
+
while (!signal.aborted && Date.now() < deadline) {
|
|
154
|
+
await sleep(Math.min(interval, Math.max(1, deadline - Date.now())), signal);
|
|
155
|
+
if (signal.aborted || Date.now() >= deadline)
|
|
156
|
+
break;
|
|
157
|
+
try {
|
|
158
|
+
const record = (await options.fetchEscalations()).find((row) => row.id === options.escalationId);
|
|
159
|
+
if (resolved(record))
|
|
160
|
+
return record ?? null;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// A transient coordinator error is why the deadline is bounded.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
export async function waitForEscalation(options) {
|
|
169
|
+
const initial = (await options.fetchEscalations()).find((row) => row.id === options.escalationId);
|
|
170
|
+
if (resolved(initial))
|
|
171
|
+
return result(options.escalationId, initial, "resolved");
|
|
172
|
+
const deadline = Date.now() + Math.max(0, options.timeoutMs);
|
|
173
|
+
if (options.timeoutMs <= 0)
|
|
174
|
+
return result(options.escalationId, initial ?? null, "timeout");
|
|
175
|
+
const controller = new AbortController();
|
|
176
|
+
const winner = await Promise.race([
|
|
177
|
+
streamLoop(options, controller.signal, deadline),
|
|
178
|
+
pollLoop(options, controller.signal, deadline),
|
|
179
|
+
]);
|
|
180
|
+
controller.abort();
|
|
181
|
+
const record = winner;
|
|
182
|
+
return record ? result(options.escalationId, record, "resolved") : result(options.escalationId, null, "timeout");
|
|
183
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local graph tools' shared implementation: build the index once, then answer
|
|
3
|
+
* `sync_impact` / `sync_context_card` / `sync_retrieve` from it.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `index.ts` so the exact same code runs two ways: in-process
|
|
6
|
+
* (today's behavior, one build per adapter process) and inside the optional
|
|
7
|
+
* daemon (`daemon.ts`, one build per repo root shared across every connected
|
|
8
|
+
* adapter). Neither path may diverge from the other — a daemon that answered
|
|
9
|
+
* `sync_impact` differently than the in-process fallback would be a silent
|
|
10
|
+
* correctness bug that only shows up when the daemon happens to be running.
|
|
11
|
+
* Keeping one implementation is what rules that out by construction.
|
|
12
|
+
*
|
|
13
|
+
* `buildLocalGraph` remains genuinely expensive (a full parse of the repo);
|
|
14
|
+
* the `run*` functions below are the cheap, per-call queries against its
|
|
15
|
+
* result and carry no I/O of their own beyond the optional skeleton read.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, mkdirSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { retrievalModeFromEnv } from "../lib/index/pipeline.js";
|
|
20
|
+
function envFlag(name, fallback) {
|
|
21
|
+
const value = process.env[name]?.trim().toLowerCase();
|
|
22
|
+
if (value === undefined)
|
|
23
|
+
return fallback;
|
|
24
|
+
return !["0", "false", "off", "no"].includes(value);
|
|
25
|
+
}
|
|
26
|
+
/** One index per (root), rebuilding per call would be absurd. Caller owns caching. */
|
|
27
|
+
export async function buildLocalGraph(root) {
|
|
28
|
+
const [registryMod, parserMod, indexMod, graphMod, symbolsMod, incrementalMod, persistenceMod, vectorCacheMod, gitHistoryMod] = await Promise.all([
|
|
29
|
+
import("../lib/index/registry.js"),
|
|
30
|
+
import("../lib/index/typescript-parser.js"),
|
|
31
|
+
import("../lib/index/file-index.js"),
|
|
32
|
+
import("../lib/index/graph.js"),
|
|
33
|
+
import("../lib/index/symbols.js"),
|
|
34
|
+
import("../lib/index/incremental.js"),
|
|
35
|
+
import("../lib/index/persistence.js"),
|
|
36
|
+
import("../lib/index/vector-cache.js"),
|
|
37
|
+
import("../lib/index/git-history.js"),
|
|
38
|
+
]);
|
|
39
|
+
const parser = await parserMod.loadTypeScriptParser();
|
|
40
|
+
if (!parser) {
|
|
41
|
+
throw new Error("local graph unavailable: no parser backend is installed. Add typescript to this " +
|
|
42
|
+
"checkout, or use blast_radius_preview, which falls back to textual matching.");
|
|
43
|
+
}
|
|
44
|
+
const registry = new registryMod.LanguageRegistry().register(parser);
|
|
45
|
+
let archive = null;
|
|
46
|
+
let vectorCache = null;
|
|
47
|
+
let keepArchiveOpen = false;
|
|
48
|
+
let indexed;
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(join(root, ".keel"), { recursive: true });
|
|
51
|
+
archive = new persistenceMod.IndexSnapshotArchive(join(root, ".keel", "index.sqlite"));
|
|
52
|
+
vectorCache = new vectorCacheMod.VectorCache(archive);
|
|
53
|
+
indexed = incrementalMod.loadOrUpdateFileIndex(root, registry, {
|
|
54
|
+
archive,
|
|
55
|
+
exclude: ["node_modules", "dist", "build", ".next", "coverage", "out"],
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
indexed = {
|
|
60
|
+
index: indexMod.buildFileIndex(root, registry, {
|
|
61
|
+
exclude: ["node_modules", "dist", "build", ".next", "coverage", "out"],
|
|
62
|
+
}),
|
|
63
|
+
freshness: {
|
|
64
|
+
indexed_commit: null,
|
|
65
|
+
index_age: null,
|
|
66
|
+
staleness_warning: "Persistent local index unavailable; used a fresh in-memory build.",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const { index, freshness } = indexed;
|
|
72
|
+
const graph = graphMod.buildDependencyGraph(index);
|
|
73
|
+
const calls = symbolsMod.resolveCallEdges(index, symbolsMod.buildSymbolTable(index));
|
|
74
|
+
const coverageMod = await import("../lib/index/coverage.js");
|
|
75
|
+
const coverage = coverageMod.buildCoverageFacts(index, graph, calls);
|
|
76
|
+
try {
|
|
77
|
+
archive?.putCoverage(index.fingerprint, coverage);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* cache unavailable; the facts are still correct in memory */
|
|
81
|
+
}
|
|
82
|
+
const retrievalMode = retrievalModeFromEnv();
|
|
83
|
+
let coChangeIndex;
|
|
84
|
+
if (retrievalMode !== "stable") {
|
|
85
|
+
try {
|
|
86
|
+
coChangeIndex = gitHistoryMod.buildCoChangeIndex(root, new Set(index.files.map((file) => file.path)), { before: "HEAD" });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
coChangeIndex = undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
keepArchiveOpen = vectorCache !== null;
|
|
93
|
+
return { root, index, graph, calls, coverage, freshness, vectorCache, coChangeIndex, retrievalMode };
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
if (!keepArchiveOpen) {
|
|
97
|
+
vectorCache?.dispose();
|
|
98
|
+
archive?.shutdown();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function runImpact(local, args) {
|
|
103
|
+
const { graph, calls, index, coverage, freshness } = local;
|
|
104
|
+
const impactMod = await import("../lib/index/impact.js");
|
|
105
|
+
const impact = impactMod.computeImpact(graph, calls, args.paths, {
|
|
106
|
+
maxDistance: args.max_distance,
|
|
107
|
+
coverage,
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
declared: args.paths,
|
|
111
|
+
affected: impact.added,
|
|
112
|
+
reasons: impactMod.explainImpact(impact),
|
|
113
|
+
complete: impact.complete,
|
|
114
|
+
caveats: impact.caveats,
|
|
115
|
+
coverage: index.coverage,
|
|
116
|
+
blind_spots: impact.coverage,
|
|
117
|
+
...freshness,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export async function runContextCard(local, args) {
|
|
121
|
+
const { index, graph, calls, freshness, root } = local;
|
|
122
|
+
const cardMod = await import("../lib/index/context-card.js");
|
|
123
|
+
const card = cardMod.buildContextCard(index, graph, calls, args.path, {
|
|
124
|
+
maxCallers: args.max_callers,
|
|
125
|
+
...(args.include_skeleton
|
|
126
|
+
? { readFile: (p) => readFileSync(join(root, p), "utf8") }
|
|
127
|
+
: {}),
|
|
128
|
+
});
|
|
129
|
+
if (!card) {
|
|
130
|
+
return {
|
|
131
|
+
error: `${args.path} is not in the local index`,
|
|
132
|
+
hint: "Check the path is repo-root-relative and that the file is tracked by git.",
|
|
133
|
+
...freshness,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const { localOnly: _localOnly, ...transportable } = card;
|
|
137
|
+
return { ...transportable, ...freshness };
|
|
138
|
+
}
|
|
139
|
+
export async function runRetrieve(local, args) {
|
|
140
|
+
const { root, index, freshness, vectorCache, coChangeIndex, retrievalMode } = local;
|
|
141
|
+
const { retrieve } = await import("../lib/index/pipeline.js");
|
|
142
|
+
const { generatePages } = await import("../lib/index/pages.js");
|
|
143
|
+
const { vocabularyWeightFromEnv } = await import("../lib/index/lexical.js");
|
|
144
|
+
const query = [args.question, args.refinement].filter(Boolean).join("\n\n");
|
|
145
|
+
const answer = await retrieve(generatePages(index), query, {
|
|
146
|
+
limit: args.limit,
|
|
147
|
+
offset: args.offset,
|
|
148
|
+
excludePaths: new Set(args.exclude_paths ?? []),
|
|
149
|
+
symbolIndex: index,
|
|
150
|
+
leasedPaths: new Set(args.leasedPaths ?? []),
|
|
151
|
+
vectorCache: vectorCache ?? undefined,
|
|
152
|
+
retrievalMode,
|
|
153
|
+
lexicalVocabularyWeight: vocabularyWeightFromEnv(),
|
|
154
|
+
queryRouting: retrievalMode !== "stable" && envFlag("KEEL_RETRIEVAL_ROUTING", false),
|
|
155
|
+
coChangeIndex,
|
|
156
|
+
...(retrievalMode !== "stable" && envFlag("KEEL_RETRIEVAL_VERIFY", false) ? { verify: { root } } : {}),
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
...answer,
|
|
160
|
+
retrieval_mode: retrievalMode,
|
|
161
|
+
...freshness,
|
|
162
|
+
results: answer.results.map(({ page, ...result }) => ({
|
|
163
|
+
path: page.target_path,
|
|
164
|
+
page_type: page.page_type,
|
|
165
|
+
quote: page.content,
|
|
166
|
+
...result,
|
|
167
|
+
})),
|
|
168
|
+
};
|
|
169
|
+
}
|