pi-better-background-tasks 0.2.20 → 0.3.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.
@@ -1,5 +1,6 @@
1
1
  // Generated from packages/log-utils/index.ts. Do not edit directly.
2
- import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { closeSync, fstatSync, openSync, readFileSync, readSync, statSync } from "node:fs";
3
4
 
4
5
  export interface TailRead {
5
6
  text: string;
@@ -101,4 +102,1026 @@ export function tailTerminalDisplay(text: string, rows: number, maxRowChars?: nu
101
102
  const rendered = terminalDisplayRows(text, maxRowChars);
102
103
  const count = Math.max(1, Math.floor(rows));
103
104
  return rendered.slice(-count).join("\n");
104
- }
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // UTF-8 total-output budgets, verbatim paging, retained-file cursors, envelope
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /** Issue #312 / OUTPUT-POLICY defaults: whole model-facing `content`, UTF-8 bytes. */
112
+ export const OUTPUT_BUDGET_BYTES = {
113
+ status: 1 * 1024,
114
+ answer: 2 * 1024,
115
+ log: 1 * 1024,
116
+ list: 1 * 1024,
117
+ callbackBatch: 2 * 1024,
118
+ rawPage: 16 * 1024,
119
+ } as const;
120
+
121
+ /** Documented hard caps. Explicit larger pages are allowed up to these values. */
122
+ export const OUTPUT_BUDGET_MAX_BYTES = {
123
+ status: 2 * 1024,
124
+ answer: 8 * 1024,
125
+ log: 4 * 1024,
126
+ list: 4 * 1024,
127
+ callbackBatch: 8 * 1024,
128
+ rawPage: 64 * 1024,
129
+ } as const;
130
+
131
+ export const OUTPUT_PAGE_DEFAULTS = {
132
+ logLines: 10,
133
+ listEntries: 10,
134
+ } as const;
135
+
136
+ export type OutputBudgetSurface = keyof typeof OUTPUT_BUDGET_BYTES;
137
+
138
+ export type PageReset = "stale-cursor" | "source-replaced" | "compacted";
139
+ export type EvidenceGapKind = "capture" | "retention" | "read";
140
+ export type StatusChange = "none" | "failure" | "content" | "reset";
141
+ export type EnvelopeSectionName =
142
+ | "identity"
143
+ | "failure"
144
+ | "decision"
145
+ | "diagnostics"
146
+ | "verbatim"
147
+ | "progress";
148
+
149
+ export interface EvidenceGap {
150
+ kind: EvidenceGapKind;
151
+ bytes?: number;
152
+ detail?: string;
153
+ }
154
+
155
+ export interface PageRequest {
156
+ cursor?: string;
157
+ /**
158
+ * UTF-8 byte budget for this page. Exactly `0` yields an empty page that is
159
+ * positioned at the cursor (nothing is skipped). Other nonpositive, NaN, or
160
+ * non-numeric values fall back to the API default. Values above the raw-page
161
+ * hard cap are clamped. A page never exceeds this budget: if the next code
162
+ * point does not fit, the page is empty and `nextCursor` does not advance.
163
+ */
164
+ maxBytes?: number;
165
+ /** Optional line cap. */
166
+ maxLines?: number;
167
+ /**
168
+ * Resource/scope bound into the cursor (for example `scope:runId`). A cursor
169
+ * minted for another resource, including another session scope, resets
170
+ * with `stale-cursor` instead of silently continuing.
171
+ */
172
+ resource?: string;
173
+ }
174
+
175
+ export interface PageResult {
176
+ text: string;
177
+ revision: string;
178
+ /** Caller-owned cursor that reproduces this page with the same maxBytes. */
179
+ cursor: string;
180
+ /** Start of the following page. At the end of a file it is append-ready. */
181
+ nextCursor: string;
182
+ hasMore: boolean;
183
+ /** Readable bytes after this page within the current snapshot. */
184
+ omittedBytes: number;
185
+ totalBytes: number;
186
+ startByte: number;
187
+ endByte: number;
188
+ reset?: PageReset;
189
+ gaps: EvidenceGap[];
190
+ /** True when this page reached the current end: `nextCursor` returns only bytes appended later. */
191
+ appendReady?: boolean;
192
+ /** Bytes of a trailing, still-incomplete UTF-8 sequence withheld until the writer completes it. */
193
+ pendingBytes?: number;
194
+ }
195
+
196
+ export interface FilePageRequest extends PageRequest {
197
+ /**
198
+ * Consumer-owned generation. Increment on replacement or same-inode
199
+ * compaction so a compatible head cannot hide a rewrite.
200
+ */
201
+ generation?: number | string;
202
+ /** Bytes permanently discarded by retention before the current file bytes. */
203
+ discardedBytes?: number;
204
+ /** Bytes never written because capture overflowed. */
205
+ captureGaps?: Array<{ bytes: number; detail?: string }>;
206
+ /** Pin pagination to this many leading bytes; defaults to the size at first read. */
207
+ snapshotBytes?: number;
208
+ }
209
+
210
+ export interface VerbatimPage {
211
+ text: string;
212
+ hasMore: boolean;
213
+ /** Start cursor of this page. */
214
+ cursor?: string;
215
+ nextCursor?: string;
216
+ omittedBytes: number;
217
+ /** Row-oriented pages (lists, incidents) count omitted rows instead of bytes. */
218
+ omittedRows?: number;
219
+ revision?: string;
220
+ reset?: PageReset;
221
+ gaps?: EvidenceGap[];
222
+ totalBytes?: number;
223
+ startByte?: number;
224
+ endByte?: number;
225
+ appendReady?: boolean;
226
+ pendingBytes?: number;
227
+ /** Where `nextCursor` is accepted when that is not the tool that returned it. */
228
+ via?: string;
229
+ }
230
+
231
+ /** A failure section may be computed for the exact bytes the envelope can give it. */
232
+ export type EnvelopeFailure = string | ((budget: number) => string | undefined);
233
+
234
+ export interface EnvelopeSections {
235
+ identity?: string;
236
+ failure?: EnvelopeFailure;
237
+ decision?: string;
238
+ diagnostics?: string;
239
+ progress?: string;
240
+ }
241
+
242
+ export interface EnvelopeOmission {
243
+ section: EnvelopeSectionName | string;
244
+ omittedBytes: number;
245
+ }
246
+
247
+ export interface AssembledEnvelope {
248
+ text: string;
249
+ byteLength: number;
250
+ truncated: boolean;
251
+ omitted: EnvelopeOmission[];
252
+ verbatim?: VerbatimPage;
253
+ continuation?: string;
254
+ gaps: EvidenceGap[];
255
+ }
256
+
257
+ export interface StatusRevisionInput {
258
+ cursor?: string;
259
+ resource: string;
260
+ contentRevision: string;
261
+ failureRevision: string;
262
+ }
263
+
264
+ export interface StatusRevisionResult {
265
+ change: StatusChange;
266
+ reset?: PageReset;
267
+ revision: string;
268
+ nextCursor: string;
269
+ }
270
+
271
+ const encoder = new TextEncoder();
272
+ const decoder = new TextDecoder("utf-8", { fatal: false });
273
+ const CURSOR_PREFIX = "p1.";
274
+ const ROW_CURSOR_PREFIX = "l1.";
275
+ const HEAD_SAMPLE_BYTES = 256;
276
+ const WINDOW_SAMPLE_BYTES = 256;
277
+ const NEWLINE = 0x0a;
278
+
279
+ interface CursorPayload {
280
+ k: "t" | "f" | "s";
281
+ r?: string;
282
+ v?: string;
283
+ o?: number;
284
+ n?: number;
285
+ g?: string;
286
+ i?: string;
287
+ h?: string;
288
+ hl?: number;
289
+ w?: string;
290
+ c?: string;
291
+ f?: string;
292
+ a?: 1;
293
+ }
294
+
295
+ function positiveInt(value: unknown): number | undefined {
296
+ const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN;
297
+ if (!Number.isFinite(n) || n <= 0) return undefined;
298
+ return Math.max(1, Math.floor(n));
299
+ }
300
+
301
+ /** Nonpositive, NaN, and non-numeric inputs fall back; callers may request a lower budget. */
302
+ export function clampBudgetBytes(value: unknown, fallback: number): number {
303
+ return positiveInt(value) ?? positiveInt(fallback) ?? 1;
304
+ }
305
+
306
+ /** Surface default, or a caller request clamped to the documented hard cap. */
307
+ export function budgetFor(surface: OutputBudgetSurface, requested?: unknown): number {
308
+ const fallback = OUTPUT_BUDGET_BYTES[surface];
309
+ const cap = OUTPUT_BUDGET_MAX_BYTES[surface];
310
+ const n = positiveInt(requested);
311
+ if (n === undefined) return fallback;
312
+ return Math.min(n, cap);
313
+ }
314
+
315
+ /** Pager budget: exact zero is an explicit empty page; garbage falls back. */
316
+ function pagerBudget(value: unknown, fallback: number): number {
317
+ if (value === 0) return 0;
318
+ const n = positiveInt(value);
319
+ if (n === undefined) return fallback;
320
+ return Math.min(n, OUTPUT_BUDGET_MAX_BYTES.rawPage);
321
+ }
322
+
323
+ export function utf8ByteLength(text: string): number {
324
+ return encoder.encode(text).byteLength;
325
+ }
326
+
327
+ function isContinuation(byte: number): boolean {
328
+ return (byte & 0xc0) === 0x80;
329
+ }
330
+
331
+ function sequenceLength(lead: number): number {
332
+ if (lead <= 0x7f) return 1;
333
+ if ((lead & 0xe0) === 0xc0) return 2;
334
+ if ((lead & 0xf0) === 0xe0) return 3;
335
+ if ((lead & 0xf8) === 0xf0) return 4;
336
+ return 1;
337
+ }
338
+
339
+ /**
340
+ * Largest end <= `to` that does not split a UTF-8 sequence, given that bytes
341
+ * after `to` are available in `bytes`. A sequence cut by the end of `bytes`
342
+ * itself is reported through `incompleteAtEnd` when `atEnd` is set.
343
+ */
344
+ function utf8SafeEnd(bytes: Uint8Array, from: number, to: number): number {
345
+ if (to <= from) return from;
346
+ const limit = Math.min(to, bytes.length);
347
+ let seqStart = limit - 1;
348
+ while (seqStart > from && isContinuation(bytes[seqStart]!) && limit - seqStart < 4) seqStart -= 1;
349
+ const lead = bytes[seqStart]!;
350
+ if (isContinuation(lead)) return limit;
351
+ const needed = sequenceLength(lead);
352
+ if (needed === 1 || seqStart + needed <= limit) return limit;
353
+ if (seqStart + needed <= bytes.length) return seqStart;
354
+ // The sequence runs past the available bytes. When every following byte is
355
+ // a continuation it is a genuine, still-incomplete write; otherwise the
356
+ // source itself is malformed and is passed through rather than stalling.
357
+ for (let i = seqStart + 1; i < bytes.length; i += 1) {
358
+ if (!isContinuation(bytes[i]!)) return limit;
359
+ }
360
+ return seqStart;
361
+ }
362
+
363
+ function alignStart(bytes: Uint8Array, start: number): number {
364
+ if (start <= 0) return 0;
365
+ if (start >= bytes.length) return bytes.length;
366
+ let i = start;
367
+ while (i < bytes.length && isContinuation(bytes[i]!)) i += 1;
368
+ return i;
369
+ }
370
+
371
+ /**
372
+ * End the page after the last newline in [from, to) when that keeps at least
373
+ * half of the page; otherwise cut at `to` (already a UTF-8 boundary), so a
374
+ * short line followed by a huge one does not produce a nearly empty page.
375
+ */
376
+ function preferNewlineEnd(bytes: Uint8Array, from: number, to: number): number {
377
+ const floor = from + Math.ceil((to - from) / 2);
378
+ for (let i = to - 1; i >= floor - 1 && i >= from; i -= 1) {
379
+ if (bytes[i] === NEWLINE) return i + 1;
380
+ }
381
+ return to;
382
+ }
383
+
384
+ /**
385
+ * Byte range for one text page. With `forceProgress` a budget smaller than
386
+ * the next code point still returns that code point (display clipping only);
387
+ * pagers pass `false` so a page never exceeds its budget.
388
+ */
389
+ function sliceUtf8Range(
390
+ bytes: Uint8Array,
391
+ start: number,
392
+ maxBytes: number,
393
+ preferNewline: boolean,
394
+ forceProgress = true,
395
+ ): { start: number; end: number } {
396
+ const from = alignStart(bytes, start);
397
+ if (from >= bytes.length) return { start: from, end: from };
398
+ const budget = Math.max(0, Math.floor(maxBytes));
399
+ let to = utf8SafeEnd(bytes, from, Math.min(bytes.length, from + budget));
400
+ if (to <= from) {
401
+ if (!forceProgress) return { start: from, end: from };
402
+ to = Math.min(bytes.length, from + sequenceLength(bytes[from]!));
403
+ }
404
+ // Prefer a newline only when this slice is truncated. If the remainder fits,
405
+ // keep a final line that has no trailing newline.
406
+ if (preferNewline && to > from && to < bytes.length) to = preferNewlineEnd(bytes, from, to);
407
+ return { start: from, end: to };
408
+ }
409
+
410
+ /** Display clipping helper. It may exceed `maxBytes` by one code point to make progress. */
411
+ export function sliceUtf8Bytes(
412
+ text: string,
413
+ startByte: number,
414
+ maxBytes: number,
415
+ preferNewline = true,
416
+ ): { text: string; startByte: number; endByte: number; bytes: number } {
417
+ const encoded = encoder.encode(text);
418
+ const range = sliceUtf8Range(encoded, startByte, maxBytes, preferNewline);
419
+ return {
420
+ text: decoder.decode(encoded.subarray(range.start, range.end)),
421
+ startByte: range.start,
422
+ endByte: range.end,
423
+ bytes: range.end - range.start,
424
+ };
425
+ }
426
+
427
+ function hashBytes(bytes: Uint8Array): string {
428
+ return createHash("sha256").update(bytes).digest("base64url");
429
+ }
430
+
431
+ /** Short digest stored in cursors (128 bits): cursors stay small in the model-facing budget. */
432
+ function shortHash(bytes: Uint8Array): string {
433
+ return hashBytes(bytes).slice(0, 22);
434
+ }
435
+
436
+ /** Cursors carry a digest of their resource/scope, never the scope text itself. */
437
+ function resourceTag(resource: string): string {
438
+ return hashBytes(encoder.encode(resource)).slice(0, 16);
439
+ }
440
+
441
+ function encodeCursor(payload: CursorPayload): string {
442
+ return CURSOR_PREFIX + Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
443
+ }
444
+
445
+ function decodeCursor(cursor: string | undefined): CursorPayload | undefined {
446
+ if (!cursor || !cursor.startsWith(CURSOR_PREFIX)) return undefined;
447
+ try {
448
+ const parsed = JSON.parse(Buffer.from(cursor.slice(CURSOR_PREFIX.length), "base64url").toString("utf8")) as CursorPayload;
449
+ if (parsed?.k === "t" || parsed?.k === "f" || parsed?.k === "s") return parsed;
450
+ } catch {
451
+ return undefined;
452
+ }
453
+ return undefined;
454
+ }
455
+
456
+ export function cursorKind(cursor: string | undefined): "t" | "f" | "s" | "l" | undefined {
457
+ if (cursor?.startsWith(ROW_CURSOR_PREFIX)) return decodeRowCursor(cursor) ? "l" : undefined;
458
+ return decodeCursor(cursor)?.k;
459
+ }
460
+
461
+ function readAt(fd: number, position: number, length: number): Buffer {
462
+ if (length <= 0) return Buffer.alloc(0);
463
+ const buffer = Buffer.allocUnsafe(length);
464
+ let filled = 0;
465
+ while (filled < length) {
466
+ const n = readSync(fd, buffer, filled, length - filled, position + filled);
467
+ if (n <= 0) break;
468
+ filled += n;
469
+ }
470
+ return filled === length ? buffer : buffer.subarray(0, filled);
471
+ }
472
+
473
+ function singleLine(value: string): string {
474
+ return value.replace(/[\r\n\x00-\x1f\x7f]/g, " ").trim();
475
+ }
476
+
477
+ function emptyPage(overrides: Partial<PageResult> & Pick<PageResult, "revision" | "cursor" | "nextCursor">): PageResult {
478
+ return {
479
+ text: "",
480
+ hasMore: false,
481
+ omittedBytes: 0,
482
+ totalBytes: 0,
483
+ startByte: 0,
484
+ endByte: 0,
485
+ gaps: [],
486
+ ...overrides,
487
+ };
488
+ }
489
+
490
+ /**
491
+ * Page exact UTF-8 text. Consecutive pages concatenate to the original string.
492
+ * Cursors are caller-owned: the same cursor plus maxBytes always yields the
493
+ * same page, and two callers do not share consumption.
494
+ */
495
+ export function pageVerbatimText(text: string, request: PageRequest = {}): PageResult {
496
+ const maxBytes = pagerBudget(request.maxBytes, OUTPUT_BUDGET_BYTES.answer);
497
+ const encoded = encoder.encode(text);
498
+ const revision = shortHash(encoded);
499
+ const resource = request.resource === undefined ? undefined : resourceTag(request.resource);
500
+ let offset = 0;
501
+ let reset: PageReset | undefined;
502
+ const parsed = decodeCursor(request.cursor);
503
+ if (request.cursor) {
504
+ if (!parsed || parsed.k !== "t" || (parsed.r ?? undefined) !== resource) {
505
+ reset = "stale-cursor";
506
+ } else if (parsed.v !== revision) {
507
+ reset = "source-replaced";
508
+ } else {
509
+ offset = Math.min(encoded.length, Math.max(0, Math.floor(parsed.o ?? 0)));
510
+ }
511
+ }
512
+ const mint = (o: number): string => encodeCursor({
513
+ k: "t",
514
+ ...(resource !== undefined ? { r: resource } : {}),
515
+ v: revision,
516
+ o,
517
+ n: encoded.length,
518
+ });
519
+ const make = (start: number, end: number): PageResult => ({
520
+ text: decoder.decode(encoded.subarray(start, end)),
521
+ revision,
522
+ cursor: mint(start),
523
+ nextCursor: mint(end),
524
+ hasMore: end < encoded.length,
525
+ omittedBytes: Math.max(0, encoded.length - end),
526
+ totalBytes: encoded.length,
527
+ startByte: start,
528
+ endByte: end,
529
+ ...(reset ? { reset } : {}),
530
+ gaps: [],
531
+ });
532
+ if (offset >= encoded.length) return make(encoded.length, encoded.length);
533
+ const range = sliceUtf8Range(encoded, offset, maxBytes, true, false);
534
+ let end = range.end;
535
+ const maxLines = positiveInt(request.maxLines);
536
+ if (maxLines !== undefined) {
537
+ let seen = 0;
538
+ for (let i = range.start; i < end; i += 1) {
539
+ if (encoded[i] === NEWLINE) {
540
+ seen += 1;
541
+ if (seen >= maxLines) {
542
+ end = i + 1;
543
+ break;
544
+ }
545
+ }
546
+ }
547
+ }
548
+ return make(range.start, end);
549
+ }
550
+
551
+ function fileGaps(request: FilePageRequest): EvidenceGap[] {
552
+ const gaps: EvidenceGap[] = [];
553
+ const discarded = request.discardedBytes;
554
+ if (typeof discarded === "number" && Number.isFinite(discarded) && discarded > 0) {
555
+ gaps.push({
556
+ kind: "retention",
557
+ bytes: Math.floor(discarded),
558
+ detail: "older retained bytes discarded",
559
+ });
560
+ }
561
+ for (const gap of request.captureGaps ?? []) {
562
+ if (!Number.isFinite(gap.bytes) || gap.bytes <= 0) continue;
563
+ gaps.push({ kind: "capture", bytes: Math.floor(gap.bytes), detail: gap.detail });
564
+ }
565
+ return gaps;
566
+ }
567
+
568
+ /**
569
+ * Identity of the file object, not just its path. Linux filesystems reuse a
570
+ * freed inode number immediately, so a delete + recreate can keep `dev:ino`;
571
+ * the birth time tells the files apart where the platform reports it at fine
572
+ * granularity. With coarse timestamps the two are indistinguishable by stat,
573
+ * and the head/pre-offset byte checks classify the change instead.
574
+ */
575
+ function fileIdentity(stats: { dev: bigint; ino: bigint; birthtimeNs: bigint }): string {
576
+ const birth = stats.birthtimeNs > 0n ? `:${stats.birthtimeNs.toString(36)}` : "";
577
+ return `${stats.dev.toString(36)}:${stats.ino.toString(36)}${birth}`;
578
+ }
579
+
580
+ function fileRevision(generation: string | undefined, identity: string, snapshot: number): string {
581
+ return `${generation ?? ""}|${identity}|${snapshot}`;
582
+ }
583
+
584
+ function fileReadError(
585
+ request: FilePageRequest,
586
+ path: string,
587
+ error: unknown,
588
+ reset?: PageReset,
589
+ ): PageResult {
590
+ const resource = resourceTag(request.resource ?? path);
591
+ const cursor = encodeCursor({ k: "f", r: resource, o: 0, n: 0 });
592
+ return emptyPage({
593
+ revision: "unreadable",
594
+ cursor,
595
+ nextCursor: cursor,
596
+ gaps: [...fileGaps(request), { kind: "read", detail: errorText(error) }],
597
+ ...(reset ? { reset } : {}),
598
+ });
599
+ }
600
+
601
+ /**
602
+ * Page retained file bytes without skipping unread ranges. Snapshot high-water
603
+ * marks keep a page stable while the file appends. Cursors bind the resource
604
+ * (task/run plus session scope), the file object's identity, the consumer
605
+ * generation, the head, and the bytes just before the offset, so replacement,
606
+ * same-inode compaction, and in-place rewrites reset instead of resuming at a
607
+ * meaningless offset. A trailing, still-incomplete UTF-8 sequence is withheld
608
+ * (`pendingBytes`) and returned whole once the writer completes it.
609
+ */
610
+ export function pageRetainedFile(path: string, request: FilePageRequest = {}): PageResult {
611
+ const maxBytes = pagerBudget(request.maxBytes, OUTPUT_BUDGET_BYTES.rawPage);
612
+ const resource = resourceTag(request.resource ?? path);
613
+ const generation = request.generation === undefined ? undefined : String(request.generation);
614
+ const suppliedGaps = fileGaps(request);
615
+ let fd: number | undefined;
616
+ try {
617
+ fd = openSync(path, "r");
618
+ } catch (error) {
619
+ return fileReadError(request, path, error, request.cursor ? "source-replaced" : undefined);
620
+ }
621
+ const opened = fd;
622
+ try {
623
+ const stats = fstatSync(opened, { bigint: true });
624
+ const size = Number(stats.size);
625
+ const identity = fileIdentity(stats);
626
+ const head = readAt(opened, 0, Math.min(HEAD_SAMPLE_BYTES, size));
627
+ const headHash = shortHash(head);
628
+ const windowHash = (o: number): string | undefined => {
629
+ if (o <= 0) return undefined;
630
+ const from = Math.max(0, o - WINDOW_SAMPLE_BYTES);
631
+ return shortHash(readAt(opened, from, o - from));
632
+ };
633
+ const initialSnapshot = (): number => Math.min(size, request.snapshotBytes !== undefined
634
+ ? clampBudgetBytes(request.snapshotBytes, size)
635
+ : size);
636
+ const parsed = decodeCursor(request.cursor);
637
+ let reset: PageReset | undefined;
638
+ let offset = 0;
639
+ let snapshot = initialSnapshot();
640
+ let appendReady = false;
641
+
642
+ if (request.cursor) {
643
+ if (!parsed || parsed.k !== "f" || parsed.r !== resource) {
644
+ reset = "stale-cursor";
645
+ } else if (parsed.i !== identity) {
646
+ reset = "source-replaced";
647
+ } else {
648
+ const o = Math.max(0, Math.floor(parsed.o ?? 0));
649
+ const n = Math.max(0, Math.floor(parsed.n ?? 0));
650
+ const headLength = Math.max(0, Math.floor(parsed.hl ?? 0));
651
+ const generationChanged = generation !== undefined && parsed.g !== generation;
652
+ const bytesIntact = o <= n
653
+ && n <= size
654
+ && headLength <= size
655
+ && (!parsed.h || shortHash(head.subarray(0, headLength)) === parsed.h)
656
+ && (o === 0 || windowHash(o) === parsed.w);
657
+ if (generationChanged) {
658
+ // The consumer declared a compaction/replacement of this file.
659
+ reset = "compacted";
660
+ } else if (!bytesIntact) {
661
+ // Same file object as far as stat can tell, but the bytes behind the
662
+ // cursor changed without a declared compaction: rewritten in place,
663
+ // or deleted and recreated on a reused inode with coarse timestamps.
664
+ reset = "source-replaced";
665
+ } else {
666
+ offset = o;
667
+ snapshot = n;
668
+ appendReady = parsed.a === 1;
669
+ }
670
+ }
671
+ }
672
+
673
+ if (appendReady && offset >= snapshot && size > snapshot) {
674
+ snapshot = Math.max(snapshot, initialSnapshot());
675
+ }
676
+
677
+ const mint = (o: number, n: number, append: boolean): string => {
678
+ const window = windowHash(o);
679
+ return encodeCursor({
680
+ k: "f",
681
+ r: resource,
682
+ o,
683
+ n,
684
+ ...(generation !== undefined ? { g: generation } : {}),
685
+ i: identity,
686
+ h: headHash,
687
+ hl: head.length,
688
+ ...(window ? { w: window } : {}),
689
+ ...(append ? { a: 1 } : {}),
690
+ });
691
+ };
692
+ const base = {
693
+ gaps: suppliedGaps,
694
+ ...(reset ? { reset } : {}),
695
+ };
696
+
697
+ if (offset > snapshot) offset = snapshot;
698
+ if (offset >= snapshot) {
699
+ return emptyPage({
700
+ revision: fileRevision(generation, identity, snapshot),
701
+ cursor: mint(offset, snapshot, true),
702
+ nextCursor: mint(snapshot, snapshot, true),
703
+ totalBytes: snapshot,
704
+ startByte: snapshot,
705
+ endByte: snapshot,
706
+ appendReady: true,
707
+ ...base,
708
+ });
709
+ }
710
+
711
+ const available = snapshot - offset;
712
+ const limit = Math.min(maxBytes, available);
713
+ const atSnapshotEnd = limit === available;
714
+ const buffer = readAt(opened, offset, Math.min(available, limit + 4));
715
+ let end = utf8SafeEnd(buffer, 0, limit);
716
+ let pendingBytes = 0;
717
+ if (atSnapshotEnd && end < limit) {
718
+ // The retained bytes stop inside a sequence that is still being written.
719
+ pendingBytes = available - end;
720
+ snapshot = offset + end;
721
+ } else if (!atSnapshotEnd && end > 0) {
722
+ end = preferNewlineEnd(buffer, 0, end);
723
+ }
724
+ const endByte = offset + end;
725
+ const revision = fileRevision(generation, identity, snapshot);
726
+ if (end === 0 && pendingBytes === 0) {
727
+ // Budget is smaller than the next code point: stay put, skip nothing.
728
+ const here = mint(offset, snapshot, false);
729
+ return emptyPage({
730
+ revision,
731
+ cursor: here,
732
+ nextCursor: here,
733
+ hasMore: true,
734
+ omittedBytes: available,
735
+ totalBytes: snapshot,
736
+ startByte: offset,
737
+ endByte: offset,
738
+ ...base,
739
+ });
740
+ }
741
+ const atEnd = endByte >= snapshot;
742
+ return {
743
+ text: decoder.decode(buffer.subarray(0, end)),
744
+ revision,
745
+ cursor: mint(offset, snapshot, false),
746
+ nextCursor: mint(endByte, snapshot, atEnd),
747
+ hasMore: !atEnd,
748
+ omittedBytes: Math.max(0, snapshot - endByte),
749
+ totalBytes: snapshot,
750
+ startByte: offset,
751
+ endByte,
752
+ ...(atEnd ? { appendReady: true } : {}),
753
+ ...(pendingBytes > 0 ? { pendingBytes } : {}),
754
+ ...base,
755
+ };
756
+ } catch (error) {
757
+ return fileReadError(request, path, error, request.cursor ? "source-replaced" : undefined);
758
+ } finally {
759
+ if (fd !== undefined) {
760
+ try { closeSync(fd); } catch { /* best effort */ }
761
+ }
762
+ }
763
+ }
764
+
765
+ function statusRevisionToken(contentRevision: string, failureRevision: string): string {
766
+ return hashBytes(encoder.encode(`${contentRevision}\0${failureRevision}`));
767
+ }
768
+
769
+ /**
770
+ * Compare a caller-owned status cursor to the current content and failure
771
+ * revisions. Failure-only changes are distinct from log-byte changes; nothing
772
+ * is consumed globally.
773
+ */
774
+ export function inspectStatusRevision(input: StatusRevisionInput): StatusRevisionResult {
775
+ const revision = statusRevisionToken(input.contentRevision, input.failureRevision);
776
+ const resource = resourceTag(input.resource);
777
+ const content = shortHash(encoder.encode(input.contentRevision)).slice(0, 16);
778
+ const failure = shortHash(encoder.encode(input.failureRevision)).slice(0, 16);
779
+ const nextCursor = encodeCursor({ k: "s", r: resource, c: content, f: failure });
780
+ const parsed = decodeCursor(input.cursor);
781
+ if (!input.cursor) return { change: "content", revision, nextCursor };
782
+ if (!parsed || parsed.k !== "s" || parsed.r !== resource) {
783
+ return { change: "reset", reset: "stale-cursor", revision, nextCursor };
784
+ }
785
+ const contentSame = parsed.c === content;
786
+ const failureSame = parsed.f === failure;
787
+ if (contentSame && failureSame) return { change: "none", revision, nextCursor };
788
+ if (contentSame && !failureSame) return { change: "failure", revision, nextCursor };
789
+ return { change: "content", revision, nextCursor };
790
+ }
791
+
792
+ export function formatUnchangedEvidence(cursor: string): string {
793
+ return `No new evidence since cursor ${cursor}.`;
794
+ }
795
+
796
+ /** Stable short revision of arbitrary JSON-serialisable status facts. */
797
+ export function revisionOf(value: unknown): string {
798
+ return createHash("sha256").update(JSON.stringify(value) ?? "undefined").digest("base64url").slice(0, 32);
799
+ }
800
+
801
+ // ---------------------------------------------------------------------------
802
+ // Row pages (lists). Keyset cursors keep paging stable while new rows arrive.
803
+ // ---------------------------------------------------------------------------
804
+
805
+ export interface RowKey {
806
+ /** Primary sort key, newest (largest) first. */
807
+ time: number;
808
+ /** Tie-break, descending. */
809
+ id: string;
810
+ }
811
+
812
+ interface RowCursorPayload {
813
+ k: "l";
814
+ r: string;
815
+ t: number;
816
+ i: string;
817
+ }
818
+
819
+ export interface RowPageRequest<T> {
820
+ cursor?: string;
821
+ /** Scope/filter identity bound into the cursor. */
822
+ resource: string;
823
+ limit: number;
824
+ maxBytes: number;
825
+ keyOf: (item: T) => RowKey;
826
+ render: (item: T) => string;
827
+ }
828
+
829
+ export interface RowPage extends VerbatimPage {
830
+ shown: number;
831
+ total: number;
832
+ /** Rows before this page in the current ordering. */
833
+ before: number;
834
+ /** Rows after this page. */
835
+ remaining: number;
836
+ /** Rows whose display text was clipped to fit (their id prefix stays visible). */
837
+ clippedRows: number;
838
+ }
839
+
840
+ function encodeRowCursor(payload: RowCursorPayload): string {
841
+ return ROW_CURSOR_PREFIX + Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
842
+ }
843
+
844
+ function decodeRowCursor(cursor: string | undefined): RowCursorPayload | undefined {
845
+ if (!cursor || !cursor.startsWith(ROW_CURSOR_PREFIX)) return undefined;
846
+ try {
847
+ const parsed = JSON.parse(Buffer.from(cursor.slice(ROW_CURSOR_PREFIX.length), "base64url").toString("utf8")) as RowCursorPayload;
848
+ if (parsed?.k === "l" && typeof parsed.r === "string" && typeof parsed.t === "number" && typeof parsed.i === "string") return parsed;
849
+ } catch {
850
+ return undefined;
851
+ }
852
+ return undefined;
853
+ }
854
+
855
+ export function isRowCursor(cursor: string | undefined): boolean {
856
+ return decodeRowCursor(cursor) !== undefined;
857
+ }
858
+
859
+ /** Sorts before every real row: a cursor anchored here starts at the first row. */
860
+ const HEAD_ROW_KEY: RowKey = { time: Number.MAX_SAFE_INTEGER, id: "" };
861
+
862
+ function rowOrder(a: RowKey, b: RowKey): number {
863
+ if (a.time !== b.time) return b.time - a.time;
864
+ return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
865
+ }
866
+
867
+ function clipRow(row: string, maxBytes: number): string {
868
+ if (utf8ByteLength(row) <= maxBytes) return row;
869
+ const marker = "…";
870
+ const room = maxBytes - utf8ByteLength(marker);
871
+ if (room <= 0) return "";
872
+ const encoded = encoder.encode(row);
873
+ const end = utf8SafeEnd(encoded, 0, room);
874
+ return `${decoder.decode(encoded.subarray(0, end))}${marker}`;
875
+ }
876
+
877
+ /**
878
+ * Page compact rows newest-first. The cursor records the last row shown, so
879
+ * rows inserted ahead of it (new tasks) never shift later pages and rows are
880
+ * neither repeated nor skipped. Only whole rows are counted as shown; a single
881
+ * row larger than the page is clipped (callers put the id first).
882
+ */
883
+ export function pageRows<T>(items: readonly T[], request: RowPageRequest<T>): RowPage {
884
+ const keyed = items.map((item) => ({ item, key: request.keyOf(item) }))
885
+ .sort((a, b) => rowOrder(a.key, b.key));
886
+ const total = keyed.length;
887
+ const limit = Math.max(1, Math.floor(request.limit));
888
+ const maxBytes = Math.max(0, Math.floor(request.maxBytes));
889
+ let start = 0;
890
+ let reset: PageReset | undefined;
891
+ const resource = resourceTag(request.resource);
892
+ if (request.cursor) {
893
+ const parsed = decodeRowCursor(request.cursor);
894
+ if (!parsed || parsed.r !== resource) {
895
+ reset = "stale-cursor";
896
+ } else {
897
+ const anchor: RowKey = { time: parsed.t, id: parsed.i };
898
+ start = keyed.findIndex((row) => rowOrder(row.key, anchor) > 0);
899
+ if (start < 0) start = total;
900
+ }
901
+ }
902
+ const lines: string[] = [];
903
+ let used = 0;
904
+ let clippedRows = 0;
905
+ let index = start;
906
+ for (; index < total && lines.length < limit; index += 1) {
907
+ const row = request.render(keyed[index]!.item);
908
+ const sep = lines.length ? 1 : 0;
909
+ const size = utf8ByteLength(row);
910
+ if (used + sep + size <= maxBytes) {
911
+ lines.push(row);
912
+ used += sep + size;
913
+ continue;
914
+ }
915
+ if (lines.length === 0) {
916
+ const clipped = clipRow(row, maxBytes);
917
+ if (clipped) {
918
+ lines.push(clipped);
919
+ used += utf8ByteLength(clipped);
920
+ clippedRows += 1;
921
+ index += 1;
922
+ }
923
+ }
924
+ break;
925
+ }
926
+ const shown = lines.length;
927
+ const remaining = Math.max(0, total - index);
928
+ const current = request.cursor && !reset ? request.cursor : undefined;
929
+ // The next page starts after the last row shown, or — when no row fit —
930
+ // at this page's own start, so a caller can always retry with a larger page.
931
+ const anchor = shown > 0 ? keyed[index - 1]!.key : start > 0 ? keyed[start - 1]!.key : HEAD_ROW_KEY;
932
+ const nextCursor = remaining > 0
933
+ ? encodeRowCursor({ k: "l", r: resource, t: anchor.time, i: anchor.id })
934
+ : undefined;
935
+ return {
936
+ text: lines.join("\n"),
937
+ hasMore: remaining > 0,
938
+ ...(current ? { cursor: current } : {}),
939
+ ...(nextCursor ? { nextCursor } : {}),
940
+ omittedBytes: 0,
941
+ omittedRows: remaining,
942
+ ...(reset ? { reset } : {}),
943
+ gaps: [],
944
+ shown,
945
+ total,
946
+ before: start,
947
+ remaining,
948
+ clippedRows,
949
+ };
950
+ }
951
+
952
+ // ---------------------------------------------------------------------------
953
+ // Priority envelope
954
+ // ---------------------------------------------------------------------------
955
+
956
+ function joinParts(parts: Array<string | undefined>): string {
957
+ return parts.filter((part): part is string => Boolean(part && part.length > 0)).join("\n");
958
+ }
959
+
960
+ function clipPrefix(text: string, maxBytes: number): { text: string; omittedBytes: number } {
961
+ const total = utf8ByteLength(text);
962
+ if (total <= maxBytes) return { text, omittedBytes: 0 };
963
+ if (maxBytes <= 0) return { text: "", omittedBytes: total };
964
+ const encoded = encoder.encode(text);
965
+ const range = sliceUtf8Range(encoded, 0, maxBytes, true, false);
966
+ return { text: decoder.decode(encoded.subarray(0, range.end)), omittedBytes: total - range.end };
967
+ }
968
+
969
+ function formatContinuation(info: {
970
+ page?: VerbatimPage;
971
+ gaps: EvidenceGap[];
972
+ omitted: EnvelopeOmission[];
973
+ statusCursor?: string;
974
+ }): string | undefined {
975
+ const lines: string[] = [];
976
+ const page = info.page;
977
+ if (page?.reset) lines.push(`reset=${page.reset}`);
978
+ if (page && (page.hasMore || page.omittedBytes > 0 || (page.omittedRows ?? 0) > 0)) {
979
+ const amount = page.omittedRows !== undefined
980
+ ? `omittedRows=${page.omittedRows}`
981
+ : `omittedBytes=${page.omittedBytes}`;
982
+ const cursor = page.nextCursor ? ` nextCursor=${page.nextCursor}${page.via ? ` (${page.via})` : ""}` : "";
983
+ lines.push(`hasMore=${page.hasMore} ${amount}${cursor}`);
984
+ } else if (page?.appendReady && page.nextCursor) {
985
+ lines.push(`end nextCursor=${page.nextCursor} (reuse to read only bytes appended later)`);
986
+ }
987
+ if (page?.pendingBytes) {
988
+ lines.push(`pendingBytes=${page.pendingBytes} (incomplete UTF-8 sequence withheld until the writer completes it)`);
989
+ }
990
+ for (const gap of info.gaps) {
991
+ const bytes = gap.bytes !== undefined ? ` bytes=${gap.bytes}` : "";
992
+ const detail = gap.detail ? ` detail=${singleLine(gap.detail)}` : "";
993
+ lines.push(`gap ${gap.kind}${bytes}${detail}`);
994
+ }
995
+ for (const item of info.omitted) {
996
+ lines.push(`omitted ${item.section} bytes=${item.omittedBytes}`);
997
+ }
998
+ if (info.statusCursor) lines.push(`statusCursor=${info.statusCursor}`);
999
+ if (lines.length === 0) return undefined;
1000
+ return ["---", ...lines].join("\n");
1001
+ }
1002
+
1003
+ /** Bytes always left for a function-valued failure section so its counts survive. */
1004
+ const FAILURE_SECTION_FLOOR = 256;
1005
+
1006
+ /**
1007
+ * Assemble one model-facing payload under a total UTF-8 byte cap.
1008
+ *
1009
+ * Budget priority: identity, decision facts (matched condition, stop error,
1010
+ * exit), continuation/gap metadata, then failures, diagnostics, the verbatim
1011
+ * page, and finally routine progress. `verbatimReserve` holds bytes back from
1012
+ * failures/diagnostics so an answer page always advances. The verbatim pager
1013
+ * receives the exact remaining budget and is never clipped afterwards, so
1014
+ * `nextCursor` always points at the first byte not shown (a zero budget yields
1015
+ * an empty page positioned at its start). Text order: identity (or failure
1016
+ * first with `failureFirst`), failure, decision, diagnostics, verbatim,
1017
+ * progress, continuation.
1018
+ */
1019
+ export function assemblePriorityEnvelope(input: {
1020
+ maxBytes: number;
1021
+ sections?: EnvelopeSections;
1022
+ verbatim?: (budget: number) => VerbatimPage;
1023
+ verbatimReserve?: number;
1024
+ gaps?: EvidenceGap[];
1025
+ /** Change-detection cursor, rendered with the continuation metadata. */
1026
+ statusCursor?: string;
1027
+ /** Render the failure section ahead of the identity line (background-task surfaces). */
1028
+ failureFirst?: boolean;
1029
+ }): AssembledEnvelope {
1030
+ const maxBytes = clampBudgetBytes(input.maxBytes, OUTPUT_BUDGET_BYTES.status);
1031
+ const sections = input.sections ?? {};
1032
+ const extraGaps = input.gaps ?? [];
1033
+ let continuationReserve = 0;
1034
+ let last: { text: string; omitted: EnvelopeOmission[]; page?: VerbatimPage; continuation?: string; gaps: EvidenceGap[] } | undefined;
1035
+
1036
+ for (let attempt = 0; attempt < 10; attempt += 1) {
1037
+ const omitted: EnvelopeOmission[] = [];
1038
+ const avail = Math.max(0, maxBytes - continuationReserve);
1039
+ let used = 0;
1040
+ let count = 0;
1041
+ const place = (name: EnvelopeSectionName, text: string | undefined, room: number): string | undefined => {
1042
+ if (!text) return undefined;
1043
+ const sep = count > 0 ? 1 : 0;
1044
+ const clipped = clipPrefix(text, room - sep);
1045
+ if (clipped.omittedBytes > 0) omitted.push({ section: name, omittedBytes: clipped.omittedBytes });
1046
+ if (!clipped.text) return undefined;
1047
+ used += sep + utf8ByteLength(clipped.text);
1048
+ count += 1;
1049
+ return clipped.text;
1050
+ };
1051
+ const identity = place("identity", sections.identity, avail - used);
1052
+ const decision = place("decision", sections.decision, avail - used);
1053
+ const failureInput = sections.failure;
1054
+ const failureFloor = failureInput ? Math.min(FAILURE_SECTION_FLOOR, Math.max(0, avail - used)) : 0;
1055
+ const reserve = input.verbatim
1056
+ ? Math.max(0, Math.min(Math.floor(input.verbatimReserve ?? 0), avail - used - failureFloor - 1))
1057
+ : 0;
1058
+ const failureRoom = avail - used - reserve;
1059
+ const failureText = typeof failureInput === "function"
1060
+ ? failureInput(Math.max(0, failureRoom - (count > 0 ? 1 : 0)))
1061
+ : failureInput;
1062
+ const failure = place("failure", failureText, failureRoom);
1063
+ const diagnostics = place("diagnostics", sections.diagnostics, avail - used - reserve);
1064
+ const verbatimBudget = Math.max(0, avail - used - (count > 0 ? 1 : 0));
1065
+ let page = input.verbatim?.(verbatimBudget);
1066
+ if (page && utf8ByteLength(page.text) > verbatimBudget) page = input.verbatim?.(0);
1067
+ const pageText = page?.text ? page.text : undefined;
1068
+ const gaps = [...extraGaps, ...(page?.gaps ?? [])];
1069
+ const body = input.failureFirst
1070
+ ? joinParts([failure, identity, decision, diagnostics, pageText])
1071
+ : joinParts([identity, failure, decision, diagnostics, pageText]);
1072
+ const bodyBytes = utf8ByteLength(body);
1073
+
1074
+ let progress: string | undefined;
1075
+ if (sections.progress) {
1076
+ // Size the continuation as if progress were clipped, so the omission
1077
+ // line it may add is already paid for.
1078
+ const worst = formatContinuation({
1079
+ page,
1080
+ gaps,
1081
+ omitted: [...omitted, { section: "progress", omittedBytes: utf8ByteLength(sections.progress) }],
1082
+ statusCursor: input.statusCursor,
1083
+ });
1084
+ const leftover = maxBytes - bodyBytes - (body ? 1 : 0) - (worst ? utf8ByteLength(worst) + 1 : 0);
1085
+ const clipped = clipPrefix(sections.progress, leftover);
1086
+ if (clipped.omittedBytes > 0) omitted.push({ section: "progress", omittedBytes: clipped.omittedBytes });
1087
+ progress = clipped.text || undefined;
1088
+ }
1089
+ const continuation = formatContinuation({ page, gaps, omitted, statusCursor: input.statusCursor });
1090
+ const text = joinParts([body, progress, continuation]);
1091
+ const size = utf8ByteLength(text);
1092
+ last = { text, omitted, page, continuation, gaps };
1093
+ if (size <= maxBytes) {
1094
+ return {
1095
+ text,
1096
+ byteLength: size,
1097
+ truncated: Boolean(page?.hasMore) || omitted.length > 0,
1098
+ omitted,
1099
+ verbatim: page,
1100
+ continuation,
1101
+ gaps,
1102
+ };
1103
+ }
1104
+ // Reserve at least the whole continuation block; grow further if the body
1105
+ // still overflows (for example when a smaller page changes the cursor).
1106
+ continuationReserve = Math.max(
1107
+ continuationReserve + (size - maxBytes),
1108
+ (continuation ? utf8ByteLength(continuation) + 1 : 0) + (progress ? utf8ByteLength(progress) + 1 : 0),
1109
+ );
1110
+ }
1111
+
1112
+ // Budgets smaller than the metadata itself: keep the continuation (the way
1113
+ // back to the evidence) ahead of any body text.
1114
+ const fallback = last ?? { text: "", omitted: [], gaps: extraGaps };
1115
+ const tail = fallback.continuation ?? "";
1116
+ const head = clipPrefix(sections.identity ?? "", Math.max(0, maxBytes - utf8ByteLength(tail) - 1)).text;
1117
+ const combined = clipPrefix(joinParts([head, tail]), maxBytes);
1118
+ return {
1119
+ text: combined.text,
1120
+ byteLength: utf8ByteLength(combined.text),
1121
+ truncated: true,
1122
+ omitted: [...fallback.omitted, ...(combined.omittedBytes > 0 ? [{ section: "continuation", omittedBytes: combined.omittedBytes }] : [])],
1123
+ verbatim: fallback.page,
1124
+ continuation: fallback.continuation,
1125
+ gaps: fallback.gaps,
1126
+ };
1127
+ }