@oh-my-pi/snapcompact 18.10.3 → 18.11.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.10.4] - 2026-10-13
6
+
7
+ ### Added
8
+
9
+ - Added `viewWithoutThinking()`, `textWithoutThinking()`, `readerIncludesThinking()` and an optional `Archive.withoutThinking` variant, so an archive rendered with reasoning can be read by a model that must not see it; without a stored variant the view keeps only reasoning-free text and no frames ([#15436](https://github.com/can1357/oh-my-pi/pull/15436) by [@will-bogusz](https://github.com/will-bogusz))
10
+
11
+ ### Fixed
12
+
13
+ - Fixed re-rendering an archive without reasoning keeping every paragraph after the first of a multi-paragraph `¶think:` section ([#15436](https://github.com/can1357/oh-my-pi/pull/15436) by [@will-bogusz](https://github.com/will-bogusz))
14
+
5
15
  ## [18.10.3] - 2026-10-12
6
16
 
7
17
  ### Breaking Changes
@@ -431,6 +431,15 @@ export interface Archive {
431
431
  textHead?: string;
432
432
  /** Newest text region kept verbatim around the imaged middle. */
433
433
  textTail?: string;
434
+ /** The same history re-rendered for a reader that excludes assistant
435
+ * reasoning ({@link SerializeOptions.includeThinking} `false`), when this
436
+ * archive carries it. Read through {@link viewWithoutThinking}. */
437
+ withoutThinking?: ArchiveView;
438
+ }
439
+ /** What a reader gets for one compaction: the summary text and the archive it introduces. */
440
+ export interface ArchiveView {
441
+ summary: string;
442
+ archive: Archive;
434
443
  }
435
444
  export interface Geometry {
436
445
  /** Characters per row (per-column line width when `columns === 2`). */
@@ -527,7 +536,8 @@ export interface SerializeOptions {
527
536
  * louder than archived tool noise. Defaults to `true`. */
528
537
  dimToolResults?: boolean;
529
538
  /** Serialize assistant reasoning as `¶think:` sections. Defaults to `true`.
530
- * Callers archiving for a Claude/Anthropic-dialect model set this `false`:
539
+ * Callers archiving for a reader whose classifier refuses quoted reasoning
540
+ * ({@link readerIncludesThinking} `false`) set this `false`:
531
541
  * the archive frames are replayed as text into every later request, and
532
542
  * reasoning rendered back to Claude trips its `reasoning_extraction`
533
543
  * classifier (issue #6093). */
@@ -598,7 +608,8 @@ export declare function renderMany(text: string, options?: RenderManyOptions): P
598
608
  * For doc shapes this wraps the text once and counts pages of `2 * rows`
599
609
  * lines; for grid shapes it divides by the frame capacity. */
600
610
  export declare function frames(text: string, options?: Pick<RenderManyOptions, "shape" | "model" | "frameSize">): number;
601
- /** Validate and extract a persisted frame archive from `preserveData`. */
611
+ /** Validate and extract a persisted frame archive from `preserveData`, with its
612
+ * {@link Archive.withoutThinking} variant when that is valid too. */
602
613
  export declare function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined;
603
614
  /** Drop the persisted frame archive ({@link PRESERVE_KEY}) from `preserveData`,
604
615
  * returning the remaining state — or `undefined` when nothing else remains, so
@@ -638,6 +649,33 @@ export declare function historyBlocks(archive: Archive, options?: HistoryBlockOp
638
649
  * Returns `blocks` itself when every frame fits.
639
650
  */
640
651
  export declare function capFrameDataBytes(blocks: (TextContent | ImageContent)[], maxFrameDataBytes: number): (TextContent | ImageContent)[];
652
+ /**
653
+ * Whether an archive read by `model` may carry assistant reasoning. The archive
654
+ * replays as text into every later request, and a reader whose classifier
655
+ * refuses reasoning quoted back into its input (the `classifier-cot-extraction`
656
+ * catalog axis, set for Claude: `reasoning_extraction`, issue #6093) gets
657
+ * `¶think:` sections and their legend left out.
658
+ */
659
+ export declare function readerIncludesThinking(model: Model): boolean;
660
+ /** Whether a snapcompact summary declares `¶think:` reasoning scopes: true for
661
+ * archives rendered with {@link SerializeOptions.includeThinking}, and for
662
+ * summaries written before that legend was gated on it. */
663
+ export declare function summaryIncludesThinking(summary: string): boolean;
664
+ /** `text` (a summary or archive source, raw or normalized) without its
665
+ * `¶think:` sections, and with the reasoning scope dropped from any legend. */
666
+ export declare function textWithoutThinking(text: string): string;
667
+ /**
668
+ * What a reader that excludes assistant reasoning reads for a compaction
669
+ * whose summary is `summary` and archive `archive`: the archive's
670
+ * {@link Archive.withoutThinking} variant when it carries one, the original
671
+ * when its summary declares no reasoning, else a fail-closed text-only form.
672
+ * That form keeps no frames (pixels cannot be scrubbed), takes its text edges
673
+ * from the source after every `¶think:` section is removed (so a section
674
+ * crossing an edge cannot leak), counts the dropped middle as truncated, and
675
+ * re-renders the summary without the reasoning legend; `details` restores its
676
+ * file list.
677
+ */
678
+ export declare function viewWithoutThinking(summary: string, archive: Archive, details?: CompactionDetails): ArchiveView;
641
679
  /**
642
680
  * Run a snapcompact compaction over prepared messages. Fully local: serializes
643
681
  * 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": "18.10.3",
4
+ "version": "18.11.0",
5
5
  "description": "Bitmap-frame context compression for vision-capable LLMs",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -34,11 +34,11 @@
34
34
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
35
35
  },
36
36
  "dependencies": {
37
- "@oh-my-pi/pi-ai": "18.10.3",
38
- "@oh-my-pi/pi-catalog": "18.10.3",
39
- "@oh-my-pi/pi-natives": "18.10.3",
40
- "@oh-my-pi/pi-utils": "18.10.3",
41
- "@oh-my-pi/pi-wire": "18.10.3"
37
+ "@oh-my-pi/pi-ai": "18.11.0",
38
+ "@oh-my-pi/pi-catalog": "18.11.0",
39
+ "@oh-my-pi/pi-natives": "18.11.0",
40
+ "@oh-my-pi/pi-utils": "18.11.0",
41
+ "@oh-my-pi/pi-wire": "18.11.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/bun": "^1.3.14"
@@ -67,7 +67,7 @@ import {
67
67
  import { resolveImageCountBudget, resolveInlineImageByteBudget } from "@oh-my-pi/pi-catalog/compat/request-size";
68
68
  import { classifyModel, compareRevision, parseRevision } from "@oh-my-pi/pi-catalog/identity";
69
69
  import { renderSnapcompactPng, snapcompactSupportedChars } from "@oh-my-pi/pi-natives";
70
- import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils";
70
+ import { formatGroupedPaths, isRecord, prompt } from "@oh-my-pi/pi-utils";
71
71
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
72
72
  import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
73
73
  import snapcompactSummaryPrompt from "./prompts/snapcompact-summary.md" with { type: "text" };
@@ -644,6 +644,16 @@ export interface Archive {
644
644
  textHead?: string;
645
645
  /** Newest text region kept verbatim around the imaged middle. */
646
646
  textTail?: string;
647
+ /** The same history re-rendered for a reader that excludes assistant
648
+ * reasoning ({@link SerializeOptions.includeThinking} `false`), when this
649
+ * archive carries it. Read through {@link viewWithoutThinking}. */
650
+ withoutThinking?: ArchiveView;
651
+ }
652
+
653
+ /** What a reader gets for one compaction: the summary text and the archive it introduces. */
654
+ export interface ArchiveView {
655
+ summary: string;
656
+ archive: Archive;
647
657
  }
648
658
 
649
659
  export interface Geometry {
@@ -835,7 +845,8 @@ export interface SerializeOptions {
835
845
  * louder than archived tool noise. Defaults to `true`. */
836
846
  dimToolResults?: boolean;
837
847
  /** Serialize assistant reasoning as `¶think:` sections. Defaults to `true`.
838
- * Callers archiving for a Claude/Anthropic-dialect model set this `false`:
848
+ * Callers archiving for a reader whose classifier refuses quoted reasoning
849
+ * ({@link readerIncludesThinking} `false`) set this `false`:
839
850
  * the archive frames are replayed as text into every later request, and
840
851
  * reasoning rendered back to Claude trips its `reasoning_extraction`
841
852
  * classifier (issue #6093). */
@@ -1768,9 +1779,8 @@ export function frames(text: string, options?: Pick<RenderManyOptions, "shape" |
1768
1779
  // Archive helpers
1769
1780
  // ============================================================================
1770
1781
 
1771
- /** Validate and extract a persisted frame archive from `preserveData`. */
1772
- export function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined {
1773
- const candidate = preserveData?.[PRESERVE_KEY];
1782
+ /** Validate one persisted archive object (frames, counters, text regions). */
1783
+ function validateArchive(candidate: unknown): Archive | undefined {
1774
1784
  if (!candidate || typeof candidate !== "object") return undefined;
1775
1785
  const archive = candidate as Archive;
1776
1786
  const frames = Array.isArray(archive.frames)
@@ -1801,6 +1811,18 @@ export function getPreservedArchive(preserveData: Record<string, unknown> | unde
1801
1811
  };
1802
1812
  }
1803
1813
 
1814
+ /** Validate and extract a persisted frame archive from `preserveData`, with its
1815
+ * {@link Archive.withoutThinking} variant when that is valid too. */
1816
+ export function getPreservedArchive(preserveData: Record<string, unknown> | undefined): Archive | undefined {
1817
+ const slot = preserveData?.[PRESERVE_KEY];
1818
+ const archive = validateArchive(slot);
1819
+ if (!archive || !isRecord(slot) || !isRecord(slot.withoutThinking)) return archive;
1820
+ const { summary, archive: variant } = slot.withoutThinking;
1821
+ if (typeof summary !== "string" || !summary) return archive;
1822
+ const variantArchive = validateArchive(variant);
1823
+ return variantArchive ? { ...archive, withoutThinking: { summary, archive: variantArchive } } : archive;
1824
+ }
1825
+
1804
1826
  /** Drop the persisted frame archive ({@link PRESERVE_KEY}) from `preserveData`,
1805
1827
  * returning the remaining state — or `undefined` when nothing else remains, so
1806
1828
  * an empty `{}` is never persisted. Callers strip the archive once its frames
@@ -2184,29 +2206,133 @@ function planArchive(text: string, high: Shape, low: Shape, maxFrames: number):
2184
2206
  };
2185
2207
  }
2186
2208
 
2209
+ const THINK_LABEL = "¶think:";
2210
+
2211
+ /** A scope label opening a serialized section, at the start of a line in raw
2212
+ * (`\n`) or normalized ({@link NEWLINE_GLYPH}) archive text. */
2213
+ const SECTION_START = new RegExp(`(?<=^|[\\n${NEWLINE_GLYPH}])(?=¶(?:user|think|ai|call):)`);
2214
+
2215
+ const TRAILING_LINE_BREAKS = new RegExp(`[\\n${NEWLINE_GLYPH}]+$`);
2216
+
2187
2217
  /**
2188
- * Drop `¶think:` sections from serialized archive source text.
2218
+ * Drop every `¶think:` section from archive source text: its label line and
2219
+ * every following line up to the next scope label.
2189
2220
  *
2190
- * Archives written before {@link SerializeOptions.includeThinking} existed bake
2191
- * reasoning into their kept source; replaying it to Claude trips the
2192
- * `reasoning_extraction` classifier (issue #6093). Re-compaction re-renders the
2193
- * whole unfolded source, so scrubbing the prior text heals a poisoned session
2194
- * at its next compaction. Conservative by construction: only sections that
2195
- * start with `¶think:` at a section boundary are dropped.
2221
+ * Archives rendered with {@link SerializeOptions.includeThinking} (or before it
2222
+ * existed) bake reasoning into their kept source; replaying it to Claude trips
2223
+ * the `reasoning_extraction` classifier (issue #6093). Re-rendering for a reader
2224
+ * that excludes thinking scrubs the prior text with this. Paragraph breaks
2225
+ * inside a thought are line breaks like any other, so a section ends only at
2226
+ * the next label; a label inside a dimmed tool-output body is output text.
2196
2227
  */
2197
2228
  function stripThinkingSections(text: string): string {
2198
- return text
2199
- .split(NEWLINE_GLYPH)
2200
- .map(segment =>
2201
- segment
2202
- .split(/\n\n(?=¶(?:user|think|ai|call):)/)
2203
- .filter(section => !section.startsWith("¶think:"))
2204
- .join("\n\n"),
2205
- )
2206
- .filter(segment => segment.length > 0)
2229
+ const sections: string[] = [];
2230
+ let dimOpen = false;
2231
+ for (const piece of text.split(SECTION_START)) {
2232
+ if (dimOpen) sections[sections.length - 1] += piece;
2233
+ else sections.push(piece);
2234
+ const on = piece.lastIndexOf(DIM_ON);
2235
+ const off = piece.lastIndexOf(DIM_OFF);
2236
+ if (on !== off) dimOpen = on > off;
2237
+ }
2238
+ const kept = sections.filter(section => !section.startsWith(THINK_LABEL));
2239
+ if (kept.length === sections.length) return text;
2240
+ const stripped = kept.join("");
2241
+ // A dropped final section leaves its predecessor's line break dangling.
2242
+ return sections.at(-1)?.startsWith(THINK_LABEL) ? stripped.replace(TRAILING_LINE_BREAKS, "") : stripped;
2243
+ }
2244
+
2245
+ /**
2246
+ * Whether an archive read by `model` may carry assistant reasoning. The archive
2247
+ * replays as text into every later request, and a reader whose classifier
2248
+ * refuses reasoning quoted back into its input (the `classifier-cot-extraction`
2249
+ * catalog axis, set for Claude: `reasoning_extraction`, issue #6093) gets
2250
+ * `¶think:` sections and their legend left out.
2251
+ */
2252
+ export function readerIncludesThinking(model: Model): boolean {
2253
+ return resolveCatalogPolicy(model).classifierCotExtraction !== true;
2254
+ }
2255
+
2256
+ /** Whether a snapcompact summary declares `¶think:` reasoning scopes: true for
2257
+ * archives rendered with {@link SerializeOptions.includeThinking}, and for
2258
+ * summaries written before that legend was gated on it. */
2259
+ export function summaryIncludesThinking(summary: string): boolean {
2260
+ return summary.includes(THINK_LABEL);
2261
+ }
2262
+
2263
+ /** The reasoning scope in a scope legend, current and earlier summary wordings
2264
+ * alike, wherever it sits: a summary, or one archived inside another archive. */
2265
+ const LEGEND_REASONING_SCOPE =
2266
+ /`¶user:`, `¶think:`, (`¶ai:`, (?:and )?`¶call:`:? (?:open )?user, )assistant reasoning, /g;
2267
+
2268
+ /** `text` (a summary or archive source, raw or normalized) without its
2269
+ * `¶think:` sections, and with the reasoning scope dropped from any legend. */
2270
+ export function textWithoutThinking(text: string): string {
2271
+ return stripThinkingSections(text).replace(LEGEND_REASONING_SCOPE, "`¶user:`, $1");
2272
+ }
2273
+
2274
+ const PREVIOUS_SUMMARY_HEAD = "[Summary of earlier history]";
2275
+
2276
+ /**
2277
+ * `archive`'s kept source as a reader that excludes reasoning may unfold it.
2278
+ * An archive persisted without {@link Archive.text} keeps only its edges, and
2279
+ * the tail's lines before its first scope label continue a section cut off in
2280
+ * the dropped middle, whose scope is unknown; they are left out.
2281
+ */
2282
+ function sourceForReaderWithoutThinking(archive: Archive): string {
2283
+ if (archive.text) return archive.text;
2284
+ const tail = archive.textTail ?? "";
2285
+ const labelled = tail.search(SECTION_START);
2286
+ return [archive.textHead, labelled < 0 ? "" : tail.slice(labelled)]
2287
+ .filter((part): part is string => typeof part === "string" && part.length > 0)
2207
2288
  .join(NEWLINE_GLYPH);
2208
2289
  }
2209
2290
 
2291
+ /**
2292
+ * What a reader that excludes assistant reasoning reads for a compaction
2293
+ * whose summary is `summary` and archive `archive`: the archive's
2294
+ * {@link Archive.withoutThinking} variant when it carries one, the original
2295
+ * when its summary declares no reasoning, else a fail-closed text-only form.
2296
+ * That form keeps no frames (pixels cannot be scrubbed), takes its text edges
2297
+ * from the source after every `¶think:` section is removed (so a section
2298
+ * crossing an edge cannot leak), counts the dropped middle as truncated, and
2299
+ * re-renders the summary without the reasoning legend; `details` restores its
2300
+ * file list.
2301
+ */
2302
+ export function viewWithoutThinking(summary: string, archive: Archive, details?: CompactionDetails): ArchiveView {
2303
+ if (archive.withoutThinking) return archive.withoutThinking;
2304
+ if (!summaryIncludesThinking(summary)) return { summary, archive };
2305
+ const source = sourceForReaderWithoutThinking(archive);
2306
+ const text = textWithoutThinking(source);
2307
+ const headChars = archive.textHead?.length ?? 0;
2308
+ const tailChars = archive.textTail?.length ?? 0;
2309
+ const fits = headChars + tailChars >= text.length;
2310
+ const textHead = fits ? text : text.slice(0, headChars);
2311
+ const textTail = fits || tailChars === 0 ? "" : text.slice(text.length - tailChars);
2312
+ const truncatedChars = archive.truncatedChars + (fits ? 0 : text.length - headChars - tailChars);
2313
+ const files = details ? formatFileList(details.readFiles, details.modifiedFiles) : "";
2314
+ return {
2315
+ summary:
2316
+ textHead.length === 0 && files.length === 0
2317
+ ? "No prior history."
2318
+ : prompt.render(snapcompactSummaryPrompt, {
2319
+ frameCount: 0,
2320
+ truncatedChars,
2321
+ includedPreviousSummary: text.startsWith(PREVIOUS_SUMMARY_HEAD),
2322
+ files: files.length > 0 ? files : undefined,
2323
+ includeThinking: false,
2324
+ }),
2325
+ archive: {
2326
+ frames: [],
2327
+ totalChars: textHead.length + textTail.length,
2328
+ truncatedChars,
2329
+ ...(text ? { text } : {}),
2330
+ ...(textHead ? { textHead } : {}),
2331
+ ...(textTail ? { textTail } : {}),
2332
+ },
2333
+ };
2334
+ }
2335
+
2210
2336
  /**
2211
2337
  * Run a snapcompact compaction over prepared messages. Fully local: serializes
2212
2338
  * the discarded history, appends it to the accumulated archive source text, and
@@ -2232,21 +2358,23 @@ export async function compact<TMessage = Message>(
2232
2358
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
2233
2359
  const serialized = serializeConversation(llmMessages, options);
2234
2360
  const previousArchive = getPreservedArchive(previousPreserveData);
2361
+ const excludeThinking = options?.includeThinking === false;
2235
2362
  const previousTextRaw =
2236
- previousArchive?.text ??
2237
- [previousArchive?.textHead, previousArchive?.textTail]
2238
- .filter((part): part is string => typeof part === "string" && part.length > 0)
2239
- .join(NEWLINE_GLYPH);
2363
+ excludeThinking && previousArchive
2364
+ ? sourceForReaderWithoutThinking(previousArchive)
2365
+ : (previousArchive?.text ??
2366
+ [previousArchive?.textHead, previousArchive?.textTail]
2367
+ .filter((part): part is string => typeof part === "string" && part.length > 0)
2368
+ .join(NEWLINE_GLYPH));
2240
2369
  // Legacy archives may carry `¶think:` sections from before includeThinking
2241
- // existed; scrub them when this compaction excludes thinking so the
2242
- // re-rendered archive stops replaying reasoning (issue #6093). They may
2243
- // also carry data URLs a pre-guard slice cut at any offset; heal those in
2244
- // archive context before the text is folded into the new source.
2370
+ // existed, and any archive may carry an earlier summary's reasoning legend;
2371
+ // scrub both when this compaction excludes thinking so the re-rendered
2372
+ // archive stops replaying reasoning (issue #6093). They may also carry data
2373
+ // URLs a pre-guard slice cut at any offset; heal those in archive context
2374
+ // before the text is folded into the new source.
2245
2375
  const previousTextHealed = elideDataUrls(previousTextRaw, "archive");
2246
2376
  const previousText =
2247
- options?.includeThinking === false && previousTextHealed.length > 0
2248
- ? stripThinkingSections(previousTextHealed)
2249
- : previousTextHealed;
2377
+ excludeThinking && previousTextHealed.length > 0 ? textWithoutThinking(previousTextHealed) : previousTextHealed;
2250
2378
  const hasPreviousText = previousText.length > 0;
2251
2379
  const includedPreviousSummary = !hasPreviousText && !!previousSummary;
2252
2380
  const shapeProbeText = renderabilityProbeText(serialized, previousPreserveData, previousSummary);
@@ -2262,7 +2390,8 @@ export async function compact<TMessage = Message>(
2262
2390
  let archiveText = normalize(serialized, { shape: high });
2263
2391
 
2264
2392
  if (includedPreviousSummary && previousSummary) {
2265
- const head = `[Summary of earlier history] ${normalize(previousSummary, { shape: high })}`;
2393
+ const lead = excludeThinking ? textWithoutThinking(previousSummary) : previousSummary;
2394
+ const head = `${PREVIOUS_SUMMARY_HEAD} ${normalize(lead, { shape: high })}`;
2266
2395
  archiveText = archiveText.length > 0 ? `${head} [Recent conversation] ${archiveText}` : head;
2267
2396
  }
2268
2397