@botcord/daemon 0.2.5 → 0.2.6

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.
Files changed (84) hide show
  1. package/dist/agent-discovery.d.ts +4 -0
  2. package/dist/agent-discovery.js +8 -0
  3. package/dist/agent-workspace.d.ts +62 -0
  4. package/dist/agent-workspace.js +140 -8
  5. package/dist/config.d.ts +49 -1
  6. package/dist/config.js +57 -1
  7. package/dist/daemon-config-map.d.ts +27 -9
  8. package/dist/daemon-config-map.js +105 -8
  9. package/dist/daemon.d.ts +2 -0
  10. package/dist/daemon.js +52 -5
  11. package/dist/doctor.d.ts +27 -1
  12. package/dist/doctor.js +22 -1
  13. package/dist/gateway/cli-resolver.d.ts +34 -0
  14. package/dist/gateway/cli-resolver.js +74 -0
  15. package/dist/gateway/dispatcher.d.ts +31 -1
  16. package/dist/gateway/dispatcher.js +337 -29
  17. package/dist/gateway/gateway.d.ts +29 -1
  18. package/dist/gateway/gateway.js +10 -0
  19. package/dist/gateway/index.d.ts +2 -0
  20. package/dist/gateway/index.js +2 -0
  21. package/dist/gateway/policy-resolver.d.ts +57 -0
  22. package/dist/gateway/policy-resolver.js +123 -0
  23. package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
  24. package/dist/gateway/runtimes/acp-stream.js +394 -0
  25. package/dist/gateway/runtimes/codex.js +7 -0
  26. package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
  27. package/dist/gateway/runtimes/hermes-agent.js +180 -0
  28. package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
  29. package/dist/gateway/runtimes/ndjson-stream.js +16 -3
  30. package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
  31. package/dist/gateway/runtimes/openclaw-acp.js +500 -0
  32. package/dist/gateway/runtimes/registry.d.ts +4 -0
  33. package/dist/gateway/runtimes/registry.js +22 -0
  34. package/dist/gateway/transcript-paths.d.ts +30 -0
  35. package/dist/gateway/transcript-paths.js +114 -0
  36. package/dist/gateway/transcript.d.ts +123 -0
  37. package/dist/gateway/transcript.js +147 -0
  38. package/dist/gateway/types.d.ts +31 -0
  39. package/dist/index.js +286 -27
  40. package/dist/mention-scan.d.ts +22 -0
  41. package/dist/mention-scan.js +35 -0
  42. package/dist/provision.d.ts +72 -1
  43. package/dist/provision.js +370 -7
  44. package/dist/system-context.d.ts +5 -4
  45. package/dist/system-context.js +35 -5
  46. package/dist/url-utils.d.ts +9 -0
  47. package/dist/url-utils.js +18 -0
  48. package/package.json +2 -1
  49. package/src/__tests__/agent-workspace.test.ts +93 -0
  50. package/src/__tests__/daemon-config-map.test.ts +79 -0
  51. package/src/__tests__/openclaw-acp.test.ts +234 -0
  52. package/src/__tests__/policy-resolver.test.ts +124 -0
  53. package/src/__tests__/policy-updated-handler.test.ts +144 -0
  54. package/src/__tests__/provision.test.ts +160 -0
  55. package/src/__tests__/system-context.test.ts +52 -0
  56. package/src/__tests__/url-utils.test.ts +37 -0
  57. package/src/agent-discovery.ts +8 -0
  58. package/src/agent-workspace.ts +173 -7
  59. package/src/config.ts +132 -4
  60. package/src/daemon-config-map.ts +154 -9
  61. package/src/daemon.ts +66 -5
  62. package/src/doctor.ts +49 -2
  63. package/src/gateway/__tests__/dispatcher.test.ts +65 -0
  64. package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
  65. package/src/gateway/__tests__/transcript.test.ts +496 -0
  66. package/src/gateway/cli-resolver.ts +92 -0
  67. package/src/gateway/dispatcher.ts +394 -26
  68. package/src/gateway/gateway.ts +46 -0
  69. package/src/gateway/index.ts +25 -0
  70. package/src/gateway/policy-resolver.ts +171 -0
  71. package/src/gateway/runtimes/acp-stream.ts +535 -0
  72. package/src/gateway/runtimes/codex.ts +7 -0
  73. package/src/gateway/runtimes/hermes-agent.ts +206 -0
  74. package/src/gateway/runtimes/ndjson-stream.ts +16 -3
  75. package/src/gateway/runtimes/openclaw-acp.ts +606 -0
  76. package/src/gateway/runtimes/registry.ts +24 -0
  77. package/src/gateway/transcript-paths.ts +145 -0
  78. package/src/gateway/transcript.ts +300 -0
  79. package/src/gateway/types.ts +32 -0
  80. package/src/index.ts +295 -30
  81. package/src/mention-scan.ts +38 -0
  82. package/src/provision.ts +438 -9
  83. package/src/system-context.ts +41 -9
  84. package/src/url-utils.ts +17 -0
@@ -0,0 +1,496 @@
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { Dispatcher, type RuntimeFactory } from "../dispatcher.js";
7
+ import { SessionStore } from "../session-store.js";
8
+ import {
9
+ createTranscriptWriter,
10
+ resolveTranscriptEnabled,
11
+ TRANSCRIPT_TEXT_LIMIT,
12
+ truncateTextField,
13
+ type TranscriptRecord,
14
+ } from "../transcript.js";
15
+ import { safePathSegment, transcriptFilePath } from "../transcript-paths.js";
16
+ import type {
17
+ ChannelAdapter,
18
+ ChannelSendContext,
19
+ ChannelSendResult,
20
+ GatewayConfig,
21
+ GatewayInboundEnvelope,
22
+ GatewayInboundMessage,
23
+ RuntimeAdapter,
24
+ RuntimeRunOptions,
25
+ RuntimeRunResult,
26
+ } from "../types.js";
27
+ import type { GatewayLogger } from "../log.js";
28
+
29
+ function silentLogger(): GatewayLogger {
30
+ return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
31
+ }
32
+
33
+ class FakeChannel implements ChannelAdapter {
34
+ readonly id = "botcord";
35
+ readonly type = "fake";
36
+ readonly sends: ChannelSendContext[] = [];
37
+ sendImpl?: (ctx: ChannelSendContext) => Promise<ChannelSendResult> | ChannelSendResult;
38
+ async start(): Promise<void> {}
39
+ async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
40
+ this.sends.push(ctx);
41
+ if (this.sendImpl) return this.sendImpl(ctx);
42
+ return {};
43
+ }
44
+ }
45
+
46
+ interface FakeRuntimeOptions {
47
+ reply?: string;
48
+ newSessionId?: string;
49
+ delayMs?: number;
50
+ throwError?: Error | string;
51
+ hang?: boolean;
52
+ }
53
+
54
+ class FakeRuntime implements RuntimeAdapter {
55
+ readonly id = "claude-code";
56
+ constructor(private readonly opts: FakeRuntimeOptions = {}) {}
57
+ async run(options: RuntimeRunOptions): Promise<RuntimeRunResult> {
58
+ if (this.opts.hang) {
59
+ await new Promise<void>((_, reject) => {
60
+ options.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
61
+ });
62
+ }
63
+ if (this.opts.delayMs) {
64
+ await new Promise<void>((resolve, reject) => {
65
+ const t = setTimeout(resolve, this.opts.delayMs);
66
+ options.signal.addEventListener(
67
+ "abort",
68
+ () => {
69
+ clearTimeout(t);
70
+ reject(new Error("aborted"));
71
+ },
72
+ { once: true },
73
+ );
74
+ });
75
+ }
76
+ if (this.opts.throwError) {
77
+ throw typeof this.opts.throwError === "string"
78
+ ? new Error(this.opts.throwError)
79
+ : this.opts.throwError;
80
+ }
81
+ return {
82
+ text: this.opts.reply ?? "hello",
83
+ newSessionId: this.opts.newSessionId ?? "sid-1",
84
+ };
85
+ }
86
+ }
87
+
88
+ function makeMessage(p: Partial<GatewayInboundMessage> = {}): GatewayInboundMessage {
89
+ return {
90
+ id: p.id ?? "msg_1",
91
+ channel: p.channel ?? "botcord",
92
+ accountId: p.accountId ?? "ag_me",
93
+ conversation: p.conversation ?? { id: "rm_oc_1", kind: "direct" },
94
+ sender: p.sender ?? { id: "ag_peer", kind: "user", name: "peer" },
95
+ text: p.text ?? "hello",
96
+ raw: p.raw ?? {},
97
+ replyTo: null,
98
+ receivedAt: Date.now(),
99
+ trace: p.trace,
100
+ };
101
+ }
102
+
103
+ function makeEnvelope(p: Partial<GatewayInboundMessage> = {}): GatewayInboundEnvelope {
104
+ return { message: makeMessage(p) };
105
+ }
106
+
107
+ function baseConfig(): GatewayConfig {
108
+ return {
109
+ channels: [{ id: "botcord", type: "botcord", accountId: "ag_me" }],
110
+ defaultRoute: { runtime: "claude-code", cwd: "/tmp/default" },
111
+ routes: [],
112
+ };
113
+ }
114
+
115
+ async function readRecords(file: string): Promise<TranscriptRecord[]> {
116
+ if (!existsSync(file)) return [];
117
+ const data = await readFile(file, "utf8");
118
+ return data
119
+ .split("\n")
120
+ .filter((l) => l.length > 0)
121
+ .map((l) => JSON.parse(l) as TranscriptRecord);
122
+ }
123
+
124
+ interface Scaffold {
125
+ dispatcher: Dispatcher;
126
+ channel: FakeChannel;
127
+ store: SessionStore;
128
+ rootDir: string;
129
+ recordsForRoom: (roomId: string, topicId?: string | null) => Promise<TranscriptRecord[]>;
130
+ cleanup: () => Promise<void>;
131
+ }
132
+
133
+ async function scaffold(opts: {
134
+ runtimeFactory?: RuntimeFactory;
135
+ turnTimeoutMs?: number;
136
+ attentionGate?: (msg: GatewayInboundMessage) => boolean | Promise<boolean>;
137
+ composeUserTurn?: (msg: GatewayInboundMessage) => string;
138
+ channel?: FakeChannel;
139
+ agentId?: string;
140
+ } = {}): Promise<Scaffold> {
141
+ const tmp = await mkdtemp(path.join(tmpdir(), "transcript-test-"));
142
+ const sessionsPath = path.join(tmp, "sessions.json");
143
+ const store = new SessionStore({ path: sessionsPath });
144
+ await store.load();
145
+ const rootDir = path.join(tmp, "agents");
146
+ const channel = opts.channel ?? new FakeChannel();
147
+ const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
148
+ const transcript = createTranscriptWriter({ rootDir, log: silentLogger(), enabled: true });
149
+ const dispatcher = new Dispatcher({
150
+ config: baseConfig(),
151
+ channels,
152
+ runtime: opts.runtimeFactory ?? (() => new FakeRuntime()),
153
+ sessionStore: store,
154
+ log: silentLogger(),
155
+ turnTimeoutMs: opts.turnTimeoutMs,
156
+ attentionGate: opts.attentionGate,
157
+ composeUserTurn: opts.composeUserTurn,
158
+ transcript,
159
+ });
160
+ return {
161
+ dispatcher,
162
+ channel,
163
+ store,
164
+ rootDir,
165
+ recordsForRoom: async (roomId: string, topicId: string | null = null) => {
166
+ const file = transcriptFilePath(rootDir, opts.agentId ?? "ag_me", roomId, topicId);
167
+ return readRecords(file);
168
+ },
169
+ cleanup: () => rm(tmp, { recursive: true, force: true }),
170
+ };
171
+ }
172
+
173
+ describe("safePathSegment", () => {
174
+ it("fast path for plain ids", () => {
175
+ expect(safePathSegment("rm_abc-123")).toBe("rm_abc-123");
176
+ expect(safePathSegment("ag_01HXYZ")).toBe("ag_01HXYZ");
177
+ });
178
+
179
+ it("invalid → _invalid_<sha256-8>", () => {
180
+ const dot = safePathSegment("..");
181
+ expect(dot).toMatch(/^_invalid_[0-9a-f]{8}$/);
182
+ expect(safePathSegment(".")).toMatch(/^_invalid_[0-9a-f]{8}$/);
183
+ expect(safePathSegment("")).toMatch(/^_invalid_[0-9a-f]{8}$/);
184
+ // Different inputs hash to different files.
185
+ expect(safePathSegment("..")).not.toBe(safePathSegment("."));
186
+ });
187
+
188
+ it("Windows reserved names take precedence over fast path", () => {
189
+ expect(safePathSegment("CON")).toBe("_win_CON");
190
+ expect(safePathSegment("con")).toBe("_win_con");
191
+ expect(safePathSegment("COM1")).toBe("_win_COM1");
192
+ expect(safePathSegment("LPT9")).toBe("_win_LPT9");
193
+ });
194
+
195
+ it("escapes path-bearing chars but keeps `%` literal", () => {
196
+ expect(safePathSegment("rm/with/slash")).toBe("rm%2Fwith%2Fslash");
197
+ // `a..b` falls out of fast path (`.` is not whitelisted) so dots get encoded.
198
+ expect(safePathSegment("a..b")).toBe("a%2E%2Eb");
199
+ // `%` itself is preserved literally.
200
+ expect(safePathSegment("100%pure")).toBe("100%pure");
201
+ });
202
+
203
+ it("truncates long escaped names without splitting %XX", () => {
204
+ const long = "%".repeat(0) + "a/".repeat(150); // long enough to need truncation after escape
205
+ const s = safePathSegment(long);
206
+ expect(s.length).toBeLessThanOrEqual(200);
207
+ // Ensure the result does not end mid-`%XX` (last 3 chars are either non-`%` block or `_<hash>`).
208
+ expect(s).toMatch(/_[0-9a-f]{8}$/);
209
+ });
210
+
211
+ it("different long inputs sharing prefix get different hashes", () => {
212
+ const a = "/".repeat(150) + "tail-A";
213
+ const b = "/".repeat(150) + "tail-B";
214
+ const sa = safePathSegment(a);
215
+ const sb = safePathSegment(b);
216
+ expect(sa).not.toBe(sb);
217
+ expect(sa.length).toBeLessThanOrEqual(200);
218
+ expect(sb.length).toBeLessThanOrEqual(200);
219
+ });
220
+ });
221
+
222
+ describe("resolveTranscriptEnabled", () => {
223
+ it("env=1 forces on", () => {
224
+ expect(resolveTranscriptEnabled("1", false)).toBe(true);
225
+ expect(resolveTranscriptEnabled("1", true)).toBe(true);
226
+ });
227
+ it("env=0 forces off", () => {
228
+ expect(resolveTranscriptEnabled("0", true)).toBe(false);
229
+ expect(resolveTranscriptEnabled("0", false)).toBe(false);
230
+ });
231
+ it("unset / other strings fall through to config", () => {
232
+ expect(resolveTranscriptEnabled(undefined, true)).toBe(true);
233
+ expect(resolveTranscriptEnabled(undefined, false)).toBe(false);
234
+ expect(resolveTranscriptEnabled("yes", true)).toBe(true);
235
+ expect(resolveTranscriptEnabled("yes", false)).toBe(false);
236
+ });
237
+ });
238
+
239
+ describe("truncateTextField", () => {
240
+ it("passes through short text", () => {
241
+ const r = truncateTextField("hi");
242
+ expect(r.text).toBe("hi");
243
+ expect(r.truncated).toBe(false);
244
+ });
245
+ it("truncates oversize", () => {
246
+ const big = "a".repeat(TRANSCRIPT_TEXT_LIMIT + 100);
247
+ const r = truncateTextField(big);
248
+ expect(r.truncated).toBe(true);
249
+ expect(r.text.length).toBe(TRANSCRIPT_TEXT_LIMIT);
250
+ });
251
+ });
252
+
253
+ describe("Dispatcher transcript integration", () => {
254
+ let cleanups: Array<() => Promise<void>>;
255
+ beforeEach(() => {
256
+ cleanups = [];
257
+ });
258
+ afterEach(async () => {
259
+ for (const c of cleanups) await c();
260
+ });
261
+
262
+ function track(s: Scaffold): Scaffold {
263
+ cleanups.push(s.cleanup);
264
+ return s;
265
+ }
266
+
267
+ it("happy path: inbound + dispatched + outbound{delivered}, all share turnId", async () => {
268
+ const s = track(await scaffold({ runtimeFactory: () => new FakeRuntime({ reply: "ok" }) }));
269
+ await s.dispatcher.handle(makeEnvelope({ conversation: { id: "rm_oc_1", kind: "direct" } }));
270
+ const recs = await s.recordsForRoom("rm_oc_1");
271
+ expect(recs.map((r) => r.kind)).toEqual(["inbound", "dispatched", "outbound"]);
272
+ expect(new Set(recs.map((r) => r.turnId)).size).toBe(1);
273
+ const out = recs[2] as Extract<TranscriptRecord, { kind: "outbound" }>;
274
+ expect(out.deliveryStatus).toBe("delivered");
275
+ expect(out.finalText).toBe("ok");
276
+ });
277
+
278
+ it("non-owner-chat: outbound{gated_non_owner_chat}, channel never sends", async () => {
279
+ const s = track(await scaffold({ runtimeFactory: () => new FakeRuntime({ reply: "ok" }) }));
280
+ await s.dispatcher.handle(
281
+ makeEnvelope({ conversation: { id: "rm_normal", kind: "group" } }),
282
+ );
283
+ const recs = await s.recordsForRoom("rm_normal");
284
+ const out = recs.find((r) => r.kind === "outbound") as Extract<TranscriptRecord, { kind: "outbound" }>;
285
+ expect(out.deliveryStatus).toBe("gated_non_owner_chat");
286
+ expect(s.channel.sends.length).toBe(0);
287
+ });
288
+
289
+ it("dashboard_user_chat raw.source_type → delivered even outside rm_oc_", async () => {
290
+ const s = track(await scaffold({ runtimeFactory: () => new FakeRuntime({ reply: "yo" }) }));
291
+ await s.dispatcher.handle(
292
+ makeEnvelope({
293
+ conversation: { id: "rm_dash", kind: "direct" },
294
+ raw: { source_type: "dashboard_user_chat" },
295
+ }),
296
+ );
297
+ const recs = await s.recordsForRoom("rm_dash");
298
+ const out = recs.find((r) => r.kind === "outbound") as Extract<TranscriptRecord, { kind: "outbound" }>;
299
+ expect(out.deliveryStatus).toBe("delivered");
300
+ });
301
+
302
+ it("empty runtime text → outbound{empty_text}", async () => {
303
+ const s = track(await scaffold({ runtimeFactory: () => new FakeRuntime({ reply: " " }) }));
304
+ await s.dispatcher.handle(makeEnvelope());
305
+ const recs = await s.recordsForRoom("rm_oc_1");
306
+ const out = recs.find((r) => r.kind === "outbound") as Extract<TranscriptRecord, { kind: "outbound" }>;
307
+ expect(out.deliveryStatus).toBe("empty_text");
308
+ expect(s.channel.sends.length).toBe(0);
309
+ });
310
+
311
+ it("channel.send throws → outbound{send_failed} with deliveryReason", async () => {
312
+ const channel = new FakeChannel();
313
+ channel.sendImpl = () => {
314
+ throw new Error("boom");
315
+ };
316
+ const s = track(await scaffold({
317
+ runtimeFactory: () => new FakeRuntime({ reply: "ok" }),
318
+ channel,
319
+ }));
320
+ await s.dispatcher.handle(makeEnvelope());
321
+ const recs = await s.recordsForRoom("rm_oc_1");
322
+ const out = recs.find((r) => r.kind === "outbound") as Extract<TranscriptRecord, { kind: "outbound" }>;
323
+ expect(out.deliveryStatus).toBe("send_failed");
324
+ expect(out.deliveryReason).toBe("boom");
325
+ });
326
+
327
+ it("runtime throws → turn_error{phase:runtime}, no outbound", async () => {
328
+ const s = track(await scaffold({
329
+ runtimeFactory: () => new FakeRuntime({ throwError: "kaboom" }),
330
+ }));
331
+ await s.dispatcher.handle(makeEnvelope());
332
+ const recs = await s.recordsForRoom("rm_oc_1");
333
+ const kinds = recs.map((r) => r.kind);
334
+ expect(kinds).toContain("turn_error");
335
+ expect(kinds).not.toContain("outbound");
336
+ const err = recs.find((r) => r.kind === "turn_error") as Extract<TranscriptRecord, { kind: "turn_error" }>;
337
+ expect(err.phase).toBe("runtime");
338
+ expect(err.error).toBe("kaboom");
339
+ });
340
+
341
+ it("attention gate false → inbound + attention_skipped only", async () => {
342
+ const s = track(await scaffold({ attentionGate: () => false }));
343
+ await s.dispatcher.handle(makeEnvelope());
344
+ const recs = await s.recordsForRoom("rm_oc_1");
345
+ expect(recs.map((r) => r.kind)).toEqual(["inbound", "attention_skipped"]);
346
+ expect(new Set(recs.map((r) => r.turnId)).size).toBe(1);
347
+ });
348
+
349
+ it("compose_failed (cancel-previous mode) emits non-terminal record then proceeds", async () => {
350
+ const s = track(await scaffold({
351
+ composeUserTurn: () => {
352
+ throw new Error("compose boom");
353
+ },
354
+ runtimeFactory: () => new FakeRuntime({ reply: "ok" }),
355
+ }));
356
+ await s.dispatcher.handle(makeEnvelope({ conversation: { id: "rm_oc_1", kind: "direct" } }));
357
+ const recs = await s.recordsForRoom("rm_oc_1");
358
+ expect(recs.map((r) => r.kind)).toEqual([
359
+ "inbound",
360
+ "compose_failed",
361
+ "dispatched",
362
+ "outbound",
363
+ ]);
364
+ });
365
+
366
+ it("pre-skip branches do not write any record", async () => {
367
+ const s = track(await scaffold());
368
+ // empty text
369
+ await s.dispatcher.handle(makeEnvelope({ text: " " }));
370
+ // own-agent echo
371
+ await s.dispatcher.handle(makeEnvelope({ sender: { id: "ag_me", kind: "agent" } }));
372
+ const recs = await s.recordsForRoom("rm_oc_1");
373
+ expect(recs).toEqual([]);
374
+ });
375
+
376
+ it("text/finalText truncation marks truncated.<field>", async () => {
377
+ const big = "X".repeat(TRANSCRIPT_TEXT_LIMIT + 50);
378
+ const s = track(await scaffold({
379
+ runtimeFactory: () => new FakeRuntime({ reply: big }),
380
+ }));
381
+ await s.dispatcher.handle(makeEnvelope({ text: big }));
382
+ const recs = await s.recordsForRoom("rm_oc_1");
383
+ const inbound = recs[0] as Extract<TranscriptRecord, { kind: "inbound" }>;
384
+ expect(inbound.truncated?.text).toBe(true);
385
+ expect(inbound.text.length).toBe(TRANSCRIPT_TEXT_LIMIT);
386
+ const outbound = recs[recs.length - 1] as Extract<TranscriptRecord, { kind: "outbound" }>;
387
+ expect(outbound.truncated?.finalText).toBe(true);
388
+ });
389
+
390
+ it("sender kind variants serialize correctly", async () => {
391
+ for (const kind of ["user", "agent", "system"] as const) {
392
+ const s = track(await scaffold({
393
+ runtimeFactory: () => new FakeRuntime({ reply: "ok" }),
394
+ }));
395
+ // for kind=agent we need a peer id to avoid own-echo skip
396
+ await s.dispatcher.handle(
397
+ makeEnvelope({ sender: { id: "ag_other", kind, name: "Bob" } }),
398
+ );
399
+ const recs = await s.recordsForRoom("rm_oc_1");
400
+ const inbound = recs[0] as Extract<TranscriptRecord, { kind: "inbound" }>;
401
+ expect(inbound.sender.kind).toBe(kind);
402
+ expect(inbound.sender.name).toBe("Bob");
403
+ }
404
+ });
405
+
406
+ it("file rotation when crossing maxFileBytes", async () => {
407
+ const tmp = await mkdtemp(path.join(tmpdir(), "transcript-rotate-"));
408
+ cleanups.push(() => rm(tmp, { recursive: true, force: true }));
409
+ const writer = createTranscriptWriter({
410
+ rootDir: tmp,
411
+ log: silentLogger(),
412
+ enabled: true,
413
+ maxFileBytes: 200, // tiny
414
+ });
415
+ const base = {
416
+ ts: new Date().toISOString(),
417
+ turnId: "tn_x",
418
+ agentId: "ag_me",
419
+ roomId: "rm_x",
420
+ topicId: null,
421
+ } as const;
422
+ for (let i = 0; i < 10; i++) {
423
+ writer.write({
424
+ ...base,
425
+ kind: "attention_skipped",
426
+ reason: "padding-" + i + "-" + "z".repeat(40),
427
+ });
428
+ }
429
+ const dir = path.join(tmp, "ag_me", "transcripts", "rm_x");
430
+ const { readdirSync } = await import("node:fs");
431
+ const files = readdirSync(dir);
432
+ // Should be at least one rotated (.YYYYMMDD-HHMMSS.jsonl) plus active
433
+ expect(files.length).toBeGreaterThan(1);
434
+ expect(files.some((f) => /_default\.\d{8}-\d{6}\.jsonl$/.test(f))).toBe(true);
435
+ expect(files).toContain("_default.jsonl");
436
+ });
437
+
438
+ it("disabled writer does not create files", async () => {
439
+ const tmp = await mkdtemp(path.join(tmpdir(), "transcript-off-"));
440
+ cleanups.push(() => rm(tmp, { recursive: true, force: true }));
441
+ const writer = createTranscriptWriter({ rootDir: tmp, log: silentLogger(), enabled: false });
442
+ expect(writer.enabled).toBe(false);
443
+ writer.write({
444
+ ts: new Date().toISOString(),
445
+ kind: "attention_skipped",
446
+ turnId: "tn_x",
447
+ agentId: "ag_me",
448
+ roomId: "rm_x",
449
+ topicId: null,
450
+ reason: "test",
451
+ });
452
+ expect(existsSync(path.join(tmp, "ag_me"))).toBe(false);
453
+ });
454
+
455
+ it("FsTranscriptWriter absorbs filesystem errors — turn still completes", async () => {
456
+ // Point the writer at a path inside a regular file (mkdir will fail).
457
+ const tmp = await mkdtemp(path.join(tmpdir(), "transcript-fail-"));
458
+ cleanups.push(() => rm(tmp, { recursive: true, force: true }));
459
+ const blocker = path.join(tmp, "blocker");
460
+ const { writeFileSync } = await import("node:fs");
461
+ writeFileSync(blocker, "x");
462
+ // rootDir below `blocker` (a file, not a dir) → mkdir/append fail every time.
463
+ const writer = createTranscriptWriter({
464
+ rootDir: path.join(blocker, "nope"),
465
+ log: silentLogger(),
466
+ enabled: true,
467
+ });
468
+ const sessionsPath = path.join(tmp, "sessions.json");
469
+ const store = new SessionStore({ path: sessionsPath });
470
+ await store.load();
471
+ const channel = new FakeChannel();
472
+ const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
473
+ const dispatcher = new Dispatcher({
474
+ config: baseConfig(),
475
+ channels,
476
+ runtime: () => new FakeRuntime({ reply: "ok" }),
477
+ sessionStore: store,
478
+ log: silentLogger(),
479
+ transcript: writer,
480
+ });
481
+ await expect(dispatcher.handle(makeEnvelope())).resolves.not.toThrow();
482
+ expect(channel.sends.length).toBe(1);
483
+ });
484
+
485
+ it("CLI path helper resolves to the same file the writer used", async () => {
486
+ const s = track(await scaffold({ runtimeFactory: () => new FakeRuntime({ reply: "ok" }) }));
487
+ await s.dispatcher.handle(
488
+ makeEnvelope({
489
+ conversation: { id: "rm_oc_1", kind: "direct", threadId: "tp_ABC" },
490
+ }),
491
+ );
492
+ const file = transcriptFilePath(s.rootDir, "ag_me", "rm_oc_1", "tp_ABC");
493
+ expect(existsSync(file)).toBe(true);
494
+ expect(statSync(file).size).toBeGreaterThan(0);
495
+ });
496
+ });
@@ -0,0 +1,92 @@
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { consoleLogger } from "./log.js";
5
+
6
+ const require = createRequire(import.meta.url);
7
+
8
+ // Tri-state cache: `undefined` means "not yet attempted"; `null` means
9
+ // "attempted and unavailable" (don't retry, don't re-log).
10
+ let cached: { binDir: string; binPath: string } | null | undefined;
11
+
12
+ export interface BundledCliBin {
13
+ /** Directory containing the `botcord` symlink — safe to prepend to PATH. */
14
+ binDir: string;
15
+ /** Absolute path to the CLI's JS entry — for direct spawn (not via PATH). */
16
+ binPath: string;
17
+ }
18
+
19
+ /**
20
+ * Resolve the bundled `@botcord/cli` package and return both the
21
+ * `<install-root>/node_modules/.bin` directory (for PATH injection so
22
+ * `botcord` shows up to runtimes) and the absolute JS entry (for callers
23
+ * that want to spawn the CLI directly without depending on the symlink).
24
+ *
25
+ * Returns `null` when `@botcord/cli` is not installed alongside the daemon
26
+ * — callers should fall back to whatever `botcord` is on the user's PATH.
27
+ */
28
+ export function resolveBundledCliBin(): BundledCliBin | null {
29
+ if (cached !== undefined) return cached;
30
+ try {
31
+ const pkgJsonPath = require.resolve("@botcord/cli/package.json");
32
+ const pkgRoot = path.dirname(pkgJsonPath);
33
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) as {
34
+ bin?: string | Record<string, string>;
35
+ };
36
+ const binRel =
37
+ typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.botcord;
38
+ if (!binRel) {
39
+ consoleLogger.warn("cli-resolver: @botcord/cli has no bin.botcord entry");
40
+ cached = null;
41
+ return null;
42
+ }
43
+ const binPath = path.resolve(pkgRoot, binRel);
44
+ // PATH must point at `<install-root>/node_modules/.bin` (where npm puts
45
+ // the `botcord` shim), not the package's own `dist/` — there is no
46
+ // executable named `botcord` inside the package directory.
47
+ const binDir = path.resolve(pkgRoot, "..", "..", ".bin");
48
+ cached = { binDir, binPath };
49
+ return cached;
50
+ } catch (err) {
51
+ consoleLogger.warn(
52
+ "cli-resolver: bundled @botcord/cli not resolvable; runtimes will fall back to PATH",
53
+ { error: err instanceof Error ? err.message : String(err) },
54
+ );
55
+ cached = null;
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /** Test-only: clear the cached resolution. */
61
+ export function __resetBundledCliBinCache(): void {
62
+ cached = undefined;
63
+ }
64
+
65
+ /**
66
+ * Return env additions that point a runtime CLI subprocess at the right
67
+ * BotCord identity:
68
+ * - `BOTCORD_HUB` — hub URL the agent is registered against
69
+ * - `BOTCORD_AGENT_ID` — default `--agent` for `botcord ...` invocations
70
+ * - `PATH` — prepended with the bundled CLI's `.bin` dir so
71
+ * `botcord` resolves to the version daemon shipped
72
+ * with (avoiding protocol-core drift). Falls
73
+ * through to whatever the user already has on PATH
74
+ * when the bundled CLI can't be resolved.
75
+ */
76
+ export function buildCliEnv(opts: {
77
+ hubUrl?: string;
78
+ accountId?: string;
79
+ basePath?: string | undefined;
80
+ }): NodeJS.ProcessEnv {
81
+ const env: NodeJS.ProcessEnv = {};
82
+ if (opts.hubUrl) env.BOTCORD_HUB = opts.hubUrl;
83
+ if (opts.accountId) env.BOTCORD_AGENT_ID = opts.accountId;
84
+ const cli = resolveBundledCliBin();
85
+ if (cli) {
86
+ const existing = opts.basePath ?? "";
87
+ env.PATH = existing
88
+ ? `${cli.binDir}${path.delimiter}${existing}`
89
+ : cli.binDir;
90
+ }
91
+ return env;
92
+ }