deepccc 0.2.3 → 0.2.5

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.
package/README.md CHANGED
@@ -42,10 +42,11 @@ npm run dev
42
42
  内部域名和绝对路径替换为公开示例,任务内容、模型配置、Agent 回复和审批流程均来自
43
43
  实际运行结果。
44
44
 
45
- 多会话可以并行处理不同任务,每个会话分别选择 model 和 effort,并实时保留 Agent
46
- 回复与工具过程:
45
+ 多会话可以并行处理不同任务,每个会话分别选择 model、subModel 和 effort。下图来自
46
+ 真实提问“DeepCCC 这个项目妙在哪”,同时展示了图片附件输入与 Agent `present_file`
47
+ 图片回传:
47
48
 
48
- ![DeepCCC Web UI 多会话与真实 Agent 回复](docs/deepccc-web-ui.png)
49
+ ![DeepCCC Web UI 图片附件与真实 Agent 回复](docs/deepccc-web-ui.png)
49
50
 
50
51
  命中需要确认的命令时,审批卡会直接出现在当前会话时间线中,不打断到弹窗:
51
52
 
@@ -59,6 +60,7 @@ npm run dev
59
60
 
60
61
  - Web-first:多会话、持久化历史、实时流式过程、停止和恢复
61
62
  - 会话配置:每个会话独立选择 model、subModel 和 effort
63
+ - 图片附件:Web 支持选择、粘贴和拖拽 PNG/JPEG/WebP;Agent 可用 `present_file` 回传图片
62
64
  - 本地工具:代码搜索、文件读写、补丁、命令执行、Git、网页搜索和抓取
63
65
  - 权限审批:危险命令在会话时间线中暂停,支持拒绝、允许一次、会话允许和永久允许
64
66
  - 上下文管理:自动压缩、原始流日志和跨会话历史检索
@@ -153,6 +155,8 @@ deepccc
153
155
 
154
156
  Web UI 支持新建、恢复、重命名和删除多会话,多个会话可以同时运行,即使它们指向同一个工作目录。每个会话可独立选择 model、subModel 和 effort,并持续复用 CLI 已保存的历史。注意:当前版本不自动创建 Git worktree;同目录的多个运行中 Agent 直接修改同一组文件,页面会提示覆盖与冲突风险。
155
157
 
158
+ 图片始终按本地附件处理,不转换为 Provider 原生多模态消息。Web 支持文件选择、剪贴板粘贴和拖拽,每条消息最多 10 张 PNG/JPEG/WebP、单张最大 20 MB;附件复制到 `~/.deepccc/attachments/<session-id>/`,Agent 收到本地绝对路径后使用可用工具自行处理。Agent 可调用 `present_file` 把当前工作目录或会话附件目录中的图片直接展示在会话中;删除会话时对应附件一并清理。
159
+
156
160
  API 设置采用单一 Provider 配置,支持 OpenAI-compatible 与 Anthropic Messages。完整 API Key 只保存在本机 `~/.deepccc/config.json`,浏览器读取设置时仅返回掩码。危险命令会在会话中暂停并请求“拒绝、允许一次、本会话允许、永久允许”,浏览器断开或审批超时默认拒绝。
157
161
 
158
162
  `git.coAuthor.enabled` 默认开启。DeepCCC 通过 `run_command` 创建 Git 提交时会保留用户为
@@ -217,11 +221,17 @@ deepccc-cli --base-url https://api.openai.com/v1 --api-key "$OPENAI_API_KEY" --m
217
221
  deepccc-cli --provider anthropic --base-url https://api.example.com --api-key "$API_KEY" --model claude-sonnet-4-6
218
222
  ```
219
223
 
220
- 指定工作目录:
224
+ 指定工作目录:
221
225
 
222
226
  ```bash
223
227
  deepccc-cli --cwd /path/to/project
224
- ```
228
+ ```
229
+
230
+ 附加一张或多张本地图片(可重复传入 `--image`,仍采用本地附件路径,不发送原生多模态内容):
231
+
232
+ ```bash
233
+ deepccc-cli --image ./error.png --image ./expected.webp
234
+ ```
225
235
 
226
236
  恢复当前工作目录最近一次会话:
227
237
 
@@ -0,0 +1,42 @@
1
+ export declare const MAX_ATTACHMENT_BYTES: number;
2
+ export declare const MAX_ATTACHMENTS_PER_MESSAGE = 10;
3
+ export declare const ATTACHMENT_PROMPT_START = "<deepccc-attachments>";
4
+ export declare const ATTACHMENT_PROMPT_END = "</deepccc-attachments>";
5
+ export interface AttachmentMeta {
6
+ attachmentId: string;
7
+ originalName: string;
8
+ mimeType: "image/png" | "image/jpeg" | "image/webp";
9
+ size: number;
10
+ absolutePath: string;
11
+ }
12
+ export interface SaveAttachmentInput {
13
+ originalName: string;
14
+ bytes: Uint8Array;
15
+ }
16
+ export interface AttachmentStoreOptions {
17
+ rootDir?: string;
18
+ idFactory?: () => string;
19
+ }
20
+ export declare class AttachmentStore {
21
+ readonly rootDir: string;
22
+ private readonly idFactory;
23
+ constructor(options?: AttachmentStoreOptions);
24
+ save(sessionId: string, input: SaveAttachmentInput): Promise<AttachmentMeta>;
25
+ importFile(sessionId: string, path: string): Promise<AttachmentMeta>;
26
+ get(sessionId: string, attachmentId: string): Promise<AttachmentMeta | null>;
27
+ read(sessionId: string, attachmentId: string): Promise<{
28
+ attachment: AttachmentMeta;
29
+ bytes: Buffer;
30
+ } | null>;
31
+ delete(sessionId: string, attachmentId: string): Promise<boolean>;
32
+ deleteSession(sessionId: string): Promise<void>;
33
+ private readStored;
34
+ private sessionDir;
35
+ private metaPath;
36
+ }
37
+ export declare function buildAttachmentPrompt(text: string, attachments: AttachmentMeta[]): string;
38
+ export declare function parseAttachmentPrompt(content: string): {
39
+ text: string;
40
+ attachments: AttachmentMeta[];
41
+ };
42
+ export declare function detectImageMime(bytes: Uint8Array): AttachmentMeta["mimeType"] | null;
@@ -0,0 +1,192 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { DEEPCCC_HOME } from "./config.js";
5
+ import { normalizeBuiltinSessionId } from "./context.js";
6
+ export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
7
+ export const MAX_ATTACHMENTS_PER_MESSAGE = 10;
8
+ export const ATTACHMENT_PROMPT_START = "<deepccc-attachments>";
9
+ export const ATTACHMENT_PROMPT_END = "</deepccc-attachments>";
10
+ export class AttachmentStore {
11
+ rootDir;
12
+ idFactory;
13
+ constructor(options = {}) {
14
+ this.rootDir = resolve(options.rootDir ?? join(DEEPCCC_HOME, "attachments"));
15
+ this.idFactory = options.idFactory ?? (() => randomUUID());
16
+ }
17
+ async save(sessionId, input) {
18
+ const bytes = Buffer.from(input.bytes);
19
+ if (!bytes.length)
20
+ throw new Error("Image attachment must not be empty");
21
+ if (bytes.length > MAX_ATTACHMENT_BYTES)
22
+ throw new Error("Image attachment exceeds the 20 MB limit");
23
+ const mimeType = detectImageMime(bytes);
24
+ if (!mimeType)
25
+ throw new Error("Only PNG, JPEG, or WebP image attachments are supported");
26
+ const normalizedSessionId = normalizeBuiltinSessionId(sessionId);
27
+ const attachmentId = normalizeAttachmentId(this.idFactory());
28
+ const extension = extensionForMime(mimeType);
29
+ const fileName = `${attachmentId}${extension}`;
30
+ const sessionDir = this.sessionDir(normalizedSessionId);
31
+ const absolutePath = join(sessionDir, fileName);
32
+ const meta = {
33
+ attachmentId,
34
+ originalName: cleanOriginalName(input.originalName, extension),
35
+ mimeType,
36
+ size: bytes.length,
37
+ absolutePath,
38
+ fileName,
39
+ };
40
+ await mkdir(sessionDir, { recursive: true });
41
+ const dataTemp = `${absolutePath}.${process.pid}.tmp`;
42
+ const metaPath = this.metaPath(normalizedSessionId, attachmentId);
43
+ const metaTemp = `${metaPath}.${process.pid}.tmp`;
44
+ await writeFile(dataTemp, bytes);
45
+ await rename(dataTemp, absolutePath);
46
+ await writeFile(metaTemp, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
47
+ await rename(metaTemp, metaPath);
48
+ return publicMeta(meta);
49
+ }
50
+ async importFile(sessionId, path) {
51
+ const absolutePath = resolve(path);
52
+ const info = await stat(absolutePath).catch(() => null);
53
+ if (!info?.isFile())
54
+ throw new Error(`Image attachment not found: ${path}`);
55
+ if (info.size > MAX_ATTACHMENT_BYTES)
56
+ throw new Error(`Image attachment exceeds the 20 MB limit: ${path}`);
57
+ return this.save(sessionId, {
58
+ originalName: basename(absolutePath),
59
+ bytes: await readFile(absolutePath),
60
+ });
61
+ }
62
+ async get(sessionId, attachmentId) {
63
+ const stored = await this.readStored(sessionId, attachmentId);
64
+ return stored ? publicMeta(stored) : null;
65
+ }
66
+ async read(sessionId, attachmentId) {
67
+ const stored = await this.readStored(sessionId, attachmentId);
68
+ if (!stored)
69
+ return null;
70
+ try {
71
+ const bytes = await readFile(stored.absolutePath);
72
+ if (bytes.length !== stored.size || detectImageMime(bytes) !== stored.mimeType)
73
+ return null;
74
+ return { attachment: publicMeta(stored), bytes };
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ async delete(sessionId, attachmentId) {
81
+ const stored = await this.readStored(sessionId, attachmentId);
82
+ if (!stored)
83
+ return false;
84
+ await Promise.all([
85
+ unlink(stored.absolutePath).catch(() => { }),
86
+ unlink(this.metaPath(sessionId, attachmentId)).catch(() => { }),
87
+ ]);
88
+ return true;
89
+ }
90
+ async deleteSession(sessionId) {
91
+ await rm(this.sessionDir(sessionId), { recursive: true, force: true });
92
+ }
93
+ async readStored(sessionId, attachmentId) {
94
+ const normalizedId = normalizeAttachmentId(attachmentId);
95
+ try {
96
+ const value = JSON.parse(await readFile(this.metaPath(sessionId, normalizedId), "utf8"));
97
+ if (value.attachmentId !== normalizedId || typeof value.fileName !== "string")
98
+ return null;
99
+ if (typeof value.originalName !== "string" || typeof value.size !== "number")
100
+ return null;
101
+ if (value.mimeType !== "image/png" && value.mimeType !== "image/jpeg" && value.mimeType !== "image/webp")
102
+ return null;
103
+ if (value.fileName !== `${normalizedId}${extensionForMime(value.mimeType)}`)
104
+ return null;
105
+ const absolutePath = join(this.sessionDir(sessionId), value.fileName);
106
+ return { ...value, absolutePath };
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ sessionDir(sessionId) {
113
+ return join(this.rootDir, normalizeBuiltinSessionId(sessionId));
114
+ }
115
+ metaPath(sessionId, attachmentId) {
116
+ return join(this.sessionDir(sessionId), `${normalizeAttachmentId(attachmentId)}.json`);
117
+ }
118
+ }
119
+ export function buildAttachmentPrompt(text, attachments) {
120
+ const prompt = text.trim() || "请分析这些图片。";
121
+ if (!attachments.length)
122
+ return prompt;
123
+ const manifest = attachments.map((attachment) => ({
124
+ attachmentId: attachment.attachmentId,
125
+ originalName: attachment.originalName,
126
+ mimeType: attachment.mimeType,
127
+ size: attachment.size,
128
+ absolutePath: attachment.absolutePath,
129
+ }));
130
+ return [
131
+ prompt,
132
+ "",
133
+ ATTACHMENT_PROMPT_START,
134
+ JSON.stringify(manifest),
135
+ ATTACHMENT_PROMPT_END,
136
+ "以上图片以本地附件文件提供。不要假设模型原生支持图片;请使用可用工具读取这些绝对路径并自行处理。",
137
+ ].join("\n");
138
+ }
139
+ export function parseAttachmentPrompt(content) {
140
+ const start = content.indexOf(ATTACHMENT_PROMPT_START);
141
+ const end = content.indexOf(ATTACHMENT_PROMPT_END, start + ATTACHMENT_PROMPT_START.length);
142
+ if (start < 0 || end < 0)
143
+ return { text: content, attachments: [] };
144
+ const raw = content.slice(start + ATTACHMENT_PROMPT_START.length, end).trim();
145
+ let attachments = [];
146
+ try {
147
+ const parsed = JSON.parse(raw);
148
+ if (Array.isArray(parsed))
149
+ attachments = parsed.filter(isAttachmentMeta);
150
+ }
151
+ catch {
152
+ return { text: content, attachments: [] };
153
+ }
154
+ return { text: content.slice(0, start).trimEnd(), attachments };
155
+ }
156
+ export function detectImageMime(bytes) {
157
+ const buffer = Buffer.from(bytes);
158
+ if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
159
+ return "image/png";
160
+ if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff)
161
+ return "image/jpeg";
162
+ if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP")
163
+ return "image/webp";
164
+ return null;
165
+ }
166
+ function extensionForMime(mimeType) {
167
+ return mimeType === "image/png" ? ".png" : mimeType === "image/jpeg" ? ".jpg" : ".webp";
168
+ }
169
+ function cleanOriginalName(value, extension) {
170
+ const cleaned = basename(value || `image${extension}`).replace(/[\u0000-\u001f<>:"/\\|?*]+/g, "_").slice(0, 180);
171
+ return cleaned || `image${extension}`;
172
+ }
173
+ function normalizeAttachmentId(value) {
174
+ const normalized = value.replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
175
+ if (!normalized || normalized === "." || normalized === "..")
176
+ throw new Error("Invalid attachment id");
177
+ return normalized;
178
+ }
179
+ function publicMeta(value) {
180
+ const { fileName: _, ...meta } = value;
181
+ return meta;
182
+ }
183
+ function isAttachmentMeta(value) {
184
+ if (!value || typeof value !== "object" || Array.isArray(value))
185
+ return false;
186
+ const attachment = value;
187
+ return typeof attachment.attachmentId === "string"
188
+ && typeof attachment.originalName === "string"
189
+ && typeof attachment.absolutePath === "string"
190
+ && typeof attachment.size === "number"
191
+ && (attachment.mimeType === "image/png" || attachment.mimeType === "image/jpeg" || attachment.mimeType === "image/webp");
192
+ }
package/dist/cli.js CHANGED
@@ -24,6 +24,7 @@ import { reduceProgress } from "./progress/reducer.js";
24
24
  import { TerminalProgressRenderer } from "./progress/terminal-renderer.js";
25
25
  import { progressView } from "./progress/view.js";
26
26
  import { defaultLogDir, setupFileLogging } from "./file-log.js";
27
+ import { AttachmentStore, buildAttachmentPrompt } from "./attachments.js";
27
28
  function parsePositiveIntegerOption(name, value) {
28
29
  const parsed = Number(value);
29
30
  if (!Number.isInteger(parsed) || parsed <= 0) {
@@ -46,6 +47,7 @@ function parseArgs(argv = process.argv.slice(2)) {
46
47
  let streamJson = false;
47
48
  let prompt = null;
48
49
  let plain = false;
50
+ const images = [];
49
51
  for (let i = 0; i < argv.length; i++) {
50
52
  const arg = argv[i];
51
53
  const next = argv[i + 1];
@@ -104,6 +106,10 @@ function parseArgs(argv = process.argv.slice(2)) {
104
106
  prompt = next;
105
107
  i++;
106
108
  }
109
+ else if (arg === "--image" && next !== undefined) {
110
+ images.push(next);
111
+ i++;
112
+ }
107
113
  else if (arg === "--plain") {
108
114
  plain = true;
109
115
  }
@@ -115,7 +121,7 @@ function parseArgs(argv = process.argv.slice(2)) {
115
121
  help = true;
116
122
  }
117
123
  }
118
- return { config, options, listSessions, resume, help, streamJson, prompt, plain };
124
+ return { config, options, listSessions, resume, help, streamJson, prompt, images, plain };
119
125
  }
120
126
  async function loadRuntime() {
121
127
  const [{ ChatSession }, { config: appConfig }] = await Promise.all([
@@ -144,6 +150,7 @@ function printHelp(appConfig) {
144
150
  " --list-sessions List saved sessions and exit",
145
151
  " --stream-json One-shot mode: write JSONL events to stdout",
146
152
  " --prompt <text> Prompt text for --stream-json",
153
+ " --image <path> Attach a PNG/JPEG/WebP file (repeatable; copied to the session attachment store)",
147
154
  " --plain Force plain streaming output (no progress block renderer)",
148
155
  " --dangerously-bypass-permissions Skip all permission prompts (aligns with chatccc's bypass mode)",
149
156
  " --help, -h Show help",
@@ -276,8 +283,8 @@ async function runStreamJson(args) {
276
283
  return 0;
277
284
  }
278
285
  const prompt = args.prompt ?? (!process.stdin.isTTY ? await readPromptFromStdin() : "");
279
- if (!prompt.trim()) {
280
- writeJsonLine({ type: "error", message: "--stream-json requires --prompt <text> or stdin input" });
286
+ if (!prompt.trim() && !args.images.length) {
287
+ writeJsonLine({ type: "error", message: "--stream-json requires --prompt <text>, stdin input, or --image <path>" });
281
288
  return 1;
282
289
  }
283
290
  let runtime;
@@ -310,15 +317,29 @@ async function runStreamJson(args) {
310
317
  writeJsonLine({ type: "error", message: err.message });
311
318
  return 1;
312
319
  }
320
+ let attachments;
321
+ try {
322
+ attachments = await importCliAttachments(resolvedSession.sessionId, args.images);
323
+ }
324
+ catch (err) {
325
+ writeJsonLine({ type: "error", message: err.message });
326
+ return 1;
327
+ }
328
+ const promptWithAttachments = buildAttachmentPrompt(prompt, attachments);
313
329
  writeJsonLine({
314
330
  type: "start",
315
331
  session_id: resolvedSession.sessionId,
316
332
  mode: resolvedSession.mode,
317
333
  cwd,
318
334
  model: args.config.model ?? runtime.appConfig.model,
335
+ attachments: attachments.map((attachment) => ({
336
+ attachment_id: attachment.attachmentId,
337
+ name: attachment.originalName,
338
+ path: attachment.absolutePath,
339
+ })),
319
340
  });
320
341
  try {
321
- for await (const event of session.chat(prompt)) {
342
+ for await (const event of session.chat(promptWithAttachments)) {
322
343
  streamJsonEvent(event);
323
344
  }
324
345
  return 0;
@@ -425,13 +446,24 @@ async function runRepl(args) {
425
446
  console.error(`${C.yellow}${err.message}${C.reset}`);
426
447
  process.exit(1);
427
448
  }
449
+ let pendingAttachments;
450
+ try {
451
+ pendingAttachments = await importCliAttachments(resolvedSession.sessionId, args.images);
452
+ }
453
+ catch (err) {
454
+ console.error(`${C.yellow}${err.message}${C.reset}`);
455
+ process.exit(1);
456
+ }
457
+ if (pendingAttachments.length) {
458
+ console.log(`${C.dim}Attachments queued: ${pendingAttachments.map((attachment) => attachment.originalName).join(", ")}${C.reset}`);
459
+ }
428
460
  let currentAbort = null;
429
461
  const ctrlCState = createCtrlCState();
430
462
  rl.prompt();
431
463
  rl.on("line", async (line) => {
432
464
  ctrlCState.reset();
433
465
  const input = line.trim();
434
- if (!input) {
466
+ if (!input && !pendingAttachments.length) {
435
467
  rl.prompt();
436
468
  return;
437
469
  }
@@ -475,8 +507,10 @@ async function runRepl(args) {
475
507
  }
476
508
  let rendererEnded = false;
477
509
  try {
510
+ const chatInput = buildAttachmentPrompt(input, pendingAttachments);
511
+ pendingAttachments = [];
478
512
  let lastAccumulated = "";
479
- for await (const event of session.chat(input, signal)) {
513
+ for await (const event of session.chat(chatInput, signal)) {
480
514
  if (renderer && view) {
481
515
  view = reduceProgress(view, event);
482
516
  if (event.type === "text" || event.type === "compact" || event.type === "status") {
@@ -564,6 +598,13 @@ async function runRepl(args) {
564
598
  process.exit(0);
565
599
  });
566
600
  }
601
+ async function importCliAttachments(sessionId, paths) {
602
+ const store = new AttachmentStore();
603
+ const attachments = [];
604
+ for (const path of paths)
605
+ attachments.push(await store.importFile(sessionId, path));
606
+ return attachments;
607
+ }
567
608
  /**
568
609
  * skill create 子命令:deepccc-cli skill create <name> [--scope global|project] [--description "..."]
569
610
  * 默认创建为全局技能(~/.deepccc/skills/<name>/SKILL.md,Codex 结构);
@@ -141,12 +141,24 @@ export interface ApplyPatchFileChange {
141
141
  export interface ApplyPatchOutput {
142
142
  changedFiles: ApplyPatchFileChange[];
143
143
  }
144
+ export interface PresentFileInput {
145
+ path: string;
146
+ caption?: string;
147
+ }
148
+ export interface PresentFileOutput {
149
+ path: string;
150
+ name: string;
151
+ mimeType: "image/png" | "image/jpeg" | "image/webp";
152
+ size: number;
153
+ caption?: string;
154
+ }
144
155
  /**
145
156
  * 把 `~` / `~/x`(含反斜杠 `~\\x`)展开为用户主目录绝对路径。
146
157
  * `~user/x` 形式不展开(Node 无内置支持),保持原样交给后续解析。
147
158
  * 供路径工具共用:技能索引里 `~/...` 路径可直接传给 read_file 等工具。
148
159
  */
149
160
  export declare function expandHomePath(value: string): string;
161
+ export declare function presentFileForTool(cwd: string, input: PresentFileInput): Promise<PresentFileOutput>;
150
162
  export declare function readFileForTool(cwd: string, input: ReadFileInput): Promise<ReadFileOutput>;
151
163
  export declare function listDirForTool(cwd: string, input?: ListDirInput): Promise<ListDirOutput>;
152
164
  export declare function searchCodeForTool(cwd: string, input: SearchCodeInput, signal?: AbortSignal, runtimeOptions?: SearchCodeRuntimeOptions): Promise<SearchCodeOutput>;
@@ -8,6 +8,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
8
8
  import { createInterface } from "node:readline";
9
9
  import { jsonSchema, tool } from "ai";
10
10
  import { isDangerousCommand } from "./permissions.js";
11
+ import { detectImageMime, MAX_ATTACHMENT_BYTES } from "./attachments.js";
11
12
  import { killProcessTree } from "./proc-tree-kill.js";
12
13
  import { searchBuiltinSessions, } from "./session-search.js";
13
14
  import { webFetchForTool, webSearchForTool, } from "./web-tools.js";
@@ -74,6 +75,25 @@ async function pathExists(path) {
74
75
  return false;
75
76
  }
76
77
  }
78
+ export async function presentFileForTool(cwd, input) {
79
+ const path = resolveToolPath(cwd, input.path);
80
+ const info = await stat(path).catch(() => null);
81
+ if (!info?.isFile())
82
+ throw new Error(`Image file not found: ${input.path}`);
83
+ if (info.size > MAX_ATTACHMENT_BYTES)
84
+ throw new Error(`Image file exceeds the 20 MB limit: ${input.path}`);
85
+ const mimeType = detectImageMime(await readFile(path));
86
+ if (!mimeType)
87
+ throw new Error(`present_file only supports PNG, JPEG, or WebP images: ${input.path}`);
88
+ const caption = input.caption?.trim();
89
+ return {
90
+ path,
91
+ name: basename(path),
92
+ mimeType,
93
+ size: info.size,
94
+ ...(caption ? { caption: caption.slice(0, 500) } : {}),
95
+ };
96
+ }
77
97
  function sha256(buffer) {
78
98
  return createHash("sha256").update(buffer).digest("hex");
79
99
  }
@@ -1011,6 +1031,19 @@ export function createBuiltinFileTools(cwd, options = {}) {
1011
1031
  }),
1012
1032
  execute: (input, options) => searchCodeForTool(cwd, input, options.abortSignal),
1013
1033
  }),
1034
+ present_file: tool({
1035
+ description: "把本地 PNG、JPEG 或 WebP 图片作为会话图片展示给用户。仅在图片文件已经生成并需要交付时调用。",
1036
+ inputSchema: jsonSchema({
1037
+ type: "object",
1038
+ additionalProperties: false,
1039
+ properties: {
1040
+ path: { type: "string", description: "图片绝对路径或相对于会话工作目录的路径。" },
1041
+ caption: { type: "string", description: "可选的图片说明。" },
1042
+ },
1043
+ required: ["path"],
1044
+ }),
1045
+ execute: (input) => presentFileForTool(cwd, input),
1046
+ }),
1014
1047
  run_command: tool({
1015
1048
  description: "在本地工作区运行非交互式 shell 命令。用于测试、git 和包脚本。返回 stdout/stderr 和 exitCode;非零退出码不是工具错误。",
1016
1049
  inputSchema: jsonSchema({