@oh-my-pi/pi-coding-agent 6.1.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 +56 -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 +25 -25
  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 +824 -639
  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 +89 -41
  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 -326
@@ -1,8 +1,8 @@
1
- import type { AgentTool } from "@oh-my-pi/pi-agent-core";
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
2
  import { StringEnum } from "@oh-my-pi/pi-ai";
3
3
  import type { Component } from "@oh-my-pi/pi-tui";
4
4
  import { Text } from "@oh-my-pi/pi-tui";
5
- import { Type } from "@sinclair/typebox";
5
+ import { type Static, Type } from "@sinclair/typebox";
6
6
  import type { Theme } from "../../modes/interactive/theme/theme";
7
7
  import type { RenderResultOptions } from "../custom-tools/types";
8
8
  import type { ToolSession } from "../sdk";
@@ -63,133 +63,135 @@ function splitIntoLines(content: string): string[] {
63
63
  return content.split("\n").map((line, i, arr) => (i < arr.length - 1 ? `${line}\n` : line));
64
64
  }
65
65
 
66
- export function createNotebookTool(session: ToolSession): AgentTool<typeof notebookSchema> {
67
- return {
68
- name: "notebook",
69
- label: "Notebook",
70
- description:
71
- "Completely replaces the contents of a specific cell in a Jupyter notebook (.ipynb file) with new source. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path. The cell_number is 0-indexed. Use edit_mode=insert to add a new cell at the index specified by cell_number. Use edit_mode=delete to delete the cell at the index specified by cell_number.",
72
- parameters: notebookSchema,
73
- execute: async (
74
- _toolCallId: string,
75
- {
76
- action,
77
- notebook_path,
78
- cell_index,
79
- content,
80
- cell_type,
81
- }: { action: string; notebook_path: string; cell_index: number; content?: string; cell_type?: string },
82
- signal?: AbortSignal,
83
- ) => {
84
- const absolutePath = resolveToCwd(notebook_path, session.cwd);
85
-
86
- return untilAborted(signal, async () => {
87
- // Check if file exists
88
- const file = Bun.file(absolutePath);
89
- if (!(await file.exists())) {
90
- throw new Error(`Notebook not found: ${notebook_path}`);
91
- }
66
+ type NotebookParams = Static<typeof notebookSchema>;
92
67
 
93
- // Read and parse notebook
94
- let notebook: Notebook;
95
- try {
96
- notebook = await file.json();
97
- } catch {
98
- throw new Error(`Invalid JSON in notebook: ${notebook_path}`);
99
- }
68
+ export class NotebookTool implements AgentTool<typeof notebookSchema, NotebookToolDetails> {
69
+ public readonly name = "notebook";
70
+ public readonly label = "Notebook";
71
+ public readonly description =
72
+ "Completely replaces the contents of a specific cell in a Jupyter notebook (.ipynb file) with new source. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path. The cell_number is 0-indexed. Use edit_mode=insert to add a new cell at the index specified by cell_number. Use edit_mode=delete to delete the cell at the index specified by cell_number.";
73
+ public readonly parameters = notebookSchema;
100
74
 
101
- // Validate notebook structure
102
- if (!notebook.cells || !Array.isArray(notebook.cells)) {
103
- throw new Error(`Invalid notebook structure (missing cells array): ${notebook_path}`);
104
- }
75
+ private readonly session: ToolSession;
105
76
 
106
- const cellCount = notebook.cells.length;
77
+ constructor(session: ToolSession) {
78
+ this.session = session;
79
+ }
107
80
 
108
- // Validate cell_index based on action
109
- if (action === "insert") {
110
- if (cell_index < 0 || cell_index > cellCount) {
111
- throw new Error(
112
- `Cell index ${cell_index} out of range for insert (0-${cellCount}) in ${notebook_path}`,
113
- );
114
- }
115
- } else {
116
- if (cell_index < 0 || cell_index >= cellCount) {
117
- throw new Error(`Cell index ${cell_index} out of range (0-${cellCount - 1}) in ${notebook_path}`);
118
- }
81
+ public async execute(
82
+ _toolCallId: string,
83
+ params: NotebookParams,
84
+ signal?: AbortSignal,
85
+ _onUpdate?: AgentToolUpdateCallback<NotebookToolDetails>,
86
+ _context?: AgentToolContext,
87
+ ): Promise<AgentToolResult<NotebookToolDetails>> {
88
+ const { action, notebook_path, cell_index, content, cell_type } = params;
89
+ const absolutePath = resolveToCwd(notebook_path, this.session.cwd);
90
+
91
+ return untilAborted(signal, async () => {
92
+ // Check if file exists
93
+ const file = Bun.file(absolutePath);
94
+ if (!(await file.exists())) {
95
+ throw new Error(`Notebook not found: ${notebook_path}`);
96
+ }
97
+
98
+ // Read and parse notebook
99
+ let notebook: Notebook;
100
+ try {
101
+ notebook = await file.json();
102
+ } catch {
103
+ throw new Error(`Invalid JSON in notebook: ${notebook_path}`);
104
+ }
105
+
106
+ // Validate notebook structure
107
+ if (!notebook.cells || !Array.isArray(notebook.cells)) {
108
+ throw new Error(`Invalid notebook structure (missing cells array): ${notebook_path}`);
109
+ }
110
+
111
+ const cellCount = notebook.cells.length;
112
+
113
+ // Validate cell_index based on action
114
+ if (action === "insert") {
115
+ if (cell_index < 0 || cell_index > cellCount) {
116
+ throw new Error(`Cell index ${cell_index} out of range for insert (0-${cellCount}) in ${notebook_path}`);
119
117
  }
120
-
121
- // Validate content for edit/insert
122
- if ((action === "edit" || action === "insert") && content === undefined) {
123
- throw new Error(`Content is required for ${action} action`);
118
+ } else {
119
+ if (cell_index < 0 || cell_index >= cellCount) {
120
+ throw new Error(`Cell index ${cell_index} out of range (0-${cellCount - 1}) in ${notebook_path}`);
124
121
  }
125
-
126
- // Perform the action
127
- let resultMessage: string;
128
- let finalCellType: string | undefined;
129
- let cellSource: string[] | undefined;
130
-
131
- switch (action) {
132
- case "edit": {
133
- const sourceLines = splitIntoLines(content!);
134
- notebook.cells[cell_index].source = sourceLines;
135
- finalCellType = notebook.cells[cell_index].cell_type;
136
- cellSource = sourceLines;
137
- resultMessage = `Replaced cell ${cell_index} (${finalCellType})`;
138
- break;
139
- }
140
- case "insert": {
141
- const sourceLines = splitIntoLines(content!);
142
- const newCellType = (cell_type as "code" | "markdown") || "code";
143
- const newCell: NotebookCell = {
144
- cell_type: newCellType,
145
- source: sourceLines,
146
- metadata: {},
147
- };
148
- if (newCellType === "code") {
149
- newCell.execution_count = null;
150
- newCell.outputs = [];
151
- }
152
- notebook.cells.splice(cell_index, 0, newCell);
153
- finalCellType = newCellType;
154
- cellSource = sourceLines;
155
- resultMessage = `Inserted ${newCellType} cell at position ${cell_index}`;
156
- break;
157
- }
158
- case "delete": {
159
- const removedCell = notebook.cells[cell_index];
160
- finalCellType = removedCell.cell_type;
161
- cellSource = removedCell.source;
162
- notebook.cells.splice(cell_index, 1);
163
- resultMessage = `Deleted cell ${cell_index} (${finalCellType})`;
164
- break;
165
- }
166
- default: {
167
- throw new Error(`Invalid action: ${action}`);
122
+ }
123
+
124
+ // Validate content for edit/insert
125
+ if ((action === "edit" || action === "insert") && content === undefined) {
126
+ throw new Error(`Content is required for ${action} action`);
127
+ }
128
+
129
+ // Perform the action
130
+ let resultMessage: string;
131
+ let finalCellType: string | undefined;
132
+ let cellSource: string[] | undefined;
133
+
134
+ switch (action) {
135
+ case "edit": {
136
+ const sourceLines = splitIntoLines(content!);
137
+ notebook.cells[cell_index].source = sourceLines;
138
+ finalCellType = notebook.cells[cell_index].cell_type;
139
+ cellSource = sourceLines;
140
+ resultMessage = `Replaced cell ${cell_index} (${finalCellType})`;
141
+ break;
142
+ }
143
+ case "insert": {
144
+ const sourceLines = splitIntoLines(content!);
145
+ const newCellType = (cell_type as "code" | "markdown") || "code";
146
+ const newCell: NotebookCell = {
147
+ cell_type: newCellType,
148
+ source: sourceLines,
149
+ metadata: {},
150
+ };
151
+ if (newCellType === "code") {
152
+ newCell.execution_count = null;
153
+ newCell.outputs = [];
168
154
  }
155
+ notebook.cells.splice(cell_index, 0, newCell);
156
+ finalCellType = newCellType;
157
+ cellSource = sourceLines;
158
+ resultMessage = `Inserted ${newCellType} cell at position ${cell_index}`;
159
+ break;
169
160
  }
161
+ case "delete": {
162
+ const removedCell = notebook.cells[cell_index];
163
+ finalCellType = removedCell.cell_type;
164
+ cellSource = removedCell.source;
165
+ notebook.cells.splice(cell_index, 1);
166
+ resultMessage = `Deleted cell ${cell_index} (${finalCellType})`;
167
+ break;
168
+ }
169
+ default: {
170
+ throw new Error(`Invalid action: ${action}`);
171
+ }
172
+ }
170
173
 
171
- // Write back with single-space indentation
172
- await Bun.write(absolutePath, JSON.stringify(notebook, null, 1));
173
-
174
- const newCellCount = notebook.cells.length;
175
- return {
176
- content: [
177
- {
178
- type: "text",
179
- text: `${resultMessage}. Notebook now has ${newCellCount} cells.`,
180
- },
181
- ],
182
- details: {
183
- action: action as "edit" | "insert" | "delete",
184
- cellIndex: cell_index,
185
- cellType: finalCellType,
186
- totalCells: newCellCount,
187
- cellSource,
174
+ // Write back with single-space indentation
175
+ await Bun.write(absolutePath, JSON.stringify(notebook, null, 1));
176
+
177
+ const newCellCount = notebook.cells.length;
178
+ return {
179
+ content: [
180
+ {
181
+ type: "text",
182
+ text: `${resultMessage}. Notebook now has ${newCellCount} cells.`,
188
183
  },
189
- };
190
- });
191
- },
192
- };
184
+ ],
185
+ details: {
186
+ action: action as "edit" | "insert" | "delete",
187
+ cellIndex: cell_index,
188
+ cellType: finalCellType,
189
+ totalCells: newCellCount,
190
+ cellSource,
191
+ },
192
+ };
193
+ });
194
+ }
193
195
  }
194
196
 
195
197
  // =============================================================================
@@ -6,8 +6,8 @@
6
6
 
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
- import type { AgentTool } from "@oh-my-pi/pi-agent-core";
10
- import { StringEnum, type TextContent } from "@oh-my-pi/pi-ai";
9
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
10
+ import { StringEnum } from "@oh-my-pi/pi-ai";
11
11
  import type { Component } from "@oh-my-pi/pi-tui";
12
12
  import { Text } from "@oh-my-pi/pi-tui";
13
13
  import { Type } from "@sinclair/typebox";
@@ -232,166 +232,182 @@ function extractPreviewLines(content: string, maxLines: number): string[] {
232
232
  return preview;
233
233
  }
234
234
 
235
- export function createOutputTool(session: ToolSession): AgentTool<typeof outputSchema, OutputToolDetails> {
236
- return {
237
- name: "output",
238
- label: "Output",
239
- description: renderPromptTemplate(outputDescription),
240
- parameters: outputSchema,
241
- execute: async (
242
- _toolCallId: string,
243
- params: {
244
- ids: string[];
245
- format?: "raw" | "json" | "stripped";
246
- query?: string;
247
- offset?: number;
248
- limit?: number;
249
- },
250
- ): Promise<{ content: TextContent[]; details: OutputToolDetails }> => {
251
- const sessionFile = session.getSessionFile();
252
-
253
- if (!sessionFile) {
254
- return {
255
- content: [{ type: "text", text: "No session - output artifacts unavailable" }],
256
- details: { outputs: [], notFound: params.ids },
257
- };
258
- }
235
+ type OutputParams = {
236
+ ids: string[];
237
+ format?: "raw" | "json" | "stripped";
238
+ query?: string;
239
+ offset?: number;
240
+ limit?: number;
241
+ };
259
242
 
260
- const artifactsDir = getArtifactsDir(sessionFile);
261
- if (!artifactsDir || !fs.existsSync(artifactsDir)) {
262
- return {
263
- content: [{ type: "text", text: "No artifacts directory found" }],
264
- details: { outputs: [], notFound: params.ids },
265
- };
266
- }
243
+ /**
244
+ * Output tool for reading agent/task outputs by ID.
245
+ *
246
+ * Resolves IDs like "reviewer_0" to artifact paths in the current session.
247
+ */
248
+ export class OutputTool implements AgentTool<typeof outputSchema, OutputToolDetails> {
249
+ public readonly name = "output";
250
+ public readonly label = "Output";
251
+ public readonly description: string;
252
+ public readonly parameters = outputSchema;
267
253
 
268
- const outputs: OutputEntry[] = [];
269
- const notFound: string[] = [];
270
- const outputContentById = new Map<string, string>();
271
- const query = params.query?.trim();
272
- const wantsQuery = query !== undefined && query.length > 0;
273
- const format = params.format ?? (wantsQuery ? "json" : "raw");
254
+ private readonly session: ToolSession;
274
255
 
275
- if (wantsQuery && (params.offset !== undefined || params.limit !== undefined)) {
276
- throw new Error("query cannot be combined with offset/limit");
277
- }
256
+ constructor(session: ToolSession) {
257
+ this.session = session;
258
+ this.description = renderPromptTemplate(outputDescription);
259
+ }
278
260
 
279
- const queryResults: Array<{ id: string; value: unknown }> = [];
261
+ public async execute(
262
+ _toolCallId: string,
263
+ params: OutputParams,
264
+ _signal?: AbortSignal,
265
+ _onUpdate?: AgentToolUpdateCallback<OutputToolDetails>,
266
+ _context?: AgentToolContext,
267
+ ): Promise<AgentToolResult<OutputToolDetails>> {
268
+ const sessionFile = this.session.getSessionFile();
280
269
 
281
- for (const id of params.ids) {
282
- const outputPath = path.join(artifactsDir, `${id}.out.md`);
270
+ if (!sessionFile) {
271
+ return {
272
+ content: [{ type: "text", text: "No session - output artifacts unavailable" }],
273
+ details: { outputs: [], notFound: params.ids },
274
+ };
275
+ }
283
276
 
284
- if (!fs.existsSync(outputPath)) {
285
- notFound.push(id);
286
- continue;
287
- }
277
+ const artifactsDir = getArtifactsDir(sessionFile);
278
+ if (!artifactsDir || !fs.existsSync(artifactsDir)) {
279
+ return {
280
+ content: [{ type: "text", text: "No artifacts directory found" }],
281
+ details: { outputs: [], notFound: params.ids },
282
+ };
283
+ }
288
284
 
289
- const rawContent = fs.readFileSync(outputPath, "utf-8");
290
- const rawLines = rawContent.split("\n");
291
- const totalLines = rawLines.length;
292
- const totalChars = rawContent.length;
293
-
294
- let selectedContent = rawContent;
295
- let range: OutputRange | undefined;
296
-
297
- if (wantsQuery && query) {
298
- let jsonValue: unknown;
299
- try {
300
- jsonValue = JSON.parse(rawContent);
301
- } catch (err) {
302
- const message = err instanceof Error ? err.message : String(err);
303
- throw new Error(`Output ${id} is not valid JSON: ${message}`);
304
- }
305
- const value = applyQuery(jsonValue, query);
306
- queryResults.push({ id, value });
307
- try {
308
- selectedContent = JSON.stringify(value, null, 2) ?? "null";
309
- } catch {
310
- selectedContent = String(value);
311
- }
312
- } else if (params.offset !== undefined || params.limit !== undefined) {
313
- const startLine = Math.max(1, params.offset ?? 1);
314
- if (startLine > totalLines) {
315
- throw new Error(
316
- `Offset ${params.offset ?? startLine} is beyond end of output (${totalLines} lines) for ${id}`,
317
- );
318
- }
319
- const effectiveLimit = params.limit ?? totalLines - startLine + 1;
320
- const endLine = Math.min(totalLines, startLine + effectiveLimit - 1);
321
- const selectedLines = rawLines.slice(startLine - 1, endLine);
322
- selectedContent = selectedLines.join("\n");
323
- range = { startLine, endLine, totalLines };
324
- }
285
+ const outputs: OutputEntry[] = [];
286
+ const notFound: string[] = [];
287
+ const outputContentById = new Map<string, string>();
288
+ const query = params.query?.trim();
289
+ const wantsQuery = query !== undefined && query.length > 0;
290
+ const format = params.format ?? (wantsQuery ? "json" : "raw");
325
291
 
326
- outputContentById.set(id, selectedContent);
327
- outputs.push({
328
- id,
329
- path: outputPath,
330
- lineCount: wantsQuery ? selectedContent.split("\n").length : totalLines,
331
- charCount: wantsQuery ? selectedContent.length : totalChars,
332
- provenance: parseOutputProvenance(id),
333
- previewLines: extractPreviewLines(selectedContent, 4),
334
- range,
335
- query: query,
336
- });
337
- }
292
+ if (wantsQuery && (params.offset !== undefined || params.limit !== undefined)) {
293
+ throw new Error("query cannot be combined with offset/limit");
294
+ }
338
295
 
339
- // Error case: some IDs not found
340
- if (notFound.length > 0) {
341
- const available = listAvailableOutputs(artifactsDir);
342
- const errorMsg =
343
- available.length > 0
344
- ? `Not found: ${notFound.join(", ")}\nAvailable: ${available.join(", ")}`
345
- : `Not found: ${notFound.join(", ")}\nNo outputs available in current session`;
346
-
347
- return {
348
- content: [{ type: "text", text: errorMsg }],
349
- details: { outputs, notFound, availableIds: available },
350
- };
296
+ const queryResults: Array<{ id: string; value: unknown }> = [];
297
+
298
+ for (const id of params.ids) {
299
+ const outputPath = path.join(artifactsDir, `${id}.out.md`);
300
+
301
+ if (!fs.existsSync(outputPath)) {
302
+ notFound.push(id);
303
+ continue;
351
304
  }
352
305
 
353
- // Success: build response based on format
354
- let contentText: string;
355
-
356
- if (format === "json") {
357
- const jsonData = wantsQuery
358
- ? queryResults
359
- : outputs.map((o) => ({
360
- id: o.id,
361
- lineCount: o.lineCount,
362
- charCount: o.charCount,
363
- provenance: o.provenance,
364
- previewLines: o.previewLines,
365
- range: o.range,
366
- content: outputContentById.get(o.id) ?? "",
367
- }));
368
- contentText = JSON.stringify(jsonData, null, 2);
369
- } else {
370
- // raw or stripped
371
- const parts = outputs.map((o) => {
372
- let content = outputContentById.get(o.id) ?? "";
373
- if (format === "stripped") {
374
- content = stripAnsi(content);
375
- }
376
- if (o.range && o.range.endLine < o.range.totalLines) {
377
- const nextOffset = o.range.endLine + 1;
378
- content += `\n\n[Showing lines ${o.range.startLine}-${o.range.endLine} of ${o.range.totalLines}. Use offset=${nextOffset} to continue]`;
379
- }
380
- // Add header for multiple outputs
381
- if (outputs.length > 1) {
382
- return `=== ${o.id} (${o.lineCount} lines, ${formatBytes(o.charCount)}) ===\n${content}`;
383
- }
384
- return content;
385
- });
386
- contentText = parts.join("\n\n");
306
+ const rawContent = fs.readFileSync(outputPath, "utf-8");
307
+ const rawLines = rawContent.split("\n");
308
+ const totalLines = rawLines.length;
309
+ const totalChars = rawContent.length;
310
+
311
+ let selectedContent = rawContent;
312
+ let range: OutputRange | undefined;
313
+
314
+ if (wantsQuery && query) {
315
+ let jsonValue: unknown;
316
+ try {
317
+ jsonValue = JSON.parse(rawContent);
318
+ } catch (err) {
319
+ const message = err instanceof Error ? err.message : String(err);
320
+ throw new Error(`Output ${id} is not valid JSON: ${message}`);
321
+ }
322
+ const value = applyQuery(jsonValue, query);
323
+ queryResults.push({ id, value });
324
+ try {
325
+ selectedContent = JSON.stringify(value, null, 2) ?? "null";
326
+ } catch {
327
+ selectedContent = String(value);
328
+ }
329
+ } else if (params.offset !== undefined || params.limit !== undefined) {
330
+ const startLine = Math.max(1, params.offset ?? 1);
331
+ if (startLine > totalLines) {
332
+ throw new Error(
333
+ `Offset ${params.offset ?? startLine} is beyond end of output (${totalLines} lines) for ${id}`,
334
+ );
335
+ }
336
+ const effectiveLimit = params.limit ?? totalLines - startLine + 1;
337
+ const endLine = Math.min(totalLines, startLine + effectiveLimit - 1);
338
+ const selectedLines = rawLines.slice(startLine - 1, endLine);
339
+ selectedContent = selectedLines.join("\n");
340
+ range = { startLine, endLine, totalLines };
387
341
  }
388
342
 
343
+ outputContentById.set(id, selectedContent);
344
+ outputs.push({
345
+ id,
346
+ path: outputPath,
347
+ lineCount: wantsQuery ? selectedContent.split("\n").length : totalLines,
348
+ charCount: wantsQuery ? selectedContent.length : totalChars,
349
+ provenance: parseOutputProvenance(id),
350
+ previewLines: extractPreviewLines(selectedContent, 4),
351
+ range,
352
+ query: query,
353
+ });
354
+ }
355
+
356
+ // Error case: some IDs not found
357
+ if (notFound.length > 0) {
358
+ const available = listAvailableOutputs(artifactsDir);
359
+ const errorMsg =
360
+ available.length > 0
361
+ ? `Not found: ${notFound.join(", ")}\nAvailable: ${available.join(", ")}`
362
+ : `Not found: ${notFound.join(", ")}\nNo outputs available in current session`;
363
+
389
364
  return {
390
- content: [{ type: "text", text: contentText }],
391
- details: { outputs },
365
+ content: [{ type: "text", text: errorMsg }],
366
+ details: { outputs, notFound, availableIds: available },
392
367
  };
393
- },
394
- };
368
+ }
369
+
370
+ // Success: build response based on format
371
+ let contentText: string;
372
+
373
+ if (format === "json") {
374
+ const jsonData = wantsQuery
375
+ ? queryResults
376
+ : outputs.map((o) => ({
377
+ id: o.id,
378
+ lineCount: o.lineCount,
379
+ charCount: o.charCount,
380
+ provenance: o.provenance,
381
+ previewLines: o.previewLines,
382
+ range: o.range,
383
+ content: outputContentById.get(o.id) ?? "",
384
+ }));
385
+ contentText = JSON.stringify(jsonData, null, 2);
386
+ } else {
387
+ // raw or stripped
388
+ const parts = outputs.map((o) => {
389
+ let content = outputContentById.get(o.id) ?? "";
390
+ if (format === "stripped") {
391
+ content = stripAnsi(content);
392
+ }
393
+ if (o.range && o.range.endLine < o.range.totalLines) {
394
+ const nextOffset = o.range.endLine + 1;
395
+ content += `\n\n[Showing lines ${o.range.startLine}-${o.range.endLine} of ${o.range.totalLines}. Use offset=${nextOffset} to continue]`;
396
+ }
397
+ // Add header for multiple outputs
398
+ if (outputs.length > 1) {
399
+ return `=== ${o.id} (${o.lineCount} lines, ${formatBytes(o.charCount)}) ===\n${content}`;
400
+ }
401
+ return content;
402
+ });
403
+ contentText = parts.join("\n\n");
404
+ }
405
+
406
+ return {
407
+ content: [{ type: "text", text: contentText }],
408
+ details: { outputs },
409
+ };
410
+ }
395
411
  }
396
412
 
397
413
  // =============================================================================