@oh-my-pi/pi-coding-agent 11.0.3 → 11.2.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 (143) hide show
  1. package/CHANGELOG.md +199 -49
  2. package/README.md +1 -1
  3. package/docs/config-usage.md +3 -4
  4. package/docs/sdk.md +6 -5
  5. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  6. package/examples/sdk/README.md +1 -1
  7. package/package.json +19 -11
  8. package/src/cli/args.ts +11 -94
  9. package/src/cli/config-cli.ts +1 -1
  10. package/src/cli/file-processor.ts +3 -3
  11. package/src/cli/oclif-help.ts +26 -0
  12. package/src/cli/web-search-cli.ts +148 -0
  13. package/src/cli.ts +8 -2
  14. package/src/commands/commit.ts +36 -0
  15. package/src/commands/config.ts +51 -0
  16. package/src/commands/grep.ts +41 -0
  17. package/src/commands/index/index.ts +136 -0
  18. package/src/commands/jupyter.ts +32 -0
  19. package/src/commands/plugin.ts +70 -0
  20. package/src/commands/setup.ts +39 -0
  21. package/src/commands/shell.ts +29 -0
  22. package/src/commands/stats.ts +29 -0
  23. package/src/commands/update.ts +21 -0
  24. package/src/commands/web-search.ts +50 -0
  25. package/src/commit/agentic/index.ts +3 -2
  26. package/src/commit/agentic/tools/analyze-file.ts +1 -3
  27. package/src/commit/git/errors.ts +4 -6
  28. package/src/commit/pipeline.ts +3 -2
  29. package/src/config/keybindings.ts +1 -3
  30. package/src/config/model-registry.ts +89 -162
  31. package/src/config/settings-schema.ts +10 -0
  32. package/src/config.ts +202 -132
  33. package/src/exa/mcp-client.ts +8 -41
  34. package/src/export/html/index.ts +1 -1
  35. package/src/extensibility/extensions/loader.ts +7 -10
  36. package/src/extensibility/extensions/runner.ts +5 -15
  37. package/src/extensibility/extensions/types.ts +1 -1
  38. package/src/extensibility/hooks/runner.ts +6 -9
  39. package/src/index.ts +0 -1
  40. package/src/ipy/kernel.ts +10 -22
  41. package/src/lsp/clients/biome-client.ts +4 -7
  42. package/src/lsp/clients/lsp-linter-client.ts +4 -6
  43. package/src/lsp/index.ts +5 -4
  44. package/src/lsp/utils.ts +18 -0
  45. package/src/main.ts +86 -181
  46. package/src/mcp/json-rpc.ts +2 -2
  47. package/src/mcp/transports/http.ts +12 -49
  48. package/src/modes/components/armin.ts +1 -3
  49. package/src/modes/components/assistant-message.ts +4 -4
  50. package/src/modes/components/bash-execution.ts +5 -3
  51. package/src/modes/components/branch-summary-message.ts +1 -3
  52. package/src/modes/components/compaction-summary-message.ts +1 -3
  53. package/src/modes/components/custom-message.ts +4 -5
  54. package/src/modes/components/extensions/extension-dashboard.ts +10 -16
  55. package/src/modes/components/extensions/extension-list.ts +5 -5
  56. package/src/modes/components/footer.ts +1 -4
  57. package/src/modes/components/hook-editor.ts +7 -32
  58. package/src/modes/components/hook-message.ts +4 -5
  59. package/src/modes/components/model-selector.ts +2 -2
  60. package/src/modes/components/plugin-settings.ts +16 -20
  61. package/src/modes/components/python-execution.ts +5 -5
  62. package/src/modes/components/session-selector.ts +6 -7
  63. package/src/modes/components/settings-defs.ts +49 -40
  64. package/src/modes/components/settings-selector.ts +8 -17
  65. package/src/modes/components/skill-message.ts +1 -3
  66. package/src/modes/components/status-line-segment-editor.ts +1 -3
  67. package/src/modes/components/status-line.ts +1 -3
  68. package/src/modes/components/todo-reminder.ts +5 -7
  69. package/src/modes/components/tree-selector.ts +10 -12
  70. package/src/modes/components/ttsr-notification.ts +1 -3
  71. package/src/modes/components/user-message-selector.ts +2 -4
  72. package/src/modes/components/welcome.ts +6 -18
  73. package/src/modes/controllers/event-controller.ts +1 -0
  74. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  75. package/src/modes/controllers/input-controller.ts +7 -34
  76. package/src/modes/controllers/selector-controller.ts +3 -3
  77. package/src/modes/interactive-mode.ts +27 -1
  78. package/src/modes/rpc/rpc-client.ts +2 -5
  79. package/src/modes/rpc/rpc-mode.ts +2 -2
  80. package/src/modes/theme/theme.ts +2 -6
  81. package/src/modes/types.ts +1 -0
  82. package/src/modes/utils/ui-helpers.ts +6 -1
  83. package/src/patch/index.ts +1 -4
  84. package/src/prompts/agents/explore.md +1 -0
  85. package/src/prompts/agents/frontmatter.md +2 -1
  86. package/src/prompts/agents/init.md +1 -0
  87. package/src/prompts/agents/plan.md +1 -0
  88. package/src/prompts/agents/reviewer.md +1 -0
  89. package/src/prompts/system/subagent-submit-reminder.md +2 -0
  90. package/src/prompts/system/subagent-system-prompt.md +2 -0
  91. package/src/prompts/system/subagent-user-prompt.md +8 -0
  92. package/src/prompts/system/system-prompt.md +5 -3
  93. package/src/prompts/system/web-search.md +6 -4
  94. package/src/prompts/tools/task.md +216 -163
  95. package/src/sdk.ts +11 -110
  96. package/src/session/agent-session.ts +117 -83
  97. package/src/session/auth-storage.ts +10 -51
  98. package/src/session/messages.ts +17 -3
  99. package/src/session/session-manager.ts +30 -30
  100. package/src/session/streaming-output.ts +1 -1
  101. package/src/ssh/ssh-executor.ts +6 -3
  102. package/src/task/agents.ts +2 -0
  103. package/src/task/discovery.ts +1 -1
  104. package/src/task/executor.ts +5 -10
  105. package/src/task/index.ts +43 -23
  106. package/src/task/render.ts +67 -64
  107. package/src/task/template.ts +17 -34
  108. package/src/task/types.ts +49 -22
  109. package/src/tools/ask.ts +1 -3
  110. package/src/tools/bash.ts +1 -4
  111. package/src/tools/browser.ts +5 -7
  112. package/src/tools/exit-plan-mode.ts +1 -4
  113. package/src/tools/fetch.ts +1 -3
  114. package/src/tools/find.ts +4 -3
  115. package/src/tools/gemini-image.ts +24 -55
  116. package/src/tools/grep.ts +4 -4
  117. package/src/tools/index.ts +12 -14
  118. package/src/tools/notebook.ts +1 -5
  119. package/src/tools/python.ts +4 -3
  120. package/src/tools/read.ts +2 -4
  121. package/src/tools/render-utils.ts +23 -0
  122. package/src/tools/ssh.ts +8 -12
  123. package/src/tools/todo-write.ts +1 -4
  124. package/src/tools/tool-errors.ts +1 -4
  125. package/src/tools/write.ts +1 -3
  126. package/src/utils/external-editor.ts +59 -0
  127. package/src/utils/file-mentions.ts +39 -1
  128. package/src/utils/image-convert.ts +1 -1
  129. package/src/utils/image-resize.ts +4 -4
  130. package/src/web/search/auth.ts +3 -33
  131. package/src/web/search/index.ts +73 -139
  132. package/src/web/search/provider.ts +58 -0
  133. package/src/web/search/providers/anthropic.ts +53 -14
  134. package/src/web/search/providers/base.ts +22 -0
  135. package/src/web/search/providers/codex.ts +38 -16
  136. package/src/web/search/providers/exa.ts +30 -6
  137. package/src/web/search/providers/gemini.ts +56 -20
  138. package/src/web/search/providers/jina.ts +28 -5
  139. package/src/web/search/providers/perplexity.ts +103 -36
  140. package/src/web/search/render.ts +84 -74
  141. package/src/web/search/types.ts +285 -59
  142. package/src/migrations.ts +0 -175
  143. package/src/session/storage-migration.ts +0 -173
@@ -21,10 +21,7 @@ export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, Ex
21
21
  public readonly description: string;
22
22
  public readonly parameters = exitPlanModeSchema;
23
23
 
24
- private readonly session: ToolSession;
25
-
26
- constructor(session: ToolSession) {
27
- this.session = session;
24
+ constructor(private readonly session: ToolSession) {
28
25
  this.description = renderPromptTemplate(exitPlanModeDescription);
29
26
  }
30
27
 
@@ -855,10 +855,8 @@ export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails
855
855
  public readonly label = "Fetch";
856
856
  public readonly description: string;
857
857
  public readonly parameters = fetchSchema;
858
- private readonly session: ToolSession;
859
858
 
860
- constructor(session: ToolSession) {
861
- this.session = session;
859
+ constructor(private readonly session: ToolSession) {
862
860
  this.description = renderPromptTemplate(fetchDescription);
863
861
  }
864
862
 
package/src/tools/find.ts CHANGED
@@ -118,11 +118,12 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
118
118
  public readonly description: string;
119
119
  public readonly parameters = findSchema;
120
120
 
121
- private readonly session: ToolSession;
122
121
  private readonly customOps?: FindOperations;
123
122
 
124
- constructor(session: ToolSession, options?: FindToolOptions) {
125
- this.session = session;
123
+ constructor(
124
+ private readonly session: ToolSession,
125
+ options?: FindToolOptions,
126
+ ) {
126
127
  this.customOps = options?.operations;
127
128
  this.description = renderPromptTemplate(findDescription);
128
129
  }
@@ -1,7 +1,7 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
3
  import { getEnvApiKey, StringEnum } from "@oh-my-pi/pi-ai";
4
- import { $env, ptree, Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
4
+ import { $env, ptree, readSseJson, Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
5
5
  import { type Static, Type } from "@sinclair/typebox";
6
6
  import type { ModelRegistry } from "../config/model-registry";
7
7
  import { renderPromptTemplate } from "../config/prompt-templates";
@@ -316,8 +316,8 @@ async function loadImageFromUrl(imageUrl: string, signal?: AbortSignal): Promise
316
316
  if (!contentType || !contentType.startsWith("image/")) {
317
317
  throw new Error(`Unsupported image type from URL: ${imageUrl}`);
318
318
  }
319
- const buffer = Buffer.from(await response.arrayBuffer());
320
- return { data: buffer.toString("base64"), mimeType: contentType };
319
+ const buffer = await response.bytes();
320
+ return { data: buffer.toBase64(), mimeType: contentType };
321
321
  }
322
322
 
323
323
  function collectOpenRouterResponseText(message: OpenRouterMessage | undefined): string | undefined {
@@ -450,8 +450,8 @@ async function loadImageFromPath(imagePath: string, cwd: string): Promise<Inline
450
450
  throw new Error(`Unsupported image type: ${imagePath}`);
451
451
  }
452
452
 
453
- const buffer = Buffer.from(await file.arrayBuffer());
454
- return { data: buffer.toString("base64"), mimeType };
453
+ const buffer = await file.bytes();
454
+ return { data: buffer.toBase64(), mimeType };
455
455
  }
456
456
 
457
457
  async function resolveInputImage(input: ImageInput, cwd: string): Promise<InlineImageData> {
@@ -585,68 +585,37 @@ interface AntigravitySseResult {
585
585
  usage?: GeminiUsageMetadata;
586
586
  }
587
587
 
588
+ const _prefix = Buffer.from("data: ", "utf-8");
589
+
588
590
  async function parseAntigravitySseForImage(response: Response, signal?: AbortSignal): Promise<AntigravitySseResult> {
589
591
  if (!response.body) {
590
592
  throw new Error("No response body");
591
593
  }
592
594
 
593
- const reader = response.body.getReader();
594
- const decoder = new TextDecoder();
595
- let buffer = "";
596
595
  const textParts: string[] = [];
597
596
  const images: InlineImageData[] = [];
598
597
  let usage: GeminiUsageMetadata | undefined;
599
598
 
600
- try {
601
- while (true) {
602
- if (signal?.aborted) {
603
- throw new Error("Request was aborted");
604
- }
605
-
606
- const { done, value } = await reader.read();
607
- if (done) break;
608
-
609
- buffer += decoder.decode(value, { stream: true });
610
- const lines = buffer.split("\n");
611
- buffer = lines.pop() ?? "";
612
-
613
- for (const line of lines) {
614
- if (!line.startsWith("data:")) continue;
615
- const jsonStr = line.slice(5).trim();
616
- if (!jsonStr) continue;
617
-
618
- const parsed = Bun.JSONL.parseChunk(`${jsonStr}\n`);
619
- if (parsed.error || parsed.values.length === 0) continue;
620
-
621
- for (const value of parsed.values) {
622
- const chunk = value as AntigravityResponseChunk;
623
- const responseData = chunk.response;
624
- if (!responseData?.candidates) continue;
625
-
626
- if (responseData.usageMetadata) {
627
- usage = responseData.usageMetadata;
628
- }
629
-
630
- for (const candidate of responseData.candidates) {
631
- const parts = candidate.content?.parts;
632
- if (!parts) continue;
633
- for (const part of parts) {
634
- if (part.text) {
635
- textParts.push(part.text);
636
- }
637
- if (part.inlineData?.data && part.inlineData?.mimeType) {
638
- images.push({
639
- data: part.inlineData.data,
640
- mimeType: part.inlineData.mimeType,
641
- });
642
- }
643
- }
644
- }
599
+ for await (const chunk of readSseJson<AntigravityResponseChunk>(response.body, signal)) {
600
+ const responseData = chunk.response;
601
+ if (!responseData) continue;
602
+ if (!responseData.candidates) continue;
603
+ for (const candidate of responseData.candidates) {
604
+ const parts = candidate.content?.parts;
605
+ if (!parts) continue;
606
+ for (const part of parts) {
607
+ if (part.text) {
608
+ textParts.push(part.text);
609
+ }
610
+ const inlineData = part.inlineData;
611
+ if (inlineData?.data && inlineData.mimeType) {
612
+ images.push({ data: inlineData.data, mimeType: inlineData.mimeType });
645
613
  }
646
614
  }
647
615
  }
648
- } finally {
649
- reader.releaseLock();
616
+ if (responseData.usageMetadata) {
617
+ usage = responseData.usageMetadata;
618
+ }
650
619
  }
651
620
 
652
621
  return { images, text: textParts, usage };
package/src/tools/grep.ts CHANGED
@@ -66,10 +66,10 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
66
66
  public readonly description: string;
67
67
  public readonly parameters = grepSchema;
68
68
 
69
- private readonly session: ToolSession;
70
-
71
- constructor(session: ToolSession, _options?: GrepToolOptions) {
72
- this.session = session;
69
+ constructor(
70
+ private readonly session: ToolSession,
71
+ _options?: GrepToolOptions,
72
+ ) {
73
73
  this.description = renderPromptTemplate(grepDescription);
74
74
  }
75
75
 
@@ -14,7 +14,7 @@ import { TaskTool } from "../task";
14
14
  import type { AgentOutputManager } from "../task/output-manager";
15
15
  import type { EventBus } from "../utils/event-bus";
16
16
  import { time } from "../utils/timings";
17
- import { WebSearchTool } from "../web/search";
17
+ import { SearchTool } from "../web/search";
18
18
  import { AskTool } from "./ask";
19
19
  import { BashTool } from "./bash";
20
20
  import { BrowserTool } from "./browser";
@@ -51,16 +51,14 @@ export {
51
51
  export { EditTool, type EditToolDetails } from "../patch";
52
52
  export { BUNDLED_AGENTS, TaskTool } from "../task";
53
53
  export {
54
- companyWebSearchTools,
55
- exaWebSearchTools,
56
- getWebSearchTools,
57
- hasExaWebSearch,
58
- linkedinWebSearchTools,
59
- setPreferredWebSearchProvider,
60
- type WebSearchProvider,
61
- type WebSearchResponse,
62
- WebSearchTool,
63
- type WebSearchToolsOptions,
54
+ companySearchTools,
55
+ exaSearchTools,
56
+ getSearchTools,
57
+ type SearchProvider,
58
+ type SearchResponse,
59
+ SearchTool,
60
+ type SearchToolsOptions,
61
+ setPreferredSearchProvider,
64
62
  webSearchCodeContextTool,
65
63
  webSearchCompanyTool,
66
64
  webSearchCrawlTool,
@@ -179,7 +177,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
179
177
  task: TaskTool.create,
180
178
  todo_write: s => new TodoWriteTool(s),
181
179
  fetch: s => new FetchTool(s),
182
- web_search: s => new WebSearchTool(s),
180
+ web_search: s => new SearchTool(s),
183
181
  write: s => new WriteTool(s),
184
182
  };
185
183
 
@@ -314,9 +312,9 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
314
312
  const slowTools: Array<{ name: string; ms: number }> = [];
315
313
  const results = await Promise.all(
316
314
  entries.map(async ([name, factory]) => {
317
- const start = performance.now();
315
+ const start = Bun.nanoseconds();
318
316
  const tool = await factory(session);
319
- const elapsed = performance.now() - start;
317
+ const elapsed = (Bun.nanoseconds() - start) / 1e6;
320
318
  if (elapsed > 5) {
321
319
  slowTools.push({ name, ms: Math.round(elapsed) });
322
320
  }
@@ -67,11 +67,7 @@ export class NotebookTool implements AgentTool<typeof notebookSchema, NotebookTo
67
67
  public readonly parameters = notebookSchema;
68
68
  public readonly concurrency = "exclusive";
69
69
 
70
- private readonly session: ToolSession;
71
-
72
- constructor(session: ToolSession) {
73
- this.session = session;
74
- }
70
+ constructor(private readonly session: ToolSession) {}
75
71
 
76
72
  public async execute(
77
73
  _toolCallId: string,
@@ -148,11 +148,12 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
148
148
  public readonly parameters = pythonSchema;
149
149
  public readonly concurrency = "exclusive";
150
150
 
151
- private readonly session: ToolSession | null;
152
151
  private readonly proxyExecutor?: PythonProxyExecutor;
153
152
 
154
- constructor(session: ToolSession | null, options?: PythonToolOptions) {
155
- this.session = session;
153
+ constructor(
154
+ private readonly session: ToolSession | null,
155
+ options?: PythonToolOptions,
156
+ ) {
156
157
  this.proxyExecutor = options?.proxyExecutor;
157
158
  this.description = getPythonToolDescription();
158
159
  }
package/src/tools/read.ts CHANGED
@@ -538,12 +538,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
538
538
  public readonly parameters = readSchema;
539
539
  public readonly nonAbortable = true;
540
540
 
541
- private readonly session: ToolSession;
542
541
  private readonly autoResizeImages: boolean;
543
542
  private readonly defaultLineNumbers: boolean;
544
543
 
545
- constructor(session: ToolSession) {
546
- this.session = session;
544
+ constructor(private readonly session: ToolSession) {
547
545
  this.autoResizeImages = session.settings.get("images.autoResize");
548
546
  this.defaultLineNumbers = session.settings.get("readLineNumbers");
549
547
  this.description = renderPromptTemplate(readDescription, {
@@ -633,7 +631,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
633
631
  const maxStr = formatSize(MAX_IMAGE_SIZE);
634
632
  throw new ToolError(`Image file too large: ${sizeStr} exceeds ${maxStr} limit.`);
635
633
  } else {
636
- const base64 = Buffer.from(buffer).toString("base64");
634
+ const base64 = new Uint8Array(buffer).toBase64();
637
635
 
638
636
  if (this.autoResizeImages) {
639
637
  // Resize image if needed - catch errors from Photon
@@ -327,6 +327,19 @@ interface ParsedDiagnostic {
327
327
  code?: string;
328
328
  }
329
329
 
330
+ function getSeverityRank(severity: ParsedDiagnostic["severity"]): number {
331
+ switch (severity) {
332
+ case "error":
333
+ return 0;
334
+ case "warning":
335
+ return 1;
336
+ case "info":
337
+ return 2;
338
+ case "hint":
339
+ return 3;
340
+ }
341
+ }
342
+
330
343
  function parseDiagnosticMessage(msg: string): ParsedDiagnostic | null {
331
344
  const match = msg.match(/^(.+?):(\d+):(\d+)\s+\[(\w+)\]\s+(?:\[([^\]]+)\]\s+)?(.+?)(?:\s+\(([^)]+)\))?$/);
332
345
  if (!match) return null;
@@ -363,6 +376,16 @@ export function formatDiagnostics(
363
376
  }
364
377
  }
365
378
 
379
+ for (const diagnostics of byFile.values()) {
380
+ diagnostics.sort((a, b) => {
381
+ const severityCompare = getSeverityRank(a.severity) - getSeverityRank(b.severity);
382
+ if (severityCompare !== 0) return severityCompare;
383
+ if (a.line !== b.line) return a.line - b.line;
384
+ if (a.col !== b.col) return a.col - b.col;
385
+ return a.message.localeCompare(b.message);
386
+ });
387
+ }
388
+
366
389
  const headerIcon = diag.errored
367
390
  ? theme.styledSymbol("status.error", "error")
368
391
  : theme.styledSymbol("status.warning", "warning");
package/src/tools/ssh.ts CHANGED
@@ -126,22 +126,18 @@ interface SshToolParams {
126
126
  export class SshTool implements AgentTool<typeof sshSchema, SSHToolDetails> {
127
127
  public readonly name = "ssh";
128
128
  public readonly label = "SSH";
129
- public readonly description: string;
130
129
  public readonly parameters = sshSchema;
131
130
  public readonly concurrency = "exclusive";
132
131
 
133
- private readonly session: ToolSession;
134
-
135
132
  private readonly allowedHosts: Set<string>;
136
- private readonly hostsByName: Map<string, SSHHost>;
137
- private readonly hostNames: string[];
138
-
139
- constructor(session: ToolSession, hostNames: string[], hostsByName: Map<string, SSHHost>, description: string) {
140
- this.session = session;
141
- this.hostNames = hostNames;
142
- this.hostsByName = hostsByName;
143
- this.allowedHosts = new Set(hostNames);
144
- this.description = description;
133
+
134
+ constructor(
135
+ private readonly session: ToolSession,
136
+ private readonly hostNames: string[],
137
+ private readonly hostsByName: Map<string, SSHHost>,
138
+ public readonly description: string,
139
+ ) {
140
+ this.allowedHosts = new Set(this.hostNames);
145
141
  }
146
142
 
147
143
  public async execute(
@@ -156,10 +156,7 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
156
156
  public readonly parameters = todoWriteSchema;
157
157
  public readonly concurrency = "exclusive";
158
158
 
159
- private readonly session: ToolSession;
160
-
161
- constructor(session: ToolSession) {
162
- this.session = session;
159
+ constructor(private readonly session: ToolSession) {
163
160
  this.description = renderPromptTemplate(todoWriteDescription);
164
161
  }
165
162
 
@@ -36,12 +36,9 @@ export interface ErrorEntry {
36
36
  * Error with multiple entries (e.g., multiple validation failures, batch errors).
37
37
  */
38
38
  export class MultiError extends ToolError {
39
- readonly errors: ErrorEntry[];
40
-
41
- constructor(errors: ErrorEntry[]) {
39
+ constructor(readonly errors: ErrorEntry[]) {
42
40
  super(errors.map(e => e.message).join("; "));
43
41
  this.name = "MultiError";
44
- this.errors = errors;
45
42
  }
46
43
 
47
44
  render(): string {
@@ -74,11 +74,9 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
74
74
  public readonly nonAbortable = true;
75
75
  public readonly concurrency = "exclusive";
76
76
 
77
- private readonly session: ToolSession;
78
77
  private readonly writethrough: WritethroughCallback;
79
78
 
80
- constructor(session: ToolSession) {
81
- this.session = session;
79
+ constructor(private readonly session: ToolSession) {
82
80
  const enableLsp = session.enableLsp ?? true;
83
81
  const enableFormat = enableLsp && session.settings.get("lsp.formatOnWrite");
84
82
  const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnWrite");
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Utilities for launching an external text editor ($VISUAL / $EDITOR).
3
+ */
4
+ import { spawn } from "node:child_process";
5
+ import * as fs from "node:fs/promises";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ import { $env, Snowflake } from "@oh-my-pi/pi-utils";
9
+
10
+ /** Returns the user's preferred editor command, or undefined if not configured. */
11
+ export function getEditorCommand(): string | undefined {
12
+ return $env.VISUAL || $env.EDITOR || undefined;
13
+ }
14
+
15
+ export interface OpenInEditorOptions {
16
+ /** File extension for the temp file (default: ".md"). */
17
+ extension?: string;
18
+ /** Custom stdio configuration (default: all "inherit"). */
19
+ stdio?: [number | "inherit", number | "inherit", number | "inherit"];
20
+ }
21
+
22
+ /**
23
+ * Opens `content` in the user's external editor and returns the edited text.
24
+ * Returns `null` if the editor exits with a non-zero code.
25
+ *
26
+ * The caller is responsible for stopping/starting the TUI around this call.
27
+ */
28
+ export async function openInEditor(
29
+ editorCmd: string,
30
+ content: string,
31
+ options?: OpenInEditorOptions,
32
+ ): Promise<string | null> {
33
+ const ext = options?.extension ?? ".md";
34
+ const tmpFile = path.join(os.tmpdir(), `omp-editor-${Snowflake.next()}${ext}`);
35
+
36
+ try {
37
+ await Bun.write(tmpFile, content);
38
+
39
+ const [editor, ...editorArgs] = editorCmd.split(" ");
40
+ const stdio = options?.stdio ?? ["inherit", "inherit", "inherit"];
41
+
42
+ const child = spawn(editor, [...editorArgs, tmpFile], { stdio });
43
+ const exitCode = await new Promise<number>((resolve, reject) => {
44
+ child.once("exit", (code, signal) => resolve(code ?? (signal ? -1 : 0)));
45
+ child.once("error", error => reject(error));
46
+ });
47
+
48
+ if (exitCode === 0) {
49
+ return (await Bun.file(tmpFile).text()).replace(/\n$/, "");
50
+ }
51
+ return null;
52
+ } finally {
53
+ try {
54
+ await fs.rm(tmpFile, { force: true });
55
+ } catch {
56
+ // Ignore cleanup errors
57
+ }
58
+ }
59
+ }
@@ -5,12 +5,15 @@
5
5
  * we automatically inject the file contents as a FileMentionMessage
6
6
  * so the agent doesn't need to read them manually.
7
7
  */
8
+ import * as fs from "node:fs/promises";
8
9
  import path from "node:path";
9
10
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
10
11
  import type { FileMentionMessage } from "../session/messages";
11
12
  import { resolveReadPath } from "../tools/path-utils";
12
13
  import { formatAge } from "../tools/render-utils";
13
14
  import { DEFAULT_MAX_BYTES, formatSize, truncateHead, truncateStringToBytesFromStart } from "../tools/truncate";
15
+ import { formatDimensionNote, resizeImage } from "./image-resize";
16
+ import { detectSupportedImageMimeTypeFromFile } from "./mime";
14
17
 
15
18
  /** Regex to match @filepath patterns in text */
16
19
  const FILE_MENTION_REGEX = /@([^\s@]+)/g;
@@ -156,9 +159,15 @@ export function extractFileMentions(text: string): string[] {
156
159
  * Generate a FileMentionMessage containing the contents of mentioned files.
157
160
  * Returns empty array if no files could be read.
158
161
  */
159
- export async function generateFileMentionMessages(filePaths: string[], cwd: string): Promise<AgentMessage[]> {
162
+ export async function generateFileMentionMessages(
163
+ filePaths: string[],
164
+ cwd: string,
165
+ options?: { autoResizeImages?: boolean },
166
+ ): Promise<AgentMessage[]> {
160
167
  if (filePaths.length === 0) return [];
161
168
 
169
+ const autoResizeImages = options?.autoResizeImages ?? true;
170
+
162
171
  const files: FileMentionMessage["files"] = [];
163
172
 
164
173
  for (const filePath of filePaths) {
@@ -172,6 +181,35 @@ export async function generateFileMentionMessages(filePaths: string[], cwd: stri
172
181
  continue;
173
182
  }
174
183
 
184
+ const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
185
+ if (mimeType) {
186
+ const buffer = await fs.readFile(absolutePath);
187
+ if (buffer.length === 0) {
188
+ continue;
189
+ }
190
+
191
+ const base64Content = buffer.toBase64();
192
+ let image = { type: "image" as const, mimeType, data: base64Content };
193
+ let dimensionNote: string | undefined;
194
+
195
+ if (autoResizeImages) {
196
+ try {
197
+ const resized = await resizeImage({ type: "image", data: base64Content, mimeType });
198
+ dimensionNote = formatDimensionNote(resized);
199
+ image = {
200
+ type: "image" as const,
201
+ mimeType: resized.mimeType,
202
+ data: resized.data,
203
+ };
204
+ } catch {
205
+ image = { type: "image" as const, mimeType, data: base64Content };
206
+ }
207
+ }
208
+
209
+ files.push({ path: filePath, content: dimensionNote ?? "", image });
210
+ continue;
211
+ }
212
+
175
213
  const content = await Bun.file(absolutePath).text();
176
214
  const { output, lineCount } = buildTextOutput(content);
177
215
  files.push({ path: filePath, content: output, lineCount });
@@ -17,7 +17,7 @@ export async function convertToPng(
17
17
  const image = await PhotonImage.parse(new Uint8Array(Buffer.from(base64Data, "base64")));
18
18
  const pngBuffer = await image.encode(ImageFormat.PNG, 100);
19
19
  return {
20
- data: Buffer.from(pngBuffer).toString("base64"),
20
+ data: Buffer.from(pngBuffer).toBase64(),
21
21
  mimeType: "image/png",
22
22
  };
23
23
  } catch {
@@ -119,7 +119,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
119
119
 
120
120
  if (best.buffer.length <= opts.maxBytes) {
121
121
  return {
122
- data: Buffer.from(best.buffer).toString("base64"),
122
+ data: best.buffer.toBase64(),
123
123
  mimeType: best.mimeType,
124
124
  originalWidth,
125
125
  originalHeight,
@@ -135,7 +135,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
135
135
 
136
136
  if (best.buffer.length <= opts.maxBytes) {
137
137
  return {
138
- data: Buffer.from(best.buffer).toString("base64"),
138
+ data: best.buffer.toBase64(),
139
139
  mimeType: best.mimeType,
140
140
  originalWidth,
141
141
  originalHeight,
@@ -160,7 +160,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
160
160
 
161
161
  if (best.buffer.length <= opts.maxBytes) {
162
162
  return {
163
- data: Buffer.from(best.buffer).toString("base64"),
163
+ data: best.buffer.toBase64(),
164
164
  mimeType: best.mimeType,
165
165
  originalWidth,
166
166
  originalHeight,
@@ -174,7 +174,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
174
174
 
175
175
  // Last resort: return smallest version we produced
176
176
  return {
177
- data: Buffer.from(best.buffer).toString("base64"),
177
+ data: best.buffer.toBase64(),
178
178
  mimeType: best.mimeType,
179
179
  originalWidth,
180
180
  originalHeight,
@@ -7,13 +7,11 @@
7
7
  * 3. OAuth credentials in ~/.omp/agent/agent.db (with expiry check)
8
8
  * 4. ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL fallback
9
9
  */
10
- import * as path from "node:path";
11
10
  import { buildAnthropicHeaders as buildProviderAnthropicHeaders, getEnvApiKey } from "@oh-my-pi/pi-ai";
12
11
  import { $env, logger } from "@oh-my-pi/pi-utils";
13
12
  import { getAgentDbPath, getConfigDirPaths } from "../../config";
14
13
  import { AgentStorage } from "../../session/agent-storage";
15
- import type { AuthCredential, AuthCredentialEntry, AuthStorageData } from "../../session/auth-storage";
16
- import { migrateJsonStorage } from "../../session/storage-migration";
14
+ import type { AuthCredential } from "../../session/auth-storage";
17
15
  import type { AnthropicAuthConfig, AnthropicOAuthCredential, ModelsJson } from "./types";
18
16
 
19
17
  const DEFAULT_BASE_URL = "https://api.anthropic.com";
@@ -60,36 +58,12 @@ function toAnthropicOAuthCredential(credential: AuthCredential): AnthropicOAuthC
60
58
  };
61
59
  }
62
60
 
63
- function normalizeAuthEntry(entry: AuthCredentialEntry | undefined): AuthCredential[] {
64
- if (!entry) return [];
65
- return Array.isArray(entry) ? entry : [entry];
66
- }
67
-
68
- async function readLegacyAnthropicOAuthCredentials(configDir: string): Promise<AnthropicOAuthCredential[]> {
69
- const authJson = await readJson<AuthStorageData>(path.join(configDir, "auth.json"));
70
- if (!authJson) return [];
71
- const entry = authJson.anthropic as AuthCredentialEntry | undefined;
72
- const credentials = normalizeAuthEntry(entry);
73
- const results: AnthropicOAuthCredential[] = [];
74
- for (const credential of credentials) {
75
- const mapped = toAnthropicOAuthCredential(credential);
76
- if (mapped) results.push(mapped);
77
- }
78
- return results;
79
- }
80
-
81
61
  /**
82
- * Reads Anthropic OAuth credentials from agent.db, migrating from legacy auth.json if needed.
62
+ * Reads Anthropic OAuth credentials from agent.db.
83
63
  * @param configDir - Path to the config directory containing agent.db
84
64
  * @returns Array of valid Anthropic OAuth credentials
85
65
  */
86
66
  async function readAnthropicOAuthCredentials(configDir: string): Promise<AnthropicOAuthCredential[]> {
87
- await migrateJsonStorage({
88
- agentDir: configDir,
89
- settingsPath: path.join(configDir, "settings.json"),
90
- authPaths: [path.join(configDir, "auth.json")],
91
- });
92
-
93
67
  const storage = await AgentStorage.open(getAgentDbPath(configDir));
94
68
  const records = storage.listAuthCredentials("anthropic");
95
69
  const credentials: AnthropicOAuthCredential[] = [];
@@ -100,10 +74,6 @@ async function readAnthropicOAuthCredentials(configDir: string): Promise<Anthrop
100
74
  }
101
75
  }
102
76
 
103
- if (credentials.length === 0) {
104
- return readLegacyAnthropicOAuthCredentials(configDir);
105
- }
106
-
107
77
  return credentials;
108
78
  }
109
79
 
@@ -132,7 +102,7 @@ export async function findAnthropicAuth(): Promise<AnthropicAuthConfig | null> {
132
102
 
133
103
  // 2. Provider with api="anthropic-messages" in models.json (check all config dirs)
134
104
  for (const configDir of configDirs) {
135
- const modelsJson = await readJson<ModelsJson>(path.join(configDir, "models.json"));
105
+ const modelsJson = await readJson<ModelsJson>(`${configDir}/models.json`);
136
106
  if (modelsJson?.providers) {
137
107
  // First pass: look for providers with actual API keys
138
108
  for (const [_name, provider] of Object.entries(modelsJson.providers)) {