crawd 0.8.7 → 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.
@@ -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'
@@ -504,6 +529,13 @@ export type CoordinatorEvent =
504
529
  | { type: 'vibeExecuted'; skipped: boolean; reason?: string }
505
530
  | { type: 'sleepCheck'; inactiveForMs: number; willSleep: boolean }
506
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 }
507
539
 
508
540
  export class Coordinator {
509
541
  private buffer: ChatMessage[] = []
@@ -520,11 +552,15 @@ export class Coordinator {
520
552
  private idleSince = 0
521
553
  private vibeTimer: NodeJS.Timeout | null = null
522
554
  private sleepCheckTimer: NodeJS.Timeout | null = null
523
- /** 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 */
524
556
  private _busy = false
525
557
  /** Serializes all triggerAgent calls to prevent concurrent runs */
526
558
  private _gatewayQueue: Promise<void> = Promise.resolve()
527
559
 
560
+ // === Plan State ===
561
+ private currentPlan: Plan | null = null
562
+ private planNudgeTimer: NodeJS.Timeout | null = null
563
+
528
564
  // === Injected dependencies ===
529
565
  private readonly clock: IClock
530
566
  private readonly logger: Pick<Console, 'log' | 'error' | 'warn'>
@@ -555,6 +591,7 @@ export class Coordinator {
555
591
  updateConfig(config: Partial<CoordinatorConfig>): void {
556
592
  this.config = { ...this.config, ...config }
557
593
  this.logger.log('[Coordinator] Config updated:', {
594
+ autonomyMode: this.config.autonomyMode,
558
595
  vibeIntervalMs: this.config.vibeIntervalMs,
559
596
  idleAfterMs: this.config.idleAfterMs,
560
597
  sleepAfterIdleMs: this.config.sleepAfterIdleMs,
@@ -563,11 +600,13 @@ export class Coordinator {
563
600
  }
564
601
 
565
602
  /** Get current state and config */
566
- getState(): { state: CoordinatorState; lastActivityAt: number; config: CoordinatorConfig } {
603
+ getState(): { state: CoordinatorState; lastActivityAt: number; config: CoordinatorConfig; plan: Plan | null; autonomyMode: AutonomyMode } {
567
604
  return {
568
605
  state: this._state,
569
606
  lastActivityAt: this.lastActivityAt,
570
607
  config: this.config,
608
+ plan: this.currentPlan,
609
+ autonomyMode: this.config.autonomyMode,
571
610
  }
572
611
  }
573
612
 
@@ -597,6 +636,7 @@ export class Coordinator {
597
636
 
598
637
  stop(): void {
599
638
  this.stopVibeLoop()
639
+ this.cancelPlanNudge()
600
640
  if (this.timer) {
601
641
  this.clock.clearTimeout(this.timer)
602
642
  this.timer = null
@@ -607,7 +647,7 @@ export class Coordinator {
607
647
 
608
648
  // === State Machine Methods ===
609
649
 
610
- /** Wake up from sleep/idle state and start the vibe loop */
650
+ /** Wake up from sleep/idle state and start the autonomy loop */
611
651
  wake(): void {
612
652
  if (this._state === 'active') return
613
653
 
@@ -617,7 +657,12 @@ export class Coordinator {
617
657
  this.logger.log('[Coordinator] WAKE - transitioning to ACTIVE state')
618
658
  this.emit({ type: 'stateChange', from, to: 'active' })
619
659
 
620
- 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
+ }
621
666
  }
622
667
 
623
668
  /** Go to idle state (between activities, eyes open) */
@@ -644,6 +689,7 @@ export class Coordinator {
644
689
  this.emit({ type: 'stateChange', from, to: 'sleep' })
645
690
 
646
691
  this.stopVibeLoop()
692
+ this.cancelPlanNudge()
647
693
  this.compactSession()
648
694
  }
649
695
 
@@ -675,6 +721,202 @@ export class Coordinator {
675
721
  return this.clock.now() - this.lastActivityAt
676
722
  }
677
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
+
678
920
  /** Start the periodic vibe loop */
679
921
  private startVibeLoop(): void {
680
922
  this.stopVibeLoop() // Clear any existing timer
@@ -723,9 +965,9 @@ export class Coordinator {
723
965
 
724
966
  /** Schedule the next vibe action */
725
967
  scheduleNextVibe(): void {
726
- // Vibe while active or idle (not while sleeping)
727
968
  if (this._state === 'sleep') return
728
- if (!this.config.vibeEnabled) return
969
+ // Only schedule vibes in vibe mode
970
+ if (this.config.autonomyMode !== 'vibe') return
729
971
 
730
972
  const nextVibeAt = this.clock.now() + this.config.vibeIntervalMs
731
973
  this.emit({ type: 'vibeScheduled', nextVibeAt })
@@ -771,7 +1013,9 @@ export class Coordinator {
771
1013
  const replies = await this.triggerFn(this.config.vibePrompt)
772
1014
  // Filter out API errors (429s, rate limits) — not agent responses
773
1015
  const agentReplies = replies.filter(r => !this.isApiError(r))
774
- 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')) {
775
1019
  noReply = true
776
1020
  } else if (!this.isCompliantReply(agentReplies)) {
777
1021
  misaligned = agentReplies.filter(r => {
@@ -919,6 +1163,7 @@ export class Coordinator {
919
1163
  this.logger.error('[Coordinator] Failed to trigger agent:', err)
920
1164
  } finally {
921
1165
  this._busy = false
1166
+ this.checkPlanProgress()
922
1167
  }
923
1168
  }).catch(() => {})
924
1169
  }
@@ -938,6 +1183,12 @@ export class Coordinator {
938
1183
  ? '\n(To reply to a specific message, prefix with its ID: [msgId] your reply)'
939
1184
  : ''
940
1185
 
941
- 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}`
942
1193
  }
943
1194
  }