@oh-my-pi/pi-coding-agent 15.11.3 → 15.11.4

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 (103) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli.js +353 -294
  3. package/dist/types/config/api-key-resolver.d.ts +9 -3
  4. package/dist/types/config/keybindings.d.ts +1 -1
  5. package/dist/types/config/model-discovery.d.ts +6 -4
  6. package/dist/types/config/model-registry.d.ts +7 -4
  7. package/dist/types/config/settings-schema.d.ts +458 -155
  8. package/dist/types/export/html/template.generated.d.ts +1 -1
  9. package/dist/types/mnemopi/config.d.ts +3 -1
  10. package/dist/types/modes/components/settings-defs.d.ts +9 -2
  11. package/dist/types/modes/components/settings-selector.d.ts +9 -4
  12. package/dist/types/modes/components/tool-execution.d.ts +12 -1
  13. package/dist/types/modes/components/transcript-container.d.ts +12 -0
  14. package/dist/types/modes/controllers/input-controller.d.ts +9 -1
  15. package/dist/types/modes/theme/theme.d.ts +23 -3
  16. package/dist/types/session/agent-session.d.ts +14 -7
  17. package/dist/types/session/auth-storage.d.ts +1 -1
  18. package/dist/types/session/snapcompact-inline.d.ts +28 -0
  19. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
  20. package/dist/types/system-prompt.d.ts +3 -1
  21. package/dist/types/task/render.d.ts +16 -6
  22. package/dist/types/tools/gh.d.ts +3 -0
  23. package/dist/types/tools/render-utils.d.ts +8 -16
  24. package/dist/types/utils/session-color.d.ts +15 -3
  25. package/dist/types/web/kagi.d.ts +1 -2
  26. package/dist/types/web/search/providers/codex.d.ts +1 -1
  27. package/dist/types/web/search/providers/gemini.d.ts +9 -6
  28. package/package.json +11 -11
  29. package/src/auto-thinking/classifier.ts +1 -5
  30. package/src/commit/model-selection.ts +3 -6
  31. package/src/config/api-key-resolver.ts +10 -3
  32. package/src/config/keybindings.ts +1 -1
  33. package/src/config/model-discovery.ts +60 -46
  34. package/src/config/model-registry.ts +21 -8
  35. package/src/config/model-resolver.ts +57 -3
  36. package/src/config/settings-schema.ts +601 -153
  37. package/src/eval/completion-bridge.ts +1 -5
  38. package/src/export/html/template.generated.ts +1 -1
  39. package/src/export/html/template.js +13 -6
  40. package/src/internal-urls/docs-index.generated.ts +5 -5
  41. package/src/internal-urls/issue-pr-protocol.ts +10 -4
  42. package/src/memories/index.ts +2 -10
  43. package/src/mnemopi/backend.ts +30 -8
  44. package/src/mnemopi/config.ts +6 -1
  45. package/src/mnemopi/state.ts +6 -0
  46. package/src/modes/components/extensions/inspector-panel.ts +6 -2
  47. package/src/modes/components/plan-review-overlay.ts +15 -17
  48. package/src/modes/components/plugin-settings.ts +22 -5
  49. package/src/modes/components/settings-defs.ts +19 -4
  50. package/src/modes/components/settings-selector.ts +493 -93
  51. package/src/modes/components/status-line/component.ts +3 -1
  52. package/src/modes/components/status-line/segments.ts +3 -1
  53. package/src/modes/components/tool-execution.ts +69 -12
  54. package/src/modes/components/transcript-container.ts +26 -0
  55. package/src/modes/components/tree-selector.ts +16 -6
  56. package/src/modes/controllers/command-controller.ts +37 -7
  57. package/src/modes/controllers/event-controller.ts +1 -0
  58. package/src/modes/controllers/input-controller.ts +68 -6
  59. package/src/modes/controllers/selector-controller.ts +81 -61
  60. package/src/modes/interactive-mode.ts +4 -2
  61. package/src/modes/rpc/rpc-mode.ts +2 -1
  62. package/src/modes/shared.ts +2 -0
  63. package/src/modes/theme/theme.ts +100 -7
  64. package/src/modes/utils/context-usage.ts +3 -1
  65. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  66. package/src/modes/utils/ui-helpers.ts +9 -5
  67. package/src/prompts/system/personalities/default.md +26 -0
  68. package/src/prompts/system/personalities/friendly.md +17 -0
  69. package/src/prompts/system/personalities/pragmatic.md +15 -0
  70. package/src/prompts/system/snapcompact-system-frames-note.md +1 -0
  71. package/src/prompts/system/snapcompact-system-stub.md +1 -0
  72. package/src/prompts/system/snapcompact-toolresult-note.md +1 -0
  73. package/src/prompts/system/system-prompt.md +5 -22
  74. package/src/prompts/tools/task.md +3 -3
  75. package/src/sdk.ts +22 -1
  76. package/src/session/agent-session.ts +91 -24
  77. package/src/session/auth-storage.ts +1 -0
  78. package/src/session/session-dump-format.ts +8 -1
  79. package/src/session/session-manager.ts +5 -5
  80. package/src/session/snapcompact-inline.ts +187 -0
  81. package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
  82. package/src/slash-commands/helpers/usage-report.ts +24 -3
  83. package/src/system-prompt.ts +15 -1
  84. package/src/task/render.ts +29 -19
  85. package/src/tool-discovery/tool-index.ts +2 -0
  86. package/src/tools/bash.ts +10 -3
  87. package/src/tools/eval-render.ts +13 -8
  88. package/src/tools/gh.ts +39 -1
  89. package/src/tools/image-gen.ts +114 -78
  90. package/src/tools/inspect-image.ts +1 -5
  91. package/src/tools/job.ts +25 -5
  92. package/src/tools/read.ts +1 -57
  93. package/src/tools/render-utils.ts +29 -31
  94. package/src/tools/ssh.ts +3 -3
  95. package/src/tools/tts.ts +40 -20
  96. package/src/utils/clipboard.ts +56 -4
  97. package/src/utils/commit-message-generator.ts +1 -5
  98. package/src/utils/session-color.ts +83 -9
  99. package/src/utils/title-generator.ts +1 -1
  100. package/src/web/kagi.ts +26 -27
  101. package/src/web/search/providers/codex.ts +42 -40
  102. package/src/web/search/providers/gemini.ts +42 -22
  103. package/src/web/search/providers/perplexity.ts +22 -10
@@ -107,7 +107,7 @@ import {
107
107
  relativePathWithinRoot,
108
108
  Snowflake,
109
109
  } from "@oh-my-pi/pi-utils";
110
- import { snapcompactCompact } from "@oh-my-pi/snapcompact";
110
+ import * as snapcompact from "@oh-my-pi/snapcompact";
111
111
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
112
112
  import { classifyDifficulty } from "../auto-thinking/classifier";
113
113
  import { reset as resetCapabilities } from "../capability";
@@ -855,8 +855,13 @@ function extractPermissionLocations(
855
855
  * `tag` is set only by `enqueueCustomMessageDisplay` (used for skill-prompt
856
856
  * custom messages queued during streaming) and is matched by the custom-role
857
857
  * `message_start` dequeue branch; user-message pushes leave it undefined and
858
- * rely on the existing text-equality match. */
859
- type QueuedDisplayEntry = { text: string; tag?: string };
858
+ * rely on the existing text-equality match. `images` carries the original
859
+ * (pre-normalization) image blocks so queue restoration (Esc / Alt+Up) can
860
+ * hand them back to the editor instead of dropping them. */
861
+ type QueuedDisplayEntry = { text: string; tag?: string; images?: ImageContent[] };
862
+
863
+ /** Entry returned by {@link AgentSession.clearQueue} / {@link AgentSession.popLastQueuedMessage}. */
864
+ export type RestoredQueuedMessage = { text: string; images?: ImageContent[] };
860
865
 
861
866
  export class AgentSession {
862
867
  readonly agent: Agent;
@@ -5028,7 +5033,7 @@ export class AgentSession {
5028
5033
  async #queueSteer(text: string, images?: ImageContent[]): Promise<void> {
5029
5034
  const normalizedImages = await normalizeModelContextImages(images);
5030
5035
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
5031
- this.#steeringMessages.push({ text: displayText });
5036
+ this.#steeringMessages.push({ text: displayText, images });
5032
5037
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
5033
5038
  if (normalizedImages && normalizedImages.length > 0) {
5034
5039
  content.push(...normalizedImages);
@@ -5040,6 +5045,16 @@ export class AgentSession {
5040
5045
  attribution: "user",
5041
5046
  timestamp: Date.now(),
5042
5047
  });
5048
+ // A steer can land on an idle session: the caller checked isStreaming
5049
+ // before the (potentially slow) image normalization above, so the turn
5050
+ // may have ended in between. Without a drain the message would strand in
5051
+ // the queue until the next manual prompt — schedule an immediate continue,
5052
+ // mirroring #queueFollowUp's idle-path delivery.
5053
+ if (this.#canAutoContinueForFollowUp()) {
5054
+ this.#scheduleAgentContinue({
5055
+ shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(),
5056
+ });
5057
+ }
5043
5058
  }
5044
5059
 
5045
5060
  /**
@@ -5048,7 +5063,7 @@ export class AgentSession {
5048
5063
  async #queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
5049
5064
  const normalizedImages = await normalizeModelContextImages(images);
5050
5065
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
5051
- this.#followUpMessages.push({ text: displayText });
5066
+ this.#followUpMessages.push({ text: displayText, images });
5052
5067
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
5053
5068
  if (normalizedImages && normalizedImages.length > 0) {
5054
5069
  content.push(...normalizedImages);
@@ -5297,12 +5312,14 @@ export class AgentSession {
5297
5312
  }
5298
5313
 
5299
5314
  /**
5300
- * Clear queued messages and return them.
5301
- * Useful for restoring to editor when user aborts.
5315
+ * Clear queued messages and return them (text plus any attached images).
5316
+ * Useful for restoring to editor when user aborts. The internal entry
5317
+ * arrays are handed out as-is — a `tag` (if any) is inert once the record
5318
+ * leaves the queue.
5302
5319
  */
5303
- clearQueue(): { steering: string[]; followUp: string[] } {
5304
- const steering = this.#steeringMessages.map(e => e.text);
5305
- const followUp = this.#followUpMessages.map(e => e.text);
5320
+ clearQueue(): { steering: RestoredQueuedMessage[]; followUp: RestoredQueuedMessage[] } {
5321
+ const steering = this.#steeringMessages;
5322
+ const followUp = this.#followUpMessages;
5306
5323
  this.#steeringMessages = [];
5307
5324
  this.#followUpMessages = [];
5308
5325
  this.agent.clearAllQueues();
@@ -5328,21 +5345,21 @@ export class AgentSession {
5328
5345
  /**
5329
5346
  * Pop the last queued message (steering first, then follow-up).
5330
5347
  * Used by dequeue keybinding to restore messages to editor one at a time.
5331
- * Returns the popped entry's `.text`; the tag (if any) dies with the
5332
- * record — no orphan state can outlive the queue entry.
5348
+ * Returns the popped entry's text and images; the tag (if any) dies with
5349
+ * the record — no orphan state can outlive the queue entry.
5333
5350
  */
5334
- popLastQueuedMessage(): string | undefined {
5351
+ popLastQueuedMessage(): RestoredQueuedMessage | undefined {
5335
5352
  // Pop from steering first (LIFO)
5336
5353
  if (this.#steeringMessages.length > 0) {
5337
5354
  const entry = this.#steeringMessages.pop();
5338
5355
  this.agent.popLastSteer();
5339
- return entry?.text;
5356
+ return entry;
5340
5357
  }
5341
5358
  // Then from follow-up
5342
5359
  if (this.#followUpMessages.length > 0) {
5343
5360
  const entry = this.#followUpMessages.pop();
5344
5361
  this.agent.popLastFollowUp();
5345
- return entry?.text;
5362
+ return entry;
5346
5363
  }
5347
5364
  return undefined;
5348
5365
  }
@@ -6368,7 +6385,10 @@ export class AgentSession {
6368
6385
  details = compactionPrep.details;
6369
6386
  preserveData = compactionPrep.preserveData;
6370
6387
  } else if (snapcompactReady) {
6371
- const snapcompactResult = await snapcompactCompact(preparation, { convertToLlm, model: this.model });
6388
+ const snapcompactResult = await snapcompact.compact(preparation, {
6389
+ convertToLlm,
6390
+ model: this.model,
6391
+ });
6372
6392
  summary = snapcompactResult.summary;
6373
6393
  shortSummary = snapcompactResult.shortSummary;
6374
6394
  firstKeptEntryId = snapcompactResult.firstKeptEntryId;
@@ -6582,7 +6602,7 @@ export class AgentSession {
6582
6602
  const rawHandoffText = await generateHandoff(
6583
6603
  this.agent.state.messages,
6584
6604
  model,
6585
- apiKey,
6605
+ this.#modelRegistry.resolver(model, this.sessionId),
6586
6606
  {
6587
6607
  systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt),
6588
6608
  tools: obfuscateProviderTools(this.#obfuscator, this.agent.state.tools),
@@ -7252,6 +7272,22 @@ export class AgentSession {
7252
7272
  this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
7253
7273
  }
7254
7274
 
7275
+ #resetCurrentResponsesProviderSession(reason: string): void {
7276
+ const currentModel = this.model;
7277
+ if (currentModel?.api !== "openai-responses" && currentModel?.api !== "openai-codex-responses") {
7278
+ return;
7279
+ }
7280
+
7281
+ this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
7282
+ this.agent.appendOnlyContext?.invalidateForModelChange();
7283
+ logger.debug("Reset Responses provider session after stale replay error", {
7284
+ provider: currentModel.provider,
7285
+ model: currentModel.id,
7286
+ api: currentModel.api,
7287
+ reason,
7288
+ });
7289
+ }
7290
+
7255
7291
  /**
7256
7292
  * Re-evaluate append-only context mode, creating or destroying the
7257
7293
  * manager as needed. Called on model switch AND setting change.
@@ -7577,7 +7613,7 @@ export class AgentSession {
7577
7613
  return await compact(
7578
7614
  this.#obfuscatePreparationForProvider(preparation),
7579
7615
  candidate,
7580
- apiKey,
7616
+ this.#modelRegistry.resolver(candidate, this.sessionId),
7581
7617
  this.#obfuscateTextForProvider(customInstructions),
7582
7618
  signal,
7583
7619
  {
@@ -7882,7 +7918,10 @@ export class AgentSession {
7882
7918
  } else if (action === "snapcompact") {
7883
7919
  // Local, deterministic: render discarded history onto PNG frames.
7884
7920
  // No model candidates, no API key, no retry loop.
7885
- const snapcompactResult = await snapcompactCompact(preparation, { convertToLlm, model: this.model });
7921
+ const snapcompactResult = await snapcompact.compact(preparation, {
7922
+ convertToLlm,
7923
+ model: this.model,
7924
+ });
7886
7925
  summary = snapcompactResult.summary;
7887
7926
  shortSummary = snapcompactResult.shortSummary;
7888
7927
  firstKeptEntryId = snapcompactResult.firstKeptEntryId;
@@ -7906,7 +7945,7 @@ export class AgentSession {
7906
7945
  compactResult = await compact(
7907
7946
  this.#obfuscatePreparationForProvider(preparation),
7908
7947
  candidate,
7909
- apiKey,
7948
+ this.#modelRegistry.resolver(candidate, this.sessionId),
7910
7949
  undefined,
7911
7950
  autoCompactionSignal,
7912
7951
  {
@@ -8293,11 +8332,32 @@ export class AgentSession {
8293
8332
  if (isContextOverflow(message, contextWindow)) return false;
8294
8333
 
8295
8334
  if (this.#isClassifierRefusal(message)) return true;
8335
+ if (this.#isStaleOpenAIResponsesReplayError(message)) return true;
8296
8336
 
8297
8337
  const err = message.errorMessage;
8298
8338
  return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
8299
8339
  }
8300
8340
 
8341
+ #isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean {
8342
+ const currentApi = this.model?.api;
8343
+ if (
8344
+ message.api !== "openai-responses" &&
8345
+ message.api !== "openai-codex-responses" &&
8346
+ currentApi !== "openai-responses" &&
8347
+ currentApi !== "openai-codex-responses"
8348
+ ) {
8349
+ return false;
8350
+ }
8351
+
8352
+ const errorMessage = message.errorMessage;
8353
+ if (!errorMessage) return false;
8354
+
8355
+ return (
8356
+ /\bItem with id ['"][^'"]+['"] not found\.?/i.test(errorMessage) ||
8357
+ (/previous[ _]?response/i.test(errorMessage) && /not[ _]?found|invalid|expired|stale/i.test(errorMessage))
8358
+ );
8359
+ }
8360
+
8301
8361
  #isClassifierRefusal(message: AssistantMessage): boolean {
8302
8362
  if (message.stopReason !== "error") return false;
8303
8363
  const stopType = message.stopDetails?.type;
@@ -8631,15 +8691,22 @@ export class AgentSession {
8631
8691
  }
8632
8692
 
8633
8693
  const errorMessage = message.errorMessage || "Unknown error";
8694
+ const staleOpenAIResponsesReplayError = this.#isStaleOpenAIResponsesReplayError(message);
8634
8695
  const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
8635
- let delayMs = calculateRetryBackoffDelayMs(retrySettings.baseDelayMs, this.#retryAttempt);
8696
+ let delayMs = staleOpenAIResponsesReplayError
8697
+ ? 0
8698
+ : calculateRetryBackoffDelayMs(retrySettings.baseDelayMs, this.#retryAttempt);
8636
8699
  let switchedCredential = false;
8637
8700
  let switchedModel = false;
8638
8701
  // Set when a usage-limit error pinned the wait to credential
8639
8702
  // availability — suppresses the generic retry-after bump below.
8640
8703
  let usageLimitWaitMs: number | undefined;
8641
8704
 
8642
- if (this.model && isUsageLimitError(errorMessage)) {
8705
+ if (staleOpenAIResponsesReplayError) {
8706
+ this.#resetCurrentResponsesProviderSession("stale replay error");
8707
+ }
8708
+
8709
+ if (this.model && !staleOpenAIResponsesReplayError && isUsageLimitError(errorMessage)) {
8643
8710
  const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
8644
8711
  const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
8645
8712
  this.model.provider,
@@ -8676,7 +8743,7 @@ export class AgentSession {
8676
8743
  }
8677
8744
 
8678
8745
  const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
8679
- if (!switchedCredential && currentSelector) {
8746
+ if (!staleOpenAIResponsesReplayError && !switchedCredential && currentSelector) {
8680
8747
  if (retrySettings.modelFallback) {
8681
8748
  if (!classifierRefusal) {
8682
8749
  this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
@@ -9813,7 +9880,7 @@ export class AgentSession {
9813
9880
  const branchSummarySettings = this.settings.getGroup("branchSummary");
9814
9881
  const result = await generateBranchSummary(entriesToSummarize, {
9815
9882
  model,
9816
- apiKey,
9883
+ apiKey: this.#modelRegistry.resolver(model, this.sessionId),
9817
9884
  signal: this.#branchSummaryAbortController.signal,
9818
9885
  customInstructions: this.#obfuscateTextForProvider(options.customInstructions),
9819
9886
  reserveTokens: branchSummarySettings.reserveTokens,
@@ -12,6 +12,7 @@ export type {
12
12
  AuthStorageOptions,
13
13
  CredentialOrigin,
14
14
  CredentialOriginKind,
15
+ OAuthAccountIdentity,
15
16
  OAuthCredential,
16
17
  SerializedAuthStorage,
17
18
  SnapshotResponse,
@@ -4,6 +4,7 @@
4
4
  import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
5
5
  import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
6
6
  import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai";
7
+ import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
7
8
  import {
8
9
  type BashExecutionMessage,
9
10
  type BranchSummaryMessage,
@@ -47,6 +48,12 @@ function stripTypeBoxFields(obj: unknown): unknown {
47
48
  return obj;
48
49
  }
49
50
 
51
+ /** Resolve tool parameters to a plain JSON Schema object for dump output. */
52
+ function toolParametersToJsonSchema(parameters: unknown): unknown {
53
+ if (isZodSchema(parameters)) return zodToWireSchema(parameters);
54
+ return stripTypeBoxFields(parameters);
55
+ }
56
+
50
57
  /** Serialize an object as XML parameter elements, one per key. */
51
58
  function formatArgsAsXml(args: Record<string, unknown>, indent = "\t"): string {
52
59
  const parts: string[] = [];
@@ -89,7 +96,7 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st
89
96
  for (const tool of tools) {
90
97
  lines.push(`<tool name="${tool.name}">`);
91
98
  lines.push(tool.description);
92
- const parametersClean = stripTypeBoxFields(tool.parameters);
99
+ const parametersClean = toolParametersToJsonSchema(tool.parameters);
93
100
  lines.push(`\nParameters:\n${formatArgsAsXml(parametersClean as Record<string, unknown>)}`);
94
101
  lines.push("<" + "/tool>\n");
95
102
  }
@@ -27,7 +27,7 @@ import {
27
27
  Snowflake,
28
28
  toError,
29
29
  } from "@oh-my-pi/pi-utils";
30
- import { getPreservedSnapcompactArchive, snapcompactImages } from "@oh-my-pi/snapcompact";
30
+ import * as snapcompact from "@oh-my-pi/snapcompact";
31
31
  import { ArtifactManager } from "./artifacts";
32
32
  import {
33
33
  type BlobPutOptions,
@@ -712,7 +712,7 @@ export function buildSessionContext(
712
712
  // the component can report them.
713
713
  for (const entry of path) {
714
714
  if (entry.type === "compaction") {
715
- const snapcompactArchive = getPreservedSnapcompactArchive(entry.preserveData);
715
+ const snapcompactArchive = snapcompact.getPreservedArchive(entry.preserveData);
716
716
  messages.push(
717
717
  createCompactionSummaryMessage(
718
718
  entry.summary,
@@ -720,7 +720,7 @@ export function buildSessionContext(
720
720
  entry.timestamp,
721
721
  entry.shortSummary,
722
722
  undefined,
723
- snapcompactArchive ? snapcompactImages(snapcompactArchive) : undefined,
723
+ snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined,
724
724
  ),
725
725
  );
726
726
  } else {
@@ -744,7 +744,7 @@ export function buildSessionContext(
744
744
 
745
745
  // Emit summary first; re-attach any archived snapcompact frames so the
746
746
  // model can keep reading the archived history after every context rebuild.
747
- const snapcompactArchive = getPreservedSnapcompactArchive(compaction.preserveData);
747
+ const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData);
748
748
  messages.push(
749
749
  createCompactionSummaryMessage(
750
750
  compaction.summary,
@@ -752,7 +752,7 @@ export function buildSessionContext(
752
752
  compaction.timestamp,
753
753
  compaction.shortSummary,
754
754
  providerPayload,
755
- snapcompactArchive ? snapcompactImages(snapcompactArchive) : undefined,
755
+ snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined,
756
756
  ),
757
757
  );
758
758
 
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Snapcompact inline imaging: per-request transform that swaps the system
3
+ * prompt and/or large historical tool results for dense PNG frames on
4
+ * vision-capable models.
5
+ *
6
+ * Runs inside the agent loop's `transformProviderContext` hook — after the
7
+ * persisted history is converted to the outgoing `Context`, before the
8
+ * provider stream call. It only ever builds NEW message objects/arrays; the
9
+ * input context shares `content` array references with the persisted
10
+ * `SessionMessageEntry` messages, so mutation would leak rendered images
11
+ * into session.jsonl.
12
+ */
13
+ import type { Context, ImageContent, Model, TextContent, ToolResultMessage, UserMessage } from "@oh-my-pi/pi-ai";
14
+ import { countTokens } from "@oh-my-pi/pi-natives";
15
+ import * as snapcompact from "@oh-my-pi/snapcompact";
16
+ import systemFramesNote from "../prompts/system/snapcompact-system-frames-note.md" with { type: "text" };
17
+ import systemStub from "../prompts/system/snapcompact-system-stub.md" with { type: "text" };
18
+ import toolResultNote from "../prompts/system/snapcompact-toolresult-note.md" with { type: "text" };
19
+
20
+ export interface SnapcompactInlineOptions {
21
+ renderSystemPrompt: boolean;
22
+ renderToolResults: boolean;
23
+ }
24
+
25
+ /**
26
+ * Image-count budget per provider. Snapcompact frames are 1568px (<2000px) so
27
+ * dimension/size limits never bind; only COUNT does. Strictest mainstream is
28
+ * Groq (~5), so unknown providers get the safe floor.
29
+ */
30
+ const INLINE_IMAGE_BUDGET_BY_PROVIDER: Record<string, number> = {
31
+ anthropic: 90,
32
+ "amazon-bedrock": 90,
33
+ openai: 200,
34
+ google: 200,
35
+ "google-vertex": 200,
36
+ "google-gemini-cli": 200,
37
+ };
38
+ const DEFAULT_INLINE_IMAGE_BUDGET = 5;
39
+ const MAX_SYSTEM_PROMPT_FRAMES = 6;
40
+ /** Tool results under this many tokens are never rasterized — the swap can't
41
+ * save enough to justify trading crisp text for an image. */
42
+ const MIN_TOOL_RESULT_TOKENS = 3000;
43
+ /** Render only if imageTokens <= textTokens * SAVINGS_MARGIN. */
44
+ const SAVINGS_MARGIN = 0.9;
45
+
46
+ /** Count image blocks already present across all message contents. */
47
+ function countContextImages(context: Context): number {
48
+ let count = 0;
49
+ for (const message of context.messages) {
50
+ const content = message.content;
51
+ if (typeof content === "string") continue;
52
+ for (const block of content) {
53
+ if (block.type === "image") count++;
54
+ }
55
+ }
56
+ return count;
57
+ }
58
+
59
+ function isTextContent(block: TextContent | ImageContent): block is TextContent {
60
+ return block.type === "text";
61
+ }
62
+
63
+ /** Image tokens must undercut text tokens by the margin to be worth rendering. */
64
+ function passesSavingsGate(frames: number, shape: snapcompact.Shape, textTokens: number): boolean {
65
+ return frames * shape.frameTokenEstimate <= textTokens * SAVINGS_MARGIN;
66
+ }
67
+
68
+ interface FrameCacheEntry {
69
+ hash: number | bigint;
70
+ frames: ImageContent[];
71
+ }
72
+
73
+ /**
74
+ * Stateless with respect to the model (passed per call, so mid-session model
75
+ * switches re-resolve shape and budget); stateful only for the render caches,
76
+ * which live as long as the session's Agent.
77
+ */
78
+ export class SnapcompactInlineTransformer {
79
+ /** Rendered tool-result frames keyed by toolCallId. */
80
+ #toolCache = new Map<string, FrameCacheEntry>();
81
+ #systemCache?: FrameCacheEntry;
82
+
83
+ constructor(private readonly options: SnapcompactInlineOptions) {}
84
+
85
+ transform(context: Context, model: Model): Context {
86
+ // Vision gate: providers silently DROP images on text-only models —
87
+ // rendering would lose the content entirely.
88
+ if (!model.input.includes("image")) return context;
89
+
90
+ const shape = snapcompact.resolveShape(model.api);
91
+ let budget =
92
+ (INLINE_IMAGE_BUDGET_BY_PROVIDER[model.provider] ?? DEFAULT_INLINE_IMAGE_BUDGET) - countContextImages(context);
93
+ if (budget <= 0) return context;
94
+
95
+ const messages = [...context.messages];
96
+ let changed = false;
97
+
98
+ if (this.options.renderToolResults) {
99
+ const toolResultIndices: number[] = [];
100
+ const liveToolCallIds = new Set<string>();
101
+ for (let i = 0; i < messages.length; i++) {
102
+ const message = messages[i];
103
+ if (message.role !== "toolResult") continue;
104
+ toolResultIndices.push(i);
105
+ liveToolCallIds.add(message.toolCallId);
106
+ }
107
+ // Oldest-first for cache-stable bytes; skip the LAST tool result so
108
+ // the freshest output stays crisp text.
109
+ for (let k = 0; k < toolResultIndices.length - 1 && budget > 0; k++) {
110
+ const index = toolResultIndices[k];
111
+ const message = messages[index] as ToolResultMessage;
112
+ // Don't re-image results that already carry images (screenshots etc.).
113
+ if (message.content.some(block => block.type === "image")) continue;
114
+ const text = message.content
115
+ .filter(isTextContent)
116
+ .map(block => block.text)
117
+ .join("\n");
118
+ const textTokens = countTokens(text);
119
+ if (textTokens < MIN_TOOL_RESULT_TOKENS) continue;
120
+ const needed = snapcompact.frames(text, { shape });
121
+ if (needed === 0 || needed > budget) continue;
122
+ if (!passesSavingsGate(needed, shape, textTokens)) continue;
123
+ const frames = this.#framesFor(this.#toolCache, message.toolCallId, text, shape);
124
+ messages[index] = { ...message, content: [{ type: "text", text: toolResultNote }, ...frames] };
125
+ budget -= frames.length;
126
+ changed = true;
127
+ }
128
+ // Drop cache entries for tool calls no longer in the context
129
+ // (compacted away) so the cache stays bounded by live history.
130
+ for (const key of this.#toolCache.keys()) {
131
+ if (!liveToolCallIds.has(key)) this.#toolCache.delete(key);
132
+ }
133
+ }
134
+
135
+ let systemPrompt = context.systemPrompt;
136
+ if (this.options.renderSystemPrompt && context.systemPrompt?.length && budget > 0) {
137
+ const joined = context.systemPrompt.join("\n\n");
138
+ const needed = snapcompact.frames(joined, { shape });
139
+ const userIndex = messages.findIndex(message => message.role === "user");
140
+ if (
141
+ needed > 0 &&
142
+ needed <= Math.min(budget, MAX_SYSTEM_PROMPT_FRAMES) &&
143
+ passesSavingsGate(needed, shape, countTokens(joined)) &&
144
+ // No user message to carry the frames → leave the prompt as text.
145
+ userIndex >= 0
146
+ ) {
147
+ const hash = Bun.hash(joined);
148
+ let cached = this.#systemCache;
149
+ if (!cached || cached.hash !== hash) {
150
+ cached = {
151
+ hash,
152
+ frames: snapcompact.renderMany(joined, { shape, maxFrames: MAX_SYSTEM_PROMPT_FRAMES }),
153
+ };
154
+ this.#systemCache = cached;
155
+ }
156
+ const frames = cached.frames;
157
+ const original = messages[userIndex] as UserMessage;
158
+ const originalContent: (TextContent | ImageContent)[] =
159
+ typeof original.content === "string" ? [{ type: "text", text: original.content }] : original.content;
160
+ messages[userIndex] = {
161
+ ...original,
162
+ content: [{ type: "text", text: systemFramesNote }, ...frames, ...originalContent],
163
+ };
164
+ systemPrompt = [systemStub];
165
+ budget -= frames.length;
166
+ changed = true;
167
+ }
168
+ }
169
+
170
+ if (!changed) return context;
171
+ return { ...context, systemPrompt, messages };
172
+ }
173
+
174
+ #framesFor(
175
+ cache: Map<string, FrameCacheEntry>,
176
+ key: string,
177
+ text: string,
178
+ shape: snapcompact.Shape,
179
+ ): ImageContent[] {
180
+ const hash = Bun.hash(text);
181
+ const cached = cache.get(key);
182
+ if (cached && cached.hash === hash) return cached.frames;
183
+ const frames = snapcompact.renderMany(text, { shape });
184
+ cache.set(key, { hash, frames });
185
+ return frames;
186
+ }
187
+ }
@@ -0,0 +1,44 @@
1
+ import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
2
+ import type { OAuthAccountIdentity } from "../../session/auth-storage";
3
+
4
+ function normalizeIdentityValue(value: unknown): string | undefined {
5
+ return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
6
+ }
7
+
8
+ /**
9
+ * True when a single usage-limit column belongs to the given OAuth identity.
10
+ *
11
+ * Single definition of the matching rules for both `/usage` renderers:
12
+ * - `accountId` ↔ report metadata `accountId`/`account_id` or `limit.scope.accountId`
13
+ * - `email` ↔ report metadata `email`
14
+ * - `projectId` ↔ report metadata `projectId` or `limit.scope.projectId`
15
+ * (Google-style providers key usage on the GCP project, not an account id)
16
+ */
17
+ export function limitMatchesActiveAccount(
18
+ report: UsageReport,
19
+ limit: UsageLimit,
20
+ identity: OAuthAccountIdentity | undefined,
21
+ ): boolean {
22
+ if (!identity) return false;
23
+ const metadata = report.metadata ?? {};
24
+ const activeAccountId = normalizeIdentityValue(identity.accountId);
25
+ if (activeAccountId) {
26
+ const reportAccountId = normalizeIdentityValue(metadata.accountId) ?? normalizeIdentityValue(metadata.account_id);
27
+ if (reportAccountId === activeAccountId) return true;
28
+ if (normalizeIdentityValue(limit.scope.accountId) === activeAccountId) return true;
29
+ }
30
+ const activeEmail = normalizeIdentityValue(identity.email);
31
+ if (activeEmail && normalizeIdentityValue(metadata.email) === activeEmail) return true;
32
+ const activeProjectId = normalizeIdentityValue(identity.projectId);
33
+ if (activeProjectId) {
34
+ if (normalizeIdentityValue(metadata.projectId) === activeProjectId) return true;
35
+ if (normalizeIdentityValue(limit.scope.projectId) === activeProjectId) return true;
36
+ }
37
+ return false;
38
+ }
39
+
40
+ /** True when any limit column in `report` belongs to the given OAuth identity. */
41
+ export function reportMatchesActiveAccount(report: UsageReport, identity: OAuthAccountIdentity | undefined): boolean {
42
+ if (!identity) return false;
43
+ return report.limits.some(limit => limitMatchesActiveAccount(report, limit, identity));
44
+ }
@@ -1,5 +1,7 @@
1
1
  import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
2
+ import type { OAuthAccountIdentity } from "../../session/auth-storage";
2
3
  import type { SlashCommandRuntime } from "../types";
4
+ import { reportMatchesActiveAccount } from "./active-oauth-account";
3
5
  import { formatDuration, renderAsciiBar } from "./format";
4
6
 
5
7
  function formatProviderName(provider: string): string {
@@ -31,7 +33,11 @@ function formatUsageReportAccount(report: UsageReport, limit: UsageLimit, index:
31
33
  return `account ${index + 1}`;
32
34
  }
33
35
 
34
- function renderUsageReports(reports: UsageReport[], nowMs: number): string {
36
+ function renderUsageReports(
37
+ reports: UsageReport[],
38
+ nowMs: number,
39
+ resolveActiveAccount?: (provider: string) => OAuthAccountIdentity | undefined,
40
+ ): string {
35
41
  const latestFetchedAt = Math.max(...reports.map(report => report.fetchedAt ?? 0));
36
42
  const lines = [`Usage${latestFetchedAt ? ` (${formatDuration(nowMs - latestFetchedAt)} ago)` : ""}`];
37
43
  const grouped = new Map<string, UsageReport[]>();
@@ -45,7 +51,9 @@ function renderUsageReports(reports: UsageReport[], nowMs: number): string {
45
51
  left.localeCompare(right),
46
52
  )) {
47
53
  lines.push("", formatProviderName(provider));
54
+ const activeAccount = resolveActiveAccount?.(provider);
48
55
  for (const report of providerReports) {
56
+ const inUse = reportMatchesActiveAccount(report, activeAccount);
49
57
  if (report.limits.length === 0) {
50
58
  const email = typeof report.metadata?.email === "string" ? report.metadata.email : "account";
51
59
  lines.push(`- ${email}: no limits reported`);
@@ -56,7 +64,9 @@ function renderUsageReports(reports: UsageReport[], nowMs: number): string {
56
64
  const window = limit.window?.label ?? limit.scope.windowId;
57
65
  const tier = limit.scope.tier ? ` (${limit.scope.tier})` : "";
58
66
  lines.push(`- ${limit.label}${tier}${window ? ` — ${window}` : ""}`);
59
- lines.push(` ${formatUsageReportAccount(report, limit, index)}: ${formatUsageAmount(limit)}`);
67
+ lines.push(
68
+ ` ${formatUsageReportAccount(report, limit, index)}: ${formatUsageAmount(limit)}${inUse ? " ← in use by this session" : ""}`,
69
+ );
60
70
  lines.push(` ${renderAsciiBar(limit.amount.usedFraction)}`);
61
71
  if (limit.window?.resetsAt && limit.window.resetsAt > nowMs) {
62
72
  lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`);
@@ -79,7 +89,18 @@ export async function buildUsageReportText(runtime: SlashCommandRuntime): Promis
79
89
  };
80
90
  if (provider.fetchUsageReports) {
81
91
  const reports = await provider.fetchUsageReports();
82
- if (reports && reports.length > 0) return renderUsageReports(reports, Date.now());
92
+ if (reports && reports.length > 0) {
93
+ const currentProvider = runtime.session.model?.provider;
94
+ const activeAccount = currentProvider
95
+ ? runtime.session.modelRegistry.authStorage.getOAuthAccountIdentity(
96
+ currentProvider,
97
+ runtime.session.sessionId,
98
+ )
99
+ : undefined;
100
+ return renderUsageReports(reports, Date.now(), providerId =>
101
+ providerId === currentProvider ? activeAccount : undefined,
102
+ );
103
+ }
83
104
  }
84
105
 
85
106
  const stats = runtime.session.sessionManager.getUsageStatistics();