@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.
@@ -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 = { type: block.kind };
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`. */
@@ -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 === true,
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` see design §6 (default-off). */
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
- constructor(rootDir, log, maxFileBytes) {
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 ?? false;
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 === true;
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-off";
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 === true);
831
+ const enabled = resolveTranscriptEnabled(process.env.BOTCORD_TRANSCRIPT, cfg?.transcript?.enabled);
830
832
  if (!enabled) {
831
- console.error("hint: transcripts are disabled (default-off). Run `botcord-daemon transcript enable` and restart the daemon, then send a new message.");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.62",
3
+ "version": "0.2.64",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- roomId: meta.roomId,
137
- topicId: meta.topicId ?? undefined,
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
- * disabled — see `BOTCORD_TRANSCRIPT` for env-driven temporary overrides.
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-off — `botcord-daemon
190
- * transcript enable` flips `enabled` and `transcript disable` flips it back.
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 {