@oh-my-pi/pi-coding-agent 11.2.3 → 11.4.0

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 (102) hide show
  1. package/CHANGELOG.md +119 -4
  2. package/examples/extensions/plan-mode.ts +1 -1
  3. package/examples/hooks/qna.ts +1 -1
  4. package/examples/hooks/status-line.ts +1 -1
  5. package/examples/sdk/11-sessions.ts +1 -1
  6. package/package.json +8 -8
  7. package/src/cli/args.ts +9 -6
  8. package/src/cli/update-cli.ts +2 -2
  9. package/src/commands/index/index.ts +2 -5
  10. package/src/commit/agentic/agent.ts +1 -1
  11. package/src/commit/changelog/index.ts +2 -2
  12. package/src/config/keybindings.ts +16 -1
  13. package/src/config/model-registry.ts +25 -20
  14. package/src/config/model-resolver.ts +8 -8
  15. package/src/config/resolve-config-value.ts +92 -0
  16. package/src/config/settings-schema.ts +9 -0
  17. package/src/config.ts +14 -1
  18. package/src/export/html/template.css +7 -0
  19. package/src/export/html/template.generated.ts +1 -1
  20. package/src/export/html/template.js +33 -16
  21. package/src/extensibility/custom-commands/bundled/review/index.ts +1 -1
  22. package/src/extensibility/extensions/index.ts +18 -0
  23. package/src/extensibility/extensions/loader.ts +15 -0
  24. package/src/extensibility/extensions/runner.ts +78 -1
  25. package/src/extensibility/extensions/types.ts +131 -5
  26. package/src/extensibility/extensions/wrapper.ts +1 -1
  27. package/src/extensibility/plugins/git-url.ts +270 -0
  28. package/src/extensibility/plugins/index.ts +2 -0
  29. package/src/extensibility/slash-commands.ts +45 -0
  30. package/src/index.ts +7 -0
  31. package/src/lsp/render.ts +50 -43
  32. package/src/lsp/utils.ts +2 -2
  33. package/src/main.ts +11 -10
  34. package/src/mcp/transports/stdio.ts +3 -5
  35. package/src/modes/components/custom-message.ts +0 -8
  36. package/src/modes/components/diff.ts +41 -13
  37. package/src/modes/components/footer.ts +4 -4
  38. package/src/modes/components/model-selector.ts +4 -0
  39. package/src/modes/components/todo-display.ts +13 -3
  40. package/src/modes/components/tool-execution.ts +30 -16
  41. package/src/modes/components/tree-selector.ts +50 -19
  42. package/src/modes/controllers/event-controller.ts +1 -0
  43. package/src/modes/controllers/extension-ui-controller.ts +34 -2
  44. package/src/modes/controllers/input-controller.ts +47 -33
  45. package/src/modes/controllers/selector-controller.ts +10 -15
  46. package/src/modes/interactive-mode.ts +50 -38
  47. package/src/modes/print-mode.ts +6 -0
  48. package/src/modes/rpc/rpc-client.ts +4 -4
  49. package/src/modes/rpc/rpc-mode.ts +17 -2
  50. package/src/modes/rpc/rpc-types.ts +2 -2
  51. package/src/modes/types.ts +1 -0
  52. package/src/modes/utils/ui-helpers.ts +3 -1
  53. package/src/patch/applicator.ts +106 -4
  54. package/src/patch/fuzzy.ts +1 -1
  55. package/src/patch/shared.ts +77 -63
  56. package/src/prompts/system/plan-mode-active.md +6 -6
  57. package/src/prompts/system/system-prompt.md +2 -1
  58. package/src/prompts/tools/ask.md +2 -2
  59. package/src/prompts/tools/gemini-image.md +2 -2
  60. package/src/prompts/tools/lsp.md +2 -2
  61. package/src/prompts/tools/patch.md +1 -1
  62. package/src/prompts/tools/python.md +3 -3
  63. package/src/prompts/tools/task.md +7 -1
  64. package/src/prompts/tools/todo-write.md +2 -2
  65. package/src/prompts/tools/web-search.md +2 -2
  66. package/src/prompts/tools/write.md +2 -5
  67. package/src/sdk.ts +15 -11
  68. package/src/session/agent-session.ts +92 -34
  69. package/src/session/auth-storage.ts +2 -1
  70. package/src/session/blob-store.ts +105 -0
  71. package/src/session/session-manager.ts +107 -44
  72. package/src/task/executor.ts +19 -9
  73. package/src/task/render.ts +80 -58
  74. package/src/tools/ask.ts +28 -5
  75. package/src/tools/bash.ts +47 -39
  76. package/src/tools/browser.ts +248 -26
  77. package/src/tools/calculator.ts +42 -23
  78. package/src/tools/fetch.ts +33 -16
  79. package/src/tools/find.ts +57 -22
  80. package/src/tools/grep.ts +54 -25
  81. package/src/tools/index.ts +5 -5
  82. package/src/tools/notebook.ts +19 -6
  83. package/src/tools/path-utils.ts +26 -1
  84. package/src/tools/python.ts +20 -14
  85. package/src/tools/read.ts +21 -8
  86. package/src/tools/render-utils.ts +5 -45
  87. package/src/tools/ssh.ts +59 -53
  88. package/src/tools/submit-result.ts +2 -2
  89. package/src/tools/todo-write.ts +32 -14
  90. package/src/tools/truncate.ts +1 -1
  91. package/src/tools/write.ts +42 -26
  92. package/src/tui/code-cell.ts +1 -1
  93. package/src/tui/output-block.ts +61 -3
  94. package/src/tui/tree-list.ts +4 -4
  95. package/src/tui/utils.ts +71 -1
  96. package/src/utils/frontmatter.ts +1 -1
  97. package/src/utils/title-generator.ts +1 -1
  98. package/src/utils/tools-manager.ts +18 -2
  99. package/src/web/scrapers/osv.ts +4 -1
  100. package/src/web/scrapers/youtube.ts +1 -1
  101. package/src/web/search/index.ts +1 -1
  102. package/src/web/search/render.ts +96 -90
package/src/sdk.ts CHANGED
@@ -387,7 +387,7 @@ function customToolToDefinition(tool: CustomTool): ToolDefinition {
387
387
  label: tool.label,
388
388
  description: tool.description,
389
389
  parameters: tool.parameters,
390
- execute: (toolCallId, params, onUpdate, ctx, signal) =>
390
+ execute: (toolCallId, params, signal, onUpdate, ctx) =>
391
391
  tool.execute(toolCallId, params, onUpdate, createCustomToolContext(ctx), signal),
392
392
  onSession: tool.onSession ? (event, ctx) => tool.onSession?.(event, createCustomToolContext(ctx)) : undefined,
393
393
  renderCall: tool.renderCall,
@@ -506,6 +506,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
506
506
  const existingSession = sessionManager.buildSessionContext();
507
507
  time("loadSession");
508
508
  const hasExistingSession = existingSession.messages.length > 0;
509
+ const hasThinkingEntry = sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
509
510
 
510
511
  const hasExplicitModel = options.model !== undefined;
511
512
  let model = options.model;
@@ -563,7 +564,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
563
564
 
564
565
  // If session has data, restore thinking level from it
565
566
  if (thinkingLevel === undefined && hasExistingSession) {
566
- thinkingLevel = existingSession.thinkingLevel as ThinkingLevel;
567
+ thinkingLevel = hasThinkingEntry
568
+ ? (existingSession.thinkingLevel as ThinkingLevel)
569
+ : ((settingsInstance.get("defaultThinkingLevel") ?? "off") as ThinkingLevel);
567
570
  }
568
571
 
569
572
  // Fall back to settings default
@@ -698,7 +701,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
698
701
  onConnecting: serverNames => {
699
702
  if (options.hasUI && serverNames.length > 0) {
700
703
  process.stderr.write(
701
- chalk.gray(`Connecting to MCP servers: ${serverNames.join(", ")}...
704
+ chalk.gray(`Connecting to MCP servers: ${serverNames.join(", ")}
702
705
  `),
703
706
  );
704
707
  }
@@ -1016,14 +1019,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1016
1019
  thinkingBudgets: settingsInstance.getGroup("thinkingBudgets"),
1017
1020
  kimiApiFormat: settingsInstance.get("providers.kimiApiFormat") ?? "anthropic",
1018
1021
  getToolContext: tc => toolContextStore.getContext(tc),
1019
- getApiKey: async () => {
1020
- const currentModel = agent.state.model;
1021
- if (!currentModel) {
1022
- throw new Error("No model selected");
1023
- }
1024
- const key = await modelRegistry.getApiKey(currentModel, sessionId);
1022
+ getApiKey: async provider => {
1023
+ // Use the provider argument from the in-flight request;
1024
+ // agent.state.model may already be switched mid-turn.
1025
+ const key = await modelRegistry.getApiKeyForProvider(provider, sessionId);
1025
1026
  if (!key) {
1026
- throw new Error(`No API key found for provider "${currentModel.provider}"`);
1027
+ throw new Error(`No API key found for provider "${provider}"`);
1027
1028
  }
1028
1029
  return key;
1029
1030
  },
@@ -1036,6 +1037,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1036
1037
  // Restore messages if session has existing data
1037
1038
  if (hasExistingSession) {
1038
1039
  agent.replaceMessages(existingSession.messages);
1040
+ if (!hasThinkingEntry) {
1041
+ sessionManager.appendThinkingLevelChange(thinkingLevel);
1042
+ }
1039
1043
  } else {
1040
1044
  // Save initial model and thinking level for new sessions so they can be restored on resume
1041
1045
  if (model) {
@@ -1072,7 +1076,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1072
1076
  const result = await warmupLspServers(cwd, {
1073
1077
  onConnecting: serverNames => {
1074
1078
  if (options.hasUI && serverNames.length > 0) {
1075
- process.stderr.write(chalk.gray(`Starting LSP servers: ${serverNames.join(", ")}...\n`));
1079
+ process.stderr.write(chalk.gray(`Starting LSP servers: ${serverNames.join(", ")}…\n`));
1076
1080
  }
1077
1081
  },
1078
1082
  });
@@ -29,7 +29,6 @@ import type {
29
29
  } from "@oh-my-pi/pi-ai";
30
30
  import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
31
31
  import { abortableSleep, isEnoent, logger } from "@oh-my-pi/pi-utils";
32
- import { YAML } from "bun";
33
32
  import type { Rule } from "../capability/rule";
34
33
  import { getAgentDbPath } from "../config";
35
34
  import { MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "../config/model-registry";
@@ -237,6 +236,8 @@ const noOpUIContext: ExtensionUIContext = {
237
236
  setFooter: () => {},
238
237
  setHeader: () => {},
239
238
  setEditorComponent: () => {},
239
+ getToolsExpanded: () => false,
240
+ setToolsExpanded: () => {},
240
241
  };
241
242
 
242
243
  async function cleanupSshResources(): Promise<void> {
@@ -906,6 +907,11 @@ export class AgentSession {
906
907
  return this.agent.state.isStreaming || this._promptInFlight;
907
908
  }
908
909
 
910
+ /** Current effective system prompt (includes any per-turn extension modifications) */
911
+ get systemPrompt(): string {
912
+ return this.agent.state.systemPrompt;
913
+ }
914
+
909
915
  /** Current retry attempt (0 if not retrying) */
910
916
  get retryAttempt(): number {
911
917
  return this._retryAttempt;
@@ -1426,6 +1432,11 @@ export class AgentSession {
1426
1432
  instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
1427
1433
  await this.compact(instructions, options);
1428
1434
  },
1435
+ switchSession: async sessionPath => {
1436
+ const success = await this.switchSession(sessionPath);
1437
+ return { cancelled: !success };
1438
+ },
1439
+ getSystemPrompt: () => this.systemPrompt,
1429
1440
  };
1430
1441
  }
1431
1442
 
@@ -1477,25 +1488,25 @@ export class AgentSession {
1477
1488
  /**
1478
1489
  * Queue a steering message to interrupt the agent mid-run.
1479
1490
  */
1480
- async steer(text: string): Promise<void> {
1491
+ async steer(text: string, images?: ImageContent[]): Promise<void> {
1481
1492
  if (text.startsWith("/")) {
1482
1493
  this._throwIfExtensionCommand(text);
1483
1494
  }
1484
1495
 
1485
1496
  const expandedText = expandPromptTemplate(text, [...this._promptTemplates]);
1486
- await this._queueSteer(expandedText);
1497
+ await this._queueSteer(expandedText, images);
1487
1498
  }
1488
1499
 
1489
1500
  /**
1490
1501
  * Queue a follow-up message to process after the agent would otherwise stop.
1491
1502
  */
1492
- async followUp(text: string): Promise<void> {
1503
+ async followUp(text: string, images?: ImageContent[]): Promise<void> {
1493
1504
  if (text.startsWith("/")) {
1494
1505
  this._throwIfExtensionCommand(text);
1495
1506
  }
1496
1507
 
1497
1508
  const expandedText = expandPromptTemplate(text, [...this._promptTemplates]);
1498
- await this._queueFollowUp(expandedText);
1509
+ await this._queueFollowUp(expandedText, images);
1499
1510
  }
1500
1511
 
1501
1512
  /**
@@ -1728,11 +1739,14 @@ export class AgentSession {
1728
1739
  await this.abort();
1729
1740
  this.agent.reset();
1730
1741
  await this.sessionManager.flush();
1731
- this.sessionManager.newSession(options);
1742
+ await this.sessionManager.newSession(options);
1732
1743
  this.agent.sessionId = this.sessionManager.getSessionId();
1733
1744
  this._steeringMessages = [];
1734
1745
  this._followUpMessages = [];
1735
1746
  this._pendingNextTurnMessages = [];
1747
+
1748
+ this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
1749
+
1736
1750
  this._todoReminderCount = 0;
1737
1751
  this._planReferenceSent = false;
1738
1752
  this._reconnectToAgent();
@@ -1934,22 +1948,39 @@ export class AgentSession {
1934
1948
  return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
1935
1949
  }
1936
1950
 
1951
+ private async _getScopedModelsWithApiKey(): Promise<Array<{ model: Model; thinkingLevel: ThinkingLevel }>> {
1952
+ const apiKeysByProvider = new Map<string, string | undefined>();
1953
+ const result: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];
1954
+
1955
+ for (const scoped of this._scopedModels) {
1956
+ const provider = scoped.model.provider;
1957
+ let apiKey: string | undefined;
1958
+ if (apiKeysByProvider.has(provider)) {
1959
+ apiKey = apiKeysByProvider.get(provider);
1960
+ } else {
1961
+ apiKey = await this._modelRegistry.getApiKeyForProvider(provider, this.sessionId);
1962
+ apiKeysByProvider.set(provider, apiKey);
1963
+ }
1964
+
1965
+ if (apiKey) {
1966
+ result.push(scoped);
1967
+ }
1968
+ }
1969
+
1970
+ return result;
1971
+ }
1972
+
1937
1973
  private async _cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
1938
- if (this._scopedModels.length <= 1) return undefined;
1974
+ const scopedModels = await this._getScopedModelsWithApiKey();
1975
+ if (scopedModels.length <= 1) return undefined;
1939
1976
 
1940
1977
  const currentModel = this.model;
1941
- let currentIndex = this._scopedModels.findIndex(sm => modelsAreEqual(sm.model, currentModel));
1978
+ let currentIndex = scopedModels.findIndex(sm => modelsAreEqual(sm.model, currentModel));
1942
1979
 
1943
1980
  if (currentIndex === -1) currentIndex = 0;
1944
- const len = this._scopedModels.length;
1981
+ const len = scopedModels.length;
1945
1982
  const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
1946
- const next = this._scopedModels[nextIndex];
1947
-
1948
- // Validate API key
1949
- const apiKey = await this._modelRegistry.getApiKey(next.model, this.sessionId);
1950
- if (!apiKey) {
1951
- throw new Error(`No API key for ${next.model.provider}/${next.model.id}`);
1952
- }
1983
+ const next = scopedModels[nextIndex];
1953
1984
 
1954
1985
  // Apply model
1955
1986
  this.agent.setModel(next.model);
@@ -2005,15 +2036,22 @@ export class AgentSession {
2005
2036
  /**
2006
2037
  * Set thinking level.
2007
2038
  * Clamps to model capabilities based on available thinking levels.
2008
- * Saves to session, with optional persistence to settings.
2039
+ * Saves to session and settings only if the level actually changes.
2009
2040
  */
2010
2041
  setThinkingLevel(level: ThinkingLevel, persist: boolean = false): void {
2011
2042
  const availableLevels = this.getAvailableThinkingLevels();
2012
2043
  const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels);
2044
+
2045
+ // Only persist if actually changing
2046
+ const isChanging = effectiveLevel !== this.agent.state.thinkingLevel;
2047
+
2013
2048
  this.agent.setThinkingLevel(effectiveLevel);
2014
- this.sessionManager.appendThinkingLevelChange(effectiveLevel);
2015
- if (persist) {
2016
- this.settings.set("defaultThinkingLevel", effectiveLevel);
2049
+
2050
+ if (isChanging) {
2051
+ this.sessionManager.appendThinkingLevelChange(effectiveLevel);
2052
+ if (persist) {
2053
+ this.settings.set("defaultThinkingLevel", effectiveLevel);
2054
+ }
2017
2055
  }
2018
2056
  }
2019
2057
 
@@ -2404,7 +2442,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
2404
2442
 
2405
2443
  // Start a new session
2406
2444
  await this.sessionManager.flush();
2407
- this.sessionManager.newSession();
2445
+ await this.sessionManager.newSession();
2408
2446
  this.agent.reset();
2409
2447
  this.agent.sessionId = this.sessionManager.getSessionId();
2410
2448
  this._steeringMessages = [];
@@ -2903,8 +2941,8 @@ Be thorough - include exact file paths, function names, error messages, and tech
2903
2941
  }
2904
2942
 
2905
2943
  private _isRetryableErrorMessage(errorMessage: string): boolean {
2906
- // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed
2907
- return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|fetch failed/i.test(
2944
+ // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed, retry delay exceeded
2945
+ return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|fetch failed|retry delay/i.test(
2908
2946
  errorMessage,
2909
2947
  );
2910
2948
  }
@@ -3354,9 +3392,19 @@ Be thorough - include exact file paths, function names, error messages, and tech
3354
3392
  }
3355
3393
  }
3356
3394
 
3357
- // Restore thinking level if saved (setThinkingLevel clamps to model capabilities)
3358
- if (sessionContext.thinkingLevel) {
3395
+ const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
3396
+ const defaultThinkingLevel = (this.settings.get("defaultThinkingLevel") ?? "off") as ThinkingLevel;
3397
+
3398
+ if (hasThinkingEntry) {
3399
+ // Restore thinking level if saved (setThinkingLevel clamps to model capabilities)
3359
3400
  this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel);
3401
+ } else {
3402
+ const availableLevels = this.getAvailableThinkingLevels();
3403
+ const effectiveLevel = availableLevels.includes(defaultThinkingLevel)
3404
+ ? defaultThinkingLevel
3405
+ : this._clampThinkingLevel(defaultThinkingLevel, availableLevels);
3406
+ this.agent.setThinkingLevel(effectiveLevel);
3407
+ this.sessionManager.appendThinkingLevelChange(effectiveLevel);
3360
3408
  }
3361
3409
 
3362
3410
  this._reconnectToAgent();
@@ -3404,7 +3452,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3404
3452
  await this.sessionManager.flush();
3405
3453
 
3406
3454
  if (!selectedEntry.parentId) {
3407
- this.sessionManager.newSession();
3455
+ await this.sessionManager.newSession({ parentSession: previousSessionFile });
3408
3456
  } else {
3409
3457
  this.sessionManager.createBranchedSession(selectedEntry.parentId);
3410
3458
  }
@@ -3826,6 +3874,16 @@ Be thorough - include exact file paths, function names, error messages, and tech
3826
3874
  formatSessionAsText(): string {
3827
3875
  const lines: string[] = [];
3828
3876
 
3877
+ /** Serialize an object as XML parameter elements, one per key. */
3878
+ function formatArgsAsXml(args: Record<string, unknown>, indent = "\t"): string {
3879
+ const parts: string[] = [];
3880
+ for (const [key, value] of Object.entries(args)) {
3881
+ const text = typeof value === "string" ? value : JSON.stringify(value);
3882
+ parts.push(`${indent}<parameter name="${key}">${text}</parameter>`);
3883
+ }
3884
+ return parts.join("\n");
3885
+ }
3886
+
3829
3887
  // Include system prompt at the beginning
3830
3888
  const systemPrompt = this.agent.state.systemPrompt;
3831
3889
  if (systemPrompt) {
@@ -3865,12 +3923,11 @@ Be thorough - include exact file paths, function names, error messages, and tech
3865
3923
  if (tools.length > 0) {
3866
3924
  lines.push("## Available Tools\n");
3867
3925
  for (const tool of tools) {
3868
- lines.push(`### ${tool.name}\n`);
3926
+ lines.push(`<tool name="${tool.name}">`);
3869
3927
  lines.push(tool.description);
3870
- lines.push("\n```yaml");
3871
3928
  const parametersClean = stripTypeBoxFields(tool.parameters);
3872
- lines.push(YAML.stringify(parametersClean, null, 2));
3873
- lines.push("```\n");
3929
+ lines.push(`\nParameters:\n${formatArgsAsXml(parametersClean as Record<string, unknown>)}`);
3930
+ lines.push("<" + "/tool>\n");
3874
3931
  }
3875
3932
  lines.push("\n");
3876
3933
  }
@@ -3902,10 +3959,11 @@ Be thorough - include exact file paths, function names, error messages, and tech
3902
3959
  lines.push(c.thinking);
3903
3960
  lines.push("</thinking>\n");
3904
3961
  } else if (c.type === "toolCall") {
3905
- lines.push(`### Tool: ${c.name}`);
3906
- lines.push("```yaml");
3907
- lines.push(YAML.stringify(c.arguments, null, 2));
3908
- lines.push("```\n");
3962
+ lines.push(`<invoke name="${c.name}">`);
3963
+ if (c.arguments && typeof c.arguments === "object") {
3964
+ lines.push(formatArgsAsXml(c.arguments as Record<string, unknown>));
3965
+ }
3966
+ lines.push("<" + "/invoke>\n");
3909
3967
  }
3910
3968
  }
3911
3969
  lines.push("");
@@ -34,6 +34,7 @@ import {
34
34
  } from "@oh-my-pi/pi-ai";
35
35
  import { logger } from "@oh-my-pi/pi-utils";
36
36
  import { getAgentDbPath } from "../config";
37
+ import { resolveConfigValue } from "../config/resolve-config-value";
37
38
  import { AgentStorage } from "./agent-storage";
38
39
 
39
40
  export type ApiKeyCredential = {
@@ -1294,7 +1295,7 @@ export class AuthStorage {
1294
1295
  const apiKeySelection = this.selectCredentialByType(provider, "api_key", sessionId);
1295
1296
  if (apiKeySelection) {
1296
1297
  this.recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
1297
- return apiKeySelection.credential.key;
1298
+ return resolveConfigValue(apiKeySelection.credential.key);
1298
1299
  }
1299
1300
 
1300
1301
  const oauthKey = await this.resolveOAuthApiKey(provider, sessionId, options);
@@ -0,0 +1,105 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
4
+
5
+ const BLOB_PREFIX = "blob:sha256:";
6
+
7
+ /**
8
+ * Content-addressed blob store for externalizing large binary data (images) from session JSONL files.
9
+ *
10
+ * Files are stored at `<dir>/<sha256-hex>` with no extension. The SHA-256 hash is computed
11
+ * over the raw binary data (not base64). Content-addressing makes writes idempotent and
12
+ * provides automatic deduplication across sessions.
13
+ */
14
+ export class BlobStore {
15
+ constructor(readonly dir: string) {}
16
+
17
+ /**
18
+ * Write binary data to the blob store.
19
+ * @returns SHA-256 hex hash of the data
20
+ */
21
+ async put(data: Buffer): Promise<string> {
22
+ const hasher = new Bun.CryptoHasher("sha256");
23
+ hasher.update(data);
24
+ const hash = hasher.digest("hex");
25
+ const blobPath = path.join(this.dir, hash);
26
+
27
+ // Content-addressed: skip write if blob already exists
28
+ try {
29
+ await fs.access(blobPath);
30
+ return hash;
31
+ } catch {
32
+ // Does not exist, write it
33
+ }
34
+
35
+ await Bun.write(blobPath, data);
36
+ return hash;
37
+ }
38
+
39
+ /** Read blob by hash, returns Buffer or null if not found. */
40
+ async get(hash: string): Promise<Buffer | null> {
41
+ const blobPath = path.join(this.dir, hash);
42
+ try {
43
+ const file = Bun.file(blobPath);
44
+ const ab = await file.arrayBuffer();
45
+ return Buffer.from(ab);
46
+ } catch (err) {
47
+ if (isEnoent(err)) return null;
48
+ throw err;
49
+ }
50
+ }
51
+
52
+ /** Check if a blob exists. */
53
+ async has(hash: string): Promise<boolean> {
54
+ try {
55
+ await fs.access(path.join(this.dir, hash));
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+ }
62
+
63
+ /** Check if a data string is a blob reference. */
64
+ export function isBlobRef(data: string): boolean {
65
+ return data.startsWith(BLOB_PREFIX);
66
+ }
67
+
68
+ /** Extract the SHA-256 hash from a blob reference string. */
69
+ export function parseBlobRef(data: string): string | null {
70
+ if (!data.startsWith(BLOB_PREFIX)) return null;
71
+ return data.slice(BLOB_PREFIX.length);
72
+ }
73
+
74
+ /** Create a blob reference string from a SHA-256 hash. */
75
+ export function makeBlobRef(hash: string): string {
76
+ return `${BLOB_PREFIX}${hash}`;
77
+ }
78
+
79
+ /**
80
+ * Externalize an image's base64 data to the blob store, returning a blob reference.
81
+ * If the data is already a blob reference, returns it unchanged.
82
+ */
83
+ export async function externalizeImageData(blobStore: BlobStore, base64Data: string): Promise<string> {
84
+ if (isBlobRef(base64Data)) return base64Data;
85
+ const buffer = Buffer.from(base64Data, "base64");
86
+ const hash = await blobStore.put(buffer);
87
+ return makeBlobRef(hash);
88
+ }
89
+
90
+ /**
91
+ * Resolve a blob reference back to base64 data.
92
+ * If the data is not a blob reference, returns it unchanged.
93
+ * If the blob is missing, logs a warning and returns a placeholder.
94
+ */
95
+ export async function resolveImageData(blobStore: BlobStore, data: string): Promise<string> {
96
+ const hash = parseBlobRef(data);
97
+ if (!hash) return data;
98
+
99
+ const buffer = await blobStore.get(hash);
100
+ if (!buffer) {
101
+ logger.warn("Blob not found for image reference", { hash });
102
+ return data; // Return the ref as-is; downstream will see invalid base64 but won't crash
103
+ }
104
+ return buffer.toString("base64");
105
+ }