crawd 0.8.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 (50) hide show
  1. package/README.md +176 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +975 -0
  4. package/dist/client.d.ts +53 -0
  5. package/dist/client.js +40 -0
  6. package/dist/types.d.ts +86 -0
  7. package/dist/types.js +0 -0
  8. package/openclaw.plugin.json +108 -0
  9. package/package.json +86 -0
  10. package/skills/crawd/SKILL.md +81 -0
  11. package/src/backend/coordinator.ts +883 -0
  12. package/src/backend/index.ts +581 -0
  13. package/src/backend/server.ts +589 -0
  14. package/src/cli.ts +130 -0
  15. package/src/client.ts +101 -0
  16. package/src/commands/auth.ts +145 -0
  17. package/src/commands/config.ts +43 -0
  18. package/src/commands/down.ts +15 -0
  19. package/src/commands/logs.ts +32 -0
  20. package/src/commands/skill.ts +189 -0
  21. package/src/commands/start.ts +120 -0
  22. package/src/commands/status.ts +73 -0
  23. package/src/commands/stop.ts +16 -0
  24. package/src/commands/stream-key.ts +45 -0
  25. package/src/commands/talk.ts +30 -0
  26. package/src/commands/up.ts +59 -0
  27. package/src/commands/update.ts +92 -0
  28. package/src/config/schema.ts +66 -0
  29. package/src/config/store.ts +185 -0
  30. package/src/daemon/manager.ts +280 -0
  31. package/src/daemon/pid.ts +102 -0
  32. package/src/lib/chat/base.ts +13 -0
  33. package/src/lib/chat/manager.ts +105 -0
  34. package/src/lib/chat/pumpfun/client.ts +56 -0
  35. package/src/lib/chat/types.ts +48 -0
  36. package/src/lib/chat/youtube/client.ts +131 -0
  37. package/src/lib/pumpfun/live/client.ts +69 -0
  38. package/src/lib/pumpfun/live/index.ts +3 -0
  39. package/src/lib/pumpfun/live/types.ts +38 -0
  40. package/src/lib/pumpfun/v2/client.ts +139 -0
  41. package/src/lib/pumpfun/v2/index.ts +5 -0
  42. package/src/lib/pumpfun/v2/socket/client.ts +60 -0
  43. package/src/lib/pumpfun/v2/socket/index.ts +6 -0
  44. package/src/lib/pumpfun/v2/socket/types.ts +7 -0
  45. package/src/lib/pumpfun/v2/types.ts +234 -0
  46. package/src/lib/tts/tiktok.ts +91 -0
  47. package/src/plugin.ts +280 -0
  48. package/src/types.ts +78 -0
  49. package/src/utils/logger.ts +43 -0
  50. package/src/utils/paths.ts +55 -0
@@ -0,0 +1,883 @@
1
+ import { randomUUID } from 'crypto'
2
+ import WebSocket from 'ws'
3
+ import type { ChatMessage } from '../lib/chat/types'
4
+
5
+ const BATCH_WINDOW_MS = 20_000
6
+ const SESSION_KEY = process.env.CRAWD_CHANNEL_ID || 'agent:main:crawd:live'
7
+
8
+ /** Coordinator configuration */
9
+ export type CoordinatorConfig = {
10
+ /** Whether autonomous vibing is enabled. Default: true */
11
+ vibeEnabled: boolean
12
+ /** How often to send vibe prompt when active (ms). Default: 60000 (1 min) */
13
+ vibeIntervalMs: number
14
+ /** Go idle after this much inactivity while active (ms). Default: 30000 (30 sec) */
15
+ idleAfterMs: number
16
+ /** Go sleep after this much inactivity while idle (ms). Default: 60000 (1 min) */
17
+ sleepAfterIdleMs: number
18
+ /** The autonomous "vibe" prompt sent periodically */
19
+ vibePrompt: string
20
+ }
21
+
22
+ export const DEFAULT_CONFIG: CoordinatorConfig = {
23
+ vibeEnabled: true,
24
+ vibeIntervalMs: 30_000,
25
+ idleAfterMs: 180_000,
26
+ sleepAfterIdleMs: 180_000,
27
+ vibePrompt: `[VIBE] You are on a livestream. Make sure the crawd skill is loaded. Do one thing on the internet or ask the chat something.`,
28
+ }
29
+
30
+ export type CoordinatorState = 'sleep' | 'idle' | 'active'
31
+
32
+ /** Parsed agent reply with optional message reference */
33
+ export type AgentReply = {
34
+ text: string
35
+ /** Formatted string of message being replied to (e.g., "@user: message") */
36
+ replyTo: string | null
37
+ /** Original message being replied to (for turn-based UI) */
38
+ originalMessage: ChatMessage | null
39
+ }
40
+
41
+ /** Function signature for triggering an agent turn */
42
+ export type TriggerAgentFn = (message: string) => Promise<string[]>
43
+
44
+ /** Payload for a node.invoke.request event from the gateway */
45
+ export type InvokeRequestPayload = {
46
+ id: string
47
+ nodeId: string
48
+ command: string
49
+ paramsJSON?: string | null
50
+ timeoutMs?: number
51
+ }
52
+
53
+ /** Interface for gateway client (allows mocking in tests) */
54
+ export interface IGatewayClient {
55
+ connect(): Promise<void>
56
+ disconnect(): void
57
+ isConnected(): boolean
58
+ isSessionBusy(): boolean
59
+ triggerAgent(message: string): Promise<string[]>
60
+ sendInvokeResult(id: string, nodeId: string, result: { ok: boolean; payload?: unknown; error?: { code: string; message: string } }): Promise<void>
61
+ onInvokeRequest?: (payload: InvokeRequestPayload) => void
62
+ }
63
+
64
+ /** Clock interface for time control in tests */
65
+ export interface IClock {
66
+ now(): number
67
+ setTimeout(callback: () => void, ms: number): NodeJS.Timeout
68
+ clearTimeout(timer: NodeJS.Timeout): void
69
+ setInterval(callback: () => void, ms: number): NodeJS.Timeout
70
+ clearInterval(timer: NodeJS.Timeout): void
71
+ }
72
+
73
+ /** Default clock using real timers */
74
+ export const realClock: IClock = {
75
+ now: () => Date.now(),
76
+ setTimeout: (cb, ms) => setTimeout(cb, ms),
77
+ clearTimeout: (t) => clearTimeout(t),
78
+ setInterval: (cb, ms) => setInterval(cb, ms),
79
+ clearInterval: (t) => clearInterval(t),
80
+ }
81
+
82
+ type GatewayFrame = {
83
+ type: 'req' | 'res' | 'event'
84
+ id?: string
85
+ method?: string
86
+ params?: Record<string, unknown>
87
+ ok?: boolean
88
+ payload?: {
89
+ status?: 'accepted' | 'ok' | 'error'
90
+ result?: {
91
+ payloads?: Array<{ text?: string }>
92
+ }
93
+ [key: string]: unknown
94
+ }
95
+ result?: unknown
96
+ error?: { code: number; message: string }
97
+ }
98
+
99
+ /**
100
+ * Gateway client for OpenClaw WebSocket protocol (persistent connection).
101
+ * Used by standalone mode (backend/index.ts) which needs persistent connection
102
+ * for node.invoke event handling.
103
+ */
104
+ export class GatewayClient implements IGatewayClient {
105
+ private ws: WebSocket | null = null
106
+ private url: string
107
+ private token: string
108
+ private connected = false
109
+ private pendingRequests = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>()
110
+ /** Track active run IDs for our session to detect if agent is busy (runId → startTimestamp) */
111
+ private activeRunIds = new Map<string, number>()
112
+ private static readonly RUN_TTL_MS = 120_000
113
+ private targetSessionKey: string
114
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
115
+ private reconnectDelay = 1000
116
+ private readonly maxReconnectDelay = 30_000
117
+ private shouldReconnect = false
118
+
119
+ /** Callback invoked when the gateway dispatches a node.invoke.request */
120
+ onInvokeRequest?: (payload: InvokeRequestPayload) => void
121
+
122
+ constructor(url: string, token: string, sessionKey: string = SESSION_KEY) {
123
+ this.url = url
124
+ this.token = token
125
+ this.targetSessionKey = sessionKey
126
+ }
127
+
128
+
129
+ async connect(): Promise<void> {
130
+ this.shouldReconnect = true
131
+ return this.doConnect()
132
+ }
133
+
134
+ private async doConnect(): Promise<void> {
135
+ return new Promise((resolve, reject) => {
136
+ this.ws = new WebSocket(this.url)
137
+ let settled = false
138
+
139
+ this.ws.on('open', async () => {
140
+ try {
141
+ await this.authenticate()
142
+ this.connected = true
143
+ this.reconnectDelay = 1000
144
+ console.log('[Gateway] Connected and authenticated')
145
+ if (!settled) { settled = true; resolve() }
146
+ } catch (err) {
147
+ if (!settled) { settled = true; reject(err) }
148
+ }
149
+ })
150
+
151
+ this.ws.on('message', (data) => {
152
+ try {
153
+ const frame = JSON.parse(data.toString()) as GatewayFrame
154
+ this.handleFrame(frame)
155
+ } catch (err) {
156
+ console.error('[Gateway] Failed to parse message:', err)
157
+ }
158
+ })
159
+
160
+ this.ws.on('close', () => {
161
+ this.connected = false
162
+ this.activeRunIds.clear()
163
+ console.log('[Gateway] Disconnected')
164
+ this.scheduleReconnect()
165
+ })
166
+
167
+ this.ws.on('error', (err) => {
168
+ console.error('[Gateway] Error:', err)
169
+ if (!settled) { settled = true; reject(err) }
170
+ })
171
+ })
172
+ }
173
+
174
+ private scheduleReconnect(): void {
175
+ if (!this.shouldReconnect) return
176
+ if (this.reconnectTimer) return
177
+ console.log(`[Gateway] Reconnecting in ${this.reconnectDelay / 1000}s...`)
178
+ this.reconnectTimer = setTimeout(async () => {
179
+ this.reconnectTimer = null
180
+ try {
181
+ await this.doConnect()
182
+ } catch {
183
+ // doConnect failed, bump delay and retry
184
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay)
185
+ this.scheduleReconnect()
186
+ }
187
+ }, this.reconnectDelay)
188
+ }
189
+
190
+ private async authenticate(): Promise<void> {
191
+ // Skip connected check since we're establishing the connection
192
+ return this.request('connect', {
193
+ minProtocol: 3,
194
+ maxProtocol: 3,
195
+ client: {
196
+ id: 'gateway-client',
197
+ version: '1.0.0',
198
+ platform: 'node',
199
+ mode: 'backend',
200
+ },
201
+ commands: ['talk'],
202
+ auth: { token: this.token },
203
+ }, true) as Promise<void>
204
+ }
205
+
206
+ private handleFrame(frame: GatewayFrame): void {
207
+ // Log all incoming frames for debugging (skip noisy ones)
208
+ if (frame.type !== 'res' || !frame.id?.startsWith('connect')) {
209
+ // Skip logging frequent health/presence events
210
+ const eventType = (frame as any).event
211
+ if (eventType !== 'health' && eventType !== 'presence') {
212
+ console.log('[Gateway] Frame received:', JSON.stringify(frame))
213
+ }
214
+ }
215
+
216
+ // Handle node.invoke.request events from gateway
217
+ if (frame.type === 'event') {
218
+ const eventType = (frame as any).event
219
+ if (eventType === 'node.invoke.request' && this.onInvokeRequest) {
220
+ const payload = (frame as any).payload as InvokeRequestPayload
221
+ if (payload?.id && payload?.command) {
222
+ this.onInvokeRequest(payload)
223
+ }
224
+ return
225
+ }
226
+ }
227
+
228
+ // Track agent run events for our session to detect busy state
229
+ if (frame.type === 'event') {
230
+ const payload = frame.payload as any
231
+ const eventType = (frame as any).event
232
+
233
+ if (eventType === 'agent' && payload?.sessionKey === this.targetSessionKey) {
234
+ const runId = payload.runId as string | undefined
235
+ const stream = payload.stream as string | undefined
236
+ const phase = payload.data?.phase as string | undefined
237
+
238
+ if (runId) {
239
+ // Track run start/end based on lifecycle events or stream type
240
+ // Lifecycle events: stream='lifecycle', data.phase='start'|'end'
241
+ // Other activity: stream='tool'|'assistant'
242
+ if (stream === 'lifecycle' && phase === 'start') {
243
+ this.activeRunIds.set(runId, Date.now())
244
+ } else if (stream === 'lifecycle' && (phase === 'end' || phase === 'error')) {
245
+ this.activeRunIds.delete(runId)
246
+ } else if (stream === 'tool' || stream === 'assistant') {
247
+ // Also mark as active during streaming
248
+ this.activeRunIds.set(runId, Date.now())
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ if (frame.type === 'res' && frame.id) {
255
+ const pending = this.pendingRequests.get(frame.id)
256
+ if (pending) {
257
+ if (frame.error) {
258
+ this.pendingRequests.delete(frame.id)
259
+ console.error('[Gateway] Request error:', frame.error)
260
+ pending.reject(new Error(frame.error.message))
261
+ } else if (frame.payload?.status === 'accepted') {
262
+ // Agent request accepted but not complete yet - wait for final response
263
+ console.log('[Gateway] Request accepted, waiting for result...')
264
+ } else {
265
+ // Final response (status "ok" or no status for non-agent requests)
266
+ this.pendingRequests.delete(frame.id)
267
+ pending.resolve(frame.payload ?? frame.result)
268
+ }
269
+ }
270
+ }
271
+ }
272
+
273
+ private request(method: string, params: Record<string, unknown>, skipConnectedCheck = false): Promise<unknown> {
274
+ return new Promise((resolve, reject) => {
275
+ if (!this.ws) {
276
+ reject(new Error('WebSocket not initialized'))
277
+ return
278
+ }
279
+ if (!skipConnectedCheck && !this.connected) {
280
+ reject(new Error('Not connected'))
281
+ return
282
+ }
283
+
284
+ const id = randomUUID()
285
+ const frame: GatewayFrame = { type: 'req', id, method, params }
286
+
287
+ this.pendingRequests.set(id, { resolve, reject })
288
+ this.ws.send(JSON.stringify(frame))
289
+
290
+ // Timeout after 60s
291
+ setTimeout(() => {
292
+ if (this.pendingRequests.has(id)) {
293
+ this.pendingRequests.delete(id)
294
+ reject(new Error('Request timeout'))
295
+ }
296
+ }, 60_000)
297
+ })
298
+ }
299
+
300
+ async triggerAgent(message: string): Promise<string[]> {
301
+ const result = await this.request('agent', {
302
+ message,
303
+ idempotencyKey: randomUUID(),
304
+ sessionKey: this.targetSessionKey,
305
+ }) as any
306
+
307
+ // Extract text from ALL payloads in agent response
308
+ const payloads = result?.result?.payloads as Array<{ text?: string }> | undefined
309
+ if (!payloads?.length) {
310
+ console.log('[Gateway] Agent response (no payloads):', JSON.stringify(result, null, 2))
311
+ return []
312
+ }
313
+
314
+ const texts = payloads
315
+ .map(p => p.text)
316
+ .filter((t): t is string => typeof t === 'string' && t.length > 0)
317
+
318
+ if (texts.length > 0) {
319
+ console.log(`[Gateway] Agent replied with ${texts.length} message(s):`, texts)
320
+ } else {
321
+ console.log('[Gateway] Agent response (no text in payloads):', JSON.stringify(result, null, 2))
322
+ }
323
+
324
+ return texts
325
+ }
326
+
327
+ isConnected(): boolean {
328
+ return this.connected
329
+ }
330
+
331
+ /** Check if the target session has an active agent run (evicts stale entries beyond TTL) */
332
+ isSessionBusy(): boolean {
333
+ const now = Date.now()
334
+ for (const [runId, startedAt] of this.activeRunIds) {
335
+ if (now - startedAt > GatewayClient.RUN_TTL_MS) {
336
+ this.activeRunIds.delete(runId)
337
+ }
338
+ }
339
+ return this.activeRunIds.size > 0
340
+ }
341
+
342
+ async sendInvokeResult(
343
+ id: string,
344
+ nodeId: string,
345
+ result: { ok: boolean; payload?: unknown; error?: { code: string; message: string } }
346
+ ): Promise<void> {
347
+ const params: Record<string, unknown> = { id, nodeId, ok: result.ok }
348
+ if (result.payload !== undefined) params.payload = result.payload
349
+ if (result.error) params.error = result.error
350
+ await this.request('node.invoke.result', params)
351
+ }
352
+
353
+ disconnect(): void {
354
+ this.shouldReconnect = false
355
+ if (this.reconnectTimer) {
356
+ clearTimeout(this.reconnectTimer)
357
+ this.reconnectTimer = null
358
+ }
359
+ this.ws?.close()
360
+ this.ws = null
361
+ this.connected = false
362
+ this.activeRunIds.clear()
363
+ }
364
+ }
365
+
366
+ /**
367
+ * One-shot gateway client. Opens a fresh WebSocket per triggerAgent call —
368
+ * authenticates, sends the agent request, waits for the full response, then closes.
369
+ * No persistent connection or reconnect logic needed.
370
+ */
371
+ export class OneShotGateway {
372
+ constructor(
373
+ private url: string,
374
+ private token: string,
375
+ private sessionKey: string = SESSION_KEY,
376
+ ) {
377
+ console.log(`[OneShotGateway] url=${url} session=${sessionKey}`)
378
+ }
379
+
380
+ async triggerAgent(message: string): Promise<string[]> {
381
+ return new Promise((resolve, reject) => {
382
+ const ws = new WebSocket(this.url)
383
+ const authId = randomUUID()
384
+ const agentId = randomUUID()
385
+ let settled = false
386
+
387
+ const timeout = setTimeout(() => {
388
+ if (!settled) {
389
+ settled = true
390
+ ws.close()
391
+ reject(new Error('One-shot gateway request timed out (120s)'))
392
+ }
393
+ }, 120_000)
394
+
395
+ const finish = (result?: string[], error?: Error) => {
396
+ if (settled) return
397
+ settled = true
398
+ clearTimeout(timeout)
399
+ ws.close()
400
+ if (error) {
401
+ console.error(`[OneShotGateway] error: ${error.message}`)
402
+ reject(error)
403
+ } else {
404
+ resolve(result ?? [])
405
+ }
406
+ }
407
+
408
+ const sendConnect = () => {
409
+ ws.send(JSON.stringify({
410
+ type: 'req',
411
+ id: authId,
412
+ method: 'connect',
413
+ params: {
414
+ minProtocol: 3,
415
+ maxProtocol: 3,
416
+ client: {
417
+ id: 'gateway-client',
418
+ version: '1.0.0',
419
+ platform: 'node',
420
+ mode: 'backend',
421
+ },
422
+ commands: ['talk'],
423
+ auth: this.token ? { token: this.token } : {},
424
+ },
425
+ }))
426
+ }
427
+
428
+ ws.on('message', (data) => {
429
+ try {
430
+ const frame = JSON.parse(data.toString()) as GatewayFrame
431
+
432
+ // Gateway sends connect.challenge before accepting connect requests
433
+ if (frame.type === 'event' && (frame as any).event === 'connect.challenge') {
434
+ sendConnect()
435
+ return
436
+ }
437
+
438
+ if (frame.type === 'res' && frame.id === authId) {
439
+ if (frame.error) {
440
+ finish(undefined, new Error(`Gateway auth failed: ${frame.error.message}`))
441
+ return
442
+ }
443
+ ws.send(JSON.stringify({
444
+ type: 'req',
445
+ id: agentId,
446
+ method: 'agent',
447
+ params: {
448
+ message,
449
+ idempotencyKey: randomUUID(),
450
+ sessionKey: this.sessionKey,
451
+ },
452
+ }))
453
+ } else if (frame.type === 'res' && frame.id === agentId) {
454
+ if (frame.error) {
455
+ finish(undefined, new Error(`Gateway agent request failed: ${frame.error.message}`))
456
+ return
457
+ }
458
+ if (frame.payload?.status === 'accepted') return
459
+ const payloads = (frame.payload as any)?.result?.payloads as Array<{ text?: string }> | undefined
460
+ const texts = payloads
461
+ ?.map(p => p.text)
462
+ .filter((t): t is string => typeof t === 'string' && t.length > 0) ?? []
463
+ finish(texts)
464
+ }
465
+ // Ignore other frames (health, agent stream events, etc.)
466
+ } catch {
467
+ // Parse error — ignore
468
+ }
469
+ })
470
+
471
+ ws.on('error', (err) => {
472
+ finish(undefined, err instanceof Error ? err : new Error(String(err)))
473
+ })
474
+
475
+ ws.on('close', () => {
476
+ if (!settled) {
477
+ finish(undefined, new Error('WebSocket closed unexpectedly'))
478
+ }
479
+ })
480
+ })
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Coordinator: batches chat messages and triggers agent turns.
486
+ */
487
+ /** Grace period for filtering old messages (30 seconds before startup) */
488
+ const STARTUP_GRACE_MS = 30_000
489
+ /** How often to check for sleep condition (ms) */
490
+ const SLEEP_CHECK_INTERVAL_MS = 10_000
491
+
492
+ /** Dependencies that can be injected for testing */
493
+ export type CoordinatorDeps = {
494
+ clock?: IClock
495
+ logger?: Pick<Console, 'log' | 'error' | 'warn'>
496
+ }
497
+
498
+ /** Event types emitted by coordinator for observability */
499
+ export type CoordinatorEvent =
500
+ | { type: 'stateChange'; from: CoordinatorState; to: CoordinatorState }
501
+ | { type: 'vibeScheduled'; nextVibeAt: number }
502
+ | { type: 'vibeExecuted'; skipped: boolean; reason?: string }
503
+ | { type: 'sleepCheck'; inactiveForMs: number; willSleep: boolean }
504
+ | { type: 'chatProcessed'; count: number }
505
+
506
+ export class Coordinator {
507
+ private buffer: ChatMessage[] = []
508
+ private timer: NodeJS.Timeout | null = null
509
+ private triggerFn: TriggerAgentFn
510
+ private onEvent?: (event: CoordinatorEvent) => void
511
+ /** Timestamp when coordinator was created (used to filter old messages on restart) */
512
+ private readonly startedAt: number
513
+
514
+ // === State Machine ===
515
+ private config: CoordinatorConfig
516
+ private _state: CoordinatorState = 'sleep'
517
+ private lastActivityAt = 0
518
+ private idleSince = 0
519
+ private vibeTimer: NodeJS.Timeout | null = null
520
+ private sleepCheckTimer: NodeJS.Timeout | null = null
521
+ /** True while a flush or talk is being processed — vibes should wait */
522
+ private _busy = false
523
+ /** Serializes all triggerAgent calls to prevent concurrent runs */
524
+ private _gatewayQueue: Promise<void> = Promise.resolve()
525
+
526
+ // === Injected dependencies ===
527
+ private readonly clock: IClock
528
+ private readonly logger: Pick<Console, 'log' | 'error' | 'warn'>
529
+
530
+ /** Recent messages by shortId — used to look up chat messages for talk tool replies */
531
+ private recentMessages = new Map<string, ChatMessage>()
532
+
533
+ constructor(
534
+ triggerAgent: TriggerAgentFn,
535
+ config: Partial<CoordinatorConfig> = {},
536
+ deps: CoordinatorDeps = {}
537
+ ) {
538
+ this.triggerFn = triggerAgent
539
+ this.config = { ...DEFAULT_CONFIG, ...config }
540
+
541
+ // Inject dependencies or use defaults
542
+ this.clock = deps.clock ?? realClock
543
+ this.logger = deps.logger ?? console
544
+ this.startedAt = this.clock.now()
545
+ }
546
+
547
+ /** Get current state (public for testing) */
548
+ get state(): CoordinatorState {
549
+ return this._state
550
+ }
551
+
552
+ /** Update configuration at runtime */
553
+ updateConfig(config: Partial<CoordinatorConfig>): void {
554
+ this.config = { ...this.config, ...config }
555
+ this.logger.log('[Coordinator] Config updated:', {
556
+ vibeIntervalMs: this.config.vibeIntervalMs,
557
+ idleAfterMs: this.config.idleAfterMs,
558
+ sleepAfterIdleMs: this.config.sleepAfterIdleMs,
559
+ })
560
+ }
561
+
562
+ /** Get current state and config */
563
+ getState(): { state: CoordinatorState; lastActivityAt: number; config: CoordinatorConfig } {
564
+ return {
565
+ state: this._state,
566
+ lastActivityAt: this.lastActivityAt,
567
+ config: this.config,
568
+ }
569
+ }
570
+
571
+ /** Look up a recent chat message by shortId (for talk tool replyTo) */
572
+ getRecentMessage(shortId: string): ChatMessage | undefined {
573
+ return this.recentMessages.get(shortId)
574
+ }
575
+
576
+ /** Set callback for coordinator events (useful for debugging/testing) */
577
+ setOnEvent(callback: (event: CoordinatorEvent) => void): void {
578
+ this.onEvent = callback
579
+ }
580
+
581
+ private emit(event: CoordinatorEvent): void {
582
+ this.onEvent?.(event)
583
+ }
584
+
585
+ start(): void {
586
+ this.logger.log('[Coordinator] Started in SLEEP state')
587
+ this.logger.log('[Coordinator] Config:', {
588
+ vibeIntervalMs: this.config.vibeIntervalMs,
589
+ idleAfterMs: this.config.idleAfterMs,
590
+ sleepAfterIdleMs: this.config.sleepAfterIdleMs,
591
+ })
592
+ }
593
+
594
+ stop(): void {
595
+ this.stopVibeLoop()
596
+ if (this.timer) {
597
+ this.clock.clearTimeout(this.timer)
598
+ this.timer = null
599
+ }
600
+ this._state = 'sleep'
601
+ this.logger.log('[Coordinator] Stopped')
602
+ }
603
+
604
+ // === State Machine Methods ===
605
+
606
+ /** Wake up from sleep/idle state and start the vibe loop */
607
+ wake(): void {
608
+ if (this._state === 'active') return
609
+
610
+ const from = this._state
611
+ this._state = 'active'
612
+ this.lastActivityAt = this.clock.now()
613
+ this.logger.log('[Coordinator] WAKE - transitioning to ACTIVE state')
614
+ this.emit({ type: 'stateChange', from, to: 'active' })
615
+
616
+ this.startVibeLoop()
617
+ }
618
+
619
+ /** Go to idle state (between activities, eyes open) */
620
+ goIdle(): void {
621
+ if (this._state === 'idle') return
622
+ if (this._state === 'sleep') return // Don't go from sleep to idle, need wake() first
623
+
624
+ const from = this._state
625
+ this._state = 'idle'
626
+ this.idleSince = this.clock.now()
627
+ this.logger.log('[Coordinator] IDLE - transitioning to IDLE state (waiting)')
628
+ this.emit({ type: 'stateChange', from, to: 'idle' })
629
+
630
+ // Keep vibe loop running but will check for sleep transition
631
+ }
632
+
633
+ /** Go to sleep state (extended inactivity, eyes closed) */
634
+ goSleep(): void {
635
+ if (this._state === 'sleep') return
636
+
637
+ const from = this._state
638
+ this._state = 'sleep'
639
+ this.logger.log('[Coordinator] SLEEP - transitioning to SLEEP state')
640
+ this.emit({ type: 'stateChange', from, to: 'sleep' })
641
+
642
+ this.stopVibeLoop()
643
+ this.compactSession()
644
+ }
645
+
646
+ /** Compact the agent's session context before sleeping to free stale history */
647
+ private compactSession(): void {
648
+ this._gatewayQueue = this._gatewayQueue.then(async () => {
649
+ try {
650
+ await this.triggerFn('/compact')
651
+ this.logger.log('[Coordinator] Session compacted before sleep')
652
+ } catch (err) {
653
+ this.logger.error('[Coordinator] Failed to compact session:', err)
654
+ }
655
+ }).catch(() => {})
656
+ }
657
+
658
+ /** Signal that the agent is speaking (via tool call) — keeps coordinator awake */
659
+ notifySpeech(): void {
660
+ if (this._state !== 'active') this.wake()
661
+ else this.resetActivity()
662
+ }
663
+
664
+ /** Reset activity timer (called on chat messages) */
665
+ resetActivity(): void {
666
+ this.lastActivityAt = this.clock.now()
667
+ }
668
+
669
+ /** Get time since last activity */
670
+ getInactiveTime(): number {
671
+ return this.clock.now() - this.lastActivityAt
672
+ }
673
+
674
+ /** Start the periodic vibe loop */
675
+ private startVibeLoop(): void {
676
+ this.stopVibeLoop() // Clear any existing timer
677
+
678
+ // Start inactivity check timer (handles active → idle → sleep transitions)
679
+ this.sleepCheckTimer = this.clock.setInterval(() => {
680
+ const inactiveFor = this.clock.now() - this.lastActivityAt
681
+
682
+ if (this._state === 'active') {
683
+ // Active → Idle after idleAfterMs
684
+ const willIdle = inactiveFor >= this.config.idleAfterMs
685
+ this.emit({ type: 'sleepCheck', inactiveForMs: inactiveFor, willSleep: false })
686
+
687
+ if (willIdle) {
688
+ this.logger.log(`[Coordinator] No activity for ${Math.round(inactiveFor / 1000)}s, going idle`)
689
+ this.goIdle()
690
+ }
691
+ } else if (this._state === 'idle') {
692
+ // Idle → Sleep after sleepAfterIdleMs (measured from when we entered idle)
693
+ const idleDuration = this.clock.now() - this.idleSince
694
+ const willSleep = idleDuration >= this.config.sleepAfterIdleMs
695
+ this.emit({ type: 'sleepCheck', inactiveForMs: idleDuration, willSleep })
696
+
697
+ if (willSleep) {
698
+ this.logger.log(`[Coordinator] Idle for ${Math.round(idleDuration / 1000)}s, going to sleep`)
699
+ this.goSleep()
700
+ }
701
+ }
702
+ }, SLEEP_CHECK_INTERVAL_MS)
703
+
704
+ // Start vibe loop
705
+ this.scheduleNextVibe()
706
+ }
707
+
708
+ /** Stop the vibe loop */
709
+ private stopVibeLoop(): void {
710
+ if (this.vibeTimer) {
711
+ this.clock.clearTimeout(this.vibeTimer)
712
+ this.vibeTimer = null
713
+ }
714
+ if (this.sleepCheckTimer) {
715
+ this.clock.clearInterval(this.sleepCheckTimer)
716
+ this.sleepCheckTimer = null
717
+ }
718
+ }
719
+
720
+ /** Schedule the next vibe action */
721
+ scheduleNextVibe(): void {
722
+ // Vibe while active or idle (not while sleeping)
723
+ if (this._state === 'sleep') return
724
+ if (!this.config.vibeEnabled) return
725
+
726
+ const nextVibeAt = this.clock.now() + this.config.vibeIntervalMs
727
+ this.emit({ type: 'vibeScheduled', nextVibeAt })
728
+ this.vibeTimer = this.clock.setTimeout(() => this.vibe(), this.config.vibeIntervalMs)
729
+ }
730
+
731
+ /** Execute one autonomous "vibe" action */
732
+ async vibe(): Promise<void> {
733
+ // Can vibe while active or idle (not while sleeping)
734
+ if (this._state === 'sleep') {
735
+ this.emit({ type: 'vibeExecuted', skipped: true, reason: 'sleeping' })
736
+ return
737
+ }
738
+
739
+ // Skip vibe if a flush/talk is still in progress (waiting for overlay ack)
740
+ if (this._busy) {
741
+ this.logger.log('[Coordinator] Vibe skipped - talk in progress')
742
+ this.emit({ type: 'vibeExecuted', skipped: true, reason: 'talk in progress' })
743
+ this.scheduleNextVibe()
744
+ return
745
+ }
746
+
747
+ // Transition to active if idle (vibing is activity)
748
+ if (this._state === 'idle') {
749
+ const from = this._state
750
+ this._state = 'active'
751
+ this.emit({ type: 'stateChange', from, to: 'active' })
752
+ }
753
+
754
+ this.logger.log('[Coordinator] Vibe - sending autonomous prompt')
755
+ this.emit({ type: 'vibeExecuted', skipped: false })
756
+
757
+ // Reset activity timer
758
+ this.resetActivity()
759
+
760
+ // Chain on the gateway queue to prevent concurrent triggerAgent() calls
761
+ this._busy = true
762
+ let noReply = false
763
+ const vibeOp = this._gatewayQueue.then(async () => {
764
+ this._busy = true
765
+ try {
766
+ const replies = await this.triggerFn(this.config.vibePrompt)
767
+ // Agent sends NO_REPLY when it has nothing to do — go to sleep
768
+ if (replies.some(r => r.trim().toUpperCase().includes('NO_REPLY'))) {
769
+ noReply = true
770
+ }
771
+ } catch (err) {
772
+ this.logger.error('[Coordinator] Vibe failed:', err)
773
+ } finally {
774
+ this._busy = false
775
+ }
776
+ })
777
+ this._gatewayQueue = vibeOp.catch(() => {})
778
+
779
+ try {
780
+ await vibeOp
781
+ } catch {}
782
+
783
+ if (noReply) {
784
+ this.logger.log('[Coordinator] Agent sent NO_REPLY, going to sleep')
785
+ this.goSleep()
786
+ return
787
+ }
788
+
789
+ // Schedule next vibe
790
+ this.scheduleNextVibe()
791
+ }
792
+
793
+ /**
794
+ * Called for each incoming chat message from any platform.
795
+ * Uses leading-edge throttle: flushes immediately on first message,
796
+ * then buffers during cooldown window.
797
+ *
798
+ * Also wakes up the coordinator if idle and resets activity timer.
799
+ */
800
+ onMessage(msg: ChatMessage): void {
801
+ // Skip messages older than startup time (with grace period) to avoid
802
+ // reprocessing chat history when container restarts
803
+ const cutoff = this.startedAt - STARTUP_GRACE_MS
804
+ if (msg.timestamp && msg.timestamp < cutoff) {
805
+ return
806
+ }
807
+
808
+ // Buffer the message — don't wake yet. We only wake when the agent
809
+ // actually produces a reply (talk tool or text fallback). This prevents
810
+ // the bot from visually waking up and then doing nothing.
811
+ this.buffer.push(msg)
812
+
813
+ // Store for shortId lookup (talk tool replyTo). Cap at 200 to avoid unbounded growth.
814
+ if (msg.shortId) {
815
+ this.recentMessages.set(msg.shortId, msg)
816
+ if (this.recentMessages.size > 200) {
817
+ const oldest = this.recentMessages.keys().next().value!
818
+ this.recentMessages.delete(oldest)
819
+ }
820
+ }
821
+
822
+ // Leading edge: if no timer running, flush immediately and start cooldown
823
+ if (!this.timer) {
824
+ this.flush()
825
+ this.timer = this.clock.setTimeout(() => this.onCooldownEnd(), BATCH_WINDOW_MS)
826
+ }
827
+ // Otherwise, message is buffered and will be flushed when cooldown ends
828
+ }
829
+
830
+ private onCooldownEnd(): void {
831
+ this.timer = null
832
+
833
+ // If messages accumulated during cooldown, flush them and restart cooldown
834
+ if (this.buffer.length > 0) {
835
+ this.flush()
836
+ this.timer = this.clock.setTimeout(() => this.onCooldownEnd(), BATCH_WINDOW_MS)
837
+ }
838
+ }
839
+
840
+ /** Whether the coordinator is busy processing a flush or talk */
841
+ get busy(): boolean { return this._busy }
842
+
843
+ private flush(): void {
844
+ if (this.buffer.length === 0) return
845
+
846
+ const batch = this.buffer.splice(0)
847
+ const batchText = this.formatBatch(batch)
848
+
849
+ this.logger.log(`[Coordinator] Flushing ${batch.length} messages`)
850
+ this.emit({ type: 'chatProcessed', count: batch.length })
851
+
852
+ // Chain on the gateway queue to prevent concurrent triggerAgent() calls
853
+ this._busy = true
854
+ this._gatewayQueue = this._gatewayQueue.then(async () => {
855
+ this._busy = true
856
+ try {
857
+ await this.triggerFn(batchText)
858
+ } catch (err) {
859
+ this.logger.error('[Coordinator] Failed to trigger agent:', err)
860
+ } finally {
861
+ this._busy = false
862
+ }
863
+ }).catch(() => {})
864
+ }
865
+
866
+ formatBatch(messages: ChatMessage[]): string {
867
+ const duration = messages.length > 1
868
+ ? Math.round((this.clock.now() - (messages[0].timestamp ?? this.clock.now())) / 1000)
869
+ : 0
870
+
871
+ const header = `[CHAT - ${messages.length} message${messages.length === 1 ? '' : 's'}${duration > 0 ? `, ${duration}s` : ''}]`
872
+ const lines = messages.map(m => {
873
+ const platform = m.platform && m.platform !== 'pumpfun' ? `[${m.platform.toUpperCase()}] ` : ''
874
+ return `[${m.shortId}] ${platform}${m.username}: ${m.message}`
875
+ })
876
+
877
+ const instruction = messages.length > 1
878
+ ? '\n(To reply to a specific message, prefix with its ID: [msgId] your reply)'
879
+ : ''
880
+
881
+ return `${header}\n${lines.join('\n')}${instruction}`
882
+ }
883
+ }