@steerable/agent-shell 0.6.61 → 0.6.63

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.
@@ -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
  }