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,280 @@
1
+ import { spawn, type ChildProcess } from 'child_process'
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ createWriteStream,
6
+ cpSync,
7
+ writeFileSync,
8
+ } from 'fs'
9
+ import { join } from 'path'
10
+ import {
11
+ LOGS_DIR,
12
+ LOG_FILES,
13
+ OVERLAY_DIR,
14
+ BACKEND_DIR,
15
+ BACKEND_TEMPLATE_DIR,
16
+ CRAWD_HOME,
17
+ TTS_CACHE_DIR,
18
+ } from '../utils/paths.js'
19
+ import { log } from '../utils/logger.js'
20
+ import { writePid, killProcess, isRunning, type ProcessName } from './pid.js'
21
+ import { loadConfig, loadEnv } from '../config/store.js'
22
+
23
+ /** Ensure all required directories exist */
24
+ export function ensureDirectories() {
25
+ const dirs = [CRAWD_HOME, LOGS_DIR, OVERLAY_DIR, BACKEND_DIR, TTS_CACHE_DIR]
26
+ for (const dir of dirs) {
27
+ if (!existsSync(dir)) {
28
+ mkdirSync(dir, { recursive: true })
29
+ }
30
+ }
31
+ }
32
+
33
+ /** Copy overlay template to user directory if needed */
34
+ export function ensureOverlay(force = false) {
35
+ if (force || !existsSync(join(OVERLAY_DIR, 'package.json'))) {
36
+ log.info('Setting up overlay...')
37
+
38
+ if (!existsSync(OVERLAY_TEMPLATE_DIR)) {
39
+ throw new Error(
40
+ `Overlay template not found at ${OVERLAY_TEMPLATE_DIR}. ` +
41
+ 'This is a bug in the crawd package.'
42
+ )
43
+ }
44
+
45
+ cpSync(OVERLAY_TEMPLATE_DIR, OVERLAY_DIR, { recursive: true })
46
+ log.success('Overlay installed to ' + OVERLAY_DIR)
47
+ return true
48
+ }
49
+ return false
50
+ }
51
+
52
+ /** Copy backend template to user directory if needed */
53
+ export function ensureBackend(force = false) {
54
+ if (force || !existsSync(join(BACKEND_DIR, 'index.ts'))) {
55
+ log.info('Setting up backend...')
56
+
57
+ if (!existsSync(BACKEND_TEMPLATE_DIR)) {
58
+ throw new Error(
59
+ `Backend template not found at ${BACKEND_TEMPLATE_DIR}. ` +
60
+ 'This is a bug in the crawd package.'
61
+ )
62
+ }
63
+
64
+ cpSync(BACKEND_TEMPLATE_DIR, BACKEND_DIR, { recursive: true })
65
+
66
+ // Create a minimal package.json for the backend
67
+ const backendPkg = {
68
+ name: '@crawd/backend',
69
+ type: 'module',
70
+ private: true,
71
+ dependencies: {
72
+ '@fastify/cors': '^10.0.2',
73
+ '@fastify/static': '^8.1.0',
74
+ 'dotenv': '^16.4.7',
75
+ 'fastify': '^5.2.1',
76
+ 'socket.io': '^4.8.1',
77
+ },
78
+ optionalDependencies: {
79
+ 'openai': '^4.77.0',
80
+ '@elevenlabs/elevenlabs-js': '^1.0.0',
81
+ },
82
+ }
83
+
84
+ writeFileSync(
85
+ join(BACKEND_DIR, 'package.json'),
86
+ JSON.stringify(backendPkg, null, 2)
87
+ )
88
+
89
+ log.success('Backend installed to ' + BACKEND_DIR)
90
+ return true
91
+ }
92
+ return false
93
+ }
94
+
95
+ /** Check if backend dependencies are installed */
96
+ function backendHasNodeModules(): boolean {
97
+ return existsSync(join(BACKEND_DIR, 'node_modules'))
98
+ }
99
+
100
+ /** Install backend dependencies */
101
+ async function installBackendDeps(): Promise<void> {
102
+ log.info('Installing backend dependencies...')
103
+
104
+ return new Promise((resolve, reject) => {
105
+ const child = spawn('pnpm', ['install'], {
106
+ cwd: BACKEND_DIR,
107
+ stdio: 'inherit',
108
+ })
109
+
110
+ child.on('close', (code) => {
111
+ if (code === 0) {
112
+ log.success('Backend dependencies installed')
113
+ resolve()
114
+ } else {
115
+ reject(new Error(`pnpm install failed with code ${code}`))
116
+ }
117
+ })
118
+
119
+ child.on('error', reject)
120
+ })
121
+ }
122
+
123
+ /** Check if overlay dependencies are installed */
124
+ function overlayHasNodeModules(): boolean {
125
+ return existsSync(join(OVERLAY_DIR, 'node_modules'))
126
+ }
127
+
128
+ /** Install overlay dependencies */
129
+ async function installOverlayDeps(): Promise<void> {
130
+ log.info('Installing overlay dependencies...')
131
+
132
+ return new Promise((resolve, reject) => {
133
+ const child = spawn('pnpm', ['install'], {
134
+ cwd: OVERLAY_DIR,
135
+ stdio: 'inherit',
136
+ })
137
+
138
+ child.on('close', (code) => {
139
+ if (code === 0) {
140
+ log.success('Overlay dependencies installed')
141
+ resolve()
142
+ } else {
143
+ reject(new Error(`pnpm install failed with code ${code}`))
144
+ }
145
+ })
146
+
147
+ child.on('error', reject)
148
+ })
149
+ }
150
+
151
+ /** Build environment variables for child processes */
152
+ function buildEnv(): NodeJS.ProcessEnv {
153
+ const config = loadConfig()
154
+ const secrets = loadEnv()
155
+ const env: NodeJS.ProcessEnv = { ...process.env }
156
+
157
+ // Secrets from ~/.crawd/.env
158
+ for (const [key, value] of Object.entries(secrets)) {
159
+ env[key] = value
160
+ }
161
+
162
+ // Config from ~/.crawd/config.json
163
+ env.CRAWD_GATEWAY_URL = config.gateway.url
164
+ env.CRAWD_CHANNEL_ID = config.gateway.channelId
165
+ env.CRAWD_BACKEND_PORT = String(config.ports.backend)
166
+ env.CRAWD_OVERLAY_PORT = String(config.ports.overlay)
167
+
168
+ return env
169
+ }
170
+
171
+ /** Start a daemon process */
172
+ function startDaemon(
173
+ name: ProcessName,
174
+ command: string,
175
+ args: string[],
176
+ cwd: string,
177
+ env: NodeJS.ProcessEnv
178
+ ): ChildProcess {
179
+ // Ensure log directory exists
180
+ if (!existsSync(LOGS_DIR)) {
181
+ mkdirSync(LOGS_DIR, { recursive: true })
182
+ }
183
+
184
+ const logStream = createWriteStream(LOG_FILES[name], { flags: 'a' })
185
+
186
+ const child = spawn(command, args, {
187
+ cwd,
188
+ env,
189
+ detached: true,
190
+ stdio: ['ignore', logStream, logStream],
191
+ })
192
+
193
+ child.unref()
194
+
195
+ if (child.pid) {
196
+ writePid(name, child.pid)
197
+ }
198
+
199
+ return child
200
+ }
201
+
202
+ /** Start the backend server */
203
+ export async function startBackend(): Promise<ChildProcess | null> {
204
+ if (isRunning('backend')) {
205
+ log.warn('Backend is already running')
206
+ return null
207
+ }
208
+
209
+ // Ensure backend is set up
210
+ ensureBackend()
211
+
212
+ // Install dependencies if needed
213
+ if (!backendHasNodeModules()) {
214
+ await installBackendDeps()
215
+ }
216
+
217
+ const config = loadConfig()
218
+ const env = buildEnv()
219
+
220
+ // Set TTS directory
221
+ env.CRAWD_TTS_DIR = TTS_CACHE_DIR
222
+
223
+ const backendEntry = join(BACKEND_DIR, 'index.ts')
224
+
225
+ log.info(`Starting backend on port ${config.ports.backend}...`)
226
+ return startDaemon('backend', 'npx', ['tsx', backendEntry], BACKEND_DIR, env)
227
+ }
228
+
229
+ /** Start the overlay dev server */
230
+ export async function startOverlay(): Promise<ChildProcess | null> {
231
+ if (isRunning('overlay')) {
232
+ log.warn('Overlay is already running')
233
+ return null
234
+ }
235
+
236
+ ensureOverlay()
237
+
238
+ if (!overlayHasNodeModules()) {
239
+ await installOverlayDeps()
240
+ }
241
+
242
+ const config = loadConfig()
243
+ const env = buildEnv()
244
+
245
+ // Set Vite port
246
+ env.PORT = String(config.ports.overlay)
247
+
248
+ log.info(`Starting overlay on port ${config.ports.overlay}...`)
249
+ return startDaemon('overlay', 'pnpm', ['dev'], OVERLAY_DIR, env)
250
+ }
251
+
252
+ /** Stop a specific daemon */
253
+ export function stopDaemon(name: ProcessName): boolean {
254
+ if (!isRunning(name)) {
255
+ log.dim(`${name} is not running`)
256
+ return false
257
+ }
258
+
259
+ const killed = killProcess(name)
260
+ if (killed) {
261
+ log.success(`Stopped ${name}`)
262
+ }
263
+ return killed
264
+ }
265
+
266
+ /** Stop all daemons */
267
+ export function stopAll() {
268
+ stopDaemon('backend')
269
+ stopDaemon('overlay')
270
+ }
271
+
272
+ /** Start all daemons */
273
+ export async function startAll() {
274
+ ensureDirectories()
275
+
276
+ const backend = await startBackend()
277
+ const overlay = await startOverlay()
278
+
279
+ return { backend, overlay }
280
+ }
@@ -0,0 +1,102 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs'
2
+ import { dirname } from 'path'
3
+ import { PID_FILES, PIDS_DIR } from '../utils/paths.js'
4
+
5
+ export type ProcessName = 'backend' | 'overlay' | 'crawdbot'
6
+
7
+ /** Write a PID file */
8
+ export function writePid(name: ProcessName, pid: number) {
9
+ if (!existsSync(PIDS_DIR)) {
10
+ mkdirSync(PIDS_DIR, { recursive: true })
11
+ }
12
+ writeFileSync(PID_FILES[name], String(pid))
13
+ }
14
+
15
+ /** Read a PID from file */
16
+ export function readPid(name: ProcessName): number | null {
17
+ const path = PID_FILES[name]
18
+ if (!existsSync(path)) {
19
+ return null
20
+ }
21
+ try {
22
+ const content = readFileSync(path, 'utf-8').trim()
23
+ const pid = parseInt(content, 10)
24
+ return isNaN(pid) ? null : pid
25
+ } catch {
26
+ return null
27
+ }
28
+ }
29
+
30
+ /** Remove a PID file */
31
+ export function removePid(name: ProcessName) {
32
+ const path = PID_FILES[name]
33
+ if (existsSync(path)) {
34
+ unlinkSync(path)
35
+ }
36
+ }
37
+
38
+ /** Check if a process is running by PID */
39
+ export function isProcessRunning(pid: number): boolean {
40
+ try {
41
+ // kill with signal 0 doesn't kill the process, just checks if it exists
42
+ process.kill(pid, 0)
43
+ return true
44
+ } catch {
45
+ return false
46
+ }
47
+ }
48
+
49
+ /** Check if a named process is running */
50
+ export function isRunning(name: ProcessName): boolean {
51
+ const pid = readPid(name)
52
+ if (pid === null) return false
53
+ return isProcessRunning(pid)
54
+ }
55
+
56
+ /** Kill a process by name if running */
57
+ export function killProcess(name: ProcessName): boolean {
58
+ const pid = readPid(name)
59
+ if (pid === null) return false
60
+
61
+ if (!isProcessRunning(pid)) {
62
+ removePid(name)
63
+ return false
64
+ }
65
+
66
+ try {
67
+ process.kill(pid, 'SIGTERM')
68
+ // Give it a moment to terminate gracefully
69
+ setTimeout(() => {
70
+ if (isProcessRunning(pid)) {
71
+ try {
72
+ process.kill(pid, 'SIGKILL')
73
+ } catch {
74
+ // Process may have already exited
75
+ }
76
+ }
77
+ }, 2000)
78
+ removePid(name)
79
+ return true
80
+ } catch {
81
+ removePid(name)
82
+ return false
83
+ }
84
+ }
85
+
86
+ /** Get status of all processes */
87
+ export function getProcessStatus(): Record<ProcessName, { running: boolean; pid: number | null }> {
88
+ return {
89
+ backend: {
90
+ running: isRunning('backend'),
91
+ pid: readPid('backend'),
92
+ },
93
+ overlay: {
94
+ running: isRunning('overlay'),
95
+ pid: readPid('overlay'),
96
+ },
97
+ crawdbot: {
98
+ running: isRunning('crawdbot'),
99
+ pid: readPid('crawdbot'),
100
+ },
101
+ }
102
+ }
@@ -0,0 +1,13 @@
1
+ import { EventEmitter } from 'events'
2
+ import type { ChatPlatform } from './types'
3
+
4
+ /**
5
+ * Abstract base class for chat clients.
6
+ * Uses EventEmitter - listen with: client.on('message', (msg) => ...)
7
+ */
8
+ export abstract class BaseChatClient extends EventEmitter {
9
+ abstract readonly platform: ChatPlatform
10
+ abstract connect(): Promise<void>
11
+ abstract disconnect(): void
12
+ abstract isConnected(): boolean
13
+ }
@@ -0,0 +1,105 @@
1
+ import type { ChatMessage } from './types'
2
+ import type { BaseChatClient } from './base'
3
+
4
+ /**
5
+ * Orchestrates multiple chat clients with reconnection policy.
6
+ * Uses string keys to allow multiple clients per platform.
7
+ */
8
+ export class ChatManager {
9
+ private clients = new Map<string, BaseChatClient>()
10
+ private messageHandlers: ((msg: ChatMessage) => void)[] = []
11
+ private reconnectTimers = new Map<string, NodeJS.Timeout>()
12
+
13
+ /**
14
+ * Register a client with a unique key.
15
+ * Key can be platform name or custom (e.g., 'youtube:stream1')
16
+ */
17
+ registerClient(key: string, client: BaseChatClient): void {
18
+ this.clients.set(key, client)
19
+
20
+ client.on('message', (msg: ChatMessage) => {
21
+ this.messageHandlers.forEach(handler => handler(msg))
22
+ })
23
+
24
+ client.on('connected', () => {
25
+ console.log(`[ChatManager] ${key} connected`)
26
+ this.clearReconnectTimer(key)
27
+ })
28
+
29
+ client.on('disconnected', () => {
30
+ console.log(`[ChatManager] ${key} disconnected`)
31
+ this.scheduleReconnect(key)
32
+ })
33
+
34
+ client.on('error', (error: Error) => {
35
+ console.error(`[ChatManager] ${key} error:`, error.message)
36
+ })
37
+ }
38
+
39
+ private scheduleReconnect(key: string, attempt = 1): void {
40
+ const maxAttempts = 5
41
+ if (attempt > maxAttempts) {
42
+ console.error(`[ChatManager] ${key} failed after ${maxAttempts} attempts, giving up`)
43
+ return
44
+ }
45
+
46
+ const delay = Math.min(5000 * Math.pow(2, attempt - 1), 60000)
47
+ console.log(`[ChatManager] ${key} reconnecting in ${delay}ms (attempt ${attempt})`)
48
+
49
+ const timer = setTimeout(async () => {
50
+ const client = this.clients.get(key)
51
+ if (!client || client.isConnected()) return
52
+
53
+ try {
54
+ await client.connect()
55
+ } catch {
56
+ this.scheduleReconnect(key, attempt + 1)
57
+ }
58
+ }, delay)
59
+
60
+ this.reconnectTimers.set(key, timer)
61
+ }
62
+
63
+ private clearReconnectTimer(key: string): void {
64
+ const timer = this.reconnectTimers.get(key)
65
+ if (timer) {
66
+ clearTimeout(timer)
67
+ this.reconnectTimers.delete(key)
68
+ }
69
+ }
70
+
71
+ async connectAll(): Promise<void> {
72
+ await Promise.allSettled(
73
+ Array.from(this.clients.entries()).map(async ([key, client]) => {
74
+ try {
75
+ await client.connect()
76
+ } catch (err) {
77
+ console.error(`[ChatManager] ${key} initial connect failed:`, err)
78
+ this.scheduleReconnect(key)
79
+ }
80
+ })
81
+ )
82
+ }
83
+
84
+ disconnectAll(): void {
85
+ this.reconnectTimers.forEach(timer => clearTimeout(timer))
86
+ this.reconnectTimers.clear()
87
+ this.clients.forEach(client => {
88
+ if (client.isConnected()) client.disconnect()
89
+ })
90
+ }
91
+
92
+ onMessage(handler: (msg: ChatMessage) => void): void {
93
+ this.messageHandlers.push(handler)
94
+ }
95
+
96
+ getConnectedKeys(): string[] {
97
+ return Array.from(this.clients.entries())
98
+ .filter(([_, client]) => client.isConnected())
99
+ .map(([key]) => key)
100
+ }
101
+
102
+ getClient(key: string): BaseChatClient | undefined {
103
+ return this.clients.get(key)
104
+ }
105
+ }
@@ -0,0 +1,56 @@
1
+ import { PumpfunSocketClient } from '../../pumpfun/v2/socket/client'
2
+ import type { PumpfunMessage } from '../../pumpfun/v2/socket/types'
3
+ import { BaseChatClient } from '../base'
4
+ import { generateShortId } from '../types'
5
+ import type { ChatPlatform, ChatMessage } from '../types'
6
+
7
+ /**
8
+ * Adapter wrapping existing PumpfunSocketClient to unified interface.
9
+ */
10
+ export class PumpFunChatClient extends BaseChatClient {
11
+ readonly platform: ChatPlatform = 'pumpfun'
12
+
13
+ private client: PumpfunSocketClient
14
+ private roomId: string
15
+ private _connected = false
16
+
17
+ constructor(roomId: string, authToken: string | null) {
18
+ super()
19
+ this.roomId = roomId
20
+ this.client = new PumpfunSocketClient(authToken)
21
+ }
22
+
23
+ async connect(): Promise<void> {
24
+ this.client.connect()
25
+ await this.client.joinRoom(this.roomId)
26
+
27
+ this.client.onMessage((msg: PumpfunMessage) => {
28
+ const chatMsg: ChatMessage = {
29
+ id: `pf:${msg.id}`,
30
+ shortId: generateShortId(),
31
+ platform: 'pumpfun',
32
+ username: msg.username,
33
+ message: msg.message,
34
+ timestamp: Date.now(),
35
+ metadata: {
36
+ userAddress: msg.userAddress,
37
+ roomId: msg.roomId
38
+ }
39
+ }
40
+ this.emit('message', chatMsg)
41
+ })
42
+
43
+ this._connected = true
44
+ this.emit('connected')
45
+ }
46
+
47
+ disconnect(): void {
48
+ this.client.disconnect()
49
+ this._connected = false
50
+ this.emit('disconnected')
51
+ }
52
+
53
+ isConnected(): boolean {
54
+ return this._connected
55
+ }
56
+ }
@@ -0,0 +1,48 @@
1
+ import { nanoid } from 'nanoid'
2
+
3
+ export type ChatPlatform = 'pumpfun' | 'youtube' | 'twitch' | 'twitter'
4
+
5
+ /** Generate a short ID for chat messages (6 chars) */
6
+ export const generateShortId = () => nanoid(6)
7
+
8
+ export type SuperChatInfo = {
9
+ amountDisplayString: string
10
+ backgroundColor: string
11
+ }
12
+
13
+ export type ChatMessageMetadata = {
14
+ // YouTube
15
+ channelId?: string
16
+ isModerator?: boolean
17
+ isMember?: boolean
18
+ superChat?: SuperChatInfo
19
+
20
+ // Pump Fun
21
+ userAddress?: string
22
+ roomId?: string
23
+
24
+ // Common
25
+ avatarUrl?: string
26
+ }
27
+
28
+ /**
29
+ * Unified message type for all chat platforms.
30
+ * `platform` is optional for backward compatibility.
31
+ */
32
+ export type ChatMessage = {
33
+ id: string
34
+ /** Short ID for agent prompt/response (6 chars, alphanumeric) */
35
+ shortId: string
36
+ username: string
37
+ message: string
38
+ platform?: ChatPlatform
39
+ timestamp?: number
40
+ metadata?: ChatMessageMetadata
41
+ }
42
+
43
+ export type ChatClientEvents = {
44
+ message: (msg: ChatMessage) => void
45
+ connected: () => void
46
+ disconnected: () => void
47
+ error: (error: Error) => void
48
+ }