@dai_ming/plugin-deliverables 1.0.20 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/INSTALL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # plugin-deliverables 安装文档
2
2
 
3
- 本文档描述 `@dai_ming/plugin-deliverables@1.0.16` 的安装、升级与验证方式。
3
+ 本文档描述 `@dai_ming/plugin-deliverables@1.0.21` 的安装、升级与验证方式。
4
4
 
5
5
  ## 1. 目标
6
6
 
@@ -22,7 +22,7 @@
22
22
  适用于已经支持 `openclaw plugins install` 的运行环境。
23
23
 
24
24
  ```bash
25
- openclaw plugins install @dai_ming/plugin-deliverables@1.0.16 --pin
25
+ openclaw plugins install @dai_ming/plugin-deliverables@1.0.21 --pin
26
26
  openclaw plugins enable plugin-deliverables
27
27
  ```
28
28
 
@@ -47,7 +47,7 @@ openclaw plugins list
47
47
 
48
48
  ```yaml
49
49
  installPlugins:
50
- - "@dai_ming/plugin-deliverables@1.0.16"
50
+ - "@dai_ming/plugin-deliverables@1.0.21"
51
51
  ```
52
52
 
53
53
  Helm 初始化时会自动完成:
@@ -66,8 +66,8 @@ Helm 初始化时会自动完成:
66
66
  ```bash
67
67
  mkdir -p /home/node/.openclaw/extensions-extra/plugin-deliverables
68
68
  cd /tmp
69
- npm pack @dai_ming/plugin-deliverables@1.0.16 --registry https://registry.npmjs.org
70
- tar xzf dai_ming-plugin-deliverables-1.0.16.tgz \
69
+ npm pack @dai_ming/plugin-deliverables@1.0.21 --registry https://registry.npmjs.org
70
+ tar xzf dai_ming-plugin-deliverables-1.0.21.tgz \
71
71
  -C /home/node/.openclaw/extensions-extra/plugin-deliverables \
72
72
  --strip-components=1
73
73
  ```
@@ -212,5 +212,5 @@ cat /home/node/.openclaw/extensions-extra/plugin-deliverables/package.json
212
212
  必须是:
213
213
 
214
214
  ```json
215
- { "version": "1.0.16" }
215
+ { "version": "1.0.21" }
216
216
  ```
package/README.md CHANGED
@@ -24,17 +24,18 @@ OpenClaw 交付物插件 — 让 AI Agent 将生成的文件(文章、HTML 页
24
24
  现在这个包同时支持 OpenClaw 原生插件加载。也就是说,除了网关侧用 `openclaw-plugin.json` 注入 MCP/skill/AGENTS 规则外,OpenClaw 还会读取 `openclaw.plugin.json` + `index.js`,把运行时 hook 也一并启用。
25
25
 
26
26
  ```bash
27
- openclaw plugins install @dai_ming/plugin-deliverables@1.0.16 --pin
27
+ openclaw plugins install @dai_ming/plugin-deliverables@1.0.21 --pin
28
28
  openclaw plugins enable plugin-deliverables
29
29
  ```
30
30
 
31
31
  这一步启用后,插件会额外提供三层兜底:
32
32
 
33
33
  1. `before_prompt_build`:把“交付物必须 upload-first”的硬规则注入到主 agent 和子 agent 的系统上下文
34
- 2. `before_tool_call`:阻止通过 `message` 附件字段直接发文件
35
- 3. `message_sending`:如果仍然有媒体/文件旁路发送,最终发送前直接取消
34
+ 2. `before_tool_call`:记录 `write` 生成的工作区文件,并阻止通过 `message` 附件字段直接发文件
35
+ 3. Palz 出站补丁:最终消息发送前扫描内部工作区路径/近期写入文件,自动上传为交付物并改写成 gateway 链接
36
+ 4. `message_sending`:如果仍然有媒体/文件旁路发送,最终发送前直接取消
36
37
 
37
- > 注意:`message_sending` 只能改文本或取消发送,不能自动把错误发送重写成 `upload_deliverable`。所以它的职责是“兜底阻断”,不是“自动修正”。
38
+ > 注意:非 Palz 渠道仍以 `before_prompt_build` + `before_tool_call` + `message_sending` 兜底为主;Palz 渠道具备自动上传和文本改写能力。
38
39
 
39
40
  ### 方式一:通过 claw-gateway Helm 部署(推荐)
40
41
 
@@ -43,11 +44,11 @@ openclaw plugins enable plugin-deliverables
43
44
  ```yaml
44
45
  # values.yaml(或 claw-gateway 管理界面的 Helm 参数)
45
46
  installPlugins:
46
- - "@dai_ming/plugin-deliverables@1.0.16"
47
+ - "@dai_ming/plugin-deliverables@1.0.21"
47
48
  ```
48
49
 
49
50
  initContainer 执行顺序:
50
- 1. **Phase 3**:`npm pack @dai_ming/plugin-deliverables@1.0.16` 下载 tarball → 解压到 `/data/extensions-extra/plugin-deliverables/`
51
+ 1. **Phase 3**:`npm pack @dai_ming/plugin-deliverables@1.0.21` 下载 tarball → 解压到 `/data/extensions-extra/plugin-deliverables/`
51
52
  2. **Phase 3b**:在插件目录执行 `npm install --omit=dev`(本插件无运行时依赖,此步骤跳过)
52
53
  3. **Phase 3e**:读取 `openclaw-plugin.json` 清单,自动:
53
54
  - 复制 `mcp-servers/deliverables.js` → `/data/mcp-servers/deliverables.js`
@@ -197,10 +198,11 @@ fi
197
198
  | Hook | 作用 |
198
199
  |------|------|
199
200
  | `before_prompt_build` | 把“生成文件必须走交付物上传”的硬规则注入系统上下文,覆盖主 agent 和子 agent |
200
- | `before_tool_call` | 阻止通过 `message` 工具的 `media/path/filePath/buffer` 等字段直接发送文件 |
201
+ | `before_tool_call` | 记录 `write` 生成的工作区文件;阻止通过 `message` 工具的 `media/path/filePath/buffer` 等字段直接发送文件 |
202
+ | Palz 出站补丁 | 扫描最终消息中的 `/data/workspace-*`、`output/*.ext`、反引号文件名等工作区文件引用,自动上传后替换为交付物链接 |
201
203
  | `message_sending` | 如果上游仍然产生了媒体/文件旁路发送,在最终出站前直接取消 |
202
204
 
203
- 这三层一起工作的目标是:即使 prompt 漂移,也尽量把“message + file/media”这条旁路堵住,统一收敛到 `deliverables__upload_deliverable`。
205
+ 这些兜底一起工作的目标是:即使 prompt 漂移,也尽量把“工作区路径 / message + file/media”这类旁路堵住,统一收敛到 gateway 交付物链接。
204
206
 
205
207
  另外,Palz 渠道下如果交付物回复包含可信 gateway 交付物链接,会被拆成“内容简介 + 最终链接”两条消息;任意外部 URL 或非 gateway OSS URL 不会被包装成 `file_url` 文件消息。runtime plugin 还会分别打印这两次真实 HTTP 发送的 request/response 日志,便于直接从 pod stdout 排查。
206
208
 
@@ -224,7 +226,7 @@ npm publish --registry https://registry.npmjs.org --access public
224
226
 
225
227
  | claw-gateway 版本 | plugin 版本 |
226
228
  |-------------------|-------------|
227
- | 当前 | 1.0.16 |
229
+ | 当前 | 1.0.21 |
228
230
 
229
231
  ---
230
232
 
package/index.js CHANGED
@@ -1,16 +1,76 @@
1
1
  "use strict";
2
2
 
3
+ const fs = require("fs");
4
+ const http = require("http");
5
+ const https = require("https");
6
+ const path = require("path");
7
+
3
8
  const FETCH_PATCH_KEY = "__plugin_deliverables_palz_fetch_patch__";
4
9
  const SUMMARY_CACHE_KEY = "__plugin_deliverables_summary_cache__";
10
+ const PENDING_FILES_KEY = "__plugin_deliverables_pending_files__";
11
+ const UPLOAD_CACHE_KEY = "__plugin_deliverables_upload_cache__";
5
12
  const SUMMARY_CACHE_LIMIT = 200;
6
13
  const SHORT_SUMMARY_THRESHOLD = 120;
7
14
  const DEFAULT_GATEWAY_PUBLIC_URL = "https://claw-gateway.csagentai.com";
8
15
  const DEFAULT_GATEWAY_INTERNAL_URL = "http://claw-gateway:8080";
16
+ const AUTO_UPLOAD_TTL_MS = 15 * 60 * 1000;
17
+ const AUTO_UPLOAD_MAX_FILES = 8;
18
+ const AUTO_UPLOAD_MAX_BYTES = 200 * 1024 * 1024;
19
+ const AUTO_UPLOAD_SCAN_MAX_ENTRIES = 4000;
20
+ const AUTO_UPLOAD_SCAN_DEPTH = 5;
9
21
  const DELIVERABLE_GATEWAY_PATH_RE = /^\/openclaw-gateway\/(?:output|preview)(?:\/|$)/u;
10
22
  const DELIVERABLE_LINK_PREFIX_RE =
11
23
  /^\s*(?:[-*+]\s+)?(?:预览链接|下载链接|文件列表|项目入口|在线预览|在线体验|目录链接)\s*[::]/u;
12
24
  const DELIVERABLE_LINK_LABEL_RE =
13
25
  /^\s*(?:[-*+]\s+)?(?:预览链接|下载链接|文件列表|项目入口|在线预览|在线体验|目录链接)\s*[::]\s*\[[^\]]+\]\([^)]+\)\s*$/u;
26
+ const INTERNAL_ABSOLUTE_PATH_RE =
27
+ /(?:\/data\/workspace-[^\s`"'<>,。;:、))\]}]+|\/home\/node\/\.openclaw\/workspace(?:-[A-Za-z0-9_.-]+)?\/[^\s`"'<>,。;:、))\]}]+)/gu;
28
+ const INLINE_FILE_REF_RE = /`([^`\n]{1,512}\.[A-Za-z0-9]{1,16})`/gu;
29
+ const OUTPUT_RELATIVE_REF_RE = /(?:^|[\s(::])((?:\.\/)?output\/[^\s`"'<>,。;:、))\]}]+\.[A-Za-z0-9]{1,16})/gu;
30
+ const FILE_EXTENSION_RE = /\.[A-Za-z0-9]{1,16}$/u;
31
+ const TEXT_EXTENSIONS = new Set([
32
+ ".css",
33
+ ".csv",
34
+ ".html",
35
+ ".htm",
36
+ ".js",
37
+ ".json",
38
+ ".jsx",
39
+ ".log",
40
+ ".md",
41
+ ".markdown",
42
+ ".mjs",
43
+ ".py",
44
+ ".sql",
45
+ ".svg",
46
+ ".ts",
47
+ ".tsx",
48
+ ".txt",
49
+ ".xml",
50
+ ".yaml",
51
+ ".yml",
52
+ ]);
53
+ const IMAGE_EXTENSIONS = new Set([".apng", ".avif", ".bmp", ".gif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".webp"]);
54
+ const VIDEO_EXTENSIONS = new Set([".avi", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm"]);
55
+ const PPT_EXTENSIONS = new Set([".ppt", ".pptx"]);
56
+ const ARCHIVE_EXTENSIONS = new Set([".7z", ".gz", ".rar", ".tar", ".tgz", ".zip"]);
57
+ const IGNORED_PATH_PARTS = new Set([
58
+ ".git",
59
+ ".hg",
60
+ ".svn",
61
+ "node_modules",
62
+ "sessions",
63
+ "skills",
64
+ ".cache",
65
+ "__pycache__",
66
+ ]);
67
+ const IGNORED_FILE_NAMES = new Set([
68
+ "AGENTS.md",
69
+ "SOUL.md",
70
+ "openclaw.json",
71
+ "openclaw-runtime.json",
72
+ "package-lock.json",
73
+ ]);
14
74
 
15
75
  const RUNTIME_DELIVERABLES_GUIDANCE = [
16
76
  "## Deliverables Runtime Guard (HARD CONSTRAINT)",
@@ -29,6 +89,648 @@ function isString(value) {
29
89
  return typeof value === "string";
30
90
  }
31
91
 
92
+ function trimString(value) {
93
+ return isString(value) ? value.trim() : "";
94
+ }
95
+
96
+ function getPendingFileStore() {
97
+ if (!globalThis[PENDING_FILES_KEY] || !(globalThis[PENDING_FILES_KEY] instanceof Map)) {
98
+ globalThis[PENDING_FILES_KEY] = new Map();
99
+ }
100
+ return globalThis[PENDING_FILES_KEY];
101
+ }
102
+
103
+ function getUploadCache() {
104
+ if (!globalThis[UPLOAD_CACHE_KEY] || !(globalThis[UPLOAD_CACHE_KEY] instanceof Map)) {
105
+ globalThis[UPLOAD_CACHE_KEY] = new Map();
106
+ }
107
+ return globalThis[UPLOAD_CACHE_KEY];
108
+ }
109
+
110
+ function pruneTimedMap(map, ttlMs) {
111
+ const now = Date.now();
112
+ for (const [key, value] of map.entries()) {
113
+ if (!value || !value.createdAt || now - Number(value.createdAt) > ttlMs) {
114
+ map.delete(key);
115
+ }
116
+ }
117
+ }
118
+
119
+ function normalizeSlash(value) {
120
+ return String(value || "").replace(/\\/g, "/");
121
+ }
122
+
123
+ function stripTrailingPathPunctuation(value) {
124
+ return String(value || "").replace(/[.,;:!?,。;:!?、))\]}》】]+$/u, "");
125
+ }
126
+
127
+ function hasFileExtension(value) {
128
+ return FILE_EXTENSION_RE.test(path.basename(stripTrailingPathPunctuation(value)));
129
+ }
130
+
131
+ function isURLLike(value) {
132
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(String(value || ""));
133
+ }
134
+
135
+ function fileExtension(filePath) {
136
+ return path.extname(stripTrailingPathPunctuation(filePath)).toLowerCase();
137
+ }
138
+
139
+ function isTextLikeFile(filePath) {
140
+ return TEXT_EXTENSIONS.has(fileExtension(filePath));
141
+ }
142
+
143
+ function deliverableTypeForPath(filePath, isDirectory) {
144
+ if (isDirectory) {
145
+ return "game";
146
+ }
147
+ const ext = fileExtension(filePath);
148
+ if (IMAGE_EXTENSIONS.has(ext) && ext !== ".svg") {
149
+ return "image";
150
+ }
151
+ if (VIDEO_EXTENSIONS.has(ext)) {
152
+ return "video";
153
+ }
154
+ if (PPT_EXTENSIONS.has(ext)) {
155
+ return "ppt";
156
+ }
157
+ if (ARCHIVE_EXTENSIONS.has(ext)) {
158
+ return "zip";
159
+ }
160
+ return "article";
161
+ }
162
+
163
+ function isAllowedWorkspacePath(candidatePath) {
164
+ const normalized = normalizeSlash(path.resolve(candidatePath));
165
+ return (
166
+ normalized.indexOf("/data/workspace-") === 0 ||
167
+ normalized.indexOf("/home/node/.openclaw/workspace") === 0
168
+ );
169
+ }
170
+
171
+ function shouldIgnorePath(candidatePath) {
172
+ const normalized = normalizeSlash(candidatePath);
173
+ const parts = normalized.split("/").filter(Boolean);
174
+ for (const part of parts) {
175
+ if (IGNORED_PATH_PARTS.has(part)) {
176
+ return true;
177
+ }
178
+ }
179
+ return IGNORED_FILE_NAMES.has(path.basename(normalized));
180
+ }
181
+
182
+ function safeStat(candidatePath) {
183
+ try {
184
+ return fs.statSync(candidatePath);
185
+ } catch (_err) {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ function workspaceRoots() {
191
+ const roots = [];
192
+ ["/home/node/.openclaw/workspace-main", "/home/node/.openclaw/workspace"].forEach((root) => {
193
+ if (safeStat(root)) {
194
+ roots.push(root);
195
+ }
196
+ });
197
+ try {
198
+ for (const entry of fs.readdirSync("/data")) {
199
+ if (!entry || entry.indexOf("workspace-") !== 0) {
200
+ continue;
201
+ }
202
+ const root = path.join("/data", entry);
203
+ if (safeStat(root)) {
204
+ roots.push(root);
205
+ }
206
+ }
207
+ } catch (_err) {
208
+ // /data is not guaranteed in non-pod test environments.
209
+ }
210
+ return roots;
211
+ }
212
+
213
+ function resolveCandidatePath(rawPath) {
214
+ const cleaned = stripTrailingPathPunctuation(trimString(rawPath));
215
+ if (!cleaned || isURLLike(cleaned)) {
216
+ return "";
217
+ }
218
+ if (path.isAbsolute(cleaned)) {
219
+ return path.resolve(cleaned);
220
+ }
221
+ return cleaned;
222
+ }
223
+
224
+ function findFileByRelativeOrBaseName(rawRef) {
225
+ const cleaned = stripTrailingPathPunctuation(trimString(rawRef)).replace(/^\.\//, "");
226
+ if (!cleaned || isURLLike(cleaned) || !hasFileExtension(cleaned)) {
227
+ return "";
228
+ }
229
+ const roots = workspaceRoots();
230
+ const directCandidates = [];
231
+ for (const root of roots) {
232
+ directCandidates.push(path.join(root, cleaned));
233
+ directCandidates.push(path.join(root, "output", cleaned));
234
+ directCandidates.push(path.join(root, path.basename(cleaned)));
235
+ directCandidates.push(path.join(root, "output", path.basename(cleaned)));
236
+ }
237
+ for (const candidate of directCandidates) {
238
+ const stat = safeStat(candidate);
239
+ if (stat && (stat.isFile() || stat.isDirectory()) && isAllowedWorkspacePath(candidate) && !shouldIgnorePath(candidate)) {
240
+ return path.resolve(candidate);
241
+ }
242
+ }
243
+
244
+ const basename = path.basename(cleaned);
245
+ const found = [];
246
+ let visited = 0;
247
+ function scan(dir, depth) {
248
+ if (depth < 0 || visited > AUTO_UPLOAD_SCAN_MAX_ENTRIES || shouldIgnorePath(dir)) {
249
+ return;
250
+ }
251
+ let entries;
252
+ try {
253
+ entries = fs.readdirSync(dir, { withFileTypes: true });
254
+ } catch (_err) {
255
+ return;
256
+ }
257
+ for (const entry of entries) {
258
+ if (visited > AUTO_UPLOAD_SCAN_MAX_ENTRIES) {
259
+ return;
260
+ }
261
+ visited += 1;
262
+ const full = path.join(dir, entry.name);
263
+ if (shouldIgnorePath(full)) {
264
+ continue;
265
+ }
266
+ if (entry.isDirectory()) {
267
+ scan(full, depth - 1);
268
+ continue;
269
+ }
270
+ if (entry.isFile() && entry.name === basename) {
271
+ const stat = safeStat(full);
272
+ if (stat) {
273
+ found.push({ path: full, mtimeMs: stat.mtimeMs });
274
+ }
275
+ }
276
+ }
277
+ }
278
+ for (const root of roots) {
279
+ scan(root, AUTO_UPLOAD_SCAN_DEPTH);
280
+ }
281
+ found.sort((a, b) => b.mtimeMs - a.mtimeMs);
282
+ return found.length > 0 ? path.resolve(found[0].path) : "";
283
+ }
284
+
285
+ function extractFileReferencesFromText(text) {
286
+ const normalized = String(text || "");
287
+ const refs = [];
288
+ const seen = new Set();
289
+ function add(raw, value, kind) {
290
+ const cleaned = stripTrailingPathPunctuation(value);
291
+ if (!cleaned || isURLLike(cleaned)) {
292
+ return;
293
+ }
294
+ const key = `${kind}:${cleaned}`;
295
+ if (seen.has(key)) {
296
+ return;
297
+ }
298
+ seen.add(key);
299
+ refs.push({ raw, value: cleaned, kind });
300
+ }
301
+
302
+ for (const match of normalized.matchAll(INTERNAL_ABSOLUTE_PATH_RE)) {
303
+ add(match[0], match[0], "absolute");
304
+ }
305
+ for (const match of normalized.matchAll(INLINE_FILE_REF_RE)) {
306
+ add(match[0], match[1], "inline");
307
+ }
308
+ for (const match of normalized.matchAll(OUTPUT_RELATIVE_REF_RE)) {
309
+ add(match[1], match[1], "relative");
310
+ }
311
+ return refs;
312
+ }
313
+
314
+ function resolveFileReference(ref) {
315
+ if (!ref || !ref.value) {
316
+ return null;
317
+ }
318
+ const candidate = resolveCandidatePath(ref.value);
319
+ const resolved = path.isAbsolute(candidate) ? candidate : findFileByRelativeOrBaseName(candidate);
320
+ if (!resolved) {
321
+ return null;
322
+ }
323
+ const stat = safeStat(resolved);
324
+ if (!stat || (!stat.isFile() && !stat.isDirectory())) {
325
+ return null;
326
+ }
327
+ if (!isAllowedWorkspacePath(resolved) || shouldIgnorePath(resolved)) {
328
+ return null;
329
+ }
330
+ return {
331
+ raw: ref.raw,
332
+ path: resolved,
333
+ fileName: path.basename(resolved),
334
+ isDirectory: stat.isDirectory(),
335
+ size: stat.isDirectory() ? 0 : stat.size,
336
+ mtimeMs: stat.mtimeMs,
337
+ };
338
+ }
339
+
340
+ function collectDirectoryFiles(dirPath) {
341
+ const files = [];
342
+ let total = 0;
343
+ let visited = 0;
344
+ function scan(current, depth) {
345
+ if (depth < 0 || visited > AUTO_UPLOAD_SCAN_MAX_ENTRIES || shouldIgnorePath(current)) {
346
+ return;
347
+ }
348
+ let entries;
349
+ try {
350
+ entries = fs.readdirSync(current, { withFileTypes: true });
351
+ } catch (_err) {
352
+ return;
353
+ }
354
+ for (const entry of entries) {
355
+ if (visited > AUTO_UPLOAD_SCAN_MAX_ENTRIES || files.length >= AUTO_UPLOAD_MAX_FILES * 50) {
356
+ return;
357
+ }
358
+ visited += 1;
359
+ const full = path.join(current, entry.name);
360
+ if (shouldIgnorePath(full)) {
361
+ continue;
362
+ }
363
+ if (entry.isDirectory()) {
364
+ scan(full, depth - 1);
365
+ continue;
366
+ }
367
+ if (!entry.isFile()) {
368
+ continue;
369
+ }
370
+ const stat = safeStat(full);
371
+ if (!stat || total + stat.size > AUTO_UPLOAD_MAX_BYTES) {
372
+ continue;
373
+ }
374
+ total += stat.size;
375
+ const rel = normalizeSlash(path.relative(dirPath, full));
376
+ if (isTextLikeFile(full)) {
377
+ files.push({ name: rel, contentText: fs.readFileSync(full, "utf8") });
378
+ } else {
379
+ files.push({ name: rel, contentBase64: fs.readFileSync(full).toString("base64") });
380
+ }
381
+ }
382
+ }
383
+ scan(dirPath, AUTO_UPLOAD_SCAN_DEPTH);
384
+ return files;
385
+ }
386
+
387
+ function extractStringField(obj, keys) {
388
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
389
+ return "";
390
+ }
391
+ for (const key of keys) {
392
+ const value = obj[key];
393
+ if (isString(value) && value.trim()) {
394
+ return value.trim();
395
+ }
396
+ }
397
+ return "";
398
+ }
399
+
400
+ function extractContextString(event, keys) {
401
+ const direct = extractStringField(event, keys);
402
+ if (direct) {
403
+ return direct;
404
+ }
405
+ const containers = [
406
+ event && event.params,
407
+ event && event.metadata,
408
+ event && event.context,
409
+ event && event.ctx,
410
+ event && event.message,
411
+ ];
412
+ for (const container of containers) {
413
+ const value = extractStringField(container, keys);
414
+ if (value) {
415
+ return value;
416
+ }
417
+ }
418
+ return "";
419
+ }
420
+
421
+ function payloadResourceId(body) {
422
+ return extractStringField(body, ["resource_id", "resourceId", "conversation_id", "conversationId"]);
423
+ }
424
+
425
+ function payloadGroupId(body) {
426
+ return extractStringField(body, ["group_id", "groupId", "conversation_id", "conversationId"]);
427
+ }
428
+
429
+ function payloadUserId(body) {
430
+ return extractStringField(body, ["user_id", "userId", "sender_id", "senderId", "owner_id", "ownerId"]);
431
+ }
432
+
433
+ function isWriteTool(toolName) {
434
+ const normalized = normalizeToolName(toolName);
435
+ return normalized === "write" || normalized.endsWith("__write") || normalized === "write_file" || normalized.endsWith("__write_file");
436
+ }
437
+
438
+ function extractWritePath(params) {
439
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
440
+ return "";
441
+ }
442
+ return extractStringField(params, [
443
+ "path",
444
+ "file_path",
445
+ "filePath",
446
+ "filename",
447
+ "fileName",
448
+ "target",
449
+ "target_path",
450
+ "targetPath",
451
+ "destination",
452
+ ]);
453
+ }
454
+
455
+ function rememberPendingFile(event) {
456
+ const params = event && event.params;
457
+ const writePath = extractWritePath(params);
458
+ if (!writePath || isURLLike(writePath)) {
459
+ return;
460
+ }
461
+ const store = getPendingFileStore();
462
+ pruneTimedMap(store, AUTO_UPLOAD_TTL_MS);
463
+ const resourceId = extractContextString(event, ["resource_id", "resourceId", "conversation_id", "conversationId"]);
464
+ const key = `${resourceId || "unknown"}:${writePath}`;
465
+ store.set(key, {
466
+ path: writePath,
467
+ resourceId,
468
+ createdAt: Date.now(),
469
+ });
470
+ }
471
+
472
+ function pendingCandidatesForPayload(body) {
473
+ const resourceId = payloadResourceId(body);
474
+ if (!resourceId) {
475
+ return [];
476
+ }
477
+ const store = getPendingFileStore();
478
+ pruneTimedMap(store, AUTO_UPLOAD_TTL_MS);
479
+ const out = [];
480
+ for (const entry of store.values()) {
481
+ if (!entry || entry.resourceId !== resourceId) {
482
+ continue;
483
+ }
484
+ const ref = { raw: entry.path, value: entry.path, kind: "pending" };
485
+ const resolved = resolveFileReference(ref);
486
+ if (resolved) {
487
+ out.push(resolved);
488
+ }
489
+ }
490
+ return out;
491
+ }
492
+
493
+ function gatewayURL() {
494
+ return (process.env.CLAW_GATEWAY_URL || DEFAULT_GATEWAY_INTERNAL_URL).replace(/\/$/, "");
495
+ }
496
+
497
+ function gatewayPublicURL() {
498
+ return (process.env.CLAW_GATEWAY_PUBLIC_URL || process.env.CLAW_GATEWAY_URL || DEFAULT_GATEWAY_PUBLIC_URL).replace(/\/$/, "");
499
+ }
500
+
501
+ function apiKey() {
502
+ return process.env.CLAW_GATEWAY_API_KEY || process.env.OPENCLAW_GATEWAY_API_KEY || "api-key-1";
503
+ }
504
+
505
+ function deriveReleaseName() {
506
+ let release = process.env.botID || process.env.OPENCLAW_RELEASE || process.env.BOT_ID || "";
507
+ if (!release) {
508
+ const tok = process.env.OPENCLAW_GATEWAY_TOKEN || "";
509
+ if (tok.indexOf("oc-") === 0) {
510
+ release = tok.slice(3);
511
+ }
512
+ }
513
+ return release;
514
+ }
515
+
516
+ function httpJSONRequest(method, requestPath, body) {
517
+ return new Promise((resolve, reject) => {
518
+ let parsed;
519
+ try {
520
+ parsed = new URL(gatewayURL() + requestPath);
521
+ } catch (err) {
522
+ reject(err);
523
+ return;
524
+ }
525
+ const isHTTPS = parsed.protocol === "https:";
526
+ const transport = isHTTPS ? https : http;
527
+ const bodyStr = body ? JSON.stringify(body) : "";
528
+ const req = transport.request(
529
+ {
530
+ hostname: parsed.hostname,
531
+ port: parsed.port || (isHTTPS ? 443 : 80),
532
+ path: parsed.pathname + (parsed.search || ""),
533
+ method,
534
+ headers: {
535
+ "Content-Type": "application/json",
536
+ "X-API-Key": apiKey(),
537
+ "Content-Length": Buffer.byteLength(bodyStr),
538
+ },
539
+ },
540
+ (res) => {
541
+ const chunks = [];
542
+ res.on("data", (chunk) => chunks.push(chunk));
543
+ res.on("end", () => {
544
+ const text = Buffer.concat(chunks).toString("utf8");
545
+ let obj;
546
+ try {
547
+ obj = JSON.parse(text);
548
+ } catch (_err) {
549
+ reject(new Error(`gateway ${res.statusCode}: non-JSON response: ${text.slice(0, 200)}`));
550
+ return;
551
+ }
552
+ if (res.statusCode >= 400) {
553
+ reject(new Error(`gateway ${res.statusCode}: ${obj.message || text.slice(0, 200)}`));
554
+ return;
555
+ }
556
+ resolve(obj);
557
+ });
558
+ },
559
+ );
560
+ req.on("error", reject);
561
+ if (bodyStr) {
562
+ req.write(bodyStr);
563
+ }
564
+ req.end();
565
+ });
566
+ }
567
+
568
+ function uploadCacheKey(candidate, resourceId) {
569
+ return `${resourceId || ""}:${candidate.path}:${candidate.mtimeMs || 0}:${candidate.size || 0}`;
570
+ }
571
+
572
+ async function uploadCandidate(candidate, body) {
573
+ const resourceId = payloadResourceId(body);
574
+ const cache = getUploadCache();
575
+ pruneTimedMap(cache, AUTO_UPLOAD_TTL_MS);
576
+ const cacheKey = uploadCacheKey(candidate, resourceId);
577
+ const cached = cache.get(cacheKey);
578
+ if (cached && cached.result) {
579
+ return cached.result;
580
+ }
581
+
582
+ const stat = safeStat(candidate.path);
583
+ if (!stat) {
584
+ throw new Error(`file no longer exists: ${candidate.fileName}`);
585
+ }
586
+ const requestBody = {
587
+ resourceId,
588
+ groupId: payloadGroupId(body),
589
+ userId: payloadUserId(body),
590
+ release: deriveReleaseName(),
591
+ type: deliverableTypeForPath(candidate.path, stat.isDirectory()),
592
+ fileName: candidate.fileName || path.basename(candidate.path),
593
+ };
594
+ if (stat.isDirectory()) {
595
+ const files = collectDirectoryFiles(candidate.path);
596
+ if (files.length === 0) {
597
+ throw new Error(`directory has no uploadable files: ${candidate.fileName}`);
598
+ }
599
+ requestBody.files = files;
600
+ } else {
601
+ if (stat.size > AUTO_UPLOAD_MAX_BYTES) {
602
+ throw new Error(`file exceeds auto-upload limit: ${candidate.fileName}`);
603
+ }
604
+ requestBody.contentBase64 = fs.readFileSync(candidate.path).toString("base64");
605
+ }
606
+
607
+ const response = await httpJSONRequest("POST", "/openclaw-gateway/be/deliverables", requestBody);
608
+ const data = response.data || response;
609
+ const previewURL = data.previewUrl || `${gatewayPublicURL()}/openclaw-gateway/preview/${data.uuid}`;
610
+ const result = {
611
+ fileName: requestBody.fileName,
612
+ uuid: data.uuid,
613
+ previewURL,
614
+ downloadURL: data.downloadUrl || previewURL,
615
+ };
616
+ cache.set(cacheKey, { result, createdAt: Date.now() });
617
+ return result;
618
+ }
619
+
620
+ function dedupeCandidates(candidates) {
621
+ const out = [];
622
+ const seen = new Set();
623
+ for (const candidate of candidates) {
624
+ if (!candidate || !candidate.path) {
625
+ continue;
626
+ }
627
+ const key = path.resolve(candidate.path);
628
+ if (seen.has(key)) {
629
+ continue;
630
+ }
631
+ seen.add(key);
632
+ out.push(candidate);
633
+ if (out.length >= AUTO_UPLOAD_MAX_FILES) {
634
+ break;
635
+ }
636
+ }
637
+ return out;
638
+ }
639
+
640
+ function escapeMarkdownLabel(label) {
641
+ return String(label || "文件").replace(/[[\]()`]/g, "");
642
+ }
643
+
644
+ function removeInternalPathLeaks(text, candidates) {
645
+ let out = String(text || "");
646
+ for (const candidate of candidates) {
647
+ if (!candidate || !candidate.raw) {
648
+ continue;
649
+ }
650
+ out = out.split(candidate.raw).join(candidate.fileName || path.basename(candidate.path));
651
+ }
652
+ return out;
653
+ }
654
+
655
+ function appendDeliverableLinks(text, uploads) {
656
+ const lines = [];
657
+ uploads.forEach((upload) => {
658
+ const label = escapeMarkdownLabel(upload.fileName);
659
+ if (upload.previewURL) {
660
+ lines.push(`预览链接:[${label}](${upload.previewURL})`);
661
+ }
662
+ if (upload.downloadURL && upload.downloadURL !== upload.previewURL) {
663
+ lines.push(`下载链接:[${label}](${upload.downloadURL})`);
664
+ }
665
+ });
666
+ if (lines.length === 0) {
667
+ return text;
668
+ }
669
+ return `${String(text || "").trim()}\n\n交付物链接:\n${lines.join("\n")}`;
670
+ }
671
+
672
+ function hasTrustedDeliverableLink(text) {
673
+ return splitDeliverableMessage(text) !== null;
674
+ }
675
+
676
+ function shouldSkipAutoUploadPayload(body) {
677
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
678
+ return true;
679
+ }
680
+ if (!isString(body.content) || !body.content.trim()) {
681
+ return true;
682
+ }
683
+ if (body.stream_id || body.seq !== undefined || body.delta !== undefined || body.is_final !== undefined) {
684
+ return true;
685
+ }
686
+ if (body.palz_msg_type || body.tool_content) {
687
+ return true;
688
+ }
689
+ return false;
690
+ }
691
+
692
+ async function autoUploadAndRewritePayload(body, api) {
693
+ if (shouldSkipAutoUploadPayload(body) || hasTrustedDeliverableLink(body.content)) {
694
+ return body;
695
+ }
696
+ const refs = extractFileReferencesFromText(body.content);
697
+ const explicitCandidates = refs.map(resolveFileReference).filter(Boolean);
698
+ const candidates = dedupeCandidates(explicitCandidates.concat(pendingCandidatesForPayload(body)));
699
+ if (candidates.length === 0) {
700
+ return body;
701
+ }
702
+
703
+ const uploads = [];
704
+ const failures = [];
705
+ for (const candidate of candidates) {
706
+ try {
707
+ uploads.push(await uploadCandidate(candidate, body));
708
+ } catch (err) {
709
+ failures.push({ candidate, error: err });
710
+ api.logger.warn?.(
711
+ `[plugin-deliverables] auto-upload failed file=${candidate.fileName || candidate.path} error=${err.message}`,
712
+ );
713
+ }
714
+ }
715
+ if (uploads.length === 0) {
716
+ if (explicitCandidates.length === 0) {
717
+ return body;
718
+ }
719
+ const next = Object.assign({}, body);
720
+ next.content = "交付物上传失败,已阻止发送工作区内部文件路径。请稍后重试或联系管理员排查上传链路。";
721
+ return next;
722
+ }
723
+ const next = Object.assign({}, body);
724
+ next.content = appendDeliverableLinks(removeInternalPathLeaks(body.content, candidates), uploads);
725
+ if (failures.length > 0) {
726
+ next.content += `\n\n另有 ${failures.length} 个文件上传失败,已避免发送内部路径。`;
727
+ }
728
+ api.logger.info?.(
729
+ `[plugin-deliverables] auto-uploaded ${uploads.length} workspace file(s) for target=${String(body.conversation_id || "")}`,
730
+ );
731
+ return next;
732
+ }
733
+
32
734
  function getSummaryCache() {
33
735
  if (!globalThis[SUMMARY_CACHE_KEY] || !(globalThis[SUMMARY_CACHE_KEY] instanceof Map)) {
34
736
  globalThis[SUMMARY_CACHE_KEY] = new Map();
@@ -546,6 +1248,7 @@ function splitDeliverableMessage(text) {
546
1248
  return {
547
1249
  summary,
548
1250
  links,
1251
+ linkUrls,
549
1252
  primaryLinkUrl: linkUrls.length > 0 ? linkUrls[0] : "",
550
1253
  };
551
1254
  }
@@ -678,6 +1381,11 @@ function installPalzFetchPatch(api) {
678
1381
  return originalFetch(input, init);
679
1382
  }
680
1383
 
1384
+ const rewritten = await autoUploadAndRewritePayload(parsed, api);
1385
+ if (rewritten !== parsed) {
1386
+ parsed = rewritten;
1387
+ }
1388
+
681
1389
  const split = shouldSplitPalzPayload(parsed);
682
1390
  if (!split) {
683
1391
  const suspiciousURL = isString(parsed.content)
@@ -695,7 +1403,7 @@ function installPalzFetchPatch(api) {
695
1403
  }
696
1404
 
697
1405
  const summaryBody = cloneBody(parsed, split.summary, "__summary");
698
- const linksBody = split.primaryLinkUrl
1406
+ const linksBody = split.primaryLinkUrl && (!Array.isArray(split.linkUrls) || split.linkUrls.length === 1)
699
1407
  ? cloneBodyAsFileLink(parsed, split.primaryLinkUrl, "__links")
700
1408
  : cloneBody(parsed, split.links, "__links");
701
1409
  const summaryBodyStr = JSON.stringify(summaryBody);
@@ -759,6 +1467,10 @@ const plugin = {
759
1467
  cacheUploadSummary(event.params);
760
1468
  return;
761
1469
  }
1470
+ if (isWriteTool(event.toolName)) {
1471
+ rememberPendingFile(event);
1472
+ return;
1473
+ }
762
1474
  if (!isOutboundMessageTool(event.toolName)) {
763
1475
  return;
764
1476
  }
@@ -789,6 +1501,8 @@ const plugin = {
789
1501
  };
790
1502
 
791
1503
  plugin.__test = {
1504
+ deliverableTypeForPath,
1505
+ extractFileReferencesFromText,
792
1506
  extractDeliverableURL,
793
1507
  findUntrustedOSSDeliverableURL,
794
1508
  isDeliverableLinkLine,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-deliverables",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "npm_package": "@dai_ming/plugin-deliverables",
5
5
  "description": "Deliverables plugin: MCP server + skill + AGENTS rules for AI-generated file uploads",
6
6
  "mcp_servers": {
@@ -2,7 +2,7 @@
2
2
  "id": "plugin-deliverables",
3
3
  "name": "Deliverables",
4
4
  "description": "Deliverables runtime guard for upload-first file delivery with Palz split-send diagnostics.",
5
- "version": "1.0.20",
5
+ "version": "1.0.21",
6
6
  "skills": ["./skills"],
7
7
  "configSchema": {
8
8
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dai_ming/plugin-deliverables",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "description": "OpenClaw deliverables plugin — upload AI-generated files to OSS and return shareable preview/download links",
5
5
  "keywords": [
6
6
  "openclaw",
@@ -6,6 +6,8 @@ process.env.CLAW_GATEWAY_PUBLIC_URL = "https://claw-gateway.csagentai.com";
6
6
 
7
7
  const plugin = require("../index.js");
8
8
  const {
9
+ deliverableTypeForPath,
10
+ extractFileReferencesFromText,
9
11
  findUntrustedOSSDeliverableURL,
10
12
  isDeliverableLinkLine,
11
13
  isTrustedDeliverableURL,
@@ -71,3 +73,28 @@ assert.strictEqual(
71
73
  ),
72
74
  "",
73
75
  );
76
+
77
+ const multiSplit = splitDeliverableMessage(
78
+ [
79
+ "已生成多个文件。",
80
+ "",
81
+ `预览链接:[设计文档](${gatewayURL})`,
82
+ `下载链接:[测试报告](https://claw-gateway.csagentai.com/openclaw-gateway/output/user/res/uuid/report.md)`,
83
+ ].join("\n"),
84
+ );
85
+ assert.ok(multiSplit);
86
+ assert.strictEqual(multiSplit.linkUrls.length, 2);
87
+
88
+ const refs = extractFileReferencesFromText(
89
+ "设计文档:`game-brief.md`\n开发路径:/data/workspace-lobster_abc/output/app/index.html\n相对路径 output/report.pdf",
90
+ );
91
+ assert.deepStrictEqual(
92
+ refs.map((ref) => ref.value),
93
+ ["/data/workspace-lobster_abc/output/app/index.html", "game-brief.md", "output/report.pdf"],
94
+ );
95
+
96
+ assert.strictEqual(deliverableTypeForPath("intro.pdf", false), "article");
97
+ assert.strictEqual(deliverableTypeForPath("cover.png", false), "image");
98
+ assert.strictEqual(deliverableTypeForPath("slides.pptx", false), "ppt");
99
+ assert.strictEqual(deliverableTypeForPath("dist.zip", false), "zip");
100
+ assert.strictEqual(deliverableTypeForPath("/data/workspace-lobster_abc/output/site", true), "game");