@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,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent session goals — the Ralph loop for Code Buddy.
|
|
3
|
+
*
|
|
4
|
+
* A goal is a free-form user objective that stays active across turns. After
|
|
5
|
+
* each turn completes, a small judge call asks an auxiliary model "is this
|
|
6
|
+
* goal satisfied by the assistant's last response?". If not, Code Buddy feeds
|
|
7
|
+
* a continuation prompt back into the same session and keeps working until
|
|
8
|
+
* the goal is done, the turn budget is exhausted, the user pauses/clears it,
|
|
9
|
+
* or a real user message preempts the loop.
|
|
10
|
+
*
|
|
11
|
+
* Ported from Hermes Agent's goal system (hermes_cli/goals.py). Invariants:
|
|
12
|
+
* - The continuation prompt is a plain user message — no system-prompt
|
|
13
|
+
* mutation, no toolset swap, prompt caching stays intact.
|
|
14
|
+
* - Judge failures are fail-OPEN ("continue"): a broken judge must not wedge
|
|
15
|
+
* progress; the turn budget is the backstop.
|
|
16
|
+
* - A real user message mid-loop preempts the continuation prompt; the judge
|
|
17
|
+
* re-runs after that turn.
|
|
18
|
+
*/
|
|
19
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
20
|
+
// Constants & defaults
|
|
21
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
22
|
+
export const DEFAULT_MAX_TURNS = 20;
|
|
23
|
+
export const DEFAULT_JUDGE_TIMEOUT_MS = 30_000;
|
|
24
|
+
// Judge output budget. The judge returns a one-line JSON verdict, but
|
|
25
|
+
// reasoning models burn tokens on hidden reasoning before emitting the
|
|
26
|
+
// visible JSON. Tight caps truncate the JSON and trip the auto-pause.
|
|
27
|
+
export const DEFAULT_JUDGE_MAX_TOKENS = 4096;
|
|
28
|
+
// Caps how much of the inputs we send to the judge.
|
|
29
|
+
export const JUDGE_GOAL_SNIPPET_CHARS = 2000;
|
|
30
|
+
export const JUDGE_SUBGOALS_SNIPPET_CHARS = 2000;
|
|
31
|
+
export const JUDGE_RESPONSE_SNIPPET_CHARS = 4000;
|
|
32
|
+
// After this many consecutive judge *parse* failures (empty output /
|
|
33
|
+
// non-JSON), the loop auto-pauses and points the user at the judge config.
|
|
34
|
+
// Guards against small models that can't follow the strict JSON contract.
|
|
35
|
+
export const MAX_CONSECUTIVE_PARSE_FAILURES = 3;
|
|
36
|
+
export const CONTINUATION_PROMPT_TEMPLATE = '[Continuing toward your standing goal]\n' +
|
|
37
|
+
'Goal: {goal}\n\n' +
|
|
38
|
+
'Continue working toward this goal. Take the next concrete step. ' +
|
|
39
|
+
'If you believe the goal is complete, state so explicitly and stop. ' +
|
|
40
|
+
'If you are blocked and need input from the user, say so clearly and stop.';
|
|
41
|
+
export const CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE = '[Continuing toward your standing goal]\n' +
|
|
42
|
+
'Goal: {goal}\n\n' +
|
|
43
|
+
'Additional criteria the user added mid-loop:\n' +
|
|
44
|
+
'{subgoals_block}\n\n' +
|
|
45
|
+
'Continue working toward the goal AND all additional criteria. Take ' +
|
|
46
|
+
'the next concrete step. If you believe the goal and every ' +
|
|
47
|
+
'additional criterion are complete, state so explicitly and stop. ' +
|
|
48
|
+
'If you are blocked and need input from the user, say so clearly ' +
|
|
49
|
+
'and stop.';
|
|
50
|
+
export const JUDGE_SYSTEM_PROMPT = "You are a strict judge evaluating whether an autonomous agent has " +
|
|
51
|
+
"achieved a user's stated goal. You receive the goal text and the " +
|
|
52
|
+
"agent's most recent response. Your only job is to decide whether " +
|
|
53
|
+
'the goal is fully satisfied based on that response.\n\n' +
|
|
54
|
+
'A goal is DONE only when:\n' +
|
|
55
|
+
'- The response explicitly confirms the goal was completed, OR\n' +
|
|
56
|
+
'- The response clearly shows the final deliverable was produced, OR\n' +
|
|
57
|
+
'- The response explains the goal is unachievable / blocked / needs ' +
|
|
58
|
+
'user input (treat this as DONE with reason describing the block).\n\n' +
|
|
59
|
+
'Otherwise the goal is NOT done — CONTINUE.\n\n' +
|
|
60
|
+
'Reply ONLY with a single JSON object on one line:\n' +
|
|
61
|
+
'{"done": <true|false>, "reason": "<one-sentence rationale>"}';
|
|
62
|
+
export const JUDGE_USER_PROMPT_TEMPLATE = 'Goal:\n{goal}\n\n' +
|
|
63
|
+
"Agent's most recent response:\n{response}\n\n" +
|
|
64
|
+
'Current time: {current_time}\n\n' +
|
|
65
|
+
'Is the goal satisfied?';
|
|
66
|
+
export const JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE = 'Goal:\n{goal}\n\n' +
|
|
67
|
+
'Additional criteria the user added mid-loop (all must also be ' +
|
|
68
|
+
'satisfied for the goal to be DONE):\n{subgoals_block}\n\n' +
|
|
69
|
+
"Agent's most recent response:\n{response}\n\n" +
|
|
70
|
+
'Current time: {current_time}\n\n' +
|
|
71
|
+
'Decision: For each numbered criterion above, find concrete ' +
|
|
72
|
+
"evidence in the agent's response that the criterion is " +
|
|
73
|
+
"satisfied. Do not accept generic phrases like 'all requirements " +
|
|
74
|
+
"met' or 'implying it was done' — require specific evidence (a " +
|
|
75
|
+
'file contents excerpt, an output line, a command result). If ' +
|
|
76
|
+
'ANY criterion lacks specific evidence in the response, the goal ' +
|
|
77
|
+
'is NOT done — return CONTINUE.\n\n' +
|
|
78
|
+
'Is the goal AND every additional criterion satisfied?';
|
|
79
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
80
|
+
// Helpers
|
|
81
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
82
|
+
export function truncateText(text, limit) {
|
|
83
|
+
if (!text)
|
|
84
|
+
return '';
|
|
85
|
+
if (text.length <= limit)
|
|
86
|
+
return text;
|
|
87
|
+
return text.slice(0, limit) + '… [truncated]';
|
|
88
|
+
}
|
|
89
|
+
/** Render subgoals as a numbered `- N. text` block. Empty string when none. */
|
|
90
|
+
export function renderSubgoalsBlock(subgoals) {
|
|
91
|
+
if (!subgoals.length)
|
|
92
|
+
return '';
|
|
93
|
+
return subgoals.map((text, i) => `- ${i + 1}. ${text}`).join('\n');
|
|
94
|
+
}
|
|
95
|
+
export function createGoalState(goal, maxTurns = DEFAULT_MAX_TURNS) {
|
|
96
|
+
return {
|
|
97
|
+
goal,
|
|
98
|
+
status: 'active',
|
|
99
|
+
turnsUsed: 0,
|
|
100
|
+
maxTurns,
|
|
101
|
+
createdAt: Date.now(),
|
|
102
|
+
lastTurnAt: 0,
|
|
103
|
+
consecutiveParseFailures: 0,
|
|
104
|
+
subgoals: [],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Defensive deserialization of a persisted goal state. Returns null when the
|
|
109
|
+
* payload isn't a usable goal. Old payloads without `subgoals` load unchanged.
|
|
110
|
+
*/
|
|
111
|
+
export function normalizeGoalState(raw) {
|
|
112
|
+
if (!raw || typeof raw !== 'object')
|
|
113
|
+
return null;
|
|
114
|
+
const data = raw;
|
|
115
|
+
const goal = typeof data.goal === 'string' ? data.goal : '';
|
|
116
|
+
if (!goal.trim())
|
|
117
|
+
return null;
|
|
118
|
+
const status = ['active', 'paused', 'done', 'cleared'].includes(String(data.status))
|
|
119
|
+
? data.status
|
|
120
|
+
: 'active';
|
|
121
|
+
const subgoals = Array.isArray(data.subgoals)
|
|
122
|
+
? data.subgoals.map(s => String(s).trim()).filter(Boolean)
|
|
123
|
+
: [];
|
|
124
|
+
const verdict = ['done', 'continue', 'skipped'].includes(String(data.lastVerdict))
|
|
125
|
+
? data.lastVerdict
|
|
126
|
+
: undefined;
|
|
127
|
+
const state = {
|
|
128
|
+
goal,
|
|
129
|
+
status,
|
|
130
|
+
turnsUsed: toInt(data.turnsUsed, 0),
|
|
131
|
+
maxTurns: toInt(data.maxTurns, DEFAULT_MAX_TURNS) || DEFAULT_MAX_TURNS,
|
|
132
|
+
createdAt: toNumber(data.createdAt, 0),
|
|
133
|
+
lastTurnAt: toNumber(data.lastTurnAt, 0),
|
|
134
|
+
consecutiveParseFailures: toInt(data.consecutiveParseFailures, 0),
|
|
135
|
+
subgoals,
|
|
136
|
+
};
|
|
137
|
+
if (verdict)
|
|
138
|
+
state.lastVerdict = verdict;
|
|
139
|
+
if (typeof data.lastReason === 'string')
|
|
140
|
+
state.lastReason = data.lastReason;
|
|
141
|
+
if (typeof data.pausedReason === 'string')
|
|
142
|
+
state.pausedReason = data.pausedReason;
|
|
143
|
+
return state;
|
|
144
|
+
}
|
|
145
|
+
function toInt(value, fallback) {
|
|
146
|
+
const n = Number(value);
|
|
147
|
+
return Number.isFinite(n) ? Math.trunc(n) : fallback;
|
|
148
|
+
}
|
|
149
|
+
function toNumber(value, fallback) {
|
|
150
|
+
const n = Number(value);
|
|
151
|
+
return Number.isFinite(n) ? n : fallback;
|
|
152
|
+
}
|
|
153
|
+
/** Printable one-liner for /goal status. */
|
|
154
|
+
export function formatGoalStatusLine(state) {
|
|
155
|
+
if (!state || state.status === 'cleared') {
|
|
156
|
+
return 'No active goal. Set one with /goal <text>.';
|
|
157
|
+
}
|
|
158
|
+
const turns = `${state.turnsUsed}/${state.maxTurns} turns`;
|
|
159
|
+
const sub = state.subgoals.length
|
|
160
|
+
? `, ${state.subgoals.length} subgoal${state.subgoals.length !== 1 ? 's' : ''}`
|
|
161
|
+
: '';
|
|
162
|
+
if (state.status === 'active') {
|
|
163
|
+
return `⊙ Goal (active, ${turns}${sub}): ${state.goal}`;
|
|
164
|
+
}
|
|
165
|
+
if (state.status === 'paused') {
|
|
166
|
+
const extra = state.pausedReason ? ` — ${state.pausedReason}` : '';
|
|
167
|
+
return `⏸ Goal (paused, ${turns}${sub}${extra}): ${state.goal}`;
|
|
168
|
+
}
|
|
169
|
+
if (state.status === 'done') {
|
|
170
|
+
return `✓ Goal done (${turns}${sub}): ${state.goal}`;
|
|
171
|
+
}
|
|
172
|
+
return `Goal (${state.status}, ${turns}${sub}): ${state.goal}`;
|
|
173
|
+
}
|
|
174
|
+
/** The canonical user-role continuation message for an active goal. */
|
|
175
|
+
export function buildContinuationPrompt(state) {
|
|
176
|
+
if (state.subgoals.length) {
|
|
177
|
+
return CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE.replace('{goal}', state.goal).replace('{subgoals_block}', renderSubgoalsBlock(state.subgoals));
|
|
178
|
+
}
|
|
179
|
+
return CONTINUATION_PROMPT_TEMPLATE.replace('{goal}', state.goal);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Apply a judge outcome to an active goal — the exact Hermes ladder.
|
|
183
|
+
* MUTATES `state` (turn counter, verdict bookkeeping, status transitions)
|
|
184
|
+
* and returns the decision; the caller persists the state wherever it
|
|
185
|
+
* lives (GoalStore file, peer-session record, …).
|
|
186
|
+
*/
|
|
187
|
+
export function applyJudgeOutcome(state, outcome, nowMs = Date.now()) {
|
|
188
|
+
state.turnsUsed += 1;
|
|
189
|
+
state.lastTurnAt = nowMs;
|
|
190
|
+
state.lastVerdict = outcome.verdict;
|
|
191
|
+
state.lastReason = outcome.reason;
|
|
192
|
+
// Reset the parse-failure streak on any usable reply, including
|
|
193
|
+
// API/transport errors (parseFailed=false), so a flaky network doesn't
|
|
194
|
+
// trip the auto-pause meant for bad judge models.
|
|
195
|
+
state.consecutiveParseFailures = outcome.parseFailed ? state.consecutiveParseFailures + 1 : 0;
|
|
196
|
+
if (outcome.verdict === 'done') {
|
|
197
|
+
state.status = 'done';
|
|
198
|
+
return {
|
|
199
|
+
status: 'done',
|
|
200
|
+
shouldContinue: false,
|
|
201
|
+
continuationPrompt: null,
|
|
202
|
+
verdict: 'done',
|
|
203
|
+
reason: outcome.reason,
|
|
204
|
+
message: `✓ Goal achieved: ${outcome.reason}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (state.consecutiveParseFailures >= MAX_CONSECUTIVE_PARSE_FAILURES) {
|
|
208
|
+
state.status = 'paused';
|
|
209
|
+
state.pausedReason = `judge model returned unparseable output ${state.consecutiveParseFailures} turns in a row`;
|
|
210
|
+
return {
|
|
211
|
+
status: 'paused',
|
|
212
|
+
shouldContinue: false,
|
|
213
|
+
continuationPrompt: null,
|
|
214
|
+
verdict: 'continue',
|
|
215
|
+
reason: outcome.reason,
|
|
216
|
+
message: `⏸ Goal paused — the judge model (${state.consecutiveParseFailures} turns) ` +
|
|
217
|
+
'isn\'t returning the required JSON verdict. Route the judge to a stricter ' +
|
|
218
|
+
'model in .codebuddy/settings.json:\n' +
|
|
219
|
+
' { "goals": { "judgeModel": "<a model that follows JSON instructions>" } }\n' +
|
|
220
|
+
'Then /goal resume to continue.',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (state.turnsUsed >= state.maxTurns) {
|
|
224
|
+
state.status = 'paused';
|
|
225
|
+
state.pausedReason = `turn budget exhausted (${state.turnsUsed}/${state.maxTurns})`;
|
|
226
|
+
return {
|
|
227
|
+
status: 'paused',
|
|
228
|
+
shouldContinue: false,
|
|
229
|
+
continuationPrompt: null,
|
|
230
|
+
verdict: 'continue',
|
|
231
|
+
reason: outcome.reason,
|
|
232
|
+
message: `⏸ Goal paused — ${state.turnsUsed}/${state.maxTurns} turns used. ` +
|
|
233
|
+
'Use /goal resume to keep going, or /goal clear to stop.',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
status: 'active',
|
|
238
|
+
shouldContinue: true,
|
|
239
|
+
continuationPrompt: buildContinuationPrompt(state),
|
|
240
|
+
verdict: 'continue',
|
|
241
|
+
reason: outcome.reason,
|
|
242
|
+
message: `↻ Continuing toward goal (${state.turnsUsed}/${state.maxTurns}): ${outcome.reason}`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
//# sourceMappingURL=goal-state.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable per-session goal persistence.
|
|
3
|
+
*
|
|
4
|
+
* One JSON file per session key under `~/.codebuddy/goals/` (honors
|
|
5
|
+
* CODEBUDDY_HOME). Files are tiny (<1 KB) so I/O is synchronous — same
|
|
6
|
+
* trade-off as TodoTracker. Writes are atomic (tmp + rename). Loads are
|
|
7
|
+
* fail-soft: a corrupt or unreadable file reads as "no goal".
|
|
8
|
+
*
|
|
9
|
+
* Cleared goals keep a tombstone (`status: 'cleared'`) for audit, mirroring
|
|
10
|
+
* Hermes' SessionDB behavior.
|
|
11
|
+
*/
|
|
12
|
+
import { GoalState } from './goal-state.js';
|
|
13
|
+
export interface GoalStoreOptions {
|
|
14
|
+
/** Override the storage directory (test isolation). */
|
|
15
|
+
storeDir?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare class GoalStore {
|
|
18
|
+
private storeDir;
|
|
19
|
+
constructor(options?: GoalStoreOptions);
|
|
20
|
+
getStoreDir(): string;
|
|
21
|
+
load(key: string): GoalState | null;
|
|
22
|
+
save(key: string, state: GoalState): void;
|
|
23
|
+
delete(key: string): void;
|
|
24
|
+
private fileFor;
|
|
25
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable per-session goal persistence.
|
|
3
|
+
*
|
|
4
|
+
* One JSON file per session key under `~/.codebuddy/goals/` (honors
|
|
5
|
+
* CODEBUDDY_HOME). Files are tiny (<1 KB) so I/O is synchronous — same
|
|
6
|
+
* trade-off as TodoTracker. Writes are atomic (tmp + rename). Loads are
|
|
7
|
+
* fail-soft: a corrupt or unreadable file reads as "no goal".
|
|
8
|
+
*
|
|
9
|
+
* Cleared goals keep a tombstone (`status: 'cleared'`) for audit, mirroring
|
|
10
|
+
* Hermes' SessionDB behavior.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { getCodeBuddyPath } from '../utils/codebuddy-home.js';
|
|
15
|
+
import { logger } from '../utils/logger.js';
|
|
16
|
+
import { normalizeGoalState } from './goal-state.js';
|
|
17
|
+
export class GoalStore {
|
|
18
|
+
storeDir;
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.storeDir = options.storeDir ?? getCodeBuddyPath('goals');
|
|
21
|
+
}
|
|
22
|
+
getStoreDir() {
|
|
23
|
+
return this.storeDir;
|
|
24
|
+
}
|
|
25
|
+
load(key) {
|
|
26
|
+
if (!key)
|
|
27
|
+
return null;
|
|
28
|
+
const file = this.fileFor(key);
|
|
29
|
+
try {
|
|
30
|
+
if (!fs.existsSync(file))
|
|
31
|
+
return null;
|
|
32
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
33
|
+
return normalizeGoalState(JSON.parse(raw));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
logger.debug('GoalStore: failed to load goal state', { key, error: String(error) });
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
save(key, state) {
|
|
41
|
+
if (!key)
|
|
42
|
+
return;
|
|
43
|
+
const file = this.fileFor(key);
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(this.storeDir, { recursive: true });
|
|
46
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
47
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf-8');
|
|
48
|
+
fs.renameSync(tmp, file);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
logger.debug('GoalStore: failed to save goal state', { key, error: String(error) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
delete(key) {
|
|
55
|
+
if (!key)
|
|
56
|
+
return;
|
|
57
|
+
try {
|
|
58
|
+
fs.rmSync(this.fileFor(key), { force: true });
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
logger.debug('GoalStore: failed to delete goal state', { key, error: String(error) });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
fileFor(key) {
|
|
65
|
+
// Session ids and dir-hash keys are already filesystem-safe; sanitize
|
|
66
|
+
// anyway so a malformed key can't escape the store directory.
|
|
67
|
+
const safe = key.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
68
|
+
return path.join(this.storeDir, `${safe}.json`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=goal-store.js.map
|
|
@@ -16,6 +16,7 @@ import { ClientCommandDispatcher } from "../commands/client-dispatcher.js";
|
|
|
16
16
|
import { extractFileReference, getFileSuggestions } from "../ui/components/FileAutocomplete.js";
|
|
17
17
|
// Import interaction logger for session tracking
|
|
18
18
|
import { getInteractionLogger } from "../logging/interaction-logger.js";
|
|
19
|
+
import { maybeContinueGoalAfterTurn } from "../goals/goal-loop.js";
|
|
19
20
|
import { logger } from '../utils/logger.js';
|
|
20
21
|
// Import history manager for persistent command history
|
|
21
22
|
import { getHistoryManager } from "../utils/history-manager.js";
|
|
@@ -35,6 +36,11 @@ export function useInputHandler({ agent, chatHistory, setChatHistory, setIsProce
|
|
|
35
36
|
// Track last escape time for double-escape detection
|
|
36
37
|
const lastEscapeTimeRef = useRef(0);
|
|
37
38
|
const DOUBLE_ESCAPE_THRESHOLD = 500; // ms
|
|
39
|
+
// Goal loop (Ralph loop): monotonically increasing turn sequence. A real
|
|
40
|
+
// user submit mid-turn bumps it, which voids the stale turn's continuation
|
|
41
|
+
// (the judge then runs after the user's turn instead — Hermes preemption).
|
|
42
|
+
const turnSeqRef = useRef(0);
|
|
43
|
+
const goalInterruptedRef = useRef(false);
|
|
38
44
|
/**
|
|
39
45
|
* Save instruction to .codebuddyrules file (Standard # capture)
|
|
40
46
|
*/
|
|
@@ -88,6 +94,7 @@ export function useInputHandler({ agent, chatHistory, setChatHistory, setIsProce
|
|
|
88
94
|
return true;
|
|
89
95
|
}
|
|
90
96
|
if (isProcessing || isStreaming) {
|
|
97
|
+
goalInterruptedRef.current = true;
|
|
91
98
|
agent.abortCurrentOperation();
|
|
92
99
|
setIsProcessing(false);
|
|
93
100
|
setIsStreaming(false);
|
|
@@ -465,6 +472,8 @@ export function useInputHandler({ agent, chatHistory, setChatHistory, setIsProce
|
|
|
465
472
|
return await ClientCommandDispatcher.dispatch(input, context);
|
|
466
473
|
};
|
|
467
474
|
const processUserMessage = async (userInput) => {
|
|
475
|
+
const mySeq = ++turnSeqRef.current;
|
|
476
|
+
goalInterruptedRef.current = false;
|
|
468
477
|
const userEntry = {
|
|
469
478
|
type: "user",
|
|
470
479
|
content: userInput,
|
|
@@ -484,10 +493,10 @@ export function useInputHandler({ agent, chatHistory, setChatHistory, setIsProce
|
|
|
484
493
|
setIsProcessing(true);
|
|
485
494
|
setCurrentActivity?.('Sending to LLM...');
|
|
486
495
|
clearInput();
|
|
496
|
+
let fullResponseContent = "";
|
|
487
497
|
try {
|
|
488
498
|
setIsStreaming(true);
|
|
489
499
|
let streamingEntry = null;
|
|
490
|
-
let fullResponseContent = "";
|
|
491
500
|
for await (const chunk of agent.processUserMessageStream(userInput)) {
|
|
492
501
|
switch (chunk.type) {
|
|
493
502
|
case "content":
|
|
@@ -677,6 +686,32 @@ export function useInputHandler({ agent, chatHistory, setChatHistory, setIsProce
|
|
|
677
686
|
setIsProcessing(false);
|
|
678
687
|
setCurrentActivity?.('');
|
|
679
688
|
processingStartTime.current = 0;
|
|
689
|
+
// Goal loop (Ralph loop): judge the finished turn and maybe auto-continue.
|
|
690
|
+
// Failures here must never break a turn — the goal system is advisory.
|
|
691
|
+
try {
|
|
692
|
+
const outcome = await maybeContinueGoalAfterTurn({
|
|
693
|
+
client: agent.getClient(),
|
|
694
|
+
lastResponse: fullResponseContent,
|
|
695
|
+
interrupted: goalInterruptedRef.current,
|
|
696
|
+
});
|
|
697
|
+
if (outcome?.message) {
|
|
698
|
+
setChatHistory((prev) => [
|
|
699
|
+
...prev,
|
|
700
|
+
{ type: 'assistant', content: outcome.message, timestamp: new Date() },
|
|
701
|
+
]);
|
|
702
|
+
}
|
|
703
|
+
// Skip the continuation if a newer turn started while we were judging —
|
|
704
|
+
// the user's message preempts the loop and gets judged after its turn.
|
|
705
|
+
if (outcome?.continuationPrompt && turnSeqRef.current === mySeq) {
|
|
706
|
+
const continuation = outcome.continuationPrompt;
|
|
707
|
+
setTimeout(() => {
|
|
708
|
+
void processUserMessage(continuation);
|
|
709
|
+
}, 50);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
logger.debug('goal after-turn hook failed', { error: String(error) });
|
|
714
|
+
}
|
|
680
715
|
};
|
|
681
716
|
return {
|
|
682
717
|
input,
|
package/dist/index.js
CHANGED
|
@@ -728,6 +728,20 @@ async function processPromptHeadless(prompt, apiKey, baseURL, model, maxToolRoun
|
|
|
728
728
|
}
|
|
729
729
|
// Process the user message
|
|
730
730
|
const chatEntries = await agent.processUserMessage(prompt);
|
|
731
|
+
// WS3-T1 — session-end flush (handoff + lesson candidates). Awaited with
|
|
732
|
+
// a hard cap so headless runs keep their continuity write without ever
|
|
733
|
+
// hanging the exit; trivial sessions no-op inside the module.
|
|
734
|
+
try {
|
|
735
|
+
const { runSessionEndFlush } = await import('./agent/session-end-flush.js');
|
|
736
|
+
const flushTimeoutMs = parseInt(process.env.CODEBUDDY_SESSION_END_FLUSH_TIMEOUT_MS || '15000', 10);
|
|
737
|
+
await Promise.race([
|
|
738
|
+
runSessionEndFlush({ chatHistory: chatEntries }),
|
|
739
|
+
new Promise((resolve) => setTimeout(resolve, flushTimeoutMs).unref()),
|
|
740
|
+
]);
|
|
741
|
+
}
|
|
742
|
+
catch (e) {
|
|
743
|
+
logger.debug('Headless session-end flush skipped', { error: String(e) });
|
|
744
|
+
}
|
|
731
745
|
// Log entries to interaction logger
|
|
732
746
|
if (interactionLogger) {
|
|
733
747
|
for (const entry of chatEntries) {
|
|
@@ -1520,6 +1534,9 @@ program
|
|
|
1520
1534
|
tags: ['interactive'],
|
|
1521
1535
|
});
|
|
1522
1536
|
agent.__interactionLogger = interactionLogger;
|
|
1537
|
+
// WS3-T1 — pre-load the flush module so the sync `exit` handler
|
|
1538
|
+
// can call it (ESM has no require; dynamic import is async).
|
|
1539
|
+
const sessionEndFlush = await import('./agent/session-end-flush.js');
|
|
1523
1540
|
const cleanup = () => {
|
|
1524
1541
|
try {
|
|
1525
1542
|
interactionLogger.endSession();
|
|
@@ -1527,10 +1544,30 @@ program
|
|
|
1527
1544
|
catch (e) {
|
|
1528
1545
|
logger.debug('Failed to end interaction logger session', { error: String(e) });
|
|
1529
1546
|
}
|
|
1547
|
+
// `exit` handlers are sync-only: write at least the handoff
|
|
1548
|
+
// (no LLM) so an interrupted session still leaves a resume
|
|
1549
|
+
// point. The full async flush runs on SIGINT/SIGTERM below.
|
|
1550
|
+
try {
|
|
1551
|
+
sessionEndFlush.writeHandoffSync(agent.getChatHistory());
|
|
1552
|
+
}
|
|
1553
|
+
catch { /* handoff is best-effort on hard exit */ }
|
|
1554
|
+
};
|
|
1555
|
+
const flushThenExit = () => {
|
|
1556
|
+
cleanup();
|
|
1557
|
+
void (async () => {
|
|
1558
|
+
try {
|
|
1559
|
+
await Promise.race([
|
|
1560
|
+
sessionEndFlush.runSessionEndFlush({ chatHistory: agent.getChatHistory() }),
|
|
1561
|
+
new Promise((resolve) => setTimeout(resolve, 8000).unref()),
|
|
1562
|
+
]);
|
|
1563
|
+
}
|
|
1564
|
+
catch { /* never block exit on the flush */ }
|
|
1565
|
+
process.exit(0);
|
|
1566
|
+
})();
|
|
1530
1567
|
};
|
|
1531
1568
|
process.on('exit', cleanup);
|
|
1532
|
-
process.on('SIGINT',
|
|
1533
|
-
process.on('SIGTERM',
|
|
1569
|
+
process.on('SIGINT', flushThenExit);
|
|
1570
|
+
process.on('SIGTERM', flushThenExit);
|
|
1534
1571
|
}
|
|
1535
1572
|
catch (err) {
|
|
1536
1573
|
logger.warn('Failed to initialize interaction logger', { error: String(err) });
|
|
@@ -2045,6 +2082,11 @@ addLazyCommand(program, 'flow', 'Execute a multi-agent planning flow (OpenManus-
|
|
|
2045
2082
|
const { createFlowCommand } = await import('./commands/flow.js');
|
|
2046
2083
|
return createFlowCommand();
|
|
2047
2084
|
});
|
|
2085
|
+
// Goal Ralph loop — headless judge-gated auto-continue (Hermes Agent parity)
|
|
2086
|
+
addLazyCommand(program, 'goal', 'Run the agent toward a standing goal until a judge model confirms it is done (Ralph loop)', async () => {
|
|
2087
|
+
const { createGoalCommand } = await import('./commands/goal-cli.js');
|
|
2088
|
+
return createGoalCommand();
|
|
2089
|
+
});
|
|
2048
2090
|
// Todo attention bias — Manus AI-inspired persistent task list
|
|
2049
2091
|
addLazyCommand(program, 'todo', 'Manage persistent task list (todo.md) — injected at end of every agent turn for focus', async () => {
|
|
2050
2092
|
const { createTodosCommand } = await import('./commands/todos.js');
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Written in append mode for performance — no full-file parsing per event.
|
|
10
10
|
* Automatic pruning keeps the 30 most recent runs.
|
|
11
11
|
*/
|
|
12
|
-
export type RunEventType = 'run_start' | 'run_end' | 'step_start' | 'step_end' | 'tool_call' | 'tool_result' | 'patch_created' | 'patch_applied' | 'decision' | 'error' | 'metric' | 'lesson_added' | 'lesson_candidate_proposed' | 'skill_selected';
|
|
12
|
+
export type RunEventType = 'run_start' | 'run_end' | 'step_start' | 'step_end' | 'tool_call' | 'tool_result' | 'patch_created' | 'patch_applied' | 'decision' | 'error' | 'metric' | 'lesson_added' | 'lesson_candidate_proposed' | 'skill_selected' | 'context_snapshot' | 'pause_suggested';
|
|
13
13
|
export interface RunEvent {
|
|
14
14
|
ts: number;
|
|
15
15
|
type: RunEventType;
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* agent-executor events so receivers can map them 1:1 to local
|
|
39
39
|
* AgentRuntime events for re-display.
|
|
40
40
|
*/
|
|
41
|
-
export declare const FLEET_EVENT_TYPES: readonly ["fleet:agent:tool_started", "fleet:agent:tool_completed", "fleet:agent:tool_error", "fleet:agent:reasoning", "fleet:workflow:event", "fleet:workflow:start", "fleet:workflow:complete", "fleet:session:spawn", "fleet:session:message", "fleet:peer:heartbeat", "fleet:peer:compacting:start", "fleet:peer:compacting:complete", "fleet:chat-session:start", "fleet:chat-session:turn", "fleet:chat-session:end"];
|
|
41
|
+
export declare const FLEET_EVENT_TYPES: readonly ["fleet:agent:tool_started", "fleet:agent:tool_completed", "fleet:agent:tool_error", "fleet:agent:reasoning", "fleet:workflow:event", "fleet:workflow:start", "fleet:workflow:complete", "fleet:session:spawn", "fleet:session:message", "fleet:peer:heartbeat", "fleet:peer:compacting:start", "fleet:peer:compacting:complete", "fleet:chat-session:start", "fleet:chat-session:turn", "fleet:chat-session:end", "fleet:chat-session:goal"];
|
|
42
42
|
export type FleetEventType = (typeof FLEET_EVENT_TYPES)[number];
|
|
43
43
|
/**
|
|
44
44
|
* Source identification carried with every fleet event so receivers know
|
|
@@ -137,3 +137,15 @@ export declare function broadcastChatSessionEnd(payload: {
|
|
|
137
137
|
sessionId: string;
|
|
138
138
|
reason?: 'end' | 'expired';
|
|
139
139
|
}): void;
|
|
140
|
+
/**
|
|
141
|
+
* Goal Ralph-loop on a peer session (Hermes gateway parity) — emitted on
|
|
142
|
+
* goal attach/pause/resume/clear and after each post-turn judge verdict.
|
|
143
|
+
* Metadata only: status/verdict/counters, never goal text or judge reasons.
|
|
144
|
+
*/
|
|
145
|
+
export declare function broadcastChatSessionGoal(payload: {
|
|
146
|
+
sessionId: string;
|
|
147
|
+
status: string;
|
|
148
|
+
verdict?: string;
|
|
149
|
+
turnsUsed?: number;
|
|
150
|
+
maxTurns?: number;
|
|
151
|
+
}): void;
|
|
@@ -67,6 +67,9 @@ export const FLEET_EVENT_TYPES = [
|
|
|
67
67
|
'fleet:chat-session:start',
|
|
68
68
|
'fleet:chat-session:turn',
|
|
69
69
|
'fleet:chat-session:end',
|
|
70
|
+
// Goal Ralph-loop on peer sessions (Hermes gateway parity). Metadata
|
|
71
|
+
// only — status/verdict/turn counters, never goal text or reasons.
|
|
72
|
+
'fleet:chat-session:goal',
|
|
70
73
|
];
|
|
71
74
|
/**
|
|
72
75
|
* Cached source. Resolved lazily on first emit so tests can stub
|
|
@@ -167,4 +170,12 @@ export function broadcastChatSessionTurn(payload) {
|
|
|
167
170
|
export function broadcastChatSessionEnd(payload) {
|
|
168
171
|
broadcastFleetEvent('fleet:chat-session:end', { ...payload });
|
|
169
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Goal Ralph-loop on a peer session (Hermes gateway parity) — emitted on
|
|
175
|
+
* goal attach/pause/resume/clear and after each post-turn judge verdict.
|
|
176
|
+
* Metadata only: status/verdict/counters, never goal text or judge reasons.
|
|
177
|
+
*/
|
|
178
|
+
export function broadcastChatSessionGoal(payload) {
|
|
179
|
+
broadcastFleetEvent('fleet:chat-session:goal', { ...payload });
|
|
180
|
+
}
|
|
170
181
|
//# sourceMappingURL=fleet-bridge.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phuetz/code-buddy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Open-source multi-provider AI coding agent for the terminal. Supports Grok, Claude, ChatGPT, Gemini, Ollama and LM Studio with 52+ tools, multi-channel messaging, skills system, and Enterprise-grade architecture.",
|
|
5
5
|
"author": "Patrice Huetz <patrice.huetz@gmail.com>",
|
|
6
6
|
"repository": {
|