@oh-my-pi/snapcompact 17.4.0 → 17.4.2

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.
@@ -344,6 +344,8 @@ export declare const PROVIDER_IMAGE_BUDGETS: Record<string, number>;
344
344
  export declare const DEFAULT_PROVIDER_IMAGE_BUDGET = 5;
345
345
  /** Per-request image budget for `provider`; unknown providers get the floor. */
346
346
  export declare function providerImageBudget(provider: string | undefined): number;
347
+ /** Archive frame cap for `provider`: image budget, never above {@link MAX_FRAMES_DEFAULT}. */
348
+ export declare function providerFrameBudget(provider: string | undefined): number;
347
349
  /** Key under `CompactionEntry.preserveData` holding the frame archive. */
348
350
  export declare const PRESERVE_KEY = "snapcompact";
349
351
  /** One developed snapcompact frame: a base64 PNG plus its reading geometry. */
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/snapcompact",
4
- "version": "17.4.0",
4
+ "version": "17.4.2",
5
5
  "description": "Bitmap-frame context compression for vision-capable LLMs",
6
6
  "homepage": "https://omp.sh",
7
- "author": "Can Boluk",
7
+ "author": "Stencil Labs, Inc.",
8
8
  "license": "MIT",
9
9
  "repository": {
10
10
  "type": "git",
@@ -31,11 +31,11 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-ai": "17.4.0",
35
- "@oh-my-pi/pi-catalog": "17.4.0",
36
- "@oh-my-pi/pi-natives": "17.4.0",
37
- "@oh-my-pi/pi-utils": "17.4.0",
38
- "@oh-my-pi/pi-wire": "17.4.0"
34
+ "@oh-my-pi/pi-ai": "17.4.2",
35
+ "@oh-my-pi/pi-catalog": "17.4.2",
36
+ "@oh-my-pi/pi-natives": "17.4.2",
37
+ "@oh-my-pi/pi-utils": "17.4.2",
38
+ "@oh-my-pi/pi-wire": "17.4.2"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/bun": "^1.3.14"
@@ -47,6 +47,8 @@
47
47
  "src",
48
48
  "README.md",
49
49
  "CHANGELOG.md",
50
+ "LICENSE",
51
+ "THIRD-PARTY-NOTICES.txt",
50
52
  "dist/types"
51
53
  ],
52
54
  "exports": {
@@ -519,6 +519,11 @@ export function providerImageBudget(provider: string | undefined): number {
519
519
  return (provider !== undefined ? PROVIDER_IMAGE_BUDGETS[provider] : undefined) ?? DEFAULT_PROVIDER_IMAGE_BUDGET;
520
520
  }
521
521
 
522
+ /** Archive frame cap for `provider`: image budget, never above {@link MAX_FRAMES_DEFAULT}. */
523
+ export function providerFrameBudget(provider: string | undefined): number {
524
+ return Math.min(providerImageBudget(provider), MAX_FRAMES_DEFAULT);
525
+ }
526
+
522
527
  /** Key under `CompactionEntry.preserveData` holding the frame archive. */
523
528
  export const PRESERVE_KEY = "snapcompact";
524
529
 
@@ -775,6 +780,132 @@ function truncateForSummary(text: string, maxChars: number, headRatio: number):
775
780
  return `${text.slice(0, headChars)} […${elided}ch elided…] ${tail}`;
776
781
  }
777
782
 
783
+ /** One elision marker as emitted by {@link truncateForSummary} (Unicode
784
+ * ellipses) or as persisted after `normalize()` (ASCII dots). */
785
+ const ELIDED_MARKER = String.raw`\[(?:…|\.{3})\d+ch elided(?:…|\.{3})\]`;
786
+
787
+ /** Unquoted RFC 2045 token used as a media-type parameter name or value.
788
+ * Quoted-string values (RFC 822) are out of scope. */
789
+ const MEDIA_TYPE_TOKEN = String.raw`[\w!#$%&'*+.^|~-]+`;
790
+
791
+ /** An inline base64 data URL. The payload may be empty or carry one embedded
792
+ * elision marker so fragments left by pre-guard slices — including a cut
793
+ * landing exactly on `;base64,` — still match. RFC 2397 allows `*( ";" parameter )`
794
+ * between type/subtype and the terminal `;base64`; unquoted tokens are matched,
795
+ * quoted-string values are out of scope. `data:` and `base64` match
796
+ * case-insensitively (`gi`). Matching starts at `data:`; Markdown wrappers are
797
+ * recovered by {@link adjacentMarkdownOpenerStart} after each hit. */
798
+ const DATA_URL_ATOM = new RegExp(
799
+ String.raw`data:([A-Za-z][\w.+-]*\/[\w.+-]+(?:;${MEDIA_TYPE_TOKEN}=${MEDIA_TYPE_TOKEN})*);base64,` +
800
+ String.raw`([A-Za-z0-9+/=]*(?:\s*${ELIDED_MARKER}\s*[A-Za-z0-9+/=]*)?)` +
801
+ String.raw`(\s*\))?`,
802
+ "gi",
803
+ );
804
+
805
+ const ELIDED_MARKER_RE = new RegExp(String.raw`\s*${ELIDED_MARKER}\s*`);
806
+ const MARKDOWN_WHITESPACE_CHAR = /\s/;
807
+
808
+ /** Canonical base64: 4-char groups with valid terminal padding. */
809
+ const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/;
810
+
811
+ /** A non-canonical payload at least this long is a damaged fragment of a real
812
+ * data URL (e.g. an archive head cut mid-payload by a structure-blind slice),
813
+ * not a prose mention like `data:image/png;base64,abc`. */
814
+ const DAMAGED_PAYLOAD_MIN_CHARS = 40;
815
+
816
+ /** Context for {@link elideDataUrls}. `source` text is intact (never sliced),
817
+ * so a short non-canonical payload is a prose mention and stays untouched.
818
+ * `archive` text may have been cut by pre-guard structure-blind slices at
819
+ * any offset — even 0–39 chars past `;base64,` — so every recognized prefix
820
+ * is suspect and is always elided. */
821
+ type DataUrlContext = "source" | "archive";
822
+
823
+ /** Start of `!?[label](\s*` immediately before `dataIndex`, or `undefined`.
824
+ * The opener must lie in `[cursor, dataIndex)`. Nested `[` in the label is
825
+ * kept (the old `[^\]\n]*` class allowed it) by taking the earliest `[` after
826
+ * a prior `]`, newline, or `cursor`; the scan never walks already-emitted
827
+ * text, so repeated `](data:...)` stays linear. */
828
+ function adjacentMarkdownOpenerStart(text: string, dataIndex: number, cursor: number): number | undefined {
829
+ let i = dataIndex;
830
+ while (i > cursor && MARKDOWN_WHITESPACE_CHAR.test(text.charAt(i - 1))) i--;
831
+ // `](` and any following whitespace must sit in [cursor, dataIndex).
832
+ if (i - 2 < cursor || text.charAt(i - 1) !== "(" || text.charAt(i - 2) !== "]") return undefined;
833
+ let opener = -1;
834
+ for (let j = i - 3; j >= cursor; j--) {
835
+ const c = text.charAt(j);
836
+ if (c === "]" || c === "\n") break;
837
+ if (c === "[") opener = j;
838
+ }
839
+ if (opener < 0) return undefined;
840
+ return opener > cursor && text.charAt(opener - 1) === "!" ? opener - 1 : opener;
841
+ }
842
+
843
+ /** Replace every inline base64 data URL atomically with a deterministic
844
+ * placeholder. A character cap that slices inside a base64 payload leaves a
845
+ * recognizable image reference that can never decode; OpenAI-dialect
846
+ * providers reject such requests as invalid image input, and because the
847
+ * corrupted text persists in the archive the session re-fails on every later
848
+ * request. The payload is worthless to a model as text, so the whole atom —
849
+ * Markdown wrapper included — collapses to its metadata. Payloads already
850
+ * carrying an elision marker, and non-canonical fragments left by pre-guard
851
+ * slices, are healed the same way.
852
+ *
853
+ * The placeholder's `<mime>` is the media type as written: type/subtype plus
854
+ * any unquoted RFC 2397 `;parameter=value` segments, original case preserved.
855
+ * Parameters are kept rather than stripped to a bare type/subtype so charset
856
+ * (and similar) remain visible after elision and the label stays a pure
857
+ * function of the captured text. */
858
+ function elideDataUrls(text: string, context: DataUrlContext = "source"): string {
859
+ if (!/;base64,/i.test(text)) return text;
860
+ DATA_URL_ATOM.lastIndex = 0;
861
+ let match = DATA_URL_ATOM.exec(text);
862
+ if (match === null) return text;
863
+ const out: string[] = [];
864
+ let cursor = 0;
865
+ while (match !== null) {
866
+ const urlStart = match.index;
867
+ const urlEnd = urlStart + match[0].length;
868
+ const mime = match[1] ?? "";
869
+ const payload = match[2] ?? "";
870
+ const closer = match[3];
871
+ const marker = ELIDED_MARKER_RE.exec(payload);
872
+ const isAtom =
873
+ context === "archive" ||
874
+ marker !== null ||
875
+ CANONICAL_BASE64.test(payload) ||
876
+ payload.length >= DAMAGED_PAYLOAD_MIN_CHARS;
877
+ if (!isAtom) {
878
+ // Advance through short prose too, so a later wrapper cannot swallow
879
+ // a data URL already copied out of its Markdown label.
880
+ out.push(text.slice(cursor, urlEnd));
881
+ cursor = urlEnd;
882
+ } else {
883
+ const b64Chars = marker
884
+ ? payload.length - marker[0].length + Number(/\d+/.exec(marker[0])?.[0] ?? 0)
885
+ : payload.length;
886
+ const placeholder = `[data URL omitted: ${mime}, ${b64Chars} base64 chars]`;
887
+ const foundOpener = adjacentMarkdownOpenerStart(text, urlStart, cursor);
888
+ const openerStart = foundOpener !== undefined && foundOpener >= cursor ? foundOpener : undefined;
889
+ const emitStart = openerStart ?? urlStart;
890
+ out.push(text.slice(cursor, emitStart));
891
+ // Swallow the Markdown wrapper only when both delimiters matched;
892
+ // otherwise re-emit whichever half was captured untouched. An opener
893
+ // that starts before the already-emitted cursor would overlap a prior
894
+ // replacement, so that URL is treated as bare.
895
+ if (openerStart !== undefined && closer !== undefined) {
896
+ out.push(placeholder);
897
+ } else {
898
+ const opener = openerStart !== undefined ? text.slice(openerStart, urlStart) : "";
899
+ out.push(opener, placeholder, closer ?? "");
900
+ }
901
+ cursor = urlEnd;
902
+ }
903
+ match = DATA_URL_ATOM.exec(text);
904
+ }
905
+ out.push(text.slice(cursor));
906
+ return out.join("");
907
+ }
908
+
778
909
  const DIM_MARKERS = /[\u000e\u000f]/g;
779
910
 
780
911
  /** Plain-text history kept verbatim at each chronological edge, in HQ-frame-
@@ -836,7 +967,7 @@ export function serializeConversation(messages: Message[], options?: SerializeOp
836
967
  // Wrap a raw tool-result body in an `<out>` block, dimming only the body so
837
968
  // the frame coloring keeps scope markers and calls loud.
838
969
  const renderResultBlock = (rawText: string): string => {
839
- const body = truncateForSummary(stripDimMarkers(rawText), toolResultMaxChars, headRatio);
970
+ const body = truncateForSummary(elideDataUrls(stripDimMarkers(rawText)), toolResultMaxChars, headRatio);
840
971
  return `<out>\n${dimToolResults ? `${DIM_ON}${body}${DIM_OFF}` : body}\n</out>`;
841
972
  };
842
973
 
@@ -894,7 +1025,7 @@ export function serializeConversation(messages: Message[], options?: SerializeOp
894
1025
  .filter(([key]) => key !== INTENT_FIELD)
895
1026
  .map(
896
1027
  ([key, value]) =>
897
- `${key}=${truncateForSummary(JSON.stringify(value) ?? "undefined", toolArgMaxChars, headRatio)}`,
1028
+ `${key}=${truncateForSummary(elideDataUrls(JSON.stringify(value) ?? "undefined"), toolArgMaxChars, headRatio)}`,
898
1029
  )
899
1030
  .join(", "),
900
1031
  toolCallMaxChars,
@@ -1616,7 +1747,7 @@ export function archiveSourceText(archive: Archive): string | undefined {
1616
1747
  [archive.textHead, archive.textTail]
1617
1748
  .filter((part): part is string => typeof part === "string" && part.length > 0)
1618
1749
  .join(NEWLINE_GLYPH);
1619
- return text.length > 0 ? toPlainText(text) : undefined;
1750
+ return text.length > 0 ? elideDataUrls(toPlainText(text), "archive") : undefined;
1620
1751
  }
1621
1752
 
1622
1753
  /** Build the text used to choose and preflight a font-aware snapcompact shape. */
@@ -1704,7 +1835,7 @@ export function historyBlocks(archive: Archive, options: HistoryBlockOptions = {
1704
1835
  : hasOmittedImages
1705
1836
  ? `\n${omittedFrameNotice(budgeted.omittedFrames, budgeted.omittedBytes)}\n`
1706
1837
  : "";
1707
- blocks.push({ type: "text", text: toPlainText(archive.textHead) + suffix });
1838
+ blocks.push({ type: "text", text: elideDataUrls(toPlainText(archive.textHead), "archive") + suffix });
1708
1839
  } else if (hasOmittedImages && !hasImages) {
1709
1840
  blocks.push({ type: "text", text: omittedFrameNotice(budgeted.omittedFrames, budgeted.omittedBytes) });
1710
1841
  }
@@ -1721,7 +1852,7 @@ export function historyBlocks(archive: Archive, options: HistoryBlockOptions = {
1721
1852
  : archive.truncatedChars > 0 || hasOmittedImages
1722
1853
  ? "\n-------------- middle history omitted above\n"
1723
1854
  : "";
1724
- const tail = prefix + toPlainText(archive.textTail);
1855
+ const tail = prefix + elideDataUrls(toPlainText(archive.textTail), "archive");
1725
1856
  const lastBlock = blocks[blocks.length - 1];
1726
1857
  if (lastBlock?.type === "text") {
1727
1858
  lastBlock.text += tail;
@@ -1917,11 +2048,14 @@ export async function compact<TMessage = Message>(
1917
2048
  .join(NEWLINE_GLYPH);
1918
2049
  // Legacy archives may carry `¶think:` sections from before includeThinking
1919
2050
  // existed; scrub them when this compaction excludes thinking so the
1920
- // re-rendered archive stops replaying reasoning (issue #6093).
2051
+ // re-rendered archive stops replaying reasoning (issue #6093). They may
2052
+ // also carry data URLs a pre-guard slice cut at any offset; heal those in
2053
+ // archive context before the text is folded into the new source.
2054
+ const previousTextHealed = elideDataUrls(previousTextRaw, "archive");
1921
2055
  const previousText =
1922
- options?.includeThinking === false && previousTextRaw.length > 0
1923
- ? stripThinkingSections(previousTextRaw)
1924
- : previousTextRaw;
2056
+ options?.includeThinking === false && previousTextHealed.length > 0
2057
+ ? stripThinkingSections(previousTextHealed)
2058
+ : previousTextHealed;
1925
2059
  const hasPreviousText = previousText.length > 0;
1926
2060
  const includedPreviousSummary = !hasPreviousText && !!previousSummary;
1927
2061
  const shapeProbeText = renderabilityProbeText(serialized, previousPreserveData, previousSummary);
@@ -1949,6 +2083,12 @@ export async function compact<TMessage = Message>(
1949
2083
  if (hasPreviousText) {
1950
2084
  archiveText = archiveText.length > 0 ? `${previousText}${NEWLINE_GLYPH}${archiveText}` : previousText;
1951
2085
  }
2086
+ // Data URLs must never reach planArchive: its edge slices are structure-
2087
+ // blind, and a split payload replays as broken image input on every later
2088
+ // request. previousText is already strictly healed above; this source-mode
2089
+ // pass covers intact URLs in fresh user/assistant text, which the
2090
+ // serializer never truncates.
2091
+ archiveText = elideDataUrls(archiveText);
1952
2092
 
1953
2093
  const layout = planArchive(archiveText, high, low, maxFrames);
1954
2094
  truncatedChars += layout.truncatedChars;