@kinqs/brainrouter-cli 0.3.4
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/.env.example +109 -0
- package/README.md +185 -0
- package/dist/agent/agent.d.ts +765 -0
- package/dist/agent/agent.js +1977 -0
- package/dist/cli/cliPrompt.d.ts +15 -0
- package/dist/cli/cliPrompt.js +62 -0
- package/dist/cli/commands/_context.d.ts +53 -0
- package/dist/cli/commands/_context.js +14 -0
- package/dist/cli/commands/_helpers.d.ts +45 -0
- package/dist/cli/commands/_helpers.js +140 -0
- package/dist/cli/commands/guard.d.ts +6 -0
- package/dist/cli/commands/guard.js +292 -0
- package/dist/cli/commands/memory.d.ts +12 -0
- package/dist/cli/commands/memory.js +263 -0
- package/dist/cli/commands/obs.d.ts +6 -0
- package/dist/cli/commands/obs.js +208 -0
- package/dist/cli/commands/orchestration.d.ts +6 -0
- package/dist/cli/commands/orchestration.js +218 -0
- package/dist/cli/commands/session.d.ts +6 -0
- package/dist/cli/commands/session.js +191 -0
- package/dist/cli/commands/ui.d.ts +6 -0
- package/dist/cli/commands/ui.js +477 -0
- package/dist/cli/commands/workflow.d.ts +6 -0
- package/dist/cli/commands/workflow.js +691 -0
- package/dist/cli/repl.d.ts +12 -0
- package/dist/cli/repl.js +894 -0
- package/dist/config/config.d.ts +22 -0
- package/dist/config/config.js +105 -0
- package/dist/config/workspace.d.ts +7 -0
- package/dist/config/workspace.js +62 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +610 -0
- package/dist/memory/briefing.d.ts +46 -0
- package/dist/memory/briefing.js +152 -0
- package/dist/memory/consolidation.d.ts +60 -0
- package/dist/memory/consolidation.js +208 -0
- package/dist/memory/formatters.d.ts +38 -0
- package/dist/memory/formatters.js +102 -0
- package/dist/memory/mentions.d.ts +10 -0
- package/dist/memory/mentions.js +72 -0
- package/dist/orchestration/orchestrator.d.ts +36 -0
- package/dist/orchestration/orchestrator.js +71 -0
- package/dist/orchestration/roles.d.ts +11 -0
- package/dist/orchestration/roles.js +117 -0
- package/dist/orchestration/tools.d.ts +244 -0
- package/dist/orchestration/tools.js +528 -0
- package/dist/prompt/breadthHint.d.ts +48 -0
- package/dist/prompt/breadthHint.js +93 -0
- package/dist/prompt/compactor.d.ts +31 -0
- package/dist/prompt/compactor.js +112 -0
- package/dist/prompt/initAgentMd.d.ts +13 -0
- package/dist/prompt/initAgentMd.js +194 -0
- package/dist/prompt/skillRunner.d.ts +34 -0
- package/dist/prompt/skillRunner.js +146 -0
- package/dist/prompt/systemPrompt.d.ts +10 -0
- package/dist/prompt/systemPrompt.js +171 -0
- package/dist/runtime/clipboard.d.ts +17 -0
- package/dist/runtime/clipboard.js +52 -0
- package/dist/runtime/llmSemaphore.d.ts +30 -0
- package/dist/runtime/llmSemaphore.js +67 -0
- package/dist/runtime/loopRunner.d.ts +25 -0
- package/dist/runtime/loopRunner.js +79 -0
- package/dist/runtime/mcpClient.d.ts +156 -0
- package/dist/runtime/mcpClient.js +234 -0
- package/dist/runtime/mcpUtils.d.ts +36 -0
- package/dist/runtime/mcpUtils.js +64 -0
- package/dist/runtime/sandbox.d.ts +48 -0
- package/dist/runtime/sandbox.js +156 -0
- package/dist/runtime/tracing.d.ts +25 -0
- package/dist/runtime/tracing.js +91 -0
- package/dist/state/cliState.d.ts +59 -0
- package/dist/state/cliState.js +311 -0
- package/dist/state/goalStore.d.ts +174 -0
- package/dist/state/goalStore.js +410 -0
- package/dist/state/hookifyStore.d.ts +80 -0
- package/dist/state/hookifyStore.js +237 -0
- package/dist/state/hooksStore.d.ts +42 -0
- package/dist/state/hooksStore.js +71 -0
- package/dist/state/preferencesStore.d.ts +41 -0
- package/dist/state/preferencesStore.js +25 -0
- package/dist/state/sessionStore.d.ts +42 -0
- package/dist/state/sessionStore.js +193 -0
- package/dist/state/taskStore.d.ts +23 -0
- package/dist/state/taskStore.js +80 -0
- package/dist/state/workflowArtifacts.d.ts +33 -0
- package/dist/state/workflowArtifacts.js +139 -0
- package/package.json +71 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { getCliStateFile, getSessionStateFile, readJsonFile, writeJsonFile } from './cliState.js';
|
|
3
|
+
/** A pausing status is one where continuation is halted but resumable. */
|
|
4
|
+
export const PAUSING_STATUSES = ['paused', 'blocked', 'usage_limited'];
|
|
5
|
+
export const DEFAULT_GOAL_BUDGET = 10;
|
|
6
|
+
/**
|
|
7
|
+
* Hard cap on the goal text length. A goal is supposed to be a 1–3 sentence
|
|
8
|
+
* outcome statement; multi-thousand-character pastes (e.g. full chat logs)
|
|
9
|
+
* derail every subsequent turn because the goal block is re-injected into
|
|
10
|
+
* the system prompt on EVERY iteration.
|
|
11
|
+
*/
|
|
12
|
+
export const GOAL_TEXT_MAX_CHARS = 4000;
|
|
13
|
+
export class GoalTooLongError extends Error {
|
|
14
|
+
length;
|
|
15
|
+
constructor(length) {
|
|
16
|
+
super(`Goal condition is limited to ${GOAL_TEXT_MAX_CHARS} characters (got ${length}). ` +
|
|
17
|
+
`Trim it to a 1–3 sentence outcome statement.`);
|
|
18
|
+
this.length = length;
|
|
19
|
+
this.name = 'GoalTooLongError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Thrown when `setGoal` would overwrite a non-complete existing goal and
|
|
24
|
+
* the caller didn't pass `force: true`. The REPL catches this and prompts
|
|
25
|
+
* the user before replacing — interrupting in-flight work without
|
|
26
|
+
* confirmation is one of the easiest ways to lose progress.
|
|
27
|
+
*
|
|
28
|
+
* A `complete` goal does NOT raise this — replacing a finished goal is
|
|
29
|
+
* just starting fresh, no work is at risk.
|
|
30
|
+
*/
|
|
31
|
+
export class GoalConflictError extends Error {
|
|
32
|
+
existing;
|
|
33
|
+
constructor(existing) {
|
|
34
|
+
// Use status-aware wording. The previous "already active" phrasing was
|
|
35
|
+
// misleading when the existing goal was paused, blocked, or
|
|
36
|
+
// usage_limited — the REPL surfaces this message verbatim and users
|
|
37
|
+
// would see "already active" for a goal they explicitly paused. Now
|
|
38
|
+
// the message reflects the actual current state.
|
|
39
|
+
const statusLabel = existing.status.replace('_', ' ');
|
|
40
|
+
const inProgressClause = existing.status === 'active'
|
|
41
|
+
? 'is in progress'
|
|
42
|
+
: `exists with status: ${statusLabel}`;
|
|
43
|
+
super(`A goal already ${inProgressClause}. ` +
|
|
44
|
+
`Pass force=true to replace it (REPL will prompt for confirmation first).`);
|
|
45
|
+
this.existing = existing;
|
|
46
|
+
this.name = 'GoalConflictError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function normalize(raw) {
|
|
50
|
+
if (!raw || !raw.text || raw.text === '')
|
|
51
|
+
return null;
|
|
52
|
+
const setAt = raw.setAt ?? new Date().toISOString();
|
|
53
|
+
const budget = raw.budget ?? { maxIterations: DEFAULT_GOAL_BUDGET, iterationsUsed: 0 };
|
|
54
|
+
// Backfill tokensUsed for older goals so consumers can rely on the field
|
|
55
|
+
// being a number when maxTokens is set later.
|
|
56
|
+
if (budget.maxTokens && typeof budget.tokensUsed !== 'number') {
|
|
57
|
+
budget.tokensUsed = 0;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
text: raw.text,
|
|
61
|
+
setAt,
|
|
62
|
+
status: raw.status ?? 'active',
|
|
63
|
+
budget,
|
|
64
|
+
startedAt: raw.startedAt ?? setAt,
|
|
65
|
+
updatedAt: raw.updatedAt ?? setAt,
|
|
66
|
+
completedAt: raw.completedAt,
|
|
67
|
+
blockedReason: raw.blockedReason,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function resolveGoalFile(workspaceRoot, sessionKey) {
|
|
71
|
+
if (sessionKey) {
|
|
72
|
+
const sessionPath = getSessionStateFile(workspaceRoot, sessionKey, 'goal.json');
|
|
73
|
+
if (fs.existsSync(sessionPath))
|
|
74
|
+
return sessionPath;
|
|
75
|
+
}
|
|
76
|
+
return getCliStateFile(workspaceRoot, 'goal.json');
|
|
77
|
+
}
|
|
78
|
+
export function readGoal(workspaceRoot, sessionKey) {
|
|
79
|
+
if (sessionKey) {
|
|
80
|
+
const sessionPath = getSessionStateFile(workspaceRoot, sessionKey, 'goal.json');
|
|
81
|
+
if (fs.existsSync(sessionPath)) {
|
|
82
|
+
return normalize(readJsonFile(sessionPath, null));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const legacyPath = getCliStateFile(workspaceRoot, 'goal.json');
|
|
86
|
+
if (fs.existsSync(legacyPath)) {
|
|
87
|
+
return normalize(readJsonFile(legacyPath, null));
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Set a new active goal. Refuses to overwrite an in-progress goal (active,
|
|
93
|
+
* paused, blocked, or usage_limited) unless `force: true` is passed. The
|
|
94
|
+
* REPL catches the resulting GoalConflictError and prompts the user before
|
|
95
|
+
* replacing. Replacing a `complete` goal is allowed silently — at that
|
|
96
|
+
* point the prior goal isn't doing any work and a new one is just starting
|
|
97
|
+
* fresh.
|
|
98
|
+
*/
|
|
99
|
+
export function setGoal(workspaceRoot, text, sessionKey, options = {}) {
|
|
100
|
+
const trimmed = text.trim();
|
|
101
|
+
if (trimmed.length > GOAL_TEXT_MAX_CHARS) {
|
|
102
|
+
throw new GoalTooLongError(trimmed.length);
|
|
103
|
+
}
|
|
104
|
+
// Conflict detection: don't silently nuke an in-progress goal. The
|
|
105
|
+
// `complete` status is exempt — the prior work is done, replacing it is
|
|
106
|
+
// just starting fresh. The REPL layer handles the prompt and re-calls
|
|
107
|
+
// with `force: true` once the user confirms.
|
|
108
|
+
if (!options.force) {
|
|
109
|
+
const existing = readGoal(workspaceRoot, sessionKey);
|
|
110
|
+
if (existing && existing.status !== 'complete') {
|
|
111
|
+
throw new GoalConflictError(existing);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const now = new Date().toISOString();
|
|
115
|
+
const goal = {
|
|
116
|
+
text: trimmed,
|
|
117
|
+
setAt: now,
|
|
118
|
+
status: 'active',
|
|
119
|
+
budget: {
|
|
120
|
+
maxIterations: options.maxIterations ?? DEFAULT_GOAL_BUDGET,
|
|
121
|
+
iterationsUsed: 0,
|
|
122
|
+
...(options.maxTokens ? { maxTokens: options.maxTokens, tokensUsed: 0 } : {}),
|
|
123
|
+
},
|
|
124
|
+
startedAt: now,
|
|
125
|
+
updatedAt: now,
|
|
126
|
+
};
|
|
127
|
+
const filePath = sessionKey
|
|
128
|
+
? getSessionStateFile(workspaceRoot, sessionKey, 'goal.json')
|
|
129
|
+
: getCliStateFile(workspaceRoot, 'goal.json');
|
|
130
|
+
writeJsonFile(filePath, goal);
|
|
131
|
+
return goal;
|
|
132
|
+
}
|
|
133
|
+
export function clearGoal(workspaceRoot, sessionKey) {
|
|
134
|
+
if (sessionKey) {
|
|
135
|
+
writeJsonFile(getSessionStateFile(workspaceRoot, sessionKey, 'goal.json'), null);
|
|
136
|
+
}
|
|
137
|
+
const legacy = getCliStateFile(workspaceRoot, 'goal.json');
|
|
138
|
+
if (fs.existsSync(legacy)) {
|
|
139
|
+
writeJsonFile(legacy, null);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function patchGoal(workspaceRoot, sessionKey, patch) {
|
|
143
|
+
const current = readGoal(workspaceRoot, sessionKey);
|
|
144
|
+
if (!current)
|
|
145
|
+
return null;
|
|
146
|
+
const next = {
|
|
147
|
+
...current,
|
|
148
|
+
...patch,
|
|
149
|
+
updatedAt: new Date().toISOString(),
|
|
150
|
+
};
|
|
151
|
+
writeJsonFile(resolveGoalFile(workspaceRoot, sessionKey), next);
|
|
152
|
+
return next;
|
|
153
|
+
}
|
|
154
|
+
export function pauseGoal(workspaceRoot, sessionKey) {
|
|
155
|
+
return patchGoal(workspaceRoot, sessionKey, { status: 'paused' });
|
|
156
|
+
}
|
|
157
|
+
export function resumeGoal(workspaceRoot, sessionKey) {
|
|
158
|
+
return patchGoal(workspaceRoot, sessionKey, { status: 'active' });
|
|
159
|
+
}
|
|
160
|
+
export function completeGoal(workspaceRoot, sessionKey, proof) {
|
|
161
|
+
return patchGoal(workspaceRoot, sessionKey, {
|
|
162
|
+
status: 'complete',
|
|
163
|
+
completedAt: new Date().toISOString(),
|
|
164
|
+
blockedReason: proof,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
export function blockGoal(workspaceRoot, sessionKey, reason) {
|
|
168
|
+
return patchGoal(workspaceRoot, sessionKey, { status: 'blocked', blockedReason: reason });
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Mark the goal as `usage_limited` — distinct from paused (user-initiated)
|
|
172
|
+
* and blocked (agent gave up). Used when the iteration or token budget
|
|
173
|
+
* runs out. The user can resume after raising the budget; the loop won't
|
|
174
|
+
* fire another turn on its own until they do.
|
|
175
|
+
*/
|
|
176
|
+
export function usageLimitGoal(workspaceRoot, sessionKey, reason) {
|
|
177
|
+
return patchGoal(workspaceRoot, sessionKey, { status: 'usage_limited', blockedReason: reason });
|
|
178
|
+
}
|
|
179
|
+
export function setGoalBudget(workspaceRoot, sessionKey, maxIterations) {
|
|
180
|
+
const current = readGoal(workspaceRoot, sessionKey);
|
|
181
|
+
if (!current)
|
|
182
|
+
return null;
|
|
183
|
+
return patchGoal(workspaceRoot, sessionKey, {
|
|
184
|
+
budget: { ...current.budget, maxIterations: Math.max(1, maxIterations) },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Set or clear the optional token budget. Pass `0` (or any negative) to
|
|
189
|
+
* clear; positive integers set the cap. Resets tokensUsed to 0 when first
|
|
190
|
+
* enabling so the goal doesn't immediately appear exhausted.
|
|
191
|
+
*/
|
|
192
|
+
export function setGoalTokenBudget(workspaceRoot, sessionKey, maxTokens) {
|
|
193
|
+
const current = readGoal(workspaceRoot, sessionKey);
|
|
194
|
+
if (!current)
|
|
195
|
+
return null;
|
|
196
|
+
if (maxTokens <= 0) {
|
|
197
|
+
const { maxTokens: _drop, tokensUsed: _drop2, ...rest } = current.budget;
|
|
198
|
+
return patchGoal(workspaceRoot, sessionKey, { budget: rest });
|
|
199
|
+
}
|
|
200
|
+
return patchGoal(workspaceRoot, sessionKey, {
|
|
201
|
+
budget: { ...current.budget, maxTokens, tokensUsed: current.budget.tokensUsed ?? 0 },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
export function tickGoalIteration(workspaceRoot, sessionKey) {
|
|
205
|
+
const current = readGoal(workspaceRoot, sessionKey);
|
|
206
|
+
if (!current)
|
|
207
|
+
return null;
|
|
208
|
+
return patchGoal(workspaceRoot, sessionKey, {
|
|
209
|
+
budget: { ...current.budget, iterationsUsed: current.budget.iterationsUsed + 1 },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Add `delta` tokens to the goal's running tally. No-op if a goal has no
|
|
214
|
+
* token budget set. Returns the updated Goal so callers can decide whether
|
|
215
|
+
* to transition to `usage_limited` afterwards.
|
|
216
|
+
*/
|
|
217
|
+
export function addGoalTokens(workspaceRoot, sessionKey, delta) {
|
|
218
|
+
if (!Number.isFinite(delta) || delta <= 0)
|
|
219
|
+
return readGoal(workspaceRoot, sessionKey);
|
|
220
|
+
const current = readGoal(workspaceRoot, sessionKey);
|
|
221
|
+
if (!current || !current.budget.maxTokens)
|
|
222
|
+
return current;
|
|
223
|
+
return patchGoal(workspaceRoot, sessionKey, {
|
|
224
|
+
budget: {
|
|
225
|
+
...current.budget,
|
|
226
|
+
tokensUsed: (current.budget.tokensUsed ?? 0) + delta,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Unified update entrypoint. Lets callers mutate text/status/budget in a
|
|
232
|
+
* single call instead of stringing pause→budget→resume together. Used by
|
|
233
|
+
* the `/goal edit` REPL subcommand.
|
|
234
|
+
*/
|
|
235
|
+
export function editGoal(workspaceRoot, sessionKey, patch) {
|
|
236
|
+
const current = readGoal(workspaceRoot, sessionKey);
|
|
237
|
+
if (!current)
|
|
238
|
+
return null;
|
|
239
|
+
if (patch.text !== undefined) {
|
|
240
|
+
const trimmed = patch.text.trim();
|
|
241
|
+
if (trimmed.length > GOAL_TEXT_MAX_CHARS) {
|
|
242
|
+
throw new GoalTooLongError(trimmed.length);
|
|
243
|
+
}
|
|
244
|
+
if (!trimmed) {
|
|
245
|
+
throw new Error('Cannot set goal text to empty. Use /goal clear instead.');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const nextBudget = { ...current.budget };
|
|
249
|
+
if (patch.maxIterations !== undefined && patch.maxIterations > 0) {
|
|
250
|
+
nextBudget.maxIterations = Math.floor(patch.maxIterations);
|
|
251
|
+
}
|
|
252
|
+
if (patch.maxTokens !== undefined) {
|
|
253
|
+
if (patch.maxTokens <= 0) {
|
|
254
|
+
delete nextBudget.maxTokens;
|
|
255
|
+
delete nextBudget.tokensUsed;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
nextBudget.maxTokens = Math.floor(patch.maxTokens);
|
|
259
|
+
nextBudget.tokensUsed = nextBudget.tokensUsed ?? 0;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return patchGoal(workspaceRoot, sessionKey, {
|
|
263
|
+
text: patch.text !== undefined ? patch.text.trim() : current.text,
|
|
264
|
+
status: patch.status ?? current.status,
|
|
265
|
+
budget: nextBudget,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* True iff scheduling ONE MORE iteration would still fit inside BOTH the
|
|
270
|
+
* iteration cap and (if set) the token cap.
|
|
271
|
+
*
|
|
272
|
+
* The continuation loop ticks AFTER deciding to continue (so `iterationsUsed`
|
|
273
|
+
* lags by one until the tick runs). To stop after exactly `maxIterations`
|
|
274
|
+
* runs total, the predicate must ask "is (used+1) still within the cap?",
|
|
275
|
+
* not "is (used) still under the cap?". The old form gave you N+1 runs.
|
|
276
|
+
*
|
|
277
|
+
* Token budget is a hard "currently used vs cap" check — we can't know the
|
|
278
|
+
* next turn's token cost ahead of time, so we just refuse to schedule when
|
|
279
|
+
* we're already at or past the cap.
|
|
280
|
+
*/
|
|
281
|
+
export function goalHasBudgetLeft(goal) {
|
|
282
|
+
if (goal.budget.iterationsUsed + 1 >= goal.budget.maxIterations)
|
|
283
|
+
return false;
|
|
284
|
+
if (typeof goal.budget.maxTokens === 'number' && goal.budget.maxTokens > 0) {
|
|
285
|
+
if ((goal.budget.tokensUsed ?? 0) >= goal.budget.maxTokens)
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* True iff this is the FINAL turn within the budget — i.e. the iteration
|
|
292
|
+
* tick is about to land but one more after it would exceed the cap. The
|
|
293
|
+
* continuation loop uses this to inject a "wrap up gracefully" steering
|
|
294
|
+
* message so the model lands soft instead of being interrupted mid-thought.
|
|
295
|
+
*
|
|
296
|
+
* Specifically: after this turn's tick, iterationsUsed will equal
|
|
297
|
+
* maxIterations - 1, so `goalHasBudgetLeft` will return false on the next
|
|
298
|
+
* decision. We detect that ahead of time by checking before the tick.
|
|
299
|
+
*/
|
|
300
|
+
export function goalIsOnFinalBudgetTurn(goal) {
|
|
301
|
+
if (goal.budget.iterationsUsed + 2 >= goal.budget.maxIterations)
|
|
302
|
+
return true;
|
|
303
|
+
if (typeof goal.budget.maxTokens === 'number' && goal.budget.maxTokens > 0) {
|
|
304
|
+
const remaining = goal.budget.maxTokens - (goal.budget.tokensUsed ?? 0);
|
|
305
|
+
// Heuristic: if more than 80% of the token budget is consumed, treat
|
|
306
|
+
// this as the final turn so the model can wrap up. Avoids the edge
|
|
307
|
+
// case where one big turn would tip us over without warning.
|
|
308
|
+
if (remaining <= goal.budget.maxTokens * 0.2)
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Wrap-up steering message injected on the final-budget turn. The agent
|
|
315
|
+
* loop pushes this into the chat history as a system message so the model
|
|
316
|
+
* pivots from "continue investigating" to "consolidate and report." Plain
|
|
317
|
+
* directive, no role-play.
|
|
318
|
+
*
|
|
319
|
+
* The message specifically reports WHICH cap is tight (iterations, tokens,
|
|
320
|
+
* or both) so the model doesn't get told "one turn left" when it actually
|
|
321
|
+
* has many iterations remaining but is near the token cap, or vice versa.
|
|
322
|
+
* Earlier versions hardcoded the iteration framing even when only the
|
|
323
|
+
* token heuristic tripped, which misled the model on token-budgeted runs.
|
|
324
|
+
*/
|
|
325
|
+
export function buildBudgetSteeringMessage(goal) {
|
|
326
|
+
const iterationsRemaining = Math.max(0, goal.budget.maxIterations - goal.budget.iterationsUsed - 1);
|
|
327
|
+
const iterationTight = goal.budget.iterationsUsed + 2 >= goal.budget.maxIterations;
|
|
328
|
+
const tokensTight = typeof goal.budget.maxTokens === 'number' &&
|
|
329
|
+
goal.budget.maxTokens > 0 &&
|
|
330
|
+
(goal.budget.maxTokens - (goal.budget.tokensUsed ?? 0)) <= goal.budget.maxTokens * 0.2;
|
|
331
|
+
let headline;
|
|
332
|
+
if (iterationTight && tokensTight) {
|
|
333
|
+
const tokensRemaining = (goal.budget.maxTokens ?? 0) - (goal.budget.tokensUsed ?? 0);
|
|
334
|
+
headline =
|
|
335
|
+
`Both budgets are nearly exhausted: ${iterationsRemaining} iteration(s) remaining ` +
|
|
336
|
+
`(cap ${goal.budget.maxIterations}) and ~${tokensRemaining.toLocaleString()} tokens remaining ` +
|
|
337
|
+
`(cap ${(goal.budget.maxTokens ?? 0).toLocaleString()}). This is your last turn.`;
|
|
338
|
+
}
|
|
339
|
+
else if (iterationTight) {
|
|
340
|
+
const tokensClause = goal.budget.maxTokens
|
|
341
|
+
? ` (tokens still have headroom: ${((goal.budget.maxTokens ?? 0) - (goal.budget.tokensUsed ?? 0)).toLocaleString()} of ${(goal.budget.maxTokens ?? 0).toLocaleString()} remaining)`
|
|
342
|
+
: '';
|
|
343
|
+
headline =
|
|
344
|
+
`You have ${iterationsRemaining || 1} iteration(s) left within the goal's iteration budget ` +
|
|
345
|
+
`(cap ${goal.budget.maxIterations})${tokensClause}. This is your last turn.`;
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
// Token cap is the trigger; iterations may still have plenty of headroom.
|
|
349
|
+
const tokensUsed = goal.budget.tokensUsed ?? 0;
|
|
350
|
+
const tokensCap = goal.budget.maxTokens ?? 0;
|
|
351
|
+
const tokensRemaining = Math.max(0, tokensCap - tokensUsed);
|
|
352
|
+
headline =
|
|
353
|
+
`You're at ${tokensUsed.toLocaleString()}/${tokensCap.toLocaleString()} tokens of the goal's budget ` +
|
|
354
|
+
`(${Math.round((tokensUsed / Math.max(1, tokensCap)) * 100)}% used) with only ~${tokensRemaining.toLocaleString()} tokens remaining. ` +
|
|
355
|
+
`Iteration count still has headroom but the token cap will trip before another full turn fits.`;
|
|
356
|
+
}
|
|
357
|
+
return [
|
|
358
|
+
'## Budget about to run out',
|
|
359
|
+
headline,
|
|
360
|
+
'Do not start any new long-running investigation, spawn new children, or read more files.',
|
|
361
|
+
'Instead:',
|
|
362
|
+
'1. Synthesize what you already know into a concise wrap-up.',
|
|
363
|
+
'2. If you have enough evidence the goal is satisfied, call `goal_complete` with the proof.',
|
|
364
|
+
'3. If you do not, call `goal_blocked` with the specific unblocker the user needs to provide.',
|
|
365
|
+
'4. If you need more budget, say so explicitly so the user can extend it.',
|
|
366
|
+
].join('\n');
|
|
367
|
+
}
|
|
368
|
+
export function formatGoalBlock(goal) {
|
|
369
|
+
const remaining = Math.max(0, goal.budget.maxIterations - goal.budget.iterationsUsed);
|
|
370
|
+
const tokenLine = goal.budget.maxTokens
|
|
371
|
+
? `**Tokens:** ${(goal.budget.tokensUsed ?? 0).toLocaleString()} of ${goal.budget.maxTokens.toLocaleString()} used`
|
|
372
|
+
: '';
|
|
373
|
+
return [
|
|
374
|
+
`## Active Goal — ${goal.status.toUpperCase().replace('_', ' ')}`,
|
|
375
|
+
'',
|
|
376
|
+
`**Outcome:** ${goal.text}`,
|
|
377
|
+
`**Iteration:** ${goal.budget.iterationsUsed + 1} of ${goal.budget.maxIterations} (${remaining} remaining)`,
|
|
378
|
+
tokenLine,
|
|
379
|
+
`**Started:** ${goal.startedAt}`,
|
|
380
|
+
goal.blockedReason ? `**Reason:** ${goal.blockedReason}` : '',
|
|
381
|
+
'',
|
|
382
|
+
'This goal is a persistent contract. After each turn the CLI may auto-continue',
|
|
383
|
+
'you with another turn until the contract is satisfied. To complete the loop:',
|
|
384
|
+
'',
|
|
385
|
+
'- **When you call `goal_complete` / `goal_blocked`, the SAME assistant message',
|
|
386
|
+
' MUST contain the user-visible deliverable as prose** — the actual answer,',
|
|
387
|
+
' analysis, report, or summary the user asked for. The `proof` / `reason` fields',
|
|
388
|
+
' are short audit metadata, NOT the deliverable. Final-turn shape:',
|
|
389
|
+
' `<prose answer the user reads>` → `goal_complete({proof: "<short audit line>"})`.',
|
|
390
|
+
' If you skip the prose, the user sees only a placeholder and your work is invisible.',
|
|
391
|
+
'- **Plan honesty:** before `goal_complete`, every item in your active plan',
|
|
392
|
+
' (from `update_plan`) MUST be marked `completed`. The CLI hard-refuses',
|
|
393
|
+
' goal_complete while pending / in_progress items remain. If you finished',
|
|
394
|
+
' the work, call `update_plan` first to mark items done. If you decided to',
|
|
395
|
+
' drop items mid-flight, mark them `completed` with a brief rationale in the',
|
|
396
|
+
' step text — the plan is your audit record, leaving items pending while',
|
|
397
|
+
' declaring done is misleading.',
|
|
398
|
+
'- Call `goal_complete` with a 1–2 sentence evidence-based proof the outcome is met',
|
|
399
|
+
' (e.g. "tests/file_X.test.ts passes; `mobile/app.tsx` renders the route").',
|
|
400
|
+
'- Call `goal_blocked` with a reason and the user input needed if no path remains.',
|
|
401
|
+
'- Otherwise (mid-goal turns): take the next concrete tool action — read a file,',
|
|
402
|
+
' write code, spawn a worker child, run a verifier. **Prose-only intermediate',
|
|
403
|
+
' responses ("I will continue") count as a no-op and the CLI will NOT auto-continue',
|
|
404
|
+
' after them** (anti-spin). This anti-spin rule covers INTERMEDIATE turns only —',
|
|
405
|
+
' the final goal-completing turn MUST include prose alongside the tool call.',
|
|
406
|
+
'',
|
|
407
|
+
'Always audit the evidence before declaring complete — failing tests, missing files,',
|
|
408
|
+
'or unverified claims mean the goal is NOT done yet.',
|
|
409
|
+
].filter(Boolean).join('\n');
|
|
410
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hookify-style markdown hooks — no-code behavior guards expressed as YAML
|
|
3
|
+
* frontmatter on a `.md` file.
|
|
4
|
+
*
|
|
5
|
+
* Hooks live as `.md` files under
|
|
6
|
+
* ~/.brainrouter/workspaces/<encoded>/hooks/
|
|
7
|
+
* with YAML frontmatter describing the event, regex pattern(s), and action.
|
|
8
|
+
* This gives users a no-code path to install behavior guards without editing
|
|
9
|
+
* shell hooks. Pre-2026-05-21 builds stored these inside the workspace itself
|
|
10
|
+
* (`<workspace>/.brainrouter/hooks/`); those files are auto-migrated to the
|
|
11
|
+
* new home on first run by `cliState.getCliStateDir`.
|
|
12
|
+
*
|
|
13
|
+
* Example file `hooks/block-rm-rf.md`:
|
|
14
|
+
*
|
|
15
|
+
* ---
|
|
16
|
+
* name: block-rm-rf
|
|
17
|
+
* enabled: true
|
|
18
|
+
* event: bash
|
|
19
|
+
* pattern: rm\s+-rf
|
|
20
|
+
* action: block
|
|
21
|
+
* ---
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ Dangerous rm command blocked. Verify the path is correct.
|
|
24
|
+
*/
|
|
25
|
+
export type HookifyEvent = 'bash' | 'file' | 'stop' | 'prompt' | 'all';
|
|
26
|
+
export type HookifyAction = 'warn' | 'block';
|
|
27
|
+
export interface HookifyCondition {
|
|
28
|
+
field: string;
|
|
29
|
+
operator: 'regex_match' | 'contains' | 'equals' | 'not_contains' | 'starts_with' | 'ends_with';
|
|
30
|
+
pattern: string;
|
|
31
|
+
}
|
|
32
|
+
export interface HookifyRule {
|
|
33
|
+
/** Stable identifier (filename without extension). */
|
|
34
|
+
id: string;
|
|
35
|
+
/** Human-readable rule name from frontmatter. */
|
|
36
|
+
name: string;
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
event: HookifyEvent;
|
|
39
|
+
/** Primary regex shortcut (mutually exclusive with conditions). */
|
|
40
|
+
pattern?: string;
|
|
41
|
+
/** Composite conditions; all must match for the rule to fire. */
|
|
42
|
+
conditions?: HookifyCondition[];
|
|
43
|
+
action: HookifyAction;
|
|
44
|
+
/** Markdown body shown to the user when the rule fires. */
|
|
45
|
+
message: string;
|
|
46
|
+
/** Absolute path to the source file (so /hookify list can cite it). */
|
|
47
|
+
sourcePath: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function ensureHookDir(workspaceRoot: string): string;
|
|
50
|
+
export declare function listHookifyRules(workspaceRoot: string): HookifyRule[];
|
|
51
|
+
export declare function parseHookifyFile(filePath: string): HookifyRule | null;
|
|
52
|
+
export interface HookifyContext {
|
|
53
|
+
/** The tool name being invoked, normalized: run_command → bash, write_file/edit_file → file, etc. */
|
|
54
|
+
event: HookifyEvent;
|
|
55
|
+
fields: Record<string, string>;
|
|
56
|
+
}
|
|
57
|
+
export interface HookifyMatch {
|
|
58
|
+
rule: HookifyRule;
|
|
59
|
+
/** "warn" surfaces a message; "block" denies the operation. */
|
|
60
|
+
action: HookifyAction;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Map a brainrouter tool invocation to the hookify event taxonomy. Returns
|
|
64
|
+
* the canonical event and the field bag that condition checks will probe.
|
|
65
|
+
*/
|
|
66
|
+
export declare function buildHookifyContext(toolName: string, args: Record<string, any>): HookifyContext;
|
|
67
|
+
export declare function buildPromptContext(prompt: string): HookifyContext;
|
|
68
|
+
export declare function buildStopContext(transcript: string): HookifyContext;
|
|
69
|
+
export declare function evaluateHookify(rules: HookifyRule[], ctx: HookifyContext): HookifyMatch[];
|
|
70
|
+
export declare function createHookifyRule(workspaceRoot: string, rule: {
|
|
71
|
+
name: string;
|
|
72
|
+
event: HookifyEvent;
|
|
73
|
+
pattern?: string;
|
|
74
|
+
action?: HookifyAction;
|
|
75
|
+
message: string;
|
|
76
|
+
conditions?: HookifyCondition[];
|
|
77
|
+
enabled?: boolean;
|
|
78
|
+
}): HookifyRule;
|
|
79
|
+
export declare function deleteHookifyRule(workspaceRoot: string, id: string): boolean;
|
|
80
|
+
export declare function toggleHookifyRule(workspaceRoot: string, id: string, enabled: boolean): boolean;
|