@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.
@@ -9,6 +9,7 @@ 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;
@@ -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"],
@@ -211,6 +212,15 @@ function diagnosticBundleCommands(filePath) {
211
212
  copyPathCommand: `printf '%s' ${shellQuote(filePath)} | xclip -selection clipboard`,
212
213
  };
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
+ }
214
224
  export async function createDiagnosticBundle(opts = {}) {
215
225
  const createdAt = new Date();
216
226
  const stamp = createdAt.toISOString().replace(/[:.]/g, "-");
@@ -219,6 +229,8 @@ export async function createDiagnosticBundle(opts = {}) {
219
229
  const logFile = opts.logFile ?? LOG_FILE_PATH;
220
230
  const configFile = opts.configFile ?? CONFIG_FILE_PATH;
221
231
  const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
232
+ const includeAllLogs = opts.includeAllLogs === true;
233
+ const logs = bundledLogs(logFile, includeAllLogs);
222
234
  mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
223
235
  const doctor = opts.doctor ?? await buildDoctorEntries();
224
236
  const status = {
@@ -233,6 +245,13 @@ export async function createDiagnosticBundle(opts = {}) {
233
245
  configPath: configFile,
234
246
  snapshotPath: snapshotFile,
235
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`,
236
255
  diagnosticsDir,
237
256
  userAuth: readUserAuthSummary(),
238
257
  };
@@ -242,11 +261,21 @@ export async function createDiagnosticBundle(opts = {}) {
242
261
  { name: "doctor.txt", data: doctor.text + "\n" },
243
262
  { name: "doctor.json", data: JSON.stringify(doctor.json, null, 2) + "\n" },
244
263
  ];
245
- const log = safeReadText(logFile);
246
- entries.push({
247
- name: "daemon.log",
248
- data: log ?? `no log file at ${logFile}\n`,
249
- });
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
+ }
250
279
  const config = safeReadText(configFile);
251
280
  entries.push({
252
281
  name: "config.json.redacted",
@@ -285,6 +285,29 @@ export function createWechatChannel(opts) {
285
285
  }
286
286
  return parts.join("\n").trim();
287
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
+ }
288
311
  function normalizeInbound(msg) {
289
312
  if (msg.message_type !== 1)
290
313
  return null;
@@ -293,11 +316,12 @@ export function createWechatChannel(opts) {
293
316
  if (!fromUid || !contextToken)
294
317
  return null;
295
318
  const text = extractText(msg);
296
- if (!text)
319
+ const multimodalSummary = text ? "" : extractMultimodalSummary(msg);
320
+ if (!text && !multimodalSummary)
297
321
  return null;
298
322
  if (!allowedSenderIds.has(fromUid))
299
323
  return null;
300
- const sanitized = sanitizeUntrustedContent(text);
324
+ const sanitized = sanitizeUntrustedContent(text || multimodalSummary);
301
325
  const receivedAt = now();
302
326
  // W10: append randomUUID() to the fallback so two messages received in
303
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,7 +1220,9 @@ 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;
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;
package/dist/log.js CHANGED
@@ -1,8 +1,10 @@
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
  const LOG_DIR = path.join(homedir(), ".botcord", "logs");
5
5
  const LOG_FILE = path.join(LOG_DIR, "daemon.log");
6
+ const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024;
7
+ const LOG_ROTATE_KEEP = 20;
6
8
  let inited = false;
7
9
  function ensureDir() {
8
10
  if (inited)
@@ -39,10 +41,96 @@ export function formatLogLine(level, msg, fields, date = new Date()) {
39
41
  const suffix = `ts=${date.toISOString()}`;
40
42
  return detail ? `${prefix} ${detail} ${suffix}` : `${prefix} ${suffix}`;
41
43
  }
44
+ function rotatedName(file, date = new Date()) {
45
+ const stamp = date.toISOString().replace(/[:.]/g, "-");
46
+ return `${file}.${stamp}.${process.pid}`;
47
+ }
48
+ export function listDaemonLogFiles(logFile = LOG_FILE) {
49
+ const dir = path.dirname(logFile);
50
+ const base = path.basename(logFile);
51
+ const entries = [];
52
+ try {
53
+ const st = statSync(logFile);
54
+ if (st.isFile()) {
55
+ entries.push({
56
+ path: logFile,
57
+ name: base,
58
+ sizeBytes: st.size,
59
+ mtimeMs: st.mtimeMs,
60
+ active: true,
61
+ });
62
+ }
63
+ }
64
+ catch {
65
+ // no active log
66
+ }
67
+ let names = [];
68
+ try {
69
+ names = readdirSync(dir);
70
+ }
71
+ catch {
72
+ return entries;
73
+ }
74
+ for (const name of names) {
75
+ if (!name.startsWith(`${base}.`))
76
+ continue;
77
+ const file = path.join(dir, name);
78
+ try {
79
+ const st = statSync(file);
80
+ if (!st.isFile())
81
+ continue;
82
+ entries.push({
83
+ path: file,
84
+ name,
85
+ sizeBytes: st.size,
86
+ mtimeMs: st.mtimeMs,
87
+ active: false,
88
+ });
89
+ }
90
+ catch {
91
+ // ignore disappearing files
92
+ }
93
+ }
94
+ return entries.sort((a, b) => {
95
+ if (a.active !== b.active)
96
+ return a.active ? -1 : 1;
97
+ return b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name);
98
+ });
99
+ }
100
+ export function rotateLogIfNeeded(logFile = LOG_FILE, nextBytes = 0, maxBytes = LOG_ROTATE_MAX_BYTES, keep = LOG_ROTATE_KEEP) {
101
+ let currentSize = 0;
102
+ try {
103
+ const st = statSync(logFile);
104
+ if (!st.isFile())
105
+ return;
106
+ currentSize = st.size;
107
+ }
108
+ catch {
109
+ return;
110
+ }
111
+ if (currentSize + nextBytes <= maxBytes)
112
+ return;
113
+ try {
114
+ renameSync(logFile, rotatedName(logFile));
115
+ }
116
+ catch {
117
+ return;
118
+ }
119
+ const rotated = listDaemonLogFiles(logFile).filter((entry) => !entry.active);
120
+ for (const entry of rotated.slice(Math.max(0, keep))) {
121
+ try {
122
+ unlinkSync(entry.path);
123
+ }
124
+ catch {
125
+ // best-effort cleanup
126
+ }
127
+ }
128
+ }
42
129
  function write(level, msg, fields) {
43
130
  ensureDir();
44
131
  const line = formatLogLine(level, msg, fields);
45
132
  try {
133
+ rotateLogIfNeeded(LOG_FILE, Buffer.byteLength(line) + 1);
46
134
  appendFileSync(LOG_FILE, line + "\n", { mode: 0o600 });
47
135
  }
48
136
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.58",
3
+ "version": "0.2.59",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {