@ozaiya/openclaw-channel 0.10.24 → 0.10.26
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/dist/index.d.ts +1 -1
- package/dist/index.js +21 -1
- package/dist/index.js.map +1 -1
- package/dist/src/channel.d.ts +28 -0
- package/dist/src/channel.js +369 -36
- package/dist/src/channel.js.map +1 -1
- package/dist/src/configSchema.d.ts +1 -1
- package/dist/src/configSchema.js +6 -1
- package/dist/src/configSchema.js.map +1 -1
- package/dist/src/gateway.js +22 -5
- package/dist/src/gateway.js.map +1 -1
- package/dist/src/sandboxScreenCdp.d.ts +10 -0
- package/dist/src/sandboxScreenCdp.js +251 -65
- package/dist/src/sandboxScreenCdp.js.map +1 -1
- package/dist/src/types.d.ts +3 -0
- package/package.json +2 -2
- package/types/openclaw-plugin-sdk.d.ts +37 -0
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ozaiyaPlugin } from "./src/channel.js";
|
|
1
|
+
import { ozaiyaPlugin, recordNativeToolStart, recordNativeToolEnd, recordLlmUsage } from "./src/channel.js";
|
|
2
2
|
import { ozaiyaPluginConfigSchema } from "./src/configSchema.js";
|
|
3
3
|
import { setOzaiyaRuntime } from "./src/runtime.js";
|
|
4
4
|
const plugin = {
|
|
@@ -9,6 +9,26 @@ const plugin = {
|
|
|
9
9
|
register(api) {
|
|
10
10
|
setOzaiyaRuntime(api.runtime);
|
|
11
11
|
api.registerChannel({ plugin: ozaiyaPlugin });
|
|
12
|
+
// Observe OpenClaw's NATIVE tool calls (browser/shell/file/etc.) so the task
|
|
13
|
+
// step-timeline + per-task usage have data for agents that work through
|
|
14
|
+
// OpenClaw's own tools rather than the channel's text tools. Best-effort and
|
|
15
|
+
// optional: older gateways without the hook API simply skip this.
|
|
16
|
+
try {
|
|
17
|
+
api.on?.("before_tool_call", (event, hookCtx) => {
|
|
18
|
+
recordNativeToolStart(event?.toolName, hookCtx?.sessionKey, hookCtx?.agentId);
|
|
19
|
+
});
|
|
20
|
+
api.on?.("after_tool_call", (event, hookCtx) => {
|
|
21
|
+
recordNativeToolEnd(event?.toolName, !event?.error, hookCtx?.sessionKey, hookCtx?.agentId, event?.durationMs);
|
|
22
|
+
});
|
|
23
|
+
// Accumulate per-task LLM token usage so the Records tab + in-chat card can
|
|
24
|
+
// show input/output token counts alongside the activity chips.
|
|
25
|
+
api.on?.("llm_output", (event, hookCtx) => {
|
|
26
|
+
recordLlmUsage(event?.usage, hookCtx?.sessionKey, hookCtx?.agentId);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
/* hook API not available on this gateway version — native steps unavailable */
|
|
31
|
+
}
|
|
12
32
|
},
|
|
13
33
|
};
|
|
14
34
|
export default plugin;
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,YAAY,EAAE,qBAAqB,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAC5G,OAAO,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEpD,MAAM,MAAM,GAAG;IACb,EAAE,EAAE,QAAQ;IACZ,IAAI,EAAE,QAAQ;IACd,WAAW,EAAE,0CAA0C;IACvD,YAAY,EAAE,wBAAwB;IACtC,QAAQ,CAAC,GAAsB;QAC7B,gBAAgB,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAC9B,GAAG,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC;QAE9C,6EAA6E;QAC7E,wEAAwE;QACxE,6EAA6E;QAC7E,kEAAkE;QAClE,IAAI,CAAC;YACH,GAAG,CAAC,EAAE,EAAE,CAAC,kBAAkB,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE;gBAC9C,qBAAqB,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;YAChF,CAAC,CAAC,CAAC;YACH,GAAG,CAAC,EAAE,EAAE,CAAC,iBAAiB,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE;gBAC7C,mBAAmB,CACjB,KAAK,EAAE,QAAQ,EACf,CAAC,KAAK,EAAE,KAAK,EACb,OAAO,EAAE,UAAU,EACnB,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,UAAU,CAClB,CAAC;YACJ,CAAC,CAAC,CAAC;YACH,4EAA4E;YAC5E,+DAA+D;YAC/D,GAAG,CAAC,EAAE,EAAE,CAAC,YAAY,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE;gBACxC,cAAc,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;YACtE,CAAC,CAAC,CAAC;QACL,CAAC;QAAC,MAAM,CAAC;YACP,+EAA+E;QACjF,CAAC;IACH,CAAC;CACF,CAAC;AAEF,eAAe,MAAM,CAAC"}
|
package/dist/src/channel.d.ts
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
1
|
import type { ChannelPlugin } from "openclaw/plugin-sdk";
|
|
2
2
|
import type { ResolvedOzaiyaAccount } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Record the START of an OpenClaw NATIVE tool call (browser/shell/file/etc.) as a
|
|
5
|
+
* task step, so the step timeline + per-task usage have data for agents that work
|
|
6
|
+
* through OpenClaw's own tools (not the channel's text tools). Called from the
|
|
7
|
+
* `before_tool_call` plugin hook. No-op when the tool is a channel tool (already
|
|
8
|
+
* tracked) or no live dispatch matches.
|
|
9
|
+
*/
|
|
10
|
+
export declare function recordNativeToolStart(toolName: string | undefined, sessionKey?: string, agentId?: string): void;
|
|
11
|
+
/**
|
|
12
|
+
* Record the END of an OpenClaw native tool call. Called from `after_tool_call`.
|
|
13
|
+
* If no matching in-progress step exists (e.g. the `before` hook was missed), a
|
|
14
|
+
* completed step is synthesized from `durationMs` so the timeline stays correct.
|
|
15
|
+
*/
|
|
16
|
+
export declare function recordNativeToolEnd(toolName: string | undefined, success: boolean, sessionKey?: string, agentId?: string, durationMs?: number): void;
|
|
17
|
+
/**
|
|
18
|
+
* Accumulate LLM token usage onto the live dispatch. Called from the `llm_output`
|
|
19
|
+
* plugin hook after each model call. Input-side tokens (fresh input + cache read +
|
|
20
|
+
* cache write — all billed as input) and output tokens are summed across the task so
|
|
21
|
+
* the Records tab + in-chat card can show per-task token usage. Best-effort: no-op
|
|
22
|
+
* when no live dispatch matches the session (mirrors the native-tool hooks).
|
|
23
|
+
*/
|
|
24
|
+
export declare function recordLlmUsage(usage: {
|
|
25
|
+
input?: number;
|
|
26
|
+
output?: number;
|
|
27
|
+
cacheRead?: number;
|
|
28
|
+
cacheWrite?: number;
|
|
29
|
+
total?: number;
|
|
30
|
+
} | undefined, sessionKey?: string, agentId?: string): void;
|
|
3
31
|
export declare const ozaiyaPlugin: ChannelPlugin<ResolvedOzaiyaAccount>;
|
package/dist/src/channel.js
CHANGED
|
@@ -122,6 +122,8 @@ function buildTaskProgressContent(dispatch, completed) {
|
|
|
122
122
|
screenId: dispatch.screenId,
|
|
123
123
|
startedAt: dispatch.startedAt,
|
|
124
124
|
endedAt: completed ? Date.now() : undefined,
|
|
125
|
+
tokensInput: dispatch.tokensInput,
|
|
126
|
+
tokensOutput: dispatch.tokensOutput,
|
|
125
127
|
},
|
|
126
128
|
};
|
|
127
129
|
}
|
|
@@ -165,8 +167,286 @@ function onToolCallComplete(dispatch, toolName, success) {
|
|
|
165
167
|
}
|
|
166
168
|
sendOrEditProgressMessage(dispatch, false).catch(() => { });
|
|
167
169
|
}
|
|
170
|
+
// Channel tools (the text-fallback set) are already step-tracked by the execute
|
|
171
|
+
// wrapper; the native-tool hook skips these names so they aren't double-counted.
|
|
172
|
+
const KNOWN_CHANNEL_TOOLS = new Set();
|
|
173
|
+
// Debounced progress-card edit. Native browsing can fire many tool calls in quick
|
|
174
|
+
// succession; without this we'd edit the chat message dozens of times. The step is
|
|
175
|
+
// recorded synchronously regardless — only the visible card update is coalesced.
|
|
176
|
+
function scheduleProgressUpdate(dispatch) {
|
|
177
|
+
if (dispatch.progressTimer)
|
|
178
|
+
return;
|
|
179
|
+
dispatch.progressTimer = setTimeout(() => {
|
|
180
|
+
dispatch.progressTimer = undefined;
|
|
181
|
+
sendOrEditProgressMessage(dispatch, false).catch(() => { });
|
|
182
|
+
}, 1000);
|
|
183
|
+
}
|
|
184
|
+
/** A short, single-line title for a task, derived from the user's prompt. */
|
|
185
|
+
function deriveTaskTitle(text) {
|
|
186
|
+
if (!text)
|
|
187
|
+
return undefined;
|
|
188
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
189
|
+
if (!oneLine)
|
|
190
|
+
return undefined;
|
|
191
|
+
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
|
192
|
+
}
|
|
193
|
+
/** Find the live dispatch a native-tool hook belongs to (by session, then agent). */
|
|
194
|
+
function findActiveDispatchBySession(sessionKey, agentId) {
|
|
195
|
+
if (!sessionKey && !agentId)
|
|
196
|
+
return undefined;
|
|
197
|
+
for (const d of activeDispatches.values()) {
|
|
198
|
+
if (sessionKey && d.sessionKey === sessionKey)
|
|
199
|
+
return d;
|
|
200
|
+
}
|
|
201
|
+
for (const d of activeDispatches.values()) {
|
|
202
|
+
if (agentId && d.agentId === agentId)
|
|
203
|
+
return d;
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Record the START of an OpenClaw NATIVE tool call (browser/shell/file/etc.) as a
|
|
209
|
+
* task step, so the step timeline + per-task usage have data for agents that work
|
|
210
|
+
* through OpenClaw's own tools (not the channel's text tools). Called from the
|
|
211
|
+
* `before_tool_call` plugin hook. No-op when the tool is a channel tool (already
|
|
212
|
+
* tracked) or no live dispatch matches.
|
|
213
|
+
*/
|
|
214
|
+
export function recordNativeToolStart(toolName, sessionKey, agentId) {
|
|
215
|
+
if (!toolName || KNOWN_CHANNEL_TOOLS.has(toolName))
|
|
216
|
+
return;
|
|
217
|
+
const dispatch = findActiveDispatchBySession(sessionKey, agentId);
|
|
218
|
+
if (!dispatch)
|
|
219
|
+
return;
|
|
220
|
+
onToolCallStart(dispatch, toolName, toolName);
|
|
221
|
+
// onToolCallStart edits immediately; for chatty native tools, debounce instead.
|
|
222
|
+
if (dispatch.progressTimer) {
|
|
223
|
+
clearTimeout(dispatch.progressTimer);
|
|
224
|
+
dispatch.progressTimer = undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Record the END of an OpenClaw native tool call. Called from `after_tool_call`.
|
|
229
|
+
* If no matching in-progress step exists (e.g. the `before` hook was missed), a
|
|
230
|
+
* completed step is synthesized from `durationMs` so the timeline stays correct.
|
|
231
|
+
*/
|
|
232
|
+
export function recordNativeToolEnd(toolName, success, sessionKey, agentId, durationMs) {
|
|
233
|
+
if (!toolName || KNOWN_CHANNEL_TOOLS.has(toolName))
|
|
234
|
+
return;
|
|
235
|
+
const dispatch = findActiveDispatchBySession(sessionKey, agentId);
|
|
236
|
+
if (!dispatch)
|
|
237
|
+
return;
|
|
238
|
+
const step = dispatch.steps.find((s) => s.toolName === toolName && s.status === "in_progress");
|
|
239
|
+
if (step) {
|
|
240
|
+
step.status = success ? "completed" : "failed";
|
|
241
|
+
step.completedAt = Date.now();
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
const end = Date.now();
|
|
245
|
+
dispatch.steps.push({
|
|
246
|
+
toolName,
|
|
247
|
+
label: toolName,
|
|
248
|
+
status: success ? "completed" : "failed",
|
|
249
|
+
category: categorize(toolName),
|
|
250
|
+
startedAt: durationMs != null ? Math.max(0, end - durationMs) : end,
|
|
251
|
+
completedAt: end,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (dispatch.currentTool === toolName) {
|
|
255
|
+
dispatch.currentTool = undefined;
|
|
256
|
+
dispatch.currentLabel = undefined;
|
|
257
|
+
dispatch.currentCategory = undefined;
|
|
258
|
+
}
|
|
259
|
+
scheduleProgressUpdate(dispatch);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Accumulate LLM token usage onto the live dispatch. Called from the `llm_output`
|
|
263
|
+
* plugin hook after each model call. Input-side tokens (fresh input + cache read +
|
|
264
|
+
* cache write — all billed as input) and output tokens are summed across the task so
|
|
265
|
+
* the Records tab + in-chat card can show per-task token usage. Best-effort: no-op
|
|
266
|
+
* when no live dispatch matches the session (mirrors the native-tool hooks).
|
|
267
|
+
*/
|
|
268
|
+
export function recordLlmUsage(usage, sessionKey, agentId) {
|
|
269
|
+
if (!usage)
|
|
270
|
+
return;
|
|
271
|
+
const dispatch = findActiveDispatchBySession(sessionKey, agentId);
|
|
272
|
+
if (!dispatch)
|
|
273
|
+
return;
|
|
274
|
+
const input = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
|
275
|
+
const output = usage.output ?? 0;
|
|
276
|
+
if (input)
|
|
277
|
+
dispatch.tokensInput = (dispatch.tokensInput ?? 0) + input;
|
|
278
|
+
if (output)
|
|
279
|
+
dispatch.tokensOutput = (dispatch.tokensOutput ?? 0) + output;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Pull the largest GitHub-flavored markdown table out of `text` as rows of cells,
|
|
283
|
+
* dropping the `---|---` separator row. Returns null when there's no real table
|
|
284
|
+
* (needs a header + separator + at least one body row).
|
|
285
|
+
*/
|
|
286
|
+
function extractMarkdownTable(text) {
|
|
287
|
+
const lines = text.split(/\r?\n/);
|
|
288
|
+
let best = null;
|
|
289
|
+
let cur = [];
|
|
290
|
+
const flush = () => {
|
|
291
|
+
if (cur.length >= 3 && (!best || cur.length > best.length))
|
|
292
|
+
best = cur;
|
|
293
|
+
cur = [];
|
|
294
|
+
};
|
|
295
|
+
for (const raw of lines) {
|
|
296
|
+
const line = raw.trim();
|
|
297
|
+
if (line.startsWith("|") && line.endsWith("|") && line.length > 2) {
|
|
298
|
+
cur.push(line.slice(1, -1).split("|").map((c) => c.trim()));
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
flush();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
flush();
|
|
305
|
+
if (!best)
|
|
306
|
+
return null;
|
|
307
|
+
return best.filter((row) => !row.every((c) => /^:?-+:?$/.test(c) || c === ""));
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Fallback table extractor for the bullet-list format these agents actually emit
|
|
311
|
+
* (e.g. section headers like "机械键盘" / "显示器" followed by `- 型号:价格` lines).
|
|
312
|
+
* Produces [分类, 型号, 价格] rows (or [项目, 价格] when there are no sections).
|
|
313
|
+
*/
|
|
314
|
+
function extractBulletTable(text) {
|
|
315
|
+
const lines = text.split(/\r?\n/);
|
|
316
|
+
const rows = [];
|
|
317
|
+
let section = "";
|
|
318
|
+
let usedSection = false;
|
|
319
|
+
// Accept the many bullet glyphs models emit (hyphen, en/em dash, •·●▪◦‣*, digits like "1.").
|
|
320
|
+
const bulletRe = /^(?:[-*•·●▪◦‣–—‐]|\d+[.)])\s*(.+?)\s*[::]\s*(.+?)\s*$/;
|
|
321
|
+
const strip = (s) => s.replace(/\*\*/g, "").replace(/`/g, "").trim();
|
|
322
|
+
for (const raw of lines) {
|
|
323
|
+
const line = raw.trim();
|
|
324
|
+
if (!line)
|
|
325
|
+
continue;
|
|
326
|
+
const m = bulletRe.exec(line);
|
|
327
|
+
if (m) {
|
|
328
|
+
const name = strip(m[1]);
|
|
329
|
+
const value = strip(m[2]);
|
|
330
|
+
if (name && value) {
|
|
331
|
+
if (section) {
|
|
332
|
+
rows.push([section, name, value]);
|
|
333
|
+
usedSection = true;
|
|
334
|
+
}
|
|
335
|
+
else
|
|
336
|
+
rows.push([name, value]);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else if (!/^(?:[-*•·●▪◦‣–—‐]|\d+[.)])/.test(line)
|
|
340
|
+
&& line.length <= 24
|
|
341
|
+
&& !line.includes(":") && !line.includes(":")
|
|
342
|
+
&& !/[。.!?!?;;,,]$/.test(line)) {
|
|
343
|
+
// A short, punctuation-free standalone line is a section header.
|
|
344
|
+
section = strip(line);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (rows.length < 2)
|
|
348
|
+
return null;
|
|
349
|
+
if (usedSection) {
|
|
350
|
+
const norm = rows.map((r) => (r.length === 3 ? r : ["", r[0], r[1]]));
|
|
351
|
+
return [["分类", "型号/项目", "价格/信息"], ...norm];
|
|
352
|
+
}
|
|
353
|
+
return [["项目", "信息"], ...rows];
|
|
354
|
+
}
|
|
355
|
+
/** Serialize table rows to RFC-4180 CSV. */
|
|
356
|
+
function rowsToCsv(rows) {
|
|
357
|
+
return rows
|
|
358
|
+
.map((r) => r.map((c) => {
|
|
359
|
+
const v = c.replace(/<br\s*\/?>/gi, " ").trim();
|
|
360
|
+
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
|
|
361
|
+
}).join(","))
|
|
362
|
+
.join("\n");
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Deliverable safety net + format guarantee. Two jobs:
|
|
366
|
+
* 1. If the model published NOTHING for a deliverable request, recover the table
|
|
367
|
+
* (markdown or the bullet-list format agents emit) from the chat reply.
|
|
368
|
+
* 2. **Excel guarantee** — when the user asked for a 表格/spreadsheet but the model
|
|
369
|
+
* published a NON-spreadsheet file (e.g. HTML), additionally produce a `.csv`
|
|
370
|
+
* (Excel-openable) from the reply, so a "表格" request always yields a CSV.
|
|
371
|
+
* Plain prose asks ("报告/文档") fall back to a markdown report only when nothing
|
|
372
|
+
* was published. Best-effort throughout.
|
|
373
|
+
*/
|
|
374
|
+
async function maybeAutoPublishDeliverable(dispatch, account, groupId, ctx) {
|
|
375
|
+
const title = dispatch.title ?? "";
|
|
376
|
+
const ask = title.toLowerCase();
|
|
377
|
+
// A tabular/spreadsheet ask — these MUST end up as a CSV the user can open in Excel.
|
|
378
|
+
const wantsSpreadsheet = /(表格|清单|列表|电子表格|导出|数据)/.test(title)
|
|
379
|
+
|| /(excel|csv|xlsx|spreadsheet)/.test(ask);
|
|
380
|
+
const wantsDeliverable = wantsSpreadsheet
|
|
381
|
+
|| /(报告|report|文档|document|文件|\bfile\b|\btable\b|export)/.test(ask)
|
|
382
|
+
|| /报告/.test(title);
|
|
383
|
+
if (!wantsDeliverable)
|
|
384
|
+
return;
|
|
385
|
+
const have = dispatch.deliverables ?? [];
|
|
386
|
+
const haveSpreadsheet = have.some((d) => /csv|spreadsheet|ms-excel|excel/i.test(d.mime ?? "") || /\.(csv|xlsx?|xls)$/i.test(d.name ?? ""));
|
|
387
|
+
// Already delivered an Excel-openable file → done.
|
|
388
|
+
if (haveSpreadsheet)
|
|
389
|
+
return;
|
|
390
|
+
// A non-spreadsheet deliverable exists AND the user didn't specifically want a
|
|
391
|
+
// spreadsheet → that deliverable already satisfies the ask. (When the user DID want
|
|
392
|
+
// a spreadsheet we fall through to add a CSV alongside the model's HTML.)
|
|
393
|
+
if (have.length > 0 && !wantsSpreadsheet)
|
|
394
|
+
return;
|
|
395
|
+
// Prefer a real markdown table, then the bullet-list format the agents actually
|
|
396
|
+
// emit. Largest extraction across all delivered chunks wins.
|
|
397
|
+
let rows = null;
|
|
398
|
+
for (const text of dispatch.replyTexts ?? []) {
|
|
399
|
+
const t = extractMarkdownTable(text) ?? extractBulletTable(text);
|
|
400
|
+
if (t && (!rows || t.length > rows.length))
|
|
401
|
+
rows = t;
|
|
402
|
+
}
|
|
403
|
+
// Markdown-report fallback only when NOTHING was published and the ask isn't a
|
|
404
|
+
// spreadsheet (a 表格 ask with no parseable table can't be forced into a CSV).
|
|
405
|
+
const longestReply = (dispatch.replyTexts ?? [])
|
|
406
|
+
.map((t) => t.trim())
|
|
407
|
+
.filter((t) => t.length >= 150)
|
|
408
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
409
|
+
const makeCsv = !!rows && rows.length >= 2;
|
|
410
|
+
const makeMd = !makeCsv && have.length === 0 && !wantsSpreadsheet && !!longestReply;
|
|
411
|
+
ctx.log?.info?.(`ozaiya: auto-deliverable — wantsSpreadsheet=${wantsSpreadsheet}, have=${have.length}, rows=${rows?.length ?? 0}, csv=${makeCsv}, md=${makeMd}`);
|
|
412
|
+
if (!makeCsv && !makeMd)
|
|
413
|
+
return;
|
|
414
|
+
try {
|
|
415
|
+
const safe = (title.replace(/[^\p{L}\p{N}_-]+/gu, "_").slice(0, 40)) || "table";
|
|
416
|
+
const body = makeCsv ? `${rowsToCsv(rows)}` : longestReply; // CSV gets a UTF-8 BOM for Excel
|
|
417
|
+
const name = makeCsv ? `${safe}.csv` : `${safe}.md`;
|
|
418
|
+
const mime = makeCsv ? "text/csv" : "text/markdown";
|
|
419
|
+
const tmp = path.join(os.tmpdir(), `ozaiya-deliverable-${Date.now()}.${makeCsv ? "csv" : "md"}`);
|
|
420
|
+
await fs.writeFile(tmp, body, "utf8");
|
|
421
|
+
const file = await prepareOutboundAttachment(account, groupId, { localPath: tmp, name, mime });
|
|
422
|
+
await fs.unlink(tmp).catch(() => { });
|
|
423
|
+
const deliverable = {
|
|
424
|
+
name: title.trim() ? `${title.trim()}${makeCsv ? "(表格)" : ""}` : file.name,
|
|
425
|
+
url: file.url,
|
|
426
|
+
mime: file.mime,
|
|
427
|
+
size: file.size,
|
|
428
|
+
published: false,
|
|
429
|
+
};
|
|
430
|
+
// For a spreadsheet ask, the CSV supersedes any HTML/markdown rendering of the
|
|
431
|
+
// SAME table the model published — drop those so the user gets one Excel asset
|
|
432
|
+
// (other artifacts like images/PDF are kept).
|
|
433
|
+
const kept = (makeCsv && wantsSpreadsheet)
|
|
434
|
+
? have.filter((d) => !/html|markdown/i.test(d.mime ?? "") && !/\.(html?|md|markdown)$/i.test(d.name ?? ""))
|
|
435
|
+
: have;
|
|
436
|
+
const dropped = have.length - kept.length;
|
|
437
|
+
dispatch.deliverables = [...kept, deliverable];
|
|
438
|
+
ctx.log?.info?.(`ozaiya: auto-published ${makeCsv ? `table CSV (${rows.length} rows)` : "markdown report"}${dropped ? ` (dropped ${dropped} non-spreadsheet deliverable${dropped > 1 ? "s" : ""})` : ""}`);
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
ctx.log?.warn?.(`ozaiya: auto-deliverable failed: ${String(err)}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
168
444
|
async function finalizeTaskProgress(dispatch) {
|
|
169
445
|
const now = Date.now();
|
|
446
|
+
if (dispatch.progressTimer) {
|
|
447
|
+
clearTimeout(dispatch.progressTimer);
|
|
448
|
+
dispatch.progressTimer = undefined;
|
|
449
|
+
}
|
|
170
450
|
for (const step of dispatch.steps) {
|
|
171
451
|
if (step.status === "in_progress") {
|
|
172
452
|
step.status = "completed";
|
|
@@ -1522,14 +1802,33 @@ export const ozaiyaPlugin = {
|
|
|
1522
1802
|
handleAction: async (ctx) => {
|
|
1523
1803
|
const { action, params, cfg, accountId } = ctx;
|
|
1524
1804
|
const account = resolveAccount(cfg, accountId);
|
|
1805
|
+
// OpenClaw core dispatches actions with params { target, targets, message, ... }; this handler
|
|
1806
|
+
// used to read { chatId, content }, so groupId came out undefined ("No group key available for
|
|
1807
|
+
// group undefined") and the reply text was dropped — the bot then silently failed to answer.
|
|
1808
|
+
// Resolve the group id from any of those shapes (stripping the ozaiya:group: prefix) and fall
|
|
1809
|
+
// back to the active inbound dispatch's group (the conversation being replied to).
|
|
1810
|
+
const resolveActionGroupId = () => {
|
|
1811
|
+
const raw = [
|
|
1812
|
+
params.chatId,
|
|
1813
|
+
params.groupId,
|
|
1814
|
+
params.target,
|
|
1815
|
+
Array.isArray(params.targets) ? params.targets[0] : undefined,
|
|
1816
|
+
].find((c) => typeof c === "string" && c.trim());
|
|
1817
|
+
if (typeof raw === "string")
|
|
1818
|
+
return raw.trim().replace(/^ozaiya:group:/, "");
|
|
1819
|
+
return activeDispatches.get(account.accountId)?.groupId
|
|
1820
|
+
?? accountToOriginGroupId.get(account.accountId);
|
|
1821
|
+
};
|
|
1822
|
+
const actionText = (params.content ?? params.message ?? params.text);
|
|
1525
1823
|
switch (action) {
|
|
1526
1824
|
case "send": {
|
|
1527
|
-
const groupId =
|
|
1528
|
-
|
|
1825
|
+
const groupId = resolveActionGroupId();
|
|
1826
|
+
if (!groupId)
|
|
1827
|
+
return { ok: false, error: "send: could not resolve target group" };
|
|
1529
1828
|
const result = await sendEncryptedChatContent({
|
|
1530
1829
|
account,
|
|
1531
1830
|
groupId,
|
|
1532
|
-
content: { text:
|
|
1831
|
+
content: { text: actionText ?? "" },
|
|
1533
1832
|
});
|
|
1534
1833
|
return { ok: true, messageId: result.message.id };
|
|
1535
1834
|
}
|
|
@@ -1541,11 +1840,12 @@ export const ozaiyaPlugin = {
|
|
|
1541
1840
|
}
|
|
1542
1841
|
case "edit": {
|
|
1543
1842
|
const messageId = params.messageId;
|
|
1544
|
-
const
|
|
1545
|
-
|
|
1843
|
+
const groupId = resolveActionGroupId();
|
|
1844
|
+
if (!groupId)
|
|
1845
|
+
return { ok: false, error: "edit: could not resolve target group" };
|
|
1546
1846
|
const groupKey = await getGroupKeyOrThrow(account, groupId);
|
|
1547
1847
|
const enrichedContent = await enrichOutgoingMessageContent({
|
|
1548
|
-
content: { text:
|
|
1848
|
+
content: { text: actionText ?? "" },
|
|
1549
1849
|
resolveLinkPreview: (url) => fetchLinkPreview(account.apiBaseUrl, account.botToken, url),
|
|
1550
1850
|
resolveAttachment: (attachment) => prepareOutboundAttachment(account, groupId, attachment),
|
|
1551
1851
|
});
|
|
@@ -3473,6 +3773,10 @@ ctx) {
|
|
|
3473
3773
|
startedAt: Date.now(),
|
|
3474
3774
|
screenId: "sandbox",
|
|
3475
3775
|
recording: null,
|
|
3776
|
+
sessionKey: route.sessionKey,
|
|
3777
|
+
agentId: route.agentId,
|
|
3778
|
+
title: deriveTaskTitle(effectiveMessageText ?? agentInput),
|
|
3779
|
+
replyTexts: [],
|
|
3476
3780
|
};
|
|
3477
3781
|
activeDispatches.set(account.accountId, dispatch);
|
|
3478
3782
|
// Start screen recording for replay (best-effort; null if nothing recordable).
|
|
@@ -3488,6 +3792,10 @@ ctx) {
|
|
|
3488
3792
|
// Uses the current bot's account directly (not the factory which re-resolves in gateway mode).
|
|
3489
3793
|
const channelTools = buildChannelTools(account, ctx.cfg);
|
|
3490
3794
|
const channelToolsByName = new Map(channelTools.map((t) => [t.name, t]));
|
|
3795
|
+
// Register channel tool names so the native-tool hook skips them (avoids double
|
|
3796
|
+
// counting; channel tools are already tracked by their execute wrapper).
|
|
3797
|
+
for (const name of channelToolsByName.keys())
|
|
3798
|
+
KNOWN_CHANNEL_TOOLS.add(name);
|
|
3491
3799
|
ctx.log?.info?.(`ozaiya: text fallback tools loaded: ${channelToolsByName.size} tools [${[...channelToolsByName.keys()].join(", ")}]`);
|
|
3492
3800
|
// Dispatch to agent with buffered block dispatcher
|
|
3493
3801
|
try {
|
|
@@ -3500,6 +3808,9 @@ ctx) {
|
|
|
3500
3808
|
ctx.log?.info?.(`ozaiya: deliver called, text length=${replyText?.length ?? 0}, empty=${!replyText?.trim()}, voiceReply=${voiceReply}, voiceReplyVoice=${voiceReplyVoice ?? 'none'}`);
|
|
3501
3809
|
if (!replyText?.trim())
|
|
3502
3810
|
return;
|
|
3811
|
+
// Remember delivered text so the post-dispatch deliverable safety net can
|
|
3812
|
+
// recover a table/report the model pasted inline instead of publishing.
|
|
3813
|
+
activeDispatches.get(account.accountId)?.replyTexts?.push(replyText);
|
|
3503
3814
|
// Generic fallback: intercept tool calls that models output as text
|
|
3504
3815
|
// instead of structured API tool_calls. Supports two formats:
|
|
3505
3816
|
// 1. JSON function syntax: tool_name({"arg":"value"}) or tool_name({arg: "value"})
|
|
@@ -3623,45 +3934,67 @@ ctx) {
|
|
|
3623
3934
|
});
|
|
3624
3935
|
}
|
|
3625
3936
|
finally {
|
|
3937
|
+
// Recover an inline table the model forgot to publish_artifact, BEFORE finalizing
|
|
3938
|
+
// so the final progress card includes the deliverable.
|
|
3939
|
+
await maybeAutoPublishDeliverable(dispatch, account, groupId, ctx).catch(() => { });
|
|
3626
3940
|
await finalizeTaskProgress(dispatch);
|
|
3627
|
-
//
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3941
|
+
// Register the task for replay (best-effort). Upload the screen recording if we
|
|
3942
|
+
// captured one; otherwise still register a STEPS-ONLY recording so the task shows
|
|
3943
|
+
// up in the bot's Records tab (and counts toward usage via task.completed). Video
|
|
3944
|
+
// is a bonus, not a prerequisite — compose-mode gateways without a recording-control
|
|
3945
|
+
// server can't capture video but still produce a useful step timeline.
|
|
3946
|
+
try {
|
|
3947
|
+
const recording = dispatch.recording;
|
|
3948
|
+
// Align step offsets to the recording start when we have one, else to the
|
|
3949
|
+
// dispatch start so a steps-only timeline is still correctly anchored at t=0.
|
|
3950
|
+
const baselineMs = recording?.startedAt ?? dispatch.startedAt;
|
|
3951
|
+
let videoUrl = "";
|
|
3952
|
+
let videoPath = null;
|
|
3953
|
+
let durationMs = Math.max(0, Date.now() - baselineMs);
|
|
3954
|
+
if (recording) {
|
|
3631
3955
|
const rec = await stopAndExtractRecording(recording);
|
|
3632
3956
|
if (rec && rec.mp4.length > 0) {
|
|
3633
3957
|
const filename = `replay-${dispatch.taskId}.mp4`.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
3634
3958
|
const uploaded = await uploadFile(account.apiBaseUrl, account.botToken, groupId, filename, "video/mp4", rec.mp4);
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
status: s.status,
|
|
3639
|
-
category: s.category,
|
|
3640
|
-
startMs: s.startedAt != null ? Math.max(0, s.startedAt - recording.startedAt) : null,
|
|
3641
|
-
endMs: s.completedAt != null ? Math.max(0, s.completedAt - recording.startedAt) : null,
|
|
3642
|
-
}));
|
|
3643
|
-
await fetch(`${account.apiBaseUrl}/v1/bot/task-recordings`, {
|
|
3644
|
-
method: "POST",
|
|
3645
|
-
headers: {
|
|
3646
|
-
Authorization: `Bearer ${account.botToken}`,
|
|
3647
|
-
"Content-Type": "application/json",
|
|
3648
|
-
},
|
|
3649
|
-
body: JSON.stringify({
|
|
3650
|
-
taskId: dispatch.taskId,
|
|
3651
|
-
groupId,
|
|
3652
|
-
videoUrl: uploaded.url,
|
|
3653
|
-
videoPath: uploaded.path ?? null,
|
|
3654
|
-
durationMs: rec.durationMs,
|
|
3655
|
-
recordingStartedAt: recording.startedAt,
|
|
3656
|
-
steps,
|
|
3657
|
-
}),
|
|
3658
|
-
}).catch(() => { });
|
|
3959
|
+
videoUrl = uploaded.url;
|
|
3960
|
+
videoPath = uploaded.path ?? null;
|
|
3961
|
+
durationMs = rec.durationMs;
|
|
3659
3962
|
}
|
|
3660
3963
|
}
|
|
3661
|
-
|
|
3662
|
-
|
|
3964
|
+
// Only register if there's something to replay (a video or at least one step).
|
|
3965
|
+
if (videoUrl || dispatch.steps.length > 0) {
|
|
3966
|
+
const steps = dispatch.steps.map((s) => ({
|
|
3967
|
+
toolName: s.toolName,
|
|
3968
|
+
label: s.label,
|
|
3969
|
+
status: s.status,
|
|
3970
|
+
category: s.category,
|
|
3971
|
+
startMs: s.startedAt != null ? Math.max(0, s.startedAt - baselineMs) : null,
|
|
3972
|
+
endMs: s.completedAt != null ? Math.max(0, s.completedAt - baselineMs) : null,
|
|
3973
|
+
}));
|
|
3974
|
+
await fetch(`${account.apiBaseUrl}/v1/bot/task-recordings`, {
|
|
3975
|
+
method: "POST",
|
|
3976
|
+
headers: {
|
|
3977
|
+
Authorization: `Bearer ${account.botToken}`,
|
|
3978
|
+
"Content-Type": "application/json",
|
|
3979
|
+
},
|
|
3980
|
+
body: JSON.stringify({
|
|
3981
|
+
taskId: dispatch.taskId,
|
|
3982
|
+
groupId,
|
|
3983
|
+
title: dispatch.title ?? null,
|
|
3984
|
+
videoUrl,
|
|
3985
|
+
videoPath,
|
|
3986
|
+
durationMs,
|
|
3987
|
+
recordingStartedAt: baselineMs,
|
|
3988
|
+
steps,
|
|
3989
|
+
inputTokens: dispatch.tokensInput ?? 0,
|
|
3990
|
+
outputTokens: dispatch.tokensOutput ?? 0,
|
|
3991
|
+
}),
|
|
3992
|
+
}).catch(() => { });
|
|
3663
3993
|
}
|
|
3664
3994
|
}
|
|
3995
|
+
catch (err) {
|
|
3996
|
+
ctx.log?.warn?.(`ozaiya: recording upload failed: ${String(err)}`);
|
|
3997
|
+
}
|
|
3665
3998
|
activeDispatches.delete(account.accountId);
|
|
3666
3999
|
}
|
|
3667
4000
|
}
|