@phuetz/code-buddy 1.0.0 → 1.1.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/README.md +3 -2
- package/dist/agent/codebuddy-agent.js +33 -0
- package/dist/agent/lesson-auto-proposer.js +10 -0
- package/dist/agent/middleware/session-duration.d.ts +36 -0
- package/dist/agent/middleware/session-duration.js +78 -0
- package/dist/agent/session-end-flush.d.ts +66 -0
- package/dist/agent/session-end-flush.js +217 -0
- package/dist/commands/cli/native-engine-commands.js +6 -1
- package/dist/commands/enhanced-command-handler.js +5 -0
- package/dist/commands/goal-cli.d.ts +41 -0
- package/dist/commands/goal-cli.js +97 -0
- package/dist/commands/handlers/goal-handler.d.ts +27 -0
- package/dist/commands/handlers/goal-handler.js +128 -0
- package/dist/commands/handlers/index.d.ts +1 -0
- package/dist/commands/handlers/index.js +2 -0
- package/dist/commands/slash/builtin-commands.js +20 -0
- package/dist/config/env-schema.js +14 -0
- package/dist/config/feature-flags.js +7 -0
- package/dist/context/context-manager-v2.d.ts +39 -0
- package/dist/context/context-manager-v2.js +90 -0
- package/dist/daemon/agent-task-executor.js +12 -1
- package/dist/daemon/autonomous-daemon.js +2 -0
- package/dist/daemon/autonomous-loop.d.ts +21 -1
- package/dist/daemon/autonomous-loop.js +60 -0
- package/dist/daemon/colab-goal.d.ts +38 -0
- package/dist/daemon/colab-goal.js +73 -0
- package/dist/fleet/colab-store.d.ts +21 -0
- package/dist/fleet/colab-store.js +16 -0
- package/dist/fleet/peer-session-bridge.d.ts +1 -1
- package/dist/fleet/peer-session-bridge.js +205 -2
- package/dist/fleet/peer-session-store.d.ts +3 -0
- package/dist/fleet/privacy-lint.d.ts +8 -0
- package/dist/fleet/privacy-lint.js +22 -0
- package/dist/goals/goal-judge.d.ts +36 -0
- package/dist/goals/goal-judge.js +129 -0
- package/dist/goals/goal-loop.d.ts +23 -0
- package/dist/goals/goal-loop.js +56 -0
- package/dist/goals/goal-manager.d.ts +71 -0
- package/dist/goals/goal-manager.js +236 -0
- package/dist/goals/goal-state.d.ts +86 -0
- package/dist/goals/goal-state.js +245 -0
- package/dist/goals/goal-store.d.ts +25 -0
- package/dist/goals/goal-store.js +71 -0
- package/dist/goals/index.d.ts +5 -0
- package/dist/goals/index.js +6 -0
- package/dist/hooks/use-input-handler.js +36 -1
- package/dist/index.js +44 -2
- package/dist/observability/run-store.d.ts +1 -1
- package/dist/server/websocket/fleet-bridge.d.ts +13 -1
- package/dist/server/websocket/fleet-bridge.js +11 -0
- package/package.json +1 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buddy goal — headless Ralph loop.
|
|
3
|
+
*
|
|
4
|
+
* Runs the full agentic loop toward a standing goal: each turn the agent
|
|
5
|
+
* works with tools, then the goal judge decides done/continue. Continuation
|
|
6
|
+
* prompts are fed back in-process until the goal is achieved, the turn
|
|
7
|
+
* budget is exhausted, or the judge auto-pauses.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* buddy goal "Fix every failing test in tests/auth/"
|
|
11
|
+
* buddy goal "Ship the feature" --max-turns 10 --judge-model qwen3:8b
|
|
12
|
+
*
|
|
13
|
+
* Exit codes: 0 = goal done, 1 = paused (budget/judge) or error.
|
|
14
|
+
*/
|
|
15
|
+
import { Command } from 'commander';
|
|
16
|
+
import { maybeContinueGoalAfterTurn } from '../goals/goal-loop.js';
|
|
17
|
+
import { getGoalManager } from '../goals/goal-manager.js';
|
|
18
|
+
import { resolveCommandProvider } from './llm-provider-resolution.js';
|
|
19
|
+
/**
|
|
20
|
+
* Drive the goal loop headlessly on an in-process agent. Sets the goal,
|
|
21
|
+
* runs the first turn with the goal text (mirroring the interactive
|
|
22
|
+
* `/goal <text>` kick-off), then follows judge verdicts until the loop
|
|
23
|
+
* stops continuing.
|
|
24
|
+
*/
|
|
25
|
+
export async function runGoalLoop(agent, goalText, options = {}) {
|
|
26
|
+
const manager = getGoalManager();
|
|
27
|
+
const state = manager.set(goalText, options.maxTurns ? { maxTurns: options.maxTurns } : {});
|
|
28
|
+
const emit = options.onMessage ?? (() => { });
|
|
29
|
+
emit(`⊙ Goal set (${state.maxTurns}-turn budget): ${state.goal}`);
|
|
30
|
+
let prompt = state.goal;
|
|
31
|
+
// Hard backstop on top of the manager's own budget/auto-pause guards.
|
|
32
|
+
const maxIterations = state.maxTurns + 1;
|
|
33
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
34
|
+
const entries = await agent.processUserMessage(prompt);
|
|
35
|
+
const lastResponse = entries
|
|
36
|
+
.filter(entry => entry.type === 'assistant' && entry.content)
|
|
37
|
+
.map(entry => entry.content)
|
|
38
|
+
.join('\n');
|
|
39
|
+
const outcome = await maybeContinueGoalAfterTurn({
|
|
40
|
+
client: agent.getClient(),
|
|
41
|
+
lastResponse,
|
|
42
|
+
interrupted: false,
|
|
43
|
+
});
|
|
44
|
+
if (outcome?.message)
|
|
45
|
+
emit(outcome.message);
|
|
46
|
+
if (!outcome?.continuationPrompt)
|
|
47
|
+
break;
|
|
48
|
+
prompt = outcome.continuationPrompt;
|
|
49
|
+
}
|
|
50
|
+
const final = manager.state;
|
|
51
|
+
return {
|
|
52
|
+
status: final?.status ?? 'unknown',
|
|
53
|
+
turnsUsed: final?.turnsUsed ?? 0,
|
|
54
|
+
...(final?.lastReason ? { lastReason: final.lastReason } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function createGoalCommand() {
|
|
58
|
+
const cmd = new Command('goal')
|
|
59
|
+
.description('Run the agent toward a standing goal until a judge model confirms it is done (Ralph loop)')
|
|
60
|
+
.argument('<goal>', 'The goal to pursue')
|
|
61
|
+
.option('--max-turns <n>', 'Turn budget (default 20, or goals.maxTurns from settings)')
|
|
62
|
+
.option('--judge-model <model>', 'Model for the goal judge (default: session model)')
|
|
63
|
+
.option('-m, --model <model>', 'Override the agent model for this run')
|
|
64
|
+
.option('--max-tool-rounds <n>', 'Max tool rounds per turn', '50')
|
|
65
|
+
.action(async (goal, options, command) => {
|
|
66
|
+
const modelOverride = options.model ?? command?.optsWithGlobals?.()?.model;
|
|
67
|
+
const resolved = resolveCommandProvider({ explicitModel: modelOverride });
|
|
68
|
+
if (!resolved) {
|
|
69
|
+
console.error('Error: No provider available — set an API key, run `buddy onboard`, or point CODEBUDDY_PROVIDER=ollama at a local Ollama.');
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
if (options.judgeModel) {
|
|
73
|
+
process.env.CODEBUDDY_GOAL_JUDGE_MODEL = options.judgeModel;
|
|
74
|
+
}
|
|
75
|
+
process.env.CODEBUDDY_DISABLE_MCP = process.env.CODEBUDDY_DISABLE_MCP ?? 'true';
|
|
76
|
+
process.env.CODEBUDDY_HEADLESS = 'true';
|
|
77
|
+
try {
|
|
78
|
+
const { CodeBuddyAgent } = await import('../agent/codebuddy-agent.js');
|
|
79
|
+
const { ConfirmationService } = await import('../utils/confirmation-service.js');
|
|
80
|
+
ConfirmationService.getInstance().setSessionFlag('allOperations', true);
|
|
81
|
+
const agent = new CodeBuddyAgent(resolved.apiKey, resolved.baseURL, resolved.model, parseInt(options.maxToolRounds, 10));
|
|
82
|
+
await agent.systemPromptReady;
|
|
83
|
+
const result = await runGoalLoop(agent, goal, {
|
|
84
|
+
...(options.maxTurns ? { maxTurns: parseInt(options.maxTurns, 10) } : {}),
|
|
85
|
+
onMessage: text => console.log(`\n${text}`),
|
|
86
|
+
});
|
|
87
|
+
agent.dispose?.();
|
|
88
|
+
process.exit(result.status === 'done' ? 0 : 1);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
console.error('Goal error:', err instanceof Error ? err.message : err);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
return cmd;
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=goal-cli.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CommandHandlerResult } from './branch-handlers.js';
|
|
2
|
+
/**
|
|
3
|
+
* /goal — standing goal with judge + auto-continue loop (the Ralph loop,
|
|
4
|
+
* ported from Hermes Agent).
|
|
5
|
+
*
|
|
6
|
+
* Forms:
|
|
7
|
+
* /goal <text> set a new goal (replaces any old one) and start turn 1
|
|
8
|
+
* /goal | status show current state
|
|
9
|
+
* /goal pause halt auto-continuation, keep the goal
|
|
10
|
+
* /goal resume restart the loop (resets the turn budget)
|
|
11
|
+
* /goal clear discard the goal (aliases: stop, done)
|
|
12
|
+
*/
|
|
13
|
+
export declare function handleGoal(args: string[]): Promise<CommandHandlerResult>;
|
|
14
|
+
/**
|
|
15
|
+
* /subgoal — extra acceptance criteria added mid-loop.
|
|
16
|
+
*
|
|
17
|
+
* Forms:
|
|
18
|
+
* /subgoal show current subgoals
|
|
19
|
+
* /subgoal <text> append a criterion
|
|
20
|
+
* /subgoal remove <n> drop subgoal n (1-based)
|
|
21
|
+
* /subgoal clear wipe all subgoals
|
|
22
|
+
*
|
|
23
|
+
* Subgoals get appended to both the judge prompt (verdict must consider
|
|
24
|
+
* them) and the continuation prompt (agent sees them) on the next turn
|
|
25
|
+
* boundary — no special kick needed.
|
|
26
|
+
*/
|
|
27
|
+
export declare function handleSubgoal(args: string[]): Promise<CommandHandlerResult>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { getGoalManager } from '../../goals/goal-manager.js';
|
|
2
|
+
/**
|
|
3
|
+
* /goal — standing goal with judge + auto-continue loop (the Ralph loop,
|
|
4
|
+
* ported from Hermes Agent).
|
|
5
|
+
*
|
|
6
|
+
* Forms:
|
|
7
|
+
* /goal <text> set a new goal (replaces any old one) and start turn 1
|
|
8
|
+
* /goal | status show current state
|
|
9
|
+
* /goal pause halt auto-continuation, keep the goal
|
|
10
|
+
* /goal resume restart the loop (resets the turn budget)
|
|
11
|
+
* /goal clear discard the goal (aliases: stop, done)
|
|
12
|
+
*/
|
|
13
|
+
export async function handleGoal(args) {
|
|
14
|
+
const arg = args.join(' ').trim();
|
|
15
|
+
const lower = arg.toLowerCase();
|
|
16
|
+
const mgr = getGoalManager();
|
|
17
|
+
if (!arg || lower === 'status') {
|
|
18
|
+
return textResult(mgr.statusLine());
|
|
19
|
+
}
|
|
20
|
+
if (lower === 'pause') {
|
|
21
|
+
const state = mgr.pause('user-paused');
|
|
22
|
+
return textResult(state ? `⏸ Goal paused: ${state.goal}` : 'No goal set.');
|
|
23
|
+
}
|
|
24
|
+
if (lower === 'resume') {
|
|
25
|
+
const state = mgr.resume();
|
|
26
|
+
if (!state) {
|
|
27
|
+
return textResult('No goal to resume.');
|
|
28
|
+
}
|
|
29
|
+
return textResult(`▶ Goal resumed: ${state.goal}\n` +
|
|
30
|
+
'Send any message to kick the loop off (e.g. "continue").');
|
|
31
|
+
}
|
|
32
|
+
if (['clear', 'stop', 'done'].includes(lower)) {
|
|
33
|
+
const had = mgr.hasGoal();
|
|
34
|
+
mgr.clear();
|
|
35
|
+
return textResult(had ? '✓ Goal cleared.' : 'No active goal.');
|
|
36
|
+
}
|
|
37
|
+
// Otherwise treat the arg as the goal text.
|
|
38
|
+
let state;
|
|
39
|
+
try {
|
|
40
|
+
state = mgr.set(arg);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return textResult(`Invalid goal: ${error instanceof Error ? error.message : String(error)}`);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
handled: true,
|
|
47
|
+
entry: {
|
|
48
|
+
type: 'assistant',
|
|
49
|
+
content: `⊙ Goal set (${state.maxTurns}-turn budget): ${state.goal}\n` +
|
|
50
|
+
'After each turn, a judge model checks if the goal is done. Code Buddy ' +
|
|
51
|
+
'keeps working until it is, you pause/clear it, or the budget is ' +
|
|
52
|
+
'exhausted. Use /goal status, /goal pause, /goal resume, /goal clear. ' +
|
|
53
|
+
'Tip: for unattended runs, enable auto-approval (/yolo or auto-edit) so ' +
|
|
54
|
+
'the loop is not blocked on confirmations.',
|
|
55
|
+
timestamp: new Date(),
|
|
56
|
+
},
|
|
57
|
+
// Kick the loop off immediately — the dispatcher feeds the goal text as
|
|
58
|
+
// the first turn, then the after-turn hook drives the continuation loop.
|
|
59
|
+
passToAI: true,
|
|
60
|
+
prompt: state.goal,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* /subgoal — extra acceptance criteria added mid-loop.
|
|
65
|
+
*
|
|
66
|
+
* Forms:
|
|
67
|
+
* /subgoal show current subgoals
|
|
68
|
+
* /subgoal <text> append a criterion
|
|
69
|
+
* /subgoal remove <n> drop subgoal n (1-based)
|
|
70
|
+
* /subgoal clear wipe all subgoals
|
|
71
|
+
*
|
|
72
|
+
* Subgoals get appended to both the judge prompt (verdict must consider
|
|
73
|
+
* them) and the continuation prompt (agent sees them) on the next turn
|
|
74
|
+
* boundary — no special kick needed.
|
|
75
|
+
*/
|
|
76
|
+
export async function handleSubgoal(args) {
|
|
77
|
+
const arg = args.join(' ').trim();
|
|
78
|
+
const mgr = getGoalManager();
|
|
79
|
+
if (!mgr.hasGoal()) {
|
|
80
|
+
return textResult('No active goal. Set one with /goal <text>.');
|
|
81
|
+
}
|
|
82
|
+
if (!arg) {
|
|
83
|
+
return textResult(`${mgr.statusLine()}\n${mgr.renderSubgoals()}`);
|
|
84
|
+
}
|
|
85
|
+
const [verb, ...restTokens] = arg.split(/\s+/);
|
|
86
|
+
const rest = restTokens.join(' ').trim();
|
|
87
|
+
if (verb?.toLowerCase() === 'remove') {
|
|
88
|
+
if (!rest) {
|
|
89
|
+
return textResult('Usage: /subgoal remove <n>');
|
|
90
|
+
}
|
|
91
|
+
const idx = Number(rest.split(/\s+/)[0]);
|
|
92
|
+
if (!Number.isInteger(idx)) {
|
|
93
|
+
return textResult('/subgoal remove: <n> must be an integer (1-based index).');
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const removed = mgr.removeSubgoal(idx);
|
|
97
|
+
return textResult(`✓ Removed subgoal ${idx}: ${removed}`);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
return textResult(`/subgoal remove: ${error instanceof Error ? error.message : String(error)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (verb?.toLowerCase() === 'clear') {
|
|
104
|
+
try {
|
|
105
|
+
const prev = mgr.clearSubgoals();
|
|
106
|
+
return textResult(prev ? `✓ Cleared ${prev} subgoal${prev !== 1 ? 's' : ''}.` : 'No subgoals to clear.');
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return textResult(`/subgoal clear: ${error instanceof Error ? error.message : String(error)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Otherwise — append the whole arg as a new subgoal.
|
|
113
|
+
try {
|
|
114
|
+
const text = mgr.addSubgoal(arg);
|
|
115
|
+
const idx = mgr.state?.subgoals.length ?? 0;
|
|
116
|
+
return textResult(`✓ Added subgoal ${idx}: ${text}`);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return textResult(`/subgoal: ${error instanceof Error ? error.message : String(error)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function textResult(content) {
|
|
123
|
+
return {
|
|
124
|
+
handled: true,
|
|
125
|
+
entry: { type: 'assistant', content, timestamp: new Date() },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=goal-handler.js.map
|
|
@@ -36,6 +36,7 @@ export { handleFastMode, isFastModeEnabled, getFastModeModel, getFastModeService
|
|
|
36
36
|
export { handleBackup, } from './backup-handlers.js';
|
|
37
37
|
export { handleBtw, setBtwClient, } from './btw-handler.js';
|
|
38
38
|
export { handleHeartbeat } from './heartbeat-handler.js';
|
|
39
|
+
export { handleGoal, handleSubgoal } from './goal-handler.js';
|
|
39
40
|
export { handleDailyReset } from './daily-reset-handler.js';
|
|
40
41
|
export { handleShare } from './team-session-handler.js';
|
|
41
42
|
export { handleAgents } from './agents-handler.js';
|
|
@@ -74,6 +74,8 @@ export { handleBackup, } from './backup-handlers.js';
|
|
|
74
74
|
export { handleBtw, setBtwClient, } from './btw-handler.js';
|
|
75
75
|
// Heartbeat handler (fleet AUTONOMOUS-FLEET-PROTOCOL v0.1)
|
|
76
76
|
export { handleHeartbeat } from './heartbeat-handler.js';
|
|
77
|
+
// Goal handler (Hermes Agent parity — Ralph loop)
|
|
78
|
+
export { handleGoal, handleSubgoal } from './goal-handler.js';
|
|
77
79
|
// Daily reset handler (audit OpenClaw heritage activation)
|
|
78
80
|
export { handleDailyReset } from './daily-reset-handler.js';
|
|
79
81
|
// Team session handler — slash /share (audit OpenClaw heritage activation, TeamSessionManager wake)
|
|
@@ -808,6 +808,26 @@ const personaCommands = [
|
|
|
808
808
|
// Autonomy & Permissions Commands
|
|
809
809
|
// ============================================================================
|
|
810
810
|
const autonomyCommands = [
|
|
811
|
+
{
|
|
812
|
+
name: 'goal',
|
|
813
|
+
description: 'Standing goal with judge + auto-continue loop (Ralph loop): /goal <text> | status | pause | resume | clear',
|
|
814
|
+
prompt: '__GOAL__',
|
|
815
|
+
filePath: '',
|
|
816
|
+
isBuiltin: true,
|
|
817
|
+
arguments: [
|
|
818
|
+
{ name: 'action', description: '<text> to set a goal, or status, pause, resume, clear', required: false }
|
|
819
|
+
]
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
name: 'subgoal',
|
|
823
|
+
description: 'Add acceptance criteria to the active goal: /subgoal <text> | remove <n> | clear',
|
|
824
|
+
prompt: '__SUBGOAL__',
|
|
825
|
+
filePath: '',
|
|
826
|
+
isBuiltin: true,
|
|
827
|
+
arguments: [
|
|
828
|
+
{ name: 'action', description: '<text> to add a criterion, remove <n>, clear, or empty to list', required: false }
|
|
829
|
+
]
|
|
830
|
+
},
|
|
811
831
|
{
|
|
812
832
|
name: 'yolo',
|
|
813
833
|
description: 'Toggle YOLO mode (full auto-execution with guardrails)',
|
|
@@ -69,6 +69,20 @@ export const ENV_SCHEMA = [
|
|
|
69
69
|
category: 'core',
|
|
70
70
|
min: 1000,
|
|
71
71
|
},
|
|
72
|
+
{
|
|
73
|
+
name: 'CODEBUDDY_GOAL_MAX_TURNS',
|
|
74
|
+
type: 'number',
|
|
75
|
+
description: 'Turn budget for /goal auto-continue loops (default 20)',
|
|
76
|
+
category: 'core',
|
|
77
|
+
min: 1,
|
|
78
|
+
max: 1000,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'CODEBUDDY_GOAL_JUDGE_MODEL',
|
|
82
|
+
type: 'string',
|
|
83
|
+
description: 'Model used by the /goal judge (default: current session model)',
|
|
84
|
+
category: 'core',
|
|
85
|
+
},
|
|
72
86
|
{
|
|
73
87
|
name: 'GROK_FORCE_TOOLS',
|
|
74
88
|
type: 'boolean',
|
|
@@ -56,6 +56,13 @@ const DEFAULT_FEATURE_FLAGS = {
|
|
|
56
56
|
category: 'ai',
|
|
57
57
|
envOverride: 'USER_MODEL_DIALECTIC_ON_SESSION_END',
|
|
58
58
|
},
|
|
59
|
+
SESSION_END_FLUSH: {
|
|
60
|
+
name: 'SESSION_END_FLUSH',
|
|
61
|
+
enabled: true,
|
|
62
|
+
description: 'At session end, write a short handoff (.codebuddy/HANDOFF.md) and propose review-gated lesson candidates from the transcript (WS3-T1)',
|
|
63
|
+
category: 'ai',
|
|
64
|
+
envOverride: 'CODEBUDDY_SESSION_END_FLUSH',
|
|
65
|
+
},
|
|
59
66
|
VOICE_CONTROL: {
|
|
60
67
|
name: 'VOICE_CONTROL',
|
|
61
68
|
enabled: false,
|
|
@@ -60,6 +60,23 @@ export interface ContextStats {
|
|
|
60
60
|
isNearLimit: boolean;
|
|
61
61
|
isCritical: boolean;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Periodic memory snapshot (WS3-T2) — a compact, persisted view of the
|
|
65
|
+
* session so very long runs (12–15 h) survive crashes and aggressive
|
|
66
|
+
* compaction without losing the thread.
|
|
67
|
+
*/
|
|
68
|
+
export interface ContextSnapshot {
|
|
69
|
+
sessionId: string;
|
|
70
|
+
takenAt: string;
|
|
71
|
+
stats: {
|
|
72
|
+
messageCount: number;
|
|
73
|
+
tokenCount: number;
|
|
74
|
+
compressionCount: number;
|
|
75
|
+
totalTokensSaved: number;
|
|
76
|
+
};
|
|
77
|
+
/** Extractive, privacy-linted summary of the conversation so far. */
|
|
78
|
+
summary: string;
|
|
79
|
+
}
|
|
63
80
|
/**
|
|
64
81
|
* Memory metrics for monitoring context manager health
|
|
65
82
|
*/
|
|
@@ -125,6 +142,10 @@ export declare class ContextManagerV2 {
|
|
|
125
142
|
private _cachedStatsFingerprint;
|
|
126
143
|
/** Last compression timestamp */
|
|
127
144
|
private lastCompressionTime;
|
|
145
|
+
/** WS3-T2 — periodic snapshot timer (unref'd, never keeps the process alive) */
|
|
146
|
+
private snapshotTimer;
|
|
147
|
+
/** WS3-T2 — snapshots taken this session */
|
|
148
|
+
private snapshotCount;
|
|
128
149
|
static readonly DEFAULT_CONFIG: ContextManagerConfig;
|
|
129
150
|
constructor(config?: Partial<ContextManagerConfig>);
|
|
130
151
|
/**
|
|
@@ -344,6 +365,24 @@ export declare class ContextManagerV2 {
|
|
|
344
365
|
* Format compression statistics as human-readable string
|
|
345
366
|
*/
|
|
346
367
|
formatCompressionStats(): string;
|
|
368
|
+
/**
|
|
369
|
+
* Take a compact snapshot of the session and persist it to
|
|
370
|
+
* `.codebuddy/context-snapshot.json` (latest wins). Returns null when the
|
|
371
|
+
* conversation is too small to be worth snapshotting.
|
|
372
|
+
*
|
|
373
|
+
* The summary is privacy-linted before it touches disk (WS3 guard-rail).
|
|
374
|
+
*/
|
|
375
|
+
takeSnapshot(messages: CodeBuddyMessage[], workDir?: string): ContextSnapshot | null;
|
|
376
|
+
/**
|
|
377
|
+
* Start the periodic snapshot loop for long sessions (12–15 h).
|
|
378
|
+
*
|
|
379
|
+
* Interval resolution: explicit param → `CODEBUDDY_SNAPSHOT_INTERVAL_MIN`
|
|
380
|
+
* env (minutes) → 45 min default. `0` (or negative) disables. The timer
|
|
381
|
+
* is unref'd so it never keeps a finished process alive.
|
|
382
|
+
*/
|
|
383
|
+
startPeriodicSnapshot(getMessages: () => CodeBuddyMessage[], intervalMs?: number, workDir?: string): void;
|
|
384
|
+
/** Stop the periodic snapshot loop (idempotent). */
|
|
385
|
+
stopPeriodicSnapshot(): void;
|
|
347
386
|
}
|
|
348
387
|
export type { KeyInformation, ContextArchive, CompressionMetrics, EnhancedCompressionResult, };
|
|
349
388
|
/**
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* - Content-type-aware Compression
|
|
13
13
|
* - Key Information Preservation
|
|
14
14
|
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import { redactSecrets } from '../fleet/privacy-lint.js';
|
|
15
18
|
import { createTokenCounter } from './token-counter.js';
|
|
16
19
|
import { logger } from '../utils/logger.js';
|
|
17
20
|
import { getModelToolConfig } from '../config/model-tools.js';
|
|
@@ -74,6 +77,10 @@ export class ContextManagerV2 {
|
|
|
74
77
|
_cachedStatsFingerprint = '';
|
|
75
78
|
/** Last compression timestamp */
|
|
76
79
|
lastCompressionTime = null;
|
|
80
|
+
/** WS3-T2 — periodic snapshot timer (unref'd, never keeps the process alive) */
|
|
81
|
+
snapshotTimer = null;
|
|
82
|
+
/** WS3-T2 — snapshots taken this session */
|
|
83
|
+
snapshotCount = 0;
|
|
77
84
|
// Default configuration based on research recommendations
|
|
78
85
|
static DEFAULT_CONFIG = {
|
|
79
86
|
maxContextTokens: 4096,
|
|
@@ -921,6 +928,89 @@ export class ContextManagerV2 {
|
|
|
921
928
|
}
|
|
922
929
|
return lines.join('\n');
|
|
923
930
|
}
|
|
931
|
+
// ==========================================================================
|
|
932
|
+
// WS3-T2 — Periodic memory snapshot
|
|
933
|
+
// ==========================================================================
|
|
934
|
+
/**
|
|
935
|
+
* Take a compact snapshot of the session and persist it to
|
|
936
|
+
* `.codebuddy/context-snapshot.json` (latest wins). Returns null when the
|
|
937
|
+
* conversation is too small to be worth snapshotting.
|
|
938
|
+
*
|
|
939
|
+
* The summary is privacy-linted before it touches disk (WS3 guard-rail).
|
|
940
|
+
*/
|
|
941
|
+
takeSnapshot(messages, workDir = process.cwd()) {
|
|
942
|
+
if (!messages || messages.length < 4)
|
|
943
|
+
return null;
|
|
944
|
+
const snapshot = {
|
|
945
|
+
sessionId: this.sessionId,
|
|
946
|
+
takenAt: new Date().toISOString(),
|
|
947
|
+
stats: {
|
|
948
|
+
messageCount: messages.length,
|
|
949
|
+
tokenCount: this.countTokens(messages),
|
|
950
|
+
compressionCount: this.compressionCount,
|
|
951
|
+
totalTokensSaved: this.totalTokensSaved,
|
|
952
|
+
},
|
|
953
|
+
summary: redactSecrets(this.createSummary(messages)),
|
|
954
|
+
};
|
|
955
|
+
try {
|
|
956
|
+
const dir = path.join(workDir, '.codebuddy');
|
|
957
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
958
|
+
fs.writeFileSync(path.join(dir, 'context-snapshot.json'), JSON.stringify(snapshot, null, 2), 'utf8');
|
|
959
|
+
}
|
|
960
|
+
catch (err) {
|
|
961
|
+
logger.debug('Context snapshot write failed', { error: String(err) });
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
this.snapshotCount++;
|
|
965
|
+
try {
|
|
966
|
+
const runStore = RunStore.getInstance();
|
|
967
|
+
if (runStore.getCurrentRunId()) {
|
|
968
|
+
runStore.appendEvent('context_snapshot', {
|
|
969
|
+
sessionId: snapshot.sessionId,
|
|
970
|
+
messageCount: snapshot.stats.messageCount,
|
|
971
|
+
tokenCount: snapshot.stats.tokenCount,
|
|
972
|
+
snapshotCount: this.snapshotCount,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
catch {
|
|
977
|
+
// Observability must never break the snapshot path.
|
|
978
|
+
}
|
|
979
|
+
return snapshot;
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Start the periodic snapshot loop for long sessions (12–15 h).
|
|
983
|
+
*
|
|
984
|
+
* Interval resolution: explicit param → `CODEBUDDY_SNAPSHOT_INTERVAL_MIN`
|
|
985
|
+
* env (minutes) → 45 min default. `0` (or negative) disables. The timer
|
|
986
|
+
* is unref'd so it never keeps a finished process alive.
|
|
987
|
+
*/
|
|
988
|
+
startPeriodicSnapshot(getMessages, intervalMs, workDir = process.cwd()) {
|
|
989
|
+
this.stopPeriodicSnapshot();
|
|
990
|
+
let resolved = intervalMs;
|
|
991
|
+
if (resolved === undefined) {
|
|
992
|
+
const envMin = parseInt(process.env.CODEBUDDY_SNAPSHOT_INTERVAL_MIN || '45', 10);
|
|
993
|
+
resolved = (Number.isFinite(envMin) ? envMin : 45) * 60_000;
|
|
994
|
+
}
|
|
995
|
+
if (!resolved || resolved <= 0)
|
|
996
|
+
return;
|
|
997
|
+
this.snapshotTimer = setInterval(() => {
|
|
998
|
+
try {
|
|
999
|
+
this.takeSnapshot(getMessages(), workDir);
|
|
1000
|
+
}
|
|
1001
|
+
catch (err) {
|
|
1002
|
+
logger.debug('Periodic context snapshot failed', { error: String(err) });
|
|
1003
|
+
}
|
|
1004
|
+
}, resolved);
|
|
1005
|
+
this.snapshotTimer.unref();
|
|
1006
|
+
}
|
|
1007
|
+
/** Stop the periodic snapshot loop (idempotent). */
|
|
1008
|
+
stopPeriodicSnapshot() {
|
|
1009
|
+
if (this.snapshotTimer) {
|
|
1010
|
+
clearInterval(this.snapshotTimer);
|
|
1011
|
+
this.snapshotTimer = null;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
924
1014
|
}
|
|
925
1015
|
/**
|
|
926
1016
|
* Create a context manager with auto-detection of model limits
|
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
import * as fs from 'fs';
|
|
35
35
|
import * as path from 'path';
|
|
36
36
|
import { spawnSync } from 'child_process';
|
|
37
|
+
import { buildColabGoalContinuationPrompt } from './colab-goal.js';
|
|
38
|
+
/** Tail of agent stdout kept for the goal-mode judge (matches the judge's 4 KB cap). */
|
|
39
|
+
const OUTPUT_TAIL_CHARS = 4000;
|
|
37
40
|
/** Resolve the buddy CLI entrypoint inside `repoRoot`. */
|
|
38
41
|
function resolveEntrypoint(repoRoot) {
|
|
39
42
|
const tsx = path.join(repoRoot, 'node_modules', '.bin', 'tsx');
|
|
@@ -82,7 +85,11 @@ export function createAgentTaskExecutor(opts = {}) {
|
|
|
82
85
|
if (!entry) {
|
|
83
86
|
return { ok: false, summary: 'no buddy entrypoint', error: `no src/index.ts or dist/index.js under ${repoRoot}` };
|
|
84
87
|
}
|
|
85
|
-
|
|
88
|
+
// Goal-mode continuation: on later turns the worker gets the judge's
|
|
89
|
+
// nudge instead of the bare task text, so it targets the remaining gap.
|
|
90
|
+
const prompt = task.goalMode && (task.goalTurnsUsed ?? 0) > 0
|
|
91
|
+
? buildColabGoalContinuationPrompt(task)
|
|
92
|
+
: `${task.title}\n\n${task.description ?? ''}`.trim();
|
|
86
93
|
const env = buildAgentEnv(model);
|
|
87
94
|
const started = Date.now();
|
|
88
95
|
const res = doSpawn(entry.cmd, [...entry.baseArgs, '-p', prompt, '--permission-mode', permissionMode, '--output-format', 'text', ...extraArgs], { cwd: workspaceRoot, env, encoding: 'utf-8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
|
|
@@ -99,6 +106,8 @@ export function createAgentTaskExecutor(opts = {}) {
|
|
|
99
106
|
error: reason,
|
|
100
107
|
};
|
|
101
108
|
}
|
|
109
|
+
// Tail of the agent's real output — what the goal-mode judge evaluates.
|
|
110
|
+
const output = (res.stdout ?? '').slice(-OUTPUT_TAIL_CHARS).trim();
|
|
102
111
|
// Acceptance gate: if the task carries a verify command AND running task-
|
|
103
112
|
// supplied shell is allowed (opt-in), the agent "finishing" isn't enough —
|
|
104
113
|
// the gate must pass for the task to count as completed. When not allowed,
|
|
@@ -126,6 +135,7 @@ export function createAgentTaskExecutor(opts = {}) {
|
|
|
126
135
|
ok: true,
|
|
127
136
|
summary: `agent ran ${task.id} [${model.tier}/${model.model}] + gate \`${gate}\` passed (${elapsedSeconds}s)`,
|
|
128
137
|
elapsedSeconds,
|
|
138
|
+
...(output ? { output } : {}),
|
|
129
139
|
};
|
|
130
140
|
}
|
|
131
141
|
const elapsedSeconds = Math.round((Date.now() - started) / 1000);
|
|
@@ -133,6 +143,7 @@ export function createAgentTaskExecutor(opts = {}) {
|
|
|
133
143
|
ok: true,
|
|
134
144
|
summary: `agent ran ${task.id} [${model.tier}/${model.model}] in ${workspaceRoot} (${elapsedSeconds}s)`,
|
|
135
145
|
elapsedSeconds,
|
|
146
|
+
...(output ? { output } : {}),
|
|
136
147
|
};
|
|
137
148
|
};
|
|
138
149
|
}
|
|
@@ -18,6 +18,7 @@ import { FileWatcherTrigger } from '../agent/file-watcher-trigger.js';
|
|
|
18
18
|
import { FleetAutonomousLoop } from './autonomous-loop.js';
|
|
19
19
|
import { createLocalModelTaskExecutor } from './ollama-task-executor.js';
|
|
20
20
|
import { createAgentTaskExecutor } from './agent-task-executor.js';
|
|
21
|
+
import { createColabGoalJudge } from './colab-goal.js';
|
|
21
22
|
const DEFAULT_INTERVAL_MS = 30_000;
|
|
22
23
|
export class FleetAutonomousDaemon {
|
|
23
24
|
loop;
|
|
@@ -132,6 +133,7 @@ export function createDefaultAutonomousLoop(opts = {}) {
|
|
|
132
133
|
store,
|
|
133
134
|
tierConfig,
|
|
134
135
|
executor,
|
|
136
|
+
goalJudge: createColabGoalJudge(),
|
|
135
137
|
...(opts.policy ? { policy: opts.policy } : {}),
|
|
136
138
|
...(opts.enabled ? { enabled: opts.enabled } : {}),
|
|
137
139
|
});
|
|
@@ -22,12 +22,19 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { type AutonomousModelChoice, type ModelTierConfig, type ModelTierPolicy } from '../agent/model-tier.js';
|
|
24
24
|
import type { ColabTask, ColabWorklogFileChange, FleetColabStore } from '../fleet/colab-store.js';
|
|
25
|
+
import { type ColabGoalJudge } from './colab-goal.js';
|
|
25
26
|
export interface TaskExecutionResult {
|
|
26
27
|
ok: boolean;
|
|
27
28
|
summary: string;
|
|
28
29
|
filesModified?: ColabWorklogFileChange[];
|
|
29
30
|
elapsedSeconds?: number;
|
|
30
31
|
error?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Tail of the worker's actual output (capped). Goal-mode judges evaluate
|
|
34
|
+
* this; absent for executors that don't capture output (judge falls back
|
|
35
|
+
* to `summary`).
|
|
36
|
+
*/
|
|
37
|
+
output?: string;
|
|
31
38
|
}
|
|
32
39
|
export type TaskExecutor = (task: ColabTask, model: AutonomousModelChoice) => Promise<TaskExecutionResult>;
|
|
33
40
|
export interface AutonomousLoopConfig {
|
|
@@ -37,9 +44,14 @@ export interface AutonomousLoopConfig {
|
|
|
37
44
|
policy?: ModelTierPolicy;
|
|
38
45
|
/** Kill-switch — when it returns false the tick is a no-op. Default: always on. */
|
|
39
46
|
enabled?: () => boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Judge for `goalMode` tasks (Hermes kanban goal-mode parity). When absent,
|
|
49
|
+
* goal-mode tasks complete like plain tasks (no judge gate).
|
|
50
|
+
*/
|
|
51
|
+
goalJudge?: ColabGoalJudge;
|
|
40
52
|
}
|
|
41
53
|
export interface TickResult {
|
|
42
|
-
outcome: 'disabled' | 'idle' | 'completed' | 'failed' | 'saturated';
|
|
54
|
+
outcome: 'disabled' | 'idle' | 'completed' | 'failed' | 'saturated' | 'goal_continue' | 'goal_blocked';
|
|
43
55
|
taskId?: string;
|
|
44
56
|
taskTitle?: string;
|
|
45
57
|
model?: AutonomousModelChoice;
|
|
@@ -58,7 +70,15 @@ export declare class FleetAutonomousLoop {
|
|
|
58
70
|
* success. Resets across process restarts — escalation is a within-run feature.
|
|
59
71
|
*/
|
|
60
72
|
private readonly failures;
|
|
73
|
+
private readonly goalJudge;
|
|
61
74
|
constructor(config: AutonomousLoopConfig);
|
|
62
75
|
/** Run a single autonomous tick. Never throws — failures are logged + reported. */
|
|
63
76
|
tick(): Promise<TickResult>;
|
|
77
|
+
/**
|
|
78
|
+
* Goal-mode decision ladder. Returns a TickResult when the loop should NOT
|
|
79
|
+
* complete the task (judge says continue / budget exhausted), or null when
|
|
80
|
+
* the task may complete (judge done/skipped, or judge unreachable —
|
|
81
|
+
* fail-open like the interactive Ralph loop).
|
|
82
|
+
*/
|
|
83
|
+
private evaluateGoalModeTask;
|
|
64
84
|
}
|