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.
Files changed (52) hide show
  1. package/README.md +86 -0
  2. package/dist/lib/agent-state.js +119 -0
  3. package/dist/lib/blast.js +462 -0
  4. package/dist/lib/client-config.js +81 -0
  5. package/dist/lib/env-compat.js +66 -0
  6. package/dist/lib/globs.js +0 -0
  7. package/dist/lib/ids.js +24 -0
  8. package/dist/lib/index/aliases.js +244 -0
  9. package/dist/lib/index/call-sites.js +178 -0
  10. package/dist/lib/index/checker-resolver.js +257 -0
  11. package/dist/lib/index/context-card.js +140 -0
  12. package/dist/lib/index/coverage.js +218 -0
  13. package/dist/lib/index/delivery.js +66 -0
  14. package/dist/lib/index/discovery.js +90 -0
  15. package/dist/lib/index/embedding.js +110 -0
  16. package/dist/lib/index/file-index.js +222 -0
  17. package/dist/lib/index/fingerprint.js +0 -0
  18. package/dist/lib/index/git-history.js +136 -0
  19. package/dist/lib/index/graph.js +234 -0
  20. package/dist/lib/index/impact.js +174 -0
  21. package/dist/lib/index/incremental.js +332 -0
  22. package/dist/lib/index/lexical.js +462 -0
  23. package/dist/lib/index/order.js +43 -0
  24. package/dist/lib/index/pages.js +357 -0
  25. package/dist/lib/index/persistence.js +233 -0
  26. package/dist/lib/index/pipeline.js +527 -0
  27. package/dist/lib/index/registry.js +106 -0
  28. package/dist/lib/index/resolve.js +280 -0
  29. package/dist/lib/index/semantic.js +381 -0
  30. package/dist/lib/index/surfaces.js +27 -0
  31. package/dist/lib/index/symbols.js +426 -0
  32. package/dist/lib/index/transformers-embedder.js +73 -0
  33. package/dist/lib/index/typescript-parser.js +532 -0
  34. package/dist/lib/index/vector-cache.js +176 -0
  35. package/dist/lib/index/verification.js +58 -0
  36. package/dist/lib/mcp-compaction.js +241 -0
  37. package/dist/lib/model-roles.js +206 -0
  38. package/dist/lib/path-warnings.js +90 -0
  39. package/dist/lib/protocol.js +95 -0
  40. package/dist/lib/types.js +69 -0
  41. package/dist/lib/version.js +21 -0
  42. package/dist/lib/worktree.js +211 -0
  43. package/dist/mcp/approval.js +0 -0
  44. package/dist/mcp/cloud-connector.js +99 -0
  45. package/dist/mcp/daemon-client.js +156 -0
  46. package/dist/mcp/daemon-protocol.js +100 -0
  47. package/dist/mcp/escalation-waiter.js +183 -0
  48. package/dist/mcp/graph-ops.js +169 -0
  49. package/dist/mcp/index.js +1151 -0
  50. package/dist/mcp/login.js +169 -0
  51. package/dist/mcp/setup.js +90 -0
  52. 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
+ }