minimal-agent 0.11.0 → 0.13.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 (181) hide show
  1. package/package.json +5 -1
  2. package/plugins/workflow-runner/plugin.js +27 -15
  3. package/plugins/workflow-runner/plugin.ts +34 -20
  4. package/plugins/workflow-runner/src/agentCommon.js +13 -12
  5. package/plugins/workflow-runner/src/agentCommon.ts +22 -13
  6. package/plugins/workflow-runner/src/expressions.js +29 -8
  7. package/plugins/workflow-runner/src/expressions.ts +28 -10
  8. package/plugins/workflow-runner/src/index.js +50 -20
  9. package/plugins/workflow-runner/src/index.ts +75 -31
  10. package/plugins/workflow-runner/src/multimodal.js +2 -1
  11. package/plugins/workflow-runner/src/multimodal.ts +2 -1
  12. package/plugins/workflow-runner/src/recovery.js +9 -2
  13. package/plugins/workflow-runner/src/recovery.ts +9 -2
  14. package/plugins/workflow-runner/src/runner.js +31 -5
  15. package/plugins/workflow-runner/src/runner.ts +33 -5
  16. package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -1
  17. package/plugins/workflow-runner/src/stepExecutors/assert.ts +3 -1
  18. package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
  19. package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
  20. package/plugins/workflow-runner/src/stepExecutors/tool.js +91 -61
  21. package/plugins/workflow-runner/src/stepExecutors/tool.ts +111 -71
  22. package/plugins/workflow-runner/src/stepExecutors/verify.js +6 -3
  23. package/plugins/workflow-runner/src/stepExecutors/verify.ts +7 -3
  24. package/plugins/workflow-runner/src/taskIdentity.js +197 -21
  25. package/plugins/workflow-runner/src/taskIdentity.ts +201 -20
  26. package/plugins/workflow-runner/src/toolRuntime.js +48 -0
  27. package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
  28. package/plugins/workflow-runner/src/types.js +113 -7
  29. package/plugins/workflow-runner/src/types.ts +137 -14
  30. package/plugins/workflow-runner/src/workflowState.js +455 -54
  31. package/plugins/workflow-runner/src/workflowState.ts +508 -63
  32. package/plugins/workflow-runner/test/cli.e2e.test.ts +20 -0
  33. package/plugins/workflow-runner/test/context-files.test.ts +47 -16
  34. package/plugins/workflow-runner/test/e2eNewLayout.test.ts +314 -0
  35. package/plugins/workflow-runner/test/engineRobustness.test.ts +9 -1
  36. package/plugins/workflow-runner/test/expressions.test.ts +42 -0
  37. package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
  38. package/plugins/workflow-runner/test/illustrate.load.test.ts +2 -2
  39. package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
  40. package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +19 -0
  41. package/plugins/workflow-runner/test/ppt.load.test.ts +15 -15
  42. package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +3 -2
  43. package/plugins/workflow-runner/test/research.load.test.ts +328 -0
  44. package/plugins/workflow-runner/test/runnerPreflight.test.ts +68 -11
  45. package/plugins/workflow-runner/test/stateLifecycle.test.ts +60 -12
  46. package/plugins/workflow-runner/test/taskArbitration.test.ts +19 -3
  47. package/plugins/workflow-runner/test/taskLifecycle.test.ts +61 -7
  48. package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
  49. package/plugins/workflow-runner/test/webppt.load.test.ts +4 -4
  50. package/plugins/workflow-runner/test/word.load.test.ts +9 -9
  51. package/skills/skill-creator/scripts/run_eval.py +3 -2
  52. package/src/cli/print.js +36 -4
  53. package/src/cli/print.ts +42 -4
  54. package/src/cli/streamJson.js +19 -4
  55. package/src/cli/streamJson.ts +17 -4
  56. package/src/config/configDefaults.js +25 -0
  57. package/src/config/configDefaults.ts +33 -0
  58. package/src/config/configFile.js +2 -21
  59. package/src/config/configFile.ts +11 -24
  60. package/src/config/configManagement.js +1 -1
  61. package/src/config/configManagement.ts +2 -2
  62. package/src/config/configService.js +24 -0
  63. package/src/config/configService.ts +23 -0
  64. package/src/context/compact.js +4 -0
  65. package/src/context/compact.ts +4 -0
  66. package/src/context/contextBundle.js +38 -13
  67. package/src/context/contextBundle.ts +47 -13
  68. package/src/harness/stallGuard.js +19 -4
  69. package/src/harness/stallGuard.ts +30 -7
  70. package/src/infra/verification.js +25 -18
  71. package/src/infra/verification.ts +50 -18
  72. package/src/loop.js +95 -11
  73. package/src/loop.ts +125 -16
  74. package/src/main.js +8 -1
  75. package/src/main.tsx +7 -1
  76. package/src/memory/constants.js +10 -0
  77. package/src/memory/constants.ts +13 -0
  78. package/src/memory/episodicMemory.js +274 -2
  79. package/src/memory/episodicMemory.ts +306 -1
  80. package/src/memory/fileStore.js +24 -4
  81. package/src/memory/fileStore.ts +22 -2
  82. package/src/memory/memoryService.js +79 -12
  83. package/src/memory/memoryService.ts +108 -9
  84. package/src/memory/repository.js +415 -85
  85. package/src/memory/repository.ts +437 -86
  86. package/src/memory/turnJournal.js +95 -0
  87. package/src/memory/turnJournal.ts +120 -0
  88. package/src/memory/types.ts +16 -0
  89. package/src/plugins/pluginApi.ts +30 -6
  90. package/src/plugins/pluginRunner.js +23 -8
  91. package/src/plugins/pluginRunner.ts +32 -13
  92. package/src/prompts/policyRegistry.js +55 -1
  93. package/src/prompts/policyRegistry.ts +55 -1
  94. package/src/prompts/promptAssembly.js +39 -7
  95. package/src/prompts/promptAssembly.ts +145 -59
  96. package/src/session/runAgentTurn.js +43 -21
  97. package/src/session/runAgentTurn.ts +68 -26
  98. package/src/session/runTurn.js +11 -46
  99. package/src/session/runTurn.ts +12 -46
  100. package/src/session/runtimeStore.js +149 -0
  101. package/src/session/runtimeStore.ts +197 -0
  102. package/src/session/sessionState.js +5 -0
  103. package/src/session/sessionState.ts +14 -0
  104. package/src/tools/advisor/advisor.js +76 -9
  105. package/src/tools/advisor/advisor.ts +79 -9
  106. package/src/tools/agent/agent.js +143 -14
  107. package/src/tools/agent/agent.ts +149 -14
  108. package/src/tools/agent/agentTaskService.js +60 -0
  109. package/src/tools/agent/agentTaskService.ts +113 -0
  110. package/src/tools/agent/agentTypes.js +5 -0
  111. package/src/tools/agent/agentTypes.ts +5 -0
  112. package/src/tools/bash/bash.js +80 -15
  113. package/src/tools/bash/bash.ts +83 -15
  114. package/src/tools/capabilities.js +5 -0
  115. package/src/tools/capabilities.ts +11 -1
  116. package/src/tools/edit/edit.js +7 -4
  117. package/src/tools/edit/edit.ts +7 -4
  118. package/src/tools/edit/multi-edit.js +4 -2
  119. package/src/tools/edit/multi-edit.ts +4 -2
  120. package/src/tools/glob/glob.js +156 -40
  121. package/src/tools/glob/glob.ts +185 -46
  122. package/src/tools/index.js +35 -17
  123. package/src/tools/index.ts +81 -20
  124. package/src/tools/lsp/lsp.js +165 -22
  125. package/src/tools/lsp/lsp.ts +206 -36
  126. package/src/tools/powershell/powershell.js +134 -27
  127. package/src/tools/powershell/powershell.ts +171 -30
  128. package/src/tools/presentresult/presentresult.js +10 -0
  129. package/src/tools/presentresult/presentresult.ts +10 -0
  130. package/src/tools/read/read.js +249 -29
  131. package/src/tools/read/read.ts +396 -59
  132. package/src/tools/shared/diffSummary.js +44 -0
  133. package/src/tools/shared/diffSummary.ts +63 -0
  134. package/src/tools/shared/fileActivity.js +2 -1
  135. package/src/tools/shared/fileActivity.ts +3 -0
  136. package/src/tools/shared/permanentDeleteGuard.js +57 -0
  137. package/src/tools/shared/permanentDeleteGuard.ts +71 -0
  138. package/src/tools/shared/toolOutputStore.js +97 -0
  139. package/src/tools/shared/toolOutputStore.ts +110 -0
  140. package/src/tools/skill/skill.js +86 -0
  141. package/src/tools/skill/skill.ts +94 -0
  142. package/src/tools/snip/snip.js +67 -0
  143. package/src/tools/snip/snip.ts +80 -0
  144. package/src/tools/tasks/backgroundTaskService.js +79 -28
  145. package/src/tools/tasks/backgroundTaskService.ts +165 -49
  146. package/src/tools/tasks/taskManagement.js +163 -0
  147. package/src/tools/tasks/taskManagement.ts +180 -0
  148. package/src/tools/tasks/taskOutput.js +64 -14
  149. package/src/tools/tasks/taskOutput.ts +102 -23
  150. package/src/tools/tasks/taskStop.js +48 -8
  151. package/src/tools/tasks/taskStop.ts +69 -9
  152. package/src/tools/tasks/taskStore.js +46 -0
  153. package/src/tools/tasks/taskStore.ts +77 -0
  154. package/src/tools/terminal/terminalCapture.js +167 -0
  155. package/src/tools/terminal/terminalCapture.ts +179 -0
  156. package/src/tools/trash/trash.js +233 -0
  157. package/src/tools/trash/trash.ts +283 -0
  158. package/src/tools/webbrowser/browserControl.js +5 -1
  159. package/src/tools/webbrowser/browserControl.ts +7 -1
  160. package/src/tools/webbrowser/webbrowser.js +8 -4
  161. package/src/tools/webbrowser/webbrowser.ts +8 -4
  162. package/src/tools/worktree/sessionWorktree.js +128 -0
  163. package/src/tools/worktree/sessionWorktree.ts +158 -0
  164. package/src/tools/write/write.js +6 -2
  165. package/src/tools/write/write.ts +6 -2
  166. package/src/types.ts +22 -4
  167. package/src/ui/hooks/useChat.js +31 -11
  168. package/src/ui/hooks/useChat.ts +31 -13
  169. package/src/web/protocol.ts +78 -3
  170. package/workflows/adaptive-research.yaml +5 -5
  171. package/workflows/book-review-short.yaml +5 -5
  172. package/workflows/e2e-write-greet.yaml +2 -2
  173. package/workflows/illustrate.yaml +12 -12
  174. package/workflows/plan.yaml +18 -18
  175. package/workflows/ppt.yaml +201 -201
  176. package/workflows/ralph-loop.yaml +3 -1
  177. package/workflows/research.md +449 -0
  178. package/workflows/research.yaml +873 -0
  179. package/workflows/webppt.yaml +47 -47
  180. package/workflows/word.yaml +80 -80
  181. package/workflows/youtube-shorts.yaml +25 -25
@@ -1,14 +1,27 @@
1
1
  import { createReadStream } from 'node:fs';
2
- import { open, readFile, stat } from 'node:fs/promises';
2
+ import { open, readFile, stat, writeFile } from 'node:fs/promises';
3
3
  import { extname } from 'node:path';
4
4
  import { createInterface } from 'node:readline';
5
+ import { execFile } from 'node:child_process';
6
+ import { mkdir } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { promisify } from 'node:util';
9
+ import { randomUUID } from 'node:crypto';
5
10
  import { z } from 'zod';
6
11
 
7
12
  import type { Tool, ToolCallContext, ToolResult } from '../../types.js';
8
13
  import { toToolParameters } from '../../utils/zodToJson.js';
9
- import { DEFAULT_MAX_RESULT_SIZE_CHARS, MAX_FILE_SIZE_BYTES, MAX_LINES_TO_READ } from '../types.js';
14
+ import {
15
+ DEFAULT_MAX_RESULT_SIZE_CHARS,
16
+ MAX_FILE_SIZE_BYTES,
17
+ MAX_LINES_TO_READ,
18
+ } from '../types.js';
10
19
  import { fileActivityMetadata } from '../shared/fileActivity.js';
11
- import { hashFileBytes, recordRead, type FileReadState } from '../shared/fileState.js';
20
+ import {
21
+ hashFileBytes,
22
+ recordRead,
23
+ type FileReadState,
24
+ } from '../shared/fileState.js';
12
25
  import {
13
26
  hasBinaryExtension,
14
27
  isBlockedDevicePath,
@@ -16,19 +29,48 @@ import {
16
29
  resolveToolPath,
17
30
  } from '../shared/fileUtils.js';
18
31
  import { filePathField } from '../shared/schemas.js';
32
+ import {
33
+ pruneRuntimeOutputs,
34
+ sessionRuntimeDir,
35
+ type RuntimeIdentity,
36
+ } from '../../session/runtimeStore.js';
19
37
 
20
38
  const inputSchema = z.object({
21
39
  file_path: filePathField('读取'),
22
- offset: z.number().int().positive().optional().describe(
23
- '文本文件为 1-indexed 起始行;Jupyter Notebook 为 1-indexed 起始 cell',
24
- ),
25
- limit: z.number().int().positive().optional().describe(
26
- `最多读取的文本行或 Notebook cells;默认 ${MAX_LINES_TO_READ}`,
27
- ),
40
+ offset: z
41
+ .number()
42
+ .int()
43
+ .positive()
44
+ .optional()
45
+ .describe(
46
+ '文本文件为 1-indexed 起始行;Jupyter Notebook 为 1-indexed 起始 cell',
47
+ ),
48
+ limit: z
49
+ .number()
50
+ .int()
51
+ .positive()
52
+ .optional()
53
+ .describe(`最多读取的文本行或 Notebook cells;默认 ${MAX_LINES_TO_READ}`),
54
+ pages: z
55
+ .string()
56
+ .regex(/^\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$/)
57
+ .optional()
58
+ .describe('PDF 页码,例如 "1-5,8";每次最多 20 页'),
59
+ pdf_mode: z
60
+ .enum(['auto', 'text', 'visual', 'both'])
61
+ .default('auto')
62
+ .optional(),
28
63
  });
29
64
  type ReadInput = z.infer<typeof inputSchema>;
30
65
 
31
- const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
66
+ const IMAGE_EXTENSIONS = new Set([
67
+ '.png',
68
+ '.jpg',
69
+ '.jpeg',
70
+ '.gif',
71
+ '.webp',
72
+ '.bmp',
73
+ ]);
32
74
  const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
33
75
  const MAX_NOTEBOOK_BYTES = 32 * 1024 * 1024;
34
76
  const SMALL_TEXT_BYTES = 16 * 1024 * 1024;
@@ -38,36 +80,55 @@ const description = `Reads local text files, images, and Jupyter notebooks.
38
80
  Usage:
39
81
  - Relative paths resolve against the current working directory.
40
82
  - Text results use 1-based line numbers. Use offset/limit for targeted ranges.
41
- - PNG, JPEG, WebP, GIF, BMP, and TIFF files are attached for visual inspection.
83
+ - PNG, JPEG, WebP, GIF, and BMP files are attached for visual inspection.
42
84
  - For .ipynb files, offset/limit select cells; results include cell id, type, source, and bounded outputs.
43
- - PDF and office files are not decoded by this tool. Use the matching project Skill for those formats.
85
+ - PDF supports page-selected text extraction and optional visual page rendering; office files use the matching project Skill.
44
86
  - This tool reads files, not directories; use Glob for directory discovery.`;
45
87
 
46
88
  function notebookSource(value: unknown): string {
47
- if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string').join('');
89
+ if (Array.isArray(value))
90
+ return value
91
+ .filter((item): item is string => typeof item === 'string')
92
+ .join('');
48
93
  return typeof value === 'string' ? value : '';
49
94
  }
50
95
 
51
- function renderNotebook(raw: string, offset: number, limit: number): {
96
+ function renderNotebook(
97
+ raw: string,
98
+ offset: number,
99
+ limit: number,
100
+ ): {
52
101
  content: string;
53
102
  total: number;
54
103
  partial: boolean;
55
104
  } {
56
- const parsed = JSON.parse(raw) as { cells?: unknown[]; metadata?: Record<string, unknown> };
105
+ const parsed = JSON.parse(raw) as {
106
+ cells?: unknown[];
107
+ metadata?: Record<string, unknown>;
108
+ };
57
109
  if (!Array.isArray(parsed.cells)) throw new Error('Notebook 缺少 cells 数组');
58
110
  const start = offset - 1;
59
111
  const cells = parsed.cells.slice(start, start + limit);
60
112
  const parts = cells.map((value, index) => {
61
- const cell = value && typeof value === 'object' ? value as Record<string, unknown> : {};
113
+ const cell =
114
+ value && typeof value === 'object'
115
+ ? (value as Record<string, unknown>)
116
+ : {};
62
117
  const outputs = Array.isArray(cell.outputs) ? cell.outputs : [];
63
- const renderedOutputs = outputs.slice(0, 8).map((output) => {
64
- if (!output || typeof output !== 'object') return String(output ?? '');
65
- const item = output as Record<string, unknown>;
66
- const text = item.text ?? (item.data && typeof item.data === 'object'
67
- ? (item.data as Record<string, unknown>)['text/plain']
68
- : undefined);
69
- return notebookSource(text).slice(0, 2_000);
70
- }).filter(Boolean).join('\n');
118
+ const renderedOutputs = outputs
119
+ .slice(0, 8)
120
+ .map((output) => {
121
+ if (!output || typeof output !== 'object') return String(output ?? '');
122
+ const item = output as Record<string, unknown>;
123
+ const text =
124
+ item.text ??
125
+ (item.data && typeof item.data === 'object'
126
+ ? (item.data as Record<string, unknown>)['text/plain']
127
+ : undefined);
128
+ return notebookSource(text).slice(0, 2_000);
129
+ })
130
+ .filter(Boolean)
131
+ .join('\n');
71
132
  return [
72
133
  `## Cell ${start + index + 1} [selector=cell-${start + index + 1}${typeof cell.id === 'string' ? `; id=${cell.id}` : ''}; type=${String(cell.cell_type ?? 'unknown')}]`,
73
134
  notebookSource(cell.source),
@@ -75,8 +136,15 @@ function renderNotebook(raw: string, offset: number, limit: number): {
75
136
  ].join('\n');
76
137
  });
77
138
  const end = start + cells.length;
78
- if (end < parsed.cells.length) parts.push(`... (${parsed.cells.length - end} cells remain; continue with offset=${end + 1})`);
79
- return { content: parts.join('\n\n'), total: parsed.cells.length, partial: start > 0 || end < parsed.cells.length };
139
+ if (end < parsed.cells.length)
140
+ parts.push(
141
+ `... (${parsed.cells.length - end} cells remain; continue with offset=${end + 1})`,
142
+ );
143
+ return {
144
+ content: parts.join('\n\n'),
145
+ total: parsed.cells.length,
146
+ partial: start > 0 || end < parsed.cells.length,
147
+ };
80
148
  }
81
149
 
82
150
  async function readStreamedText(
@@ -91,7 +159,14 @@ async function readStreamedText(
91
159
  const utf16 = bytesRead >= 2 && prefix[0] === 0xff && prefix[1] === 0xfe;
92
160
  const stream = createReadStream(filePath, {
93
161
  encoding: utf16 ? 'utf16le' : 'utf8',
94
- start: utf16 ? 2 : bytesRead >= 3 && prefix[0] === 0xef && prefix[1] === 0xbb && prefix[2] === 0xbf ? 3 : 0,
162
+ start: utf16
163
+ ? 2
164
+ : bytesRead >= 3 &&
165
+ prefix[0] === 0xef &&
166
+ prefix[1] === 0xbb &&
167
+ prefix[2] === 0xbf
168
+ ? 3
169
+ : 0,
95
170
  });
96
171
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
97
172
  const lines: string[] = [];
@@ -110,61 +185,198 @@ async function readStreamedText(
110
185
  return { numbered: lines.join('\n'), totalKnown: line, hasMore };
111
186
  }
112
187
 
113
- async function call(input: ReadInput, state?: FileReadState, cwd?: string): Promise<ToolResult> {
188
+ async function call(
189
+ input: ReadInput,
190
+ state?: FileReadState,
191
+ cwd?: string,
192
+ runtimeIdentity?: RuntimeIdentity,
193
+ ): Promise<ToolResult> {
114
194
  const offset = input.offset ?? 1;
115
195
  const limit = input.limit ?? MAX_LINES_TO_READ;
116
196
  const resolved = resolveToolPath(input.file_path, cwd);
117
197
  if (!resolved.ok) return resolved;
118
198
  const filePath = resolved.resolvedPath;
119
- if (isBlockedDevicePath(filePath)) return { ok: false, error: `不允许读取设备文件:${filePath}` };
199
+ if (isBlockedDevicePath(filePath))
200
+ return { ok: false, error: `不允许读取设备文件:${filePath}` };
120
201
 
121
202
  const info = await stat(filePath).catch((error: Error) => ({ error }));
122
- if ('error' in info) return { ok: false, error: `文件不存在或无法读取:${filePath}(${info.error.message})` };
123
- if (!info.isFile()) return { ok: false, error: `${filePath} 不是文件;目录请用 Glob。` };
203
+ if ('error' in info)
204
+ return {
205
+ ok: false,
206
+ error: `文件不存在或无法读取:${filePath}(${info.error.message})`,
207
+ };
208
+ if (!info.isFile())
209
+ return { ok: false, error: `${filePath} 不是文件;目录请用 Glob。` };
124
210
  const ext = extname(filePath).toLowerCase();
125
211
 
212
+ if (ext === '.pdf') {
213
+ try {
214
+ const bytes = await readFile(filePath);
215
+ if (bytes.subarray(0, 5).toString() !== '%PDF-') {
216
+ return {
217
+ ok: false,
218
+ error: '文件扩展名是 PDF,但缺少有效 %PDF- 文件头',
219
+ };
220
+ }
221
+ const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
222
+ // ⚠️ 释放要调**加载任务**的 destroy,不是文档代理的:pdfjs v6 的
223
+ // PDFDocumentProxy 上没有 destroy(只有 cleanup),写成 document.destroy()
224
+ // 会在 finally 里抛 TypeError,被外层 catch 成「PDF 解析失败」——
225
+ // 于是每一次 PDF 读取都失败,且错误文案完全指不向真因。
226
+ const loadingTask = pdfjs.getDocument({
227
+ data: new Uint8Array(bytes),
228
+ useSystemFonts: true,
229
+ });
230
+ const document = await loadingTask.promise;
231
+ try {
232
+ const requested = parsePdfPages(input.pages, document.numPages);
233
+ if (requested.length > 20)
234
+ return { ok: false, error: '一次最多读取 20 个 PDF 页面' };
235
+ const chunks: string[] = [];
236
+ for (const pageNumber of requested) {
237
+ const page = await document.getPage(pageNumber);
238
+ const text = await page.getTextContent();
239
+ chunks.push(
240
+ `## Page ${pageNumber}\n${text.items
241
+ .map((item) =>
242
+ 'str' in item && typeof item.str === 'string' ? item.str : '',
243
+ )
244
+ .filter(Boolean)
245
+ .join(' ')}`,
246
+ );
247
+ }
248
+ const mode = input.pdf_mode ?? 'auto';
249
+ const sparse =
250
+ chunks.join('').replace(/\s/g, '').length < requested.length * 80;
251
+ const wantVisual =
252
+ mode === 'visual' || mode === 'both' || (mode === 'auto' && sparse);
253
+ const images = wantVisual
254
+ ? await renderPdfPages(
255
+ document,
256
+ filePath,
257
+ requested.slice(0, 10),
258
+ runtimeIdentity,
259
+ )
260
+ : [];
261
+ recordRead(filePath, { contentHash: hashFileBytes(bytes) }, state);
262
+ const rawText =
263
+ mode === 'visual' ? '[visual-only mode]' : chunks.join('\n\n');
264
+ const text =
265
+ rawText.length > DEFAULT_MAX_RESULT_SIZE_CHARS
266
+ ? `${rawText.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS)}\n... (PDF 文本输出已截断;请缩小 pages 范围继续读取)`
267
+ : rawText;
268
+ return {
269
+ ok: true,
270
+ content: `PDF ${document.numPages} pages; selected ${requested.join(', ')}.\n\n${text}${wantVisual && !images.length ? '\n\n视觉渲染不可用(Canvas 与 pdftoppm 均不可用);文本提取结果仍可用。' : ''}`,
271
+ metadata: await fileActivityMetadata(
272
+ filePath,
273
+ 'read',
274
+ 'not_run',
275
+ 'source',
276
+ cwd,
277
+ ),
278
+ ...(images.length
279
+ ? {
280
+ visionImages: images.map((source) => ({ source })),
281
+ visionNote:
282
+ '这是所选 PDF 页面的视觉渲染,请检查版式、图表和扫描内容。',
283
+ }
284
+ : {}),
285
+ };
286
+ } finally {
287
+ await loadingTask.destroy().catch(() => undefined);
288
+ }
289
+ } catch (error) {
290
+ const message = (error as Error).message;
291
+ return {
292
+ ok: false,
293
+ error: `PDF 解析失败${/password/i.test(message) ? '(文件可能已加密)' : ''}:${message}`,
294
+ };
295
+ }
296
+ }
297
+
126
298
  if (IMAGE_EXTENSIONS.has(ext)) {
127
- if (info.size > MAX_IMAGE_BYTES) return { ok: false, error: `图片超过 ${MAX_IMAGE_BYTES} 字节上限` };
299
+ if (info.size > MAX_IMAGE_BYTES)
300
+ return { ok: false, error: `图片超过 ${MAX_IMAGE_BYTES} 字节上限` };
128
301
  const bytes = await readFile(filePath);
129
302
  recordRead(filePath, { contentHash: hashFileBytes(bytes) }, state);
130
303
  return {
131
304
  ok: true,
132
305
  content: `已读取图片:${filePath}(${info.size} 字节)。图像已附加供视觉模型检查;非视觉模型只能确认文件元数据,不能解读像素内容。`,
133
- metadata: await fileActivityMetadata(filePath, 'read', 'not_run', 'source', cwd),
306
+ metadata: await fileActivityMetadata(
307
+ filePath,
308
+ 'read',
309
+ 'not_run',
310
+ 'source',
311
+ cwd,
312
+ ),
134
313
  visionImages: [{ source: filePath }],
135
314
  visionNote: '这是 Read 工具读取的本地图片,请直接检查图像内容。',
136
315
  };
137
316
  }
138
317
 
139
318
  if (ext === '.ipynb') {
140
- if (info.size > MAX_NOTEBOOK_BYTES) return { ok: false, error: `Notebook 超过 ${MAX_NOTEBOOK_BYTES} 字节上限` };
319
+ if (info.size > MAX_NOTEBOOK_BYTES)
320
+ return {
321
+ ok: false,
322
+ error: `Notebook 超过 ${MAX_NOTEBOOK_BYTES} 字节上限`,
323
+ };
141
324
  try {
142
325
  const snapshot = await readTextFile(filePath);
143
- const rendered = renderNotebook(snapshot.content, offset, Math.min(limit, 200));
326
+ const rendered = renderNotebook(
327
+ snapshot.content,
328
+ offset,
329
+ Math.min(limit, 200),
330
+ );
144
331
  let content = rendered.content;
145
332
  let truncated = false;
146
333
  if (content.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
147
334
  content = `${content.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS)}\n\n... (Notebook 输出已截断)`;
148
335
  truncated = true;
149
336
  }
150
- recordRead(filePath, {
151
- offset,
152
- limit,
153
- partial: rendered.partial,
154
- truncated,
155
- ...(!rendered.partial && !truncated ? { contentHash: hashFileBytes(snapshot.bytes) } : {}),
156
- }, state);
157
- return { ok: true, content, metadata: await fileActivityMetadata(filePath, 'read', 'not_run', 'source', cwd) };
337
+ recordRead(
338
+ filePath,
339
+ {
340
+ offset,
341
+ limit,
342
+ partial: rendered.partial,
343
+ truncated,
344
+ ...(!rendered.partial && !truncated
345
+ ? { contentHash: hashFileBytes(snapshot.bytes) }
346
+ : {}),
347
+ },
348
+ state,
349
+ );
350
+ return {
351
+ ok: true,
352
+ content,
353
+ metadata: await fileActivityMetadata(
354
+ filePath,
355
+ 'read',
356
+ 'not_run',
357
+ 'source',
358
+ cwd,
359
+ ),
360
+ };
158
361
  } catch (error) {
159
- return { ok: false, error: `Notebook 解析失败:${(error as Error).message}` };
362
+ return {
363
+ ok: false,
364
+ error: `Notebook 解析失败:${(error as Error).message}`,
365
+ };
160
366
  }
161
367
  }
162
368
 
163
369
  if (hasBinaryExtension(filePath)) {
164
- return { ok: false, error: `不支持直接读取 ${ext || '该二进制格式'};PDF/Office 文件请使用对应 Skill。` };
370
+ return {
371
+ ok: false,
372
+ error: `不支持直接读取 ${ext || '该二进制格式'};PDF/Office 文件请使用对应 Skill。`,
373
+ };
165
374
  }
166
375
  if (info.size > MAX_FILE_SIZE_BYTES) {
167
- return { ok: false, error: `文件超过 ${MAX_FILE_SIZE_BYTES} 字节硬上限。请用 Grep 定位,或用 Bash 做流式处理。` };
376
+ return {
377
+ ok: false,
378
+ error: `文件超过 ${MAX_FILE_SIZE_BYTES} 字节硬上限。请用 Grep 定位,或用 Bash 做流式处理。`,
379
+ };
168
380
  }
169
381
 
170
382
  try {
@@ -175,10 +387,13 @@ async function call(input: ReadInput, state?: FileReadState, cwd?: string): Prom
175
387
  if (info.size <= SMALL_TEXT_BYTES) {
176
388
  const snapshot = await readTextFile(filePath);
177
389
  completeBytes = snapshot.bytes;
178
- const all = snapshot.content.length === 0 ? [] : snapshot.content.split('\n');
390
+ const all =
391
+ snapshot.content.length === 0 ? [] : snapshot.content.split('\n');
179
392
  const start = offset - 1;
180
393
  const selected = all.slice(start, start + limit);
181
- content = selected.map((line, index) => `${start + index + 1}\t${line}`).join('\n');
394
+ content = selected
395
+ .map((line, index) => `${start + index + 1}\t${line}`)
396
+ .join('\n');
182
397
  totalLines = all.length;
183
398
  hasMore = start + selected.length < all.length;
184
399
  } else {
@@ -194,24 +409,145 @@ async function call(input: ReadInput, state?: FileReadState, cwd?: string): Prom
194
409
  truncated = true;
195
410
  }
196
411
  const partial = offset !== 1 || hasMore;
197
- if (hasMore) content += `\n\n... (还有更多内容;继续使用 offset=${offset + limit})`;
198
- const reads = recordRead(filePath, {
199
- offset,
200
- limit,
201
- partial,
202
- truncated,
203
- ...(!partial && !truncated && completeBytes ? { contentHash: hashFileBytes(completeBytes) } : {}),
204
- }, state);
412
+ if (hasMore)
413
+ content += `\n\n... (还有更多内容;继续使用 offset=${offset + limit})`;
414
+ const reads = recordRead(
415
+ filePath,
416
+ {
417
+ offset,
418
+ limit,
419
+ partial,
420
+ truncated,
421
+ ...(!partial && !truncated && completeBytes
422
+ ? { contentHash: hashFileBytes(completeBytes) }
423
+ : {}),
424
+ },
425
+ state,
426
+ );
205
427
  if (info.size > 100 * 1024 && offset === 1) {
206
428
  content = `⚠️ 大文件:结果受行数和输出字符上限约束,请按需分段读取。\n\n${content}`;
207
429
  }
208
- if (reads >= 2) content = `⚠️ 重复读取空转:该区间未变化,前一次 Read 结果仍然有效。\n\n${content}`;
209
- return { ok: true, content, metadata: await fileActivityMetadata(filePath, 'read', 'not_run', 'source', cwd) };
430
+ if (reads >= 2)
431
+ content = `⚠️ 重复读取空转:该区间未变化,前一次 Read 结果仍然有效。\n\n${content}`;
432
+ return {
433
+ ok: true,
434
+ content,
435
+ metadata: await fileActivityMetadata(
436
+ filePath,
437
+ 'read',
438
+ 'not_run',
439
+ 'source',
440
+ cwd,
441
+ ),
442
+ };
210
443
  } catch (error) {
211
444
  return { ok: false, error: `读取失败:${(error as Error).message}` };
212
445
  }
213
446
  }
214
447
 
448
+ async function renderPdfPages(
449
+ document: Awaited<
450
+ ReturnType<
451
+ (typeof import('pdfjs-dist/legacy/build/pdf.mjs'))['getDocument']
452
+ >
453
+ >['promise'] extends Promise<infer T>
454
+ ? T
455
+ : never,
456
+ filePath: string,
457
+ pages: number[],
458
+ identity?: RuntimeIdentity,
459
+ ): Promise<string[]> {
460
+ const outputDir = join(sessionRuntimeDir(identity ?? {}), 'outputs');
461
+ const outputPrefix = `pdf_${randomUUID()}`;
462
+ await mkdir(outputDir, { recursive: true, mode: 0o700 });
463
+ const images: string[] = [];
464
+ const dynamicImport = new Function(
465
+ 'specifier',
466
+ 'return import(specifier)',
467
+ ) as (specifier: string) => Promise<{
468
+ createCanvas?: (
469
+ width: number,
470
+ height: number,
471
+ ) => {
472
+ getContext(type: '2d'): unknown;
473
+ toBuffer(type: 'image/png'): Buffer;
474
+ };
475
+ }>;
476
+ let createCanvas: Awaited<ReturnType<typeof dynamicImport>>['createCanvas'];
477
+ try {
478
+ ({ createCanvas } = await dynamicImport('@napi-rs/canvas'));
479
+ } catch {
480
+ /* optional adapter */
481
+ }
482
+ if (createCanvas) {
483
+ try {
484
+ for (const pageNumber of pages) {
485
+ const page = await document.getPage(pageNumber);
486
+ const viewport = page.getViewport({ scale: 2 });
487
+ const canvas = createCanvas(
488
+ Math.ceil(viewport.width),
489
+ Math.ceil(viewport.height),
490
+ );
491
+ await page.render({
492
+ canvas: canvas.getContext('2d') as unknown as Parameters<typeof page.render>[0]['canvas'],
493
+ viewport,
494
+ }).promise;
495
+ const target = join(
496
+ outputDir,
497
+ `${outputPrefix}-page-${pageNumber}.png`,
498
+ );
499
+ await writeFile(target, canvas.toBuffer('image/png'), { mode: 0o600 });
500
+ images.push(target);
501
+ }
502
+ void pruneRuntimeOutputs();
503
+ return images;
504
+ } catch {
505
+ images.length = 0;
506
+ }
507
+ }
508
+ for (const pageNumber of pages) {
509
+ const prefix = join(outputDir, `${outputPrefix}-page-${pageNumber}`);
510
+ try {
511
+ await promisify(execFile)(
512
+ 'pdftoppm',
513
+ [
514
+ '-f',
515
+ String(pageNumber),
516
+ '-l',
517
+ String(pageNumber),
518
+ '-singlefile',
519
+ '-png',
520
+ '-r',
521
+ '144',
522
+ filePath,
523
+ prefix,
524
+ ],
525
+ { maxBuffer: 1024 * 1024 },
526
+ );
527
+ images.push(`${prefix}.png`);
528
+ } catch {
529
+ break;
530
+ }
531
+ }
532
+ void pruneRuntimeOutputs();
533
+ return images;
534
+ }
535
+
536
+ function parsePdfPages(spec: string | undefined, total: number): number[] {
537
+ if (!spec)
538
+ return Array.from({ length: Math.min(total, 20) }, (_, index) => index + 1);
539
+ const pages = new Set<number>();
540
+ for (const part of spec.split(',')) {
541
+ const [first, last] = part.split('-').map(Number);
542
+ for (let page = first!; page <= (last ?? first!); page += 1) {
543
+ if (page < 1 || page > total)
544
+ throw new Error(`PDF 页码超出范围:${page}(共 ${total} 页)`);
545
+ pages.add(page);
546
+ }
547
+ }
548
+ return [...pages].sort((left, right) => left - right);
549
+ }
550
+
215
551
  export const readTool: Tool<ReadInput> = {
216
552
  name: 'Read',
217
553
  description,
@@ -222,5 +558,6 @@ export const readTool: Tool<ReadInput> = {
222
558
  maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
223
559
  interruptBehavior: () => 'cancel',
224
560
  call: (input) => call(input),
225
- callWithContext: (input, _signal, context: ToolCallContext) => call(input, context.sessionState?.fileReads, context.cwd),
561
+ callWithContext: (input, _signal, context: ToolCallContext) =>
562
+ call(input, context.sessionState?.fileReads, context.cwd, context),
226
563
  };
@@ -0,0 +1,44 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createTwoFilesPatch, diffLines } from 'diff';
3
+ const MAX_BYTES = 5 * 1024 * 1024;
4
+ const MAX_LINES = 200_000;
5
+ const MAX_DIFF_CHARS = 16_000;
6
+ function hash(value) {
7
+ return createHash('sha256').update(value).digest('hex');
8
+ }
9
+ export function buildPatchSummary(before, after, label) {
10
+ const beforeHash = hash(before);
11
+ const afterHash = hash(after);
12
+ const totalBytes = Buffer.byteLength(before) + Buffer.byteLength(after);
13
+ const totalLines = (before.match(/\n/g)?.length ?? 0) + (after.match(/\n/g)?.length ?? 0) + 2;
14
+ if (totalBytes > MAX_BYTES || totalLines > MAX_LINES) {
15
+ return {
16
+ beforeHash,
17
+ afterHash,
18
+ additions: 0,
19
+ deletions: 0,
20
+ truncated: true,
21
+ omittedReason: 'too_large',
22
+ };
23
+ }
24
+ const changes = diffLines(before, after);
25
+ const additions = changes
26
+ .filter((change) => change.added)
27
+ .reduce((sum, change) => sum + (change.count ?? 0), 0);
28
+ const deletions = changes
29
+ .filter((change) => change.removed)
30
+ .reduce((sum, change) => sum + (change.count ?? 0), 0);
31
+ let unifiedDiff = createTwoFilesPatch(`a/${label}`, `b/${label}`, before, after, undefined, undefined, { context: 3 });
32
+ const truncated = unifiedDiff.length > MAX_DIFF_CHARS;
33
+ if (truncated) {
34
+ unifiedDiff = `${unifiedDiff.slice(0, MAX_DIFF_CHARS)}\n... (diff preview truncated)`;
35
+ }
36
+ return {
37
+ unifiedDiff,
38
+ beforeHash,
39
+ afterHash,
40
+ additions,
41
+ deletions,
42
+ truncated,
43
+ };
44
+ }