@oh-my-pi/pi-coding-agent 16.2.5 → 16.2.7

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 (93) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/dist/cli.js +3089 -3104
  3. package/dist/types/cli/bench-cli.d.ts +6 -1
  4. package/dist/types/commands/bench.d.ts +8 -0
  5. package/dist/types/config/model-discovery.d.ts +6 -1
  6. package/dist/types/config/service-tier.d.ts +39 -24
  7. package/dist/types/config/settings-schema.d.ts +37 -37
  8. package/dist/types/edit/index.d.ts +1 -0
  9. package/dist/types/edit/renderer.d.ts +4 -0
  10. package/dist/types/edit/snapshot-details.d.ts +33 -0
  11. package/dist/types/mcp/config-writer.d.ts +48 -0
  12. package/dist/types/mcp/oauth-flow.d.ts +4 -6
  13. package/dist/types/mcp/types.d.ts +3 -0
  14. package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
  15. package/dist/types/session/agent-session.d.ts +37 -13
  16. package/dist/types/session/messages.d.ts +1 -1
  17. package/dist/types/session/session-context.d.ts +2 -2
  18. package/dist/types/session/session-entries.d.ts +2 -2
  19. package/dist/types/session/session-manager.d.ts +5 -6
  20. package/dist/types/system-prompt.test.d.ts +1 -0
  21. package/dist/types/task/executor.d.ts +6 -6
  22. package/dist/types/task/types.d.ts +6 -0
  23. package/dist/types/task/worktree.d.ts +32 -6
  24. package/dist/types/tiny/title-client.d.ts +5 -1
  25. package/dist/types/tools/index.d.ts +3 -3
  26. package/dist/types/utils/git.d.ts +17 -0
  27. package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
  28. package/dist/types/web/search/types.d.ts +1 -1
  29. package/package.json +12 -12
  30. package/src/cli/args.ts +32 -1
  31. package/src/cli/bench-cli.ts +97 -22
  32. package/src/cli/tiny-models-cli.ts +18 -4
  33. package/src/cli/web-search-cli.ts +6 -1
  34. package/src/commands/bench.ts +10 -1
  35. package/src/config/mcp-schema.json +11 -2
  36. package/src/config/model-discovery.ts +66 -8
  37. package/src/config/model-registry.ts +13 -6
  38. package/src/config/service-tier.ts +85 -56
  39. package/src/config/settings-schema.ts +42 -36
  40. package/src/config/settings.ts +47 -0
  41. package/src/edit/hashline/execute.ts +15 -9
  42. package/src/edit/index.ts +19 -6
  43. package/src/edit/modes/patch.ts +3 -2
  44. package/src/edit/modes/replace.ts +3 -2
  45. package/src/edit/renderer.ts +4 -0
  46. package/src/edit/snapshot-details.ts +77 -0
  47. package/src/eval/agent-bridge.ts +4 -2
  48. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  49. package/src/internal-urls/docs-index.generated.txt +1 -1
  50. package/src/main.ts +1 -1
  51. package/src/mcp/config-writer.ts +121 -0
  52. package/src/mcp/config.ts +10 -6
  53. package/src/mcp/oauth-flow.ts +10 -8
  54. package/src/mcp/transports/stdio.ts +9 -17
  55. package/src/mcp/types.ts +3 -0
  56. package/src/modes/components/extensions/extension-dashboard.ts +46 -0
  57. package/src/modes/components/extensions/state-manager.ts +24 -3
  58. package/src/modes/controllers/event-controller.ts +24 -8
  59. package/src/modes/controllers/tool-args-reveal.ts +100 -22
  60. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  61. package/src/prompts/bench.md +4 -10
  62. package/src/prompts/tools/irc.md +19 -29
  63. package/src/prompts/tools/job.md +8 -14
  64. package/src/prompts/tools/lsp.md +19 -30
  65. package/src/prompts/tools/task.md +42 -62
  66. package/src/sdk.ts +13 -11
  67. package/src/session/agent-session.ts +196 -76
  68. package/src/session/messages.ts +1 -1
  69. package/src/session/session-context.ts +4 -4
  70. package/src/session/session-entries.ts +2 -2
  71. package/src/session/session-listing.ts +9 -8
  72. package/src/session/session-loader.ts +98 -3
  73. package/src/session/session-manager.ts +43 -6
  74. package/src/slash-commands/builtin-registry.ts +2 -10
  75. package/src/subprocess/worker-client.ts +12 -4
  76. package/src/system-prompt.test.ts +158 -0
  77. package/src/system-prompt.ts +69 -26
  78. package/src/task/executor.ts +23 -16
  79. package/src/task/index.ts +7 -5
  80. package/src/task/isolation-runner.ts +15 -1
  81. package/src/task/types.ts +6 -0
  82. package/src/task/worktree.ts +219 -38
  83. package/src/tiny/title-client.ts +19 -13
  84. package/src/tools/index.ts +3 -3
  85. package/src/tools/irc.ts +9 -3
  86. package/src/tools/path-utils.ts +4 -2
  87. package/src/tools/read.ts +28 -28
  88. package/src/utils/file-mentions.ts +10 -1
  89. package/src/utils/git.ts +38 -0
  90. package/src/web/search/index.ts +14 -8
  91. package/src/web/search/providers/duckduckgo.ts +150 -78
  92. package/src/web/search/providers/gemini.ts +268 -185
  93. package/src/web/search/types.ts +1 -1
@@ -92,6 +92,8 @@ import type {
92
92
  ResetCreditRedeemOutcome,
93
93
  ResetCreditTarget,
94
94
  ServiceTier,
95
+ ServiceTierByFamily,
96
+ ServiceTierFamily,
95
97
  SimpleStreamOptions,
96
98
  TextContent,
97
99
  ToolCall,
@@ -105,7 +107,9 @@ import {
105
107
  deriveClaudeDeviceId,
106
108
  Effort,
107
109
  parseRateLimitReason,
108
- resolveServiceTier,
110
+ realizesPriorityServiceTier,
111
+ resolveModelServiceTier,
112
+ serviceTierFamily,
109
113
  streamSimple,
110
114
  } from "@oh-my-pi/pi-ai";
111
115
  import * as AIError from "@oh-my-pi/pi-ai/error";
@@ -168,7 +172,7 @@ import {
168
172
  } from "../config/model-resolver";
169
173
  import { MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
170
174
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
171
- import { resolveServiceTierSetting } from "../config/service-tier";
175
+ import { buildServiceTierByFamily, serviceTierForAllFamilies, serviceTierSettingToTier } from "../config/service-tier";
172
176
  import type { Settings, SkillsSettings } from "../config/settings";
173
177
  import { getDefault, onAppendOnlyModeChanged, validateProviderMaxInFlightRequests } from "../config/settings";
174
178
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
@@ -506,6 +510,8 @@ export interface AgentSessionConfig {
506
510
  scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
507
511
  /** Initial session thinking selector. */
508
512
  thinkingLevel?: ConfiguredThinkingLevel;
513
+ /** Initial per-family service tiers (OpenAI / Anthropic / Google) for the live session. */
514
+ serviceTierByFamily?: ServiceTierByFamily;
509
515
  /** Prompt templates for expansion */
510
516
  promptTemplates?: PromptTemplate[];
511
517
  /** File-based slash commands for expansion */
@@ -549,6 +555,8 @@ export interface AgentSessionConfig {
549
555
  * main agent. Defaults to plain `streamSimple` when omitted.
550
556
  */
551
557
  advisorStreamFn?: StreamFn;
558
+ /** Hint that OpenAI Codex requests should prefer websocket transport when supported. */
559
+ preferWebsockets?: boolean;
552
560
  /** Provider payload hook used by the active session request path */
553
561
  onPayload?: SimpleStreamOptions["onPayload"];
554
562
  /** Provider response hook used by the active session request path */
@@ -1468,6 +1476,7 @@ export class AgentSession {
1468
1476
  #transformProviderContext: ((context: Context, model: Model) => Context | Promise<Context>) | undefined;
1469
1477
  #sideStreamFn: StreamFn;
1470
1478
  #advisorStreamFn: StreamFn | undefined;
1479
+ #preferWebsockets: boolean | undefined;
1471
1480
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
1472
1481
  #rebuildSystemPrompt:
1473
1482
  | ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
@@ -1784,6 +1793,7 @@ export class AgentSession {
1784
1793
  // toggle scopes priority to Fireworks alone, without mutating the shared
1785
1794
  // session `serviceTier` that drives `/fast` and OpenAI/Anthropic priority.
1786
1795
  this.agent.serviceTierResolver = model => this.#effectiveServiceTier(model);
1796
+ this.#serviceTierByFamily = config.serviceTierByFamily ?? {};
1787
1797
  this.#advisorTools = config.advisorTools;
1788
1798
  this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
1789
1799
  this.#advisorSharedInstructions = config.advisorSharedInstructions;
@@ -1798,6 +1808,7 @@ export class AgentSession {
1798
1808
  this.#transformProviderContext = config.transformProviderContext;
1799
1809
  this.#sideStreamFn = config.sideStreamFn ?? streamSimple;
1800
1810
  this.#advisorStreamFn = config.advisorStreamFn;
1811
+ this.#preferWebsockets = config.preferWebsockets;
1801
1812
  this.#onPayload = config.onPayload;
1802
1813
  this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
1803
1814
  // Avoid wrapping in an `async` closure when no user callback is configured: the
@@ -2041,15 +2052,20 @@ export class AgentSession {
2041
2052
  const legacy = !this.#advisorConfigs?.length;
2042
2053
  const roster: AdvisorConfig[] = legacy ? [{ name: "default" }] : this.#advisorConfigs!;
2043
2054
 
2044
- // Advisor service tier (`serviceTierAdvisor`): "none" (default) runs the
2045
- // advisor on standard processing; "inherit" tracks the session's live tier
2046
- // per request (like the main agent, including /fast toggles) via a resolver;
2047
- // a concrete value pins the advisor to that tier. One value for all advisors.
2048
- const advisorTierSetting = this.settings.get("serviceTierAdvisor");
2049
- const advisorServiceTier =
2050
- advisorTierSetting === "inherit" ? undefined : resolveServiceTierSetting(advisorTierSetting, undefined);
2051
- const advisorServiceTierResolver =
2052
- advisorTierSetting === "inherit" ? (model: Model) => this.#effectiveServiceTier(model) : undefined;
2055
+ // Advisor service tier (`tier.advisor`): "none" (default) runs the advisor
2056
+ // on standard processing; "inherit" tracks the session's live per-family
2057
+ // tiers per request (like the main agent, including /fast toggles); a
2058
+ // concrete value is broadcast across families and applied to the advisor
2059
+ // model's family. One value for all advisors.
2060
+ const advisorTierSetting = this.settings.get("tier.advisor");
2061
+ const advisorTierMap =
2062
+ advisorTierSetting === "inherit"
2063
+ ? undefined
2064
+ : serviceTierForAllFamilies(serviceTierSettingToTier(advisorTierSetting));
2065
+ const advisorServiceTierResolver = (model: Model): ServiceTier | undefined =>
2066
+ advisorTierSetting === "inherit"
2067
+ ? this.#effectiveServiceTier(model)
2068
+ : resolveModelServiceTier(advisorTierMap, model);
2053
2069
 
2054
2070
  const usedSlugs = new Set<string>();
2055
2071
  for (const config of roster) {
@@ -2139,6 +2155,7 @@ export class AgentSession {
2139
2155
  sessionId: advisorSessionId,
2140
2156
  promptCacheKey: advisorSessionId,
2141
2157
  providerSessionState: this.#providerSessionState,
2158
+ preferWebsockets: this.#preferWebsockets,
2142
2159
  getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
2143
2160
  streamFn: this.#advisorStreamFn,
2144
2161
  onPayload: this.#onPayload,
@@ -2147,7 +2164,7 @@ export class AgentSession {
2147
2164
  transformProviderContext: this.#transformProviderContext,
2148
2165
  intentTracing: false,
2149
2166
  telemetry: advisorTelemetry,
2150
- serviceTier: advisorServiceTier,
2167
+ serviceTier: undefined,
2151
2168
  serviceTierResolver: advisorServiceTierResolver,
2152
2169
  });
2153
2170
  advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
@@ -2624,6 +2641,11 @@ export class AgentSession {
2624
2641
  return this.#providerSessionState;
2625
2642
  }
2626
2643
 
2644
+ /** Hint forwarded to provider calls that support websocket transport. */
2645
+ get preferWebsockets(): boolean | undefined {
2646
+ return this.#preferWebsockets;
2647
+ }
2648
+
2627
2649
  getHindsightSessionState(): HindsightSessionState | undefined {
2628
2650
  return this.#hindsightSessionState;
2629
2651
  }
@@ -3205,10 +3227,11 @@ export class AgentSession {
3205
3227
  if (event.message.role === "assistant") {
3206
3228
  this.#lastAssistantMessage = event.message;
3207
3229
  const assistantMsg = event.message as AssistantMessage;
3208
- const currentGrantsAnthropicPriority =
3209
- this.serviceTier === "priority" || this.serviceTier === "claude-only";
3210
- if (assistantMsg.disabledFeatures?.includes("priority") && currentGrantsAnthropicPriority) {
3211
- this.setServiceTier(undefined);
3230
+ if (
3231
+ assistantMsg.disabledFeatures?.includes("priority") &&
3232
+ this.#serviceTierByFamily.anthropic === "priority"
3233
+ ) {
3234
+ this.setServiceTierFamily("anthropic", undefined);
3212
3235
  this.emitNotice(
3213
3236
  "warning",
3214
3237
  "Priority/fast mode rejected for this model; retried without it. Fast mode is now off.",
@@ -5193,8 +5216,11 @@ export class AgentSession {
5193
5216
  return this.#autoResolvedLevel;
5194
5217
  }
5195
5218
 
5196
- get serviceTier(): ServiceTier | undefined {
5197
- return this.agent.serviceTier;
5219
+ #serviceTierByFamily: ServiceTierByFamily = {};
5220
+
5221
+ /** Live per-family service tiers (OpenAI / Anthropic / Google). */
5222
+ get serviceTierByFamily(): ServiceTierByFamily {
5223
+ return this.#serviceTierByFamily;
5198
5224
  }
5199
5225
 
5200
5226
  /** Whether agent is currently streaming a response */
@@ -7882,7 +7908,7 @@ export class AgentSession {
7882
7908
  this.#scheduledHiddenNextTurnGeneration = undefined;
7883
7909
 
7884
7910
  this.sessionManager.appendThinkingLevelChange(this.thinkingLevel, this.configuredThinkingLevel());
7885
- this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
7911
+ this.sessionManager.appendServiceTierChange(this.#serviceTierEntry());
7886
7912
  if (nextDiscoverySessionToolNames) {
7887
7913
  await this.#applyActiveToolsByName(nextDiscoverySessionToolNames, { persistMCPSelection: false });
7888
7914
  if (this.getSelectedMCPToolNames().length > 0) {
@@ -8424,38 +8450,36 @@ export class AgentSession {
8424
8450
  }
8425
8451
 
8426
8452
  /**
8427
- * True when *any* fast-mode-granting service tier is configured, regardless
8428
- * of whether the active model's provider actually realizes it. Used by the
8429
- * toggle (`/fast on|off`) so re-toggling a scoped tier (`openai-only`,
8430
- * `claude-only`) doesn't silently broaden it to unscoped `priority`.
8453
+ * True when the currently selected model's family is set to `priority` — the
8454
+ * `/fast` on/off state for the active model. Returns false when no model is
8455
+ * selected or the model exposes no service-tier family (e.g. Fireworks, which
8456
+ * has its own Providers Fireworks Tier toggle).
8431
8457
  *
8432
- * For "is fast mode actually applied to the next request?" use
8433
- * {@link isFastModeActive} instead — that one respects the model's provider.
8458
+ * For "is priority actually applied to the next request?" use
8459
+ * {@link isFastModeActive} instead.
8434
8460
  */
8435
8461
  isFastModeEnabled(): boolean {
8436
- return (
8437
- this.serviceTier === "priority" || this.serviceTier === "claude-only" || this.serviceTier === "openai-only"
8438
- );
8462
+ const family = this.model ? serviceTierFamily(this.model) : undefined;
8463
+ return family ? this.#serviceTierByFamily[family] === "priority" : false;
8439
8464
  }
8440
8465
 
8441
8466
  /**
8442
- * True when the configured `serviceTier` resolves to `"priority"` for the
8443
- * *currently selected model's provider*. Returns false for scoped tiers
8444
- * that don't match (e.g. `"openai-only"` on an anthropic model) and when
8445
- * no model is selected.
8467
+ * True when `priority` is actually realized on the wire for the currently
8468
+ * selected model (OpenAI/Google `service_tier`, direct Anthropic fast mode,
8469
+ * or Fireworks priority). Returns false for tiers the active model can't
8470
+ * realize and when no model is selected.
8446
8471
  */
8447
8472
  isFastModeActive(): boolean {
8448
- return resolveServiceTier(this.#effectiveServiceTier(), this.model?.provider) === "priority";
8473
+ const model = this.model;
8474
+ return !!model && realizesPriorityServiceTier(this.#effectiveServiceTier(model), model);
8449
8475
  }
8450
8476
 
8451
8477
  /**
8452
- * Effective wire service-tier for a request to `model`. Fireworks models
8453
- * take the Priority serving path only when the Providers › Fireworks Tier
8454
- * setting is `"priority"` that toggle is the sole opt-in, so a global
8455
- * `serviceTier: "priority"` (for OpenAI/Anthropic) never silently incurs
8456
- * Fireworks priority costs and never for `-fast` variants, whose Fast
8457
- * serving path is mutually exclusive with Priority. Every other provider
8458
- * uses the session `serviceTier` unchanged.
8478
+ * Effective wire service-tier for a request to `model`. Fireworks models take
8479
+ * the Priority serving path only when the Providers › Fireworks Tier setting
8480
+ * is `"priority"` (and never for `-fast` variants, whose Fast serving path is
8481
+ * mutually exclusive with Priority). Every other model resolves the live
8482
+ * per-family tier map down to the entry for its family.
8459
8483
  */
8460
8484
  #effectiveServiceTier(model: Model | undefined = this.model): ServiceTier | undefined {
8461
8485
  if (model?.provider === "fireworks") {
@@ -8463,40 +8487,56 @@ export class AgentSession {
8463
8487
  ? "priority"
8464
8488
  : undefined;
8465
8489
  }
8466
- return this.serviceTier;
8490
+ if (!model) return undefined;
8491
+ return resolveModelServiceTier(this.#serviceTierByFamily, model);
8492
+ }
8493
+
8494
+ /** The live per-family tier map, or `null` when empty (for session persistence). */
8495
+ #serviceTierEntry(): ServiceTierByFamily | null {
8496
+ return Object.keys(this.#serviceTierByFamily).length > 0 ? this.#serviceTierByFamily : null;
8497
+ }
8498
+
8499
+ /** Set one family's tier (or clear it with `undefined`); persists the change. */
8500
+ setServiceTierFamily(family: ServiceTierFamily, tier: ServiceTier | undefined): void {
8501
+ if (this.#serviceTierByFamily[family] === tier) return;
8502
+ const next: ServiceTierByFamily = { ...this.#serviceTierByFamily };
8503
+ if (tier) next[family] = tier;
8504
+ else delete next[family];
8505
+ this.#applyServiceTierByFamily(next);
8467
8506
  }
8468
8507
 
8469
- setServiceTier(serviceTier: ServiceTier | undefined): void {
8470
- if (this.serviceTier === serviceTier) return;
8471
- // Re-arming priority on Anthropic? Clear the per-session auto-fallback
8472
- // sticky disable so the next request actually carries `speed: "fast"`
8473
- // again. Without this, `/fast on` (or user switching to a tier that
8474
- // grants anthropic priority) after an auto-disable is a silent no-op
8475
- // and the warning notice fires every turn.
8476
- if (serviceTier === "priority" || serviceTier === "claude-only") {
8508
+ /** Replace the whole per-family tier map; persists + re-arms Anthropic fast mode. */
8509
+ #applyServiceTierByFamily(next: ServiceTierByFamily): void {
8510
+ // Re-arming Anthropic priority clears the per-session fast-mode auto-disable
8511
+ // so the next request actually carries `speed: "fast"` again.
8512
+ if (next.anthropic === "priority" && this.#serviceTierByFamily.anthropic !== "priority") {
8477
8513
  clearAnthropicFastModeFallback(this.#providerSessionState);
8478
8514
  }
8479
- this.agent.serviceTier = serviceTier;
8480
- this.sessionManager.appendServiceTierChange(serviceTier ?? null);
8515
+ this.#serviceTierByFamily = next;
8516
+ this.sessionManager.appendServiceTierChange(this.#serviceTierEntry());
8481
8517
  }
8482
8518
 
8519
+ /**
8520
+ * `/fast on|off` targets the family of the currently selected model: it sets
8521
+ * (or clears) that family's `priority` tier. Models without a service-tier
8522
+ * family (Fireworks, or providers with no tier knob) have nothing to toggle.
8523
+ */
8483
8524
  setFastMode(enabled: boolean): void {
8484
- if (enabled && this.isFastModeEnabled()) {
8485
- // Already on under any scope — keep the user's scoped value.
8525
+ const family = this.model ? serviceTierFamily(this.model) : undefined;
8526
+ if (!family) {
8527
+ this.emitNotice("info", "The current model has no service-tier control for /fast to toggle.", "priority");
8486
8528
  return;
8487
8529
  }
8488
8530
  if (!enabled) {
8489
- this.setServiceTier(undefined);
8531
+ if (this.#serviceTierByFamily[family] === "priority") this.setServiceTierFamily(family, undefined);
8490
8532
  return;
8491
8533
  }
8492
- const scope = this.settings.get("fastModeScope");
8493
- this.setServiceTier(scope === "openai" ? "openai-only" : scope === "claude" ? "claude-only" : "priority");
8534
+ this.setServiceTierFamily(family, "priority");
8494
8535
  }
8495
8536
 
8496
8537
  toggleFastMode(): boolean {
8497
- const enabled = !this.isFastModeEnabled();
8498
- this.setFastMode(enabled);
8499
- return enabled;
8538
+ this.setFastMode(!this.isFastModeEnabled());
8539
+ return this.isFastModeEnabled();
8500
8540
  }
8501
8541
 
8502
8542
  /**
@@ -8861,6 +8901,8 @@ export class AgentSession {
8861
8901
  const wantsSnapcompact =
8862
8902
  compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions;
8863
8903
  const snapcompactReady = wantsSnapcompact;
8904
+ const snapcompactShapeSetting = this.settings.get("snapcompact.shape");
8905
+ let snapcompactShape: snapcompact.Shape | undefined;
8864
8906
  if (wantsSnapcompact && !this.model.input.includes("image")) {
8865
8907
  this.emitNotice(
8866
8908
  "warning",
@@ -8869,8 +8911,16 @@ export class AgentSession {
8869
8911
  );
8870
8912
  throw new Error(`snapcompact cannot run locally: ${this.model.id} is text-only.`);
8871
8913
  } else if (snapcompactReady) {
8872
- const text = snapcompact.serializeConversation(convertToLlm(preparation.messagesToSummarize));
8873
- const renderScan = snapcompact.scanRenderability(text);
8914
+ const text = snapcompact.serializeConversation(
8915
+ convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
8916
+ );
8917
+ const probeText = snapcompact.renderabilityProbeText(
8918
+ text,
8919
+ preparation.previousPreserveData,
8920
+ preparation.previousSummary,
8921
+ );
8922
+ snapcompactShape = snapcompact.resolveShapeForText(probeText, this.model, snapcompactShapeSetting);
8923
+ const renderScan = snapcompact.scanRenderability(probeText, { shape: snapcompactShape });
8874
8924
  if (!renderScan.isSafe) {
8875
8925
  const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
8876
8926
  this.emitNotice(
@@ -8908,10 +8958,14 @@ export class AgentSession {
8908
8958
  );
8909
8959
  throw new Error("snapcompact cannot run locally: kept history alone exceeds the context budget.");
8910
8960
  } else {
8961
+ const shape = snapcompactShape;
8962
+ if (!shape) {
8963
+ throw new Error("snapcompact shape was not resolved before rendering.");
8964
+ }
8911
8965
  snapcompactResult = await snapcompact.compact(preparation, {
8912
8966
  convertToLlm,
8913
8967
  model: this.model,
8914
- shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
8968
+ ...(snapcompactShapeSetting === "auto" ? {} : { shape }),
8915
8969
  maxFrames,
8916
8970
  });
8917
8971
  const ctxWindow = this.model?.contextWindow ?? 0;
@@ -11263,7 +11317,14 @@ export class AgentSession {
11263
11317
  const text = snapcompact.serializeConversation(
11264
11318
  convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
11265
11319
  );
11266
- const renderScan = snapcompact.scanRenderability(text);
11320
+ const probeText = snapcompact.renderabilityProbeText(
11321
+ text,
11322
+ preparation.previousPreserveData,
11323
+ preparation.previousSummary,
11324
+ );
11325
+ const shapeSetting = this.settings.get("snapcompact.shape");
11326
+ const shape = snapcompact.resolveShapeForText(probeText, this.model, shapeSetting);
11327
+ const renderScan = snapcompact.scanRenderability(probeText, { shape });
11267
11328
  if (!renderScan.isSafe) {
11268
11329
  const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
11269
11330
  logger.warn("Snapcompact disabled: high non-ASCII rate detected", {
@@ -11283,7 +11344,7 @@ export class AgentSession {
11283
11344
  snapcompactResult = await snapcompact.compact(preparation, {
11284
11345
  convertToLlm,
11285
11346
  model: this.model,
11286
- shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
11347
+ ...(shapeSetting === "auto" ? {} : { shape }),
11287
11348
  maxFrames,
11288
11349
  });
11289
11350
  if (snapcompactResult) {
@@ -12829,6 +12890,50 @@ export class AgentSession {
12829
12890
  // IRC Delivery
12830
12891
  // =========================================================================
12831
12892
 
12893
+ /**
12894
+ * Surfaces (and consumes) IRC incoming asides that have reached this running
12895
+ * session but have not yet been folded into the next model step.
12896
+ *
12897
+ * The inbox tool injects the formatted body into the tool result, so the
12898
+ * model sees it once via the result. Leaving the record in
12899
+ * {@link #pendingIrcAsides} would let the aside provider deliver it a second
12900
+ * time at the next step boundary — including on `peek`, which is why peek
12901
+ * also drains here.
12902
+ */
12903
+ drainPendingIrcInboxMessages(agentId: string): IrcMessage[] {
12904
+ const messages: IrcMessage[] = [];
12905
+ const remaining: CustomMessage[] = [];
12906
+ for (const record of this.#pendingIrcAsides) {
12907
+ if (record.customType !== "irc:incoming") {
12908
+ remaining.push(record);
12909
+ continue;
12910
+ }
12911
+ const details = record.details;
12912
+ if (!details || typeof details !== "object") {
12913
+ remaining.push(record);
12914
+ continue;
12915
+ }
12916
+ const id = Reflect.get(details, "id");
12917
+ const from = Reflect.get(details, "from");
12918
+ const body = Reflect.get(details, "message");
12919
+ const replyTo = Reflect.get(details, "replyTo");
12920
+ if (typeof id !== "string" || typeof from !== "string" || typeof body !== "string") {
12921
+ remaining.push(record);
12922
+ continue;
12923
+ }
12924
+ messages.push({
12925
+ id,
12926
+ from,
12927
+ to: agentId,
12928
+ body,
12929
+ ts: record.timestamp,
12930
+ ...(typeof replyTo === "string" ? { replyTo } : {}),
12931
+ });
12932
+ }
12933
+ this.#pendingIrcAsides = remaining;
12934
+ return messages;
12935
+ }
12936
+
12832
12937
  /**
12833
12938
  * Deliver an IRC message into this session (recipient side; called by the
12834
12939
  * IrcBus). Emits the `irc_message` session event for UI cards and injects
@@ -13140,7 +13245,15 @@ export class AgentSession {
13140
13245
  // Flush pending writes before switching so restore snapshots reflect committed state.
13141
13246
  await this.sessionManager.flush();
13142
13247
  const previousSessionState = this.sessionManager.captureState();
13143
- const previousSessionContext = this.buildDisplaySessionContext();
13248
+ // Only same-session reloads compare against the prior context to detect
13249
+ // rollback edits (`#didSessionMessagesChange` below). Building it for a
13250
+ // different-session switch is a pure waste — and on huge pre-fix sessions
13251
+ // it materializes every persisted snapcompact frame plus the
13252
+ // `openaiRemoteCompaction.replacementHistory` payload into messages,
13253
+ // blowing the heap before the new session even loads (issue #3846). The
13254
+ // error-recovery path rebuilds the context on demand from the restored
13255
+ // state instead.
13256
+ const previousSessionContext = switchingToDifferentSession ? undefined : this.buildDisplaySessionContext();
13144
13257
  // switchSession replaces these arrays wholesale during load/rollback, so retaining
13145
13258
  // the existing message objects is sufficient and avoids structured-clone failures for
13146
13259
  // extension/custom metadata that is valid to persist but not cloneable.
@@ -13153,7 +13266,7 @@ export class AgentSession {
13153
13266
  const previousThinkingLevel = this.#thinkingLevel;
13154
13267
  const previousAutoThinking = this.#autoThinking;
13155
13268
  const previousAutoResolvedLevel = this.#autoResolvedLevel;
13156
- const previousServiceTier = this.agent.serviceTier;
13269
+ const previousServiceTierByFamily = this.#serviceTierByFamily;
13157
13270
  const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
13158
13271
  const previousTools = [...this.agent.state.tools];
13159
13272
  const previousBaseSystemPrompt = this.#baseSystemPrompt;
@@ -13179,7 +13292,7 @@ export class AgentSession {
13179
13292
 
13180
13293
  const sessionContext = this.buildDisplaySessionContext();
13181
13294
  const didReloadConversationChange =
13182
- !switchingToDifferentSession &&
13295
+ previousSessionContext !== undefined &&
13183
13296
  this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
13184
13297
  const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
13185
13298
  await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
@@ -13239,7 +13352,11 @@ export class AgentSession {
13239
13352
  .getBranch()
13240
13353
  .some(entry => entry.type === "service_tier_change");
13241
13354
  const defaultThinkingLevel = parseConfiguredThinkingLevel(this.settings.get("defaultThinkingLevel"));
13242
- const configuredServiceTier = this.settings.get("serviceTier");
13355
+ const configuredServiceTierByFamily = buildServiceTierByFamily(
13356
+ this.settings.get("tier.openai"),
13357
+ this.settings.get("tier.anthropic"),
13358
+ this.settings.get("tier.google"),
13359
+ );
13243
13360
  // Restore the thinking selector. Each change persists the configured
13244
13361
  // selector (`auto` or a concrete level), so prefer it: an `auto` session
13245
13362
  // resumes in auto mode (reclassifying the next turn) instead of freezing at
@@ -13268,11 +13385,9 @@ export class AgentSession {
13268
13385
  this.#thinkingLevel = resolveThinkingLevelForModel(this.model, restoredThinkingLevel);
13269
13386
  }
13270
13387
  this.#applyThinkingLevelToAgent(this.#thinkingLevel);
13271
- this.agent.serviceTier = hasServiceTierEntry
13272
- ? sessionContext.serviceTier
13273
- : configuredServiceTier === "none"
13274
- ? undefined
13275
- : configuredServiceTier;
13388
+ this.#serviceTierByFamily = hasServiceTierEntry
13389
+ ? (sessionContext.serviceTier ?? {})
13390
+ : configuredServiceTierByFamily;
13276
13391
 
13277
13392
  if (switchingToDifferentSession) {
13278
13393
  await this.#resetMemoryContextForNewTranscript();
@@ -13295,7 +13410,12 @@ export class AgentSession {
13295
13410
  this.#rekeyMnemopiMemoryForCurrentSessionId();
13296
13411
  let restoreMcpError: unknown;
13297
13412
  try {
13298
- await this.#restoreMCPSelectionsForSessionContext(previousSessionContext, {
13413
+ // `previousSessionContext` was skipped on different-session switches to
13414
+ // avoid materializing the previous session's heavy compaction payload
13415
+ // in the success path; rebuild it here on demand from the restored
13416
+ // state so MCP selection restoration still has its inputs.
13417
+ const mcpRestoreContext = previousSessionContext ?? this.buildDisplaySessionContext();
13418
+ await this.#restoreMCPSelectionsForSessionContext(mcpRestoreContext, {
13299
13419
  fallbackSelectedMCPToolNames: previousFallbackSelectedMCPToolNames,
13300
13420
  });
13301
13421
  } catch (mcpError) {
@@ -13324,7 +13444,7 @@ export class AgentSession {
13324
13444
  this.#autoThinking = previousAutoThinking;
13325
13445
  this.#autoResolvedLevel = previousAutoResolvedLevel;
13326
13446
  this.#applyThinkingLevelToAgent(previousThinkingLevel);
13327
- this.agent.serviceTier = previousServiceTier;
13447
+ this.#serviceTierByFamily = previousServiceTierByFamily;
13328
13448
  this.#syncTodoPhasesFromBranch();
13329
13449
  this.#resetAllAdvisorRuntimes();
13330
13450
  this.#reconnectToAgent();
@@ -14278,7 +14398,7 @@ export class AgentSession {
14278
14398
  const payload = {
14279
14399
  model: this.agent.state.model ?? null,
14280
14400
  thinkingLevel: this.#thinkingLevel ?? null,
14281
- serviceTier: this.agent.serviceTier ?? null,
14401
+ serviceTier: this.#serviceTierEntry(),
14282
14402
  systemPrompt: this.agent.state.systemPrompt,
14283
14403
  tools: this.agent.state.tools.map(tool => ({
14284
14404
  name: tool.name,
@@ -488,7 +488,7 @@ export interface FileMentionMessage {
488
488
  /** File size in bytes, if known. */
489
489
  byteSize?: number;
490
490
  /** Why the file contents were omitted from auto-read. */
491
- skippedReason?: "tooLarge";
491
+ skippedReason?: "tooLarge" | "binary";
492
492
  image?: ImageContent;
493
493
  }>;
494
494
  timestamp: number;
@@ -1,5 +1,5 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
- import type { ProviderPayload, ServiceTier } from "@oh-my-pi/pi-ai";
2
+ import { coerceServiceTierByFamily, type ProviderPayload, type ServiceTierByFamily } from "@oh-my-pi/pi-ai";
3
3
  import * as snapcompact from "@oh-my-pi/snapcompact";
4
4
  import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "./messages";
5
5
  import { type CompactionEntry, EPHEMERAL_MODEL_CHANGE_ROLE, type SessionEntry } from "./session-entries";
@@ -9,7 +9,7 @@ export interface SessionContext {
9
9
  thinkingLevel?: string;
10
10
  /** Configured thinking selector (`"auto"` or a concrete level) from the latest change. */
11
11
  configuredThinkingLevel?: string;
12
- serviceTier?: ServiceTier;
12
+ serviceTier?: ServiceTierByFamily;
13
13
  /** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
14
14
  models: Record<string, string>;
15
15
  /** Names of TTSR rules that have been injected this session */
@@ -138,7 +138,7 @@ export function buildSessionContext(
138
138
  // Extract settings and find compaction
139
139
  let thinkingLevel: string | undefined = "off";
140
140
  let configuredThinkingLevel: string | undefined;
141
- let serviceTier: ServiceTier | undefined;
141
+ let serviceTier: ServiceTierByFamily | undefined;
142
142
  const models: Record<string, string> = {};
143
143
  let compaction: CompactionEntry | null = null;
144
144
  const injectedTtsrRulesSet = new Set<string>();
@@ -169,7 +169,7 @@ export function buildSessionContext(
169
169
  }
170
170
  }
171
171
  } else if (entry.type === "service_tier_change") {
172
- serviceTier = entry.serviceTier ?? undefined;
172
+ serviceTier = coerceServiceTierByFamily(entry.serviceTier);
173
173
  } else if (entry.type === "message" && entry.message.role === "assistant") {
174
174
  // Legacy fallback: infer default model from assistant messages only
175
175
  // when no explicit `model_change` (role=default) entry has been
@@ -1,5 +1,5 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
- import type { ImageContent, MessageAttribution, ServiceTier, TextContent } from "@oh-my-pi/pi-ai";
2
+ import type { ImageContent, MessageAttribution, ServiceTierByFamily, TextContent } from "@oh-my-pi/pi-ai";
3
3
 
4
4
  export const CURRENT_SESSION_VERSION = 3;
5
5
 
@@ -73,7 +73,7 @@ export interface ModelChangeEntry extends SessionEntryBase {
73
73
 
74
74
  export interface ServiceTierChangeEntry extends SessionEntryBase {
75
75
  type: "service_tier_change";
76
- serviceTier: ServiceTier | null;
76
+ serviceTier: ServiceTierByFamily | null;
77
77
  }
78
78
 
79
79
  export interface CompactionEntry<T = unknown> extends SessionEntryBase {
@@ -245,20 +245,21 @@ function countMessageMarkers(content: string): number {
245
245
  return count;
246
246
  }
247
247
 
248
- function extractFirstUserMessageFromPrefix(content: string): string | undefined {
249
- const roleIndex = content.indexOf('"role"');
250
- if (roleIndex === -1) return undefined;
248
+ function extractFirstDisplayMessageFromPrefix(content: string): string | undefined {
249
+ let fallback: string | undefined;
250
+ let index = content.indexOf('"role"');
251
251
 
252
- let index = roleIndex;
253
252
  while (index !== -1) {
254
253
  const role = extractStringProperty(content, "role", index);
255
- if (role === "user") {
256
- return extractStringProperty(content, "content", index) ?? extractStringProperty(content, "text", index);
254
+ const text = extractStringProperty(content, "content", index) ?? extractStringProperty(content, "text", index);
255
+ if (text) {
256
+ if (role === "user") return text;
257
+ if (!fallback && (role === "developer" || role === "assistant")) fallback = text;
257
258
  }
258
259
  index = content.indexOf('"role"', index + 6);
259
260
  }
260
261
 
261
- return undefined;
262
+ return fallback;
262
263
  }
263
264
 
264
265
  interface SessionListHeader {
@@ -394,7 +395,7 @@ async function scanSessionFile(
394
395
  }
395
396
  }
396
397
 
397
- firstMessage ||= extractFirstUserMessageFromPrefix(content) ?? "";
398
+ firstMessage ||= extractFirstDisplayMessageFromPrefix(content) ?? "";
398
399
  const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
399
400
  return {
400
401
  path: file,