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.
- package/README.md +176 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +975 -0
- package/dist/client.d.ts +53 -0
- package/dist/client.js +40 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.js +0 -0
- package/openclaw.plugin.json +108 -0
- package/package.json +86 -0
- package/skills/crawd/SKILL.md +81 -0
- package/src/backend/coordinator.ts +883 -0
- package/src/backend/index.ts +581 -0
- package/src/backend/server.ts +589 -0
- package/src/cli.ts +130 -0
- package/src/client.ts +101 -0
- package/src/commands/auth.ts +145 -0
- package/src/commands/config.ts +43 -0
- package/src/commands/down.ts +15 -0
- package/src/commands/logs.ts +32 -0
- package/src/commands/skill.ts +189 -0
- package/src/commands/start.ts +120 -0
- package/src/commands/status.ts +73 -0
- package/src/commands/stop.ts +16 -0
- package/src/commands/stream-key.ts +45 -0
- package/src/commands/talk.ts +30 -0
- package/src/commands/up.ts +59 -0
- package/src/commands/update.ts +92 -0
- package/src/config/schema.ts +66 -0
- package/src/config/store.ts +185 -0
- package/src/daemon/manager.ts +280 -0
- package/src/daemon/pid.ts +102 -0
- package/src/lib/chat/base.ts +13 -0
- package/src/lib/chat/manager.ts +105 -0
- package/src/lib/chat/pumpfun/client.ts +56 -0
- package/src/lib/chat/types.ts +48 -0
- package/src/lib/chat/youtube/client.ts +131 -0
- package/src/lib/pumpfun/live/client.ts +69 -0
- package/src/lib/pumpfun/live/index.ts +3 -0
- package/src/lib/pumpfun/live/types.ts +38 -0
- package/src/lib/pumpfun/v2/client.ts +139 -0
- package/src/lib/pumpfun/v2/index.ts +5 -0
- package/src/lib/pumpfun/v2/socket/client.ts +60 -0
- package/src/lib/pumpfun/v2/socket/index.ts +6 -0
- package/src/lib/pumpfun/v2/socket/types.ts +7 -0
- package/src/lib/pumpfun/v2/types.ts +234 -0
- package/src/lib/tts/tiktok.ts +91 -0
- package/src/plugin.ts +280 -0
- package/src/types.ts +78 -0
- package/src/utils/logger.ts +43 -0
- 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
|
+
}
|