@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,59 @@
|
|
|
1
|
+
export declare function isPathInside(parent: string, candidate: string): boolean;
|
|
2
|
+
/**
|
|
3
|
+
* User-global brainrouter home. Defaults to `~/.brainrouter`. Override with
|
|
4
|
+
* the `BRAINROUTER_HOME` env var — tests set this to keep their state out of
|
|
5
|
+
* the real user home.
|
|
6
|
+
*/
|
|
7
|
+
export declare function getBrainrouterHome(): string;
|
|
8
|
+
/**
|
|
9
|
+
* Per-workspace state root inside the global home. Encodes the absolute
|
|
10
|
+
* workspace path with a readable prefix + short hash so two workspaces with
|
|
11
|
+
* the same basename never collide.
|
|
12
|
+
*
|
|
13
|
+
* ~/.brainrouter/workspaces/BrainRouter-3a7f9c12/
|
|
14
|
+
*/
|
|
15
|
+
export declare function getWorkspaceStateRoot(workspaceRoot: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* CLI state directory for a workspace. Defaults to
|
|
18
|
+
* ~/.brainrouter/workspaces/<encoded>/cli
|
|
19
|
+
* Older builds wrote to <workspaceRoot>/.brainrouter/cli — `getWorkspaceStateRoot`
|
|
20
|
+
* handles the one-time migration so transcripts/goals/plans/hooks/memories
|
|
21
|
+
* follow the user instead of cluttering the project.
|
|
22
|
+
*/
|
|
23
|
+
export declare function getCliStateDir(workspaceRoot: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Workspace-local state directory, e.g. `<workspace>/.brainrouter/`. Reserved
|
|
26
|
+
* for artifacts that are *meant* to be committed alongside the code — durable
|
|
27
|
+
* workflow specs, task breakdowns, walkthrough notes. Everything else
|
|
28
|
+
* (sessions, hooks, hookify rules, memories, preferences, transcripts) lives
|
|
29
|
+
* under `getWorkspaceStateRoot` in the user-global home so the project tree
|
|
30
|
+
* stays clean.
|
|
31
|
+
*/
|
|
32
|
+
export declare function getWorkspaceLocalDir(workspaceRoot: string): string;
|
|
33
|
+
export declare function getCliStateFile(workspaceRoot: string, fileName: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* Encode a sessionKey to a safe directory name. Base64url keeps it short and
|
|
36
|
+
* round-trippable so listSessions can recover the original key. The 180-char
|
|
37
|
+
* cap matches the previous transcript filename limit.
|
|
38
|
+
*/
|
|
39
|
+
export declare function encodeSessionKey(sessionKey: string): string;
|
|
40
|
+
export declare function decodeSessionKey(encoded: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Per-session state bucket at `<workspace>/.brainrouter/cli/sessions/<encoded>/`.
|
|
43
|
+
* Goal, plan, transcript, and any future per-session artifacts live together
|
|
44
|
+
* here so users can browse one folder per chat session instead of hunting
|
|
45
|
+
* across siblings.
|
|
46
|
+
*/
|
|
47
|
+
export declare function getSessionStateDir(workspaceRoot: string, sessionKey: string): string;
|
|
48
|
+
export declare function getSessionStateFile(workspaceRoot: string, sessionKey: string, fileName: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* List every persisted session bucket: returns `{ sessionKey, dir, modifiedAt }`
|
|
51
|
+
* newest first. Used by `/sessions` to render a picker.
|
|
52
|
+
*/
|
|
53
|
+
export declare function listSessionDirs(workspaceRoot: string): Array<{
|
|
54
|
+
sessionKey: string;
|
|
55
|
+
dir: string;
|
|
56
|
+
modifiedAt: string;
|
|
57
|
+
}>;
|
|
58
|
+
export declare function readJsonFile<T>(filePath: string, fallback: T): T;
|
|
59
|
+
export declare function writeJsonFile(filePath: string, value: unknown): void;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
export function isPathInside(parent, candidate) {
|
|
6
|
+
const relative = path.relative(parent, candidate);
|
|
7
|
+
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* User-global brainrouter home. Defaults to `~/.brainrouter`. Override with
|
|
11
|
+
* the `BRAINROUTER_HOME` env var — tests set this to keep their state out of
|
|
12
|
+
* the real user home.
|
|
13
|
+
*/
|
|
14
|
+
export function getBrainrouterHome() {
|
|
15
|
+
const override = process.env.BRAINROUTER_HOME?.trim();
|
|
16
|
+
const target = override ?? path.join(os.homedir(), '.brainrouter');
|
|
17
|
+
fs.mkdirSync(target, { recursive: true });
|
|
18
|
+
// Resolve symlinks (eg. macOS /tmp → /private/tmp) so callers comparing
|
|
19
|
+
// against `realpathSync(workspaceRoot)` see the same root.
|
|
20
|
+
try {
|
|
21
|
+
return fs.realpathSync(target);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return target;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Per-workspace state root inside the global home. Encodes the absolute
|
|
29
|
+
* workspace path with a readable prefix + short hash so two workspaces with
|
|
30
|
+
* the same basename never collide.
|
|
31
|
+
*
|
|
32
|
+
* ~/.brainrouter/workspaces/BrainRouter-3a7f9c12/
|
|
33
|
+
*/
|
|
34
|
+
export function getWorkspaceStateRoot(workspaceRoot) {
|
|
35
|
+
const abs = fs.realpathSync(workspaceRoot);
|
|
36
|
+
const home = getBrainrouterHome();
|
|
37
|
+
const encoded = encodeWorkspacePath(abs);
|
|
38
|
+
const dir = path.join(home, 'workspaces', encoded);
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
// Migration check fires here (idempotent) so hooks/ and memories/ get
|
|
41
|
+
// moved over even if the caller never goes through getCliStateDir.
|
|
42
|
+
migrateLegacyWorkspaceState(workspaceRoot, dir);
|
|
43
|
+
return dir;
|
|
44
|
+
}
|
|
45
|
+
function encodeWorkspacePath(absWorkspaceRoot) {
|
|
46
|
+
const base = path.basename(absWorkspaceRoot).replace(/[^A-Za-z0-9._-]+/g, '_') || 'root';
|
|
47
|
+
const hash = crypto.createHash('sha1').update(absWorkspaceRoot).digest('hex').slice(0, 8);
|
|
48
|
+
return `${base.slice(0, 60)}-${hash}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* CLI state directory for a workspace. Defaults to
|
|
52
|
+
* ~/.brainrouter/workspaces/<encoded>/cli
|
|
53
|
+
* Older builds wrote to <workspaceRoot>/.brainrouter/cli — `getWorkspaceStateRoot`
|
|
54
|
+
* handles the one-time migration so transcripts/goals/plans/hooks/memories
|
|
55
|
+
* follow the user instead of cluttering the project.
|
|
56
|
+
*/
|
|
57
|
+
export function getCliStateDir(workspaceRoot) {
|
|
58
|
+
const wsRoot = getWorkspaceStateRoot(workspaceRoot);
|
|
59
|
+
const stateDir = path.join(wsRoot, 'cli');
|
|
60
|
+
if (!isPathInside(wsRoot, stateDir)) {
|
|
61
|
+
throw new Error('CLI state directory escapes workspace state root.');
|
|
62
|
+
}
|
|
63
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
64
|
+
return stateDir;
|
|
65
|
+
}
|
|
66
|
+
let migrationAttempted = new Set();
|
|
67
|
+
function migrateLegacyWorkspaceState(workspaceRoot, newRoot) {
|
|
68
|
+
if (migrationAttempted.has(workspaceRoot))
|
|
69
|
+
return;
|
|
70
|
+
migrationAttempted.add(workspaceRoot);
|
|
71
|
+
try {
|
|
72
|
+
const abs = fs.realpathSync(workspaceRoot);
|
|
73
|
+
const legacyRoot = path.join(abs, '.brainrouter');
|
|
74
|
+
if (!fs.existsSync(legacyRoot))
|
|
75
|
+
return;
|
|
76
|
+
// If the legacy tree IS the new tree (because BRAINROUTER_HOME points at the
|
|
77
|
+
// workspace), do nothing.
|
|
78
|
+
if (path.resolve(legacyRoot) === path.resolve(newRoot))
|
|
79
|
+
return;
|
|
80
|
+
// The workspace-local "workflows/" tree is intentionally part of the
|
|
81
|
+
// workspace and must NOT be migrated away — that's the documented
|
|
82
|
+
// place to keep spec.md / tasks.md / walkthrough.md so the team can
|
|
83
|
+
// commit them. We only rescue cli/, hooks/, and memories/.
|
|
84
|
+
const markerFile = path.join(newRoot, '.migrated-from-workspace');
|
|
85
|
+
if (!fs.existsSync(markerFile)) {
|
|
86
|
+
for (const sub of ['cli', 'hooks', 'memories']) {
|
|
87
|
+
const src = path.join(legacyRoot, sub);
|
|
88
|
+
if (fs.existsSync(src)) {
|
|
89
|
+
copyDirRecursive(src, path.join(newRoot, sub));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
fs.writeFileSync(markerFile, `Migrated from ${legacyRoot} at ${new Date().toISOString()}\n`, 'utf8');
|
|
93
|
+
process.stderr.write(`brainrouter: migrated legacy state from ${legacyRoot} to ${newRoot}\n`);
|
|
94
|
+
}
|
|
95
|
+
// Now neutralize the legacy directory so the agent's list_dir / read_file
|
|
96
|
+
// don't see stale state in the workspace tree. Anything that ISN'T a
|
|
97
|
+
// workflows/ folder is moved to .brainrouter.migrated/. If only
|
|
98
|
+
// workflows/ remains, the workspace-local .brainrouter/ stays as the
|
|
99
|
+
// canonical home for committable artifacts.
|
|
100
|
+
const archiveRoot = path.join(abs, '.brainrouter.migrated');
|
|
101
|
+
const entries = fs.readdirSync(legacyRoot, { withFileTypes: true });
|
|
102
|
+
let archivedAny = false;
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (entry.name === 'workflows')
|
|
105
|
+
continue;
|
|
106
|
+
const from = path.join(legacyRoot, entry.name);
|
|
107
|
+
const to = path.join(archiveRoot, entry.name);
|
|
108
|
+
try {
|
|
109
|
+
fs.mkdirSync(archiveRoot, { recursive: true });
|
|
110
|
+
if (!fs.existsSync(to)) {
|
|
111
|
+
fs.renameSync(from, to);
|
|
112
|
+
archivedAny = true;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// Already archived from a prior run — just remove the stale copy.
|
|
116
|
+
fs.rmSync(from, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// best-effort: skip files we can't rename
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (archivedAny) {
|
|
124
|
+
process.stderr.write(`brainrouter: archived legacy in-workspace state to ${archiveRoot} (safe to delete after verifying)\n`);
|
|
125
|
+
}
|
|
126
|
+
// If the workspace-local `.brainrouter/` is now completely empty (no
|
|
127
|
+
// `workflows/` to preserve), remove the empty shell so the user
|
|
128
|
+
// doesn't see a stray folder reappear every session. We only delete
|
|
129
|
+
// it when empty — never when it still has committable workflow
|
|
130
|
+
// artifacts inside.
|
|
131
|
+
try {
|
|
132
|
+
const remaining = fs.readdirSync(legacyRoot);
|
|
133
|
+
if (remaining.length === 0) {
|
|
134
|
+
fs.rmdirSync(legacyRoot);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// best-effort cleanup
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
// Migration is best-effort. If it fails (permissions etc.), the CLI still
|
|
143
|
+
// runs against the new location and the user can copy manually.
|
|
144
|
+
process.stderr.write(`brainrouter: legacy-state migration skipped (${err.message ?? err})\n`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Workspace-local state directory, e.g. `<workspace>/.brainrouter/`. Reserved
|
|
149
|
+
* for artifacts that are *meant* to be committed alongside the code — durable
|
|
150
|
+
* workflow specs, task breakdowns, walkthrough notes. Everything else
|
|
151
|
+
* (sessions, hooks, hookify rules, memories, preferences, transcripts) lives
|
|
152
|
+
* under `getWorkspaceStateRoot` in the user-global home so the project tree
|
|
153
|
+
* stays clean.
|
|
154
|
+
*/
|
|
155
|
+
export function getWorkspaceLocalDir(workspaceRoot) {
|
|
156
|
+
const root = fs.realpathSync(workspaceRoot);
|
|
157
|
+
const dir = path.join(root, '.brainrouter');
|
|
158
|
+
if (!isPathInside(root, dir)) {
|
|
159
|
+
throw new Error('Workspace-local brainrouter directory escapes workspace root.');
|
|
160
|
+
}
|
|
161
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
162
|
+
return dir;
|
|
163
|
+
}
|
|
164
|
+
function copyDirRecursive(src, dst) {
|
|
165
|
+
if (!fs.existsSync(src))
|
|
166
|
+
return;
|
|
167
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
168
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
169
|
+
const srcPath = path.join(src, entry.name);
|
|
170
|
+
const dstPath = path.join(dst, entry.name);
|
|
171
|
+
if (entry.isDirectory()) {
|
|
172
|
+
copyDirRecursive(srcPath, dstPath);
|
|
173
|
+
}
|
|
174
|
+
else if (entry.isFile()) {
|
|
175
|
+
if (fs.existsSync(dstPath))
|
|
176
|
+
continue; // don't clobber existing state
|
|
177
|
+
fs.copyFileSync(srcPath, dstPath);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export function getCliStateFile(workspaceRoot, fileName) {
|
|
182
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(fileName)) {
|
|
183
|
+
throw new Error(`Invalid CLI state file name: ${fileName}`);
|
|
184
|
+
}
|
|
185
|
+
const stateDir = getCliStateDir(workspaceRoot);
|
|
186
|
+
const filePath = path.join(stateDir, fileName);
|
|
187
|
+
if (!isPathInside(stateDir, filePath)) {
|
|
188
|
+
throw new Error(`CLI state file escapes state directory: ${fileName}`);
|
|
189
|
+
}
|
|
190
|
+
return filePath;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Encode a sessionKey to a safe directory name. Base64url keeps it short and
|
|
194
|
+
* round-trippable so listSessions can recover the original key. The 180-char
|
|
195
|
+
* cap matches the previous transcript filename limit.
|
|
196
|
+
*/
|
|
197
|
+
export function encodeSessionKey(sessionKey) {
|
|
198
|
+
return Buffer.from(sessionKey, 'utf8').toString('base64url').slice(0, 180);
|
|
199
|
+
}
|
|
200
|
+
export function decodeSessionKey(encoded) {
|
|
201
|
+
try {
|
|
202
|
+
return Buffer.from(encoded, 'base64url').toString('utf8');
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return encoded;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Per-session state bucket at `<workspace>/.brainrouter/cli/sessions/<encoded>/`.
|
|
210
|
+
* Goal, plan, transcript, and any future per-session artifacts live together
|
|
211
|
+
* here so users can browse one folder per chat session instead of hunting
|
|
212
|
+
* across siblings.
|
|
213
|
+
*/
|
|
214
|
+
export function getSessionStateDir(workspaceRoot, sessionKey) {
|
|
215
|
+
const stateDir = getCliStateDir(workspaceRoot);
|
|
216
|
+
const sessionsDir = path.join(stateDir, 'sessions');
|
|
217
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
218
|
+
const sessionDir = path.join(sessionsDir, encodeSessionKey(sessionKey));
|
|
219
|
+
if (!isPathInside(sessionsDir, sessionDir)) {
|
|
220
|
+
throw new Error('Session state directory escapes CLI state dir.');
|
|
221
|
+
}
|
|
222
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
223
|
+
return sessionDir;
|
|
224
|
+
}
|
|
225
|
+
export function getSessionStateFile(workspaceRoot, sessionKey, fileName) {
|
|
226
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(fileName)) {
|
|
227
|
+
throw new Error(`Invalid session state file name: ${fileName}`);
|
|
228
|
+
}
|
|
229
|
+
const sessionDir = getSessionStateDir(workspaceRoot, sessionKey);
|
|
230
|
+
const filePath = path.join(sessionDir, fileName);
|
|
231
|
+
if (!isPathInside(sessionDir, filePath)) {
|
|
232
|
+
throw new Error('Session state file escapes session directory.');
|
|
233
|
+
}
|
|
234
|
+
return filePath;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* List every persisted session bucket: returns `{ sessionKey, dir, modifiedAt }`
|
|
238
|
+
* newest first. Used by `/sessions` to render a picker.
|
|
239
|
+
*/
|
|
240
|
+
export function listSessionDirs(workspaceRoot) {
|
|
241
|
+
const sessionsDir = path.join(getCliStateDir(workspaceRoot), 'sessions');
|
|
242
|
+
if (!fs.existsSync(sessionsDir))
|
|
243
|
+
return [];
|
|
244
|
+
const entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const entry of entries) {
|
|
247
|
+
if (!entry.isDirectory())
|
|
248
|
+
continue;
|
|
249
|
+
const dir = path.join(sessionsDir, entry.name);
|
|
250
|
+
let mtime = new Date(0);
|
|
251
|
+
try {
|
|
252
|
+
const stat = fs.statSync(dir);
|
|
253
|
+
mtime = stat.mtime;
|
|
254
|
+
// The transcript drives "last activity" better than the dir mtime.
|
|
255
|
+
const transcript = path.join(dir, 'transcript.jsonl');
|
|
256
|
+
if (fs.existsSync(transcript)) {
|
|
257
|
+
mtime = fs.statSync(transcript).mtime;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch { /* unreadable */ }
|
|
261
|
+
out.push({
|
|
262
|
+
sessionKey: decodeSessionKey(entry.name),
|
|
263
|
+
dir,
|
|
264
|
+
modifiedAt: mtime.toISOString(),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return out.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
|
268
|
+
}
|
|
269
|
+
export function readJsonFile(filePath, fallback) {
|
|
270
|
+
if (!fs.existsSync(filePath)) {
|
|
271
|
+
return fallback;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
275
|
+
if (!raw.trim()) {
|
|
276
|
+
return fallback;
|
|
277
|
+
}
|
|
278
|
+
return JSON.parse(raw);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
// Falling back instead of throwing means a single corrupted state file
|
|
282
|
+
// (truncated JSON from Ctrl-C mid-write, partial migration, hand-edit)
|
|
283
|
+
// can't prevent the REPL from booting. Quarantine the bad file so the
|
|
284
|
+
// user can inspect it, then return the caller's fallback value. The
|
|
285
|
+
// alternative — propagating — meant a half-byte goal.json bricked the
|
|
286
|
+
// entire CLI because createSystemMessage reads it on every turn start.
|
|
287
|
+
try {
|
|
288
|
+
const quarantine = `${filePath}.corrupt-${Date.now()}`;
|
|
289
|
+
fs.renameSync(filePath, quarantine);
|
|
290
|
+
console.warn(`[brainrouter] could not parse ${filePath} (${err.message}); ` +
|
|
291
|
+
`moved to ${quarantine} and falling back to default.`);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// Couldn't quarantine — just warn and continue with the fallback.
|
|
295
|
+
console.warn(`[brainrouter] could not parse ${filePath}: ${err.message}; using default.`);
|
|
296
|
+
}
|
|
297
|
+
return fallback;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
export function writeJsonFile(filePath, value) {
|
|
301
|
+
const dir = path.dirname(filePath);
|
|
302
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
303
|
+
// Temp suffix needs to be unique even when two writers run in the same
|
|
304
|
+
// millisecond (e.g. goal + plan + prefs writes during a single
|
|
305
|
+
// auto-continuation tick). Date.now() is millisecond-resolution so the old
|
|
306
|
+
// form `${pid}.${ms}.tmp` collides under load; add a 6-byte random nonce.
|
|
307
|
+
const nonce = crypto.randomBytes(6).toString('hex');
|
|
308
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.${nonce}.tmp`;
|
|
309
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
310
|
+
fs.renameSync(tmpPath, filePath);
|
|
311
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent goal / continuation contract for the agent. A goal is not just
|
|
3
|
+
* a sticky string — it carries lifecycle status, a budget that bounds how
|
|
4
|
+
* far auto-continuation will go, and timestamps so resumed sessions know
|
|
5
|
+
* exactly where they left off.
|
|
6
|
+
*
|
|
7
|
+
* - text: the outcome that should be true when done
|
|
8
|
+
* - status: active | paused | complete | blocked | usage_limited
|
|
9
|
+
* - budget: iteration AND optional token caps; auto-continuation
|
|
10
|
+
* halts (and the goal moves to `usage_limited`) when
|
|
11
|
+
* either is exhausted
|
|
12
|
+
* - timestamps: startedAt, updatedAt, completedAt
|
|
13
|
+
* - blockedReason: filled when the agent calls goal_blocked
|
|
14
|
+
*
|
|
15
|
+
* Status semantics:
|
|
16
|
+
* - active — continuation loop is allowed to fire next turn
|
|
17
|
+
* - paused — user-initiated suspend; resume re-arms the loop
|
|
18
|
+
* - complete — outcome satisfied; loop stops permanently
|
|
19
|
+
* - blocked — agent reported a hard impasse (missing data, external
|
|
20
|
+
* dep); loop stops until user intervenes
|
|
21
|
+
* - usage_limited — budget (iterations or tokens) exhausted; resumable
|
|
22
|
+
* after raising the budget. NEW compared to the old
|
|
23
|
+
* paused/blocked-only model: lets the UI distinguish
|
|
24
|
+
* "you ran out of room" from "user paused" from
|
|
25
|
+
* "agent gave up."
|
|
26
|
+
*
|
|
27
|
+
* Storage (per-session bucket):
|
|
28
|
+
* ~/.brainrouter/workspaces/<encoded>/cli/sessions/<encodedKey>/goal.json
|
|
29
|
+
*
|
|
30
|
+
* Legacy fallback paths exist for sessions created before per-session
|
|
31
|
+
* goal isolation was added. normalize() fills missing fields with defaults
|
|
32
|
+
* so resumed sessions don't crash on first read.
|
|
33
|
+
*/
|
|
34
|
+
export type GoalStatus = 'active' | 'paused' | 'complete' | 'blocked' | 'usage_limited';
|
|
35
|
+
/** A pausing status is one where continuation is halted but resumable. */
|
|
36
|
+
export declare const PAUSING_STATUSES: readonly GoalStatus[];
|
|
37
|
+
export interface GoalBudget {
|
|
38
|
+
maxIterations: number;
|
|
39
|
+
iterationsUsed: number;
|
|
40
|
+
/**
|
|
41
|
+
* Optional cumulative-token cap. When set, each turn's prompt+completion
|
|
42
|
+
* tokens accumulate into `tokensUsed`; once `tokensUsed >= maxTokens` the
|
|
43
|
+
* goal moves to `usage_limited` instead of just consuming another
|
|
44
|
+
* iteration. Lets users protect a fixed dollar budget without having to
|
|
45
|
+
* estimate the iteration count by hand.
|
|
46
|
+
*/
|
|
47
|
+
maxTokens?: number;
|
|
48
|
+
tokensUsed?: number;
|
|
49
|
+
}
|
|
50
|
+
export interface Goal {
|
|
51
|
+
text: string;
|
|
52
|
+
setAt: string;
|
|
53
|
+
status: GoalStatus;
|
|
54
|
+
budget: GoalBudget;
|
|
55
|
+
startedAt: string;
|
|
56
|
+
updatedAt: string;
|
|
57
|
+
completedAt?: string;
|
|
58
|
+
blockedReason?: string;
|
|
59
|
+
}
|
|
60
|
+
export declare const DEFAULT_GOAL_BUDGET = 10;
|
|
61
|
+
/**
|
|
62
|
+
* Hard cap on the goal text length. A goal is supposed to be a 1–3 sentence
|
|
63
|
+
* outcome statement; multi-thousand-character pastes (e.g. full chat logs)
|
|
64
|
+
* derail every subsequent turn because the goal block is re-injected into
|
|
65
|
+
* the system prompt on EVERY iteration.
|
|
66
|
+
*/
|
|
67
|
+
export declare const GOAL_TEXT_MAX_CHARS = 4000;
|
|
68
|
+
export declare class GoalTooLongError extends Error {
|
|
69
|
+
readonly length: number;
|
|
70
|
+
constructor(length: number);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Thrown when `setGoal` would overwrite a non-complete existing goal and
|
|
74
|
+
* the caller didn't pass `force: true`. The REPL catches this and prompts
|
|
75
|
+
* the user before replacing — interrupting in-flight work without
|
|
76
|
+
* confirmation is one of the easiest ways to lose progress.
|
|
77
|
+
*
|
|
78
|
+
* A `complete` goal does NOT raise this — replacing a finished goal is
|
|
79
|
+
* just starting fresh, no work is at risk.
|
|
80
|
+
*/
|
|
81
|
+
export declare class GoalConflictError extends Error {
|
|
82
|
+
readonly existing: Goal;
|
|
83
|
+
constructor(existing: Goal);
|
|
84
|
+
}
|
|
85
|
+
export declare function readGoal(workspaceRoot: string, sessionKey?: string): Goal | null;
|
|
86
|
+
/**
|
|
87
|
+
* Set a new active goal. Refuses to overwrite an in-progress goal (active,
|
|
88
|
+
* paused, blocked, or usage_limited) unless `force: true` is passed. The
|
|
89
|
+
* REPL catches the resulting GoalConflictError and prompts the user before
|
|
90
|
+
* replacing. Replacing a `complete` goal is allowed silently — at that
|
|
91
|
+
* point the prior goal isn't doing any work and a new one is just starting
|
|
92
|
+
* fresh.
|
|
93
|
+
*/
|
|
94
|
+
export declare function setGoal(workspaceRoot: string, text: string, sessionKey?: string, options?: {
|
|
95
|
+
maxIterations?: number;
|
|
96
|
+
maxTokens?: number;
|
|
97
|
+
force?: boolean;
|
|
98
|
+
}): Goal;
|
|
99
|
+
export declare function clearGoal(workspaceRoot: string, sessionKey?: string): void;
|
|
100
|
+
export declare function pauseGoal(workspaceRoot: string, sessionKey?: string): Goal | null;
|
|
101
|
+
export declare function resumeGoal(workspaceRoot: string, sessionKey?: string): Goal | null;
|
|
102
|
+
export declare function completeGoal(workspaceRoot: string, sessionKey?: string, proof?: string): Goal | null;
|
|
103
|
+
export declare function blockGoal(workspaceRoot: string, sessionKey: string | undefined, reason: string): Goal | null;
|
|
104
|
+
/**
|
|
105
|
+
* Mark the goal as `usage_limited` — distinct from paused (user-initiated)
|
|
106
|
+
* and blocked (agent gave up). Used when the iteration or token budget
|
|
107
|
+
* runs out. The user can resume after raising the budget; the loop won't
|
|
108
|
+
* fire another turn on its own until they do.
|
|
109
|
+
*/
|
|
110
|
+
export declare function usageLimitGoal(workspaceRoot: string, sessionKey: string | undefined, reason: string): Goal | null;
|
|
111
|
+
export declare function setGoalBudget(workspaceRoot: string, sessionKey: string | undefined, maxIterations: number): Goal | null;
|
|
112
|
+
/**
|
|
113
|
+
* Set or clear the optional token budget. Pass `0` (or any negative) to
|
|
114
|
+
* clear; positive integers set the cap. Resets tokensUsed to 0 when first
|
|
115
|
+
* enabling so the goal doesn't immediately appear exhausted.
|
|
116
|
+
*/
|
|
117
|
+
export declare function setGoalTokenBudget(workspaceRoot: string, sessionKey: string | undefined, maxTokens: number): Goal | null;
|
|
118
|
+
export declare function tickGoalIteration(workspaceRoot: string, sessionKey?: string): Goal | null;
|
|
119
|
+
/**
|
|
120
|
+
* Add `delta` tokens to the goal's running tally. No-op if a goal has no
|
|
121
|
+
* token budget set. Returns the updated Goal so callers can decide whether
|
|
122
|
+
* to transition to `usage_limited` afterwards.
|
|
123
|
+
*/
|
|
124
|
+
export declare function addGoalTokens(workspaceRoot: string, sessionKey: string | undefined, delta: number): Goal | null;
|
|
125
|
+
/**
|
|
126
|
+
* Unified update entrypoint. Lets callers mutate text/status/budget in a
|
|
127
|
+
* single call instead of stringing pause→budget→resume together. Used by
|
|
128
|
+
* the `/goal edit` REPL subcommand.
|
|
129
|
+
*/
|
|
130
|
+
export declare function editGoal(workspaceRoot: string, sessionKey: string | undefined, patch: {
|
|
131
|
+
text?: string;
|
|
132
|
+
status?: GoalStatus;
|
|
133
|
+
maxIterations?: number;
|
|
134
|
+
maxTokens?: number;
|
|
135
|
+
}): Goal | null;
|
|
136
|
+
/**
|
|
137
|
+
* True iff scheduling ONE MORE iteration would still fit inside BOTH the
|
|
138
|
+
* iteration cap and (if set) the token cap.
|
|
139
|
+
*
|
|
140
|
+
* The continuation loop ticks AFTER deciding to continue (so `iterationsUsed`
|
|
141
|
+
* lags by one until the tick runs). To stop after exactly `maxIterations`
|
|
142
|
+
* runs total, the predicate must ask "is (used+1) still within the cap?",
|
|
143
|
+
* not "is (used) still under the cap?". The old form gave you N+1 runs.
|
|
144
|
+
*
|
|
145
|
+
* Token budget is a hard "currently used vs cap" check — we can't know the
|
|
146
|
+
* next turn's token cost ahead of time, so we just refuse to schedule when
|
|
147
|
+
* we're already at or past the cap.
|
|
148
|
+
*/
|
|
149
|
+
export declare function goalHasBudgetLeft(goal: Goal): boolean;
|
|
150
|
+
/**
|
|
151
|
+
* True iff this is the FINAL turn within the budget — i.e. the iteration
|
|
152
|
+
* tick is about to land but one more after it would exceed the cap. The
|
|
153
|
+
* continuation loop uses this to inject a "wrap up gracefully" steering
|
|
154
|
+
* message so the model lands soft instead of being interrupted mid-thought.
|
|
155
|
+
*
|
|
156
|
+
* Specifically: after this turn's tick, iterationsUsed will equal
|
|
157
|
+
* maxIterations - 1, so `goalHasBudgetLeft` will return false on the next
|
|
158
|
+
* decision. We detect that ahead of time by checking before the tick.
|
|
159
|
+
*/
|
|
160
|
+
export declare function goalIsOnFinalBudgetTurn(goal: Goal): boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Wrap-up steering message injected on the final-budget turn. The agent
|
|
163
|
+
* loop pushes this into the chat history as a system message so the model
|
|
164
|
+
* pivots from "continue investigating" to "consolidate and report." Plain
|
|
165
|
+
* directive, no role-play.
|
|
166
|
+
*
|
|
167
|
+
* The message specifically reports WHICH cap is tight (iterations, tokens,
|
|
168
|
+
* or both) so the model doesn't get told "one turn left" when it actually
|
|
169
|
+
* has many iterations remaining but is near the token cap, or vice versa.
|
|
170
|
+
* Earlier versions hardcoded the iteration framing even when only the
|
|
171
|
+
* token heuristic tripped, which misled the model on token-budgeted runs.
|
|
172
|
+
*/
|
|
173
|
+
export declare function buildBudgetSteeringMessage(goal: Goal): string;
|
|
174
|
+
export declare function formatGoalBlock(goal: Goal): string;
|