@oh-my-pi/pi-coding-agent 16.1.16 → 16.1.18

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli.js +16646 -16608
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/cli/usage-cli.d.ts +14 -0
  5. package/dist/types/commands/token.d.ts +9 -0
  6. package/dist/types/config/model-discovery.d.ts +1 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +7 -0
  9. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +34 -15
  10. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  11. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  12. package/dist/types/hindsight/content.d.ts +2 -10
  13. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  14. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  15. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +26 -10
  16. package/dist/types/modes/components/extensions/extension-list.d.ts +13 -0
  17. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  18. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  19. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  20. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  21. package/dist/types/session/agent-session.d.ts +6 -6
  22. package/dist/types/session/provider-image-budget.d.ts +3 -0
  23. package/dist/types/session/session-context.d.ts +6 -5
  24. package/dist/types/task/parallel.d.ts +4 -0
  25. package/dist/types/thinking.d.ts +8 -1
  26. package/dist/types/tiny/title-client.d.ts +2 -2
  27. package/dist/types/utils/tools-manager.d.ts +2 -0
  28. package/package.json +12 -12
  29. package/scripts/build-binary.ts +9 -16
  30. package/src/auto-thinking/classifier.ts +7 -2
  31. package/src/cli/profile-alias.ts +38 -7
  32. package/src/cli/usage-cli.ts +40 -5
  33. package/src/commands/token.ts +54 -0
  34. package/src/config/model-discovery.ts +59 -8
  35. package/src/config/model-registry.ts +74 -3
  36. package/src/discovery/omp-extension-roots.ts +1 -3
  37. package/src/extensibility/extensions/wrapper.ts +3 -2
  38. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  39. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +40 -0
  40. package/src/extensibility/plugins/legacy-pi-compat.ts +240 -90
  41. package/src/extensibility/plugins/loader.ts +71 -23
  42. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  43. package/src/extensibility/tool-event-input.ts +57 -0
  44. package/src/hindsight/content.ts +21 -12
  45. package/src/mcp/tool-bridge.ts +27 -2
  46. package/src/mnemopi/state.ts +5 -2
  47. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  48. package/src/modes/components/chat-transcript-builder.ts +6 -0
  49. package/src/modes/components/custom-editor.test.ts +18 -1
  50. package/src/modes/components/custom-editor.ts +77 -45
  51. package/src/modes/components/extensions/extension-dashboard.ts +221 -165
  52. package/src/modes/components/extensions/extension-list.ts +66 -33
  53. package/src/modes/components/hook-editor.ts +15 -2
  54. package/src/modes/components/settings-selector.ts +2 -2
  55. package/src/modes/components/status-line/component.ts +52 -8
  56. package/src/modes/components/status-line/segments.ts +5 -1
  57. package/src/modes/components/status-line/types.ts +1 -0
  58. package/src/modes/components/welcome.ts +12 -14
  59. package/src/modes/controllers/command-controller.ts +16 -5
  60. package/src/modes/controllers/input-controller.ts +115 -3
  61. package/src/modes/controllers/selector-controller.ts +23 -1
  62. package/src/modes/interactive-mode.ts +3 -3
  63. package/src/modes/utils/ui-helpers.ts +3 -3
  64. package/src/sdk.ts +8 -10
  65. package/src/session/agent-session.ts +193 -49
  66. package/src/session/provider-image-budget.ts +86 -0
  67. package/src/session/session-context.ts +14 -7
  68. package/src/session/session-storage.ts +24 -2
  69. package/src/session/snapcompact-inline.ts +19 -3
  70. package/src/slash-commands/builtin-registry.ts +0 -22
  71. package/src/slash-commands/helpers/usage-report.ts +9 -1
  72. package/src/task/parallel.ts +6 -1
  73. package/src/thinking.ts +9 -2
  74. package/src/tiny/title-client.ts +75 -21
  75. package/src/utils/tools-manager.ts +67 -10
@@ -84,6 +84,22 @@ export class SelectorController {
84
84
  ),
85
85
  );
86
86
  }
87
+
88
+ /**
89
+ * Restore keyboard focus to whatever currently owns the editor slot. The
90
+ * slot can hold the editor itself or a hook selector/input/editor pushed
91
+ * in by `ExtensionUiController` — e.g. an approval prompt that fired while
92
+ * a fullscreen overlay was up. `overlayHandle.hide()` restores focus to
93
+ * the component focused when the overlay opened, which is stale in that
94
+ * case (the editor was swapped out): keys land on a hidden editor and the
95
+ * visible prompt receives nothing (issue #3349). Call this after the
96
+ * overlay hides to re-target focus at the visible slot owner.
97
+ */
98
+ focusActiveEditorArea(): void {
99
+ const visible = this.ctx.editorContainer.children[0] ?? this.ctx.editor;
100
+ this.ctx.ui.setFocus(visible);
101
+ }
102
+
87
103
  /**
88
104
  * Shows a selector component in place of the editor.
89
105
  * @param create Factory that receives a `done` callback and returns the component and focus target
@@ -109,7 +125,7 @@ export class SelectorController {
109
125
  let overlayHandle: OverlayHandle | undefined;
110
126
  const done = () => {
111
127
  overlayHandle?.hide();
112
- this.ctx.ui.setFocus(this.ctx.editor);
128
+ this.focusActiveEditorArea();
113
129
  this.ctx.ui.requestRender();
114
130
  };
115
131
  const selector = new SettingsSelectorComponent(
@@ -216,14 +232,19 @@ export class SelectorController {
216
232
  */
217
233
  async showExtensionsDashboard(): Promise<void> {
218
234
  const dashboard = await ExtensionDashboard.create(getProjectDir(), this.ctx.settings, this.ctx.ui.terminal.rows);
235
+ // Fullscreen dashboard on the alternate screen (the /settings idiom): the
236
+ // overlay borrows the terminal's alt buffer and enables mouse tracking for
237
+ // its lifetime, leaving the transcript untouched underneath.
219
238
  const overlay = this.ctx.ui.showOverlay(dashboard, {
220
239
  width: "100%",
221
240
  maxHeight: "100%",
222
241
  anchor: "top-left",
223
242
  margin: 0,
243
+ fullscreen: true,
224
244
  });
225
245
  dashboard.onClose = () => {
226
246
  overlay.hide();
247
+ this.focusActiveEditorArea();
227
248
  this.ctx.ui.requestRender();
228
249
  };
229
250
  dashboard.onRequestRender = () => {
@@ -251,6 +272,7 @@ export class SelectorController {
251
272
  });
252
273
  dashboard.onClose = () => {
253
274
  overlay.hide();
275
+ this.focusActiveEditorArea();
254
276
  this.ctx.ui.requestRender();
255
277
  };
256
278
  dashboard.onRequestRender = () => {
@@ -1441,9 +1441,9 @@ export class InteractiveMode implements InteractiveModeContext {
1441
1441
 
1442
1442
  rebuildChatFromMessages(): void {
1443
1443
  this.chatContainer.clear();
1444
- // Full-history transcript: compactions render as inline dividers instead
1445
- // of restarting the visible conversation (the LLM context still resets).
1446
- const context = this.viewSession.buildTranscriptSessionContext();
1444
+ // Live display uses the compacted transcript tail; export/resume callers
1445
+ // can still request the full inline compaction history.
1446
+ const context = this.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: true });
1447
1447
  this.renderSessionContext(context);
1448
1448
  // During the pre-streaming window — after `startPendingSubmission` has
1449
1449
  // optimistically rendered the user's message but before the user
@@ -517,9 +517,9 @@ export class UiHelpers {
517
517
  this.ctx.pendingBashComponents = [];
518
518
  this.ctx.pendingPythonComponents = [];
519
519
 
520
- // Display always uses the full-history transcript: compactions show as
521
- // inline dividers instead of restarting the visible conversation.
522
- const context = this.ctx.viewSession.buildTranscriptSessionContext();
520
+ // Live display uses the compacted transcript tail; export/resume callers
521
+ // can still request the full inline compaction history.
522
+ const context = this.ctx.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: true });
523
523
  this.ctx.renderSessionContext(context, {
524
524
  updateFooter: true,
525
525
  populateHistory: !this.ctx.focusedAgentId,
package/src/sdk.ts CHANGED
@@ -115,6 +115,7 @@ import {
115
115
  USER_INTERRUPT_LABEL,
116
116
  wrapSteeringForModel,
117
117
  } from "./session/messages";
118
+ import { clampProviderContextImages } from "./session/provider-image-budget";
118
119
  import { getRestorableSessionModels } from "./session/session-context";
119
120
  import { SessionManager } from "./session/session-manager";
120
121
  import { SnapcompactInlineTransformer } from "./session/snapcompact-inline";
@@ -2420,8 +2421,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2420
2421
  return wrapSteeringForModel(withContext);
2421
2422
  };
2422
2423
  // Per-request provider-context transforms. Obfuscate FIRST so secrets are
2423
- // redacted from text before snapcompact rasterizes it into PNG frames.
2424
- // Both operate on the transient outgoing Context only never persisted.
2424
+ // redacted from text before snapcompact rasterizes it into PNG frames, then
2425
+ // clamp images to the active provider budget before the request is sent.
2425
2426
  const snapcompactSystemPromptMode = settings.get("snapcompact.systemPrompt");
2426
2427
  const snapcompactInline =
2427
2428
  snapcompactSystemPromptMode !== "none" || settings.get("snapcompact.toolResults")
@@ -2436,14 +2437,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2436
2437
  createSnapcompactSavingsRecorder(() => sessionManager.getSessionFile() ?? null),
2437
2438
  )
2438
2439
  : undefined;
2439
- const transformProviderContext =
2440
- obfuscator || snapcompactInline
2441
- ? async (context: Context, transformModel: Model): Promise<Context> => {
2442
- let transformed = obfuscator ? obfuscateProviderContext(obfuscator, context) : context;
2443
- if (snapcompactInline) transformed = await snapcompactInline.transform(transformed, transformModel);
2444
- return transformed;
2445
- }
2446
- : undefined;
2440
+ const transformProviderContext = async (context: Context, transformModel: Model): Promise<Context> => {
2441
+ let transformed = obfuscator ? obfuscateProviderContext(obfuscator, context) : context;
2442
+ if (snapcompactInline) transformed = await snapcompactInline.transform(transformed, transformModel);
2443
+ return clampProviderContextImages(transformed, transformModel);
2444
+ };
2447
2445
  const onPayload = async (payload: unknown, _model?: Model) => {
2448
2446
  return await extensionRunner.emitBeforeProviderRequest(payload);
2449
2447
  };
@@ -44,6 +44,7 @@ import {
44
44
  CompactionCancelledError,
45
45
  type CompactionPreparation,
46
46
  type CompactionResult,
47
+ type CompactionSettings,
47
48
  calculateContextTokens,
48
49
  calculatePromptTokens,
49
50
  collectEntriesForBranchSummary,
@@ -303,7 +304,7 @@ import {
303
304
  stripImagesFromMessage,
304
305
  USER_INTERRUPT_LABEL,
305
306
  } from "./messages";
306
- import type { SessionContext } from "./session-context";
307
+ import type { BuildSessionContextOptions, SessionContext } from "./session-context";
307
308
  import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-context";
308
309
  import { formatSessionDumpText } from "./session-dump-format";
309
310
  import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions } from "./session-entries";
@@ -1365,6 +1366,13 @@ export class AgentSession {
1365
1366
  #pendingRewindReport: string | undefined = undefined;
1366
1367
  #rewoundToolResultIds = new Set<string>();
1367
1368
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1369
+ /**
1370
+ * Sticky across an in-flight prompt run: a successful `yield` makes the run
1371
+ * terminal for execution purposes, so any trailing empty/aborted assistant
1372
+ * stop must NOT trigger empty-stop/unexpected-stop/compaction continuations.
1373
+ * Cleared in `#promptWithMessage` so the next prompt evaluates cleanly.
1374
+ */
1375
+ #yieldTerminationPending = false;
1368
1376
  #providerSessionState = new Map<string, ProviderSessionState>();
1369
1377
  #hindsightSessionState: HindsightSessionState | undefined = undefined;
1370
1378
  readonly rawSseDebugBuffer: RawSseDebugBuffer;
@@ -2554,6 +2562,7 @@ export class AgentSession {
2554
2562
  }
2555
2563
  if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
2556
2564
  this.#lastSuccessfulYieldToolCallId = event.toolCallId;
2565
+ this.#yieldTerminationPending = true;
2557
2566
  }
2558
2567
 
2559
2568
  // TTSR: Check for pattern matches on assistant text/thinking and tool argument deltas
@@ -2824,15 +2833,24 @@ export class AgentSession {
2824
2833
  }
2825
2834
 
2826
2835
  const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active";
2827
- if (this.#assistantEndedWithSuccessfulYield(msg)) {
2836
+ const yieldOnThisMessage = this.#assistantEndedWithSuccessfulYield(msg);
2837
+ // A successful `yield` in this run is terminal for execution purposes.
2838
+ // Suppress empty-stop retry, unexpected-stop retry, queued-message drain,
2839
+ // and compaction-driven continuations for the rest of this prompt cycle:
2840
+ // the executor consumed the yield as the terminal result, so a trailing
2841
+ // empty/aborted assistant stop must NOT revive the agent loop. The
2842
+ // `#yieldTerminationPending` sticky flag clears on the next `prompt()`.
2843
+ if (yieldOnThisMessage || this.#yieldTerminationPending) {
2828
2844
  this.#lastSuccessfulYieldToolCallId = undefined;
2829
- if (activeGoal) {
2845
+ if (yieldOnThisMessage && activeGoal) {
2830
2846
  maintenanceRoute("successful-yield-active-goal-checkCompaction");
2831
2847
  const compactionTask = this.#checkCompaction(msg);
2832
2848
  this.#trackPostPromptTask(compactionTask);
2833
2849
  await compactionTask;
2834
- } else {
2850
+ } else if (yieldOnThisMessage) {
2835
2851
  maintenanceRoute("successful-yield-no-active-goal");
2852
+ } else {
2853
+ maintenanceRoute("post-yield-trailing-stop-suppressed");
2836
2854
  }
2837
2855
  await emitAgentEndNotification();
2838
2856
  return;
@@ -5288,13 +5306,21 @@ export class AgentSession {
5288
5306
  }
5289
5307
 
5290
5308
  /**
5291
- * Full-history transcript for TUI display: every path entry in
5292
- * chronological order with compactions rendered inline at the point they
5293
- * fired (instead of replacing prior history). Display-only — NEVER feed
5294
- * the result to `agent.replaceMessages` or a provider.
5309
+ * Transcript for TUI display. Full history is kept for export/resume-style
5310
+ * callers; live chat can collapse compacted history to keep the hot render
5311
+ * surface bounded. Display-only — NEVER feed the result to
5312
+ * `agent.replaceMessages` or a provider.
5295
5313
  */
5296
- buildTranscriptSessionContext(): SessionContext {
5297
- return deobfuscateSessionContext(this.sessionManager.buildSessionContext({ transcript: true }), this.#obfuscator);
5314
+ buildTranscriptSessionContext(
5315
+ options?: Pick<BuildSessionContextOptions, "collapseCompactedHistory">,
5316
+ ): SessionContext {
5317
+ return deobfuscateSessionContext(
5318
+ this.sessionManager.buildSessionContext({
5319
+ transcript: true,
5320
+ collapseCompactedHistory: options?.collapseCompactedHistory,
5321
+ }),
5322
+ this.#obfuscator,
5323
+ );
5298
5324
  }
5299
5325
 
5300
5326
  #obfuscateTextForProvider(text: string | undefined): string | undefined {
@@ -6000,6 +6026,10 @@ export class AgentSession {
6000
6026
  this.#todoReminderAwaitingProgress = false;
6001
6027
  this.#emptyStopRetryCount = 0;
6002
6028
  this.#unexpectedStopRetryCount = 0;
6029
+ // A new prompt cycle starts: drop any sticky yield-termination from the
6030
+ // previous run so empty-stop / unexpected-stop / compaction maintenance
6031
+ // can evaluate this turn normally.
6032
+ this.#yieldTerminationPending = false;
6003
6033
 
6004
6034
  await this.#maybeRestoreRetryFallbackPrimary();
6005
6035
 
@@ -7028,20 +7058,22 @@ export class AgentSession {
7028
7058
  throw new Error(`No API key for ${model.provider}/${model.id}`);
7029
7059
  }
7030
7060
 
7061
+ const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
7062
+
7031
7063
  this.#clearActiveRetryFallback();
7032
- this.#setModelWithProviderSessionReset(model);
7033
- this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role);
7064
+ this.#setModelWithProviderSessionReset(targetModel);
7065
+ this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
7034
7066
  if (options?.persist) {
7035
7067
  this.settings.setModelRole(
7036
7068
  role,
7037
- this.#formatRoleModelValue(role, model, options.selector, options.thinkingLevel),
7069
+ this.#formatRoleModelValue(role, targetModel, options.selector, options.thinkingLevel),
7038
7070
  );
7039
7071
  }
7040
- this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
7072
+ this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
7041
7073
 
7042
7074
  // Re-apply thinking for the newly selected model. Prefer the model's
7043
7075
  // configured defaultLevel; otherwise preserve the current level (or auto).
7044
- this.#reapplyThinkingLevel(model.thinking?.defaultLevel);
7076
+ this.#reapplyThinkingLevel(targetModel.thinking?.defaultLevel);
7045
7077
  await this.#syncAfterModelChange(previousEditMode);
7046
7078
  }
7047
7079
 
@@ -7062,20 +7094,22 @@ export class AgentSession {
7062
7094
  throw new Error(`No API key for ${model.provider}/${model.id}`);
7063
7095
  }
7064
7096
 
7097
+ const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
7098
+
7065
7099
  this.#clearActiveRetryFallback();
7066
- this.#setModelWithProviderSessionReset(model);
7100
+ this.#setModelWithProviderSessionReset(targetModel);
7067
7101
  this.sessionManager.appendModelChange(
7068
- `${model.provider}/${model.id}`,
7102
+ `${targetModel.provider}/${targetModel.id}`,
7069
7103
  options?.ephemeral ? EPHEMERAL_MODEL_CHANGE_ROLE : "temporary",
7070
7104
  );
7071
- this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
7105
+ this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
7072
7106
 
7073
7107
  // Apply explicit thinking level if given; otherwise prefer the model's
7074
7108
  // configured defaultLevel; otherwise re-clamp the current level (or auto).
7075
7109
  if (thinkingLevel !== undefined) {
7076
7110
  this.setThinkingLevel(thinkingLevel);
7077
7111
  } else {
7078
- this.#reapplyThinkingLevel(model.thinking?.defaultLevel);
7112
+ this.#reapplyThinkingLevel(targetModel.thinking?.defaultLevel);
7079
7113
  }
7080
7114
  await this.#syncAfterModelChange(previousEditMode);
7081
7115
  }
@@ -7363,6 +7397,10 @@ export class AgentSession {
7363
7397
  async #applyAutoThinkingLevel(promptText: string, generation: number): Promise<void> {
7364
7398
  const model = this.model;
7365
7399
  if (!model?.reasoning) return;
7400
+ // Models with reasoning but no controllable effort surface (devin-agent
7401
+ // Cascade routes effort via sibling model ids, not a wire param) have
7402
+ // nothing to pick — skip classification rather than discard its result.
7403
+ if (getSupportedEfforts(model).length === 0) return;
7366
7404
 
7367
7405
  let resolved: Effort | undefined;
7368
7406
  if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(promptText)) {
@@ -7859,31 +7897,45 @@ export class AgentSession {
7859
7897
  let tokensBefore: number;
7860
7898
  let details: unknown;
7861
7899
 
7862
- // Snapcompact runs locally first; if its frame archive plus the kept
7863
- // history still overflows the model window, fall back to an LLM summary
7864
- // (far cheaper than ~FRAME_TOKEN_ESTIMATE per frame).
7900
+ // Snapcompact runs locally first. The frame cap is sized from the live
7901
+ // model window via #computeSnapcompactMaxFrames so the post-render context
7902
+ // fits without the warning loop (issue #3247). Zero-frame budget → skip
7903
+ // snapcompact and take the summarizer path immediately.
7865
7904
  let snapcompactResult: snapcompact.CompactionResult | undefined;
7866
7905
  if (snapcompactReady) {
7867
- snapcompactResult = await snapcompact.compact(preparation, {
7868
- convertToLlm,
7869
- model: this.model,
7870
- shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
7871
- });
7872
- const ctxWindow = this.model?.contextWindow ?? 0;
7873
- const budget =
7874
- ctxWindow > 0
7875
- ? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
7876
- : Number.POSITIVE_INFINITY;
7877
- if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
7878
- logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", {
7906
+ const maxFrames = this.#computeSnapcompactMaxFrames(preparation, effectiveSettings);
7907
+ if (maxFrames < 1) {
7908
+ logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
7879
7909
  model: this.model?.id,
7880
7910
  });
7881
7911
  this.emitNotice(
7882
7912
  "warning",
7883
- "snapcompact could not bring the context under the limit — using an LLM summary instead",
7913
+ "snapcompact: kept history alone exceeds the context budget — using an LLM summary instead",
7884
7914
  "compaction",
7885
7915
  );
7886
- snapcompactResult = undefined;
7916
+ } else {
7917
+ snapcompactResult = await snapcompact.compact(preparation, {
7918
+ convertToLlm,
7919
+ model: this.model,
7920
+ shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
7921
+ maxFrames,
7922
+ });
7923
+ const ctxWindow = this.model?.contextWindow ?? 0;
7924
+ const budget =
7925
+ ctxWindow > 0
7926
+ ? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
7927
+ : Number.POSITIVE_INFINITY;
7928
+ if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
7929
+ logger.warn("Snapcompact still overflows the window after frame-budget sizing; falling back", {
7930
+ model: this.model?.id,
7931
+ });
7932
+ this.emitNotice(
7933
+ "warning",
7934
+ "snapcompact could not bring the context under the limit — using an LLM summary instead",
7935
+ "compaction",
7936
+ );
7937
+ snapcompactResult = undefined;
7938
+ }
7887
7939
  }
7888
7940
  }
7889
7941
 
@@ -9614,6 +9666,82 @@ export class AgentSession {
9614
9666
  return { kind: "needsLlm", hookContext, hookPrompt, preserveData };
9615
9667
  }
9616
9668
 
9669
+ /**
9670
+ * Cap on snapcompact frames the post-compaction context can carry without
9671
+ * busting the model window. Mirrors the per-frame token charge used by the
9672
+ * projection ({@link snapcompact.FRAME_TOKEN_ESTIMATE}, the conservative
9673
+ * high-res Anthropic ceiling), so picking `maxFrames` from this helper makes
9674
+ * {@link #projectSnapcompactContextTokens} succeed by construction.
9675
+ *
9676
+ * Skip vs. cap use different reserves on purpose. The **skip** decision
9677
+ * (return `0`) trips only when kept-recent plus non-message tokens already
9678
+ * eat the entire `ctxWindow − reserve` envelope: at that point no archive
9679
+ * shape — frame-bearing or text-only — can fit, and the caller MUST
9680
+ * shortcut to the LLM summarizer instead of re-running snapcompact to
9681
+ * re-emit the "could not bring the context under the limit" warning every
9682
+ * threshold tick. The **cap** calculation subtracts a shape-aware reserve
9683
+ * (`2 × geometry(shape).capacity` chars worth of text edges, billed at the
9684
+ * tiktoken cl100k baseline, plus a 2k summary-template allowance) sized
9685
+ * from the same `shape` snapcompact will use, so the projection still
9686
+ * passes once frames land — but it MUST NOT gate the skip decision, since
9687
+ * a frame-less archive (`text.length <= 2 * edgeCap` short-circuit in
9688
+ * `planArchive`) typically costs only a few hundred tokens of summary
9689
+ * lead and would fit under residual headroom far smaller than the cap
9690
+ * reserve (chatgpt-codex reviews on #3249).
9691
+ *
9692
+ * Returns `1` when the frame charge would overflow but the text-only path
9693
+ * still has room: snapcompact's planner picks the frame-less layout
9694
+ * automatically when the discarded text fits in the edges, so giving it
9695
+ * the minimum cap lets it succeed instead of being skipped outright.
9696
+ *
9697
+ * Without this cap, the bundled `MAX_FRAMES_DEFAULT = 80` × 5024 tokens =
9698
+ * ~402k frame-token projection always overflows any sub-1M-token window
9699
+ * (issue #3247).
9700
+ */
9701
+ #computeSnapcompactMaxFrames(preparation: CompactionPreparation, settings: CompactionSettings): number {
9702
+ const ctxWindow = this.model?.contextWindow ?? 0;
9703
+ if (ctxWindow <= 0) return snapcompact.MAX_FRAMES_DEFAULT;
9704
+ const reserve = effectiveReserveTokens(ctxWindow, settings);
9705
+ let baseTokens = computeNonMessageTokens(this);
9706
+ for (const message of preparation.recentMessages) {
9707
+ baseTokens += estimateTokens(message);
9708
+ }
9709
+ const totalBudget = ctxWindow - reserve;
9710
+ // Skip iff there is no headroom whatsoever; a text-only archive costs
9711
+ // far less than the cap reserve below, so any positive residual is
9712
+ // worth attempting and the projection guard catches actual overflow.
9713
+ if (baseTokens >= totalBudget) return 0;
9714
+ // Cap reserve mirrors what `estimateTokens(summaryMessage)` will charge
9715
+ // when frames > 0: `countTokens(summaryTemplate ‖ textHead ‖ textTail)`
9716
+ // plus `numFrames × FRAME_TOKEN_ESTIMATE`. Resolve the shape this
9717
+ // snapcompact pass will actually use (matches the `shape` argument
9718
+ // passed to `snapcompact.compact` in the auto and manual paths) so the
9719
+ // text-edge cost reflects the live frame geometry rather than a fixed
9720
+ // approximation. Reviewer (chatgpt-codex on #3249): a 4k reserve
9721
+ // undersized the ~7k text-edge cost on the default Anthropic
9722
+ // 11on16-bw shape, so the projection then rejected the `maxFrames`
9723
+ // the cap had picked and the warning loop reappeared.
9724
+ //
9725
+ // - `textHead` and `textTail` each consume up to `geometry.capacity`
9726
+ // chars when frames > 0 (one HQ-capacity page per edge: see
9727
+ // `TEXT_EDGE_PAGES = 1` in `planArchive`), so 2 × capacity chars
9728
+ // total. Per-shape capacity: Anthropic 11on16-bw ~13.9k, Opus
9729
+ // 1932px ~21k, Gemini 8on22-bw 2048px ~23.8k, OpenAI 1568px ~13.9k.
9730
+ // - tiktoken cl100k ≈ 4 chars/token on ASCII (verified empirically
9731
+ // for prose, code, and JSON); a 1.15 multiplier absorbs tokenizer
9732
+ // drift on denser content (e.g. dense JSON / tool-result blobs).
9733
+ // - Summary template (intro + FILES section + grid notes) bills
9734
+ // ~2k tokens for typical sessions.
9735
+ const shape = snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape"));
9736
+ const edgeCap = snapcompact.geometry(shape).capacity;
9737
+ const textEdgeTokens = Math.ceil((2 * edgeCap * 1.15) / 4);
9738
+ const SUMMARY_TEMPLATE_TOKENS = 2000;
9739
+ const capReserve = textEdgeTokens + SUMMARY_TEMPLATE_TOKENS;
9740
+ const frameBudget = totalBudget - baseTokens - capReserve;
9741
+ if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1;
9742
+ return Math.min(Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE), snapcompact.MAX_FRAMES_DEFAULT);
9743
+ }
9744
+
9617
9745
  /**
9618
9746
  * Project the post-compaction context size of a snapcompact result: kept
9619
9747
  * recent messages + the summary message with its re-attached frames + the
@@ -9862,24 +9990,20 @@ export class AgentSession {
9862
9990
  let tokensBefore: number;
9863
9991
  let details: unknown;
9864
9992
 
9865
- // Snapcompact runs locally first; if its frame archive plus the kept
9866
- // history still overflows the model window (frames default to
9867
- // MAX_FRAMES_DEFAULT and cost ~FRAME_TOKEN_ESTIMATE each), an LLM
9868
- // summary is far cheaper downgrade to context-full and take the
9869
- // summarizer path.
9993
+ // Snapcompact runs locally first. The post-compaction context = kept-recent
9994
+ // + a summary message carrying the imaged archive at FRAME_TOKEN_ESTIMATE
9995
+ // per frame; #computeSnapcompactMaxFrames sizes the frame cap from the
9996
+ // live window so we don't run snapcompact just to overflow and fall back
9997
+ // every threshold tick. Kept-recent already over budget → skip snapcompact
9998
+ // outright (a single frame won't fit). Otherwise the projection below is
9999
+ // only a defensive guard for summary-text drift.
9870
10000
  let snapcompactResult: snapcompact.CompactionResult | undefined;
9871
10001
  if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
9872
10002
  const text = snapcompact.serializeConversation(
9873
10003
  convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
9874
10004
  );
9875
10005
  const renderScan = snapcompact.scanRenderability(text);
9876
- if (renderScan.isSafe) {
9877
- snapcompactResult = await snapcompact.compact(preparation, {
9878
- convertToLlm,
9879
- model: this.model,
9880
- shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
9881
- });
9882
- } else {
10006
+ if (!renderScan.isSafe) {
9883
10007
  logger.warn("Snapcompact disabled: high non-ASCII rate detected; falling back to an LLM summary", {
9884
10008
  model: this.model?.id,
9885
10009
  unrenderableRatio: renderScan.unrenderableRatio,
@@ -9890,6 +10014,26 @@ export class AgentSession {
9890
10014
  "compaction",
9891
10015
  );
9892
10016
  action = "context-full";
10017
+ } else {
10018
+ const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
10019
+ if (maxFrames < 1) {
10020
+ logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
10021
+ model: this.model?.id,
10022
+ });
10023
+ this.emitNotice(
10024
+ "warning",
10025
+ "snapcompact: kept history alone exceeds the context budget — using an LLM summary instead",
10026
+ "compaction",
10027
+ );
10028
+ action = "context-full";
10029
+ } else {
10030
+ snapcompactResult = await snapcompact.compact(preparation, {
10031
+ convertToLlm,
10032
+ model: this.model,
10033
+ shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
10034
+ maxFrames,
10035
+ });
10036
+ }
9893
10037
  }
9894
10038
 
9895
10039
  if (snapcompactResult) {
@@ -9900,7 +10044,7 @@ export class AgentSession {
9900
10044
  : Number.POSITIVE_INFINITY;
9901
10045
  const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
9902
10046
  if (projected > budget) {
9903
- logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", {
10047
+ logger.warn("Snapcompact still overflows the window after frame-budget sizing; falling back", {
9904
10048
  model: this.model?.id,
9905
10049
  projected,
9906
10050
  budget,
@@ -0,0 +1,86 @@
1
+ import type {
2
+ Context,
3
+ DeveloperMessage,
4
+ ImageContent,
5
+ Model,
6
+ TextContent,
7
+ ToolResultMessage,
8
+ UserMessage,
9
+ } from "@oh-my-pi/pi-ai";
10
+ import { providerImageBudget } from "@oh-my-pi/snapcompact";
11
+
12
+ const TOOL_RESULT_IMAGE_OMISSION: TextContent = {
13
+ type: "text",
14
+ text: "[image omitted: provider image limit]",
15
+ };
16
+
17
+ function countImages(context: Context): number {
18
+ let count = 0;
19
+ for (const message of context.messages) {
20
+ if (!Array.isArray(message.content)) continue;
21
+ for (const part of message.content) {
22
+ if (part.type === "image") count++;
23
+ }
24
+ }
25
+ return count;
26
+ }
27
+
28
+ function clampContent(
29
+ content: readonly (TextContent | ImageContent)[],
30
+ state: { remainingDrops: number },
31
+ ): (TextContent | ImageContent)[] | undefined {
32
+ let changed = false;
33
+ const clamped: (TextContent | ImageContent)[] = [];
34
+ for (const part of content) {
35
+ if (part.type === "image" && state.remainingDrops > 0) {
36
+ state.remainingDrops--;
37
+ changed = true;
38
+ continue;
39
+ }
40
+ clamped.push(part);
41
+ }
42
+ return changed ? clamped : undefined;
43
+ }
44
+
45
+ function clampUserMessage(message: UserMessage, state: { remainingDrops: number }): UserMessage {
46
+ if (!Array.isArray(message.content) || state.remainingDrops <= 0) return message;
47
+ const content = clampContent(message.content, state);
48
+ return content ? { ...message, content } : message;
49
+ }
50
+
51
+ function clampDeveloperMessage(message: DeveloperMessage, state: { remainingDrops: number }): DeveloperMessage {
52
+ if (!Array.isArray(message.content) || state.remainingDrops <= 0) return message;
53
+ const content = clampContent(message.content, state);
54
+ return content ? { ...message, content } : message;
55
+ }
56
+
57
+ function clampToolResultMessage(message: ToolResultMessage, state: { remainingDrops: number }): ToolResultMessage {
58
+ if (state.remainingDrops <= 0) return message;
59
+ const content = clampContent(message.content, state);
60
+ if (!content) return message;
61
+ return { ...message, content: content.length > 0 ? content : [TOOL_RESULT_IMAGE_OMISSION] };
62
+ }
63
+
64
+ /** Drops oldest transient image blocks so outgoing vision requests fit the active provider's image cap. */
65
+ export function clampProviderContextImages(context: Context, model: Model): Context {
66
+ if (!model.input.includes("image")) return context;
67
+ const limit = providerImageBudget(model.provider);
68
+ const totalImages = countImages(context);
69
+ if (totalImages <= limit) return context;
70
+
71
+ const state = { remainingDrops: totalImages - limit };
72
+ const messages = context.messages.map(message => {
73
+ switch (message.role) {
74
+ case "user":
75
+ return clampUserMessage(message, state);
76
+ case "developer":
77
+ return clampDeveloperMessage(message, state);
78
+ case "toolResult":
79
+ return clampToolResultMessage(message, state);
80
+ case "assistant":
81
+ return message;
82
+ }
83
+ return message;
84
+ });
85
+ return { ...context, messages };
86
+ }
@@ -62,13 +62,14 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
62
62
 
63
63
  export interface BuildSessionContextOptions {
64
64
  /**
65
- * Build the full-history display transcript instead of the LLM context:
66
- * every path entry in chronological order, with each compaction emitted
67
- * inline as a `compactionSummary` message at the position it fired rather
68
- * than replacing the history before it. Display-only — never send the
69
- * result to a provider.
65
+ * Build the display transcript instead of the LLM context. By default this
66
+ * preserves every path entry with compactions inline; set
67
+ * `collapseCompactedHistory` for the live TUI surface to render only the
68
+ * latest compacted tail.
70
69
  */
71
70
  transcript?: boolean;
71
+ /** In transcript mode, elide entries replaced by the latest compaction. */
72
+ collapseCompactedHistory?: boolean;
72
73
  }
73
74
 
74
75
  /**
@@ -255,7 +256,7 @@ export function buildSessionContext(
255
256
  }
256
257
  };
257
258
 
258
- if (options?.transcript) {
259
+ if (options?.transcript && !options.collapseCompactedHistory) {
259
260
  // Display transcript: every entry in chronological order. Compactions do
260
261
  // not erase prior history here — each renders inline (as a divider in the
261
262
  // TUI) at the point it fired, with any snapcompact frames re-attached so
@@ -294,6 +295,7 @@ export function buildSessionContext(
294
295
  })();
295
296
  const remoteReplacementHistory = providerPayload?.items;
296
297
 
298
+ if (options?.transcript) handleEntryResetTracking(compaction);
297
299
  // Emit summary first; re-attach any archived snapcompact frames so the
298
300
  // model can keep reading the archived history after every context rebuild.
299
301
  const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData);
@@ -312,7 +314,12 @@ export function buildSessionContext(
312
314
  // Find compaction index in path
313
315
  const compactionIdx = path.findIndex(e => e.type === "compaction" && e.id === compaction.id);
314
316
 
315
- if (!remoteReplacementHistory) {
317
+ // The remote replacement payload (OpenAI remote compaction) carries the
318
+ // kept turns for the LLM context only; it is not rendered as visible
319
+ // messages. The collapsed display transcript must still emit the kept
320
+ // SessionEntry rows so a remotely-compacted session keeps its recent
321
+ // turns visible instead of showing only the summary and post-compaction.
322
+ if (!remoteReplacementHistory || options?.transcript) {
316
323
  // Emit kept messages (before compaction, starting from firstKeptEntryId)
317
324
  let foundFirstKept = false;
318
325
  for (let i = 0; i < compactionIdx; i++) {