@botcord/daemon 0.2.58 → 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.
@@ -205,6 +205,10 @@ interface QueueState {
205
205
  serialWorkerActive: boolean;
206
206
  }
207
207
 
208
+ interface DeferredMultimodalEntry extends BufferedSerialEntry {
209
+ queuedAt: number;
210
+ }
211
+
208
212
  /**
209
213
  * Gateway dispatcher: consumes `GatewayInboundEnvelope` and drives a runtime
210
214
  * turn per message, respecting queue mode, trust level, streaming, and
@@ -233,6 +237,7 @@ export class Dispatcher {
233
237
  private readonly resolveHubUrl?: (accountId: string) => string | undefined;
234
238
  private readonly transcript: TranscriptWriter;
235
239
  private readonly queues: Map<string, QueueState> = new Map();
240
+ private readonly deferredMultimodal: Map<string, DeferredMultimodalEntry[]> = new Map();
236
241
  /**
237
242
  * Last `/hub/typing` ping timestamp per (accountId, conversationId).
238
243
  * Used to debounce cancel-previous bursts so we don't trip Hub's 20/min
@@ -286,6 +291,11 @@ export class Dispatcher {
286
291
  return;
287
292
  }
288
293
 
294
+ const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
295
+ const route = resolveRoute(msg, this.config, managed);
296
+ const mode = resolveQueueMode(route, msg.conversation.kind);
297
+ const queueKey = buildQueueKey(msg);
298
+
289
299
  // Pre-skip: empty/whitespace text.
290
300
  const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
291
301
  if (!rawText) {
@@ -298,28 +308,87 @@ export class Dispatcher {
298
308
  // turnId and write the inbound transcript record.
299
309
  const turnId = randomUUID();
300
310
 
301
- const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
302
- const route = resolveRoute(msg, this.config, managed);
303
- const mode = resolveQueueMode(route, msg.conversation.kind);
304
- const queueKey = buildQueueKey(msg);
311
+ // Multimodal-only arrivals (files/images without sender-authored text)
312
+ // should not wake the runtime on their own. Ack them, record the inbound
313
+ // event, and prepend them to the next text-bearing turn for this queue.
314
+ if (isMultimodalOnlyMessage(msg)) {
315
+ await this.safeAck(envelope);
316
+ this.emitInbound(turnId, msg);
317
+ this.deferMultimodal(queueKey, { route, msg, channel, turnId, queuedAt: Date.now() });
318
+ this.log.info("dispatcher: deferred multimodal-only inbound", {
319
+ agentId: msg.accountId,
320
+ roomId: msg.conversation.id,
321
+ topicId: msg.conversation.threadId ?? null,
322
+ turnId,
323
+ messageId: msg.id,
324
+ senderId: msg.sender.id,
325
+ senderKind: msg.sender.kind,
326
+ mode,
327
+ queueKey,
328
+ });
329
+ if (this.onInbound) {
330
+ try {
331
+ await this.onInbound(msg);
332
+ } catch (err) {
333
+ this.log.warn("dispatcher: onInbound threw — continuing", {
334
+ messageId: msg.id,
335
+ error: err instanceof Error ? err.message : String(err),
336
+ });
337
+ }
338
+ }
339
+ return;
340
+ }
341
+
342
+ const deferred = this.takeDeferredMultimodal(queueKey);
343
+ let dispatchMsg = msg;
344
+ let dispatchTurnId: string = turnId;
345
+ let dispatchRoute = route;
346
+ let dispatchChannel = channel;
347
+ let text = rawText;
348
+ let mergedFromDeferredTurnIds: string[] = [];
349
+ if (deferred.length > 0) {
350
+ const merged = this.mergeSerialBuffer(
351
+ [...deferred, { route, msg, channel, turnId }],
352
+ queueKey,
353
+ );
354
+ if (merged) {
355
+ dispatchMsg = merged.msg;
356
+ dispatchTurnId = merged.turnId;
357
+ dispatchRoute = merged.route;
358
+ dispatchChannel = merged.channel;
359
+ text = merged.text;
360
+ mergedFromDeferredTurnIds = deferred.map((e) => e.turnId);
361
+ for (const entry of deferred) {
362
+ this.transcript.write({
363
+ ts: nowIso(),
364
+ kind: "dropped",
365
+ turnId: entry.turnId,
366
+ agentId: entry.msg.accountId,
367
+ roomId: entry.msg.conversation.id,
368
+ topicId: entry.msg.conversation.threadId ?? null,
369
+ reason: "batch_merged",
370
+ supersededBy: dispatchTurnId,
371
+ });
372
+ }
373
+ }
374
+ }
305
375
 
306
376
  // Compose the final user-turn text only for cancel-previous mode, where
307
377
  // the dispatcher consumes the pre-composed text directly. Serial mode
308
378
  // re-runs the composer at drain time on the merged message (so it sees
309
379
  // the full coalesced batch instead of any single arrival), so calling
310
380
  // the composer here would just be redundant work.
311
- let text = rawText;
312
381
  let composeFailedError: string | undefined;
313
382
  if (mode === "cancel-previous" && this.composeUserTurn) {
314
383
  try {
315
- const composed = this.composeUserTurn(msg);
384
+ const composed = this.composeUserTurn(dispatchMsg);
316
385
  if (typeof composed === "string" && composed.length > 0) {
317
386
  text = composed;
318
387
  }
319
388
  } catch (err) {
320
389
  composeFailedError = err instanceof Error ? err.message : String(err);
321
390
  this.log.warn("dispatcher: composeUserTurn threw — using raw text", {
322
- messageId: msg.id,
391
+ messageId: dispatchMsg.id,
323
392
  error: composeFailedError,
324
393
  });
325
394
  }
@@ -364,28 +433,28 @@ export class Dispatcher {
364
433
  if (this.attentionGate) {
365
434
  let wake = true;
366
435
  try {
367
- const result = this.attentionGate(msg);
436
+ const result = this.attentionGate(dispatchMsg);
368
437
  wake = result instanceof Promise ? await result : result;
369
438
  } catch (err) {
370
439
  this.log.warn("dispatcher: attentionGate threw — waking", {
371
- messageId: msg.id,
440
+ messageId: dispatchMsg.id,
372
441
  error: err instanceof Error ? err.message : String(err),
373
442
  });
374
443
  wake = true;
375
444
  }
376
445
  if (!wake) {
377
446
  this.log.debug("dispatcher skip turn: attention policy", {
378
- messageId: msg.id,
379
- accountId: msg.accountId,
380
- conversationId: msg.conversation.id,
447
+ messageId: dispatchMsg.id,
448
+ accountId: dispatchMsg.accountId,
449
+ conversationId: dispatchMsg.conversation.id,
381
450
  });
382
451
  this.transcript.write({
383
452
  ts: nowIso(),
384
453
  kind: "attention_skipped",
385
- turnId,
386
- agentId: msg.accountId,
387
- roomId: msg.conversation.id,
388
- topicId: msg.conversation.threadId ?? null,
454
+ turnId: dispatchTurnId,
455
+ agentId: dispatchMsg.accountId,
456
+ roomId: dispatchMsg.conversation.id,
457
+ topicId: dispatchMsg.conversation.threadId ?? null,
389
458
  reason: "attention_gate_false",
390
459
  });
391
460
  return;
@@ -396,19 +465,35 @@ export class Dispatcher {
396
465
  this.transcript.write({
397
466
  ts: nowIso(),
398
467
  kind: "compose_failed",
399
- turnId,
400
- agentId: msg.accountId,
401
- roomId: msg.conversation.id,
402
- topicId: msg.conversation.threadId ?? null,
468
+ turnId: dispatchTurnId,
469
+ agentId: dispatchMsg.accountId,
470
+ roomId: dispatchMsg.conversation.id,
471
+ topicId: dispatchMsg.conversation.threadId ?? null,
403
472
  error: composeFailedError,
404
473
  fallback: "raw_text",
405
474
  });
406
475
  }
407
476
 
408
477
  if (mode === "cancel-previous") {
409
- await this.runCancelPrevious(queueKey, route, text, msg, channel, turnId);
478
+ await this.runCancelPrevious(
479
+ queueKey,
480
+ dispatchRoute,
481
+ text,
482
+ dispatchMsg,
483
+ dispatchChannel,
484
+ dispatchTurnId,
485
+ mergedFromDeferredTurnIds,
486
+ );
410
487
  } else {
411
- await this.runSerial(queueKey, route, text, msg, channel, turnId);
488
+ await this.runSerial(
489
+ queueKey,
490
+ dispatchRoute,
491
+ text,
492
+ dispatchMsg,
493
+ dispatchChannel,
494
+ dispatchTurnId,
495
+ mergedFromDeferredTurnIds,
496
+ );
412
497
  }
413
498
  }
414
499
 
@@ -452,6 +537,37 @@ export class Dispatcher {
452
537
  return q;
453
538
  }
454
539
 
540
+ private deferMultimodal(queueKey: string, entry: DeferredMultimodalEntry): void {
541
+ const list = this.deferredMultimodal.get(queueKey) ?? [];
542
+ list.push(entry);
543
+ while (list.length > MAX_BATCH_BUFFER_ENTRIES) {
544
+ const dropped = list.shift()!;
545
+ this.log.warn("dispatcher: deferred multimodal buffer overflow — dropped oldest", {
546
+ queueKey,
547
+ droppedMessageId: dropped.msg.id,
548
+ bufferCap: MAX_BATCH_BUFFER_ENTRIES,
549
+ });
550
+ this.transcript.write({
551
+ ts: nowIso(),
552
+ kind: "dropped",
553
+ turnId: dropped.turnId,
554
+ agentId: dropped.msg.accountId,
555
+ roomId: dropped.msg.conversation.id,
556
+ topicId: dropped.msg.conversation.threadId ?? null,
557
+ reason: "queue_overflow",
558
+ supersededBy: null,
559
+ });
560
+ }
561
+ this.deferredMultimodal.set(queueKey, list);
562
+ }
563
+
564
+ private takeDeferredMultimodal(queueKey: string): DeferredMultimodalEntry[] {
565
+ const list = this.deferredMultimodal.get(queueKey);
566
+ if (!list || list.length === 0) return [];
567
+ this.deferredMultimodal.delete(queueKey);
568
+ return list;
569
+ }
570
+
455
571
  private async runCancelPrevious(
456
572
  queueKey: string,
457
573
  route: GatewayRoute,
@@ -459,6 +575,7 @@ export class Dispatcher {
459
575
  msg: GatewayInboundEnvelope["message"],
460
576
  channel: ChannelAdapter,
461
577
  turnId: string,
578
+ mergedFromTurnIds: string[] = [],
462
579
  ): Promise<void> {
463
580
  const q = this.getQueue(queueKey);
464
581
  // Bump the generation on every arrival. Older arrivals still awaiting
@@ -518,7 +635,7 @@ export class Dispatcher {
518
635
  });
519
636
  return;
520
637
  }
521
- await this.runTurn(queueKey, route, text, msg, channel, turnId, []);
638
+ await this.runTurn(queueKey, route, text, msg, channel, turnId, mergedFromTurnIds);
522
639
  }
523
640
 
524
641
  /**
@@ -546,6 +663,7 @@ export class Dispatcher {
546
663
  msg: GatewayInboundEnvelope["message"],
547
664
  channel: ChannelAdapter,
548
665
  turnId: string,
666
+ mergedFromTurnIds: string[] = [],
549
667
  ): Promise<void> {
550
668
  const q = this.getQueue(queueKey);
551
669
  q.serialBuffer.push({ route, msg, channel, turnId });
@@ -591,8 +709,10 @@ export class Dispatcher {
591
709
  });
592
710
  }
593
711
  }
594
- const mergedFromTurnIds =
595
- drained.length > 1 ? drained.slice(0, -1).map((e) => e.turnId) : [];
712
+ const mergedTurnIds =
713
+ drained.length > 1
714
+ ? [...mergedFromTurnIds, ...drained.slice(0, -1).map((e) => e.turnId)]
715
+ : mergedFromTurnIds;
596
716
  await this.runTurn(
597
717
  queueKey,
598
718
  merged.route,
@@ -600,7 +720,7 @@ export class Dispatcher {
600
720
  merged.msg,
601
721
  merged.channel,
602
722
  merged.turnId,
603
- mergedFromTurnIds,
723
+ mergedTurnIds,
604
724
  );
605
725
  }
606
726
  } finally {
@@ -681,8 +801,13 @@ export class Dispatcher {
681
801
  const latestRaw = (latest.msg.raw as Record<string, unknown> | null | undefined) ?? {};
682
802
  const mergedRaw = { ...latestRaw, batch: items };
683
803
  const anyMentioned = entries.some((e) => e.msg.mentioned === true);
804
+ const mergedText = entries
805
+ .map((e) => (typeof e.msg.text === "string" ? e.msg.text.trim() : ""))
806
+ .filter((s) => s.length > 0)
807
+ .join("\n");
684
808
  const mergedMsg: GatewayInboundEnvelope["message"] = {
685
809
  ...latest.msg,
810
+ ...(mergedText ? { text: mergedText } : {}),
686
811
  mentioned: anyMentioned,
687
812
  raw: mergedRaw,
688
813
  };
@@ -1527,6 +1652,67 @@ function isBotCordChannel(channel: ChannelAdapter): boolean {
1527
1652
  return channel.type === "botcord" || channel.id === "botcord";
1528
1653
  }
1529
1654
 
1655
+ function isMultimodalOnlyMessage(msg: GatewayInboundEnvelope["message"]): boolean {
1656
+ if (!hasMultimodalContent(msg.raw)) return false;
1657
+ return !hasAuthoredText(msg.raw);
1658
+ }
1659
+
1660
+ function hasAuthoredText(raw: unknown): boolean {
1661
+ if (!raw || typeof raw !== "object") return false;
1662
+ const obj = raw as Record<string, unknown>;
1663
+ const batch = obj.batch;
1664
+ if (Array.isArray(batch)) return batch.some((item) => hasAuthoredText(item));
1665
+
1666
+ if (typeof obj.text === "string" && obj.text.trim().length > 0) {
1667
+ // BotCord's /hub/inbox `text` may be synthesized from attachment metadata
1668
+ // when payload text is empty, so prefer envelope payload below when present.
1669
+ if (!obj.envelope || typeof obj.envelope !== "object") return true;
1670
+ }
1671
+
1672
+ const envelope = obj.envelope as Record<string, unknown> | undefined;
1673
+ const payload = envelope?.payload as Record<string, unknown> | undefined;
1674
+ if (payload) {
1675
+ for (const key of ["text", "body", "message"]) {
1676
+ const value = payload[key];
1677
+ if (typeof value === "string" && value.trim().length > 0) return true;
1678
+ }
1679
+ return false;
1680
+ }
1681
+
1682
+ const itemList = obj.item_list;
1683
+ if (Array.isArray(itemList)) {
1684
+ return itemList.some((item) => {
1685
+ if (!item || typeof item !== "object") return false;
1686
+ const textItem = (item as { text_item?: { text?: unknown } }).text_item;
1687
+ return typeof textItem?.text === "string" && textItem.text.trim().length > 0;
1688
+ });
1689
+ }
1690
+
1691
+ return typeof obj.text === "string" && obj.text.trim().length > 0;
1692
+ }
1693
+
1694
+ function hasMultimodalContent(raw: unknown): boolean {
1695
+ if (!raw || typeof raw !== "object") return false;
1696
+ const obj = raw as Record<string, unknown>;
1697
+ const batch = obj.batch;
1698
+ if (Array.isArray(batch)) return batch.some((item) => hasMultimodalContent(item));
1699
+
1700
+ const envelope = obj.envelope as Record<string, unknown> | undefined;
1701
+ const payload = envelope?.payload as Record<string, unknown> | undefined;
1702
+ const attachments = payload?.attachments;
1703
+ if (Array.isArray(attachments) && attachments.length > 0) return true;
1704
+
1705
+ const itemList = obj.item_list;
1706
+ if (Array.isArray(itemList)) {
1707
+ return itemList.some((item) => {
1708
+ if (!item || typeof item !== "object") return false;
1709
+ return (item as { type?: unknown }).type !== 1;
1710
+ });
1711
+ }
1712
+
1713
+ return false;
1714
+ }
1715
+
1530
1716
  function resolveQueueMode(
1531
1717
  route: GatewayRoute,
1532
1718
  kind: "direct" | "group",
package/src/index.ts CHANGED
@@ -132,9 +132,12 @@ Commands:
132
132
  route list
133
133
  route remove --room <rm_xxx>|--prefix <rm_xxx>
134
134
  config Print resolved config
135
- doctor [--json] [--bundle] Scan local runtimes (${ADAPTER_LIST});
135
+ doctor [--json] [--bundle] [--full-log] Scan local runtimes (${ADAPTER_LIST});
136
136
  --bundle also writes a zip under
137
- ~/.botcord/diagnostics/
137
+ ~/.botcord/diagnostics/. Bundles
138
+ daemon.log plus the latest 5 rotated
139
+ logs by default; --full-log bundles
140
+ all retained rotated logs.
138
141
  memory get [--agent <ag_xxx>] [--json] Show current working memory
139
142
  memory set [--agent <ag_xxx>] --goal <text>
140
143
  Pin/update the agent's work goal
@@ -168,6 +171,7 @@ const BOOLEAN_FLAGS = new Set([
168
171
  "follow",
169
172
  "json",
170
173
  "bundle",
174
+ "full-log",
171
175
  "help",
172
176
  "h",
173
177
  "mentioned",
@@ -1347,7 +1351,9 @@ const fsFileReader: DoctorFileReader = {
1347
1351
 
1348
1352
  async function cmdDoctor(args: ParsedArgs): Promise<void> {
1349
1353
  if (args.flags.bundle === true) {
1350
- const bundle = await createDiagnosticBundle();
1354
+ const bundle = await createDiagnosticBundle({
1355
+ includeAllLogs: args.flags["full-log"] === true,
1356
+ });
1351
1357
  if (args.flags.json === true) {
1352
1358
  console.log(JSON.stringify({ bundle }, null, 2));
1353
1359
  return;
package/src/log.ts CHANGED
@@ -1,9 +1,11 @@
1
- import { appendFileSync, mkdirSync } from "node:fs";
1
+ import { appendFileSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
 
5
5
  const LOG_DIR = path.join(homedir(), ".botcord", "logs");
6
6
  const LOG_FILE = path.join(LOG_DIR, "daemon.log");
7
+ const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024;
8
+ const LOG_ROTATE_KEEP = 20;
7
9
 
8
10
  let inited = false;
9
11
  function ensureDir(): void {
@@ -18,6 +20,14 @@ function ensureDir(): void {
18
20
 
19
21
  type Level = "info" | "warn" | "error" | "debug";
20
22
 
23
+ export interface LogFileEntry {
24
+ path: string;
25
+ name: string;
26
+ sizeBytes: number;
27
+ mtimeMs: number;
28
+ active: boolean;
29
+ }
30
+
21
31
  function formatValue(value: unknown): string {
22
32
  if (value instanceof Error) return JSON.stringify(value.stack ?? value.message);
23
33
  if (typeof value === "string") return JSON.stringify(value);
@@ -44,10 +54,99 @@ export function formatLogLine(
44
54
  return detail ? `${prefix} ${detail} ${suffix}` : `${prefix} ${suffix}`;
45
55
  }
46
56
 
57
+ function rotatedName(file: string, date = new Date()): string {
58
+ const stamp = date.toISOString().replace(/[:.]/g, "-");
59
+ return `${file}.${stamp}.${process.pid}`;
60
+ }
61
+
62
+ export function listDaemonLogFiles(logFile = LOG_FILE): LogFileEntry[] {
63
+ const dir = path.dirname(logFile);
64
+ const base = path.basename(logFile);
65
+ const entries: LogFileEntry[] = [];
66
+
67
+ try {
68
+ const st = statSync(logFile);
69
+ if (st.isFile()) {
70
+ entries.push({
71
+ path: logFile,
72
+ name: base,
73
+ sizeBytes: st.size,
74
+ mtimeMs: st.mtimeMs,
75
+ active: true,
76
+ });
77
+ }
78
+ } catch {
79
+ // no active log
80
+ }
81
+
82
+ let names: string[] = [];
83
+ try {
84
+ names = readdirSync(dir);
85
+ } catch {
86
+ return entries;
87
+ }
88
+
89
+ for (const name of names) {
90
+ if (!name.startsWith(`${base}.`)) continue;
91
+ const file = path.join(dir, name);
92
+ try {
93
+ const st = statSync(file);
94
+ if (!st.isFile()) continue;
95
+ entries.push({
96
+ path: file,
97
+ name,
98
+ sizeBytes: st.size,
99
+ mtimeMs: st.mtimeMs,
100
+ active: false,
101
+ });
102
+ } catch {
103
+ // ignore disappearing files
104
+ }
105
+ }
106
+
107
+ return entries.sort((a, b) => {
108
+ if (a.active !== b.active) return a.active ? -1 : 1;
109
+ return b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name);
110
+ });
111
+ }
112
+
113
+ export function rotateLogIfNeeded(
114
+ logFile = LOG_FILE,
115
+ nextBytes = 0,
116
+ maxBytes = LOG_ROTATE_MAX_BYTES,
117
+ keep = LOG_ROTATE_KEEP,
118
+ ): void {
119
+ let currentSize = 0;
120
+ try {
121
+ const st = statSync(logFile);
122
+ if (!st.isFile()) return;
123
+ currentSize = st.size;
124
+ } catch {
125
+ return;
126
+ }
127
+ if (currentSize + nextBytes <= maxBytes) return;
128
+
129
+ try {
130
+ renameSync(logFile, rotatedName(logFile));
131
+ } catch {
132
+ return;
133
+ }
134
+
135
+ const rotated = listDaemonLogFiles(logFile).filter((entry) => !entry.active);
136
+ for (const entry of rotated.slice(Math.max(0, keep))) {
137
+ try {
138
+ unlinkSync(entry.path);
139
+ } catch {
140
+ // best-effort cleanup
141
+ }
142
+ }
143
+ }
144
+
47
145
  function write(level: Level, msg: string, fields?: Record<string, unknown>): void {
48
146
  ensureDir();
49
147
  const line = formatLogLine(level, msg, fields);
50
148
  try {
149
+ rotateLogIfNeeded(LOG_FILE, Buffer.byteLength(line) + 1);
51
150
  appendFileSync(LOG_FILE, line + "\n", { mode: 0o600 });
52
151
  } catch {
53
152
  // ignore log write errors