@turingflow/agent-kit 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +592 -2
  2. package/dist/agent.d.ts +27 -0
  3. package/dist/agent.js +31 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +105 -0
  6. package/dist/handlers.d.ts +92 -0
  7. package/dist/handlers.js +211 -0
  8. package/dist/index.d.ts +13 -0
  9. package/dist/index.js +10 -0
  10. package/dist/sdk.d.ts +106 -0
  11. package/dist/sdk.js +309 -0
  12. package/dist/server/agent.d.ts +27 -0
  13. package/dist/server/agent.js +31 -0
  14. package/dist/server/artifact-hooks.d.ts +6 -0
  15. package/dist/server/artifact-hooks.js +37 -0
  16. package/dist/server/artifact-tool-tracker.d.ts +24 -0
  17. package/dist/server/artifact-tool-tracker.js +347 -0
  18. package/dist/server/artifacts.d.ts +227 -0
  19. package/dist/server/artifacts.js +1061 -0
  20. package/dist/server/auth/config.d.ts +34 -0
  21. package/dist/server/auth/config.js +82 -0
  22. package/dist/server/auth/db.d.ts +159 -0
  23. package/dist/server/auth/db.js +680 -0
  24. package/dist/server/auth/handlers.d.ts +27 -0
  25. package/dist/server/auth/handlers.js +297 -0
  26. package/dist/server/auth/index.d.ts +15 -0
  27. package/dist/server/auth/index.js +14 -0
  28. package/dist/server/auth/login-options.d.ts +14 -0
  29. package/dist/server/auth/login-options.js +50 -0
  30. package/dist/server/auth/providers/dingtalk.d.ts +16 -0
  31. package/dist/server/auth/providers/dingtalk.js +177 -0
  32. package/dist/server/auth/providers/feishu.d.ts +15 -0
  33. package/dist/server/auth/providers/feishu.js +100 -0
  34. package/dist/server/auth/providers/index.d.ts +13 -0
  35. package/dist/server/auth/providers/index.js +24 -0
  36. package/dist/server/auth/session.d.ts +48 -0
  37. package/dist/server/auth/session.js +108 -0
  38. package/dist/server/auth/types.d.ts +118 -0
  39. package/dist/server/auth/types.js +7 -0
  40. package/dist/server/browser-agent-mcp.d.ts +67 -0
  41. package/dist/server/browser-agent-mcp.js +608 -0
  42. package/dist/server/cli.d.ts +6 -0
  43. package/dist/server/cli.js +112 -0
  44. package/dist/server/debug-log.d.ts +25 -0
  45. package/dist/server/debug-log.js +86 -0
  46. package/dist/server/handlers.d.ts +141 -0
  47. package/dist/server/handlers.js +523 -0
  48. package/dist/server/index.d.ts +30 -0
  49. package/dist/server/index.js +28 -0
  50. package/dist/server/kit-api.d.ts +23 -0
  51. package/dist/server/kit-api.js +153 -0
  52. package/dist/server/model-sources.d.ts +32 -0
  53. package/dist/server/model-sources.js +58 -0
  54. package/dist/server/oa.d.ts +87 -0
  55. package/dist/server/oa.js +397 -0
  56. package/dist/server/providers/openrouter.d.ts +51 -0
  57. package/dist/server/providers/openrouter.js +283 -0
  58. package/dist/server/render-markdown.d.ts +2 -0
  59. package/dist/server/render-markdown.js +50 -0
  60. package/dist/server/sdk.d.ts +269 -0
  61. package/dist/server/sdk.js +2025 -0
  62. package/dist/server/session-access.d.ts +5 -0
  63. package/dist/server/session-access.js +68 -0
  64. package/dist/server/session-fork.d.ts +10 -0
  65. package/dist/server/session-fork.js +142 -0
  66. package/dist/server/session-store.d.ts +56 -0
  67. package/dist/server/session-store.js +148 -0
  68. package/dist/server/sessions.d.ts +24 -0
  69. package/dist/server/sessions.js +541 -0
  70. package/dist/server/share.d.ts +15 -0
  71. package/dist/server/share.js +433 -0
  72. package/dist/server/tool-activity.d.ts +2 -0
  73. package/dist/server/tool-activity.js +26 -0
  74. package/dist/server/transcription/xiaomi-mimo.d.ts +8 -0
  75. package/dist/server/transcription/xiaomi-mimo.js +86 -0
  76. package/dist/server/upload.d.ts +13 -0
  77. package/dist/server/upload.js +81 -0
  78. package/dist/server/user-files.d.ts +28 -0
  79. package/dist/server/user-files.js +311 -0
  80. package/dist/server/voice.d.ts +25 -0
  81. package/dist/server/voice.js +61 -0
  82. package/dist/server/web-fetch.d.ts +71 -0
  83. package/dist/server/web-fetch.js +330 -0
  84. package/dist/styles.css +3354 -0
  85. package/dist/transcription/xiaomi-mimo.d.ts +8 -0
  86. package/dist/transcription/xiaomi-mimo.js +86 -0
  87. package/dist/ui/index.d.ts +358 -0
  88. package/dist/ui/index.js +7730 -0
  89. package/dist/upload.d.ts +13 -0
  90. package/dist/upload.js +81 -0
  91. package/dist/voice.d.ts +25 -0
  92. package/dist/voice.js +61 -0
  93. package/package.json +74 -4
@@ -0,0 +1,1061 @@
1
+ /**
2
+ * agent-kit · 产物(Artifact)管理
3
+ *
4
+ * 问题背景:模型用 Write 工具生成的 HTML / Markdown / 图片等「供用户查看的文件」,
5
+ * 原来会落到任意磁盘位置(如 apps/web/),且模型常在回复里复述真实路径,
6
+ * 既泄露服务器目录结构,前端也无法直接渲染。
7
+ *
8
+ * 本模块提供:
9
+ * - 会话隔离目录:<产物根>/.agent/<sessionId>/(选了项目 → 项目根/.agent/;无项目 → ~/.agent/),
10
+ * 每个会话只能看到自己的产出;
11
+ * - 模型只接触虚拟路径 /agent-output/<sessionId>/...,真实目录永不进入模型上下文;
12
+ * - 产物登记表(registry):模型按用户指示写到「任意位置」(如用户指定目录)的文件,
13
+ * 在执行层登记真实路径后即成为本会话产物——原位置即产物,不复制、不搬移进 .agent;
14
+ * 用户未指定位置时仍默认落 .agent/<sessionId>(见 buildArtifactPrompt);
15
+ * - 基线/增量扫描:一轮对话后找出新文件,作为产物推给前端;
16
+ * - 安全读取:readArtifact 严格限制在 session 目录内与已登记的产物路径上,任何穿越尝试返回 null;
17
+ * - 脱敏:redactOutputPaths 抹掉模型可能复述的 /agent-output 标记。
18
+ */
19
+ import fs from "node:fs/promises";
20
+ import { existsSync } from "node:fs";
21
+ import path from "node:path";
22
+ import os from "node:os";
23
+ import { createHash, randomUUID } from "node:crypto";
24
+ /** 真实磁盘产物目录名(隐藏目录:选了项目在项目根、无项目在 ~ 下),靠 .gitignore 排除 */
25
+ export const ARTIFACT_DIR_NAME = ".agent";
26
+ /** 模型上下文里的虚拟输出根:模型写文件用 /agent-output/<sessionId>/<name>(保持稳定,不改) */
27
+ export const ARTIFACT_VIRTUAL_ROOT = "/agent-output";
28
+ /** 产物根目录(真实磁盘位置,宿主侧):无工作区时统一放用户目录 ~/.agent */
29
+ export function artifactRoot() {
30
+ return path.join(os.homedir(), ARTIFACT_DIR_NAME);
31
+ }
32
+ /**
33
+ * CodeBuddy CLI 沙箱项目根:<homedir>/.codebuddy/projects/<cwd转写>/。
34
+ * 模型用 Bash/Python 等工具写文件时,默认落在沙箱会话目录 <project>/<sessionId>/(而非宿主 .agent),
35
+ * 因此产物收集与读取必须同时覆盖两个位置。转写规则:X:\a\b → x-a-b。
36
+ * cwdOverride:工作区模式下传工作目录(CLI cwd 已是工作目录,沙箱根随 cwd 变化);
37
+ * 目录不存在时返回 null(非 CodeBuddy CLI 环境不启用)。
38
+ */
39
+ export function sandboxProjectRoot(cwdOverride) {
40
+ try {
41
+ const cwd = cwdOverride && cwdOverride.trim() ? cwdOverride.trim() : process.cwd();
42
+ const m = cwd.match(/^([A-Za-z]):\\(.*)$/);
43
+ if (!m)
44
+ return null;
45
+ const proj = m[1].toLowerCase() + "-" + m[2].split("\\").join("-");
46
+ const root = path.join(os.homedir(), ".codebuddy", "projects", proj);
47
+ return existsSync(root) ? root : null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /** 工作区模式下某会话的产物根(<工作区>/.agent);无工作区返回宿主根 ~/.agent */
54
+ function artifactRootFor(workspaceDir) {
55
+ return workspaceDir && workspaceDir.trim() ? path.join(workspaceDir.trim(), ARTIFACT_DIR_NAME) : artifactRoot();
56
+ }
57
+ /** 某会话的候选产物目录:产物根/.agent/<sid> + 沙箱 <project>/<sid>(存在时),产物根优先 */
58
+ export function sessionDirCandidates(sessionId, workspaceDir) {
59
+ const s = sanitizeSessionId(sessionId);
60
+ const root = artifactRootFor(workspaceDir);
61
+ const list = [path.join(root, s)];
62
+ const sb = sandboxProjectRoot(workspaceDir);
63
+ if (sb)
64
+ list.push(path.join(sb, s));
65
+ return list;
66
+ }
67
+ /**
68
+ * 取会话「基 id」:编辑重发/重新生成时,前端会把发给服务端的 SDK transcript id
69
+ * 换成 `${sid}_${uuid}`(让 CLI 不加载旧 transcript,见 ChatWindow 的 rt.sdkSid),
70
+ * 产物目录因此跟着换成一个「分叉目录」。但产物**归属仍是原会话**——前端卡片与
71
+ * 预览请求一律带基 sid(activeSid)。会话 id 是 UUID 本身不含 '_',故 '_' 前段即基 id。
72
+ */
73
+ export function baseSessionId(sessionId) {
74
+ const s = String(sessionId || "");
75
+ const i = s.indexOf("_");
76
+ return i > 0 ? s.slice(0, i) : s;
77
+ }
78
+ /**
79
+ * 同源分叉目录列表:某基会话下所有 `<base>_<uuid>` 目录(宿主根 + 沙箱根)。
80
+ *
81
+ * 为什么需要:模型写文件时用的是当轮真实的 SDK transcript id(编辑重发 = 新 transcript),
82
+ * 于是文件落在分叉目录里;而读取/列举一律按基会话走。不把分叉目录并进来就会出现
83
+ * 「卡片在、点开 404」的错觉(文件明明在磁盘上)。按基 id 前缀扫一层目录即可覆盖,
84
+ * 根目录条目数量级为会话数,可接受。
85
+ */
86
+ export async function forkSessionDirs(sessionId, workspaceDir) {
87
+ const base = sanitizeSessionId(baseSessionId(sessionId));
88
+ const prefix = `${base}_`;
89
+ const roots = [artifactRootFor(workspaceDir)];
90
+ const sb = sandboxProjectRoot(workspaceDir);
91
+ if (sb)
92
+ roots.push(sb);
93
+ const out = [];
94
+ for (const r of roots) {
95
+ let entries;
96
+ try {
97
+ entries = await fs.readdir(r, { withFileTypes: true });
98
+ }
99
+ catch {
100
+ continue;
101
+ }
102
+ for (const e of entries) {
103
+ if (e.isDirectory() && e.name.startsWith(prefix))
104
+ out.push(path.join(r, e.name));
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+ /** 读取用:基目录候选 + 全部分叉目录(去重,保持「宿主根优先」的既有次序)。 */
110
+ export async function readDirCandidates(sessionId, workspaceDir) {
111
+ const out = [];
112
+ for (const d of [...sessionDirCandidates(sessionId, workspaceDir), ...(await forkSessionDirs(sessionId, workspaceDir))]) {
113
+ if (!out.includes(d))
114
+ out.push(d);
115
+ }
116
+ return out;
117
+ }
118
+ /** 只允许会话 ID 使用安全字符,杜绝路径穿越 */
119
+ export function sanitizeSessionId(s) {
120
+ const cleaned = String(s || "").replace(/[^a-zA-Z0-9_-]/g, "");
121
+ return cleaned || "unknown";
122
+ }
123
+ /** 清洗文件名:去除前导分隔符、去除 . 与 .. 段,防穿越;过长截断 */
124
+ export function sanitizeArtifactName(name) {
125
+ const parts = String(name)
126
+ .split(/[\\/]+/)
127
+ .filter((p) => p && p !== "." && p !== "..");
128
+ return parts.join("/").slice(0, 200) || "file";
129
+ }
130
+ /**
131
+ * 中间产物目录名(相对会话目录的**第一段**命中即视为中间产物,不向用户展示):
132
+ * - _drafts / _intermediate / _tmp:模型写草稿 / 临时 / 半成品文件的约定目录(见 buildArtifactPrompt);
133
+ * - tool-results:CodeBuddy CLI 沙箱会话目录里的内部工具执行记录(chatcmpl-tool-*-render.txt、
134
+ * mcp-web-fetch-*.txt 等),不是用户可见产物;
135
+ * - subagents:CLI 子代理(subagent)的 transcript / 结果目录(位于会话目录内 subagents/<id>/),
136
+ * 同样属于 SDK 内部文件,不是用户交互产物;
137
+ * - attachments:**用户上传附件的 staging 目录**(服务端把用户文件复制到 CLI cwd 的
138
+ * attachments/<rel>,见 handlers.ts stageVirtualCopy)。用户的文件永远不是模型生成的产物,
139
+ * 收集/列表必须整体跳过——否则用户截图会出现在「产物」面板里。
140
+ * 只有会话目录根下(及非保留子目录)的文件才是「最终产物」。
141
+ * 点开头(隐藏文件/目录)同样视为中间产物,避免 .DS_Store/.gitkeep 之类进入产物列表。
142
+ */
143
+ export const INTERMEDIATE_DIR_NAMES = new Set(["_drafts", "_intermediate", "_tmp", "tool-results", "subagents", "attachments"]);
144
+ /** 判断一个相对路径是否属于中间产物:第一段命中保留目录名 / 以点开头;
145
+ * 或**任意路径段**命中 CodeBuddy CLI 内部目录(tool-results / subagents / .codebuddy)——
146
+ * subagents/<id>/tool-results/xxx 这类 SDK 内部文件即使不在第一段也必须过滤。 */
147
+ export function isIntermediateRel(rel) {
148
+ const parts = String(rel).split(/[\\/]+/).filter(Boolean);
149
+ const first = parts[0] || "";
150
+ if (first.startsWith(".") || INTERMEDIATE_DIR_NAMES.has(first))
151
+ return true;
152
+ return parts.some((p) => p === "tool-results" || p === "subagents" || p === ".codebuddy");
153
+ }
154
+ /** CLI 内部绝对路径判定(registry 登记条目用):路径任意段命中 tool-results/subagents/.codebuddy
155
+ * → CodeBuddy SDK 内部文件,不是用户交互产物,不展示。 */
156
+ export function isCliInternalAbsPath(abs) {
157
+ return String(abs)
158
+ .split(/[\\/]+/)
159
+ .some((p) => p === "tool-results" || p === "subagents" || p === ".codebuddy");
160
+ }
161
+ /* ======================= 产物登记表(registry) =======================
162
+ * 策略:只要模型创建了文件,无论落在哪个目录都是本会话的输出物,原位置直接展示,
163
+ * 不复制、不搬移进 .agent。执行层(sdk.ts withArtifactRegistration)在写文件工具
164
+ * (Write/Edit/MultiEdit/NotebookEdit)获准执行时登记目标真实路径;Bash/Python 等
165
+ * shell 写入无法可靠解析路径,由工作目录根扫描兜底登记(见 collectWorkFiles)。
166
+ * 登记条目持久化在 <产物根>/.registry/<sid>.json(点开头隐藏目录,永远不会被
167
+ * isIntermediateRel / collectArtifacts 当作产物收集)。 */
168
+ /** 登记表目录名(在产物根下,点开头 = 中间产物,不进入产物清单) */
169
+ const REGISTRY_DIR_NAME = ".registry";
170
+ function registryFileFor(sessionId, workspaceDir) {
171
+ return path.join(artifactRootFor(workspaceDir), REGISTRY_DIR_NAME, sanitizeSessionId(sessionId) + ".json");
172
+ }
173
+ /** 读取会话登记表(损坏/不存在 → 空数组)。
174
+ * 含编辑重发/重新生成产生的分叉登记表(`<base>_<uuid>`):模型那一轮写的是分叉
175
+ * transcript id,登记项落在分叉登记表里,而读取一律按基会话走 → 必须合并,
176
+ * 否则「模型写到用户指定目录」的文件会出现同样的卡片 404。 */
177
+ export async function loadRegistry(sessionId, workspaceDir) {
178
+ const names = [sanitizeSessionId(sessionId)];
179
+ const base = sanitizeSessionId(baseSessionId(sessionId));
180
+ if (base !== names[0]) {
181
+ // 分叉登记表:<base>_<uuid>.json(uuid 段含 '-',sanitize 后仍保持唯一)
182
+ const regDir = path.join(artifactRootFor(workspaceDir), REGISTRY_DIR_NAME);
183
+ let entries;
184
+ try {
185
+ entries = await fs.readdir(regDir, { withFileTypes: true });
186
+ }
187
+ catch {
188
+ entries = [];
189
+ }
190
+ for (const e of entries) {
191
+ if (e.isFile() && e.name.startsWith(`${base}_`) && e.name.endsWith(".json"))
192
+ names.push(e.name.slice(0, -5));
193
+ }
194
+ }
195
+ const merged = [];
196
+ const seenAbs = new Set();
197
+ const seenId = new Set();
198
+ for (const n of names) {
199
+ let arr;
200
+ try {
201
+ arr = JSON.parse(await fs.readFile(registryFileFor(n, workspaceDir), "utf8"));
202
+ }
203
+ catch {
204
+ continue;
205
+ }
206
+ if (!Array.isArray(arr))
207
+ continue;
208
+ for (const e of arr) {
209
+ if (!e || typeof e !== "object" ||
210
+ typeof e.id !== "string" ||
211
+ typeof e.name !== "string" ||
212
+ typeof e.absPath !== "string")
213
+ continue;
214
+ // 同一路径/同一 id 只保留首个(基会话登记表优先,保持原有语义)
215
+ if (seenAbs.has(e.absPath) || seenId.has(e.id))
216
+ continue;
217
+ seenAbs.add(e.absPath);
218
+ seenId.add(e.id);
219
+ merged.push(e);
220
+ }
221
+ }
222
+ return merged;
223
+ }
224
+ /** 只读本会话自己的登记表文件(不合并分叉):读-改-写与落盘必须用本,避免把
225
+ * 「基 + 全部分叉」的合并结果写回某一个分叉文件,造成登记项跨文件漂移。 */
226
+ async function loadOwnRegistry(sessionId, workspaceDir) {
227
+ try {
228
+ const raw = await fs.readFile(registryFileFor(sessionId, workspaceDir), "utf8");
229
+ const arr = JSON.parse(raw);
230
+ if (!Array.isArray(arr))
231
+ return [];
232
+ return arr.filter((e) => !!e && typeof e === "object" &&
233
+ typeof e.id === "string" &&
234
+ typeof e.name === "string" &&
235
+ typeof e.absPath === "string");
236
+ }
237
+ catch {
238
+ return [];
239
+ }
240
+ }
241
+ /** 同一会话的读-改-写串行化,避免并行工具登记时覆盖其他文件。 */
242
+ const registryMutations = new Map();
243
+ async function mutateRegistry(sessionId, workspaceDir, update) {
244
+ const key = registryFileFor(sessionId, workspaceDir);
245
+ const previous = registryMutations.get(key) ?? Promise.resolve();
246
+ const operation = previous.catch(() => { }).then(async () => {
247
+ const entries = await loadOwnRegistry(sessionId, workspaceDir);
248
+ await update(entries);
249
+ await saveRegistry(sessionId, entries, workspaceDir);
250
+ });
251
+ registryMutations.set(key, operation);
252
+ try {
253
+ await operation;
254
+ }
255
+ finally {
256
+ if (registryMutations.get(key) === operation)
257
+ registryMutations.delete(key);
258
+ }
259
+ }
260
+ /** 原子替换登记表,读取列表不会看到半截 JSON。 */
261
+ export async function saveRegistry(sessionId, entries, workspaceDir) {
262
+ let temporary;
263
+ try {
264
+ const f = registryFileFor(sessionId, workspaceDir);
265
+ await fs.mkdir(path.dirname(f), { recursive: true });
266
+ temporary = `${f}.${randomUUID()}.tmp`;
267
+ await fs.writeFile(temporary, JSON.stringify(entries), "utf8");
268
+ await fs.rename(temporary, f);
269
+ }
270
+ catch {
271
+ /* 登记失败不阻塞主流程(产物仅少一次展示机会) */
272
+ }
273
+ finally {
274
+ if (temporary)
275
+ await fs.unlink(temporary).catch(() => { });
276
+ }
277
+ }
278
+ /** 把模型工具入参里的路径归一化为真实绝对路径:
279
+ * - /agent-output/<sid>/x → <产物根>/<sid>/x(与 pathAliases 同一映射,登记层拿原始入参时自行解析)
280
+ * - bash 风格 /d/a/b → D:\a\b(Windows)
281
+ * - 相对路径 → 相对执行 cwd 解析
282
+ * 无法解析返回 null。 */
283
+ export function normalizeArtifactAbsPath(rawPath, workspaceDir, cwd) {
284
+ let p = String(rawPath ?? "").trim();
285
+ if (!p)
286
+ return null;
287
+ if (p.startsWith(ARTIFACT_VIRTUAL_ROOT + "/")) {
288
+ const root = workspaceDir && workspaceDir.trim() ? path.join(workspaceDir.trim(), ARTIFACT_DIR_NAME) : artifactRoot();
289
+ p = path.join(root, p.slice(ARTIFACT_VIRTUAL_ROOT.length + 1).replace(/^[/\\]+/, ""));
290
+ }
291
+ else {
292
+ const m = p.match(/^\/([a-z])\/(.*)$/s);
293
+ if (m)
294
+ p = `${m[1].toUpperCase()}:\\${m[2].replace(/\//g, "\\")}`;
295
+ if (!path.isAbsolute(p))
296
+ p = path.resolve(cwd && cwd.trim() ? cwd.trim() : process.cwd(), p);
297
+ }
298
+ return path.normalize(p);
299
+ }
300
+ /** 极简稳定 hash(djb2 → 8 位 hex),用于任意路径产物的 id 后缀(防同名不同目录冲突) */
301
+ function hash8(s) {
302
+ let h = 5381;
303
+ for (let i = 0; i < s.length; i++)
304
+ h = ((h << 5) + h + s.charCodeAt(i)) | 0;
305
+ return (h >>> 0).toString(16).padStart(8, "0").slice(0, 8);
306
+ }
307
+ /**
308
+ * 登记一个产物路径(幂等:absPath 已登记则跳过)。id 规则:
309
+ * - 在会话目录候选内 → 相对会话目录路径(与扫描产物 id 一致,天然去重);
310
+ * - 工作目录根散落文件 → 文件名(与历史 collectWorkFiles id 一致,旧卡片可继续预览);
311
+ * - 其他任意路径(用户指定目录)→ ext-<hash8>-<basename>。
312
+ */
313
+ export async function registerArtifactPath(sessionId, rawPath, workspaceDir, cwd, origin = "model", before) {
314
+ try {
315
+ const abs = normalizeArtifactAbsPath(rawPath, workspaceDir, cwd);
316
+ if (!abs)
317
+ return;
318
+ // CLI 内部文件(tool-results / subagents / .codebuddy 沙箱)不登记为产物:
319
+ // 只有用户交互产生的文件(模型按用户指示 Write 的目标)才算产出物
320
+ if (isCliInternalAbsPath(abs))
321
+ return;
322
+ // 会话目录内的 attachments/ 子目录是用户上传附件的 staging 区(见 handlers.ts
323
+ // stageVirtualCopy):用户的文件永远不是模型生成的产物,模型意外写入此处也不登记。
324
+ // 覆盖分叉目录:本轮 sid 可能就是 `<base>_<uuid>`(编辑重发),而登记/收集另一侧
325
+ // 按基会话走,两边候选集不一致会让同一文件在登记与读取时算出不同 id。
326
+ for (const dir of await readDirCandidates(sessionId, workspaceDir)) {
327
+ const rel = path.relative(dir, abs);
328
+ if (rel && !rel.startsWith("..") && !path.isAbsolute(rel) && isIntermediateRel(rel)) {
329
+ return;
330
+ }
331
+ }
332
+ const base = path.basename(abs);
333
+ let id = "";
334
+ for (const dir of await readDirCandidates(sessionId, workspaceDir)) {
335
+ const rel = path.relative(dir, abs);
336
+ if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
337
+ id = sanitizeArtifactName(rel.split("\\").join("/"));
338
+ break;
339
+ }
340
+ }
341
+ // Dedicated session output directories contain generated files, including legacy
342
+ // outputs created before the registry recorded their creation identity.
343
+ const inSessionOutput = Boolean(id);
344
+ if (!id) {
345
+ const workRoot = workDirRoot(workspaceDir);
346
+ const relW = path.relative(workRoot, abs);
347
+ if (relW && !relW.startsWith("..") && !path.isAbsolute(relW) && !relW.includes(path.sep)) {
348
+ id = sanitizeArtifactName(relW.split("\\").join("/"));
349
+ }
350
+ else {
351
+ id = `ext-${hash8(abs)}-${sanitizeArtifactName(base)}`;
352
+ }
353
+ }
354
+ const initial = before ?? await snapshotFile(abs);
355
+ await mutateRegistry(sessionId, workspaceDir, (entries) => {
356
+ const existing = entries.find((e) => e.absPath === abs);
357
+ if (existing) {
358
+ if (origin === "model" && existing.origin === "scan") {
359
+ existing.origin = "model";
360
+ existing.createdBySession = inSessionOutput || initial.exists === false;
361
+ }
362
+ return;
363
+ }
364
+ entries.push({
365
+ id, name: base, absPath: abs, type: artifactType(base), lastSeen: false,
366
+ origin, registeredAt: Date.now(), createdBySession: origin === "model" && (inSessionOutput || initial.exists === false),
367
+ });
368
+ });
369
+ }
370
+ catch {
371
+ /* 忽略登记失败 */
372
+ }
373
+ }
374
+ const MIME = {
375
+ html: "text/html; charset=utf-8",
376
+ htm: "text/html; charset=utf-8",
377
+ md: "text/markdown; charset=utf-8",
378
+ markdown: "text/markdown; charset=utf-8",
379
+ png: "image/png",
380
+ jpg: "image/jpeg",
381
+ jpeg: "image/jpeg",
382
+ gif: "image/gif",
383
+ webp: "image/webp",
384
+ svg: "image/svg+xml",
385
+ pdf: "application/pdf",
386
+ txt: "text/plain; charset=utf-8",
387
+ json: "application/json; charset=utf-8",
388
+ csv: "text/csv; charset=utf-8",
389
+ };
390
+ /** 文本/代码类扩展名(可内嵌预览,前端 <pre> 等宽渲染);html/md/图片/pdf 已在 artifactType 前序分支处理 */
391
+ const TEXT_EXTS = new Set([
392
+ "txt", "json", "css", "js", "mjs", "cjs", "ts", "tsx", "jsx", "py", "csv",
393
+ "xml", "yaml", "yml", "toml", "ini", "sh", "bash", "bat", "ps1", "sql",
394
+ "java", "go", "rs", "c", "cpp", "h", "rb", "php", "vue", "svelte",
395
+ ]);
396
+ export function artifactType(name) {
397
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
398
+ if (ext === "html" || ext === "htm")
399
+ return "html";
400
+ if (ext === "md" || ext === "markdown")
401
+ return "markdown";
402
+ if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext))
403
+ return "image";
404
+ if (ext === "pdf")
405
+ return "pdf";
406
+ if (ext === "docx")
407
+ return "word";
408
+ if (TEXT_EXTS.has(ext))
409
+ return "text";
410
+ return "other";
411
+ }
412
+ export function mimeFor(name) {
413
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
414
+ return MIME[ext] || "application/octet-stream";
415
+ }
416
+ /** 确保会话目录存在并返回其路径(工作区模式下为 <工作区>/.agent/<sid>,无工作区 ~/.agent/<sid>) */
417
+ export async function ensureSessionDir(sessionId, workspaceDir) {
418
+ const root = artifactRootFor(workspaceDir);
419
+ const dir = path.join(root, sanitizeSessionId(sessionId));
420
+ await fs.mkdir(dir, { recursive: true });
421
+ // 根目录也确保存在(首次)
422
+ await fs.mkdir(root, { recursive: true });
423
+ return dir;
424
+ }
425
+ /** 文本 diff 白名单:这些扩展名的文件参与左右内容对比(基线读内容快照、收集带新旧内容);
426
+ * 其余(图片/pdf 等二进制)只标记变更类型,不携带内容(前端显示"无法对比"提示)。 */
427
+ const TEXT_DIFF_EXTS = new Set([
428
+ ...TEXT_EXTS,
429
+ "txt", "md", "markdown", "json", "css", "js", "mjs", "cjs", "ts", "tsx", "jsx", "py",
430
+ "html", "htm", "xml", "yaml", "yml", "toml", "sql", "sh", "bat", "svg", "ini", "conf", "log", "csv", "ipynb", "tex",
431
+ ]);
432
+ /** 文件是否参与文本 diff(扩展名在白名单内) */
433
+ export function isTextDiffable(name) {
434
+ const ext = String(name).split(".").pop()?.toLowerCase() ?? "";
435
+ return TEXT_DIFF_EXTS.has(ext);
436
+ }
437
+ /** 基线内容快照上限:超过此大小的文件视为不可 diff(超大文本/二进制),content 置 null */
438
+ const BASELINE_CONTENT_LIMIT = 512 * 1024;
439
+ /** 工作目录(模型写文件的 cwd)根散落文件纳入产物时的排除表:常见工程配置文件不作为产物展示 */
440
+ const WORK_DIR_EXCLUDE = [
441
+ /^package\.json$/i, /^package-lock\.json$/i, /^pnpm-(lock\.yaml|workspace\.yaml)$/i,
442
+ /^yarn\.lock$/i, /^tsconfig.*\.json$/i, /^next\.config\..*$/i, /^vite\.config\..*$/i,
443
+ /^eslint(\.config)?\..*$/i, /^prettier(\.config)?\..*$/i, /^\.env.*$/i, /^\.gitignore$/i,
444
+ /^\.npmrc$/i, /^\.gitattributes$/i, /^\.gitmodules$/i, /^LICENSE$/i,
445
+ // 运行时/工程说明文件:dev server 重写的 *.log、README/CHANGELOG/CONTRIBUTING 均非对话产物
446
+ /^.*\.log$/i, /^README.*$/i, /^CHANGELOG.*$/i, /^CONTRIBUTING.*$/i,
447
+ // jsconfig.json:Next.js 可读性配置(tsconfig 的 JS 版),绝不是模型写的产物
448
+ /^jsconfig\.json$/i,
449
+ ];
450
+ /** 模型写文件的工作目录:选了工作区用工作区,否则进程 cwd(模型默认在 cwd 写文件) */
451
+ function workDirRoot(workspaceDir) {
452
+ return workspaceDir && workspaceDir.trim() ? workspaceDir.trim() : process.cwd();
453
+ }
454
+ /** 工作目录根散落文件候选(仅根目录文件;子目录不扫——避免工程源码/依赖混入产物) */
455
+ async function workFileCandidates(workDir) {
456
+ const out = [];
457
+ let entries;
458
+ try {
459
+ entries = await fs.readdir(workDir, { withFileTypes: true });
460
+ }
461
+ catch {
462
+ return out;
463
+ }
464
+ for (const e of entries) {
465
+ if (!e.isFile())
466
+ continue;
467
+ if (isIntermediateRel(e.name))
468
+ continue;
469
+ if (WORK_DIR_EXCLUDE.some((re) => re.test(e.name)))
470
+ continue;
471
+ out.push({ name: e.name, abs: path.join(workDir, e.name) });
472
+ }
473
+ return out;
474
+ }
475
+ /**
476
+ * 把工作目录根**散落文件**登记为产物(幂等),保证列表/预览读原路径即可;不再复制副本进 .agent。
477
+ *
478
+ * 关键点:这里登记的文件只作「候选」,origin 一律记 scan —— 它们是工作目录里本来就在的
479
+ * 工程文件(jsconfig.json、README 之类)。已登记过的路径**不覆盖**原条目,
480
+ * 免得把 collectArtifacts 判定过的真产物降级成 scan。
481
+ */
482
+ async function ensureWorkRegistered(workDir, sessionId, workspaceDir) {
483
+ const registry = await loadRegistry(sessionId, workspaceDir);
484
+ const byAbs = new Map(registry.map((e) => [e.absPath, e]));
485
+ for (const f of await workFileCandidates(workDir)) {
486
+ const abs = path.join(workDir, f.name);
487
+ if (byAbs.has(abs))
488
+ continue; // 已登记(模型写的或上一次扫描登记的)→ 沿用原条目
489
+ await registerArtifactPath(sessionId, abs, workspaceDir, workDir, "scan");
490
+ }
491
+ }
492
+ /**
493
+ * 产物表里的登记条目该不该展示:
494
+ * - 非工作目录的登记条目(模型按用户指示写到任意位置,origin=model)→ 展示;
495
+ * - 工作目录根内 origin=model(模型 Write/Edit 工具显式登记)→ 展示;
496
+ * - 工作目录根内 origin=scan(列表兜底扫描登记的工程/外部进程文件)→ 一律不展示。
497
+ * 不做 mtime 启发式:外部进程(dev server 重写 .log、编辑器改 README)同样会让 mtime
498
+ * 变新,无法与「模型 shell 写入」可靠区分 → 保守不展示(纯聊天轮次不再凭空冒产物)。
499
+ */
500
+ async function shouldShowWorkFile(e, workDir) {
501
+ if (e.createdBySession === false || e.origin === "scan")
502
+ return false;
503
+ if (e.createdBySession === true)
504
+ return true;
505
+ const resolved = path.resolve(e.absPath);
506
+ const wd = path.resolve(workDir);
507
+ if (resolved !== wd && !resolved.startsWith(wd + path.sep))
508
+ return true; // 非工作目录产物:原逻辑
509
+ if ((e.origin ?? "scan") !== "scan") {
510
+ // 历史迁移:早期 collectWorkFiles 未传 origin 时,README/*.log/LICENSE 等工作目录根
511
+ // 工程文件被误标成 model。命中排除表的一律不展示(模型未来改 README 等也按此处理)。
512
+ if (WORK_DIR_EXCLUDE.some((re) => re.test(path.basename(resolved))))
513
+ return false;
514
+ return true; // 模型工具写的文件:就是产物
515
+ }
516
+ return false; // scan 条目(工程脚手架 / 运行时文件)一律不展示
517
+ }
518
+ /** collectArtifacts 用:工作目录根散落文件 → 登记原路径 + 变更(按 w: 基线判断 added/modified)。
519
+ * 产物即原文件本身(不复制、不搬移);源文件 mtime/size 与基线一致(未变)则跳过;
520
+ * 重写(源变)记为 modified,首次为 added。 */
521
+ async function collectWorkFiles(workDir, sessionId, workspaceDir, baseline) {
522
+ const out = [];
523
+ for (const f of await workFileCandidates(workDir)) {
524
+ const key = "w:" + f.name;
525
+ const stamp = baseline.get(key);
526
+ try {
527
+ const ss = await fs.stat(f.abs, { bigint: true });
528
+ if (stamp && stamp.mtimeNs === ss.mtimeNs && stamp.size === Number(ss.size))
529
+ continue;
530
+ // origin 必须显式传 scan:工作目录根散落文件是「工程/外部进程文件」,不是模型工具写的真产物。
531
+ // 不传会落默认 model → shouldShowWorkFile 放行 → README/dev-restart-out.log 被误当对话产物。
532
+ await registerArtifactPath(sessionId, f.abs, workspaceDir, workDir, "scan");
533
+ const change = {
534
+ id: sanitizeArtifactName(f.name),
535
+ name: f.name,
536
+ type: artifactType(f.name),
537
+ size: Number(ss.size),
538
+ change: stamp ? "modified" : "added",
539
+ };
540
+ if (isTextDiffable(f.name) && Number(ss.size) <= BASELINE_CONTENT_LIMIT) {
541
+ change.oldContent = stamp?.content ?? null;
542
+ change.newContent = await fs.readFile(f.abs, "utf8").catch(() => null);
543
+ }
544
+ out.push(change);
545
+ }
546
+ catch {
547
+ /* 忽略并发/权限 */
548
+ }
549
+ }
550
+ return out;
551
+ }
552
+ /** 对常见文档保留摘要,即使重写改变 mtime 也能识别内容没有变化。 */
553
+ async function snapshotFile(absPath) {
554
+ try {
555
+ const stat = await fs.stat(absPath, { bigint: true });
556
+ if (!stat.isFile())
557
+ return { exists: false, mtimeNs: 0n, size: 0, content: null };
558
+ const size = Number(stat.size);
559
+ const data = size <= 16 * 1024 * 1024 ? await fs.readFile(absPath).catch(() => null) : null;
560
+ return {
561
+ exists: true,
562
+ mtimeNs: stat.mtimeNs,
563
+ size,
564
+ content: data && size <= BASELINE_CONTENT_LIMIT && isTextDiffable(absPath) && !data.includes(0)
565
+ ? data.toString("utf8") : null,
566
+ digest: data ? createHash("sha256").update(data).digest("hex") : undefined,
567
+ };
568
+ }
569
+ catch (error) {
570
+ if (["ENOENT", "ENOTDIR"].includes(error.code ?? "")) {
571
+ return { exists: false, mtimeNs: 0n, size: 0, content: null };
572
+ }
573
+ throw error;
574
+ }
575
+ }
576
+ function sameFile(before, after) {
577
+ if (!before || before.exists === false || after.exists === false)
578
+ return false;
579
+ if (before.digest && after.digest)
580
+ return before.digest === after.digest;
581
+ if (before.content != null && after.content != null)
582
+ return before.content === after.content;
583
+ return before.mtimeNs === after.mtimeNs && before.size === after.size;
584
+ }
585
+ /**
586
+ * 基线:记录某会话「开始前」已存在的产物(宿主 + 沙箱两个位置的会话目录,以及产物根下散落文件),
587
+ * 返回 key("s:"/"t:" + 文件名)→ 文件戳,用于增量与更新识别。
588
+ */
589
+ export async function baselineArtifacts(sessionId, workspaceDir) {
590
+ const map = new Map();
591
+ const stamp = async (dir, prefix, rel = "") => {
592
+ let entries;
593
+ try {
594
+ entries = await fs.readdir(dir, { withFileTypes: true });
595
+ }
596
+ catch {
597
+ return;
598
+ }
599
+ for (const e of entries) {
600
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
601
+ if (isIntermediateRel(childRel))
602
+ continue;
603
+ if (e.isDirectory() && prefix === "s:") {
604
+ await stamp(path.join(dir, e.name), prefix, childRel);
605
+ continue;
606
+ }
607
+ if (!e.isFile())
608
+ continue;
609
+ try {
610
+ // 相同虚拟路径的宿主文件优先,与 readArtifact 的读取次序一致。
611
+ if (!map.has(prefix + childRel))
612
+ map.set(prefix + childRel, await snapshotFile(path.join(dir, e.name)));
613
+ }
614
+ catch {
615
+ /* 文件被并发删除等情况忽略 */
616
+ }
617
+ }
618
+ };
619
+ // 工作目录根散落文件(模型按用户指示写到 cwd,如「放到根目录」)也入基线(w: 前缀),
620
+ // 用于增量识别(首次 added / 重写 modified);工程配置/隐藏文件排除
621
+ const stampWorkDir = async (dir) => {
622
+ let entries;
623
+ try {
624
+ entries = await fs.readdir(dir, { withFileTypes: true });
625
+ }
626
+ catch {
627
+ return;
628
+ }
629
+ for (const e of entries) {
630
+ if (!e.isFile())
631
+ continue;
632
+ if (isIntermediateRel(e.name))
633
+ continue;
634
+ if (WORK_DIR_EXCLUDE.some((re) => re.test(e.name)))
635
+ continue;
636
+ try {
637
+ map.set("w:" + e.name, await snapshotFile(path.join(dir, e.name)));
638
+ }
639
+ catch {
640
+ /* 忽略 */
641
+ }
642
+ }
643
+ };
644
+ // 基线覆盖基目录 + 全部分叉目录(编辑重发轮次写的文件也算本会话既有产物,
645
+ // 否则下一轮会把上一轮的文件全判成 added)
646
+ for (const dir of await readDirCandidates(sessionId, workspaceDir)) {
647
+ await stamp(dir, "s:");
648
+ }
649
+ await stamp(artifactRootFor(workspaceDir), "t:");
650
+ await stampWorkDir(workDirRoot(workspaceDir));
651
+ // 登记在任意目录的既有文件也必须有本轮基线,尤其用于 shell 修改和删除的旧内容。
652
+ for (const entry of await loadRegistry(sessionId, workspaceDir)) {
653
+ if (entry.origin === "scan" || isCliInternalAbsPath(entry.absPath))
654
+ continue;
655
+ try {
656
+ map.set("r:" + entry.absPath, await snapshotFile(entry.absPath));
657
+ }
658
+ catch { /* 无权读取的文件不影响本轮 */ }
659
+ }
660
+ return map;
661
+ }
662
+ /**
663
+ * 写工具执行前采集:目标文件在本轮编辑前是否已存在 + 文本内容快照(供 diff 旧内容)。
664
+ * 写入 into(键 = 归一化绝对路径)。同一文件本轮多次编辑只保留首次快照(= 本轮开始前状态)。
665
+ *
666
+ * 用途:模型用 Edit/Write 改「已有文件」(如工作区里的工程源码)时,登记表(registry)新建
667
+ * 条目的 lastSeen 恒为 false,光靠 collectArtifacts 无法区分「新建文件」与「编辑已有文件」,
668
+ * 会把编辑误判为 added(→ 只进「产物」不进「变更」,且缺旧内容无法 diff)。执行前若文件已存在,
669
+ * 不存在也保留快照:本轮先 Write 新建再 Edit 时,仍按新建文件归类。
670
+ */
671
+ export async function capturePreWriteSnapshot(absPath, into) {
672
+ if (into.has(absPath))
673
+ return;
674
+ try {
675
+ const snapshot = await snapshotFile(absPath);
676
+ if (!into.has(absPath))
677
+ into.set(absPath, snapshot);
678
+ }
679
+ catch {
680
+ /* 权限错误不应误判为文件不存在 */
681
+ }
682
+ }
683
+ /**
684
+ * 一轮对话结束后收集本轮「变更」(新增、内容更新、删除):
685
+ * - 会话子目录(宿主 .agent 与 CodeBuddy 沙箱两处)里的新文件/更新文件记为变更(added/modified),
686
+ * 递归扫描子目录(模型常把产物写进 brand-icons/ 等子目录,此前只扫顶层导致卡片缺失);
687
+ * - 中间产物目录(_drafts/、_intermediate/、_tmp/ 及点开头隐藏项)一律不收集,
688
+ * 只有会话目录根下(及非保留子目录)的文件才作为「最终产物」呈现给用户;
689
+ * - 宿主根目录下「散落」的新**文件**(模型忘记带 sessionId 子目录)移入会话子目录,保证隔离;
690
+ * **根目录下的目录不做任何搬移/扫描**——它可能是其他会话自己的目录,搬入会造成跨会话产物混入;
691
+ * - 同名文件被重新写入(覆盖/更新,mtime 或大小变化)记为 modified;
692
+ * - 删除检测:会话子目录内基线存在、本轮结束后磁盘已无 → deleted(根目录散落文件不判,
693
+ * 它们可能被搬移进会话目录或属其他会话,误判风险高);
694
+ * - 文本文件(isTextDiffable 且 ≤ 上限)携带 oldContent/newContent 供前端左右 diff;
695
+ * 二进制/超大文件只标记变更类型。
696
+ * 返回变更清单(新增/修改在前、删除在后,按 id 去重)。id 为相对会话目录的 posix 路径。
697
+ */
698
+ export async function collectArtifacts(sessionId, baseline, workspaceDir,
699
+ /** 写工具执行前的目标文件快照(键 = 归一化绝对路径):区分「编辑已有文件」与「新建文件」并提供 diff 旧内容 */
700
+ preWrite) {
701
+ const sessionDir = await ensureSessionDir(sessionId, workspaceDir);
702
+ const root = artifactRootFor(workspaceDir);
703
+ const found = [];
704
+ const diskKeys = new Set(); // 本轮结束后磁盘上仍存在的 key(deleted 检测用)
705
+ /** 递归扫描一个目录;rel 为相对该扫描根的 posix 路径(顶层文件为 "",子目录为 "sub/") */
706
+ const consider = async (dir, prefix, moveTo, rel) => {
707
+ let entries;
708
+ try {
709
+ entries = await fs.readdir(dir, { withFileTypes: true });
710
+ }
711
+ catch {
712
+ return;
713
+ }
714
+ for (const e of entries) {
715
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
716
+ const key = prefix + childRel;
717
+ if (e.isDirectory()) {
718
+ // 根目录(t:)下的目录一律不搬移、不扫描:它可能是**其他会话自己的目录**
719
+ //(如另一场对话的 .agent/<sid>/),整体搬进来会造成跨会话产物混入。
720
+ // 只处理「模型忘记带 sessionId」的散落**文件**;散落子目录属罕见且无法安全归因,留原地即可。
721
+ if (prefix === "t:")
722
+ continue;
723
+ // 中间产物目录(_drafts/ 等)不进入产物清单
724
+ if (isIntermediateRel(childRel))
725
+ continue;
726
+ await consider(path.join(dir, e.name), prefix, moveTo, childRel);
727
+ continue;
728
+ }
729
+ if (!e.isFile())
730
+ continue;
731
+ // 中间产物不收集(_drafts/ 下的草稿、点开头隐藏文件)
732
+ if (isIntermediateRel(childRel))
733
+ continue;
734
+ // 文件戳:新增(基线无记录)或内容更新(mtime/大小变化)都算本轮产物
735
+ const absolutePath = path.join(dir, e.name);
736
+ const current = await snapshotFile(absolutePath).catch(() => null);
737
+ if (!current || current.exists === false)
738
+ continue;
739
+ if (diskKeys.has(key))
740
+ continue; // 宿主目录优先,重复虚拟路径只处理一次
741
+ diskKeys.add(key);
742
+ const stamp = baseline.get(key);
743
+ if (sameFile(stamp, current))
744
+ continue;
745
+ if (prefix === "t:" && moveTo) {
746
+ // 散落根目录的文件 → 移入会话子目录,确保跨会话不串
747
+ const src = path.join(dir, e.name);
748
+ const dst = path.join(moveTo, sanitizeArtifactName(childRel));
749
+ try {
750
+ await fs.rename(src, dst);
751
+ }
752
+ catch {
753
+ try {
754
+ const buf = await fs.readFile(src);
755
+ await fs.writeFile(dst, buf);
756
+ await fs.unlink(src);
757
+ }
758
+ catch {
759
+ /* 忽略无法移动的文件 */
760
+ }
761
+ }
762
+ }
763
+ const id = sanitizeArtifactName(childRel);
764
+ const change = {
765
+ id,
766
+ name: childRel,
767
+ type: artifactType(e.name),
768
+ size: current.size,
769
+ change: stamp && stamp.exists !== false ? "modified" : "added",
770
+ };
771
+ change.oldContent = stamp?.content ?? (change.change === "added" ? "" : null);
772
+ change.newContent = current.content ?? null;
773
+ found.push(change);
774
+ // shell 等工具直接写会话目录时也持久化产出身份,后续 Edit 不会把它误当成用户既有文件。
775
+ if (change.change === "added") {
776
+ const trackedPath = prefix === "t:" && moveTo ? path.join(moveTo, id) : absolutePath;
777
+ await registerArtifactPath(sessionId, trackedPath, workspaceDir, undefined, "model", {
778
+ exists: false, mtimeNs: 0n, size: 0, content: null,
779
+ });
780
+ }
781
+ }
782
+ };
783
+ // 收集覆盖基目录 + 全部分叉目录:编辑重发/重新生成那一轮,模型写的是分叉
784
+ // transcript id 的目录,只扫基目录会漏推这一轮的产物卡片。
785
+ // 本轮 sid 自身对应的目录排在最前(宿主根优先的既有次序不变)。
786
+ for (const dir of await readDirCandidates(sessionId, workspaceDir)) {
787
+ await consider(dir, "s:", null, "");
788
+ }
789
+ await consider(root, "t:", sessionDir, "");
790
+ // 工作目录根散落文件(README/.log 等工程或外部进程文件):只登记(origin=scan),
791
+ // 不产生本轮变更推送——它们不是对话产物;模型按指示用 Write 写 cwd 根的文件会以
792
+ // origin=model 走 registry 段正常推送(见下),此处只兜底登记,不做展示。
793
+ await collectWorkFiles(workDirRoot(workspaceDir), sessionId, workspaceDir, baseline);
794
+ // 删除检测:会话子目录(s:)内基线存在、磁盘已无 → deleted(t: 散落 / w: 工作目录不判,
795
+ // 副本保留在会话目录,误判风险高)
796
+ for (const [key, stamp] of baseline) {
797
+ if (!key.startsWith("s:") || stamp.exists === false)
798
+ continue;
799
+ if (diskKeys.has(key))
800
+ continue;
801
+ const id = sanitizeArtifactName(key.slice(2));
802
+ found.push({
803
+ id,
804
+ name: id,
805
+ type: artifactType(id),
806
+ size: stamp.size,
807
+ change: "deleted",
808
+ oldContent: stamp?.content ?? null,
809
+ });
810
+ }
811
+ // —— 登记产物(模型写到任意位置的文件,见 registerArtifactPath)——
812
+ // 逐个对比磁盘状态与上次收集戳:编辑已有文件 → modified(带旧内容);新登记且此前不存在 →
813
+ // added;戳变化 → modified;上次存在、本次消失 → deleted;登记过但从未写成功 → 忽略(不误报 deleted)。
814
+ // 「此前是否存在」由执行前快照 preWrite 判定(登记条目 lastSeen 恒 false,单凭它会把编辑误判为 added)。
815
+ // 登记条目随收集更新 lastSeen/戳并持久化。
816
+ await mutateRegistry(sessionId, workspaceDir, async (registry) => {
817
+ for (const e of registry) {
818
+ // scan 条目(工作目录根工程/外部进程文件)不产生变更推送:只做登记兜底,永不推卡片
819
+ if (e.origin === "scan")
820
+ continue;
821
+ // 有本轮工具追踪时,只将实际观察到的写入归因给 Agent。
822
+ // 历史登记的工作区文件被编辑器/外部进程改动,不应在纯聊天轮次中冒出变更。
823
+ if (preWrite && !preWrite.has(e.absPath))
824
+ continue;
825
+ // CLI 内部文件(tool-results / subagents / .codebuddy 沙箱)不收集为产物
826
+ if (isCliInternalAbsPath(e.absPath))
827
+ continue;
828
+ if ((await readDirCandidates(sessionId, workspaceDir)).some((dir) => {
829
+ const rel = path.relative(dir, e.absPath);
830
+ return rel && !rel.startsWith("..") && !path.isAbsolute(rel) && isIntermediateRel(rel);
831
+ }))
832
+ continue;
833
+ const current = await snapshotFile(e.absPath).catch(() => null);
834
+ if (!current)
835
+ continue;
836
+ const wasSeen = e.lastSeen;
837
+ const pre = baseline.get("r:" + e.absPath) ?? preWrite?.get(e.absPath);
838
+ const before = pre ?? (wasSeen && e.mtimeNs != null
839
+ ? { exists: true, mtimeNs: BigInt(e.mtimeNs), size: e.size ?? 0, content: null } : undefined);
840
+ if (current.exists === false) {
841
+ if (before?.exists !== false && (before || wasSeen)) {
842
+ found.push({
843
+ id: e.id,
844
+ name: e.name,
845
+ type: e.type,
846
+ size: before?.size ?? e.size ?? 0,
847
+ change: "deleted",
848
+ oldContent: before?.content ?? null,
849
+ newContent: "",
850
+ });
851
+ }
852
+ e.lastSeen = false;
853
+ e.mtimeNs = undefined;
854
+ e.size = undefined;
855
+ continue;
856
+ }
857
+ const size = current.size;
858
+ const mtimeNs = String(current.mtimeNs);
859
+ const unchanged = sameFile(before, current);
860
+ e.lastSeen = true;
861
+ e.mtimeNs = mtimeNs;
862
+ e.size = size;
863
+ if (unchanged)
864
+ continue;
865
+ const change = {
866
+ id: e.id,
867
+ name: e.name,
868
+ type: e.type,
869
+ size,
870
+ change: before && before.exists !== false ? "modified" : "added",
871
+ };
872
+ change.oldContent = before?.content ?? (change.change === "added" ? "" : null);
873
+ change.newContent = current.content ?? null;
874
+ found.push(change);
875
+ }
876
+ });
877
+ const seen = new Set();
878
+ return found.filter((a) => (seen.has(a.id) ? false : (seen.add(a.id), true)));
879
+ }
880
+ /**
881
+ * 列出某会话的全部产物(递归,含非保留子目录),用于「查看所有产物 (N)」按钮与右侧列表面板。
882
+ * 与 collectArtifacts 不同:不做基线增量,直接返回磁盘上当前存在的全部文件;
883
+ * 中间产物目录(_drafts/ 等)与隐藏文件不进入列表,只呈现最终产物。
884
+ * 会话目录不存在时返回空数组。
885
+ */
886
+ export async function listSessionArtifacts(sessionId, workspaceDir) {
887
+ const safeSession = sanitizeSessionId(sessionId);
888
+ const seen = new Set();
889
+ const out = [];
890
+ await ensureWorkRegistered(workDirRoot(workspaceDir), sessionId, workspaceDir);
891
+ const registry = await loadRegistry(sessionId, workspaceDir);
892
+ const editedOnly = new Set(registry.filter((e) => e.createdBySession === false && e.origin !== "scan").map((e) => e.absPath));
893
+ const walk = async (dir, rel) => {
894
+ let entries;
895
+ try {
896
+ entries = await fs.readdir(dir, { withFileTypes: true });
897
+ }
898
+ catch {
899
+ return;
900
+ }
901
+ for (const e of entries) {
902
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
903
+ if (e.isDirectory()) {
904
+ // 中间产物目录不进入列表(_drafts/ 等草稿目录)
905
+ if (isIntermediateRel(childRel))
906
+ continue;
907
+ await walk(path.join(dir, e.name), childRel);
908
+ continue;
909
+ }
910
+ if (!e.isFile())
911
+ continue;
912
+ // 中间产物文件(保留目录名 / 点开头隐藏文件)不向用户展示
913
+ if (isIntermediateRel(childRel))
914
+ continue;
915
+ if (editedOnly.has(path.join(dir, e.name)))
916
+ continue;
917
+ const stat = await fs
918
+ .stat(path.join(dir, e.name), { bigint: true })
919
+ .catch(() => null);
920
+ if (!stat)
921
+ continue;
922
+ const id = sanitizeArtifactName(childRel);
923
+ if (seen.has(id))
924
+ continue;
925
+ seen.add(id);
926
+ out.push({
927
+ id,
928
+ name: childRel,
929
+ type: artifactType(e.name),
930
+ size: Number(stat.size),
931
+ mtimeMs: Number(stat.mtimeMs),
932
+ dir,
933
+ });
934
+ }
935
+ };
936
+ for (const dir of await readDirCandidates(safeSession, workspaceDir)) {
937
+ await walk(dir, "");
938
+ }
939
+ // 登记产物(任意位置创建的文件):当前仍存在于磁盘的条目并入列表
940
+ const workDir = workDirRoot(workspaceDir);
941
+ for (const e of registry) {
942
+ // CLI 内部文件(tool-results / subagents / .codebuddy 沙箱)不展示
943
+ if (isCliInternalAbsPath(e.absPath))
944
+ continue;
945
+ if ((await readDirCandidates(sessionId, workspaceDir)).some((dir) => {
946
+ const rel = path.relative(dir, e.absPath);
947
+ return rel && !rel.startsWith("..") && !path.isAbsolute(rel) && isIntermediateRel(rel);
948
+ }))
949
+ continue;
950
+ // 工作目录根散落文件:登记时刻之前就存在 → 工程脚手架文件,不是本轮产物(不展示)
951
+ if (!(await shouldShowWorkFile(e, workDir)))
952
+ continue;
953
+ if (seen.has(e.id))
954
+ continue;
955
+ const stat = await fs
956
+ .stat(e.absPath, { bigint: true })
957
+ .catch(() => null);
958
+ if (!stat)
959
+ continue;
960
+ seen.add(e.id);
961
+ out.push({
962
+ id: e.id,
963
+ name: e.name,
964
+ type: e.type,
965
+ size: Number(stat.size),
966
+ mtimeMs: Number(stat.mtimeMs),
967
+ dir: path.dirname(e.absPath),
968
+ });
969
+ }
970
+ // 最新产物在前:右侧列表首项与自动预览(list[0])都指向最新生成的文件
971
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
972
+ return out.map(({ mtimeMs, ...meta }) => meta);
973
+ }
974
+ /** 安全读取产物:在产物根与沙箱候选会话目录内查找(产物根优先),任何穿越尝试返回 null;
975
+ * 会话目录未命中时,回查登记表(registry):模型写到任意位置的文件按登记 id/name 读原路径,
976
+ * 仅限已登记的真实路径(任意 id 无法读取未登记文件)。 */
977
+ export async function readArtifact(sessionId, id, workspaceDir) {
978
+ const safeSession = sanitizeSessionId(sessionId);
979
+ const safeId = sanitizeArtifactName(id);
980
+ for (const dir of await readDirCandidates(safeSession, workspaceDir)) {
981
+ const expected = path.resolve(dir);
982
+ const resolved = path.resolve(dir, safeId);
983
+ if (resolved !== expected && !resolved.startsWith(expected + path.sep))
984
+ continue;
985
+ try {
986
+ const buf = await fs.readFile(resolved);
987
+ // docx 原文件直接返回:前端 WordArtifact 用 mammoth(浏览器版) 转换渲染,输入/产物统一走原文件
988
+ return { buf, mime: mimeFor(safeId) };
989
+ }
990
+ catch {
991
+ /* 尝试下一个候选目录 */
992
+ }
993
+ }
994
+ // 登记产物(原位置即产物):按 id(ext-xxx-... 或工作目录根文件名)精确匹配登记条目
995
+ for (const e of await loadRegistry(sessionId, workspaceDir)) {
996
+ if (e.id !== safeId && e.name !== safeId)
997
+ continue;
998
+ // 双重校验:只读登记过的真实路径本身(路径穿越 / 篡改一律拒绝)
999
+ if (path.resolve(e.absPath) !== e.absPath)
1000
+ continue;
1001
+ try {
1002
+ const buf = await fs.readFile(e.absPath);
1003
+ return { buf, mime: mimeFor(e.name) };
1004
+ }
1005
+ catch {
1006
+ return null;
1007
+ }
1008
+ }
1009
+ return null;
1010
+ }
1011
+ /** 构造前端拉取产物的隔离 URL(session 与 file 均经编码,服务端二次校验) */
1012
+ export function artifactUrl(basePath, sessionId, id) {
1013
+ const bp = (basePath || "/").replace(/\/+$/, "");
1014
+ return `${bp}/api/artifact?session=${encodeURIComponent(sessionId)}&file=${encodeURIComponent(id)}`;
1015
+ }
1016
+ /**
1017
+ * 脱敏(第一道):把「真实产物根目录」替换成虚拟标记 /agent-output。
1018
+ * 同时处理 Windows 盘符路径(D:\...\agent-output)与 CLI 使用的 bash 风格路径(/d/.../agent-output),
1019
+ * 避免任一真实路径残留在模型输出中。
1020
+ */
1021
+ export function redactArtifactPaths(text) {
1022
+ const root = artifactRoot();
1023
+ const posix = root.replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_, d) => `/${d.toLowerCase()}`);
1024
+ return String(text ?? "").split(root).join(ARTIFACT_VIRTUAL_ROOT).split(posix).join(ARTIFACT_VIRTUAL_ROOT);
1025
+ }
1026
+ /**
1027
+ * 脱敏:抹掉模型可能复述的虚拟输出路径标记(/agent-output...),
1028
+ * 避免内部约定外泄。配合系统提示(不要提路径)作为安全网。
1029
+ */
1030
+ export function redactOutputPaths(text) {
1031
+ return String(text ?? "").replace(/\/agent-output(?:\/[^\s)<>"']*)?/g, "");
1032
+ }
1033
+ /**
1034
+ * 清洗助手文本里的限流噪音(服务端源头,防止 429 错误进入回复正文/存库):
1035
+ * - CLI transcript 会给消息加 trace 前缀((32hex/uuid)),剥离前缀、保留正文;
1036
+ * - 429 频率限制错误整段(可能带 trace 前缀/后缀)直接丢弃;
1037
+ * - 内容若清洗后为空(纯错误轮次),返回空串,前端不展示空气泡。
1038
+ */
1039
+ export function sanitizeAssistantText(text) {
1040
+ if (!text)
1041
+ return text;
1042
+ const out = [];
1043
+ for (const rawLine of String(text).split("\n")) {
1044
+ // 保留行首缩进(代码块排版依赖),清洗只作用于行内容本身:
1045
+ // 此前对每行 trim() 会系统性剥掉代码块缩进(44/44 代码块行首空格丢失的根因)。
1046
+ const leading = /^[ \t]*/.exec(rawLine)?.[0] ?? "";
1047
+ const trimmed = rawLine.trim();
1048
+ if (!trimmed) {
1049
+ out.push("");
1050
+ continue;
1051
+ }
1052
+ let rest = trimmed.replace(/^\(\s*[0-9a-fA-F]{8,}\s*\/\s*[0-9a-fA-F-]{8,}[^)\n]*\)\s*/, "");
1053
+ if (/429/.test(rest) && /频率限制|rate\s*[- ]?limit/i.test(rest))
1054
+ continue;
1055
+ if (!rest.trim())
1056
+ continue;
1057
+ rest = rest.replace(/429[^\n]*?频率限制[^\n]*$/i, "");
1058
+ out.push(leading + rest);
1059
+ }
1060
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
1061
+ }