@botcord/daemon 0.2.63 → 0.2.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp-logs.d.ts +5 -1
- package/dist/acp-logs.js +4 -3
- package/dist/config.d.ts +3 -3
- package/dist/control-channel.d.ts +1 -0
- package/dist/control-channel.js +58 -7
- package/dist/daemon.js +1 -1
- package/dist/diagnostics.js +62 -1
- package/dist/gateway/channels/botcord.js +54 -11
- package/dist/gateway/dispatcher.js +76 -10
- package/dist/gateway/gateway.d.ts +2 -1
- package/dist/gateway/gateway.js +1 -1
- package/dist/gateway/runtimes/openclaw-acp.js +41 -5
- package/dist/gateway/transcript.d.ts +21 -4
- package/dist/gateway/transcript.js +66 -5
- package/dist/index.js +7 -5
- package/dist/turn-text.js +2 -1
- package/package.json +1 -1
- package/src/__tests__/control-channel.test.ts +37 -0
- package/src/__tests__/turn-text.test.ts +16 -0
- package/src/acp-logs.ts +9 -3
- package/src/config.ts +3 -3
- package/src/control-channel.ts +60 -7
- package/src/daemon.ts +1 -1
- package/src/diagnostics.ts +67 -1
- package/src/gateway/__tests__/dispatcher.test.ts +44 -0
- package/src/gateway/__tests__/transcript.test.ts +27 -2
- package/src/gateway/channels/botcord.ts +50 -11
- package/src/gateway/dispatcher.ts +77 -8
- package/src/gateway/gateway.ts +3 -2
- package/src/gateway/runtimes/openclaw-acp.ts +56 -6
- package/src/gateway/transcript.ts +98 -8
- package/src/index.ts +6 -5
- package/src/turn-text.ts +2 -1
|
@@ -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/dist/turn-text.js
CHANGED
|
@@ -33,7 +33,8 @@ function readEnvelopeType(raw) {
|
|
|
33
33
|
}
|
|
34
34
|
function isThirdPartyConversation(conversationId) {
|
|
35
35
|
return (conversationId.startsWith("telegram:") ||
|
|
36
|
-
conversationId.startsWith("wechat:")
|
|
36
|
+
conversationId.startsWith("wechat:") ||
|
|
37
|
+
conversationId.startsWith("feishu:"));
|
|
37
38
|
}
|
|
38
39
|
function replyDeliveryHint(msg) {
|
|
39
40
|
return isThirdPartyConversation(msg.conversation.id)
|
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
|
});
|
|
@@ -134,6 +134,22 @@ describe("composeBotCordUserTurn", () => {
|
|
|
134
134
|
expect(out).not.toContain("botcord_send");
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
it("does not tell Feishu chats to use botcord_send", () => {
|
|
138
|
+
const out = composeBotCordUserTurn(
|
|
139
|
+
makeMessage({
|
|
140
|
+
channel: "gw_feishu_123",
|
|
141
|
+
conversation: { id: "feishu:user:oc_alice", kind: "direct" },
|
|
142
|
+
sender: { id: "feishu:user:ou_alice", name: "Alice", kind: "user" },
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
expect(out).toContain("third-party gateway chat");
|
|
146
|
+
expect(out).toContain("Reply normally in your final assistant message");
|
|
147
|
+
expect(out).toContain("conversation_id: feishu:user:oc_alice");
|
|
148
|
+
expect(out).toContain("channel: gw_feishu_123");
|
|
149
|
+
expect(out).not.toContain("Plain text output WILL NOT be sent");
|
|
150
|
+
expect(out).not.toContain("botcord_send");
|
|
151
|
+
});
|
|
152
|
+
|
|
137
153
|
it("passes owner-chat messages through verbatim (no wrapper, no hint)", () => {
|
|
138
154
|
const out = composeBotCordUserTurn(
|
|
139
155
|
makeMessage({
|
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 {
|
package/src/control-channel.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
|
|
26
26
|
/** Exponential backoff plan for transient disconnects. */
|
|
27
27
|
const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
28
|
+
const RECONNECT_JITTER_RATIO = 0.25;
|
|
28
29
|
/**
|
|
29
30
|
* Keepalive cadence. Has to stay below the smallest idle-timeout in any
|
|
30
31
|
* intermediary on the daemon → Hub WS path. Cloudflare and AWS ALB both
|
|
@@ -55,6 +56,11 @@ export function controlSigningInput(
|
|
|
55
56
|
return jcsCanonicalize(obj) ?? "{}";
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
function withReconnectJitter(delayMs: number): { delayMs: number; jitterMs: number } {
|
|
60
|
+
const jitterMs = Math.floor(Math.random() * delayMs * RECONNECT_JITTER_RATIO);
|
|
61
|
+
return { delayMs: delayMs + jitterMs, jitterMs };
|
|
62
|
+
}
|
|
63
|
+
|
|
58
64
|
/** Handler invoked for each inbound frame. Return value is the ack payload. */
|
|
59
65
|
export type ControlFrameHandler = (
|
|
60
66
|
frame: ControlFrame,
|
|
@@ -110,6 +116,7 @@ export class ControlChannel {
|
|
|
110
116
|
private readonly seenFrameIds: string[] = [];
|
|
111
117
|
private connectInflight: Promise<void> | null = null;
|
|
112
118
|
private connected = false;
|
|
119
|
+
private connectionSeq = 0;
|
|
113
120
|
|
|
114
121
|
constructor(opts: ControlChannelOptions) {
|
|
115
122
|
this.auth = opts.auth;
|
|
@@ -220,6 +227,20 @@ export class ControlChannel {
|
|
|
220
227
|
private async connect(): Promise<void> {
|
|
221
228
|
const record = this.auth.current;
|
|
222
229
|
if (!record) throw new Error("control-channel: no user-auth");
|
|
230
|
+
const current = this.ws;
|
|
231
|
+
if (
|
|
232
|
+
current &&
|
|
233
|
+
(current.readyState === WebSocket.CONNECTING || current.readyState === WebSocket.OPEN)
|
|
234
|
+
) {
|
|
235
|
+
daemonLog.debug("control-channel connect skipped (socket already active)", {
|
|
236
|
+
readyState: current.readyState,
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (this.reconnectTimer) {
|
|
241
|
+
clearTimeout(this.reconnectTimer);
|
|
242
|
+
this.reconnectTimer = null;
|
|
243
|
+
}
|
|
223
244
|
|
|
224
245
|
const accessToken = await this.auth.ensureAccessToken();
|
|
225
246
|
const url = buildDaemonWebSocketUrl(
|
|
@@ -229,6 +250,7 @@ export class ControlChannel {
|
|
|
229
250
|
);
|
|
230
251
|
daemonLog.info("control-channel connecting", { url });
|
|
231
252
|
|
|
253
|
+
const connectionId = ++this.connectionSeq;
|
|
232
254
|
const ws = new this.webSocketCtor(url, {
|
|
233
255
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
234
256
|
});
|
|
@@ -237,6 +259,15 @@ export class ControlChannel {
|
|
|
237
259
|
await new Promise<void>((resolve, reject) => {
|
|
238
260
|
const onOpen = (): void => {
|
|
239
261
|
ws.removeListener("error", onError);
|
|
262
|
+
if (this.stopRequested || this.ws !== ws || connectionId !== this.connectionSeq) {
|
|
263
|
+
try {
|
|
264
|
+
ws.close(1000, "stale control-channel connection");
|
|
265
|
+
} catch {
|
|
266
|
+
// ignore
|
|
267
|
+
}
|
|
268
|
+
resolve();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
240
271
|
this.connected = true;
|
|
241
272
|
this.reconnectAttempts = 0;
|
|
242
273
|
daemonLog.info("control-channel connected", { url });
|
|
@@ -245,14 +276,21 @@ export class ControlChannel {
|
|
|
245
276
|
};
|
|
246
277
|
const onError = (err: Error): void => {
|
|
247
278
|
ws.removeListener("open", onOpen);
|
|
279
|
+
if (this.ws !== ws || connectionId !== this.connectionSeq) {
|
|
280
|
+
resolve();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
248
283
|
reject(err);
|
|
249
284
|
};
|
|
250
285
|
ws.once("open", onOpen);
|
|
251
286
|
ws.once("error", onError);
|
|
252
287
|
});
|
|
253
288
|
|
|
254
|
-
ws.on("message", (data) =>
|
|
255
|
-
|
|
289
|
+
ws.on("message", (data) => {
|
|
290
|
+
if (this.ws !== ws || connectionId !== this.connectionSeq) return;
|
|
291
|
+
void this.onMessage(data);
|
|
292
|
+
});
|
|
293
|
+
ws.on("close", (code, reason) => this.onClose(code, reason, ws, connectionId));
|
|
256
294
|
ws.on("error", (err) =>
|
|
257
295
|
daemonLog.warn("control-channel error", {
|
|
258
296
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -292,8 +330,12 @@ export class ControlChannel {
|
|
|
292
330
|
}
|
|
293
331
|
}
|
|
294
332
|
|
|
295
|
-
private onClose(code: number, reason: Buffer): void {
|
|
333
|
+
private onClose(code: number, reason: Buffer, ws?: WebSocket, connectionId?: number): void {
|
|
296
334
|
const reasonText = reason?.toString() || "";
|
|
335
|
+
if (ws && (this.ws !== ws || connectionId !== this.connectionSeq)) {
|
|
336
|
+
daemonLog.debug("control-channel stale close ignored", { code, reason: reasonText });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
297
339
|
this.connected = false;
|
|
298
340
|
this.stopKeepalive();
|
|
299
341
|
this.ws = null;
|
|
@@ -314,6 +356,14 @@ export class ControlChannel {
|
|
|
314
356
|
|
|
315
357
|
private scheduleReconnect(err?: unknown): void {
|
|
316
358
|
if (this.stopRequested) return;
|
|
359
|
+
if (this.reconnectTimer) return;
|
|
360
|
+
const current = this.ws;
|
|
361
|
+
if (
|
|
362
|
+
current &&
|
|
363
|
+
(current.readyState === WebSocket.CONNECTING || current.readyState === WebSocket.OPEN)
|
|
364
|
+
) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
317
367
|
if (err instanceof AuthRefreshRejectedError) {
|
|
318
368
|
this.stopRequested = true;
|
|
319
369
|
daemonLog.warn("control-channel: refresh rejected; halting reconnect (re-login required)", {
|
|
@@ -323,20 +373,23 @@ export class ControlChannel {
|
|
|
323
373
|
}
|
|
324
374
|
const attempt = this.reconnectAttempts;
|
|
325
375
|
this.reconnectAttempts = attempt + 1;
|
|
326
|
-
const
|
|
376
|
+
const baseDelayMs = this.backoff[Math.min(attempt, this.backoff.length - 1)];
|
|
377
|
+
const { delayMs, jitterMs } = withReconnectJitter(baseDelayMs);
|
|
327
378
|
if (err) {
|
|
328
379
|
daemonLog.warn("control-channel reconnect scheduled", {
|
|
329
|
-
delayMs
|
|
380
|
+
delayMs,
|
|
381
|
+
baseDelayMs,
|
|
382
|
+
jitterMs,
|
|
330
383
|
error: err instanceof Error ? err.message : String(err),
|
|
331
384
|
});
|
|
332
385
|
} else {
|
|
333
|
-
daemonLog.info("control-channel reconnect scheduled", { delayMs
|
|
386
|
+
daemonLog.info("control-channel reconnect scheduled", { delayMs, baseDelayMs, jitterMs });
|
|
334
387
|
}
|
|
335
388
|
this.reconnectTimer = setTimeout(() => {
|
|
336
389
|
this.reconnectTimer = null;
|
|
337
390
|
if (this.stopRequested) return;
|
|
338
391
|
this.connect().catch((err) => this.scheduleReconnect(err));
|
|
339
|
-
},
|
|
392
|
+
}, delayMs);
|
|
340
393
|
}
|
|
341
394
|
|
|
342
395
|
private async onMessage(data: WebSocket.RawData): Promise<void> {
|
package/src/daemon.ts
CHANGED
|
@@ -518,7 +518,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
518
518
|
resolveHubUrl,
|
|
519
519
|
transcriptEnabled: resolveTranscriptEnabled(
|
|
520
520
|
process.env.BOTCORD_TRANSCRIPT,
|
|
521
|
-
opts.config.transcript?.enabled
|
|
521
|
+
opts.config.transcript?.enabled,
|
|
522
522
|
),
|
|
523
523
|
});
|
|
524
524
|
|