@steerable/agent-shell 0.6.60 → 0.6.62

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.
@@ -3,9 +3,10 @@
3
3
  * actually see, replacing the old behavior of dropping only the file *path*
4
4
  * into the prompt text.
5
5
  *
6
- * Decoding / downscaling runs only when an {@link ImageDecoder} is supplied;
7
- * otherwise the original bytes pass through under the encoded cap. These caps
8
- * are enforced before the image ever reaches the model:
6
+ * PNG and JPEG are decoded by the built-in raster decoder so the host can
7
+ * crop and downscale without Electron. Pass `null` to skip that decoder and
8
+ * forward the original bytes under the encoded cap. These caps are enforced
9
+ * before the image ever reaches the model:
9
10
  *
10
11
  * - source bytes (`IMAGE_MAX_SOURCE_BYTES`) — refuse to read huge files;
11
12
  * - long-edge px (`IMAGE_MAX_DIMENSION`) — downscale so the provider's
@@ -19,6 +20,7 @@
19
20
  */
20
21
  import { readFileSync, statSync } from 'fs';
21
22
  import { basename, extname } from 'path';
23
+ import { builtinRasterSupports, createRasterImageDecoder } from './raster-image.js';
22
24
  /** Refuse to read source files larger than this (10 MB). */
23
25
  export const IMAGE_MAX_SOURCE_BYTES = 10 * 1024 * 1024;
24
26
  /** Downscale so the long edge is at most this many px (matches common vision limits). */
@@ -29,6 +31,20 @@ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bm
29
31
  export function isImagePath(path) {
30
32
  return IMAGE_EXTENSIONS.has(extname(path).toLowerCase());
31
33
  }
34
+ let builtinDecoder = null;
35
+ /**
36
+ * `undefined` selects the built-in PNG/JPEG decoder. `null` keeps the
37
+ * original-bytes path. An explicit decoder (tests, or a host native image)
38
+ * replaces the built-in one.
39
+ */
40
+ function resolveImageDecoder(filePath, decoder) {
41
+ if (decoder !== undefined)
42
+ return decoder;
43
+ if (!builtinRasterSupports(filePath))
44
+ return null;
45
+ builtinDecoder ??= createRasterImageDecoder();
46
+ return builtinDecoder;
47
+ }
32
48
  /**
33
49
  * Validate the wire value (`metadata.images` from the renderer) into a clean
34
50
  * input list. Anything that isn't an object with a non-empty string `path`
@@ -73,7 +89,7 @@ export function computeTargetSize(width, height, maxDimension = IMAGE_MAX_DIMENS
73
89
  * tool result. `_image` is consumed by the CoreLoop as an image content part;
74
90
  * it is not a path or model-visible base64 string.
75
91
  */
76
- export function processViewImage(input, decoder = null) {
92
+ export function processViewImage(input, decoder) {
77
93
  if (!isImagePath(input.path)) {
78
94
  return {
79
95
  success: false,
@@ -103,10 +119,17 @@ export function processViewImage(input, decoder = null) {
103
119
  needsFollowup: true,
104
120
  };
105
121
  }
106
- if (!decoder) {
122
+ const resolved = resolveImageDecoder(input.path, decoder);
123
+ if (!resolved) {
107
124
  return viewImageWithoutDecoder(input, sourceBytes, maxEdge);
108
125
  }
109
- let rendered = decoder.createFromPath(input.path);
126
+ let rendered;
127
+ try {
128
+ rendered = resolved.createFromPath(input.path);
129
+ }
130
+ catch {
131
+ return { success: false, error: '不是可识别的图片', needsFollowup: true };
132
+ }
110
133
  if (rendered.isEmpty()) {
111
134
  return { success: false, error: '不是可识别的图片', needsFollowup: true };
112
135
  }
@@ -264,7 +287,7 @@ function formatMb(bytes) {
264
287
  * Process a batch of image attachments. Synchronous: decoder decode /
265
288
  * resize / encode are all synchronous, and the byte check uses `statSync`.
266
289
  */
267
- export function processImageAttachments(files, decoder = null) {
290
+ export function processImageAttachments(files, decoder) {
268
291
  const images = [];
269
292
  const notes = [];
270
293
  for (const file of files) {
@@ -282,7 +305,8 @@ export function processImageAttachments(files, decoder = null) {
282
305
  notes.push(`- ${label}:源文件 ${formatMb(sourceBytes)}MB 超过 ${formatMb(IMAGE_MAX_SOURCE_BYTES)}MB 上限,未附加`);
283
306
  continue;
284
307
  }
285
- if (!decoder) {
308
+ const fileDecoder = resolveImageDecoder(file.path, decoder);
309
+ if (!fileDecoder) {
286
310
  // The bytes are already an image the provider can read; pass them
287
311
  // through when they fit the encoded cap.
288
312
  if (sourceBytes > IMAGE_MAX_ENCODED_BYTES) {
@@ -302,7 +326,14 @@ export function processImageAttachments(files, decoder = null) {
302
326
  notes.push(`- ${label}(原图)`);
303
327
  continue;
304
328
  }
305
- const image = decoder.createFromPath(file.path);
329
+ let image;
330
+ try {
331
+ image = fileDecoder.createFromPath(file.path);
332
+ }
333
+ catch {
334
+ notes.push(`- ${label}:不是可识别的图片,未附加`);
335
+ continue;
336
+ }
306
337
  if (image.isEmpty()) {
307
338
  notes.push(`- ${label}:不是可识别的图片,未附加`);
308
339
  continue;
@@ -0,0 +1,27 @@
1
+ export interface StoredTurn {
2
+ id: string;
3
+ role: 'user' | 'assistant' | 'system' | 'tool';
4
+ content: string;
5
+ messageMetadata?: string | null;
6
+ }
7
+ export interface HistoryWrite {
8
+ role: StoredTurn['role'];
9
+ content: string;
10
+ messageMetadata: string | null;
11
+ }
12
+ /**
13
+ * Newest-first history. The newest two messages stay. Older ones become one summary,
14
+ * and the caller deletes from the oldest id (which removes the whole chat) before writing these back.
15
+ */
16
+ export declare function compactHistory(messages: readonly StoredTurn[]): {
17
+ deleteFromId: string;
18
+ writes: HistoryWrite[];
19
+ compacted: number;
20
+ } | null;
21
+ /** Newest-first. Cut from the newest user message through the reply after it. */
22
+ export declare function rewindFrom(messages: readonly StoredTurn[]): {
23
+ deleteFromId: string;
24
+ removed: number;
25
+ } | null;
26
+ /** Newest-first history copied in time order. */
27
+ export declare function forkHistory(messages: readonly StoredTurn[]): HistoryWrite[];
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Newest-first history. The newest two messages stay. Older ones become one summary,
3
+ * and the caller deletes from the oldest id (which removes the whole chat) before writing these back.
4
+ */
5
+ export function compactHistory(messages) {
6
+ if (messages.length <= 2)
7
+ return null;
8
+ const kept = messages.slice(0, 2);
9
+ const older = messages.slice(2);
10
+ const oldest = older[older.length - 1];
11
+ if (!oldest)
12
+ return null;
13
+ const summary = older.slice().reverse().map((message) => {
14
+ const text = message.content.replace(/\s+/g, ' ').trim();
15
+ const clipped = text.length > 80 ? `${text.slice(0, 80)}…` : text;
16
+ return `${message.role}: ${clipped}`;
17
+ }).join('\n');
18
+ return {
19
+ deleteFromId: oldest.id,
20
+ compacted: older.length,
21
+ writes: [
22
+ { role: 'assistant', content: `已压缩 ${older.length} 条\n${summary}`, messageMetadata: null },
23
+ ...kept.slice().reverse().map((message) => ({
24
+ role: message.role,
25
+ content: message.content,
26
+ messageMetadata: message.messageMetadata ?? null,
27
+ })),
28
+ ],
29
+ };
30
+ }
31
+ /** Newest-first. Cut from the newest user message through the reply after it. */
32
+ export function rewindFrom(messages) {
33
+ const index = messages.findIndex((message) => message.role === 'user');
34
+ const target = index >= 0 ? messages[index] : undefined;
35
+ if (!target)
36
+ return null;
37
+ return { deleteFromId: target.id, removed: index + 1 };
38
+ }
39
+ /** Newest-first history copied in time order. */
40
+ export function forkHistory(messages) {
41
+ return messages.slice().reverse().map((message) => ({
42
+ role: message.role,
43
+ content: message.content,
44
+ messageMetadata: message.messageMetadata ?? null,
45
+ }));
46
+ }
@@ -192,6 +192,7 @@ export declare class LocalBackendRouter {
192
192
  * 用 parseSSEData 把 data 反序列化为 JSON 后用 type/content 字段路由。
193
193
  */
194
194
  private sseData;
195
+ private historyTurns;
195
196
  private toRecord;
196
197
  private safeJson;
197
198
  private notFound;
@@ -48,6 +48,7 @@ import { chatAttachmentsDirPath } from '../attachments.js';
48
48
  import { loadProjectRuleFiles } from '../project-rules.js';
49
49
  import { allocateProjectHome, ensureChatWorkspace, ensureProjectHome, expandUserPath, } from '../project-home.js';
50
50
  import { registerLiveStream, getLiveStream, removeLiveStream } from './live-stream.js';
51
+ import { compactHistory, forkHistory, rewindFrom } from './history-edit.js';
51
52
  import { beginPackTurnObservers, collectPackExecWritableRoots, collectPackForcedSkillVars, collectPackWorldState, } from './pack-turn-hooks.js';
52
53
  import { matchPackBackendRoute } from './pack-backend-routes.js';
53
54
  import { invokePackHttpRoute } from '../host/http-routes.js';
@@ -870,6 +871,45 @@ export class LocalBackendRouter {
870
871
  await supervisor.cancelChat(streamId);
871
872
  return { status: 200, data: { success: true } };
872
873
  }
874
+ const compactMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/compact$/);
875
+ if (compactMatch && method === 'POST') {
876
+ const chatId = compactMatch[1];
877
+ const chat = await this.store.getChat(chatId);
878
+ if (!chat)
879
+ return { status: 404, data: { detail: 'chat not found' } };
880
+ const plan = compactHistory(await this.historyTurns(chatId));
881
+ if (!plan)
882
+ return { status: 200, data: { compacted: 0 } };
883
+ await this.store.deleteMessagesFrom(chatId, plan.deleteFromId);
884
+ for (const write of plan.writes) {
885
+ await this.store.addMessage(chatId, write.role, write.content, write.messageMetadata);
886
+ }
887
+ return { status: 200, data: { compacted: plan.compacted } };
888
+ }
889
+ const forkMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/fork$/);
890
+ if (forkMatch && method === 'POST') {
891
+ const chatId = forkMatch[1];
892
+ const chat = await this.store.getChat(chatId);
893
+ if (!chat)
894
+ return { status: 404, data: { detail: 'chat not found' } };
895
+ const created = await this.store.createChat(chat.title || '分叉', chat.agentId, chat.projectId);
896
+ for (const write of forkHistory(await this.historyTurns(chatId))) {
897
+ await this.store.addMessage(created.id, write.role, write.content, write.messageMetadata);
898
+ }
899
+ return { status: 200, data: { chatId: created.id } };
900
+ }
901
+ const rewindMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/rewind$/);
902
+ if (rewindMatch && method === 'POST') {
903
+ const chatId = rewindMatch[1];
904
+ const chat = await this.store.getChat(chatId);
905
+ if (!chat)
906
+ return { status: 404, data: { detail: 'chat not found' } };
907
+ const cut = rewindFrom(await this.historyTurns(chatId));
908
+ if (!cut)
909
+ return { status: 200, data: { removed: 0 } };
910
+ const removed = await this.store.deleteMessagesFrom(chatId, cut.deleteFromId);
911
+ return { status: 200, data: { removed } };
912
+ }
873
913
  if (method === 'GET' && pathname === '/api/v2/chat-agents') {
874
914
  const includeArchived = url.searchParams.get('include_archived') === 'true';
875
915
  const agents = await this.store.listChatAgents(includeArchived);
@@ -3407,6 +3447,7 @@ export class LocalBackendRouter {
3407
3447
  sinceMs: turnStartedAtMs,
3408
3448
  actions: executedActions,
3409
3449
  projectRoot: workspaceRoot,
3450
+ finalText: assistantText,
3410
3451
  });
3411
3452
  }
3412
3453
  catch (turnFilesErr) {
@@ -3556,6 +3597,15 @@ export class LocalBackendRouter {
3556
3597
  sseData(data) {
3557
3598
  return `data: ${JSON.stringify(data)}\n\n`;
3558
3599
  }
3600
+ async historyTurns(chatId) {
3601
+ const messages = await this.store.listMessages(chatId);
3602
+ return messages.map((message) => ({
3603
+ id: message.id,
3604
+ role: message.role,
3605
+ content: message.content,
3606
+ messageMetadata: message.messageMetadata,
3607
+ }));
3608
+ }
3559
3609
  toRecord(body) {
3560
3610
  if (!body || typeof body !== 'object')
3561
3611
  return {};
@@ -27,7 +27,7 @@ match: any
27
27
  - 命令可能改文件、装包、删数据时(destructive 类操作),**先**简单告诉用户你要执行什么,**再**发 tool_call;不要中间停手等用户确认(用户已经请求了)。
28
28
  - `local_exec_shell` 返回包含 `stdout` / `stderr` / `exitCode`。非 0 退出码视为失败:把 stderr 摘要给用户,并建议下一步。
29
29
  - 路径不存在 / 权限不足时,把 fs 错误原文告诉用户,**不要**反复重试同一个路径。
30
- - **交付文件要声明**:用户要的产出(表格、PPT、文档、报告、图片、导出包)写好后,在最终回复前调用 `present_files` 声明它们,脚本/命令生成的也一样。辅助脚本、临时文件、只为自查渲染效果导出的预览图、中间导出副本**不要**声明——它们会自动列在「已编辑文件」里。
30
+ - **交付文件要声明**:用户要的产出(表格、幻灯片、文档、报告、图片、导出包)写好后,在最终回复前调用 `present_files`。每份独立交付文件都写 `purpose: "output"`;需要记录预览或中间文件时分别写 `purpose: "preview"` / `"intermediate"`,它们不会生成交付卡片。脚本、命令生成的文件也一样。最终回复用行内代码写出每份独立交付文件的完整路径。辅助脚本、临时文件、只为自查渲染效果导出的预览图和中间导出副本不要标为 output。
31
31
 
32
32
 
33
33
  - **超时 ≠ 失败,严禁原样重跑启动命令**:返回里出现 `timedOut: true` 时,只是 shell 等待被掐断——如果命令是启动 GUI / 长时间运行的程序(如 Qt 界面软件),该程序**很可能仍在运行**,用户可能正在里面操作。此时**不要**再次执行同一条启动命令(会把程序重复启动多次)。正确做法:先确认进程是否在运行(如 `Get-Process`);确实需要长时间等待时传更大的 `timeout` 参数;启动 GUI 程序时优先用不阻塞的方式(PowerShell `Start-Process "app.exe"`),启动成功即视为完成,不要等它退出。
@@ -47,5 +47,5 @@ match: any
47
47
 
48
48
  - **没跑过的代码不要说"运行结果如下"。** 只有看到 `local_run_snippet` 的真实返回(`exitCode: 0` + `stdout`)才能汇报结果;非 0 退出码就读 `stderr` 改代码重跑(最多迭代 2-3 次,还不行就如实说明卡点)。
49
49
  - 脚本里读写用户文件时,路径用用户提供的真实路径或项目内相对路径;不要编造路径。
50
- - 脚本产生的**交付物**(报表、转换后的文件)要落到用户能找到的地方(项目目录或用户指定路径),并在回复里给出完整路径,最终回复前用 `present_files` 声明——scratch 目录是临时区,不是交付位置。
50
+ - 脚本产生的**交付物**(报表、转换后的文件)要落到用户能找到的地方(项目目录或用户指定路径),最终回复前用 `present_files` 以 `purpose: "output"` 声明,并在回复里用行内代码给出完整路径——scratch 目录是临时区,不是交付位置。
51
51
  - 代码保持简单直接:能 20 行解决就不要写 100 行;不要引入与任务无关的"工程化"。
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 本轮哪些文件升成交付卡片。
3
+ *
4
+ * 证据优先级:生成流程结构化结果 > 最终回复引用 > present_files > 文件扫描。
5
+ * 启发式只纠正没有明确 purpose 的 present_files 和扫描结果;明确标为 output
6
+ * 的同名 PDF 与幻灯片会同时保留。
7
+ */
8
+ export type ArtifactPurpose = 'output' | 'preview' | 'intermediate';
9
+ export type DeliveryEvidenceSource = 'generation' | 'final-reference' | 'present-files';
10
+ export interface DeliveryEvidence {
11
+ path: string;
12
+ source: DeliveryEvidenceSource;
13
+ /** present_files 的旧调用没有 purpose,此时才允许启发式纠错。 */
14
+ purpose?: ArtifactPurpose;
15
+ }
16
+ /**
17
+ * 选出本轮交付卡片的路径。结构化证据不会被文件名启发式覆盖。
18
+ */
19
+ export declare function selectDeliverablePaths(files: readonly {
20
+ path: string;
21
+ }[], evidence: readonly DeliveryEvidence[]): Set<string>;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * 本轮哪些文件升成交付卡片。
3
+ *
4
+ * 证据优先级:生成流程结构化结果 > 最终回复引用 > present_files > 文件扫描。
5
+ * 启发式只纠正没有明确 purpose 的 present_files 和扫描结果;明确标为 output
6
+ * 的同名 PDF 与幻灯片会同时保留。
7
+ */
8
+ import path from 'node:path';
9
+ const SOURCE_EXTENSIONS = new Set([
10
+ '.pptx', '.ppt', '.key', // shell-neutral:allow — Office 幻灯片扩展名,不是产品品牌
11
+ '.docx', '.doc', '.pages',
12
+ '.xlsx', '.xls', '.xlsm', '.numbers',
13
+ ]);
14
+ const PRESENTATION_EXTENSIONS = new Set([
15
+ '.pptx', '.ppt', '.key', // shell-neutral:allow — Office 幻灯片扩展名,不是产品品牌
16
+ ]);
17
+ const IMAGE_OR_PDF_EXTENSIONS = new Set([
18
+ '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg',
19
+ ]);
20
+ const RENDER_EXTENSIONS = new Set([
21
+ '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg',
22
+ '.html', '.htm', '.csv', '.tsv',
23
+ '.zip', '.tar', '.gz', '.7z',
24
+ '.mp4', '.mov', '.mp3', '.epub', '.rtf',
25
+ ]);
26
+ const HELPER_EXTENSIONS = new Set([
27
+ '.mjs', '.js', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.sh', '.rb', '.go', '.rs',
28
+ '.java', '.css', '.json', '.yml', '.yaml', '.toml',
29
+ ]);
30
+ const PREVIEW_NAME = /预览|截图|图像版|preview|screenshot|thumbnail/i;
31
+ /** 没有办公文件时,自动升成卡片的渲染文件上限。再多就交给 present_files 点名。 */
32
+ const AUTO_RENDER_MAX = 4;
33
+ /**
34
+ * 选出本轮交付卡片的路径。结构化证据不会被文件名启发式覆盖。
35
+ */
36
+ export function selectDeliverablePaths(files, evidence) {
37
+ const unique = [];
38
+ const seen = new Set();
39
+ for (const file of files) {
40
+ if (seen.has(file.path))
41
+ continue;
42
+ seen.add(file.path);
43
+ unique.push(file);
44
+ }
45
+ const strongest = strongestEvidence(evidence, seen);
46
+ const explicitOutputs = new Set([...strongest.values()]
47
+ .filter((item) => item.purpose === 'output')
48
+ .map((item) => item.path));
49
+ const excluded = new Set([...strongest.values()]
50
+ .filter((item) => item.purpose === 'preview' || item.purpose === 'intermediate')
51
+ .map((item) => item.path));
52
+ const ambiguousReferences = new Set([...strongest.values()]
53
+ .filter((item) => item.purpose === undefined)
54
+ .map((item) => item.path));
55
+ const sources = unique.filter((file) => isSource(file.path)).map((file) => file.path);
56
+ const availableSources = sources.filter((item) => !excluded.has(item));
57
+ const outsideSources = availableSources.filter((item) => !inAgentScratch(item));
58
+ const sourcePool = outsideSources.length > 0 ? outsideSources : availableSources;
59
+ let inferred;
60
+ if (ambiguousReferences.size > 0) {
61
+ inferred = [...ambiguousReferences].filter((item) => !excluded.has(item) && !isShadow(item, availableSources));
62
+ const parents = availableSources.filter((source) => [...ambiguousReferences].some((item) => isShadowOf(item, source)));
63
+ const outsideParents = parents.filter((item) => !inAgentScratch(item));
64
+ for (const source of outsideParents.length > 0 ? outsideParents : parents) {
65
+ if (!inferred.includes(source))
66
+ inferred.push(source);
67
+ }
68
+ // 点名的全是脚本或预览、本轮又只有一份办公文件:卡片给那一份,不给脚本。
69
+ const onlySource = sourcePool.length === 1 ? sourcePool[0] : undefined;
70
+ if (onlySource &&
71
+ !inferred.some((item) => isSource(item)) &&
72
+ [...ambiguousReferences].every((item) => isHelper(item) || isShadow(item, availableSources))) {
73
+ inferred = [onlySource];
74
+ }
75
+ if (inferred.some((item) => isSource(item))) {
76
+ inferred = inferred.filter((item) => !isHelper(item));
77
+ }
78
+ if (inferred.length === 0) {
79
+ inferred = [...ambiguousReferences].filter((item) => !excluded.has(item));
80
+ }
81
+ }
82
+ else if (explicitOutputs.size > 0) {
83
+ inferred = [];
84
+ }
85
+ else if (sourcePool.length > 0) {
86
+ inferred = [...sourcePool];
87
+ }
88
+ else {
89
+ inferred = autoRenders(unique.filter((file) => !excluded.has(file.path)));
90
+ }
91
+ return new Set([...explicitOutputs, ...inferred]);
92
+ }
93
+ function strongestEvidence(evidence, knownPaths) {
94
+ const strongest = new Map();
95
+ for (const item of evidence) {
96
+ if (!knownPaths.has(item.path))
97
+ continue;
98
+ const previous = strongest.get(item.path);
99
+ if (!previous ||
100
+ evidenceRank(item.source) > evidenceRank(previous.source) ||
101
+ (item.source === previous.source &&
102
+ item.purpose !== undefined &&
103
+ previous.purpose === undefined)) {
104
+ strongest.set(item.path, item);
105
+ }
106
+ }
107
+ return strongest;
108
+ }
109
+ function evidenceRank(source) {
110
+ switch (source) {
111
+ case 'generation':
112
+ return 3;
113
+ case 'final-reference':
114
+ return 2;
115
+ case 'present-files':
116
+ return 1;
117
+ }
118
+ }
119
+ function autoRenders(files) {
120
+ const renders = files.filter((file) => isRender(file.path) && !inAgentScratch(file.path));
121
+ const plain = renders.filter((file) => !previewName(file.path));
122
+ const pool = plain.length > 0 ? plain : renders;
123
+ if (pool.length === 0 || pool.length > AUTO_RENDER_MAX)
124
+ return [];
125
+ return pool.map((file) => file.path);
126
+ }
127
+ function isShadow(filePath, sources) {
128
+ return sources.some((source) => isShadowOf(filePath, source));
129
+ }
130
+ /** 预览 PDF / 截图:和幻灯片同干名,或文件名像预览且跟办公文件在同一目录。 */
131
+ function isShadowOf(filePath, sourcePath) {
132
+ if (filePath === sourcePath || !isSource(sourcePath) || !isRender(filePath))
133
+ return false;
134
+ if (stemsMatch(filePath, sourcePath) &&
135
+ isPresentation(sourcePath) &&
136
+ isImageOrPdf(filePath)) {
137
+ return true;
138
+ }
139
+ return previewName(filePath) && sameDir(filePath, sourcePath);
140
+ }
141
+ function isSource(filePath) {
142
+ return SOURCE_EXTENSIONS.has(extensionOf(filePath));
143
+ }
144
+ function isPresentation(filePath) {
145
+ return PRESENTATION_EXTENSIONS.has(extensionOf(filePath));
146
+ }
147
+ function isImageOrPdf(filePath) {
148
+ return IMAGE_OR_PDF_EXTENSIONS.has(extensionOf(filePath));
149
+ }
150
+ function isRender(filePath) {
151
+ return RENDER_EXTENSIONS.has(extensionOf(filePath));
152
+ }
153
+ function isHelper(filePath) {
154
+ return HELPER_EXTENSIONS.has(extensionOf(filePath));
155
+ }
156
+ function previewName(filePath) {
157
+ return PREVIEW_NAME.test(path.basename(filePath));
158
+ }
159
+ function extensionOf(filePath) {
160
+ return path.extname(filePath).toLowerCase();
161
+ }
162
+ function stemsMatch(left, right) {
163
+ const a = stemKey(left);
164
+ const b = stemKey(right);
165
+ return a.length > 0 && a === b;
166
+ }
167
+ function stemKey(filePath) {
168
+ const base = path.basename(filePath);
169
+ const ext = path.extname(base);
170
+ const stem = ext ? base.slice(0, -ext.length) : base;
171
+ return stem
172
+ .replace(/预览|截图|图像版/g, '')
173
+ .replace(/preview|screenshot|thumbnail/gi, '')
174
+ .replace(/[\s._\-—–()()【】[\]]+/g, '')
175
+ .toLowerCase()
176
+ .normalize('NFC');
177
+ }
178
+ function sameDir(left, right) {
179
+ return directoryOf(left) === directoryOf(right);
180
+ }
181
+ function directoryOf(filePath) {
182
+ return path.dirname(filePath).replace(/\\/g, '/').replace(/\/+$/, '').normalize('NFC');
183
+ }
184
+ function inAgentScratch(filePath) {
185
+ return /(?:^|[/\\])(?:review_work|scripts)(?:[/\\]|$)/i.test(filePath);
186
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 回合产物文件收集(「本轮写了哪些文件」列表)。
3
3
  *
4
- * 四个来源取并集:
4
+ * 六个来源取并集:
5
5
  *
6
6
  * 1. 工作区扫描(递归):回合的可写根(项目根 + 场景包工作区,与 exec
7
7
  * 沙箱同源)里 mtime/birthtime ≥ 回合开始时间的文件。覆盖脚本/命令
@@ -16,6 +16,10 @@
16
16
  * 4. 命令/代码文本里的绝对路径字面量:exec 命令、snippet 代码中出现的
17
17
  * 绝对路径(含 ~/ 前缀与引号包裹形式),stat + 时间水位线验证后并入。
18
18
  * 覆盖脚本写到与 cwd 无关的任意位置(如 doc.save('/tmp/x/a.pdf'))。
19
+ * 5. 生成工具结果的结构化 artifacts:每项明确给出 path 与
20
+ * output / preview / intermediate purpose,是交付选择的最高优先级。
21
+ * 6. 最终回复里的文件引用:Codex file citation、Markdown 本地链接和
22
+ * 行内代码路径,作为模型最终确认的交付文件。
19
23
  *
20
24
  * 来源 3/4 都是「猜候选、靠 stat + mtime/birthtime 水位线证伪」:命令里
21
25
  * 提到的既有文件(ls /etc/hosts)时间戳旧、不会被误收;不存在的路径
@@ -29,6 +33,7 @@
29
33
  * 扫描是有界 best-effort:忽略依赖/构建缓存目录,深度与遍历条目数有硬顶,
30
34
  * 超限直接截断返回已收集部分——产物列表是展示面,永远不该拖垮回合收尾。
31
35
  */
36
+ import { type DeliveryEvidenceSource } from './turn-file-selection.js';
32
37
  export interface TurnFile {
33
38
  /** 绝对路径(点击打开直接用)。 */
34
39
  path: string;
@@ -40,8 +45,12 @@ export interface TurnFile {
40
45
  additions?: number;
41
46
  /** 删除行数(展示用,如 -0)。 */
42
47
  deletions?: number;
43
- /** 类别:deliverable = present_files 声明的最终交付文件;intermediate = 其余本轮写过的文件。 */
48
+ /** 类别:deliverable = 本轮交付卡片;intermediate = 其余本轮写过的文件。 */
44
49
  category?: 'deliverable' | 'intermediate';
50
+ /** 新回合由后端完成分层证据选择;前端不再二次猜测。 */
51
+ selection?: 'resolved';
52
+ /** 成为交付卡片所依据的最高优先级证据。 */
53
+ deliverySource?: DeliveryEvidenceSource | 'inference';
45
54
  /** present_files 给出的一行说明(交付卡片副标题)。 */
46
55
  description?: string;
47
56
  }
@@ -56,7 +65,10 @@ export interface TurnFileAction {
56
65
  export declare const DELIVERABLE_EXTENSIONS: Set<string>;
57
66
  /** 是否为临时文件 / 办公软件锁定文件(如 ~$ 开头的文件名),产物列表中一律排除。 */
58
67
  export declare function isIgnoredFileName(name: string): boolean;
59
- /** 判定文件是最终交付文件还是中间修改文件。 */
68
+ /**
69
+ * 按扩展名做的单文件猜测。回合级取舍在 `selectDeliverablePaths`:
70
+ * 同名预览不会因为扩展名就和正文一起升成卡片。
71
+ */
60
72
  export declare function classifyFileCategory(filePath: string): 'deliverable' | 'intermediate';
61
73
  /** 从统一 diff 文本中统计增删行数。 */
62
74
  export declare function parseDiffStats(diffText: string): {
@@ -74,4 +86,6 @@ export declare function collectTurnFiles(options: {
74
86
  actions?: readonly TurnFileAction[];
75
87
  projectRoot?: string | null;
76
88
  homeDir?: string;
89
+ /** 完整助手回复;其中明确的本地文件链接或行内代码路径属于最终引用。 */
90
+ finalText?: string;
77
91
  }): Promise<TurnFile[]>;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 回合产物文件收集(「本轮写了哪些文件」列表)。
3
3
  *
4
- * 四个来源取并集:
4
+ * 六个来源取并集:
5
5
  *
6
6
  * 1. 工作区扫描(递归):回合的可写根(项目根 + 场景包工作区,与 exec
7
7
  * 沙箱同源)里 mtime/birthtime ≥ 回合开始时间的文件。覆盖脚本/命令
@@ -16,6 +16,10 @@
16
16
  * 4. 命令/代码文本里的绝对路径字面量:exec 命令、snippet 代码中出现的
17
17
  * 绝对路径(含 ~/ 前缀与引号包裹形式),stat + 时间水位线验证后并入。
18
18
  * 覆盖脚本写到与 cwd 无关的任意位置(如 doc.save('/tmp/x/a.pdf'))。
19
+ * 5. 生成工具结果的结构化 artifacts:每项明确给出 path 与
20
+ * output / preview / intermediate purpose,是交付选择的最高优先级。
21
+ * 6. 最终回复里的文件引用:Codex file citation、Markdown 本地链接和
22
+ * 行内代码路径,作为模型最终确认的交付文件。
19
23
  *
20
24
  * 来源 3/4 都是「猜候选、靠 stat + mtime/birthtime 水位线证伪」:命令里
21
25
  * 提到的既有文件(ls /etc/hosts)时间戳旧、不会被误收;不存在的路径
@@ -32,7 +36,9 @@
32
36
  import fs from 'node:fs/promises';
33
37
  import os from 'node:os';
34
38
  import path from 'node:path';
35
- import { PRESENT_FILES_TOOL_NAME, readPresentedArgs, } from '../present-files.js';
39
+ import { fileURLToPath } from 'node:url';
40
+ import { PRESENT_FILES_TOOL_NAME, readPresentedArgs, resolvePresentedPath, } from '../present-files.js';
41
+ import { selectDeliverablePaths, } from './turn-file-selection.js';
36
42
  /** 这些目录要么体量巨大(node_modules/target),要么是框架内部状态(.steerable),扫了只有噪音。 */
37
43
  const IGNORED_DIR_NAMES = new Set([
38
44
  'node_modules',
@@ -81,7 +87,10 @@ export function isIgnoredFileName(name) {
81
87
  return true;
82
88
  return false;
83
89
  }
84
- /** 判定文件是最终交付文件还是中间修改文件。 */
90
+ /**
91
+ * 按扩展名做的单文件猜测。回合级取舍在 `selectDeliverablePaths`:
92
+ * 同名预览不会因为扩展名就和正文一起升成卡片。
93
+ */
85
94
  export function classifyFileCategory(filePath) {
86
95
  const normalized = filePath.replace(/\\/g, '/');
87
96
  const ext = path.extname(normalized).toLowerCase();
@@ -161,21 +170,55 @@ export async function collectTurnFiles(options) {
161
170
  await scanShallow(dir, sinceMs, byPath);
162
171
  }
163
172
  // present_files 声明的文件:本轮可能没动过(交付已有文件),不受时间水位线
164
- // 约束,只要求仍是普通文件。
165
- const presented = new Map();
173
+ // 约束,只要求仍是普通文件。路径按 realpath + NFC 并到扫描结果上,避免
174
+ // macOS 文件名归一化或 /var 与 /private/var 把同一文件收成两条。
175
+ const presentedByKey = new Map();
166
176
  for (const action of options.actions ?? []) {
167
177
  if (action.tool !== PRESENT_FILES_TOOL_NAME || action.success === false)
168
178
  continue;
169
179
  for (const file of readPresentedArgs(action.arguments, projectRoot, homeDir)) {
170
- presented.set(file.path, file);
180
+ const key = await canonicalFileKey(file.path);
181
+ const prev = presentedByKey.get(key);
182
+ if (!prev || (!prev.description && file.description))
183
+ presentedByKey.set(key, file);
171
184
  }
172
185
  }
173
- for (const file of presented.values()) {
174
- if (byPath.has(file.path))
186
+ for (const [key, file] of presentedByKey) {
187
+ if (byPath.has(key))
175
188
  continue;
176
189
  const stat = await statPresentedFile(file.path);
177
190
  if (stat)
178
- byPath.set(file.path, stat);
191
+ byPath.set(key, stat);
192
+ }
193
+ // 生成工具可在成功结果的 artifacts 数组里直接声明 path + purpose。
194
+ // 这是最高优先级来源,不用文件名猜主文件与预览文件。
195
+ const generatedByKey = new Map();
196
+ for (const action of options.actions ?? []) {
197
+ if (action.success === false)
198
+ continue;
199
+ for (const artifact of readStructuredArtifacts(action.result, projectRoot, homeDir)) {
200
+ const key = await canonicalFileKey(artifact.path);
201
+ generatedByKey.set(key, artifact);
202
+ if (byPath.has(key))
203
+ continue;
204
+ const stat = await statPresentedFile(artifact.path);
205
+ if (stat)
206
+ byPath.set(key, stat);
207
+ }
208
+ }
209
+ // 最终回复中的 Codex file citation、Markdown 本地链接和行内代码路径是次高
210
+ // 优先级。它们也可以引用本轮没改过但交付给用户的已有文件。
211
+ const finalReferencesByKey = new Map();
212
+ for (const reference of readFinalFileReferences(options.finalText ?? '', projectRoot, homeDir)) {
213
+ const key = await canonicalFileKey(reference.path);
214
+ const previous = finalReferencesByKey.get(key);
215
+ if (!previous || reference.explicitOutput)
216
+ finalReferencesByKey.set(key, reference);
217
+ if (byPath.has(key))
218
+ continue;
219
+ const stat = await statPresentedFile(reference.path);
220
+ if (stat)
221
+ byPath.set(key, stat);
179
222
  }
180
223
  // 从 actions 中提取增删行数统计(local_edit_file 的 diff 或 local_write_file 的 content)
181
224
  const statsByPath = new Map();
@@ -211,23 +254,58 @@ export async function collectTurnFiles(options) {
211
254
  }
212
255
  }
213
256
  }
214
- const enriched = [];
215
- for (const file of byPath.values()) {
216
- const base = path.basename(file.path);
217
- if (isIgnoredFileName(base))
257
+ const visible = [];
258
+ for (const [key, file] of byPath) {
259
+ if (isIgnoredFileName(path.basename(file.path)))
218
260
  continue;
261
+ visible.push({ key, file });
262
+ }
263
+ const evidence = [];
264
+ for (const { key, file } of visible) {
265
+ const generated = generatedByKey.get(key);
266
+ if (generated) {
267
+ evidence.push({
268
+ path: file.path,
269
+ source: 'generation',
270
+ purpose: generated.purpose,
271
+ });
272
+ }
273
+ const finalReference = finalReferencesByKey.get(key);
274
+ if (finalReference) {
275
+ evidence.push({
276
+ path: file.path,
277
+ source: 'final-reference',
278
+ ...(finalReference.explicitOutput ? { purpose: 'output' } : {}),
279
+ });
280
+ }
281
+ const presented = presentedByKey.get(key);
282
+ if (presented) {
283
+ evidence.push({
284
+ path: file.path,
285
+ source: 'present-files',
286
+ ...(presented.purpose ? { purpose: presented.purpose } : {}),
287
+ });
288
+ }
289
+ }
290
+ const deliverablePaths = selectDeliverablePaths(visible.map(({ file }) => file), evidence);
291
+ const enriched = [];
292
+ for (const { key, file } of visible) {
219
293
  const stats = statsByPath.get(file.path);
220
- const declared = presented.get(file.path);
221
- // 本轮有声明时以声明为准;没有声明(旧模型、未调用)才退回扩展名规则。
222
- const category = presented.size > 0
223
- ? declared
224
- ? 'deliverable'
225
- : 'intermediate'
226
- : classifyFileCategory(file.path);
294
+ const declared = presentedByKey.get(key);
295
+ const generated = generatedByKey.get(key);
296
+ const category = deliverablePaths.has(file.path) ? 'deliverable' : 'intermediate';
297
+ const deliverySource = category === 'deliverable'
298
+ ? selectedDeliverySource(key, generatedByKey, finalReferencesByKey, presentedByKey)
299
+ : undefined;
227
300
  enriched.push({
228
301
  ...file,
229
302
  category,
230
- ...(declared?.description ? { description: declared.description } : {}),
303
+ selection: 'resolved',
304
+ ...(deliverySource ? { deliverySource } : {}),
305
+ // 预览被拿掉之后,不把它的说明贴到推断出的可编辑文件上。
306
+ ...(category === 'deliverable' && (generated?.description || declared?.description)
307
+ ? { description: generated?.description ?? declared?.description }
308
+ : {}),
231
309
  ...(stats ? { additions: stats.additions, deletions: stats.deletions } : {}),
232
310
  });
233
311
  }
@@ -268,9 +346,23 @@ async function walk(dir, sinceMs, depth, state, out) {
268
346
  continue;
269
347
  const file = await statTurnFile(full, sinceMs);
270
348
  if (file)
271
- out.set(full, file);
349
+ await putTurnFile(out, file);
350
+ }
351
+ }
352
+ /** 同一文件的扫描路径和模型声明路径收成一个键。 */
353
+ async function canonicalFileKey(filePath) {
354
+ try {
355
+ return (await fs.realpath(filePath)).normalize('NFC');
356
+ }
357
+ catch {
358
+ return path.normalize(filePath).normalize('NFC');
272
359
  }
273
360
  }
361
+ async function putTurnFile(out, file) {
362
+ const key = await canonicalFileKey(file.path);
363
+ if (!out.has(key))
364
+ out.set(key, file);
365
+ }
274
366
  /** stat 一个文件,命中「本轮触碰过」则返回 TurnFile,否则 null。 */
275
367
  async function statTurnFile(full, sinceMs) {
276
368
  let stat;
@@ -304,6 +396,125 @@ async function statPresentedFile(full) {
304
396
  return null;
305
397
  return { path: full, kind: 'modified', size: stat.size };
306
398
  }
399
+ /**
400
+ * 生成工具结果的标准产物字段:
401
+ * `{ artifacts: [{ path, purpose, description? }] }`,sidecar 包装结果也可放在
402
+ * `data.artifacts`。purpose 必填,避免把没有语义的路径数组冒充可靠证据。
403
+ */
404
+ function readStructuredArtifacts(result, projectRoot, homeDir) {
405
+ if (!result || typeof result !== 'object')
406
+ return [];
407
+ const record = result;
408
+ const data = record.data && typeof record.data === 'object'
409
+ ? record.data
410
+ : null;
411
+ const raw = Array.isArray(record.artifacts)
412
+ ? record.artifacts
413
+ : Array.isArray(data?.artifacts)
414
+ ? data.artifacts
415
+ : [];
416
+ const artifacts = [];
417
+ for (const item of raw) {
418
+ if (!item || typeof item !== 'object')
419
+ continue;
420
+ const artifact = item;
421
+ if (typeof artifact.path !== 'string' || !isArtifactPurpose(artifact.purpose))
422
+ continue;
423
+ const resolved = resolvePresentedPath(artifact.path, projectRoot, homeDir);
424
+ if (!resolved)
425
+ continue;
426
+ const description = typeof artifact.description === 'string' ? artifact.description.trim() : '';
427
+ artifacts.push({
428
+ path: resolved,
429
+ purpose: artifact.purpose,
430
+ ...(description ? { description } : {}),
431
+ });
432
+ }
433
+ return artifacts;
434
+ }
435
+ function isArtifactPurpose(value) {
436
+ return value === 'output' || value === 'preview' || value === 'intermediate';
437
+ }
438
+ function readFinalFileReferences(text, projectRoot, homeDir) {
439
+ if (!text)
440
+ return [];
441
+ const rawPaths = new Map();
442
+ for (const match of text.matchAll(/:codex-file-citation\{([^}]*)\}/g)) {
443
+ const attributes = match[1] ?? '';
444
+ const purpose = directiveAttribute(attributes, 'purpose');
445
+ const filePath = directiveAttribute(attributes, 'path');
446
+ if (purpose === 'output' && filePath)
447
+ rawPaths.set(filePath, true);
448
+ }
449
+ for (const match of text.matchAll(/!?\[[^\]]*]\((?:<([^>]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\)/g)) {
450
+ const target = match[1] ?? match[2];
451
+ if (target && !rawPaths.has(target))
452
+ rawPaths.set(target, false);
453
+ }
454
+ for (const match of text.matchAll(/`([^`\n]+)`/g)) {
455
+ const candidate = match[1]?.trim();
456
+ if (candidate && looksLikeLocalFileReference(candidate) && !rawPaths.has(candidate)) {
457
+ rawPaths.set(candidate, false);
458
+ }
459
+ }
460
+ const resolved = new Map();
461
+ for (const [raw, explicitOutput] of rawPaths) {
462
+ const decoded = decodeFileReference(raw);
463
+ if (!decoded || /^(?:https?|data):/i.test(decoded))
464
+ continue;
465
+ const filePath = resolvePresentedPath(decoded, projectRoot, homeDir);
466
+ if (!filePath)
467
+ continue;
468
+ const previous = resolved.get(filePath);
469
+ if (!previous || explicitOutput) {
470
+ resolved.set(filePath, { path: filePath, explicitOutput });
471
+ }
472
+ }
473
+ return [...resolved.values()];
474
+ }
475
+ function directiveAttribute(attributes, name) {
476
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
477
+ const match = new RegExp(`(?:^|\\s)${escaped}=(?:"([^"]*)"|'([^']*)'|([^\\s}]+))`).exec(attributes);
478
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
479
+ }
480
+ function decodeFileReference(raw) {
481
+ let value = raw.trim();
482
+ try {
483
+ value = decodeURIComponent(value);
484
+ }
485
+ catch {
486
+ // 非法百分号不是可靠的文件引用。
487
+ return null;
488
+ }
489
+ if (value.startsWith('file://')) {
490
+ try {
491
+ return fileURLToPath(value);
492
+ }
493
+ catch {
494
+ return null;
495
+ }
496
+ }
497
+ return value;
498
+ }
499
+ function looksLikeLocalFileReference(value) {
500
+ if (/^(?:~\/|\/|[A-Za-z]:[\\/])/.test(value))
501
+ return path.extname(value).length > 1;
502
+ return (!/^[a-z][a-z0-9+.-]*:/i.test(value) &&
503
+ /(?:^|[/\\])[^/\\]+\.[A-Za-z0-9]{1,10}$/.test(value));
504
+ }
505
+ function selectedDeliverySource(key, generatedByKey, finalReferencesByKey, presentedByKey) {
506
+ const generated = generatedByKey.get(key);
507
+ if (generated?.purpose === 'output')
508
+ return 'generation';
509
+ if (generated?.purpose === 'preview' || generated?.purpose === 'intermediate') {
510
+ return 'inference';
511
+ }
512
+ if (finalReferencesByKey.has(key))
513
+ return 'final-reference';
514
+ if (presentedByKey.has(key))
515
+ return 'present-files';
516
+ return 'inference';
517
+ }
307
518
  /** local_edit_file 的 diff:直连结果在顶层,经 sidecar 回流的结果折进 `data`。 */
308
519
  function editDiffOf(result) {
309
520
  if (!result || typeof result !== 'object')
@@ -340,11 +551,11 @@ async function collectWriteToolPath(action, sinceMs, projectRoot, out) {
340
551
  : projectRoot
341
552
  ? path.resolve(projectRoot, raw)
342
553
  : null;
343
- if (!full || out.has(full))
554
+ if (!full || out.has(await canonicalFileKey(full)))
344
555
  return;
345
556
  const file = await statTurnFile(full, sinceMs);
346
557
  if (file)
347
- out.set(full, file);
558
+ await putTurnFile(out, file);
348
559
  }
349
560
  /**
350
561
  * exec 行动的实际工作目录(与 tool-router / local-executor 的解析规则对齐):
@@ -394,11 +605,11 @@ async function scanShallow(dir, sinceMs, out) {
394
605
  if (entry.isSymbolicLink() || !entry.isFile())
395
606
  continue;
396
607
  const full = path.join(dir, entry.name);
397
- if (out.has(full))
608
+ if (out.has(await canonicalFileKey(full)))
398
609
  continue;
399
610
  const file = await statTurnFile(full, sinceMs);
400
611
  if (file)
401
- out.set(full, file);
612
+ await putTurnFile(out, file);
402
613
  }
403
614
  }
404
615
  /**
@@ -433,10 +644,10 @@ async function collectPathLiterals(action, sinceMs, homeDir, out) {
433
644
  }
434
645
  }
435
646
  for (const full of candidates) {
436
- if (out.has(full))
647
+ if (out.has(await canonicalFileKey(full)))
437
648
  continue;
438
649
  const file = await statTurnFile(full, sinceMs);
439
650
  if (file)
440
- out.set(full, file);
651
+ await putTurnFile(out, file);
441
652
  }
442
653
  }
@@ -6,6 +6,7 @@
6
6
  * 导出副本,所以由模型显式声明。工具只 stat 元数据、不读内容,也不复制
7
7
  * 文件——用户打开的是源文件当前版本。
8
8
  */
9
+ import type { ArtifactPurpose } from './local-backend/turn-file-selection.js';
9
10
  export declare const PRESENT_FILES_TOOL_NAME = "present_files";
10
11
  export declare const PRESENT_FILES_SCHEMA: {
11
12
  name: string;
@@ -30,6 +31,11 @@ export declare const PRESENT_FILES_SCHEMA: {
30
31
  type: string;
31
32
  description: string;
32
33
  };
34
+ purpose: {
35
+ type: string;
36
+ enum: string[];
37
+ description: string;
38
+ };
33
39
  };
34
40
  required: string[];
35
41
  additionalProperties: boolean;
@@ -43,6 +49,7 @@ export declare const PRESENT_FILES_SCHEMA: {
43
49
  export interface PresentedFile {
44
50
  path: string;
45
51
  description?: string;
52
+ purpose?: ArtifactPurpose;
46
53
  }
47
54
  /**
48
55
  * 把参数里的路径解析成绝对路径:展开 `~/`,相对路径按项目根解析;
@@ -18,8 +18,9 @@ export const PRESENT_FILES_SCHEMA = {
18
18
  'When a file you created or updated is an output the user asked to receive ' +
19
19
  '(spreadsheet, slide deck, document, report, image, exported archive), call this after writing it ' +
20
20
  'and before your final reply, including files produced by scripts or commands. ' +
21
- 'Do not present helper scripts, scratch files, render previews made only to check your work, ' +
22
- 'or intermediate exports. Files you write but do not present are still listed to the user as edited files.',
21
+ 'Set purpose="output" for every independent deliverable. If you include a preview or intermediate ' +
22
+ 'file for context, label it purpose="preview" or purpose="intermediate"; those files stay in the ' +
23
+ 'edited-files list. Do not label helper scripts or layout-check screenshots as outputs.',
23
24
  mode: 'read',
24
25
  inputSchema: {
25
26
  type: 'object',
@@ -40,6 +41,11 @@ export const PRESENT_FILES_SCHEMA = {
40
41
  type: 'string',
41
42
  description: 'Optional one-line summary shown on the file card.',
42
43
  },
44
+ purpose: {
45
+ type: 'string',
46
+ enum: ['output', 'preview', 'intermediate'],
47
+ description: 'Use output for an independent final deliverable; preview/intermediate never creates a deliverable card.',
48
+ },
43
49
  },
44
50
  required: ['path'],
45
51
  additionalProperties: false,
@@ -83,7 +89,16 @@ export function readPresentedArgs(args, projectRoot, homeDir) {
83
89
  if (!resolved)
84
90
  continue;
85
91
  const description = typeof record.description === 'string' ? record.description.trim() : '';
86
- out.push({ path: resolved, ...(description ? { description } : {}) });
92
+ const purpose = record.purpose === 'output' ||
93
+ record.purpose === 'preview' ||
94
+ record.purpose === 'intermediate'
95
+ ? record.purpose
96
+ : undefined;
97
+ out.push({
98
+ path: resolved,
99
+ ...(description ? { description } : {}),
100
+ ...(purpose ? { purpose } : {}),
101
+ });
87
102
  }
88
103
  return out;
89
104
  }
@@ -0,0 +1,8 @@
1
+ import type { ImageDecoder } from './image-attachment.js';
2
+ /** True when the built-in decoder can read this path's extension. */
3
+ export declare function builtinRasterSupports(filePath: string): boolean;
4
+ /**
5
+ * Decode a local PNG or JPEG into a crop/resize/encode surface.
6
+ * Unreadable or unsupported files yield an empty image.
7
+ */
8
+ export declare function createRasterImageDecoder(): ImageDecoder;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * PNG/JPEG decode, crop, resize, and re-encode for the Node host.
3
+ *
4
+ * `view_image` used to require an injected {@link ImageDecoder} (Electron
5
+ * `nativeImage`). The headless host has none, so any image larger than
6
+ * `maxEdge` failed with "当前环境不能缩放". This decoder is the default.
7
+ */
8
+ import { createRequire } from 'node:module';
9
+ import { readFileSync } from 'fs';
10
+ import { extname } from 'path';
11
+ const require = createRequire(import.meta.url);
12
+ const { PNG } = require('pngjs');
13
+ const jpeg = require('jpeg-js');
14
+ const RASTER_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg']);
15
+ /** True when the built-in decoder can read this path's extension. */
16
+ export function builtinRasterSupports(filePath) {
17
+ return RASTER_EXTENSIONS.has(extname(filePath).toLowerCase());
18
+ }
19
+ /**
20
+ * Decode a local PNG or JPEG into a crop/resize/encode surface.
21
+ * Unreadable or unsupported files yield an empty image.
22
+ */
23
+ export function createRasterImageDecoder() {
24
+ return {
25
+ createFromPath(filePath) {
26
+ const decoded = decodeRasterFile(filePath);
27
+ if (!decoded)
28
+ return EMPTY_IMAGE;
29
+ return new RasterImage(decoded.width, decoded.height, decoded.rgba);
30
+ },
31
+ };
32
+ }
33
+ function decodeRasterFile(filePath) {
34
+ if (!builtinRasterSupports(filePath))
35
+ return null;
36
+ let bytes;
37
+ try {
38
+ bytes = readFileSync(filePath);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ try {
44
+ const ext = extname(filePath).toLowerCase();
45
+ if (ext === '.png') {
46
+ const png = PNG.sync.read(bytes);
47
+ return { width: png.width, height: png.height, rgba: Buffer.from(png.data) };
48
+ }
49
+ const decoded = jpeg.decode(bytes, { useTArray: true, formatAsRGBA: true });
50
+ if (decoded.width < 1 || decoded.height < 1)
51
+ return null;
52
+ return {
53
+ width: decoded.width,
54
+ height: decoded.height,
55
+ rgba: Buffer.from(decoded.data),
56
+ };
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ class RasterImage {
63
+ width;
64
+ height;
65
+ rgba;
66
+ constructor(width, height, rgba) {
67
+ this.width = width;
68
+ this.height = height;
69
+ this.rgba = rgba;
70
+ }
71
+ isEmpty() {
72
+ return this.width < 1 || this.height < 1 || this.rgba.length < this.width * this.height * 4;
73
+ }
74
+ getSize() {
75
+ return { width: this.width, height: this.height };
76
+ }
77
+ crop(rect) {
78
+ return new RasterImage(rect.width, rect.height, cropRgba(this.rgba, this.width, rect.x, rect.y, rect.width, rect.height));
79
+ }
80
+ resize(options) {
81
+ const width = options.width ?? this.width;
82
+ const height = options.height ?? this.height;
83
+ return new RasterImage(width, height, resizeRgba(this.rgba, this.width, this.height, width, height));
84
+ }
85
+ toPNG() {
86
+ const png = new PNG({ width: this.width, height: this.height });
87
+ this.rgba.copy(png.data);
88
+ return PNG.sync.write(png);
89
+ }
90
+ toJPEG(quality) {
91
+ const encoded = jpeg.encode({ data: this.rgba, width: this.width, height: this.height }, quality);
92
+ return Buffer.from(encoded.data);
93
+ }
94
+ }
95
+ const EMPTY_IMAGE = {
96
+ isEmpty: () => true,
97
+ getSize: () => ({ width: 0, height: 0 }),
98
+ crop: () => EMPTY_IMAGE,
99
+ resize: () => EMPTY_IMAGE,
100
+ toPNG: () => Buffer.alloc(0),
101
+ toJPEG: () => Buffer.alloc(0),
102
+ };
103
+ function cropRgba(src, srcWidth, x, y, width, height) {
104
+ const dst = Buffer.alloc(width * height * 4);
105
+ for (let row = 0; row < height; row++) {
106
+ const srcStart = ((y + row) * srcWidth + x) * 4;
107
+ src.copy(dst, row * width * 4, srcStart, srcStart + width * 4);
108
+ }
109
+ return dst;
110
+ }
111
+ /**
112
+ * Area-average downscale. Each output pixel is the mean of the source
113
+ * rectangle it covers, so a long screenshot can be sent under `maxEdge`
114
+ * without aliasing into a single row of samples.
115
+ */
116
+ function resizeRgba(src, srcWidth, srcHeight, dstWidth, dstHeight) {
117
+ if (dstWidth === srcWidth && dstHeight === srcHeight)
118
+ return Buffer.from(src);
119
+ const dst = Buffer.alloc(Math.max(0, dstWidth * dstHeight * 4));
120
+ if (dstWidth < 1 || dstHeight < 1)
121
+ return dst;
122
+ for (let y = 0; y < dstHeight; y++) {
123
+ const y0 = Math.floor((y * srcHeight) / dstHeight);
124
+ const y1 = Math.min(srcHeight, Math.max(y0 + 1, Math.ceil(((y + 1) * srcHeight) / dstHeight)));
125
+ for (let x = 0; x < dstWidth; x++) {
126
+ const x0 = Math.floor((x * srcWidth) / dstWidth);
127
+ const x1 = Math.min(srcWidth, Math.max(x0 + 1, Math.ceil(((x + 1) * srcWidth) / dstWidth)));
128
+ let r = 0;
129
+ let g = 0;
130
+ let b = 0;
131
+ let a = 0;
132
+ let count = 0;
133
+ for (let sy = y0; sy < y1; sy++) {
134
+ const row = sy * srcWidth;
135
+ for (let sx = x0; sx < x1; sx++) {
136
+ const i = (row + sx) * 4;
137
+ r += src[i];
138
+ g += src[i + 1];
139
+ b += src[i + 2];
140
+ a += src[i + 3];
141
+ count += 1;
142
+ }
143
+ }
144
+ const o = (y * dstWidth + x) * 4;
145
+ dst[o] = Math.round(r / count);
146
+ dst[o + 1] = Math.round(g / count);
147
+ dst[o + 2] = Math.round(b / count);
148
+ dst[o + 3] = Math.round(a / count);
149
+ }
150
+ }
151
+ return dst;
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.6.60",
3
+ "version": "0.6.62",
4
4
  "description": "Steerable framework — product-neutral Node HostRuntime and headless HTTP server shared by Tauri desktop and browser deployments; scenario packs extend it through @steerable/pack-sdk.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/",
@@ -50,10 +50,12 @@
50
50
  "conf": "10.2.0",
51
51
  "dotenv": "^17.4.2",
52
52
  "he": "^1.2.0",
53
+ "jpeg-js": "^0.4.4",
53
54
  "node-pty": "^1.1.0",
54
- "@steerable/agent-harness": "0.6.60",
55
- "@steerable/agent-protocol": "0.6.60",
56
- "@steerable/pack-sdk": "0.6.60"
55
+ "pngjs": "^7.0.0",
56
+ "@steerable/agent-harness": "0.6.62",
57
+ "@steerable/agent-protocol": "0.6.62",
58
+ "@steerable/pack-sdk": "0.6.62"
57
59
  },
58
60
  "devDependencies": {
59
61
  "@types/better-sqlite3": "^7.6.13",