@oh-my-pi/pi-agent-core 18.6.0 → 18.6.3

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.
@@ -77,6 +77,8 @@ export interface CompactionV2Usage {
77
77
  export interface CompactionV2Request {
78
78
  body: OpenAICodexCompactionBody;
79
79
  input: unknown[];
80
+ /** Serialized user-written turns to keep next to the compaction item; defaults to `input`. */
81
+ retainedUserItems: unknown[];
80
82
  retainedMessageBudget: number;
81
83
  sessionId?: string;
82
84
  promptCacheKey?: string;
@@ -190,6 +192,7 @@ export function buildCompactionV2Request(
190
192
  sessionId?: string;
191
193
  promptCacheKey?: string;
192
194
  retainedMessageBudget?: number;
195
+ retainedUserItems?: unknown[];
193
196
  },
194
197
  ): CompactionV2Request {
195
198
  const cacheOptions = { sessionId: options?.sessionId, promptCacheKey: options?.promptCacheKey };
@@ -223,12 +226,14 @@ export function buildCompactionV2RequestFromBody(
223
226
  sessionId?: string;
224
227
  promptCacheKey?: string;
225
228
  retainedMessageBudget?: number;
229
+ retainedUserItems?: unknown[];
226
230
  },
227
231
  ): CompactionV2Request {
228
232
  const input = Array.isArray(body.input) ? body.input : [];
229
233
  return {
230
234
  body: { ...body, model: resolveCompactionV2Model(model), input },
231
235
  input,
236
+ retainedUserItems: options?.retainedUserItems ?? input,
232
237
  retainedMessageBudget: resolveCompactionV2RetainedMessageBudget(options?.retainedMessageBudget),
233
238
  sessionId: options?.sessionId,
234
239
  promptCacheKey: options?.promptCacheKey,
@@ -536,7 +541,7 @@ function finishCompactionV2Collection(
536
541
 
537
542
  const compactionItem = state.compactionItems[0];
538
543
  const { replacementHistory, retainedImageCount } = buildCompactionV2ReplacementHistory(
539
- request.input,
544
+ request.retainedUserItems,
540
545
  compactionItem,
541
546
  request.retainedMessageBudget,
542
547
  );
@@ -665,15 +670,14 @@ function isRetryableCompactionError(error: Error): boolean {
665
670
  // Replacement History
666
671
  // ============================================================================
667
672
 
668
- /** Build Codex-style V2 replacement history from prompt input plus compaction output. */
673
+ /** Build Codex-style V2 replacement history from retention candidates plus compaction output. */
669
674
  export function buildCompactionV2ReplacementHistory(
670
675
  input: unknown[],
671
676
  compactionItem: Record<string, unknown>,
672
677
  retainedMessageBudget = V2_RETAINED_MESSAGE_TOKEN_BUDGET,
673
678
  ): { replacementHistory: Array<Record<string, unknown>>; retainedImageCount: number } {
674
679
  const retained = input.filter(
675
- (item): item is Record<string, unknown> =>
676
- isRecord(item) && isRetainedForCompactionV2(item) && shouldKeepCompactionV2HistoryItem(item),
680
+ (item): item is Record<string, unknown> => isRecord(item) && isRetainedUserMessageForCompactionV2(item),
677
681
  );
678
682
  const replacementHistory = truncateRetainedMessagesForCompactionV2(
679
683
  retained,
@@ -684,17 +688,11 @@ export function buildCompactionV2ReplacementHistory(
684
688
  return { replacementHistory, retainedImageCount };
685
689
  }
686
690
 
687
- function isRetainedForCompactionV2(item: Record<string, unknown>): boolean {
688
- if (item.type !== "message") return false;
689
- const role = stringField(item, "role");
690
- return role === "user" || role === "developer" || role === "system";
691
- }
692
-
693
- function shouldKeepCompactionV2HistoryItem(item: Record<string, unknown>): boolean {
694
- if (item.type !== "message") return item.type === "compaction";
695
- const role = stringField(item, "role");
696
- if (role !== "user") return false;
697
- return !isContextualUserMessage(item);
691
+ function isRetainedUserMessageForCompactionV2(item: Record<string, unknown>): boolean {
692
+ // Responses input messages may omit `type`: omp serializes turns as
693
+ // `{ role, content }`, which the API reads as `type: "message"`.
694
+ const isMessage = item.type === "message" || (item.type === undefined && typeof item.role === "string");
695
+ return isMessage && item.role === "user" && !isContextualUserMessage(item);
698
696
  }
699
697
 
700
698
  function isContextualUserMessage(item: Record<string, unknown>): boolean {
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import {
9
+ type AnthropicCompactionFiles,
9
10
  type Api,
10
11
  type ApiKey,
11
12
  type AssistantMessage,
@@ -676,6 +677,24 @@ export interface SummaryOptions {
676
677
  initiatorOverride?: MessageAttribution;
677
678
  metadata?: Record<string, unknown>;
678
679
  convertToLlm?: ConvertToLlm;
680
+ /**
681
+ * The provider context a live turn sends for `summarized` + `retained`, cut to
682
+ * `summarized`. Anthropic on-demand compaction sends it so retained signed
683
+ * thinking keeps its prefix; without it the request uses `remoteSystemPrompt`,
684
+ * `tools` and `convertToLlm`.
685
+ */
686
+ buildProviderContext?: (
687
+ summarized: AgentMessage[],
688
+ retained: AgentMessage[],
689
+ signal?: AbortSignal,
690
+ ) => Promise<Context>;
691
+ /**
692
+ * Whether a message is a turn the user wrote. Remote Compaction V2 keeps these
693
+ * next to the compaction item. Defaults to `role === "user"`; hosts whose
694
+ * user-initiated turns also arrive as custom messages (e.g. skill invocations)
695
+ * widen it.
696
+ */
697
+ isUserAuthored?: (message: AgentMessage) => boolean;
679
698
  /**
680
699
  * Optional telemetry handle. When provided, every LLM call emitted during
681
700
  * compaction is wrapped in an OTEL chat span tagged with
@@ -1612,6 +1631,8 @@ export async function compact(
1612
1631
  initiatorOverride: options?.initiatorOverride,
1613
1632
  metadata: options?.metadata,
1614
1633
  convertToLlm: options?.convertToLlm,
1634
+ buildProviderContext: options?.buildProviderContext,
1635
+ isUserAuthored: options?.isUserAuthored,
1615
1636
  telemetry: options?.telemetry,
1616
1637
  // Honor /model thinking selection on every fan-out summarizer.
1617
1638
  // Without this propagation, generateSummary / generateTurnPrefixSummary
@@ -1675,7 +1696,17 @@ export async function compact(
1675
1696
  previousRemoteCompaction?.provider === model.provider
1676
1697
  ? previousRemoteCompaction.replacementHistory
1677
1698
  : undefined;
1678
- const messages = (summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages);
1699
+ const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
1700
+ const messages = convertToLlm(remoteMessages);
1701
+ // Replacement history keeps what the user wrote. Summaries, archive
1702
+ // migrations, and custom/hook messages can serialize as user-role items
1703
+ // too, so pick the user's own messages before serialization erases that,
1704
+ // then serialize each one exactly as the request does.
1705
+ const isUserAuthored = summaryOptions.isUserAuthored ?? ((message: AgentMessage) => message.role === "user");
1706
+ const userMessages = convertToLlm(
1707
+ [...messagesToSummarize, ...turnPrefixMessages, ...recentMessages].filter(isUserAuthored),
1708
+ );
1709
+ const retainedUserItems: unknown[] = [...(previousReplacementHistory ?? [])];
1679
1710
  const remoteSystemPrompt = summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT];
1680
1711
  let codexBody: OpenAICodexCompactionBody | undefined;
1681
1712
  let remoteHistory: Array<Record<string, unknown>>;
@@ -1715,8 +1746,19 @@ export async function compact(
1715
1746
  }
1716
1747
  remoteHistory = stripOpenAIResponsesOutputOnlyStatusesForReplay(nativeInput);
1717
1748
  codexBody.input = remoteHistory;
1749
+ for (const message of userMessages) {
1750
+ const userBody = await buildTransformedCodexRequestBody(
1751
+ model,
1752
+ { messages: [message] },
1753
+ { responsesLite: model.useResponsesLite },
1754
+ );
1755
+ retainedUserItems.push(...(userBody.input ?? []));
1756
+ }
1718
1757
  } else {
1719
1758
  remoteHistory = buildOpenAiResponsesCompactionInput(messages, model, previousReplacementHistory);
1759
+ for (const message of userMessages) {
1760
+ retainedUserItems.push(...buildOpenAiResponsesCompactionInput([message], model, undefined));
1761
+ }
1720
1762
  }
1721
1763
  if (remoteHistory.length > 0) {
1722
1764
  try {
@@ -1754,6 +1796,7 @@ export async function compact(
1754
1796
  sessionId: summaryOptions.sessionId,
1755
1797
  promptCacheKey: summaryOptions.promptCacheKey,
1756
1798
  retainedMessageBudget: settings.v2RetainedMessageBudget,
1799
+ retainedUserItems,
1757
1800
  };
1758
1801
  const request = codexBody
1759
1802
  ? buildCompactionV2RequestFromBody(model, { ...codexBody, input: trimmed.input }, requestOptions)
@@ -1781,11 +1824,17 @@ export async function compact(
1781
1824
  // summarization" and keep compaction running on an aborted signal.
1782
1825
  if (signal?.aborted) throw err;
1783
1826
  nativeCompactionError = selectNativeCompactionError(nativeCompactionError, err);
1784
- logger.warn("OpenAI V2 remote compaction failed, falling back to V1 remote compaction", {
1785
- error: err instanceof Error ? err.message : String(err),
1786
- model: model.id,
1787
- provider: model.provider,
1788
- });
1827
+ // Claim the V1 fallback only when the V1 block below will run.
1828
+ logger.warn(
1829
+ shouldUseOpenAiRemoteCompaction(model)
1830
+ ? "OpenAI V2 remote compaction failed, falling back to V1 remote compaction"
1831
+ : "OpenAI V2 remote compaction failed",
1832
+ {
1833
+ error: err instanceof Error ? err.message : String(err),
1834
+ model: model.id,
1835
+ provider: model.provider,
1836
+ },
1837
+ );
1789
1838
  }
1790
1839
  }
1791
1840
  }
@@ -1848,32 +1897,40 @@ export async function compact(
1848
1897
  let nativeSignature: string | undefined;
1849
1898
  let nativeFirstKeptEntryId = firstKeptEntryId;
1850
1899
  let nativeUsedTokens: number | undefined;
1900
+ let nativeRetainedFiles: AnthropicCompactionFiles[] | undefined;
1851
1901
  if (!usedRemoteCompaction && settings.remoteEnabled !== false && shouldUseAnthropicNativeCompaction(model)) {
1852
1902
  const previousNative = getPreservedAnthropicCompactionData(previousPreserveData);
1853
1903
  // Lead with the previous summary as the live context renders it:
1854
1904
  // natively when this provider wrote it, as text otherwise.
1855
1905
  // A prior snapcompact archive is already merged into that summary
1856
- // text. Predate the rewrite marker before all replayed messages so
1906
+ // text. Like the live context, the summary keeps its commit time and
1907
+ // predates its rewrite marker before all replayed messages, so
1857
1908
  // retained thinking stays bound to the original prefix.
1858
1909
  const firstReplayed = messagesToSummarize[0] ?? turnPrefixMessages[0] ?? recentMessages[0];
1859
- const previousSummaryAt =
1860
- firstReplayed !== undefined ? new Date(firstReplayed.timestamp - 1).toISOString() : new Date().toISOString();
1861
1910
  const previousSummaryMessage = previousSummaryForCompaction
1862
- ? createCompactionSummaryMessage(previousSummaryForCompaction, tokensBefore, previousSummaryAt, {
1863
- providerPayload:
1864
- previousNative?.provider === model.provider
1865
- ? {
1866
- type: "anthropicCompaction",
1867
- provider: previousNative.provider,
1868
- content: previousNative.content,
1869
- ...(previousNative.signature ? { signature: previousNative.signature } : {}),
1870
- ...(previousNative.encryptedContent
1871
- ? { encryptedContent: previousNative.encryptedContent }
1872
- : {}),
1873
- ...(previousNative.filesText ? { filesText: previousNative.filesText } : {}),
1874
- }
1875
- : undefined,
1876
- })
1911
+ ? createCompactionSummaryMessage(
1912
+ previousSummaryForCompaction,
1913
+ tokensBefore,
1914
+ preparation.previousSummaryTimestamp ?? new Date().toISOString(),
1915
+ {
1916
+ historyRewriteAt: firstReplayed !== undefined ? firstReplayed.timestamp - 1 : undefined,
1917
+ providerPayload:
1918
+ previousNative?.provider === model.provider
1919
+ ? {
1920
+ type: "anthropicCompaction",
1921
+ provider: previousNative.provider,
1922
+ content: previousNative.content,
1923
+ ...(previousNative.signature ? { signature: previousNative.signature } : {}),
1924
+ ...(previousNative.encryptedContent
1925
+ ? { encryptedContent: previousNative.encryptedContent }
1926
+ : {}),
1927
+ ...(previousNative.filesText ? { filesText: previousNative.filesText } : {}),
1928
+ ...(previousNative.retainedFiles ? { retainedFiles: previousNative.retainedFiles } : {}),
1929
+ ...(previousNative.exactTail ? { exactTail: true as const } : {}),
1930
+ }
1931
+ : undefined,
1932
+ },
1933
+ )
1877
1934
  : undefined;
1878
1935
  const allMessages = [...messagesToSummarize, ...turnPrefixMessages, ...recentMessages];
1879
1936
  const originalCut = messagesToSummarize.length + turnPrefixMessages.length;
@@ -1891,20 +1948,57 @@ export async function compact(
1891
1948
  ? firstKeptEntryId
1892
1949
  : (recentEntryIds?.[safeCut - originalCut] ?? "");
1893
1950
  for (let i = originalCut; i < safeCut; i++) extractFileOpsFromMessage(allMessages[i], fileOps);
1894
- const messages = (summaryOptions.convertToLlm ?? defaultConvertToLlm)([
1951
+ const summarizedMessages = [
1895
1952
  ...(previousSummaryMessage ? [previousSummaryMessage] : []),
1896
1953
  ...allMessages.slice(0, safeCut),
1897
- ]);
1954
+ ];
1955
+ // Earlier file metadata replays before the first message created after
1956
+ // its summary. Metadata due inside the new retained tail stays out of the
1957
+ // request, and the new summary keeps replaying it. So does metadata due
1958
+ // right at the cut when the first kept message is a user-side turn: live
1959
+ // requests sent it merged into that turn, and ending the request with it
1960
+ // would merge the summarized range into the kept turn instead, which
1961
+ // invalidates the kept thinking.
1962
+ const firstRetained = allMessages[safeCut];
1963
+ const filesDueBefore =
1964
+ firstRetained === undefined
1965
+ ? undefined
1966
+ : firstRetained.role === "assistant"
1967
+ ? firstRetained.timestamp
1968
+ : (allMessages.slice(0, safeCut).findLast(message => message.role !== "toolResult")?.timestamp ??
1969
+ Number.NEGATIVE_INFINITY);
1970
+ const previousPayload = previousSummaryMessage?.providerPayload;
1971
+ if (
1972
+ filesDueBefore !== undefined &&
1973
+ previousSummaryMessage !== undefined &&
1974
+ previousPayload?.type === "anthropicCompaction" &&
1975
+ previousPayload.exactTail
1976
+ ) {
1977
+ const carried = [
1978
+ ...(previousPayload.retainedFiles ?? []),
1979
+ ...(previousPayload.filesText
1980
+ ? [{ text: previousPayload.filesText, after: previousSummaryMessage.timestamp }]
1981
+ : []),
1982
+ ].filter(files => files.after >= filesDueBefore);
1983
+ if (carried.length > 0) nativeRetainedFiles = carried;
1984
+ }
1898
1985
  try {
1986
+ // The live turn's own system prompt, wire tools and transformed
1987
+ // history when the host can build them: kept thinking remains valid
1988
+ // only when the summarized prefix matches what was sent byte for byte.
1989
+ const context = summaryOptions.buildProviderContext
1990
+ ? await summaryOptions.buildProviderContext(summarizedMessages, allMessages.slice(safeCut), signal)
1991
+ : {
1992
+ systemPrompt: summaryOptions.remoteSystemPrompt ?? [],
1993
+ messages: (summaryOptions.convertToLlm ?? defaultConvertToLlm)(summarizedMessages),
1994
+ tools: summaryOptions.tools,
1995
+ };
1899
1996
  const remote = await requestAnthropicNativeCompaction(
1900
1997
  model,
1901
1998
  apiKey,
1902
1999
  {
1903
- // The live prompt, not the local summarizer's synthetic system
1904
- // prompt: kept thinking remains valid only under identical controls.
1905
- systemPrompt: summaryOptions.remoteSystemPrompt ?? [],
1906
- messages,
1907
- tools: summaryOptions.tools,
2000
+ context,
2001
+ filesDueBefore,
1908
2002
  instructions: buildAnthropicCompactionInstructions(
1909
2003
  summaryOptions.promptOverride ?? SUMMARIZATION_PROMPT,
1910
2004
  customInstructions,
@@ -2026,6 +2120,8 @@ export async function compact(
2026
2120
  content: nativeSummary,
2027
2121
  ...(nativeSignature ? { signature: nativeSignature } : {}),
2028
2122
  ...(filesText ? { filesText } : {}),
2123
+ ...(nativeRetainedFiles ? { retainedFiles: nativeRetainedFiles } : {}),
2124
+ exactTail: true,
2029
2125
  model: model.id,
2030
2126
  usedTokens: nativeUsedTokens,
2031
2127
  });
@@ -101,8 +101,8 @@ export interface TtsrInjectionEntry extends SessionEntryBase {
101
101
 
102
102
  export interface SessionInitEntry extends SessionEntryBase {
103
103
  type: "session_init";
104
- /** Full system prompt sent to the model */
105
- systemPrompt: string;
104
+ /** System prompt blocks exactly as sent to the model; files written before blocks were kept store one joined string. */
105
+ systemPrompt: string[] | string;
106
106
  /** Initial task/user message */
107
107
  task: string;
108
108
  /** Tools available to the agent */
@@ -53,6 +53,7 @@ import {
53
53
  OPENAI_HEADERS,
54
54
  } from "@oh-my-pi/pi-catalog/wire/codex";
55
55
  import { $env, isRecord, logger, prompt, ptree, stringifyJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
56
+ import { dataUrlImageSize, estimateImageTokens } from "../image-tokens";
56
57
  import { Tokenizer } from "../tokenizer";
57
58
  import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
58
59
  import { prepareBedrockCompactionRequest } from "./bedrock";
@@ -80,7 +81,8 @@ export const REMOTE_COMPACTION_TIMEOUT_MS = 300_000;
80
81
  export const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE = prompt.render(contextWindowTruncatedOutputPrompt);
81
82
 
82
83
  const REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS = 256;
83
- const REMOTE_COMPACTION_IMAGE_TOKEN_ESTIMATE = 12_000;
84
+ /** Per-`input_image` record estimates; trim re-probes share untouched items by reference. */
85
+ const remoteImageTokenCache = new WeakMap<Record<string, unknown>, number>();
84
86
  const TOOL_RESULT_IMAGE_ATTACHMENT_TEXT = "Attached image(s) from tool result:";
85
87
 
86
88
  interface NormalizedEstimateValue {
@@ -88,12 +90,12 @@ interface NormalizedEstimateValue {
88
90
  imageTokens: number;
89
91
  }
90
92
 
91
- function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstimateValue {
93
+ function normalizeRemoteCompactionEstimateValue(value: unknown, dropEncrypted = true): NormalizedEstimateValue {
92
94
  if (Array.isArray(value)) {
93
95
  const normalized: unknown[] = [];
94
96
  let imageTokens = 0;
95
97
  for (const item of value) {
96
- const result = normalizeRemoteCompactionEstimateValue(item);
98
+ const result = normalizeRemoteCompactionEstimateValue(item, dropEncrypted);
97
99
  normalized.push(result.value);
98
100
  imageTokens += result.imageTokens;
99
101
  }
@@ -103,10 +105,16 @@ function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstim
103
105
 
104
106
  const record = value as Record<string, unknown>;
105
107
  if (record.type === "input_image") {
106
- return {
107
- value: { ...record, image_url: "<image>" },
108
- imageTokens: REMOTE_COMPACTION_IMAGE_TOKEN_ESTIMATE,
109
- };
108
+ let imageTokens = remoteImageTokenCache.get(record);
109
+ if (imageTokens === undefined) {
110
+ const detail = record.detail;
111
+ imageTokens = estimateImageTokens(
112
+ typeof record.image_url === "string" ? dataUrlImageSize(record.image_url) : null,
113
+ detail === "low" || detail === "high" || detail === "original" || detail === "auto" ? detail : undefined,
114
+ );
115
+ remoteImageTokenCache.set(record, imageTokens);
116
+ }
117
+ return { value: { ...record, image_url: "<image>" }, imageTokens };
110
118
  }
111
119
 
112
120
  const normalized: Record<string, unknown> = {};
@@ -115,20 +123,35 @@ function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstim
115
123
  // Opaque encrypted reasoning/compaction state: its local base64 size far
116
124
  // exceeds what the provider bills, so it stays out of the fit estimate
117
125
  // (same policy as `MessageCountOptions.excludeEncryptedReasoning`).
118
- if (key === "encrypted_content" && typeof item === "string") continue;
119
- const result = normalizeRemoteCompactionEstimateValue(item);
126
+ if (dropEncrypted && key === "encrypted_content" && typeof item === "string") continue;
127
+ const result = normalizeRemoteCompactionEstimateValue(item, dropEncrypted);
120
128
  normalized[key] = result.value;
121
129
  imageTokens += result.imageTokens;
122
130
  }
123
131
  return { value: normalized, imageTokens };
124
132
  }
125
133
 
134
+ /**
135
+ * Tokens of stored Responses replacement history. Retained `input_image` parts
136
+ * count at their image estimate rather than as base64 text; everything else,
137
+ * opaque compaction state included, counts as serialized.
138
+ */
139
+ export function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number {
140
+ const normalized = normalizeRemoteCompactionEstimateValue(items, false);
141
+ const serialized = stringifyJson(normalized.value);
142
+ return (serialized === undefined ? 0 : tokenizer.countTokens(serialized)) + normalized.imageTokens;
143
+ }
144
+
126
145
  export interface TrimRemoteCompactionInputResult {
127
146
  input: Array<Record<string, unknown>>;
128
147
  rewrittenOutputs: number;
129
148
  estimatedTokensBefore: number;
130
149
  estimatedTokensAfter: number;
131
- /** Whether `input` fits the model window; false means it must not be sent. */
150
+ /**
151
+ * Whether `input` may be sent; false means it must not be. True when the
152
+ * full estimate fits, or when only the image estimate pushes it over (image
153
+ * pricing is a guess; the provider's own accounting decides).
154
+ */
132
155
  fits: boolean;
133
156
  }
134
157
 
@@ -138,15 +161,17 @@ interface RemoteCompactionBudgetProbe {
138
161
  tokens: number;
139
162
  /** Whether the request fits the window. Always true when no window is known. */
140
163
  fits: boolean;
164
+ /** Whether the request minus its image estimate fits the window. */
165
+ textFits: boolean;
141
166
  }
142
167
 
143
168
  /**
144
- * Cheap-first sizing of a remote-compaction request. Images and the request
145
- * frame are charged flat, so they come off the budget rather than through the
146
- * tokenizer; opaque `encrypted_content` payloads are excluded. The serialized
147
- * transcript is then probed with {@link Tokenizer.checkTokenBudget}, which only
148
- * pays for an exact count when the byte bound cannot already prove the request
149
- * fits.
169
+ * Cheap-first sizing of a remote-compaction request. Images (priced from their
170
+ * dimensions) and the request frame come off the budget rather than through
171
+ * the tokenizer; opaque `encrypted_content` payloads are excluded. The
172
+ * serialized transcript is then probed with {@link Tokenizer.checkTokenBudget},
173
+ * which only pays for an exact count when the byte bound cannot already prove
174
+ * the request fits.
150
175
  */
151
176
  function probeRemoteCompactionInputBudget(
152
177
  input: Array<Record<string, unknown>>,
@@ -159,10 +184,15 @@ function probeRemoteCompactionInputBudget(
159
184
  const serialized = stringifyJson(normalized.value) ?? "";
160
185
  const flatTokens = normalized.imageTokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS;
161
186
  if (!contextWindow || contextWindow <= 0) {
162
- return { tokens: tokenizer.countTokens(serialized, "upperbound") + flatTokens, fits: true };
187
+ return { tokens: tokenizer.countTokens(serialized, "upperbound") + flatTokens, fits: true, textFits: true };
163
188
  }
164
189
  const budget = tokenizer.checkTokenBudget(serialized, Math.max(0, contextWindow - flatTokens));
165
- return { tokens: budget.tokens + flatTokens, fits: budget.fits };
190
+ return {
191
+ tokens: budget.tokens + flatTokens,
192
+ fits: budget.fits,
193
+ // A failed check carries the exact text count (or its byte upper bound).
194
+ textFits: budget.fits || budget.tokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS <= contextWindow,
195
+ };
166
196
  }
167
197
 
168
198
  function rewriteToolOutputForContextWindow(item: Record<string, unknown>): Record<string, unknown> | undefined {
@@ -192,7 +222,9 @@ function isToolResultImageAttachment(item: Record<string, unknown>): boolean {
192
222
  * Preserve the full native transcript unless trailing tool outputs alone push a
193
223
  * remote compaction request beyond the model window. Replacing only those
194
224
  * outputs keeps call/result pairing and all earlier assistant/reasoning history,
195
- * matching Codex's recovery path for oversized tool turns.
225
+ * matching Codex's recovery path for oversized tool turns. A request whose text
226
+ * fits but whose image estimate does not is still sendable: refusing it on an
227
+ * image guess would skip native compaction the provider would have accepted.
196
228
  */
197
229
  export function trimRemoteCompactionInputToContextWindow(
198
230
  input: Array<Record<string, unknown>>,
@@ -226,22 +258,25 @@ export function trimRemoteCompactionInputToContextWindow(
226
258
  after = probeRemoteCompactionInputBudget(rewrittenInput, tokenizer, instructions, tools, contextWindow);
227
259
  }
228
260
 
229
- if (!rewrittenInput || !after.fits) {
261
+ // Rewrites that reach a full fit are kept. When they cannot, and the text
262
+ // already fit, the overflow is image-only: rewriting cannot shrink the image
263
+ // estimate, so send the untouched input rather than discard tool output.
264
+ if (rewrittenInput && (after.fits || (!before.textFits && after.textFits))) {
230
265
  return {
231
- input,
232
- rewrittenOutputs: 0,
266
+ input: rewrittenInput,
267
+ rewrittenOutputs,
233
268
  estimatedTokensBefore: before.tokens,
234
- estimatedTokensAfter: before.tokens,
235
- fits: false,
269
+ estimatedTokensAfter: after.tokens,
270
+ fits: true,
236
271
  };
237
272
  }
238
273
 
239
274
  return {
240
- input: rewrittenInput,
241
- rewrittenOutputs,
275
+ input,
276
+ rewrittenOutputs: 0,
242
277
  estimatedTokensBefore: before.tokens,
243
- estimatedTokensAfter: after.tokens,
244
- fits: true,
278
+ estimatedTokensAfter: before.tokens,
279
+ fits: before.textFits,
245
280
  };
246
281
  }
247
282
 
@@ -0,0 +1,77 @@
1
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
2
+ import {
3
+ type ImageSize,
4
+ type ImageTokenization,
5
+ imageTokens,
6
+ resolveImageTokenization,
7
+ } from "@oh-my-pi/pi-catalog/compat/image-tokenization";
8
+ import { parseImageMetadata } from "@oh-my-pi/pi-utils";
9
+
10
+ /**
11
+ * Dimension-based image token estimates shared by the local context counter
12
+ * ({@link Tokenizer.countMessage}) and the native remote-compaction fit probe.
13
+ * Both sides must price the same image the same way: when they disagreed
14
+ * (1.2k flat locally vs a 12k flat worst case remotely) an image-heavy session
15
+ * stayed under the local compaction trigger yet was refused as over-window by
16
+ * the remote probe.
17
+ *
18
+ * Follows the catalog's image rule for the OpenAI Responses wire (GPT-5.5's
19
+ * 32px patches, a per-detail pixel limit and patch budget, x1.2 multiplier),
20
+ * which is also a close upper estimate for other providers once omp has
21
+ * downscaled the image (≤1568px by default).
22
+ */
23
+
24
+ /** OpenAI image `detail` values; `undefined` means the provider default (`auto`). */
25
+ export type ImageDetail = ImageContent["detail"];
26
+
27
+ export type { ImageSize };
28
+
29
+ // Larger than every detail level's pixel limit, so an unknown size charges
30
+ // the level's full patch budget.
31
+ const UNKNOWN_SIZE: ImageSize = { width: 65_535, height: 65_535 };
32
+
33
+ let wireRule: ImageTokenization | undefined;
34
+
35
+ /** The OpenAI Responses wire's image rule, resolved from the catalog once. */
36
+ function openAiWireRule(): ImageTokenization {
37
+ if (wireRule) return wireRule;
38
+ const rule = resolveImageTokenization({ api: "openai-responses" });
39
+ if (!rule) throw new Error("The catalog has no image-tokenization rule for the openai-responses wire");
40
+ wireRule = rule;
41
+ return rule;
42
+ }
43
+
44
+ /**
45
+ * Estimated input tokens for one image. Unknown dimensions (undecodable data,
46
+ * remote URLs, provider file ids) charge the detail level's full patch budget.
47
+ */
48
+ export function estimateImageTokens(size: ImageSize | null | undefined, detail?: ImageDetail): number {
49
+ return imageTokens(openAiWireRule(), size && size.width > 0 && size.height > 0 ? size : UNKNOWN_SIZE, detail);
50
+ }
51
+
52
+ // Enough decoded bytes to reach a JPEG SOF marker behind typical EXIF/ICC
53
+ // segments; PNG/GIF/WebP need only the first 30 bytes. Multiple of 4 so the
54
+ // base64 slice decodes cleanly.
55
+ const HEADER_BASE64_CHARS = 4 * Math.ceil((64 * 1024) / 3);
56
+
57
+ /** Pixel size read from the header of base64-encoded image data, or null. */
58
+ export function base64ImageSize(base64: string): ImageSize | null {
59
+ if (base64.length === 0) return null;
60
+ const header = Buffer.from(base64.slice(0, HEADER_BASE64_CHARS), "base64");
61
+ const metadata = parseImageMetadata(header);
62
+ if (!metadata?.width || !metadata.height) return null;
63
+ return { width: metadata.width, height: metadata.height };
64
+ }
65
+
66
+ /** Pixel size of a `data:<mime>;base64,` URL, or null for any other URL. */
67
+ export function dataUrlImageSize(url: string): ImageSize | null {
68
+ if (!url.startsWith("data:")) return null;
69
+ const comma = url.indexOf(",");
70
+ if (comma < 0 || !url.slice(0, comma).endsWith(";base64")) return null;
71
+ return base64ImageSize(url.slice(comma + 1));
72
+ }
73
+
74
+ /** {@link estimateImageTokens} for an in-memory image block. */
75
+ export function estimateImageContentTokens(image: Pick<ImageContent, "data" | "detail">): number {
76
+ return estimateImageTokens(base64ImageSize(image.data), image.detail);
77
+ }