crawd 0.9.8 → 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 +1 -1
- package/src/backend/coordinator.test.ts +43 -3
- package/src/backend/coordinator.ts +138 -15
- package/src/backend/server.ts +1 -1
- package/src/plugin.ts +1 -1
package/package.json
CHANGED
|
@@ -281,7 +281,7 @@ describe('Coordinator — Plan Mode', () => {
|
|
|
281
281
|
coord.stop()
|
|
282
282
|
})
|
|
283
283
|
|
|
284
|
-
it('
|
|
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
|
-
//
|
|
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(
|
|
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:
|
|
317
|
+
id: clientId,
|
|
224
318
|
version: '1.0.0',
|
|
225
319
|
platform: 'node',
|
|
226
|
-
mode:
|
|
320
|
+
mode: clientMode,
|
|
227
321
|
},
|
|
228
|
-
|
|
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
|
-
|
|
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:
|
|
553
|
+
id: clientId,
|
|
446
554
|
version: '1.0.0',
|
|
447
555
|
platform: 'node',
|
|
448
|
-
mode:
|
|
556
|
+
mode: clientMode,
|
|
449
557
|
},
|
|
450
|
-
|
|
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,
|
|
666
|
-
if (this.config.autonomyMode === 'plan' && this.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.', '']
|
package/src/backend/server.ts
CHANGED
|
@@ -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
|
|
224
|
+
content: [{ type: 'text', text: result.spoken ? `Replied to ${username}: "${text}"` : 'Failed to reply' }],
|
|
225
225
|
details: result,
|
|
226
226
|
}
|
|
227
227
|
},
|