@arnilo/prism-coding-agent 0.0.5 → 0.0.7
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/CHANGELOG.md +17 -1
- package/README.md +10 -6
- package/dist/bounded-file.d.ts +2 -0
- package/dist/bounded-file.js +25 -0
- package/dist/edit-diff.d.ts +0 -10
- package/dist/edit-diff.js +1 -33
- package/dist/edit.d.ts +23 -4
- package/dist/edit.js +55 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/limits.d.ts +22 -0
- package/dist/limits.js +28 -0
- package/dist/output-accumulator.d.ts +17 -10
- package/dist/output-accumulator.js +127 -86
- package/dist/read.d.ts +39 -8
- package/dist/read.js +174 -60
- package/dist/shell.d.ts +4 -0
- package/dist/shell.js +98 -74
- package/dist/truncate.d.ts +7 -4
- package/dist/truncate.js +6 -6
- package/dist/write.d.ts +9 -2
- package/dist/write.js +10 -5
- package/package.json +2 -2
|
@@ -1,38 +1,25 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Streaming output accumulator with bounded memory.
|
|
3
|
-
*
|
|
4
|
-
* Behavioral port of pi's core/tools/output-accumulator for @arnilo/prism-coding-agent.
|
|
5
|
-
* Appends raw chunks through a streaming UTF-8 decoder, keeps only a decoded tail
|
|
6
|
-
* for display snapshots, and spills the full output to a temp file once limits are
|
|
7
|
-
* exceeded. stdlib only (node:crypto/fs/os).
|
|
8
|
-
*
|
|
9
|
-
* Single deviation from pi: the default temp-file prefix is `prism-output`
|
|
10
|
-
* (pi uses `pi-output`) — cosmetic, user-overridable via `tempFilePrefix`.
|
|
11
|
-
*/
|
|
1
|
+
/** Streaming UTF-8 output retention with bounded memory and spill storage. */
|
|
12
2
|
import { randomBytes } from "node:crypto";
|
|
13
|
-
import {
|
|
3
|
+
import { closeSync, openSync, writeSync } from "node:fs";
|
|
4
|
+
import { rm } from "node:fs/promises";
|
|
14
5
|
import { tmpdir } from "node:os";
|
|
15
6
|
import { join } from "node:path";
|
|
16
|
-
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES,
|
|
7
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, DEFAULT_MAX_TOTAL_OUTPUT_BYTES, HARD_MAX_BYTES, HARD_MAX_LINES, HARD_MAX_TOTAL_OUTPUT_BYTES, validateCodingLimit, } from "./limits.js";
|
|
8
|
+
import { truncateTail } from "./truncate.js";
|
|
17
9
|
function defaultTempFilePath(prefix) {
|
|
18
|
-
|
|
19
|
-
return join(tmpdir(), `${prefix}-${id}.log`);
|
|
10
|
+
return join(tmpdir(), `${prefix}-${randomBytes(16).toString("hex")}.log`);
|
|
20
11
|
}
|
|
21
12
|
function byteLength(text) {
|
|
22
13
|
return Buffer.byteLength(text, "utf-8");
|
|
23
14
|
}
|
|
24
|
-
/**
|
|
25
|
-
* Incrementally tracks streaming output with bounded memory.
|
|
26
|
-
*
|
|
27
|
-
* Appends decode chunks with a streaming UTF-8 decoder, keeps only a decoded
|
|
28
|
-
* tail for display snapshots, and opens a temp file when the full output needs
|
|
29
|
-
* to be preserved.
|
|
30
|
-
*/
|
|
31
15
|
export class OutputAccumulator {
|
|
32
16
|
maxLines;
|
|
33
17
|
maxBytes;
|
|
18
|
+
maxTotalOutputBytes;
|
|
34
19
|
maxRollingBytes;
|
|
35
20
|
tempFilePrefix;
|
|
21
|
+
onLimit;
|
|
22
|
+
onStorageError;
|
|
36
23
|
decoder = new TextDecoder();
|
|
37
24
|
rawChunks = [];
|
|
38
25
|
tailText = "";
|
|
@@ -45,99 +32,133 @@ export class OutputAccumulator {
|
|
|
45
32
|
currentLineBytes = 0;
|
|
46
33
|
hasOpenLine = false;
|
|
47
34
|
finished = false;
|
|
35
|
+
exceeded = false;
|
|
48
36
|
tempFilePath;
|
|
49
|
-
|
|
37
|
+
tempFileFd;
|
|
38
|
+
tempFileError;
|
|
50
39
|
constructor(options = {}) {
|
|
51
|
-
this.maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
|
52
|
-
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
53
|
-
this.
|
|
40
|
+
this.maxLines = validateCodingLimit("maxLines", options.maxLines ?? DEFAULT_MAX_LINES, HARD_MAX_LINES);
|
|
41
|
+
this.maxBytes = validateCodingLimit("maxBytes", options.maxBytes ?? DEFAULT_MAX_BYTES, HARD_MAX_BYTES);
|
|
42
|
+
this.maxTotalOutputBytes = validateCodingLimit("maxTotalOutputBytes", options.maxTotalOutputBytes ?? DEFAULT_MAX_TOTAL_OUTPUT_BYTES, HARD_MAX_TOTAL_OUTPUT_BYTES);
|
|
43
|
+
if (this.maxTotalOutputBytes < this.maxBytes) {
|
|
44
|
+
throw new Error("maxTotalOutputBytes must be at least maxBytes");
|
|
45
|
+
}
|
|
46
|
+
this.maxRollingBytes = this.maxBytes * 2;
|
|
54
47
|
this.tempFilePrefix = options.tempFilePrefix ?? "prism-output";
|
|
48
|
+
if (!/^[A-Za-z0-9._-]+$/.test(this.tempFilePrefix)) {
|
|
49
|
+
throw new Error("tempFilePrefix may contain only letters, numbers, dot, underscore, and hyphen");
|
|
50
|
+
}
|
|
51
|
+
this.onLimit = options.onLimit;
|
|
52
|
+
this.onStorageError = options.onStorageError;
|
|
55
53
|
}
|
|
56
54
|
append(data) {
|
|
57
|
-
if (this.finished)
|
|
55
|
+
if (this.finished)
|
|
58
56
|
throw new Error("Cannot append to a finished output accumulator");
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
this.
|
|
64
|
-
this.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
57
|
+
const remaining = this.maxTotalOutputBytes - this.totalRawBytes;
|
|
58
|
+
const accepted = remaining > 0 ? data.subarray(0, remaining) : data.subarray(0, 0);
|
|
59
|
+
if (accepted.length > 0) {
|
|
60
|
+
this.totalRawBytes += accepted.length;
|
|
61
|
+
this.appendDecodedText(this.decoder.decode(accepted, { stream: true }));
|
|
62
|
+
if (this.tempFileFd !== undefined || this.shouldUseTempFile()) {
|
|
63
|
+
this.ensureTempFile();
|
|
64
|
+
this.writeTemp(accepted);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
this.rawChunks.push(Buffer.from(accepted));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (accepted.length !== data.length && !this.exceeded) {
|
|
71
|
+
this.exceeded = true;
|
|
72
|
+
this.onLimit?.();
|
|
73
|
+
}
|
|
74
|
+
return !this.exceeded;
|
|
69
75
|
}
|
|
70
76
|
finish() {
|
|
71
|
-
if (this.finished)
|
|
77
|
+
if (this.finished)
|
|
72
78
|
return;
|
|
73
|
-
}
|
|
74
79
|
this.finished = true;
|
|
75
80
|
this.appendDecodedText(this.decoder.decode());
|
|
76
|
-
if (this.shouldUseTempFile())
|
|
81
|
+
if (this.shouldUseTempFile())
|
|
77
82
|
this.ensureTempFile();
|
|
78
|
-
}
|
|
79
83
|
}
|
|
80
84
|
snapshot(options = {}) {
|
|
81
85
|
const tailTruncation = truncateTail(this.getSnapshotText(), {
|
|
82
86
|
maxLines: this.maxLines,
|
|
83
87
|
maxBytes: this.maxBytes,
|
|
84
88
|
});
|
|
85
|
-
const truncated = this.totalLines > this.maxLines || this.totalDecodedBytes > this.maxBytes;
|
|
86
|
-
const truncatedBy = truncated
|
|
87
|
-
? (tailTruncation.truncatedBy ?? (this.totalDecodedBytes > this.maxBytes ? "bytes" : "lines"))
|
|
88
|
-
: null;
|
|
89
|
+
const truncated = this.totalLines > this.maxLines || this.totalDecodedBytes > this.maxBytes || this.exceeded;
|
|
89
90
|
const truncation = {
|
|
90
91
|
...tailTruncation,
|
|
91
92
|
truncated,
|
|
92
|
-
truncatedBy
|
|
93
|
+
truncatedBy: truncated
|
|
94
|
+
? (tailTruncation.truncatedBy ?? (this.totalDecodedBytes > this.maxBytes || this.exceeded ? "bytes" : "lines"))
|
|
95
|
+
: null,
|
|
93
96
|
totalLines: this.totalLines,
|
|
97
|
+
totalLinesKnown: true,
|
|
94
98
|
totalBytes: this.totalDecodedBytes,
|
|
99
|
+
totalBytesKnown: !this.exceeded,
|
|
95
100
|
maxLines: this.maxLines,
|
|
96
101
|
maxBytes: this.maxBytes,
|
|
97
102
|
};
|
|
98
|
-
if (options.persistIfTruncated && truncation.truncated)
|
|
103
|
+
if (options.persistIfTruncated && truncation.truncated)
|
|
99
104
|
this.ensureTempFile();
|
|
100
|
-
}
|
|
101
105
|
return {
|
|
102
106
|
content: truncation.content,
|
|
103
107
|
truncation,
|
|
104
|
-
fullOutputPath: this.tempFilePath,
|
|
108
|
+
fullOutputPath: this.tempFileError ? undefined : this.tempFilePath,
|
|
105
109
|
};
|
|
106
110
|
}
|
|
107
111
|
async closeTempFile() {
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
112
|
+
if (this.tempFileFd !== undefined) {
|
|
113
|
+
const fd = this.tempFileFd;
|
|
114
|
+
this.tempFileFd = undefined;
|
|
115
|
+
try {
|
|
116
|
+
closeSync(fd);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
this.recordTempError(error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (this.tempFileError)
|
|
123
|
+
throw this.tempFileError;
|
|
124
|
+
}
|
|
125
|
+
async cleanupTempFile() {
|
|
126
|
+
const path = this.tempFilePath;
|
|
127
|
+
let closeError;
|
|
128
|
+
try {
|
|
129
|
+
await this.closeTempFile();
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
closeError = error;
|
|
133
|
+
}
|
|
134
|
+
this.tempFilePath = undefined;
|
|
135
|
+
this.tempFileError = undefined;
|
|
136
|
+
if (path)
|
|
137
|
+
await rm(path, { force: true });
|
|
138
|
+
if (closeError)
|
|
139
|
+
throw closeError;
|
|
126
140
|
}
|
|
127
141
|
getLastLineBytes() {
|
|
128
142
|
return this.currentLineBytes;
|
|
129
143
|
}
|
|
144
|
+
getTotalRawBytes() {
|
|
145
|
+
return this.totalRawBytes;
|
|
146
|
+
}
|
|
147
|
+
isOutputLimitExceeded() {
|
|
148
|
+
return this.exceeded;
|
|
149
|
+
}
|
|
150
|
+
hasStorageError() {
|
|
151
|
+
return this.tempFileError !== undefined;
|
|
152
|
+
}
|
|
130
153
|
appendDecodedText(text) {
|
|
131
|
-
if (text.length === 0)
|
|
154
|
+
if (text.length === 0)
|
|
132
155
|
return;
|
|
133
|
-
}
|
|
134
156
|
const bytes = byteLength(text);
|
|
135
157
|
this.totalDecodedBytes += bytes;
|
|
136
158
|
this.tailText += text;
|
|
137
159
|
this.tailBytes += bytes;
|
|
138
|
-
if (this.tailBytes > this.maxRollingBytes * 2)
|
|
160
|
+
if (this.tailBytes > this.maxRollingBytes * 2)
|
|
139
161
|
this.trimTail();
|
|
140
|
-
}
|
|
141
162
|
let newlines = 0;
|
|
142
163
|
let lastNewline = -1;
|
|
143
164
|
for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) {
|
|
@@ -163,36 +184,56 @@ export class OutputAccumulator {
|
|
|
163
184
|
return;
|
|
164
185
|
}
|
|
165
186
|
let start = buffer.length - this.maxRollingBytes;
|
|
166
|
-
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80)
|
|
187
|
+
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80)
|
|
167
188
|
start++;
|
|
168
|
-
|
|
169
|
-
this.tailStartsAtLineBoundary =
|
|
170
|
-
start === 0 ? this.tailStartsAtLineBoundary : buffer[start - 1] === 0x0a;
|
|
189
|
+
this.tailStartsAtLineBoundary = start === 0 ? this.tailStartsAtLineBoundary : buffer[start - 1] === 0x0a;
|
|
171
190
|
this.tailText = buffer.subarray(start).toString("utf-8");
|
|
172
191
|
this.tailBytes = byteLength(this.tailText);
|
|
173
192
|
}
|
|
174
193
|
getSnapshotText() {
|
|
175
|
-
if (this.tailStartsAtLineBoundary)
|
|
194
|
+
if (this.tailStartsAtLineBoundary)
|
|
176
195
|
return this.tailText;
|
|
177
|
-
}
|
|
178
196
|
const firstNewline = this.tailText.indexOf("\n");
|
|
179
197
|
return firstNewline === -1 ? this.tailText : this.tailText.slice(firstNewline + 1);
|
|
180
198
|
}
|
|
181
199
|
shouldUseTempFile() {
|
|
182
|
-
return
|
|
183
|
-
this.totalDecodedBytes > this.maxBytes ||
|
|
184
|
-
this.totalLines > this.maxLines);
|
|
200
|
+
return this.totalRawBytes > this.maxBytes || this.totalDecodedBytes > this.maxBytes || this.totalLines > this.maxLines;
|
|
185
201
|
}
|
|
186
202
|
ensureTempFile() {
|
|
187
|
-
if (this.tempFilePath)
|
|
203
|
+
if (this.tempFilePath || this.tempFileError)
|
|
188
204
|
return;
|
|
205
|
+
const path = defaultTempFilePath(this.tempFilePrefix);
|
|
206
|
+
try {
|
|
207
|
+
this.tempFileFd = openSync(path, "wx", 0o600);
|
|
208
|
+
this.tempFilePath = path;
|
|
209
|
+
for (const chunk of this.rawChunks)
|
|
210
|
+
this.writeTemp(chunk);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
this.recordTempError(error);
|
|
189
214
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
for (const chunk of this.rawChunks) {
|
|
193
|
-
this.tempFileStream.write(chunk);
|
|
215
|
+
finally {
|
|
216
|
+
this.rawChunks = [];
|
|
194
217
|
}
|
|
195
|
-
|
|
218
|
+
}
|
|
219
|
+
writeTemp(data) {
|
|
220
|
+
// ponytail: synchronous spill is zero-queue backpressure; use a bounded async writer only if measured throughput requires it.
|
|
221
|
+
if (this.tempFileFd === undefined || this.tempFileError)
|
|
222
|
+
return;
|
|
223
|
+
try {
|
|
224
|
+
let offset = 0;
|
|
225
|
+
while (offset < data.length)
|
|
226
|
+
offset += writeSync(this.tempFileFd, data, offset);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
this.recordTempError(error);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
recordTempError(error) {
|
|
233
|
+
if (this.tempFileError)
|
|
234
|
+
return;
|
|
235
|
+
this.tempFileError = error instanceof Error ? error : new Error(String(error));
|
|
236
|
+
this.onStorageError?.();
|
|
196
237
|
}
|
|
197
238
|
}
|
|
198
239
|
//# sourceMappingURL=output-accumulator.js.map
|
package/dist/read.d.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Read tool: read a file from the host filesystem.
|
|
3
3
|
*
|
|
4
4
|
* Behavioral port of pi's core/tools/read for @arnilo/prism-coding-agent, adapted to Prism's
|
|
5
|
-
* `ToolDefinition` contract.
|
|
6
|
-
*
|
|
5
|
+
* `ToolDefinition` contract. Keeps pi's offset/limit continuation behavior while streaming one
|
|
6
|
+
* bounded text page; image path remains magic-byte MIME → bounded `ImageContent`. Drops pi's
|
|
7
7
|
* TUI (`renderCall`/`renderResult`, theme/syntax-highlight, compact classifications, key hints) and
|
|
8
8
|
* the model-aware non-vision note (Prism's `ToolExecutionContext` has no model field).
|
|
9
9
|
*
|
|
@@ -27,7 +27,7 @@ export declare function detectSupportedImageMimeType(buffer: Buffer): string | n
|
|
|
27
27
|
/** Sniff the leading bytes of a file and return its image MIME type (null if not a supported image). */
|
|
28
28
|
export declare function detectSupportedImageMimeTypeFromFile(filePath: string): Promise<string | null>;
|
|
29
29
|
/** Default maximum image file size before read/transform (10 MB). */
|
|
30
|
-
export
|
|
30
|
+
export { DEFAULT_MAX_IMAGE_BYTES } from "./limits.js";
|
|
31
31
|
/** Input passed to an optional host-owned image transformer. */
|
|
32
32
|
export interface TransformImageInput {
|
|
33
33
|
readonly buffer: Buffer;
|
|
@@ -39,13 +39,42 @@ export type TransformImage = (input: TransformImageInput) => Promise<Buffer>;
|
|
|
39
39
|
* Pluggable operations for the read tool. Override to delegate file reading to remote systems
|
|
40
40
|
* (e.g. SSH) while keeping the tool's truncation/offset/limit behavior.
|
|
41
41
|
*/
|
|
42
|
+
export interface ReadTextOptions {
|
|
43
|
+
readonly offset: number;
|
|
44
|
+
readonly limit?: number;
|
|
45
|
+
readonly maxLines: number;
|
|
46
|
+
readonly maxBytes: number;
|
|
47
|
+
readonly maxScanBytes: number;
|
|
48
|
+
readonly signal?: AbortSignal;
|
|
49
|
+
}
|
|
50
|
+
export interface ReadTextResult {
|
|
51
|
+
readonly content: string;
|
|
52
|
+
readonly startLine: number;
|
|
53
|
+
readonly outputLines: number;
|
|
54
|
+
readonly hasMore: boolean;
|
|
55
|
+
readonly nextOffset?: number;
|
|
56
|
+
readonly truncatedBy: "lines" | "bytes" | null;
|
|
57
|
+
readonly firstLineExceedsLimit: boolean;
|
|
58
|
+
readonly scannedBytes: number;
|
|
59
|
+
readonly totalLines?: number;
|
|
60
|
+
readonly totalBytes?: number;
|
|
61
|
+
}
|
|
42
62
|
export interface ReadOperations {
|
|
43
|
-
/** Read file
|
|
44
|
-
readFile: (absolutePath: string
|
|
63
|
+
/** Read a bounded binary file. Backends must honor `maxBytes` before retaining more data. */
|
|
64
|
+
readFile: (absolutePath: string, options: {
|
|
65
|
+
maxBytes: number;
|
|
66
|
+
signal?: AbortSignal;
|
|
67
|
+
}) => Promise<Buffer>;
|
|
68
|
+
/** Read one bounded text page. Required so remote backends cannot fall back to a full-file read. */
|
|
69
|
+
readText: (absolutePath: string, options: ReadTextOptions) => Promise<ReadTextResult>;
|
|
45
70
|
/** Check the file is readable (throw if not). */
|
|
46
|
-
access: (absolutePath: string
|
|
47
|
-
|
|
48
|
-
|
|
71
|
+
access: (absolutePath: string, options?: {
|
|
72
|
+
signal?: AbortSignal;
|
|
73
|
+
}) => Promise<void>;
|
|
74
|
+
/** Return file size in bytes for image bound checks. */
|
|
75
|
+
statFile: (absolutePath: string, options?: {
|
|
76
|
+
signal?: AbortSignal;
|
|
77
|
+
}) => Promise<{
|
|
49
78
|
size: number;
|
|
50
79
|
}>;
|
|
51
80
|
/** Detect image MIME type from the file; return null/undefined for non-images. */
|
|
@@ -61,6 +90,8 @@ export interface ReadToolOptions {
|
|
|
61
90
|
autoResizeImages?: boolean;
|
|
62
91
|
/** Reject image reads larger than this many bytes (default {@link DEFAULT_MAX_IMAGE_BYTES}). */
|
|
63
92
|
maxImageBytes?: number;
|
|
93
|
+
/** Maximum raw bytes scanned to reach one text page (default 64 MiB). */
|
|
94
|
+
maxScanBytes?: number;
|
|
64
95
|
/** Optional host callback to resize or re-encode images before base64 encoding. */
|
|
65
96
|
transformImage?: TransformImage;
|
|
66
97
|
/** Custom operations backend (default: local filesystem). */
|