pi-supernova 0.3.0 → 0.3.2
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 +98 -0
- package/docs/CHANGELOG.md +22 -0
- package/index.js +4 -3
- package/package.json +1 -1
- package/src/bridge/catalog.js +2 -1
- package/src/bridge/host-bridge.js +70 -8
- package/src/context/outline.js +21 -1
- package/src/context/repo-index.js +1 -1
- package/src/fs/diff.js +5 -4
- package/src/fs/vfs.js +2 -1
- package/src/fs/workspace.js +1 -0
- package/src/runtime/guest-worker.js +18 -8
- package/src/runtime/runtime.js +1 -1
package/README.md
CHANGED
|
@@ -109,6 +109,68 @@ options, not mandatory stages of source resolution. Ordinary calls also get:
|
|
|
109
109
|
Distant edit regions have separate windows and continuation pointers for omitted
|
|
110
110
|
lines. Structural warnings and source windows are not substitutes for tests.
|
|
111
111
|
|
|
112
|
+
### Safe read-modify-write
|
|
113
|
+
|
|
114
|
+
Plain reads are bounded views, not guaranteed full-file buffers. Use
|
|
115
|
+
`read({path:"file.txt",complete:true})` when code needs the complete file; it
|
|
116
|
+
throws rather than handing back partial text. Prefer `edit` for large-file
|
|
117
|
+
replacements, or reconstruct exact `resolve:true` windows before writing.
|
|
118
|
+
Writes reject Supernova truncation markers, including legacy host-result markers.
|
|
119
|
+
For intentionally writing literal marker documentation only, opt in with
|
|
120
|
+
`write({path,content,allowReadArtifacts:true})`. This is a data-loss guard, not
|
|
121
|
+
full dataflow tracking or a security sandbox.
|
|
122
|
+
|
|
123
|
+
Explicit read arrays reject missing/failed paths. For typed partial outcomes use
|
|
124
|
+
`Promise.allSettled(paths.map(path => read(path)))`. Successful arrays remain arrays.
|
|
125
|
+
For embedded source with backslash escapes, use `String.raw` template literals
|
|
126
|
+
(and escape delimiter backticks), or JSON-quoted strings. Validate generated code.
|
|
127
|
+
|
|
128
|
+
On hosts exposing `sessionManager.getArtifactsDir()`, bare `agent://<id>` and
|
|
129
|
+
`artifact://<number>` read files from the calling session's artifact directory.
|
|
130
|
+
They preserve ID casing and support offset/limit and structured continuation.
|
|
131
|
+
These resources are read-only; ambiguous artifact IDs and escaping symlinks fail.
|
|
132
|
+
This is not the full OMP URI language: cross-session search, nested path/query
|
|
133
|
+
selectors and other schemes are not implemented. Hosts without an artifact
|
|
134
|
+
directory report that limitation rather than treating the URI as a local path.
|
|
135
|
+
|
|
136
|
+
For unstructured logs/text, `read(path,{about:"STT database"})` returns bounded,
|
|
137
|
+
line-numbered matching windows, or explicitly reports no matching text. It is not
|
|
138
|
+
a complete-file read. Write temporary investigation files under the workspace
|
|
139
|
+
(e.g. `.work/probe.py`): ordinary `write`/`edit` paths cannot escape it, including
|
|
140
|
+
absolute `/tmp` paths. Shell execution is a separate trusted boundary, not a sandbox.
|
|
141
|
+
|
|
142
|
+
### Large inputs and report outputs
|
|
143
|
+
|
|
144
|
+
The default program limit is 48,000 UTF-16 code units (configurable via
|
|
145
|
+
`maxCodeChars` and exposed in the tool schema). Split larger documents into
|
|
146
|
+
separate invocations: first `write(path, firstChunk)`, then
|
|
147
|
+
`write({path,content:nextChunk,append:true})`. Append uses the complete internal
|
|
148
|
+
file buffer, never a bounded model-facing read; it retains conflict checks and
|
|
149
|
+
per-program rollback. Missing files are created. Multiple invocations are not
|
|
150
|
+
one atomic transaction: for an all-or-nothing publication, assemble a new staging
|
|
151
|
+
file and publish it only when complete. External write overrides reject append.
|
|
152
|
+
|
|
153
|
+
Supernova is a bounded foreground executor, not a durable background-job manager.
|
|
154
|
+
For long archive scans, use resumable chunks or a host background-job tool and write
|
|
155
|
+
progress records under `.work`. Set the inner `bash` timeout shorter than the
|
|
156
|
+
outer program timeout (for example 10 seconds inside a 20-second program) to retain
|
|
157
|
+
bounded shell diagnostics. A hard guest deadline cannot guarantee pending shell
|
|
158
|
+
output delivery; progress files survive shell execution but staged VFS writes may
|
|
159
|
+
roll back.
|
|
160
|
+
|
|
161
|
+
Large returned objects are bounded previews, not retained artifacts. Select fields
|
|
162
|
+
and array windows before returning, rather than parsing a truncated preview:
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
const report = JSON.parse(await read({path:"report.json",complete:true}));
|
|
166
|
+
return {verdict:report.verdict, values:report.values.slice(5000,5003)};
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
If the raw JSON exceeds the read budget, reconstruct exact source windows or run
|
|
170
|
+
a bounded parser through `bash`. There is no implicit continuation handle for
|
|
171
|
+
arbitrary guest objects. For embedded code, JSON-encode the source string once;
|
|
172
|
+
do not nest shell, JavaScript, and Python quoting unless it is necessary.
|
|
173
|
+
|
|
112
174
|
## Execution and automatic batching
|
|
113
175
|
|
|
114
176
|
Compatible independently started reads coalesce at the worker/host boundary.
|
|
@@ -161,6 +223,32 @@ Default limits are in `src/config/config.default.json`. Configuration loads from
|
|
|
161
223
|
Text limits are character budgets, not tokenizer counts. `/supernova` reports
|
|
162
224
|
programs and output characters without labelling characters as tokens.
|
|
163
225
|
|
|
226
|
+
## Optional workspace change notifications
|
|
227
|
+
|
|
228
|
+
Supernova works without another search package. On hosts exposing `pi.events`, it
|
|
229
|
+
provides an advisory `workspace:changed` event for independent cache/index consumers:
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
{ version: 1, cwd: "/absolute/workspace", paths: ["/absolute/workspace/file.js"] }
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`cwd` identifies the calling workspace. `paths` contains absolute file paths after
|
|
236
|
+
a successful disk flush; paths may contain filesystem aliases. The frozen event
|
|
237
|
+
and path array contain no source text. Checkpoint merges, restored rollbacks, and
|
|
238
|
+
read-only programs emit nothing. A shell boundary can flush paths before a later
|
|
239
|
+
program failure, so notifications are not conditional on overall tool success.
|
|
240
|
+
|
|
241
|
+
`paths: null` means the changed paths are unknown: shell or delegated mutation
|
|
242
|
+
attempts emit this even on failure, as does an incomplete commit recovery. Consumers
|
|
243
|
+
should invalidate conservatively, not interpret it as an empty change list or a
|
|
244
|
+
guarantee that shell effects stayed inside `cwd`.
|
|
245
|
+
|
|
246
|
+
Subscribe with `pi.events.on("workspace:changed", handler)`. Mark cached state dirty
|
|
247
|
+
synchronously, then refresh when queried; asynchronous listeners are not awaited.
|
|
248
|
+
Observer failures cannot roll back writes. This is an optional extension convention,
|
|
249
|
+
not a built-in host standard, durable event log, or cross-process filesystem watcher.
|
|
250
|
+
There is no dependency on or automatic routing to any consumer package.
|
|
251
|
+
|
|
164
252
|
## Security and host boundary
|
|
165
253
|
|
|
166
254
|
CodeMode executes trusted JavaScript in a terminable worker, **not a security
|
|
@@ -182,6 +270,16 @@ cover batching fidelity, image/context retention, checkpoints, mutation ordering
|
|
|
182
270
|
external symlinks, deadlines, worker isolation and execution-context environment.
|
|
183
271
|
The former deleted suite has not been silently reinstated.
|
|
184
272
|
|
|
273
|
+
Test user-visible contracts through registered programs: exact source, on-disk
|
|
274
|
+
results, failure/rollback, isolation, bounded output, and usable host rendering.
|
|
275
|
+
Inject filesystem faults only to exercise real failure paths; do not prescribe
|
|
276
|
+
private helper layouts, staging filenames, or syscall counts. New regressions must
|
|
277
|
+
fail before the fix; for existing behavior, verify that a named deliberate defect
|
|
278
|
+
makes the intended test fail before accepting it. Keep the original 12 acceptance
|
|
279
|
+
tests unchanged. Cost gates cover avoidable search processes, per-read budgets and
|
|
280
|
+
progress flooding; latency claims belong in the explicit measurement lane, not
|
|
281
|
+
arbitrary wall-clock assertions.
|
|
282
|
+
|
|
185
283
|
```bash
|
|
186
284
|
npm test --prefix packages/pi-supernova
|
|
187
285
|
npm run lint:supernova
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.2] - 2026-09-07
|
|
4
|
+
|
|
5
|
+
- Focus `read(path,{about})` on matching line windows in unstructured logs/text instead of returning a truncated unrelated prefix; report no matches explicitly.
|
|
6
|
+
- Create recursive source watchers as non-persistent so Linux/Node 24 hosts can exit naturally; add a child-process regression verified on Spark.
|
|
7
|
+
- Expose the configured program-length cap in the schema and add transactional `write({path,content,append:true})` for large-document chunks without bounded read-back. Document typed partial reads, embedded-source quoting and bounded report projections.
|
|
8
|
+
- Emit optional, producer-independent `workspace:changed` v1 notifications at disk commit boundaries, with conservative unknown-path invalidation for external mutations. Staging and restored rollbacks emit nothing; observer failures cannot break writes.
|
|
9
|
+
|
|
10
|
+
- Refuse writes containing truncated-read payload markers; add `read({path,complete:true})` for fail-closed full-file reads. Preserve ordinary bounded views and exact source continuation.
|
|
11
|
+
- Explicit read arrays now reject failed paths; use `Promise.allSettled` over independent reads for typed partial results.
|
|
12
|
+
- Resolve bare `agent://` and `artifact://` IDs from the calling host session’s artifact directory, with read-only scope, ambiguity checks and bounded pagination. This does not implement the full host URI language.
|
|
13
|
+
- Document embedded-source escaping and add regressions for the reported read/write corruption, batch errors and session-resource isolation, including actual OMP execution.
|
|
14
|
+
|
|
15
|
+
## [0.3.1] - 2026-09-06
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Preserve post-edit coordinates for patch deletions, including zero-length new ranges. After an earlier hunk inserts lines, the edit result now opens the actual deletion region rather than an unrelated earlier source window. File mutation semantics are unchanged.
|
|
20
|
+
|
|
21
|
+
### Tests
|
|
22
|
+
|
|
23
|
+
- Add a focused shifted-deletion regression and an opt-in stress runner that can target an isolated npm installation. Cover concurrent programs, 129-read batches, contended writes, cancellation after confirmed staging, immutable progress frames, source fidelity and cold lookup across 5,000 files.
|
|
24
|
+
|
|
3
25
|
## [0.3.0] - 2026-09-05
|
|
4
26
|
|
|
5
27
|
### Source operations
|
package/index.js
CHANGED
|
@@ -90,14 +90,15 @@ read("symbol or question") → locate and open source in one call, without an in
|
|
|
90
90
|
read({query, resolve:true}) → {status,path,line,lines,text,complete,nextOffset?} for a direct resolve→edit handoff
|
|
91
91
|
read(path, {about: question}) → relevant file bodies, or source selection inside a directory
|
|
92
92
|
read({query, evidence:true}) → ranked evidence; read({path, outline:true}) → structural declarations
|
|
93
|
-
write(path, text) → write a file
|
|
93
|
+
write(path, text) → write a file; write({path,content,append:true}) appends a chunk without a bounded read
|
|
94
94
|
edit(path, oldText, newText) → post-edit lines, checks, and references
|
|
95
95
|
edit(async () => {...}) → filesystem checkpoint: commit on success, rollback on throw; no shell commands, nesting, or concurrent outside commands
|
|
96
96
|
bash(command, {cwd?, timeoutMs?}) → bounded output; throws on non-zero exit
|
|
97
97
|
bash({command, args:[...]}) → literal argv without shell expansion of arguments
|
|
98
98
|
|
|
99
99
|
Only found selects and opens a file. Uncertain reads return ambiguous, not_found, or incomplete with no selected path. Use resolve:true for structured status checks; narrow the directory with path+about when uncertain.
|
|
100
|
-
|
|
100
|
+
For read-modify-write, use read({path,complete:true}); it rejects partial output. Prefer edit for large files. Array reads reject failures; use Promise.allSettled for per-path outcomes.
|
|
101
|
+
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?}).
|
|
101
102
|
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.`;
|
|
102
103
|
|
|
103
104
|
export default function piSupernova(pi) {
|
|
@@ -152,7 +153,7 @@ export function registerCodeMode(pi) {
|
|
|
152
153
|
"Use read, write, edit, and bash inside supernova. Start with read(question), or read(directory, {about: question}) for scoped source selection. A source question already opens the selected file; do not issue a redundant read. Use read({query,resolve:true}) and check status before editing its path. Explicit about/outline/evidence reads remain available when needed. Return a compact value.",
|
|
153
154
|
],
|
|
154
155
|
parameters: Type.Object({
|
|
155
|
-
code: Type.String({ description:
|
|
156
|
+
code: Type.String({ maxLength: config.maxCodeChars ?? 48000, description: `JavaScript program: async body or arrow function. Maximum ${config.maxCodeChars ?? 48000} UTF-16 code units; split large writes into write({path,content,append:true}) chunks.` }),
|
|
156
157
|
timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, description: "Hard timeout in ms." })),
|
|
157
158
|
}, { required: ["code"] }),
|
|
158
159
|
// One self-owned result frame is shared by Pi and OMP; renderCall stays empty
|
package/package.json
CHANGED
package/src/bridge/catalog.js
CHANGED
|
@@ -13,11 +13,12 @@ const NATIVE_TOOL_DEFINITIONS = [
|
|
|
13
13
|
about: { type: "string", description: "Question or symbol: expand file bodies, or locate and open source inside a directory" },
|
|
14
14
|
query: { type: "string", description: "Source question; optional path scopes the search directory" },
|
|
15
15
|
resolve: { type: "boolean", description: "Return structured source/status for a direct resolve-to-edit handoff" },
|
|
16
|
+
complete: { type: "boolean", description: "Fail unless the entire requested file fits without clipping" },
|
|
16
17
|
} },
|
|
17
18
|
},
|
|
18
19
|
{
|
|
19
20
|
name: "write", description: "Write UTF-8 content to a workspace file.",
|
|
20
|
-
parameters: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] },
|
|
21
|
+
parameters: { type: "object", properties: { path: { type: "string" }, content: { type: "string" }, allowReadArtifacts: { type: "boolean", description: "Explicit opt-in for intentionally writing literal truncation-marker text" } }, required: ["path", "content"] },
|
|
21
22
|
},
|
|
22
23
|
{
|
|
23
24
|
name: "edit", description: "Apply unique text replacements to a workspace file; returns the post-edit lines, a structural check, and references to changed declarations.",
|
|
@@ -134,11 +134,11 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
134
134
|
return openSource(result, params, signal);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
async function openSource(result, params, signal) {
|
|
137
|
+
async function openSource(result, params, signal, resolvedPath) {
|
|
138
138
|
const cwd = getCwd();
|
|
139
139
|
if (result.status !== "found") return textResult(JSON.stringify(result), { isSnap: true });
|
|
140
140
|
signal?.throwIfAborted();
|
|
141
|
-
const opened = await readFile(path.resolve(cwd, result.path), { ...params, about: undefined }, result.line);
|
|
141
|
+
const opened = await readFile(resolvedPath ?? path.resolve(cwd, result.path), { ...params, about: undefined }, result.line, result.path);
|
|
142
142
|
const block = opened.content[0];
|
|
143
143
|
if (block.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
|
|
144
144
|
const { firstLine, lastLine, sourceChars, nextOffset, complete } = opened.details;
|
|
@@ -185,12 +185,49 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
185
185
|
}
|
|
186
186
|
}));
|
|
187
187
|
signal?.throwIfAborted();
|
|
188
|
-
|
|
188
|
+
const response = textResult("", { count: results.length, batch: true, independent: params._independent === true, items: results.map(r => r.text), itemErrors: results.map(r => r.error?.message ?? null), errors: results.filter(r => r.error).map(r => r.error) });
|
|
189
|
+
response.isError = params._independent !== true && results.some(r => r.error);
|
|
190
|
+
return response;
|
|
189
191
|
}
|
|
190
192
|
return reads.schedule("read", () => readSingle(params, cwd, targetParam, signal), signal);
|
|
191
193
|
}
|
|
192
194
|
|
|
195
|
+
async function resolveSessionResource(uri, signal) {
|
|
196
|
+
const match = /^(agent|artifact):\/\/([^/?#]+)$/i.exec(uri);
|
|
197
|
+
if (!match) throw new Error("session resource reads support bare agent://<id> and artifact://<number>; use offset/limit for pagination");
|
|
198
|
+
const kind = match[1].toLowerCase();
|
|
199
|
+
const id = decodeURIComponent(match[2]);
|
|
200
|
+
if (!id || id === "." || id === ".." || (/[/\\]/u.test(id) || Array.from(id).some(char => char.charCodeAt(0) < 32)) || (kind === "artifact" && !/^\d+$/.test(id))) throw new Error("invalid session resource ID");
|
|
201
|
+
const dir = hooks.artifactsDir?.();
|
|
202
|
+
if (!isString(dir) || !dir) throw new Error("this host session does not expose an artifacts directory for " + uri);
|
|
203
|
+
signal?.throwIfAborted();
|
|
204
|
+
const root = await fs.realpath(dir);
|
|
205
|
+
let file = id + ".md";
|
|
206
|
+
if (kind === "artifact") {
|
|
207
|
+
const matches = [];
|
|
208
|
+
let count = 0;
|
|
209
|
+
for await (const entry of await fs.opendir(root)) {
|
|
210
|
+
signal?.throwIfAborted();
|
|
211
|
+
if (++count > 4096) throw new Error("session artifact lookup exceeded its directory budget");
|
|
212
|
+
if (entry.name.startsWith(id + ".") && !entry.isDirectory()) matches.push(entry.name);
|
|
213
|
+
}
|
|
214
|
+
if (matches.length !== 1) throw new Error(matches.length ? "ambiguous session artifact: " + uri : "session artifact not found: " + uri);
|
|
215
|
+
file = matches[0];
|
|
216
|
+
}
|
|
217
|
+
const target = await fs.realpath(path.join(root, file));
|
|
218
|
+
if (!target.startsWith(root + path.sep)) throw new Error("session resource escapes its artifacts directory");
|
|
219
|
+
if (!(await fs.stat(target)).isFile()) throw new Error("session resource is not a file: " + uri);
|
|
220
|
+
signal?.throwIfAborted();
|
|
221
|
+
return target;
|
|
222
|
+
}
|
|
223
|
+
|
|
193
224
|
async function readSingle(params, cwd, targetParam, signal) {
|
|
225
|
+
if (isString(targetParam) && /^(?:agent|artifact):\/\//i.test(targetParam)) {
|
|
226
|
+
const target = await resolveSessionResource(targetParam, signal);
|
|
227
|
+
return params.resolve
|
|
228
|
+
? openSource({status:"found",path:targetParam,line:params.offset ?? 1}, params, signal, target)
|
|
229
|
+
: readFile(target, params, undefined, targetParam);
|
|
230
|
+
}
|
|
194
231
|
if (isString(params?.query)) {
|
|
195
232
|
const scope = targetParam && targetParam !== params.query ? resolveReadPath(cwd, targetParam) : cwd;
|
|
196
233
|
return sourceRead(params.query, scope, signal, params);
|
|
@@ -208,9 +245,9 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
208
245
|
}
|
|
209
246
|
|
|
210
247
|
/** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
|
|
211
|
-
async function readFile(targetPath, params, sourceLine) {
|
|
248
|
+
async function readFile(targetPath, params, sourceLine, displayPath) {
|
|
212
249
|
const cwd = getCwd();
|
|
213
|
-
const rel = relativeSlash(cwd, targetPath);
|
|
250
|
+
const rel = displayPath ?? relativeSlash(cwd, targetPath);
|
|
214
251
|
const mime = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp" }[path.extname(targetPath).toLowerCase()];
|
|
215
252
|
if (mime) {
|
|
216
253
|
if ((await fs.stat(targetPath)).size > 20 * 1024 * 1024) throw new Error("image exceeds 20 MiB; resize it before reading");
|
|
@@ -233,6 +270,9 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
233
270
|
const offset = params?.offset ?? (sourceLine && text.length > budget ? Math.max(1, sourceLine - 2) : 1);
|
|
234
271
|
const firstLine = isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1;
|
|
235
272
|
const sliced = sliceLines(text, offset, params?.limit);
|
|
273
|
+
if (params.complete === true && (sliced !== text || sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget))) {
|
|
274
|
+
throw new Error(`incomplete read of ${rel}: complete:true requires the entire file within the read budget; use edit() for replacements or reconstruct resolve:true source windows`);
|
|
275
|
+
}
|
|
236
276
|
if (sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget)) {
|
|
237
277
|
let cap = budget - 160;
|
|
238
278
|
if (params.resolve) {
|
|
@@ -397,11 +437,16 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
397
437
|
const target = await resolveWorkspacePath(cwd, params?.path, "write", false);
|
|
398
438
|
if (signal?.aborted) throw new Error("aborted");
|
|
399
439
|
if (!isString(params?.content)) throw new Error("write requires string content");
|
|
400
|
-
|
|
440
|
+
if (params.append !== undefined && params.append !== true && params.append !== false) throw new Error("write append must be a boolean");
|
|
441
|
+
let content = params.content;
|
|
442
|
+
if (params.allowReadArtifacts !== true && /\[read truncated;|…\[(?:host-result|output|value) truncated \d+ chars\]…/u.test(content)) {
|
|
443
|
+
throw new Error("refusing to write truncated read output; use edit() or reconstruct complete source windows. Set allowReadArtifacts:true only to intentionally write literal truncation-marker text");
|
|
444
|
+
}
|
|
401
445
|
let prevText = "";
|
|
402
446
|
try {
|
|
403
447
|
prevText = await vfs.read(target, { preserveRead: true });
|
|
404
448
|
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
449
|
+
if (params.append === true) content = prevText + content;
|
|
405
450
|
const { speculative } = await vfs.write(target, content);
|
|
406
451
|
index.touch(relativeSlash(cwd, target));
|
|
407
452
|
const diff = buildWriteDiff(target, prevText, content);
|
|
@@ -527,6 +572,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
527
572
|
vfs.invalidateCache();
|
|
528
573
|
index.invalidate();
|
|
529
574
|
clearPathCache();
|
|
575
|
+
hooks.workspaceChanged();
|
|
530
576
|
}
|
|
531
577
|
const { stdout, stderr } = res;
|
|
532
578
|
let text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
|
|
@@ -596,7 +642,10 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
|
596
642
|
export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger }) {
|
|
597
643
|
const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
|
|
598
644
|
const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 40 });
|
|
599
|
-
const vfs = new CausalVfs(
|
|
645
|
+
const vfs = new CausalVfs(paths => {
|
|
646
|
+
index.invalidate();
|
|
647
|
+
notifyWorkspaceChanged(paths);
|
|
648
|
+
}, target => resolveWorkspacePath(getCwd(), target, "commit", false, true));
|
|
600
649
|
const executors = registry?.executors ?? new Map();
|
|
601
650
|
const definitions = registry?.definitions ?? new Map();
|
|
602
651
|
const sharedRegistry = registry ?? { executors, definitions, index, callSeq: 0 };
|
|
@@ -611,6 +660,18 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
611
660
|
let trace = [];
|
|
612
661
|
let callListener = null;
|
|
613
662
|
const scheduler = createNativeScheduler();
|
|
663
|
+
// Advisory host event, not a tool or a transaction participant. Consumers
|
|
664
|
+
// invalidate synchronously; failures must never affect committed bytes.
|
|
665
|
+
function notifyWorkspaceChanged(paths = null) {
|
|
666
|
+
if (!isFunction(pi?.events?.emit)) return;
|
|
667
|
+
const event = Object.freeze({
|
|
668
|
+
version: 1, cwd: path.resolve(getCwd()),
|
|
669
|
+
paths: paths === null ? null : Object.freeze([...new Set(paths)]),
|
|
670
|
+
});
|
|
671
|
+
try { pi.events.emit("workspace:changed", event)?.catch?.(() => {}); } catch {}
|
|
672
|
+
}
|
|
673
|
+
hooks.workspaceChanged = notifyWorkspaceChanged;
|
|
674
|
+
hooks.artifactsDir = () => activeCtx?.sessionManager?.getArtifactsDir?.();
|
|
614
675
|
hooks.commandEnv = () => {
|
|
615
676
|
const env = { ...process.env };
|
|
616
677
|
const current = {
|
|
@@ -800,6 +861,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
800
861
|
const delegated = hostTool(name);
|
|
801
862
|
const exec = delegated ? delegated.execute.bind(delegated) : hostSession ? undefined : executors.get(name);
|
|
802
863
|
if (exec) {
|
|
864
|
+
if (name === "write" && args?.append === true) throw new Error("append requires the Supernova-owned write adapter, not an external override");
|
|
803
865
|
const fallbackDiff = await writeFallbackDiff(name, args);
|
|
804
866
|
const mutating = isMutatingTool(name, config, args, definitions.get(name));
|
|
805
867
|
if (mutating) await vfs.prepareExternalMutation(name);
|
|
@@ -812,7 +874,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
812
874
|
completeRecord(record, res, fallbackDiff);
|
|
813
875
|
return res;
|
|
814
876
|
} finally {
|
|
815
|
-
if (mutating) { vfs.invalidateCache(); index.invalidate(); clearPathCache(); }
|
|
877
|
+
if (mutating) { vfs.invalidateCache(); index.invalidate(); clearPathCache(); notifyWorkspaceChanged(); }
|
|
816
878
|
}
|
|
817
879
|
}
|
|
818
880
|
|
package/src/context/outline.js
CHANGED
|
@@ -54,6 +54,26 @@ function expandedBlock(span, raw, opts) {
|
|
|
54
54
|
return out.join("\n");
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
function focusedText(raw, lower, stems, relPath, opts) {
|
|
58
|
+
const hits = lower.map((line, i) => ({i, score: stems.filter(s => line.includes(s)).length}))
|
|
59
|
+
.filter(hit => hit.score > 0).sort((a, b) => b.score - a.score || a.i - b.i);
|
|
60
|
+
const parts = [];
|
|
61
|
+
const covered = new Set();
|
|
62
|
+
let budget = opts.maxChars;
|
|
63
|
+
for (const {i} of hits) {
|
|
64
|
+
if (parts.length >= opts.maxExpanded) break;
|
|
65
|
+
if (covered.has(i)) continue;
|
|
66
|
+
const start = Math.max(0, i - 3), end = Math.min(raw.length, i + 4);
|
|
67
|
+
const block = raw.slice(start, end).map((line, j) => String(start + j + 1).padStart(5) + " " + line).join("\n");
|
|
68
|
+
if (block.length > budget) continue;
|
|
69
|
+
parts.push(block); budget -= block.length;
|
|
70
|
+
for (let j = start; j < end; j++) covered.add(j);
|
|
71
|
+
}
|
|
72
|
+
const status = parts.length ? "focused text windows (not a complete file)"
|
|
73
|
+
: hits.length ? "matching text exceeds view budget; first match at line " + (hits[0].i + 1) : "no matching text";
|
|
74
|
+
return {text: "// " + relPath + " · " + status + "; read(path, line, count) for raw source\n" + parts.join("\n---\n"), expanded: parts.length, declarations: 0};
|
|
75
|
+
}
|
|
76
|
+
|
|
57
77
|
/**
|
|
58
78
|
* @param entry index entry (text + cached lines/surface)
|
|
59
79
|
* @param about question or symbol; empty ⇒ pure skeleton (every body folded)
|
|
@@ -63,8 +83,8 @@ export function outlineFile(entry, relPath, about, options = {}) {
|
|
|
63
83
|
const { raw, lower } = WorkspaceIndex.linesOf(entry);
|
|
64
84
|
const lineCount = raw.length;
|
|
65
85
|
const spans = WorkspaceIndex.spansOf(entry).map((s) => ({ ...s, signature: raw[s.start - 1].trim() }));
|
|
66
|
-
if (spans.length === 0) return null; // no structure: caller falls back to plain text
|
|
67
86
|
const stems = [...new Set(tokenizeQuery(about || "").tokens.map(stem))];
|
|
87
|
+
if (spans.length === 0) return about ? focusedText(raw, lower, stems, relPath, opts) : null;
|
|
68
88
|
const expanded = chooseExpanded(spans, lower, stems, raw, opts);
|
|
69
89
|
|
|
70
90
|
const parts = [];
|
|
@@ -102,7 +102,7 @@ export class WorkspaceIndex {
|
|
|
102
102
|
let ok = false;
|
|
103
103
|
try {
|
|
104
104
|
let timer = null;
|
|
105
|
-
const watcher = fs.watch(root, { recursive: true }, () => {
|
|
105
|
+
const watcher = fs.watch(root, { recursive: true, persistent: false }, () => {
|
|
106
106
|
if (timer) return;
|
|
107
107
|
timer = setTimeout(() => {
|
|
108
108
|
timer = null;
|
package/src/fs/diff.js
CHANGED
|
@@ -74,10 +74,11 @@ export function buildPatchDiff(filePath, patchText) {
|
|
|
74
74
|
let inHunk = false;
|
|
75
75
|
|
|
76
76
|
for (const patchLine of patchLines) {
|
|
77
|
-
const headerMatch = (/^@@\s+-(\d+)(
|
|
77
|
+
const headerMatch = (/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?/).exec(patchLine);
|
|
78
78
|
if (headerMatch) {
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
// A zero-length range names the line before the insertion/deletion point.
|
|
80
|
+
oldLineNum = Number(headerMatch[1]) + Number(headerMatch[2] === "0");
|
|
81
|
+
newLineNum = Number(headerMatch[3]) + Number(headerMatch[4] === "0");
|
|
81
82
|
inHunk = true;
|
|
82
83
|
continue;
|
|
83
84
|
}
|
|
@@ -86,7 +87,7 @@ export function buildPatchDiff(filePath, patchText) {
|
|
|
86
87
|
if (!kind) continue;
|
|
87
88
|
if (kind === "remove") {
|
|
88
89
|
removed += 1;
|
|
89
|
-
lines.push({ type: "remove", lineNum: oldLineNum, text: patchLine.slice(1) });
|
|
90
|
+
lines.push({ type: "remove", lineNum: oldLineNum, newLineNum, text: patchLine.slice(1) });
|
|
90
91
|
oldLineNum += 1;
|
|
91
92
|
} else if (kind === "add") {
|
|
92
93
|
added += 1;
|
package/src/fs/vfs.js
CHANGED
|
@@ -157,7 +157,7 @@ export class CausalVfs {
|
|
|
157
157
|
this.setCache(entry.logicalPath, entry.content);
|
|
158
158
|
this.expected.delete(entry.logicalPath);
|
|
159
159
|
}
|
|
160
|
-
if (staged.length) this.onNewFile?.();
|
|
160
|
+
if (staged.length) this.onNewFile?.(staged.map(entry => entry.target));
|
|
161
161
|
} catch (error) {
|
|
162
162
|
failed = true;
|
|
163
163
|
const recoveryErrors = [];
|
|
@@ -173,6 +173,7 @@ export class CausalVfs {
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
this.invalidateCache();
|
|
176
|
+
if (recoveryErrors.length) this.onNewFile?.(null);
|
|
176
177
|
if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
|
|
177
178
|
throw error;
|
|
178
179
|
} finally {
|
package/src/fs/workspace.js
CHANGED
|
@@ -60,6 +60,7 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
|
|
|
60
60
|
if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
|
|
61
61
|
throw new Error(`${opName} requires path`);
|
|
62
62
|
}
|
|
63
|
+
if (/^(?:agent|artifact):\/\//i.test(inputPath.trim())) throw new Error(`${opName} requires a filesystem path; session resource URIs are read-only`);
|
|
63
64
|
const resolvedCwd = getResolvedCwd(cwd);
|
|
64
65
|
const target = path.resolve(resolvedCwd, inputPath.trim());
|
|
65
66
|
assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
|
|
@@ -49,6 +49,12 @@ function unwrapValue(res) {
|
|
|
49
49
|
return res;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function unwrapRead(res, args) {
|
|
53
|
+
const value = unwrapValue(res);
|
|
54
|
+
if (args.complete === true && res?.truncated) throw new Error("incomplete read: complete:true refuses truncated host output");
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
function unwrapJsonValue(res) {
|
|
53
59
|
const value = unwrapValue(res);
|
|
54
60
|
try {
|
|
@@ -124,8 +130,8 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
124
130
|
const wave = pending.slice(start, start + 64);
|
|
125
131
|
const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
|
|
126
132
|
const run = wave.length === 1
|
|
127
|
-
? nova.call("read", wave[0].args).then(res => ({ values: [
|
|
128
|
-
: nova.call("read", args).then(res => {
|
|
133
|
+
? 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); return { values: res.items, errors: res.itemErrors ?? [] }; });
|
|
129
135
|
void run.then(({ values, errors }) => {
|
|
130
136
|
if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
|
|
131
137
|
for (let i = 0; i < wave.length; i++) {
|
|
@@ -143,6 +149,7 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
143
149
|
const read = async (p, a, b) => {
|
|
144
150
|
assertScope();
|
|
145
151
|
const args = readArgs(p, a, b);
|
|
152
|
+
if (args.complete === true && (args.outline || args.evidence || args.about)) throw new Error("complete:true requires a raw file read, not an outline or evidence view");
|
|
146
153
|
const evidencePath = isObject(p) && !Array.isArray(p) ? p.path : args.about ? p : undefined;
|
|
147
154
|
p = args.path;
|
|
148
155
|
if (args.evidence) return unwrapJsonValue(await invoke("evidence", { ...args, path: evidencePath, query: args.about ?? args.query ?? p }));
|
|
@@ -150,18 +157,21 @@ function buildGuestApi(available, batchRead, runId, nativeArgv) {
|
|
|
150
157
|
if (Array.isArray(p)) {
|
|
151
158
|
if (p.length > 64) throw new Error("read accepts at most 64 paths per batch");
|
|
152
159
|
if (p.some(item => !isString(item) || !item.trim())) throw new Error("read paths must be non-empty strings");
|
|
153
|
-
const readEach = () => Promise.all(p.map(
|
|
154
|
-
try { return await read({ ...args, path: item }); }
|
|
155
|
-
catch (error) { return `[read error: ${item}] ${error.message}`; }
|
|
156
|
-
}));
|
|
160
|
+
const readEach = () => Promise.all(p.map(item => read({ ...args, path: item })));
|
|
157
161
|
if (!batchRead || args.resolve) return readEach();
|
|
158
162
|
const res = await invoke("read", args);
|
|
159
|
-
|
|
163
|
+
const failed = res?.itemErrors?.findIndex(error => error != null) ?? -1;
|
|
164
|
+
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
|
+
unwrapRead(res, args);
|
|
160
166
|
if (Array.isArray(res?.items)) return res.items;
|
|
161
167
|
// Captured host executor without batch support: fan out.
|
|
162
168
|
return readEach();
|
|
163
169
|
}
|
|
164
|
-
if (!batchRead)
|
|
170
|
+
if (!batchRead) {
|
|
171
|
+
const res = await invoke("read", args);
|
|
172
|
+
unwrapRead(res, args);
|
|
173
|
+
return args.resolve ? unwrapJsonValue(res) : unwrapRead(res, args);
|
|
174
|
+
}
|
|
165
175
|
const key = JSON.stringify({ ...args, path: undefined });
|
|
166
176
|
if (queuedReads.length && queuedReads[0].key !== key) flushReads();
|
|
167
177
|
return new Promise((resolve, reject) => {
|
package/src/runtime/runtime.js
CHANGED
|
@@ -139,7 +139,7 @@ export async function runGuestProgram({ code, nova = {}, config = {}, signal, on
|
|
|
139
139
|
const fail = (error) => ({ ok: false, error: truncateChars(String(error), config.maxReturnChars ?? 32000, "error").text, logs, logTruncated, wallMs: wall() });
|
|
140
140
|
let logTruncated = false;
|
|
141
141
|
if (!isString(code) || !code.trim()) return fail("code must be a non-empty string");
|
|
142
|
-
if (code.length > (config.maxCodeChars ?? 48000)) return fail("code exceeds " + (config.maxCodeChars ?? 48000) + " characters");
|
|
142
|
+
if (code.length > (config.maxCodeChars ?? 48000)) return fail("code exceeds " + (config.maxCodeChars ?? 48000) + " characters; split large writes into write({path,content,append:true}) chunks");
|
|
143
143
|
if (signal?.aborted) return fail(ABORT_MESSAGE);
|
|
144
144
|
const runId = ++runSeq;
|
|
145
145
|
const timeoutMs = config.timeoutMs ?? 60000;
|