@controlflow-ai/daemon 0.1.0

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 (67) hide show
  1. package/README.md +360 -0
  2. package/bin/console.js +2 -0
  3. package/bin/daemon.js +2 -0
  4. package/bin/pal.js +2 -0
  5. package/bin/server.js +2 -0
  6. package/package.json +31 -0
  7. package/src/agent-runtime.ts +285 -0
  8. package/src/app.ts +745 -0
  9. package/src/args.ts +54 -0
  10. package/src/artifacts.ts +85 -0
  11. package/src/cli.ts +284 -0
  12. package/src/client.ts +310 -0
  13. package/src/coco.ts +52 -0
  14. package/src/codex.ts +41 -0
  15. package/src/coding-agent-runtime.ts +20 -0
  16. package/src/config.ts +106 -0
  17. package/src/console.ts +349 -0
  18. package/src/daemon-client.ts +91 -0
  19. package/src/daemon.ts +580 -0
  20. package/src/db.ts +2830 -0
  21. package/src/failure-message.ts +17 -0
  22. package/src/format.ts +13 -0
  23. package/src/http.ts +55 -0
  24. package/src/lark/agent-runtime.ts +142 -0
  25. package/src/lark/cli.ts +549 -0
  26. package/src/lark/credentials.ts +105 -0
  27. package/src/lark/daemon-integration.ts +108 -0
  28. package/src/lark/dispatcher.ts +374 -0
  29. package/src/lark/event-router.ts +329 -0
  30. package/src/lark/inbound-events.ts +131 -0
  31. package/src/lark/server-integration.ts +445 -0
  32. package/src/lark/setup.ts +326 -0
  33. package/src/lark/ws-daemon.ts +224 -0
  34. package/src/lark-fixture-diagnostics.ts +56 -0
  35. package/src/lark-fixture.ts +277 -0
  36. package/src/local-api.ts +155 -0
  37. package/src/local-auth.ts +45 -0
  38. package/src/migrations/001_initial.ts +61 -0
  39. package/src/migrations/002_daemon_deliveries.ts +52 -0
  40. package/src/migrations/003_sessions_runs.ts +49 -0
  41. package/src/migrations/004_message_idempotency.ts +21 -0
  42. package/src/migrations/005_artifacts.ts +24 -0
  43. package/src/migrations/006_lark_channel_foundation.ts +119 -0
  44. package/src/migrations/007_agents_a0.ts +17 -0
  45. package/src/migrations/008_b0_chat_history.ts +31 -0
  46. package/src/migrations/009_b0_transcript_ingest_seq.ts +35 -0
  47. package/src/migrations/010_b0_transcript_shadow_external_ids.ts +32 -0
  48. package/src/migrations/011_b0_channel_conversation_audit_only.ts +27 -0
  49. package/src/migrations/012_b0_cross_conversation_invariant.ts +45 -0
  50. package/src/migrations/013_b1_0_eng_inbound_raw_events.ts +56 -0
  51. package/src/migrations/014_agents_runtime.ts +10 -0
  52. package/src/migrations/015_agent_runtime_sessions.ts +15 -0
  53. package/src/migrations/016_room_participants.ts +27 -0
  54. package/src/migrations/017_unified_room_delivery.ts +203 -0
  55. package/src/migrations/018_room_display_names.ts +36 -0
  56. package/src/migrations/019_computer_connections.ts +63 -0
  57. package/src/migrations/020_computer_agent_assignments.ts +20 -0
  58. package/src/migrations/021_provider_identity_bindings.ts +32 -0
  59. package/src/migrations.ts +85 -0
  60. package/src/neeko.ts +23 -0
  61. package/src/provider-identity.ts +40 -0
  62. package/src/runtime-registry.ts +41 -0
  63. package/src/server-auth.ts +13 -0
  64. package/src/server.ts +63 -0
  65. package/src/token-file.ts +57 -0
  66. package/src/types.ts +408 -0
  67. package/src/web.ts +565 -0
@@ -0,0 +1,108 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import { MessageStore } from '../db.js';
3
+ import { loadLarkCredentials, type LarkCredential } from './credentials.js';
4
+ import { extractMentionOpenIds, ingestLarkMessage, type LarkMessageEnvelope } from './event-router.js';
5
+ import { shouldAcceptForAgent, type ReceivePolicy } from './dispatcher.js';
6
+ import { startLarkDaemon, type LarkDaemonHandle } from './ws-daemon.js';
7
+
8
+ export interface LarkIntegrationOptions {
9
+ agent: string;
10
+ /** Shared MessageStore for DB operations (caller owns lifecycle). */
11
+ store: MessageStore;
12
+ policy?: ReceivePolicy;
13
+ botOpenIdByApp?: Map<string, string>;
14
+ logger?: Partial<Pick<Console, 'log' | 'warn' | 'error'>>;
15
+ }
16
+
17
+ export interface LarkIntegrationResult {
18
+ handles: LarkDaemonHandle[];
19
+ stop(): void;
20
+ }
21
+
22
+ /**
23
+ * Find Lark bots bound to the given agent key in lark.json.
24
+ */
25
+ export function findLarkBotsForAgent(agent: string, configPath?: string): LarkCredential[] {
26
+ const store = loadLarkCredentials(configPath);
27
+ return store.bots.filter((b) => b.agent === agent);
28
+ }
29
+
30
+ /**
31
+ * Start Lark WS connections for the given agent.
32
+ * When a message arrives, it is ingested into the DB and a delivery is created
33
+ * for the agent (delivery mode, same as the standalone lark daemon).
34
+ */
35
+ export function startLarkForAgent(options: LarkIntegrationOptions): LarkIntegrationResult {
36
+ const log = {
37
+ log: options.logger?.log ?? console.log,
38
+ warn: options.logger?.warn ?? console.warn,
39
+ error: options.logger?.error ?? console.error,
40
+ };
41
+ const policy = options.policy ?? 'every';
42
+ const msgStore = options.store;
43
+ const botOpenIdByApp = options.botOpenIdByApp ?? new Map<string, string>();
44
+
45
+ const bots = findLarkBotsForAgent(options.agent);
46
+ if (bots.length === 0) {
47
+ return { handles: [], stop() {} };
48
+ }
49
+
50
+ log.log(`[lark] integrating ${bots.length} bot(s) for agent=${options.agent} policy=${policy}`);
51
+
52
+ const handles = bots.map((bot) =>
53
+ startLarkDaemon({
54
+ appId: bot.appId,
55
+ appSecret: bot.appSecret,
56
+ db: msgStore.db,
57
+ logger: { info: log.log, warn: log.warn, error: log.error },
58
+ onEvent: ({ envelope, data, storeResult }) => {
59
+ log.log(`[lark/${bot.appId}] ${envelope} stored id=${storeResult.id} event_id=${storeResult.event_id} parse_ok=${storeResult.parse_ok}`);
60
+ if (envelope === 'im.message.receive_v1') {
61
+ try {
62
+ const result = ingestLarkMessage({
63
+ appId: bot.appId,
64
+ envelope: data as LarkMessageEnvelope,
65
+ store: msgStore,
66
+ });
67
+ if (result.status === 'ok' && result.message) {
68
+ const tag = result.deduped ? 'dup' : result.threadOrphan ? 'orphan' : 'new';
69
+ log.log(
70
+ `[lark/${bot.appId}] ingest ${tag} lock_msg=${result.message.id} chat=${result.message.chat_name} parent=${result.message.parent_id ?? '-'}`,
71
+ );
72
+ if (tag === 'new') {
73
+ const mentionOpenIds = extractMentionOpenIds(data as LarkMessageEnvelope);
74
+ const accept = shouldAcceptForAgent({
75
+ policy,
76
+ botOpenId: botOpenIdByApp.get(bot.appId) ?? null,
77
+ mentionOpenIds,
78
+ });
79
+ if (!accept) {
80
+ log.log(`[lark/${bot.appId}] policy=${policy} drop (no mention or off)`);
81
+ } else {
82
+ try {
83
+ const delivery = msgStore.createDelivery({ messageId: result.message.id, agent: options.agent });
84
+ log.log(`[lark/${bot.appId}] created delivery id=${delivery.id} for agent=${options.agent} message=${result.message.id}`);
85
+ } catch (err) {
86
+ log.warn(`[lark/${bot.appId}] createDelivery failed: ${err instanceof Error ? err.message : String(err)}`);
87
+ }
88
+ }
89
+ }
90
+ } else if (result.status === 'skipped') {
91
+ log.log(`[lark/${bot.appId}] ingest skipped reason=${result.reason}`);
92
+ }
93
+ } catch (err) {
94
+ log.error(`[lark/${bot.appId}] ingest error:`, err);
95
+ }
96
+ }
97
+ },
98
+ }),
99
+ );
100
+
101
+ return {
102
+ handles,
103
+ stop() {
104
+ for (const h of handles) h.stop();
105
+ log.log(`[lark] stopped ${handles.length} bot(s) for agent=${options.agent}`);
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,374 @@
1
+ import type { Message } from '../types.js';
2
+ import type { AgentInvocation, AgentReply, AgentRuntime } from './agent-runtime.js';
3
+
4
+ export interface ChatDispatcherDeps {
5
+ runtime: AgentRuntime;
6
+ /** Called when the runtime produces a non-empty reply. Implementations
7
+ * typically forward it to Lark via sendTextMessage. */
8
+ onReply: (input: { chatKey: string; reply: AgentReply; sourceMessages: DispatchInput[] }) => Promise<void> | void;
9
+ /** Optional structured logger. */
10
+ logger?: Partial<Pick<Console, 'log' | 'warn' | 'error'>>;
11
+ }
12
+
13
+ export interface DispatchInput {
14
+ chatKey: string;
15
+ senderOpenId: string;
16
+ text: string;
17
+ larkMessageId: string;
18
+ lockMessageId: number;
19
+ parentLockMessageId: number | null;
20
+ /** When true, the message reached the runtime previously but the run
21
+ * was aborted/failed before producing a reply. The dispatcher attaches
22
+ * this on re-delivery after restart(). */
23
+ isRedelivery?: boolean;
24
+ }
25
+
26
+ interface PendingItem {
27
+ input: DispatchInput;
28
+ resolve: () => void;
29
+ }
30
+
31
+ interface InFlight {
32
+ batch: PendingItem[];
33
+ abortController: AbortController;
34
+ }
35
+
36
+ interface ChatQueueState {
37
+ pending: PendingItem[];
38
+ /** Set when a runtime call is currently in progress for this chat. */
39
+ inFlight: InFlight | null;
40
+ }
41
+
42
+ /**
43
+ * Per-chat serial dispatcher.
44
+ *
45
+ * Semantics (matches wiki "消息正在处理时, 之后消息堆积; 处理完成后拼接"):
46
+ *
47
+ * - If the chat queue is idle, the new message immediately triggers a
48
+ * runtime run with `messages = [thisMessage]`, `isFresh = true`.
49
+ * - If the chat queue is busy, the new message is appended to the pending
50
+ * buffer. When the current run completes, all accumulated pending
51
+ * messages are flushed in a single next invocation with `isFresh = false`.
52
+ * - Runtime errors are logged but do NOT block the queue.
53
+ * - Different chats progress independently.
54
+ *
55
+ * Kill / restart (wiki: "daemon 提供 kill 并重启, 重启后未完成消息重发"):
56
+ *
57
+ * - `killChat(chatKey)` fires the AbortSignal on the in-flight invocation
58
+ * and clears the queue. Runtimes that respect the signal should bail out;
59
+ * the kill returns the in-flight batch + the pending tail so the caller
60
+ * can inspect what was dropped.
61
+ * - `restartChat(chatKey)` is `killChat` + re-enqueue of the killed
62
+ * in-flight batch with `isRedelivery=true`. This implements the wiki
63
+ * guarantee that messages that did not get a successful reply are
64
+ * re-delivered after restart.
65
+ */
66
+ export class ChatDispatcher {
67
+ private readonly queues = new Map<string, ChatQueueState>();
68
+
69
+ constructor(private readonly deps: ChatDispatcherDeps) {}
70
+
71
+ get activeChatCount(): number {
72
+ return this.queues.size;
73
+ }
74
+
75
+ inspect(): Array<{ chatKey: string; pending: number; running: boolean }> {
76
+ return Array.from(this.queues.entries()).map(([chatKey, state]) => ({
77
+ chatKey,
78
+ pending: state.pending.length,
79
+ running: state.inFlight !== null,
80
+ }));
81
+ }
82
+
83
+ enqueue(input: DispatchInput): Promise<void> {
84
+ return this.enqueueBatch([input]);
85
+ }
86
+
87
+ /**
88
+ * Enqueue multiple messages as a single logical batch. If the chat is
89
+ * idle, all items run in one runtime invocation (`messages = [...]`,
90
+ * `isFresh = true`). If the chat is busy, all items are appended to
91
+ * pending and will be flushed together in the next run.
92
+ */
93
+ enqueueBatch(inputs: DispatchInput[]): Promise<void> {
94
+ if (inputs.length === 0) return Promise.resolve();
95
+ return new Promise<void>((resolve) => {
96
+ const chatKey = inputs[0].chatKey;
97
+ // Ensure all inputs share the same chatKey; reject heterogeneous batches.
98
+ for (const inp of inputs) {
99
+ if (inp.chatKey !== chatKey) {
100
+ throw new Error('enqueueBatch requires all inputs to share chatKey');
101
+ }
102
+ }
103
+ let state = this.queues.get(chatKey);
104
+ if (!state) {
105
+ state = { pending: [], inFlight: null };
106
+ this.queues.set(chatKey, state);
107
+ }
108
+ let outstanding = inputs.length;
109
+ const settle = (): void => {
110
+ outstanding -= 1;
111
+ if (outstanding <= 0) resolve();
112
+ };
113
+ const items: PendingItem[] = inputs.map((inp) => ({ input: inp, resolve: settle }));
114
+ if (state.inFlight) {
115
+ state.pending.push(...items);
116
+ return;
117
+ }
118
+ const ctl = new AbortController();
119
+ state.inFlight = { batch: items, abortController: ctl };
120
+ void this.dispatch(chatKey, items, true, ctl.signal);
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Abort the in-flight run (if any) and drop pending messages.
126
+ * Returns the messages that were either in flight or queued.
127
+ * Pending resolvers are still resolved (they are no longer queued).
128
+ */
129
+ killChat(chatKey: string): { aborted: DispatchInput[]; dropped: DispatchInput[] } {
130
+ const state = this.queues.get(chatKey);
131
+ if (!state) return { aborted: [], dropped: [] };
132
+ const aborted = state.inFlight ? state.inFlight.batch.map((b) => b.input) : [];
133
+ const dropped = state.pending.map((p) => p.input);
134
+ // Resolve all queued pending so callers don't hang.
135
+ for (const p of state.pending) p.resolve();
136
+ state.pending = [];
137
+ if (state.inFlight) state.inFlight.abortController.abort();
138
+ // We leave inFlight set so dispatch() can detect the abort and clean up.
139
+ return { aborted, dropped };
140
+ }
141
+
142
+ /**
143
+ * Kill current activity and re-enqueue the in-flight + pending messages
144
+ * for a fresh run. The re-enqueued items carry `isRedelivery=true`.
145
+ * Returns the count of re-enqueued messages.
146
+ */
147
+ restartChat(chatKey: string): { redelivered: number } {
148
+ const { aborted, dropped } = this.killChat(chatKey);
149
+ const all = [...aborted, ...dropped];
150
+ for (const item of all) {
151
+ void this.enqueue({ ...item, isRedelivery: true });
152
+ }
153
+ return { redelivered: all.length };
154
+ }
155
+
156
+ /** Kill every chat. Returns aggregate counts. */
157
+ killAll(): { aborted: number; dropped: number } {
158
+ let aborted = 0;
159
+ let dropped = 0;
160
+ for (const chatKey of Array.from(this.queues.keys())) {
161
+ const result = this.killChat(chatKey);
162
+ aborted += result.aborted.length;
163
+ dropped += result.dropped.length;
164
+ }
165
+ return { aborted, dropped };
166
+ }
167
+
168
+ /** Restart every chat (kill + re-enqueue). */
169
+ restartAll(): { redelivered: number } {
170
+ let redelivered = 0;
171
+ for (const chatKey of Array.from(this.queues.keys())) {
172
+ redelivered += this.restartChat(chatKey).redelivered;
173
+ }
174
+ return { redelivered };
175
+ }
176
+
177
+ private async dispatch(chatKey: string, batch: PendingItem[], isFresh: boolean, signal: AbortSignal): Promise<void> {
178
+ const invocation: AgentInvocation = {
179
+ chatKey,
180
+ isFresh,
181
+ isRedelivery: batch.some((b) => b.input.isRedelivery),
182
+ messages: batch.map((b) => ({
183
+ senderOpenId: b.input.senderOpenId,
184
+ text: b.input.text,
185
+ larkMessageId: b.input.larkMessageId,
186
+ lockMessageId: b.input.lockMessageId,
187
+ parentLockMessageId: b.input.parentLockMessageId,
188
+ })),
189
+ signal,
190
+ };
191
+ let reply: AgentReply | null = null;
192
+ let aborted = false;
193
+ try {
194
+ reply = await this.deps.runtime.run(invocation);
195
+ } catch (err) {
196
+ if (signal.aborted) {
197
+ aborted = true;
198
+ this.log('log', `runtime ${this.deps.runtime.name} aborted on ${chatKey}`);
199
+ } else {
200
+ this.log('error', `runtime ${this.deps.runtime.name} threw on ${chatKey}: ${err instanceof Error ? err.message : String(err)}`);
201
+ }
202
+ reply = null;
203
+ }
204
+ if (!aborted && reply && reply.text && reply.text.trim().length > 0) {
205
+ try {
206
+ await this.deps.onReply({ chatKey, reply, sourceMessages: batch.map((b) => b.input) });
207
+ } catch (err) {
208
+ this.log('error', `onReply threw on ${chatKey}: ${err instanceof Error ? err.message : String(err)}`);
209
+ }
210
+ }
211
+ for (const item of batch) item.resolve();
212
+ const state = this.queues.get(chatKey);
213
+ if (!state) return;
214
+ if (state.pending.length === 0) {
215
+ state.inFlight = null;
216
+ this.queues.delete(chatKey);
217
+ return;
218
+ }
219
+ const nextBatch = state.pending.splice(0);
220
+ const ctl = new AbortController();
221
+ state.inFlight = { batch: nextBatch, abortController: ctl };
222
+ void this.dispatch(chatKey, nextBatch, false, ctl.signal);
223
+ }
224
+
225
+ private log(level: 'log' | 'warn' | 'error', msg: string): void {
226
+ const logger = this.deps.logger ?? console;
227
+ const fn = logger[level] ?? console[level] ?? console.log;
228
+ fn(`[dispatcher] ${msg}`);
229
+ }
230
+ }
231
+
232
+ /** Build a chat key suitable for the dispatcher from a Message row. */
233
+ export function chatKeyOf(message: Pick<Message, 'chat_id' | 'chat_name'>): string {
234
+ return message.chat_id;
235
+ }
236
+
237
+ // ─── Receive policy ────────────────────────────────────────────────────────
238
+
239
+ export type ReceivePolicy = 'every' | 'mention' | 'periodic' | 'off';
240
+
241
+ export function parseReceivePolicy(spec: string | undefined | null, fallback: ReceivePolicy = 'every'): ReceivePolicy {
242
+ const v = (spec ?? '').trim().toLowerCase();
243
+ if (v === 'every' || v === '') return spec ? 'every' : fallback;
244
+ if (v === 'mention' || v === 'mention-only' || v === 'm') return 'mention';
245
+ if (v === 'periodic' || v === 'pull' || v === 'p') return 'periodic';
246
+ if (v === 'off' || v === 'none' || v === 'silent') return 'off';
247
+ throw new Error(`unknown receive policy: ${spec}`);
248
+ }
249
+
250
+ export interface PolicyCheckInput {
251
+ policy: ReceivePolicy;
252
+ /** Bot's own open_id (for mention matching). */
253
+ botOpenId: string | null;
254
+ mentionOpenIds: string[];
255
+ }
256
+
257
+ /**
258
+ * Decide whether a freshly-ingested message should reach the agent
259
+ * dispatcher *immediately*. Note that `periodic` returns false here —
260
+ * caller is expected to route those messages into PeriodicQueue.add
261
+ * instead so they get flushed on the next tick.
262
+ */
263
+ export function shouldAcceptForAgent(input: PolicyCheckInput): boolean {
264
+ if (input.policy === 'off') return false;
265
+ if (input.policy === 'periodic') return false;
266
+ if (input.policy === 'every') return true;
267
+ // mention-only: require bot's open_id to appear in mention list.
268
+ if (!input.botOpenId) return false;
269
+ return input.mentionOpenIds.includes(input.botOpenId);
270
+ }
271
+
272
+ // ─── Periodic accumulator ──────────────────────────────────────────────────
273
+
274
+ export interface PeriodicQueueDeps {
275
+ intervalMs: number;
276
+ /** Called once per chat per tick with the accumulated batch.
277
+ * Implementation typically calls dispatcher.enqueue(...) once per item
278
+ * (the dispatcher will then coalesce them per its own semantics). */
279
+ onFlush: (input: { chatKey: string; batch: DispatchInput[] }) => Promise<void> | void;
280
+ logger?: Partial<Pick<Console, 'log' | 'warn' | 'error'>>;
281
+ }
282
+
283
+ /**
284
+ * Per-chat accumulator that flushes on a fixed interval. Used to implement
285
+ * the wiki's "定期 pull" agent receive strategy: rather than dispatching on
286
+ * every incoming message, messages buffer until the timer ticks; then they
287
+ * are released to the agent in one batch.
288
+ *
289
+ * Lifecycle:
290
+ * - `start()` arms the interval timer.
291
+ * - `add(input)` enqueues a message for its chatKey.
292
+ * - `flushChat(chatKey)` releases that chat's buffer immediately.
293
+ * - `flushAll()` releases everything.
294
+ * - `stop()` clears the timer and releases nothing.
295
+ *
296
+ * Concurrency: tick handler is async; if a tick fires while the previous
297
+ * tick is still in flight, the new tick still runs (we never block ticks).
298
+ */
299
+ export class PeriodicQueue {
300
+ private readonly buffers = new Map<string, DispatchInput[]>();
301
+ private timer: ReturnType<typeof setInterval> | null = null;
302
+ private stopped = false;
303
+
304
+ constructor(private readonly deps: PeriodicQueueDeps) {
305
+ if (deps.intervalMs <= 0) {
306
+ throw new Error('intervalMs must be > 0');
307
+ }
308
+ }
309
+
310
+ start(): void {
311
+ if (this.timer || this.stopped) return;
312
+ this.timer = setInterval(() => {
313
+ void this.flushAll();
314
+ }, this.deps.intervalMs);
315
+ // Don't block process exit on the periodic tick.
316
+ if (typeof (this.timer as { unref?: () => void }).unref === 'function') {
317
+ (this.timer as { unref: () => void }).unref();
318
+ }
319
+ }
320
+
321
+ stop(): void {
322
+ this.stopped = true;
323
+ if (this.timer) {
324
+ clearInterval(this.timer);
325
+ this.timer = null;
326
+ }
327
+ }
328
+
329
+ /** Add a message to its chat's buffer. */
330
+ add(input: DispatchInput): void {
331
+ if (this.stopped) return;
332
+ let buf = this.buffers.get(input.chatKey);
333
+ if (!buf) {
334
+ buf = [];
335
+ this.buffers.set(input.chatKey, buf);
336
+ }
337
+ buf.push(input);
338
+ }
339
+
340
+ /** Buffered counts per chat, for tests / introspection. */
341
+ inspect(): Array<{ chatKey: string; pending: number }> {
342
+ return Array.from(this.buffers.entries()).map(([chatKey, buf]) => ({ chatKey, pending: buf.length }));
343
+ }
344
+
345
+ /** Flush a single chat now. Returns the count released. */
346
+ async flushChat(chatKey: string): Promise<number> {
347
+ const buf = this.buffers.get(chatKey);
348
+ if (!buf || buf.length === 0) return 0;
349
+ const batch = buf.splice(0);
350
+ this.buffers.delete(chatKey);
351
+ try {
352
+ await this.deps.onFlush({ chatKey, batch });
353
+ } catch (err) {
354
+ this.log('error', `onFlush threw on ${chatKey}: ${err instanceof Error ? err.message : String(err)}`);
355
+ }
356
+ return batch.length;
357
+ }
358
+
359
+ /** Flush every chat. Returns the total count released. */
360
+ async flushAll(): Promise<number> {
361
+ if (this.buffers.size === 0) return 0;
362
+ let total = 0;
363
+ for (const chatKey of Array.from(this.buffers.keys())) {
364
+ total += await this.flushChat(chatKey);
365
+ }
366
+ return total;
367
+ }
368
+
369
+ private log(level: 'log' | 'warn' | 'error', msg: string): void {
370
+ const logger = this.deps.logger ?? console;
371
+ const fn = logger[level] ?? console[level] ?? console.log;
372
+ fn(`[periodic] ${msg}`);
373
+ }
374
+ }