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.
- package/dist/types.d.ts +5 -23
- package/openclaw.plugin.json +8 -40
- package/package.json +4 -2
- package/skills/crawd/SKILL.md +37 -0
- package/src/backend/coordinator.test.ts +393 -0
- package/src/backend/coordinator.ts +267 -16
- package/src/backend/index.ts +29 -208
- package/src/backend/server.ts +67 -219
- package/src/plugin.ts +122 -33
- package/src/types.ts +4 -23
- package/src/lib/tts/tiktok.ts +0 -91
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
10
|
-
|
|
11
|
-
/** How often to send vibe prompt when active (ms). Default:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|