@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.
Files changed (51) hide show
  1. package/README.md +3 -2
  2. package/dist/agent/codebuddy-agent.js +33 -0
  3. package/dist/agent/lesson-auto-proposer.js +10 -0
  4. package/dist/agent/middleware/session-duration.d.ts +36 -0
  5. package/dist/agent/middleware/session-duration.js +78 -0
  6. package/dist/agent/session-end-flush.d.ts +66 -0
  7. package/dist/agent/session-end-flush.js +217 -0
  8. package/dist/commands/cli/native-engine-commands.js +6 -1
  9. package/dist/commands/enhanced-command-handler.js +5 -0
  10. package/dist/commands/goal-cli.d.ts +41 -0
  11. package/dist/commands/goal-cli.js +97 -0
  12. package/dist/commands/handlers/goal-handler.d.ts +27 -0
  13. package/dist/commands/handlers/goal-handler.js +128 -0
  14. package/dist/commands/handlers/index.d.ts +1 -0
  15. package/dist/commands/handlers/index.js +2 -0
  16. package/dist/commands/slash/builtin-commands.js +20 -0
  17. package/dist/config/env-schema.js +14 -0
  18. package/dist/config/feature-flags.js +7 -0
  19. package/dist/context/context-manager-v2.d.ts +39 -0
  20. package/dist/context/context-manager-v2.js +90 -0
  21. package/dist/daemon/agent-task-executor.js +12 -1
  22. package/dist/daemon/autonomous-daemon.js +2 -0
  23. package/dist/daemon/autonomous-loop.d.ts +21 -1
  24. package/dist/daemon/autonomous-loop.js +60 -0
  25. package/dist/daemon/colab-goal.d.ts +38 -0
  26. package/dist/daemon/colab-goal.js +73 -0
  27. package/dist/fleet/colab-store.d.ts +21 -0
  28. package/dist/fleet/colab-store.js +16 -0
  29. package/dist/fleet/peer-session-bridge.d.ts +1 -1
  30. package/dist/fleet/peer-session-bridge.js +205 -2
  31. package/dist/fleet/peer-session-store.d.ts +3 -0
  32. package/dist/fleet/privacy-lint.d.ts +8 -0
  33. package/dist/fleet/privacy-lint.js +22 -0
  34. package/dist/goals/goal-judge.d.ts +36 -0
  35. package/dist/goals/goal-judge.js +129 -0
  36. package/dist/goals/goal-loop.d.ts +23 -0
  37. package/dist/goals/goal-loop.js +56 -0
  38. package/dist/goals/goal-manager.d.ts +71 -0
  39. package/dist/goals/goal-manager.js +236 -0
  40. package/dist/goals/goal-state.d.ts +86 -0
  41. package/dist/goals/goal-state.js +245 -0
  42. package/dist/goals/goal-store.d.ts +25 -0
  43. package/dist/goals/goal-store.js +71 -0
  44. package/dist/goals/index.d.ts +5 -0
  45. package/dist/goals/index.js +6 -0
  46. package/dist/hooks/use-input-handler.js +36 -1
  47. package/dist/index.js +44 -2
  48. package/dist/observability/run-store.d.ts +1 -1
  49. package/dist/server/websocket/fleet-bridge.d.ts +13 -1
  50. package/dist/server/websocket/fleet-bridge.js +11 -0
  51. package/package.json +1 -1
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Goal judge — asks an auxiliary LLM whether the standing goal is satisfied
3
+ * by the agent's last response.
4
+ *
5
+ * Deliberately fail-OPEN: any transport error, timeout, or missing client
6
+ * returns "continue" so a broken judge never wedges progress — the turn
7
+ * budget and the consecutive-parse-failures auto-pause are the backstops.
8
+ *
9
+ * `parseFailed` is true only when the judge call succeeded but its output was
10
+ * unusable (empty or non-JSON). API/transport errors return false — they are
11
+ * transient and must not count toward the parse-failure auto-pause.
12
+ */
13
+ import { CodeBuddyClient } from '../codebuddy/client.js';
14
+ import { GoalVerdict } from './goal-state.js';
15
+ export interface GoalJudgeResult {
16
+ verdict: GoalVerdict;
17
+ reason: string;
18
+ parseFailed: boolean;
19
+ }
20
+ export interface GoalJudgeParams {
21
+ goal: string;
22
+ lastResponse: string;
23
+ subgoals?: string[];
24
+ /** Override the judge model (config `goals.judgeModel`). Empty → client default. */
25
+ model?: string;
26
+ timeoutMs?: number;
27
+ }
28
+ /** Signature used by GoalManager so tests can inject a fake judge. */
29
+ export type GoalJudgeFn = (params: GoalJudgeParams) => Promise<GoalJudgeResult>;
30
+ export declare function judgeGoal(client: CodeBuddyClient | null, params: GoalJudgeParams): Promise<GoalJudgeResult>;
31
+ export declare function buildJudgeUserPrompt(params: GoalJudgeParams): string;
32
+ /**
33
+ * Parse the judge's reply. Fail-open: anything unusable reads as "continue"
34
+ * with `parseFailed: true` so callers can auto-pause after N strikes.
35
+ */
36
+ export declare function parseJudgeResponse(raw: string): GoalJudgeResult;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Goal judge — asks an auxiliary LLM whether the standing goal is satisfied
3
+ * by the agent's last response.
4
+ *
5
+ * Deliberately fail-OPEN: any transport error, timeout, or missing client
6
+ * returns "continue" so a broken judge never wedges progress — the turn
7
+ * budget and the consecutive-parse-failures auto-pause are the backstops.
8
+ *
9
+ * `parseFailed` is true only when the judge call succeeded but its output was
10
+ * unusable (empty or non-JSON). API/transport errors return false — they are
11
+ * transient and must not count toward the parse-failure auto-pause.
12
+ */
13
+ import { getCostTracker } from '../utils/cost-tracker.js';
14
+ import { parseJsonResponse } from '../utils/llm-retry.js';
15
+ import { logger } from '../utils/logger.js';
16
+ import { DEFAULT_JUDGE_TIMEOUT_MS, JUDGE_GOAL_SNIPPET_CHARS, JUDGE_RESPONSE_SNIPPET_CHARS, JUDGE_SUBGOALS_SNIPPET_CHARS, JUDGE_SYSTEM_PROMPT, JUDGE_USER_PROMPT_TEMPLATE, JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE, renderSubgoalsBlock, truncateText, } from './goal-state.js';
17
+ export async function judgeGoal(client, params) {
18
+ if (!params.goal.trim()) {
19
+ return { verdict: 'skipped', reason: 'empty goal', parseFailed: false };
20
+ }
21
+ if (!params.lastResponse.trim()) {
22
+ // No substantive reply this turn — almost certainly not done yet.
23
+ return { verdict: 'continue', reason: 'empty response (nothing to evaluate)', parseFailed: false };
24
+ }
25
+ if (!client) {
26
+ return { verdict: 'continue', reason: 'no judge client available', parseFailed: false };
27
+ }
28
+ const prompt = buildJudgeUserPrompt(params);
29
+ const timeoutMs = params.timeoutMs ?? DEFAULT_JUDGE_TIMEOUT_MS;
30
+ let raw = '';
31
+ try {
32
+ const response = await withTimeout(client.chat([
33
+ { role: 'system', content: JUDGE_SYSTEM_PROMPT },
34
+ { role: 'user', content: prompt },
35
+ ], [], { ...(params.model ? { model: params.model } : {}), temperature: 0 }), timeoutMs);
36
+ raw = response?.choices?.[0]?.message?.content ?? '';
37
+ recordJudgeCost(client, params.model, response?.usage);
38
+ }
39
+ catch (error) {
40
+ const name = error instanceof Error ? error.name : 'Error';
41
+ logger.info('goal judge: API call failed — falling through to continue', {
42
+ error: String(error),
43
+ });
44
+ return { verdict: 'continue', reason: `judge error: ${name}`, parseFailed: false };
45
+ }
46
+ const result = parseJudgeResponse(raw);
47
+ logger.info('goal judge: verdict', {
48
+ verdict: result.verdict,
49
+ reason: truncateText(result.reason, 120),
50
+ });
51
+ return result;
52
+ }
53
+ export function buildJudgeUserPrompt(params) {
54
+ const cleanSubgoals = (params.subgoals ?? []).map(s => s.trim()).filter(Boolean);
55
+ const currentTime = new Date().toString();
56
+ const goal = truncateText(params.goal, JUDGE_GOAL_SNIPPET_CHARS);
57
+ const response = truncateText(params.lastResponse, JUDGE_RESPONSE_SNIPPET_CHARS);
58
+ if (cleanSubgoals.length) {
59
+ return JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE.replace('{goal}', goal)
60
+ .replace('{subgoals_block}', truncateText(renderSubgoalsBlock(cleanSubgoals), JUDGE_SUBGOALS_SNIPPET_CHARS))
61
+ .replace('{response}', response)
62
+ .replace('{current_time}', currentTime);
63
+ }
64
+ return JUDGE_USER_PROMPT_TEMPLATE.replace('{goal}', goal)
65
+ .replace('{response}', response)
66
+ .replace('{current_time}', currentTime);
67
+ }
68
+ /**
69
+ * Parse the judge's reply. Fail-open: anything unusable reads as "continue"
70
+ * with `parseFailed: true` so callers can auto-pause after N strikes.
71
+ */
72
+ export function parseJudgeResponse(raw) {
73
+ if (!raw || !raw.trim()) {
74
+ return { verdict: 'continue', reason: 'judge returned empty response', parseFailed: true };
75
+ }
76
+ let data;
77
+ try {
78
+ data = parseJsonResponse(raw);
79
+ }
80
+ catch {
81
+ return {
82
+ verdict: 'continue',
83
+ reason: `judge reply was not JSON: ${truncateText(raw, 200)}`,
84
+ parseFailed: true,
85
+ };
86
+ }
87
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
88
+ return {
89
+ verdict: 'continue',
90
+ reason: `judge reply was not JSON: ${truncateText(raw, 200)}`,
91
+ parseFailed: true,
92
+ };
93
+ }
94
+ const record = data;
95
+ const doneVal = record.done;
96
+ const done = typeof doneVal === 'string'
97
+ ? ['true', 'yes', '1', 'done'].includes(doneVal.trim().toLowerCase())
98
+ : Boolean(doneVal);
99
+ const reason = String(record.reason ?? '').trim() || 'no reason provided';
100
+ return { verdict: done ? 'done' : 'continue', reason, parseFailed: false };
101
+ }
102
+ /** Judge calls consume real tokens — record them in the session cost ledger. */
103
+ function recordJudgeCost(client, modelOverride, usage) {
104
+ if (!usage)
105
+ return;
106
+ try {
107
+ const model = modelOverride || client.getCurrentModel?.() || 'unknown';
108
+ getCostTracker().recordUsage(usage.prompt_tokens ?? 0, usage.completion_tokens ?? 0, model);
109
+ }
110
+ catch (error) {
111
+ logger.debug('goal judge: cost recording failed', { error: String(error) });
112
+ }
113
+ }
114
+ async function withTimeout(promise, ms) {
115
+ let timer;
116
+ try {
117
+ return await Promise.race([
118
+ promise,
119
+ new Promise((_, reject) => {
120
+ timer = setTimeout(() => reject(new Error(`goal judge timed out after ${ms}ms`)), ms);
121
+ }),
122
+ ]);
123
+ }
124
+ finally {
125
+ if (timer)
126
+ clearTimeout(timer);
127
+ }
128
+ }
129
+ //# sourceMappingURL=goal-judge.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * After-turn goal driver — UI-agnostic port of Hermes'
3
+ * `_maybe_continue_goal_after_turn`.
4
+ *
5
+ * Called by the interactive turn loop after each completed turn. Decides
6
+ * whether to surface a status message and/or feed a continuation prompt
7
+ * back into the session. Safe to call when no goal is set — returns fast.
8
+ */
9
+ import { CodeBuddyClient } from '../codebuddy/client.js';
10
+ export interface GoalTurnOutcome {
11
+ /** User-visible status line (✓ / ⏸ / ↻) to append to chat history. */
12
+ message?: string;
13
+ /** When set, the caller should auto-submit this as the next user message. */
14
+ continuationPrompt?: string;
15
+ }
16
+ export interface GoalAfterTurnOptions {
17
+ client: CodeBuddyClient | null;
18
+ /** The assistant's full response text for the turn that just finished. */
19
+ lastResponse: string;
20
+ /** True when the turn was user-interrupted (Esc). */
21
+ interrupted: boolean;
22
+ }
23
+ export declare function maybeContinueGoalAfterTurn(options: GoalAfterTurnOptions): Promise<GoalTurnOutcome | null>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * After-turn goal driver — UI-agnostic port of Hermes'
3
+ * `_maybe_continue_goal_after_turn`.
4
+ *
5
+ * Called by the interactive turn loop after each completed turn. Decides
6
+ * whether to surface a status message and/or feed a continuation prompt
7
+ * back into the session. Safe to call when no goal is set — returns fast.
8
+ */
9
+ import { logger } from '../utils/logger.js';
10
+ import { judgeGoal } from './goal-judge.js';
11
+ import { getGoalManager, resolveGoalsConfig } from './goal-manager.js';
12
+ // The executor appends a per-turn usage footer ("[tokens: … | cost: …]") as a
13
+ // final content chunk; strip it so the judge sees only substantive output.
14
+ const USAGE_FOOTER_RE = /\n?\[tokens: [^\]]*\]\s*$/;
15
+ export async function maybeContinueGoalAfterTurn(options) {
16
+ const manager = getGoalManager();
17
+ if (!manager.isActive())
18
+ return null;
19
+ // If the turn was user-interrupted, auto-pause instead of judging: the
20
+ // judge would almost always say "continue" on the partial output and
21
+ // immediately re-queue another turn — exactly what the user cancelled.
22
+ if (options.interrupted) {
23
+ try {
24
+ manager.pause('user-interrupted (Esc)');
25
+ }
26
+ catch (error) {
27
+ logger.debug('goal pause-on-interrupt failed', { error: String(error) });
28
+ }
29
+ return {
30
+ message: '⏸ Goal paused — turn was interrupted. Use /goal resume to continue, or /goal clear to stop.',
31
+ };
32
+ }
33
+ const lastResponse = options.lastResponse.replace(USAGE_FOOTER_RE, '').trim();
34
+ // No substantive reply (transient API failure, empty stream): skip judging
35
+ // so we don't burn budget or trip the parse-failure counter.
36
+ if (!lastResponse)
37
+ return null;
38
+ const config = resolveGoalsConfig();
39
+ const decision = await manager.evaluateAfterTurn(lastResponse, {
40
+ judge: params => judgeGoal(options.client, {
41
+ ...params,
42
+ ...(config.judgeModel ? { model: config.judgeModel } : {}),
43
+ timeoutMs: config.judgeTimeoutMs,
44
+ }),
45
+ });
46
+ if (decision.verdict === 'inactive')
47
+ return null;
48
+ const outcome = {};
49
+ if (decision.message)
50
+ outcome.message = decision.message;
51
+ if (decision.shouldContinue && decision.continuationPrompt) {
52
+ outcome.continuationPrompt = decision.continuationPrompt;
53
+ }
54
+ return outcome;
55
+ }
56
+ //# sourceMappingURL=goal-loop.js.map
@@ -0,0 +1,71 @@
1
+ /**
2
+ * GoalManager — per-session goal state + continuation decisions.
3
+ *
4
+ * The interactive UI holds one GoalManager per session key (lazily resolved
5
+ * on every operation, so `--resume`/`--continue` reattach automatically and
6
+ * sessionless runs stay durable via a cwd-derived key).
7
+ *
8
+ * Network-free by design: the judge is injected into `evaluateAfterTurn` so
9
+ * the manager (and its tests) never touch a provider.
10
+ */
11
+ import { GoalJudgeFn } from './goal-judge.js';
12
+ import { GoalState, GoalStatus, GoalVerdict } from './goal-state.js';
13
+ import { GoalStore } from './goal-store.js';
14
+ export interface GoalTurnDecision {
15
+ status: GoalStatus | null;
16
+ shouldContinue: boolean;
17
+ continuationPrompt: string | null;
18
+ verdict: GoalVerdict | 'inactive';
19
+ reason: string;
20
+ /** User-visible one-liner (✓ / ⏸ / ↻). Empty when nothing to show. */
21
+ message: string;
22
+ }
23
+ export interface GoalsConfig {
24
+ maxTurns: number;
25
+ judgeModel: string;
26
+ judgeMaxTokens: number;
27
+ judgeTimeoutMs: number;
28
+ }
29
+ export declare class GoalManager {
30
+ readonly sessionKey: string;
31
+ private store;
32
+ private defaultMaxTurns;
33
+ private _state;
34
+ constructor(sessionKey: string, store: GoalStore, defaultMaxTurns?: number);
35
+ get state(): GoalState | null;
36
+ isActive(): boolean;
37
+ hasGoal(): boolean;
38
+ statusLine(): string;
39
+ set(goal: string, options?: {
40
+ maxTurns?: number;
41
+ }): GoalState;
42
+ pause(reason?: string): GoalState | null;
43
+ resume(options?: {
44
+ resetBudget?: boolean;
45
+ }): GoalState | null;
46
+ clear(): void;
47
+ markDone(reason: string): void;
48
+ addSubgoal(text: string): string;
49
+ removeSubgoal(index1Based: number): string;
50
+ clearSubgoals(): number;
51
+ renderSubgoals(): string;
52
+ /**
53
+ * Run the judge and update state. Both real user prompts and continuation
54
+ * prompts we fed ourselves increment `turnsUsed` — both consume budget.
55
+ */
56
+ evaluateAfterTurn(lastResponse: string, deps: {
57
+ judge: GoalJudgeFn;
58
+ }): Promise<GoalTurnDecision>;
59
+ nextContinuationPrompt(): string | null;
60
+ }
61
+ export declare function resolveGoalsConfig(): GoalsConfig;
62
+ /**
63
+ * Resolve the key goals are persisted under. Prefers the live session id
64
+ * (so `--resume`/`--continue` reattach, Hermes `goal:<session_id>`
65
+ * semantics); falls back to a cwd-derived key so sessionless interactive
66
+ * runs are still durable.
67
+ */
68
+ export declare function resolveGoalSessionKey(): string;
69
+ export declare function getGoalManager(sessionKey?: string): GoalManager;
70
+ /** Test seam: clear cached managers and optionally redirect persistence. */
71
+ export declare function resetGoalManagers(store?: GoalStore | null): void;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * GoalManager — per-session goal state + continuation decisions.
3
+ *
4
+ * The interactive UI holds one GoalManager per session key (lazily resolved
5
+ * on every operation, so `--resume`/`--continue` reattach automatically and
6
+ * sessionless runs stay durable via a cwd-derived key).
7
+ *
8
+ * Network-free by design: the judge is injected into `evaluateAfterTurn` so
9
+ * the manager (and its tests) never touch a provider.
10
+ */
11
+ import crypto from 'crypto';
12
+ import path from 'path';
13
+ import { getSettingsHierarchy } from '../config/settings-hierarchy.js';
14
+ import { getSessionStore } from '../persistence/session-store.js';
15
+ import { logger } from '../utils/logger.js';
16
+ import { DEFAULT_JUDGE_MAX_TOKENS, DEFAULT_JUDGE_TIMEOUT_MS, DEFAULT_MAX_TURNS, applyJudgeOutcome, buildContinuationPrompt, createGoalState, formatGoalStatusLine, renderSubgoalsBlock, } from './goal-state.js';
17
+ import { GoalStore } from './goal-store.js';
18
+ export class GoalManager {
19
+ sessionKey;
20
+ store;
21
+ defaultMaxTurns;
22
+ _state;
23
+ constructor(sessionKey, store, defaultMaxTurns = DEFAULT_MAX_TURNS) {
24
+ this.sessionKey = sessionKey;
25
+ this.store = store;
26
+ this.defaultMaxTurns = defaultMaxTurns;
27
+ const loaded = this.store.load(sessionKey);
28
+ // A cleared tombstone reads as "no goal" (kept on disk for audit).
29
+ this._state = loaded && loaded.status !== 'cleared' ? loaded : null;
30
+ }
31
+ // --- introspection ------------------------------------------------
32
+ get state() {
33
+ return this._state;
34
+ }
35
+ isActive() {
36
+ return this._state?.status === 'active';
37
+ }
38
+ hasGoal() {
39
+ return this._state !== null && ['active', 'paused'].includes(this._state.status);
40
+ }
41
+ statusLine() {
42
+ return formatGoalStatusLine(this._state);
43
+ }
44
+ // --- mutation -----------------------------------------------------
45
+ set(goal, options = {}) {
46
+ const text = (goal || '').trim();
47
+ if (!text) {
48
+ throw new Error('goal text is empty');
49
+ }
50
+ const state = createGoalState(text, options.maxTurns || this.defaultMaxTurns);
51
+ this._state = state;
52
+ this.store.save(this.sessionKey, state);
53
+ return state;
54
+ }
55
+ pause(reason = 'user-paused') {
56
+ if (!this._state)
57
+ return null;
58
+ this._state.status = 'paused';
59
+ this._state.pausedReason = reason;
60
+ this.store.save(this.sessionKey, this._state);
61
+ return this._state;
62
+ }
63
+ resume(options = {}) {
64
+ if (!this._state)
65
+ return null;
66
+ this._state.status = 'active';
67
+ delete this._state.pausedReason;
68
+ if (options.resetBudget ?? true) {
69
+ this._state.turnsUsed = 0;
70
+ }
71
+ this.store.save(this.sessionKey, this._state);
72
+ return this._state;
73
+ }
74
+ clear() {
75
+ if (!this._state)
76
+ return;
77
+ this._state.status = 'cleared';
78
+ this.store.save(this.sessionKey, this._state);
79
+ this._state = null;
80
+ }
81
+ markDone(reason) {
82
+ if (!this._state)
83
+ return;
84
+ this._state.status = 'done';
85
+ this._state.lastVerdict = 'done';
86
+ this._state.lastReason = reason;
87
+ this.store.save(this.sessionKey, this._state);
88
+ }
89
+ // --- /subgoal user controls ---------------------------------------
90
+ addSubgoal(text) {
91
+ if (!this.hasGoal() || !this._state) {
92
+ throw new Error('no active goal');
93
+ }
94
+ const clean = (text || '').trim();
95
+ if (!clean) {
96
+ throw new Error('subgoal text is empty');
97
+ }
98
+ this._state.subgoals.push(clean);
99
+ this.store.save(this.sessionKey, this._state);
100
+ return clean;
101
+ }
102
+ removeSubgoal(index1Based) {
103
+ if (!this.hasGoal() || !this._state) {
104
+ throw new Error('no active goal');
105
+ }
106
+ const idx = Math.trunc(index1Based) - 1;
107
+ if (idx < 0 || idx >= this._state.subgoals.length) {
108
+ throw new Error(`index out of range (1..${this._state.subgoals.length})`);
109
+ }
110
+ const [removed] = this._state.subgoals.splice(idx, 1);
111
+ this.store.save(this.sessionKey, this._state);
112
+ return removed ?? '';
113
+ }
114
+ clearSubgoals() {
115
+ if (!this.hasGoal() || !this._state) {
116
+ throw new Error('no active goal');
117
+ }
118
+ const prev = this._state.subgoals.length;
119
+ this._state.subgoals = [];
120
+ this.store.save(this.sessionKey, this._state);
121
+ return prev;
122
+ }
123
+ renderSubgoals() {
124
+ if (!this._state)
125
+ return '(no active goal)';
126
+ if (!this._state.subgoals.length) {
127
+ return '(no subgoals — use /subgoal <text> to add criteria)';
128
+ }
129
+ return renderSubgoalsBlock(this._state.subgoals);
130
+ }
131
+ // --- the main entry point called after every turn -----------------
132
+ /**
133
+ * Run the judge and update state. Both real user prompts and continuation
134
+ * prompts we fed ourselves increment `turnsUsed` — both consume budget.
135
+ */
136
+ async evaluateAfterTurn(lastResponse, deps) {
137
+ const state = this._state;
138
+ if (!state || state.status !== 'active') {
139
+ return {
140
+ status: state?.status ?? null,
141
+ shouldContinue: false,
142
+ continuationPrompt: null,
143
+ verdict: 'inactive',
144
+ reason: 'no active goal',
145
+ message: '',
146
+ };
147
+ }
148
+ const outcome = await deps.judge({
149
+ goal: state.goal,
150
+ lastResponse,
151
+ ...(state.subgoals.length ? { subgoals: state.subgoals } : {}),
152
+ });
153
+ const decision = applyJudgeOutcome(state, outcome);
154
+ this.store.save(this.sessionKey, state);
155
+ return decision;
156
+ }
157
+ nextContinuationPrompt() {
158
+ if (!this._state || this._state.status !== 'active')
159
+ return null;
160
+ return buildContinuationPrompt(this._state);
161
+ }
162
+ }
163
+ // ============================================================================
164
+ // Config
165
+ // ============================================================================
166
+ export function resolveGoalsConfig() {
167
+ let raw = {};
168
+ try {
169
+ const settings = getSettingsHierarchy().getAllSettings();
170
+ if (settings && typeof settings.goals === 'object' && settings.goals !== null) {
171
+ raw = settings.goals;
172
+ }
173
+ }
174
+ catch (error) {
175
+ logger.debug('goals: settings hierarchy unavailable, using defaults', { error: String(error) });
176
+ }
177
+ const envMaxTurns = Number(process.env.CODEBUDDY_GOAL_MAX_TURNS);
178
+ const maxTurns = Number.isFinite(envMaxTurns) && envMaxTurns > 0
179
+ ? Math.trunc(envMaxTurns)
180
+ : positiveInt(raw.maxTurns, DEFAULT_MAX_TURNS);
181
+ const judgeModel = process.env.CODEBUDDY_GOAL_JUDGE_MODEL || String(raw.judgeModel ?? '').trim();
182
+ return {
183
+ maxTurns,
184
+ judgeModel,
185
+ judgeMaxTokens: positiveInt(raw.judgeMaxTokens, DEFAULT_JUDGE_MAX_TOKENS),
186
+ judgeTimeoutMs: positiveInt(raw.judgeTimeoutMs, DEFAULT_JUDGE_TIMEOUT_MS),
187
+ };
188
+ }
189
+ function positiveInt(value, fallback) {
190
+ const n = Number(value);
191
+ return Number.isFinite(n) && n > 0 ? Math.trunc(n) : fallback;
192
+ }
193
+ // ============================================================================
194
+ // Singleton registry (one manager per session key)
195
+ // ============================================================================
196
+ const registry = new Map();
197
+ let storeOverride = null;
198
+ /**
199
+ * Resolve the key goals are persisted under. Prefers the live session id
200
+ * (so `--resume`/`--continue` reattach, Hermes `goal:<session_id>`
201
+ * semantics); falls back to a cwd-derived key so sessionless interactive
202
+ * runs are still durable.
203
+ */
204
+ export function resolveGoalSessionKey() {
205
+ try {
206
+ const sessionId = getSessionStore().getCurrentSessionId();
207
+ if (sessionId)
208
+ return sessionId;
209
+ }
210
+ catch (error) {
211
+ logger.debug('goals: session store unavailable, using cwd key', { error: String(error) });
212
+ }
213
+ const cwdHash = crypto.createHash('sha256').update(path.resolve(process.cwd())).digest('hex');
214
+ return `dir-${cwdHash.slice(0, 12)}`;
215
+ }
216
+ export function getGoalManager(sessionKey) {
217
+ const key = sessionKey ?? resolveGoalSessionKey();
218
+ let manager = registry.get(key);
219
+ if (!manager) {
220
+ const store = storeOverride ?? new GoalStore();
221
+ manager = new GoalManager(key, store, resolveGoalsConfig().maxTurns);
222
+ registry.set(key, manager);
223
+ if (registry.size > 20) {
224
+ const firstKey = registry.keys().next().value;
225
+ if (firstKey)
226
+ registry.delete(firstKey);
227
+ }
228
+ }
229
+ return manager;
230
+ }
231
+ /** Test seam: clear cached managers and optionally redirect persistence. */
232
+ export function resetGoalManagers(store = null) {
233
+ registry.clear();
234
+ storeOverride = store;
235
+ }
236
+ //# sourceMappingURL=goal-manager.js.map
@@ -0,0 +1,86 @@
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
+ export type GoalStatus = 'active' | 'paused' | 'done' | 'cleared';
20
+ export type GoalVerdict = 'done' | 'continue' | 'skipped';
21
+ export interface GoalState {
22
+ goal: string;
23
+ status: GoalStatus;
24
+ turnsUsed: number;
25
+ maxTurns: number;
26
+ createdAt: number;
27
+ lastTurnAt: number;
28
+ lastVerdict?: GoalVerdict;
29
+ lastReason?: string;
30
+ /** Why we auto-paused (budget exhausted, judge parse failures, interrupt). */
31
+ pausedReason?: string;
32
+ /** Judge-output parse failures in a row. API/transport errors don't count. */
33
+ consecutiveParseFailures: number;
34
+ /**
35
+ * User-added criteria appended mid-loop via /subgoal. When non-empty both
36
+ * the judge prompt and the continuation prompt include them. Defaults to
37
+ * empty so old persisted state loads unchanged.
38
+ */
39
+ subgoals: string[];
40
+ }
41
+ export declare const DEFAULT_MAX_TURNS = 20;
42
+ export declare const DEFAULT_JUDGE_TIMEOUT_MS = 30000;
43
+ export declare const DEFAULT_JUDGE_MAX_TOKENS = 4096;
44
+ export declare const JUDGE_GOAL_SNIPPET_CHARS = 2000;
45
+ export declare const JUDGE_SUBGOALS_SNIPPET_CHARS = 2000;
46
+ export declare const JUDGE_RESPONSE_SNIPPET_CHARS = 4000;
47
+ export declare const MAX_CONSECUTIVE_PARSE_FAILURES = 3;
48
+ export declare const CONTINUATION_PROMPT_TEMPLATE: string;
49
+ export declare const CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE: string;
50
+ export declare const JUDGE_SYSTEM_PROMPT: string;
51
+ export declare const JUDGE_USER_PROMPT_TEMPLATE: string;
52
+ export declare const JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE: string;
53
+ export declare function truncateText(text: string, limit: number): string;
54
+ /** Render subgoals as a numbered `- N. text` block. Empty string when none. */
55
+ export declare function renderSubgoalsBlock(subgoals: string[]): string;
56
+ export declare function createGoalState(goal: string, maxTurns?: number): GoalState;
57
+ /**
58
+ * Defensive deserialization of a persisted goal state. Returns null when the
59
+ * payload isn't a usable goal. Old payloads without `subgoals` load unchanged.
60
+ */
61
+ export declare function normalizeGoalState(raw: unknown): GoalState | null;
62
+ /** Printable one-liner for /goal status. */
63
+ export declare function formatGoalStatusLine(state: GoalState | null): string;
64
+ /** The canonical user-role continuation message for an active goal. */
65
+ export declare function buildContinuationPrompt(state: GoalState): string;
66
+ export interface GoalJudgeOutcome {
67
+ verdict: GoalVerdict;
68
+ reason: string;
69
+ parseFailed: boolean;
70
+ }
71
+ export interface GoalTurnDecisionCore {
72
+ status: GoalStatus;
73
+ shouldContinue: boolean;
74
+ continuationPrompt: string | null;
75
+ verdict: GoalVerdict;
76
+ reason: string;
77
+ /** User-visible one-liner (✓ / ⏸ / ↻). */
78
+ message: string;
79
+ }
80
+ /**
81
+ * Apply a judge outcome to an active goal — the exact Hermes ladder.
82
+ * MUTATES `state` (turn counter, verdict bookkeeping, status transitions)
83
+ * and returns the decision; the caller persists the state wherever it
84
+ * lives (GoalStore file, peer-session record, …).
85
+ */
86
+ export declare function applyJudgeOutcome(state: GoalState, outcome: GoalJudgeOutcome, nowMs?: number): GoalTurnDecisionCore;