@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
|
@@ -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
|
|
@@ -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
|
|
|
@@ -14,6 +14,12 @@ export const TRANSCRIPT_TEXT_LIMIT = 32 * 1024;
|
|
|
14
14
|
/** Soft cap on a single transcript file before rotation. */
|
|
15
15
|
export const TRANSCRIPT_FILE_LIMIT = 8 * 1024 * 1024;
|
|
16
16
|
|
|
17
|
+
/** Default retention window for transcript JSONL files. */
|
|
18
|
+
export const TRANSCRIPT_RETENTION_MS = 3 * 24 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
/** Minimum interval between background transcript retention sweeps. */
|
|
21
|
+
export const TRANSCRIPT_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
22
|
+
|
|
17
23
|
/** Default root directory for per-agent transcript trees. */
|
|
18
24
|
export function defaultTranscriptRoot(): string {
|
|
19
25
|
return path.join(homedir(), ".botcord", "agents");
|
|
@@ -26,6 +32,7 @@ export function defaultTranscriptRoot(): string {
|
|
|
26
32
|
export type TranscriptRecordKind =
|
|
27
33
|
| "inbound"
|
|
28
34
|
| "dispatched"
|
|
35
|
+
| "block"
|
|
29
36
|
| "compose_failed"
|
|
30
37
|
| "outbound"
|
|
31
38
|
| "turn_error"
|
|
@@ -65,6 +72,15 @@ export interface DispatchedTranscriptRecord extends TranscriptRecordBase {
|
|
|
65
72
|
truncated?: { composedText?: true };
|
|
66
73
|
}
|
|
67
74
|
|
|
75
|
+
export interface BlockTranscriptRecord extends TranscriptRecordBase {
|
|
76
|
+
kind: "block";
|
|
77
|
+
runtime: string;
|
|
78
|
+
seq: number;
|
|
79
|
+
blockType: string;
|
|
80
|
+
summary: TranscriptBlockSummary;
|
|
81
|
+
raw?: unknown;
|
|
82
|
+
}
|
|
83
|
+
|
|
68
84
|
export interface ComposeFailedTranscriptRecord extends TranscriptRecordBase {
|
|
69
85
|
kind: "compose_failed";
|
|
70
86
|
error: string;
|
|
@@ -122,6 +138,7 @@ export interface DroppedTranscriptRecord extends TranscriptRecordBase {
|
|
|
122
138
|
export type TranscriptRecord =
|
|
123
139
|
| InboundTranscriptRecord
|
|
124
140
|
| DispatchedTranscriptRecord
|
|
141
|
+
| BlockTranscriptRecord
|
|
125
142
|
| ComposeFailedTranscriptRecord
|
|
126
143
|
| OutboundTranscriptRecord
|
|
127
144
|
| TurnErrorTranscriptRecord
|
|
@@ -162,10 +179,14 @@ export interface CreateTranscriptWriterOptions {
|
|
|
162
179
|
/** Defaults to `~/.botcord/agents`. */
|
|
163
180
|
rootDir?: string;
|
|
164
181
|
log: GatewayLogger;
|
|
165
|
-
/** Defaults to `false`
|
|
182
|
+
/** Defaults to `true`; pass `false` to disable persistence. */
|
|
166
183
|
enabled?: boolean;
|
|
167
184
|
/** Override file rotation threshold (bytes). Defaults to TRANSCRIPT_FILE_LIMIT. */
|
|
168
185
|
maxFileBytes?: number;
|
|
186
|
+
/** Delete transcript JSONL files older than this. Defaults to 3 days. */
|
|
187
|
+
retentionMs?: number;
|
|
188
|
+
/** Minimum interval between retention sweeps. Defaults to 6 hours. */
|
|
189
|
+
cleanupIntervalMs?: number;
|
|
169
190
|
}
|
|
170
191
|
|
|
171
192
|
interface FileMeta {
|
|
@@ -188,17 +209,29 @@ class FsTranscriptWriter implements TranscriptWriter {
|
|
|
188
209
|
readonly rootDir: string;
|
|
189
210
|
private readonly log: GatewayLogger;
|
|
190
211
|
private readonly maxFileBytes: number;
|
|
212
|
+
private readonly retentionMs: number;
|
|
213
|
+
private readonly cleanupIntervalMs: number;
|
|
191
214
|
private readonly fileMeta = new Map<string, FileMeta>();
|
|
192
215
|
private firstWriteAnnounced = false;
|
|
193
|
-
|
|
194
|
-
|
|
216
|
+
private lastCleanupAt = 0;
|
|
217
|
+
|
|
218
|
+
constructor(
|
|
219
|
+
rootDir: string,
|
|
220
|
+
log: GatewayLogger,
|
|
221
|
+
maxFileBytes: number,
|
|
222
|
+
retentionMs: number,
|
|
223
|
+
cleanupIntervalMs: number,
|
|
224
|
+
) {
|
|
195
225
|
this.rootDir = rootDir;
|
|
196
226
|
this.log = log;
|
|
197
227
|
this.maxFileBytes = maxFileBytes;
|
|
228
|
+
this.retentionMs = retentionMs;
|
|
229
|
+
this.cleanupIntervalMs = cleanupIntervalMs;
|
|
198
230
|
}
|
|
199
231
|
|
|
200
232
|
write(rec: TranscriptRecord): void {
|
|
201
233
|
try {
|
|
234
|
+
this.cleanupOldFiles();
|
|
202
235
|
const file = transcriptFilePath(this.rootDir, rec.agentId, rec.roomId, rec.topicId);
|
|
203
236
|
const dir = path.dirname(file);
|
|
204
237
|
try {
|
|
@@ -264,16 +297,37 @@ class FsTranscriptWriter implements TranscriptWriter {
|
|
|
264
297
|
});
|
|
265
298
|
}
|
|
266
299
|
}
|
|
300
|
+
|
|
301
|
+
private cleanupOldFiles(): void {
|
|
302
|
+
const now = Date.now();
|
|
303
|
+
if (now - this.lastCleanupAt < this.cleanupIntervalMs) return;
|
|
304
|
+
this.lastCleanupAt = now;
|
|
305
|
+
const cutoff = now - this.retentionMs;
|
|
306
|
+
const removed = cleanupTranscriptFiles(this.rootDir, cutoff);
|
|
307
|
+
if (removed > 0) {
|
|
308
|
+
this.log.info("transcript cleanup removed old files", {
|
|
309
|
+
dir: this.rootDir,
|
|
310
|
+
removed,
|
|
311
|
+
retentionMs: this.retentionMs,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
267
315
|
}
|
|
268
316
|
|
|
269
317
|
export function createTranscriptWriter(
|
|
270
318
|
opts: CreateTranscriptWriterOptions,
|
|
271
319
|
): TranscriptWriter {
|
|
272
320
|
const rootDir = opts.rootDir ?? defaultTranscriptRoot();
|
|
273
|
-
const enabled = opts.enabled ??
|
|
321
|
+
const enabled = opts.enabled ?? true;
|
|
274
322
|
if (!enabled) return new NoopTranscriptWriter(rootDir);
|
|
275
323
|
const maxBytes = opts.maxFileBytes ?? TRANSCRIPT_FILE_LIMIT;
|
|
276
|
-
return new FsTranscriptWriter(
|
|
324
|
+
return new FsTranscriptWriter(
|
|
325
|
+
rootDir,
|
|
326
|
+
opts.log,
|
|
327
|
+
maxBytes,
|
|
328
|
+
opts.retentionMs ?? TRANSCRIPT_RETENTION_MS,
|
|
329
|
+
opts.cleanupIntervalMs ?? TRANSCRIPT_CLEANUP_INTERVAL_MS,
|
|
330
|
+
);
|
|
277
331
|
}
|
|
278
332
|
|
|
279
333
|
/**
|
|
@@ -284,11 +338,47 @@ export function createTranscriptWriter(
|
|
|
284
338
|
*/
|
|
285
339
|
export function resolveTranscriptEnabled(
|
|
286
340
|
envVal: string | undefined,
|
|
287
|
-
configEnabled: boolean,
|
|
341
|
+
configEnabled: boolean | undefined,
|
|
288
342
|
): boolean {
|
|
289
343
|
if (envVal === "1") return true;
|
|
290
344
|
if (envVal === "0") return false;
|
|
291
|
-
return configEnabled;
|
|
345
|
+
return configEnabled ?? true;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function cleanupTranscriptFiles(rootDir: string, cutoffMs: number): number {
|
|
349
|
+
let removed = 0;
|
|
350
|
+
const visit = (dir: string, depth: number): void => {
|
|
351
|
+
if (depth < 0) return;
|
|
352
|
+
let entries: string[];
|
|
353
|
+
try {
|
|
354
|
+
entries = readdirSync(dir);
|
|
355
|
+
} catch {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
for (const entry of entries) {
|
|
359
|
+
const file = path.join(dir, entry);
|
|
360
|
+
try {
|
|
361
|
+
const st = statSync(file);
|
|
362
|
+
if (st.isDirectory()) {
|
|
363
|
+
visit(file, depth - 1);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (
|
|
367
|
+
st.isFile() &&
|
|
368
|
+
entry.endsWith(".jsonl") &&
|
|
369
|
+
file.includes(`${path.sep}transcripts${path.sep}`) &&
|
|
370
|
+
st.mtimeMs < cutoffMs
|
|
371
|
+
) {
|
|
372
|
+
rmSync(file, { force: true });
|
|
373
|
+
removed += 1;
|
|
374
|
+
}
|
|
375
|
+
} catch {
|
|
376
|
+
// ignore disappearing files and permission errors
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
visit(rootDir, 6);
|
|
381
|
+
return removed;
|
|
292
382
|
}
|
|
293
383
|
|
|
294
384
|
function formatStamp(d: Date): string {
|
package/src/index.ts
CHANGED
|
@@ -888,13 +888,14 @@ function cmdTranscriptStatus(): void {
|
|
|
888
888
|
const e = err as Error & { code?: string };
|
|
889
889
|
if (e.code !== CONFIG_MISSING) throw err;
|
|
890
890
|
}
|
|
891
|
-
const configEnabled = cfg?.transcript?.enabled
|
|
891
|
+
const configEnabled = cfg?.transcript?.enabled;
|
|
892
892
|
const env = process.env.BOTCORD_TRANSCRIPT;
|
|
893
893
|
const effective = resolveTranscriptEnabled(env, configEnabled);
|
|
894
894
|
let source: string;
|
|
895
895
|
if (env === "1" || env === "0") source = `env BOTCORD_TRANSCRIPT=${env}`;
|
|
896
|
-
else if (configEnabled) source = "config (transcript.enabled=true)";
|
|
897
|
-
else source = "
|
|
896
|
+
else if (configEnabled === true) source = "config (transcript.enabled=true)";
|
|
897
|
+
else if (configEnabled === false) source = "config (transcript.enabled=false)";
|
|
898
|
+
else source = "default-on";
|
|
898
899
|
console.log(`enabled: ${effective}`);
|
|
899
900
|
console.log(`source: ${source}`);
|
|
900
901
|
console.log(`root: ${defaultTranscriptRoot()}`);
|
|
@@ -942,11 +943,11 @@ function cmdTranscriptTail(args: ParsedArgs): Promise<void> | void {
|
|
|
942
943
|
}
|
|
943
944
|
const enabled = resolveTranscriptEnabled(
|
|
944
945
|
process.env.BOTCORD_TRANSCRIPT,
|
|
945
|
-
cfg?.transcript?.enabled
|
|
946
|
+
cfg?.transcript?.enabled,
|
|
946
947
|
);
|
|
947
948
|
if (!enabled) {
|
|
948
949
|
console.error(
|
|
949
|
-
"hint: transcripts are disabled
|
|
950
|
+
"hint: transcripts are disabled. Run `botcord-daemon transcript enable` and restart the daemon, then send a new message.",
|
|
950
951
|
);
|
|
951
952
|
}
|
|
952
953
|
process.exit(1);
|