@botcord/daemon 0.2.62 → 0.2.64
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.d.ts +1 -0
- package/dist/diagnostics.js +168 -2
- 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/package.json +1 -1
- package/src/__tests__/control-channel.test.ts +37 -0
- package/src/__tests__/diagnostics.test.ts +23 -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 +173 -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
|
@@ -10,6 +10,8 @@ const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
|
|
|
10
10
|
* (or `botcord send` CLI via Bash) to actually deliver replies.
|
|
11
11
|
*/
|
|
12
12
|
const OWNER_CHAT_ROOM_PREFIX = "rm_oc_";
|
|
13
|
+
const TRANSCRIPT_BLOCK_RAW_LIMIT = 16 * 1024;
|
|
14
|
+
const SECRET_KEY_RE = /token|secret|private.?key|api.?key|authorization|password/i;
|
|
13
15
|
/** Maximum number of buffered serial entries per queue. Excess entries drop oldest. */
|
|
14
16
|
const MAX_BATCH_BUFFER_ENTRIES = 40;
|
|
15
17
|
/**
|
|
@@ -33,6 +35,60 @@ const TYPING_DEBOUNCE_MS = 2000;
|
|
|
33
35
|
const TYPING_REFRESH_MS = 4000;
|
|
34
36
|
/** LRU cap on the typing-recency map so long-running daemons don't grow unbounded. */
|
|
35
37
|
const TYPING_RECENCY_CAP = 1024;
|
|
38
|
+
function transcriptBlocksVerbose() {
|
|
39
|
+
return process.env.BOTCORD_TRANSCRIPT_BLOCKS === "verbose" ||
|
|
40
|
+
process.env.BOTCORD_TRACE_VERBOSE === "1";
|
|
41
|
+
}
|
|
42
|
+
function summarizeStreamBlock(block) {
|
|
43
|
+
const summary = { type: block.kind };
|
|
44
|
+
const raw = block.raw;
|
|
45
|
+
if (raw && typeof raw === "object") {
|
|
46
|
+
if (typeof raw.text === "string")
|
|
47
|
+
summary.chars = raw.text.length;
|
|
48
|
+
if (typeof raw.name === "string")
|
|
49
|
+
summary.name = raw.name;
|
|
50
|
+
const update = raw.params?.update ?? raw.update;
|
|
51
|
+
if (update && typeof update === "object") {
|
|
52
|
+
const u = update;
|
|
53
|
+
if (typeof u.sessionUpdate === "string" && !summary.name)
|
|
54
|
+
summary.name = u.sessionUpdate;
|
|
55
|
+
const toolCall = u.toolCall;
|
|
56
|
+
if (toolCall && typeof toolCall === "object") {
|
|
57
|
+
const toolName = toolCall.name;
|
|
58
|
+
if (typeof toolName === "string")
|
|
59
|
+
summary.name = toolName;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return summary;
|
|
64
|
+
}
|
|
65
|
+
function redactAndCap(value, budget = TRANSCRIPT_BLOCK_RAW_LIMIT) {
|
|
66
|
+
const seen = new WeakSet();
|
|
67
|
+
const walk = (v) => {
|
|
68
|
+
if (typeof v === "string") {
|
|
69
|
+
return redactSecretString(v.length > budget ? `${v.slice(0, budget)}…` : v);
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(v))
|
|
72
|
+
return v.slice(0, 50).map(walk);
|
|
73
|
+
if (!v || typeof v !== "object")
|
|
74
|
+
return v;
|
|
75
|
+
if (seen.has(v))
|
|
76
|
+
return "[Circular]";
|
|
77
|
+
seen.add(v);
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const [key, child] of Object.entries(v).slice(0, 80)) {
|
|
80
|
+
out[key] = SECRET_KEY_RE.test(key) ? "[REDACTED]" : walk(child);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
};
|
|
84
|
+
return walk(value);
|
|
85
|
+
}
|
|
86
|
+
function redactSecretString(value) {
|
|
87
|
+
return value
|
|
88
|
+
.replace(/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]")
|
|
89
|
+
.replace(/\b(token=)[^\s"']+/gi, "$1[REDACTED]")
|
|
90
|
+
.replace(/\b(drt_|dit_|gho_)[A-Za-z0-9_-]+/g, "$1[REDACTED]");
|
|
91
|
+
}
|
|
36
92
|
/**
|
|
37
93
|
* Reason carried on `AbortController.abort()` when a cancel-previous wave
|
|
38
94
|
* is taking over the slot. Distinguishing this from a timeout abort lets
|
|
@@ -701,15 +757,23 @@ export class Dispatcher {
|
|
|
701
757
|
(streamable || !isBotCordChannel(channel));
|
|
702
758
|
const canStream = streamable && typeof traceId === "string" && typeof channel.streamBlock === "function";
|
|
703
759
|
const recordBlock = (block) => {
|
|
704
|
-
const summary =
|
|
705
|
-
const raw = block.raw;
|
|
706
|
-
if (raw && typeof raw === "object") {
|
|
707
|
-
if (typeof raw.text === "string")
|
|
708
|
-
summary.chars = raw.text.length;
|
|
709
|
-
if (typeof raw.name === "string")
|
|
710
|
-
summary.name = raw.name;
|
|
711
|
-
}
|
|
760
|
+
const summary = summarizeStreamBlock(block);
|
|
712
761
|
slot.blocks.push(summary);
|
|
762
|
+
if (this.transcript.enabled) {
|
|
763
|
+
this.transcript.write({
|
|
764
|
+
ts: new Date().toISOString(),
|
|
765
|
+
kind: "block",
|
|
766
|
+
turnId,
|
|
767
|
+
agentId: msg.accountId,
|
|
768
|
+
roomId: msg.conversation.id,
|
|
769
|
+
topicId: msg.conversation.threadId ?? null,
|
|
770
|
+
runtime: route.runtime,
|
|
771
|
+
seq: block.seq,
|
|
772
|
+
blockType: block.kind,
|
|
773
|
+
summary,
|
|
774
|
+
...(transcriptBlocksVerbose() ? { raw: redactAndCap(block.raw) } : {}),
|
|
775
|
+
});
|
|
776
|
+
}
|
|
713
777
|
};
|
|
714
778
|
// Owner-chat lifecycle state for typing/thinking. The dispatcher is the
|
|
715
779
|
// only component that sees turn boundaries + channel capabilities + trace
|
|
@@ -877,7 +941,7 @@ export class Dispatcher {
|
|
|
877
941
|
sendThinkingMarker(event.phase, event.label, "runtime");
|
|
878
942
|
}
|
|
879
943
|
: undefined;
|
|
880
|
-
const onBlock = canStream
|
|
944
|
+
const onBlock = (canStream || this.transcript.enabled)
|
|
881
945
|
? (block) => {
|
|
882
946
|
// Always record adapter-emitted blocks for transcript fidelity, even
|
|
883
947
|
// after abort — the transcript reflects what the runtime emitted,
|
|
@@ -885,6 +949,8 @@ export class Dispatcher {
|
|
|
885
949
|
recordBlock(block);
|
|
886
950
|
if (controller.signal.aborted)
|
|
887
951
|
return;
|
|
952
|
+
if (!canStream)
|
|
953
|
+
return;
|
|
888
954
|
// Synthesize thinking.started before non-assistant blocks. After
|
|
889
955
|
// we've seen any assistant_text, only `tool_use` may re-enter
|
|
890
956
|
// thinking — terminal markers like `system`/`other` (codex
|
|
@@ -904,7 +970,7 @@ export class Dispatcher {
|
|
|
904
970
|
thinkingActive = false;
|
|
905
971
|
sawAssistantText = true;
|
|
906
972
|
}
|
|
907
|
-
forwardBlockToChannel(block);
|
|
973
|
+
forwardBlockToChannel?.(block);
|
|
908
974
|
}
|
|
909
975
|
: undefined;
|
|
910
976
|
// Helper used by terminal paths (success / timeout / error) to ensure
|
|
@@ -58,7 +58,8 @@ export interface GatewayBootOptions {
|
|
|
58
58
|
* Tri-state convenience: if `transcript` is not provided, the gateway
|
|
59
59
|
* constructs a writer using this flag plus `transcriptRootDir`. Use
|
|
60
60
|
* {@link resolveTranscriptEnabled} to combine `BOTCORD_TRANSCRIPT` env with
|
|
61
|
-
* the persistent daemon-config flag.
|
|
61
|
+
* the persistent daemon-config flag. When omitted, transcripts are enabled
|
|
62
|
+
* by default.
|
|
62
63
|
*/
|
|
63
64
|
transcriptEnabled?: boolean;
|
|
64
65
|
/** Root directory for transcript files. Defaults to `~/.botcord/agents`. */
|
package/dist/gateway/gateway.js
CHANGED
|
@@ -57,7 +57,7 @@ export class Gateway {
|
|
|
57
57
|
const transcript = opts.transcript
|
|
58
58
|
?? createTranscriptWriter({
|
|
59
59
|
log: this.log,
|
|
60
|
-
enabled: opts.transcriptEnabled
|
|
60
|
+
enabled: opts.transcriptEnabled,
|
|
61
61
|
rootDir: opts.transcriptRootDir,
|
|
62
62
|
});
|
|
63
63
|
this.dispatcher = new Dispatcher({
|
|
@@ -109,6 +109,12 @@ export class OpenclawAcpAdapter {
|
|
|
109
109
|
// synthetic test calls).
|
|
110
110
|
conversationKey: stringField(opts.context, "conversationKey") ?? "default",
|
|
111
111
|
});
|
|
112
|
+
const traceContext = {
|
|
113
|
+
turnId: stringField(opts.context, "turnId"),
|
|
114
|
+
messageId: stringField(opts.context, "messageId"),
|
|
115
|
+
roomId: stringField(opts.context, "roomId"),
|
|
116
|
+
topicId: nullableStringField(opts.context, "topicId"),
|
|
117
|
+
};
|
|
112
118
|
const key = poolKey(opts.accountId, gateway.name);
|
|
113
119
|
let handle;
|
|
114
120
|
try {
|
|
@@ -199,7 +205,17 @@ export class OpenclawAcpAdapter {
|
|
|
199
205
|
throw new Error(`newSession failed: ${err.message}`);
|
|
200
206
|
}
|
|
201
207
|
}
|
|
202
|
-
handle.subscribers.set(acpSessionId, onNotification);
|
|
208
|
+
handle.subscribers.set(acpSessionId, { notify: onNotification, traceContext });
|
|
209
|
+
handle.trace?.write({
|
|
210
|
+
stream: "turn_context",
|
|
211
|
+
...traceContext,
|
|
212
|
+
params: {
|
|
213
|
+
sessionId: acpSessionId,
|
|
214
|
+
sessionKey,
|
|
215
|
+
openclawAgent,
|
|
216
|
+
cwd: opts.cwd,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
203
219
|
if (opts.signal?.aborted) {
|
|
204
220
|
return failResult(acpSessionId, "openclaw-acp: aborted before prompt");
|
|
205
221
|
}
|
|
@@ -232,7 +248,18 @@ export class OpenclawAcpAdapter {
|
|
|
232
248
|
});
|
|
233
249
|
handle.subscribers.delete(acpSessionId);
|
|
234
250
|
acpSessionId = fresh;
|
|
235
|
-
handle.subscribers.set(acpSessionId, onNotification);
|
|
251
|
+
handle.subscribers.set(acpSessionId, { notify: onNotification, traceContext });
|
|
252
|
+
handle.trace?.write({
|
|
253
|
+
stream: "turn_context",
|
|
254
|
+
...traceContext,
|
|
255
|
+
params: {
|
|
256
|
+
sessionId: acpSessionId,
|
|
257
|
+
sessionKey,
|
|
258
|
+
openclawAgent,
|
|
259
|
+
cwd: opts.cwd,
|
|
260
|
+
recreatedFrom: oldSessionId,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
236
263
|
log.info("openclaw-acp.session-recreated", {
|
|
237
264
|
accountId: opts.accountId,
|
|
238
265
|
oldSessionId,
|
|
@@ -525,19 +552,20 @@ function routeMessage(handle, msg) {
|
|
|
525
552
|
}
|
|
526
553
|
// Notification.
|
|
527
554
|
if (msg?.method && msg?.params) {
|
|
555
|
+
const sid = msg.params?.sessionId;
|
|
556
|
+
const sub = typeof sid === "string" ? handle.subscribers.get(sid) : undefined;
|
|
528
557
|
handle.trace?.write({
|
|
529
558
|
stream: "rpc_in",
|
|
530
559
|
direction: "in",
|
|
531
560
|
method: msg.method,
|
|
532
561
|
status: "notification",
|
|
562
|
+
...(sub?.traceContext ?? {}),
|
|
533
563
|
params: msg.params,
|
|
534
564
|
});
|
|
535
|
-
const sid = msg.params?.sessionId;
|
|
536
565
|
if (typeof sid === "string") {
|
|
537
|
-
const sub = handle.subscribers.get(sid);
|
|
538
566
|
if (sub) {
|
|
539
567
|
try {
|
|
540
|
-
sub({ method: msg.method, params: msg.params });
|
|
568
|
+
sub.notify({ method: msg.method, params: msg.params });
|
|
541
569
|
}
|
|
542
570
|
catch (err) {
|
|
543
571
|
log.warn("openclaw-acp.subscriber-threw", {
|
|
@@ -913,6 +941,14 @@ function stringField(bag, key) {
|
|
|
913
941
|
const v = bag[key];
|
|
914
942
|
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
915
943
|
}
|
|
944
|
+
function nullableStringField(bag, key) {
|
|
945
|
+
if (!bag)
|
|
946
|
+
return undefined;
|
|
947
|
+
const v = bag[key];
|
|
948
|
+
if (v === null)
|
|
949
|
+
return null;
|
|
950
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
951
|
+
}
|
|
916
952
|
/**
|
|
917
953
|
* Build the OpenClaw ACP `sessionKey` for a daemon turn. `accountId` is
|
|
918
954
|
* always included to prevent two daemon agents from colliding on the same
|
|
@@ -6,9 +6,13 @@ import type { GatewayLogger } from "./log.js";
|
|
|
6
6
|
export declare const TRANSCRIPT_TEXT_LIMIT: number;
|
|
7
7
|
/** Soft cap on a single transcript file before rotation. */
|
|
8
8
|
export declare const TRANSCRIPT_FILE_LIMIT: number;
|
|
9
|
+
/** Default retention window for transcript JSONL files. */
|
|
10
|
+
export declare const TRANSCRIPT_RETENTION_MS: number;
|
|
11
|
+
/** Minimum interval between background transcript retention sweeps. */
|
|
12
|
+
export declare const TRANSCRIPT_CLEANUP_INTERVAL_MS: number;
|
|
9
13
|
/** Default root directory for per-agent transcript trees. */
|
|
10
14
|
export declare function defaultTranscriptRoot(): string;
|
|
11
|
-
export type TranscriptRecordKind = "inbound" | "dispatched" | "compose_failed" | "outbound" | "turn_error" | "attention_skipped" | "dropped";
|
|
15
|
+
export type TranscriptRecordKind = "inbound" | "dispatched" | "block" | "compose_failed" | "outbound" | "turn_error" | "attention_skipped" | "dropped";
|
|
12
16
|
export interface TranscriptRecordBase {
|
|
13
17
|
ts: string;
|
|
14
18
|
kind: TranscriptRecordKind;
|
|
@@ -45,6 +49,14 @@ export interface DispatchedTranscriptRecord extends TranscriptRecordBase {
|
|
|
45
49
|
composedText?: true;
|
|
46
50
|
};
|
|
47
51
|
}
|
|
52
|
+
export interface BlockTranscriptRecord extends TranscriptRecordBase {
|
|
53
|
+
kind: "block";
|
|
54
|
+
runtime: string;
|
|
55
|
+
seq: number;
|
|
56
|
+
blockType: string;
|
|
57
|
+
summary: TranscriptBlockSummary;
|
|
58
|
+
raw?: unknown;
|
|
59
|
+
}
|
|
48
60
|
export interface ComposeFailedTranscriptRecord extends TranscriptRecordBase {
|
|
49
61
|
kind: "compose_failed";
|
|
50
62
|
error: string;
|
|
@@ -86,7 +98,7 @@ export interface DroppedTranscriptRecord extends TranscriptRecordBase {
|
|
|
86
98
|
reason: DroppedReason;
|
|
87
99
|
supersededBy?: string | null;
|
|
88
100
|
}
|
|
89
|
-
export type TranscriptRecord = InboundTranscriptRecord | DispatchedTranscriptRecord | ComposeFailedTranscriptRecord | OutboundTranscriptRecord | TurnErrorTranscriptRecord | AttentionSkippedTranscriptRecord | DroppedTranscriptRecord;
|
|
101
|
+
export type TranscriptRecord = InboundTranscriptRecord | DispatchedTranscriptRecord | BlockTranscriptRecord | ComposeFailedTranscriptRecord | OutboundTranscriptRecord | TurnErrorTranscriptRecord | AttentionSkippedTranscriptRecord | DroppedTranscriptRecord;
|
|
90
102
|
/**
|
|
91
103
|
* Truncate `value` to TRANSCRIPT_TEXT_LIMIT chars. Returns the (possibly
|
|
92
104
|
* truncated) text and whether truncation occurred. Surrogate-pair aware: if
|
|
@@ -108,10 +120,14 @@ export interface CreateTranscriptWriterOptions {
|
|
|
108
120
|
/** Defaults to `~/.botcord/agents`. */
|
|
109
121
|
rootDir?: string;
|
|
110
122
|
log: GatewayLogger;
|
|
111
|
-
/** Defaults to `false`
|
|
123
|
+
/** Defaults to `true`; pass `false` to disable persistence. */
|
|
112
124
|
enabled?: boolean;
|
|
113
125
|
/** Override file rotation threshold (bytes). Defaults to TRANSCRIPT_FILE_LIMIT. */
|
|
114
126
|
maxFileBytes?: number;
|
|
127
|
+
/** Delete transcript JSONL files older than this. Defaults to 3 days. */
|
|
128
|
+
retentionMs?: number;
|
|
129
|
+
/** Minimum interval between retention sweeps. Defaults to 6 hours. */
|
|
130
|
+
cleanupIntervalMs?: number;
|
|
115
131
|
}
|
|
116
132
|
export declare function createTranscriptWriter(opts: CreateTranscriptWriterOptions): TranscriptWriter;
|
|
117
133
|
/**
|
|
@@ -120,4 +136,5 @@ export declare function createTranscriptWriter(opts: CreateTranscriptWriterOptio
|
|
|
120
136
|
* - env === "0" → false (force off)
|
|
121
137
|
* - any other / unset → fall back to `configEnabled`
|
|
122
138
|
*/
|
|
123
|
-
export declare function resolveTranscriptEnabled(envVal: string | undefined, configEnabled: boolean): boolean;
|
|
139
|
+
export declare function resolveTranscriptEnabled(envVal: string | undefined, configEnabled: boolean | undefined): boolean;
|
|
140
|
+
export declare function cleanupTranscriptFiles(rootDir: string, cutoffMs: number): number;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { transcriptFilePath } from "./transcript-paths.js";
|
|
@@ -9,6 +9,10 @@ import { transcriptFilePath } from "./transcript-paths.js";
|
|
|
9
9
|
export const TRANSCRIPT_TEXT_LIMIT = 32 * 1024;
|
|
10
10
|
/** Soft cap on a single transcript file before rotation. */
|
|
11
11
|
export const TRANSCRIPT_FILE_LIMIT = 8 * 1024 * 1024;
|
|
12
|
+
/** Default retention window for transcript JSONL files. */
|
|
13
|
+
export const TRANSCRIPT_RETENTION_MS = 3 * 24 * 60 * 60 * 1000;
|
|
14
|
+
/** Minimum interval between background transcript retention sweeps. */
|
|
15
|
+
export const TRANSCRIPT_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
12
16
|
/** Default root directory for per-agent transcript trees. */
|
|
13
17
|
export function defaultTranscriptRoot() {
|
|
14
18
|
return path.join(homedir(), ".botcord", "agents");
|
|
@@ -45,15 +49,21 @@ class FsTranscriptWriter {
|
|
|
45
49
|
rootDir;
|
|
46
50
|
log;
|
|
47
51
|
maxFileBytes;
|
|
52
|
+
retentionMs;
|
|
53
|
+
cleanupIntervalMs;
|
|
48
54
|
fileMeta = new Map();
|
|
49
55
|
firstWriteAnnounced = false;
|
|
50
|
-
|
|
56
|
+
lastCleanupAt = 0;
|
|
57
|
+
constructor(rootDir, log, maxFileBytes, retentionMs, cleanupIntervalMs) {
|
|
51
58
|
this.rootDir = rootDir;
|
|
52
59
|
this.log = log;
|
|
53
60
|
this.maxFileBytes = maxFileBytes;
|
|
61
|
+
this.retentionMs = retentionMs;
|
|
62
|
+
this.cleanupIntervalMs = cleanupIntervalMs;
|
|
54
63
|
}
|
|
55
64
|
write(rec) {
|
|
56
65
|
try {
|
|
66
|
+
this.cleanupOldFiles();
|
|
57
67
|
const file = transcriptFilePath(this.rootDir, rec.agentId, rec.roomId, rec.topicId);
|
|
58
68
|
const dir = path.dirname(file);
|
|
59
69
|
try {
|
|
@@ -118,14 +128,29 @@ class FsTranscriptWriter {
|
|
|
118
128
|
});
|
|
119
129
|
}
|
|
120
130
|
}
|
|
131
|
+
cleanupOldFiles() {
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
if (now - this.lastCleanupAt < this.cleanupIntervalMs)
|
|
134
|
+
return;
|
|
135
|
+
this.lastCleanupAt = now;
|
|
136
|
+
const cutoff = now - this.retentionMs;
|
|
137
|
+
const removed = cleanupTranscriptFiles(this.rootDir, cutoff);
|
|
138
|
+
if (removed > 0) {
|
|
139
|
+
this.log.info("transcript cleanup removed old files", {
|
|
140
|
+
dir: this.rootDir,
|
|
141
|
+
removed,
|
|
142
|
+
retentionMs: this.retentionMs,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
121
146
|
}
|
|
122
147
|
export function createTranscriptWriter(opts) {
|
|
123
148
|
const rootDir = opts.rootDir ?? defaultTranscriptRoot();
|
|
124
|
-
const enabled = opts.enabled ??
|
|
149
|
+
const enabled = opts.enabled ?? true;
|
|
125
150
|
if (!enabled)
|
|
126
151
|
return new NoopTranscriptWriter(rootDir);
|
|
127
152
|
const maxBytes = opts.maxFileBytes ?? TRANSCRIPT_FILE_LIMIT;
|
|
128
|
-
return new FsTranscriptWriter(rootDir, opts.log, maxBytes);
|
|
153
|
+
return new FsTranscriptWriter(rootDir, opts.log, maxBytes, opts.retentionMs ?? TRANSCRIPT_RETENTION_MS, opts.cleanupIntervalMs ?? TRANSCRIPT_CLEANUP_INTERVAL_MS);
|
|
129
154
|
}
|
|
130
155
|
/**
|
|
131
156
|
* Resolve the tri-state enable flag (env wins; otherwise config). See design §5.
|
|
@@ -138,7 +163,43 @@ export function resolveTranscriptEnabled(envVal, configEnabled) {
|
|
|
138
163
|
return true;
|
|
139
164
|
if (envVal === "0")
|
|
140
165
|
return false;
|
|
141
|
-
return configEnabled;
|
|
166
|
+
return configEnabled ?? true;
|
|
167
|
+
}
|
|
168
|
+
export function cleanupTranscriptFiles(rootDir, cutoffMs) {
|
|
169
|
+
let removed = 0;
|
|
170
|
+
const visit = (dir, depth) => {
|
|
171
|
+
if (depth < 0)
|
|
172
|
+
return;
|
|
173
|
+
let entries;
|
|
174
|
+
try {
|
|
175
|
+
entries = readdirSync(dir);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
const file = path.join(dir, entry);
|
|
182
|
+
try {
|
|
183
|
+
const st = statSync(file);
|
|
184
|
+
if (st.isDirectory()) {
|
|
185
|
+
visit(file, depth - 1);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (st.isFile() &&
|
|
189
|
+
entry.endsWith(".jsonl") &&
|
|
190
|
+
file.includes(`${path.sep}transcripts${path.sep}`) &&
|
|
191
|
+
st.mtimeMs < cutoffMs) {
|
|
192
|
+
rmSync(file, { force: true });
|
|
193
|
+
removed += 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// ignore disappearing files and permission errors
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
visit(rootDir, 6);
|
|
202
|
+
return removed;
|
|
142
203
|
}
|
|
143
204
|
function formatStamp(d) {
|
|
144
205
|
const pad = (n) => n.toString().padStart(2, "0");
|
package/dist/index.js
CHANGED
|
@@ -770,16 +770,18 @@ function cmdTranscriptStatus() {
|
|
|
770
770
|
if (e.code !== CONFIG_MISSING)
|
|
771
771
|
throw err;
|
|
772
772
|
}
|
|
773
|
-
const configEnabled = cfg?.transcript?.enabled
|
|
773
|
+
const configEnabled = cfg?.transcript?.enabled;
|
|
774
774
|
const env = process.env.BOTCORD_TRANSCRIPT;
|
|
775
775
|
const effective = resolveTranscriptEnabled(env, configEnabled);
|
|
776
776
|
let source;
|
|
777
777
|
if (env === "1" || env === "0")
|
|
778
778
|
source = `env BOTCORD_TRANSCRIPT=${env}`;
|
|
779
|
-
else if (configEnabled)
|
|
779
|
+
else if (configEnabled === true)
|
|
780
780
|
source = "config (transcript.enabled=true)";
|
|
781
|
+
else if (configEnabled === false)
|
|
782
|
+
source = "config (transcript.enabled=false)";
|
|
781
783
|
else
|
|
782
|
-
source = "default-
|
|
784
|
+
source = "default-on";
|
|
783
785
|
console.log(`enabled: ${effective}`);
|
|
784
786
|
console.log(`source: ${source}`);
|
|
785
787
|
console.log(`root: ${defaultTranscriptRoot()}`);
|
|
@@ -826,9 +828,9 @@ function cmdTranscriptTail(args) {
|
|
|
826
828
|
catch {
|
|
827
829
|
// ignore — config may simply not exist yet
|
|
828
830
|
}
|
|
829
|
-
const enabled = resolveTranscriptEnabled(process.env.BOTCORD_TRANSCRIPT, cfg?.transcript?.enabled
|
|
831
|
+
const enabled = resolveTranscriptEnabled(process.env.BOTCORD_TRANSCRIPT, cfg?.transcript?.enabled);
|
|
830
832
|
if (!enabled) {
|
|
831
|
-
console.error("hint: transcripts are disabled
|
|
833
|
+
console.error("hint: transcripts are disabled. Run `botcord-daemon transcript enable` and restart the daemon, then send a new message.");
|
|
832
834
|
}
|
|
833
835
|
process.exit(1);
|
|
834
836
|
}
|
package/package.json
CHANGED
|
@@ -322,6 +322,43 @@ describe("ControlChannel — REVOKE frame (plan §6.3)", () => {
|
|
|
322
322
|
});
|
|
323
323
|
});
|
|
324
324
|
|
|
325
|
+
describe("ControlChannel — reconnect scheduling", () => {
|
|
326
|
+
beforeEach(() => {
|
|
327
|
+
FakeWebSocket.instances.length = 0;
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("adds jitter and coalesces duplicate close events into one reconnect", async () => {
|
|
331
|
+
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(1);
|
|
332
|
+
const auth = new UserAuthManager({
|
|
333
|
+
record: makeAuthRecord(),
|
|
334
|
+
file: "/tmp/never-written-user-auth.json",
|
|
335
|
+
});
|
|
336
|
+
const ctor = makeFakeCtor();
|
|
337
|
+
const ch = new ControlChannel({
|
|
338
|
+
auth,
|
|
339
|
+
handle: () => ({ ok: true }),
|
|
340
|
+
webSocketCtor: ctor as unknown as typeof import("ws").default,
|
|
341
|
+
hubPublicKey: null,
|
|
342
|
+
backoffMs: [25],
|
|
343
|
+
});
|
|
344
|
+
await ch.start();
|
|
345
|
+
const ws = FakeWebSocket.instances[0];
|
|
346
|
+
|
|
347
|
+
ws.emit("close", 1012, Buffer.from(""));
|
|
348
|
+
ws.emit("close", 1012, Buffer.from(""));
|
|
349
|
+
|
|
350
|
+
// Base delay is 25ms; with random=1 and 25% jitter the actual delay is
|
|
351
|
+
// 31ms. Duplicate close events should still leave only one timer queued.
|
|
352
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
353
|
+
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
354
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
355
|
+
expect(FakeWebSocket.instances).toHaveLength(2);
|
|
356
|
+
|
|
357
|
+
randomSpy.mockRestore();
|
|
358
|
+
await ch.stop();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
325
362
|
afterEach(() => {
|
|
326
363
|
FakeWebSocket.instances.length = 0;
|
|
327
364
|
});
|
|
@@ -11,16 +11,22 @@ describe("diagnostics bundle", () => {
|
|
|
11
11
|
const logFile = path.join(tmp, "daemon.log");
|
|
12
12
|
const configFile = path.join(tmp, "config.json");
|
|
13
13
|
const snapshotFile = path.join(tmp, "snapshot.json");
|
|
14
|
+
const sessionsFile = path.join(tmp, "sessions.json");
|
|
14
15
|
const diagnosticsDir = path.join(tmp, "diagnostics");
|
|
15
16
|
writeFileSync(logFile, 'Authorization: Bearer secret-token\n{"refreshToken":"drt_secret"}\n');
|
|
16
17
|
writeFileSync(configFile, '{"token":"agent-secret","ok":true}\n');
|
|
17
18
|
writeFileSync(snapshotFile, '{"version":1}\n');
|
|
19
|
+
writeFileSync(
|
|
20
|
+
sessionsFile,
|
|
21
|
+
'{"version":1,"entries":{"k":{"runtimeSessionId":"sess_1","token":"session-secret"}}}\n',
|
|
22
|
+
);
|
|
18
23
|
|
|
19
24
|
const bundle = await createDiagnosticBundle({
|
|
20
25
|
diagnosticsDir,
|
|
21
26
|
logFile,
|
|
22
27
|
configFile,
|
|
23
28
|
snapshotFile,
|
|
29
|
+
sessionsFile,
|
|
24
30
|
doctor: { text: "doctor ok", json: { ok: true } },
|
|
25
31
|
});
|
|
26
32
|
expect(bundle.filename).toMatch(/^botcord-daemon-diagnostics-.*\.zip$/);
|
|
@@ -42,12 +48,29 @@ describe("diagnostics bundle", () => {
|
|
|
42
48
|
expect(listing).toContain("doctor.json");
|
|
43
49
|
expect(listing).toContain("status.json");
|
|
44
50
|
expect(listing).toContain("config.json.redacted");
|
|
51
|
+
expect(listing).toContain("sessions.json.redacted");
|
|
45
52
|
|
|
46
53
|
const log = execFileSync("unzip", ["-p", bundle.path, "daemon.log"], {
|
|
47
54
|
encoding: "utf8",
|
|
48
55
|
});
|
|
49
56
|
expect(log).toContain("Authorization: Bearer [REDACTED]");
|
|
50
57
|
expect(log).toContain('"refreshToken":"[REDACTED]"');
|
|
58
|
+
|
|
59
|
+
const status = JSON.parse(execFileSync("unzip", ["-p", bundle.path, "status.json"], {
|
|
60
|
+
encoding: "utf8",
|
|
61
|
+
}));
|
|
62
|
+
expect(status.daemon.packageName).toBe("@botcord/daemon");
|
|
63
|
+
expect(status.daemon.version).toMatch(/^\d+\.\d+\.\d+/);
|
|
64
|
+
expect(status.daemon.entrypoint).toBeTruthy();
|
|
65
|
+
expect(status.daemon.packages["@botcord/daemon"]).toBe(status.daemon.version);
|
|
66
|
+
expect(status.environment.PATH).toBeTruthy();
|
|
67
|
+
expect(status.sessionsPath).toBe(sessionsFile);
|
|
68
|
+
|
|
69
|
+
const sessions = execFileSync("unzip", ["-p", bundle.path, "sessions.json.redacted"], {
|
|
70
|
+
encoding: "utf8",
|
|
71
|
+
});
|
|
72
|
+
expect(sessions).toContain('"runtimeSessionId":"sess_1"');
|
|
73
|
+
expect(sessions).toContain('"token":"[REDACTED]"');
|
|
51
74
|
}, 20_000);
|
|
52
75
|
|
|
53
76
|
it("bundles active log plus latest 5 rotated logs by default, or all with includeAllLogs", async () => {
|
package/src/acp-logs.ts
CHANGED
|
@@ -20,6 +20,7 @@ export type AcpTraceStream =
|
|
|
20
20
|
| "child_error"
|
|
21
21
|
| "stderr"
|
|
22
22
|
| "stdout_non_json"
|
|
23
|
+
| "turn_context"
|
|
23
24
|
| "rpc_in"
|
|
24
25
|
| "rpc_out";
|
|
25
26
|
|
|
@@ -37,6 +38,10 @@ export interface AcpTraceMeta {
|
|
|
37
38
|
|
|
38
39
|
export interface AcpTraceEvent {
|
|
39
40
|
stream: AcpTraceStream;
|
|
41
|
+
turnId?: string;
|
|
42
|
+
messageId?: string;
|
|
43
|
+
roomId?: string;
|
|
44
|
+
topicId?: string | null;
|
|
40
45
|
direction?: "in" | "out";
|
|
41
46
|
pid?: number;
|
|
42
47
|
id?: number | string;
|
|
@@ -132,9 +137,10 @@ function writeAcpTrace(
|
|
|
132
137
|
ts: new Date().toISOString(),
|
|
133
138
|
runtime: meta.runtime,
|
|
134
139
|
accountId: meta.accountId,
|
|
135
|
-
turnId: meta.turnId,
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
turnId: event.turnId ?? meta.turnId,
|
|
141
|
+
messageId: event.messageId,
|
|
142
|
+
roomId: event.roomId ?? meta.roomId,
|
|
143
|
+
topicId: event.topicId ?? meta.topicId ?? undefined,
|
|
138
144
|
gatewayName: meta.gatewayName,
|
|
139
145
|
gatewayUrl: meta.gatewayUrl,
|
|
140
146
|
hermesProfile: meta.hermesProfile,
|
package/src/config.ts
CHANGED
|
@@ -159,7 +159,7 @@ export interface DaemonConfig {
|
|
|
159
159
|
streamBlocks: boolean;
|
|
160
160
|
/**
|
|
161
161
|
* Persistent transcript-logging settings (design §3 / §6). Defaults to
|
|
162
|
-
*
|
|
162
|
+
* enabled — see `BOTCORD_TRANSCRIPT` for env-driven temporary overrides.
|
|
163
163
|
*/
|
|
164
164
|
transcript?: TranscriptConfig;
|
|
165
165
|
|
|
@@ -186,8 +186,8 @@ export interface DaemonConfig {
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
/**
|
|
189
|
-
* Persistent transcript settings (design §6). Default-
|
|
190
|
-
* transcript
|
|
189
|
+
* Persistent transcript settings (design §6). Default-on — `botcord-daemon
|
|
190
|
+
* transcript disable` sets `enabled=false`, and `transcript enable` flips it back.
|
|
191
191
|
* The env var `BOTCORD_TRANSCRIPT` can override at boot.
|
|
192
192
|
*/
|
|
193
193
|
export interface TranscriptConfig {
|