@botcord/daemon 0.2.57 → 0.2.59

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.
@@ -9,12 +9,15 @@ export interface CreateDiagnosticBundleOptions {
9
9
  text: string;
10
10
  json: unknown;
11
11
  };
12
+ includeAllLogs?: boolean;
12
13
  }
13
14
  export interface DiagnosticBundleResult {
14
15
  path: string;
15
16
  filename: string;
16
17
  sizeBytes: number;
17
18
  createdAt: string;
19
+ revealCommand: string;
20
+ copyPathCommand: string;
18
21
  }
19
22
  export interface DiagnosticUploadResult {
20
23
  bundleId: string;
@@ -5,11 +5,12 @@ import { Buffer } from "node:buffer";
5
5
  import { deflateRawSync } from "node:zlib";
6
6
  import { AUTH_EXPIRED_FLAG_PATH, USER_AUTH_PATH, } from "./user-auth.js";
7
7
  import { CONFIG_FILE_PATH, PID_PATH, SNAPSHOT_PATH, loadConfig, } from "./config.js";
8
- import { LOG_FILE_PATH } from "./log.js";
8
+ import { listDaemonLogFiles, LOG_FILE_PATH } from "./log.js";
9
9
  import { channelsFromDaemonConfig, defaultHttpFetcher, renderDoctor, runDoctor, } from "./doctor.js";
10
10
  import { detectRuntimes } from "./adapters/runtimes.js";
11
11
  const DIAGNOSTICS_DIR = path.join(homedir(), ".botcord", "diagnostics");
12
12
  const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
13
+ const DEFAULT_ROTATED_LOGS_IN_BUNDLE = 5;
13
14
  const SECRET_PATTERNS = [
14
15
  [/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]"],
15
16
  [/("?(?:accessToken|access_token|refreshToken|refresh_token|token|privateKey|private_key|secret)"?\s*:\s*")[^"]+(")/gi, "$1[REDACTED]$2"],
@@ -189,6 +190,37 @@ function createZip(entries) {
189
190
  ]);
190
191
  return Buffer.concat([...localParts, central, end]);
191
192
  }
193
+ function shellQuote(s) {
194
+ return `'${s.replace(/'/g, `'\\''`)}'`;
195
+ }
196
+ function diagnosticBundleCommands(filePath) {
197
+ if (process.platform === "darwin") {
198
+ return {
199
+ revealCommand: `open -R ${shellQuote(filePath)}`,
200
+ copyPathCommand: `printf '%s' ${shellQuote(filePath)} | pbcopy`,
201
+ };
202
+ }
203
+ if (process.platform === "win32") {
204
+ const psPath = filePath.replace(/'/g, "''");
205
+ return {
206
+ revealCommand: `explorer.exe /select,"${filePath.replace(/"/g, '""')}"`,
207
+ copyPathCommand: `powershell.exe -NoProfile -Command "Set-Clipboard -Value '${psPath}'"`,
208
+ };
209
+ }
210
+ return {
211
+ revealCommand: `xdg-open ${shellQuote(path.dirname(filePath))}`,
212
+ copyPathCommand: `printf '%s' ${shellQuote(filePath)} | xclip -selection clipboard`,
213
+ };
214
+ }
215
+ function bundledLogs(logFile, includeAllLogs) {
216
+ const all = listDaemonLogFiles(logFile);
217
+ const active = all.filter((entry) => entry.active);
218
+ const rotated = all.filter((entry) => !entry.active);
219
+ return [
220
+ ...active,
221
+ ...(includeAllLogs ? rotated : rotated.slice(0, DEFAULT_ROTATED_LOGS_IN_BUNDLE)),
222
+ ];
223
+ }
192
224
  export async function createDiagnosticBundle(opts = {}) {
193
225
  const createdAt = new Date();
194
226
  const stamp = createdAt.toISOString().replace(/[:.]/g, "-");
@@ -197,6 +229,8 @@ export async function createDiagnosticBundle(opts = {}) {
197
229
  const logFile = opts.logFile ?? LOG_FILE_PATH;
198
230
  const configFile = opts.configFile ?? CONFIG_FILE_PATH;
199
231
  const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
232
+ const includeAllLogs = opts.includeAllLogs === true;
233
+ const logs = bundledLogs(logFile, includeAllLogs);
200
234
  mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
201
235
  const doctor = opts.doctor ?? await buildDoctorEntries();
202
236
  const status = {
@@ -211,6 +245,13 @@ export async function createDiagnosticBundle(opts = {}) {
211
245
  configPath: configFile,
212
246
  snapshotPath: snapshotFile,
213
247
  logPath: logFile,
248
+ logsBundled: logs.map((entry) => ({
249
+ name: entry.name,
250
+ path: entry.path,
251
+ sizeBytes: entry.sizeBytes,
252
+ active: entry.active,
253
+ })),
254
+ logsBundleMode: includeAllLogs ? "all" : `active_plus_${DEFAULT_ROTATED_LOGS_IN_BUNDLE}_rotated`,
214
255
  diagnosticsDir,
215
256
  userAuth: readUserAuthSummary(),
216
257
  };
@@ -220,11 +261,21 @@ export async function createDiagnosticBundle(opts = {}) {
220
261
  { name: "doctor.txt", data: doctor.text + "\n" },
221
262
  { name: "doctor.json", data: JSON.stringify(doctor.json, null, 2) + "\n" },
222
263
  ];
223
- const log = safeReadText(logFile);
224
- entries.push({
225
- name: "daemon.log",
226
- data: log ?? `no log file at ${logFile}\n`,
227
- });
264
+ if (logs.length === 0) {
265
+ entries.push({
266
+ name: "daemon.log",
267
+ data: `no log file at ${logFile}\n`,
268
+ });
269
+ }
270
+ else {
271
+ for (const entry of logs) {
272
+ const log = safeReadText(entry.path);
273
+ entries.push({
274
+ name: entry.active ? "daemon.log" : `logs/${entry.name}`,
275
+ data: log ?? `no log file at ${entry.path}\n`,
276
+ });
277
+ }
278
+ }
228
279
  const config = safeReadText(configFile);
229
280
  entries.push({
230
281
  name: "config.json.redacted",
@@ -238,11 +289,13 @@ export async function createDiagnosticBundle(opts = {}) {
238
289
  const zip = createZip(entries);
239
290
  const out = path.join(diagnosticsDir, filename);
240
291
  writeFileSync(out, zip, { mode: 0o600 });
292
+ const commands = diagnosticBundleCommands(out);
241
293
  return {
242
294
  path: out,
243
295
  filename,
244
296
  sizeBytes: zip.length,
245
297
  createdAt: createdAt.toISOString(),
298
+ ...commands,
246
299
  };
247
300
  }
248
301
  export async function uploadDiagnosticBundle(opts) {
@@ -18,6 +18,8 @@ export interface WechatChannelOptions {
18
18
  stateDebounceMs?: number;
19
19
  /** Test hook: override Date.now() for trace cache TTL assertions. */
20
20
  now?: () => number;
21
+ /** Test hook: override trace context cache cap without a 5000-poll test. */
22
+ traceContextMax?: number;
21
23
  }
22
24
  /**
23
25
  * WeChat (iLink Bot API) channel adapter.
@@ -53,6 +53,9 @@ export function createWechatChannel(opts) {
53
53
  const allowedSenderIds = new Set((opts.allowedSenderIds ?? []).map((s) => String(s)));
54
54
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
55
55
  const now = opts.now ?? (() => Date.now());
56
+ const traceContextMax = opts.traceContextMax && opts.traceContextMax > 0
57
+ ? opts.traceContextMax
58
+ : TRACE_CONTEXT_MAX;
56
59
  let botToken = opts.botToken;
57
60
  let stateStore = null;
58
61
  let stopCallback = null;
@@ -103,7 +106,7 @@ export function createWechatChannel(opts) {
103
106
  }
104
107
  function rememberTrace(traceId, ctx) {
105
108
  // W1: prune oldest entry by updatedAt when cap is reached.
106
- if (traceContexts.size >= TRACE_CONTEXT_MAX) {
109
+ if (traceContexts.size >= traceContextMax) {
107
110
  let oldestKey;
108
111
  let oldestAt = Infinity;
109
112
  for (const [k, v] of traceContexts) {
@@ -282,6 +285,29 @@ export function createWechatChannel(opts) {
282
285
  }
283
286
  return parts.join("\n").trim();
284
287
  }
288
+ function extractMultimodalSummary(msg) {
289
+ const parts = [];
290
+ for (const item of msg.item_list ?? []) {
291
+ if (!item || item.type === 1)
292
+ continue;
293
+ if (item.type === 2) {
294
+ parts.push("[Image]");
295
+ continue;
296
+ }
297
+ if (item.type === 5) {
298
+ const name = item.video_item?.file_name;
299
+ parts.push(name ? `[Video: ${name}]` : "[Video]");
300
+ continue;
301
+ }
302
+ if (item.type === 4) {
303
+ const name = item.file_item?.file_name;
304
+ parts.push(name ? `[File: ${name}]` : "[File]");
305
+ continue;
306
+ }
307
+ parts.push(`[Unsupported media item: type=${String(item.type ?? "unknown")}]`);
308
+ }
309
+ return parts.join("\n").trim();
310
+ }
285
311
  function normalizeInbound(msg) {
286
312
  if (msg.message_type !== 1)
287
313
  return null;
@@ -290,11 +316,12 @@ export function createWechatChannel(opts) {
290
316
  if (!fromUid || !contextToken)
291
317
  return null;
292
318
  const text = extractText(msg);
293
- if (!text)
319
+ const multimodalSummary = text ? "" : extractMultimodalSummary(msg);
320
+ if (!text && !multimodalSummary)
294
321
  return null;
295
322
  if (!allowedSenderIds.has(fromUid))
296
323
  return null;
297
- const sanitized = sanitizeUntrustedContent(text);
324
+ const sanitized = sanitizeUntrustedContent(text || multimodalSummary);
298
325
  const receivedAt = now();
299
326
  // W10: append randomUUID() to the fallback so two messages received in
300
327
  // the same millisecond can't collide. Trace id below already does this.
@@ -94,6 +94,7 @@ export declare class Dispatcher {
94
94
  private readonly resolveHubUrl?;
95
95
  private readonly transcript;
96
96
  private readonly queues;
97
+ private readonly deferredMultimodal;
97
98
  /**
98
99
  * Last `/hub/typing` ping timestamp per (accountId, conversationId).
99
100
  * Used to debounce cancel-previous bursts so we don't trip Hub's 20/min
@@ -107,6 +108,8 @@ export declare class Dispatcher {
107
108
  turns(): Record<string, TurnStatusSnapshot>;
108
109
  private safeAck;
109
110
  private getQueue;
111
+ private deferMultimodal;
112
+ private takeDeferredMultimodal;
110
113
  private runCancelPrevious;
111
114
  /**
112
115
  * Serial mode with coalesce-on-drain semantics:
@@ -78,6 +78,7 @@ export class Dispatcher {
78
78
  resolveHubUrl;
79
79
  transcript;
80
80
  queues = new Map();
81
+ deferredMultimodal = new Map();
81
82
  /**
82
83
  * Last `/hub/typing` ping timestamp per (accountId, conversationId).
83
84
  * Used to debounce cancel-previous bursts so we don't trip Hub's 20/min
@@ -125,6 +126,10 @@ export class Dispatcher {
125
126
  await this.safeAck(envelope);
126
127
  return;
127
128
  }
129
+ const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
130
+ const route = resolveRoute(msg, this.config, managed);
131
+ const mode = resolveQueueMode(route, msg.conversation.kind);
132
+ const queueKey = buildQueueKey(msg);
128
133
  // Pre-skip: empty/whitespace text.
129
134
  const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
130
135
  if (!rawText) {
@@ -135,20 +140,76 @@ export class Dispatcher {
135
140
  // From here on, the inbound is a real conversation event — generate a
136
141
  // turnId and write the inbound transcript record.
137
142
  const turnId = randomUUID();
138
- const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
139
- const route = resolveRoute(msg, this.config, managed);
140
- const mode = resolveQueueMode(route, msg.conversation.kind);
141
- const queueKey = buildQueueKey(msg);
143
+ // Multimodal-only arrivals (files/images without sender-authored text)
144
+ // should not wake the runtime on their own. Ack them, record the inbound
145
+ // event, and prepend them to the next text-bearing turn for this queue.
146
+ if (isMultimodalOnlyMessage(msg)) {
147
+ await this.safeAck(envelope);
148
+ this.emitInbound(turnId, msg);
149
+ this.deferMultimodal(queueKey, { route, msg, channel, turnId, queuedAt: Date.now() });
150
+ this.log.info("dispatcher: deferred multimodal-only inbound", {
151
+ agentId: msg.accountId,
152
+ roomId: msg.conversation.id,
153
+ topicId: msg.conversation.threadId ?? null,
154
+ turnId,
155
+ messageId: msg.id,
156
+ senderId: msg.sender.id,
157
+ senderKind: msg.sender.kind,
158
+ mode,
159
+ queueKey,
160
+ });
161
+ if (this.onInbound) {
162
+ try {
163
+ await this.onInbound(msg);
164
+ }
165
+ catch (err) {
166
+ this.log.warn("dispatcher: onInbound threw — continuing", {
167
+ messageId: msg.id,
168
+ error: err instanceof Error ? err.message : String(err),
169
+ });
170
+ }
171
+ }
172
+ return;
173
+ }
174
+ const deferred = this.takeDeferredMultimodal(queueKey);
175
+ let dispatchMsg = msg;
176
+ let dispatchTurnId = turnId;
177
+ let dispatchRoute = route;
178
+ let dispatchChannel = channel;
179
+ let text = rawText;
180
+ let mergedFromDeferredTurnIds = [];
181
+ if (deferred.length > 0) {
182
+ const merged = this.mergeSerialBuffer([...deferred, { route, msg, channel, turnId }], queueKey);
183
+ if (merged) {
184
+ dispatchMsg = merged.msg;
185
+ dispatchTurnId = merged.turnId;
186
+ dispatchRoute = merged.route;
187
+ dispatchChannel = merged.channel;
188
+ text = merged.text;
189
+ mergedFromDeferredTurnIds = deferred.map((e) => e.turnId);
190
+ for (const entry of deferred) {
191
+ this.transcript.write({
192
+ ts: nowIso(),
193
+ kind: "dropped",
194
+ turnId: entry.turnId,
195
+ agentId: entry.msg.accountId,
196
+ roomId: entry.msg.conversation.id,
197
+ topicId: entry.msg.conversation.threadId ?? null,
198
+ reason: "batch_merged",
199
+ supersededBy: dispatchTurnId,
200
+ });
201
+ }
202
+ }
203
+ }
142
204
  // Compose the final user-turn text only for cancel-previous mode, where
143
205
  // the dispatcher consumes the pre-composed text directly. Serial mode
144
206
  // re-runs the composer at drain time on the merged message (so it sees
145
207
  // the full coalesced batch instead of any single arrival), so calling
146
208
  // the composer here would just be redundant work.
147
- let text = rawText;
148
209
  let composeFailedError;
149
210
  if (mode === "cancel-previous" && this.composeUserTurn) {
150
211
  try {
151
- const composed = this.composeUserTurn(msg);
212
+ const composed = this.composeUserTurn(dispatchMsg);
152
213
  if (typeof composed === "string" && composed.length > 0) {
153
214
  text = composed;
154
215
  }
@@ -156,7 +217,7 @@ export class Dispatcher {
156
217
  catch (err) {
157
218
  composeFailedError = err instanceof Error ? err.message : String(err);
158
219
  this.log.warn("dispatcher: composeUserTurn threw — using raw text", {
159
- messageId: msg.id,
220
+ messageId: dispatchMsg.id,
160
221
  error: composeFailedError,
161
222
  });
162
223
  }
@@ -197,29 +258,29 @@ export class Dispatcher {
197
258
  if (this.attentionGate) {
198
259
  let wake = true;
199
260
  try {
200
- const result = this.attentionGate(msg);
261
+ const result = this.attentionGate(dispatchMsg);
201
262
  wake = result instanceof Promise ? await result : result;
202
263
  }
203
264
  catch (err) {
204
265
  this.log.warn("dispatcher: attentionGate threw — waking", {
205
- messageId: msg.id,
266
+ messageId: dispatchMsg.id,
206
267
  error: err instanceof Error ? err.message : String(err),
207
268
  });
208
269
  wake = true;
209
270
  }
210
271
  if (!wake) {
211
272
  this.log.debug("dispatcher skip turn: attention policy", {
212
- messageId: msg.id,
213
- accountId: msg.accountId,
214
- conversationId: msg.conversation.id,
273
+ messageId: dispatchMsg.id,
274
+ accountId: dispatchMsg.accountId,
275
+ conversationId: dispatchMsg.conversation.id,
215
276
  });
216
277
  this.transcript.write({
217
278
  ts: nowIso(),
218
279
  kind: "attention_skipped",
219
- turnId,
220
- agentId: msg.accountId,
221
- roomId: msg.conversation.id,
222
- topicId: msg.conversation.threadId ?? null,
280
+ turnId: dispatchTurnId,
281
+ agentId: dispatchMsg.accountId,
282
+ roomId: dispatchMsg.conversation.id,
283
+ topicId: dispatchMsg.conversation.threadId ?? null,
223
284
  reason: "attention_gate_false",
224
285
  });
225
286
  return;
@@ -229,19 +290,19 @@ export class Dispatcher {
229
290
  this.transcript.write({
230
291
  ts: nowIso(),
231
292
  kind: "compose_failed",
232
- turnId,
233
- agentId: msg.accountId,
234
- roomId: msg.conversation.id,
235
- topicId: msg.conversation.threadId ?? null,
293
+ turnId: dispatchTurnId,
294
+ agentId: dispatchMsg.accountId,
295
+ roomId: dispatchMsg.conversation.id,
296
+ topicId: dispatchMsg.conversation.threadId ?? null,
236
297
  error: composeFailedError,
237
298
  fallback: "raw_text",
238
299
  });
239
300
  }
240
301
  if (mode === "cancel-previous") {
241
- await this.runCancelPrevious(queueKey, route, text, msg, channel, turnId);
302
+ await this.runCancelPrevious(queueKey, dispatchRoute, text, dispatchMsg, dispatchChannel, dispatchTurnId, mergedFromDeferredTurnIds);
242
303
  }
243
304
  else {
244
- await this.runSerial(queueKey, route, text, msg, channel, turnId);
305
+ await this.runSerial(queueKey, dispatchRoute, text, dispatchMsg, dispatchChannel, dispatchTurnId, mergedFromDeferredTurnIds);
245
306
  }
246
307
  }
247
308
  /** Snapshot of currently running turns keyed by queue key. */
@@ -283,7 +344,37 @@ export class Dispatcher {
283
344
  }
284
345
  return q;
285
346
  }
286
- async runCancelPrevious(queueKey, route, text, msg, channel, turnId) {
347
+ deferMultimodal(queueKey, entry) {
348
+ const list = this.deferredMultimodal.get(queueKey) ?? [];
349
+ list.push(entry);
350
+ while (list.length > MAX_BATCH_BUFFER_ENTRIES) {
351
+ const dropped = list.shift();
352
+ this.log.warn("dispatcher: deferred multimodal buffer overflow — dropped oldest", {
353
+ queueKey,
354
+ droppedMessageId: dropped.msg.id,
355
+ bufferCap: MAX_BATCH_BUFFER_ENTRIES,
356
+ });
357
+ this.transcript.write({
358
+ ts: nowIso(),
359
+ kind: "dropped",
360
+ turnId: dropped.turnId,
361
+ agentId: dropped.msg.accountId,
362
+ roomId: dropped.msg.conversation.id,
363
+ topicId: dropped.msg.conversation.threadId ?? null,
364
+ reason: "queue_overflow",
365
+ supersededBy: null,
366
+ });
367
+ }
368
+ this.deferredMultimodal.set(queueKey, list);
369
+ }
370
+ takeDeferredMultimodal(queueKey) {
371
+ const list = this.deferredMultimodal.get(queueKey);
372
+ if (!list || list.length === 0)
373
+ return [];
374
+ this.deferredMultimodal.delete(queueKey);
375
+ return list;
376
+ }
377
+ async runCancelPrevious(queueKey, route, text, msg, channel, turnId, mergedFromTurnIds = []) {
287
378
  const q = this.getQueue(queueKey);
288
379
  // Bump the generation on every arrival. Older arrivals still awaiting
289
380
  // the prior turn's teardown will observe `myGen !== q.cancelGen` when
@@ -342,7 +433,7 @@ export class Dispatcher {
342
433
  });
343
434
  return;
344
435
  }
345
- await this.runTurn(queueKey, route, text, msg, channel, turnId, []);
436
+ await this.runTurn(queueKey, route, text, msg, channel, turnId, mergedFromTurnIds);
346
437
  }
347
438
  /**
348
439
  * Serial mode with coalesce-on-drain semantics:
@@ -362,7 +453,7 @@ export class Dispatcher {
362
453
  * merged message so the runtime sees a single coherent prompt covering all
363
454
  * coalesced messages.
364
455
  */
365
- async runSerial(queueKey, route, _text, msg, channel, turnId) {
456
+ async runSerial(queueKey, route, _text, msg, channel, turnId, mergedFromTurnIds = []) {
366
457
  const q = this.getQueue(queueKey);
367
458
  q.serialBuffer.push({ route, msg, channel, turnId });
368
459
  while (q.serialBuffer.length > MAX_BATCH_BUFFER_ENTRIES) {
@@ -409,8 +500,10 @@ export class Dispatcher {
409
500
  });
410
501
  }
411
502
  }
412
- const mergedFromTurnIds = drained.length > 1 ? drained.slice(0, -1).map((e) => e.turnId) : [];
413
- await this.runTurn(queueKey, merged.route, merged.text, merged.msg, merged.channel, merged.turnId, mergedFromTurnIds);
503
+ const mergedTurnIds = drained.length > 1
504
+ ? [...mergedFromTurnIds, ...drained.slice(0, -1).map((e) => e.turnId)]
505
+ : mergedFromTurnIds;
506
+ await this.runTurn(queueKey, merged.route, merged.text, merged.msg, merged.channel, merged.turnId, mergedTurnIds);
414
507
  }
415
508
  }
416
509
  finally {
@@ -478,8 +571,13 @@ export class Dispatcher {
478
571
  const latestRaw = latest.msg.raw ?? {};
479
572
  const mergedRaw = { ...latestRaw, batch: items };
480
573
  const anyMentioned = entries.some((e) => e.msg.mentioned === true);
574
+ const mergedText = entries
575
+ .map((e) => (typeof e.msg.text === "string" ? e.msg.text.trim() : ""))
576
+ .filter((s) => s.length > 0)
577
+ .join("\n");
481
578
  const mergedMsg = {
482
579
  ...latest.msg,
580
+ ...(mergedText ? { text: mergedText } : {}),
483
581
  mentioned: anyMentioned,
484
582
  raw: mergedRaw,
485
583
  };
@@ -1296,6 +1394,67 @@ function isOwnerChatRoom(msg) {
1296
1394
  function isBotCordChannel(channel) {
1297
1395
  return channel.type === "botcord" || channel.id === "botcord";
1298
1396
  }
1397
+ function isMultimodalOnlyMessage(msg) {
1398
+ if (!hasMultimodalContent(msg.raw))
1399
+ return false;
1400
+ return !hasAuthoredText(msg.raw);
1401
+ }
1402
+ function hasAuthoredText(raw) {
1403
+ if (!raw || typeof raw !== "object")
1404
+ return false;
1405
+ const obj = raw;
1406
+ const batch = obj.batch;
1407
+ if (Array.isArray(batch))
1408
+ return batch.some((item) => hasAuthoredText(item));
1409
+ if (typeof obj.text === "string" && obj.text.trim().length > 0) {
1410
+ // BotCord's /hub/inbox `text` may be synthesized from attachment metadata
1411
+ // when payload text is empty, so prefer envelope payload below when present.
1412
+ if (!obj.envelope || typeof obj.envelope !== "object")
1413
+ return true;
1414
+ }
1415
+ const envelope = obj.envelope;
1416
+ const payload = envelope?.payload;
1417
+ if (payload) {
1418
+ for (const key of ["text", "body", "message"]) {
1419
+ const value = payload[key];
1420
+ if (typeof value === "string" && value.trim().length > 0)
1421
+ return true;
1422
+ }
1423
+ return false;
1424
+ }
1425
+ const itemList = obj.item_list;
1426
+ if (Array.isArray(itemList)) {
1427
+ return itemList.some((item) => {
1428
+ if (!item || typeof item !== "object")
1429
+ return false;
1430
+ const textItem = item.text_item;
1431
+ return typeof textItem?.text === "string" && textItem.text.trim().length > 0;
1432
+ });
1433
+ }
1434
+ return typeof obj.text === "string" && obj.text.trim().length > 0;
1435
+ }
1436
+ function hasMultimodalContent(raw) {
1437
+ if (!raw || typeof raw !== "object")
1438
+ return false;
1439
+ const obj = raw;
1440
+ const batch = obj.batch;
1441
+ if (Array.isArray(batch))
1442
+ return batch.some((item) => hasMultimodalContent(item));
1443
+ const envelope = obj.envelope;
1444
+ const payload = envelope?.payload;
1445
+ const attachments = payload?.attachments;
1446
+ if (Array.isArray(attachments) && attachments.length > 0)
1447
+ return true;
1448
+ const itemList = obj.item_list;
1449
+ if (Array.isArray(itemList)) {
1450
+ return itemList.some((item) => {
1451
+ if (!item || typeof item !== "object")
1452
+ return false;
1453
+ return item.type !== 1;
1454
+ });
1455
+ }
1456
+ return false;
1457
+ }
1299
1458
  function resolveQueueMode(route, kind) {
1300
1459
  if (route.queueMode)
1301
1460
  return route.queueMode;
package/dist/index.js CHANGED
@@ -82,9 +82,12 @@ Commands:
82
82
  route list
83
83
  route remove --room <rm_xxx>|--prefix <rm_xxx>
84
84
  config Print resolved config
85
- doctor [--json] [--bundle] Scan local runtimes (${ADAPTER_LIST});
85
+ doctor [--json] [--bundle] [--full-log] Scan local runtimes (${ADAPTER_LIST});
86
86
  --bundle also writes a zip under
87
- ~/.botcord/diagnostics/
87
+ ~/.botcord/diagnostics/. Bundles
88
+ daemon.log plus the latest 5 rotated
89
+ logs by default; --full-log bundles
90
+ all retained rotated logs.
88
91
  memory get [--agent <ag_xxx>] [--json] Show current working memory
89
92
  memory set [--agent <ag_xxx>] --goal <text>
90
93
  Pin/update the agent's work goal
@@ -109,6 +112,7 @@ const BOOLEAN_FLAGS = new Set([
109
112
  "follow",
110
113
  "json",
111
114
  "bundle",
115
+ "full-log",
112
116
  "help",
113
117
  "h",
114
118
  "mentioned",
@@ -1216,13 +1220,17 @@ const fsFileReader = {
1216
1220
  };
1217
1221
  async function cmdDoctor(args) {
1218
1222
  if (args.flags.bundle === true) {
1219
- const bundle = await createDiagnosticBundle();
1223
+ const bundle = await createDiagnosticBundle({
1224
+ includeAllLogs: args.flags["full-log"] === true,
1225
+ });
1220
1226
  if (args.flags.json === true) {
1221
1227
  console.log(JSON.stringify({ bundle }, null, 2));
1222
1228
  return;
1223
1229
  }
1224
1230
  console.log(`diagnostic bundle written: ${bundle.path}`);
1225
1231
  console.log(`size: ${bundle.sizeBytes} bytes`);
1232
+ console.log(`open in Finder/file manager: ${bundle.revealCommand}`);
1233
+ console.log(`copy path to clipboard: ${bundle.copyPathCommand}`);
1226
1234
  console.log("Send this zip file to the BotCord developer/support contact.");
1227
1235
  return;
1228
1236
  }
package/dist/log.d.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  type Level = "info" | "warn" | "error" | "debug";
2
+ export interface LogFileEntry {
3
+ path: string;
4
+ name: string;
5
+ sizeBytes: number;
6
+ mtimeMs: number;
7
+ active: boolean;
8
+ }
2
9
  export declare function formatLogLine(level: Level, msg: string, fields: Record<string, unknown> | undefined, date?: Date): string;
10
+ export declare function listDaemonLogFiles(logFile?: string): LogFileEntry[];
11
+ export declare function rotateLogIfNeeded(logFile?: string, nextBytes?: number, maxBytes?: number, keep?: number): void;
3
12
  export declare const log: {
4
13
  info: (msg: string, fields?: Record<string, unknown>) => void;
5
14
  warn: (msg: string, fields?: Record<string, unknown>) => void;