@ozaiya/openclaw-channel 0.10.24 → 0.10.25
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 +343 -30
- 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";
|
|
@@ -3473,6 +3753,10 @@ ctx) {
|
|
|
3473
3753
|
startedAt: Date.now(),
|
|
3474
3754
|
screenId: "sandbox",
|
|
3475
3755
|
recording: null,
|
|
3756
|
+
sessionKey: route.sessionKey,
|
|
3757
|
+
agentId: route.agentId,
|
|
3758
|
+
title: deriveTaskTitle(effectiveMessageText ?? agentInput),
|
|
3759
|
+
replyTexts: [],
|
|
3476
3760
|
};
|
|
3477
3761
|
activeDispatches.set(account.accountId, dispatch);
|
|
3478
3762
|
// Start screen recording for replay (best-effort; null if nothing recordable).
|
|
@@ -3488,6 +3772,10 @@ ctx) {
|
|
|
3488
3772
|
// Uses the current bot's account directly (not the factory which re-resolves in gateway mode).
|
|
3489
3773
|
const channelTools = buildChannelTools(account, ctx.cfg);
|
|
3490
3774
|
const channelToolsByName = new Map(channelTools.map((t) => [t.name, t]));
|
|
3775
|
+
// Register channel tool names so the native-tool hook skips them (avoids double
|
|
3776
|
+
// counting; channel tools are already tracked by their execute wrapper).
|
|
3777
|
+
for (const name of channelToolsByName.keys())
|
|
3778
|
+
KNOWN_CHANNEL_TOOLS.add(name);
|
|
3491
3779
|
ctx.log?.info?.(`ozaiya: text fallback tools loaded: ${channelToolsByName.size} tools [${[...channelToolsByName.keys()].join(", ")}]`);
|
|
3492
3780
|
// Dispatch to agent with buffered block dispatcher
|
|
3493
3781
|
try {
|
|
@@ -3500,6 +3788,9 @@ ctx) {
|
|
|
3500
3788
|
ctx.log?.info?.(`ozaiya: deliver called, text length=${replyText?.length ?? 0}, empty=${!replyText?.trim()}, voiceReply=${voiceReply}, voiceReplyVoice=${voiceReplyVoice ?? 'none'}`);
|
|
3501
3789
|
if (!replyText?.trim())
|
|
3502
3790
|
return;
|
|
3791
|
+
// Remember delivered text so the post-dispatch deliverable safety net can
|
|
3792
|
+
// recover a table/report the model pasted inline instead of publishing.
|
|
3793
|
+
activeDispatches.get(account.accountId)?.replyTexts?.push(replyText);
|
|
3503
3794
|
// Generic fallback: intercept tool calls that models output as text
|
|
3504
3795
|
// instead of structured API tool_calls. Supports two formats:
|
|
3505
3796
|
// 1. JSON function syntax: tool_name({"arg":"value"}) or tool_name({arg: "value"})
|
|
@@ -3623,45 +3914,67 @@ ctx) {
|
|
|
3623
3914
|
});
|
|
3624
3915
|
}
|
|
3625
3916
|
finally {
|
|
3917
|
+
// Recover an inline table the model forgot to publish_artifact, BEFORE finalizing
|
|
3918
|
+
// so the final progress card includes the deliverable.
|
|
3919
|
+
await maybeAutoPublishDeliverable(dispatch, account, groupId, ctx).catch(() => { });
|
|
3626
3920
|
await finalizeTaskProgress(dispatch);
|
|
3627
|
-
//
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3921
|
+
// Register the task for replay (best-effort). Upload the screen recording if we
|
|
3922
|
+
// captured one; otherwise still register a STEPS-ONLY recording so the task shows
|
|
3923
|
+
// up in the bot's Records tab (and counts toward usage via task.completed). Video
|
|
3924
|
+
// is a bonus, not a prerequisite — compose-mode gateways without a recording-control
|
|
3925
|
+
// server can't capture video but still produce a useful step timeline.
|
|
3926
|
+
try {
|
|
3927
|
+
const recording = dispatch.recording;
|
|
3928
|
+
// Align step offsets to the recording start when we have one, else to the
|
|
3929
|
+
// dispatch start so a steps-only timeline is still correctly anchored at t=0.
|
|
3930
|
+
const baselineMs = recording?.startedAt ?? dispatch.startedAt;
|
|
3931
|
+
let videoUrl = "";
|
|
3932
|
+
let videoPath = null;
|
|
3933
|
+
let durationMs = Math.max(0, Date.now() - baselineMs);
|
|
3934
|
+
if (recording) {
|
|
3631
3935
|
const rec = await stopAndExtractRecording(recording);
|
|
3632
3936
|
if (rec && rec.mp4.length > 0) {
|
|
3633
3937
|
const filename = `replay-${dispatch.taskId}.mp4`.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
3634
3938
|
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(() => { });
|
|
3939
|
+
videoUrl = uploaded.url;
|
|
3940
|
+
videoPath = uploaded.path ?? null;
|
|
3941
|
+
durationMs = rec.durationMs;
|
|
3659
3942
|
}
|
|
3660
3943
|
}
|
|
3661
|
-
|
|
3662
|
-
|
|
3944
|
+
// Only register if there's something to replay (a video or at least one step).
|
|
3945
|
+
if (videoUrl || dispatch.steps.length > 0) {
|
|
3946
|
+
const steps = dispatch.steps.map((s) => ({
|
|
3947
|
+
toolName: s.toolName,
|
|
3948
|
+
label: s.label,
|
|
3949
|
+
status: s.status,
|
|
3950
|
+
category: s.category,
|
|
3951
|
+
startMs: s.startedAt != null ? Math.max(0, s.startedAt - baselineMs) : null,
|
|
3952
|
+
endMs: s.completedAt != null ? Math.max(0, s.completedAt - baselineMs) : null,
|
|
3953
|
+
}));
|
|
3954
|
+
await fetch(`${account.apiBaseUrl}/v1/bot/task-recordings`, {
|
|
3955
|
+
method: "POST",
|
|
3956
|
+
headers: {
|
|
3957
|
+
Authorization: `Bearer ${account.botToken}`,
|
|
3958
|
+
"Content-Type": "application/json",
|
|
3959
|
+
},
|
|
3960
|
+
body: JSON.stringify({
|
|
3961
|
+
taskId: dispatch.taskId,
|
|
3962
|
+
groupId,
|
|
3963
|
+
title: dispatch.title ?? null,
|
|
3964
|
+
videoUrl,
|
|
3965
|
+
videoPath,
|
|
3966
|
+
durationMs,
|
|
3967
|
+
recordingStartedAt: baselineMs,
|
|
3968
|
+
steps,
|
|
3969
|
+
inputTokens: dispatch.tokensInput ?? 0,
|
|
3970
|
+
outputTokens: dispatch.tokensOutput ?? 0,
|
|
3971
|
+
}),
|
|
3972
|
+
}).catch(() => { });
|
|
3663
3973
|
}
|
|
3664
3974
|
}
|
|
3975
|
+
catch (err) {
|
|
3976
|
+
ctx.log?.warn?.(`ozaiya: recording upload failed: ${String(err)}`);
|
|
3977
|
+
}
|
|
3665
3978
|
activeDispatches.delete(account.accountId);
|
|
3666
3979
|
}
|
|
3667
3980
|
}
|