pi-supernova 0.3.1 → 0.4.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 +220 -3
- package/docs/CHANGELOG.md +40 -0
- package/docs/TOKEN_COSTS.md +171 -0
- package/index.js +116 -37
- package/package.json +3 -1
- package/src/bridge/catalog.js +37 -2
- package/src/bridge/host-bridge.js +340 -13
- package/src/bridge/native-tools.js +35 -1
- package/src/config/config.default.json +1 -1
- package/src/config/config.js +19 -0
- package/src/context/evidence.js +119 -5
- package/src/context/fuzzy.js +37 -0
- package/src/context/ledger.js +128 -6
- package/src/context/outline.js +45 -1
- package/src/context/repo-index.js +63 -1
- package/src/context/search.js +45 -0
- package/src/context/snap.js +80 -4
- package/src/context/surface.js +25 -0
- package/src/fs/check.js +50 -0
- package/src/fs/diff.js +16 -0
- package/src/fs/json-read.js +87 -0
- package/src/fs/patch.js +26 -0
- package/src/fs/vfs.js +101 -6
- package/src/fs/workspace.js +36 -0
- package/src/output/bottleneck.js +46 -0
- package/src/output/format.js +117 -17
- package/src/runtime/guest-worker.js +151 -18
- package/src/runtime/parallel.js +33 -0
- package/src/runtime/program-batch.js +112 -0
- package/src/runtime/program-file.js +40 -0
- package/src/runtime/reference.js +25 -0
- package/src/runtime/runtime.js +90 -6
- package/src/shared/decode.js +29 -0
- package/src/ui/omp-frame.js +30 -1
- package/src/ui/render-measure.js +24 -0
- package/src/ui/render.js +96 -2
package/src/output/format.js
CHANGED
|
@@ -4,7 +4,9 @@ export function truncateChars(text, maxChars, label = "value") {
|
|
|
4
4
|
const normalized = isString(text) ? text : String(text ?? "");
|
|
5
5
|
const numericLimit = Number(maxChars);
|
|
6
6
|
const limit = Number.isFinite(numericLimit) ? Math.max(0, Math.floor(numericLimit)) : numericLimit === Infinity ? normalized.length : 0;
|
|
7
|
+
|
|
7
8
|
if (normalized.length <= limit) return { text: normalized, truncated: false };
|
|
9
|
+
|
|
8
10
|
if (limit <= 100) {
|
|
9
11
|
return {
|
|
10
12
|
text: normalized.slice(0, headEnd(normalized, limit)),
|
|
@@ -12,20 +14,25 @@ export function truncateChars(text, maxChars, label = "value") {
|
|
|
12
14
|
originalChars: normalized.length,
|
|
13
15
|
};
|
|
14
16
|
}
|
|
17
|
+
|
|
15
18
|
let head = headEnd(normalized, Math.floor(limit * 0.7));
|
|
16
19
|
let tail = 0;
|
|
17
20
|
let marker = "";
|
|
21
|
+
|
|
18
22
|
for (;;) {
|
|
19
23
|
const omitted = normalized.length - head - tail;
|
|
20
24
|
marker = "\n…[" + label + " truncated " + omitted + " chars]…\n";
|
|
25
|
+
|
|
21
26
|
if (marker.length > limit) return { text: normalized.slice(0, headEnd(normalized, limit)), truncated: true, originalChars: normalized.length };
|
|
22
27
|
const budget = limit - marker.length;
|
|
23
28
|
const nextHead = headEnd(normalized, Math.min(head, budget));
|
|
24
29
|
const nextTail = normalized.length - tailStartIndex(normalized, Math.max(0, budget - nextHead));
|
|
30
|
+
|
|
25
31
|
if (nextHead === head && nextTail === tail) break;
|
|
26
32
|
head = nextHead;
|
|
27
33
|
tail = nextTail;
|
|
28
34
|
}
|
|
35
|
+
|
|
29
36
|
return { text: normalized.slice(0, head) + marker + normalized.slice(normalized.length - tail), truncated: true, originalChars: normalized.length };
|
|
30
37
|
}
|
|
31
38
|
|
|
@@ -33,6 +40,7 @@ export function truncateChars(text, maxChars, label = "value") {
|
|
|
33
40
|
// boundary, so a cut must never split a surrogate pair.
|
|
34
41
|
function headEnd(text, end) {
|
|
35
42
|
const code = text.charCodeAt(end - 1);
|
|
43
|
+
|
|
36
44
|
return code >= 0xd800 && code <= 0xdbff ? end - 1 : end;
|
|
37
45
|
}
|
|
38
46
|
|
|
@@ -40,29 +48,71 @@ function tailStartIndex(text, tail) {
|
|
|
40
48
|
if (tail <= 0) return text.length;
|
|
41
49
|
const start = text.length - tail;
|
|
42
50
|
const code = text.charCodeAt(start);
|
|
51
|
+
|
|
43
52
|
return code >= 0xdc00 && code <= 0xdfff ? start + 1 : start;
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
// Unicode mode matches lone surrogate code points, not valid UTF-16 pairs.
|
|
47
56
|
const UNPAIRED_SURROGATE = /[\uD800-\uDFFF]/u;
|
|
48
57
|
|
|
58
|
+
// Without the Unicode flag this class matches any surrogate code unit, including
|
|
59
|
+
// the halves of a valid pair, so it is a strictly wider test. Source text almost
|
|
60
|
+
// never contains a surrogate at all, and the wide scan decides those strings on
|
|
61
|
+
// its own; only text that really has one pays for the precise Unicode pass.
|
|
62
|
+
const SURROGATE_CODE_UNIT = /[\uD800-\uDFFF]/;
|
|
63
|
+
|
|
64
|
+
function hasUnpairedSurrogate(text) {
|
|
65
|
+
return SURROGATE_CODE_UNIT.test(text) && UNPAIRED_SURROGATE.test(text);
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
function hasWellFormedStrings(values) {
|
|
50
|
-
for (const value of values) if (!isString(value) ||
|
|
69
|
+
for (const value of values) if (!isString(value) || hasUnpairedSurrogate(value)) return false;
|
|
70
|
+
|
|
51
71
|
return true;
|
|
52
72
|
}
|
|
53
73
|
|
|
54
74
|
/** Lossless framing for source arrays, not string escaping or source compression. */
|
|
55
75
|
export function formatReturn(value) {
|
|
56
76
|
if (isString(value)) return value;
|
|
77
|
+
|
|
57
78
|
if (Array.isArray(value) && value.length && hasWellFormedStrings(value) && value.some(text => text.includes("\n"))) {
|
|
58
79
|
const raw = "strings[" + value.length + "]\n" + value.map((text, i) => "[" + i + "] " + text.length + " UTF-16 units\n" + text + "\n").join("");
|
|
59
80
|
const escapedSize = value.reduce((sum, text) => sum + JSON.stringify(text).length, value.length + 1);
|
|
81
|
+
|
|
60
82
|
if (raw.length < escapedSize) return raw;
|
|
61
83
|
}
|
|
62
|
-
|
|
84
|
+
|
|
85
|
+
const escaped = formatValue(value);
|
|
86
|
+
const strings = [];
|
|
87
|
+
|
|
88
|
+
const visit = input => {
|
|
89
|
+
if (isString(input) && input.includes("\n") && !hasUnpairedSurrogate(input) && JSON.stringify(input).length - input.length > 64) {
|
|
90
|
+
const index = strings.push(input) - 1;
|
|
91
|
+
|
|
92
|
+
return { [RAW_TEXT]: "raw[" + index + "]" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (Array.isArray(input)) return input.map(visit);
|
|
96
|
+
|
|
97
|
+
if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, visit(child)]));
|
|
98
|
+
|
|
99
|
+
return input;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const referencedValue = visit(value);
|
|
103
|
+
|
|
104
|
+
if (!strings.length) return escaped;
|
|
105
|
+
// Keep every key, value, duplicate string and byte. References are unquoted
|
|
106
|
+
// expressions, so literal "raw[0]" values and header-like source cannot collide.
|
|
107
|
+
const framed = formatValue(referencedValue) + "\nraw strings[" + strings.length + "]\n" + strings.map((text, i) => "raw[" + i + "] " + text.length + " UTF-16 units\n" + text + "\n").join("");
|
|
108
|
+
|
|
109
|
+
return framed.length < escaped.length ? framed : escaped;
|
|
63
110
|
}
|
|
64
111
|
|
|
112
|
+
const RAW_TEXT = Symbol("raw text reference");
|
|
113
|
+
|
|
65
114
|
const IDENT_KEY = /^[A-Za-z_$][\w$]*$/;
|
|
115
|
+
|
|
66
116
|
const FORMAT_WIDTH = 120;
|
|
67
117
|
|
|
68
118
|
function formatKey(key) {
|
|
@@ -71,26 +121,67 @@ function formatKey(key) {
|
|
|
71
121
|
|
|
72
122
|
function formatPrimitive(value) {
|
|
73
123
|
if (value === undefined) return "undefined";
|
|
124
|
+
|
|
74
125
|
if (Number.isNaN(value) || value === Infinity || value === -Infinity) return String(value);
|
|
126
|
+
|
|
75
127
|
return JSON.stringify(value) ?? String(value);
|
|
76
128
|
}
|
|
77
129
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Single-line rendering that gives up the moment it would exceed `limit` characters.
|
|
132
|
+
*
|
|
133
|
+
* formatValue consults this at every level, so the previous build-the-whole-string
|
|
134
|
+
* form re-walked each subtree once per ancestor: a container that is too wide paid
|
|
135
|
+
* the full cost of its children and then paid for them again while descending.
|
|
136
|
+
* Abandoning at the budget keeps the pass linear. Returning null is exactly
|
|
137
|
+
* equivalent to "the flat form is longer than limit", because an abandoned walk
|
|
138
|
+
* always has at least `used` characters still to come.
|
|
139
|
+
*/
|
|
140
|
+
function formatFlatWithin(value, limit) {
|
|
141
|
+
const parts = [];
|
|
142
|
+
let used = 0;
|
|
143
|
+
|
|
144
|
+
const push = (chunk) => {
|
|
145
|
+
if (used + chunk.length > limit) return false;
|
|
146
|
+
used += chunk.length;
|
|
147
|
+
parts.push(chunk);
|
|
148
|
+
|
|
149
|
+
return true;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return walkFlat(value, push) ? parts.join("") : null;
|
|
83
153
|
}
|
|
84
154
|
|
|
85
|
-
function
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
155
|
+
function walkFlat(value, push) {
|
|
156
|
+
if (value?.[RAW_TEXT] !== undefined) return push(value[RAW_TEXT]);
|
|
157
|
+
|
|
158
|
+
if (!isObject(value) && !Array.isArray(value)) return push(formatPrimitive(value));
|
|
159
|
+
|
|
160
|
+
if (Array.isArray(value)) {
|
|
161
|
+
if (value.length === 0) return push("[]");
|
|
162
|
+
|
|
163
|
+
if (!push("[")) return false;
|
|
164
|
+
|
|
165
|
+
for (let i = 0; i < value.length; i++) {
|
|
166
|
+
if (i && !push(",")) return false;
|
|
167
|
+
|
|
168
|
+
if (!walkFlat(value[i] === undefined ? null : value[i], push)) return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return push("]");
|
|
92
172
|
}
|
|
93
|
-
|
|
173
|
+
|
|
174
|
+
const keys = Object.keys(value).filter((key) => value[key] !== undefined);
|
|
175
|
+
|
|
176
|
+
if (keys.length === 0) return push("{}");
|
|
177
|
+
|
|
178
|
+
for (let i = 0; i < keys.length; i++) {
|
|
179
|
+
if (!push((i ? "," : "{") + formatKey(keys[i]) + ":")) return false;
|
|
180
|
+
|
|
181
|
+
if (!walkFlat(value[keys[i]], push)) return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return push("}");
|
|
94
185
|
}
|
|
95
186
|
|
|
96
187
|
/**
|
|
@@ -100,14 +191,23 @@ function formatFlat(value) {
|
|
|
100
191
|
* than JSON.stringify(value, null, 2) on typical shaped returns (gpt-tokenizer).
|
|
101
192
|
*/
|
|
102
193
|
export function formatValue(value, indent = "", width = FORMAT_WIDTH) {
|
|
103
|
-
|
|
104
|
-
|
|
194
|
+
if (value?.[RAW_TEXT] !== undefined) return value[RAW_TEXT];
|
|
195
|
+
|
|
196
|
+
if (!isObject(value) && !Array.isArray(value)) return formatPrimitive(value);
|
|
197
|
+
const flat = formatFlatWithin(value, width - indent.length);
|
|
198
|
+
|
|
199
|
+
if (flat !== null) return flat;
|
|
105
200
|
const pad = indent + " ";
|
|
201
|
+
|
|
106
202
|
if (Array.isArray(value)) {
|
|
107
203
|
if (value.length === 0) return "[]";
|
|
204
|
+
|
|
108
205
|
return "[\n" + value.map((item) => pad + formatValue(item === undefined ? null : item, pad, width)).join(",\n") + "\n" + indent + "]";
|
|
109
206
|
}
|
|
207
|
+
|
|
110
208
|
const keys = Object.keys(value).filter((key) => value[key] !== undefined);
|
|
209
|
+
|
|
111
210
|
if (keys.length === 0) return "{}";
|
|
211
|
+
|
|
112
212
|
return "{\n" + keys.map((key) => pad + formatKey(key) + ":" + formatValue(value[key], pad, width)).join(",\n") + "\n" + indent + "}";
|
|
113
213
|
}
|
|
@@ -2,27 +2,37 @@ import { parentPort } from "node:worker_threads";
|
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
3
|
import { isString, isObject, isFunction, toPlain } from "../shared/decode.js";
|
|
4
4
|
import { truncateChars } from "../output/format.js";
|
|
5
|
+
import { sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
|
|
5
6
|
|
|
6
7
|
// Guest programs run here, off the host thread. The host can terminate() this
|
|
7
8
|
// worker mid-loop, so a runaway "while (true) {}" or process.exit() in guest
|
|
8
9
|
// code cannot take the harness down with it.
|
|
9
10
|
|
|
10
11
|
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
|
|
12
|
+
|
|
11
13
|
const PARAMS = ["console", "read", "edit", "write", "bash"];
|
|
14
|
+
|
|
12
15
|
const BODY_LINE_OFFSET = 2;
|
|
16
|
+
|
|
13
17
|
/** Best-effort guest line:col from an error stack (V8 "<anonymous>:L:C", JSC "eval code").*/
|
|
14
18
|
function guestLocation(err) {
|
|
15
19
|
const stack = String(err?.stack);
|
|
16
20
|
const m = /(?:<anonymous>:|(?:eval code|anonymous)(?:@|:))(\d+):(\d+)/.exec(stack);
|
|
21
|
+
|
|
17
22
|
if (!m) return null;
|
|
18
23
|
const line = Number(m[1]) - BODY_LINE_OFFSET;
|
|
24
|
+
|
|
19
25
|
if (line < 1) return null;
|
|
26
|
+
|
|
20
27
|
return { line, col: Number(m[2]) };
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
let activeRunId = 0;
|
|
31
|
+
|
|
24
32
|
let runActive = false;
|
|
33
|
+
|
|
25
34
|
let rpcSeq = 0;
|
|
35
|
+
|
|
26
36
|
const pendingRpc = new Map();
|
|
27
37
|
|
|
28
38
|
function post(msg) {
|
|
@@ -31,9 +41,11 @@ function post(msg) {
|
|
|
31
41
|
|
|
32
42
|
function callRpc(runId, method, args) {
|
|
33
43
|
if (!runActive || runId !== activeRunId) return Promise.reject(new Error("program is already complete"));
|
|
44
|
+
|
|
34
45
|
return new Promise((resolve, reject) => {
|
|
35
46
|
const id = ++rpcSeq;
|
|
36
47
|
pendingRpc.set(id, { resolve, reject });
|
|
48
|
+
|
|
37
49
|
try {
|
|
38
50
|
post({ op: "rpc", id, runId, method, args });
|
|
39
51
|
} catch (err) {
|
|
@@ -45,12 +57,23 @@ function callRpc(runId, method, args) {
|
|
|
45
57
|
|
|
46
58
|
function unwrapValue(res) {
|
|
47
59
|
if (res?.ok === false) throw new Error(String(res.value ?? res.error ?? "host tool failed"));
|
|
60
|
+
|
|
48
61
|
if ("value" in Object(res)) return res.value;
|
|
62
|
+
|
|
49
63
|
return res;
|
|
50
64
|
}
|
|
51
65
|
|
|
66
|
+
function unwrapRead(res, args) {
|
|
67
|
+
const value = unwrapValue(res);
|
|
68
|
+
|
|
69
|
+
if ((args.complete === true || args.json !== undefined) && res?.truncated) throw new Error("incomplete read: complete:true or json refuses truncated host output");
|
|
70
|
+
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
52
74
|
function unwrapJsonValue(res) {
|
|
53
75
|
const value = unwrapValue(res);
|
|
76
|
+
|
|
54
77
|
try {
|
|
55
78
|
return JSON.parse(value);
|
|
56
79
|
} catch {
|
|
@@ -61,9 +84,13 @@ function unwrapJsonValue(res) {
|
|
|
61
84
|
/** Keep details/truncated reachable but out of the returned literal unless they carry signal. */
|
|
62
85
|
function leanEnvelope(res) {
|
|
63
86
|
if (!isObject(res)) return res;
|
|
87
|
+
|
|
64
88
|
if ("details" in res) Object.defineProperty(res, "details", { value: res.details, enumerable: false, writable: true });
|
|
89
|
+
|
|
65
90
|
for (const key of Object.keys(res)) if (res[key] === undefined) delete res[key];
|
|
91
|
+
|
|
66
92
|
if (res.truncated === false) delete res.truncated;
|
|
93
|
+
|
|
67
94
|
return res;
|
|
68
95
|
}
|
|
69
96
|
|
|
@@ -72,10 +99,13 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
72
99
|
const availableSet = new Set(available);
|
|
73
100
|
const checkpointScope = new AsyncLocalStorage();
|
|
74
101
|
let checkpoint = null;
|
|
102
|
+
|
|
75
103
|
const assertScope = () => {
|
|
76
104
|
const token = checkpointScope.getStore();
|
|
105
|
+
|
|
77
106
|
if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
|
|
78
107
|
};
|
|
108
|
+
|
|
79
109
|
const nova = {
|
|
80
110
|
search: (query, limit) => rpc("search", [query, limit]),
|
|
81
111
|
describe: (name) => rpc("describe", [name]),
|
|
@@ -88,6 +118,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
88
118
|
reason: { value: wave?.reason, enumerable: false },
|
|
89
119
|
results: { value: results, enumerable: false },
|
|
90
120
|
});
|
|
121
|
+
|
|
91
122
|
return results;
|
|
92
123
|
},
|
|
93
124
|
async speculate(fn) {
|
|
@@ -95,6 +126,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
95
126
|
const token = {};
|
|
96
127
|
checkpoint = token;
|
|
97
128
|
let began = false;
|
|
129
|
+
|
|
98
130
|
try {
|
|
99
131
|
flushReads();
|
|
100
132
|
await rpc("speculateBegin", []);
|
|
@@ -102,10 +134,13 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
102
134
|
const value = await checkpointScope.run(token, fn);
|
|
103
135
|
flushReads();
|
|
104
136
|
await rpc("speculateCommit", []);
|
|
137
|
+
|
|
105
138
|
return { ok: true, committed: true, value };
|
|
106
139
|
} catch (err) {
|
|
107
140
|
flushReads();
|
|
141
|
+
|
|
108
142
|
if (began) await rpc("speculateRollback", []);
|
|
143
|
+
|
|
109
144
|
return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
|
|
110
145
|
} finally { checkpoint = null; }
|
|
111
146
|
},
|
|
@@ -117,97 +152,171 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
117
152
|
|
|
118
153
|
// Coalesce already-started compatible reads without rewriting JS control flow.
|
|
119
154
|
let queuedReads = [];
|
|
155
|
+
|
|
120
156
|
function flushReads() {
|
|
121
157
|
const pending = queuedReads;
|
|
122
158
|
queuedReads = [];
|
|
159
|
+
|
|
123
160
|
for (let start = 0; start < pending.length; start += 64) {
|
|
124
161
|
const wave = pending.slice(start, start + 64);
|
|
125
162
|
const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
|
|
163
|
+
|
|
126
164
|
const run = wave.length === 1
|
|
127
|
-
? nova.call("read", wave[0].args).then(res => ({ values: [
|
|
128
|
-
: nova.call("read", args).then(res => {
|
|
165
|
+
? nova.call("read", wave[0].args).then(res => ({ values: [unwrapRead(res, wave[0].args)], errors: [] }))
|
|
166
|
+
: nova.call("read", args).then(res => { unwrapRead(res, args);
|
|
167
|
+
|
|
168
|
+
return { values: res.items, errors: res.itemErrors ?? [] }; });
|
|
169
|
+
|
|
129
170
|
void run.then(({ values, errors }) => {
|
|
130
171
|
if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
|
|
172
|
+
|
|
131
173
|
for (let i = 0; i < wave.length; i++) {
|
|
132
174
|
const value = values[i];
|
|
175
|
+
|
|
133
176
|
if (errors[i]) wave[i].reject(new Error(errors[i]));
|
|
134
177
|
else wave[i].resolve(value);
|
|
135
178
|
}
|
|
136
179
|
}).catch(error => { for (const job of wave) job.reject(error); });
|
|
137
180
|
}
|
|
138
181
|
}
|
|
139
|
-
|
|
182
|
+
|
|
183
|
+
const invoke = (name, args) => { assertScope(); flushReads();
|
|
184
|
+
|
|
185
|
+
return nova.call(name, args); };
|
|
186
|
+
|
|
140
187
|
const readArgs = (p, a, b) => isObject(p) && !Array.isArray(p)
|
|
141
188
|
? { ...p, path: p.path ?? p.query }
|
|
142
189
|
: isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
|
|
190
|
+
|
|
143
191
|
const read = async (p, a, b) => {
|
|
144
192
|
assertScope();
|
|
145
|
-
const args = readArgs(p, a, b);
|
|
193
|
+
const args = sessionJsonArgs(readArgs(p, a, b));
|
|
194
|
+
validateJsonRead(args);
|
|
195
|
+
const decode = value => args.resolve || args.json !== undefined ? JSON.parse(value) : value;
|
|
196
|
+
|
|
197
|
+
if (args.complete === true && (args.outline || args.evidence || args.about)) throw new Error("complete:true requires a raw file read, not an outline or evidence view");
|
|
146
198
|
const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : args.about ? p : undefined;
|
|
147
199
|
p = args.path;
|
|
200
|
+
|
|
148
201
|
if (args.evidence) return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query: args.about ?? args.query ?? p }));
|
|
202
|
+
|
|
149
203
|
if (args.outline) return unwrapJsonValue(await invoke("surface", args));
|
|
204
|
+
|
|
150
205
|
if (Array.isArray(p)) {
|
|
151
206
|
if (p.length > 64) throw new Error("read accepts at most 64 paths per batch");
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (!batchRead || args.resolve) return readEach();
|
|
207
|
+
|
|
208
|
+
for (const item of p) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
|
|
209
|
+
const readEach = () => Promise.all(p.map(item => read({ ...args, path: item })));
|
|
210
|
+
|
|
211
|
+
if (!batchRead || args.resolve || p.some(item => /^(agent|artifact):\/\/.*\?/i.test(item))) return readEach();
|
|
158
212
|
const res = await invoke("read", args);
|
|
159
|
-
|
|
160
|
-
|
|
213
|
+
const failed = res?.itemErrors?.findIndex(error => error != null) ?? -1;
|
|
214
|
+
|
|
215
|
+
if (failed >= 0) throw new Error(`read failed for ${p[failed]}: ${res.itemErrors[failed]}; use Promise.allSettled(paths.map(path => read(path))) for per-path outcomes`);
|
|
216
|
+
unwrapRead(res, args);
|
|
217
|
+
|
|
218
|
+
if (Array.isArray(res?.items)) return res.items.map(decode);
|
|
219
|
+
|
|
161
220
|
// Captured host executor without batch support: fan out.
|
|
162
221
|
return readEach();
|
|
163
222
|
}
|
|
164
|
-
|
|
223
|
+
|
|
224
|
+
if (!batchRead) {
|
|
225
|
+
const res = await invoke("read", args);
|
|
226
|
+
unwrapRead(res, args);
|
|
227
|
+
|
|
228
|
+
return decode(unwrapRead(res, args));
|
|
229
|
+
}
|
|
230
|
+
|
|
165
231
|
const key = JSON.stringify({ ...args, path: undefined });
|
|
232
|
+
|
|
166
233
|
if (queuedReads.length && queuedReads[0].key !== key) flushReads();
|
|
234
|
+
|
|
167
235
|
return new Promise((resolve, reject) => {
|
|
168
236
|
queuedReads.push({ args, key, resolve, reject });
|
|
237
|
+
|
|
169
238
|
if (queuedReads.length === 1) queueMicrotask(flushReads);
|
|
170
|
-
}).then(
|
|
239
|
+
}).then(decode);
|
|
171
240
|
};
|
|
241
|
+
|
|
172
242
|
const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
|
|
243
|
+
|
|
173
244
|
const edit = async (p, oldText, newText) => {
|
|
174
245
|
if (isFunction(p)) return nova.speculate(p);
|
|
246
|
+
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
|
+
|
|
248
|
+
if (isObject(p) && (Array.isArray(p) || oldText !== undefined || newText !== undefined)) throw new Error(usage);
|
|
249
|
+
|
|
250
|
+
if (!isObject(p) && isObject(oldText) && !Array.isArray(oldText)) throw new Error(usage);
|
|
175
251
|
const args = isObject(p) ? p : Array.isArray(oldText) ? { path: p, edits: oldText } : { path: p, oldText, newText };
|
|
252
|
+
|
|
253
|
+
if (!isString(args.path) || !args.path.trim()) throw new Error(usage);
|
|
254
|
+
const modes = Number(args.patch !== undefined) + Number(args.edits !== undefined) + Number(args.oldText !== undefined || args.newText !== undefined);
|
|
255
|
+
|
|
256
|
+
if (modes !== 1 || (Array.isArray(oldText) && newText !== undefined)) throw new Error(usage);
|
|
257
|
+
|
|
258
|
+
if (args.patch !== undefined) {
|
|
259
|
+
if (!isString(args.patch) || !args.patch.trim()) throw new Error(usage);
|
|
260
|
+
} else {
|
|
261
|
+
const edits = args.edits === undefined ? [args] : args.edits;
|
|
262
|
+
|
|
263
|
+
if (!Array.isArray(edits) || !edits.length) throw new Error(usage);
|
|
264
|
+
|
|
265
|
+
for (const e of edits) if (!isString(e?.oldText) || !e.oldText.length || !isString(e?.newText)) throw new Error(usage + "; replacements require non-empty oldText and string newText");
|
|
266
|
+
}
|
|
267
|
+
|
|
176
268
|
return unwrapValue(await invoke(args.patch === undefined ? "edit" : "apply_patch", args));
|
|
177
269
|
};
|
|
270
|
+
|
|
178
271
|
const patch = async (p, diff) => unwrapValue(await nova.call("apply_patch", { path: p, patch: diff }));
|
|
272
|
+
|
|
179
273
|
const bash = async (command, opts) => {
|
|
180
274
|
const args = isObject(command) ? { ...command } : { command, ...opts };
|
|
275
|
+
|
|
181
276
|
if (args.args !== undefined) {
|
|
182
277
|
if (!isString(args.command) || !Array.isArray(args.args) || args.args.some(arg => !isString(arg))) throw new Error("bash argv requires a command string and an array of string args");
|
|
278
|
+
|
|
183
279
|
if (nativeArgv) args._directArgv = true;
|
|
184
280
|
else {
|
|
185
281
|
delete args._directArgv;
|
|
186
282
|
args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
|
|
187
283
|
}
|
|
188
284
|
}
|
|
285
|
+
|
|
189
286
|
command = args.command;
|
|
287
|
+
|
|
190
288
|
if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
|
|
191
289
|
const res = await invoke("bash", args);
|
|
290
|
+
|
|
192
291
|
if (res?.ok === false) {
|
|
193
292
|
let exitCode;
|
|
293
|
+
|
|
194
294
|
try {
|
|
195
295
|
exitCode = JSON.parse(res.details).exitCode;
|
|
196
296
|
} catch {}
|
|
297
|
+
|
|
197
298
|
const output = String(res.value).trimEnd();
|
|
198
299
|
const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
|
|
199
300
|
throw new Error("command failed" + suffix + ": " + command + (output ? "\n" + output : ""));
|
|
200
301
|
}
|
|
302
|
+
|
|
201
303
|
let text = unwrapValue(res);
|
|
304
|
+
|
|
202
305
|
if (res?.truncated && isString(text) && !text.includes("truncated")) text += "\n…[output truncated]…";
|
|
306
|
+
|
|
203
307
|
return text;
|
|
204
308
|
};
|
|
309
|
+
|
|
205
310
|
const quoteShellArg = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'";
|
|
311
|
+
|
|
206
312
|
const exec = async (cmd, args, opts) => {
|
|
207
313
|
const command = String(cmd ?? "").trim();
|
|
314
|
+
|
|
208
315
|
if (!command) throw new Error("exec requires command");
|
|
316
|
+
|
|
209
317
|
// exec("git status") is a shell line; exec("git", ["status"]) is argv.
|
|
210
318
|
if (!Array.isArray(args) || args.length === 0) return bash(command, opts);
|
|
319
|
+
|
|
211
320
|
return bash([command, ...args].map(quoteShellArg).join(" "), opts);
|
|
212
321
|
};
|
|
213
322
|
|
|
@@ -217,16 +326,23 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
217
326
|
function makeConsole(runId, limits) {
|
|
218
327
|
let count = 0;
|
|
219
328
|
let truncated = false;
|
|
329
|
+
|
|
220
330
|
const markTruncated = () => {
|
|
221
331
|
if (!truncated) post({ op: "logTruncated", runId });
|
|
222
332
|
truncated = true;
|
|
223
333
|
};
|
|
334
|
+
|
|
224
335
|
const emit = (...args) => {
|
|
225
|
-
if (count >= limits.maxLogLines) { markTruncated();
|
|
336
|
+
if (count >= limits.maxLogLines) { markTruncated();
|
|
337
|
+
|
|
338
|
+
return; }
|
|
339
|
+
|
|
226
340
|
count++;
|
|
341
|
+
|
|
227
342
|
const line = args
|
|
228
343
|
.map((a) => {
|
|
229
344
|
if (isString(a)) return a;
|
|
345
|
+
|
|
230
346
|
try {
|
|
231
347
|
return JSON.stringify(toPlain(a));
|
|
232
348
|
} catch {
|
|
@@ -234,10 +350,13 @@ function makeConsole(runId, limits) {
|
|
|
234
350
|
}
|
|
235
351
|
})
|
|
236
352
|
.join(" ");
|
|
353
|
+
|
|
237
354
|
const clipped = truncateChars(line, limits.maxLogLineChars, "log");
|
|
355
|
+
|
|
238
356
|
if (clipped.truncated) markTruncated();
|
|
239
357
|
post({ op: "log", runId, line: clipped.text, truncated: clipped.truncated });
|
|
240
358
|
};
|
|
359
|
+
|
|
241
360
|
return { log: emit, warn: emit, error: emit, info: emit, debug: emit };
|
|
242
361
|
}
|
|
243
362
|
|
|
@@ -252,25 +371,34 @@ async function handleRun(msg) {
|
|
|
252
371
|
activeRunId = runId;
|
|
253
372
|
runActive = true;
|
|
254
373
|
let compiled;
|
|
374
|
+
|
|
255
375
|
try {
|
|
256
|
-
|
|
376
|
+
// Existing programs may declare their own data variable; bind it only when supplied.
|
|
377
|
+
const bindings = msg.data === undefined ? PARAMS : [...PARAMS, "data"];
|
|
378
|
+
compiled = { fn: new AsyncFunction(...bindings, prepared.body), hasReturn: prepared.hasReturn };
|
|
257
379
|
} catch (err) {
|
|
258
|
-
postFailure(runId, err);
|
|
380
|
+
postFailure(runId, new Error("JavaScript syntax error: " + err.message + "; no commands ran. When passing data, do not redeclare its binding."));
|
|
381
|
+
|
|
259
382
|
return;
|
|
260
383
|
}
|
|
384
|
+
|
|
261
385
|
const api = buildGuestApi(available, batchRead, runId, msg.nativeArgv === true);
|
|
262
386
|
const scopedConsole = makeConsole(runId, limits);
|
|
387
|
+
|
|
263
388
|
try {
|
|
264
389
|
const value = await compiled.fn(
|
|
265
|
-
scopedConsole, api.read, api.edit, api.write, api.bash,
|
|
390
|
+
scopedConsole, api.read, api.edit, api.write, api.bash, msg.data,
|
|
266
391
|
);
|
|
392
|
+
|
|
267
393
|
if (runId !== activeRunId) return;
|
|
268
394
|
let plain;
|
|
395
|
+
|
|
269
396
|
try {
|
|
270
397
|
plain = toPlain(value);
|
|
271
398
|
} catch (err) {
|
|
272
399
|
plain = "[unserializable: " + (err?.message || err) + "]";
|
|
273
400
|
}
|
|
401
|
+
|
|
274
402
|
runActive = false;
|
|
275
403
|
post({ op: "done", runId, value: plain, undefinedReturn: value === undefined && !compiled.hasReturn, hasReturn: compiled.hasReturn });
|
|
276
404
|
} catch (err) {
|
|
@@ -281,14 +409,19 @@ async function handleRun(msg) {
|
|
|
281
409
|
|
|
282
410
|
parentPort.on("message", (msg) => {
|
|
283
411
|
if (!isObject(msg)) return;
|
|
412
|
+
|
|
284
413
|
if (msg.op === "rpc:result") {
|
|
285
414
|
const pending = pendingRpc.get(msg.id);
|
|
415
|
+
|
|
286
416
|
if (!pending) return;
|
|
287
417
|
pendingRpc.delete(msg.id);
|
|
418
|
+
|
|
288
419
|
if (msg.ok) pending.resolve(msg.value);
|
|
289
420
|
else pending.reject(new Error(msg.error));
|
|
421
|
+
|
|
290
422
|
return;
|
|
291
423
|
}
|
|
424
|
+
|
|
292
425
|
if (msg.op === "run") void handleRun(msg);
|
|
293
426
|
});
|
|
294
427
|
|