pi-supernova 0.3.2 → 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 +134 -15
- package/docs/CHANGELOG.md +28 -0
- package/docs/TOKEN_COSTS.md +171 -0
- package/index.js +116 -38
- package/package.json +3 -1
- package/src/bridge/catalog.js +35 -1
- package/src/bridge/host-bridge.js +271 -6
- 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 +62 -0
- 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 +99 -5
- package/src/fs/workspace.js +35 -0
- package/src/output/bottleneck.js +46 -0
- package/src/output/format.js +117 -17
- package/src/runtime/guest-worker.js +136 -13
- 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,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,9 +84,13 @@ 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
|
|
|
@@ -78,10 +99,13 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
78
99
|
const availableSet = new Set(available);
|
|
79
100
|
const checkpointScope = new AsyncLocalStorage();
|
|
80
101
|
let checkpoint = null;
|
|
102
|
+
|
|
81
103
|
const assertScope = () => {
|
|
82
104
|
const token = checkpointScope.getStore();
|
|
105
|
+
|
|
83
106
|
if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
|
|
84
107
|
};
|
|
108
|
+
|
|
85
109
|
const nova = {
|
|
86
110
|
search: (query, limit) => rpc("search", [query, limit]),
|
|
87
111
|
describe: (name) => rpc("describe", [name]),
|
|
@@ -94,6 +118,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
94
118
|
reason: { value: wave?.reason, enumerable: false },
|
|
95
119
|
results: { value: results, enumerable: false },
|
|
96
120
|
});
|
|
121
|
+
|
|
97
122
|
return results;
|
|
98
123
|
},
|
|
99
124
|
async speculate(fn) {
|
|
@@ -101,6 +126,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
101
126
|
const token = {};
|
|
102
127
|
checkpoint = token;
|
|
103
128
|
let began = false;
|
|
129
|
+
|
|
104
130
|
try {
|
|
105
131
|
flushReads();
|
|
106
132
|
await rpc("speculateBegin", []);
|
|
@@ -108,10 +134,13 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
108
134
|
const value = await checkpointScope.run(token, fn);
|
|
109
135
|
flushReads();
|
|
110
136
|
await rpc("speculateCommit", []);
|
|
137
|
+
|
|
111
138
|
return { ok: true, committed: true, value };
|
|
112
139
|
} catch (err) {
|
|
113
140
|
flushReads();
|
|
141
|
+
|
|
114
142
|
if (began) await rpc("speculateRollback", []);
|
|
143
|
+
|
|
115
144
|
return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
|
|
116
145
|
} finally { checkpoint = null; }
|
|
117
146
|
},
|
|
@@ -123,101 +152,171 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
123
152
|
|
|
124
153
|
// Coalesce already-started compatible reads without rewriting JS control flow.
|
|
125
154
|
let queuedReads = [];
|
|
155
|
+
|
|
126
156
|
function flushReads() {
|
|
127
157
|
const pending = queuedReads;
|
|
128
158
|
queuedReads = [];
|
|
159
|
+
|
|
129
160
|
for (let start = 0; start < pending.length; start += 64) {
|
|
130
161
|
const wave = pending.slice(start, start + 64);
|
|
131
162
|
const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
|
|
163
|
+
|
|
132
164
|
const run = wave.length === 1
|
|
133
165
|
? 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);
|
|
166
|
+
: nova.call("read", args).then(res => { unwrapRead(res, args);
|
|
167
|
+
|
|
168
|
+
return { values: res.items, errors: res.itemErrors ?? [] }; });
|
|
169
|
+
|
|
135
170
|
void run.then(({ values, errors }) => {
|
|
136
171
|
if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
|
|
172
|
+
|
|
137
173
|
for (let i = 0; i < wave.length; i++) {
|
|
138
174
|
const value = values[i];
|
|
175
|
+
|
|
139
176
|
if (errors[i]) wave[i].reject(new Error(errors[i]));
|
|
140
177
|
else wave[i].resolve(value);
|
|
141
178
|
}
|
|
142
179
|
}).catch(error => { for (const job of wave) job.reject(error); });
|
|
143
180
|
}
|
|
144
181
|
}
|
|
145
|
-
|
|
182
|
+
|
|
183
|
+
const invoke = (name, args) => { assertScope(); flushReads();
|
|
184
|
+
|
|
185
|
+
return nova.call(name, args); };
|
|
186
|
+
|
|
146
187
|
const readArgs = (p, a, b) => isObject(p) && !Array.isArray(p)
|
|
147
188
|
? { ...p, path: p.path ?? p.query }
|
|
148
189
|
: isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
|
|
190
|
+
|
|
149
191
|
const read = async (p, a, b) => {
|
|
150
192
|
assertScope();
|
|
151
|
-
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
|
+
|
|
152
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");
|
|
153
198
|
const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : args.about ? p : undefined;
|
|
154
199
|
p = args.path;
|
|
200
|
+
|
|
155
201
|
if (args.evidence) return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query: args.about ?? args.query ?? p }));
|
|
202
|
+
|
|
156
203
|
if (args.outline) return unwrapJsonValue(await invoke("surface", args));
|
|
204
|
+
|
|
157
205
|
if (Array.isArray(p)) {
|
|
158
206
|
if (p.length > 64) throw new Error("read accepts at most 64 paths per batch");
|
|
159
|
-
|
|
207
|
+
|
|
208
|
+
for (const item of p) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
|
|
160
209
|
const readEach = () => Promise.all(p.map(item => read({ ...args, path: item })));
|
|
161
|
-
|
|
210
|
+
|
|
211
|
+
if (!batchRead || args.resolve || p.some(item => /^(agent|artifact):\/\/.*\?/i.test(item))) return readEach();
|
|
162
212
|
const res = await invoke("read", args);
|
|
163
213
|
const failed = res?.itemErrors?.findIndex(error => error != null) ?? -1;
|
|
214
|
+
|
|
164
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`);
|
|
165
216
|
unwrapRead(res, args);
|
|
166
|
-
|
|
217
|
+
|
|
218
|
+
if (Array.isArray(res?.items)) return res.items.map(decode);
|
|
219
|
+
|
|
167
220
|
// Captured host executor without batch support: fan out.
|
|
168
221
|
return readEach();
|
|
169
222
|
}
|
|
223
|
+
|
|
170
224
|
if (!batchRead) {
|
|
171
225
|
const res = await invoke("read", args);
|
|
172
226
|
unwrapRead(res, args);
|
|
173
|
-
|
|
227
|
+
|
|
228
|
+
return decode(unwrapRead(res, args));
|
|
174
229
|
}
|
|
230
|
+
|
|
175
231
|
const key = JSON.stringify({ ...args, path: undefined });
|
|
232
|
+
|
|
176
233
|
if (queuedReads.length && queuedReads[0].key !== key) flushReads();
|
|
234
|
+
|
|
177
235
|
return new Promise((resolve, reject) => {
|
|
178
236
|
queuedReads.push({ args, key, resolve, reject });
|
|
237
|
+
|
|
179
238
|
if (queuedReads.length === 1) queueMicrotask(flushReads);
|
|
180
|
-
}).then(
|
|
239
|
+
}).then(decode);
|
|
181
240
|
};
|
|
241
|
+
|
|
182
242
|
const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
|
|
243
|
+
|
|
183
244
|
const edit = async (p, oldText, newText) => {
|
|
184
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);
|
|
185
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
|
+
|
|
186
268
|
return unwrapValue(await invoke(args.patch === undefined ? "edit" : "apply_patch", args));
|
|
187
269
|
};
|
|
270
|
+
|
|
188
271
|
const patch = async (p, diff) => unwrapValue(await nova.call("apply_patch", { path: p, patch: diff }));
|
|
272
|
+
|
|
189
273
|
const bash = async (command, opts) => {
|
|
190
274
|
const args = isObject(command) ? { ...command } : { command, ...opts };
|
|
275
|
+
|
|
191
276
|
if (args.args !== undefined) {
|
|
192
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
|
+
|
|
193
279
|
if (nativeArgv) args._directArgv = true;
|
|
194
280
|
else {
|
|
195
281
|
delete args._directArgv;
|
|
196
282
|
args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
|
|
197
283
|
}
|
|
198
284
|
}
|
|
285
|
+
|
|
199
286
|
command = args.command;
|
|
287
|
+
|
|
200
288
|
if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
|
|
201
289
|
const res = await invoke("bash", args);
|
|
290
|
+
|
|
202
291
|
if (res?.ok === false) {
|
|
203
292
|
let exitCode;
|
|
293
|
+
|
|
204
294
|
try {
|
|
205
295
|
exitCode = JSON.parse(res.details).exitCode;
|
|
206
296
|
} catch {}
|
|
297
|
+
|
|
207
298
|
const output = String(res.value).trimEnd();
|
|
208
299
|
const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
|
|
209
300
|
throw new Error("command failed" + suffix + ": " + command + (output ? "\n" + output : ""));
|
|
210
301
|
}
|
|
302
|
+
|
|
211
303
|
let text = unwrapValue(res);
|
|
304
|
+
|
|
212
305
|
if (res?.truncated && isString(text) && !text.includes("truncated")) text += "\n…[output truncated]…";
|
|
306
|
+
|
|
213
307
|
return text;
|
|
214
308
|
};
|
|
309
|
+
|
|
215
310
|
const quoteShellArg = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'";
|
|
311
|
+
|
|
216
312
|
const exec = async (cmd, args, opts) => {
|
|
217
313
|
const command = String(cmd ?? "").trim();
|
|
314
|
+
|
|
218
315
|
if (!command) throw new Error("exec requires command");
|
|
316
|
+
|
|
219
317
|
// exec("git status") is a shell line; exec("git", ["status"]) is argv.
|
|
220
318
|
if (!Array.isArray(args) || args.length === 0) return bash(command, opts);
|
|
319
|
+
|
|
221
320
|
return bash([command, ...args].map(quoteShellArg).join(" "), opts);
|
|
222
321
|
};
|
|
223
322
|
|
|
@@ -227,16 +326,23 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
227
326
|
function makeConsole(runId, limits) {
|
|
228
327
|
let count = 0;
|
|
229
328
|
let truncated = false;
|
|
329
|
+
|
|
230
330
|
const markTruncated = () => {
|
|
231
331
|
if (!truncated) post({ op: "logTruncated", runId });
|
|
232
332
|
truncated = true;
|
|
233
333
|
};
|
|
334
|
+
|
|
234
335
|
const emit = (...args) => {
|
|
235
|
-
if (count >= limits.maxLogLines) { markTruncated();
|
|
336
|
+
if (count >= limits.maxLogLines) { markTruncated();
|
|
337
|
+
|
|
338
|
+
return; }
|
|
339
|
+
|
|
236
340
|
count++;
|
|
341
|
+
|
|
237
342
|
const line = args
|
|
238
343
|
.map((a) => {
|
|
239
344
|
if (isString(a)) return a;
|
|
345
|
+
|
|
240
346
|
try {
|
|
241
347
|
return JSON.stringify(toPlain(a));
|
|
242
348
|
} catch {
|
|
@@ -244,10 +350,13 @@ function makeConsole(runId, limits) {
|
|
|
244
350
|
}
|
|
245
351
|
})
|
|
246
352
|
.join(" ");
|
|
353
|
+
|
|
247
354
|
const clipped = truncateChars(line, limits.maxLogLineChars, "log");
|
|
355
|
+
|
|
248
356
|
if (clipped.truncated) markTruncated();
|
|
249
357
|
post({ op: "log", runId, line: clipped.text, truncated: clipped.truncated });
|
|
250
358
|
};
|
|
359
|
+
|
|
251
360
|
return { log: emit, warn: emit, error: emit, info: emit, debug: emit };
|
|
252
361
|
}
|
|
253
362
|
|
|
@@ -262,25 +371,34 @@ async function handleRun(msg) {
|
|
|
262
371
|
activeRunId = runId;
|
|
263
372
|
runActive = true;
|
|
264
373
|
let compiled;
|
|
374
|
+
|
|
265
375
|
try {
|
|
266
|
-
|
|
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 };
|
|
267
379
|
} catch (err) {
|
|
268
|
-
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
|
+
|
|
269
382
|
return;
|
|
270
383
|
}
|
|
384
|
+
|
|
271
385
|
const api = buildGuestApi(available, batchRead, runId, msg.nativeArgv === true);
|
|
272
386
|
const scopedConsole = makeConsole(runId, limits);
|
|
387
|
+
|
|
273
388
|
try {
|
|
274
389
|
const value = await compiled.fn(
|
|
275
|
-
scopedConsole, api.read, api.edit, api.write, api.bash,
|
|
390
|
+
scopedConsole, api.read, api.edit, api.write, api.bash, msg.data,
|
|
276
391
|
);
|
|
392
|
+
|
|
277
393
|
if (runId !== activeRunId) return;
|
|
278
394
|
let plain;
|
|
395
|
+
|
|
279
396
|
try {
|
|
280
397
|
plain = toPlain(value);
|
|
281
398
|
} catch (err) {
|
|
282
399
|
plain = "[unserializable: " + (err?.message || err) + "]";
|
|
283
400
|
}
|
|
401
|
+
|
|
284
402
|
runActive = false;
|
|
285
403
|
post({ op: "done", runId, value: plain, undefinedReturn: value === undefined && !compiled.hasReturn, hasReturn: compiled.hasReturn });
|
|
286
404
|
} catch (err) {
|
|
@@ -291,14 +409,19 @@ async function handleRun(msg) {
|
|
|
291
409
|
|
|
292
410
|
parentPort.on("message", (msg) => {
|
|
293
411
|
if (!isObject(msg)) return;
|
|
412
|
+
|
|
294
413
|
if (msg.op === "rpc:result") {
|
|
295
414
|
const pending = pendingRpc.get(msg.id);
|
|
415
|
+
|
|
296
416
|
if (!pending) return;
|
|
297
417
|
pendingRpc.delete(msg.id);
|
|
418
|
+
|
|
298
419
|
if (msg.ok) pending.resolve(msg.value);
|
|
299
420
|
else pending.reject(new Error(msg.error));
|
|
421
|
+
|
|
300
422
|
return;
|
|
301
423
|
}
|
|
424
|
+
|
|
302
425
|
if (msg.op === "run") void handleRun(msg);
|
|
303
426
|
});
|
|
304
427
|
|