@oh-my-pi/pi-coding-agent 16.2.6 → 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 (59) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/cli.js +2656 -2651
  3. package/dist/types/cli/bench-cli.d.ts +3 -3
  4. package/dist/types/commands/bench.d.ts +1 -1
  5. package/dist/types/config/service-tier.d.ts +39 -24
  6. package/dist/types/config/settings-schema.d.ts +36 -36
  7. package/dist/types/mcp/config-writer.d.ts +48 -0
  8. package/dist/types/mcp/types.d.ts +3 -0
  9. package/dist/types/session/agent-session.d.ts +33 -13
  10. package/dist/types/session/messages.d.ts +1 -1
  11. package/dist/types/session/session-context.d.ts +2 -2
  12. package/dist/types/session/session-entries.d.ts +2 -2
  13. package/dist/types/session/session-manager.d.ts +2 -2
  14. package/dist/types/system-prompt.test.d.ts +1 -0
  15. package/dist/types/task/executor.d.ts +6 -6
  16. package/dist/types/task/types.d.ts +6 -0
  17. package/dist/types/task/worktree.d.ts +32 -6
  18. package/dist/types/tiny/title-client.d.ts +5 -1
  19. package/dist/types/tools/index.d.ts +3 -3
  20. package/dist/types/utils/git.d.ts +17 -0
  21. package/package.json +12 -12
  22. package/src/cli/bench-cli.ts +19 -12
  23. package/src/cli/tiny-models-cli.ts +18 -4
  24. package/src/commands/bench.ts +3 -3
  25. package/src/config/mcp-schema.json +10 -1
  26. package/src/config/service-tier.ts +85 -56
  27. package/src/config/settings-schema.ts +42 -36
  28. package/src/config/settings.ts +47 -0
  29. package/src/eval/agent-bridge.ts +4 -2
  30. package/src/internal-urls/docs-index.generated.txt +1 -1
  31. package/src/main.ts +1 -1
  32. package/src/mcp/config-writer.ts +121 -0
  33. package/src/mcp/config.ts +10 -6
  34. package/src/mcp/types.ts +3 -0
  35. package/src/modes/components/extensions/extension-dashboard.ts +46 -0
  36. package/src/modes/components/extensions/state-manager.ts +24 -3
  37. package/src/modes/controllers/event-controller.ts +7 -0
  38. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  39. package/src/sdk.ts +12 -11
  40. package/src/session/agent-session.ts +186 -76
  41. package/src/session/messages.ts +1 -1
  42. package/src/session/session-context.ts +4 -4
  43. package/src/session/session-entries.ts +2 -2
  44. package/src/session/session-manager.ts +9 -2
  45. package/src/slash-commands/builtin-registry.ts +2 -10
  46. package/src/system-prompt.test.ts +158 -0
  47. package/src/system-prompt.ts +69 -26
  48. package/src/task/executor.ts +23 -16
  49. package/src/task/index.ts +7 -5
  50. package/src/task/isolation-runner.ts +15 -1
  51. package/src/task/types.ts +6 -0
  52. package/src/task/worktree.ts +219 -38
  53. package/src/tiny/title-client.ts +19 -13
  54. package/src/tools/index.ts +3 -3
  55. package/src/tools/irc.ts +9 -3
  56. package/src/tools/read.ts +28 -28
  57. package/src/utils/file-mentions.ts +10 -1
  58. package/src/utils/git.ts +38 -0
  59. package/src/web/search/providers/duckduckgo.ts +17 -3
@@ -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 */
@@ -1787,6 +1793,7 @@ export class AgentSession {
1787
1793
  // toggle scopes priority to Fireworks alone, without mutating the shared
1788
1794
  // session `serviceTier` that drives `/fast` and OpenAI/Anthropic priority.
1789
1795
  this.agent.serviceTierResolver = model => this.#effectiveServiceTier(model);
1796
+ this.#serviceTierByFamily = config.serviceTierByFamily ?? {};
1790
1797
  this.#advisorTools = config.advisorTools;
1791
1798
  this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
1792
1799
  this.#advisorSharedInstructions = config.advisorSharedInstructions;
@@ -2045,15 +2052,20 @@ export class AgentSession {
2045
2052
  const legacy = !this.#advisorConfigs?.length;
2046
2053
  const roster: AdvisorConfig[] = legacy ? [{ name: "default" }] : this.#advisorConfigs!;
2047
2054
 
2048
- // Advisor service tier (`serviceTierAdvisor`): "none" (default) runs the
2049
- // advisor on standard processing; "inherit" tracks the session's live tier
2050
- // per request (like the main agent, including /fast toggles) via a resolver;
2051
- // a concrete value pins the advisor to that tier. One value for all advisors.
2052
- const advisorTierSetting = this.settings.get("serviceTierAdvisor");
2053
- const advisorServiceTier =
2054
- advisorTierSetting === "inherit" ? undefined : resolveServiceTierSetting(advisorTierSetting, undefined);
2055
- const advisorServiceTierResolver =
2056
- 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);
2057
2069
 
2058
2070
  const usedSlugs = new Set<string>();
2059
2071
  for (const config of roster) {
@@ -2152,7 +2164,7 @@ export class AgentSession {
2152
2164
  transformProviderContext: this.#transformProviderContext,
2153
2165
  intentTracing: false,
2154
2166
  telemetry: advisorTelemetry,
2155
- serviceTier: advisorServiceTier,
2167
+ serviceTier: undefined,
2156
2168
  serviceTierResolver: advisorServiceTierResolver,
2157
2169
  });
2158
2170
  advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
@@ -3215,10 +3227,11 @@ export class AgentSession {
3215
3227
  if (event.message.role === "assistant") {
3216
3228
  this.#lastAssistantMessage = event.message;
3217
3229
  const assistantMsg = event.message as AssistantMessage;
3218
- const currentGrantsAnthropicPriority =
3219
- this.serviceTier === "priority" || this.serviceTier === "claude-only";
3220
- if (assistantMsg.disabledFeatures?.includes("priority") && currentGrantsAnthropicPriority) {
3221
- this.setServiceTier(undefined);
3230
+ if (
3231
+ assistantMsg.disabledFeatures?.includes("priority") &&
3232
+ this.#serviceTierByFamily.anthropic === "priority"
3233
+ ) {
3234
+ this.setServiceTierFamily("anthropic", undefined);
3222
3235
  this.emitNotice(
3223
3236
  "warning",
3224
3237
  "Priority/fast mode rejected for this model; retried without it. Fast mode is now off.",
@@ -5203,8 +5216,11 @@ export class AgentSession {
5203
5216
  return this.#autoResolvedLevel;
5204
5217
  }
5205
5218
 
5206
- get serviceTier(): ServiceTier | undefined {
5207
- 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;
5208
5224
  }
5209
5225
 
5210
5226
  /** Whether agent is currently streaming a response */
@@ -7892,7 +7908,7 @@ export class AgentSession {
7892
7908
  this.#scheduledHiddenNextTurnGeneration = undefined;
7893
7909
 
7894
7910
  this.sessionManager.appendThinkingLevelChange(this.thinkingLevel, this.configuredThinkingLevel());
7895
- this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
7911
+ this.sessionManager.appendServiceTierChange(this.#serviceTierEntry());
7896
7912
  if (nextDiscoverySessionToolNames) {
7897
7913
  await this.#applyActiveToolsByName(nextDiscoverySessionToolNames, { persistMCPSelection: false });
7898
7914
  if (this.getSelectedMCPToolNames().length > 0) {
@@ -8434,38 +8450,36 @@ export class AgentSession {
8434
8450
  }
8435
8451
 
8436
8452
  /**
8437
- * True when *any* fast-mode-granting service tier is configured, regardless
8438
- * of whether the active model's provider actually realizes it. Used by the
8439
- * toggle (`/fast on|off`) so re-toggling a scoped tier (`openai-only`,
8440
- * `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).
8441
8457
  *
8442
- * For "is fast mode actually applied to the next request?" use
8443
- * {@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.
8444
8460
  */
8445
8461
  isFastModeEnabled(): boolean {
8446
- return (
8447
- this.serviceTier === "priority" || this.serviceTier === "claude-only" || this.serviceTier === "openai-only"
8448
- );
8462
+ const family = this.model ? serviceTierFamily(this.model) : undefined;
8463
+ return family ? this.#serviceTierByFamily[family] === "priority" : false;
8449
8464
  }
8450
8465
 
8451
8466
  /**
8452
- * True when the configured `serviceTier` resolves to `"priority"` for the
8453
- * *currently selected model's provider*. Returns false for scoped tiers
8454
- * that don't match (e.g. `"openai-only"` on an anthropic model) and when
8455
- * 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.
8456
8471
  */
8457
8472
  isFastModeActive(): boolean {
8458
- return resolveServiceTier(this.#effectiveServiceTier(), this.model?.provider) === "priority";
8473
+ const model = this.model;
8474
+ return !!model && realizesPriorityServiceTier(this.#effectiveServiceTier(model), model);
8459
8475
  }
8460
8476
 
8461
8477
  /**
8462
- * Effective wire service-tier for a request to `model`. Fireworks models
8463
- * take the Priority serving path only when the Providers › Fireworks Tier
8464
- * setting is `"priority"` that toggle is the sole opt-in, so a global
8465
- * `serviceTier: "priority"` (for OpenAI/Anthropic) never silently incurs
8466
- * Fireworks priority costs and never for `-fast` variants, whose Fast
8467
- * serving path is mutually exclusive with Priority. Every other provider
8468
- * 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.
8469
8483
  */
8470
8484
  #effectiveServiceTier(model: Model | undefined = this.model): ServiceTier | undefined {
8471
8485
  if (model?.provider === "fireworks") {
@@ -8473,40 +8487,56 @@ export class AgentSession {
8473
8487
  ? "priority"
8474
8488
  : undefined;
8475
8489
  }
8476
- 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;
8477
8497
  }
8478
8498
 
8479
- setServiceTier(serviceTier: ServiceTier | undefined): void {
8480
- if (this.serviceTier === serviceTier) return;
8481
- // Re-arming priority on Anthropic? Clear the per-session auto-fallback
8482
- // sticky disable so the next request actually carries `speed: "fast"`
8483
- // again. Without this, `/fast on` (or user switching to a tier that
8484
- // grants anthropic priority) after an auto-disable is a silent no-op
8485
- // and the warning notice fires every turn.
8486
- if (serviceTier === "priority" || serviceTier === "claude-only") {
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);
8506
+ }
8507
+
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") {
8487
8513
  clearAnthropicFastModeFallback(this.#providerSessionState);
8488
8514
  }
8489
- this.agent.serviceTier = serviceTier;
8490
- this.sessionManager.appendServiceTierChange(serviceTier ?? null);
8515
+ this.#serviceTierByFamily = next;
8516
+ this.sessionManager.appendServiceTierChange(this.#serviceTierEntry());
8491
8517
  }
8492
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
+ */
8493
8524
  setFastMode(enabled: boolean): void {
8494
- if (enabled && this.isFastModeEnabled()) {
8495
- // 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");
8496
8528
  return;
8497
8529
  }
8498
8530
  if (!enabled) {
8499
- this.setServiceTier(undefined);
8531
+ if (this.#serviceTierByFamily[family] === "priority") this.setServiceTierFamily(family, undefined);
8500
8532
  return;
8501
8533
  }
8502
- const scope = this.settings.get("fastModeScope");
8503
- this.setServiceTier(scope === "openai" ? "openai-only" : scope === "claude" ? "claude-only" : "priority");
8534
+ this.setServiceTierFamily(family, "priority");
8504
8535
  }
8505
8536
 
8506
8537
  toggleFastMode(): boolean {
8507
- const enabled = !this.isFastModeEnabled();
8508
- this.setFastMode(enabled);
8509
- return enabled;
8538
+ this.setFastMode(!this.isFastModeEnabled());
8539
+ return this.isFastModeEnabled();
8510
8540
  }
8511
8541
 
8512
8542
  /**
@@ -8871,6 +8901,8 @@ export class AgentSession {
8871
8901
  const wantsSnapcompact =
8872
8902
  compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions;
8873
8903
  const snapcompactReady = wantsSnapcompact;
8904
+ const snapcompactShapeSetting = this.settings.get("snapcompact.shape");
8905
+ let snapcompactShape: snapcompact.Shape | undefined;
8874
8906
  if (wantsSnapcompact && !this.model.input.includes("image")) {
8875
8907
  this.emitNotice(
8876
8908
  "warning",
@@ -8879,8 +8911,16 @@ export class AgentSession {
8879
8911
  );
8880
8912
  throw new Error(`snapcompact cannot run locally: ${this.model.id} is text-only.`);
8881
8913
  } else if (snapcompactReady) {
8882
- const text = snapcompact.serializeConversation(convertToLlm(preparation.messagesToSummarize));
8883
- 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 });
8884
8924
  if (!renderScan.isSafe) {
8885
8925
  const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
8886
8926
  this.emitNotice(
@@ -8918,10 +8958,14 @@ export class AgentSession {
8918
8958
  );
8919
8959
  throw new Error("snapcompact cannot run locally: kept history alone exceeds the context budget.");
8920
8960
  } else {
8961
+ const shape = snapcompactShape;
8962
+ if (!shape) {
8963
+ throw new Error("snapcompact shape was not resolved before rendering.");
8964
+ }
8921
8965
  snapcompactResult = await snapcompact.compact(preparation, {
8922
8966
  convertToLlm,
8923
8967
  model: this.model,
8924
- shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
8968
+ ...(snapcompactShapeSetting === "auto" ? {} : { shape }),
8925
8969
  maxFrames,
8926
8970
  });
8927
8971
  const ctxWindow = this.model?.contextWindow ?? 0;
@@ -11273,7 +11317,14 @@ export class AgentSession {
11273
11317
  const text = snapcompact.serializeConversation(
11274
11318
  convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
11275
11319
  );
11276
- 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 });
11277
11328
  if (!renderScan.isSafe) {
11278
11329
  const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
11279
11330
  logger.warn("Snapcompact disabled: high non-ASCII rate detected", {
@@ -11293,7 +11344,7 @@ export class AgentSession {
11293
11344
  snapcompactResult = await snapcompact.compact(preparation, {
11294
11345
  convertToLlm,
11295
11346
  model: this.model,
11296
- shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
11347
+ ...(shapeSetting === "auto" ? {} : { shape }),
11297
11348
  maxFrames,
11298
11349
  });
11299
11350
  if (snapcompactResult) {
@@ -12839,6 +12890,50 @@ export class AgentSession {
12839
12890
  // IRC Delivery
12840
12891
  // =========================================================================
12841
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
+
12842
12937
  /**
12843
12938
  * Deliver an IRC message into this session (recipient side; called by the
12844
12939
  * IrcBus). Emits the `irc_message` session event for UI cards and injects
@@ -13150,7 +13245,15 @@ export class AgentSession {
13150
13245
  // Flush pending writes before switching so restore snapshots reflect committed state.
13151
13246
  await this.sessionManager.flush();
13152
13247
  const previousSessionState = this.sessionManager.captureState();
13153
- 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();
13154
13257
  // switchSession replaces these arrays wholesale during load/rollback, so retaining
13155
13258
  // the existing message objects is sufficient and avoids structured-clone failures for
13156
13259
  // extension/custom metadata that is valid to persist but not cloneable.
@@ -13163,7 +13266,7 @@ export class AgentSession {
13163
13266
  const previousThinkingLevel = this.#thinkingLevel;
13164
13267
  const previousAutoThinking = this.#autoThinking;
13165
13268
  const previousAutoResolvedLevel = this.#autoResolvedLevel;
13166
- const previousServiceTier = this.agent.serviceTier;
13269
+ const previousServiceTierByFamily = this.#serviceTierByFamily;
13167
13270
  const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
13168
13271
  const previousTools = [...this.agent.state.tools];
13169
13272
  const previousBaseSystemPrompt = this.#baseSystemPrompt;
@@ -13189,7 +13292,7 @@ export class AgentSession {
13189
13292
 
13190
13293
  const sessionContext = this.buildDisplaySessionContext();
13191
13294
  const didReloadConversationChange =
13192
- !switchingToDifferentSession &&
13295
+ previousSessionContext !== undefined &&
13193
13296
  this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
13194
13297
  const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
13195
13298
  await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
@@ -13249,7 +13352,11 @@ export class AgentSession {
13249
13352
  .getBranch()
13250
13353
  .some(entry => entry.type === "service_tier_change");
13251
13354
  const defaultThinkingLevel = parseConfiguredThinkingLevel(this.settings.get("defaultThinkingLevel"));
13252
- 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
+ );
13253
13360
  // Restore the thinking selector. Each change persists the configured
13254
13361
  // selector (`auto` or a concrete level), so prefer it: an `auto` session
13255
13362
  // resumes in auto mode (reclassifying the next turn) instead of freezing at
@@ -13278,11 +13385,9 @@ export class AgentSession {
13278
13385
  this.#thinkingLevel = resolveThinkingLevelForModel(this.model, restoredThinkingLevel);
13279
13386
  }
13280
13387
  this.#applyThinkingLevelToAgent(this.#thinkingLevel);
13281
- this.agent.serviceTier = hasServiceTierEntry
13282
- ? sessionContext.serviceTier
13283
- : configuredServiceTier === "none"
13284
- ? undefined
13285
- : configuredServiceTier;
13388
+ this.#serviceTierByFamily = hasServiceTierEntry
13389
+ ? (sessionContext.serviceTier ?? {})
13390
+ : configuredServiceTierByFamily;
13286
13391
 
13287
13392
  if (switchingToDifferentSession) {
13288
13393
  await this.#resetMemoryContextForNewTranscript();
@@ -13305,7 +13410,12 @@ export class AgentSession {
13305
13410
  this.#rekeyMnemopiMemoryForCurrentSessionId();
13306
13411
  let restoreMcpError: unknown;
13307
13412
  try {
13308
- 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, {
13309
13419
  fallbackSelectedMCPToolNames: previousFallbackSelectedMCPToolNames,
13310
13420
  });
13311
13421
  } catch (mcpError) {
@@ -13334,7 +13444,7 @@ export class AgentSession {
13334
13444
  this.#autoThinking = previousAutoThinking;
13335
13445
  this.#autoResolvedLevel = previousAutoResolvedLevel;
13336
13446
  this.#applyThinkingLevelToAgent(previousThinkingLevel);
13337
- this.agent.serviceTier = previousServiceTier;
13447
+ this.#serviceTierByFamily = previousServiceTierByFamily;
13338
13448
  this.#syncTodoPhasesFromBranch();
13339
13449
  this.#resetAllAdvisorRuntimes();
13340
13450
  this.#reconnectToAgent();
@@ -14288,7 +14398,7 @@ export class AgentSession {
14288
14398
  const payload = {
14289
14399
  model: this.agent.state.model ?? null,
14290
14400
  thinkingLevel: this.#thinkingLevel ?? null,
14291
- serviceTier: this.agent.serviceTier ?? null,
14401
+ serviceTier: this.#serviceTierEntry(),
14292
14402
  systemPrompt: this.agent.state.systemPrompt,
14293
14403
  tools: this.agent.state.tools.map(tool => ({
14294
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 {
@@ -1,6 +1,13 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { ImageContent, Message, MessageAttribution, ServiceTier, TextContent, Usage } from "@oh-my-pi/pi-ai";
3
+ import type {
4
+ ImageContent,
5
+ Message,
6
+ MessageAttribution,
7
+ ServiceTierByFamily,
8
+ TextContent,
9
+ Usage,
10
+ } from "@oh-my-pi/pi-ai";
4
11
  import {
5
12
  directoryExists,
6
13
  getBlobsDir,
@@ -1286,7 +1293,7 @@ export class SessionManager {
1286
1293
  return entry.id;
1287
1294
  }
1288
1295
 
1289
- appendServiceTierChange(serviceTier: ServiceTier | null): string {
1296
+ appendServiceTierChange(serviceTier: ServiceTierByFamily | null): string {
1290
1297
  const entry: ServiceTierChangeEntry = { type: "service_tier_change", ...this.#freshEntryFields(), serviceTier };
1291
1298
  this.#recordEntry(entry);
1292
1299
  return entry.id;
@@ -73,17 +73,9 @@ function refreshStatusLine(ctx: InteractiveModeContext): void {
73
73
  ctx.ui.requestRender();
74
74
  }
75
75
 
76
- /** `/fast status` label: "off", "on", or scope-qualified "on (… only)". */
76
+ /** `/fast status` label for the active model: "on" when its family is priority, else "off". */
77
77
  function formatFastModeStatus(session: AgentSession): string {
78
- if (!session.isFastModeEnabled()) return "off";
79
- switch (session.serviceTier) {
80
- case "openai-only":
81
- return "on (OpenAI only)";
82
- case "claude-only":
83
- return "on (Claude only)";
84
- default:
85
- return "on";
86
- }
78
+ return session.isFastModeEnabled() ? "on" : "off";
87
79
  }
88
80
 
89
81
  const AUTOCOMPLETE_DETAIL_LIMIT = 48;