pi-supernova 0.3.2 → 0.5.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 +148 -29
- package/docs/CHANGELOG.md +52 -0
- package/docs/TOKEN_COSTS.md +173 -0
- package/index.js +155 -41
- package/package.json +3 -1
- package/src/bridge/catalog.js +35 -1
- package/src/bridge/host-bridge.js +411 -50
- 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 +24 -0
- package/src/context/repo-index.js +102 -4
- package/src/context/search.js +45 -0
- package/src/context/snap.js +118 -6
- package/src/context/spans.js +39 -0
- package/src/context/surface.js +33 -6
- package/src/fs/check.js +50 -0
- package/src/fs/diff.js +16 -0
- package/src/fs/json-read.js +91 -0
- package/src/fs/patch.js +26 -0
- package/src/fs/vfs.js +102 -5
- package/src/fs/workspace.js +62 -3
- package/src/output/bottleneck.js +63 -4
- package/src/output/format.js +136 -17
- package/src/runtime/guest-worker.js +169 -31
- 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 +18 -0
- package/src/runtime/runtime.js +90 -8
- package/src/shared/decode.js +40 -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,90 @@ 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
|
|
|
74
|
+
/** Keep every array item in an oversized return by giving each a fair truncated share. */
|
|
75
|
+
export function formatBoundedStringArray(values, budget) {
|
|
76
|
+
const n = values.length;
|
|
77
|
+
const header = "strings[" + n + "]\n";
|
|
78
|
+
let remaining = Math.max(0, budget - header.length);
|
|
79
|
+
let out = header;
|
|
80
|
+
|
|
81
|
+
for (let i = 0; i < n; i++) {
|
|
82
|
+
const itemHeader = "[" + i + "] " + values[i].length + " UTF-16 units\n";
|
|
83
|
+
const per = Math.max(32, Math.floor(remaining / (n - i)) - itemHeader.length - 1);
|
|
84
|
+
const bounded = truncateChars(values[i], per, "return");
|
|
85
|
+
const chunk = itemHeader + bounded.text + "\n";
|
|
86
|
+
remaining = Math.max(0, remaining - chunk.length);
|
|
87
|
+
out += chunk;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
54
93
|
/** Lossless framing for source arrays, not string escaping or source compression. */
|
|
55
94
|
export function formatReturn(value) {
|
|
56
95
|
if (isString(value)) return value;
|
|
96
|
+
|
|
57
97
|
if (Array.isArray(value) && value.length && hasWellFormedStrings(value) && value.some(text => text.includes("\n"))) {
|
|
58
98
|
const raw = "strings[" + value.length + "]\n" + value.map((text, i) => "[" + i + "] " + text.length + " UTF-16 units\n" + text + "\n").join("");
|
|
59
99
|
const escapedSize = value.reduce((sum, text) => sum + JSON.stringify(text).length, value.length + 1);
|
|
100
|
+
|
|
60
101
|
if (raw.length < escapedSize) return raw;
|
|
61
102
|
}
|
|
62
|
-
|
|
103
|
+
|
|
104
|
+
const escaped = formatValue(value);
|
|
105
|
+
const strings = [];
|
|
106
|
+
|
|
107
|
+
const visit = input => {
|
|
108
|
+
if (isString(input) && input.includes("\n") && !hasUnpairedSurrogate(input) && JSON.stringify(input).length - input.length > 64) {
|
|
109
|
+
const index = strings.push(input) - 1;
|
|
110
|
+
|
|
111
|
+
return { [RAW_TEXT]: "raw[" + index + "]" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (Array.isArray(input)) return input.map(visit);
|
|
115
|
+
|
|
116
|
+
if (isObject(input)) return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, visit(child)]));
|
|
117
|
+
|
|
118
|
+
return input;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const referencedValue = visit(value);
|
|
122
|
+
|
|
123
|
+
if (!strings.length) return escaped;
|
|
124
|
+
// Keep every key, value, duplicate string and byte. References are unquoted
|
|
125
|
+
// expressions, so literal "raw[0]" values and header-like source cannot collide.
|
|
126
|
+
const framed = formatValue(referencedValue) + "\nraw strings[" + strings.length + "]\n" + strings.map((text, i) => "raw[" + i + "] " + text.length + " UTF-16 units\n" + text + "\n").join("");
|
|
127
|
+
|
|
128
|
+
return framed.length < escaped.length ? framed : escaped;
|
|
63
129
|
}
|
|
64
130
|
|
|
131
|
+
const RAW_TEXT = Symbol("raw text reference");
|
|
132
|
+
|
|
65
133
|
const IDENT_KEY = /^[A-Za-z_$][\w$]*$/;
|
|
134
|
+
|
|
66
135
|
const FORMAT_WIDTH = 120;
|
|
67
136
|
|
|
68
137
|
function formatKey(key) {
|
|
@@ -71,26 +140,67 @@ function formatKey(key) {
|
|
|
71
140
|
|
|
72
141
|
function formatPrimitive(value) {
|
|
73
142
|
if (value === undefined) return "undefined";
|
|
143
|
+
|
|
74
144
|
if (Number.isNaN(value) || value === Infinity || value === -Infinity) return String(value);
|
|
145
|
+
|
|
75
146
|
return JSON.stringify(value) ?? String(value);
|
|
76
147
|
}
|
|
77
148
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Single-line rendering that gives up the moment it would exceed `limit` characters.
|
|
151
|
+
*
|
|
152
|
+
* formatValue consults this at every level, so the previous build-the-whole-string
|
|
153
|
+
* form re-walked each subtree once per ancestor: a container that is too wide paid
|
|
154
|
+
* the full cost of its children and then paid for them again while descending.
|
|
155
|
+
* Abandoning at the budget keeps the pass linear. Returning null is exactly
|
|
156
|
+
* equivalent to "the flat form is longer than limit", because an abandoned walk
|
|
157
|
+
* always has at least `used` characters still to come.
|
|
158
|
+
*/
|
|
159
|
+
function formatFlatWithin(value, limit) {
|
|
160
|
+
const parts = [];
|
|
161
|
+
let used = 0;
|
|
162
|
+
|
|
163
|
+
const push = (chunk) => {
|
|
164
|
+
if (used + chunk.length > limit) return false;
|
|
165
|
+
used += chunk.length;
|
|
166
|
+
parts.push(chunk);
|
|
167
|
+
|
|
168
|
+
return true;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return walkFlat(value, push) ? parts.join("") : null;
|
|
83
172
|
}
|
|
84
173
|
|
|
85
|
-
function
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
174
|
+
function walkFlat(value, push) {
|
|
175
|
+
if (value?.[RAW_TEXT] !== undefined) return push(value[RAW_TEXT]);
|
|
176
|
+
|
|
177
|
+
if (!isObject(value) && !Array.isArray(value)) return push(formatPrimitive(value));
|
|
178
|
+
|
|
179
|
+
if (Array.isArray(value)) {
|
|
180
|
+
if (value.length === 0) return push("[]");
|
|
181
|
+
|
|
182
|
+
if (!push("[")) return false;
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < value.length; i++) {
|
|
185
|
+
if (i && !push(",")) return false;
|
|
186
|
+
|
|
187
|
+
if (!walkFlat(value[i] === undefined ? null : value[i], push)) return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return push("]");
|
|
92
191
|
}
|
|
93
|
-
|
|
192
|
+
|
|
193
|
+
const keys = Object.keys(value).filter((key) => value[key] !== undefined);
|
|
194
|
+
|
|
195
|
+
if (keys.length === 0) return push("{}");
|
|
196
|
+
|
|
197
|
+
for (let i = 0; i < keys.length; i++) {
|
|
198
|
+
if (!push((i ? "," : "{") + formatKey(keys[i]) + ":")) return false;
|
|
199
|
+
|
|
200
|
+
if (!walkFlat(value[keys[i]], push)) return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return push("}");
|
|
94
204
|
}
|
|
95
205
|
|
|
96
206
|
/**
|
|
@@ -100,14 +210,23 @@ function formatFlat(value) {
|
|
|
100
210
|
* than JSON.stringify(value, null, 2) on typical shaped returns (gpt-tokenizer).
|
|
101
211
|
*/
|
|
102
212
|
export function formatValue(value, indent = "", width = FORMAT_WIDTH) {
|
|
103
|
-
|
|
104
|
-
|
|
213
|
+
if (value?.[RAW_TEXT] !== undefined) return value[RAW_TEXT];
|
|
214
|
+
|
|
215
|
+
if (!isObject(value) && !Array.isArray(value)) return formatPrimitive(value);
|
|
216
|
+
const flat = formatFlatWithin(value, width - indent.length);
|
|
217
|
+
|
|
218
|
+
if (flat !== null) return flat;
|
|
105
219
|
const pad = indent + " ";
|
|
220
|
+
|
|
106
221
|
if (Array.isArray(value)) {
|
|
107
222
|
if (value.length === 0) return "[]";
|
|
223
|
+
|
|
108
224
|
return "[\n" + value.map((item) => pad + formatValue(item === undefined ? null : item, pad, width)).join(",\n") + "\n" + indent + "]";
|
|
109
225
|
}
|
|
226
|
+
|
|
110
227
|
const keys = Object.keys(value).filter((key) => value[key] !== undefined);
|
|
228
|
+
|
|
111
229
|
if (keys.length === 0) return "{}";
|
|
230
|
+
|
|
112
231
|
return "{\n" + keys.map((key) => pad + formatKey(key) + ":" + formatValue(value[key], pad, width)).join(",\n") + "\n" + indent + "}";
|
|
113
232
|
}
|
|
@@ -1,28 +1,38 @@
|
|
|
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
|
+
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,18 +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
|
|
|
52
66
|
function unwrapRead(res, args) {
|
|
53
67
|
const value = unwrapValue(res);
|
|
54
|
-
|
|
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
|
+
|
|
55
71
|
return value;
|
|
56
72
|
}
|
|
57
73
|
|
|
58
74
|
function unwrapJsonValue(res) {
|
|
59
75
|
const value = unwrapValue(res);
|
|
76
|
+
|
|
60
77
|
try {
|
|
61
78
|
return JSON.parse(value);
|
|
62
79
|
} catch {
|
|
@@ -67,24 +84,33 @@ function unwrapJsonValue(res) {
|
|
|
67
84
|
/** Keep details/truncated reachable but out of the returned literal unless they carry signal. */
|
|
68
85
|
function leanEnvelope(res) {
|
|
69
86
|
if (!isObject(res)) return res;
|
|
87
|
+
|
|
70
88
|
if ("details" in res) Object.defineProperty(res, "details", { value: res.details, enumerable: false, writable: true });
|
|
89
|
+
|
|
71
90
|
for (const key of Object.keys(res)) if (res[key] === undefined) delete res[key];
|
|
91
|
+
|
|
72
92
|
if (res.truncated === false) delete res.truncated;
|
|
93
|
+
|
|
73
94
|
return res;
|
|
74
95
|
}
|
|
75
96
|
|
|
76
|
-
function
|
|
97
|
+
function quoteShellArg(value) {
|
|
98
|
+
return "'" + String(value).replaceAll("'", "'\\''") + "'";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildGuestApi(available, batchRead, runId, _nativeArgv) {
|
|
77
102
|
const rpc = (method, args) => callRpc(runId, method, args);
|
|
78
103
|
const availableSet = new Set(available);
|
|
79
104
|
const checkpointScope = new AsyncLocalStorage();
|
|
80
105
|
let checkpoint = null;
|
|
106
|
+
|
|
81
107
|
const assertScope = () => {
|
|
82
108
|
const token = checkpointScope.getStore();
|
|
109
|
+
|
|
83
110
|
if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
|
|
84
111
|
};
|
|
112
|
+
|
|
85
113
|
const nova = {
|
|
86
|
-
search: (query, limit) => rpc("search", [query, limit]),
|
|
87
|
-
describe: (name) => rpc("describe", [name]),
|
|
88
114
|
call: async (name, args) => leanEnvelope(await rpc("call", [name, args])),
|
|
89
115
|
async callMany(calls) {
|
|
90
116
|
const wave = await rpc("callMany", [calls]);
|
|
@@ -94,6 +120,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
94
120
|
reason: { value: wave?.reason, enumerable: false },
|
|
95
121
|
results: { value: results, enumerable: false },
|
|
96
122
|
});
|
|
123
|
+
|
|
97
124
|
return results;
|
|
98
125
|
},
|
|
99
126
|
async speculate(fn) {
|
|
@@ -101,6 +128,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
101
128
|
const token = {};
|
|
102
129
|
checkpoint = token;
|
|
103
130
|
let began = false;
|
|
131
|
+
|
|
104
132
|
try {
|
|
105
133
|
flushReads();
|
|
106
134
|
await rpc("speculateBegin", []);
|
|
@@ -108,10 +136,13 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
108
136
|
const value = await checkpointScope.run(token, fn);
|
|
109
137
|
flushReads();
|
|
110
138
|
await rpc("speculateCommit", []);
|
|
139
|
+
|
|
111
140
|
return { ok: true, committed: true, value };
|
|
112
141
|
} catch (err) {
|
|
113
142
|
flushReads();
|
|
143
|
+
|
|
114
144
|
if (began) await rpc("speculateRollback", []);
|
|
145
|
+
|
|
115
146
|
return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
|
|
116
147
|
} finally { checkpoint = null; }
|
|
117
148
|
},
|
|
@@ -123,120 +154,210 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
123
154
|
|
|
124
155
|
// Coalesce already-started compatible reads without rewriting JS control flow.
|
|
125
156
|
let queuedReads = [];
|
|
157
|
+
|
|
126
158
|
function flushReads() {
|
|
127
159
|
const pending = queuedReads;
|
|
128
160
|
queuedReads = [];
|
|
161
|
+
|
|
129
162
|
for (let start = 0; start < pending.length; start += 64) {
|
|
130
163
|
const wave = pending.slice(start, start + 64);
|
|
131
164
|
const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
|
|
165
|
+
|
|
132
166
|
const run = wave.length === 1
|
|
133
167
|
? nova.call("read", wave[0].args).then(res => ({ values: [unwrapRead(res, wave[0].args)], errors: [] }))
|
|
134
|
-
: nova.call("read", args).then(res => { unwrapRead(res, args);
|
|
168
|
+
: nova.call("read", args).then(res => { unwrapRead(res, args);
|
|
169
|
+
|
|
170
|
+
return { values: res.items, errors: res.itemErrors ?? [] }; });
|
|
171
|
+
|
|
135
172
|
void run.then(({ values, errors }) => {
|
|
136
173
|
if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
|
|
174
|
+
|
|
137
175
|
for (let i = 0; i < wave.length; i++) {
|
|
138
176
|
const value = values[i];
|
|
177
|
+
|
|
139
178
|
if (errors[i]) wave[i].reject(new Error(errors[i]));
|
|
140
179
|
else wave[i].resolve(value);
|
|
141
180
|
}
|
|
142
181
|
}).catch(error => { for (const job of wave) job.reject(error); });
|
|
143
182
|
}
|
|
144
183
|
}
|
|
145
|
-
|
|
184
|
+
|
|
185
|
+
const invoke = (name, args) => { assertScope(); flushReads();
|
|
186
|
+
|
|
187
|
+
return nova.call(name, args); };
|
|
188
|
+
|
|
146
189
|
const readArgs = (p, a, b) => isObject(p) && !Array.isArray(p)
|
|
147
190
|
? { ...p, path: p.path ?? p.query }
|
|
148
191
|
: isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
|
|
192
|
+
|
|
149
193
|
const read = async (p, a, b) => {
|
|
150
194
|
assertScope();
|
|
151
|
-
const args = readArgs(p, a, b);
|
|
195
|
+
const args = sessionJsonArgs(readArgs(p, a, b));
|
|
196
|
+
if (isString(args.path) && args.resolve === undefined && args.json === undefined && !looksLikePath(args.path) && !/^(?:agent|artifact):\/\//i.test(args.path)) {
|
|
197
|
+
args.resolve = true;
|
|
198
|
+
}
|
|
199
|
+
validateJsonRead(args);
|
|
200
|
+
const decode = value => args.resolve || args.json !== undefined ? JSON.parse(value) : value;
|
|
201
|
+
|
|
152
202
|
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");
|
|
153
203
|
const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : args.about ? p : undefined;
|
|
154
204
|
p = args.path;
|
|
205
|
+
|
|
155
206
|
if (args.evidence) return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query: args.about ?? args.query ?? p }));
|
|
207
|
+
|
|
156
208
|
if (args.outline) return unwrapJsonValue(await invoke("surface", args));
|
|
209
|
+
|
|
157
210
|
if (Array.isArray(p)) {
|
|
158
211
|
if (p.length > 64) throw new Error("read accepts at most 64 paths per batch");
|
|
159
|
-
|
|
212
|
+
|
|
213
|
+
for (const item of p) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
|
|
160
214
|
const readEach = () => Promise.all(p.map(item => read({ ...args, path: item })));
|
|
161
|
-
|
|
215
|
+
|
|
216
|
+
if (!batchRead || args.resolve || p.some(item => /^(agent|artifact):\/\/.*\?/i.test(item))) return readEach();
|
|
162
217
|
const res = await invoke("read", args);
|
|
163
218
|
const failed = res?.itemErrors?.findIndex(error => error != null) ?? -1;
|
|
219
|
+
|
|
164
220
|
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`);
|
|
165
221
|
unwrapRead(res, args);
|
|
166
|
-
|
|
222
|
+
|
|
223
|
+
if (Array.isArray(res?.items)) return res.items.map(decode);
|
|
224
|
+
|
|
167
225
|
// Captured host executor without batch support: fan out.
|
|
168
226
|
return readEach();
|
|
169
227
|
}
|
|
228
|
+
|
|
170
229
|
if (!batchRead) {
|
|
171
230
|
const res = await invoke("read", args);
|
|
172
231
|
unwrapRead(res, args);
|
|
173
|
-
|
|
232
|
+
|
|
233
|
+
return decode(unwrapRead(res, args));
|
|
174
234
|
}
|
|
235
|
+
|
|
175
236
|
const key = JSON.stringify({ ...args, path: undefined });
|
|
237
|
+
|
|
176
238
|
if (queuedReads.length && queuedReads[0].key !== key) flushReads();
|
|
239
|
+
|
|
177
240
|
return new Promise((resolve, reject) => {
|
|
178
241
|
queuedReads.push({ args, key, resolve, reject });
|
|
242
|
+
|
|
179
243
|
if (queuedReads.length === 1) queueMicrotask(flushReads);
|
|
180
|
-
}).then(
|
|
244
|
+
}).then(decode);
|
|
181
245
|
};
|
|
246
|
+
|
|
182
247
|
const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
|
|
248
|
+
|
|
249
|
+
const viewSpan = (value) => {
|
|
250
|
+
const start = isNumber(value.start) ? value.start : Array.isArray(value.lines) ? value.lines[0] : value.line;
|
|
251
|
+
const end = isNumber(value.end) ? value.end : Array.isArray(value.lines) && value.lines.length > 1 ? value.lines[1] : start;
|
|
252
|
+
|
|
253
|
+
if (!isNumber(start) || !isNumber(end) || start < 1 || end < start) return null;
|
|
254
|
+
|
|
255
|
+
return { start: Math.floor(start), end: Math.floor(end) };
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
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);
|
|
259
|
+
|
|
183
260
|
const edit = async (p, oldText, newText) => {
|
|
184
261
|
if (isFunction(p)) return nova.speculate(p);
|
|
262
|
+
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"})';
|
|
263
|
+
|
|
264
|
+
if (isView(p) && isString(oldText) && (newText === undefined || isString(newText))) {
|
|
265
|
+
if (isNumber(p.nextOffset)) throw new Error("edit view is incomplete");
|
|
266
|
+
const span = viewSpan(p);
|
|
267
|
+
const args = { path: p.path, viewStart: span.start, viewEnd: span.end, viewText: p.text, newText: newText === undefined ? oldText : newText };
|
|
268
|
+
|
|
269
|
+
if (newText !== undefined) args.oldText = oldText;
|
|
270
|
+
|
|
271
|
+
return unwrapValue(await invoke("edit", args));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (isObject(p) && (Array.isArray(p) || oldText !== undefined || newText !== undefined)) throw new Error(usage);
|
|
275
|
+
|
|
276
|
+
if (!isObject(p) && isObject(oldText) && !Array.isArray(oldText)) throw new Error(usage);
|
|
185
277
|
const args = isObject(p) ? p : Array.isArray(oldText) ? { path: p, edits: oldText } : { path: p, oldText, newText };
|
|
278
|
+
|
|
279
|
+
if (!isString(args.path) || !args.path.trim()) throw new Error(usage);
|
|
280
|
+
const modes = Number(args.patch !== undefined) + Number(args.edits !== undefined) + Number(args.oldText !== undefined || args.newText !== undefined);
|
|
281
|
+
|
|
282
|
+
if (modes !== 1 || (Array.isArray(oldText) && newText !== undefined)) throw new Error(usage);
|
|
283
|
+
|
|
284
|
+
if (args.patch !== undefined) {
|
|
285
|
+
if (!isString(args.patch) || !args.patch.trim()) throw new Error(usage);
|
|
286
|
+
} else {
|
|
287
|
+
const edits = args.edits === undefined ? [args] : args.edits;
|
|
288
|
+
|
|
289
|
+
if (!Array.isArray(edits) || !edits.length) throw new Error(usage);
|
|
290
|
+
|
|
291
|
+
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");
|
|
292
|
+
}
|
|
293
|
+
|
|
186
294
|
return unwrapValue(await invoke(args.patch === undefined ? "edit" : "apply_patch", args));
|
|
187
295
|
};
|
|
188
|
-
|
|
296
|
+
|
|
189
297
|
const bash = async (command, opts) => {
|
|
190
298
|
const args = isObject(command) ? { ...command } : { command, ...opts };
|
|
299
|
+
|
|
191
300
|
if (args.args !== undefined) {
|
|
192
|
-
if (!isString(args.command) || !Array.isArray(args.args)
|
|
193
|
-
|
|
194
|
-
|
|
301
|
+
if (!isString(args.command) || !Array.isArray(args.args)) throw new Error("bash argv requires a command string and an array of string args");
|
|
302
|
+
|
|
303
|
+
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");
|
|
304
|
+
|
|
305
|
+
// Literal argv is a Supernova-owned contract. Host bash tools often ignore
|
|
306
|
+
// `args` and would run only `command` (bare `ssh`). Windows still needs a shell.
|
|
307
|
+
if (process.platform === "win32") {
|
|
195
308
|
delete args._directArgv;
|
|
196
309
|
args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
|
|
197
|
-
|
|
310
|
+
delete args.args;
|
|
311
|
+
} else args._directArgv = true;
|
|
198
312
|
}
|
|
313
|
+
|
|
199
314
|
command = args.command;
|
|
315
|
+
|
|
200
316
|
if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
|
|
201
317
|
const res = await invoke("bash", args);
|
|
318
|
+
|
|
202
319
|
if (res?.ok === false) {
|
|
203
320
|
let exitCode;
|
|
321
|
+
|
|
204
322
|
try {
|
|
205
323
|
exitCode = JSON.parse(res.details).exitCode;
|
|
206
324
|
} catch {}
|
|
325
|
+
|
|
207
326
|
const output = String(res.value).trimEnd();
|
|
208
327
|
const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
|
|
209
328
|
throw new Error("command failed" + suffix + ": " + command + (output ? "\n" + output : ""));
|
|
210
329
|
}
|
|
330
|
+
|
|
211
331
|
let text = unwrapValue(res);
|
|
332
|
+
|
|
212
333
|
if (res?.truncated && isString(text) && !text.includes("truncated")) text += "\n…[output truncated]…";
|
|
334
|
+
|
|
213
335
|
return text;
|
|
214
336
|
};
|
|
215
|
-
const quoteShellArg = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'";
|
|
216
|
-
const exec = async (cmd, args, opts) => {
|
|
217
|
-
const command = String(cmd ?? "").trim();
|
|
218
|
-
if (!command) throw new Error("exec requires command");
|
|
219
|
-
// exec("git status") is a shell line; exec("git", ["status"]) is argv.
|
|
220
|
-
if (!Array.isArray(args) || args.length === 0) return bash(command, opts);
|
|
221
|
-
return bash([command, ...args].map(quoteShellArg).join(" "), opts);
|
|
222
|
-
};
|
|
223
337
|
|
|
224
|
-
return {
|
|
338
|
+
return { read, write, edit, bash };
|
|
225
339
|
}
|
|
226
340
|
|
|
227
341
|
function makeConsole(runId, limits) {
|
|
228
342
|
let count = 0;
|
|
229
343
|
let truncated = false;
|
|
344
|
+
|
|
230
345
|
const markTruncated = () => {
|
|
231
346
|
if (!truncated) post({ op: "logTruncated", runId });
|
|
232
347
|
truncated = true;
|
|
233
348
|
};
|
|
349
|
+
|
|
234
350
|
const emit = (...args) => {
|
|
235
|
-
if (count >= limits.maxLogLines) { markTruncated();
|
|
351
|
+
if (count >= limits.maxLogLines) { markTruncated();
|
|
352
|
+
|
|
353
|
+
return; }
|
|
354
|
+
|
|
236
355
|
count++;
|
|
356
|
+
|
|
237
357
|
const line = args
|
|
238
358
|
.map((a) => {
|
|
239
359
|
if (isString(a)) return a;
|
|
360
|
+
|
|
240
361
|
try {
|
|
241
362
|
return JSON.stringify(toPlain(a));
|
|
242
363
|
} catch {
|
|
@@ -244,10 +365,13 @@ function makeConsole(runId, limits) {
|
|
|
244
365
|
}
|
|
245
366
|
})
|
|
246
367
|
.join(" ");
|
|
368
|
+
|
|
247
369
|
const clipped = truncateChars(line, limits.maxLogLineChars, "log");
|
|
370
|
+
|
|
248
371
|
if (clipped.truncated) markTruncated();
|
|
249
372
|
post({ op: "log", runId, line: clipped.text, truncated: clipped.truncated });
|
|
250
373
|
};
|
|
374
|
+
|
|
251
375
|
return { log: emit, warn: emit, error: emit, info: emit, debug: emit };
|
|
252
376
|
}
|
|
253
377
|
|
|
@@ -262,25 +386,34 @@ async function handleRun(msg) {
|
|
|
262
386
|
activeRunId = runId;
|
|
263
387
|
runActive = true;
|
|
264
388
|
let compiled;
|
|
389
|
+
|
|
265
390
|
try {
|
|
266
|
-
|
|
391
|
+
// Existing programs may declare their own data variable; bind it only when supplied.
|
|
392
|
+
const bindings = msg.data === undefined ? PARAMS : [...PARAMS, "data"];
|
|
393
|
+
compiled = { fn: new AsyncFunction(...bindings, prepared.body), hasReturn: prepared.hasReturn };
|
|
267
394
|
} catch (err) {
|
|
268
|
-
postFailure(runId, err);
|
|
395
|
+
postFailure(runId, new Error("JavaScript syntax error: " + err.message + "; no commands ran. When passing data, do not redeclare its binding."));
|
|
396
|
+
|
|
269
397
|
return;
|
|
270
398
|
}
|
|
399
|
+
|
|
271
400
|
const api = buildGuestApi(available, batchRead, runId, msg.nativeArgv === true);
|
|
272
401
|
const scopedConsole = makeConsole(runId, limits);
|
|
402
|
+
|
|
273
403
|
try {
|
|
274
404
|
const value = await compiled.fn(
|
|
275
|
-
scopedConsole, api.read, api.edit, api.write, api.bash,
|
|
405
|
+
scopedConsole, api.read, api.edit, api.write, api.bash, msg.data,
|
|
276
406
|
);
|
|
407
|
+
|
|
277
408
|
if (runId !== activeRunId) return;
|
|
278
409
|
let plain;
|
|
410
|
+
|
|
279
411
|
try {
|
|
280
412
|
plain = toPlain(value);
|
|
281
413
|
} catch (err) {
|
|
282
414
|
plain = "[unserializable: " + (err?.message || err) + "]";
|
|
283
415
|
}
|
|
416
|
+
|
|
284
417
|
runActive = false;
|
|
285
418
|
post({ op: "done", runId, value: plain, undefinedReturn: value === undefined && !compiled.hasReturn, hasReturn: compiled.hasReturn });
|
|
286
419
|
} catch (err) {
|
|
@@ -291,14 +424,19 @@ async function handleRun(msg) {
|
|
|
291
424
|
|
|
292
425
|
parentPort.on("message", (msg) => {
|
|
293
426
|
if (!isObject(msg)) return;
|
|
427
|
+
|
|
294
428
|
if (msg.op === "rpc:result") {
|
|
295
429
|
const pending = pendingRpc.get(msg.id);
|
|
430
|
+
|
|
296
431
|
if (!pending) return;
|
|
297
432
|
pendingRpc.delete(msg.id);
|
|
433
|
+
|
|
298
434
|
if (msg.ok) pending.resolve(msg.value);
|
|
299
435
|
else pending.reject(new Error(msg.error));
|
|
436
|
+
|
|
300
437
|
return;
|
|
301
438
|
}
|
|
439
|
+
|
|
302
440
|
if (msg.op === "run") void handleRun(msg);
|
|
303
441
|
});
|
|
304
442
|
|