@dai_ming/plugin-deliverables 1.2.3 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -81,10 +81,9 @@ Tool name note:
81
81
  Keep that format instead of forcing a zip link.
82
82
  23. Do NOT only say "已保存到工作空间".
83
83
  24. Do NOT append workspace path or a raw URL block after the Markdown links.
84
- 25. 群聊场景(消息元数据包含 `is_group_chat == true` 或 `conversation_type == "group"`)调用上传工具时,MUST 传入 `group_id`(取自元数据的 `group_id` `conversation_id`)和 `group_name`(取自元数据的 `group_name` `group_subject`)。
85
- 26. 单聊/私聊场景,不传 `group_id` 或传 `"default"`,不传 `group_name`。
86
- 27. 无法判断会话类型时,若 `resource_id` 存在且不是 `"default"`,将 `resource_id` 作为 `group_id` 传入。
87
- 28. 调用上传工具时 MUST 传入 `user_id`:单聊取 `owner_id`,群聊取 `sender_id`。漏传 `user_id` 会导致 KB 上传失败(error 13002: 用户不存在),尤其在同时传了真实 `group_id` 的场景下。
84
+ 25. 调用上传工具时,MUST 原样传入会话元数据:`conversation_type`(`"direct"` 或 `"group"`)、`conversation_id`、`owner_id`、`sender_id`、`lobster_id`、`lobster_name`,照消息元数据/会话上下文填,不要自己加工。这些都是必填。插件会据此推导 `user_id` `group_id`(单聊 `owner_id` + 个人文件夹;群聊 `sender_id` + 群)。
85
+ 26. 群聊场景额外传 `group_id` `group_name`(取自消息元数据);单聊不传。
86
+ 27. 不要把单聊的 `conversation_id` `group_id` 传——单聊请把 `group_id` 留空或传 `"default"`,否则 KB 会因"用户不在群内"上传失败。
88
87
 
89
88
  ### Exception
90
89
 
@@ -1,3 +1,4 @@
1
1
  {
2
- "kbBaseUrl": "http://knowledge-db:8080/api/v1"
2
+ "kbBaseUrl": "http://knowledge-db:8080/api/v1",
3
+ "engineBaseUrl": "http://uniclaw-kb-engine:8070/uniclaw-kb-engine/api/v1"
3
4
  }
@@ -1,3 +1,4 @@
1
1
  {
2
- "kbBaseUrl": "http://knowledge-db:8080/api/v1"
2
+ "kbBaseUrl": "http://knowledge-db:8080/api/v1",
3
+ "engineBaseUrl": "http://uniclaw-kb-engine:8070/uniclaw-kb-engine/api/v1"
3
4
  }
@@ -1,3 +1,4 @@
1
1
  {
2
- "kbBaseUrl": "http://knowledge-db:8080/api/v1"
2
+ "kbBaseUrl": "http://knowledge-db:8080/api/v1",
3
+ "engineBaseUrl": "http://uniclaw-kb-engine:8070/uniclaw-kb-engine/api/v1"
3
4
  }
package/index.js CHANGED
@@ -12,9 +12,10 @@ const UPLOAD_CACHE_KEY = "__plugin_deliverables_upload_cache__";
12
12
  const SUMMARY_CACHE_LIMIT = 200;
13
13
  const SHORT_SUMMARY_THRESHOLD = 120;
14
14
  const DEFAULT_KB_BASE_URL = "http://knowledge-db:8080/api/v1";
15
- const DEFAULT_KB_API_KEY = "kb-agent-key-2025";
15
+ // Deliverables now go through the kb-engine (classification + path building +
16
+ // async move) instead of calling ai-kb's batch-upload directly.
17
+ const DEFAULT_ENGINE_BASE_URL = "http://uniclaw-kb-engine:8070/uniclaw-kb-engine/api/v1";
16
18
  const DEFAULT_GROUP_ID = "default";
17
- const DEFAULT_GROUP_NAME = "我的文件夹";
18
19
  const DEFAULT_DELIVERABLES_CONFIG_FILENAME = "deliverables.config.json";
19
20
  let _cachedDeliverableConfig = null;
20
21
 
@@ -125,8 +126,8 @@ const RUNTIME_DELIVERABLES_GUIDANCE = [
125
126
  "- Never use `type: \"link\"` for local files, relative paths, or generated files such as `review.html`; use `file_path`, `content_text`, or `files` so the file is uploaded.",
126
127
  "- If the deliverables upload tool returns an error, retry with a valid `file_path` or explain the upload failure; never invent or rewrite OSS/download URLs.",
127
128
  "- After a successful upload, reply with a substantive content summary before the links. For documents/articles/reports/PDFs, include 1 short intro plus 3-5 concise bullets covering the actual sections, highlights, or findings; never respond with only a generic one-sentence upload notice.",
128
- "- CRITICAL: In group chats (metadata `is_group_chat == true` or `conversation_type == \"group\"`), you MUST pass `group_id` (from metadata `group_id` or `conversation_id`) and `group_name` (from metadata `group_name` or `group_subject`) to `deliverables__upload_deliverable`. For single/direct chats, omit `group_id` or pass `\"default\"`. Omitting `group_id` in a group chat causes the deliverable to land in the wrong knowledge base.",
129
- "- CRITICAL: You MUST always pass `user_id` to `deliverables__upload_deliverable`. In single/direct chats, use `owner_id`; in group chats, use `sender_id`. Omitting `user_id` causes KB upload failures (error 13002: 用户不存在) when `group_id` is set to a real group.",
129
+ "- CRITICAL: Pass the raw conversation metadata to `deliverables__upload_deliverable` verbatim `conversation_type` (\"direct\" or \"group\"), `conversation_id`, `owner_id`, `sender_id`, `lobster_id`, and `lobster_name` exactly as they appear in the conversation context/message metadata. These are all required. The plugin derives `user_id` and `group_id` from them (direct owner_id + personal folder; group → sender_id + the group), so you do NOT pick those yourself. In group chats you MUST also pass `group_id` and `group_name`. Never pass a single-chat `conversation_id` as `group_id` it causes a KB upload failure (user is not a member of group).",
130
+ "- `lobster_name` is the deliverable's first-level isolation directory (keeps different agents' deliverables separated). Use the real `lobster_name` from conversation context/message metadata when present; do not invent one or fall back to release/resource ids.",
130
131
  ].join("\n");
131
132
 
132
133
  const UPLOAD_DELIVERABLE_TOOL = {
@@ -139,21 +140,33 @@ const UPLOAD_DELIVERABLE_TOOL = {
139
140
  inputSchema: {
140
141
  type: "object",
141
142
  properties: {
142
- resource_id: {
143
+ conversation_type: {
143
144
  type: "string",
144
- description: "当前聊天框/会话的唯一 ID,用于缓存去重。",
145
+ description: "会话类型,从消息元数据 conversation_type 获取:单聊为 'direct',群聊为 'group'。决定 user_id 取值与是否按群归档。",
145
146
  },
146
- user_id: {
147
+ conversation_id: {
147
148
  type: "string",
148
- description: "上传者用户 ID。单聊取 owner_id,群聊取 sender_id。",
149
+ description: "会话 ID,从消息元数据 conversation_id 获取。群聊时作为 group_id 兜底;并用于缓存去重。",
149
150
  },
150
151
  owner_id: {
151
152
  type: "string",
152
- description: "等同于 user_id,从消息元数据 owner_id 字段获取。两者任传其一即可。",
153
+ description: "会话归属用户 ID,从消息元数据 owner_id 获取。单聊上传者即 owner_id。",
154
+ },
155
+ sender_id: {
156
+ type: "string",
157
+ description: "消息发送者 ID,从消息元数据 sender_id 获取。群聊上传者即 sender_id。",
158
+ },
159
+ lobster_name: {
160
+ type: "string",
161
+ description: "产出该交付物的 agent 显示名,从会话上下文/消息元数据 lobster_name 获取;作为交付物的一级隔离目录。必填。",
162
+ },
163
+ lobster_id: {
164
+ type: "string",
165
+ description: "agent 稳定 ID,从消息元数据 lobster_id 获取。随交付物记录,预留按 ID 隔离。",
153
166
  },
154
167
  group_id: {
155
168
  type: "string",
156
- description: "分组 ID。单聊不传或传 'default';群聊传群 ID",
169
+ description: "分组 ID。单聊不传或传 'default';群聊传群 ID(不传则用 conversation_id 兜底)。",
157
170
  },
158
171
  group_name: {
159
172
  type: "string",
@@ -194,7 +207,16 @@ const UPLOAD_DELIVERABLE_TOOL = {
194
207
  },
195
208
  },
196
209
  },
197
- required: ["type", "file_name"],
210
+ required: [
211
+ "type",
212
+ "file_name",
213
+ "conversation_type",
214
+ "conversation_id",
215
+ "owner_id",
216
+ "sender_id",
217
+ "lobster_name",
218
+ "lobster_id",
219
+ ],
198
220
  },
199
221
  };
200
222
 
@@ -285,23 +307,37 @@ function deliverableTypeForPath(filePath, isDirectory) {
285
307
  return "article";
286
308
  }
287
309
 
310
+ function openclawRoot() {
311
+ return path.resolve(__dirname, "..", "..");
312
+ }
313
+
314
+ function workspaceTenantBases() {
315
+ return [
316
+ "/data/tenants",
317
+ process.env.OPENCLAW_HOME,
318
+ process.env.HOME ? path.join(process.env.HOME, ".openclaw") : "",
319
+ openclawRoot(),
320
+ ].filter((base, index, all) => base && all.indexOf(base) === index);
321
+ }
322
+
323
+ function isPathUnderTenantWorkspace(normalizedPath, normalizedBase) {
324
+ if (normalizedPath.indexOf(normalizedBase + "/") !== 0) {
325
+ return false;
326
+ }
327
+ const relative = normalizedPath.slice(normalizedBase.length + 1);
328
+ const parts = relative.split("/").filter(Boolean);
329
+ return parts.length >= 2 && parts[1].indexOf("workspace-") === 0;
330
+ }
331
+
288
332
  function isAllowedWorkspacePath(candidatePath) {
289
333
  const normalized = normalizeSlash(path.resolve(candidatePath));
290
- // 老布局:根目录直接挂 workspace(保持向后兼容)。
291
- if (
292
- normalized.indexOf("/data/workspace-") === 0 ||
293
- normalized.indexOf("/home/node/.openclaw/workspace") === 0
294
- ) {
295
- return true;
296
- }
297
- const openclawRoot = normalizeSlash(path.resolve(__dirname, "..", ".."));
298
- if (normalized.indexOf(openclawRoot + "/workspace") === 0) {
299
- return true;
334
+ for (const base of workspaceTenantBases()) {
335
+ const normalizedBase = normalizeSlash(path.resolve(base));
336
+ if (isPathUnderTenantWorkspace(normalized, normalizedBase)) {
337
+ return true;
338
+ }
300
339
  }
301
- // 新布局(多租户)及通用情况:绝对路径中任意位置含有 `xxx/workspace-*`
302
- // 目录段都允许,覆盖 /data/tenants/<user>/workspace-<lobster>、
303
- // /home/node/.openclaw/<user>/workspace-<lobster> 等。
304
- return /\/workspace-[^/]+(?:\/|$)/.test(normalized);
340
+ return false;
305
341
  }
306
342
 
307
343
  function shouldIgnorePath(candidatePath) {
@@ -325,28 +361,7 @@ async function safeStat(candidatePath) {
325
361
 
326
362
  async function workspaceRoots() {
327
363
  const roots = [];
328
- for (const root of ["/home/node/.openclaw/workspace-main", "/home/node/.openclaw/workspace"]) {
329
- if (await safeStat(root)) {
330
- roots.push(root);
331
- }
332
- }
333
- try {
334
- for (const entry of await fs.promises.readdir("/data")) {
335
- if (!entry || entry.indexOf("workspace-") !== 0) {
336
- continue;
337
- }
338
- const root = path.join("/data", entry);
339
- if (await safeStat(root)) {
340
- roots.push(root);
341
- }
342
- }
343
- } catch (_err) {
344
- // /data is not guaranteed in non-pod test environments.
345
- }
346
- // 多租户布局:枚举 <base>/<tenant-or-user>/workspace-* 下的 workspace 根目录,
347
- // 覆盖 /data/tenants/<user>/workspace-<lobster>、
348
- // /home/node/.openclaw/<user>/workspace-<lobster> 等。
349
- for (const base of ["/data/tenants", "/home/node/.openclaw"]) {
364
+ for (const base of workspaceTenantBases()) {
350
365
  let tenants;
351
366
  try {
352
367
  tenants = await fs.promises.readdir(base);
@@ -369,26 +384,13 @@ async function workspaceRoots() {
369
384
  continue;
370
385
  }
371
386
  const root = path.join(tenantDir, child);
372
- if ((await safeStat(root)) && roots.indexOf(root) === -1) {
387
+ const stat = await safeStat(root);
388
+ if (stat && stat.isDirectory() && roots.indexOf(root) === -1) {
373
389
  roots.push(root);
374
390
  }
375
391
  }
376
392
  }
377
393
  }
378
- const openclawRoot = path.resolve(__dirname, "..", "..");
379
- try {
380
- for (const entry of await fs.promises.readdir(openclawRoot)) {
381
- if (!entry || entry.indexOf("workspace") !== 0) {
382
- continue;
383
- }
384
- const root = path.join(openclawRoot, entry);
385
- if ((await safeStat(root)) && roots.indexOf(root) === -1) {
386
- roots.push(root);
387
- }
388
- }
389
- } catch (_err) {
390
- // local dev path may not exist
391
- }
392
394
  return roots;
393
395
  }
394
396
 
@@ -566,7 +568,22 @@ async function collectDirectoryFiles(dirPath) {
566
568
  }
567
569
  }
568
570
  await scan(dirPath, AUTO_UPLOAD_SCAN_DEPTH);
569
- return files;
571
+ return sortDeliverableFiles(files);
572
+ }
573
+
574
+ function deliverableFileSortKey(file) {
575
+ const name = normalizeSlash(file && file.name ? file.name : "").toLowerCase();
576
+ if (name === "index.html" || name === "index.htm") {
577
+ return "0:" + name;
578
+ }
579
+ if (path.basename(name) === "index.html" || path.basename(name) === "index.htm") {
580
+ return "1:" + name;
581
+ }
582
+ return "2:" + name;
583
+ }
584
+
585
+ function sortDeliverableFiles(files) {
586
+ return (files || []).slice().sort((a, b) => deliverableFileSortKey(a).localeCompare(deliverableFileSortKey(b)));
570
587
  }
571
588
 
572
589
  function extractStringField(obj, keys) {
@@ -603,8 +620,25 @@ function extractContextString(event, keys) {
603
620
  return "";
604
621
  }
605
622
 
606
- function payloadResourceId(body) {
607
- return extractStringField(body, ["resource_id", "resourceId", "conversation_id", "conversationId"]);
623
+ // payloadConversationID is the per-conversation key used for cache dedup and for
624
+ // correlating the upload-tool-call event with the later message-send event
625
+ // (replaces the old resource_id). conversation_id is present in both contexts.
626
+ function payloadConversationID(body) {
627
+ return extractStringField(body, ["conversation_id", "conversationId", "resource_id", "resourceId"]);
628
+ }
629
+
630
+ // isGroupChat reports whether the context is a group chat, keyed on
631
+ // conversation_type (the authoritative field the runtime actually sends —
632
+ // "direct" / "group", matching the engine). Only in a group chat is a
633
+ // conversation_id a meaningful group_id; in a direct chat it is a per-user
634
+ // session id (conv_<user>_<lobster>) that is NOT a group and must never be used
635
+ // as group_id (ai-kb would reject it with "user is not a member of group").
636
+ // Absent conversation_type, default to direct (single chat) — the safe choice.
637
+ function isGroupChat(obj) {
638
+ if (!obj || typeof obj !== "object") {
639
+ return false;
640
+ }
641
+ return extractStringField(obj, ["conversation_type", "conversationType"]).toLowerCase() === "group";
608
642
  }
609
643
 
610
644
  function payloadGroupId(body) {
@@ -612,7 +646,12 @@ function payloadGroupId(body) {
612
646
  if (explicit) {
613
647
  return explicit;
614
648
  }
615
- const fallback = extractStringField(body, ["resource_id", "resourceId", "conversation_id", "conversationId"]);
649
+ // Only fall back to the conversation id as a group id in a group chat; a direct
650
+ // chat must stay empty (-> engine treats it as the personal "我的文件夹" root).
651
+ if (!isGroupChat(body)) {
652
+ return "";
653
+ }
654
+ const fallback = extractStringField(body, ["conversation_id", "conversationId"]);
616
655
  return fallback && fallback !== "default" ? fallback : "";
617
656
  }
618
657
 
@@ -620,73 +659,76 @@ function enrichDeliverableGroupContext(event) {
620
659
  if (!event || !event.params || typeof event.params !== "object") {
621
660
  return;
622
661
  }
623
- const existingGroupId = trimString(event.params.group_id);
624
- if (!existingGroupId) {
625
- const inferredGroupId = extractContextString(event, [
626
- "group_id", "groupId", "conversation_id", "conversationId",
627
- ]);
628
- if (inferredGroupId && inferredGroupId !== "default") {
629
- event.params.group_id = inferredGroupId;
662
+ // Populate the raw routing fields from the message context when the agent did
663
+ // not pass them. group_id / user_id are then DERIVED from these at request time
664
+ // (payloadGroupId / payloadUserId), so we do not need to compute them here.
665
+ const rawFields = [
666
+ ["conversation_type", ["conversation_type", "conversationType"]],
667
+ ["conversation_id", ["conversation_id", "conversationId"]],
668
+ ["owner_id", ["owner_id", "ownerId"]],
669
+ ["sender_id", ["sender_id", "senderId"]],
670
+ ["group_id", ["group_id", "groupId"]],
671
+ ];
672
+ for (const [target, keys] of rawFields) {
673
+ if (trimString(event.params[target])) {
674
+ continue;
675
+ }
676
+ const inferred = extractContextString(event, keys);
677
+ if (inferred) {
678
+ event.params[target] = inferred;
630
679
  }
631
680
  }
632
681
  const existingGroupName = trimString(event.params.group_name);
633
682
  if (!existingGroupName) {
634
683
  const inferredGroupName = extractContextString(event, [
635
- "group_name", "groupName", "group_subject", "groupSubject",
684
+ "group_name", "groupName",
636
685
  ]);
637
686
  if (inferredGroupName) {
638
687
  event.params.group_name = inferredGroupName;
639
688
  }
640
689
  }
641
- const existingUserId = trimString(event.params.user_id);
642
- if (!existingUserId || existingUserId === "default") {
643
- const inferredUserId = extractContextString(event, [
644
- "sender_id", "senderId", "owner_id", "ownerId", "user_id", "userId",
690
+ // lobster_name is the deliverable's first-level isolation dir. Prefer the real
691
+ // value from params/context/metadata; do NOT fall back to release/resource ids —
692
+ // those yield wrong, unreadable directory names. A still-missing value is
693
+ // rejected by the engine, which is the correct signal.
694
+ const existingLobsterName = trimString(event.params.lobster_name);
695
+ if (!existingLobsterName) {
696
+ const inferredLobsterName = extractContextString(event, [
697
+ "lobster_name", "lobsterName", "agent_name", "agentName",
698
+ ]);
699
+ if (inferredLobsterName) {
700
+ event.params.lobster_name = inferredLobsterName;
701
+ }
702
+ }
703
+ const existingLobsterId = trimString(event.params.lobster_id);
704
+ if (!existingLobsterId) {
705
+ const inferredLobsterId = extractContextString(event, [
706
+ "lobster_id", "lobsterId", "agent_id", "agentId",
645
707
  ]);
646
- if (inferredUserId && inferredUserId !== "default") {
647
- event.params.user_id = inferredUserId;
708
+ if (inferredLobsterId) {
709
+ event.params.lobster_id = inferredLobsterId;
648
710
  }
649
711
  }
650
712
  }
651
713
 
714
+ // payloadUserId resolves the uploader id from conversation_type — direct chats
715
+ // upload as owner_id, group chats as sender_id (matching the deliverables
716
+ // guidance). Upstream never sends a `user_id`; it is always derived here. Each
717
+ // branch falls back to the other id so a partially-populated payload still
718
+ // resolves something.
652
719
  function payloadUserId(body) {
653
- return extractStringField(body, ["user_id", "userId", "sender_id", "senderId", "owner_id", "ownerId"]);
654
- }
655
-
656
- function normalizeBareUserID(value) {
657
- let raw = trimString(value);
658
- if (!raw) {
659
- return "";
720
+ if (isGroupChat(body)) {
721
+ return extractStringField(body, ["sender_id", "senderId", "owner_id", "ownerId"]);
660
722
  }
661
- if (raw.indexOf("user_") === 0 && raw.indexOf("_lobster_") > 0) {
662
- raw = raw.slice("user_".length, raw.indexOf("_lobster_"));
663
- } else if (raw.indexOf("user-") === 0 || raw.indexOf("user_") === 0) {
664
- raw = raw.slice("user-".length);
665
- }
666
- return raw;
667
- }
668
-
669
- function userIDFromRelease(release) {
670
- return normalizeBareUserID(release);
723
+ return extractStringField(body, ["owner_id", "ownerId", "sender_id", "senderId"]);
671
724
  }
672
725
 
673
- function userIDFromResourceID(resourceID) {
674
- const raw = trimString(resourceID);
675
- if (raw.indexOf("user_") !== 0 && raw.indexOf("user-") !== 0) {
676
- return "";
677
- }
678
- return normalizeBareUserID(raw);
726
+ function payloadLobsterName(body) {
727
+ return extractStringField(body, ["lobster_name", "lobsterName", "agent_name", "agentName"]);
679
728
  }
680
729
 
681
- function deriveReleaseName() {
682
- let release = process.env.botID || process.env.OPENCLAW_RELEASE || process.env.BOT_ID || "";
683
- if (!release) {
684
- const tok = process.env.OPENCLAW_GATEWAY_TOKEN || "";
685
- if (tok.indexOf("oc-") === 0) {
686
- release = tok.slice(3);
687
- }
688
- }
689
- return release;
730
+ function payloadLobsterId(body) {
731
+ return extractStringField(body, ["lobster_id", "lobsterId", "agent_id", "agentId"]);
690
732
  }
691
733
 
692
734
  function isWriteTool(toolName) {
@@ -719,25 +761,25 @@ function rememberPendingFile(event) {
719
761
  }
720
762
  const store = getPendingFileStore();
721
763
  pruneTimedMap(store, AUTO_UPLOAD_TTL_MS);
722
- const resourceId = extractContextString(event, ["resource_id", "resourceId", "conversation_id", "conversationId"]);
723
- const key = `${resourceId || "unknown"}:${writePath}`;
764
+ const conversationId = extractContextString(event, ["conversation_id", "conversationId", "resource_id", "resourceId"]);
765
+ const key = `${conversationId || "unknown"}:${writePath}`;
724
766
  store.set(key, {
725
767
  path: writePath,
726
- resourceId,
768
+ conversationId,
727
769
  createdAt: Date.now(),
728
770
  });
729
771
  }
730
772
 
731
773
  async function pendingCandidatesForPayload(body) {
732
- const resourceId = payloadResourceId(body);
733
- if (!resourceId) {
774
+ const conversationId = payloadConversationID(body);
775
+ if (!conversationId) {
734
776
  return [];
735
777
  }
736
778
  const store = getPendingFileStore();
737
779
  pruneTimedMap(store, AUTO_UPLOAD_TTL_MS);
738
780
  const out = [];
739
781
  for (const entry of store.values()) {
740
- if (!entry || entry.resourceId !== resourceId) {
782
+ if (!entry || entry.conversationId !== conversationId) {
741
783
  continue;
742
784
  }
743
785
  const ref = { raw: entry.path, value: entry.path, kind: "pending" };
@@ -749,97 +791,14 @@ async function pendingCandidatesForPayload(body) {
749
791
  return out;
750
792
  }
751
793
 
752
- function kbBaseURL() {
794
+ function engineBaseURL() {
753
795
  const cfg = loadDeliverableConfig();
754
- return (process.env.KNOWLEDGE_DB_URL || cfg.kbBaseUrl || DEFAULT_KB_BASE_URL).replace(/\/$/, "");
755
- }
756
-
757
- function kbApiKey() {
758
- return process.env.KNOWLEDGE_DB_API_KEY || DEFAULT_KB_API_KEY;
759
- }
760
-
761
- function kbUploadPath(groupId, groupName) {
762
- const gid = trimString(groupId) || DEFAULT_GROUP_ID;
763
- const gname = gid === DEFAULT_GROUP_ID
764
- ? DEFAULT_GROUP_NAME
765
- : trimString(groupName) || gid;
766
- return "/" + gname + "/raw/交付物";
767
- }
768
-
769
- function kbUploadItems(response) {
770
- const data = (response && response.data) || response || {};
771
- return Array.isArray(data.items) ? data.items : [];
772
- }
773
-
774
- function kbFirstSuccessfulUploadItem(response) {
775
- const items = kbUploadItems(response);
776
- if (items.length === 0) {
777
- return null;
778
- }
779
- return items.find((item) => item && !item.error && item.document) || items[0] || null;
780
- }
781
-
782
- function kbExtractDocument(response) {
783
- const item = kbFirstSuccessfulUploadItem(response);
784
- if (item && item.document && typeof item.document === "object" && !Array.isArray(item.document)) {
785
- return item.document;
786
- }
787
- const data = (response && response.data) || response || {};
788
- if (data.document && typeof data.document === "object" && !Array.isArray(data.document)) {
789
- return data.document;
790
- }
791
- return data && typeof data === "object" && !Array.isArray(data) ? data : {};
792
- }
793
-
794
- function kbExtractDocumentId(response) {
795
- const document = kbExtractDocument(response);
796
- if (document.id !== undefined) {
797
- return document.id;
798
- }
799
- const data = (response && response.data) || response || {};
800
- if (data.id !== undefined) {
801
- return data.id;
802
- }
803
- return null;
804
- }
805
-
806
- function extractURLField(obj, keys) {
807
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
808
- return "";
809
- }
810
- for (const key of keys) {
811
- const value = obj[key];
812
- if (isString(value) && value.trim()) {
813
- return value.trim();
814
- }
815
- }
816
- return "";
817
- }
818
-
819
- function kbExtractReturnedLinks(response) {
820
- const item = kbFirstSuccessfulUploadItem(response) || {};
821
- const document = kbExtractDocument(response);
822
- return {
823
- downloadURL:
824
- extractURLField(document, ["download_url", "downloadURL"]) ||
825
- extractURLField(item, ["download_url", "downloadURL"]),
826
- previewURL:
827
- extractURLField(document, ["preview_url", "previewURL"]) ||
828
- extractURLField(item, ["preview_url", "previewURL"]),
829
- };
830
- }
831
-
832
- function requireKBReturnedLink(response) {
833
- const links = kbExtractReturnedLinks(response);
834
- if (!links.previewURL && !links.downloadURL) {
835
- throw new Error("kb upload response missing preview_url/download_url");
836
- }
837
- return links;
796
+ return (process.env.UNICLAW_KB_ENGINE_URL || cfg.engineBaseUrl || DEFAULT_ENGINE_BASE_URL).replace(/\/$/, "");
838
797
  }
839
798
 
840
799
  function buildFileBuffersFromBody(body, isDirectory) {
841
800
  if (isDirectory && Array.isArray(body.files)) {
842
- return body.files.map((file) => ({
801
+ return sortDeliverableFiles(body.files).map((file) => ({
843
802
  fileName: file.name || "file",
844
803
  content: file.contentText
845
804
  ? Buffer.from(file.contentText, "utf8")
@@ -852,11 +811,29 @@ function buildFileBuffersFromBody(body, isDirectory) {
852
811
  return [{ fileName: body.fileName || "file", content }];
853
812
  }
854
813
 
855
- function kbMultipartUpload(fileBuffers, fields, pathsArray) {
814
+ function multipartEscape(value) {
815
+ return String(value || "")
816
+ .replace(/\\/g, "\\\\")
817
+ .replace(/"/g, '\\"')
818
+ .replace(/\r/g, "")
819
+ .replace(/\n/g, "");
820
+ }
821
+
822
+ function multipartLeafName(value) {
823
+ const normalized = String(value || "file").replace(/\\/g, "/");
824
+ const leaf = path.basename(normalized) || "file";
825
+ return multipartEscape(leaf);
826
+ }
827
+
828
+ // engineDeliverableUpload posts a deliverable to the kb-engine sync endpoint
829
+ // (multipart: a `payload` JSON field + repeated `files` parts). The engine
830
+ // uploads to the agent root, returns preview/download links immediately, and
831
+ // classifies + moves the file asynchronously. fileBuffers is [{fileName, content:Buffer}].
832
+ function engineDeliverableUpload(fileBuffers, payload) {
856
833
  return new Promise((resolve, reject) => {
857
834
  let parsed;
858
835
  try {
859
- parsed = new URL(kbBaseURL() + "/documents/batch-upload");
836
+ parsed = new URL(engineBaseURL() + "/deliverables");
860
837
  } catch (err) {
861
838
  reject(err);
862
839
  return;
@@ -864,49 +841,41 @@ function kbMultipartUpload(fileBuffers, fields, pathsArray) {
864
841
  const boundary = "----Deliverables" + Date.now() + Math.random().toString(36).slice(2);
865
842
  const parts = [];
866
843
 
844
+ // payload JSON field
845
+ parts.push(
846
+ Buffer.from(
847
+ "--" + boundary + "\r\n" +
848
+ 'Content-Disposition: form-data; name="payload"\r\n\r\n' +
849
+ JSON.stringify(payload) + "\r\n",
850
+ ),
851
+ );
852
+
867
853
  for (const file of fileBuffers) {
854
+ // Strip CR/LF from the relative path so a newline in a file name cannot
855
+ // inject extra multipart parts; the leaf filename is escaped separately.
856
+ const relativePath = String(file.fileName || "file").replace(/\\/g, "/").replace(/[\r\n]/g, "");
868
857
  parts.push(
869
858
  Buffer.from(
870
859
  "--" + boundary + "\r\n" +
871
- 'Content-Disposition: form-data; name="files"; filename="' + file.fileName + '"\r\n' +
872
- "Content-Type: application/octet-stream\r\n\r\n",
860
+ 'Content-Disposition: form-data; name="relative_paths"\r\n\r\n' +
861
+ relativePath + "\r\n",
873
862
  ),
874
863
  );
875
- parts.push(file.content);
876
- parts.push(Buffer.from("\r\n"));
877
- }
878
-
879
- if (Array.isArray(pathsArray) && pathsArray.length > 0) {
880
- for (const p of pathsArray) {
881
- parts.push(
882
- Buffer.from(
883
- "--" + boundary + "\r\n" +
884
- 'Content-Disposition: form-data; name="paths"\r\n\r\n' +
885
- (p || "") + "\r\n",
886
- ),
887
- );
888
- }
889
- }
890
-
891
- for (const [key, value] of Object.entries(fields || {})) {
892
- if (value === undefined || value === null || value === "") {
893
- continue;
894
- }
895
864
  parts.push(
896
865
  Buffer.from(
897
866
  "--" + boundary + "\r\n" +
898
- 'Content-Disposition: form-data; name="' + key + '"\r\n\r\n' +
899
- String(value) + "\r\n",
867
+ 'Content-Disposition: form-data; name="files"; filename="' + multipartLeafName(file.fileName) + '"\r\n' +
868
+ "Content-Type: application/octet-stream\r\n\r\n",
900
869
  ),
901
870
  );
871
+ parts.push(file.content);
872
+ parts.push(Buffer.from("\r\n"));
902
873
  }
903
-
904
874
  parts.push(Buffer.from("--" + boundary + "--\r\n"));
905
875
  const body = Buffer.concat(parts);
906
876
 
907
877
  const isHTTPS = parsed.protocol === "https:";
908
878
  const transport = isHTTPS ? https : http;
909
- const userId = (fields && fields.user_id) || "default";
910
879
  const req = transport.request(
911
880
  {
912
881
  hostname: parsed.hostname,
@@ -915,11 +884,9 @@ function kbMultipartUpload(fileBuffers, fields, pathsArray) {
915
884
  method: "POST",
916
885
  headers: {
917
886
  "Content-Type": "multipart/form-data; boundary=" + boundary,
918
- "X-User-ID": userId,
919
- "X-API-Key": kbApiKey(),
920
887
  "Content-Length": body.length,
921
888
  },
922
- timeout: 30000,
889
+ timeout: 60000,
923
890
  },
924
891
  (res) => {
925
892
  const chunks = [];
@@ -930,23 +897,25 @@ function kbMultipartUpload(fileBuffers, fields, pathsArray) {
930
897
  try {
931
898
  obj = JSON.parse(text);
932
899
  } catch (_err) {
933
- reject(new Error(`kb ${res.statusCode}: non-JSON response: ${text.slice(0, 200)}`));
900
+ reject(new Error(`engine ${res.statusCode}: non-JSON response: ${text.slice(0, 200)}`));
934
901
  return;
935
902
  }
936
903
  if (res.statusCode >= 400) {
937
- reject(new Error(`kb ${res.statusCode}: ${obj.message || text.slice(0, 200)}`));
904
+ reject(new Error(`engine ${res.statusCode}: ${obj.err_message || text.slice(0, 200)}`));
938
905
  return;
939
906
  }
940
- if (obj.code !== 0 && obj.code !== undefined && res.statusCode !== 206) {
941
- reject(new Error(`kb API error: ${obj.message || text.slice(0, 200)}`));
907
+ // Unified engine envelope: {code, err_message, data}. code != 0 is a
908
+ // business error.
909
+ if (obj.code !== 0 && obj.code !== undefined) {
910
+ reject(new Error(`engine error: ${obj.err_message || text.slice(0, 200)}`));
942
911
  return;
943
912
  }
944
- resolve(obj);
913
+ resolve(obj.data || obj);
945
914
  });
946
915
  },
947
916
  );
948
917
  req.on("timeout", () => {
949
- req.destroy(new Error("kb upload timeout after 30s"));
918
+ req.destroy(new Error("engine upload timeout after 60s"));
950
919
  });
951
920
  req.on("error", reject);
952
921
  req.write(body);
@@ -1263,21 +1232,34 @@ async function buildNativeUploadRequestBody(args) {
1263
1232
 
1264
1233
  async function uploadDeliverable(args) {
1265
1234
  const { body, isDirectory } = await buildNativeUploadRequestBody(args || {});
1235
+ const conversationType = extractStringField(args || {}, ["conversation_type", "conversationType"]);
1266
1236
  const userId = payloadUserId(args) || "default";
1267
1237
  const groupId = payloadGroupId(args) || DEFAULT_GROUP_ID;
1268
- const groupName = extractStringField(args || {}, ["group_name", "groupName", "group_subject", "groupSubject"]);
1269
- const uploadPath = kbUploadPath(groupId, groupName);
1238
+ const groupName = extractStringField(args || {}, ["group_name", "groupName"]);
1239
+ // lobster_name must be the real context value (or the agent-supplied same
1240
+ // value); no release/resource-id fallback. If still missing, engine rejects it.
1241
+ const lobsterName = payloadLobsterName(args);
1242
+ const lobsterId = payloadLobsterId(args);
1270
1243
  const fileBuffers = buildFileBuffersFromBody(body, isDirectory);
1271
- const fields = {
1244
+ // The engine builds the storage path and classifies — the plugin only supplies
1245
+ // the necessary parameters. content_text (single text file) is a classification
1246
+ // hint so the async classifier can pick a topic without re-reading the file.
1247
+ const payload = {
1272
1248
  user_id: userId,
1249
+ conversation_type: conversationType,
1250
+ lobster_name: lobsterName,
1251
+ lobster_id: lobsterId,
1273
1252
  group_id: groupId,
1274
- path: uploadPath,
1253
+ group_name: groupName,
1254
+ deliverable_type: body.type || "",
1255
+ content_text: !isDirectory && isString(body.contentText) ? body.contentText : "",
1275
1256
  };
1276
- const response = await kbMultipartUpload(fileBuffers, fields);
1277
- const documentId = kbExtractDocumentId(response);
1278
- const links = requireKBReturnedLink(response);
1279
- const previewURL = links.previewURL || links.downloadURL;
1280
- const downloadURL = links.downloadURL || previewURL;
1257
+ const data = await engineDeliverableUpload(fileBuffers, payload);
1258
+ const previewURL = data.preview_url || data.download_url || "";
1259
+ const downloadURL = data.download_url || previewURL;
1260
+ if (!previewURL && !downloadURL) {
1261
+ throw new Error("engine upload response missing preview_url/download_url");
1262
+ }
1281
1263
  const replyMarkdown = buildReplyMarkdown({
1282
1264
  previewURL,
1283
1265
  downloadURL,
@@ -1285,9 +1267,11 @@ async function uploadDeliverable(args) {
1285
1267
  isDirectory,
1286
1268
  });
1287
1269
  return {
1288
- document_id: documentId,
1270
+ document_id: data.document_id,
1289
1271
  download_url: downloadURL,
1290
1272
  preview_url: previewURL,
1273
+ category: data.category || "",
1274
+ classify_task_id: data.classify_task_id || "",
1291
1275
  reply_markdown: replyMarkdown,
1292
1276
  message: replyMarkdown,
1293
1277
  };
@@ -1299,15 +1283,15 @@ function nativeToolResult(result) {
1299
1283
  };
1300
1284
  }
1301
1285
 
1302
- function uploadCacheKey(candidate, resourceId) {
1303
- return `${resourceId || ""}:${candidate.path}:${candidate.mtimeMs || 0}:${candidate.size || 0}`;
1286
+ function uploadCacheKey(candidate, conversationId) {
1287
+ return `${conversationId || ""}:${candidate.path}:${candidate.mtimeMs || 0}:${candidate.size || 0}`;
1304
1288
  }
1305
1289
 
1306
1290
  async function uploadCandidate(candidate, body) {
1307
- const resourceId = payloadResourceId(body);
1291
+ const conversationId = payloadConversationID(body);
1308
1292
  const cache = getUploadCache();
1309
1293
  pruneTimedMap(cache, AUTO_UPLOAD_TTL_MS);
1310
- const cacheKey = uploadCacheKey(candidate, resourceId);
1294
+ const cacheKey = uploadCacheKey(candidate, conversationId);
1311
1295
  const cached = cache.get(cacheKey);
1312
1296
  if (cached && cached.result) {
1313
1297
  return cached.result;
@@ -1319,12 +1303,19 @@ async function uploadCandidate(candidate, body) {
1319
1303
  }
1320
1304
 
1321
1305
  const userId = payloadUserId(body) || "default";
1306
+ const conversationType = extractStringField(body || {}, ["conversation_type", "conversationType"]);
1322
1307
  const groupId = payloadGroupId(body) || DEFAULT_GROUP_ID;
1308
+ const groupName = extractStringField(body || {}, ["group_name", "groupName"]);
1309
+ // Auto-upload normally sees lobster_name on the outbound message body. If it is
1310
+ // absent, fall back only to lobster_id (a real, stable metadata field), never to
1311
+ // release/resource-derived names.
1312
+ const lobsterId = payloadLobsterId(body);
1313
+ const lobsterName = payloadLobsterName(body) || lobsterId;
1323
1314
  const fileName = candidate.fileName || path.basename(candidate.path);
1324
- const uploadPath = kbUploadPath(groupId, "");
1315
+ const isDirectory = stat.isDirectory();
1325
1316
 
1326
1317
  let fileBuffers;
1327
- if (stat.isDirectory()) {
1318
+ if (isDirectory) {
1328
1319
  const files = await collectDirectoryFiles(candidate.path);
1329
1320
  if (files.length === 0) {
1330
1321
  throw new Error(`directory has no uploadable files: ${candidate.fileName}`);
@@ -1340,18 +1331,24 @@ async function uploadCandidate(candidate, body) {
1340
1331
  fileBuffers = [{ fileName, content: await fs.promises.readFile(candidate.path) }];
1341
1332
  }
1342
1333
 
1343
- const response = await kbMultipartUpload(fileBuffers, {
1334
+ const data = await engineDeliverableUpload(fileBuffers, {
1344
1335
  user_id: userId,
1336
+ conversation_type: conversationType,
1337
+ lobster_name: lobsterName,
1338
+ lobster_id: lobsterId,
1345
1339
  group_id: groupId,
1346
- path: uploadPath,
1340
+ group_name: groupName,
1341
+ deliverable_type: deliverableTypeForPath(candidate.path, isDirectory),
1342
+ content_text: "",
1347
1343
  });
1348
- const documentId = kbExtractDocumentId(response);
1349
- const links = requireKBReturnedLink(response);
1350
- const previewURL = links.previewURL || links.downloadURL;
1351
- const downloadURL = links.downloadURL || previewURL;
1344
+ const previewURL = data.preview_url || data.download_url || "";
1345
+ const downloadURL = data.download_url || previewURL;
1346
+ if (!previewURL && !downloadURL) {
1347
+ throw new Error("engine upload response missing preview_url/download_url");
1348
+ }
1352
1349
  const result = {
1353
1350
  fileName,
1354
- document_id: documentId,
1351
+ document_id: data.document_id,
1355
1352
  previewURL,
1356
1353
  downloadURL,
1357
1354
  };
@@ -1781,8 +1778,8 @@ function buildSummaryFromUploadParams(params) {
1781
1778
  }
1782
1779
 
1783
1780
  function cacheUploadSummary(params) {
1784
- const resourceId = isString(params && params.resource_id) ? params.resource_id.trim() : "";
1785
- if (!resourceId) {
1781
+ const conversationId = payloadConversationID(params || {});
1782
+ if (!conversationId) {
1786
1783
  return;
1787
1784
  }
1788
1785
  const summary = buildSummaryFromUploadParams(params);
@@ -1790,7 +1787,7 @@ function cacheUploadSummary(params) {
1790
1787
  return;
1791
1788
  }
1792
1789
  const cache = getSummaryCache();
1793
- cache.set(resourceId, {
1790
+ cache.set(conversationId, {
1794
1791
  summary,
1795
1792
  createdAt: Date.now(),
1796
1793
  });
@@ -2151,7 +2148,7 @@ function shouldSplitPalzPayload(body) {
2151
2148
  return null;
2152
2149
  }
2153
2150
 
2154
- const cachedSummary = takeCachedSummary(body.resource_id);
2151
+ const cachedSummary = takeCachedSummary(payloadConversationID(body));
2155
2152
  if (cachedSummary && isSummaryTooShort(split.summary)) {
2156
2153
  return {
2157
2154
  summary: cachedSummary,
@@ -2408,12 +2405,13 @@ plugin.__test = {
2408
2405
  isDeliverableLinkLine,
2409
2406
  isOSSLikeURL,
2410
2407
  isTrustedDeliverableURL,
2411
- kbExtractDocumentId,
2412
- kbExtractReturnedLinks,
2413
- kbMultipartUpload,
2414
- kbUploadPath,
2408
+ isGroupChat,
2409
+ payloadUserId,
2410
+ payloadGroupId,
2411
+ payloadConversationID,
2412
+ engineDeliverableUpload,
2413
+ enrichDeliverableGroupContext,
2415
2414
  loadDeliverableConfig,
2416
- requireKBReturnedLink,
2417
2415
  selectPalzFileURL,
2418
2416
  splitDeliverableMessage,
2419
2417
  };
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "plugin-deliverables",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "npm_package": "@dai_ming/plugin-deliverables",
5
5
  "description": "Deliverables plugin: native upload tool + skill + AGENTS rules for AI-generated file uploads",
6
6
  "skills": {
7
7
  "deliverables": "skills/deliverables/SKILL.md"
8
8
  },
9
+ "contracts": {
10
+ "tools": ["deliverables__upload_deliverable"]
11
+ },
9
12
  "agents_rules": {
10
13
  "file": "agents-rules/deliverables.md",
11
14
  "start_marker": "DELIVERABLE_LINK_RULES_START",
@@ -2,8 +2,11 @@
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.2.3",
5
+ "version": "1.2.5",
6
6
  "skills": ["./skills"],
7
+ "contracts": {
8
+ "tools": ["deliverables__upload_deliverable"]
9
+ },
7
10
  "configSchema": {
8
11
  "type": "object",
9
12
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dai_ming/plugin-deliverables",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "OpenClaw deliverables native plugin — upload AI-generated files to OSS and return shareable preview/download links",
5
5
  "keywords": [
6
6
  "openclaw",
@@ -28,17 +28,21 @@ description: 上传AI生成的文件到交付物系统,返回可分享的预
28
28
 
29
29
  | 参数 | 必填 | 取值来源 | 示例 |
30
30
  |------|------|---------|------|
31
- | `user_id` | **是(必传)** | 单聊取 `owner_id`,群聊取 `sender_id`。**不可省略**,否则 KB 上传报错 13002 | `cbb0fab9...` |
32
- | `group_id` | 群聊时必填 | 群聊取消息元数据中的 `group_id`;**单聊不传**(自动为 `default`) | `grp_43c75713` |
33
- | `group_name` | 群聊时必填 | 群聊取消息元数据中的群聊名称;**单聊不传** | `项目讨论群` |
31
+ | `conversation_type` | **是** | 消息元数据 `conversation_type`:单聊 `"direct"`,群聊 `"group"` | `direct` |
32
+ | `conversation_id` | **是** | 消息元数据 `conversation_id`,原样传 | `conv_xxx_main` |
33
+ | `owner_id` | **是** | 消息元数据 `owner_id`,原样传 | `cbb0fab9...` |
34
+ | `sender_id` | **是** | 消息元数据 `sender_id`,原样传 | `cbb0fab9...` |
35
+ | `lobster_name` | **是** | 会话上下文/消息元数据中的 `lobster_name`,作交付物一级目录 | `我的助手` |
36
+ | `lobster_id` | **是** | 消息元数据 `lobster_id`,原样传 | `main` |
37
+ | `group_id` | 群聊**必填** | 群聊取消息元数据 `group_id`;**单聊不传**(自动归个人文件夹) | `grp_xxx` |
38
+ | `group_name` | 群聊**必填** | 群聊取消息元数据中的群聊名称;**单聊不传** | `项目讨论群` |
34
39
  | `type` | 是 | 根据内容选择:`article`/`game`/`image`/`video`/`ppt`/`pdf`/`zip`;`link` 仅用于外部 `http(s)` URL | `article` |
35
40
  | `file_name` | 是 | 有意义的文件名,单文件必须含扩展名;若用户未指定文档格式,默认用 `.md` | `report-2026.html` |
36
41
  | `content_text` | 按需 | 文件的完整文本内容(HTML/Markdown等) | `<html>...</html>` |
37
42
  | `file_path` | 按需 | PDF/PPT/图片/zip 等二进制文件的本地路径,推荐使用,避免手工复制 base64 | `output/sample.pdf` |
38
43
 
39
- > **单聊(direct chat)**:`user_id` `owner_id`,不传 `group_id` `group_name`。
40
- > **群聊(group chat)**:`user_id` `sender_id`,`group_id` `group_name` 从群聊元数据获取。
41
- > **⚠ 警告**:`user_id` 必须传入真实值。当 `group_id` 是真实群 ID 但 `user_id` 缺失或为 `"default"` 时,KB 后端会返回 `500: 用户不存在`。
44
+ > **不用自己算 `user_id` / `group_id`**:把上面的原始元数据(`conversation_type`/`conversation_id`/`owner_id`/`sender_id`)照填,插件会自动推导——单聊用 `owner_id` 且归个人文件夹,群聊用 `sender_id` 且归该群。
45
+ > **不要把单聊的 `conversation_id` `group_id` 传**,否则 KB 会因"用户不在群内"上传失败。
42
46
 
43
47
  ## 二进制文件上传(强制)
44
48