crawd 0.9.7 → 0.9.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawd",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "CLI for crawd.bot - AI agent livestreaming platform",
5
5
  "type": "module",
6
6
  "types": "./dist/types.d.ts",
@@ -72,6 +72,8 @@ Chat arrives as `[CRAWD:CHAT]` batches:
72
72
 
73
73
  Each message has a short ID in brackets. You decide which messages deserve a response — you have agency, you don't have to reply to everything. Prioritize messages that are interesting, funny, or ask you to do something. **You MUST reply to chat ONLY via `livestream_reply` tool calls.** Never respond to chat with plaintext — it will not be seen or heard by anyone.
74
74
 
75
+ **Reply FIRST, act SECOND.** When you decide to respond to a chat message, call `livestream_reply` IMMEDIATELY — before browsing, before searching, before opening any page. Viewers are waiting for your reaction. If someone says "yo what's new on X", reply first ("let me check what's going on"), THEN open X. The reply is instant acknowledgment; the browsing is the follow-up. Dead air while you silently research kills the vibe. Talk first, do second.
76
+
75
77
  ## Autonomous Vibes
76
78
 
77
79
  The coordinator manages your activity cycle through three states:
@@ -281,7 +281,7 @@ describe('Coordinator — Plan Mode', () => {
281
281
  coord.stop()
282
282
  })
283
283
 
284
- it('does not nudge when plan is completed', async () => {
284
+ it('nudges to create new plan when plan is completed', async () => {
285
285
  const coord = createCoordinator()
286
286
  coord.setPlan('Quick', ['Only step'])
287
287
  coord.markStepDone(0)
@@ -291,9 +291,49 @@ describe('Coordinator — Plan Mode', () => {
291
291
  clock.advance(200)
292
292
  await new Promise(r => setTimeout(r, 50))
293
293
 
294
- // No plan nudge should have been sent
294
+ // Should nudge agent to create a new plan
295
295
  const planNudges = triggerFn.mock.calls.filter((c: any[]) => c[0]?.includes('[CRAWD:PLAN]'))
296
- expect(planNudges).toHaveLength(0)
296
+ expect(planNudges).toHaveLength(1)
297
+ expect(planNudges[0][0]).toContain('No active plan')
298
+ expect(planNudges[0][0]).toContain('plan_set')
299
+
300
+ coord.stop()
301
+ })
302
+
303
+ it('includes reply context in plan nudge when no active plan', async () => {
304
+ const coord = createCoordinator()
305
+
306
+ // Agent replies to a chat message — no plan exists
307
+ coord.notifySpeech({ username: 'viewer42', message: 'yo you are popular on x' })
308
+
309
+ clock.advance(200)
310
+ await vi.waitFor(() => {
311
+ const calls = triggerFn.mock.calls.filter((c: any[]) => c[0]?.includes('[CRAWD:PLAN]'))
312
+ return expect(calls.length).toBeGreaterThan(0)
313
+ })
314
+
315
+ const planNudge = triggerFn.mock.calls.find((c: any[]) => c[0]?.includes('[CRAWD:PLAN]'))
316
+ expect(planNudge![0]).toContain('viewer42')
317
+ expect(planNudge![0]).toContain('yo you are popular on x')
318
+ // Chat context is a hint, not a directive
319
+ expect(planNudge![0]).toContain('ignore it')
320
+
321
+ coord.stop()
322
+ })
323
+
324
+ it('does not include chat hint when no reply context', async () => {
325
+ const coord = createCoordinator()
326
+
327
+ coord.wake()
328
+
329
+ clock.advance(200)
330
+ await vi.waitFor(() => {
331
+ const calls = triggerFn.mock.calls.filter((c: any[]) => c[0]?.includes('[CRAWD:PLAN]'))
332
+ return expect(calls.length).toBeGreaterThan(0)
333
+ })
334
+
335
+ const planNudge = triggerFn.mock.calls.find((c: any[]) => c[0]?.includes('[CRAWD:PLAN]'))
336
+ expect(planNudge![0]).not.toContain('Recent chat context')
297
337
 
298
338
  coord.stop()
299
339
  })
@@ -1,9 +1,87 @@
1
- import { randomUUID } from 'crypto'
1
+ import { randomUUID, createPrivateKey, createPublicKey, sign } from 'crypto'
2
+ import { readFileSync, existsSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { homedir } from 'os'
2
5
  import WebSocket from 'ws'
3
6
  import type { ChatMessage } from '../lib/chat/types'
4
7
 
5
8
  const SESSION_KEY = process.env.CRAWD_CHANNEL_ID || 'agent:main:crawd:live'
6
9
 
10
+ // ---------------------------------------------------------------------------
11
+ // Device identity for gateway authentication
12
+ // ---------------------------------------------------------------------------
13
+
14
+ type DeviceIdentity = {
15
+ deviceId: string
16
+ publicKeyPem: string
17
+ privateKeyPem: string
18
+ }
19
+
20
+ /**
21
+ * Load device identity from ~/.openclaw/identity/device.json.
22
+ * Returns null if not found (gateway will fall back to token-only auth without scopes).
23
+ */
24
+ function loadDeviceIdentity(): DeviceIdentity | null {
25
+ const identityPath = join(homedir(), '.openclaw', 'identity', 'device.json')
26
+ try {
27
+ if (!existsSync(identityPath)) return null
28
+ const raw = JSON.parse(readFileSync(identityPath, 'utf8'))
29
+ if (raw?.version === 1 && typeof raw.deviceId === 'string' &&
30
+ typeof raw.publicKeyPem === 'string' && typeof raw.privateKeyPem === 'string') {
31
+ return { deviceId: raw.deviceId, publicKeyPem: raw.publicKeyPem, privateKeyPem: raw.privateKeyPem }
32
+ }
33
+ return null
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ function base64UrlEncode(buf: Buffer): string {
40
+ return buf.toString('base64url')
41
+ }
42
+
43
+ /** Extract raw 32-byte Ed25519 public key from PEM and return as base64url */
44
+ function publicKeyRawBase64Url(publicKeyPem: string): string {
45
+ const pubKey = createPublicKey(publicKeyPem)
46
+ const der = pubKey.export({ type: 'spki', format: 'der' })
47
+ // Ed25519 SPKI DER: 12-byte header + 32-byte raw key
48
+ return base64UrlEncode(der.subarray(der.length - 32))
49
+ }
50
+
51
+ /** Sign a device auth payload with Ed25519 private key */
52
+ function signPayload(privateKeyPem: string, payload: string): string {
53
+ const key = createPrivateKey(privateKeyPem)
54
+ return base64UrlEncode(sign(null, Buffer.from(payload, 'utf8'), key))
55
+ }
56
+
57
+ /**
58
+ * Build the device auth fields for a gateway connect request.
59
+ * Returns the `device` object to include in connect params, or undefined if no identity.
60
+ */
61
+ function buildDeviceAuth(
62
+ identity: DeviceIdentity,
63
+ opts: { clientId: string; clientMode: string; role: string; scopes: string[]; token?: string }
64
+ ): { id: string; publicKey: string; signature: string; signedAt: number } {
65
+ const signedAtMs = Date.now()
66
+ const payload = [
67
+ 'v1',
68
+ identity.deviceId,
69
+ opts.clientId,
70
+ opts.clientMode,
71
+ opts.role,
72
+ opts.scopes.join(','),
73
+ String(signedAtMs),
74
+ opts.token ?? '',
75
+ ].join('|')
76
+
77
+ return {
78
+ id: identity.deviceId,
79
+ publicKey: publicKeyRawBase64Url(identity.publicKeyPem),
80
+ signature: signPayload(identity.privateKeyPem, payload),
81
+ signedAt: signedAtMs,
82
+ }
83
+ }
84
+
7
85
  // ---------------------------------------------------------------------------
8
86
  // Plan types
9
87
  // ---------------------------------------------------------------------------
@@ -142,6 +220,7 @@ export class GatewayClient implements IGatewayClient {
142
220
  private reconnectDelay = 1000
143
221
  private readonly maxReconnectDelay = 30_000
144
222
  private shouldReconnect = false
223
+ private deviceIdentity: DeviceIdentity | null
145
224
 
146
225
  /** Callback invoked when the gateway dispatches a node.invoke.request */
147
226
  onInvokeRequest?: (payload: InvokeRequestPayload) => void
@@ -150,6 +229,12 @@ export class GatewayClient implements IGatewayClient {
150
229
  this.url = url
151
230
  this.token = token
152
231
  this.targetSessionKey = sessionKey
232
+ this.deviceIdentity = loadDeviceIdentity()
233
+ if (this.deviceIdentity) {
234
+ console.log(`[Gateway] Device identity loaded: ${this.deviceIdentity.deviceId.slice(0, 12)}...`)
235
+ } else {
236
+ console.warn('[Gateway] No device identity found — scopes will be stripped by gateway')
237
+ }
153
238
  }
154
239
 
155
240
 
@@ -215,19 +300,30 @@ export class GatewayClient implements IGatewayClient {
215
300
  }
216
301
 
217
302
  private async authenticate(): Promise<void> {
303
+ const scopes = ['operator.write']
304
+ const clientId = 'gateway-client'
305
+ const clientMode = 'backend'
306
+ const role = 'operator'
307
+
308
+ const device = this.deviceIdentity
309
+ ? buildDeviceAuth(this.deviceIdentity, { clientId, clientMode, role, scopes, token: this.token })
310
+ : undefined
311
+
218
312
  // Skip connected check since we're establishing the connection
219
313
  return this.request('connect', {
220
314
  minProtocol: 3,
221
315
  maxProtocol: 3,
222
316
  client: {
223
- id: 'gateway-client',
317
+ id: clientId,
224
318
  version: '1.0.0',
225
319
  platform: 'node',
226
- mode: 'backend',
320
+ mode: clientMode,
227
321
  },
228
- scopes: ['operator.write'],
322
+ role,
323
+ scopes,
229
324
  commands: ['talk'],
230
325
  auth: { token: this.token },
326
+ device,
231
327
  }, true) as Promise<void>
232
328
  }
233
329
 
@@ -397,12 +493,15 @@ export class GatewayClient implements IGatewayClient {
397
493
  * No persistent connection or reconnect logic needed.
398
494
  */
399
495
  export class OneShotGateway {
496
+ private deviceIdentity: DeviceIdentity | null
497
+
400
498
  constructor(
401
499
  private url: string,
402
500
  private token: string,
403
501
  private sessionKey: string = SESSION_KEY,
404
502
  ) {
405
- console.log(`[OneShotGateway] url=${url} session=${sessionKey}`)
503
+ this.deviceIdentity = loadDeviceIdentity()
504
+ console.log(`[OneShotGateway] url=${url} session=${sessionKey} device=${this.deviceIdentity ? this.deviceIdentity.deviceId.slice(0, 12) + '...' : 'none'}`)
406
505
  }
407
506
 
408
507
  async triggerAgent(message: string): Promise<string[]> {
@@ -434,6 +533,15 @@ export class OneShotGateway {
434
533
  }
435
534
 
436
535
  const sendConnect = () => {
536
+ const scopes = ['operator.write']
537
+ const clientId = 'gateway-client'
538
+ const clientMode = 'backend'
539
+ const role = 'operator'
540
+
541
+ const device = this.deviceIdentity
542
+ ? buildDeviceAuth(this.deviceIdentity, { clientId, clientMode, role, scopes, token: this.token })
543
+ : undefined
544
+
437
545
  ws.send(JSON.stringify({
438
546
  type: 'req',
439
547
  id: authId,
@@ -442,14 +550,16 @@ export class OneShotGateway {
442
550
  minProtocol: 3,
443
551
  maxProtocol: 3,
444
552
  client: {
445
- id: 'gateway-client',
553
+ id: clientId,
446
554
  version: '1.0.0',
447
555
  platform: 'node',
448
- mode: 'backend',
556
+ mode: clientMode,
449
557
  },
450
- scopes: ['operator.write'],
558
+ role,
559
+ scopes,
451
560
  commands: ['talk'],
452
561
  auth: this.token ? { token: this.token } : {},
562
+ device,
453
563
  },
454
564
  }))
455
565
  }
@@ -563,6 +673,7 @@ export class Coordinator {
563
673
  // === Plan State ===
564
674
  private currentPlan: Plan | null = null
565
675
  private planNudgeTimer: NodeJS.Timeout | null = null
676
+ private lastReplyContext: { username: string; message: string } | null = null
566
677
 
567
678
  // === Injected dependencies ===
568
679
  private readonly clock: IClock
@@ -662,8 +773,8 @@ export class Coordinator {
662
773
 
663
774
  this.startVibeLoop() // starts sleep/idle check timers + vibe loop (if mode=vibe)
664
775
 
665
- // In plan mode, also start plan nudges if there are pending steps
666
- if (this.config.autonomyMode === 'plan' && this.hasPendingPlanSteps()) {
776
+ // In plan mode, start plan nudges (pending steps or needs a new plan)
777
+ if (this.config.autonomyMode === 'plan' && this.needsPlanNudge()) {
667
778
  this.schedulePlanNudge()
668
779
  }
669
780
  }
@@ -696,7 +807,8 @@ export class Coordinator {
696
807
  }
697
808
 
698
809
  /** Signal that the agent is speaking (via tool call) — keeps coordinator awake */
699
- notifySpeech(): void {
810
+ notifySpeech(replyContext?: { username: string; message: string }): void {
811
+ if (replyContext) this.lastReplyContext = replyContext
700
812
  if (this._state !== 'active') this.wake()
701
813
  else this.resetActivity()
702
814
  }
@@ -772,6 +884,11 @@ export class Coordinator {
772
884
  return this.currentPlan.steps.some(s => s.status === 'pending')
773
885
  }
774
886
 
887
+ /** Check if a plan nudge is needed — either pending steps or no plan at all */
888
+ private needsPlanNudge(): boolean {
889
+ return this.hasPendingPlanSteps() || !this.currentPlan || this.currentPlan.status !== 'active'
890
+ }
891
+
775
892
  // === Plan Nudge Loop ===
776
893
 
777
894
  /** Schedule next plan nudge (short delay to avoid spinning) */
@@ -779,7 +896,7 @@ export class Coordinator {
779
896
  this.cancelPlanNudge()
780
897
  if (this._state === 'sleep') return
781
898
  if (this.config.autonomyMode !== 'plan') return
782
- if (!this.hasPendingPlanSteps()) return
899
+ if (!this.needsPlanNudge()) return
783
900
 
784
901
  const delay = this.config.planNudgeDelayMs
785
902
  const nextNudgeAt = this.clock.now() + delay
@@ -799,7 +916,7 @@ export class Coordinator {
799
916
  private checkPlanProgress(): void {
800
917
  if (this.config.autonomyMode !== 'plan') return
801
918
  if (this._state === 'sleep') return
802
- if (this.hasPendingPlanSteps()) {
919
+ if (this.needsPlanNudge()) {
803
920
  this.schedulePlanNudge()
804
921
  }
805
922
  }
@@ -818,7 +935,7 @@ export class Coordinator {
818
935
  return
819
936
  }
820
937
 
821
- if (!this.hasPendingPlanSteps()) {
938
+ if (!this.needsPlanNudge()) {
822
939
  this.logger.log('[Coordinator] Plan nudge skipped - no pending steps')
823
940
  this.emit({ type: 'planNudgeExecuted', skipped: true, reason: 'no pending steps' })
824
941
  return
@@ -882,7 +999,13 @@ export class Coordinator {
882
999
  /** Build the [CRAWD:PLAN] prompt with current plan progress */
883
1000
  private buildPlanNudgePrompt(): string {
884
1001
  if (!this.currentPlan || this.currentPlan.status !== 'active') {
885
- return '[CRAWD:PLAN] No active plan. Come up with something fun to do — check X, browse HN, find something weird on the internet, go down a rabbit hole. Create a plan using plan_set with concrete steps. Be creative, not reactive. Or respond with NO_REPLY to idle.'
1002
+ const ctx = this.lastReplyContext
1003
+ this.lastReplyContext = null
1004
+ const base = '[CRAWD:PLAN] No active plan. Come up with something to do — check X, browse HN, find something weird on the internet, go down a rabbit hole. Create a plan using plan_set with 2-4 concrete steps. Be creative and unpredictable.'
1005
+ const chatHint = ctx
1006
+ ? `\nRecent chat context: ${ctx.username} said "${ctx.message}" — you can riff on this, ignore it, or do something completely unrelated. Your call.`
1007
+ : ''
1008
+ return `${base}${chatHint}\nOr respond with NO_REPLY to idle.`
886
1009
  }
887
1010
 
888
1011
  const lines: string[] = ['[CRAWD:PLAN] Continue your plan.', '']
@@ -157,7 +157,7 @@ export class CrawdBackend {
157
157
  return { spoken: false }
158
158
  }
159
159
 
160
- this.coordinator?.notifySpeech()
160
+ this.coordinator?.notifySpeech({ username: chat.username, message: chat.message })
161
161
 
162
162
  const id = randomUUID()
163
163
  this.io.emit('crawd:reply-turn', {
package/src/plugin.ts CHANGED
@@ -221,7 +221,7 @@ const plugin: PluginDefinition = {
221
221
  const { text, username, message } = params as { text: string; username: string; message: string }
222
222
  const result = await b.handleReply(text, { username, message })
223
223
  return {
224
- content: [{ type: 'text', text: result.spoken ? `Replied to @${username}: "${text}"` : 'Failed to reply' }],
224
+ content: [{ type: 'text', text: result.spoken ? `Replied to ${username}: "${text}"` : 'Failed to reply' }],
225
225
  details: result,
226
226
  }
227
227
  },