kritya 0.8.2-beta

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +434 -0
  3. package/dist/agent/budget.js +25 -0
  4. package/dist/agent/compactor.js +78 -0
  5. package/dist/agent/contextWarning.js +6 -0
  6. package/dist/agent/killSwitch.js +111 -0
  7. package/dist/agent/loop.js +543 -0
  8. package/dist/agent/memory.js +145 -0
  9. package/dist/agent/plugins.js +408 -0
  10. package/dist/agent/skills.js +210 -0
  11. package/dist/agent/skillsCli.js +51 -0
  12. package/dist/agent/systemPrompt.js +122 -0
  13. package/dist/agent/tokens.js +55 -0
  14. package/dist/agent/toolExecutor.js +331 -0
  15. package/dist/agent/workflow.js +496 -0
  16. package/dist/agent/worktree.js +116 -0
  17. package/dist/atomicWrite.js +109 -0
  18. package/dist/audit/audit.js +230 -0
  19. package/dist/audit/cli.js +135 -0
  20. package/dist/commands/custom.js +69 -0
  21. package/dist/commands/mcpCommand.js +433 -0
  22. package/dist/commands/pluginsCommand.js +204 -0
  23. package/dist/commands/registry.js +739 -0
  24. package/dist/config/config.js +214 -0
  25. package/dist/config/debug.js +19 -0
  26. package/dist/config/models.js +82 -0
  27. package/dist/config/retention.js +24 -0
  28. package/dist/config/winAcl.js +38 -0
  29. package/dist/crash.js +102 -0
  30. package/dist/engine.js +113 -0
  31. package/dist/git/git.js +46 -0
  32. package/dist/headless.js +274 -0
  33. package/dist/hooks/hooks.js +122 -0
  34. package/dist/index.js +670 -0
  35. package/dist/lsp/client.js +413 -0
  36. package/dist/lsp/manager.js +0 -0
  37. package/dist/lsp/registry.js +62 -0
  38. package/dist/mcp/callback.js +141 -0
  39. package/dist/mcp/client.js +945 -0
  40. package/dist/mcp/login.js +117 -0
  41. package/dist/mcp/oauth.js +345 -0
  42. package/dist/mcp/servers.js +114 -0
  43. package/dist/mcp/spawnWin.js +86 -0
  44. package/dist/mcp/tokens.js +97 -0
  45. package/dist/mcp/transport.js +295 -0
  46. package/dist/net/urlSafety.js +158 -0
  47. package/dist/permissions/danger.js +96 -0
  48. package/dist/permissions/permissions.js +62 -0
  49. package/dist/permissions/rules.js +69 -0
  50. package/dist/plugins/discover.js +108 -0
  51. package/dist/plugins/mcp.js +80 -0
  52. package/dist/provider/client.js +460 -0
  53. package/dist/provider/switchyardClient.js +46 -0
  54. package/dist/provider/switchyardSidecar.js +245 -0
  55. package/dist/provider/textToolCalls.js +140 -0
  56. package/dist/repomap/repoMap.js +168 -0
  57. package/dist/repomap/symbols.js +190 -0
  58. package/dist/session/store.js +328 -0
  59. package/dist/shell/background.js +131 -0
  60. package/dist/shell/sandbox.js +396 -0
  61. package/dist/telemetry/metrics.js +172 -0
  62. package/dist/telemetry/otlp.js +128 -0
  63. package/dist/telemetry/tracer.js +240 -0
  64. package/dist/tools/askUser.js +64 -0
  65. package/dist/tools/bg.js +59 -0
  66. package/dist/tools/common.js +158 -0
  67. package/dist/tools/deepResearch.js +126 -0
  68. package/dist/tools/diff.js +70 -0
  69. package/dist/tools/document/docx.js +44 -0
  70. package/dist/tools/document/pdf.js +190 -0
  71. package/dist/tools/document/pptx.js +148 -0
  72. package/dist/tools/document/types.js +26 -0
  73. package/dist/tools/document/xlsx.js +87 -0
  74. package/dist/tools/document.js +389 -0
  75. package/dist/tools/edit.js +56 -0
  76. package/dist/tools/fetchUrl.js +218 -0
  77. package/dist/tools/fuzzyMatch.js +74 -0
  78. package/dist/tools/glob.js +34 -0
  79. package/dist/tools/grep.js +85 -0
  80. package/dist/tools/ignore.js +28 -0
  81. package/dist/tools/index.js +66 -0
  82. package/dist/tools/ls.js +27 -0
  83. package/dist/tools/lsp.js +239 -0
  84. package/dist/tools/notebook.js +215 -0
  85. package/dist/tools/read.js +32 -0
  86. package/dist/tools/repoMap.js +26 -0
  87. package/dist/tools/secretScan.js +143 -0
  88. package/dist/tools/shell.js +134 -0
  89. package/dist/tools/skills.js +69 -0
  90. package/dist/tools/subagent.js +57 -0
  91. package/dist/tools/tasks.js +43 -0
  92. package/dist/tools/webSearch.js +97 -0
  93. package/dist/tools/write.js +47 -0
  94. package/dist/tools/writeAgent.js +72 -0
  95. package/dist/trust/aiDisclosure.js +45 -0
  96. package/dist/trust/mcpTrust.js +111 -0
  97. package/dist/trust/trust.js +231 -0
  98. package/dist/types.js +1 -0
  99. package/dist/ui/AiDisclosurePrompt.js +23 -0
  100. package/dist/ui/App.js +449 -0
  101. package/dist/ui/Banner.js +71 -0
  102. package/dist/ui/ElicitationPrompt.js +46 -0
  103. package/dist/ui/Markdown.js +148 -0
  104. package/dist/ui/McpTrustPrompt.js +42 -0
  105. package/dist/ui/ModelPicker.js +19 -0
  106. package/dist/ui/PermissionPrompt.js +27 -0
  107. package/dist/ui/SelectList.js +24 -0
  108. package/dist/ui/Spinner.js +12 -0
  109. package/dist/ui/StatusLine.js +11 -0
  110. package/dist/ui/TranscriptItem.js +13 -0
  111. package/dist/ui/TrustPrompt.js +12 -0
  112. package/dist/ui/highlight.js +137 -0
  113. package/dist/ui/inline.js +241 -0
  114. package/dist/ui/mermaid.js +98 -0
  115. package/dist/ui/table.js +148 -0
  116. package/dist/ui/toolOutputPreview.js +34 -0
  117. package/dist/ui/useAgent.js +534 -0
  118. package/dist/ui/useKillSwitch.js +65 -0
  119. package/dist/ui/useSessionResume.js +39 -0
  120. package/dist/ui/useUsageBudget.js +149 -0
  121. package/dist/ui/viewport.js +71 -0
  122. package/dist/undo/undo.js +293 -0
  123. package/dist/version.js +4 -0
  124. package/package.json +122 -0
@@ -0,0 +1,245 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import http from "node:http";
4
+ import net from "node:net";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { debugLog } from "../config/debug.js";
8
+ /** The curated NVIDIA models this integration routes across. */
9
+ export const SWITCHYARD_WEAK_MODEL = "nvidia/nemotron-3.5-lightning-30b-a3b";
10
+ export const SWITCHYARD_STRONG_MODEL = "nvidia/nemotron-3-ultra-550b-a55b";
11
+ /** Tried in order, direct against NVIDIA, if switchyard-server itself is unreachable or exhausts its own retries. */
12
+ export const SWITCHYARD_FALLBACK_MODELS = [
13
+ "meta/muse-glimmer-30b",
14
+ "thinkingmachines/inkling",
15
+ "z-ai/glm-5.2",
16
+ ];
17
+ /** The route id in the generated routes.toml — also the `model` field kritya sends. */
18
+ export const SWITCHYARD_ROUTE_ID = "switchyard";
19
+ /**
20
+ * Difficulty cutoff for `mode = "capability"`: the classifier scores each
21
+ * incoming request and anything above this goes to the strong tier. 0.5 is
22
+ * NVIDIA's own default — lower sends more traffic to the strong model.
23
+ *
24
+ * The sibling mode, `"escalation"`, is deliberately not used: it answers on
25
+ * the weak tier first and asks a judge whether that model is *stuck*, which
26
+ * is a signal about a spinning agent loop, not about a hard question. A
27
+ * difficult one-shot prompt that the weak model answers competently never
28
+ * trips it, so per-question routing has to classify the request up front.
29
+ */
30
+ const SWITCHYARD_BASE_THRESHOLD = 0.5;
31
+ const READY_TIMEOUT_MS = 10_000;
32
+ /** Exported for tests; the sidecar startup path is the only real caller. */
33
+ export function routesToml(nvidiaBaseUrl) {
34
+ return `schema_version = 1
35
+
36
+ [llm_clients.nvidia]
37
+ format = "openai_chat"
38
+ base_url = "${nvidiaBaseUrl}"
39
+ api_key_env = "NVIDIA_API_KEY"
40
+ max_retries = 2
41
+
42
+ [targets.weak]
43
+ id = "${SWITCHYARD_WEAK_MODEL}"
44
+ llm_client = "nvidia"
45
+
46
+ [targets.strong]
47
+ id = "${SWITCHYARD_STRONG_MODEL}"
48
+ llm_client = "nvidia"
49
+
50
+ [routes.${SWITCHYARD_ROUTE_ID}]
51
+ id = "${SWITCHYARD_ROUTE_ID}"
52
+ type = "llm_classifier"
53
+ mode = "capability"
54
+ classifier_target = "weak"
55
+ strong_target = "strong"
56
+ weak_target = "weak"
57
+ base_threshold = ${SWITCHYARD_BASE_THRESHOLD}
58
+ `;
59
+ }
60
+ /** Thrown when the switchyard-server binary can't be found or fails to come up. */
61
+ export class SwitchyardUnavailableError extends Error {
62
+ constructor(message) {
63
+ super(message);
64
+ this.name = "SwitchyardUnavailableError";
65
+ }
66
+ }
67
+ /** Module-level singleton: one sidecar per kritya process, reused across
68
+ * repeated `ensureSwitchyardSidecar` calls (e.g. /provider switching back
69
+ * and forth) rather than spawning a new server each time. */
70
+ let sidecar;
71
+ function freePort() {
72
+ return new Promise((resolve, reject) => {
73
+ const srv = net.createServer();
74
+ srv.unref();
75
+ srv.on("error", reject);
76
+ srv.listen(0, "127.0.0.1", () => {
77
+ const addr = srv.address();
78
+ const port = typeof addr === "object" && addr ? addr.port : 0;
79
+ srv.close(() => resolve(port));
80
+ });
81
+ });
82
+ }
83
+ function waitForPort(port, timeoutMs) {
84
+ const deadline = Date.now() + timeoutMs;
85
+ return new Promise((resolve, reject) => {
86
+ const attempt = () => {
87
+ const sock = net.connect({ port, host: "127.0.0.1" }, () => {
88
+ sock.end();
89
+ resolve();
90
+ });
91
+ sock.on("error", () => {
92
+ sock.destroy();
93
+ if (Date.now() >= deadline) {
94
+ reject(new SwitchyardUnavailableError(`switchyard-server did not start listening on port ${port} within ${timeoutMs}ms`));
95
+ }
96
+ else {
97
+ setTimeout(attempt, 250);
98
+ }
99
+ });
100
+ };
101
+ attempt();
102
+ });
103
+ }
104
+ /**
105
+ * The TCP port can accept connections before switchyard-server's HTTP layer
106
+ * has finished wiring up routes internally — a socket that accepts and then
107
+ * hangs up (or resets) looks identical to "still starting" from here, so
108
+ * that's treated the same as connection-refused and retried. Any actual HTTP
109
+ * response, even an error status, proves the server is dispatching requests
110
+ * and is what a real chat request needs — this is deliberately not a chat
111
+ * completion itself, so it costs no tokens and doesn't depend on a specific
112
+ * route existing.
113
+ */
114
+ function waitForHttpReady(port, timeoutMs) {
115
+ const deadline = Date.now() + timeoutMs;
116
+ return new Promise((resolve, reject) => {
117
+ const attempt = () => {
118
+ const req = http.get({ host: "127.0.0.1", port, path: "/v1/models", timeout: 2000 }, (res) => {
119
+ res.resume(); // drain so the socket can close
120
+ resolve();
121
+ });
122
+ req.on("timeout", () => req.destroy());
123
+ req.on("error", () => {
124
+ if (Date.now() >= deadline) {
125
+ reject(new SwitchyardUnavailableError(`switchyard-server didn't respond to HTTP requests on port ${port} within ${timeoutMs}ms`));
126
+ }
127
+ else {
128
+ setTimeout(attempt, 250);
129
+ }
130
+ });
131
+ };
132
+ attempt();
133
+ });
134
+ }
135
+ /**
136
+ * Start (or reuse) the local switchyard-server sidecar for this kritya
137
+ * process: writes a routes.toml scoped to the weak/strong tiers, launches
138
+ * the binary on a free localhost port, and waits for it to accept
139
+ * connections. Idempotent within a process.
140
+ */
141
+ export async function ensureSwitchyardSidecar(nvidiaApiKey, nvidiaBaseUrl) {
142
+ if (!sidecar) {
143
+ sidecar = startSidecar(nvidiaApiKey, nvidiaBaseUrl).catch((err) => {
144
+ sidecar = undefined; // let the next call retry instead of caching the failure forever
145
+ throw err;
146
+ });
147
+ }
148
+ const handle = await sidecar;
149
+ return { baseUrl: handle.baseUrl };
150
+ }
151
+ async function startSidecar(nvidiaApiKey, nvidiaBaseUrl) {
152
+ const port = await freePort();
153
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kritya-switchyard-"));
154
+ const configPath = path.join(dir, "routes.toml");
155
+ fs.writeFileSync(configPath, routesToml(nvidiaBaseUrl));
156
+ debugLog("switchyard-server", `config at ${configPath}, starting on 127.0.0.1:${port}`);
157
+ const proc = spawn("switchyard-server", ["--config", configPath, "--host", "127.0.0.1", "--port", String(port)], { env: { ...process.env, NVIDIA_API_KEY: nvidiaApiKey }, stdio: ["ignore", "pipe", "pipe"] });
158
+ proc.stdout?.on("data", (d) => debugLog("switchyard-server stdout", d.toString()));
159
+ proc.stderr?.on("data", (d) => debugLog("switchyard-server stderr", d.toString()));
160
+ const exited = new Promise((_, reject) => {
161
+ proc.once("error", (err) => {
162
+ if (err.code === "ENOENT") {
163
+ reject(new SwitchyardUnavailableError("switchyard-server not found on PATH. Install it with: cargo install --locked switchyard-server"));
164
+ }
165
+ else {
166
+ reject(err);
167
+ }
168
+ });
169
+ proc.once("exit", (code) => {
170
+ reject(new SwitchyardUnavailableError(`switchyard-server exited before it was ready (code ${code}). Run with KRITYA_DEBUG=1 to see its output.`));
171
+ });
172
+ });
173
+ exited.catch(() => { }); // observed via the race below; don't let the late rejection go unhandled
174
+ const readyDeadline = Date.now() + READY_TIMEOUT_MS;
175
+ await Promise.race([waitForPort(port, READY_TIMEOUT_MS), exited]);
176
+ await Promise.race([waitForHttpReady(port, Math.max(readyDeadline - Date.now(), 1000)), exited]);
177
+ debugLog("switchyard-server", `ready on 127.0.0.1:${port}`);
178
+ const cleanup = () => {
179
+ try {
180
+ proc.kill();
181
+ }
182
+ catch {
183
+ // already gone
184
+ }
185
+ };
186
+ process.once("exit", cleanup);
187
+ process.once("SIGINT", cleanup);
188
+ process.once("SIGTERM", cleanup);
189
+ return { baseUrl: `http://127.0.0.1:${port}/v1`, proc };
190
+ }
191
+ /**
192
+ * The single place every model-default chain (engine.ts, headless.ts,
193
+ * index.tsx, and the /provider switch in useAgent.ts) should go through.
194
+ * `SWITCHYARD_ROUTE_ID` is a routing directive, not a real model — it only
195
+ * means anything when switchyard is the active provider. Without this guard,
196
+ * it can end up carried over (via config.model, a --model flag left from a
197
+ * previous run, or /provider switching away) to a provider that has no idea
198
+ * what "switchyard" is and 404s on it. Candidates are tried in order; the
199
+ * first one that isn't empty and isn't a stale route id wins.
200
+ */
201
+ export function resolveEffectiveModel(providerName, candidates, fallback) {
202
+ for (const candidate of candidates) {
203
+ if (!candidate)
204
+ continue;
205
+ if (candidate === SWITCHYARD_ROUTE_ID && providerName !== "switchyard")
206
+ continue;
207
+ return candidate;
208
+ }
209
+ return fallback;
210
+ }
211
+ /**
212
+ * Warn when the model that just resolved for switchyard will silently bypass
213
+ * its routing this run. `/model` now persists per provider (see useAgent.ts)
214
+ * so this shouldn't arise from normal use going forward, but a legacy
215
+ * top-level `config.model` (saved before that fix, or hand-edited) can still
216
+ * win when `providers.switchyard.model` isn't set. `explicitModel` (a
217
+ * --model flag for this run) isn't flagged: that's a deliberate one-off, not
218
+ * a stale leftover.
219
+ */
220
+ export function staleSwitchyardModelWarning(providerName, effectiveModel, explicitModel) {
221
+ if (providerName !== "switchyard" || explicitModel)
222
+ return undefined;
223
+ if (!effectiveModel || effectiveModel === SWITCHYARD_ROUTE_ID)
224
+ return undefined;
225
+ return (`⚠ Resolved model "${effectiveModel}" for switchyard, which bypasses its routing and calls ` +
226
+ `that model directly every turn. Run /model switchyard (or remove "model" from ` +
227
+ `~/.kritya/config.json) to restore routing.`);
228
+ }
229
+ /** Stop the sidecar, if one is running. Safe to call even if none was started. */
230
+ export function stopSwitchyardSidecar() {
231
+ if (!sidecar)
232
+ return;
233
+ const started = sidecar;
234
+ sidecar = undefined;
235
+ started
236
+ .then((h) => {
237
+ try {
238
+ h.proc.kill();
239
+ }
240
+ catch {
241
+ // already gone
242
+ }
243
+ })
244
+ .catch(() => { });
245
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Recover tool calls a model wrote into its text instead of the tool-call
3
+ * channel.
4
+ *
5
+ * Every OpenAI-compatible provider is supposed to return tool calls as
6
+ * structured `tool_calls` deltas, and almost always does. But models do slip —
7
+ * mid-conversation, after a long tool chain — and emit the call as prose
8
+ * instead: a bare JSON object, often inside a code fence or a `<tool_call>`
9
+ * tag. Nothing downstream recognizes that, so the turn ends as if the model
10
+ * were done: the action never runs, and the user is shown a wall of raw JSON
11
+ * as the final answer. That is a silent, wrong-looking failure at exactly the
12
+ * moment the work was nearly finished.
13
+ *
14
+ * Recovery is deliberately narrow, because guessing wrong means running a tool
15
+ * the model never asked for:
16
+ *
17
+ * - the text must be *entirely* the payload (after unwrapping one fence or
18
+ * tag), so prose that merely quotes a tool call is left alone;
19
+ * - it must parse as JSON;
20
+ * - every call in it must name a tool that actually exists — one unknown name
21
+ * and the whole recovery is abandoned rather than partly applied.
22
+ */
23
+ /** Wrappers models put around a text-channel tool call. */
24
+ const TAG_RE = /^<\s*(tool_call|toolcall|tool▁call|function_call|tool_use)\s*>([\s\S]*?)<\s*\/\s*\1\s*>$/i;
25
+ /** Keys a model uses for the tool's name, and for its arguments. */
26
+ const NAME_KEYS = ["name", "tool", "tool_name", "function", "recipient_name"];
27
+ const ARG_KEYS = ["arguments", "parameters", "args", "input", "parameter", "tool_input"];
28
+ /** Strip one layer of code fence and/or `<tool_call>`-style tag. */
29
+ function unwrap(text) {
30
+ let body = text.trim();
31
+ for (let i = 0; i < 3; i++) {
32
+ const tagged = TAG_RE.exec(body);
33
+ if (tagged) {
34
+ body = tagged[2].trim();
35
+ continue;
36
+ }
37
+ if (body.startsWith("```")) {
38
+ // ```json\n{...}\n``` — the closing fence is sometimes missing when the
39
+ // model ran out of tokens, so it is optional.
40
+ const stripped = body
41
+ .replace(/^```[a-zA-Z0-9_-]*[ \t]*\r?\n?/, "")
42
+ .replace(/\r?\n?```$/, "")
43
+ .trim();
44
+ if (stripped === body)
45
+ break;
46
+ body = stripped;
47
+ continue;
48
+ }
49
+ // A trailing fence with no opening one — the shape seen when a model
50
+ // starts the JSON immediately but still closes the block.
51
+ if (body.endsWith("```")) {
52
+ body = body.slice(0, -3).trim();
53
+ continue;
54
+ }
55
+ break;
56
+ }
57
+ return body;
58
+ }
59
+ function asRecord(v) {
60
+ return v && typeof v === "object" && !Array.isArray(v) ? v : null;
61
+ }
62
+ /** Pull `{name, args}` out of one entry, in whichever shape the model used. */
63
+ function toCall(entry, isKnown) {
64
+ const obj = asRecord(entry);
65
+ if (!obj)
66
+ return null;
67
+ // {"function": {"name": "...", "arguments": {...}}} — the OpenAI wire shape.
68
+ const fn = asRecord(obj.function);
69
+ if (fn && typeof fn.name === "string") {
70
+ return { name: fn.name, args: pickArgs(fn) };
71
+ }
72
+ // {"name": "write_file", "arguments": {...}} and its synonyms.
73
+ for (const key of NAME_KEYS) {
74
+ const value = obj[key];
75
+ if (typeof value === "string" && value.trim()) {
76
+ return { name: value.trim(), args: pickArgs(obj) };
77
+ }
78
+ }
79
+ // {"write_file": {...}} — the tool name as the only key.
80
+ const keys = Object.keys(obj);
81
+ if (keys.length === 1 && isKnown(keys[0])) {
82
+ return { name: keys[0], args: obj[keys[0]] };
83
+ }
84
+ return null;
85
+ }
86
+ function pickArgs(obj) {
87
+ for (const key of ARG_KEYS) {
88
+ if (key in obj)
89
+ return obj[key];
90
+ }
91
+ return {};
92
+ }
93
+ /** Arguments as the JSON string the rest of the pipeline expects. */
94
+ function argsToJson(args) {
95
+ if (typeof args === "string") {
96
+ const trimmed = args.trim();
97
+ // Providers double-encode arguments as often as not; either is fine here,
98
+ // but a non-JSON string is not arguments at all.
99
+ try {
100
+ const parsed = JSON.parse(trimmed);
101
+ return asRecord(parsed) ? JSON.stringify(parsed) : "{}";
102
+ }
103
+ catch {
104
+ return "{}";
105
+ }
106
+ }
107
+ const record = asRecord(args);
108
+ return record ? JSON.stringify(record) : "{}";
109
+ }
110
+ export function recoverToolCalls(text, knownTools) {
111
+ if (!text.trim() || knownTools.size === 0)
112
+ return [];
113
+ const body = unwrap(text);
114
+ if (!body.startsWith("{") && !body.startsWith("["))
115
+ return [];
116
+ let parsed;
117
+ try {
118
+ parsed = JSON.parse(body);
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ const isKnown = (name) => knownTools.has(name);
124
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
125
+ if (entries.length === 0)
126
+ return [];
127
+ const calls = [];
128
+ for (const [i, entry] of entries.entries()) {
129
+ const call = toCall(entry, isKnown);
130
+ // All or nothing: a payload we only partly understand is one we don't.
131
+ if (!call || !isKnown(call.name))
132
+ return [];
133
+ calls.push({
134
+ id: `recovered_${i}_${Date.now().toString(36)}`,
135
+ name: call.name,
136
+ argsJson: argsToJson(call.args),
137
+ });
138
+ }
139
+ return calls;
140
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Repo map: a cheap, whole-repository structural skeleton fed to the model so
3
+ * it can orient itself in an unfamiliar or large codebase without reading every
4
+ * file. It lists source files ranked by importance, each with its definition
5
+ * signatures (from symbols.ts) but no bodies. This is the low-cost half of
6
+ * semantic codebase navigation — no embeddings, no index to keep in sync, no
7
+ * data leaving the machine — and covers the large majority of "where does X
8
+ * live" questions on its own. The model calls read_file for the actual bodies.
9
+ */
10
+ import fs from "node:fs/promises";
11
+ import path from "node:path";
12
+ import fg from "fast-glob";
13
+ import { isPathSafe, resolveSafe } from "../tools/common.js";
14
+ import { loadIgnorePatterns } from "../tools/ignore.js";
15
+ import { CODE_EXTENSIONS, extractSymbols } from "./symbols.js";
16
+ const DEFAULTS = { maxFiles: 2000, maxOutputChars: 20_000, maxSymbolsPerFile: 40 };
17
+ /** Skip files larger than this — generated bundles, vendored blobs, etc. */
18
+ const MAX_FILE_BYTES = 512 * 1024;
19
+ /** Directory segments that mark low-signal files: ranked down, never up. */
20
+ const DEMOTE_SEGMENTS = new Set([
21
+ "test",
22
+ "tests",
23
+ "__tests__",
24
+ "spec",
25
+ "specs",
26
+ "fixtures",
27
+ "fixture",
28
+ "mocks",
29
+ "__mocks__",
30
+ "vendor",
31
+ "third_party",
32
+ "generated",
33
+ "gen",
34
+ "dist",
35
+ "build",
36
+ "out",
37
+ "examples",
38
+ "example",
39
+ ]);
40
+ /** Directory segments that mark primary source: ranked up. */
41
+ const PROMOTE_SEGMENTS = new Set(["src", "lib", "app", "pkg", "cmd", "internal", "core"]);
42
+ /** Filenames (sans extension) that tend to be entry points: ranked up. */
43
+ const ENTRYPOINT_NAMES = new Set(["index", "main", "mod", "lib", "app", "server", "cli", "init"]);
44
+ function extname(rel) {
45
+ const dot = rel.lastIndexOf(".");
46
+ return dot === -1 ? "" : rel.slice(dot + 1).toLowerCase();
47
+ }
48
+ /**
49
+ * A path-only importance score, computed before reading any file so the
50
+ * `maxFiles` cap keeps the most relevant files on a huge repo. Higher is more
51
+ * important.
52
+ */
53
+ function pathScore(rel) {
54
+ const segments = rel.split(/[\\/]/);
55
+ const name = segments[segments.length - 1];
56
+ const stem = name.slice(0, name.length - extname(name).length - 1);
57
+ let score = 0;
58
+ for (const seg of segments.slice(0, -1)) {
59
+ const lower = seg.toLowerCase();
60
+ if (DEMOTE_SEGMENTS.has(lower))
61
+ score -= 4;
62
+ if (PROMOTE_SEGMENTS.has(lower))
63
+ score += 3;
64
+ }
65
+ if (ENTRYPOINT_NAMES.has(stem.toLowerCase()))
66
+ score += 2;
67
+ if (/\.(min|bundle|generated|gen)\./i.test(name) || name.endsWith(".d.ts"))
68
+ score -= 4;
69
+ // Shallower files are usually more central; a gentle nudge, not a dominant term.
70
+ score -= (segments.length - 1) * 0.2;
71
+ return score;
72
+ }
73
+ /** Render one file's block: its path, then its indented signatures. */
74
+ function renderFile(file) {
75
+ const minIndent = Math.min(...file.symbols.map((s) => s.indent));
76
+ const lines = file.symbols.map((s) => {
77
+ const level = Math.min(Math.round((s.indent - minIndent) / 2), 4);
78
+ return `${" ".repeat(level + 1)}${s.text}`;
79
+ });
80
+ return `${file.rel.replaceAll("\\", "/")}\n${lines.join("\n")}`;
81
+ }
82
+ /**
83
+ * Build the repo map for `workspace` (optionally scoped to `subdir`). Returns a
84
+ * ready-to-show string; always bounded in size (never throws on a large repo —
85
+ * it caps files and output and reports what it omitted).
86
+ */
87
+ export async function buildRepoMap(workspace, subdir = ".", options = {}) {
88
+ const opts = { ...DEFAULTS, ...options };
89
+ const root = resolveSafe(workspace, subdir || ".");
90
+ const scopeLabel = path.relative(workspace, root) || "(workspace root)";
91
+ const found = await fg("**/*", {
92
+ cwd: root,
93
+ dot: false,
94
+ onlyFiles: true,
95
+ followSymbolicLinks: false,
96
+ ignore: ["**/node_modules/**", "**/.git/**", ...loadIgnorePatterns(workspace)],
97
+ suppressErrors: true,
98
+ });
99
+ // Keep only source files, then rank by path so the maxFiles cap (which bounds
100
+ // how many files we actually read) retains the most important ones.
101
+ const candidates = found
102
+ .filter((f) => CODE_EXTENSIONS.has(extname(f)))
103
+ .map((f) => ({ rel: path.join(path.relative(workspace, root), f), local: f }))
104
+ .filter((c) => isPathSafe(workspace, path.join(root, c.local)))
105
+ .map((c) => ({ ...c, ps: pathScore(c.rel) }))
106
+ .sort((a, b) => b.ps - a.ps);
107
+ const totalSource = candidates.length;
108
+ const scanned = candidates.slice(0, opts.maxFiles);
109
+ const mapped = [];
110
+ for (const c of scanned) {
111
+ const abs = path.join(root, c.local);
112
+ let stat;
113
+ try {
114
+ stat = await fs.stat(abs);
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ if (stat.size > MAX_FILE_BYTES)
120
+ continue;
121
+ let content;
122
+ try {
123
+ content = await fs.readFile(abs, "utf8");
124
+ }
125
+ catch {
126
+ continue;
127
+ }
128
+ if (content.slice(0, 8000).includes("\0"))
129
+ continue;
130
+ const symbols = extractSymbols(content, extname(c.local), opts.maxSymbolsPerFile);
131
+ if (!symbols.length)
132
+ continue;
133
+ // Final score blends the path score with a modest bonus for structural
134
+ // density, so a central file full of definitions sorts above a stub.
135
+ const score = c.ps + Math.min(symbols.length, 20) * 0.25;
136
+ mapped.push({ rel: c.rel, symbols, score });
137
+ }
138
+ if (!mapped.length) {
139
+ const reason = totalSource === 0
140
+ ? "no source files found"
141
+ : "no definitions were extracted (unsupported languages, or files without top-level definitions)";
142
+ return `Repo map for ${scopeLabel}: ${reason}. Use glob/grep to explore instead.`;
143
+ }
144
+ mapped.sort((a, b) => b.score - a.score);
145
+ const header = `Repo map for ${scopeLabel} — structural skeleton (definition signatures only, no bodies). ` +
146
+ `Ranked by importance; call read_file for full code.`;
147
+ const blocks = [];
148
+ let used = header.length;
149
+ let shown = 0;
150
+ for (const file of mapped) {
151
+ const block = renderFile(file);
152
+ if (shown > 0 && used + block.length + 2 > opts.maxOutputChars)
153
+ break;
154
+ blocks.push(block);
155
+ used += block.length + 2;
156
+ shown++;
157
+ }
158
+ const omitted = mapped.length - shown;
159
+ const cappedNote = totalSource > scanned.length
160
+ ? ` ${totalSource - scanned.length} lower-ranked file(s) were not scanned.`
161
+ : "";
162
+ const footer = omitted > 0
163
+ ? `\n\n… ${omitted} more mapped file(s) not shown (output limit). Re-run repo_map with a path to focus, or use grep/read_file.${cappedNote}`
164
+ : cappedNote
165
+ ? `\n${cappedNote.trim()}`
166
+ : "";
167
+ return `${header} Showing ${shown} of ${mapped.length} file(s).\n\n${blocks.join("\n\n")}${footer}`;
168
+ }