@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.
- package/dist/diagnostics.d.ts +3 -0
- package/dist/diagnostics.js +59 -6
- package/dist/gateway/channels/wechat.d.ts +2 -0
- package/dist/gateway/channels/wechat.js +30 -3
- package/dist/gateway/dispatcher.d.ts +3 -0
- package/dist/gateway/dispatcher.js +186 -27
- package/dist/index.js +11 -3
- package/dist/log.d.ts +9 -0
- package/dist/log.js +89 -1
- package/package.json +1 -1
- package/src/__tests__/diagnostics.test.ts +43 -1
- package/src/__tests__/log.test.ts +28 -1
- package/src/__tests__/wechat-channel.test.ts +56 -7
- package/src/diagnostics.ts +69 -6
- package/src/gateway/__tests__/dispatcher.test.ts +52 -0
- package/src/gateway/channels/wechat.ts +36 -3
- package/src/gateway/dispatcher.ts +212 -26
- package/src/index.ts +11 -3
- package/src/log.ts +100 -1
package/dist/diagnostics.d.ts
CHANGED
|
@@ -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;
|
package/dist/diagnostics.js
CHANGED
|
@@ -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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 >=
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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(
|
|
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:
|
|
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(
|
|
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:
|
|
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:
|
|
213
|
-
accountId:
|
|
214
|
-
conversationId:
|
|
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:
|
|
221
|
-
roomId:
|
|
222
|
-
topicId:
|
|
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:
|
|
234
|
-
roomId:
|
|
235
|
-
topicId:
|
|
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,
|
|
302
|
+
await this.runCancelPrevious(queueKey, dispatchRoute, text, dispatchMsg, dispatchChannel, dispatchTurnId, mergedFromDeferredTurnIds);
|
|
242
303
|
}
|
|
243
304
|
else {
|
|
244
|
-
await this.runSerial(queueKey,
|
|
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
|
-
|
|
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
|
|
413
|
-
|
|
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]
|
|
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;
|