pi-supernova 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -24
- package/docs/CHANGELOG.md +70 -0
- package/docs/TOKEN_COSTS.md +64 -28
- package/index.js +64 -25
- package/package.json +2 -2
- package/src/bridge/catalog.js +14 -10
- package/src/bridge/host-bridge.js +919 -158
- package/src/bridge/native-tools.js +16 -6
- package/src/context/evidence.js +13 -5
- package/src/context/fuzzy.js +34 -13
- package/src/context/outline.js +11 -2
- package/src/context/repo-index.js +190 -19
- package/src/context/search.js +70 -17
- package/src/context/snap.js +128 -40
- package/src/context/spans.js +39 -0
- package/src/context/surface.js +23 -15
- package/src/fs/check.js +10 -2
- package/src/fs/json-read.js +5 -1
- package/src/fs/patch.js +4 -2
- package/src/fs/vfs.js +133 -33
- package/src/fs/workspace.js +34 -4
- package/src/output/bottleneck.js +53 -15
- package/src/output/format.js +63 -29
- package/src/runtime/guest-worker.js +172 -61
- package/src/runtime/parallel.js +4 -1
- package/src/runtime/program-batch.js +17 -6
- package/src/runtime/program-file.js +6 -3
- package/src/runtime/reference.js +15 -22
- package/src/runtime/runtime.js +17 -8
- package/src/shared/decode.js +26 -3
- package/src/ui/omp-frame.js +11 -4
- package/src/ui/render.js +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parentPort } from "node:worker_threads";
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
-
import { isString, isObject, isFunction, toPlain } from "../shared/decode.js";
|
|
3
|
+
import { isString, isObject, isFunction, isNumber, toPlain, looksLikePath } from "../shared/decode.js";
|
|
4
4
|
import { truncateChars } from "../output/format.js";
|
|
5
5
|
import { sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
|
|
6
6
|
|
|
@@ -94,7 +94,11 @@ function leanEnvelope(res) {
|
|
|
94
94
|
return res;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
function
|
|
97
|
+
function quoteShellArg(value) {
|
|
98
|
+
return "'" + String(value).replaceAll("'", "'\\''") + "'";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildGuestApi(available, batchRead, runId, _nativeArgv) {
|
|
98
102
|
const rpc = (method, args) => callRpc(runId, method, args);
|
|
99
103
|
const availableSet = new Set(available);
|
|
100
104
|
const checkpointScope = new AsyncLocalStorage();
|
|
@@ -106,20 +110,41 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
106
110
|
if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
|
|
107
111
|
};
|
|
108
112
|
|
|
113
|
+
let operationTail = Promise.resolve();
|
|
114
|
+
|
|
115
|
+
const enqueueHost = operation => {
|
|
116
|
+
const next = operationTail.then(operation);
|
|
117
|
+
operationTail = next.then(() => {}, () => {});
|
|
118
|
+
|
|
119
|
+
return next;
|
|
120
|
+
};
|
|
121
|
+
|
|
109
122
|
const nova = {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
123
|
+
call(name, args) {
|
|
124
|
+
assertScope(); flushReads();
|
|
125
|
+
const promise = enqueueHost(() => rpc("call", [name, args]).then(leanEnvelope));
|
|
126
|
+
|
|
127
|
+
promise.catch(() => {});
|
|
128
|
+
|
|
129
|
+
return promise;
|
|
130
|
+
},
|
|
131
|
+
callMany(calls) {
|
|
132
|
+
assertScope(); flushReads();
|
|
133
|
+
const promise = enqueueHost(async () => {
|
|
134
|
+
const wave = await rpc("callMany", [calls]);
|
|
135
|
+
const results = Array.isArray(wave?.results) ? wave.results : Array.isArray(wave) ? wave : [];
|
|
136
|
+
Object.defineProperties(results, {
|
|
137
|
+
mode: { value: wave?.mode, enumerable: false },
|
|
138
|
+
reason: { value: wave?.reason, enumerable: false },
|
|
139
|
+
results: { value: results, enumerable: false },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return results;
|
|
120
143
|
});
|
|
121
144
|
|
|
122
|
-
|
|
145
|
+
promise.catch(() => {});
|
|
146
|
+
|
|
147
|
+
return promise;
|
|
123
148
|
},
|
|
124
149
|
async speculate(fn) {
|
|
125
150
|
if (checkpoint) throw new Error("edit checkpoints cannot overlap or nest; await the current checkpoint");
|
|
@@ -128,30 +153,31 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
128
153
|
let began = false;
|
|
129
154
|
|
|
130
155
|
try {
|
|
131
|
-
|
|
132
|
-
await rpc("speculateBegin", []);
|
|
156
|
+
await drainReads();
|
|
157
|
+
await enqueueHost(() => rpc("speculateBegin", []));
|
|
133
158
|
began = true;
|
|
134
159
|
const value = await checkpointScope.run(token, fn);
|
|
135
|
-
|
|
136
|
-
await rpc("speculateCommit", []);
|
|
160
|
+
await drainReads();
|
|
161
|
+
await enqueueHost(() => rpc("speculateCommit", []));
|
|
137
162
|
|
|
138
163
|
return { ok: true, committed: true, value };
|
|
139
164
|
} catch (err) {
|
|
140
|
-
|
|
165
|
+
await drainReads();
|
|
141
166
|
|
|
142
|
-
if (began) await rpc("speculateRollback", []);
|
|
167
|
+
if (began) await enqueueHost(() => rpc("speculateRollback", []));
|
|
143
168
|
|
|
144
169
|
return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
|
|
145
170
|
} finally { checkpoint = null; }
|
|
146
171
|
},
|
|
147
|
-
surface
|
|
148
|
-
evidence
|
|
149
|
-
snap
|
|
172
|
+
surface(filePath) { assertScope(); flushReads(); const promise = enqueueHost(() => rpc("call", ["surface", { path: filePath }]).then(unwrapJsonValue)); promise.catch(() => {}); return promise; },
|
|
173
|
+
evidence(query, opts) { assertScope(); flushReads(); const promise = enqueueHost(() => rpc("call", ["evidence", { query, ...opts }]).then(unwrapJsonValue)); promise.catch(() => {}); return promise; },
|
|
174
|
+
snap(query, targetPath) { assertScope(); flushReads(); const promise = enqueueHost(() => rpc("call", ["snap", { query, path: targetPath }]).then(unwrapJsonValue)); promise.catch(() => {}); return promise; },
|
|
150
175
|
has: (name) => availableSet.has(name),
|
|
151
176
|
};
|
|
152
177
|
|
|
153
178
|
// Coalesce already-started compatible reads without rewriting JS control flow.
|
|
154
179
|
let queuedReads = [];
|
|
180
|
+
const pendingReadWaves = new Set();
|
|
155
181
|
|
|
156
182
|
function flushReads() {
|
|
157
183
|
const pending = queuedReads;
|
|
@@ -161,13 +187,13 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
161
187
|
const wave = pending.slice(start, start + 64);
|
|
162
188
|
const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
|
|
163
189
|
|
|
164
|
-
const run = wave.length === 1
|
|
165
|
-
?
|
|
166
|
-
:
|
|
190
|
+
const run = enqueueHost(() => wave.length === 1
|
|
191
|
+
? rpc("call", ["read", wave[0].args]).then(leanEnvelope).then(res => ({ values: [unwrapRead(res, wave[0].args)], errors: [] }))
|
|
192
|
+
: rpc("call", ["read", args]).then(leanEnvelope).then(res => { unwrapRead(res, args);
|
|
167
193
|
|
|
168
|
-
return { values: res.items, errors: res.itemErrors ?? [] }; });
|
|
194
|
+
return { values: res.items, errors: res.itemErrors ?? [] }; }));
|
|
169
195
|
|
|
170
|
-
|
|
196
|
+
const delivery = run.then(({ values, errors }) => {
|
|
171
197
|
if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
|
|
172
198
|
|
|
173
199
|
for (let i = 0; i < wave.length; i++) {
|
|
@@ -177,28 +203,80 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
177
203
|
else wave[i].resolve(value);
|
|
178
204
|
}
|
|
179
205
|
}).catch(error => { for (const job of wave) job.reject(error); });
|
|
206
|
+
|
|
207
|
+
pendingReadWaves.add(delivery);
|
|
208
|
+
void delivery.finally(() => pendingReadWaves.delete(delivery));
|
|
180
209
|
}
|
|
181
210
|
}
|
|
182
211
|
|
|
183
|
-
|
|
212
|
+
async function drainReads() {
|
|
213
|
+
for (;;) {
|
|
214
|
+
flushReads();
|
|
215
|
+
const waves = [...pendingReadWaves];
|
|
184
216
|
|
|
185
|
-
|
|
217
|
+
if (!waves.length) return;
|
|
218
|
+
await Promise.allSettled(waves);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const invoke = (name, args) => {
|
|
223
|
+
assertScope(); flushReads();
|
|
224
|
+
const promise = nova.call(name, args);
|
|
186
225
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
226
|
+
promise.catch(() => {});
|
|
227
|
+
|
|
228
|
+
return promise;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const readArgs = (p, a, b) => {
|
|
232
|
+
if (isObject(p) && !Array.isArray(p)) {
|
|
233
|
+
const args = { ...p, path: p.path ?? p.target ?? p.query };
|
|
234
|
+
|
|
235
|
+
if (p.path === undefined && p.target !== undefined) delete args.target;
|
|
236
|
+
|
|
237
|
+
return args;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
|
|
241
|
+
};
|
|
190
242
|
|
|
191
243
|
const read = async (p, a, b) => {
|
|
192
244
|
assertScope();
|
|
193
245
|
const args = sessionJsonArgs(readArgs(p, a, b));
|
|
246
|
+
if (isString(args.path) && args.resolve === undefined && args.complete !== true && args.json === undefined && args.about === undefined && args.query === undefined && !looksLikePath(args.path) && !/^(?:agent|artifact):\/\//i.test(args.path)) {
|
|
247
|
+
args.resolve = true;
|
|
248
|
+
}
|
|
194
249
|
validateJsonRead(args);
|
|
195
|
-
const decode = value => args.resolve || args.json !== undefined ? JSON.parse(value) : value;
|
|
196
250
|
|
|
197
|
-
|
|
198
|
-
|
|
251
|
+
for (const [key, value] of [["resolve", args.resolve], ["complete", args.complete], ["outline", args.outline], ["evidence", args.evidence]]) {
|
|
252
|
+
if (value !== undefined && typeof value !== "boolean") throw new Error("read " + key + " must be a boolean");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (args.about !== undefined && !isString(args.about)) throw new Error("read about must be a string");
|
|
256
|
+
if (args.query !== undefined && !isString(args.query)) throw new Error("read query must be a string");
|
|
257
|
+
const focusModes = [args.about !== undefined, args.query !== undefined, args.outline === true].filter(Boolean).length;
|
|
258
|
+
|
|
259
|
+
if (focusModes > 1 || (args.outline === true && args.evidence === true)) throw new Error("read accepts only one of about, query, outline, or evidence");
|
|
260
|
+
if (args.resolve === true && args.complete === true) throw new Error("read accepts either resolve or complete, not both");
|
|
261
|
+
const decode = value => {
|
|
262
|
+
if ((!args.resolve && args.json === undefined) || !isString(value)) return value;
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
return JSON.parse(value);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const selector = args.json === undefined ? "" : " (" + (Array.isArray(args.json) ? args.json.join(", ") : String(args.json)) + ")";
|
|
268
|
+
throw new Error("JSON read failed for " + String(args.path ?? args.target ?? "resource") + selector + ": " + (error instanceof Error ? error.message : String(error)));
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
if (args.complete === true && (args.outline || args.evidence || args.about || args.query)) throw new Error("complete:true requires a raw file read, not a source view");
|
|
273
|
+
const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : (isString(p) && looksLikePath(p) ? p : undefined);
|
|
199
274
|
p = args.path;
|
|
200
275
|
|
|
201
|
-
if (args.evidence)
|
|
276
|
+
if (args.evidence) {
|
|
277
|
+
const query = args.about ?? args.query ?? (isString(p) ? p : undefined);
|
|
278
|
+
return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query }));
|
|
279
|
+
}
|
|
202
280
|
|
|
203
281
|
if (args.outline) return unwrapJsonValue(await invoke("surface", args));
|
|
204
282
|
|
|
@@ -206,9 +284,24 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
206
284
|
if (p.length > 64) throw new Error("read accepts at most 64 paths per batch");
|
|
207
285
|
|
|
208
286
|
for (const item of p) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
|
|
209
|
-
const readEach = () =>
|
|
287
|
+
const readEach = async () => {
|
|
288
|
+
const values = await Promise.all(p.map(item => read({ ...args, path: item })));
|
|
289
|
+
const missing = args.resolve ? values.findIndex((value, index) => value?.status === "not_found" && looksLikePath(p[index])) : -1;
|
|
290
|
+
|
|
291
|
+
if (missing >= 0) throw new Error(`read failed for ${p[missing]}: not_found; use Promise.allSettled(paths.map(path => read(path))) for per-path outcomes`);
|
|
292
|
+
|
|
293
|
+
return values;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const guardedReadEach = () => {
|
|
297
|
+
const promise = readEach();
|
|
298
|
+
|
|
299
|
+
promise.catch(() => {});
|
|
300
|
+
|
|
301
|
+
return promise;
|
|
302
|
+
};
|
|
210
303
|
|
|
211
|
-
if (!batchRead || args.resolve || p.some(item => /^(agent|artifact):\/\/.*\?/i.test(item))) return
|
|
304
|
+
if (!batchRead || args.resolve || p.some(item => /^(agent|artifact):\/\/.*\?/i.test(item))) return guardedReadEach();
|
|
212
305
|
const res = await invoke("read", args);
|
|
213
306
|
const failed = res?.itemErrors?.findIndex(error => error != null) ?? -1;
|
|
214
307
|
|
|
@@ -218,7 +311,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
218
311
|
if (Array.isArray(res?.items)) return res.items.map(decode);
|
|
219
312
|
|
|
220
313
|
// Captured host executor without batch support: fan out.
|
|
221
|
-
return
|
|
314
|
+
return guardedReadEach();
|
|
222
315
|
}
|
|
223
316
|
|
|
224
317
|
if (!batchRead) {
|
|
@@ -232,19 +325,44 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
232
325
|
|
|
233
326
|
if (queuedReads.length && queuedReads[0].key !== key) flushReads();
|
|
234
327
|
|
|
235
|
-
|
|
328
|
+
const promise = new Promise((resolve, reject) => {
|
|
236
329
|
queuedReads.push({ args, key, resolve, reject });
|
|
237
330
|
|
|
238
331
|
if (queuedReads.length === 1) queueMicrotask(flushReads);
|
|
239
332
|
}).then(decode);
|
|
333
|
+
|
|
334
|
+
promise.catch(() => {});
|
|
335
|
+
|
|
336
|
+
return promise;
|
|
240
337
|
};
|
|
241
338
|
|
|
242
339
|
const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
|
|
243
340
|
|
|
341
|
+
const viewSpan = (value) => {
|
|
342
|
+
const start = isNumber(value.start) ? value.start : Array.isArray(value.lines) ? value.lines[0] : value.line;
|
|
343
|
+
const end = isNumber(value.end) ? value.end : Array.isArray(value.lines) && value.lines.length > 1 ? value.lines[1] : start;
|
|
344
|
+
|
|
345
|
+
if (!isNumber(start) || !isNumber(end) || start < 1 || end < start) return null;
|
|
346
|
+
|
|
347
|
+
return { start: Math.floor(start), end: Math.floor(end) };
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const isView = (value) => isObject(value) && !Array.isArray(value) && isString(value.path) && value.path.trim() && isString(value.text) && (value.status === undefined || value.status === "found") && viewSpan(value);
|
|
351
|
+
|
|
244
352
|
const edit = async (p, oldText, newText) => {
|
|
245
353
|
if (isFunction(p)) return nova.speculate(p);
|
|
246
354
|
const usage = 'invalid edit signature; use edit(path,oldText,newText), edit({path,edits:[{oldText,newText}]}), or edit({path,patch:"@@ -1 +1 @@\n-old\n+new\n"})';
|
|
247
355
|
|
|
356
|
+
if (isView(p) && isString(oldText) && (newText === undefined || isString(newText))) {
|
|
357
|
+
if (isNumber(p.nextOffset)) throw new Error("edit view is incomplete");
|
|
358
|
+
const span = viewSpan(p);
|
|
359
|
+
const args = { path: p.path, viewStart: span.start, viewEnd: span.end, viewText: p.text, newText: newText === undefined ? oldText : newText };
|
|
360
|
+
|
|
361
|
+
if (newText !== undefined) args.oldText = oldText;
|
|
362
|
+
|
|
363
|
+
return unwrapValue(await invoke("edit", args));
|
|
364
|
+
}
|
|
365
|
+
|
|
248
366
|
if (isObject(p) && (Array.isArray(p) || oldText !== undefined || newText !== undefined)) throw new Error(usage);
|
|
249
367
|
|
|
250
368
|
if (!isObject(p) && isObject(oldText) && !Array.isArray(oldText)) throw new Error(usage);
|
|
@@ -268,19 +386,22 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
268
386
|
return unwrapValue(await invoke(args.patch === undefined ? "edit" : "apply_patch", args));
|
|
269
387
|
};
|
|
270
388
|
|
|
271
|
-
const patch = async (p, diff) => unwrapValue(await nova.call("apply_patch", { path: p, patch: diff }));
|
|
272
|
-
|
|
273
389
|
const bash = async (command, opts) => {
|
|
274
390
|
const args = isObject(command) ? { ...command } : { command, ...opts };
|
|
275
391
|
|
|
276
392
|
if (args.args !== undefined) {
|
|
277
|
-
if (!isString(args.command) || !Array.isArray(args.args)
|
|
393
|
+
if (!isString(args.command) || !Array.isArray(args.args)) throw new Error("bash argv requires a command string and an array of string args");
|
|
394
|
+
|
|
395
|
+
for (let i = 0; i < args.args.length; i++) if (!isString(args.args[i])) throw new Error("bash argv requires a command string and an array of string args");
|
|
396
|
+
args.args = args.args.map(String);
|
|
278
397
|
|
|
279
|
-
|
|
280
|
-
|
|
398
|
+
// Literal argv is a Supernova-owned contract. Host bash tools often ignore
|
|
399
|
+
// `args` and would run only `command` (bare `ssh`). Windows still needs a shell.
|
|
400
|
+
if (process.platform === "win32") {
|
|
281
401
|
delete args._directArgv;
|
|
282
402
|
args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
|
|
283
|
-
|
|
403
|
+
delete args.args;
|
|
404
|
+
} else args._directArgv = true;
|
|
284
405
|
}
|
|
285
406
|
|
|
286
407
|
command = args.command;
|
|
@@ -307,20 +428,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
307
428
|
return text;
|
|
308
429
|
};
|
|
309
430
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const exec = async (cmd, args, opts) => {
|
|
313
|
-
const command = String(cmd ?? "").trim();
|
|
314
|
-
|
|
315
|
-
if (!command) throw new Error("exec requires command");
|
|
316
|
-
|
|
317
|
-
// exec("git status") is a shell line; exec("git", ["status"]) is argv.
|
|
318
|
-
if (!Array.isArray(args) || args.length === 0) return bash(command, opts);
|
|
319
|
-
|
|
320
|
-
return bash([command, ...args].map(quoteShellArg).join(" "), opts);
|
|
321
|
-
};
|
|
322
|
-
|
|
323
|
-
return { nova, read, write, edit, patch, surface: nova.surface, snap: nova.snap, evidence: nova.evidence, bash, exec, speculate: nova.speculate };
|
|
431
|
+
return { read, write, edit, bash, nova };
|
|
324
432
|
}
|
|
325
433
|
|
|
326
434
|
function makeConsole(runId, limits) {
|
|
@@ -344,7 +452,10 @@ function makeConsole(runId, limits) {
|
|
|
344
452
|
if (isString(a)) return a;
|
|
345
453
|
|
|
346
454
|
try {
|
|
347
|
-
|
|
455
|
+
const plain = toPlain(a);
|
|
456
|
+
const encoded = JSON.stringify(plain);
|
|
457
|
+
|
|
458
|
+
return encoded === undefined ? String(plain) : encoded;
|
|
348
459
|
} catch {
|
|
349
460
|
return String(a);
|
|
350
461
|
}
|
package/src/runtime/parallel.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { isFunction } from "../shared/decode.js";
|
|
1
|
+
import { isFunction, isObject } from "../shared/decode.js";
|
|
2
2
|
|
|
3
3
|
const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "find", "ls", "snap", "evidence", "surface", "asgrep_search", "asgrep_status", "ast_grep", "web_search"]);
|
|
4
4
|
|
|
5
5
|
const READ_ONLY_LSP = new Set(["definition", "references", "hover", "symbols", "diagnostics", "implementation", "type_definition", "incoming_calls", "outgoing_calls"]);
|
|
6
6
|
|
|
7
7
|
export function isMutatingTool(name, config = {}, args = {}, definition) {
|
|
8
|
+
if (!isObject(args)) args = {};
|
|
9
|
+
name = String(name);
|
|
10
|
+
|
|
8
11
|
if ((config.mutatingTools ?? []).includes(name)) return true;
|
|
9
12
|
|
|
10
13
|
if ((config.mutatingPrefixes ?? []).some(prefix => prefix && name.startsWith(prefix))) return true;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isObject, isString } from "../shared/decode.js";
|
|
2
2
|
import { truncateChars } from "../output/format.js";
|
|
3
3
|
|
|
4
|
-
const textOf = result => result.content.filter(block => block
|
|
4
|
+
const textOf = result => (Array.isArray(result?.content) ? result.content : []).filter(block => block?.type === "text").map(block => block.text).join("\n");
|
|
5
5
|
|
|
6
6
|
const mutationTotals = results => results.reduce((total, result) => {
|
|
7
7
|
const m = result.details?.mutations;
|
|
@@ -24,7 +24,7 @@ export function programBatchText(results, total, stopped = "") {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
function batchInputs(params, config) {
|
|
27
|
-
if (["code","file"
|
|
27
|
+
if (["code","file"].some(key => params[key] !== undefined)) throw new Error("programs cannot combine with top-level code or file; no programs ran");
|
|
28
28
|
|
|
29
29
|
if (!Array.isArray(params.programs) || !params.programs.length || params.programs.length > 32) throw new Error("programs requires 1..32 entries; no programs ran");
|
|
30
30
|
|
|
@@ -35,20 +35,31 @@ function batchInputs(params, config) {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const hasDefault = params.data !== undefined;
|
|
38
39
|
let encoded;
|
|
39
40
|
|
|
40
|
-
try { encoded = JSON.stringify(params.programs); } catch { throw new Error("programs must be JSON-serializable; no programs ran"); }
|
|
41
|
+
try { encoded = JSON.stringify(hasDefault ? {programs:params.programs,data:params.data} : params.programs); } catch { throw new Error("programs and data must be JSON-serializable; no programs ran"); }
|
|
41
42
|
|
|
42
43
|
if (encoded.length > (config.maxCodeChars ?? 48000)) throw new Error("programs JSON exceeds the code character budget; no programs ran");
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
const parsed = JSON.parse(encoded);
|
|
46
|
+
|
|
47
|
+
if (!hasDefault) return parsed;
|
|
48
|
+
if (!Object.hasOwn(parsed,"data")) throw new Error("data must be JSON-serializable; no programs ran");
|
|
49
|
+
|
|
50
|
+
// The runtime snapshots data separately for each fresh guest. An explicit
|
|
51
|
+
// entry replaces the default wholesale; falsy values are not missing values.
|
|
52
|
+
return parsed.programs.map(program => program.data === undefined ? {...program,data:parsed.data} : program);
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
/** Explicit known continuations, not inferred plans, retries, or a shared heap. */
|
|
48
56
|
export async function runProgramBatch(id, params, signal, onUpdate, ctx, config, execute) {
|
|
49
57
|
const programs = batchInputs(params,config);
|
|
58
|
+
const requestedTimeout = params.timeoutMs === undefined ? config.timeoutMs : Number(params.timeoutMs);
|
|
59
|
+
|
|
60
|
+
if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) throw new Error("program batch timeoutMs must be a positive finite number");
|
|
50
61
|
const started = performance.now();
|
|
51
|
-
const timeout =
|
|
62
|
+
const timeout = requestedTimeout;
|
|
52
63
|
const deadline = started + timeout;
|
|
53
64
|
const controller = new AbortController();
|
|
54
65
|
const combined = signal ? AbortSignal.any([signal,controller.signal]) : controller.signal;
|
|
@@ -76,7 +87,7 @@ export async function runProgramBatch(id, params, signal, onUpdate, ctx, config,
|
|
|
76
87
|
trace.push(...(result.details?.trace ?? []));
|
|
77
88
|
let image = 0;
|
|
78
89
|
|
|
79
|
-
for (const block of result.content) if (block
|
|
90
|
+
for (const block of Array.isArray(result?.content) ? result.content : []) if (block?.type === "image" && isString(block.data)) {
|
|
80
91
|
imageBytes += Buffer.byteLength(block.data,"base64");
|
|
81
92
|
|
|
82
93
|
if (images.length >= 16 || imageBytes > 20*1024*1024) { stopped = "batch image budget exceeded; remaining programs did not run"; break; }
|
|
@@ -4,6 +4,9 @@ import { resolveWorkspacePath } from "../fs/workspace.js";
|
|
|
4
4
|
/** Explicit source reuse, never a cached program or a persistent guest heap. */
|
|
5
5
|
export async function readProgramFile(file, cwd, maxChars, signal) {
|
|
6
6
|
signal?.throwIfAborted();
|
|
7
|
+
const chars = Number(maxChars);
|
|
8
|
+
|
|
9
|
+
if (!Number.isInteger(chars) || chars <= 0) throw new Error("program file maxChars must be a positive integer");
|
|
7
10
|
const target = await resolveWorkspacePath(cwd, file, "program file", false, true);
|
|
8
11
|
// A FIFO must fail without waiting for a writer or occupying an I/O worker.
|
|
9
12
|
const handle = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
@@ -15,8 +18,8 @@ export async function readProgramFile(file, cwd, maxChars, signal) {
|
|
|
15
18
|
if (!stat.isFile()) throw new Error("program file requires a regular file");
|
|
16
19
|
// Every UTF-16 unit needs at most three UTF-8 bytes. Also check streamed size:
|
|
17
20
|
// another editor can grow the file after stat. No prefix-only execution.
|
|
18
|
-
const maxBytes =
|
|
19
|
-
const tooLarge = () => new Error("code exceeds " +
|
|
21
|
+
const maxBytes = chars * 3;
|
|
22
|
+
const tooLarge = () => new Error("code exceeds " + chars + " characters; split the program");
|
|
20
23
|
|
|
21
24
|
if (stat.size > maxBytes) throw tooLarge();
|
|
22
25
|
const chunks = [];
|
|
@@ -33,7 +36,7 @@ export async function readProgramFile(file, cwd, maxChars, signal) {
|
|
|
33
36
|
// Do not silently replace invalid bytes in executable source. Preserve BOMs.
|
|
34
37
|
const code = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
|
|
35
38
|
|
|
36
|
-
if (code.length >
|
|
39
|
+
if (code.length > chars) throw tooLarge();
|
|
37
40
|
|
|
38
41
|
return code;
|
|
39
42
|
} finally { await handle.close(); }
|
package/src/runtime/reference.js
CHANGED
|
@@ -1,25 +1,18 @@
|
|
|
1
1
|
// Complete model-facing API reference; kept in every request, not moved into history.
|
|
2
|
-
export const REFERENCE = `
|
|
2
|
+
export const REFERENCE = `JavaScript async body or arrow with read, write, edit, bash. Strings stay raw. Exactly one of code or file; file rereads that program (same limits, cwd, fresh guest). Caps are UTF-16. Put Markdown/scripts/argv in data.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
read(
|
|
6
|
-
read(
|
|
7
|
-
read(
|
|
8
|
-
read("
|
|
9
|
-
read(
|
|
10
|
-
read({query,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
bash(command, {cwd?, timeoutMs?}) → bounded output; throws on non-zero exit
|
|
17
|
-
bash({command, args:[...]}) → literal argv without shell expansion of arguments
|
|
4
|
+
read(path|paths, offset?, limit?) → raw text or text[]; read(directory) → entries[]; up to 64 paths
|
|
5
|
+
read(imagePath) → image (PNG/JPEG/GIF/WebP/BMP); 20 MiB max, 16 attachments max
|
|
6
|
+
read({path,json:".field"}) → parsed JSON; .items[0:3], quoted keys, 1-64 selectors, or true; 16 MiB cap; no jq
|
|
7
|
+
read("agent://id?q=.answer") → JSON field from session artifacts
|
|
8
|
+
read("symbol or question") → same view as resolve:true; source questions use at most 16 keywords
|
|
9
|
+
read({query,resolve:true}) → {status,path,line,lines,text,complete,nextOffset?}
|
|
10
|
+
read(path,{about}) → matching windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations
|
|
11
|
+
write(path, text) → replace; write({path,content,append:true}) → append without a prior read
|
|
12
|
+
edit(path,oldText,newText) | edit({path,edits}) | edit({path,patch}) → numbered post-edit lines, checks, references
|
|
13
|
+
edit(async () => {...}) → checkpoint: commit on success, rollback on throw; no shell, nesting, or outside commands
|
|
14
|
+
bash(command,{cwd?,timeoutMs?}) → bounded output; nonzero throws
|
|
15
|
+
bash({command,args}) → literal argv, no shell expansion of args
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Object arguments also work: read({path, offset?, limit?, about?, outline?, evidence?, resolve?, complete?}), edit({path, edits:[{oldText,newText}]}), edit({path,patch}), write({path,content}), bash({command,timeoutMs?}).
|
|
22
|
-
File changes stage until program success; later errors roll them back. Shell calls commit preceding writes and cannot be rolled back. Outcomes report committed/rolledBack file versions and external-call attempts.
|
|
23
|
-
Independent read starts batch automatically. Mutations preserve submission order. Plain reads remain self-contained; oversized reads provide continuation offsets. Return only what the model needs. console.log is captured.
|
|
24
|
-
|
|
25
|
-
For known continuations use programs:[{code|file,data?},...]. Entries run sequentially in fresh guests with separate commits. The batch stops on failure and returns all attempted results, including a typed stop report; earlier commits remain. Deadlines, host calls, logs and output are shared across the batch. Use separate calls when the next action needs model reasoning.`;
|
|
17
|
+
edit oldText is an exact substring of read(); a miss includes a numbered window. Found is a span, not the file; uncertain returns ambiguous, not_found, or incomplete. Check resolve:true status; edit(view,text) replaces that window; edit(view,old,new) is unique inside it. complete:true rejects partial files; use about or offset/limit for large audits. Array reads reject failures; Promise.allSettled for per-path outcomes. Edits stage until success; bash commits preceding writes.
|
|
18
|
+
programs:[{code|file,data?},...] sequential fresh guests, separate commits; top-level data defaults per entry, explicit entry data replaces it. Stop on failure keeps earlier commits. Separate calls when the next step needs a model decision.`;
|
package/src/runtime/runtime.js
CHANGED
|
@@ -47,7 +47,7 @@ function prepareProgram(code) {
|
|
|
47
47
|
|
|
48
48
|
if (candidate && FUNCTION_TYPES.has(candidate.type)) {
|
|
49
49
|
expression = candidate;
|
|
50
|
-
expressionSource = code.slice(
|
|
50
|
+
expressionSource = code.slice(statement.start, statement.end).replace(/;\s*$/, "");
|
|
51
51
|
}
|
|
52
52
|
} catch (bodyError) {
|
|
53
53
|
expressionSource = code.trimEnd().replace(/;+\s*$/, "");
|
|
@@ -154,8 +154,6 @@ const RPC_METHODS = {
|
|
|
154
154
|
|
|
155
155
|
return Array.isArray(wave) ? { results: [...wave], mode: wave.mode, reason: wave.reason } : wave;
|
|
156
156
|
},
|
|
157
|
-
search: (nova, args) => nova.search(args[0], args[1]),
|
|
158
|
-
describe: (nova, args) => nova.describe(args[0]),
|
|
159
157
|
speculateBegin: (nova) => nova.speculateBegin(),
|
|
160
158
|
speculateCommit: (nova) => nova.speculateCommit(),
|
|
161
159
|
speculateRollback: (nova) => nova.speculateRollback(),
|
|
@@ -187,7 +185,10 @@ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, n
|
|
|
187
185
|
|
|
188
186
|
if (signal?.aborted) return fail(ABORT_MESSAGE);
|
|
189
187
|
const runId = ++runSeq;
|
|
190
|
-
const
|
|
188
|
+
const requestedTimeout = Number(config.timeoutMs === undefined ? 60000 : config.timeoutMs);
|
|
189
|
+
|
|
190
|
+
if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) return fail("timeoutMs must be a positive finite number");
|
|
191
|
+
const timeoutMs = Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout)));
|
|
191
192
|
const rssLimit = rssBytes() + (config.maxHeapMb ?? 512) * MEMORY_SLACK * 1048576;
|
|
192
193
|
|
|
193
194
|
return new Promise((resolve) => {
|
|
@@ -197,6 +198,7 @@ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, n
|
|
|
197
198
|
let completing = false;
|
|
198
199
|
let hostError;
|
|
199
200
|
let notifyingHost = false;
|
|
201
|
+
let aborting = false;
|
|
200
202
|
const pending = new Set();
|
|
201
203
|
const inputController = new AbortController();
|
|
202
204
|
|
|
@@ -226,11 +228,13 @@ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, n
|
|
|
226
228
|
};
|
|
227
229
|
|
|
228
230
|
const abort = () => {
|
|
229
|
-
if (finished) return;
|
|
231
|
+
if (finished || aborting) return;
|
|
232
|
+
aborting = true;
|
|
230
233
|
cancelHost();
|
|
231
234
|
|
|
232
235
|
try { onTimeout?.(); } catch {}
|
|
233
236
|
|
|
237
|
+
aborting = false;
|
|
234
238
|
finish(fail(ABORT_MESSAGE));
|
|
235
239
|
};
|
|
236
240
|
|
|
@@ -269,8 +273,11 @@ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, n
|
|
|
269
273
|
handle?.worker.off("exit", onExit);
|
|
270
274
|
void killWorker(handle);
|
|
271
275
|
|
|
272
|
-
if (!outcome.ok) cancelHost();
|
|
273
|
-
|
|
276
|
+
if (pending.size || !outcome.ok) cancelHost();
|
|
277
|
+
// A host tool that ignores cancellation must not keep the result unsettled
|
|
278
|
+
// after the guest worker is gone. Give it a brief drain window only.
|
|
279
|
+
await Promise.race([Promise.allSettled(pending), new Promise(resolve => setTimeout(resolve, 250))]);
|
|
280
|
+
if (pending.size && outcome.ok) hostError ??= "program completed with a host call still running";
|
|
274
281
|
|
|
275
282
|
if (finished) return;
|
|
276
283
|
finish(outcome.ok && hostError ? fail(hostError) : outcome);
|
|
@@ -341,7 +348,9 @@ export async function runGuestProgram({ code, file, cwd = process.cwd(), data, n
|
|
|
341
348
|
await handle.ready;
|
|
342
349
|
|
|
343
350
|
if (finished || signal?.aborted) return abort();
|
|
344
|
-
|
|
351
|
+
let available = isFunction(nova.names) ? await nova.names() : [];
|
|
352
|
+
|
|
353
|
+
if (!Array.isArray(available)) available = [];
|
|
345
354
|
|
|
346
355
|
if (finished || signal?.aborted) return abort();
|
|
347
356
|
handle.worker.on("message", onMessage);
|