@oh-my-pi/pi-coding-agent 6.2.0 → 6.7.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 (93) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/docs/sdk.md +1 -1
  3. package/package.json +5 -5
  4. package/scripts/generate-template.ts +6 -6
  5. package/src/cli/args.ts +3 -0
  6. package/src/core/agent-session.ts +39 -0
  7. package/src/core/bash-executor.ts +3 -3
  8. package/src/core/cursor/exec-bridge.ts +95 -88
  9. package/src/core/custom-commands/bundled/review/index.ts +142 -145
  10. package/src/core/custom-commands/bundled/wt/index.ts +68 -66
  11. package/src/core/custom-commands/loader.ts +4 -6
  12. package/src/core/custom-tools/index.ts +2 -2
  13. package/src/core/custom-tools/loader.ts +66 -61
  14. package/src/core/custom-tools/types.ts +4 -4
  15. package/src/core/custom-tools/wrapper.ts +61 -25
  16. package/src/core/event-bus.ts +19 -47
  17. package/src/core/extensions/index.ts +8 -4
  18. package/src/core/extensions/loader.ts +160 -120
  19. package/src/core/extensions/types.ts +4 -4
  20. package/src/core/extensions/wrapper.ts +149 -100
  21. package/src/core/hooks/index.ts +1 -1
  22. package/src/core/hooks/tool-wrapper.ts +96 -70
  23. package/src/core/hooks/types.ts +1 -2
  24. package/src/core/index.ts +1 -0
  25. package/src/core/mcp/index.ts +6 -2
  26. package/src/core/mcp/json-rpc.ts +88 -0
  27. package/src/core/mcp/loader.ts +22 -4
  28. package/src/core/mcp/manager.ts +202 -48
  29. package/src/core/mcp/tool-bridge.ts +143 -55
  30. package/src/core/mcp/tool-cache.ts +122 -0
  31. package/src/core/python-executor.ts +3 -9
  32. package/src/core/sdk.ts +33 -32
  33. package/src/core/session-manager.ts +30 -0
  34. package/src/core/settings-manager.ts +34 -1
  35. package/src/core/ssh/ssh-executor.ts +6 -84
  36. package/src/core/streaming-output.ts +107 -53
  37. package/src/core/tools/ask.ts +92 -93
  38. package/src/core/tools/bash.ts +103 -94
  39. package/src/core/tools/calculator.ts +41 -26
  40. package/src/core/tools/complete.ts +76 -66
  41. package/src/core/tools/context.ts +22 -24
  42. package/src/core/tools/exa/index.ts +1 -1
  43. package/src/core/tools/exa/mcp-client.ts +56 -101
  44. package/src/core/tools/find.ts +250 -253
  45. package/src/core/tools/git.ts +39 -33
  46. package/src/core/tools/grep.ts +440 -427
  47. package/src/core/tools/index.ts +62 -61
  48. package/src/core/tools/ls.ts +119 -114
  49. package/src/core/tools/lsp/clients/biome-client.ts +5 -7
  50. package/src/core/tools/lsp/clients/index.ts +4 -4
  51. package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
  52. package/src/core/tools/lsp/config.ts +2 -2
  53. package/src/core/tools/lsp/index.ts +604 -578
  54. package/src/core/tools/notebook.ts +121 -119
  55. package/src/core/tools/output.ts +163 -147
  56. package/src/core/tools/patch/applicator.ts +1100 -0
  57. package/src/core/tools/patch/diff.ts +362 -0
  58. package/src/core/tools/patch/fuzzy.ts +647 -0
  59. package/src/core/tools/patch/index.ts +430 -0
  60. package/src/core/tools/patch/normalize.ts +220 -0
  61. package/src/core/tools/patch/normative.ts +49 -0
  62. package/src/core/tools/patch/parser.ts +528 -0
  63. package/src/core/tools/patch/shared.ts +228 -0
  64. package/src/core/tools/patch/types.ts +244 -0
  65. package/src/core/tools/python.ts +139 -136
  66. package/src/core/tools/read.ts +237 -216
  67. package/src/core/tools/render-utils.ts +196 -77
  68. package/src/core/tools/renderers.ts +1 -1
  69. package/src/core/tools/ssh.ts +99 -80
  70. package/src/core/tools/task/executor.ts +11 -7
  71. package/src/core/tools/task/index.ts +352 -343
  72. package/src/core/tools/task/worker.ts +13 -23
  73. package/src/core/tools/todo-write.ts +74 -59
  74. package/src/core/tools/web-fetch.ts +54 -47
  75. package/src/core/tools/web-search/index.ts +27 -16
  76. package/src/core/tools/write.ts +73 -44
  77. package/src/core/ttsr.ts +106 -152
  78. package/src/core/voice.ts +49 -39
  79. package/src/index.ts +16 -12
  80. package/src/lib/worktree/index.ts +1 -9
  81. package/src/modes/interactive/components/diff.ts +15 -8
  82. package/src/modes/interactive/components/settings-defs.ts +24 -0
  83. package/src/modes/interactive/components/tool-execution.ts +34 -6
  84. package/src/modes/interactive/controllers/event-controller.ts +6 -19
  85. package/src/modes/interactive/controllers/input-controller.ts +1 -1
  86. package/src/modes/interactive/utils/ui-helpers.ts +5 -1
  87. package/src/modes/rpc/rpc-mode.ts +99 -81
  88. package/src/prompts/tools/patch.md +76 -0
  89. package/src/prompts/tools/read.md +1 -1
  90. package/src/prompts/tools/{edit.md → replace.md} +1 -0
  91. package/src/utils/shell.ts +0 -40
  92. package/src/core/tools/edit-diff.ts +0 -574
  93. package/src/core/tools/edit.ts +0 -345
@@ -15,7 +15,7 @@
15
15
  * and add "(Recommended)" at the end of the label
16
16
  */
17
17
 
18
- import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
18
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
19
  import type { Component } from "@oh-my-pi/pi-tui";
20
20
  import { Text } from "@oh-my-pi/pi-tui";
21
21
  import { Type } from "@sinclair/typebox";
@@ -188,7 +188,7 @@ function formatQuestionResult(result: QuestionResult): string {
188
188
  }
189
189
 
190
190
  // =============================================================================
191
- // Tool Implementation
191
+ // Tool Class
192
192
  // =============================================================================
193
193
 
194
194
  interface AskParams {
@@ -203,110 +203,109 @@ interface AskParams {
203
203
  }>;
204
204
  }
205
205
 
206
- export function createAskTool(session: ToolSession): null | AgentTool<typeof askSchema, AskToolDetails> {
207
- if (!session.hasUI) {
208
- return null;
206
+ /**
207
+ * Ask tool for interactive user prompting during execution.
208
+ *
209
+ * Allows gathering user preferences, clarifying instructions, and getting decisions
210
+ * on implementation choices as the agent works.
211
+ */
212
+ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
213
+ public readonly name = "ask";
214
+ public readonly label = "Ask";
215
+ public readonly description: string;
216
+ public readonly parameters = askSchema;
217
+
218
+ constructor(_session: ToolSession) {
219
+ this.description = renderPromptTemplate(askDescription);
209
220
  }
210
- return {
211
- name: "ask",
212
- label: "Ask",
213
- description: renderPromptTemplate(askDescription),
214
- parameters: askSchema,
215
-
216
- async execute(
217
- _toolCallId: string,
218
- params: AskParams,
219
- _signal?: AbortSignal,
220
- _onUpdate?: AgentToolUpdateCallback<AskToolDetails>,
221
- context?: AgentToolContext,
222
- ) {
223
- // Headless fallback
224
- if (!context?.hasUI || !context.ui) {
225
- return {
226
- content: [{ type: "text" as const, text: "Error: User prompt requires interactive mode" }],
227
- details: {},
228
- };
229
- }
230
-
231
- const { ui } = context;
232
-
233
- // Multi-part questions mode
234
- if (params.questions && params.questions.length > 0) {
235
- const results: QuestionResult[] = [];
236
221
 
237
- for (const q of params.questions) {
238
- const optionLabels = q.options.map((o) => o.label);
239
- const { selectedOptions, customInput } = await askSingleQuestion(
240
- ui,
241
- q.question,
242
- optionLabels,
243
- q.multi ?? false,
244
- );
245
-
246
- results.push({
247
- id: q.id,
248
- question: q.question,
249
- options: optionLabels,
250
- multi: q.multi ?? false,
251
- selectedOptions,
252
- customInput,
253
- });
254
- }
222
+ static createIf(session: ToolSession): AskTool | null {
223
+ return session.hasUI ? new AskTool(session) : null;
224
+ }
255
225
 
256
- const details: AskToolDetails = { results };
257
- const responseLines = results.map(formatQuestionResult);
258
- const responseText = `User answers:\n${responseLines.join("\n")}`;
226
+ public async execute(
227
+ _toolCallId: string,
228
+ params: AskParams,
229
+ _signal?: AbortSignal,
230
+ _onUpdate?: AgentToolUpdateCallback<AskToolDetails>,
231
+ context?: AgentToolContext,
232
+ ): Promise<AgentToolResult<AskToolDetails>> {
233
+ // Headless fallback
234
+ if (!context?.hasUI || !context.ui) {
235
+ return {
236
+ content: [{ type: "text" as const, text: "Error: User prompt requires interactive mode" }],
237
+ details: {},
238
+ };
239
+ }
259
240
 
260
- return { content: [{ type: "text" as const, text: responseText }], details };
241
+ const { ui } = context;
242
+
243
+ // Multi-part questions mode
244
+ if (params.questions && params.questions.length > 0) {
245
+ const results: QuestionResult[] = [];
246
+
247
+ for (const q of params.questions) {
248
+ const optionLabels = q.options.map((o) => o.label);
249
+ const { selectedOptions, customInput } = await askSingleQuestion(
250
+ ui,
251
+ q.question,
252
+ optionLabels,
253
+ q.multi ?? false,
254
+ );
255
+
256
+ results.push({
257
+ id: q.id,
258
+ question: q.question,
259
+ options: optionLabels,
260
+ multi: q.multi ?? false,
261
+ selectedOptions,
262
+ customInput,
263
+ });
261
264
  }
262
265
 
263
- // Single question mode (backwards compatible)
264
- const question = params.question ?? "";
265
- const options = params.options ?? [];
266
- const multi = params.multi ?? false;
267
- const optionLabels = options.map((o) => o.label);
268
-
269
- if (!question || optionLabels.length === 0) {
270
- return {
271
- content: [{ type: "text" as const, text: "Error: question and options are required" }],
272
- details: {},
273
- };
274
- }
266
+ const details: AskToolDetails = { results };
267
+ const responseLines = results.map(formatQuestionResult);
268
+ const responseText = `User answers:\n${responseLines.join("\n")}`;
269
+
270
+ return { content: [{ type: "text" as const, text: responseText }], details };
271
+ }
275
272
 
276
- const { selectedOptions, customInput } = await askSingleQuestion(ui, question, optionLabels, multi);
273
+ // Single question mode (backwards compatible)
274
+ const question = params.question ?? "";
275
+ const options = params.options ?? [];
276
+ const multi = params.multi ?? false;
277
+ const optionLabels = options.map((o) => o.label);
277
278
 
278
- const details: AskToolDetails = {
279
- question,
280
- options: optionLabels,
281
- multi,
282
- selectedOptions,
283
- customInput,
279
+ if (!question || optionLabels.length === 0) {
280
+ return {
281
+ content: [{ type: "text" as const, text: "Error: question and options are required" }],
282
+ details: {},
284
283
  };
284
+ }
285
285
 
286
- let responseText: string;
287
- if (customInput) {
288
- responseText = `User provided custom input: ${customInput}`;
289
- } else if (selectedOptions.length > 0) {
290
- responseText = multi
291
- ? `User selected: ${selectedOptions.join(", ")}`
292
- : `User selected: ${selectedOptions[0]}`;
293
- } else {
294
- responseText = "User cancelled the selection";
295
- }
286
+ const { selectedOptions, customInput } = await askSingleQuestion(ui, question, optionLabels, multi);
287
+
288
+ const details: AskToolDetails = {
289
+ question,
290
+ options: optionLabels,
291
+ multi,
292
+ selectedOptions,
293
+ customInput,
294
+ };
295
+
296
+ let responseText: string;
297
+ if (customInput) {
298
+ responseText = `User provided custom input: ${customInput}`;
299
+ } else if (selectedOptions.length > 0) {
300
+ responseText = multi ? `User selected: ${selectedOptions.join(", ")}` : `User selected: ${selectedOptions[0]}`;
301
+ } else {
302
+ responseText = "User cancelled the selection";
303
+ }
296
304
 
297
- return { content: [{ type: "text" as const, text: responseText }], details };
298
- },
299
- };
305
+ return { content: [{ type: "text" as const, text: responseText }], details };
306
+ }
300
307
  }
301
308
 
302
- /** Default ask tool - returns null when no UI */
303
- export const askTool = createAskTool({
304
- cwd: process.cwd(),
305
- hasUI: false,
306
- getSessionFile: () => null,
307
- getSessionSpawns: () => "*",
308
- });
309
-
310
309
  // =============================================================================
311
310
  // TUI Renderer
312
311
  // =============================================================================
@@ -1,5 +1,5 @@
1
1
  import { relative, resolve, sep } from "node:path";
2
- import type { AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core";
2
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
3
  import type { Component } from "@oh-my-pi/pi-tui";
4
4
  import { Text, truncateToWidth } from "@oh-my-pi/pi-tui";
5
5
  import { Type } from "@sinclair/typebox";
@@ -52,113 +52,122 @@ export interface BashToolOptions {
52
52
  operations?: BashOperations;
53
53
  }
54
54
 
55
- export function createBashTool(session: ToolSession, options?: BashToolOptions): AgentTool<typeof bashSchema> {
56
- return {
57
- name: "bash",
58
- label: "Bash",
59
- description: renderPromptTemplate(bashDescription),
60
- parameters: bashSchema,
61
- execute: async (
62
- _toolCallId: string,
63
- { command, timeout, workdir }: { command: string; timeout?: number; workdir?: string },
64
- signal?: AbortSignal,
65
- onUpdate?,
66
- ctx?: AgentToolContext,
67
- ) => {
68
- // Check interception if enabled and available tools are known
69
- if (session.settings?.getBashInterceptorEnabled()) {
70
- const rules = session.settings?.getBashInterceptorRules?.();
71
- const interception = checkBashInterception(command, ctx?.toolNames ?? [], rules);
72
- if (interception.block) {
73
- throw new Error(interception.message);
74
- }
75
- if (session.settings?.getBashInterceptorSimpleLsEnabled?.() !== false) {
76
- const lsInterception = checkSimpleLsInterception(command, ctx?.toolNames ?? []);
77
- if (lsInterception.block) {
78
- throw new Error(lsInterception.message);
79
- }
55
+ /**
56
+ * Bash tool implementation.
57
+ *
58
+ * Executes bash commands with optional timeout and working directory.
59
+ * Supports custom operations for remote execution.
60
+ */
61
+ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
62
+ public readonly name = "bash";
63
+ public readonly label = "Bash";
64
+ public readonly description: string;
65
+ public readonly parameters = bashSchema;
66
+
67
+ private readonly session: ToolSession;
68
+ private readonly options?: BashToolOptions;
69
+
70
+ constructor(session: ToolSession, options?: BashToolOptions) {
71
+ this.session = session;
72
+ this.options = options;
73
+ this.description = renderPromptTemplate(bashDescription);
74
+ }
75
+
76
+ public async execute(
77
+ _toolCallId: string,
78
+ { command, timeout, workdir }: { command: string; timeout?: number; workdir?: string },
79
+ signal?: AbortSignal,
80
+ onUpdate?: AgentToolUpdateCallback<BashToolDetails>,
81
+ ctx?: AgentToolContext,
82
+ ): Promise<AgentToolResult<BashToolDetails>> {
83
+ // Check interception if enabled and available tools are known
84
+ if (this.session.settings?.getBashInterceptorEnabled()) {
85
+ const rules = this.session.settings?.getBashInterceptorRules?.();
86
+ const interception = checkBashInterception(command, ctx?.toolNames ?? [], rules);
87
+ if (interception.block) {
88
+ throw new Error(interception.message);
89
+ }
90
+ if (this.session.settings?.getBashInterceptorSimpleLsEnabled?.() !== false) {
91
+ const lsInterception = checkSimpleLsInterception(command, ctx?.toolNames ?? []);
92
+ if (lsInterception.block) {
93
+ throw new Error(lsInterception.message);
80
94
  }
81
95
  }
96
+ }
82
97
 
83
- const commandCwd = workdir ? resolveToCwd(workdir, session.cwd) : session.cwd;
84
- let cwdStat: Awaited<ReturnType<Bun.BunFile["stat"]>>;
85
- try {
86
- cwdStat = await Bun.file(commandCwd).stat();
87
- } catch {
88
- throw new Error(`Working directory does not exist: ${commandCwd}`);
89
- }
90
- if (!cwdStat.isDirectory()) {
91
- throw new Error(`Working directory is not a directory: ${commandCwd}`);
92
- }
98
+ const commandCwd = workdir ? resolveToCwd(workdir, this.session.cwd) : this.session.cwd;
99
+ let cwdStat: Awaited<ReturnType<Bun.BunFile["stat"]>>;
100
+ try {
101
+ cwdStat = await Bun.file(commandCwd).stat();
102
+ } catch {
103
+ throw new Error(`Working directory does not exist: ${commandCwd}`);
104
+ }
105
+ if (!cwdStat.isDirectory()) {
106
+ throw new Error(`Working directory is not a directory: ${commandCwd}`);
107
+ }
93
108
 
94
- // Track output for streaming updates
95
- let currentOutput = "";
96
-
97
- const executorOptions: BashExecutorOptions = {
98
- cwd: commandCwd,
99
- timeout: timeout ? timeout * 1000 : undefined, // Convert to milliseconds
100
- signal,
101
- onChunk: (chunk) => {
102
- currentOutput += chunk;
103
- if (onUpdate) {
104
- const truncation = truncateTail(currentOutput);
105
- onUpdate({
106
- content: [{ type: "text", text: truncation.content || "" }],
107
- details: truncation.truncated
108
- ? {
109
- truncation,
110
- fullOutput: currentOutput,
111
- }
112
- : undefined,
113
- });
114
- }
115
- },
116
- };
109
+ // Track output for streaming updates
110
+ let currentOutput = "";
111
+
112
+ const executorOptions: BashExecutorOptions = {
113
+ cwd: commandCwd,
114
+ timeout: timeout ? timeout * 1000 : undefined, // Convert to milliseconds
115
+ signal,
116
+ onChunk: (chunk) => {
117
+ currentOutput += chunk;
118
+ if (onUpdate) {
119
+ const truncation = truncateTail(currentOutput);
120
+ onUpdate({
121
+ content: [{ type: "text", text: truncation.content || "" }],
122
+ details: truncation.truncated ? { truncation, fullOutput: currentOutput } : {},
123
+ });
124
+ }
125
+ },
126
+ };
117
127
 
118
- // Use custom operations if provided, otherwise use default local executor
119
- const result = options?.operations
120
- ? await executeBashWithOperations(command, commandCwd, options.operations, executorOptions)
121
- : await executeBash(command, executorOptions);
128
+ // Use custom operations if provided, otherwise use default local executor
129
+ const result = this.options?.operations
130
+ ? await executeBashWithOperations(command, commandCwd, this.options.operations, executorOptions)
131
+ : await executeBash(command, executorOptions);
122
132
 
123
- // Handle errors
124
- if (result.cancelled) {
125
- throw new Error(result.output || "Command aborted");
126
- }
133
+ // Handle errors
134
+ if (result.cancelled) {
135
+ throw new Error(result.output || "Command aborted");
136
+ }
127
137
 
128
- // Apply tail truncation for final output
129
- const truncation = truncateTail(result.output);
130
- let outputText = truncation.content || "(no output)";
138
+ // Apply tail truncation for final output
139
+ const truncation = truncateTail(result.output);
140
+ let outputText = truncation.content || "(no output)";
131
141
 
132
- let details: BashToolDetails | undefined;
142
+ let details: BashToolDetails | undefined;
133
143
 
134
- if (truncation.truncated) {
135
- details = {
136
- truncation,
137
- fullOutputPath: result.fullOutputPath,
138
- fullOutput: currentOutput,
139
- };
144
+ if (truncation.truncated) {
145
+ details = {
146
+ truncation,
147
+ fullOutputPath: result.fullOutputPath,
148
+ fullOutput: currentOutput,
149
+ };
140
150
 
141
- const startLine = truncation.totalLines - truncation.outputLines + 1;
142
- const endLine = truncation.totalLines;
151
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
152
+ const endLine = truncation.totalLines;
143
153
 
144
- if (truncation.lastLinePartial) {
145
- const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8"));
146
- outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${result.fullOutputPath}]`;
147
- } else if (truncation.truncatedBy === "lines") {
148
- outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${result.fullOutputPath}]`;
149
- } else {
150
- outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`;
151
- }
154
+ if (truncation.lastLinePartial) {
155
+ const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8"));
156
+ outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${result.fullOutputPath}]`;
157
+ } else if (truncation.truncatedBy === "lines") {
158
+ outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${result.fullOutputPath}]`;
159
+ } else {
160
+ outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`;
152
161
  }
162
+ }
153
163
 
154
- if (result.exitCode !== 0 && result.exitCode !== undefined) {
155
- outputText += `\n\nCommand exited with code ${result.exitCode}`;
156
- throw new Error(outputText);
157
- }
164
+ if (result.exitCode !== 0 && result.exitCode !== undefined) {
165
+ outputText += `\n\nCommand exited with code ${result.exitCode}`;
166
+ throw new Error(outputText);
167
+ }
158
168
 
159
- return { content: [{ type: "text", text: outputText }], details };
160
- },
161
- };
169
+ return { content: [{ type: "text", text: outputText }], details };
170
+ }
162
171
  }
163
172
 
164
173
  // =============================================================================
@@ -1,4 +1,4 @@
1
- import type { AgentTool } from "@oh-my-pi/pi-agent-core";
1
+ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Component } from "@oh-my-pi/pi-tui";
3
3
  import { Text } from "@oh-my-pi/pi-tui";
4
4
  import { Type } from "@sinclair/typebox";
@@ -390,32 +390,47 @@ function formatResult(value: number): string {
390
390
  return String(value);
391
391
  }
392
392
 
393
- export function createCalculatorTool(_session: ToolSession): AgentTool<typeof calculatorSchema> {
394
- return {
395
- name: "calc",
396
- label: "Calc",
397
- description: renderPromptTemplate(calculatorDescription),
398
- parameters: calculatorSchema,
399
- execute: async (
400
- _toolCallId: string,
401
- { calculations }: { calculations: Array<{ expression: string; prefix: string; suffix: string }> },
402
- signal?: AbortSignal,
403
- ) => {
404
- return untilAborted(signal, async () => {
405
- const results = calculations.map((calc) => {
406
- const value = evaluateExpression(calc.expression);
407
- const output = `${calc.prefix}${formatResult(value)}${calc.suffix}`;
408
- return { expression: calc.expression, value, output };
409
- });
410
-
411
- const outputText = results.map((result) => result.output).join("\n");
412
- return {
413
- content: [{ type: "text", text: outputText }],
414
- details: { results },
415
- };
393
+ // ═══════════════════════════════════════════════════════════════════════════
394
+ // Tool Class
395
+ // ═══════════════════════════════════════════════════════════════════════════
396
+
397
+ type CalculatorParams = { calculations: Array<{ expression: string; prefix: string; suffix: string }> };
398
+
399
+ /**
400
+ * Calculator tool for evaluating mathematical expressions.
401
+ *
402
+ * Supports decimal, hex (0x), binary (0b), octal (0o) literals,
403
+ * standard arithmetic operators, and parentheses.
404
+ */
405
+ export class CalculatorTool implements AgentTool<typeof calculatorSchema, CalculatorToolDetails> {
406
+ public readonly name = "calc";
407
+ public readonly label = "Calc";
408
+ public readonly description: string;
409
+ public readonly parameters = calculatorSchema;
410
+
411
+ constructor(_session: ToolSession) {
412
+ this.description = renderPromptTemplate(calculatorDescription);
413
+ }
414
+
415
+ public async execute(
416
+ _toolCallId: string,
417
+ { calculations }: CalculatorParams,
418
+ signal?: AbortSignal,
419
+ ): Promise<AgentToolResult<CalculatorToolDetails>> {
420
+ return untilAborted(signal, async () => {
421
+ const results = calculations.map((calc) => {
422
+ const value = evaluateExpression(calc.expression);
423
+ const output = `${calc.prefix}${formatResult(value)}${calc.suffix}`;
424
+ return { expression: calc.expression, value, output };
416
425
  });
417
- },
418
- };
426
+
427
+ const outputText = results.map((result) => result.output).join("\n");
428
+ return {
429
+ content: [{ type: "text", text: outputText }],
430
+ details: { results },
431
+ };
432
+ });
433
+ }
419
434
  }
420
435
 
421
436
  // =============================================================================