@oh-my-pi/snapcompact 16.2.7 → 16.2.8

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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.2.8] - 2026-06-30
6
+
7
+ ### Fixed
8
+
9
+ - Fixed large snapcompact archives being reconstructed into unbounded per-request image payloads by adding a frame base64 byte budget and omitting over-budget archive frames from prompt blocks. ([#3792](https://github.com/can1357/oh-my-pi/issues/3792))
10
+
5
11
  ## [16.2.7] - 2026-06-30
6
12
 
7
13
  ### Added
@@ -317,6 +317,21 @@ export declare const HQ_EDGE_FRAMES = 3;
317
317
  * cap, billed at +5% margin (ceil(4784 * 1.05)). Keeps the overflow guard from
318
318
  * undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
319
319
  export declare const FRAME_TOKEN_ESTIMATE = 5024;
320
+ /** Conservative upper bound for one persisted frame's base64 payload. The
321
+ * measured high-res Anthropic `8x13`/`11on16` PNG frames sit around 159 KB;
322
+ * 170 KB leaves margin for denser glyph pages without permitting multi-MB
323
+ * standing request bodies at large context windows. */
324
+ export declare const FRAME_DATA_BYTES_ESTIMATE = 170000;
325
+ /** Maximum snapcompact image base64 carried in every rebuilt provider request.
326
+ * Above this, provider backends can accept the HTTP body but fail mid-stream
327
+ * with opaque 5xx errors. Keep this independent from visual-token budgeting:
328
+ * a 1M-token model can afford 70 images on paper, but not the resulting
329
+ * ~11 MB JSON payload on every turn. */
330
+ export declare const FRAME_DATA_BYTES_BUDGET = 3000000;
331
+ /** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET}. */
332
+ export declare function maxFramesForDataBudget(maxFrameDataBytes?: number): number;
333
+ /** Base64 byte length for persisted snapcompact frames. */
334
+ export declare function frameDataBytes(frames: readonly Pick<Frame, "data">[]): number;
320
335
  /**
321
336
  * Per-request image-count budgets by provider id. These cap how many images an
322
337
  * entire request may carry (archive/system-prompt/tool-result imaging combined).
@@ -543,13 +558,18 @@ export declare function stripPreservedArchive(preserveData: Record<string, unkno
543
558
  export declare function archiveSourceText(archive: Archive): string | undefined;
544
559
  /** Build the text used to choose and preflight a font-aware snapcompact shape. */
545
560
  export declare function renderabilityProbeText(serialized: string, previousPreserveData?: Record<string, unknown>, previousSummary?: string): string;
561
+ /** Options for reconstructing a persisted snapcompact archive into prompt blocks. */
562
+ export interface HistoryBlockOptions {
563
+ /** Hard cap on image base64 bytes attached to one rebuilt provider request. */
564
+ maxFrameDataBytes?: number;
565
+ }
546
566
  /** Convert archive frames into LLM image blocks (oldest first). */
547
567
  export declare function images(archive: Archive): ImageContent[];
548
568
  /** Ordered archive blocks for a compaction summary message, oldest to newest:
549
569
  * the oldest text region, the imaged middle, then the newest text region.
550
570
  * Runtime-only; reconstructed from {@link Archive} on each context rebuild
551
571
  * instead of persisted on the session entry. */
552
- export declare function historyBlocks(archive: Archive): (TextContent | ImageContent)[];
572
+ export declare function historyBlocks(archive: Archive, options?: HistoryBlockOptions): (TextContent | ImageContent)[];
553
573
  /**
554
574
  * Run a snapcompact compaction over prepared messages. Fully local: serializes
555
575
  * the discarded history, appends it to the accumulated archive source text, and
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/snapcompact",
4
- "version": "16.2.7",
4
+ "version": "16.2.8",
5
5
  "description": "Bitmap-frame context compression for vision-capable LLMs",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,10 +31,10 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-ai": "16.2.7",
35
- "@oh-my-pi/pi-natives": "16.2.7",
36
- "@oh-my-pi/pi-utils": "16.2.7",
37
- "@oh-my-pi/pi-wire": "16.2.7"
34
+ "@oh-my-pi/pi-ai": "16.2.8",
35
+ "@oh-my-pi/pi-natives": "16.2.8",
36
+ "@oh-my-pi/pi-utils": "16.2.8",
37
+ "@oh-my-pi/pi-wire": "16.2.8"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/bun": "^1.3.14"
@@ -424,6 +424,29 @@ export const HQ_EDGE_FRAMES = 3;
424
424
  * undercounting a high-res archive at the raised {@link MAX_FRAMES_DEFAULT}. */
425
425
  export const FRAME_TOKEN_ESTIMATE = 5024;
426
426
 
427
+ /** Conservative upper bound for one persisted frame's base64 payload. The
428
+ * measured high-res Anthropic `8x13`/`11on16` PNG frames sit around 159 KB;
429
+ * 170 KB leaves margin for denser glyph pages without permitting multi-MB
430
+ * standing request bodies at large context windows. */
431
+ export const FRAME_DATA_BYTES_ESTIMATE = 170_000;
432
+
433
+ /** Maximum snapcompact image base64 carried in every rebuilt provider request.
434
+ * Above this, provider backends can accept the HTTP body but fail mid-stream
435
+ * with opaque 5xx errors. Keep this independent from visual-token budgeting:
436
+ * a 1M-token model can afford 70 images on paper, but not the resulting
437
+ * ~11 MB JSON payload on every turn. */
438
+ export const FRAME_DATA_BYTES_BUDGET = 3_000_000;
439
+
440
+ /** Frame-count cap implied by {@link FRAME_DATA_BYTES_BUDGET}. */
441
+ export function maxFramesForDataBudget(maxFrameDataBytes: number = FRAME_DATA_BYTES_BUDGET): number {
442
+ return Math.max(1, Math.floor(maxFrameDataBytes / FRAME_DATA_BYTES_ESTIMATE));
443
+ }
444
+
445
+ /** Base64 byte length for persisted snapcompact frames. */
446
+ export function frameDataBytes(frames: readonly Pick<Frame, "data">[]): number {
447
+ return frames.reduce((sum, frame) => sum + frame.data.length, 0);
448
+ }
449
+
427
450
  /**
428
451
  * Per-request image-count budgets by provider id. These cap how many images an
429
452
  * entire request may carry (archive/system-prompt/tool-result imaging combined).
@@ -1542,6 +1565,54 @@ export function renderabilityProbeText(
1542
1565
  return serialized;
1543
1566
  }
1544
1567
 
1568
+ /** Options for reconstructing a persisted snapcompact archive into prompt blocks. */
1569
+ export interface HistoryBlockOptions {
1570
+ /** Hard cap on image base64 bytes attached to one rebuilt provider request. */
1571
+ maxFrameDataBytes?: number;
1572
+ }
1573
+
1574
+ function formatFrameDataBytes(bytes: number): string {
1575
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
1576
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
1577
+ return `${bytes} B`;
1578
+ }
1579
+
1580
+ function imagesWithinBudget(
1581
+ archive: Archive,
1582
+ maxFrameDataBytes: number | undefined,
1583
+ ): { images: ImageContent[]; omittedFrames: number; omittedBytes: number } {
1584
+ if (maxFrameDataBytes === undefined) {
1585
+ return { images: images(archive), omittedFrames: 0, omittedBytes: 0 };
1586
+ }
1587
+
1588
+ let usedBytes = 0;
1589
+ let omittedFrames = 0;
1590
+ let omittedBytes = 0;
1591
+ const keptNewestFirst: Frame[] = [];
1592
+ for (let index = archive.frames.length - 1; index >= 0; index--) {
1593
+ const frame = archive.frames[index];
1594
+ if (!frame) continue;
1595
+ const bytes = frame.data.length;
1596
+ if (usedBytes + bytes > maxFrameDataBytes) {
1597
+ omittedFrames++;
1598
+ omittedBytes += bytes;
1599
+ continue;
1600
+ }
1601
+ usedBytes += bytes;
1602
+ keptNewestFirst.push(frame);
1603
+ }
1604
+ keptNewestFirst.reverse();
1605
+ return { images: images({ ...archive, frames: keptNewestFirst }), omittedFrames, omittedBytes };
1606
+ }
1607
+
1608
+ function omittedFrameNotice(omittedFrames: number, omittedBytes: number): string {
1609
+ return [
1610
+ "-------------- snapcompact image middle omitted",
1611
+ `${omittedFrames.toLocaleString()} archived image frame${omittedFrames === 1 ? "" : "s"} (${formatFrameDataBytes(omittedBytes)} base64) exceeded the per-request snapcompact payload budget. The compacted summary and visible text edges remain available.`,
1612
+ "--------------",
1613
+ ].join("\n");
1614
+ }
1615
+
1545
1616
  /** Convert archive frames into LLM image blocks (oldest first). */
1546
1617
  export function images(archive: Archive): ImageContent[] {
1547
1618
  return archive.frames.map(frame => ({
@@ -1555,23 +1626,38 @@ export function images(archive: Archive): ImageContent[] {
1555
1626
  * the oldest text region, the imaged middle, then the newest text region.
1556
1627
  * Runtime-only; reconstructed from {@link Archive} on each context rebuild
1557
1628
  * instead of persisted on the session entry. */
1558
- export function historyBlocks(archive: Archive): (TextContent | ImageContent)[] {
1629
+ export function historyBlocks(archive: Archive, options: HistoryBlockOptions = {}): (TextContent | ImageContent)[] {
1559
1630
  const blocks: (TextContent | ImageContent)[] = [];
1560
- const hasImages = archive.frames.length > 0;
1631
+ const budgeted = imagesWithinBudget(archive, options.maxFrameDataBytes);
1632
+ const hasImages = budgeted.images.length > 0;
1633
+ const hasOmittedImages = budgeted.omittedFrames > 0;
1561
1634
  if (archive.textHead) {
1562
- const suffix = hasImages ? "\n-------------- imaged middle below\n" : "";
1635
+ const suffix = hasImages
1636
+ ? "\n-------------- imaged middle below\n"
1637
+ : hasOmittedImages
1638
+ ? `\n${omittedFrameNotice(budgeted.omittedFrames, budgeted.omittedBytes)}\n`
1639
+ : "";
1563
1640
  blocks.push({ type: "text", text: toPlainText(archive.textHead) + suffix });
1641
+ } else if (hasOmittedImages && !hasImages) {
1642
+ blocks.push({ type: "text", text: omittedFrameNotice(budgeted.omittedFrames, budgeted.omittedBytes) });
1643
+ }
1644
+ // Omitted frames are the OLDEST archived images: the byte budget keeps the
1645
+ // newest tail frames, so the gap notice precedes the kept images to keep the
1646
+ // reconstructed blocks oldest-to-newest.
1647
+ if (hasImages && hasOmittedImages) {
1648
+ blocks.push({ type: "text", text: omittedFrameNotice(budgeted.omittedFrames, budgeted.omittedBytes) });
1564
1649
  }
1565
- blocks.push(...images(archive));
1650
+ blocks.push(...budgeted.images);
1566
1651
  if (archive.textTail) {
1567
1652
  const prefix = hasImages
1568
1653
  ? "-------------- imaged middle above\n"
1569
- : archive.truncatedChars > 0
1654
+ : archive.truncatedChars > 0 || hasOmittedImages
1570
1655
  ? "\n-------------- middle history omitted above\n"
1571
1656
  : "";
1572
1657
  const tail = prefix + toPlainText(archive.textTail);
1573
- if (blocks.length > 0 && blocks[blocks.length - 1]?.type === "text") {
1574
- (blocks[blocks.length - 1] as TextContent).text += tail;
1658
+ const lastBlock = blocks[blocks.length - 1];
1659
+ if (lastBlock?.type === "text") {
1660
+ lastBlock.text += tail;
1575
1661
  } else {
1576
1662
  blocks.push({ type: "text", text: tail });
1577
1663
  }