@botcord/daemon 0.2.63 → 0.2.65
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/acp-logs.d.ts +5 -1
- package/dist/acp-logs.js +4 -3
- package/dist/config.d.ts +3 -3
- package/dist/control-channel.d.ts +1 -0
- package/dist/control-channel.js +58 -7
- package/dist/daemon.js +1 -1
- package/dist/diagnostics.js +62 -1
- package/dist/gateway/channels/botcord.js +54 -11
- package/dist/gateway/dispatcher.js +76 -10
- package/dist/gateway/gateway.d.ts +2 -1
- package/dist/gateway/gateway.js +1 -1
- package/dist/gateway/runtimes/openclaw-acp.js +41 -5
- package/dist/gateway/transcript.d.ts +21 -4
- package/dist/gateway/transcript.js +66 -5
- package/dist/index.js +7 -5
- package/dist/turn-text.js +2 -1
- package/package.json +1 -1
- package/src/__tests__/control-channel.test.ts +37 -0
- package/src/__tests__/turn-text.test.ts +16 -0
- package/src/acp-logs.ts +9 -3
- package/src/config.ts +3 -3
- package/src/control-channel.ts +60 -7
- package/src/daemon.ts +1 -1
- package/src/diagnostics.ts +67 -1
- package/src/gateway/__tests__/dispatcher.test.ts +44 -0
- package/src/gateway/__tests__/transcript.test.ts +27 -2
- package/src/gateway/channels/botcord.ts +50 -11
- package/src/gateway/dispatcher.ts +77 -8
- package/src/gateway/gateway.ts +3 -2
- package/src/gateway/runtimes/openclaw-acp.ts +56 -6
- package/src/gateway/transcript.ts +98 -8
- package/src/index.ts +6 -5
- package/src/turn-text.ts +2 -1
package/src/diagnostics.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir, hostname, platform, release, arch } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { Buffer } from "node:buffer";
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from "./config.js";
|
|
23
23
|
import { listDaemonLogFiles, LOG_FILE_PATH, type LogFileEntry } from "./log.js";
|
|
24
24
|
import { listAcpTraceLogFiles, listRuntimeLogFiles } from "./acp-logs.js";
|
|
25
|
+
import { defaultTranscriptRoot } from "./gateway/transcript.js";
|
|
25
26
|
import {
|
|
26
27
|
channelsFromDaemonConfig,
|
|
27
28
|
defaultHttpFetcher,
|
|
@@ -59,6 +60,9 @@ const ENV_ALLOWLIST = new Set([
|
|
|
59
60
|
"BOTCORD_KIMI_CLI_BIN",
|
|
60
61
|
"OPENCLAW_ACP_URL",
|
|
61
62
|
]);
|
|
63
|
+
const TRANSCRIPT_LOG_DIAGNOSTICS_DEFAULT = 10;
|
|
64
|
+
const TRANSCRIPT_LOG_DIAGNOSTICS_ALL = 50;
|
|
65
|
+
const TRANSCRIPT_LOG_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
62
66
|
|
|
63
67
|
export interface CreateDiagnosticBundleOptions {
|
|
64
68
|
diagnosticsDir?: string;
|
|
@@ -416,6 +420,55 @@ function bundledLogs(logFile: string, includeAllLogs: boolean): LogFileEntry[] {
|
|
|
416
420
|
];
|
|
417
421
|
}
|
|
418
422
|
|
|
423
|
+
function listTranscriptLogFiles(includeAll: boolean): LogFileEntry[] {
|
|
424
|
+
const root = defaultTranscriptRoot();
|
|
425
|
+
const out: LogFileEntry[] = [];
|
|
426
|
+
collectTranscriptFiles(root, root, out, 5);
|
|
427
|
+
const limit = includeAll ? TRANSCRIPT_LOG_DIAGNOSTICS_ALL : TRANSCRIPT_LOG_DIAGNOSTICS_DEFAULT;
|
|
428
|
+
return out
|
|
429
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name))
|
|
430
|
+
.slice(0, limit);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function collectTranscriptFiles(
|
|
434
|
+
root: string,
|
|
435
|
+
dir: string,
|
|
436
|
+
out: LogFileEntry[],
|
|
437
|
+
maxDepth: number,
|
|
438
|
+
): void {
|
|
439
|
+
if (maxDepth < 0) return;
|
|
440
|
+
let names: string[];
|
|
441
|
+
try {
|
|
442
|
+
names = readdirSync(dir);
|
|
443
|
+
} catch {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
for (const name of names) {
|
|
447
|
+
const file = path.join(dir, name);
|
|
448
|
+
try {
|
|
449
|
+
const st = statSync(file);
|
|
450
|
+
if (st.isDirectory()) {
|
|
451
|
+
collectTranscriptFiles(root, file, out, maxDepth - 1);
|
|
452
|
+
} else if (
|
|
453
|
+
st.isFile() &&
|
|
454
|
+
name.endsWith(".jsonl") &&
|
|
455
|
+
file.includes(`${path.sep}transcripts${path.sep}`) &&
|
|
456
|
+
st.size <= TRANSCRIPT_LOG_MAX_FILE_BYTES
|
|
457
|
+
) {
|
|
458
|
+
out.push({
|
|
459
|
+
path: file,
|
|
460
|
+
name: path.relative(root, file) || name,
|
|
461
|
+
sizeBytes: st.size,
|
|
462
|
+
mtimeMs: st.mtimeMs,
|
|
463
|
+
active: true,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
} catch {
|
|
467
|
+
// ignore files that disappear while collecting diagnostics
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
419
472
|
export async function createDiagnosticBundle(
|
|
420
473
|
opts: CreateDiagnosticBundleOptions = {},
|
|
421
474
|
): Promise<DiagnosticBundleResult> {
|
|
@@ -431,6 +484,7 @@ export async function createDiagnosticBundle(
|
|
|
431
484
|
const logs = bundledLogs(logFile, includeAllLogs);
|
|
432
485
|
const acpLogs = listAcpTraceLogFiles(includeAllLogs);
|
|
433
486
|
const runtimeLogs = listRuntimeLogFiles(includeAllLogs);
|
|
487
|
+
const transcriptLogs = listTranscriptLogFiles(includeAllLogs);
|
|
434
488
|
mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
|
|
435
489
|
|
|
436
490
|
const doctor = opts.doctor ?? await buildDoctorEntries();
|
|
@@ -465,6 +519,11 @@ export async function createDiagnosticBundle(
|
|
|
465
519
|
path: entry.path,
|
|
466
520
|
sizeBytes: entry.sizeBytes,
|
|
467
521
|
})),
|
|
522
|
+
transcriptLogsBundled: transcriptLogs.map((entry) => ({
|
|
523
|
+
name: entry.name,
|
|
524
|
+
path: entry.path,
|
|
525
|
+
sizeBytes: entry.sizeBytes,
|
|
526
|
+
})),
|
|
468
527
|
logsBundleMode: includeAllLogs ? "all" : `active_plus_${DEFAULT_ROTATED_LOGS_IN_BUNDLE}_rotated`,
|
|
469
528
|
diagnosticsDir,
|
|
470
529
|
userAuth: readUserAuthSummary(),
|
|
@@ -504,6 +563,13 @@ export async function createDiagnosticBundle(
|
|
|
504
563
|
data: log ?? `no runtime log file at ${entry.path}\n`,
|
|
505
564
|
});
|
|
506
565
|
}
|
|
566
|
+
for (const entry of transcriptLogs) {
|
|
567
|
+
const log = safeReadText(entry.path);
|
|
568
|
+
entries.push({
|
|
569
|
+
name: `transcripts/${entry.name.split(path.sep).join("/")}`,
|
|
570
|
+
data: log ?? `no transcript log file at ${entry.path}\n`,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
507
573
|
const config = safeReadText(configFile);
|
|
508
574
|
entries.push({
|
|
509
575
|
name: "config.json.redacted",
|
|
@@ -1276,6 +1276,50 @@ describe("Dispatcher", () => {
|
|
|
1276
1276
|
expect(blockTypes).toEqual(["system", "tool_use"]);
|
|
1277
1277
|
});
|
|
1278
1278
|
|
|
1279
|
+
it("transcript: records runtime blocks even when channel streaming is disabled", async () => {
|
|
1280
|
+
const blocks: StreamBlock[] = [
|
|
1281
|
+
{
|
|
1282
|
+
raw: {
|
|
1283
|
+
params: {
|
|
1284
|
+
update: {
|
|
1285
|
+
sessionUpdate: "tool_call",
|
|
1286
|
+
toolCall: { name: "botcord_send", rawInput: { text: "hello" } },
|
|
1287
|
+
},
|
|
1288
|
+
},
|
|
1289
|
+
},
|
|
1290
|
+
kind: "tool_use",
|
|
1291
|
+
seq: 7,
|
|
1292
|
+
},
|
|
1293
|
+
];
|
|
1294
|
+
const runtime = new FakeRuntime({ blocks, reply: "ok", newSessionId: "sid" });
|
|
1295
|
+
const channel = new FakeChannel();
|
|
1296
|
+
const records: import("../transcript.js").TranscriptRecord[] = [];
|
|
1297
|
+
const { store, dir } = await makeStore();
|
|
1298
|
+
tempDirs.push(dir);
|
|
1299
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
1300
|
+
const dispatcher = new Dispatcher({
|
|
1301
|
+
config: baseConfig(),
|
|
1302
|
+
channels,
|
|
1303
|
+
runtime: () => runtime,
|
|
1304
|
+
sessionStore: store,
|
|
1305
|
+
log: silentLogger(),
|
|
1306
|
+
transcript: { enabled: true, rootDir: dir, write: (rec) => records.push(rec) },
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
await dispatcher.handle(
|
|
1310
|
+
makeEnvelope({ trace: { id: "tr", streamable: false } }),
|
|
1311
|
+
);
|
|
1312
|
+
|
|
1313
|
+
expect(channel.streams).toHaveLength(0);
|
|
1314
|
+
const block = records.find((r) => r.kind === "block");
|
|
1315
|
+
expect(block).toMatchObject({
|
|
1316
|
+
kind: "block",
|
|
1317
|
+
blockType: "tool_use",
|
|
1318
|
+
seq: 7,
|
|
1319
|
+
summary: { type: "tool_use", name: "botcord_send" },
|
|
1320
|
+
});
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1279
1323
|
it("runtime throws: sends error reply, does not write session", async () => {
|
|
1280
1324
|
const runtime = new FakeRuntime({ throwError: "boom" });
|
|
1281
1325
|
const channel = new FakeChannel();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
-
import { existsSync, statSync } from "node:fs";
|
|
1
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync, statSync, utimesSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -7,7 +7,9 @@ import { Dispatcher, type RuntimeFactory } from "../dispatcher.js";
|
|
|
7
7
|
import { SessionStore } from "../session-store.js";
|
|
8
8
|
import {
|
|
9
9
|
createTranscriptWriter,
|
|
10
|
+
cleanupTranscriptFiles,
|
|
10
11
|
resolveTranscriptEnabled,
|
|
12
|
+
TRANSCRIPT_RETENTION_MS,
|
|
11
13
|
TRANSCRIPT_TEXT_LIMIT,
|
|
12
14
|
truncateTextField,
|
|
13
15
|
type TranscriptRecord,
|
|
@@ -239,6 +241,10 @@ describe("resolveTranscriptEnabled", () => {
|
|
|
239
241
|
expect(resolveTranscriptEnabled("yes", true)).toBe(true);
|
|
240
242
|
expect(resolveTranscriptEnabled("yes", false)).toBe(false);
|
|
241
243
|
});
|
|
244
|
+
it("defaults on when env and config are both unset", () => {
|
|
245
|
+
expect(resolveTranscriptEnabled(undefined, undefined)).toBe(true);
|
|
246
|
+
expect(resolveTranscriptEnabled("yes", undefined)).toBe(true);
|
|
247
|
+
});
|
|
242
248
|
});
|
|
243
249
|
|
|
244
250
|
describe("truncateTextField", () => {
|
|
@@ -460,6 +466,25 @@ describe("Dispatcher transcript integration", () => {
|
|
|
460
466
|
expect(files).toContain("_default.jsonl");
|
|
461
467
|
});
|
|
462
468
|
|
|
469
|
+
it("cleans transcript files older than the retention window", async () => {
|
|
470
|
+
const tmp = await mkdtemp(path.join(tmpdir(), "transcript-clean-"));
|
|
471
|
+
cleanups.push(() => rm(tmp, { recursive: true, force: true }));
|
|
472
|
+
const oldFile = transcriptFilePath(tmp, "ag_me", "rm_old", null);
|
|
473
|
+
const freshFile = transcriptFilePath(tmp, "ag_me", "rm_fresh", null);
|
|
474
|
+
await mkdir(path.dirname(oldFile), { recursive: true });
|
|
475
|
+
await mkdir(path.dirname(freshFile), { recursive: true });
|
|
476
|
+
await writeFile(oldFile, "{}\n", { mode: 0o600 });
|
|
477
|
+
await writeFile(freshFile, "{}\n", { mode: 0o600 });
|
|
478
|
+
const oldDate = new Date(Date.now() - TRANSCRIPT_RETENTION_MS - 60_000);
|
|
479
|
+
utimesSync(oldFile, oldDate, oldDate);
|
|
480
|
+
|
|
481
|
+
const removed = cleanupTranscriptFiles(tmp, Date.now() - TRANSCRIPT_RETENTION_MS);
|
|
482
|
+
|
|
483
|
+
expect(removed).toBe(1);
|
|
484
|
+
expect(existsSync(oldFile)).toBe(false);
|
|
485
|
+
expect(existsSync(freshFile)).toBe(true);
|
|
486
|
+
});
|
|
487
|
+
|
|
463
488
|
it("disabled writer does not create files", async () => {
|
|
464
489
|
const tmp = await mkdtemp(path.join(tmpdir(), "transcript-off-"));
|
|
465
490
|
cleanups.push(() => rm(tmp, { recursive: true, force: true }));
|
|
@@ -25,6 +25,7 @@ import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
|
25
25
|
import { revokeAgent } from "../../provision.js";
|
|
26
26
|
|
|
27
27
|
const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
28
|
+
const RECONNECT_JITTER_RATIO = 0.25;
|
|
28
29
|
const KEEPALIVE_INTERVAL = 20_000;
|
|
29
30
|
const MAX_AUTH_FAILURES = 5;
|
|
30
31
|
const SEEN_MESSAGES_CAP = 500;
|
|
@@ -33,6 +34,11 @@ const DM_ROOM_PREFIX = "rm_dm_";
|
|
|
33
34
|
const INBOX_POLL_LIMIT = 50;
|
|
34
35
|
const CHANNEL_PERMANENT_STOP = "channel_permanent_stop";
|
|
35
36
|
|
|
37
|
+
function withReconnectJitter(delayMs: number): { delayMs: number; jitterMs: number } {
|
|
38
|
+
const jitterMs = Math.floor(Math.random() * delayMs * RECONNECT_JITTER_RATIO);
|
|
39
|
+
return { delayMs: delayMs + jitterMs, jitterMs };
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
type InboxDrainTrigger =
|
|
37
43
|
| "ws_auth_ok"
|
|
38
44
|
| "ws_inbox_update"
|
|
@@ -477,6 +483,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
477
483
|
let reconnectTimer: NodeJS.Timeout | null = null;
|
|
478
484
|
let keepaliveTimer: NodeJS.Timeout | null = null;
|
|
479
485
|
let reconnectAttempt = 0;
|
|
486
|
+
let connectionSeq = 0;
|
|
480
487
|
let consecutiveAuthFailures = 0;
|
|
481
488
|
let running = true;
|
|
482
489
|
let permanentStopping = false;
|
|
@@ -603,23 +610,36 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
603
610
|
|
|
604
611
|
function scheduleReconnect() {
|
|
605
612
|
if (!running) return;
|
|
606
|
-
|
|
613
|
+
if (reconnectTimer) return;
|
|
614
|
+
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const baseDelayMs =
|
|
607
618
|
RECONNECT_BACKOFF[Math.min(reconnectAttempt, RECONNECT_BACKOFF.length - 1)];
|
|
619
|
+
const { delayMs, jitterMs } = withReconnectJitter(baseDelayMs);
|
|
608
620
|
reconnectAttempt += 1;
|
|
609
621
|
markStatus({
|
|
610
622
|
connected: false,
|
|
611
623
|
restartPending: true,
|
|
612
624
|
reconnectAttempts: reconnectAttempt,
|
|
613
625
|
});
|
|
614
|
-
log.info("botcord ws reconnect scheduled", {
|
|
626
|
+
log.info("botcord ws reconnect scheduled", {
|
|
627
|
+
delayMs,
|
|
628
|
+
baseDelayMs,
|
|
629
|
+
jitterMs,
|
|
630
|
+
attempt: reconnectAttempt,
|
|
631
|
+
});
|
|
615
632
|
reconnectTimer = setTimeout(() => {
|
|
616
633
|
reconnectTimer = null;
|
|
617
634
|
void connect();
|
|
618
|
-
},
|
|
635
|
+
}, delayMs);
|
|
619
636
|
}
|
|
620
637
|
|
|
621
638
|
async function connect() {
|
|
622
639
|
if (!running) return;
|
|
640
|
+
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
623
643
|
const agentId = options.agentId;
|
|
624
644
|
markStatus({ connected: false, restartPending: false });
|
|
625
645
|
if (pendingRefresh) {
|
|
@@ -644,8 +664,11 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
644
664
|
const url = buildHubWebSocketUrl(hubUrl);
|
|
645
665
|
log.info("botcord ws connecting", { url, agentId });
|
|
646
666
|
|
|
667
|
+
const connectionId = ++connectionSeq;
|
|
668
|
+
let socket: WebSocket;
|
|
647
669
|
try {
|
|
648
|
-
|
|
670
|
+
socket = new wsCtor(url);
|
|
671
|
+
ws = socket;
|
|
649
672
|
} catch (err) {
|
|
650
673
|
log.error("botcord ws construct failed", { agentId, err: String(err) });
|
|
651
674
|
markStatus({ lastError: String(err) });
|
|
@@ -653,11 +676,20 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
653
676
|
return;
|
|
654
677
|
}
|
|
655
678
|
|
|
656
|
-
|
|
657
|
-
|
|
679
|
+
socket.on("open", () => {
|
|
680
|
+
if (!running || ws !== socket || connectionId !== connectionSeq) {
|
|
681
|
+
try {
|
|
682
|
+
socket.close();
|
|
683
|
+
} catch {
|
|
684
|
+
// ignore
|
|
685
|
+
}
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
socket.send(JSON.stringify({ type: "auth", token }));
|
|
658
689
|
});
|
|
659
690
|
|
|
660
|
-
|
|
691
|
+
socket.on("message", (data: WebSocket.RawData) => {
|
|
692
|
+
if (ws !== socket || connectionId !== connectionSeq) return;
|
|
661
693
|
let msg: { type?: string; agent_id?: string } | null = null;
|
|
662
694
|
try {
|
|
663
695
|
msg = JSON.parse(String(data));
|
|
@@ -677,10 +709,11 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
677
709
|
});
|
|
678
710
|
log.info("botcord ws authenticated", { agentId: msg.agent_id });
|
|
679
711
|
void fireInbox("ws_auth_ok");
|
|
712
|
+
if (keepaliveTimer) clearInterval(keepaliveTimer);
|
|
680
713
|
keepaliveTimer = setInterval(() => {
|
|
681
|
-
if (ws &&
|
|
714
|
+
if (ws === socket && socket.readyState === WebSocket.OPEN) {
|
|
682
715
|
try {
|
|
683
|
-
|
|
716
|
+
socket.send(JSON.stringify({ type: "ping" }));
|
|
684
717
|
} catch {
|
|
685
718
|
// ignore
|
|
686
719
|
}
|
|
@@ -696,10 +729,15 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
696
729
|
}
|
|
697
730
|
});
|
|
698
731
|
|
|
699
|
-
|
|
732
|
+
socket.on("close", (code: number, reason: Buffer) => {
|
|
700
733
|
const reasonStr = reason?.toString() || "";
|
|
734
|
+
if (ws !== socket || connectionId !== connectionSeq) {
|
|
735
|
+
log.debug("botcord ws stale close ignored", { agentId, code, reason: reasonStr });
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
701
738
|
log.info("botcord ws closed", { agentId, code, reason: reasonStr });
|
|
702
739
|
clearTimers();
|
|
740
|
+
ws = null;
|
|
703
741
|
markStatus({ connected: false });
|
|
704
742
|
if (!running) {
|
|
705
743
|
if (permanentStopping) return;
|
|
@@ -740,7 +778,8 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
740
778
|
scheduleReconnect();
|
|
741
779
|
});
|
|
742
780
|
|
|
743
|
-
|
|
781
|
+
socket.on("error", (err: Error) => {
|
|
782
|
+
if (ws !== socket || connectionId !== connectionSeq) return;
|
|
744
783
|
log.warn("botcord ws error", { agentId, err: String(err) });
|
|
745
784
|
markStatus({ lastError: String(err) });
|
|
746
785
|
});
|
|
@@ -37,6 +37,8 @@ const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
|
|
|
37
37
|
* (or `botcord send` CLI via Bash) to actually deliver replies.
|
|
38
38
|
*/
|
|
39
39
|
const OWNER_CHAT_ROOM_PREFIX = "rm_oc_";
|
|
40
|
+
const TRANSCRIPT_BLOCK_RAW_LIMIT = 16 * 1024;
|
|
41
|
+
const SECRET_KEY_RE = /token|secret|private.?key|api.?key|authorization|password/i;
|
|
40
42
|
|
|
41
43
|
/** Maximum number of buffered serial entries per queue. Excess entries drop oldest. */
|
|
42
44
|
const MAX_BATCH_BUFFER_ENTRIES = 40;
|
|
@@ -66,6 +68,62 @@ const TYPING_REFRESH_MS = 4000;
|
|
|
66
68
|
/** LRU cap on the typing-recency map so long-running daemons don't grow unbounded. */
|
|
67
69
|
const TYPING_RECENCY_CAP = 1024;
|
|
68
70
|
|
|
71
|
+
function transcriptBlocksVerbose(): boolean {
|
|
72
|
+
return process.env.BOTCORD_TRANSCRIPT_BLOCKS === "verbose" ||
|
|
73
|
+
process.env.BOTCORD_TRACE_VERBOSE === "1";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function summarizeStreamBlock(block: StreamBlock): TranscriptBlockSummary {
|
|
77
|
+
const summary: TranscriptBlockSummary = { type: block.kind };
|
|
78
|
+
const raw = block.raw as {
|
|
79
|
+
text?: unknown;
|
|
80
|
+
name?: unknown;
|
|
81
|
+
update?: unknown;
|
|
82
|
+
params?: { update?: unknown };
|
|
83
|
+
} | null | undefined;
|
|
84
|
+
if (raw && typeof raw === "object") {
|
|
85
|
+
if (typeof raw.text === "string") summary.chars = raw.text.length;
|
|
86
|
+
if (typeof raw.name === "string") summary.name = raw.name;
|
|
87
|
+
const update = raw.params?.update ?? raw.update;
|
|
88
|
+
if (update && typeof update === "object") {
|
|
89
|
+
const u = update as Record<string, unknown>;
|
|
90
|
+
if (typeof u.sessionUpdate === "string" && !summary.name) summary.name = u.sessionUpdate;
|
|
91
|
+
const toolCall = u.toolCall;
|
|
92
|
+
if (toolCall && typeof toolCall === "object") {
|
|
93
|
+
const toolName = (toolCall as Record<string, unknown>).name;
|
|
94
|
+
if (typeof toolName === "string") summary.name = toolName;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return summary;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function redactAndCap(value: unknown, budget = TRANSCRIPT_BLOCK_RAW_LIMIT): unknown {
|
|
102
|
+
const seen = new WeakSet<object>();
|
|
103
|
+
const walk = (v: unknown): unknown => {
|
|
104
|
+
if (typeof v === "string") {
|
|
105
|
+
return redactSecretString(v.length > budget ? `${v.slice(0, budget)}…` : v);
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(v)) return v.slice(0, 50).map(walk);
|
|
108
|
+
if (!v || typeof v !== "object") return v;
|
|
109
|
+
if (seen.has(v)) return "[Circular]";
|
|
110
|
+
seen.add(v);
|
|
111
|
+
const out: Record<string, unknown> = {};
|
|
112
|
+
for (const [key, child] of Object.entries(v as Record<string, unknown>).slice(0, 80)) {
|
|
113
|
+
out[key] = SECRET_KEY_RE.test(key) ? "[REDACTED]" : walk(child);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
};
|
|
117
|
+
return walk(value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function redactSecretString(value: string): string {
|
|
121
|
+
return value
|
|
122
|
+
.replace(/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]")
|
|
123
|
+
.replace(/\b(token=)[^\s"']+/gi, "$1[REDACTED]")
|
|
124
|
+
.replace(/\b(drt_|dit_|gho_)[A-Za-z0-9_-]+/g, "$1[REDACTED]");
|
|
125
|
+
}
|
|
126
|
+
|
|
69
127
|
/** Factory signature for building a runtime adapter at turn dispatch time. */
|
|
70
128
|
export type RuntimeFactory = (
|
|
71
129
|
runtimeId: string,
|
|
@@ -943,13 +1001,23 @@ export class Dispatcher {
|
|
|
943
1001
|
const canStream =
|
|
944
1002
|
streamable && typeof traceId === "string" && typeof channel.streamBlock === "function";
|
|
945
1003
|
const recordBlock = (block: StreamBlock): void => {
|
|
946
|
-
const summary
|
|
947
|
-
const raw = block.raw as { text?: unknown; name?: unknown } | null | undefined;
|
|
948
|
-
if (raw && typeof raw === "object") {
|
|
949
|
-
if (typeof raw.text === "string") summary.chars = raw.text.length;
|
|
950
|
-
if (typeof raw.name === "string") summary.name = raw.name;
|
|
951
|
-
}
|
|
1004
|
+
const summary = summarizeStreamBlock(block);
|
|
952
1005
|
slot.blocks.push(summary);
|
|
1006
|
+
if (this.transcript.enabled) {
|
|
1007
|
+
this.transcript.write({
|
|
1008
|
+
ts: new Date().toISOString(),
|
|
1009
|
+
kind: "block",
|
|
1010
|
+
turnId,
|
|
1011
|
+
agentId: msg.accountId,
|
|
1012
|
+
roomId: msg.conversation.id,
|
|
1013
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1014
|
+
runtime: route.runtime,
|
|
1015
|
+
seq: block.seq,
|
|
1016
|
+
blockType: block.kind,
|
|
1017
|
+
summary,
|
|
1018
|
+
...(transcriptBlocksVerbose() ? { raw: redactAndCap(block.raw) } : {}),
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
953
1021
|
};
|
|
954
1022
|
|
|
955
1023
|
// Owner-chat lifecycle state for typing/thinking. The dispatcher is the
|
|
@@ -1118,13 +1186,14 @@ export class Dispatcher {
|
|
|
1118
1186
|
}
|
|
1119
1187
|
: undefined;
|
|
1120
1188
|
|
|
1121
|
-
const onBlock = canStream
|
|
1189
|
+
const onBlock = (canStream || this.transcript.enabled)
|
|
1122
1190
|
? (block: StreamBlock) => {
|
|
1123
1191
|
// Always record adapter-emitted blocks for transcript fidelity, even
|
|
1124
1192
|
// after abort — the transcript reflects what the runtime emitted,
|
|
1125
1193
|
// not what the dispatcher chose to forward.
|
|
1126
1194
|
recordBlock(block);
|
|
1127
1195
|
if (controller.signal.aborted) return;
|
|
1196
|
+
if (!canStream) return;
|
|
1128
1197
|
// Synthesize thinking.started before non-assistant blocks. After
|
|
1129
1198
|
// we've seen any assistant_text, only `tool_use` may re-enter
|
|
1130
1199
|
// thinking — terminal markers like `system`/`other` (codex
|
|
@@ -1144,7 +1213,7 @@ export class Dispatcher {
|
|
|
1144
1213
|
thinkingActive = false;
|
|
1145
1214
|
sawAssistantText = true;
|
|
1146
1215
|
}
|
|
1147
|
-
forwardBlockToChannel
|
|
1216
|
+
forwardBlockToChannel?.(block);
|
|
1148
1217
|
}
|
|
1149
1218
|
: undefined;
|
|
1150
1219
|
|
package/src/gateway/gateway.ts
CHANGED
|
@@ -78,7 +78,8 @@ export interface GatewayBootOptions {
|
|
|
78
78
|
* Tri-state convenience: if `transcript` is not provided, the gateway
|
|
79
79
|
* constructs a writer using this flag plus `transcriptRootDir`. Use
|
|
80
80
|
* {@link resolveTranscriptEnabled} to combine `BOTCORD_TRANSCRIPT` env with
|
|
81
|
-
* the persistent daemon-config flag.
|
|
81
|
+
* the persistent daemon-config flag. When omitted, transcripts are enabled
|
|
82
|
+
* by default.
|
|
82
83
|
*/
|
|
83
84
|
transcriptEnabled?: boolean;
|
|
84
85
|
/** Root directory for transcript files. Defaults to `~/.botcord/agents`. */
|
|
@@ -145,7 +146,7 @@ export class Gateway {
|
|
|
145
146
|
opts.transcript
|
|
146
147
|
?? createTranscriptWriter({
|
|
147
148
|
log: this.log,
|
|
148
|
-
enabled: opts.transcriptEnabled
|
|
149
|
+
enabled: opts.transcriptEnabled,
|
|
149
150
|
rootDir: opts.transcriptRootDir,
|
|
150
151
|
});
|
|
151
152
|
|
|
@@ -35,7 +35,7 @@ interface AcpProcessHandle {
|
|
|
35
35
|
/** Pending JSON-RPC requests keyed by id. */
|
|
36
36
|
pending: Map<number, PendingCall>;
|
|
37
37
|
/** Per-ACP-sessionId notification subscribers. */
|
|
38
|
-
subscribers: Map<string,
|
|
38
|
+
subscribers: Map<string, AcpSubscriber>;
|
|
39
39
|
nextId: number;
|
|
40
40
|
buffer: string;
|
|
41
41
|
nonJsonStdoutTail: string[];
|
|
@@ -66,6 +66,18 @@ interface AcpNotification {
|
|
|
66
66
|
params: any;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
interface AcpTurnTraceContext {
|
|
70
|
+
turnId?: string;
|
|
71
|
+
messageId?: string;
|
|
72
|
+
roomId?: string;
|
|
73
|
+
topicId?: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface AcpSubscriber {
|
|
77
|
+
notify: (note: AcpNotification) => void;
|
|
78
|
+
traceContext: AcpTurnTraceContext;
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
const ACP_POOL = new Map<string, AcpProcessHandle>();
|
|
70
82
|
|
|
71
83
|
function poolKey(accountId: string, gatewayName: string): string {
|
|
@@ -183,6 +195,12 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
|
|
|
183
195
|
// synthetic test calls).
|
|
184
196
|
conversationKey: stringField(opts.context, "conversationKey") ?? "default",
|
|
185
197
|
});
|
|
198
|
+
const traceContext: AcpTurnTraceContext = {
|
|
199
|
+
turnId: stringField(opts.context, "turnId"),
|
|
200
|
+
messageId: stringField(opts.context, "messageId"),
|
|
201
|
+
roomId: stringField(opts.context, "roomId"),
|
|
202
|
+
topicId: nullableStringField(opts.context, "topicId"),
|
|
203
|
+
};
|
|
186
204
|
|
|
187
205
|
const key = poolKey(opts.accountId, gateway.name);
|
|
188
206
|
let handle: AcpProcessHandle;
|
|
@@ -273,7 +291,17 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
|
|
|
273
291
|
throw new Error(`newSession failed: ${(err as Error).message}`);
|
|
274
292
|
}
|
|
275
293
|
}
|
|
276
|
-
handle.subscribers.set(acpSessionId, onNotification);
|
|
294
|
+
handle.subscribers.set(acpSessionId, { notify: onNotification, traceContext });
|
|
295
|
+
handle.trace?.write({
|
|
296
|
+
stream: "turn_context",
|
|
297
|
+
...traceContext,
|
|
298
|
+
params: {
|
|
299
|
+
sessionId: acpSessionId,
|
|
300
|
+
sessionKey,
|
|
301
|
+
openclawAgent,
|
|
302
|
+
cwd: opts.cwd,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
277
305
|
|
|
278
306
|
if (opts.signal?.aborted) {
|
|
279
307
|
return failResult(acpSessionId, "openclaw-acp: aborted before prompt");
|
|
@@ -308,7 +336,18 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
|
|
|
308
336
|
});
|
|
309
337
|
handle.subscribers.delete(acpSessionId);
|
|
310
338
|
acpSessionId = fresh;
|
|
311
|
-
handle.subscribers.set(acpSessionId, onNotification);
|
|
339
|
+
handle.subscribers.set(acpSessionId, { notify: onNotification, traceContext });
|
|
340
|
+
handle.trace?.write({
|
|
341
|
+
stream: "turn_context",
|
|
342
|
+
...traceContext,
|
|
343
|
+
params: {
|
|
344
|
+
sessionId: acpSessionId,
|
|
345
|
+
sessionKey,
|
|
346
|
+
openclawAgent,
|
|
347
|
+
cwd: opts.cwd,
|
|
348
|
+
recreatedFrom: oldSessionId,
|
|
349
|
+
},
|
|
350
|
+
});
|
|
312
351
|
log.info("openclaw-acp.session-recreated", {
|
|
313
352
|
accountId: opts.accountId,
|
|
314
353
|
oldSessionId,
|
|
@@ -627,19 +666,20 @@ function routeMessage(handle: AcpProcessHandle, msg: any): void {
|
|
|
627
666
|
}
|
|
628
667
|
// Notification.
|
|
629
668
|
if (msg?.method && msg?.params) {
|
|
669
|
+
const sid = msg.params?.sessionId;
|
|
670
|
+
const sub = typeof sid === "string" ? handle.subscribers.get(sid) : undefined;
|
|
630
671
|
handle.trace?.write({
|
|
631
672
|
stream: "rpc_in",
|
|
632
673
|
direction: "in",
|
|
633
674
|
method: msg.method,
|
|
634
675
|
status: "notification",
|
|
676
|
+
...(sub?.traceContext ?? {}),
|
|
635
677
|
params: msg.params,
|
|
636
678
|
});
|
|
637
|
-
const sid = msg.params?.sessionId;
|
|
638
679
|
if (typeof sid === "string") {
|
|
639
|
-
const sub = handle.subscribers.get(sid);
|
|
640
680
|
if (sub) {
|
|
641
681
|
try {
|
|
642
|
-
sub({ method: msg.method, params: msg.params });
|
|
682
|
+
sub.notify({ method: msg.method, params: msg.params });
|
|
643
683
|
} catch (err) {
|
|
644
684
|
log.warn("openclaw-acp.subscriber-threw", {
|
|
645
685
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -1026,6 +1066,16 @@ function stringField(bag: Record<string, unknown> | undefined, key: string): str
|
|
|
1026
1066
|
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
1027
1067
|
}
|
|
1028
1068
|
|
|
1069
|
+
function nullableStringField(
|
|
1070
|
+
bag: Record<string, unknown> | undefined,
|
|
1071
|
+
key: string,
|
|
1072
|
+
): string | null | undefined {
|
|
1073
|
+
if (!bag) return undefined;
|
|
1074
|
+
const v = bag[key];
|
|
1075
|
+
if (v === null) return null;
|
|
1076
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1029
1079
|
/**
|
|
1030
1080
|
* Build the OpenClaw ACP `sessionKey` for a daemon turn. `accountId` is
|
|
1031
1081
|
* always included to prevent two daemon agents from colliding on the same
|