bingocode 1.1.100 → 1.1.102
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/bootstrap/state.ts +33 -0
- package/src/hooks/useGoalEvaluator.ts +98 -0
- package/src/manager/CliMenuManager.tsx +38 -29
- package/src/manager/CliMenuUi.tsx +29 -9
- package/src/screens/REPL.tsx +6 -0
- package/src/skills/bundled/goal.ts +71 -0
- package/src/skills/bundled/index.ts +1 -0
- package/src/utils/goalEvaluator.ts +64 -0
package/package.json
CHANGED
package/src/bootstrap/state.ts
CHANGED
|
@@ -135,6 +135,10 @@ type State = {
|
|
|
135
135
|
// (useScheduledTasks). Set by cronScheduler.start() when the JSON has
|
|
136
136
|
// entries, or by CronCreateTool. Not persisted.
|
|
137
137
|
scheduledTasksEnabled: boolean
|
|
138
|
+
// Session-only goal condition for /goal command. Null when no active goal.
|
|
139
|
+
goalCondition: string | null
|
|
140
|
+
goalIterationCount: number
|
|
141
|
+
goalMaxIterations: number
|
|
138
142
|
// Session-only cron tasks created via CronCreate with durable: false.
|
|
139
143
|
// Fire on schedule like file-backed tasks but are never written to
|
|
140
144
|
// .claude/scheduled_tasks.json — they die with the process. Typed via
|
|
@@ -357,6 +361,10 @@ function getInitialState(): State {
|
|
|
357
361
|
sessionBypassPermissionsMode: false,
|
|
358
362
|
// Scheduled tasks disabled until flag or dialog enables them
|
|
359
363
|
scheduledTasksEnabled: false,
|
|
364
|
+
// Goal condition for /goal command (null = no active goal)
|
|
365
|
+
goalCondition: null,
|
|
366
|
+
goalIterationCount: 0,
|
|
367
|
+
goalMaxIterations: 20,
|
|
360
368
|
sessionCronTasks: [],
|
|
361
369
|
sessionCreatedTeams: new Set(),
|
|
362
370
|
// Session-only trust flag (not persisted to disk)
|
|
@@ -1772,3 +1780,28 @@ export function setPromptId(id: string | null): void {
|
|
|
1772
1780
|
STATE.promptId = id
|
|
1773
1781
|
}
|
|
1774
1782
|
|
|
1783
|
+
// ============================================================================
|
|
1784
|
+
// /goal session state accessors
|
|
1785
|
+
// ============================================================================
|
|
1786
|
+
|
|
1787
|
+
export function getGoalCondition(): string | null {
|
|
1788
|
+
return STATE.goalCondition
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
export function setGoalCondition(condition: string | null): void {
|
|
1792
|
+
STATE.goalCondition = condition
|
|
1793
|
+
STATE.goalIterationCount = 0
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
export function getGoalIterationCount(): number {
|
|
1797
|
+
return STATE.goalIterationCount
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
export function incrementGoalIterationCount(): void {
|
|
1801
|
+
STATE.goalIterationCount++
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
export function getGoalMaxIterations(): number {
|
|
1805
|
+
return STATE.goalMaxIterations
|
|
1806
|
+
}
|
|
1807
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import type { MutableRefObject } from 'react'
|
|
3
|
+
import type { MessageType } from '../components/messages.js'
|
|
4
|
+
import {
|
|
5
|
+
getGoalCondition,
|
|
6
|
+
getGoalIterationCount,
|
|
7
|
+
getGoalMaxIterations,
|
|
8
|
+
incrementGoalIterationCount,
|
|
9
|
+
setGoalCondition,
|
|
10
|
+
} from '../bootstrap/state.js'
|
|
11
|
+
import { enqueue } from '../utils/messageQueueManager.js'
|
|
12
|
+
import { evaluateGoal } from '../utils/goalEvaluator.js'
|
|
13
|
+
|
|
14
|
+
type UseGoalEvaluatorParams = {
|
|
15
|
+
lastQueryCompletionTime: number
|
|
16
|
+
messagesRef: MutableRefObject<MessageType[]>
|
|
17
|
+
isQueryActive: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* React hook that fires an independent goal evaluator after each turn.
|
|
22
|
+
*
|
|
23
|
+
* Triggered by lastQueryCompletionTime changing (set in REPL.tsx after
|
|
24
|
+
* queryGuard.end() succeeds). Uses messagesRef (not React state) to avoid
|
|
25
|
+
* batching issues — the ref is synchronously updated via Zustand pattern
|
|
26
|
+
* before React re-renders.
|
|
27
|
+
*
|
|
28
|
+
* On goal not satisfied: enqueues a continuation message with priority 'now'
|
|
29
|
+
* so useQueueProcessor picks it up immediately for the next turn.
|
|
30
|
+
* On goal satisfied or max iterations reached: clears the goal condition.
|
|
31
|
+
*/
|
|
32
|
+
export function useGoalEvaluator({
|
|
33
|
+
lastQueryCompletionTime,
|
|
34
|
+
messagesRef,
|
|
35
|
+
isQueryActive,
|
|
36
|
+
}: UseGoalEvaluatorParams): void {
|
|
37
|
+
const lastEvaluatedAt = useRef(0)
|
|
38
|
+
const evaluating = useRef(false)
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const condition = getGoalCondition()
|
|
42
|
+
if (!condition) return
|
|
43
|
+
if (isQueryActive) return
|
|
44
|
+
if (lastQueryCompletionTime === 0) return
|
|
45
|
+
if (lastQueryCompletionTime === lastEvaluatedAt.current) return
|
|
46
|
+
if (evaluating.current) return
|
|
47
|
+
|
|
48
|
+
lastEvaluatedAt.current = lastQueryCompletionTime
|
|
49
|
+
evaluating.current = true
|
|
50
|
+
|
|
51
|
+
void (async () => {
|
|
52
|
+
try {
|
|
53
|
+
const iterCount = getGoalIterationCount()
|
|
54
|
+
const maxIter = getGoalMaxIterations()
|
|
55
|
+
|
|
56
|
+
if (iterCount >= maxIter) {
|
|
57
|
+
setGoalCondition(null)
|
|
58
|
+
enqueue({
|
|
59
|
+
value: `⚠️ /goal stopped after ${maxIter} iterations. Goal not achieved: "${condition}"`,
|
|
60
|
+
mode: 'task-notification',
|
|
61
|
+
priority: 'now',
|
|
62
|
+
})
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Read snapshot BEFORE await to avoid stale data
|
|
67
|
+
const messages = messagesRef.current
|
|
68
|
+
const result = await evaluateGoal(condition, messages)
|
|
69
|
+
|
|
70
|
+
// Race protection: user may have called /goal clear during the await
|
|
71
|
+
if (getGoalCondition() !== condition) return
|
|
72
|
+
|
|
73
|
+
incrementGoalIterationCount()
|
|
74
|
+
|
|
75
|
+
if (result.satisfied) {
|
|
76
|
+
setGoalCondition(null)
|
|
77
|
+
enqueue({
|
|
78
|
+
value: `✅ Goal achieved (iteration ${iterCount + 1}): ${result.reason}`,
|
|
79
|
+
mode: 'task-notification',
|
|
80
|
+
priority: 'now',
|
|
81
|
+
})
|
|
82
|
+
} else {
|
|
83
|
+
const continueMsg = result.gap
|
|
84
|
+
? `Goal not yet met (${iterCount + 1}/${maxIter}). Gap: ${result.gap}. Continue toward: "${condition}"`
|
|
85
|
+
: `Goal not yet met (${iterCount + 1}/${maxIter}, reason: ${result.reason}). Continue toward: "${condition}"`
|
|
86
|
+
enqueue({
|
|
87
|
+
value: continueMsg,
|
|
88
|
+
mode: 'task-notification',
|
|
89
|
+
priority: 'now',
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
evaluating.current = false
|
|
94
|
+
}
|
|
95
|
+
})()
|
|
96
|
+
// messages intentionally excluded from deps — read via ref to avoid batching issues
|
|
97
|
+
}, [lastQueryCompletionTime, isQueryActive])
|
|
98
|
+
}
|
|
@@ -73,8 +73,8 @@ async function buildSpawnEnv(): Promise<NodeJS.ProcessEnv> {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
// Top height: Home fits LogoV2 + Toolbar, other pages more compact
|
|
76
|
-
const TOP_H_HOME = Number(process.env.CLI_TOP_H_HOME ||
|
|
77
|
-
const TOP_H_COMPACT = Number(process.env.CLI_TOP_H_COMPACT ||
|
|
76
|
+
const TOP_H_HOME = Number(process.env.CLI_TOP_H_HOME || 10);
|
|
77
|
+
const TOP_H_COMPACT = Number(process.env.CLI_TOP_H_COMPACT || 7);
|
|
78
78
|
// Bottom bar height
|
|
79
79
|
const BOTTOM_H = Number(process.env.CLI_BOTTOM_H || 3);
|
|
80
80
|
|
|
@@ -91,13 +91,13 @@ const i18nMap = {
|
|
|
91
91
|
about: 'Bingo CLI - Version Info & About',
|
|
92
92
|
aboutContent: [
|
|
93
93
|
'Bingo is an AI assistant terminal client.',
|
|
94
|
-
'Author: leanchy (Email: leanchy07@outlook.com)',
|
|
95
|
-
'Github: github.com/leanchy/bingo-claude-code-offline-installer',
|
|
96
94
|
'1. API Config: Press "P" or select "API Config" to set up your keys.',
|
|
97
95
|
'2. Model Slots: Configure specific models in the Provider panel.',
|
|
98
96
|
'3. Background Service: Bingo runs a local server to manage sessions.',
|
|
99
97
|
'4. Start Chat: Run `bingocode` or `claude` in any terminal to start.',
|
|
100
98
|
].join('\n'),
|
|
99
|
+
author: 'Author: leanchy (Email: leanchy07@outlook.com)',
|
|
100
|
+
github: 'Github: github.com/leanchy/bingo-claude-code-offline-installer',
|
|
101
101
|
mark: '→ Mark Session',
|
|
102
102
|
unmark: '→ Unmark Session',
|
|
103
103
|
tipsSimple: 'L Lang | ESC Back | ←→ Menu | ↩ Enter | ? Help',
|
|
@@ -119,13 +119,13 @@ const i18nMap = {
|
|
|
119
119
|
about: 'Bingo CLI Terminal - Version Info & About',
|
|
120
120
|
aboutContent: [
|
|
121
121
|
'Bingo is an AI assistant terminal client.',
|
|
122
|
-
'Author: leanchy (Email: leanchy07@outlook.com)',
|
|
123
|
-
'Github: github.com/leanchy/bingo-claude-code-offline-installer',
|
|
124
122
|
'1. API Config: Press "P" or select "API Config" to set up your keys.',
|
|
125
123
|
'2. Model Slots: Configure specific models in the Provider panel.',
|
|
126
124
|
'3. Background Service: Bingo runs a local server to manage sessions.',
|
|
127
125
|
'4. Start Chat: Run `bingocode` or `claude` in any terminal to start.',
|
|
128
126
|
].join('\n'),
|
|
127
|
+
author: 'Author: leanchy (Email: leanchy07@outlook.com)',
|
|
128
|
+
github: 'Github: github.com/leanchy/bingo-claude-code-offline-installer',
|
|
129
129
|
mark: '→ Mark Session',
|
|
130
130
|
unmark: '→ Unmark Session',
|
|
131
131
|
tipsSimple: 'L Lang | ESC Back | ←→ Menu | ↩ Enter | ? Help',
|
|
@@ -305,7 +305,7 @@ export const CliMenuManager: React.FC = () => {
|
|
|
305
305
|
const [showHelp, setShowHelp] = useState(false);
|
|
306
306
|
|
|
307
307
|
// Keyboard navigation for lists
|
|
308
|
-
const [
|
|
308
|
+
const [historyHighlightIndex, setHistoryHighlightIndex] = useState(0);
|
|
309
309
|
|
|
310
310
|
// Quick Resume (R)
|
|
311
311
|
const [quickResumeRequested, setQuickResumeRequested] = useState(false);
|
|
@@ -571,6 +571,7 @@ export const CliMenuManager: React.FC = () => {
|
|
|
571
571
|
setHistoryCursor(null);
|
|
572
572
|
setSessionMessages([]);
|
|
573
573
|
setMsgsPage(0);
|
|
574
|
+
setHistoryHighlightIndex(0);
|
|
574
575
|
setSettingsOffset(0);
|
|
575
576
|
return;
|
|
576
577
|
}
|
|
@@ -611,30 +612,22 @@ export const CliMenuManager: React.FC = () => {
|
|
|
611
612
|
// History shortcuts
|
|
612
613
|
if (!showHelp && page === 'history') {
|
|
613
614
|
if (historyMenuStage === 'list') {
|
|
614
|
-
const HIST_VISIBLE = MID_H - 2;
|
|
615
|
-
if (key.downArrow || input === 'j' || input === '\u001b[B') {
|
|
616
|
-
// Internal SelectInput handles cursor, we just need to track offset for ScrollBar
|
|
617
|
-
setListOffset(o => Math.min(o + 1, Math.max(0, groupedHistoryItems.length - HIST_VISIBLE)));
|
|
618
|
-
}
|
|
619
|
-
if (key.upArrow || input === 'k' || input === '\u001b[A') {
|
|
620
|
-
setListOffset(o => Math.max(0, o - 1));
|
|
621
|
-
}
|
|
622
615
|
if (input === 'q') {
|
|
623
616
|
setPage(null);
|
|
624
617
|
setHistoryMenuStage('list');
|
|
625
618
|
setSelectedHistory(null);
|
|
626
619
|
setHistoryCursor(null);
|
|
627
|
-
|
|
620
|
+
setHistoryHighlightIndex(0);
|
|
628
621
|
return;
|
|
629
622
|
}
|
|
630
623
|
if (input === 'j' && historyHasMore) {
|
|
631
624
|
setHistoryCursor(historyList[historyList.length - 1]?.id || null);
|
|
632
|
-
|
|
625
|
+
setHistoryHighlightIndex(0);
|
|
633
626
|
return;
|
|
634
627
|
}
|
|
635
628
|
if (input === 'k') {
|
|
636
629
|
setHistoryCursor(null);
|
|
637
|
-
|
|
630
|
+
setHistoryHighlightIndex(0);
|
|
638
631
|
return;
|
|
639
632
|
}
|
|
640
633
|
} else if (historyMenuStage === 'window') {
|
|
@@ -934,12 +927,23 @@ export const CliMenuManager: React.FC = () => {
|
|
|
934
927
|
// Home: WelcomeV2 (58 cols wide)
|
|
935
928
|
if (page === null) {
|
|
936
929
|
const WELCOME_W = 58;
|
|
937
|
-
const leftPad = Math.max(0, Math.floor((VIEW_W - WELCOME_W) / 2));
|
|
938
930
|
return (
|
|
939
931
|
<Box flexDirection="column" width={VIEW_W} height={MID_H}>
|
|
940
932
|
<Box flexDirection="row" width={VIEW_W} flexGrow={1}>
|
|
941
|
-
<Box
|
|
942
|
-
|
|
933
|
+
<Box paddingX={2}>
|
|
934
|
+
<WelcomeV2 />
|
|
935
|
+
</Box>
|
|
936
|
+
<Box flexGrow={1} flexDirection="column" alignItems="flex-end" paddingRight={4} paddingTop={1}>
|
|
937
|
+
<Box borderStyle="classic" borderColor="gray" paddingX={1} flexDirection="column">
|
|
938
|
+
<Text color="cyan" bold>Version Info</Text>
|
|
939
|
+
<Text> </Text>
|
|
940
|
+
<Text dimColor>v1.1.101</Text>
|
|
941
|
+
<Text dimColor>Stable Release</Text>
|
|
942
|
+
<Text> </Text>
|
|
943
|
+
<Text color="gray">Arch: {process.arch}</Text>
|
|
944
|
+
<Text color="gray">Node: {process.version}</Text>
|
|
945
|
+
</Box>
|
|
946
|
+
</Box>
|
|
943
947
|
</Box>
|
|
944
948
|
{!apiUrl && !bootErr && (
|
|
945
949
|
<StateDisplay type="loading" message="Starting server..." />
|
|
@@ -1145,16 +1149,21 @@ export const CliMenuManager: React.FC = () => {
|
|
|
1145
1149
|
|
|
1146
1150
|
// About
|
|
1147
1151
|
if (page === 'about') {
|
|
1152
|
+
const i18n = i18nMap[lang] as any;
|
|
1148
1153
|
return (
|
|
1149
|
-
<Box width={VIEW_W} height={MID_H} flexDirection="column">
|
|
1150
|
-
<
|
|
1151
|
-
|
|
1152
|
-
<
|
|
1154
|
+
<Box width={VIEW_W} height={MID_H} flexDirection="column" paddingX={1}>
|
|
1155
|
+
<Box flexGrow={1} flexDirection="column">
|
|
1156
|
+
<Text color="cyan" bold>{i18n.about}</Text>
|
|
1157
|
+
<Box marginTop={1} flexDirection="column">
|
|
1158
|
+
<Text>{i18n.aboutContent}</Text>
|
|
1159
|
+
</Box>
|
|
1160
|
+
<Box marginTop={1}>
|
|
1161
|
+
<Hint>API Base: {apiUrl}</Hint>
|
|
1162
|
+
</Box>
|
|
1153
1163
|
</Box>
|
|
1154
|
-
<Box marginTop={1}>
|
|
1155
|
-
<
|
|
1156
|
-
|
|
1157
|
-
</Hint>
|
|
1164
|
+
<Box borderStyle="classic" borderColor="gray" paddingX={1} flexDirection="column" marginTop={1}>
|
|
1165
|
+
<Text dimColor size="small">{i18n.author}</Text>
|
|
1166
|
+
<Text dimColor size="small">{i18n.github}</Text>
|
|
1158
1167
|
</Box>
|
|
1159
1168
|
</Box>
|
|
1160
1169
|
);
|
|
@@ -266,16 +266,36 @@ export const TopBar: React.FC<{
|
|
|
266
266
|
compactLogo: React.ReactNode;
|
|
267
267
|
toolbar?: React.ReactNode;
|
|
268
268
|
ip?: string;
|
|
269
|
-
}> = memo(({ ready, page, width = 80, height = 5, homeLogo, compactLogo, toolbar, ip }) =>
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
269
|
+
}> = memo(({ ready, page, width = 80, height = 5, homeLogo, compactLogo, toolbar, ip }) => {
|
|
270
|
+
const isHome = page === null;
|
|
271
|
+
return (
|
|
272
|
+
<Panel width={width} height={height} borderStyle="round" paddingX={1} paddingY={0}>
|
|
273
|
+
<Box width={width - 4} flexDirection="row" alignItems="flex-start">
|
|
274
|
+
{/* Left Section: Welcome Text & IP */}
|
|
275
|
+
<Box flexDirection="column" width={20} marginRight={2}>
|
|
276
|
+
{isHome ? (
|
|
277
|
+
<Box flexDirection="column">
|
|
278
|
+
<Text bold color="cyan">Welcome Bingo</Text>
|
|
279
|
+
<Text bold color="cyan">Code</Text>
|
|
280
|
+
</Box>
|
|
281
|
+
) : (
|
|
282
|
+
<Text bold color="cyan">Bingo Code</Text>
|
|
283
|
+
)}
|
|
284
|
+
{ip ? (
|
|
285
|
+
<Box marginTop={1}>
|
|
286
|
+
<Text color="gray" dimColor>IP: {ip.replace(':','\n ')}</Text>
|
|
287
|
+
</Box>
|
|
288
|
+
) : null}
|
|
289
|
+
</Box>
|
|
290
|
+
|
|
291
|
+
{/* Center/Right Section: Logo + Toolbar (Dynamic) */}
|
|
292
|
+
<Box flexGrow={1} flexDirection="column">
|
|
293
|
+
{toolbar}
|
|
294
|
+
</Box>
|
|
274
295
|
</Box>
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
));
|
|
296
|
+
</Panel>
|
|
297
|
+
);
|
|
298
|
+
});
|
|
279
299
|
|
|
280
300
|
// InfoPair (Label fixed width for column alignment)
|
|
281
301
|
export const InfoPair: React.FC<{ label: string; value: string; labelColor?: string; valueColor?: string; labelWidth?: number }> = memo(({
|
package/src/screens/REPL.tsx
CHANGED
|
@@ -197,6 +197,7 @@ const PROACTIVE_FALSE = () => false;
|
|
|
197
197
|
const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false;
|
|
198
198
|
const useProactive = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null;
|
|
199
199
|
const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : null;
|
|
200
|
+
const useGoalEvaluator = require('../hooks/useGoalEvaluator.js').useGoalEvaluator
|
|
200
201
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
201
202
|
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js';
|
|
202
203
|
import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js';
|
|
@@ -3888,6 +3889,11 @@ export function REPL({
|
|
|
3888
3889
|
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
|
|
3889
3890
|
queryGuard
|
|
3890
3891
|
});
|
|
3892
|
+
useGoalEvaluator({
|
|
3893
|
+
lastQueryCompletionTime,
|
|
3894
|
+
messagesRef,
|
|
3895
|
+
isQueryActive,
|
|
3896
|
+
});
|
|
3891
3897
|
|
|
3892
3898
|
// We'll use the global lastInteractionTime from state.ts
|
|
3893
3899
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getGoalCondition,
|
|
3
|
+
getGoalMaxIterations,
|
|
4
|
+
setGoalCondition,
|
|
5
|
+
} from '../../bootstrap/state.js'
|
|
6
|
+
import { registerBundledSkill } from '../bundledSkills.js'
|
|
7
|
+
|
|
8
|
+
const USAGE = `Usage: /goal <condition>
|
|
9
|
+
|
|
10
|
+
Set a session goal. The agent will keep working until the condition is met.
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
/goal all tests pass
|
|
14
|
+
/goal login flow handles empty email without crash
|
|
15
|
+
/goal PR is ready for review with passing CI
|
|
16
|
+
|
|
17
|
+
To cancel: /goal clear`
|
|
18
|
+
|
|
19
|
+
export function registerGoalSkill(): void {
|
|
20
|
+
registerBundledSkill({
|
|
21
|
+
name: 'goal',
|
|
22
|
+
description:
|
|
23
|
+
'Set a session-level goal condition and loop until met. Use when user says "/goal <condition>" or wants autonomous execution until a specific outcome is reached.',
|
|
24
|
+
argumentHint: '<condition | clear>',
|
|
25
|
+
userInvocable: true,
|
|
26
|
+
async getPromptForCommand(args) {
|
|
27
|
+
const trimmed = args.trim()
|
|
28
|
+
|
|
29
|
+
if (!trimmed) {
|
|
30
|
+
return [{ type: 'text', text: USAGE }]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (['clear', 'stop', 'cancel'].includes(trimmed)) {
|
|
34
|
+
const current = getGoalCondition()
|
|
35
|
+
if (current) {
|
|
36
|
+
setGoalCondition(null)
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
type: 'text',
|
|
40
|
+
text: `Goal cancelled: "${current}". Tell the user their goal has been cancelled.`,
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
return [
|
|
45
|
+
{
|
|
46
|
+
type: 'text',
|
|
47
|
+
text: 'No active goal to cancel. Tell the user there is no active goal.',
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setGoalCondition(trimmed)
|
|
53
|
+
const maxIter = getGoalMaxIterations()
|
|
54
|
+
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
type: 'text',
|
|
58
|
+
text: `# /goal activated
|
|
59
|
+
|
|
60
|
+
Goal condition: "${trimmed}"
|
|
61
|
+
|
|
62
|
+
This goal is now registered for this session. An independent evaluator model will check after each turn whether the goal is satisfied. Maximum ${maxIter} iterations.
|
|
63
|
+
|
|
64
|
+
Tell the user: Goal set — you will work autonomously until "${trimmed}" is achieved (max ${maxIter} turns). Send \`/goal clear\` to cancel.
|
|
65
|
+
|
|
66
|
+
Now begin: assess current state and take the first concrete action toward the goal.`,
|
|
67
|
+
},
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
}
|
|
@@ -22,6 +22,7 @@ export function initBundledSkills(): void {
|
|
|
22
22
|
require('./simplify.js').registerSimplifySkill()
|
|
23
23
|
require('./batch.js').registerBatchSkill()
|
|
24
24
|
require('./stuck.js').registerStuckSkill()
|
|
25
|
+
require('./goal.js').registerGoalSkill()
|
|
25
26
|
if (feature('KAIROS') || feature('KAIROS_DREAM')) {
|
|
26
27
|
const { registerDreamSkill } = require('./dream.js')
|
|
27
28
|
registerDreamSkill()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk'
|
|
2
|
+
import type { MessageType } from '../components/messages.js'
|
|
3
|
+
|
|
4
|
+
export const GOAL_EVALUATOR_MODEL = 'claude-haiku-4-5'
|
|
5
|
+
|
|
6
|
+
export type GoalEvalResult = {
|
|
7
|
+
satisfied: boolean
|
|
8
|
+
reason: string
|
|
9
|
+
gap: string | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Evaluate whether the goal condition has been met based on recent messages.
|
|
14
|
+
*
|
|
15
|
+
* Runs as an independent Anthropic client call — completely decoupled from the
|
|
16
|
+
* main query chain. Never pollutes conversation state or tool history.
|
|
17
|
+
*/
|
|
18
|
+
export async function evaluateGoal(
|
|
19
|
+
goalCondition: string,
|
|
20
|
+
messages: MessageType[],
|
|
21
|
+
): Promise<GoalEvalResult> {
|
|
22
|
+
const client = new Anthropic()
|
|
23
|
+
|
|
24
|
+
const recentAssistantTexts = messages
|
|
25
|
+
.filter(m => m.type === 'assistant' || m.role === 'assistant')
|
|
26
|
+
.slice(-5)
|
|
27
|
+
.map(m => {
|
|
28
|
+
if (typeof m.message?.content === 'string') return m.message.content
|
|
29
|
+
if (Array.isArray(m.message?.content)) {
|
|
30
|
+
return m.message.content
|
|
31
|
+
.filter((b: { type: string }) => b.type === 'text')
|
|
32
|
+
.map((b: { text: string }) => b.text)
|
|
33
|
+
.join('\n')
|
|
34
|
+
}
|
|
35
|
+
return ''
|
|
36
|
+
})
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.join('\n---\n')
|
|
39
|
+
|
|
40
|
+
const prompt = `You are a goal completion evaluator. Determine if the goal has been fully achieved.
|
|
41
|
+
|
|
42
|
+
Goal: "${goalCondition}"
|
|
43
|
+
|
|
44
|
+
Recent assistant output:
|
|
45
|
+
${recentAssistantTexts || '(none yet)'}
|
|
46
|
+
|
|
47
|
+
Respond in JSON only:
|
|
48
|
+
{"satisfied": true|false, "reason": "<one sentence>", "gap": "<missing item or null>"}`
|
|
49
|
+
|
|
50
|
+
const response = await client.messages.create({
|
|
51
|
+
model: GOAL_EVALUATOR_MODEL,
|
|
52
|
+
max_tokens: 256,
|
|
53
|
+
messages: [{ role: 'user', content: prompt }],
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const text =
|
|
57
|
+
response.content[0]?.type === 'text' ? response.content[0].text : ''
|
|
58
|
+
try {
|
|
59
|
+
const cleaned = text.replace(/^```(?:json)?\n?|\n?```$/g, '').trim()
|
|
60
|
+
return JSON.parse(cleaned) as GoalEvalResult
|
|
61
|
+
} catch {
|
|
62
|
+
return { satisfied: false, reason: 'Evaluator parse error', gap: text }
|
|
63
|
+
}
|
|
64
|
+
}
|