pi-cliffcompaction 0.1.0 → 0.2.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.
@@ -18,7 +18,7 @@ import {
18
18
  type JsonObject,
19
19
  type JsonValue,
20
20
  } from "../decode.ts";
21
- import { digestObj } from "../hashing.ts";
21
+ import { digestBytes, digestObj } from "../hashing.ts";
22
22
  import { canonicalJson } from "../json.ts";
23
23
  import {
24
24
  makeDialect,
@@ -32,7 +32,15 @@ const MODEL_ITEM_TYPES = new Set([
32
32
  "reasoning",
33
33
  "function_call",
34
34
  "custom_tool_call",
35
+ "apply_patch_call",
36
+ "computer_call",
37
+ "code_interpreter_call",
38
+ "mcp_call",
39
+ "mcp_list_tools",
40
+ "mcp_approval_request",
35
41
  "local_shell_call",
42
+ "shell_call",
43
+ "file_search_call",
36
44
  "web_search_call",
37
45
  "tool_search_call",
38
46
  "image_generation_call",
@@ -55,6 +63,11 @@ function isModelOutput(item: JsonObject): boolean {
55
63
  return asString(item.role) === "assistant";
56
64
  }
57
65
 
66
+ // Server discovery is part of the model run; a client reply closes it.
67
+ if (t === "tool_search_output") {
68
+ return asString(item.execution) === "server";
69
+ }
70
+
58
71
  return MODEL_ITEM_TYPES.has(t);
59
72
  }
60
73
 
@@ -75,6 +88,8 @@ function contentText(content: JsonValue | undefined): string {
75
88
 
76
89
  if (t === "input_text" || t === "output_text" || t === "text") {
77
90
  parts.push(asString(p.text));
91
+ } else if (t === "refusal") {
92
+ parts.push(asString(p.refusal));
78
93
  }
79
94
  }
80
95
 
@@ -88,6 +103,48 @@ function contentText(content: JsonValue | undefined): string {
88
103
  return String(content);
89
104
  }
90
105
 
106
+ function callArgs(item: JsonObject, type: string): JsonValue | undefined {
107
+ if (type === "custom_tool_call") return item.input;
108
+
109
+ if (type === "apply_patch_call") return item.operation;
110
+
111
+ if (type === "computer_call") return item.actions ?? item.action;
112
+
113
+ if (type === "local_shell_call" || type === "shell_call" || type === "web_search_call") return item.action;
114
+
115
+ if (type === "file_search_call") return item.queries;
116
+
117
+ if (type === "code_interpreter_call") return item.code;
118
+
119
+ if (type === "image_generation_call") return item.revised_prompt;
120
+
121
+ return item.arguments;
122
+ }
123
+
124
+ function codeLogs(outputs: JsonValue | undefined): string {
125
+ const parts: string[] = [];
126
+
127
+ for (const output of asArray(outputs)) {
128
+ if (isRecord(output) && asString(output.type) === "logs") {
129
+ parts.push(asString(output.logs));
130
+ }
131
+ }
132
+
133
+ return parts.join("\n");
134
+ }
135
+
136
+ function fileSearchText(results: JsonValue | undefined): string {
137
+ const parts: string[] = [];
138
+
139
+ for (const result of asArray(results)) {
140
+ if (isRecord(result) && isString(result.text)) {
141
+ parts.push(result.text);
142
+ }
143
+ }
144
+
145
+ return parts.join("\n");
146
+ }
147
+
91
148
  function canonContent(content: JsonValue | undefined): JsonArray {
92
149
  if (isString(content)) {
93
150
  return [["text", content]];
@@ -128,6 +185,7 @@ export function digestMessage(item: JsonObject): string {
128
185
  "reasoning",
129
186
  asString(item.encrypted_content),
130
187
  canonicalJson(item.summary ?? []),
188
+ canonicalJson(item.content ?? []),
131
189
  ]);
132
190
  }
133
191
 
@@ -138,17 +196,29 @@ export function digestMessage(item: JsonObject): string {
138
196
  out = canonicalJson(out ?? null);
139
197
  }
140
198
 
141
- return digestObj(["function_call_output", asString(item.call_id), out]);
199
+ // Textual JSON and content blocks have different recap semantics.
200
+ return digestObj(["function_call_output", asString(item.call_id), isString(item.output), out]);
142
201
  }
143
202
 
144
203
  if (t.endsWith("_call") || t === "function_call") {
145
- let args = item.arguments;
204
+ let args = callArgs(item, t);
146
205
 
147
206
  if (!isString(args)) {
148
207
  args = canonicalJson(args ?? "");
149
208
  }
150
209
 
151
- return digestObj([t, asString(item.call_id), asString(item.name), args]);
210
+ const fields: JsonArray = [t, asString(item.call_id), asString(item.name), args];
211
+
212
+ if (t === "mcp_call") fields.push(asString(item.output), asString(item.error));
213
+
214
+ if (t === "code_interpreter_call") fields.push(canonicalJson(item.outputs ?? []));
215
+
216
+ if (t === "file_search_call") fields.push(canonicalJson(item.results ?? []));
217
+
218
+ // Image changes can invalidate the cliff's trigger even without textual excerpts.
219
+ if (t === "image_generation_call") fields.push(isString(item.result) ? digestBytes(item.result) : null);
220
+
221
+ return digestObj(fields);
152
222
  }
153
223
 
154
224
  const stripped: JsonObject = {};
@@ -263,7 +333,7 @@ export function summarizeMessage(item: JsonObject, cfg: Config): string[] {
263
333
 
264
334
  const bits: string[] = [];
265
335
 
266
- for (const p of asArray(item.summary)) {
336
+ for (const p of asArray(item.summary).concat(asArray(item.content))) {
267
337
  if (isRecord(p)) {
268
338
  bits.push(asString(p.text));
269
339
  }
@@ -278,11 +348,15 @@ export function summarizeMessage(item: JsonObject, cfg: Config): string[] {
278
348
  return ["thinking: " + truncate(text, cfg.thinkingMaxChars)];
279
349
  }
280
350
 
281
- if (t === "function_call_output") {
351
+ if (t === "function_call_output" || t === "custom_tool_call_output" || t === "local_shell_call_output" || t === "shell_call_output" || t === "apply_patch_call_output") {
282
352
  let out = item.output;
283
353
 
284
- if (!isString(out)) {
285
- out = contentText(out) || canonicalJson(out ?? null);
354
+ if (t === "shell_call_output") {
355
+ // Native shell arrays contain command results, not content attachments.
356
+ out = canonicalJson(out ?? []);
357
+ } else if (!isString(out)) {
358
+ // Attachment-only content arrays must not become textual JSON excerpts.
359
+ out = contentText(out) || (isArray(out) ? "" : canonicalJson(out ?? null));
286
360
  }
287
361
 
288
362
  out = out.trim();
@@ -295,7 +369,7 @@ export function summarizeMessage(item: JsonObject, cfg: Config): string[] {
295
369
  }
296
370
 
297
371
  if (t.endsWith("_call")) {
298
- let args = item.arguments;
372
+ let args = callArgs(item, t);
299
373
 
300
374
  if (!isString(args)) {
301
375
  args = canonicalJson(args ?? "");
@@ -303,7 +377,22 @@ export function summarizeMessage(item: JsonObject, cfg: Config): string[] {
303
377
 
304
378
  const name = asString(item.name) || t;
305
379
 
306
- return ["[" + name + "] " + truncate(args, cfg.cmdMaxChars)];
380
+ const lines = ["[" + name + "] " + truncate(args, cfg.cmdMaxChars)];
381
+
382
+ if (t === "mcp_call" || t === "code_interpreter_call" || t === "file_search_call") {
383
+ const outputs = t === "mcp_call" ? [asString(item.output), asString(item.error)]
384
+ : [t === "file_search_call" ? fileSearchText(item.results) : codeLogs(item.outputs)];
385
+
386
+ for (const result of outputs) {
387
+ const output = result.trim();
388
+
389
+ if (output && output.length <= cfg.resultMaxChars) {
390
+ lines.push("result: " + output);
391
+ }
392
+ }
393
+ }
394
+
395
+ return lines;
307
396
  }
308
397
 
309
398
  return [];
package/lib/engine.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { createHash } from "node:crypto";
10
- import { compact, type CompactResult } from "./cliff.ts";
10
+ import { compact, groupTurns, type CompactResult } from "./cliff.ts";
11
11
  import { replaceConfig, type Config, type ConfigPatch } from "./config.ts";
12
12
  import {
13
13
  isArray,
@@ -16,9 +16,9 @@ import {
16
16
  type JsonObject,
17
17
  type JsonValue,
18
18
  } from "./decode.ts";
19
- import { SUMMARY_HEADER, type Dialect } from "./dialects/base.ts";
19
+ import { fitSummary, SUMMARY_HEADER, type Dialect } from "./dialects/base.ts";
20
20
  import { digestBytes } from "./hashing.ts";
21
- import { dumpsCount, dumpsDefault, dumpsLen, objectWithoutKey } from "./json.ts";
21
+ import { canonicalJson, dumpsCount, dumpsDefault, objectWithoutKey } from "./json.ts";
22
22
  import { imagePayloadFromNode, tokensForPayload } from "./images.ts";
23
23
  import { makeEntry, PrefixStore } from "./store.ts";
24
24
 
@@ -67,6 +67,8 @@ export type RequestCtx = {
67
67
  dialect: Dialect;
68
68
  msgs: JsonObject[];
69
69
  chain: string[];
70
+ cacheChain: string[];
71
+ policy: string;
70
72
  baseCut: number;
71
73
  baseHead: number;
72
74
  substituted: JsonObject[];
@@ -86,12 +88,8 @@ function outgoingBody(ctx: RequestCtx): JsonObject {
86
88
  return ctx.body;
87
89
  }
88
90
 
89
- const out: JsonObject = {};
90
-
91
- for (const key of Object.keys(ctx.body)) {
92
- out[key] = ctx.body[key];
93
- }
94
-
91
+ // Spread creates own data properties even for JSON keys like __proto__.
92
+ const out: JsonObject = { ...ctx.body };
95
93
  out[ctx.dialect.messagesKey] = ctx.substituted;
96
94
 
97
95
  return out;
@@ -118,6 +116,18 @@ function messagesOf(body: JsonObject, dialect: Dialect): JsonObject[] {
118
116
  return out;
119
117
  }
120
118
 
119
+ function turnEnds(msgs: JsonObject[], dialect: Dialect): Set<number> {
120
+ const ends = new Set<number>();
121
+ let end = 0;
122
+
123
+ for (const turn of groupTurns(msgs, dialect)) {
124
+ end += turn.length;
125
+ ends.add(end);
126
+ }
127
+
128
+ return ends;
129
+ }
130
+
121
131
  function summaryText(msg: JsonObject): string {
122
132
  const content = msg.content;
123
133
 
@@ -144,93 +154,86 @@ export function messageChars(msg: JsonObject): number {
144
154
  return msgChars(msg);
145
155
  }
146
156
 
147
- type PrefixDigestState = {
148
- digests: string[];
149
- chain: string[];
150
- shared: number;
157
+ type MessageObservation = {
158
+ snapshot: string;
159
+ digest: string;
160
+ chain: string;
161
+ cacheChain: string;
162
+ chars: number;
151
163
  };
152
164
 
153
165
  export class Engine {
154
166
  readonly cfg: Config;
155
167
  readonly store: PrefixStore;
156
- private prefixMsgs: JsonObject[] = [];
157
- private prefixDigests: string[] = [];
158
- private prefixChain: string[] = [];
159
- private prefixDialect: Dialect | null = null;
160
- private prefixArrayBillable = 0;
168
+ private observations: MessageObservation[] = [];
169
+ private observedDialect: Dialect | null = null;
161
170
 
162
171
  constructor(cfg: Config, store?: PrefixStore) {
163
172
  this.cfg = cfg;
164
173
  this.store = store ?? new PrefixStore(cfg.storeMaxEntries, cfg.storeMaxBytes);
165
174
  }
166
175
 
167
- private digestGrowingPrefix(msgs: JsonObject[], dialect: Dialect): PrefixDigestState {
168
- let shared = 0;
169
-
170
- if (this.prefixDialect === dialect) {
171
- const limit = Math.min(msgs.length, this.prefixMsgs.length);
172
-
173
- while (shared < limit && msgs[shared] === this.prefixMsgs[shared]) {
174
- shared += 1;
175
- }
176
- }
177
-
178
- const digests = this.prefixDigests.slice(0, shared);
179
-
180
- for (let i = shared; i < msgs.length; i++) {
181
- digests.push(dialect.digestMessage(msgs[i]));
182
- }
183
-
184
- const chain = this.prefixChain.slice(0, shared);
185
- let prev = shared > 0 ? chain[shared - 1] : "";
186
-
187
- for (let i = shared; i < msgs.length; i++) {
188
- prev = digestBytes(prev + digests[i]);
189
- chain.push(prev);
190
- }
191
-
192
- this.prefixMsgs = msgs;
193
- this.prefixDigests = digests;
194
- this.prefixChain = chain;
195
- this.prefixDialect = dialect;
196
-
197
- return { digests, chain, shared };
198
- }
199
-
200
- private messagesArrayBillable(msgs: JsonObject[], shared: number, prevLen: number, prevBillable: number): number {
201
- if (shared > 0 && shared === prevLen && prevBillable > 0) {
202
- let n = prevBillable;
203
-
204
- for (let i = shared; i < msgs.length; i++) {
205
- n += 2 + billableChars(msgs[i]);
206
- }
207
-
208
- this.prefixArrayBillable = n;
209
-
210
- return n;
176
+ private observe(msgs: JsonObject[], dialect: Dialect): MessageObservation[] {
177
+ const prior = this.observedDialect === dialect ? this.observations : [];
178
+ const observed: MessageObservation[] = [];
179
+ let prefix = "";
180
+ let cachePrefix = "";
181
+ let samePrefix = true;
182
+ let sameCachePrefix = true;
183
+
184
+ for (let i = 0; i < msgs.length; i++) {
185
+ // Inputs are JSON data. Compare current serialized content, never object
186
+ // identity; nested edits and noncanonical budget metadata remain visible.
187
+ const snapshot = JSON.stringify(msgs[i]);
188
+ const old = prior[i];
189
+ const unchanged = old !== undefined && old.snapshot === snapshot;
190
+ const digest = unchanged ? old.digest : dialect.digestMessage(msgs[i]);
191
+ samePrefix = samePrefix && old !== undefined && old.digest === digest;
192
+ prefix = samePrefix ? old.chain : digestBytes(prefix + digest);
193
+ const chars = unchanged ? old.chars : billableChars(msgs[i]);
194
+ // Encoding overhead can move an earlier cliff without changing canonical identity.
195
+ sameCachePrefix = sameCachePrefix && samePrefix && old !== undefined && old.chars === chars;
196
+ cachePrefix = sameCachePrefix ? old.cacheChain : digestBytes(cachePrefix + digest + ":" + chars);
197
+ observed.push({
198
+ snapshot: unchanged ? old.snapshot : snapshot,
199
+ digest,
200
+ chain: prefix,
201
+ cacheChain: cachePrefix,
202
+ chars,
203
+ });
211
204
  }
212
205
 
213
- const n = billableChars(msgs);
214
- this.prefixArrayBillable = n;
206
+ // Retain only the latest request's observations, not every branch visited.
207
+ this.observations = observed;
208
+ this.observedDialect = dialect;
215
209
 
216
- return n;
210
+ return observed;
217
211
  }
218
212
 
219
213
  prepare(body: JsonObject, dialect: Dialect): RequestCtx {
220
214
  const msgs = messagesOf(body, dialect);
221
- const prevLen = this.prefixMsgs.length;
222
- const prevBillable = this.prefixArrayBillable;
223
- const { chain, shared } = this.digestGrowingPrefix(msgs, dialect);
215
+ const observed = this.observe(msgs, dialect);
216
+ const chain = observed.map((entry) => entry.chain);
217
+ const cacheChain = observed.map((entry) => entry.cacheChain);
224
218
  const emptyBody = objectWithoutKey(body, dialect.messagesKey);
225
219
  emptyBody[dialect.messagesKey] = [];
226
- const msgsBillable = this.messagesArrayBillable(msgs, shared, prevLen, prevBillable);
227
- const estTokensIn = Math.trunc(Math.max(billableChars(emptyBody) - 2 + msgsBillable, 0) / 4);
220
+ const fixedChars = billableChars(emptyBody);
221
+ const raw = body[dialect.messagesKey];
222
+
223
+ // Array punctuation belongs to the request, not to individual messages.
224
+ const inputChars = fixedChars + observed.reduce((sum, entry) => sum + entry.chars, 0)
225
+ + Math.max(0, msgs.length - 1) * 2;
226
+
227
+ const estTokensIn = isArray(raw) && raw.length === msgs.length
228
+ ? Math.trunc(inputChars / 4) : estimateTokens(body);
228
229
 
229
230
  const ctx: RequestCtx = {
230
231
  body,
231
232
  dialect,
232
233
  msgs,
233
234
  chain,
235
+ cacheChain,
236
+ policy: canonicalJson([this.cfg, fixedChars]),
234
237
  baseCut: 0,
235
238
  baseHead: 0,
236
239
  substituted: msgs,
@@ -244,10 +247,23 @@ export class Engine {
244
247
  outMsgs: 0,
245
248
  };
246
249
 
250
+ // Canonical-equivalent encodings can cross the trigger without changing
251
+ // the chain. Cached cliffs must not replace an already-fitting request.
252
+ if (ctx.estTokensIn <= this.cfg.thresholdTokens) {
253
+ ctx.estTokensOut = ctx.estTokensIn;
254
+
255
+ return ctx;
256
+ }
257
+
258
+ // With no protected tail, a cached final call can become an interior
259
+ // item when a later request appends its result or another model-run item.
260
+ const safeCuts = this.cfg.keepRecent === 0 ? turnEnds(msgs, dialect) : null;
261
+
247
262
  for (let i = msgs.length - 1; i >= 0; i--) {
248
- const entry = this.store.get(chain[i]);
263
+ const entry = this.store.get(cacheChain[i]);
249
264
 
250
- if (entry === undefined) {
265
+ if (entry === undefined || entry.policy !== ctx.policy || entry.dialect !== dialect
266
+ || (safeCuts !== null && !safeCuts.has(entry.cut))) {
251
267
  continue;
252
268
  }
253
269
 
@@ -261,7 +277,8 @@ export class Engine {
261
277
  substituted.push(msgs[h]);
262
278
  }
263
279
 
264
- substituted.push(entry.summary);
280
+ // Outgoing JSON belongs to this request, not to the shared prefix cache.
281
+ substituted.push(structuredClone(entry.summary));
265
282
 
266
283
  for (let t = entry.cut; t < msgs.length; t++) {
267
284
  substituted.push(msgs[t]);
@@ -331,7 +348,8 @@ export class Engine {
331
348
 
332
349
  private rungCfg(rung: number): Config {
333
350
  const patch: ConfigPatch = {
334
- keepRecent: 1,
351
+ // Never raise keepRecent: 0 means "summarize the tail too".
352
+ keepRecent: Math.min(this.cfg.keepRecent, 1),
335
353
  };
336
354
 
337
355
  if (rung >= 2) {
@@ -344,7 +362,7 @@ export class Engine {
344
362
  }
345
363
 
346
364
  private truncateSummary(ctx: RequestCtx, reason = "reactive"): boolean {
347
- if (!ctx.compacted || ctx.baseCut <= 0) {
365
+ if (ctx.baseCut <= 0) {
348
366
  return false;
349
367
  }
350
368
 
@@ -364,35 +382,15 @@ export class Engine {
364
382
  }
365
383
  }
366
384
 
367
- const bodyCopy: JsonObject = {};
368
-
369
- for (const key of Object.keys(ctx.body)) {
370
- bodyCopy[key] = ctx.body[key];
371
- }
372
-
385
+ const bodyCopy: JsonObject = { ...ctx.body };
373
386
  bodyCopy[ctx.dialect.messagesKey] = others;
374
- const fixed = dumpsLen(bodyCopy);
375
- const budget = this.cfg.thresholdTokens * 4 - fixed - SUMMARY_HEADER.length - 64;
387
+ const fixed = billableChars(bodyCopy) + (others.length > 0 ? 2 : 0);
376
388
  const rest = text.slice(SUMMARY_HEADER.length).trim();
377
389
  const parts = rest.length > 0 ? rest.split("\n\n---\n\n") : [];
378
- const kept: string[] = [];
379
- let used = 0;
380
-
381
- for (let i = parts.length - 1; i >= 0; i--) {
382
- const part = parts[i];
383
390
 
384
- if (used + part.length > Math.max(budget, 0)) {
385
- break;
386
- }
387
-
388
- kept.push(part);
389
- used += part.length + 9;
390
- }
391
-
392
- kept.reverse();
393
-
394
- const newText =
395
- kept.length > 0 ? SUMMARY_HEADER + "\n\n" + kept.join("\n\n---\n\n") : SUMMARY_HEADER;
391
+ const newText = fitSummary(SUMMARY_HEADER, parts, (candidate) =>
392
+ Math.trunc((fixed + billableChars(ctx.dialect.userMessage(candidate))) / 4) <= this.cfg.thresholdTokens,
393
+ );
396
394
 
397
395
  if (newText.length >= text.length) {
398
396
  return false;
@@ -414,10 +412,7 @@ export class Engine {
414
412
  ctx.substituted = substituted;
415
413
  ctx.modified = true;
416
414
  ctx.estTokensOut = estimateTokens(outgoingBody(ctx));
417
- this.store.put(
418
- ctx.chain[ctx.baseCut - 1],
419
- makeEntry(ctx.baseHead, newSummary, ctx.baseCut),
420
- );
415
+ ctx.overBudget = ctx.estTokensOut > this.cfg.thresholdTokens;
421
416
  void reason;
422
417
 
423
418
  return true;
@@ -435,14 +430,15 @@ export class Engine {
435
430
  const emptyBody = objectWithoutKey(ctx.body, ctx.dialect.messagesKey);
436
431
  emptyBody[ctx.dialect.messagesKey] = [];
437
432
  const fixedChars = billableChars(emptyBody);
438
- const thresholdChars = cfg.thresholdTokens * 4;
439
433
 
440
434
  let working: JsonObject[];
441
435
  let headLen: number;
442
436
  let origCut: number;
443
437
  let haveSummary: boolean;
444
438
 
445
- if (ctx.baseCut > 0) {
439
+ // Escalation knobs must recap the original history. Reusing the previous
440
+ // summary cannot apply thought caps: compact() refuses a same-length rewrite.
441
+ if (ctx.baseCut > 0 && compactCfg === null) {
446
442
  working = ctx.substituted.slice(0, ctx.baseHead + 1);
447
443
  headLen = ctx.baseHead;
448
444
  origCut = ctx.baseCut;
@@ -511,11 +507,19 @@ export class Engine {
511
507
  return true;
512
508
  };
513
509
 
510
+ // keepRecent 0 consumes the newest turn too. Wait for its entire run
511
+ // before rewriting, otherwise its following tool result becomes orphaned.
512
+ const safeCuts = knobs.keepRecent === 0 ? turnEnds(msgs, ctx.dialect) : null;
513
+
514
514
  for (let i = origCut; i < msgs.length; i++) {
515
515
  working.push(msgs[i]);
516
516
  chars += cachedMsgChars(msgs[i]);
517
517
 
518
- if (chars > thresholdChars) {
518
+ if (safeCuts !== null && !safeCuts.has(i + 1)) {
519
+ continue;
520
+ }
521
+
522
+ if (Math.trunc((chars - (working.length > 0 ? 2 : 0)) / 4) > cfg.thresholdTokens) {
519
523
  const result = compact(working, ctx.dialect, knobs);
520
524
 
521
525
  if (result === null) {
@@ -551,7 +555,15 @@ export class Engine {
551
555
  ctx.chainSteps = nCompactions;
552
556
  ctx.summaryFp = lastSummary ? summaryFingerprint(lastSummary) : "";
553
557
  ctx.outMsgs = working.length;
554
- this.store.put(ctx.chain[origCut - 1], makeEntry(headLen, lastSummary ?? {}, origCut));
558
+ ctx.overBudget = ctx.estTokensOut > cfg.thresholdTokens;
559
+
560
+ // Only deterministic proactive compaction is reusable by sibling requests.
561
+ if (!force && compactCfg === null) {
562
+ const entry = makeEntry(headLen, structuredClone(lastSummary ?? {}), origCut);
563
+ entry.policy = ctx.policy;
564
+ entry.dialect = ctx.dialect;
565
+ this.store.put(ctx.cacheChain[origCut - 1], entry);
566
+ }
555
567
 
556
568
  return true;
557
569
  }
package/lib/images.ts CHANGED
@@ -22,7 +22,7 @@ export const PATCH_PX = 28;
22
22
 
23
23
  export const MAX_IMAGE_TOKENS = 4_784;
24
24
 
25
- export const DEFAULT_IMAGE_TOKENS = 1_568;
25
+ export const DEFAULT_IMAGE_TOKENS = MAX_IMAGE_TOKENS;
26
26
 
27
27
  const HEADER_B64_CHARS = 8192;
28
28
 
@@ -94,6 +94,12 @@ function jpegDimensions(raw: Uint8Array): [number, number] | null {
94
94
 
95
95
  const marker = raw[i + 1];
96
96
 
97
+ // JPEG permits repeated 0xff fill bytes before a marker code.
98
+ if (marker === 0xff) {
99
+ i += 1;
100
+ continue;
101
+ }
102
+
97
103
  if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
98
104
  i += 2;
99
105
  continue;
@@ -122,20 +128,20 @@ function webpDimensions(raw: Uint8Array): [number, number] | null {
122
128
  const riff = [0x52, 0x49, 0x46, 0x46];
123
129
  const webp = [0x57, 0x45, 0x42, 0x50];
124
130
 
125
- if (!startsWith(raw, riff) || !startsWith(raw, webp, 8) || raw.length < 30) {
131
+ if (!startsWith(raw, riff) || !startsWith(raw, webp, 8) || raw.length < 25) {
126
132
  return null;
127
133
  }
128
134
 
129
135
  const fmt = String.fromCharCode(raw[12], raw[13], raw[14], raw[15]);
130
136
 
131
- if (fmt === "VP8X") {
137
+ if (fmt === "VP8X" && raw.length >= 30) {
132
138
  const w = (raw[24] | (raw[25] << 8) | (raw[26] << 16)) + 1;
133
139
  const h = (raw[27] | (raw[28] << 8) | (raw[29] << 16)) + 1;
134
140
 
135
141
  return [w, h];
136
142
  }
137
143
 
138
- if (fmt === "VP8 ") {
144
+ if (fmt === "VP8 " && raw.length >= 30) {
139
145
  return [readUInt16LE(raw, 26) & 0x3fff, readUInt16LE(raw, 28) & 0x3fff];
140
146
  }
141
147
 
@@ -188,10 +194,6 @@ function ceilDiv(n: number, d: number): number {
188
194
  }
189
195
 
190
196
  export function tokensForPayload(payload: string): number {
191
- if (!payload.startsWith("data:")) {
192
- return DEFAULT_IMAGE_TOKENS;
193
- }
194
-
195
197
  const raw = headerBytes(payload);
196
198
  const dims = raw ? dimensions(raw) : null;
197
199
 
@@ -207,6 +209,10 @@ export function tokensForPayload(payload: string): number {
207
209
  export function imagePayloadFromNode(obj: JsonObject): string | null {
208
210
  const kind = asString(obj.type);
209
211
 
212
+ if (kind === "image_generation_call") {
213
+ return isString(obj.result) ? obj.result : null;
214
+ }
215
+
210
216
  if (kind === "image") {
211
217
  const source = asObject(obj.source);
212
218
 
@@ -214,10 +220,18 @@ export function imagePayloadFromNode(obj: JsonObject): string | null {
214
220
  return source.data;
215
221
  }
216
222
 
217
- return null;
223
+ if (source && isString(source.url)) {
224
+ return source.url;
225
+ }
226
+
227
+ if (isString(obj.data)) {
228
+ return obj.data;
229
+ }
230
+
231
+ return isString(obj.url) ? obj.url : null;
218
232
  }
219
233
 
220
- if (kind === "input_image" || kind === "image_url") {
234
+ if (kind === "input_image" || kind === "image_url" || kind === "computer_screenshot") {
221
235
  const urlField = obj.image_url;
222
236
 
223
237
  if (isString(urlField)) {
@@ -230,6 +244,11 @@ export function imagePayloadFromNode(obj: JsonObject): string | null {
230
244
  return nested.url;
231
245
  }
232
246
 
247
+ // Uploaded image references have no local dimensions, like hosted URLs.
248
+ if ((kind === "input_image" || kind === "computer_screenshot") && isString(obj.file_id)) {
249
+ return obj.file_id;
250
+ }
251
+
233
252
  return null;
234
253
  }
235
254
 
package/lib/index.ts CHANGED
@@ -41,15 +41,18 @@ export {
41
41
  piDialect,
42
42
  SUMMARY_HEADER,
43
43
  PI_COMPACTION_PREFIX,
44
+ BRANCH_SUMMARY_PREFIX,
45
+ BRANCH_SUMMARY_SUFFIX,
44
46
  truncate,
45
47
  stripTaskNotifications,
46
48
  } from "./dialects/index.ts";
47
49
 
48
50
  export type { Dialect } from "./dialects/index.ts";
49
51
 
50
- export { compactSession, liveFromEntries } from "./pi-hook.ts";
52
+ export { compactSession, liveFromEntries, resolveCompactReason } from "./pi-hook.ts";
51
53
 
52
54
  export type {
55
+ CompactReason,
53
56
  CompactSessionInput,
54
57
  CompactSessionOutput,
55
58
  SessionMessageRef,