@ozaiya/openclaw-channel 0.10.23 → 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 CHANGED
@@ -12,7 +12,7 @@ declare const plugin: {
12
12
  description: string;
13
13
  configSchema: {
14
14
  readonly type: "object";
15
- readonly additionalProperties: false;
15
+ readonly additionalProperties: true;
16
16
  readonly properties: {
17
17
  readonly enabled: {
18
18
  readonly type: "boolean";
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;AAChD,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;IAChD,CAAC;CACF,CAAC;AAEF,eAAe,MAAM,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"}
@@ -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>;
@@ -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
- // Stop + upload the screen recording, then register it for replay (all best-effort).
3628
- const recording = dispatch.recording;
3629
- if (recording) {
3630
- try {
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
- const steps = dispatch.steps.map((s) => ({
3636
- toolName: s.toolName,
3637
- label: s.label,
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
- catch (err) {
3662
- ctx.log?.warn?.(`ozaiya: recording upload failed: ${String(err)}`);
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
  }