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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ Tests live in `tests/`. Public docs use `--`. `bench/` stays local.
6
+
3
7
  ## 0.1.0
4
8
 
5
9
  First release. Port of CliffCompaction (Nguyen, Cho, Chen & Dettmers,
package/README.md CHANGED
@@ -23,7 +23,7 @@ After install, **fully restart** Pi/OMP. `/reload` can keep old JavaScript modul
23
23
 
24
24
  ---
25
25
 
26
- ## Why use it
26
+ ## Compared to Pi compact
27
27
 
28
28
  Default Pi compact asks a model to write a structured memo (`Goal`, `Progress`, `Key Decisions`, file lists). Tool dumps are truncated for that summarizer, then rewritten. The last ~20k tokens stay verbatim. The previous memo is fed into the next one.
29
29
 
@@ -49,11 +49,6 @@ This package keeps the same *when* (token window, overflow, `/compact`) and repl
49
49
 
50
50
  ## Install
51
51
 
52
- ```bash
53
- pi install npm:pi-cliffcompaction
54
- omp install npm:pi-cliffcompaction
55
- ```
56
-
57
52
  From a clone of [AdityaVG13/pi-stack](https://github.com/AdityaVG13/pi-stack), inside the repo:
58
53
 
59
54
  ```bash
@@ -201,7 +196,7 @@ Override path: `PI_CLIFF_CONFIG` / `OMP_CLIFF_CONFIG`, or `PI_CONFIG_DIR` / `OMP
201
196
  | `keepThinking` | `true` | Fold thinking as text; `false` drops it |
202
197
  | `cmdMaxChars` | `150` | Tool-call signature budget |
203
198
  | `resultMaxChars` | `500` | Longer tool results are dropped |
204
- | `humanMaxChars` | `20000` | Sanity cap on user text in the summary |
199
+ | `humanMaxChars` | `20000` | Cap on summarized middle user text; not the protected live head |
205
200
  | `shadow` | `false` | Cancel compaction; log what would have happened |
206
201
  | `strict` | `false` | Walk summary truncation (rung 3) when still over the library threshold |
207
202
  | `thresholdTokens` | `200000` | Engine/library trigger (chars/4). Pi's own trigger is separate |
@@ -235,7 +230,9 @@ The Pi extension is a thin adapter. The algorithm is importable with no Pi host:
235
230
  | `Engine.reactive(ctx)` | Context-length error ladder. |
236
231
  | Dialects | `anthropic`, `openai` (Chat Completions), `openai-responses`, `pi` |
237
232
 
238
- Token estimate is chars/4 on `json.dumps`-style serialization, except images which are priced from PNG/JPEG/GIF/WebP dimensions (Anthropic 28x28 patches, cap 4784).
233
+ Token estimates use the floor of serialized UTF-16 code units / 4, with Python-style separators but JavaScript number/string semantics. This is not a tokenizer. Images use PNG/JPEG/GIF/WebP dimensions (28x28 patches, capped at 4784); raw base64, data URLs and Pi image blocks are recognized. Unknown dimensions conservatively cost 4784 without fetching remote URLs.
234
+
235
+ Digests are implementation-local: key ordering, floating-point formatting and Unicode counts can differ from CPython. The shared ASCII reference fixtures do not establish general cross-language hash compatibility. Library inputs must be JSON data.
239
236
 
240
237
  ---
241
238
 
@@ -244,15 +241,20 @@ Token estimate is chars/4 on `json.dumps`-style serialization, except images whi
244
241
  - Summaries contain only excerpts of original text, never a paraphrase.
245
242
  - Exactly one summary header after compaction; re-compaction does not nest.
246
243
  - Head messages and kept tail messages are identity-equal to the input objects (library `compact` / `Engine`).
247
- - A mutated history fails to match the prefix store and is forwarded verbatim.
244
+ - `Engine.prepare` validates every message against its current serialized content, including nested in-place edits. Only identical content can reuse a digest/cost; hash-chain prefixes are reused only while canonical digests agree. The engine retains snapshots for its latest request, not every visited branch.
245
+ - Cached summaries are scoped to the dialect, configuration and fixed request budget. Emergency escalation/truncation stays request-local. Changed canonical content cannot reuse a summary covering the old content.
246
+ - Prefix-store entry and serialized UTF-8 byte caps are hard admission limits. Oversized entries are not cached and do not evict useful entries; zero capacity disables admission. These are not total heap limits.
247
+ - OpenAI Chat summary keys include the effective `reasoning_content` / `reasoning` text so reasoning redactions invalidate old summaries.
248
248
  - Image token cost does not track base64 length.
249
249
 
250
250
  ## Error model
251
251
 
252
252
  - `compact` returns `null` when there is no assistant turn, not enough turns to keep, or the rewrite would not shrink the list.
253
253
  - `Engine.prepare` never throws on a well-formed body; store misses and inconsistent entries fail-open to passthrough.
254
- - The Pi hook catches handler errors and returns undefined (Pi default compaction).
255
- - `strict: true` on the engine marks `overBudget` after the ladder; the Pi hook uses rung 3 truncation when `strict` is set.
254
+ - The Pi hook catches handler errors and returns undefined (Pi default compaction). Protected heads containing images or other non-text content also use this fallback, rather than silently losing attachments. The fallback may use an LLM and does not preserve image bytes verbatim.
255
+ - Pi compaction keeps the original head across successive cliffs, without recycling an old summary or retaining the discarded middle. Protected head text is not trimmed, truncated or interpreted as a summary marker.
256
+ - Strict truncation packs against the actual local billable estimate, including escaping and the kept tail. Library head messages and the Pi hook's folded live head text are protected, even when that floor cannot fit.
257
+ - The engine exposes `overBudget`; the Pi hook persists `details.overBudget` with its estimate. Neither silently drops the protected task or tail to force a fit.
256
258
 
257
259
  ## Tests
258
260
 
@@ -260,16 +262,15 @@ Token estimate is chars/4 on `json.dumps`-style serialization, except images whi
260
262
  npm test --prefix packages/pi-cliffcompaction
261
263
  ```
262
264
 
263
- 119 tests, including `test/reference-gold.test.mjs`: bit-level checks against the Python reference on shared fixtures (compact output, hash chains, billable chars, `Engine.prepare` cuts/estimates, published image-token table).
265
+ Tests cover content-validated caching/redaction, warm/cold equivalence, escaped budget boundaries, cache admission, image accounting and protected Pi task text. `tests/reference-gold.test.mjs` checks the shared reference fixtures; `tests/json.test.mjs` records the JavaScript-local compatibility boundaries.
264
266
 
265
267
  ## No-claim boundaries
266
268
 
267
269
  - This package does **not** claim the paper's SWE-bench / Terminal-Bench / KernelBench scores. Those were measured on other scaffolds with this algorithm.
268
270
  - Trigger timing is Pi's (`compaction.reserveTokens`). The GitHub proxy default of 200k tokens is a library default, not what Pi uses unless you set Pi's reserve so the remaining window matches.
269
271
  - Selector / Soft Group Verification from the paper is out of scope.
270
-
271
- ## Gotchas
272
-
272
+ - Model-level hash identity is conditional on collision freedom for the compared inputs. No global fixed-length hash injectivity, SHA-256 collision proof, or Lean-to-TypeScript refinement proof is claimed.
273
+ - Redacting a request prevents stale-summary reuse for that history; it does not securely erase older prefix-store entries from memory before eviction.
273
274
  - Other extensions that also handle `session_before_compact` will race. Load one.
274
275
  - `/compact` with extra instructions is ignored.
275
276
  - Shadow mode on overflow cancels recovery compaction; the overflowing request is left as-is (fail-open).
package/index.ts CHANGED
@@ -15,7 +15,14 @@ import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent
15
15
  import { convertToLlm } from "@earendil-works/pi-coding-agent";
16
16
  import { loadConfig, type Config } from "./lib/config.ts";
17
17
  import { isRecord, type JsonObject, type JsonValue } from "./lib/decode.ts";
18
- import { compactSession, liveFromEntries, type CliffDetails, type HookEntry } from "./lib/pi-hook.ts";
18
+ import {
19
+ compactSession,
20
+ liveFromEntries,
21
+ resolveCompactReason,
22
+ type CliffDetails,
23
+ type CompactReason,
24
+ type HookEntry,
25
+ } from "./lib/pi-hook.ts";
19
26
 
20
27
  type LastCliff = {
21
28
  reason: string;
@@ -23,6 +30,10 @@ type LastCliff = {
23
30
  details: CliffDetails;
24
31
  };
25
32
 
33
+ function eventReason(event: object): unknown {
34
+ return Object.prototype.hasOwnProperty.call(event, "reason") ? (event as { reason?: unknown }).reason : undefined;
35
+ }
36
+
26
37
  function jsonObjectFrom(value: JsonValue): JsonObject {
27
38
  // SAFETY: LLM Message is JSON-serializable; reparse to JsonObject.
28
39
  const parsed: JsonValue = JSON.parse(JSON.stringify(value));
@@ -61,6 +72,15 @@ function hookEntriesFromBranch(branchEntries: readonly SessionEntry[]): HookEntr
61
72
  continue;
62
73
  }
63
74
 
75
+ if (entry.type === "branch_summary" && entry.summary) {
76
+ out.push({
77
+ id: entry.id,
78
+ kind: "branch_summary",
79
+ summary: entry.summary,
80
+ });
81
+ continue;
82
+ }
83
+
64
84
  if (entry.type === "compaction") {
65
85
  out.push({
66
86
  id: entry.id,
@@ -102,6 +122,7 @@ function formatStatus(cfg: Config, last: LastCliff | null): string {
102
122
  export default function registerCliffCompaction(pi: ExtensionAPI) {
103
123
  let cfg = loadConfig();
104
124
  let last: LastCliff | null = null;
125
+ let pendingReason: CompactReason = "manual";
105
126
 
106
127
  pi.registerCommand("cliff", {
107
128
  description: "CliffCompaction status and config (mechanical autocompaction)",
@@ -125,12 +146,24 @@ export default function registerCliffCompaction(pi: ExtensionAPI) {
125
146
  },
126
147
  });
127
148
 
149
+ // session_before_compact has no reason. Auto-compaction emits this first.
150
+ pi.on("auto_compaction_start", (event) => {
151
+ pendingReason = resolveCompactReason(eventReason(event));
152
+ });
153
+
154
+ pi.on("auto_compaction_end", () => {
155
+ pendingReason = "manual";
156
+ });
157
+
128
158
  pi.on("session_before_compact", async (event, ctx) => {
129
159
  if (!cfg.enabled) {
130
160
  return;
131
161
  }
132
162
 
133
- const { preparation, branchEntries, reason } = event;
163
+ const { preparation, branchEntries } = event;
164
+ const fromEvent = resolveCompactReason(eventReason(event));
165
+ const reason = fromEvent !== "manual" ? fromEvent : pendingReason;
166
+ pendingReason = "manual";
134
167
 
135
168
  try {
136
169
  const entries = hookEntriesFromBranch(branchEntries);
@@ -180,6 +213,6 @@ export default function registerCliffCompaction(pi: ExtensionAPI) {
180
213
  return;
181
214
  }
182
215
 
183
- ctx.ui.setStatus("cliff", "cliff · compacted (" + event.reason + ")");
216
+ ctx.ui.setStatus("cliff", "cliff · compacted (" + (last?.reason ?? "done") + ")");
184
217
  });
185
218
  }
package/lib/cliff.ts CHANGED
@@ -70,6 +70,15 @@ export function compact(
70
70
 
71
71
  let headLen = firstAssistant;
72
72
 
73
+ // A cliff followed by another user prompt is no longer the final head item.
74
+ // Only the original prefix before that cliff remains protected on a recap.
75
+ for (let i = 0; i < firstAssistant; i++) {
76
+ if (dialect.isSummaryMessage(messages[i])) {
77
+ headLen = i;
78
+ break;
79
+ }
80
+ }
81
+
73
82
  while (
74
83
  headLen > 0 &&
75
84
  (dialect.isSummaryMessage(messages[headLen - 1]) ||
@@ -124,11 +133,32 @@ export function compact(
124
133
  newMessages.push(msg);
125
134
  }
126
135
 
127
- if (newMessages.length >= messages.length) {
136
+ const cut = messages.length - kept.length;
137
+
138
+ if (newMessages.length > messages.length) {
128
139
  return null;
129
140
  }
130
141
 
131
- const cut = messages.length - kept.length;
142
+ // keepRecent 0 replaces a one-message body with one summary. Message count
143
+ // stays the same; thought/result caps can still shrink the payload.
144
+ // Do not treat "drop the previous summary, keep the same tail" as gain:
145
+ // that is a same-length rewrite that throws away compacted history.
146
+ if (newMessages.length === messages.length) {
147
+ let onlySummaries = true;
148
+ let oldChars = 0;
149
+
150
+ for (let i = headLen; i < cut; i++) {
151
+ if (!dialect.isSummaryMessage(messages[i])) {
152
+ onlySummaries = false;
153
+ }
154
+
155
+ oldChars += JSON.stringify(messages[i]).length;
156
+ }
157
+
158
+ if (onlySummaries || JSON.stringify(summary).length >= oldChars) {
159
+ return null;
160
+ }
161
+ }
132
162
 
133
163
  return { messages: newMessages, headLen, summary, cut };
134
164
  }
package/lib/config.ts CHANGED
@@ -70,9 +70,15 @@ function envInt(name: string, fallback: number): number {
70
70
  return fallback;
71
71
  }
72
72
 
73
- const n = Number.parseInt(raw, 10);
73
+ const trimmed = raw.trim();
74
74
 
75
- if (!Number.isFinite(n)) {
75
+ if (!/^[+-]?\d+$/.test(trimmed)) {
76
+ return fallback;
77
+ }
78
+
79
+ const n = Number.parseInt(trimmed, 10);
80
+
81
+ if (!Number.isSafeInteger(n) || n < 0) {
76
82
  return fallback;
77
83
  }
78
84
 
@@ -116,7 +122,7 @@ export function replaceConfig(cfg: Config, patch: ConfigPatch): Config {
116
122
  function intField(obj: JsonObject, snake: string, camel: string, fallback: number): number {
117
123
  const raw = obj[snake] ?? obj[camel];
118
124
 
119
- if (isNumber(raw) && Number.isInteger(raw)) {
125
+ if (isNumber(raw) && Number.isInteger(raw) && raw >= 0) {
120
126
  return raw;
121
127
  }
122
128
 
@@ -183,6 +189,12 @@ export function defaultConfigPath(): string {
183
189
  return explicit;
184
190
  }
185
191
 
192
+ const configDir = envRaw("PI_CONFIG_DIR") || envRaw("OMP_CONFIG_DIR");
193
+
194
+ if (configDir) {
195
+ return join(configDir, "cliffcompaction.json");
196
+ }
197
+
186
198
  const home = homedir();
187
199
  const piDir = envRaw("PI_CONFIG_DIR") ?? join(home, ".pi", "agent");
188
200
  const ompDir = envRaw("OMP_CONFIG_DIR") ?? join(home, ".omp", "agent");
package/lib/decode.ts CHANGED
@@ -2,9 +2,9 @@
2
2
  * Boundary decoders (anti-slop). JSON request bodies are untyped at the
3
3
  * I/O edge; everything inside the engine consumes these predicates.
4
4
  *
5
- * Hot-path checks use constructor identity: JSON.parse and object
6
- * literals never box primitives. Null-prototype objects still count as
7
- * records (Object.create(null).constructor is undefined).
5
+ * Primitive checks use constructor identity: JSON data never boxes
6
+ * primitives. Records use prototype identity so an own constructor field
7
+ * remains data. Null-prototype objects also count as records.
8
8
  */
9
9
 
10
10
  export type JsonPrimitive = string | number | boolean | null;
@@ -45,9 +45,9 @@ export function isObject(value: JsonValue | undefined | null): value is JsonObje
45
45
  return false;
46
46
  }
47
47
 
48
- const ctor = value.constructor;
48
+ const proto = Object.getPrototypeOf(value);
49
49
 
50
- return ctor === Object || ctor === undefined;
50
+ return proto === Object.prototype || proto === null;
51
51
  }
52
52
 
53
53
  export function isRecord(value: JsonValue | undefined | null): value is JsonObject {
@@ -41,6 +41,8 @@ function resultText(content: JsonValue | undefined): string {
41
41
  for (const p of content) {
42
42
  if (isRecord(p) && asString(p.type) === "text") {
43
43
  parts.push(asString(p.text));
44
+ } else if (isRecord(p) && asString(p.type) === "search_result") {
45
+ parts.push(resultText(p.content));
44
46
  } else if (isString(p)) {
45
47
  parts.push(p);
46
48
  }
@@ -68,12 +70,25 @@ function canonBlock(block: JsonObject): JsonArray {
68
70
  }
69
71
 
70
72
  if (t === "tool_result") {
71
- return [
73
+ const result: JsonArray = [
72
74
  "tool_result",
73
75
  asString(block.tool_use_id),
74
76
  resultText(block.content),
75
77
  asBoolean(block.is_error, false),
76
78
  ];
79
+
80
+ const attachments: JsonArray = [];
81
+
82
+ for (const part of asArray(block.content)) {
83
+ if (isRecord(part) && (asString(part.type) === "image" || asString(part.type) === "document")) {
84
+ attachments.push(canonBlock(part));
85
+ }
86
+ }
87
+
88
+ // Dropped attachment bytes still determine whether the original turn triggers a cliff.
89
+ if (attachments.length > 0) result.push(attachments);
90
+
91
+ return result;
77
92
  }
78
93
 
79
94
  if (t === "thinking") {
@@ -86,6 +101,19 @@ function canonBlock(block: JsonObject): JsonArray {
86
101
 
87
102
  if (t === "image" || t === "document") {
88
103
  const src = asObject(block.source) ?? {};
104
+
105
+ if (t === "document" && asString(src.type) === "content") {
106
+ const blocks: JsonArray = [];
107
+
108
+ for (const part of asArray(src.content)) {
109
+ if (isRecord(part)) {
110
+ blocks.push(canonBlock(part));
111
+ }
112
+ }
113
+
114
+ return [t, "content", digestObj(isString(src.content) ? src.content : blocks)];
115
+ }
116
+
89
117
  const payload = asString(src.data) || asString(src.url);
90
118
  const h = digestBytes(payload);
91
119
 
@@ -207,9 +235,28 @@ function summarizeAssistant(msg: JsonObject, cfg: Config): string[] {
207
235
  if (cfg.keepThinking) {
208
236
  thinkings.push(asString(b.thinking));
209
237
  }
210
- } else if (t === "tool_use") {
238
+ } else if (t === "tool_use" || t === "server_tool_use" || t === "mcp_tool_use") {
211
239
  const args = canonicalJson(b.input ?? {});
212
240
  sigs.push("[" + asString(b.name, "?") + "] " + truncate(args, cfg.cmdMaxChars));
241
+ } else if (t === "mcp_tool_result" || t === "bash_code_execution_tool_result" || t === "code_execution_tool_result"
242
+ || t === "text_editor_code_execution_tool_result"
243
+ || (t === "web_search_tool_result" && asString(asObject(b.content)?.type) === "web_search_tool_result_error")
244
+ || (t === "web_fetch_tool_result" && asString(asObject(b.content)?.type) === "web_fetch_tool_result_error")
245
+ || (t === "tool_search_tool_result" && asString(asObject(b.content)?.type) === "tool_search_tool_result_error")) {
246
+ const content = asObject(b.content);
247
+
248
+ if (t === "text_editor_code_execution_tool_result"
249
+ && asString(content?.type) === "text_editor_code_execution_view_result"
250
+ && asString(content?.file_type) !== "text") {
251
+ continue;
252
+ }
253
+
254
+ const text = t === "mcp_tool_result"
255
+ ? resultText(b.content).trim() : canonicalJson(b.content ?? null);
256
+
257
+ if (text && text.length <= cfg.resultMaxChars) {
258
+ sigs.push("result: " + text);
259
+ }
213
260
  }
214
261
  }
215
262
  }
@@ -293,7 +340,7 @@ function summarizeUser(msg: JsonObject, cfg: Config): string[] {
293
340
  }
294
341
 
295
342
  parts.push("user: " + truncate(text.trim(), cfg.humanMaxChars));
296
- } else if (t === "tool_result") {
343
+ } else if (t === "tool_result" || t === "search_result") {
297
344
  const text = resultText(b.content).trim();
298
345
 
299
346
  if (text && text.length <= cfg.resultMaxChars) {
@@ -15,6 +15,12 @@ export const SUMMARY_HEADER =
15
15
  export const PI_COMPACTION_PREFIX =
16
16
  "The conversation history before this point was compacted into the following summary:";
17
17
 
18
+ /** Match Pi's convertToLlm wrapping of /tree branch_summary entries. */
19
+ export const BRANCH_SUMMARY_PREFIX =
20
+ "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
21
+
22
+ export const BRANCH_SUMMARY_SUFFIX = "</summary>";
23
+
18
24
  const TASK_NOTIFICATION_RE = /<task-notification>[\s\S]*?<\/task-notification>\s*/g;
19
25
 
20
26
  export function stripTaskNotifications(text: string): string {
@@ -36,6 +42,28 @@ export function startsWithSummaryHeader(text: string): boolean {
36
42
  return text.startsWith(SUMMARY_HEADER) || text.startsWith(PI_COMPACTION_PREFIX);
37
43
  }
38
44
 
45
+ /** Keep the newest contiguous summary parts that fit the caller's wire budget.
46
+ * The protected prefix survives even when it alone cannot fit.
47
+ */
48
+ export function fitSummary(prefix: string, parts: string[], fits: (text: string) => boolean): string {
49
+ let text = prefix;
50
+ // Header-only prefixes need a blank line before the first part. A prefix that
51
+ // already holds protected parts (folded Pi head) must keep the --- delimiter.
52
+ const glue = prefix.includes("\n\n") ? "\n\n---\n\n" : "\n\n";
53
+
54
+ for (let i = parts.length - 1; i >= 0; i--) {
55
+ const candidate = prefix + glue + parts.slice(i).join("\n\n---\n\n");
56
+
57
+ if (!fits(candidate)) {
58
+ break;
59
+ }
60
+
61
+ text = candidate;
62
+ }
63
+
64
+ return text;
65
+ }
66
+
39
67
  export type Dialect = {
40
68
  name: string;
41
69
  digestMessage: (msg: JsonObject) => string;
@@ -11,7 +11,14 @@ import type { Dialect } from "./base.ts";
11
11
 
12
12
  export type { Dialect } from "./base.ts";
13
13
 
14
- export { SUMMARY_HEADER, PI_COMPACTION_PREFIX, truncate, stripTaskNotifications } from "./base.ts";
14
+ export {
15
+ SUMMARY_HEADER,
16
+ PI_COMPACTION_PREFIX,
17
+ BRANCH_SUMMARY_PREFIX,
18
+ BRANCH_SUMMARY_SUFFIX,
19
+ truncate,
20
+ stripTaskNotifications,
21
+ } from "./base.ts";
15
22
 
16
23
  export { DIALECT as anthropicDialect } from "./anthropic.ts";
17
24
 
@@ -53,8 +53,14 @@ export function contentText(content: JsonValue | undefined): string {
53
53
  const parts: string[] = [];
54
54
 
55
55
  for (const p of content) {
56
- if (isRecord(p) && asString(p.type) === "text") {
57
- parts.push(asString(p.text));
56
+ if (isRecord(p)) {
57
+ const type = asString(p.type);
58
+
59
+ if (type === "text") {
60
+ parts.push(asString(p.text));
61
+ } else if (type === "refusal") {
62
+ parts.push(asString(p.refusal));
63
+ }
58
64
  }
59
65
  }
60
66
 
@@ -68,6 +74,17 @@ export function contentText(content: JsonValue | undefined): string {
68
74
  return String(content);
69
75
  }
70
76
 
77
+ function toolCallsOf(msg: JsonObject): JsonArray {
78
+ const calls = asArray(msg.tool_calls).slice();
79
+ const legacy = asObject(msg.function_call);
80
+
81
+ if (legacy !== null) {
82
+ calls.push({ function: legacy });
83
+ }
84
+
85
+ return calls;
86
+ }
87
+
71
88
  export function digestMessage(msg: JsonObject): string {
72
89
  const content = msg.content;
73
90
  let blocks: JsonArray = [];
@@ -86,15 +103,23 @@ export function digestMessage(msg: JsonObject): string {
86
103
  blocks = out;
87
104
  }
88
105
 
106
+ // Refusal-only completions have null content but still carry assistant text.
107
+ const refusal = asString(msg.refusal);
108
+
109
+ if (refusal) {
110
+ blocks.push(["refusal", refusal]);
111
+ }
112
+
89
113
  const toolCalls: JsonArray = [];
90
114
 
91
- for (const tc of asArray(msg.tool_calls)) {
115
+ for (const tc of toolCallsOf(msg)) {
92
116
  if (!isRecord(tc)) {
93
117
  continue;
94
118
  }
95
119
 
96
- const fn = asObject(tc.function) ?? {};
97
- toolCalls.push([asString(tc.id), asString(fn.name), asString(fn.arguments)]);
120
+ const custom = asString(tc.type) === "custom";
121
+ const fn = asObject(custom ? tc.custom : tc.function) ?? {};
122
+ toolCalls.push([asString(tc.id), asString(fn.name), asString(custom ? fn.input : fn.arguments)]);
98
123
  }
99
124
 
100
125
  return digestObj([
@@ -103,6 +128,8 @@ export function digestMessage(msg: JsonObject): string {
103
128
  toolCalls,
104
129
  asString(msg.tool_call_id),
105
130
  asString(msg.name),
131
+ // Reasoning is summary content, not volatile transport metadata.
132
+ asString(msg.reasoning_content ?? msg.reasoning),
106
133
  ]);
107
134
  }
108
135
 
@@ -129,16 +156,18 @@ function summarizeAssistant(msg: JsonObject, cfg: Config): string[] {
129
156
  }
130
157
  }
131
158
 
132
- const thought = truncate(contentText(msg.content).trim(), cfg.thoughtMaxChars);
159
+ const text = [contentText(msg.content).trim(), asString(msg.refusal).trim()].filter(Boolean).join("\n");
160
+ const thought = truncate(text, cfg.thoughtMaxChars);
133
161
  const sigs: string[] = [];
134
162
 
135
- for (const tc of asArray(msg.tool_calls)) {
163
+ for (const tc of toolCallsOf(msg)) {
136
164
  if (!isRecord(tc)) {
137
165
  continue;
138
166
  }
139
167
 
140
- const fn = asObject(tc.function) ?? {};
141
- let args = fn.arguments;
168
+ const custom = asString(tc.type) === "custom";
169
+ const fn = asObject(custom ? tc.custom : tc.function) ?? {};
170
+ let args = custom ? fn.input : fn.arguments;
142
171
 
143
172
  if (!isString(args)) {
144
173
  args = canonicalJson(args ?? "");
@@ -171,7 +200,7 @@ export function summarizeMessage(msg: JsonObject, cfg: Config): string[] {
171
200
  return summarizeAssistant(msg, cfg);
172
201
  }
173
202
 
174
- if (role === "tool") {
203
+ if (role === "tool" || role === "function") {
175
204
  const text = contentText(msg.content).trim();
176
205
 
177
206
  if (text && text.length <= cfg.resultMaxChars) {