@cruxy/cli 1.11.1 → 1.11.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/dist/agent/instruction-loss.js +204 -0
- package/dist/agent/prompts.js +25 -4
- package/dist/agent/session.js +165 -33
- package/dist/agent/status.js +18 -0
- package/dist/checkpoint/service.js +44 -3
- package/dist/cli/commands/pr.js +14 -0
- package/dist/cli/commands/run.js +35 -0
- package/dist/cli/commands/sessions.js +8 -0
- package/dist/cli/session-commands.js +3 -1
- package/dist/cli/session-factory.js +54 -6
- package/dist/config/schema.js +9 -0
- package/dist/errors/constructors.js +15 -6
- package/dist/errors/types.js +7 -0
- package/dist/indexing/embedder.js +34 -11
- package/dist/indexing/model-cache.js +399 -0
- package/dist/mcp/bounds.js +8 -1
- package/dist/plan/execute.js +4 -1
- package/dist/plan/service.js +42 -5
- package/dist/plan/step-message.js +49 -0
- package/dist/render/context-view.js +44 -1
- package/dist/render/status-view.js +13 -0
- package/dist/session/index.js +7 -3
- package/dist/session/log.js +163 -2
- package/dist/session/owner.js +123 -0
- package/dist/session/prune.js +11 -0
- package/dist/session/recorded-runs.js +56 -0
- package/dist/session/replay.js +75 -1
- package/dist/session/resume.js +110 -3
- package/dist/session/types.js +158 -0
- package/dist/subagent/orchestrator.js +2 -2
- package/dist/subagent/registry-scope.js +28 -5
- package/dist/testing/run-tests-tool.js +3 -1
- package/dist/tools/create-pull-request.js +8 -1
- package/dist/tools/file/apply-patch.js +53 -23
- package/dist/tools/file/edit-file.js +19 -1
- package/dist/tools/file/snapshot.js +68 -0
- package/dist/tools/file/write-file.js +31 -5
- package/dist/tools/registry.js +39 -8
- package/dist/tools/schema-depth.js +79 -6
- package/dist/tools/shell/exec.js +7 -0
- package/dist/tools/shell/run-command.js +45 -21
- package/dist/utils/process-owner.js +107 -0
- package/dist/vcs/generate.js +48 -6
- package/dist/verification/index.js +15 -0
- package/dist/verification/ledger.js +99 -0
- package/dist/verification/types.js +26 -0
- package/dist/verification/view.js +87 -0
- package/package.json +3 -2
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { EnvHttpProxyAgent, fetch as undiciFetch } from "undici";
|
|
5
|
+
import { extract as tarExtract } from "tar";
|
|
6
|
+
/**
|
|
7
|
+
* Cruxy-owned download + extraction of the local embedding model.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists (#306): fastembed's own `retrieveModel` extracts the model
|
|
10
|
+
* tarball with `tar@6.2.1`, which carries a family of path-traversal /
|
|
11
|
+
* symlink-poisoning advisories, has no fixed 6.x, and cannot be overridden to
|
|
12
|
+
* tar 7 (fastembed does `import tar from "tar"`; tar 7 has no default export —
|
|
13
|
+
* the override was tested and breaks `cruxy index` at module link). fastembed
|
|
14
|
+
* 2.1.0 is its latest release and pins `tar ^6.2.0`.
|
|
15
|
+
*
|
|
16
|
+
* So cruxy provisions the model directory itself and hands fastembed an
|
|
17
|
+
* absolute path via `model: CUSTOM` + `modelAbsoluteDirPath`. In fastembed's
|
|
18
|
+
* `init`, `retrieveModel` sits on the other arm of a ternary on `CUSTOM`, so it
|
|
19
|
+
* is unreachable once we go through this module — tar 6 stays in the
|
|
20
|
+
* dependency tree (scanners will keep flagging it) but never runs.
|
|
21
|
+
*
|
|
22
|
+
* This code writes to every user's disk, so the archive is treated as hostile:
|
|
23
|
+
* - every entry is checked by an explicit allow-list (regular files and
|
|
24
|
+
* directories only; a single top-level directory named for the model; no
|
|
25
|
+
* `..`, absolute, drive-letter, backslash or NUL paths; bounded count and
|
|
26
|
+
* size) — never the library's defaults alone. A refused entry fails the
|
|
27
|
+
* whole extraction; it is never silently skipped.
|
|
28
|
+
* - download and extraction happen in a staging directory next to the final
|
|
29
|
+
* one; the final directory appears only via a rename after the required
|
|
30
|
+
* file set has been verified, so an interrupted run can never leave a
|
|
31
|
+
* directory that looks complete.
|
|
32
|
+
* - a completion marker records the verified file sizes; a cache hit requires
|
|
33
|
+
* the marker AND matching sizes, so a partial or tampered directory is
|
|
34
|
+
* redone rather than used.
|
|
35
|
+
*/
|
|
36
|
+
/** Model directory name inside the cache; also the archive's top-level dir. */
|
|
37
|
+
export const MODEL_NAME = "fast-bge-small-en-v1.5";
|
|
38
|
+
/** Where fastembed (and Qdrant's Python fastembed) fetch this archive from. */
|
|
39
|
+
export const MODEL_ARCHIVE_URL = `https://storage.googleapis.com/qdrant-fastembed/${MODEL_NAME}.tar.gz`;
|
|
40
|
+
/** The ONNX graph fastembed loads from the model directory. */
|
|
41
|
+
export const MODEL_ONNX_FILE = "model_optimized.onnx";
|
|
42
|
+
/**
|
|
43
|
+
* Files fastembed's `init` reads. The archive also ships `ort_config.json` and
|
|
44
|
+
* `vocab.txt`; they are extracted if present but not required.
|
|
45
|
+
*/
|
|
46
|
+
export const REQUIRED_FILES = [
|
|
47
|
+
"config.json",
|
|
48
|
+
MODEL_ONNX_FILE,
|
|
49
|
+
"special_tokens_map.json",
|
|
50
|
+
"tokenizer.json",
|
|
51
|
+
"tokenizer_config.json",
|
|
52
|
+
];
|
|
53
|
+
/** Written into the model dir after verification; absent ⇒ incomplete. */
|
|
54
|
+
export const MARKER_FILE = ".cruxy-model.json";
|
|
55
|
+
/** Compressed archive ceiling (the real one is ~77 MB). */
|
|
56
|
+
export const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
|
|
57
|
+
/** Sum of declared entry sizes ceiling (the real one is ~134 MB). */
|
|
58
|
+
export const MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024;
|
|
59
|
+
/** Entry-count ceiling (the real archive has 8). */
|
|
60
|
+
export const MAX_ENTRIES = 64;
|
|
61
|
+
/** Abort the download if no bytes arrive for this long. */
|
|
62
|
+
const IDLE_TIMEOUT_MS = 60_000;
|
|
63
|
+
/** Staging dirs older than this are leftovers of a dead run and are swept. */
|
|
64
|
+
const STALE_STAGING_MS = 6 * 60 * 60 * 1000;
|
|
65
|
+
/**
|
|
66
|
+
* Failure of the download / extraction / verification pipeline. `kind` lets
|
|
67
|
+
* the caller pick next steps: a `download` failure is the classic offline
|
|
68
|
+
* first run; `refused` means the archive contained an entry the allow-list
|
|
69
|
+
* rejected (retrying blindly is wrong); `incomplete` means the archive did not
|
|
70
|
+
* contain the required file set, or the extracted set failed verification.
|
|
71
|
+
*/
|
|
72
|
+
export class ModelCacheError extends Error {
|
|
73
|
+
kind;
|
|
74
|
+
constructor(kind, message, cause) {
|
|
75
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
76
|
+
this.name = "ModelCacheError";
|
|
77
|
+
this.kind = kind;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Ensure `<cacheDir>/<MODEL_NAME>` holds a verified, complete model and return
|
|
82
|
+
* that absolute path. Downloads and extracts only when the cache is missing or
|
|
83
|
+
* fails verification; never trusts a directory without its marker.
|
|
84
|
+
*/
|
|
85
|
+
export async function ensureModelDir(opts) {
|
|
86
|
+
const cacheDir = path.resolve(opts.cacheDir);
|
|
87
|
+
const modelDir = path.join(cacheDir, MODEL_NAME);
|
|
88
|
+
if (await isCompleteModelDir(modelDir))
|
|
89
|
+
return modelDir;
|
|
90
|
+
await fs.mkdir(cacheDir, { recursive: true });
|
|
91
|
+
await sweepStaleStaging(cacheDir);
|
|
92
|
+
// Stage next to the destination so the final publish is a same-filesystem
|
|
93
|
+
// rename (atomic on POSIX; on Windows it either succeeds or throws).
|
|
94
|
+
const stagingDir = path.join(cacheDir, `.${MODEL_NAME}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
95
|
+
const archivePath = `${stagingDir}.tar.gz`;
|
|
96
|
+
try {
|
|
97
|
+
await fs.mkdir(stagingDir, { recursive: true });
|
|
98
|
+
await downloadArchive(opts.url ?? MODEL_ARCHIVE_URL, archivePath, opts.fetchImpl, opts.onProgress);
|
|
99
|
+
opts.onProgress?.({ phase: "extract" });
|
|
100
|
+
await extractArchive(archivePath, stagingDir);
|
|
101
|
+
opts.onProgress?.({ phase: "verify" });
|
|
102
|
+
const extracted = path.join(stagingDir, MODEL_NAME);
|
|
103
|
+
const files = await verifyModelDir(extracted);
|
|
104
|
+
await writeMarker(extracted, files);
|
|
105
|
+
await publish(extracted, modelDir);
|
|
106
|
+
return modelDir;
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await fs.rm(archivePath, { force: true }).catch(() => { });
|
|
110
|
+
await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => { });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* True only when the marker exists, parses, names this model, and every
|
|
115
|
+
* required file is a regular file whose size matches the marker. Anything
|
|
116
|
+
* else — a missing marker (a partial or legacy extraction), a size mismatch,
|
|
117
|
+
* a symlink where a file should be — is "incomplete" and gets redone.
|
|
118
|
+
*/
|
|
119
|
+
export async function isCompleteModelDir(modelDir) {
|
|
120
|
+
let marker;
|
|
121
|
+
try {
|
|
122
|
+
const raw = await fs.readFile(path.join(modelDir, MARKER_FILE), "utf8");
|
|
123
|
+
marker = JSON.parse(raw);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (!marker ||
|
|
129
|
+
marker.version !== 1 ||
|
|
130
|
+
marker.model !== MODEL_NAME ||
|
|
131
|
+
typeof marker.files !== "object" ||
|
|
132
|
+
marker.files === null) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
for (const name of REQUIRED_FILES) {
|
|
136
|
+
const expected = marker.files[name];
|
|
137
|
+
if (typeof expected !== "number" || expected <= 0)
|
|
138
|
+
return false;
|
|
139
|
+
const st = await fs.lstat(path.join(modelDir, name)).catch(() => null);
|
|
140
|
+
if (!st || !st.isFile() || st.size !== expected)
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
// ── download ──────────────────────────────────────────────────────────────────
|
|
146
|
+
async function downloadArchive(url, dest, fetchImpl, onProgress) {
|
|
147
|
+
const doFetch = fetchImpl ?? undiciFetch;
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
let idle;
|
|
150
|
+
const armIdle = () => {
|
|
151
|
+
if (idle)
|
|
152
|
+
clearTimeout(idle);
|
|
153
|
+
idle = setTimeout(() => controller.abort(new Error(`no data for ${IDLE_TIMEOUT_MS}ms`)), IDLE_TIMEOUT_MS);
|
|
154
|
+
idle.unref?.();
|
|
155
|
+
};
|
|
156
|
+
let res;
|
|
157
|
+
try {
|
|
158
|
+
armIdle();
|
|
159
|
+
res = await doFetch(url, {
|
|
160
|
+
signal: controller.signal,
|
|
161
|
+
redirect: "follow",
|
|
162
|
+
// Honor HTTPS_PROXY / NO_PROXY for this fixed, public URL (the web tool
|
|
163
|
+
// deliberately does not; it has an SSRF guard to protect instead). The
|
|
164
|
+
// agent is built only when a proxy is configured: undici prints an
|
|
165
|
+
// "experimental" warning on construction, and non-proxy users should
|
|
166
|
+
// never see it.
|
|
167
|
+
...(fetchImpl || !proxyConfigured()
|
|
168
|
+
? {}
|
|
169
|
+
: { dispatcher: new EnvHttpProxyAgent() }),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
if (idle)
|
|
174
|
+
clearTimeout(idle);
|
|
175
|
+
throw new ModelCacheError("download", `downloading ${url} failed: ${messageOf(err)}`, err);
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
throw new ModelCacheError("download", `downloading ${url} failed: HTTP ${res.status}`);
|
|
180
|
+
}
|
|
181
|
+
const declared = Number(res.headers.get("content-length") ?? "");
|
|
182
|
+
if (Number.isFinite(declared) && declared > MAX_ARCHIVE_BYTES) {
|
|
183
|
+
throw new ModelCacheError("refused", `refused model archive: Content-Length ${declared} exceeds the ${MAX_ARCHIVE_BYTES}-byte ceiling`);
|
|
184
|
+
}
|
|
185
|
+
if (!res.body) {
|
|
186
|
+
throw new ModelCacheError("download", `downloading ${url}: empty body`);
|
|
187
|
+
}
|
|
188
|
+
const total = Number.isFinite(declared) && declared > 0 ? declared : undefined;
|
|
189
|
+
const fh = await fs.open(dest, "w");
|
|
190
|
+
let received = 0;
|
|
191
|
+
try {
|
|
192
|
+
for await (const chunk of res.body) {
|
|
193
|
+
armIdle();
|
|
194
|
+
received += chunk.byteLength;
|
|
195
|
+
if (received > MAX_ARCHIVE_BYTES) {
|
|
196
|
+
controller.abort();
|
|
197
|
+
throw new ModelCacheError("refused", `refused model archive: body exceeds the ${MAX_ARCHIVE_BYTES}-byte ceiling`);
|
|
198
|
+
}
|
|
199
|
+
await fh.write(chunk);
|
|
200
|
+
onProgress?.({ phase: "download", received, total });
|
|
201
|
+
}
|
|
202
|
+
if (total !== undefined && received !== total) {
|
|
203
|
+
throw new ModelCacheError("download", `downloading ${url}: connection closed after ${received} of ${total} bytes`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (err instanceof ModelCacheError)
|
|
208
|
+
throw err;
|
|
209
|
+
throw new ModelCacheError("download", `downloading ${url} failed: ${messageOf(err)}`, err);
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await fh.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
if (idle)
|
|
217
|
+
clearTimeout(idle);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// ── extraction ────────────────────────────────────────────────────────────────
|
|
221
|
+
/**
|
|
222
|
+
* Decide whether one archive entry may be written. Returns a reason string to
|
|
223
|
+
* refuse, or null to allow. Deliberately an allow-list: anything not proven
|
|
224
|
+
* safe is refused. Runs BEFORE tar's own checks, and independently of them.
|
|
225
|
+
*/
|
|
226
|
+
export function refuseEntry(entryPath, type, linkpath) {
|
|
227
|
+
if (type !== "File" && type !== "Directory") {
|
|
228
|
+
return `entry type ${type} is not allowed (${entryPath})`;
|
|
229
|
+
}
|
|
230
|
+
if (linkpath)
|
|
231
|
+
return `entry carries a link target (${entryPath})`;
|
|
232
|
+
if (entryPath.length === 0)
|
|
233
|
+
return "empty entry path";
|
|
234
|
+
if (entryPath.includes("\0"))
|
|
235
|
+
return "entry path contains NUL";
|
|
236
|
+
if (entryPath.includes("\\"))
|
|
237
|
+
return `entry path contains a backslash (${entryPath})`;
|
|
238
|
+
if (entryPath.startsWith("/"))
|
|
239
|
+
return `absolute entry path (${entryPath})`;
|
|
240
|
+
if (/^[A-Za-z]:/.test(entryPath))
|
|
241
|
+
return `drive-letter entry path (${entryPath})`;
|
|
242
|
+
const parts = entryPath.split("/");
|
|
243
|
+
// A directory entry may end with "/"; nothing else may have an empty segment.
|
|
244
|
+
if (type === "Directory" &&
|
|
245
|
+
parts.length > 1 &&
|
|
246
|
+
parts[parts.length - 1] === "") {
|
|
247
|
+
parts.pop();
|
|
248
|
+
}
|
|
249
|
+
for (const seg of parts) {
|
|
250
|
+
if (seg === "" || seg === "." || seg === "..") {
|
|
251
|
+
return `entry path has an empty, "." or ".." segment (${entryPath})`;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (parts[0] !== MODEL_NAME) {
|
|
255
|
+
return `entry is outside the ${MODEL_NAME}/ directory (${entryPath})`;
|
|
256
|
+
}
|
|
257
|
+
if (type === "Directory" && parts.length !== 1) {
|
|
258
|
+
return `nested directory not allowed (${entryPath})`;
|
|
259
|
+
}
|
|
260
|
+
if (type === "File" && parts.length !== 2) {
|
|
261
|
+
return `file must sit directly under ${MODEL_NAME}/ (${entryPath})`;
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
async function extractArchive(archivePath, cwd) {
|
|
266
|
+
const refused = [];
|
|
267
|
+
let entries = 0;
|
|
268
|
+
let declaredBytes = 0;
|
|
269
|
+
// On the extract path tar always hands a ReadEntry (Stats is the create-side
|
|
270
|
+
// shape of the same option), but keep the narrowing explicit.
|
|
271
|
+
const filter = (entryPath, entry) => {
|
|
272
|
+
entries += 1;
|
|
273
|
+
if (entries > MAX_ENTRIES) {
|
|
274
|
+
refused.push(`more than ${MAX_ENTRIES} entries`);
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
if (!("header" in entry)) {
|
|
278
|
+
refused.push(`unexpected non-archive entry (${entryPath})`);
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
const reason = refuseEntry(entryPath, String(entry.type), entry.linkpath ? String(entry.linkpath) : undefined);
|
|
282
|
+
if (reason) {
|
|
283
|
+
refused.push(reason);
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
declaredBytes += Number(entry.size ?? 0);
|
|
287
|
+
if (declaredBytes > MAX_EXTRACTED_BYTES) {
|
|
288
|
+
refused.push(`declared size exceeds the ${MAX_EXTRACTED_BYTES}-byte ceiling`);
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
};
|
|
293
|
+
try {
|
|
294
|
+
await tarExtract({
|
|
295
|
+
file: archivePath,
|
|
296
|
+
cwd,
|
|
297
|
+
// tar's own guards stay on as a second layer: strict turns its warnings
|
|
298
|
+
// (e.g. its independent ".." detection) into errors; preservePaths=false
|
|
299
|
+
// keeps its absolute-path stripping; no ownership or mode replay.
|
|
300
|
+
strict: true,
|
|
301
|
+
preservePaths: false,
|
|
302
|
+
preserveOwner: false,
|
|
303
|
+
noChmod: true,
|
|
304
|
+
noMtime: true,
|
|
305
|
+
filter,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
if (refused.length > 0) {
|
|
310
|
+
throw new ModelCacheError("refused", `refused model archive: ${refused.join("; ")}`, err);
|
|
311
|
+
}
|
|
312
|
+
throw new ModelCacheError("incomplete", `extracting the model archive failed: ${messageOf(err)}`, err);
|
|
313
|
+
}
|
|
314
|
+
if (refused.length > 0) {
|
|
315
|
+
throw new ModelCacheError("refused", `refused model archive: ${refused.join("; ")}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// ── verification + publish ────────────────────────────────────────────────────
|
|
319
|
+
/**
|
|
320
|
+
* Every required file must be a regular, non-empty file; nothing in the dir
|
|
321
|
+
* may be a symlink (belt and braces over the entry filter). Returns the size
|
|
322
|
+
* manifest for the marker.
|
|
323
|
+
*/
|
|
324
|
+
async function verifyModelDir(dir) {
|
|
325
|
+
const st = await fs.lstat(dir).catch(() => null);
|
|
326
|
+
if (!st || !st.isDirectory()) {
|
|
327
|
+
throw new ModelCacheError("incomplete", `model archive did not contain a ${MODEL_NAME}/ directory`);
|
|
328
|
+
}
|
|
329
|
+
const files = {};
|
|
330
|
+
for (const name of await fs.readdir(dir)) {
|
|
331
|
+
const s = await fs.lstat(path.join(dir, name));
|
|
332
|
+
if (s.isSymbolicLink()) {
|
|
333
|
+
throw new ModelCacheError("refused", `symlink found after extraction: ${name}`);
|
|
334
|
+
}
|
|
335
|
+
if (s.isFile())
|
|
336
|
+
files[name] = s.size;
|
|
337
|
+
}
|
|
338
|
+
const missing = REQUIRED_FILES.filter((n) => !(files[n] > 0));
|
|
339
|
+
if (missing.length > 0) {
|
|
340
|
+
throw new ModelCacheError("incomplete", `model archive is missing or has empty required file(s): ${missing.join(", ")}`);
|
|
341
|
+
}
|
|
342
|
+
return files;
|
|
343
|
+
}
|
|
344
|
+
async function writeMarker(dir, files) {
|
|
345
|
+
const marker = {
|
|
346
|
+
version: 1,
|
|
347
|
+
model: MODEL_NAME,
|
|
348
|
+
files,
|
|
349
|
+
extractedAt: new Date().toISOString(),
|
|
350
|
+
};
|
|
351
|
+
await fs.writeFile(path.join(dir, MARKER_FILE), JSON.stringify(marker, null, 2));
|
|
352
|
+
}
|
|
353
|
+
/** Rename the verified staging dir into place; tolerate a concurrent winner. */
|
|
354
|
+
async function publish(from, to) {
|
|
355
|
+
try {
|
|
356
|
+
await fs.rename(from, to);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
// Another process may have published a complete model meanwhile — accept
|
|
361
|
+
// it. Otherwise whatever is there failed verification: replace it.
|
|
362
|
+
if (await isCompleteModelDir(to))
|
|
363
|
+
return;
|
|
364
|
+
await fs.rm(to, { recursive: true, force: true });
|
|
365
|
+
try {
|
|
366
|
+
await fs.rename(from, to);
|
|
367
|
+
}
|
|
368
|
+
catch (err2) {
|
|
369
|
+
throw new ModelCacheError("incomplete", `could not move the verified model into ${to}: ${messageOf(err2)}`, err2 ?? err);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async function sweepStaleStaging(cacheDir) {
|
|
374
|
+
const prefix = `.${MODEL_NAME}.tmp-`;
|
|
375
|
+
const now = Date.now();
|
|
376
|
+
let names;
|
|
377
|
+
try {
|
|
378
|
+
names = await fs.readdir(cacheDir);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
for (const name of names) {
|
|
384
|
+
if (!name.startsWith(prefix))
|
|
385
|
+
continue;
|
|
386
|
+
const p = path.join(cacheDir, name);
|
|
387
|
+
const st = await fs.lstat(p).catch(() => null);
|
|
388
|
+
if (st && now - st.mtimeMs > STALE_STAGING_MS) {
|
|
389
|
+
await fs.rm(p, { recursive: true, force: true }).catch(() => { });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function proxyConfigured() {
|
|
394
|
+
const env = process.env;
|
|
395
|
+
return Boolean(env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy);
|
|
396
|
+
}
|
|
397
|
+
function messageOf(err) {
|
|
398
|
+
return err instanceof Error ? err.message : String(err);
|
|
399
|
+
}
|
package/dist/mcp/bounds.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MAX_SCHEMA_DEPTH, schemaDepth } from "../tools/schema-depth.js";
|
|
1
|
+
import { MAX_SCHEMA_DEPTH, MAX_SCHEMA_NODES, schemaDepth, schemaNodes, } from "../tools/schema-depth.js";
|
|
2
2
|
const PERMISSIVE_SCHEMA = {
|
|
3
3
|
type: "object",
|
|
4
4
|
additionalProperties: true,
|
|
@@ -48,6 +48,13 @@ export function boundToolList(tools, bounds) {
|
|
|
48
48
|
inputSchema = { ...PERMISSIVE_SCHEMA };
|
|
49
49
|
notes.push(`input schema nests ${depth} levels deep, at or over the ${MAX_SCHEMA_DEPTH}-level provider limit, and was replaced with a permissive one`);
|
|
50
50
|
}
|
|
51
|
+
// Same order, same reasoning: nodes are counted on the post-cap value, and
|
|
52
|
+
// the gateway's bound is inclusive (P4) — 400 passes, 401 fails the request.
|
|
53
|
+
const nodes = schemaNodes(inputSchema);
|
|
54
|
+
if (nodes > MAX_SCHEMA_NODES) {
|
|
55
|
+
inputSchema = { ...PERMISSIVE_SCHEMA };
|
|
56
|
+
notes.push(`input schema has ${nodes} nodes, over the ${MAX_SCHEMA_NODES}-node provider limit, and was replaced with a permissive one`);
|
|
57
|
+
}
|
|
51
58
|
return { name: t.name, description, inputSchema, notes };
|
|
52
59
|
});
|
|
53
60
|
return { tools: bounded, droppedCount };
|
package/dist/plan/execute.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CruxyError } from "../errors/index.js";
|
|
2
2
|
import { promptContinueAfterFailure } from "./approve.js";
|
|
3
3
|
export async function executePlan(plan, deps) {
|
|
4
|
-
const { runStep, io, renderer } = deps;
|
|
4
|
+
const { runStep, io, renderer, record } = deps;
|
|
5
5
|
try {
|
|
6
6
|
for (const [index, step] of plan.steps.entries()) {
|
|
7
7
|
step.status = "running";
|
|
@@ -13,14 +13,17 @@ export async function executePlan(plan, deps) {
|
|
|
13
13
|
});
|
|
14
14
|
renderer?.setPhase({ kind: "executing-step" });
|
|
15
15
|
renderer?.setPlan(plan.steps);
|
|
16
|
+
record?.(step);
|
|
16
17
|
try {
|
|
17
18
|
await runStep(step);
|
|
18
19
|
step.status = "done";
|
|
19
20
|
renderer?.setPlan(plan.steps);
|
|
21
|
+
record?.(step);
|
|
20
22
|
}
|
|
21
23
|
catch (err) {
|
|
22
24
|
step.status = "failed";
|
|
23
25
|
renderer?.setPlan(plan.steps);
|
|
26
|
+
record?.(step);
|
|
24
27
|
// Surface the failure via the U.5 shape when we have it.
|
|
25
28
|
const detail = err instanceof CruxyError
|
|
26
29
|
? `${err.title}${err.cause ? ` — ${err.cause}` : ""}`
|
package/dist/plan/service.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ToolRegistry } from "../tools/index.js";
|
|
|
3
3
|
import { runAgent } from "../agent/loop.js";
|
|
4
4
|
import { promptPlanDecision } from "./approve.js";
|
|
5
5
|
import { executePlan } from "./execute.js";
|
|
6
|
+
import { stepInstruction } from "./step-message.js";
|
|
6
7
|
import { makeSubmitPlanTool } from "./submit-plan.js";
|
|
7
8
|
/**
|
|
8
9
|
* Orchestrates a plan-mode turn (C.31): propose → approve/revise (capped) →
|
|
@@ -13,6 +14,32 @@ import { makeSubmitPlanTool } from "./submit-plan.js";
|
|
|
13
14
|
*
|
|
14
15
|
* A propose phase that ends with NO plan is a completed conversational turn, not
|
|
15
16
|
* an error — see the `!holder.plan` branch below.
|
|
17
|
+
*
|
|
18
|
+
* WHAT SURVIVES THE PROCESS, AND WHAT DOES NOT (plan-durability).
|
|
19
|
+
*
|
|
20
|
+
* The plan object lives in `holder` below for exactly one call of this
|
|
21
|
+
* function. Three things outlive it: the transcript (every step's messages
|
|
22
|
+
* reach the log as they happen, through `args.compact`), the typed record
|
|
23
|
+
* (`args.record` — the approval as a fact, and each step transition), and the
|
|
24
|
+
* checkpoint (see `execute.ts` for what that one actually covers). After a
|
|
25
|
+
* crash, a closed terminal, or Ctrl-C — which is a real SIGINT here, because
|
|
26
|
+
* the TUI releases stdin for the duration of a turn — `--resume` restores the
|
|
27
|
+
* mode and the history and DESCRIBES the plan; it does not continue it.
|
|
28
|
+
*
|
|
29
|
+
* NOT BUILT: a resumable executor. Re-entering `executePlan` at step N on
|
|
30
|
+
* resume needs three things this turn does not have: a re-entry point that
|
|
31
|
+
* rebuilds the plan from the record rather than from a `submit_plan` call; a
|
|
32
|
+
* fresh-consent decision, because the approval was given in another process
|
|
33
|
+
* against a tree that has since changed (and `[g]`'s grants are gone with
|
|
34
|
+
* that process, rightly); and a second checkpoint latch, which would split one
|
|
35
|
+
* undo unit into two. Once the transcript is durable the model can continue
|
|
36
|
+
* from step N+1 on a one-line nudge — it can see its own plan and every "Do
|
|
37
|
+
* ONLY step" message that ran — and there is no recorded interruption where
|
|
38
|
+
* that has failed.
|
|
39
|
+
*
|
|
40
|
+
* REVISIT TRIGGER: one observed case where transcript-driven continuation
|
|
41
|
+
* fails — a resumed session that, told to carry on, redoes a finished step or
|
|
42
|
+
* cannot tell where it stopped. Until then the record is the deliverable.
|
|
16
43
|
*/
|
|
17
44
|
/** Default cap on plan revisions before failing loud. */
|
|
18
45
|
export const MAX_PLAN_REVISIONS = 3;
|
|
@@ -82,6 +109,7 @@ export async function runPlanSession(args) {
|
|
|
82
109
|
router: args.router,
|
|
83
110
|
taskClass: "plan",
|
|
84
111
|
onRequestUsage: args.onRequestUsage,
|
|
112
|
+
compact: args.compact,
|
|
85
113
|
}));
|
|
86
114
|
if (!holder.plan) {
|
|
87
115
|
// NO PLAN IS NOT A FAILURE. The propose phase's registry is read-only plus
|
|
@@ -121,14 +149,18 @@ export async function runPlanSession(args) {
|
|
|
121
149
|
if (decision.kind === "approve-grant") {
|
|
122
150
|
args.planPolicy.enableSafeStepGrants();
|
|
123
151
|
}
|
|
152
|
+
// The approval, recorded as a fact (plan-durability): which choice, and
|
|
153
|
+
// the steps it covered. Written before the first step runs, so a plan
|
|
154
|
+
// that dies in step 1 still has its approval on disk.
|
|
155
|
+
args.record?.planApproved(decision.kind, plan.steps.map((s) => ({ id: s.id, title: s.title, kind: s.kind })));
|
|
124
156
|
// Execute step-by-step, driving one agent turn per step against the full
|
|
125
157
|
// registry. Each step's actions still pass through the U.3 gate.
|
|
126
158
|
const runStep = async (step) => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
});
|
|
159
|
+
// The scoping instruction plus the whole plan's current statuses
|
|
160
|
+
// (plan-durability): the executor has already marked this step
|
|
161
|
+
// `running` and every earlier one `done` or `failed`, so the message
|
|
162
|
+
// says which — see `step-message.ts` for why the failed case matters.
|
|
163
|
+
messages.push({ role: "user", content: stepInstruction(plan, step) });
|
|
132
164
|
accumulate(await runAgent({
|
|
133
165
|
messages,
|
|
134
166
|
provider: args.provider,
|
|
@@ -142,12 +174,17 @@ export async function runPlanSession(args) {
|
|
|
142
174
|
router: args.router,
|
|
143
175
|
taskClass: "main-turn",
|
|
144
176
|
onRequestUsage: args.onRequestUsage,
|
|
177
|
+
// The step message pushed above reaches the log at this loop's
|
|
178
|
+
// first iteration, BEFORE the step's first model call — so a step
|
|
179
|
+
// that dies still leaves on disk which step it was.
|
|
180
|
+
compact: args.compact,
|
|
145
181
|
}));
|
|
146
182
|
};
|
|
147
183
|
await executePlan(plan, {
|
|
148
184
|
runStep,
|
|
149
185
|
io: args.io,
|
|
150
186
|
renderer: args.renderer,
|
|
187
|
+
record: (step) => args.record?.planStep(step.id, step.status),
|
|
151
188
|
});
|
|
152
189
|
return finish();
|
|
153
190
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-step instruction the executor sends the model (plan-durability).
|
|
3
|
+
*
|
|
4
|
+
* The first paragraph is the C.31 scoping instruction, unchanged: do this one
|
|
5
|
+
* step, nothing else, then stop. What follows it is new — the whole plan with
|
|
6
|
+
* each step's CURRENT status — and it exists because of what the model could
|
|
7
|
+
* not see without it.
|
|
8
|
+
*
|
|
9
|
+
* The model already has the plan: its own `submit_plan` call sits in the
|
|
10
|
+
* history with every step as the tool input. What it never had was status. A
|
|
11
|
+
* step that failed and that the user chose to continue past (`[c]` at the
|
|
12
|
+
* failure prompt) was followed by exactly the same message a successful step
|
|
13
|
+
* was — "Do ONLY step N+1" — so the model walked into step N+1 believing step N
|
|
14
|
+
* had landed, and built on work that was not there. The block below makes a
|
|
15
|
+
* failed step read as failed, in the message that asks for the next one.
|
|
16
|
+
*
|
|
17
|
+
* This is a prompt change, not a tool: no schema, no new tool, and no change to
|
|
18
|
+
* the tool set between phases (the prefix-cache argument in cli#150). It is
|
|
19
|
+
* appended to EVERY step message, a one-step plan included, so the shape the
|
|
20
|
+
* model learns is the same shape every time rather than one that appears from
|
|
21
|
+
* step 2 on. Plain words rather than the renderer's glyphs, because the reader
|
|
22
|
+
* here is the model and the status has to survive as text.
|
|
23
|
+
*/
|
|
24
|
+
export function stepInstruction(plan, step) {
|
|
25
|
+
const head = `The plan is approved. Do ONLY step ${step.id}: ${step.title}. ${step.rationale} ` +
|
|
26
|
+
"Do not start any other step. When this step is complete, stop.";
|
|
27
|
+
const n = plan.steps.length;
|
|
28
|
+
const lines = plan.steps.map((s) => ` ${s.id}. ${statusLabel(s, step)} — ${s.title}`);
|
|
29
|
+
return `${head}\n\nPlan status (${n} step${n === 1 ? "" : "s"}):\n${lines.join("\n")}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One step's status as the model should read it. `failed` is the label that
|
|
33
|
+
* matters: it names the user's decision, so the model knows the step was not
|
|
34
|
+
* skipped by accident and is not going to be retried by the executor.
|
|
35
|
+
*/
|
|
36
|
+
function statusLabel(s, current) {
|
|
37
|
+
if (s.id === current.id)
|
|
38
|
+
return "this step";
|
|
39
|
+
switch (s.status) {
|
|
40
|
+
case "done":
|
|
41
|
+
return "done";
|
|
42
|
+
case "failed":
|
|
43
|
+
return "FAILED — the user chose to continue past it; do not assume its work exists";
|
|
44
|
+
case "running":
|
|
45
|
+
return "running";
|
|
46
|
+
case "pending":
|
|
47
|
+
return "pending";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -49,7 +49,9 @@ function share(tokens, of) {
|
|
|
49
49
|
* a table into a misaligned mess; the default is unbounded for callers that
|
|
50
50
|
* reflow themselves (the TUI's main column).
|
|
51
51
|
*/
|
|
52
|
-
export function contextReportLines(report, t, width = Infinity
|
|
52
|
+
export function contextReportLines(report, t, width = Infinity,
|
|
53
|
+
/** What compaction has cost so far (P3); omitted → the section is left out. */
|
|
54
|
+
tally) {
|
|
53
55
|
const { reading, compaction } = report;
|
|
54
56
|
const lines = [t.heading("context")];
|
|
55
57
|
// The headline, worded exactly as the panel words it — same estimate, same
|
|
@@ -88,6 +90,13 @@ export function contextReportLines(report, t, width = Infinity) {
|
|
|
88
90
|
lines.push(`${head}${c.excerpt === "" ? "" : t.muted(` — ${c.excerpt}`)}`);
|
|
89
91
|
}
|
|
90
92
|
}
|
|
93
|
+
// ── what compaction has cost so far (P3) ──────────────────────────────────
|
|
94
|
+
if (tally) {
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push(t.strong("compaction so far"));
|
|
97
|
+
const so = compactionTallyLines(tally, t, " ");
|
|
98
|
+
lines.push(...(so.length > 0 ? so : [t.muted(" none yet this session")]));
|
|
99
|
+
}
|
|
91
100
|
// ── what compaction would do ──────────────────────────────────────────────
|
|
92
101
|
lines.push("");
|
|
93
102
|
lines.push(t.strong("if you compact now"));
|
|
@@ -113,3 +122,37 @@ export function contextReportLines(report, t, width = Infinity) {
|
|
|
113
122
|
? lines.map((l) => fit(l, width, t.glyph.ellipsis))
|
|
114
123
|
: lines;
|
|
115
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* What compaction has cost a session (P3 context quality), as lines — the
|
|
127
|
+
* one renderer behind `/context`'s "compaction so far" and the one-shot
|
|
128
|
+
* summary, so both describe the tally the same way. Empty when nothing has
|
|
129
|
+
* compacted: the one-shot prints nothing, `/context` says "none yet".
|
|
130
|
+
*
|
|
131
|
+
* The sums cover only the compactions that recorded a cost; when that is
|
|
132
|
+
* fewer than the count (a log written before P3), the line says so rather
|
|
133
|
+
* than presenting a partial sum as the whole. A suspected instruction loss is
|
|
134
|
+
* a WARNING line: it is the one figure here a user should act on.
|
|
135
|
+
*/
|
|
136
|
+
export function compactionTallyLines(tally, t, indent = "") {
|
|
137
|
+
if (tally.count === 0)
|
|
138
|
+
return [];
|
|
139
|
+
const lines = [];
|
|
140
|
+
const key = t.strong("compaction");
|
|
141
|
+
const times = `${tally.count} time${tally.count === 1 ? "" : "s"}`;
|
|
142
|
+
if (tally.measured === 0) {
|
|
143
|
+
lines.push(`${indent}${key} ${times} ${t.muted("(cost not recorded — written before it was measured)")}`);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const scope = tally.measured < tally.count
|
|
147
|
+
? t.muted(` (${tally.count - tally.measured} recorded no cost)`)
|
|
148
|
+
: "";
|
|
149
|
+
lines.push(`${indent}${key} ${times}${t.sep}freed ${approx(tally.freedTokens)}${t.sep}` +
|
|
150
|
+
t.muted(`summaries cost ${approx(tally.summaryInputTokens)} in / ${approx(tally.summaryOutputTokens)} out`) +
|
|
151
|
+
scope);
|
|
152
|
+
}
|
|
153
|
+
if (tally.instructionLosses > 0) {
|
|
154
|
+
const n = tally.instructionLosses;
|
|
155
|
+
lines.push(`${indent}${t.warning(`${n} compaction${n === 1 ? "" : "s"} may have dropped an instruction you gave — restate any that still apply`)}`);
|
|
156
|
+
}
|
|
157
|
+
return lines;
|
|
158
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { capacityLevel, formatCapacity, } from "../utils/disk.js";
|
|
2
2
|
import { fit } from "./layout.js";
|
|
3
3
|
import { formatTokens } from "./state.js";
|
|
4
|
+
import { formatDuration } from "./test-view.js";
|
|
4
5
|
/** `key value`, aligned on a fixed gutter so the column is scannable. */
|
|
5
6
|
function row(key, value, t) {
|
|
6
7
|
return ` ${t.muted(key.padEnd(11))} ${value}`;
|
|
@@ -96,6 +97,18 @@ export function sessionStatusLines(status, t, width = Infinity) {
|
|
|
96
97
|
lines.push(row("jobs", detail, t));
|
|
97
98
|
}
|
|
98
99
|
lines.push(row("tools", t.muted(`${status.tools} available to the model`), t));
|
|
100
|
+
// What last ran and how it exited, dated — the record's answer to "was
|
|
101
|
+
// this verified", which is the reader's question to settle, not this
|
|
102
|
+
// row's. "none recorded" is said out loud rather than omitted.
|
|
103
|
+
if (status.verification) {
|
|
104
|
+
const v = status.verification;
|
|
105
|
+
const exit = v.exitCode === null ? "no exit code" : `exit ${v.exitCode}`;
|
|
106
|
+
lines.push(row("verify", `${t.muted(v.tool)} ${v.command} ${t.glyph.arrow} ${v.passed ? t.muted(exit) : t.danger(exit)} ` +
|
|
107
|
+
t.muted(`(${formatDuration(v.durationMs)}) ${t.glyph.sep} ${v.age}`), t));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
lines.push(row("verify", t.warning("none recorded this session"), t));
|
|
111
|
+
}
|
|
99
112
|
// Roots last: one line each, so a multi-root session shows which repo each
|
|
100
113
|
// change lands in — the fact the startup banner states once and then loses.
|
|
101
114
|
lines.push("");
|