pi-supernova 0.4.0 → 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 +14 -14
- package/docs/CHANGELOG.md +24 -0
- package/docs/TOKEN_COSTS.md +22 -20
- package/index.js +57 -21
- package/package.json +1 -1
- package/src/bridge/host-bridge.js +143 -47
- package/src/context/repo-index.js +40 -4
- package/src/context/snap.js +38 -2
- package/src/context/spans.js +39 -0
- package/src/context/surface.js +8 -6
- package/src/fs/json-read.js +5 -1
- package/src/fs/vfs.js +3 -0
- package/src/fs/workspace.js +27 -3
- package/src/output/bottleneck.js +17 -4
- package/src/output/format.js +19 -0
- package/src/runtime/guest-worker.js +39 -24
- package/src/runtime/reference.js +15 -22
- package/src/runtime/runtime.js +0 -2
- package/src/shared/decode.js +11 -0
package/README.md
CHANGED
|
@@ -50,12 +50,13 @@ settings. The runtime does not silently rewrite your tool policy.
|
|
|
50
50
|
| `read` | `read(path, offset?, limit?)`, `read({path,offset,limit})`; one-based line windows |
|
|
51
51
|
| `read` | `return await read("plot.png")`; displays images directly, without a browser |
|
|
52
52
|
| `read` | `read({path,json:".verdict"})`; parse full JSON before bounded field selection |
|
|
53
|
-
| `read` | `read(directory)`, `read("symbol or question")`, `read(path,{about:question})`;
|
|
54
|
-
| `read` | `read({query,resolve:true})`;
|
|
53
|
+
| `read` | `read(directory)`, `read("symbol or question")`, `read(path,{about:question})`; a path is raw text, a symbol is a view |
|
|
54
|
+
| `read` | `read({query,resolve:true})`; same view as `read("symbol")`: status, path, line, lines, text, complete |
|
|
55
55
|
| `read` | `read({query,evidence:true})`; ranked evidence with provenance; optional `path` scopes discovery |
|
|
56
56
|
| `read` | `read({path,outline:true})`; structural declarations |
|
|
57
57
|
| `read` | `read([path1,path2])`; up to 64 paths, ordered values with labelled individual failures |
|
|
58
|
-
| `edit` | `edit(path,oldText,newText)`, `edit({path,edits:[{oldText,newText}]})`;
|
|
58
|
+
| `edit` | `edit(path,oldText,newText)`, `edit({path,edits:[{oldText,newText}]})`; unique in the file |
|
|
59
|
+
| `edit` | `edit(view,text)` CAS-replaces that span; `edit(view,old,new)` is unique inside it |
|
|
59
60
|
| `edit` | `edit({path,patch})`; unified patch application |
|
|
60
61
|
| `edit` | `edit(async () => {...})`; filesystem-only checkpoint, described below |
|
|
61
62
|
| `write` | `write(path,text)`, `write({path,content})`; atomic replacement |
|
|
@@ -70,27 +71,26 @@ payloads are not repeated in owned direct-execution errors;
|
|
|
70
71
|
stdout/stderr, exit status and source context remain. Session environment variables are taken
|
|
71
72
|
from the current execution context, not inherited from a different parent session.
|
|
72
73
|
|
|
73
|
-
Source questions
|
|
74
|
+
Source questions locate a declaration in one command. An exact
|
|
74
75
|
declaration match uses one bounded direct ripgrep search, without a prerequisite
|
|
75
76
|
file listing, persistent index, embeddings or summarization. A transient filename
|
|
76
77
|
listing is a fallback for unmatched content or unresolved bare filenames. Natural-language
|
|
77
78
|
questions reuse lexical stemming. Ripgrep must be available on PATH.
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
`read(path)` stays raw text. `read("symbol")` is the same view as
|
|
81
|
+
`read({query, resolve:true})` — not the file, not a path/range header:
|
|
81
82
|
|
|
82
83
|
```javascript
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
|
|
84
|
+
const v = await read("validateRefreshToken");
|
|
85
|
+
if (v.status !== "found") return v;
|
|
86
|
+
await edit(v, v.text.replace("token.length > 3", "token.length > 5"));
|
|
86
87
|
```
|
|
87
88
|
|
|
88
|
-
The
|
|
89
|
-
`
|
|
90
|
-
|
|
91
|
-
the matching line and give a continuation; they are not summarized. Uncertain
|
|
89
|
+
The view contains `status`, `path`, the matching `line`, span `lines`, unchanged
|
|
90
|
+
`text`, `complete`, and `nextOffset` when a budget clip continues. A declaration
|
|
91
|
+
snap is that span (`complete` is false unless the span is the whole file). Uncertain
|
|
92
92
|
results report `ambiguous`, `not_found` or `incomplete` with no selected path.
|
|
93
|
-
Use `{path: directory, about: question
|
|
93
|
+
Use `{path: directory, about: question}` to narrow the scope.
|
|
94
94
|
|
|
95
95
|
Ordinary reads stay self-contained. Outlines and graph evidence remain explicit
|
|
96
96
|
options, not mandatory stages of source resolution. Ordinary calls also get:
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2026-09-12
|
|
4
|
+
|
|
5
|
+
- Report overlapping `supernova` calls from every participant in the wave, including the first-started call that finishes last. Start-order or finish-order counters missed that side; a peak concurrent count resets when the wave drains. Sequential calls, `programs` batches, and failed programs still do not leak a split hint. The regression forces the slower first program so the last-finisher case cannot flake under load.
|
|
6
|
+
|
|
7
|
+
- Shrink the standing tool definition without compressing source or results. Duplicate object-form restatements, schema prose already covered by the command list, and discoverable operational asides are gone; signatures and safety rules stay in the always-sent reference. Frozen six-call traffic is 14,970 / 14,787 tokens (o200k / cl100k): 19.23% / 19.24% below d444eb7. The current-pass gate now requires 19% vs d444eb7.
|
|
8
|
+
|
|
9
|
+
- An unmatched or non-unique edit keeps the file and returns a numbered window of the actual source (16-line cap, same coordinates as a successful edit) so the next program can copy oldText without a blind re-read. Successful read/write/edit results are unchanged.
|
|
10
|
+
|
|
11
|
+
- A program that writes or edits and returns nothing still delivers those mutation receipts (numbered post-edit lines, `wrote` paths). Reads without a return stay a no-return hint and do not dump file contents. Explicit `return await edit(...)` is not duplicated.
|
|
12
|
+
|
|
13
|
+
- `edit(view, text)` replaces a `resolve:true` window by line span with CAS against the viewed bytes. Duplicate substrings no longer block a ranged edit; a stale view fails and rolls back. The silent receipt names that span (`edited path:2-2`), not a ±2 context window.
|
|
14
|
+
|
|
15
|
+
- A `resolve:true` snap of a declaration is that declaration's span, not the whole file just because the file fits the read budget. `edit(view, text)` then replaces the function, not the file. `edit(view, old, new)` is unique inside that span. A budget-clipped view (`nextOffset`) is not editable.
|
|
16
|
+
|
|
17
|
+
- Gravity: `read("symbol")` is the same view as `read({query, resolve:true})`. `read(path)` stays raw text. `looksLikePath` lives in `shared/decode.js` so guest and host share the identifier-vs-path rule.
|
|
18
|
+
|
|
19
|
+
- Nested declaration spans: a parent still includes its body (brace-matched from the opening line), one-line class methods resolve, and two exact same-name declarations in one file are ambiguous instead of snapping the first. Ambiguous candidates (same-file or cross-file) are that hit's span (signature, lines, text, context), not a clone of the first match's window. Span pick/slice live in `src/context/spans.js` for locate + host resolve.
|
|
20
|
+
|
|
21
|
+
- Do not subscribe a `context` observer when `seenWindow` is 0. A no-op listener still ran on every provider context event; opt-in retention windows still register.
|
|
22
|
+
|
|
23
|
+
- Encode the module BIND spine as a regression: acyclic imports, no upward edges, context and runtime stay siblings. host-bridge remains the fused INVOKE kernel.
|
|
24
|
+
|
|
25
|
+
- Drop guest/host RPC for `search` and `describe`, and stop returning unused `exec`/`patch`/`nova` bindings from the worker API. The guest still injects only read, write, edit, bash.
|
|
26
|
+
|
|
3
27
|
## [0.4.0] - 2026-09-10
|
|
4
28
|
|
|
5
29
|
- Disable citation elision by default (`seenWindow: 0`), including direct bridge/ledger defaults. A failure-first integration regression preserves complete results across hidden, reordered and removed context. Positive windows remain an explicit research opt-in, not a validated retention guarantee. The historical ledger measurements below describe that experimental mode only.
|
package/docs/TOKEN_COSTS.md
CHANGED
|
@@ -69,30 +69,29 @@ text contributes to totals through later history, not as a second charge.
|
|
|
69
69
|
|
|
70
70
|
## Measured results
|
|
71
71
|
|
|
72
|
-
Observed on macOS with Node v26.7.0
|
|
73
|
-
produced the same token counts.
|
|
72
|
+
Observed on macOS with Node v26.7.0.
|
|
74
73
|
|
|
75
74
|
| Tokenizer | Non-batched baseline | Batched baseline (d444eb7) | Current | Further reduction | Total reduction |
|
|
76
75
|
| --- | ---: | ---: | ---: | ---: | ---: |
|
|
77
|
-
| o200k_base |
|
|
78
|
-
| cl100k_base |
|
|
76
|
+
| o200k_base | 28,130 | 18,535 | 9,537 | **48.55%** | **66.10%** |
|
|
77
|
+
| cl100k_base | 27,841 | 18,310 | 9,420 | **48.55%** | **66.17%** |
|
|
79
78
|
|
|
80
79
|
The gate requires at least 40% reduction on **each tokenizer for the complete
|
|
81
80
|
workload**, not for every scenario individually. Token counts and reductions are
|
|
82
81
|
computed from the recorded baseline and fresh execution results, not constants
|
|
83
|
-
returned by the runtime. A second gate requires another
|
|
82
|
+
returned by the runtime. A second gate requires another 19% against the measured
|
|
84
83
|
batched baseline from commit d444eb7. Its six-call argument hash is pinned as well
|
|
85
84
|
as the original workload: removing a decision boundary cannot satisfy this gate.
|
|
86
85
|
|
|
87
86
|
### What changed after the batched baseline
|
|
88
87
|
|
|
89
|
-
The serialized definition falls from 1,068 to
|
|
90
|
-
1,057 to
|
|
91
|
-
the command
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
The serialized definition falls from 1,068 to 577 tokens with o200k_base and from
|
|
89
|
+
1,057 to 572 with cl100k_base. Duplicate object-form restatements, parameter
|
|
90
|
+
prose already covered by the command list, and discoverable operational asides
|
|
91
|
+
were removed; command signatures and safety rules (`complete:true`, JSON 16 MiB /
|
|
92
|
+
no jq, array-read rejection, transactions, `programs` batch, edit oldText as an
|
|
93
|
+
exact substring, `edit(view,text)`) remain in the standing reference. No source
|
|
94
|
+
text or independent result is removed or compressed.
|
|
96
95
|
|
|
97
96
|
For the fixed six-call schedule, the accounting can also be written as:
|
|
98
97
|
|
|
@@ -100,14 +99,17 @@ For the fixed six-call schedule, the accounting can also be written as:
|
|
|
100
99
|
Total = (N+1)*D + sum((N-i+2)*A_i + (N-i+1)*R_i, i = 1..N)
|
|
101
100
|
~~~
|
|
102
101
|
|
|
103
|
-
Seven definition appearances save 7*
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
102
|
+
Seven definition appearances still save 7*491 = 3,437 tokens with o200k_base versus
|
|
103
|
+
d444eb7's 1,068-token definition. Snap-to-span then changed the first repair
|
|
104
|
+
observation from a whole-file view to the `MAX_JSON_BYTES` declaration
|
|
105
|
+
(`lines:[3,3]`, `complete:false`). That smaller result is replayed through later
|
|
106
|
+
requests; no source or independent result is compressed or dropped. Frozen
|
|
107
|
+
programs, arguments and decision boundaries are unchanged. Combined with
|
|
108
|
+
batching, current o200k traffic is 9,558 vs d444eb7's 18,535 (48.43%).
|
|
109
|
+
|
|
110
|
+
The current definition is now *below* the non-batched baseline (577 vs 908
|
|
111
|
+
o200k_base). Batching still adds result framing. One-off calls should not be
|
|
112
|
+
assumed to benefit from the batch API.
|
|
111
113
|
|
|
112
114
|
The report also includes separate source-framing and argument-reuse comparisons.
|
|
113
115
|
Those component measurements are not total-session savings, and the reported
|
package/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { REFERENCE } from "./src/runtime/reference.js";
|
|
|
4
4
|
import { isString, isFunction } from "./src/shared/decode.js";
|
|
5
5
|
import { loadConfig } from "./src/config/config.js";
|
|
6
6
|
import { createHostBridge } from "./src/bridge/host-bridge.js";
|
|
7
|
-
import { truncateChars } from "./src/output/format.js";
|
|
7
|
+
import { truncateChars, formatBoundedStringArray } from "./src/output/format.js";
|
|
8
8
|
import { runGuestProgram, warmGuestWorker, stopWarmGuestWorker } from "./src/runtime/runtime.js";
|
|
9
9
|
import { renderSupernovaCall, renderSupernovaResult } from "./src/ui/render.js";
|
|
10
10
|
|
|
@@ -99,6 +99,15 @@ function mutationText(outcome) {
|
|
|
99
99
|
return "\nmutations: committed=" + m.committed + " rolledBack=" + m.rolledBack + " (file versions)" + external + uncertain;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
function mutationReceipts(trace) {
|
|
103
|
+
if (!Array.isArray(trace)) return "";
|
|
104
|
+
|
|
105
|
+
return trace
|
|
106
|
+
.filter(row => row?.ok && (row.name === "write" || row.name === "edit") && isString(row.resultText) && row.resultText)
|
|
107
|
+
.map(row => row.resultText)
|
|
108
|
+
.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
102
111
|
// Corrective hint, emitted only when a turn actually split. Independent work
|
|
103
112
|
// belongs in one program: a split cannot use the single prewarmed worker and pays
|
|
104
113
|
// one extra spawn per sibling. Costs nothing until it fires, so it needs no room in
|
|
@@ -119,6 +128,25 @@ function successText(outcome, call) {
|
|
|
119
128
|
return `ok #${call} ${outcome.wallMs}ms${truncated}${outcome.mutations?.committed || outcome.mutations?.rolledBack || outcome.mutations?.external ? mutationText(outcome) : ""}${splitTurnHint(outcome)}${logsBlock(outcome, "\n--- result")}\n${outcome.resultText}${hint}`;
|
|
120
129
|
}
|
|
121
130
|
|
|
131
|
+
function fitOutput(outcome, call, limit, format) {
|
|
132
|
+
let text = format(outcome, call);
|
|
133
|
+
|
|
134
|
+
if (text.length <= limit) return text;
|
|
135
|
+
outcome.returnTruncated = true;
|
|
136
|
+
const wrapper = format({ ...outcome, resultText: "", logs: [] }, call);
|
|
137
|
+
const room = Math.max(256, limit - wrapper.length);
|
|
138
|
+
|
|
139
|
+
if (Array.isArray(outcome.result) && outcome.result.length && outcome.result.every(isString)) {
|
|
140
|
+
outcome.resultText = formatBoundedStringArray(outcome.result, room);
|
|
141
|
+
} else if (isString(outcome.resultText) && outcome.resultText.length > room) {
|
|
142
|
+
outcome.resultText = truncateChars(outcome.resultText, room, "output").text;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
text = format(outcome, call);
|
|
146
|
+
|
|
147
|
+
return text.length <= limit ? text : truncateChars(text, limit, "output").text;
|
|
148
|
+
}
|
|
149
|
+
|
|
122
150
|
const TOOL_DESCRIPTION = REFERENCE;
|
|
123
151
|
|
|
124
152
|
export default function piSupernova(pi) {
|
|
@@ -140,6 +168,9 @@ export function registerCodeMode(pi) {
|
|
|
140
168
|
// Counting them lets a result say so without adding standing guidance to the
|
|
141
169
|
// tool definition, which is resent on every request.
|
|
142
170
|
let inFlight = 0;
|
|
171
|
+
// Peak concurrent execute() bodies in the current wave. Start-order or
|
|
172
|
+
// finish-order alone cannot see a first-started call that finishes last.
|
|
173
|
+
let overlapPeak = 0;
|
|
143
174
|
|
|
144
175
|
function cancelWarmTimer() {
|
|
145
176
|
if (warmTimer !== undefined) clearImmediate(warmTimer);
|
|
@@ -176,17 +207,17 @@ export function registerCodeMode(pi) {
|
|
|
176
207
|
name: "supernova",
|
|
177
208
|
label: "Supernova",
|
|
178
209
|
description: TOOL_DESCRIPTION,
|
|
179
|
-
promptSnippet: "
|
|
210
|
+
promptSnippet: "read, write, edit, bash",
|
|
180
211
|
parameters: Type.Object({
|
|
181
212
|
code: Type.Optional(Type.String({ maxLength: config.maxCodeChars ?? 48000 })),
|
|
182
213
|
file: Type.Optional(Type.String({ minLength: 1 })),
|
|
183
|
-
data: Type.Optional(Type.Unknown(
|
|
184
|
-
timeoutMs: Type.Optional(Type.Integer({ minimum: 1000
|
|
214
|
+
data: Type.Optional(Type.Unknown()),
|
|
215
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: 1000 })),
|
|
185
216
|
programs: Type.Optional(Type.Array(Type.Object({
|
|
186
217
|
code: Type.Optional(Type.String({ maxLength: config.maxCodeChars ?? 48000 })),
|
|
187
218
|
file: Type.Optional(Type.String({ minLength: 1 })),
|
|
188
219
|
data: Type.Optional(Type.Unknown()),
|
|
189
|
-
}, {additionalProperties:false}), {minItems:1,maxItems:32
|
|
220
|
+
}, {additionalProperties:false}), {minItems:1,maxItems:32})),
|
|
190
221
|
}),
|
|
191
222
|
// One self-owned result frame is shared by Pi and OMP; renderCall stays empty
|
|
192
223
|
// so separate call/result slots cannot duplicate the lifecycle card.
|
|
@@ -214,8 +245,9 @@ export function registerCodeMode(pi) {
|
|
|
214
245
|
emitProgress([]);
|
|
215
246
|
const started = performance.now();
|
|
216
247
|
let outcome;
|
|
217
|
-
const overlappedTurn = inFlight > 0 ? inFlight + 1 : 0;
|
|
218
248
|
inFlight += 1;
|
|
249
|
+
overlapPeak = Math.max(overlapPeak, inFlight);
|
|
250
|
+
let peakSeen = overlapPeak;
|
|
219
251
|
|
|
220
252
|
try {
|
|
221
253
|
refreshCatalog(runBridge);
|
|
@@ -244,7 +276,9 @@ export function registerCodeMode(pi) {
|
|
|
244
276
|
while (runBridge.getOverlayDepth()) runBridge.rollbackSpeculation();
|
|
245
277
|
outcome = { ok: false, error: error instanceof Error ? error.message : String(error), logs: outcome?.logs ?? [], wallMs: Math.round(performance.now() - started) };
|
|
246
278
|
} finally {
|
|
279
|
+
peakSeen = Math.max(peakSeen, overlapPeak);
|
|
247
280
|
inFlight -= 1;
|
|
281
|
+
if (inFlight === 0) overlapPeak = 0;
|
|
248
282
|
runBridge.setCallListener(null);
|
|
249
283
|
emitProgress.flush();
|
|
250
284
|
signal?.removeEventListener("abort", abortRun);
|
|
@@ -264,21 +298,20 @@ export function registerCodeMode(pi) {
|
|
|
264
298
|
}
|
|
265
299
|
|
|
266
300
|
if (budget) budget.logLines += outcome.logs?.length ?? 0;
|
|
267
|
-
|
|
268
|
-
// running: report the overlap from either side so the hint does not depend on
|
|
269
|
-
// which invocation happened to start first.
|
|
270
|
-
outcome.overlappedTurn = overlappedTurn || (inFlight > 0 ? inFlight + 1 : 0);
|
|
301
|
+
outcome.overlappedTurn = peakSeen > 1 ? peakSeen : 0;
|
|
271
302
|
outcome.mutations = runBridge.getMutations();
|
|
272
303
|
const trace = runBridge.getTrace();
|
|
273
|
-
const format = outcome.ok ? successText : errorText;
|
|
274
|
-
let text = format(outcome, call);
|
|
275
304
|
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
text = format(outcome, call);
|
|
279
|
-
}
|
|
305
|
+
if (outcome.ok && outcome.result === undefined) {
|
|
306
|
+
const receipts = mutationReceipts(trace);
|
|
280
307
|
|
|
281
|
-
|
|
308
|
+
if (receipts) {
|
|
309
|
+
outcome.resultText = receipts;
|
|
310
|
+
outcome.undefinedReturn = false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const format = outcome.ok ? successText : errorText;
|
|
314
|
+
const bounded = fitOutput(outcome, call, config.maxReturnChars, format);
|
|
282
315
|
const visible = runBridge.ledger.dedupe(bounded, call);
|
|
283
316
|
|
|
284
317
|
const response = result(visible, {
|
|
@@ -300,10 +333,13 @@ export function registerCodeMode(pi) {
|
|
|
300
333
|
});
|
|
301
334
|
|
|
302
335
|
// This is a pre-conversion observation, not a final-payload retention proof.
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
336
|
+
// Shipping seenWindow:0 must not subscribe: a no-op listener still runs on every
|
|
337
|
+
// provider context event. Opt-in windows register here.
|
|
338
|
+
if ((config.seenWindow ?? 0) > 0) {
|
|
339
|
+
pi.on("context", event => {
|
|
340
|
+
try { bridge.ledger.observe(event?.messages); } catch {}
|
|
341
|
+
});
|
|
342
|
+
}
|
|
307
343
|
|
|
308
344
|
pi.on("session_shutdown", () => { stopped = true; cancelWarmTimer();
|
|
309
345
|
|
package/package.json
CHANGED
|
@@ -3,10 +3,11 @@ import * as fs from "node:fs/promises";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { packageHostResult, hostResultFailed } from "../output/bottleneck.js";
|
|
6
|
-
import { isString, isNumber, isFunction, isObject } from "../shared/decode.js";
|
|
6
|
+
import { isString, isNumber, isFunction, isObject, looksLikePath } from "../shared/decode.js";
|
|
7
7
|
import { isMutatingTool, runParallelWave, createNativeScheduler } from "../runtime/parallel.js";
|
|
8
8
|
import { unknownToolMessage } from "./catalog.js";
|
|
9
9
|
import { extractStructuralSurface } from "../context/surface.js";
|
|
10
|
+
import { pickSpan } from "../context/spans.js";
|
|
10
11
|
import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "../fs/diff.js";
|
|
11
12
|
import { executeSnap } from "../context/snap.js";
|
|
12
13
|
import { selectEvidence } from "../context/evidence.js";
|
|
@@ -18,7 +19,7 @@ import { declaredName } from "../context/repo-index.js";
|
|
|
18
19
|
import { CausalVfs } from "../fs/vfs.js";
|
|
19
20
|
import { MAX_JSON_BYTES, jsonProjector, sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
|
|
20
21
|
import { applyPatchToText } from "../fs/patch.js";
|
|
21
|
-
import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "../fs/workspace.js";
|
|
22
|
+
import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash, assertFilesystemPath } from "../fs/workspace.js";
|
|
22
23
|
import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs, referencesForNames } from "../context/search.js";
|
|
23
24
|
|
|
24
25
|
function textResult(text, details) {
|
|
@@ -28,6 +29,20 @@ function textResult(text, details) {
|
|
|
28
29
|
};
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
function resultDiff(response) {
|
|
33
|
+
let details = response?.details;
|
|
34
|
+
|
|
35
|
+
if (isString(details)) {
|
|
36
|
+
try {
|
|
37
|
+
details = JSON.parse(details);
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return isObject(details) ? details.diff : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
/** Unwrap a single matching quote pair around the whole string (`'git status'`). */
|
|
32
47
|
function unwrapIfFullyQuoted(s) {
|
|
33
48
|
if (s.length < 2) return s;
|
|
@@ -52,19 +67,9 @@ function sliceLines(text, offset, limit) {
|
|
|
52
67
|
return lines.slice(startIndex, startIndex + count).join("\n");
|
|
53
68
|
}
|
|
54
69
|
|
|
55
|
-
function looksLikePath(target) {
|
|
56
|
-
return (
|
|
57
|
-
isString(target) &&
|
|
58
|
-
(target.includes("/") ||
|
|
59
|
-
target.includes("\\") ||
|
|
60
|
-
target.startsWith(".") ||
|
|
61
|
-
(!/\s/.test(target) && path.extname(target).length > 0))
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
70
|
function resolveReadPath(cwd, target) {
|
|
66
71
|
if (!isString(target) || !target.trim()) throw new Error("read requires path");
|
|
67
|
-
const input = target
|
|
72
|
+
const input = assertFilesystemPath(target, "read");
|
|
68
73
|
|
|
69
74
|
return path.resolve(cwd, input === "~" ? homedir() : input.startsWith("~/") ? path.join(homedir(), input.slice(2)) : input);
|
|
70
75
|
}
|
|
@@ -87,6 +92,39 @@ async function probeExistingPath(cwd, targetParam, vfs) {
|
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
const EDIT_PREVIEW_LINES = 16;
|
|
96
|
+
|
|
97
|
+
function sourceLines(content) {
|
|
98
|
+
const raw = content.split("\n");
|
|
99
|
+
|
|
100
|
+
if (raw.at(-1) === "") raw.pop();
|
|
101
|
+
|
|
102
|
+
return raw;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function lineNumberAt(content, index) {
|
|
106
|
+
let line = 1;
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < index; i++) if (content.charCodeAt(i) === 10) line++;
|
|
109
|
+
|
|
110
|
+
return line;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formatNumberedLine(n, text) {
|
|
114
|
+
return String(n).padStart(5) + " " + text;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function numberedPreview(content, cap = EDIT_PREVIEW_LINES) {
|
|
118
|
+
const lines = sourceLines(content);
|
|
119
|
+
|
|
120
|
+
if (lines.length === 0) return "0 lines";
|
|
121
|
+
const shown = lines.slice(0, cap);
|
|
122
|
+
const body = shown.map((line, i) => formatNumberedLine(i + 1, line)).join("\n");
|
|
123
|
+
const suffix = lines.length > cap ? lines.length + " lines total" : lines.length + " lines";
|
|
124
|
+
|
|
125
|
+
return body + "\n" + suffix;
|
|
126
|
+
}
|
|
127
|
+
|
|
90
128
|
function applyReplacements(target, content, requestedEdits) {
|
|
91
129
|
if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
|
|
92
130
|
|
|
@@ -99,11 +137,16 @@ function applyReplacements(target, content, requestedEdits) {
|
|
|
99
137
|
const index = content.indexOf(replacement.oldText);
|
|
100
138
|
|
|
101
139
|
if (index < 0) {
|
|
102
|
-
throw new Error(
|
|
140
|
+
throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + numberedPreview(content));
|
|
103
141
|
}
|
|
142
|
+
const second = content.indexOf(replacement.oldText, index + 1);
|
|
104
143
|
|
|
105
|
-
if (
|
|
106
|
-
|
|
144
|
+
if (second >= 0) {
|
|
145
|
+
const lines = sourceLines(content);
|
|
146
|
+
const a = lineNumberAt(content, index);
|
|
147
|
+
const b = lineNumberAt(content, second);
|
|
148
|
+
|
|
149
|
+
throw new Error("edit target is not unique in " + target + ": lines " + a + " and " + b + "; include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]\n" + formatNumberedLine(a, lines[a - 1] ?? "") + "\n" + formatNumberedLine(b, lines[b - 1] ?? ""));
|
|
107
150
|
}
|
|
108
151
|
|
|
109
152
|
return { ...replacement, index, end: index + replacement.oldText.length };
|
|
@@ -125,6 +168,27 @@ function applyReplacements(target, content, requestedEdits) {
|
|
|
125
168
|
return { updated, matches };
|
|
126
169
|
}
|
|
127
170
|
|
|
171
|
+
function applyViewReplace(target, content, start, end, oldText, newText) {
|
|
172
|
+
const current = sliceLines(content, start, end - start + 1);
|
|
173
|
+
|
|
174
|
+
if (current !== oldText) {
|
|
175
|
+
const shown = current.length ? current : content;
|
|
176
|
+
|
|
177
|
+
throw new Error("edit view is stale in " + target + ": lines " + start + "-" + end + " changed\n" + numberedPreview(shown));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const hadTrail = content.endsWith("\n");
|
|
181
|
+
const lines = content.split("\n");
|
|
182
|
+
|
|
183
|
+
if (hadTrail && lines.at(-1) === "") lines.pop();
|
|
184
|
+
const insert = newText.split("\n");
|
|
185
|
+
|
|
186
|
+
if (newText.endsWith("\n") && insert.at(-1) === "") insert.pop();
|
|
187
|
+
const updated = [...lines.slice(0, start - 1), ...insert, ...lines.slice(end)].join("\n") + (hadTrail ? "\n" : "");
|
|
188
|
+
|
|
189
|
+
return { updated, oldText, newText };
|
|
190
|
+
}
|
|
191
|
+
|
|
128
192
|
function formatDirectoryEntry(name, type, size = 0) {
|
|
129
193
|
const sizeSuffix = size ? `, ${size} bytes` : "";
|
|
130
194
|
|
|
@@ -151,6 +215,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
151
215
|
const reads = createNativeScheduler();
|
|
152
216
|
|
|
153
217
|
async function sourceRead(query, searchDir, signal, params = {}) {
|
|
218
|
+
params = { ...params, resolve: params.resolve !== false };
|
|
154
219
|
const cwd = getCwd();
|
|
155
220
|
|
|
156
221
|
const includeHidden = path.relative(cwd, searchDir).split(path.sep)
|
|
@@ -160,15 +225,15 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
160
225
|
pathContext: { frecency: index.frecency, currentFile: index.lastTouched },
|
|
161
226
|
overlayText: p => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), signal });
|
|
162
227
|
|
|
163
|
-
return openSource(result, params, signal);
|
|
228
|
+
return openSource(result, params, signal, undefined, query);
|
|
164
229
|
}
|
|
165
230
|
|
|
166
|
-
async function openSource(result, params, signal, resolvedPath) {
|
|
231
|
+
async function openSource(result, params, signal, resolvedPath, query) {
|
|
167
232
|
const cwd = getCwd();
|
|
168
233
|
|
|
169
234
|
if (result.status !== "found") return textResult(JSON.stringify(result), { isSnap: true });
|
|
170
235
|
signal?.throwIfAborted();
|
|
171
|
-
const opened = await readFile(resolvedPath ?? path.resolve(cwd, result.path), { ...params, about: undefined }, result.line, result.path);
|
|
236
|
+
const opened = await readFile(resolvedPath ?? path.resolve(cwd, result.path), { ...params, about: undefined }, result.line, result.path, query);
|
|
172
237
|
const block = opened.content[0];
|
|
173
238
|
|
|
174
239
|
if (block.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
|
|
@@ -318,7 +383,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
318
383
|
}
|
|
319
384
|
|
|
320
385
|
/** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
|
|
321
|
-
async function readFile(targetPath, params, sourceLine, displayPath) {
|
|
386
|
+
async function readFile(targetPath, params, sourceLine, displayPath, query) {
|
|
322
387
|
const cwd = getCwd();
|
|
323
388
|
const rel = displayPath ?? relativeSlash(cwd, targetPath);
|
|
324
389
|
|
|
@@ -371,9 +436,22 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
371
436
|
|
|
372
437
|
const explicit = isNumber(params?.offset) || isNumber(params?.limit);
|
|
373
438
|
const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - (params.resolve ? 1024 : 256));
|
|
374
|
-
|
|
439
|
+
let offset = params?.offset;
|
|
440
|
+
let limit = params?.limit;
|
|
441
|
+
|
|
442
|
+
if (!explicit && params.resolve) {
|
|
443
|
+
const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(targetPath, text));
|
|
444
|
+
const span = pickSpan(spans, { line: sourceLine, name: query });
|
|
445
|
+
|
|
446
|
+
if (span) {
|
|
447
|
+
offset = span.start;
|
|
448
|
+
limit = span.end - span.start + 1;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
offset ??= sourceLine && text.length > budget ? Math.max(1, sourceLine - 2) : 1;
|
|
375
453
|
const firstLine = isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1;
|
|
376
|
-
const sliced = sliceLines(text, offset,
|
|
454
|
+
const sliced = sliceLines(text, offset, limit);
|
|
377
455
|
|
|
378
456
|
if (params.complete === true && (sliced !== text || sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget))) {
|
|
379
457
|
throw new Error(`incomplete read of ${rel}: complete:true requires the entire file within the read budget; use json:".field" for JSON reports, about for text selection, edit() for replacements, or reconstruct resolve:true source windows`);
|
|
@@ -416,19 +494,23 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
416
494
|
* lines with numbers, a quick structural check, and bounded lexical reference hints.
|
|
417
495
|
* These do not replace tests or semantic caller resolution.
|
|
418
496
|
*/
|
|
419
|
-
async function editSummary(cwd, target, original, updated, diff, signal) {
|
|
497
|
+
async function editSummary(cwd, target, original, updated, diff, signal, span) {
|
|
420
498
|
const rel = relativeSlash(cwd, target);
|
|
421
499
|
const newLines = updated.split("\n");
|
|
422
500
|
const ranges = [];
|
|
423
501
|
|
|
424
|
-
|
|
425
|
-
.
|
|
502
|
+
if (span && Number.isInteger(span.start) && Number.isInteger(span.end) && span.start >= 1 && span.end >= span.start) {
|
|
503
|
+
ranges.push({ start: span.start, end: Math.min(newLines.length, span.end) });
|
|
504
|
+
} else {
|
|
505
|
+
const positions = diff.lines.filter(row => row.type !== "context")
|
|
506
|
+
.map(row => Math.min(newLines.length, row.newLineNum ?? row.lineNum)).sort((a, b) => a - b);
|
|
426
507
|
|
|
427
|
-
|
|
428
|
-
|
|
508
|
+
for (const line of positions) {
|
|
509
|
+
const start = Math.max(1, line - 2), end = Math.min(newLines.length, line + 2);
|
|
429
510
|
|
|
430
|
-
|
|
431
|
-
|
|
511
|
+
if (ranges.length && start <= ranges.at(-1).end + 1) ranges.at(-1).end = Math.max(ranges.at(-1).end, end);
|
|
512
|
+
else ranges.push({ start, end });
|
|
513
|
+
}
|
|
432
514
|
}
|
|
433
515
|
|
|
434
516
|
const blocks = [];
|
|
@@ -625,11 +707,28 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
625
707
|
|
|
626
708
|
if (signal?.aborted) throw new Error("aborted");
|
|
627
709
|
|
|
710
|
+
const content = await vfs.read(target);
|
|
711
|
+
|
|
712
|
+
if (isNumber(params?.viewStart) && isNumber(params?.viewEnd) && isString(params?.viewText) && isString(params?.newText)) {
|
|
713
|
+
const windowNext = isString(params.oldText)
|
|
714
|
+
? applyReplacements(target, params.viewText, [{ oldText: params.oldText, newText: params.newText }]).updated
|
|
715
|
+
: params.newText;
|
|
716
|
+
const { updated } = applyViewReplace(target, content, params.viewStart, params.viewEnd, params.viewText, windowNext);
|
|
717
|
+
const { speculative } = await vfs.write(target, updated);
|
|
718
|
+
index.touch(relativeSlash(cwd, target));
|
|
719
|
+
const diffFrom = isString(params.oldText) ? params.oldText : params.viewText;
|
|
720
|
+
const diffTo = isString(params.oldText) ? params.newText : windowNext;
|
|
721
|
+
const diff = buildEditDiff(target, content, diffFrom, diffTo);
|
|
722
|
+
const inserted = sourceLines(windowNext);
|
|
723
|
+
const spanEnd = params.viewStart + Math.max(inserted.length, 1) - 1;
|
|
724
|
+
const summary = await editSummary(cwd, target, content, updated, diff, signal, { start: params.viewStart, end: spanEnd });
|
|
725
|
+
|
|
726
|
+
return textResult(summary, { path: target, speculative, diff });
|
|
727
|
+
}
|
|
728
|
+
|
|
628
729
|
const requestedEdits = Array.isArray(params?.edits)
|
|
629
730
|
? params.edits
|
|
630
731
|
: [{ oldText: params?.oldText, newText: params?.newText }];
|
|
631
|
-
|
|
632
|
-
const content = await vfs.read(target);
|
|
633
732
|
const { updated, matches } = applyReplacements(target, content, requestedEdits);
|
|
634
733
|
const { speculative } = await vfs.write(target, updated);
|
|
635
734
|
index.touch(relativeSlash(cwd, target));
|
|
@@ -733,7 +832,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
733
832
|
},
|
|
734
833
|
async bash(params, signal) {
|
|
735
834
|
const cwd = getCwd();
|
|
736
|
-
const literal = params?.
|
|
835
|
+
const literal = Array.isArray(params?.args) && process.platform !== "win32" && params.args.length === Object.keys(params.args).length && params.args.every(isString);
|
|
737
836
|
|
|
738
837
|
if (literal && (!isString(params.command) || !Array.isArray(params.args) || params.args.some(arg => !isString(arg)))) throw new Error("bash argv requires a command string and an array of string args");
|
|
739
838
|
const command = literal ? String(params.command) : unwrapIfFullyQuoted(String(params?.command ?? "").trim());
|
|
@@ -850,6 +949,11 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
850
949
|
};
|
|
851
950
|
}
|
|
852
951
|
|
|
952
|
+
/**
|
|
953
|
+
* Fused INVOKE kernel. Guest RPC is the only caller; fuel is cwd + vfs + signal.
|
|
954
|
+
* BIND stays downward (see tests/contracts/layers.test.mjs). Do not split this
|
|
955
|
+
* closure into pass-through files that re-import each other.
|
|
956
|
+
*/
|
|
853
957
|
export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger, budget }) {
|
|
854
958
|
const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
|
|
855
959
|
const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 0 });
|
|
@@ -1018,20 +1122,6 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1018
1122
|
return vfs.rollback();
|
|
1019
1123
|
}
|
|
1020
1124
|
|
|
1021
|
-
function resultDiff(response) {
|
|
1022
|
-
let details = response?.details;
|
|
1023
|
-
|
|
1024
|
-
if (isString(details)) {
|
|
1025
|
-
try {
|
|
1026
|
-
details = JSON.parse(details);
|
|
1027
|
-
} catch {
|
|
1028
|
-
return undefined;
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
return isObject(details) ? details.diff : undefined;
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
1125
|
function notifyCall(record) {
|
|
1036
1126
|
if (!callListener) return;
|
|
1037
1127
|
|
|
@@ -1106,8 +1196,9 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1106
1196
|
try {
|
|
1107
1197
|
const delegated = hostTool(name);
|
|
1108
1198
|
const exec = delegated ? delegated.execute.bind(delegated) : hostSession ? undefined : executors.get(name);
|
|
1199
|
+
const argvOwned = name === "bash" && Array.isArray(args?.args) && process.platform !== "win32" && args.args.length === Object.keys(args.args).length && args.args.every(isString);
|
|
1109
1200
|
|
|
1110
|
-
if (exec) {
|
|
1201
|
+
if (exec && !argvOwned) {
|
|
1111
1202
|
if (name === "read" && (args?.json !== undefined || /^(agent|artifact):\/\/.*\?/i.test(String(args?.path)))) throw new Error("JSON projection requires the Supernova-owned read adapter, not an external override");
|
|
1112
1203
|
|
|
1113
1204
|
if (name === "write" && args?.append === true) throw new Error("append requires the Supernova-owned write adapter, not an external override");
|
|
@@ -1158,6 +1249,11 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1158
1249
|
const exitCode = isObject(res?.details) ? res.details.exitCode : undefined;
|
|
1159
1250
|
|
|
1160
1251
|
if (Number.isInteger(exitCode) && exitCode !== 0) record.exitCode = exitCode;
|
|
1252
|
+
const text = isObject(res) && Array.isArray(res.content)
|
|
1253
|
+
? res.content.filter(part => part?.type === "text" && isString(part.text)).map(part => part.text).join("\n")
|
|
1254
|
+
: undefined;
|
|
1255
|
+
|
|
1256
|
+
if (text) record.resultText = text;
|
|
1161
1257
|
}
|
|
1162
1258
|
|
|
1163
1259
|
async function call(name, args) {
|
|
@@ -96,6 +96,42 @@ export function globToRegExp(glob) {
|
|
|
96
96
|
return new RegExp(glob.includes("/") ? "^" + body + "$" : "(?:^|/)" + body + "$");
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
function declarationEnd(raw, lower, start, lineCount, ext) {
|
|
100
|
+
if (ext === ".py") {
|
|
101
|
+
const indentOf = (i) => raw[i].length - raw[i].trimStart().length;
|
|
102
|
+
const base = indentOf(start - 1);
|
|
103
|
+
let end = start;
|
|
104
|
+
|
|
105
|
+
for (let i = start; i < lineCount; i++) {
|
|
106
|
+
if (lower[i] === "") { end = i + 1; continue; }
|
|
107
|
+
if (indentOf(i) <= base) break;
|
|
108
|
+
end = i + 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return Math.min(end, lineCount);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let depth = 0;
|
|
115
|
+
|
|
116
|
+
for (const ch of raw[start - 1] ?? "") {
|
|
117
|
+
if (ch === "{") depth++;
|
|
118
|
+
else if (ch === "}") depth--;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (depth <= 0) return start;
|
|
122
|
+
|
|
123
|
+
for (let i = start; i < raw.length; i++) {
|
|
124
|
+
for (const ch of raw[i]) {
|
|
125
|
+
if (ch === "{") depth++;
|
|
126
|
+
else if (ch === "}") depth--;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (depth <= 0) return i + 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return lineCount;
|
|
133
|
+
}
|
|
134
|
+
|
|
99
135
|
export class WorkspaceIndex {
|
|
100
136
|
constructor(runCommand) {
|
|
101
137
|
this.runCommand = runCommand;
|
|
@@ -274,18 +310,18 @@ export class WorkspaceIndex {
|
|
|
274
310
|
}
|
|
275
311
|
|
|
276
312
|
/**
|
|
277
|
-
* Declaration spans [start, end] (1-based, inclusive)
|
|
278
|
-
*
|
|
313
|
+
* Declaration spans [start, end] (1-based, inclusive). Nested bodies stay inside the parent
|
|
314
|
+
* (brace-matched for JS-like, indent for Python). The file's leading header is not a span.
|
|
279
315
|
*/
|
|
280
316
|
static spansOf(entry) {
|
|
281
317
|
if (entry.spans) return entry.spans;
|
|
282
318
|
const { items, lineCount } = WorkspaceIndex.surfaceOf(entry);
|
|
283
|
-
const { lower } = WorkspaceIndex.linesOf(entry);
|
|
319
|
+
const { lower, raw } = WorkspaceIndex.linesOf(entry);
|
|
284
320
|
const spans = [];
|
|
285
321
|
|
|
286
322
|
for (let i = 0; i < items.length; i++) {
|
|
287
323
|
const start = items[i].line;
|
|
288
|
-
let end =
|
|
324
|
+
let end = declarationEnd(raw, lower, start, lineCount, entry.ext);
|
|
289
325
|
|
|
290
326
|
while (end > start && lower[end - 1] === "") end--;
|
|
291
327
|
spans.push({ start, end, name: items[i].name, kind: items[i].kind, isExport: items[i].isExport === true });
|
package/src/context/snap.js
CHANGED
|
@@ -3,6 +3,8 @@ import { isString } from "../shared/decode.js";
|
|
|
3
3
|
import { truncateChars } from "../output/format.js";
|
|
4
4
|
import * as fs from "node:fs/promises";
|
|
5
5
|
import { extractStructuralSurface } from "./surface.js";
|
|
6
|
+
import { WorkspaceIndex } from "./repo-index.js";
|
|
7
|
+
import { pickSpan, spanCandidate, spanWindow } from "./spans.js";
|
|
6
8
|
import { rankPaths } from "./fuzzy.js";
|
|
7
9
|
import { isTestPath, runCommand, relativeSlash } from "../fs/workspace.js";
|
|
8
10
|
|
|
@@ -86,7 +88,7 @@ function makeCandidate(filePath, dir, query, tokens, flags) {
|
|
|
86
88
|
return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
|
|
87
89
|
pathCoverage: tokens.filter(token => lower.includes(token)).length,
|
|
88
90
|
matched: new Set(), exactDefinition: false, definitionCoverage: 0, lineCoverage: 0,
|
|
89
|
-
line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1 };
|
|
91
|
+
line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1, exactLines: new Set() };
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
|
|
@@ -115,6 +117,8 @@ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
|
|
|
115
117
|
|
|
116
118
|
const score = (exact ? 10000 : 0) + definitionCoverage * 40 + matches.length;
|
|
117
119
|
|
|
120
|
+
if (exact) candidate.exactLines.add(lineNumber);
|
|
121
|
+
|
|
118
122
|
if (score > candidate.anchorScore) {
|
|
119
123
|
candidate.anchorScore = score;
|
|
120
124
|
candidate.line = lineNumber;
|
|
@@ -220,6 +224,31 @@ function location(candidate, root) {
|
|
|
220
224
|
context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
|
|
221
225
|
}
|
|
222
226
|
|
|
227
|
+
async function spanCandidates(filePath, lines, root, overlayText) {
|
|
228
|
+
const staged = overlayText(filePath);
|
|
229
|
+
const text = staged !== undefined ? staged : await fs.readFile(filePath, "utf8");
|
|
230
|
+
const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(filePath, text));
|
|
231
|
+
const rel = path.relative(root, filePath);
|
|
232
|
+
|
|
233
|
+
return lines.map(line => {
|
|
234
|
+
const span = pickSpan(spans, { line }) ?? { start: line, end: line };
|
|
235
|
+
|
|
236
|
+
return spanCandidate(rel, line, spanWindow(text, span.start, span.end));
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function rankedSpanCandidates(ranked, root, overlayText) {
|
|
241
|
+
const out = [];
|
|
242
|
+
|
|
243
|
+
for (const candidate of ranked) {
|
|
244
|
+
const lines = candidate.exactLines?.size ? [...candidate.exactLines].sort((a, b) => a - b) : [candidate.line];
|
|
245
|
+
out.push(...await spanCandidates(candidate.path, lines, root, overlayText));
|
|
246
|
+
if (out.length >= MAX_ALTERNATIVES) break;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return out.slice(0, MAX_ALTERNATIVES);
|
|
250
|
+
}
|
|
251
|
+
|
|
223
252
|
export async function executeSnap({ query, searchDir, root, includeHidden = false, run = runCommand, overlayText = () => undefined, pendingPaths = [], pathContext = {}, signal }) {
|
|
224
253
|
const flags = tokenizeQuery(query);
|
|
225
254
|
const tokens = [...new Set(flags.tokens.map(stem))];
|
|
@@ -306,7 +335,14 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
|
|
|
306
335
|
const coverage = Math.max(best.matched.size, best.pathCoverage) / tokens.length;
|
|
307
336
|
const uniqueExact = best.exactDefinition && !second?.exactDefinition || best.exactPath && !second?.exactPath && !second?.exactDefinition;
|
|
308
337
|
|
|
309
|
-
if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5))
|
|
338
|
+
if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5)) {
|
|
339
|
+
return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates(ranked, relativeRoot, overlayText) };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (best.exactLines.size > 1) {
|
|
343
|
+
return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates([best], relativeRoot, overlayText) };
|
|
344
|
+
}
|
|
345
|
+
|
|
310
346
|
const confidence = uniqueExact ? 0.95 : Math.min(0.85, 0.5 + coverage * 0.2 + margin * 0.15);
|
|
311
347
|
|
|
312
348
|
return { ...candidates[0], status: "found", confidence: Number(confidence.toFixed(2)) };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { truncateChars } from "../output/format.js";
|
|
2
|
+
|
|
3
|
+
export function pickSpan(spans, { line, name } = {}) {
|
|
4
|
+
const needle = typeof name === "string" && /^[A-Za-z_$][\w$]*$/.test(name.trim()) ? name.trim().toLowerCase() : "";
|
|
5
|
+
const named = needle ? spans.filter(item => item.name.toLowerCase() === needle) : [];
|
|
6
|
+
|
|
7
|
+
if (named.length === 1) return named[0];
|
|
8
|
+
if (!line) return;
|
|
9
|
+
|
|
10
|
+
return spans.find(item => item.start === line)
|
|
11
|
+
?? spans.filter(item => item.start <= line && line <= item.end).sort((a, b) => (a.end - a.start) - (b.end - b.start))[0];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function spanWindow(text, start, end) {
|
|
15
|
+
const raw = text.split("\n");
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
start,
|
|
19
|
+
end,
|
|
20
|
+
text: raw.slice(start - 1, end).join("\n"),
|
|
21
|
+
signature: truncateChars((raw[start - 1] ?? "").trim().replace(/\{.*$/, "").trim(), 240, "signature").text,
|
|
22
|
+
context: Array.from({ length: Math.max(0, end - start + 1) }, (_, i) => {
|
|
23
|
+
const n = start + i;
|
|
24
|
+
|
|
25
|
+
return { line: n, text: raw[n - 1] ?? "" };
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function spanCandidate(relPath, line, window) {
|
|
31
|
+
return {
|
|
32
|
+
path: relPath,
|
|
33
|
+
line,
|
|
34
|
+
lines: [window.start, window.end],
|
|
35
|
+
text: window.text,
|
|
36
|
+
signature: window.signature,
|
|
37
|
+
context: window.context.map(row => (row.line === line ? "►" : " ") + row.line + " " + row.text),
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/context/surface.js
CHANGED
|
@@ -81,10 +81,10 @@ const JS_DECL_PATTERNS = [
|
|
|
81
81
|
// Module-level tables/constants (column 0 only): without them the previous declaration's span swallows them.
|
|
82
82
|
const JS_TOP_LEVEL_BINDING = /^(const|let|var)\s+([a-zA-Z0-9_$]+)\s*=/;
|
|
83
83
|
|
|
84
|
-
// Indented methods (object-literal adapters, class members)
|
|
85
|
-
const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{
|
|
84
|
+
// Indented methods (object-literal adapters, class members), including one-liners.
|
|
85
|
+
const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{/;
|
|
86
86
|
|
|
87
|
-
const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{
|
|
87
|
+
const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{/;
|
|
88
88
|
|
|
89
89
|
const NOT_METHOD_NAMES = new Set(["if", "for", "while", "switch", "catch", "function", "return", "else", "do", "try", "with", "await", "typeof", "new", "constructor"]);
|
|
90
90
|
|
|
@@ -93,16 +93,18 @@ function methodItem(line, lineNumber, depth) {
|
|
|
93
93
|
|
|
94
94
|
if (!match || NOT_METHOD_NAMES.has(match[1])) return null;
|
|
95
95
|
|
|
96
|
-
return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{
|
|
96
|
+
return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{.*$/, "").trim(), line: lineNumber, depth };
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
function declarationItem(line, rawLine, lineNumber) {
|
|
100
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
101
|
+
const depth = Math.floor(indent / 2);
|
|
100
102
|
const patterns = /^\S/.test(rawLine) ? [...JS_DECL_PATTERNS, [JS_TOP_LEVEL_BINDING, false]] : JS_DECL_PATTERNS;
|
|
101
103
|
|
|
102
104
|
for (const [pattern, isExport] of patterns) {
|
|
103
105
|
const match = pattern.exec(line);
|
|
104
106
|
|
|
105
|
-
if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth
|
|
107
|
+
if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth };
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
return null;
|
|
@@ -116,7 +118,7 @@ function scanJavaScript(lines) {
|
|
|
116
118
|
|
|
117
119
|
if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
|
|
118
120
|
const indent = lines[i].length - lines[i].trimStart().length;
|
|
119
|
-
const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, 1) : null);
|
|
121
|
+
const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, Math.max(1, Math.floor(indent / 2))) : null);
|
|
120
122
|
|
|
121
123
|
if (item) items.push(item);
|
|
122
124
|
}
|
package/src/fs/json-read.js
CHANGED
|
@@ -43,7 +43,11 @@ export function jsonProjector(json) {
|
|
|
43
43
|
return function* (root) {
|
|
44
44
|
for (const steps of plans) yield steps.reduce((value, step) => {
|
|
45
45
|
if (step.key !== undefined) {
|
|
46
|
-
if (!isObject(value) || !Object.hasOwn(value, step.key))
|
|
46
|
+
if (!isObject(value) || !Object.hasOwn(value, step.key)) {
|
|
47
|
+
const keys = isObject(value) ? Object.keys(value) : [];
|
|
48
|
+
const preview = keys.length ? "; available keys: " + keys.slice(0, 24).map(key => JSON.stringify(key)).join(", ") + (keys.length > 24 ? ", …" : "") : "";
|
|
49
|
+
throw new Error("JSON field not found: " + JSON.stringify(step.key) + preview);
|
|
50
|
+
}
|
|
47
51
|
|
|
48
52
|
return value[step.key];
|
|
49
53
|
}
|
package/src/fs/vfs.js
CHANGED
|
@@ -11,6 +11,9 @@ let commitTail = Promise.resolve();
|
|
|
11
11
|
export class CausalVfs {
|
|
12
12
|
constructor(onNewFile, validateWrite) {
|
|
13
13
|
this.validateWrite = validateWrite;
|
|
14
|
+
// Last-seen original bytes for write CAS, not a read cache. read() always
|
|
15
|
+
// hits disk unless an overlay is staged. Serving cache on read would be a
|
|
16
|
+
// false-valid against editors/git between two reads.
|
|
14
17
|
this.cache = new Map();
|
|
15
18
|
this.overlays = [];
|
|
16
19
|
this.expected = new Map();
|
package/src/fs/workspace.js
CHANGED
|
@@ -64,14 +64,38 @@ export function isTestPath(filePath) {
|
|
|
64
64
|
return segments.some((s) => TEST_SEGMENTS.has(s)) || /\.(test|spec)\./.test(base);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
/** Reject scheme:// and scheme:/ paths. A single-letter drive (C:/) stays a filesystem path. */
|
|
68
|
+
export function assertFilesystemPath(inputPath, opName, allowSessionRead = false) {
|
|
68
69
|
if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
|
|
69
70
|
throw new Error(`${opName} requires path`);
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
const trimmed = inputPath.trim();
|
|
74
|
+
|
|
75
|
+
if (/^(?:agent|artifact):\/\//i.test(trimmed)) {
|
|
76
|
+
if (allowSessionRead) return trimmed;
|
|
77
|
+
throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const uri = /^([a-zA-Z][a-zA-Z0-9+.-]*):(.*)$/.exec(trimmed);
|
|
81
|
+
|
|
82
|
+
if (uri) {
|
|
83
|
+
const scheme = uri[1];
|
|
84
|
+
const rest = uri[2];
|
|
85
|
+
const windowsDrive = scheme.length === 1 && (rest.startsWith("/") || rest.startsWith("\\"));
|
|
86
|
+
|
|
87
|
+
if (!windowsDrive && (rest.startsWith("//") || rest.startsWith("/"))) {
|
|
88
|
+
throw new Error(`${opName} does not accept ${scheme}: URI paths; use a workspace filesystem path`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return trimmed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false, fresh = false) {
|
|
96
|
+
const trimmed = assertFilesystemPath(inputPath, opName);
|
|
73
97
|
const resolvedCwd = getResolvedCwd(cwd);
|
|
74
|
-
const target = path.resolve(resolvedCwd,
|
|
98
|
+
const target = path.resolve(resolvedCwd, trimmed);
|
|
75
99
|
assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
|
|
76
100
|
|
|
77
101
|
if (!allowRoot && target === resolvedCwd) {
|
package/src/output/bottleneck.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { isString, isObject } from "../shared/decode.js";
|
|
5
|
-
import { truncateChars, formatReturn } from "./format.js";
|
|
5
|
+
import { truncateChars, formatReturn, formatBoundedStringArray } from "./format.js";
|
|
6
6
|
|
|
7
7
|
function json(value) {
|
|
8
8
|
try { return JSON.stringify(value) ?? "null"; } catch { return JSON.stringify(String(value)); }
|
|
@@ -152,12 +152,19 @@ export function packageHostResult(raw, config) {
|
|
|
152
152
|
export function packageFinalReturn(value, logs, config) {
|
|
153
153
|
const images = [];
|
|
154
154
|
let imageBytes = 0;
|
|
155
|
+
let imageOverflow = false;
|
|
155
156
|
|
|
156
157
|
const collect = input => {
|
|
157
158
|
if (input?.type === "image" && isString(input.data) && isString(input.mimeType) && input.mimeType.startsWith("image/")) {
|
|
158
|
-
|
|
159
|
+
const size = Buffer.byteLength(input.data, "base64");
|
|
159
160
|
|
|
160
|
-
if (images.length >= 16 || imageBytes > 20 * 1024 * 1024)
|
|
161
|
+
if (images.length >= 16 || imageBytes + size > 20 * 1024 * 1024) {
|
|
162
|
+
imageOverflow = true;
|
|
163
|
+
|
|
164
|
+
return "[image omitted: exceeds 16 attachments or 20 MiB]";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
imageBytes += size;
|
|
161
168
|
images.push({ type: "image", data: input.data, mimeType: input.mimeType });
|
|
162
169
|
|
|
163
170
|
return `[image ${images.length}: ${input.mimeType}]`;
|
|
@@ -171,7 +178,13 @@ export function packageFinalReturn(value, logs, config) {
|
|
|
171
178
|
};
|
|
172
179
|
|
|
173
180
|
value = collect(value);
|
|
174
|
-
const
|
|
181
|
+
const maxReturn = config.maxReturnChars ?? 32000;
|
|
182
|
+
const formatted = formatReturn(value);
|
|
183
|
+
const serialized = formatted.length <= maxReturn
|
|
184
|
+
? { text: formatted, truncated: imageOverflow }
|
|
185
|
+
: Array.isArray(value) && value.length && value.every(isString)
|
|
186
|
+
? { text: formatBoundedStringArray(value, maxReturn), truncated: true }
|
|
187
|
+
: { ...truncateChars(formatted, maxReturn, "return"), truncated: true };
|
|
175
188
|
const maxLines = config.maxLogLines ?? 100;
|
|
176
189
|
let logTruncated = logs.length > maxLines;
|
|
177
190
|
|
package/src/output/format.js
CHANGED
|
@@ -71,6 +71,25 @@ function hasWellFormedStrings(values) {
|
|
|
71
71
|
return true;
|
|
72
72
|
}
|
|
73
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
|
+
|
|
74
93
|
/** Lossless framing for source arrays, not string escaping or source compression. */
|
|
75
94
|
export function formatReturn(value) {
|
|
76
95
|
if (isString(value)) return value;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parentPort } from "node:worker_threads";
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
-
import { isString, isObject, isFunction, toPlain } from "../shared/decode.js";
|
|
3
|
+
import { isString, isObject, isFunction, isNumber, toPlain, looksLikePath } from "../shared/decode.js";
|
|
4
4
|
import { truncateChars } from "../output/format.js";
|
|
5
5
|
import { sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
|
|
6
6
|
|
|
@@ -94,7 +94,11 @@ function leanEnvelope(res) {
|
|
|
94
94
|
return res;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
function
|
|
97
|
+
function quoteShellArg(value) {
|
|
98
|
+
return "'" + String(value).replaceAll("'", "'\\''") + "'";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildGuestApi(available, batchRead, runId, _nativeArgv) {
|
|
98
102
|
const rpc = (method, args) => callRpc(runId, method, args);
|
|
99
103
|
const availableSet = new Set(available);
|
|
100
104
|
const checkpointScope = new AsyncLocalStorage();
|
|
@@ -107,8 +111,6 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
107
111
|
};
|
|
108
112
|
|
|
109
113
|
const nova = {
|
|
110
|
-
search: (query, limit) => rpc("search", [query, limit]),
|
|
111
|
-
describe: (name) => rpc("describe", [name]),
|
|
112
114
|
call: async (name, args) => leanEnvelope(await rpc("call", [name, args])),
|
|
113
115
|
async callMany(calls) {
|
|
114
116
|
const wave = await rpc("callMany", [calls]);
|
|
@@ -191,6 +193,9 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
191
193
|
const read = async (p, a, b) => {
|
|
192
194
|
assertScope();
|
|
193
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
|
+
}
|
|
194
199
|
validateJsonRead(args);
|
|
195
200
|
const decode = value => args.resolve || args.json !== undefined ? JSON.parse(value) : value;
|
|
196
201
|
|
|
@@ -241,10 +246,31 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
241
246
|
|
|
242
247
|
const write = async (p, content) => unwrapValue(await invoke("write", isObject(p) ? p : { path: p, content }));
|
|
243
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
|
+
|
|
244
260
|
const edit = async (p, oldText, newText) => {
|
|
245
261
|
if (isFunction(p)) return nova.speculate(p);
|
|
246
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"})';
|
|
247
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
|
+
|
|
248
274
|
if (isObject(p) && (Array.isArray(p) || oldText !== undefined || newText !== undefined)) throw new Error(usage);
|
|
249
275
|
|
|
250
276
|
if (!isObject(p) && isObject(oldText) && !Array.isArray(oldText)) throw new Error(usage);
|
|
@@ -268,19 +294,21 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
268
294
|
return unwrapValue(await invoke(args.patch === undefined ? "edit" : "apply_patch", args));
|
|
269
295
|
};
|
|
270
296
|
|
|
271
|
-
const patch = async (p, diff) => unwrapValue(await nova.call("apply_patch", { path: p, patch: diff }));
|
|
272
|
-
|
|
273
297
|
const bash = async (command, opts) => {
|
|
274
298
|
const args = isObject(command) ? { ...command } : { command, ...opts };
|
|
275
299
|
|
|
276
300
|
if (args.args !== undefined) {
|
|
277
|
-
if (!isString(args.command) || !Array.isArray(args.args)
|
|
301
|
+
if (!isString(args.command) || !Array.isArray(args.args)) throw new Error("bash argv requires a command string and an array of string args");
|
|
278
302
|
|
|
279
|
-
|
|
280
|
-
|
|
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") {
|
|
281
308
|
delete args._directArgv;
|
|
282
309
|
args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
|
|
283
|
-
|
|
310
|
+
delete args.args;
|
|
311
|
+
} else args._directArgv = true;
|
|
284
312
|
}
|
|
285
313
|
|
|
286
314
|
command = args.command;
|
|
@@ -307,20 +335,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
307
335
|
return text;
|
|
308
336
|
};
|
|
309
337
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const exec = async (cmd, args, opts) => {
|
|
313
|
-
const command = String(cmd ?? "").trim();
|
|
314
|
-
|
|
315
|
-
if (!command) throw new Error("exec requires command");
|
|
316
|
-
|
|
317
|
-
// exec("git status") is a shell line; exec("git", ["status"]) is argv.
|
|
318
|
-
if (!Array.isArray(args) || args.length === 0) return bash(command, opts);
|
|
319
|
-
|
|
320
|
-
return bash([command, ...args].map(quoteShellArg).join(" "), opts);
|
|
321
|
-
};
|
|
322
|
-
|
|
323
|
-
return { nova, read, write, edit, patch, surface: nova.surface, snap: nova.snap, evidence: nova.evidence, bash, exec, speculate: nova.speculate };
|
|
338
|
+
return { read, write, edit, bash };
|
|
324
339
|
}
|
|
325
340
|
|
|
326
341
|
function makeConsole(runId, limits) {
|
package/src/runtime/reference.js
CHANGED
|
@@ -1,25 +1,18 @@
|
|
|
1
1
|
// Complete model-facing API reference; kept in every request, not moved into history.
|
|
2
|
-
export const REFERENCE = `
|
|
2
|
+
export const REFERENCE = `JavaScript async body or arrow with read, write, edit, bash. Strings stay raw. Exactly one of code or file; file rereads that program (same limits, cwd, fresh guest). Caps are UTF-16. Put Markdown/scripts/argv in data.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
read(
|
|
6
|
-
read(
|
|
7
|
-
read(
|
|
8
|
-
read("
|
|
9
|
-
read(
|
|
10
|
-
read({query,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
bash(command, {cwd?, timeoutMs?}) → bounded output; throws on non-zero exit
|
|
17
|
-
bash({command, args:[...]}) → literal argv without shell expansion of arguments
|
|
4
|
+
read(path|paths, offset?, limit?) → raw text or text[]; read(directory) → entries
|
|
5
|
+
read(imagePath) → image (PNG/JPEG/GIF/WebP/BMP)
|
|
6
|
+
read({path,json:".field"}) → parsed JSON; .items[0:3], quoted keys, selector arrays, or true; 16 MiB cap; no jq
|
|
7
|
+
read("agent://id?q=.answer") → JSON field from session artifacts
|
|
8
|
+
read("symbol or question") → same view as resolve:true
|
|
9
|
+
read({query,resolve:true}) → {status,path,line,lines,text,complete,nextOffset?}
|
|
10
|
+
read(path,{about}) → matching windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations
|
|
11
|
+
write(path, text) → replace; write({path,content,append:true}) → append without a prior read
|
|
12
|
+
edit(path,oldText,newText) | edit({path,edits}) | edit({path,patch}) → numbered post-edit lines, checks, references
|
|
13
|
+
edit(async () => {...}) → checkpoint: commit on success, rollback on throw; no shell, nesting, or outside commands
|
|
14
|
+
bash(command,{cwd?,timeoutMs?}) → bounded output; nonzero throws
|
|
15
|
+
bash({command,args}) → literal argv, no shell expansion of args
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Object arguments also work: read({path, offset?, limit?, about?, outline?, evidence?, resolve?, complete?}), edit({path, edits:[{oldText,newText}]}), edit({path,patch}), write({path,content}), bash({command,timeoutMs?}).
|
|
22
|
-
File changes stage until program success; later errors roll them back. Shell calls commit preceding writes and cannot be rolled back. Outcomes report committed/rolledBack file versions and external-call attempts.
|
|
23
|
-
Independent read starts batch automatically. Mutations preserve submission order. Plain reads remain self-contained; oversized reads provide continuation offsets. Return only what the model needs. console.log is captured.
|
|
24
|
-
|
|
25
|
-
For known continuations use programs:[{code|file,data?},...]. Entries run sequentially in fresh guests with separate commits. The batch stops on failure and returns all attempted results, including a typed stop report; earlier commits remain. Deadlines, host calls, logs and output are shared across the batch. Use separate calls when the next action needs model reasoning.`;
|
|
17
|
+
edit oldText is an exact substring of read(); a miss includes a numbered window. Found is a span, not the file; uncertain returns ambiguous, not_found, or incomplete. Check resolve:true status; edit(view,text) replaces that window; edit(view,old,new) is unique inside it. complete:true rejects partial files; use about or offset/limit for large audits. Array reads reject failures; Promise.allSettled for per-path outcomes. Edits stage until success; bash commits preceding writes.
|
|
18
|
+
programs:[{code|file,data?},...] sequential fresh guests, separate commits; stop on failure keeps earlier commits. Separate calls when the next step needs a model decision.`;
|
package/src/runtime/runtime.js
CHANGED
|
@@ -154,8 +154,6 @@ const RPC_METHODS = {
|
|
|
154
154
|
|
|
155
155
|
return Array.isArray(wave) ? { results: [...wave], mode: wave.mode, reason: wave.reason } : wave;
|
|
156
156
|
},
|
|
157
|
-
search: (nova, args) => nova.search(args[0], args[1]),
|
|
158
|
-
describe: (nova, args) => nova.describe(args[0]),
|
|
159
157
|
speculateBegin: (nova) => nova.speculateBegin(),
|
|
160
158
|
speculateCommit: (nova) => nova.speculateCommit(),
|
|
161
159
|
speculateRollback: (nova) => nova.speculateRollback(),
|
package/src/shared/decode.js
CHANGED
|
@@ -8,6 +8,17 @@ export const isFunction = (v) => toStr.call(v) === "[object Function]" || v inst
|
|
|
8
8
|
|
|
9
9
|
export const isNumber = (v) => toStr.call(v) === "[object Number]" && Number.isFinite(v);
|
|
10
10
|
|
|
11
|
+
/** Path-shaped if it has a slash, is relative, or has a file extension. Identifiers are not paths. */
|
|
12
|
+
export function looksLikePath(target) {
|
|
13
|
+
return (
|
|
14
|
+
isString(target) &&
|
|
15
|
+
(target.includes("/") ||
|
|
16
|
+
target.includes("\\") ||
|
|
17
|
+
target.startsWith(".") ||
|
|
18
|
+
(!/\s/.test(target) && /\.[A-Za-z0-9]+$/.test(target)))
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
const MAX_DEPTH = 64;
|
|
12
23
|
|
|
13
24
|
const MAX_TYPED_ARRAY = 4096;
|