crawd 0.8.7 → 0.9.1

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.
@@ -4,29 +4,54 @@ import type { ChatMessage } from '../lib/chat/types'
4
4
 
5
5
  const SESSION_KEY = process.env.CRAWD_CHANNEL_ID || 'agent:main:crawd:live'
6
6
 
7
- /** Coordinator configuration */
7
+ // ---------------------------------------------------------------------------
8
+ // Plan types
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export type PlanStep = {
12
+ description: string
13
+ status: 'pending' | 'done'
14
+ }
15
+
16
+ export type Plan = {
17
+ id: string
18
+ goal: string
19
+ steps: PlanStep[]
20
+ createdAt: number
21
+ status: 'active' | 'completed' | 'abandoned'
22
+ }
23
+
24
+ export type AutonomyMode = 'vibe' | 'plan' | 'none'
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Coordinator configuration
28
+ // ---------------------------------------------------------------------------
29
+
8
30
  export type CoordinatorConfig = {
9
- /** Whether autonomous vibing is enabled. Default: true */
10
- vibeEnabled: boolean
11
- /** How often to send vibe prompt when active (ms). Default: 60000 (1 min) */
31
+ /** Autonomy mode: 'vibe' (periodic prompts), 'plan' (goal-driven loop), 'none' (disabled). */
32
+ autonomyMode: AutonomyMode
33
+ /** How often to send vibe prompt when active (ms). Default: 30000 (30 sec). Only used in vibe mode. */
12
34
  vibeIntervalMs: number
13
- /** Go idle after this much inactivity while active (ms). Default: 30000 (30 sec) */
35
+ /** Go idle after this much inactivity while active (ms). Default: 180000 (3 min) */
14
36
  idleAfterMs: number
15
- /** Go sleep after this much inactivity while idle (ms). Default: 60000 (1 min) */
37
+ /** Go sleep after this much inactivity while idle (ms). Default: 180000 (3 min) */
16
38
  sleepAfterIdleMs: number
17
39
  /** Chat batch throttle window (ms). Default: 20000 (20 sec) */
18
40
  batchWindowMs: number
19
- /** The autonomous "vibe" prompt sent periodically */
41
+ /** The autonomous "vibe" prompt sent periodically. Only used in vibe mode. */
20
42
  vibePrompt: string
43
+ /** Delay between plan nudges (ms). Default: 2000 (2 sec). Only used in plan mode. */
44
+ planNudgeDelayMs: number
21
45
  }
22
46
 
23
47
  export const DEFAULT_CONFIG: CoordinatorConfig = {
24
- vibeEnabled: true,
48
+ autonomyMode: 'vibe',
25
49
  vibeIntervalMs: 30_000,
26
50
  idleAfterMs: 180_000,
27
51
  sleepAfterIdleMs: 180_000,
28
52
  batchWindowMs: 20_000,
29
53
  vibePrompt: `[CRAWD:VIBE] You are on a livestream. Make sure the crawd skill is loaded. Do one thing on the internet or ask the chat something. Respond with LIVESTREAM_REPLIED after using a tool, or NO_REPLY if you have nothing to say.`,
54
+ planNudgeDelayMs: 2_000,
30
55
  }
31
56
 
32
57
  export type CoordinatorState = 'sleep' | 'idle' | 'active'
@@ -481,6 +506,102 @@ export class OneShotGateway {
481
506
  })
482
507
  })
483
508
  }
509
+
510
+ /** Compact a session via the gateway's sessions.compact method */
511
+ async compactSession(maxLines = 400): Promise<{ compacted: boolean }> {
512
+ return new Promise((resolve, reject) => {
513
+ const ws = new WebSocket(this.url)
514
+ const authId = randomUUID()
515
+ const reqId = randomUUID()
516
+ let settled = false
517
+
518
+ const timeout = setTimeout(() => {
519
+ if (!settled) {
520
+ settled = true
521
+ ws.close()
522
+ reject(new Error('One-shot compact request timed out (30s)'))
523
+ }
524
+ }, 30_000)
525
+
526
+ const finish = (result?: { compacted: boolean }, error?: Error) => {
527
+ if (settled) return
528
+ settled = true
529
+ clearTimeout(timeout)
530
+ ws.close()
531
+ if (error) {
532
+ console.error(`[OneShotGateway] compact error: ${error.message}`)
533
+ reject(error)
534
+ } else {
535
+ resolve(result ?? { compacted: false })
536
+ }
537
+ }
538
+
539
+ const sendConnect = () => {
540
+ ws.send(JSON.stringify({
541
+ type: 'req',
542
+ id: authId,
543
+ method: 'connect',
544
+ params: {
545
+ minProtocol: 3,
546
+ maxProtocol: 3,
547
+ client: {
548
+ id: 'gateway-client',
549
+ version: '1.0.0',
550
+ platform: 'node',
551
+ mode: 'backend',
552
+ },
553
+ auth: this.token ? { token: this.token } : {},
554
+ },
555
+ }))
556
+ }
557
+
558
+ ws.on('message', (data) => {
559
+ try {
560
+ const frame = JSON.parse(data.toString()) as GatewayFrame
561
+
562
+ if (frame.type === 'event' && (frame as any).event === 'connect.challenge') {
563
+ sendConnect()
564
+ return
565
+ }
566
+
567
+ if (frame.type === 'res' && frame.id === authId) {
568
+ if (frame.error) {
569
+ finish(undefined, new Error(`Gateway auth failed: ${frame.error.message}`))
570
+ return
571
+ }
572
+ ws.send(JSON.stringify({
573
+ type: 'req',
574
+ id: reqId,
575
+ method: 'sessions.compact',
576
+ params: {
577
+ key: this.sessionKey,
578
+ maxLines,
579
+ },
580
+ }))
581
+ } else if (frame.type === 'res' && frame.id === reqId) {
582
+ if (frame.error) {
583
+ finish(undefined, new Error(`sessions.compact failed: ${frame.error.message}`))
584
+ return
585
+ }
586
+ const payload = frame.payload as any
587
+ finish({ compacted: payload?.compacted === true })
588
+ }
589
+ } catch {
590
+ // Parse error — ignore
591
+ }
592
+ })
593
+
594
+ ws.on('error', (err) => {
595
+ finish(undefined, err instanceof Error ? err : new Error(String(err)))
596
+ })
597
+
598
+ ws.on('close', () => {
599
+ if (!settled) {
600
+ finish(undefined, new Error('WebSocket closed unexpectedly'))
601
+ }
602
+ })
603
+ })
604
+ }
484
605
  }
485
606
 
486
607
  /**
@@ -504,11 +625,19 @@ export type CoordinatorEvent =
504
625
  | { type: 'vibeExecuted'; skipped: boolean; reason?: string }
505
626
  | { type: 'sleepCheck'; inactiveForMs: number; willSleep: boolean }
506
627
  | { type: 'chatProcessed'; count: number }
628
+ // Plan events
629
+ | { type: 'planCreated'; planId: string; goal: string; stepCount: number }
630
+ | { type: 'planStepDone'; planId: string; step: number }
631
+ | { type: 'planCompleted'; planId: string }
632
+ | { type: 'planAbandoned'; planId: string }
633
+ | { type: 'planNudgeScheduled'; nextNudgeAt: number }
634
+ | { type: 'planNudgeExecuted'; skipped: boolean; reason?: string }
507
635
 
508
636
  export class Coordinator {
509
637
  private buffer: ChatMessage[] = []
510
638
  private timer: NodeJS.Timeout | null = null
511
639
  private triggerFn: TriggerAgentFn
640
+ private compactFn?: () => Promise<void>
512
641
  private onEvent?: (event: CoordinatorEvent) => void
513
642
  /** Timestamp when coordinator was created (used to filter old messages on restart) */
514
643
  private readonly startedAt: number
@@ -520,11 +649,15 @@ export class Coordinator {
520
649
  private idleSince = 0
521
650
  private vibeTimer: NodeJS.Timeout | null = null
522
651
  private sleepCheckTimer: NodeJS.Timeout | null = null
523
- /** True while a flush or talk is being processed — vibes should wait */
652
+ /** True while a flush or talk is being processed — vibes/nudges should wait */
524
653
  private _busy = false
525
654
  /** Serializes all triggerAgent calls to prevent concurrent runs */
526
655
  private _gatewayQueue: Promise<void> = Promise.resolve()
527
656
 
657
+ // === Plan State ===
658
+ private currentPlan: Plan | null = null
659
+ private planNudgeTimer: NodeJS.Timeout | null = null
660
+
528
661
  // === Injected dependencies ===
529
662
  private readonly clock: IClock
530
663
  private readonly logger: Pick<Console, 'log' | 'error' | 'warn'>
@@ -555,6 +688,7 @@ export class Coordinator {
555
688
  updateConfig(config: Partial<CoordinatorConfig>): void {
556
689
  this.config = { ...this.config, ...config }
557
690
  this.logger.log('[Coordinator] Config updated:', {
691
+ autonomyMode: this.config.autonomyMode,
558
692
  vibeIntervalMs: this.config.vibeIntervalMs,
559
693
  idleAfterMs: this.config.idleAfterMs,
560
694
  sleepAfterIdleMs: this.config.sleepAfterIdleMs,
@@ -563,11 +697,13 @@ export class Coordinator {
563
697
  }
564
698
 
565
699
  /** Get current state and config */
566
- getState(): { state: CoordinatorState; lastActivityAt: number; config: CoordinatorConfig } {
700
+ getState(): { state: CoordinatorState; lastActivityAt: number; config: CoordinatorConfig; plan: Plan | null; autonomyMode: AutonomyMode } {
567
701
  return {
568
702
  state: this._state,
569
703
  lastActivityAt: this.lastActivityAt,
570
704
  config: this.config,
705
+ plan: this.currentPlan,
706
+ autonomyMode: this.config.autonomyMode,
571
707
  }
572
708
  }
573
709
 
@@ -581,6 +717,11 @@ export class Coordinator {
581
717
  this.onEvent = callback
582
718
  }
583
719
 
720
+ /** Set the compact function (uses gateway sessions.compact instead of sending /compact as agent message) */
721
+ setCompactFn(fn: () => Promise<void>): void {
722
+ this.compactFn = fn
723
+ }
724
+
584
725
  private emit(event: CoordinatorEvent): void {
585
726
  this.onEvent?.(event)
586
727
  }
@@ -597,6 +738,7 @@ export class Coordinator {
597
738
 
598
739
  stop(): void {
599
740
  this.stopVibeLoop()
741
+ this.cancelPlanNudge()
600
742
  if (this.timer) {
601
743
  this.clock.clearTimeout(this.timer)
602
744
  this.timer = null
@@ -607,7 +749,7 @@ export class Coordinator {
607
749
 
608
750
  // === State Machine Methods ===
609
751
 
610
- /** Wake up from sleep/idle state and start the vibe loop */
752
+ /** Wake up from sleep/idle state and start the autonomy loop */
611
753
  wake(): void {
612
754
  if (this._state === 'active') return
613
755
 
@@ -617,7 +759,12 @@ export class Coordinator {
617
759
  this.logger.log('[Coordinator] WAKE - transitioning to ACTIVE state')
618
760
  this.emit({ type: 'stateChange', from, to: 'active' })
619
761
 
620
- this.startVibeLoop()
762
+ this.startVibeLoop() // starts sleep/idle check timers + vibe loop (if mode=vibe)
763
+
764
+ // In plan mode, also start plan nudges if there are pending steps
765
+ if (this.config.autonomyMode === 'plan' && this.hasPendingPlanSteps()) {
766
+ this.schedulePlanNudge()
767
+ }
621
768
  }
622
769
 
623
770
  /** Go to idle state (between activities, eyes open) */
@@ -644,14 +791,16 @@ export class Coordinator {
644
791
  this.emit({ type: 'stateChange', from, to: 'sleep' })
645
792
 
646
793
  this.stopVibeLoop()
794
+ this.cancelPlanNudge()
647
795
  this.compactSession()
648
796
  }
649
797
 
650
798
  /** Compact the agent's session context before sleeping to free stale history */
651
799
  private compactSession(): void {
800
+ if (!this.compactFn) return
652
801
  this._gatewayQueue = this._gatewayQueue.then(async () => {
653
802
  try {
654
- await this.triggerFn('/compact')
803
+ await this.compactFn!()
655
804
  this.logger.log('[Coordinator] Session compacted before sleep')
656
805
  } catch (err) {
657
806
  this.logger.error('[Coordinator] Failed to compact session:', err)
@@ -675,6 +824,203 @@ export class Coordinator {
675
824
  return this.clock.now() - this.lastActivityAt
676
825
  }
677
826
 
827
+ // === Plan Methods ===
828
+
829
+ /** Create or replace the current plan */
830
+ setPlan(goal: string, steps: string[]): Plan {
831
+ if (this.currentPlan?.status === 'active') {
832
+ this.currentPlan.status = 'abandoned'
833
+ this.emit({ type: 'planAbandoned', planId: this.currentPlan.id })
834
+ }
835
+
836
+ const plan: Plan = {
837
+ id: randomUUID(),
838
+ goal,
839
+ steps: steps.map(s => ({ description: s, status: 'pending' as const })),
840
+ createdAt: this.clock.now(),
841
+ status: 'active',
842
+ }
843
+ this.currentPlan = plan
844
+ this.emit({ type: 'planCreated', planId: plan.id, goal, stepCount: steps.length })
845
+
846
+ if (this._state !== 'active') this.wake()
847
+
848
+ return plan
849
+ }
850
+
851
+ /** Mark a step as done (0-indexed). Returns updated plan or null. */
852
+ markStepDone(stepIndex: number): Plan | null {
853
+ if (!this.currentPlan || this.currentPlan.status !== 'active') return null
854
+ if (stepIndex < 0 || stepIndex >= this.currentPlan.steps.length) return null
855
+
856
+ this.currentPlan.steps[stepIndex].status = 'done'
857
+ this.emit({ type: 'planStepDone', planId: this.currentPlan.id, step: stepIndex })
858
+
859
+ const allDone = this.currentPlan.steps.every(s => s.status === 'done')
860
+ if (allDone) {
861
+ this.currentPlan.status = 'completed'
862
+ this.emit({ type: 'planCompleted', planId: this.currentPlan.id })
863
+ }
864
+
865
+ return this.currentPlan
866
+ }
867
+
868
+ /** Abandon the current plan */
869
+ abandonPlan(): Plan | null {
870
+ if (!this.currentPlan || this.currentPlan.status !== 'active') return null
871
+ this.currentPlan.status = 'abandoned'
872
+ this.emit({ type: 'planAbandoned', planId: this.currentPlan.id })
873
+ this.cancelPlanNudge()
874
+ return this.currentPlan
875
+ }
876
+
877
+ /** Get the current plan */
878
+ getPlan(): Plan | null {
879
+ return this.currentPlan
880
+ }
881
+
882
+ /** Check if there is an active plan with pending steps */
883
+ private hasPendingPlanSteps(): boolean {
884
+ if (!this.currentPlan || this.currentPlan.status !== 'active') return false
885
+ return this.currentPlan.steps.some(s => s.status === 'pending')
886
+ }
887
+
888
+ // === Plan Nudge Loop ===
889
+
890
+ /** Schedule next plan nudge (short delay to avoid spinning) */
891
+ private schedulePlanNudge(): void {
892
+ this.cancelPlanNudge()
893
+ if (this._state === 'sleep') return
894
+ if (this.config.autonomyMode !== 'plan') return
895
+ if (!this.hasPendingPlanSteps()) return
896
+
897
+ const delay = this.config.planNudgeDelayMs
898
+ const nextNudgeAt = this.clock.now() + delay
899
+ this.emit({ type: 'planNudgeScheduled', nextNudgeAt })
900
+ this.planNudgeTimer = this.clock.setTimeout(() => this.planNudge(), delay)
901
+ }
902
+
903
+ /** Cancel pending plan nudge */
904
+ private cancelPlanNudge(): void {
905
+ if (this.planNudgeTimer) {
906
+ this.clock.clearTimeout(this.planNudgeTimer)
907
+ this.planNudgeTimer = null
908
+ }
909
+ }
910
+
911
+ /** Check plan state and schedule next nudge if needed */
912
+ private checkPlanProgress(): void {
913
+ if (this.config.autonomyMode !== 'plan') return
914
+ if (this._state === 'sleep') return
915
+ if (this.hasPendingPlanSteps()) {
916
+ this.schedulePlanNudge()
917
+ }
918
+ }
919
+
920
+ /** Execute a plan nudge — send [CRAWD:PLAN] prompt to agent */
921
+ private async planNudge(): Promise<void> {
922
+ if (this._state === 'sleep') {
923
+ this.emit({ type: 'planNudgeExecuted', skipped: true, reason: 'sleeping' })
924
+ return
925
+ }
926
+
927
+ if (this._busy) {
928
+ this.logger.log('[Coordinator] Plan nudge skipped - busy')
929
+ this.emit({ type: 'planNudgeExecuted', skipped: true, reason: 'busy' })
930
+ this.schedulePlanNudge()
931
+ return
932
+ }
933
+
934
+ if (!this.hasPendingPlanSteps()) {
935
+ this.logger.log('[Coordinator] Plan nudge skipped - no pending steps')
936
+ this.emit({ type: 'planNudgeExecuted', skipped: true, reason: 'no pending steps' })
937
+ return
938
+ }
939
+
940
+ if (this._state === 'idle') {
941
+ const from = this._state
942
+ this._state = 'active'
943
+ this.emit({ type: 'stateChange', from, to: 'active' })
944
+ }
945
+
946
+ this.logger.log('[Coordinator] Plan nudge - sending plan prompt')
947
+ this.emit({ type: 'planNudgeExecuted', skipped: false })
948
+ this.resetActivity()
949
+
950
+ const prompt = this.buildPlanNudgePrompt()
951
+
952
+ this._busy = true
953
+ let noReply = false
954
+ const nudgeOp = this._gatewayQueue.then(async () => {
955
+ this._busy = true
956
+ try {
957
+ const replies = await this.triggerFn(prompt)
958
+ const agentReplies = replies.filter(r => !this.isApiError(r))
959
+
960
+ // Treat empty replies as NO_REPLY — gateway returns empty payloads
961
+ // when the agent responds with text-only (no tool calls)
962
+ if (agentReplies.length === 0 || agentReplies.some(r => r.trim().toUpperCase() === 'NO_REPLY')) {
963
+ noReply = true
964
+ } else if (!this.isCompliantReply(agentReplies)) {
965
+ const misaligned = agentReplies.filter(r => {
966
+ const t = r.trim().toUpperCase()
967
+ return t !== 'NO_REPLY' && t !== 'LIVESTREAM_REPLIED'
968
+ })
969
+ if (misaligned.length > 0) {
970
+ await this.sendMisalignment(misaligned)
971
+ }
972
+ }
973
+ } catch (err) {
974
+ this.logger.error('[Coordinator] Plan nudge failed:', err)
975
+ } finally {
976
+ this._busy = false
977
+ }
978
+ })
979
+ this._gatewayQueue = nudgeOp.catch(() => {})
980
+
981
+ try {
982
+ await nudgeOp
983
+ } catch {}
984
+
985
+ if (noReply) {
986
+ this.logger.log('[Coordinator] Agent sent NO_REPLY during plan nudge, going to sleep')
987
+ this.goSleep()
988
+ return
989
+ }
990
+
991
+ this.checkPlanProgress()
992
+ }
993
+
994
+ /** Build the [CRAWD:PLAN] prompt with current plan progress */
995
+ private buildPlanNudgePrompt(): string {
996
+ if (!this.currentPlan || this.currentPlan.status !== 'active') {
997
+ return '[CRAWD:PLAN] No active plan. Create one using plan_set or respond with NO_REPLY to idle.'
998
+ }
999
+
1000
+ const lines: string[] = ['[CRAWD:PLAN] Continue your plan.', '']
1001
+ lines.push(`Target: ${this.currentPlan.goal}`)
1002
+
1003
+ let nextStepIdx = -1
1004
+ for (let i = 0; i < this.currentPlan.steps.length; i++) {
1005
+ const step = this.currentPlan.steps[i]
1006
+ const isDone = step.status === 'done'
1007
+ if (!isDone && nextStepIdx === -1) nextStepIdx = i
1008
+ const marker = isDone ? '[x]' : (nextStepIdx === i ? '[-]' : '[ ]')
1009
+ const arrow = nextStepIdx === i ? ' <-- next' : ''
1010
+ lines.push(`${marker} ${i}. ${step.description}${arrow}`)
1011
+ }
1012
+
1013
+ lines.push('')
1014
+ if (nextStepIdx >= 0) {
1015
+ lines.push(`Work on step ${nextStepIdx}. Use plan_step_done when complete.`)
1016
+ }
1017
+ lines.push('You can use plan_abandon to drop this plan, or plan_set to replace it.')
1018
+ lines.push('IMPORTANT: Never mention plans, steps, or progress on stream. Just do the thing naturally.')
1019
+ lines.push('Respond with LIVESTREAM_REPLIED after speaking, or NO_REPLY if you have nothing to say.')
1020
+
1021
+ return lines.join('\n')
1022
+ }
1023
+
678
1024
  /** Start the periodic vibe loop */
679
1025
  private startVibeLoop(): void {
680
1026
  this.stopVibeLoop() // Clear any existing timer
@@ -723,9 +1069,9 @@ export class Coordinator {
723
1069
 
724
1070
  /** Schedule the next vibe action */
725
1071
  scheduleNextVibe(): void {
726
- // Vibe while active or idle (not while sleeping)
727
1072
  if (this._state === 'sleep') return
728
- if (!this.config.vibeEnabled) return
1073
+ // Only schedule vibes in vibe mode
1074
+ if (this.config.autonomyMode !== 'vibe') return
729
1075
 
730
1076
  const nextVibeAt = this.clock.now() + this.config.vibeIntervalMs
731
1077
  this.emit({ type: 'vibeScheduled', nextVibeAt })
@@ -771,7 +1117,9 @@ export class Coordinator {
771
1117
  const replies = await this.triggerFn(this.config.vibePrompt)
772
1118
  // Filter out API errors (429s, rate limits) — not agent responses
773
1119
  const agentReplies = replies.filter(r => !this.isApiError(r))
774
- if (agentReplies.some(r => r.trim().toUpperCase() === 'NO_REPLY')) {
1120
+ // Treat empty replies as NO_REPLY — gateway returns empty payloads
1121
+ // when the agent responds with text-only (no tool calls)
1122
+ if (agentReplies.length === 0 || agentReplies.some(r => r.trim().toUpperCase() === 'NO_REPLY')) {
775
1123
  noReply = true
776
1124
  } else if (!this.isCompliantReply(agentReplies)) {
777
1125
  misaligned = agentReplies.filter(r => {
@@ -919,6 +1267,7 @@ export class Coordinator {
919
1267
  this.logger.error('[Coordinator] Failed to trigger agent:', err)
920
1268
  } finally {
921
1269
  this._busy = false
1270
+ this.checkPlanProgress()
922
1271
  }
923
1272
  }).catch(() => {})
924
1273
  }
@@ -938,6 +1287,12 @@ export class Coordinator {
938
1287
  ? '\n(To reply to a specific message, prefix with its ID: [msgId] your reply)'
939
1288
  : ''
940
1289
 
941
- return `${header}\n${lines.join('\n')}${instruction}`
1290
+ // In plan mode with no active plan, instruct agent to create one
1291
+ let planInstruction = ''
1292
+ if (this.config.autonomyMode === 'plan' && !this.hasPendingPlanSteps()) {
1293
+ planInstruction = '\n\nYou are in plan mode. Create a plan using plan_set based on these messages or your own ideas, then start working on it. Never mention plans or steps on stream — just do things naturally.'
1294
+ }
1295
+
1296
+ return `${header}\n${lines.join('\n')}${instruction}${planInstruction}`
942
1297
  }
943
1298
  }