@oh-my-pi/snapcompact 16.1.7 → 16.1.9

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.1.8] - 2026-06-20
6
+
7
+ ### Breaking Changes
8
+
9
+ - Changed core rendering functions `render` and `renderMany` to be asynchronous
10
+
5
11
  ## [16.1.0] - 2026-06-19
6
12
 
7
13
  ### Added
@@ -496,7 +496,7 @@ export declare function wrap(text: string, width: number): string[];
496
496
  export declare function geometry(shape: Shape, size?: number): Geometry;
497
497
  /** Render one snapcompact frame from already-normalized text. Doc shapes
498
498
  * (`columns === 2`) expect one page of `\n`-joined pre-wrapped lines. */
499
- export declare function render(text: string, shape: Shape, size?: number): RenderedFrame;
499
+ export declare function render(text: string, shape: Shape, size?: number): Promise<RenderedFrame>;
500
500
  /** Options for {@link renderMany} and {@link frames}. */
501
501
  export interface RenderManyOptions {
502
502
  /** Explicit shape; wins over `model`. */
@@ -510,10 +510,9 @@ export interface RenderManyOptions {
510
510
  }
511
511
  /**
512
512
  * Render arbitrary text into snapcompact PNG frames as LLM image blocks
513
- * (first page first). Synchronous: safe to call from per-request transforms.
514
- * Empty/whitespace-only input yields no frames.
513
+ * (first page first). Empty/whitespace-only input yields no frames.
515
514
  */
516
- export declare function renderMany(text: string, options?: RenderManyOptions): ImageContent[];
515
+ export declare function renderMany(text: string, options?: RenderManyOptions): Promise<ImageContent[]>;
517
516
  /** Frames needed to hold `text` at the given shape/size, without rendering.
518
517
  * For doc shapes this wraps the text once and counts pages of `2 * rows`
519
518
  * lines; for grid shapes it divides by the frame capacity. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/snapcompact",
4
- "version": "16.1.7",
4
+ "version": "16.1.9",
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.1.7",
35
- "@oh-my-pi/pi-natives": "16.1.7",
36
- "@oh-my-pi/pi-utils": "16.1.7",
37
- "@oh-my-pi/pi-wire": "16.1.7"
34
+ "@oh-my-pi/pi-ai": "16.1.9",
35
+ "@oh-my-pi/pi-natives": "16.1.9",
36
+ "@oh-my-pi/pi-utils": "16.1.9",
37
+ "@oh-my-pi/pi-wire": "16.1.9"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/bun": "^1.3.14"
@@ -1151,15 +1151,8 @@ export function geometry(shape: Shape, size: number = shape.frameSize): Geometry
1151
1151
 
1152
1152
  const NEWLINES = /\n/g;
1153
1153
 
1154
- /** Render one snapcompact frame from already-normalized text. Doc shapes
1155
- * (`columns === 2`) expect one page of `\n`-joined pre-wrapped lines. */
1156
- export function render(text: string, shape: Shape, size: number = shape.frameSize): RenderedFrame {
1157
- const { cols, rows, capacity } = geometry(shape, size);
1158
- let visible = text.length - (text.match(DIM_MARKERS)?.length ?? 0);
1159
- // Doc line separators consume no cell; in the grid they print as a blank.
1160
- if (shape.columns === 2) visible -= text.match(NEWLINES)?.length ?? 0;
1161
- const chars = Math.min(visible, capacity);
1162
- const data = renderSnapcompactPng(text, {
1154
+ function nativeRenderOptions(shape: Shape, size: number) {
1155
+ return {
1163
1156
  size,
1164
1157
  font: shape.font,
1165
1158
  cellWidth: shape.cellWidth,
@@ -1168,7 +1161,22 @@ export function render(text: string, shape: Shape, size: number = shape.frameSiz
1168
1161
  variant: shape.variant,
1169
1162
  lineRepeat: shape.lineRepeat,
1170
1163
  columns: shape.columns,
1171
- });
1164
+ };
1165
+ }
1166
+
1167
+ function renderedChars(text: string, shape: Shape, capacity: number): number {
1168
+ let visible = text.length - (text.match(DIM_MARKERS)?.length ?? 0);
1169
+ // Doc line separators consume no cell; in the grid they print as a blank.
1170
+ if (shape.columns === 2) visible -= text.match(NEWLINES)?.length ?? 0;
1171
+ return Math.min(visible, capacity);
1172
+ }
1173
+
1174
+ /** Render one snapcompact frame from already-normalized text. Doc shapes
1175
+ * (`columns === 2`) expect one page of `\n`-joined pre-wrapped lines. */
1176
+ export async function render(text: string, shape: Shape, size: number = shape.frameSize): Promise<RenderedFrame> {
1177
+ const { cols, rows, capacity } = geometry(shape, size);
1178
+ const chars = renderedChars(text, shape, capacity);
1179
+ const data = await renderSnapcompactPng(text, nativeRenderOptions(shape, size));
1172
1180
  return { data, cols, rows, chars };
1173
1181
  }
1174
1182
 
@@ -1198,38 +1206,39 @@ export interface RenderManyOptions {
1198
1206
 
1199
1207
  /**
1200
1208
  * Render arbitrary text into snapcompact PNG frames as LLM image blocks
1201
- * (first page first). Synchronous: safe to call from per-request transforms.
1202
- * Empty/whitespace-only input yields no frames.
1209
+ * (first page first). Empty/whitespace-only input yields no frames.
1203
1210
  */
1204
- export function renderMany(text: string, options?: RenderManyOptions): ImageContent[] {
1211
+ export async function renderMany(text: string, options?: RenderManyOptions): Promise<ImageContent[]> {
1205
1212
  const shape = options?.shape ?? resolveShape(options?.model);
1206
1213
  const frameSize = options?.frameSize ?? shape.frameSize;
1207
1214
  const geo = geometry(shape, frameSize);
1208
1215
  const normalized = normalize(text);
1209
- const frames: ImageContent[] = [];
1210
- const push = (rendered: RenderedFrame): void => {
1211
- frames.push({
1212
- type: "image",
1213
- data: rendered.data,
1214
- mimeType: "image/png",
1215
- ...(shape.imageDetail ? { detail: shape.imageDetail } : {}),
1216
- });
1217
- };
1216
+ const cap = options?.maxFrames;
1217
+ // Build the per-frame texts in order first (cheap, synchronous), then fan
1218
+ // the native PNG renders out concurrently — render() is async/off-thread,
1219
+ // so awaiting each before starting the next leaves throughput on the table.
1220
+ const pageTexts: string[] = [];
1218
1221
  if (shape.columns === 2) {
1219
1222
  const finish = pageFinisher(shape);
1220
1223
  for (const page of docPages(normalized, geo)) {
1221
- if (options?.maxFrames !== undefined && frames.length >= options.maxFrames) break;
1222
- push(render(finish(page), shape, frameSize));
1224
+ if (cap !== undefined && pageTexts.length >= cap) break;
1225
+ pageTexts.push(finish(page));
1226
+ }
1227
+ } else {
1228
+ for (let offset = 0; offset < normalized.length; offset += geo.capacity) {
1229
+ if (cap !== undefined && pageTexts.length >= cap) break;
1230
+ let chunk = normalized.slice(offset, offset + geo.capacity);
1231
+ if (shape.stopwordDim) chunk = dimStopwords(chunk);
1232
+ pageTexts.push(chunk);
1223
1233
  }
1224
- return frames;
1225
- }
1226
- for (let offset = 0; offset < normalized.length; offset += geo.capacity) {
1227
- if (options?.maxFrames !== undefined && frames.length >= options.maxFrames) break;
1228
- let chunk = normalized.slice(offset, offset + geo.capacity);
1229
- if (shape.stopwordDim) chunk = dimStopwords(chunk);
1230
- push(render(chunk, shape, frameSize));
1231
1234
  }
1232
- return frames;
1235
+ const rendered = await Promise.all(pageTexts.map(page => render(page, shape, frameSize)));
1236
+ return rendered.map(frame => ({
1237
+ type: "image",
1238
+ data: frame.data,
1239
+ mimeType: "image/png",
1240
+ ...(shape.imageDetail ? { detail: shape.imageDetail } : {}),
1241
+ }));
1233
1242
  }
1234
1243
 
1235
1244
  /** Frames needed to hold `text` at the given shape/size, without rendering.
@@ -1482,7 +1491,13 @@ export async function compact<TMessage = Message>(
1482
1491
  let archiveText = normalize(serializeConversation(llmMessages, options));
1483
1492
 
1484
1493
  const previousArchive = getPreservedArchive(previousPreserveData);
1485
- const includedPreviousSummary = !previousArchive && !!previousSummary;
1494
+ const previousText =
1495
+ previousArchive?.text ??
1496
+ [previousArchive?.textHead, previousArchive?.textTail]
1497
+ .filter((part): part is string => typeof part === "string" && part.length > 0)
1498
+ .join(NEWLINE_GLYPH);
1499
+ const hasPreviousText = previousText.length > 0;
1500
+ const includedPreviousSummary = !hasPreviousText && !!previousSummary;
1486
1501
  if (includedPreviousSummary && previousSummary) {
1487
1502
  const head = `[Summary of earlier history] ${normalize(previousSummary)}`;
1488
1503
  archiveText = archiveText.length > 0 ? `${head} [Recent conversation] ${archiveText}` : head;
@@ -1493,8 +1508,7 @@ export async function compact<TMessage = Message>(
1493
1508
  // Re-compacting a snapcompacted history unfolds the prior archive's source
1494
1509
  // text and treats it as one coherent transcript: the previous kept source
1495
1510
  // ages in ahead of the new history, then the whole thing is re-rendered.
1496
- const previousText = previousArchive?.text;
1497
- if (previousText) {
1511
+ if (hasPreviousText) {
1498
1512
  archiveText = archiveText.length > 0 ? `${previousText}${NEWLINE_GLYPH}${archiveText}` : previousText;
1499
1513
  }
1500
1514
 
@@ -1504,34 +1518,33 @@ export async function compact<TMessage = Message>(
1504
1518
  // Re-render the planned frames, carrying any open dim span across every
1505
1519
  // boundary: textHead → frames → textTail.
1506
1520
  let dimOpen = layout.textHead.lastIndexOf(DIM_ON) > layout.textHead.lastIndexOf(DIM_OFF);
1507
- const newFrames: Frame[] = [];
1521
+ const newFrames: Promise<Frame>[] = [];
1508
1522
  for (const planned of layout.frames) {
1509
1523
  let pageText: string = dimOpen ? DIM_ON + planned.text : planned.text;
1510
1524
  dimOpen = pageText.lastIndexOf(DIM_ON) > pageText.lastIndexOf(DIM_OFF);
1511
1525
  if (planned.shape.stopwordDim) pageText = dimStopwords(pageText);
1512
- const rendered = render(pageText, planned.shape);
1513
- newFrames.push({
1514
- data: rendered.data,
1515
- mimeType: "image/png",
1516
- cols: rendered.cols,
1517
- rows: rendered.rows,
1518
- chars: rendered.chars,
1519
- font: planned.shape.font,
1520
- variant: planned.shape.variant,
1521
- lineRepeat: planned.shape.lineRepeat,
1522
- ...(planned.shape.columns === 2 ? { columns: 2 } : {}),
1523
- ...(planned.shape.stopwordDim ? { stopwordDim: true } : {}),
1524
- ...(planned.shape.imageDetail ? { detail: planned.shape.imageDetail } : {}),
1525
- });
1526
- // Keep the event loop responsive between native render passes.
1527
- await Bun.sleep(0);
1526
+ newFrames.push(
1527
+ render(pageText, planned.shape).then(rendered => ({
1528
+ data: rendered.data,
1529
+ mimeType: "image/png",
1530
+ cols: rendered.cols,
1531
+ rows: rendered.rows,
1532
+ chars: rendered.chars,
1533
+ font: planned.shape.font,
1534
+ variant: planned.shape.variant,
1535
+ lineRepeat: planned.shape.lineRepeat,
1536
+ ...(planned.shape.columns === 2 ? { columns: 2 } : {}),
1537
+ ...(planned.shape.stopwordDim ? { stopwordDim: true } : {}),
1538
+ ...(planned.shape.imageDetail ? { detail: planned.shape.imageDetail } : {}),
1539
+ })),
1540
+ );
1528
1541
  }
1529
1542
 
1530
1543
  const textHead = layout.textHead;
1531
1544
  const textTail = layout.textTail.length > 0 ? (dimOpen ? DIM_ON : "") + layout.textTail : "";
1532
1545
  const textChars = textHead.length + textTail.length;
1533
1546
 
1534
- const frames = newFrames;
1547
+ const frames = await Promise.all(newFrames);
1535
1548
  const totalChars = frames.reduce((sum, frame) => sum + frame.chars, 0) + textChars;
1536
1549
  const mixedShapes = frames.some(
1537
1550
  frame =>