@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.
@@ -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 { createWriteStream } from "node:fs";
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, truncateTail } from "./truncate.js";
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
- const id = randomBytes(8).toString("hex");
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
- tempFileStream;
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.maxRollingBytes = Math.max(this.maxBytes * 2, 1);
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
- this.totalRawBytes += data.length;
61
- this.appendDecodedText(this.decoder.decode(data, { stream: true }));
62
- if (this.tempFileStream || this.shouldUseTempFile()) {
63
- this.ensureTempFile();
64
- this.tempFileStream?.write(data);
65
- }
66
- else if (data.length > 0) {
67
- this.rawChunks.push(data);
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 (!this.tempFileStream) {
109
- return;
110
- }
111
- const stream = this.tempFileStream;
112
- this.tempFileStream = undefined;
113
- await new Promise((resolve, reject) => {
114
- const onError = (error) => {
115
- stream.off("finish", onFinish);
116
- reject(error);
117
- };
118
- const onFinish = () => {
119
- stream.off("error", onError);
120
- resolve();
121
- };
122
- stream.once("error", onError);
123
- stream.once("finish", onFinish);
124
- stream.end();
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 (this.totalRawBytes > this.maxBytes ||
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
- this.tempFilePath = defaultTempFilePath(this.tempFilePrefix);
191
- this.tempFileStream = createWriteStream(this.tempFilePath);
192
- for (const chunk of this.rawChunks) {
193
- this.tempFileStream.write(chunk);
215
+ finally {
216
+ this.rawChunks = [];
194
217
  }
195
- this.rawChunks = [];
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. Faithfully ports pi's text path (offset/limit `truncateHead`
6
- * continuation notices) and image path (magic-byte MIME → `ImageContent` with base64). Drops pi's
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 declare const DEFAULT_MAX_IMAGE_BYTES = 10000000;
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 contents as a Buffer. */
44
- readFile: (absolutePath: string) => Promise<Buffer>;
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) => Promise<void>;
47
- /** Return file size in bytes for image bound checks (default: local `fs.stat`). */
48
- statFile?: (absolutePath: string) => Promise<{
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). */