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.
- package/LICENSE +21 -0
- package/README.md +434 -0
- package/dist/agent/budget.js +25 -0
- package/dist/agent/compactor.js +78 -0
- package/dist/agent/contextWarning.js +6 -0
- package/dist/agent/killSwitch.js +111 -0
- package/dist/agent/loop.js +543 -0
- package/dist/agent/memory.js +145 -0
- package/dist/agent/plugins.js +408 -0
- package/dist/agent/skills.js +210 -0
- package/dist/agent/skillsCli.js +51 -0
- package/dist/agent/systemPrompt.js +122 -0
- package/dist/agent/tokens.js +55 -0
- package/dist/agent/toolExecutor.js +331 -0
- package/dist/agent/workflow.js +496 -0
- package/dist/agent/worktree.js +116 -0
- package/dist/atomicWrite.js +109 -0
- package/dist/audit/audit.js +230 -0
- package/dist/audit/cli.js +135 -0
- package/dist/commands/custom.js +69 -0
- package/dist/commands/mcpCommand.js +433 -0
- package/dist/commands/pluginsCommand.js +204 -0
- package/dist/commands/registry.js +739 -0
- package/dist/config/config.js +214 -0
- package/dist/config/debug.js +19 -0
- package/dist/config/models.js +82 -0
- package/dist/config/retention.js +24 -0
- package/dist/config/winAcl.js +38 -0
- package/dist/crash.js +102 -0
- package/dist/engine.js +113 -0
- package/dist/git/git.js +46 -0
- package/dist/headless.js +274 -0
- package/dist/hooks/hooks.js +122 -0
- package/dist/index.js +670 -0
- package/dist/lsp/client.js +413 -0
- package/dist/lsp/manager.js +0 -0
- package/dist/lsp/registry.js +62 -0
- package/dist/mcp/callback.js +141 -0
- package/dist/mcp/client.js +945 -0
- package/dist/mcp/login.js +117 -0
- package/dist/mcp/oauth.js +345 -0
- package/dist/mcp/servers.js +114 -0
- package/dist/mcp/spawnWin.js +86 -0
- package/dist/mcp/tokens.js +97 -0
- package/dist/mcp/transport.js +295 -0
- package/dist/net/urlSafety.js +158 -0
- package/dist/permissions/danger.js +96 -0
- package/dist/permissions/permissions.js +62 -0
- package/dist/permissions/rules.js +69 -0
- package/dist/plugins/discover.js +108 -0
- package/dist/plugins/mcp.js +80 -0
- package/dist/provider/client.js +460 -0
- package/dist/provider/switchyardClient.js +46 -0
- package/dist/provider/switchyardSidecar.js +245 -0
- package/dist/provider/textToolCalls.js +140 -0
- package/dist/repomap/repoMap.js +168 -0
- package/dist/repomap/symbols.js +190 -0
- package/dist/session/store.js +328 -0
- package/dist/shell/background.js +131 -0
- package/dist/shell/sandbox.js +396 -0
- package/dist/telemetry/metrics.js +172 -0
- package/dist/telemetry/otlp.js +128 -0
- package/dist/telemetry/tracer.js +240 -0
- package/dist/tools/askUser.js +64 -0
- package/dist/tools/bg.js +59 -0
- package/dist/tools/common.js +158 -0
- package/dist/tools/deepResearch.js +126 -0
- package/dist/tools/diff.js +70 -0
- package/dist/tools/document/docx.js +44 -0
- package/dist/tools/document/pdf.js +190 -0
- package/dist/tools/document/pptx.js +148 -0
- package/dist/tools/document/types.js +26 -0
- package/dist/tools/document/xlsx.js +87 -0
- package/dist/tools/document.js +389 -0
- package/dist/tools/edit.js +56 -0
- package/dist/tools/fetchUrl.js +218 -0
- package/dist/tools/fuzzyMatch.js +74 -0
- package/dist/tools/glob.js +34 -0
- package/dist/tools/grep.js +85 -0
- package/dist/tools/ignore.js +28 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/ls.js +27 -0
- package/dist/tools/lsp.js +239 -0
- package/dist/tools/notebook.js +215 -0
- package/dist/tools/read.js +32 -0
- package/dist/tools/repoMap.js +26 -0
- package/dist/tools/secretScan.js +143 -0
- package/dist/tools/shell.js +134 -0
- package/dist/tools/skills.js +69 -0
- package/dist/tools/subagent.js +57 -0
- package/dist/tools/tasks.js +43 -0
- package/dist/tools/webSearch.js +97 -0
- package/dist/tools/write.js +47 -0
- package/dist/tools/writeAgent.js +72 -0
- package/dist/trust/aiDisclosure.js +45 -0
- package/dist/trust/mcpTrust.js +111 -0
- package/dist/trust/trust.js +231 -0
- package/dist/types.js +1 -0
- package/dist/ui/AiDisclosurePrompt.js +23 -0
- package/dist/ui/App.js +449 -0
- package/dist/ui/Banner.js +71 -0
- package/dist/ui/ElicitationPrompt.js +46 -0
- package/dist/ui/Markdown.js +148 -0
- package/dist/ui/McpTrustPrompt.js +42 -0
- package/dist/ui/ModelPicker.js +19 -0
- package/dist/ui/PermissionPrompt.js +27 -0
- package/dist/ui/SelectList.js +24 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/StatusLine.js +11 -0
- package/dist/ui/TranscriptItem.js +13 -0
- package/dist/ui/TrustPrompt.js +12 -0
- package/dist/ui/highlight.js +137 -0
- package/dist/ui/inline.js +241 -0
- package/dist/ui/mermaid.js +98 -0
- package/dist/ui/table.js +148 -0
- package/dist/ui/toolOutputPreview.js +34 -0
- package/dist/ui/useAgent.js +534 -0
- package/dist/ui/useKillSwitch.js +65 -0
- package/dist/ui/useSessionResume.js +39 -0
- package/dist/ui/useUsageBudget.js +149 -0
- package/dist/ui/viewport.js +71 -0
- package/dist/undo/undo.js +293 -0
- package/dist/version.js +4 -0
- 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
|
+
}
|