crawd 0.8.6 → 0.9.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.
@@ -2,29 +2,56 @@ import { randomUUID } from 'crypto'
2
2
  import WebSocket from 'ws'
3
3
  import type { ChatMessage } from '../lib/chat/types'
4
4
 
5
- const BATCH_WINDOW_MS = 20_000
6
5
  const SESSION_KEY = process.env.CRAWD_CHANNEL_ID || 'agent:main:crawd:live'
7
6
 
8
- /** 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
+
9
30
  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) */
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. */
13
34
  vibeIntervalMs: number
14
- /** 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) */
15
36
  idleAfterMs: number
16
- /** 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) */
17
38
  sleepAfterIdleMs: number
18
- /** The autonomous "vibe" prompt sent periodically */
39
+ /** Chat batch throttle window (ms). Default: 20000 (20 sec) */
40
+ batchWindowMs: number
41
+ /** The autonomous "vibe" prompt sent periodically. Only used in vibe mode. */
19
42
  vibePrompt: string
43
+ /** Delay between plan nudges (ms). Default: 2000 (2 sec). Only used in plan mode. */
44
+ planNudgeDelayMs: number
20
45
  }
21
46
 
22
47
  export const DEFAULT_CONFIG: CoordinatorConfig = {
23
- vibeEnabled: true,
48
+ autonomyMode: 'vibe',
24
49
  vibeIntervalMs: 30_000,
25
50
  idleAfterMs: 180_000,
26
51
  sleepAfterIdleMs: 180_000,
52
+ batchWindowMs: 20_000,
27
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,
28
55
  }
29
56
 
30
57
  export type CoordinatorState = 'sleep' | 'idle' | 'active'
@@ -502,6 +529,13 @@ export type CoordinatorEvent =
502
529
  | { type: 'vibeExecuted'; skipped: boolean; reason?: string }
503
530
  | { type: 'sleepCheck'; inactiveForMs: number; willSleep: boolean }
504
531
  | { type: 'chatProcessed'; count: number }
532
+ // Plan events
533
+ | { type: 'planCreated'; planId: string; goal: string; stepCount: number }
534
+ | { type: 'planStepDone'; planId: string; step: number }
535
+ | { type: 'planCompleted'; planId: string }
536
+ | { type: 'planAbandoned'; planId: string }
537
+ | { type: 'planNudgeScheduled'; nextNudgeAt: number }
538
+ | { type: 'planNudgeExecuted'; skipped: boolean; reason?: string }
505
539
 
506
540
  export class Coordinator {
507
541
  private buffer: ChatMessage[] = []
@@ -518,11 +552,15 @@ export class Coordinator {
518
552
  private idleSince = 0
519
553
  private vibeTimer: NodeJS.Timeout | null = null
520
554
  private sleepCheckTimer: NodeJS.Timeout | null = null
521
- /** True while a flush or talk is being processed — vibes should wait */
555
+ /** True while a flush or talk is being processed — vibes/nudges should wait */
522
556
  private _busy = false
523
557
  /** Serializes all triggerAgent calls to prevent concurrent runs */
524
558
  private _gatewayQueue: Promise<void> = Promise.resolve()
525
559
 
560
+ // === Plan State ===
561
+ private currentPlan: Plan | null = null
562
+ private planNudgeTimer: NodeJS.Timeout | null = null
563
+
526
564
  // === Injected dependencies ===
527
565
  private readonly clock: IClock
528
566
  private readonly logger: Pick<Console, 'log' | 'error' | 'warn'>
@@ -553,18 +591,22 @@ export class Coordinator {
553
591
  updateConfig(config: Partial<CoordinatorConfig>): void {
554
592
  this.config = { ...this.config, ...config }
555
593
  this.logger.log('[Coordinator] Config updated:', {
594
+ autonomyMode: this.config.autonomyMode,
556
595
  vibeIntervalMs: this.config.vibeIntervalMs,
557
596
  idleAfterMs: this.config.idleAfterMs,
558
597
  sleepAfterIdleMs: this.config.sleepAfterIdleMs,
598
+ batchWindowMs: this.config.batchWindowMs,
559
599
  })
560
600
  }
561
601
 
562
602
  /** Get current state and config */
563
- getState(): { state: CoordinatorState; lastActivityAt: number; config: CoordinatorConfig } {
603
+ getState(): { state: CoordinatorState; lastActivityAt: number; config: CoordinatorConfig; plan: Plan | null; autonomyMode: AutonomyMode } {
564
604
  return {
565
605
  state: this._state,
566
606
  lastActivityAt: this.lastActivityAt,
567
607
  config: this.config,
608
+ plan: this.currentPlan,
609
+ autonomyMode: this.config.autonomyMode,
568
610
  }
569
611
  }
570
612
 
@@ -588,11 +630,13 @@ export class Coordinator {
588
630
  vibeIntervalMs: this.config.vibeIntervalMs,
589
631
  idleAfterMs: this.config.idleAfterMs,
590
632
  sleepAfterIdleMs: this.config.sleepAfterIdleMs,
633
+ batchWindowMs: this.config.batchWindowMs,
591
634
  })
592
635
  }
593
636
 
594
637
  stop(): void {
595
638
  this.stopVibeLoop()
639
+ this.cancelPlanNudge()
596
640
  if (this.timer) {
597
641
  this.clock.clearTimeout(this.timer)
598
642
  this.timer = null
@@ -603,7 +647,7 @@ export class Coordinator {
603
647
 
604
648
  // === State Machine Methods ===
605
649
 
606
- /** Wake up from sleep/idle state and start the vibe loop */
650
+ /** Wake up from sleep/idle state and start the autonomy loop */
607
651
  wake(): void {
608
652
  if (this._state === 'active') return
609
653
 
@@ -613,7 +657,12 @@ export class Coordinator {
613
657
  this.logger.log('[Coordinator] WAKE - transitioning to ACTIVE state')
614
658
  this.emit({ type: 'stateChange', from, to: 'active' })
615
659
 
616
- this.startVibeLoop()
660
+ this.startVibeLoop() // starts sleep/idle check timers + vibe loop (if mode=vibe)
661
+
662
+ // In plan mode, also start plan nudges if there are pending steps
663
+ if (this.config.autonomyMode === 'plan' && this.hasPendingPlanSteps()) {
664
+ this.schedulePlanNudge()
665
+ }
617
666
  }
618
667
 
619
668
  /** Go to idle state (between activities, eyes open) */
@@ -640,6 +689,7 @@ export class Coordinator {
640
689
  this.emit({ type: 'stateChange', from, to: 'sleep' })
641
690
 
642
691
  this.stopVibeLoop()
692
+ this.cancelPlanNudge()
643
693
  this.compactSession()
644
694
  }
645
695
 
@@ -671,6 +721,202 @@ export class Coordinator {
671
721
  return this.clock.now() - this.lastActivityAt
672
722
  }
673
723
 
724
+ // === Plan Methods ===
725
+
726
+ /** Create or replace the current plan */
727
+ setPlan(goal: string, steps: string[]): Plan {
728
+ if (this.currentPlan?.status === 'active') {
729
+ this.currentPlan.status = 'abandoned'
730
+ this.emit({ type: 'planAbandoned', planId: this.currentPlan.id })
731
+ }
732
+
733
+ const plan: Plan = {
734
+ id: randomUUID(),
735
+ goal,
736
+ steps: steps.map(s => ({ description: s, status: 'pending' as const })),
737
+ createdAt: this.clock.now(),
738
+ status: 'active',
739
+ }
740
+ this.currentPlan = plan
741
+ this.emit({ type: 'planCreated', planId: plan.id, goal, stepCount: steps.length })
742
+
743
+ if (this._state !== 'active') this.wake()
744
+
745
+ return plan
746
+ }
747
+
748
+ /** Mark a step as done (0-indexed). Returns updated plan or null. */
749
+ markStepDone(stepIndex: number): Plan | null {
750
+ if (!this.currentPlan || this.currentPlan.status !== 'active') return null
751
+ if (stepIndex < 0 || stepIndex >= this.currentPlan.steps.length) return null
752
+
753
+ this.currentPlan.steps[stepIndex].status = 'done'
754
+ this.emit({ type: 'planStepDone', planId: this.currentPlan.id, step: stepIndex })
755
+
756
+ const allDone = this.currentPlan.steps.every(s => s.status === 'done')
757
+ if (allDone) {
758
+ this.currentPlan.status = 'completed'
759
+ this.emit({ type: 'planCompleted', planId: this.currentPlan.id })
760
+ }
761
+
762
+ return this.currentPlan
763
+ }
764
+
765
+ /** Abandon the current plan */
766
+ abandonPlan(): Plan | null {
767
+ if (!this.currentPlan || this.currentPlan.status !== 'active') return null
768
+ this.currentPlan.status = 'abandoned'
769
+ this.emit({ type: 'planAbandoned', planId: this.currentPlan.id })
770
+ this.cancelPlanNudge()
771
+ return this.currentPlan
772
+ }
773
+
774
+ /** Get the current plan */
775
+ getPlan(): Plan | null {
776
+ return this.currentPlan
777
+ }
778
+
779
+ /** Check if there is an active plan with pending steps */
780
+ private hasPendingPlanSteps(): boolean {
781
+ if (!this.currentPlan || this.currentPlan.status !== 'active') return false
782
+ return this.currentPlan.steps.some(s => s.status === 'pending')
783
+ }
784
+
785
+ // === Plan Nudge Loop ===
786
+
787
+ /** Schedule next plan nudge (short delay to avoid spinning) */
788
+ private schedulePlanNudge(): void {
789
+ this.cancelPlanNudge()
790
+ if (this._state === 'sleep') return
791
+ if (this.config.autonomyMode !== 'plan') return
792
+ if (!this.hasPendingPlanSteps()) return
793
+
794
+ const delay = this.config.planNudgeDelayMs
795
+ const nextNudgeAt = this.clock.now() + delay
796
+ this.emit({ type: 'planNudgeScheduled', nextNudgeAt })
797
+ this.planNudgeTimer = this.clock.setTimeout(() => this.planNudge(), delay)
798
+ }
799
+
800
+ /** Cancel pending plan nudge */
801
+ private cancelPlanNudge(): void {
802
+ if (this.planNudgeTimer) {
803
+ this.clock.clearTimeout(this.planNudgeTimer)
804
+ this.planNudgeTimer = null
805
+ }
806
+ }
807
+
808
+ /** Check plan state and schedule next nudge if needed */
809
+ private checkPlanProgress(): void {
810
+ if (this.config.autonomyMode !== 'plan') return
811
+ if (this._state === 'sleep') return
812
+ if (this.hasPendingPlanSteps()) {
813
+ this.schedulePlanNudge()
814
+ }
815
+ }
816
+
817
+ /** Execute a plan nudge — send [CRAWD:PLAN] prompt to agent */
818
+ private async planNudge(): Promise<void> {
819
+ if (this._state === 'sleep') {
820
+ this.emit({ type: 'planNudgeExecuted', skipped: true, reason: 'sleeping' })
821
+ return
822
+ }
823
+
824
+ if (this._busy) {
825
+ this.logger.log('[Coordinator] Plan nudge skipped - busy')
826
+ this.emit({ type: 'planNudgeExecuted', skipped: true, reason: 'busy' })
827
+ this.schedulePlanNudge()
828
+ return
829
+ }
830
+
831
+ if (!this.hasPendingPlanSteps()) {
832
+ this.logger.log('[Coordinator] Plan nudge skipped - no pending steps')
833
+ this.emit({ type: 'planNudgeExecuted', skipped: true, reason: 'no pending steps' })
834
+ return
835
+ }
836
+
837
+ if (this._state === 'idle') {
838
+ const from = this._state
839
+ this._state = 'active'
840
+ this.emit({ type: 'stateChange', from, to: 'active' })
841
+ }
842
+
843
+ this.logger.log('[Coordinator] Plan nudge - sending plan prompt')
844
+ this.emit({ type: 'planNudgeExecuted', skipped: false })
845
+ this.resetActivity()
846
+
847
+ const prompt = this.buildPlanNudgePrompt()
848
+
849
+ this._busy = true
850
+ let noReply = false
851
+ const nudgeOp = this._gatewayQueue.then(async () => {
852
+ this._busy = true
853
+ try {
854
+ const replies = await this.triggerFn(prompt)
855
+ const agentReplies = replies.filter(r => !this.isApiError(r))
856
+
857
+ // Treat empty replies as NO_REPLY — gateway returns empty payloads
858
+ // when the agent responds with text-only (no tool calls)
859
+ if (agentReplies.length === 0 || agentReplies.some(r => r.trim().toUpperCase() === 'NO_REPLY')) {
860
+ noReply = true
861
+ } else if (!this.isCompliantReply(agentReplies)) {
862
+ const misaligned = agentReplies.filter(r => {
863
+ const t = r.trim().toUpperCase()
864
+ return t !== 'NO_REPLY' && t !== 'LIVESTREAM_REPLIED'
865
+ })
866
+ if (misaligned.length > 0) {
867
+ await this.sendMisalignment(misaligned)
868
+ }
869
+ }
870
+ } catch (err) {
871
+ this.logger.error('[Coordinator] Plan nudge failed:', err)
872
+ } finally {
873
+ this._busy = false
874
+ }
875
+ })
876
+ this._gatewayQueue = nudgeOp.catch(() => {})
877
+
878
+ try {
879
+ await nudgeOp
880
+ } catch {}
881
+
882
+ if (noReply) {
883
+ this.logger.log('[Coordinator] Agent sent NO_REPLY during plan nudge, going to sleep')
884
+ this.goSleep()
885
+ return
886
+ }
887
+
888
+ this.checkPlanProgress()
889
+ }
890
+
891
+ /** Build the [CRAWD:PLAN] prompt with current plan progress */
892
+ private buildPlanNudgePrompt(): string {
893
+ if (!this.currentPlan || this.currentPlan.status !== 'active') {
894
+ return '[CRAWD:PLAN] No active plan. Create one using plan_set or respond with NO_REPLY to idle.'
895
+ }
896
+
897
+ const lines: string[] = ['[CRAWD:PLAN] Continue your plan.', '']
898
+ lines.push(`Target: ${this.currentPlan.goal}`)
899
+
900
+ let nextStepIdx = -1
901
+ for (let i = 0; i < this.currentPlan.steps.length; i++) {
902
+ const step = this.currentPlan.steps[i]
903
+ const isDone = step.status === 'done'
904
+ if (!isDone && nextStepIdx === -1) nextStepIdx = i
905
+ const marker = isDone ? '[x]' : (nextStepIdx === i ? '[-]' : '[ ]')
906
+ const arrow = nextStepIdx === i ? ' <-- next' : ''
907
+ lines.push(`${marker} ${i}. ${step.description}${arrow}`)
908
+ }
909
+
910
+ lines.push('')
911
+ if (nextStepIdx >= 0) {
912
+ lines.push(`Work on step ${nextStepIdx}. Use plan_step_done when complete.`)
913
+ }
914
+ lines.push('You can use plan_abandon to drop this plan, or plan_set to replace it.')
915
+ lines.push('Respond with LIVESTREAM_REPLIED after speaking, or NO_REPLY if you have nothing to say.')
916
+
917
+ return lines.join('\n')
918
+ }
919
+
674
920
  /** Start the periodic vibe loop */
675
921
  private startVibeLoop(): void {
676
922
  this.stopVibeLoop() // Clear any existing timer
@@ -719,9 +965,9 @@ export class Coordinator {
719
965
 
720
966
  /** Schedule the next vibe action */
721
967
  scheduleNextVibe(): void {
722
- // Vibe while active or idle (not while sleeping)
723
968
  if (this._state === 'sleep') return
724
- if (!this.config.vibeEnabled) return
969
+ // Only schedule vibes in vibe mode
970
+ if (this.config.autonomyMode !== 'vibe') return
725
971
 
726
972
  const nextVibeAt = this.clock.now() + this.config.vibeIntervalMs
727
973
  this.emit({ type: 'vibeScheduled', nextVibeAt })
@@ -767,7 +1013,9 @@ export class Coordinator {
767
1013
  const replies = await this.triggerFn(this.config.vibePrompt)
768
1014
  // Filter out API errors (429s, rate limits) — not agent responses
769
1015
  const agentReplies = replies.filter(r => !this.isApiError(r))
770
- if (agentReplies.some(r => r.trim().toUpperCase() === 'NO_REPLY')) {
1016
+ // Treat empty replies as NO_REPLY — gateway returns empty payloads
1017
+ // when the agent responds with text-only (no tool calls)
1018
+ if (agentReplies.length === 0 || agentReplies.some(r => r.trim().toUpperCase() === 'NO_REPLY')) {
771
1019
  noReply = true
772
1020
  } else if (!this.isCompliantReply(agentReplies)) {
773
1021
  misaligned = agentReplies.filter(r => {
@@ -836,7 +1084,7 @@ export class Coordinator {
836
1084
  // Leading edge: if no timer running, flush immediately and start cooldown
837
1085
  if (!this.timer) {
838
1086
  this.flush()
839
- this.timer = this.clock.setTimeout(() => this.onCooldownEnd(), BATCH_WINDOW_MS)
1087
+ this.timer = this.clock.setTimeout(() => this.onCooldownEnd(), this.config.batchWindowMs)
840
1088
  }
841
1089
  // Otherwise, message is buffered and will be flushed when cooldown ends
842
1090
  }
@@ -847,7 +1095,7 @@ export class Coordinator {
847
1095
  // If messages accumulated during cooldown, flush them and restart cooldown
848
1096
  if (this.buffer.length > 0) {
849
1097
  this.flush()
850
- this.timer = this.clock.setTimeout(() => this.onCooldownEnd(), BATCH_WINDOW_MS)
1098
+ this.timer = this.clock.setTimeout(() => this.onCooldownEnd(), this.config.batchWindowMs)
851
1099
  }
852
1100
  }
853
1101
 
@@ -915,6 +1163,7 @@ export class Coordinator {
915
1163
  this.logger.error('[Coordinator] Failed to trigger agent:', err)
916
1164
  } finally {
917
1165
  this._busy = false
1166
+ this.checkPlanProgress()
918
1167
  }
919
1168
  }).catch(() => {})
920
1169
  }
@@ -934,6 +1183,12 @@ export class Coordinator {
934
1183
  ? '\n(To reply to a specific message, prefix with its ID: [msgId] your reply)'
935
1184
  : ''
936
1185
 
937
- return `${header}\n${lines.join('\n')}${instruction}`
1186
+ // In plan mode with no active plan, instruct agent to create one
1187
+ let planInstruction = ''
1188
+ if (this.config.autonomyMode === 'plan' && !this.hasPendingPlanSteps()) {
1189
+ 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.'
1190
+ }
1191
+
1192
+ return `${header}\n${lines.join('\n')}${instruction}${planInstruction}`
938
1193
  }
939
1194
  }