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

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 (65) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/cli.js +2749 -2726
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/config/model-discovery.d.ts +1 -0
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
  7. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  8. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  9. package/dist/types/hindsight/content.d.ts +2 -10
  10. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  11. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  12. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  13. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  14. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  15. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  16. package/dist/types/session/agent-session.d.ts +6 -6
  17. package/dist/types/session/provider-image-budget.d.ts +3 -0
  18. package/dist/types/session/session-context.d.ts +6 -5
  19. package/dist/types/task/parallel.d.ts +4 -0
  20. package/dist/types/thinking.d.ts +8 -1
  21. package/dist/types/tiny/title-client.d.ts +2 -2
  22. package/dist/types/utils/tools-manager.d.ts +2 -0
  23. package/package.json +12 -12
  24. package/src/auto-thinking/classifier.ts +7 -2
  25. package/src/cli/profile-alias.ts +38 -7
  26. package/src/cli/usage-cli.ts +5 -1
  27. package/src/config/model-discovery.ts +59 -8
  28. package/src/config/model-registry.ts +74 -3
  29. package/src/discovery/omp-extension-roots.ts +1 -3
  30. package/src/extensibility/extensions/wrapper.ts +3 -2
  31. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  32. package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
  33. package/src/extensibility/plugins/loader.ts +71 -23
  34. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  35. package/src/extensibility/tool-event-input.ts +57 -0
  36. package/src/hindsight/content.ts +21 -12
  37. package/src/mcp/tool-bridge.ts +27 -2
  38. package/src/mnemopi/state.ts +5 -2
  39. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  40. package/src/modes/components/chat-transcript-builder.ts +6 -0
  41. package/src/modes/components/custom-editor.test.ts +18 -1
  42. package/src/modes/components/custom-editor.ts +77 -45
  43. package/src/modes/components/hook-editor.ts +15 -2
  44. package/src/modes/components/settings-selector.ts +2 -2
  45. package/src/modes/components/status-line/component.ts +52 -8
  46. package/src/modes/components/status-line/segments.ts +5 -1
  47. package/src/modes/components/status-line/types.ts +1 -0
  48. package/src/modes/components/welcome.ts +12 -14
  49. package/src/modes/controllers/command-controller.ts +16 -5
  50. package/src/modes/controllers/input-controller.ts +115 -3
  51. package/src/modes/controllers/selector-controller.ts +19 -1
  52. package/src/modes/interactive-mode.ts +3 -3
  53. package/src/modes/utils/ui-helpers.ts +3 -3
  54. package/src/sdk.ts +8 -10
  55. package/src/session/agent-session.ts +193 -49
  56. package/src/session/provider-image-budget.ts +86 -0
  57. package/src/session/session-context.ts +14 -7
  58. package/src/session/session-storage.ts +24 -2
  59. package/src/session/snapcompact-inline.ts +19 -3
  60. package/src/slash-commands/builtin-registry.ts +0 -22
  61. package/src/slash-commands/helpers/usage-report.ts +9 -1
  62. package/src/task/parallel.ts +6 -1
  63. package/src/thinking.ts +9 -2
  64. package/src/tiny/title-client.ts +75 -21
  65. package/src/utils/tools-manager.ts +67 -10
@@ -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++) {
@@ -137,8 +137,30 @@ export class FileSessionStorage implements SessionStorage {
137
137
  }
138
138
 
139
139
  writeTextSync(fpath: string, content: string): void {
140
- this.ensureDirSync(path.dirname(fpath));
141
- fs.writeFileSync(fpath, content);
140
+ const dir = path.dirname(fpath);
141
+ this.ensureDirSync(dir);
142
+ const tempPath = path.join(dir, `.${path.basename(fpath)}.${Snowflake.next()}.tmp`);
143
+ try {
144
+ fs.writeFileSync(tempPath, content);
145
+ fs.renameSync(tempPath, fpath);
146
+ } catch (err) {
147
+ try {
148
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
149
+ } catch (cleanupErr) {
150
+ if (!isEnoent(cleanupErr)) {
151
+ logger.warn("Failed to remove session rewrite temp file", {
152
+ sessionFile: fpath,
153
+ tempPath,
154
+ error: toError(cleanupErr).message,
155
+ });
156
+ }
157
+ }
158
+ if (hasFsCode(err, "EPERM")) {
159
+ fs.writeFileSync(fpath, content);
160
+ return;
161
+ }
162
+ throw toError(err);
163
+ }
142
164
  }
143
165
 
144
166
  statSync(path: string): SessionStorageStat {
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { countTokens } from "@oh-my-pi/pi-agent-core";
18
18
  import type { Context, ImageContent, Model, TextContent, ToolResultMessage, UserMessage } from "@oh-my-pi/pi-ai";
19
+ import { isPersonalGitHubCopilotBaseUrl } from "@oh-my-pi/pi-catalog/wire/github-copilot";
19
20
  import * as snapcompact from "@oh-my-pi/snapcompact";
20
21
  import contextFramesNote from "../prompts/system/snapcompact-context-frames-note.md" with { type: "text" };
21
22
  import contextStub from "../prompts/system/snapcompact-context-stub.md" with { type: "text" };
@@ -77,6 +78,19 @@ function passesSavingsGate(frames: number, shape: snapcompact.Shape, textTokens:
77
78
  return frames * shape.frameTokenEstimate <= textTokens * SAVINGS_MARGIN;
78
79
  }
79
80
 
81
+ /**
82
+ * The model is vision-capable for the endpoint we're actually about to hit.
83
+ * GitHub Copilot business and enterprise hosts respond `400 vision is not
84
+ * supported` on image inputs (issue #3387), so even if a stale cached spec
85
+ * still advertises `["text","image"]` we MUST not rasterize transcripts when
86
+ * the resolved `baseUrl` is non-personal.
87
+ */
88
+ function canSendImages(model: Model): boolean {
89
+ if (!model.input.includes("image")) return false;
90
+ if (model.provider === "github-copilot" && !isPersonalGitHubCopilotBaseUrl(model.baseUrl)) return false;
91
+ return true;
92
+ }
93
+
80
94
  interface SystemPromptImageTarget {
81
95
  scope: Exclude<SnapcompactSystemPromptMode, "none">;
82
96
  text: string;
@@ -277,7 +291,7 @@ export function estimateInlineSavings(input: {
277
291
  messages: readonly InlineMessageView[];
278
292
  }): SnapcompactSavingsEstimate {
279
293
  const { options, model } = input;
280
- if (!model?.input.includes("image")) {
294
+ if (!model || !canSendImages(model)) {
281
295
  return { visionCapable: false, savedTokens: 0 };
282
296
  }
283
297
 
@@ -416,8 +430,10 @@ export class SnapcompactInlineTransformer {
416
430
 
417
431
  async transform(context: Context, model: Model): Promise<Context> {
418
432
  // Vision gate: providers silently DROP images on text-only models —
419
- // rendering would lose the content entirely.
420
- if (!model.input.includes("image")) return context;
433
+ // rendering would lose the content entirely. Also short-circuits when the
434
+ // resolved endpoint rejects vision regardless of the model's input list
435
+ // (issue #3387: Copilot business endpoint).
436
+ if (!canSendImages(model)) return context;
421
437
 
422
438
  const shape = snapcompact.resolveShape(model, this.options.shape);
423
439
  const budget = snapcompact.providerImageBudget(model.provider) - countContextImages(context);