@oh-my-pi/snapcompact 18.14.2 → 18.16.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,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.16.0] - 2026-11-02
6
+
7
+ ### Added
8
+
9
+ - Added `archiveIncludesThinking()`, whether a compaction may replay reasoning from its summary's legend or its archive's text, and `viewSummary()`, which keeps a summary the template did not write in its own words, followed by the template's guide to the view's text ([#17745](https://github.com/can1357/oh-my-pi/pull/17745) by [@will-bogusz](https://github.com/will-bogusz))
10
+
11
+ ### Fixed
12
+
13
+ - Fixed `viewWithoutFrames` and `viewWithoutThinking` keeping an archive's reasoning for a reader without it when the summary (say, one an extension wrote) declares no `¶think:` scope, and replacing such a summary with the template's ([#17745](https://github.com/can1357/oh-my-pi/pull/17745) by [@will-bogusz](https://github.com/will-bogusz))
14
+
15
+ ## [18.15.0] - 2026-11-01
16
+
17
+ ### Added
18
+
19
+ - Added `viewWithoutFrames`: what a reader without image input reads for an archive, its kept text in place of its frames ([#17510](https://github.com/can1357/oh-my-pi/pull/17510) by [@will-bogusz](https://github.com/will-bogusz))
20
+
5
21
  ## [18.13.3] - 2026-10-28
6
22
 
7
23
  ### Changed
@@ -661,6 +661,13 @@ export declare function readerIncludesThinking(model: Model): boolean;
661
661
  * archives rendered with {@link SerializeOptions.includeThinking}, and for
662
662
  * summaries written before that legend was gated on it. */
663
663
  export declare function summaryIncludesThinking(summary: string): boolean;
664
+ /**
665
+ * Whether a compaction whose summary is `summary` and archive `archive` may
666
+ * replay assistant reasoning: its summary declares `¶think:` scopes, or its
667
+ * kept text carries a `¶think:` section. A summary an extension or custom
668
+ * template wrote need not carry the legend while its archive's source does.
669
+ */
670
+ export declare function archiveIncludesThinking(summary: string, archive: Archive): boolean;
664
671
  /** `text` (a summary or archive source, raw or normalized) without its
665
672
  * `¶think:` sections, and with the reasoning scope dropped from any legend. */
666
673
  export declare function textWithoutThinking(text: string): string;
@@ -668,14 +675,46 @@ export declare function textWithoutThinking(text: string): string;
668
675
  * What a reader that excludes assistant reasoning reads for a compaction
669
676
  * whose summary is `summary` and archive `archive`: the archive's
670
677
  * {@link Archive.withoutThinking} variant when it carries one, the original
671
- * when its summary declares no reasoning, else a fail-closed text-only form.
678
+ * when it carries no reasoning ({@link archiveIncludesThinking}), else a
679
+ * fail-closed text-only form.
672
680
  * That form keeps no frames (pixels cannot be scrubbed), takes its text edges
673
681
  * from the source after every `¶think:` section is removed (so a section
674
682
  * crossing an edge cannot leak), counts the dropped middle as truncated, and
675
683
  * re-renders the summary without the reasoning legend; `details` restores its
676
- * file list.
684
+ * file list. A summary the template did not write keeps its own words
685
+ * ({@link viewSummary}).
677
686
  */
678
687
  export declare function viewWithoutThinking(summary: string, archive: Archive, details?: CompactionDetails): ArchiveView;
688
+ /**
689
+ * The summary a reader reads with an archive view: `rendered`, the template's
690
+ * summary for that view, when `summary` came from the template; otherwise
691
+ * `summary`'s own words (say, an extension's), without reasoning when
692
+ * `dropThinking`, followed by `rendered`, so the facts it alone holds survive
693
+ * the swap and the reader still gets the guide to the view's text.
694
+ */
695
+ export declare function viewSummary(summary: string, rendered: string, dropThinking: boolean): string;
696
+ /** Options for {@link viewWithoutFrames}. */
697
+ export interface ViewWithoutFramesOptions {
698
+ /** `false` for a reader that excludes assistant reasoning ({@link readerIncludesThinking}). */
699
+ includeThinking?: boolean;
700
+ /** Most archive characters the reader takes; the middle past it is dropped, both ends kept. */
701
+ maxChars?: number;
702
+ }
703
+ /**
704
+ * What a reader without image input reads for a compaction whose summary is
705
+ * `summary` and archive `archive`: the archive's kept source
706
+ * ({@link Archive.text}) as plain text in place of its frames, and the summary
707
+ * re-rendered without the frame legend, its file list kept (one the template
708
+ * did not write keeps its own words, {@link viewSummary}). With
709
+ * `includeThinking: false` the text and legend also leave out reasoning, as
710
+ * {@link viewWithoutThinking} does, without rendering frames this reader
711
+ * cannot see; past `maxChars` the middle of the text is dropped. An archive
712
+ * persisted without that source keeps its text edges and counts the imaged
713
+ * middle as dropped; a reader without reasoning also skips the tail's lines
714
+ * before its first scope label, whatever the summary declares. An archive with
715
+ * no frames, no reasoning to drop and no text past `maxChars` is returned as is.
716
+ */
717
+ export declare function viewWithoutFrames(summary: string, archive: Archive, options?: ViewWithoutFramesOptions): ArchiveView;
679
718
  /**
680
719
  * Run a snapcompact compaction over prepared messages. Fully local: serializes
681
720
  * 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.14.2",
4
+ "version": "18.16.0",
5
5
  "description": "Bitmap-frame context compression for vision-capable LLMs",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -34,11 +34,12 @@
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.14.2",
38
- "@oh-my-pi/pi-catalog": "18.14.2",
39
- "@oh-my-pi/pi-natives": "18.14.2",
40
- "@oh-my-pi/pi-utils": "18.14.2",
41
- "@oh-my-pi/pi-wire": "18.14.2"
37
+ "@oh-my-pi/omptype": "18.16.0",
38
+ "@oh-my-pi/pi-ai": "18.16.0",
39
+ "@oh-my-pi/pi-catalog": "18.16.0",
40
+ "@oh-my-pi/pi-natives": "18.16.0",
41
+ "@oh-my-pi/pi-utils": "18.16.0",
42
+ "@oh-my-pi/pi-wire": "18.16.0"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@types/bun": "^1.3.14"
@@ -19,6 +19,7 @@ Reading HISTORY:
19
19
  {{/if}}{{#if truncatedChars}}- About {{truncatedChars}} characters of older middle history dropped to fit archive budget.
20
20
  {{/if}}- If an exact earlier detail matters and a section is unclear, re-derive from workspace (re-read files, re-run commands), rather than guess.
21
21
 
22
+ {{!-- viewWithoutFrames (snapcompact.ts) reads this FILES section back out of a rendered summary, and viewSummary tells this template's summaries from others by their closing HISTORY heading: keep FILES_SECTION_HEAD and HISTORY_SECTION_HEAD in step with these headings. --}}
22
23
  {{#if files}}FILES
23
24
  ===================
24
25
  {{files}}
@@ -53,6 +53,7 @@
53
53
  * re-attached to the compaction summary message on every context rebuild.
54
54
  */
55
55
 
56
+ import { type } from "@oh-my-pi/omptype";
56
57
  import type { Api, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
57
58
  import { reachesBudgetedHost } from "@oh-my-pi/pi-ai/providers/anthropic-state";
58
59
  import { resolveCatalogPolicy } from "@oh-my-pi/pi-catalog/compat/catalog-policy";
@@ -320,32 +321,25 @@ export const SHAPES = {
320
321
  legacy: priceShape(SHAPE_VARIANTS["5x8-sent"], { api: "anthropic-messages" }),
321
322
  } satisfies Record<string, Shape>;
322
323
 
324
+ /** In-memory shapes may carry optional keys as `undefined`. */
325
+ const ShapeSchema = type({
326
+ font: "'5x8' | '8x8' | '6x12' | '8x13' | 'silver'",
327
+ cellWidth: "number > 0",
328
+ cellHeight: "number > 0",
329
+ "stretch?": "boolean | undefined",
330
+ variant: "'sent' | 'bw'",
331
+ "stopwordDim?": "boolean | undefined",
332
+ "columns?": "1 | 2 | undefined",
333
+ lineRepeat: "number > 0",
334
+ frameSize: "number > 0",
335
+ frameTokenEstimate: "number > 0",
336
+ "frameTokensVerified?": "boolean | undefined",
337
+ "imageDetail?": "'auto' | 'low' | 'high' | 'original' | undefined",
338
+ });
339
+
323
340
  /** Runtime guard for shape overrides loaded from config or preserve data. */
324
341
  export function isShape(value: unknown): value is Shape {
325
- if (!value || typeof value !== "object") return false;
326
- const shape = value as Record<string, unknown>;
327
- const font = shape.font;
328
- const variant = shape.variant;
329
- const detail = shape.imageDetail;
330
- return (
331
- (font === "5x8" || font === "8x8" || font === "6x12" || font === "8x13" || font === "silver") &&
332
- typeof shape.cellWidth === "number" &&
333
- shape.cellWidth > 0 &&
334
- typeof shape.cellHeight === "number" &&
335
- shape.cellHeight > 0 &&
336
- (shape.stretch === undefined || typeof shape.stretch === "boolean") &&
337
- (variant === "sent" || variant === "bw") &&
338
- (shape.stopwordDim === undefined || typeof shape.stopwordDim === "boolean") &&
339
- (shape.columns === undefined || shape.columns === 1 || shape.columns === 2) &&
340
- typeof shape.lineRepeat === "number" &&
341
- shape.lineRepeat > 0 &&
342
- typeof shape.frameSize === "number" &&
343
- shape.frameSize > 0 &&
344
- typeof shape.frameTokenEstimate === "number" &&
345
- shape.frameTokenEstimate > 0 &&
346
- (shape.frameTokensVerified === undefined || typeof shape.frameTokensVerified === "boolean") &&
347
- (detail === undefined || detail === "auto" || detail === "low" || detail === "high" || detail === "original")
348
- );
342
+ return ShapeSchema.allows(value);
349
343
  }
350
344
 
351
345
  /** Eval-winning variant per wire family (fallback when the model id matches
@@ -2259,6 +2253,19 @@ export function summaryIncludesThinking(summary: string): boolean {
2259
2253
  return summary.includes(THINK_LABEL);
2260
2254
  }
2261
2255
 
2256
+ /**
2257
+ * Whether a compaction whose summary is `summary` and archive `archive` may
2258
+ * replay assistant reasoning: its summary declares `¶think:` scopes, or its
2259
+ * kept text carries a `¶think:` section. A summary an extension or custom
2260
+ * template wrote need not carry the legend while its archive's source does.
2261
+ */
2262
+ export function archiveIncludesThinking(summary: string, archive: Archive): boolean {
2263
+ if (summaryIncludesThinking(summary)) return true;
2264
+ return [archive.text ?? archive.textHead, archive.text === undefined ? archive.textTail : undefined].some(
2265
+ text => text?.includes(THINK_LABEL) === true && stripThinkingSections(text) !== text,
2266
+ );
2267
+ }
2268
+
2262
2269
  /** The reasoning scope in a scope legend, current and earlier summary wordings
2263
2270
  * alike, wherever it sits: a summary, or one archived inside another archive. */
2264
2271
  const LEGEND_REASONING_SCOPE =
@@ -2291,16 +2298,18 @@ function sourceForReaderWithoutThinking(archive: Archive): string {
2291
2298
  * What a reader that excludes assistant reasoning reads for a compaction
2292
2299
  * whose summary is `summary` and archive `archive`: the archive's
2293
2300
  * {@link Archive.withoutThinking} variant when it carries one, the original
2294
- * when its summary declares no reasoning, else a fail-closed text-only form.
2301
+ * when it carries no reasoning ({@link archiveIncludesThinking}), else a
2302
+ * fail-closed text-only form.
2295
2303
  * That form keeps no frames (pixels cannot be scrubbed), takes its text edges
2296
2304
  * from the source after every `¶think:` section is removed (so a section
2297
2305
  * crossing an edge cannot leak), counts the dropped middle as truncated, and
2298
2306
  * re-renders the summary without the reasoning legend; `details` restores its
2299
- * file list.
2307
+ * file list. A summary the template did not write keeps its own words
2308
+ * ({@link viewSummary}).
2300
2309
  */
2301
2310
  export function viewWithoutThinking(summary: string, archive: Archive, details?: CompactionDetails): ArchiveView {
2302
2311
  if (archive.withoutThinking) return archive.withoutThinking;
2303
- if (!summaryIncludesThinking(summary)) return { summary, archive };
2312
+ if (!archiveIncludesThinking(summary, archive)) return { summary, archive };
2304
2313
  const source = sourceForReaderWithoutThinking(archive);
2305
2314
  const text = textWithoutThinking(source);
2306
2315
  const headChars = archive.textHead?.length ?? 0;
@@ -2311,7 +2320,8 @@ export function viewWithoutThinking(summary: string, archive: Archive, details?:
2311
2320
  const truncatedChars = archive.truncatedChars + (fits ? 0 : text.length - headChars - tailChars);
2312
2321
  const files = details ? formatFileList(details.readFiles, details.modifiedFiles) : "";
2313
2322
  return {
2314
- summary:
2323
+ summary: viewSummary(
2324
+ summary,
2315
2325
  textHead.length === 0 && files.length === 0
2316
2326
  ? "No prior history."
2317
2327
  : prompt.render(snapcompactSummaryPrompt, {
@@ -2321,6 +2331,8 @@ export function viewWithoutThinking(summary: string, archive: Archive, details?:
2321
2331
  files: files.length > 0 ? files : undefined,
2322
2332
  includeThinking: false,
2323
2333
  }),
2334
+ true,
2335
+ ),
2324
2336
  archive: {
2325
2337
  frames: [],
2326
2338
  totalChars: textHead.length + textTail.length,
@@ -2332,6 +2344,135 @@ export function viewWithoutThinking(summary: string, archive: Archive, details?:
2332
2344
  };
2333
2345
  }
2334
2346
 
2347
+ const FILES_SECTION_HEAD = "FILES\n===================\n";
2348
+ const HISTORY_SECTION_HEAD = "HISTORY\n===================";
2349
+
2350
+ /**
2351
+ * The summary a reader reads with an archive view: `rendered`, the template's
2352
+ * summary for that view, when `summary` came from the template; otherwise
2353
+ * `summary`'s own words (say, an extension's), without reasoning when
2354
+ * `dropThinking`, followed by `rendered`, so the facts it alone holds survive
2355
+ * the swap and the reader still gets the guide to the view's text.
2356
+ */
2357
+ export function viewSummary(summary: string, rendered: string, dropThinking: boolean): string {
2358
+ if (summary.trimEnd().endsWith(HISTORY_SECTION_HEAD)) return rendered;
2359
+ const own = (dropThinking ? textWithoutThinking(summary) : summary).trim();
2360
+ return own.length > 0 ? `${own}\n\n${rendered}` : rendered;
2361
+ }
2362
+
2363
+ /** The FILES section of a snapcompact summary as it was rendered, or `undefined` when it lists none. */
2364
+ function summaryFileSection(summary: string): string | undefined {
2365
+ const start = summary.indexOf(FILES_SECTION_HEAD);
2366
+ const end = summary.lastIndexOf(HISTORY_SECTION_HEAD);
2367
+ if (start < 0 || end < start) return undefined;
2368
+ const files = summary.slice(start + FILES_SECTION_HEAD.length, end).replace(TRAILING_LINE_BREAKS, "");
2369
+ return files.length > 0 ? files : undefined;
2370
+ }
2371
+
2372
+ /** Options for {@link viewWithoutFrames}. */
2373
+ export interface ViewWithoutFramesOptions {
2374
+ /** `false` for a reader that excludes assistant reasoning ({@link readerIncludesThinking}). */
2375
+ includeThinking?: boolean;
2376
+ /** Most archive characters the reader takes; the middle past it is dropped, both ends kept. */
2377
+ maxChars?: number;
2378
+ }
2379
+
2380
+ /** `archive`'s text regions cut to `maxChars`, keeping both ends and counting the cut middle as dropped. */
2381
+ function keepEnds(archive: Archive, maxChars: number): Archive {
2382
+ if (archive.totalChars <= maxChars) return archive;
2383
+ let head = archive.textHead ?? "";
2384
+ let tail = archive.textTail ?? "";
2385
+ if (tail.length === 0) {
2386
+ const headChars = Math.ceil(maxChars / 2);
2387
+ tail = head.slice(head.length - (maxChars - headChars));
2388
+ head = head.slice(0, headChars);
2389
+ } else {
2390
+ const headChars = Math.min(head.length, Math.max(Math.ceil(maxChars / 2), maxChars - tail.length));
2391
+ const tailChars = Math.min(tail.length, maxChars - headChars);
2392
+ head = head.slice(0, headChars);
2393
+ tail = tail.slice(tail.length - tailChars);
2394
+ }
2395
+ const { textHead: _head, textTail: _tail, ...rest } = archive;
2396
+ return {
2397
+ ...rest,
2398
+ totalChars: head.length + tail.length,
2399
+ truncatedChars: archive.truncatedChars + archive.totalChars - head.length - tail.length,
2400
+ ...(head ? { textHead: head } : {}),
2401
+ ...(tail ? { textTail: tail } : {}),
2402
+ };
2403
+ }
2404
+
2405
+ /**
2406
+ * What a reader without image input reads for a compaction whose summary is
2407
+ * `summary` and archive `archive`: the archive's kept source
2408
+ * ({@link Archive.text}) as plain text in place of its frames, and the summary
2409
+ * re-rendered without the frame legend, its file list kept (one the template
2410
+ * did not write keeps its own words, {@link viewSummary}). With
2411
+ * `includeThinking: false` the text and legend also leave out reasoning, as
2412
+ * {@link viewWithoutThinking} does, without rendering frames this reader
2413
+ * cannot see; past `maxChars` the middle of the text is dropped. An archive
2414
+ * persisted without that source keeps its text edges and counts the imaged
2415
+ * middle as dropped; a reader without reasoning also skips the tail's lines
2416
+ * before its first scope label, whatever the summary declares. An archive with
2417
+ * no frames, no reasoning to drop and no text past `maxChars` is returned as is.
2418
+ */
2419
+ export function viewWithoutFrames(
2420
+ summary: string,
2421
+ archive: Archive,
2422
+ options: ViewWithoutFramesOptions = {},
2423
+ ): ArchiveView {
2424
+ const excludeThinking = options.includeThinking === false;
2425
+ const dropThinking = excludeThinking && archiveIncludesThinking(summary, archive);
2426
+ // Without the whole source the tail may open inside a section cut off in the
2427
+ // dropped middle, whose scope is unknown (it may be reasoning even when no
2428
+ // edge shows a label); a reader without reasoning skips those lines (see
2429
+ // sourceForReaderWithoutThinking).
2430
+ const tail = archive.text ? "" : (archive.textTail ?? "");
2431
+ const tailStart = excludeThinking && tail ? tail.search(SECTION_START) : 0;
2432
+ const fits = options.maxChars === undefined || archive.totalChars <= options.maxChars;
2433
+ if (archive.frames.length === 0 && !dropThinking && fits && tailStart === 0) return { summary, archive };
2434
+ const scrub = (text: string): string => (dropThinking ? textWithoutThinking(text) : text);
2435
+ let textArchive: Archive;
2436
+ if (archive.text) {
2437
+ const text = scrub(archive.text);
2438
+ textArchive = {
2439
+ frames: [],
2440
+ totalChars: text.length,
2441
+ truncatedChars: archive.truncatedChars,
2442
+ text,
2443
+ textHead: text,
2444
+ };
2445
+ } else {
2446
+ const keptTail = tailStart < 0 ? "" : tail.slice(tailStart);
2447
+ const textHead = scrub(archive.textHead ?? "");
2448
+ const textTail = scrub(keptTail);
2449
+ const framedChars = archive.frames.reduce((sum, frame) => sum + frame.chars, 0);
2450
+ textArchive = {
2451
+ frames: [],
2452
+ totalChars: textHead.length + textTail.length,
2453
+ truncatedChars: archive.truncatedChars + framedChars + tail.length - keptTail.length,
2454
+ ...(textHead ? { textHead } : {}),
2455
+ ...(textTail ? { textTail } : {}),
2456
+ };
2457
+ }
2458
+ if (options.maxChars !== undefined) textArchive = keepEnds(textArchive, Math.max(0, Math.floor(options.maxChars)));
2459
+ const source = textArchive.text ?? textArchive.textHead ?? "";
2460
+ return {
2461
+ summary: viewSummary(
2462
+ summary,
2463
+ prompt.render(snapcompactSummaryPrompt, {
2464
+ frameCount: 0,
2465
+ truncatedChars: textArchive.truncatedChars,
2466
+ includedPreviousSummary: source.startsWith(PREVIOUS_SUMMARY_HEAD),
2467
+ files: summaryFileSection(summary),
2468
+ includeThinking: summaryIncludesThinking(summary) && !dropThinking,
2469
+ }),
2470
+ dropThinking,
2471
+ ),
2472
+ archive: textArchive,
2473
+ };
2474
+ }
2475
+
2335
2476
  /**
2336
2477
  * Run a snapcompact compaction over prepared messages. Fully local: serializes
2337
2478
  * the discarded history, appends it to the accumulated archive source text, and