@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,237 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getWorkspaceStateRoot } from './cliState.js';
|
|
4
|
+
function hookDir(workspaceRoot) {
|
|
5
|
+
return path.join(getWorkspaceStateRoot(workspaceRoot), 'hooks');
|
|
6
|
+
}
|
|
7
|
+
export function ensureHookDir(workspaceRoot) {
|
|
8
|
+
const dir = hookDir(workspaceRoot);
|
|
9
|
+
if (!fs.existsSync(dir))
|
|
10
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
export function listHookifyRules(workspaceRoot) {
|
|
14
|
+
const dir = hookDir(workspaceRoot);
|
|
15
|
+
if (!fs.existsSync(dir))
|
|
16
|
+
return [];
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
19
|
+
if (!entry.endsWith('.md'))
|
|
20
|
+
continue;
|
|
21
|
+
const full = path.join(dir, entry);
|
|
22
|
+
try {
|
|
23
|
+
const rule = parseHookifyFile(full);
|
|
24
|
+
if (rule)
|
|
25
|
+
out.push(rule);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Skip malformed rule files; surfacing the error is the REPL's job.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
export function parseHookifyFile(filePath) {
|
|
34
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
35
|
+
const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(content);
|
|
36
|
+
if (!match)
|
|
37
|
+
return null;
|
|
38
|
+
const frontmatter = match[1];
|
|
39
|
+
const body = match[2].trim();
|
|
40
|
+
const meta = {};
|
|
41
|
+
let currentList = null;
|
|
42
|
+
let currentCond = null;
|
|
43
|
+
for (const rawLine of frontmatter.split('\n')) {
|
|
44
|
+
const line = rawLine.replace(/\r$/, '');
|
|
45
|
+
if (!line.trim())
|
|
46
|
+
continue;
|
|
47
|
+
if (line.trimStart().startsWith('-') && currentList) {
|
|
48
|
+
const inner = line.trim().slice(1).trim();
|
|
49
|
+
currentCond = { field: '', operator: 'regex_match', pattern: '' };
|
|
50
|
+
const [k, ...rest] = inner.split(':');
|
|
51
|
+
if (rest.length > 0)
|
|
52
|
+
currentCond[k.trim()] = rest.join(':').trim();
|
|
53
|
+
currentList.push(currentCond);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (line.startsWith(' ') && currentCond) {
|
|
57
|
+
const [k, ...rest] = line.trim().split(':');
|
|
58
|
+
if (rest.length > 0)
|
|
59
|
+
currentCond[k.trim()] = rest.join(':').trim();
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const [k, ...rest] = line.split(':');
|
|
63
|
+
if (rest.length === 0)
|
|
64
|
+
continue;
|
|
65
|
+
const key = k.trim();
|
|
66
|
+
const value = rest.join(':').trim();
|
|
67
|
+
if (key === 'conditions') {
|
|
68
|
+
currentList = [];
|
|
69
|
+
meta.conditions = currentList;
|
|
70
|
+
currentCond = null;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
currentList = null;
|
|
74
|
+
currentCond = null;
|
|
75
|
+
meta[key] = value;
|
|
76
|
+
}
|
|
77
|
+
const id = path.basename(filePath).replace(/\.md$/, '');
|
|
78
|
+
if (!meta.event)
|
|
79
|
+
return null;
|
|
80
|
+
return {
|
|
81
|
+
id,
|
|
82
|
+
name: typeof meta.name === 'string' ? meta.name : id,
|
|
83
|
+
enabled: meta.enabled === undefined ? true : meta.enabled === 'true' || meta.enabled === true,
|
|
84
|
+
event: meta.event,
|
|
85
|
+
pattern: typeof meta.pattern === 'string' ? meta.pattern : undefined,
|
|
86
|
+
conditions: Array.isArray(meta.conditions) && meta.conditions.length > 0 ? meta.conditions : undefined,
|
|
87
|
+
action: (meta.action === 'block' ? 'block' : 'warn'),
|
|
88
|
+
message: body,
|
|
89
|
+
sourcePath: filePath,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Map a brainrouter tool invocation to the hookify event taxonomy. Returns
|
|
94
|
+
* the canonical event and the field bag that condition checks will probe.
|
|
95
|
+
*/
|
|
96
|
+
export function buildHookifyContext(toolName, args) {
|
|
97
|
+
if (toolName === 'run_command') {
|
|
98
|
+
return { event: 'bash', fields: { command: String(args.command ?? '') } };
|
|
99
|
+
}
|
|
100
|
+
if (toolName === 'write_file') {
|
|
101
|
+
return {
|
|
102
|
+
event: 'file',
|
|
103
|
+
fields: {
|
|
104
|
+
file_path: String(args.path ?? ''),
|
|
105
|
+
content: String(args.content ?? ''),
|
|
106
|
+
new_text: String(args.content ?? ''),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (toolName === 'edit_file') {
|
|
111
|
+
return {
|
|
112
|
+
event: 'file',
|
|
113
|
+
fields: {
|
|
114
|
+
file_path: String(args.path ?? ''),
|
|
115
|
+
old_text: String(args.targetContent ?? ''),
|
|
116
|
+
new_text: String(args.replacementContent ?? ''),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (toolName === 'apply_patch') {
|
|
121
|
+
return { event: 'file', fields: { new_text: String(args.patch ?? '') } };
|
|
122
|
+
}
|
|
123
|
+
return { event: 'all', fields: {} };
|
|
124
|
+
}
|
|
125
|
+
export function buildPromptContext(prompt) {
|
|
126
|
+
return { event: 'prompt', fields: { user_prompt: prompt } };
|
|
127
|
+
}
|
|
128
|
+
export function buildStopContext(transcript) {
|
|
129
|
+
return { event: 'stop', fields: { transcript } };
|
|
130
|
+
}
|
|
131
|
+
function fieldMatches(value, op, pattern) {
|
|
132
|
+
if (!pattern)
|
|
133
|
+
return false;
|
|
134
|
+
switch (op) {
|
|
135
|
+
case 'regex_match':
|
|
136
|
+
try {
|
|
137
|
+
return new RegExp(pattern).test(value);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
case 'contains':
|
|
143
|
+
return value.includes(pattern);
|
|
144
|
+
case 'equals':
|
|
145
|
+
return value === pattern;
|
|
146
|
+
case 'not_contains':
|
|
147
|
+
return !value.includes(pattern);
|
|
148
|
+
case 'starts_with':
|
|
149
|
+
return value.startsWith(pattern);
|
|
150
|
+
case 'ends_with':
|
|
151
|
+
return value.endsWith(pattern);
|
|
152
|
+
default:
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export function evaluateHookify(rules, ctx) {
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const rule of rules) {
|
|
159
|
+
if (!rule.enabled)
|
|
160
|
+
continue;
|
|
161
|
+
if (rule.event !== 'all' && rule.event !== ctx.event)
|
|
162
|
+
continue;
|
|
163
|
+
let matched = false;
|
|
164
|
+
if (rule.pattern) {
|
|
165
|
+
const haystack = Object.values(ctx.fields).join('\n');
|
|
166
|
+
try {
|
|
167
|
+
matched = new RegExp(rule.pattern).test(haystack);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
matched = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!matched && rule.conditions && rule.conditions.length > 0) {
|
|
174
|
+
matched = rule.conditions.every((c) => {
|
|
175
|
+
const value = ctx.fields[c.field] ?? '';
|
|
176
|
+
return fieldMatches(value, c.operator, c.pattern);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (matched)
|
|
180
|
+
out.push({ rule, action: rule.action });
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
export function createHookifyRule(workspaceRoot, rule) {
|
|
185
|
+
const dir = ensureHookDir(workspaceRoot);
|
|
186
|
+
const slug = rule.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || `rule-${Date.now()}`;
|
|
187
|
+
const file = path.join(dir, `${slug}.md`);
|
|
188
|
+
const lines = ['---'];
|
|
189
|
+
lines.push(`name: ${rule.name}`);
|
|
190
|
+
lines.push(`enabled: ${rule.enabled === false ? 'false' : 'true'}`);
|
|
191
|
+
lines.push(`event: ${rule.event}`);
|
|
192
|
+
if (rule.pattern)
|
|
193
|
+
lines.push(`pattern: ${rule.pattern}`);
|
|
194
|
+
if (rule.action)
|
|
195
|
+
lines.push(`action: ${rule.action}`);
|
|
196
|
+
if (rule.conditions && rule.conditions.length > 0) {
|
|
197
|
+
lines.push('conditions:');
|
|
198
|
+
for (const c of rule.conditions) {
|
|
199
|
+
lines.push(` - field: ${c.field}`);
|
|
200
|
+
lines.push(` operator: ${c.operator}`);
|
|
201
|
+
lines.push(` pattern: ${c.pattern}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
lines.push('---');
|
|
205
|
+
lines.push('');
|
|
206
|
+
lines.push(rule.message.trim());
|
|
207
|
+
lines.push('');
|
|
208
|
+
fs.writeFileSync(file, lines.join('\n'), 'utf8');
|
|
209
|
+
const parsed = parseHookifyFile(file);
|
|
210
|
+
if (!parsed)
|
|
211
|
+
throw new Error(`Failed to read back created rule at ${file}`);
|
|
212
|
+
return parsed;
|
|
213
|
+
}
|
|
214
|
+
export function deleteHookifyRule(workspaceRoot, id) {
|
|
215
|
+
const dir = hookDir(workspaceRoot);
|
|
216
|
+
const file = path.join(dir, `${id}.md`);
|
|
217
|
+
if (!fs.existsSync(file))
|
|
218
|
+
return false;
|
|
219
|
+
fs.unlinkSync(file);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
export function toggleHookifyRule(workspaceRoot, id, enabled) {
|
|
223
|
+
const dir = hookDir(workspaceRoot);
|
|
224
|
+
const file = path.join(dir, `${id}.md`);
|
|
225
|
+
if (!fs.existsSync(file))
|
|
226
|
+
return false;
|
|
227
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
228
|
+
const next = content.replace(/(\nenabled:\s*)(true|false)/, `$1${enabled ? 'true' : 'false'}`);
|
|
229
|
+
if (next === content) {
|
|
230
|
+
const inserted = content.replace(/^---\n/, `---\nenabled: ${enabled ? 'true' : 'false'}\n`);
|
|
231
|
+
fs.writeFileSync(file, inserted, 'utf8');
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
fs.writeFileSync(file, next, 'utf8');
|
|
235
|
+
}
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle shell hooks. A hook is a shell command string that runs at a
|
|
3
|
+
* specific agent-loop point (pre-tool, post-tool, stop). Non-zero exit codes
|
|
4
|
+
* from `pre-tool` hooks can block the tool call ("approval gate"); other
|
|
5
|
+
* events are informational.
|
|
6
|
+
*
|
|
7
|
+
* Persisted at <workspace>/.brainrouter/cli/hooks.json so they survive CLI
|
|
8
|
+
* restarts and travel with the project.
|
|
9
|
+
*/
|
|
10
|
+
export type HookEvent = 'pre-tool' | 'post-tool' | 'pre-turn' | 'post-turn' | 'session-start' | 'session-end';
|
|
11
|
+
export interface Hook {
|
|
12
|
+
id: string;
|
|
13
|
+
event: HookEvent;
|
|
14
|
+
command: string;
|
|
15
|
+
match?: string;
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function readHooks(workspaceRoot: string): Hook[];
|
|
20
|
+
export declare function addHook(workspaceRoot: string, input: {
|
|
21
|
+
event: HookEvent;
|
|
22
|
+
command: string;
|
|
23
|
+
match?: string;
|
|
24
|
+
}): Hook;
|
|
25
|
+
export declare function removeHook(workspaceRoot: string, id: string): boolean;
|
|
26
|
+
export declare function setHookEnabled(workspaceRoot: string, id: string, enabled: boolean): boolean;
|
|
27
|
+
export interface HookRunResult {
|
|
28
|
+
hook: Hook;
|
|
29
|
+
exitCode: number;
|
|
30
|
+
stdout: string;
|
|
31
|
+
stderr: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Run all enabled hooks for the given event. The CLI uses synchronous spawning
|
|
35
|
+
* with a hard timeout — hooks are meant for fast lifecycle taps (lint, notify,
|
|
36
|
+
* log), not long-running work. A `pre-tool` hook returning non-zero blocks the
|
|
37
|
+
* tool call; other events are advisory.
|
|
38
|
+
*/
|
|
39
|
+
export declare function runHooks(workspaceRoot: string, event: HookEvent, context?: {
|
|
40
|
+
tool?: string;
|
|
41
|
+
payload?: Record<string, unknown>;
|
|
42
|
+
}, timeoutMs?: number): HookRunResult[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { getCliStateFile, readJsonFile, writeJsonFile } from './cliState.js';
|
|
3
|
+
const EMPTY = { hooks: [] };
|
|
4
|
+
export function readHooks(workspaceRoot) {
|
|
5
|
+
return readJsonFile(getCliStateFile(workspaceRoot, 'hooks.json'), EMPTY).hooks;
|
|
6
|
+
}
|
|
7
|
+
export function addHook(workspaceRoot, input) {
|
|
8
|
+
const all = readHooks(workspaceRoot);
|
|
9
|
+
const hook = {
|
|
10
|
+
id: `hook_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
|
|
11
|
+
event: input.event,
|
|
12
|
+
command: input.command,
|
|
13
|
+
match: input.match,
|
|
14
|
+
enabled: true,
|
|
15
|
+
createdAt: new Date().toISOString(),
|
|
16
|
+
};
|
|
17
|
+
all.push(hook);
|
|
18
|
+
writeJsonFile(getCliStateFile(workspaceRoot, 'hooks.json'), { hooks: all });
|
|
19
|
+
return hook;
|
|
20
|
+
}
|
|
21
|
+
export function removeHook(workspaceRoot, id) {
|
|
22
|
+
const all = readHooks(workspaceRoot);
|
|
23
|
+
const filtered = all.filter((h) => h.id !== id);
|
|
24
|
+
if (filtered.length === all.length)
|
|
25
|
+
return false;
|
|
26
|
+
writeJsonFile(getCliStateFile(workspaceRoot, 'hooks.json'), { hooks: filtered });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
export function setHookEnabled(workspaceRoot, id, enabled) {
|
|
30
|
+
const all = readHooks(workspaceRoot);
|
|
31
|
+
const target = all.find((h) => h.id === id);
|
|
32
|
+
if (!target)
|
|
33
|
+
return false;
|
|
34
|
+
target.enabled = enabled;
|
|
35
|
+
writeJsonFile(getCliStateFile(workspaceRoot, 'hooks.json'), { hooks: all });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Run all enabled hooks for the given event. The CLI uses synchronous spawning
|
|
40
|
+
* with a hard timeout — hooks are meant for fast lifecycle taps (lint, notify,
|
|
41
|
+
* log), not long-running work. A `pre-tool` hook returning non-zero blocks the
|
|
42
|
+
* tool call; other events are advisory.
|
|
43
|
+
*/
|
|
44
|
+
export function runHooks(workspaceRoot, event, context = {}, timeoutMs = 5000) {
|
|
45
|
+
const results = [];
|
|
46
|
+
for (const hook of readHooks(workspaceRoot)) {
|
|
47
|
+
if (!hook.enabled || hook.event !== event)
|
|
48
|
+
continue;
|
|
49
|
+
if (hook.match && context.tool && !context.tool.includes(hook.match))
|
|
50
|
+
continue;
|
|
51
|
+
const env = {
|
|
52
|
+
...process.env,
|
|
53
|
+
BRAINROUTER_HOOK_EVENT: event,
|
|
54
|
+
BRAINROUTER_HOOK_TOOL: context.tool ?? '',
|
|
55
|
+
BRAINROUTER_HOOK_PAYLOAD: context.payload ? JSON.stringify(context.payload) : '',
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
const stdout = execSync(hook.command, { env, timeout: timeoutMs, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
59
|
+
results.push({ hook, exitCode: 0, stdout, stderr: '' });
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
results.push({
|
|
63
|
+
hook,
|
|
64
|
+
exitCode: typeof err.status === 'number' ? err.status : 1,
|
|
65
|
+
stdout: typeof err.stdout === 'string' ? err.stdout : (err.stdout?.toString?.() ?? ''),
|
|
66
|
+
stderr: typeof err.stderr === 'string' ? err.stderr : (err.stderr?.toString?.() ?? err.message ?? ''),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return results;
|
|
71
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-workspace runtime preferences that don't justify their own file.
|
|
3
|
+
*
|
|
4
|
+
* Persisted at `<workspace>/.brainrouter/cli/preferences.json` so they survive
|
|
5
|
+
* CLI restarts but stay scoped to the project (different repos can have
|
|
6
|
+
* different settings).
|
|
7
|
+
*/
|
|
8
|
+
export interface Preferences {
|
|
9
|
+
/** When true, every worker spawn is auto-followed by a reviewer pass on the diff. */
|
|
10
|
+
autoReview: boolean;
|
|
11
|
+
/** Editor mode for the readline composer. */
|
|
12
|
+
editorMode: 'emacs' | 'vi';
|
|
13
|
+
/** Status-line layout: comma-separated segments from {mode,branch,dirty,model,tokens,session}. */
|
|
14
|
+
statusline: string;
|
|
15
|
+
/**
|
|
16
|
+
* When true, `run_command` skips the per-call confirmation prompt and runs
|
|
17
|
+
* immediately. Pair with sandboxing (BRAINROUTER_SANDBOX=on) if you want
|
|
18
|
+
* the safety net without the friction. Off by default — opt-in via /yolo.
|
|
19
|
+
*/
|
|
20
|
+
autoApproveShell: boolean;
|
|
21
|
+
/** Syntax highlighting theme for markdown output. Tied to/theme. */
|
|
22
|
+
theme: 'auto' | 'light' | 'dark' | 'mono';
|
|
23
|
+
/** Terminal title format segments (model, branch, session, mode) or 'off'. Tied to/title. */
|
|
24
|
+
terminalTitle: string;
|
|
25
|
+
/** Communication style for the agent. Tied to/personality. */
|
|
26
|
+
personality: 'concise' | 'standard' | 'detailed' | 'pair-programmer';
|
|
27
|
+
/** When true, REPL output skips markdown rendering for copy-friendly raw text. Tied to/raw. */
|
|
28
|
+
rawScrollback: boolean;
|
|
29
|
+
/** When true, gated experimental features are unlocked. Tied to/experimental. */
|
|
30
|
+
experimental: boolean;
|
|
31
|
+
/** When true, the memory pipeline runs phase1/phase2 consolidation on session start. Tied to/memories. */
|
|
32
|
+
memoriesEnabled: boolean;
|
|
33
|
+
/** Custom keybindings as JSON-stringified map for /keymap. Empty means defaults. */
|
|
34
|
+
keymap: string;
|
|
35
|
+
/** Extra read-only paths granted to sandboxed run_command. Workspace is always readable+writable. */
|
|
36
|
+
sandboxReadPaths: string[];
|
|
37
|
+
/** Extra write-allowed paths granted to sandboxed run_command. */
|
|
38
|
+
sandboxWritePaths: string[];
|
|
39
|
+
}
|
|
40
|
+
export declare function readPreferences(workspaceRoot: string): Preferences;
|
|
41
|
+
export declare function writePreferences(workspaceRoot: string, prefs: Partial<Preferences>): Preferences;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getCliStateFile, readJsonFile, writeJsonFile } from './cliState.js';
|
|
2
|
+
const DEFAULT = {
|
|
3
|
+
autoReview: false,
|
|
4
|
+
editorMode: 'emacs',
|
|
5
|
+
statusline: 'mode',
|
|
6
|
+
autoApproveShell: false,
|
|
7
|
+
theme: 'auto',
|
|
8
|
+
terminalTitle: 'model,session',
|
|
9
|
+
personality: 'standard',
|
|
10
|
+
rawScrollback: false,
|
|
11
|
+
experimental: false,
|
|
12
|
+
memoriesEnabled: true,
|
|
13
|
+
keymap: '',
|
|
14
|
+
sandboxReadPaths: [],
|
|
15
|
+
sandboxWritePaths: [],
|
|
16
|
+
};
|
|
17
|
+
export function readPreferences(workspaceRoot) {
|
|
18
|
+
const stored = readJsonFile(getCliStateFile(workspaceRoot, 'preferences.json'), {});
|
|
19
|
+
return { ...DEFAULT, ...stored };
|
|
20
|
+
}
|
|
21
|
+
export function writePreferences(workspaceRoot, prefs) {
|
|
22
|
+
const merged = { ...readPreferences(workspaceRoot), ...prefs };
|
|
23
|
+
writeJsonFile(getCliStateFile(workspaceRoot, 'preferences.json'), merged);
|
|
24
|
+
return merged;
|
|
25
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface TranscriptEntry {
|
|
2
|
+
role: string;
|
|
3
|
+
content?: unknown;
|
|
4
|
+
name?: string;
|
|
5
|
+
tool_call_id?: string;
|
|
6
|
+
tool_calls?: unknown;
|
|
7
|
+
isError?: boolean;
|
|
8
|
+
timestamp: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function appendTranscriptEntry(workspaceRoot: string, sessionKey: string, entry: Omit<TranscriptEntry, 'timestamp'> & {
|
|
11
|
+
timestamp?: string;
|
|
12
|
+
}): void;
|
|
13
|
+
export declare function readTranscriptEntries(workspaceRoot: string, sessionKey: string, limit?: number): TranscriptEntry[];
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the transcript path for writes. Always uses the per-session bucket
|
|
16
|
+
* at `<state>/sessions/<encodedKey>/transcript.jsonl` so each chat session
|
|
17
|
+
* keeps its history co-located with its goal/plan/etc.
|
|
18
|
+
*/
|
|
19
|
+
export declare function getTranscriptPath(workspaceRoot: string, sessionKey: string): string;
|
|
20
|
+
export declare function redactText(value: string): string;
|
|
21
|
+
export declare function redactTranscriptEntry(entry: TranscriptEntry): TranscriptEntry;
|
|
22
|
+
export interface TranscriptSummary {
|
|
23
|
+
sessionKey: string;
|
|
24
|
+
fileName: string;
|
|
25
|
+
modifiedAt: string;
|
|
26
|
+
turnCount: number;
|
|
27
|
+
firstUserMessage?: string;
|
|
28
|
+
/** Absolute path to the session bucket (new layout) or undefined for legacy. */
|
|
29
|
+
sessionDir?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* List all persisted transcripts under the workspace, newest first. Scans the
|
|
33
|
+
* new `sessions/<encodedKey>/transcript.jsonl` layout AND the legacy
|
|
34
|
+
* `transcripts/<encodedKey>.jsonl` files so the picker shows every session
|
|
35
|
+
* even after the upgrade. Per-session files always win on dedupe.
|
|
36
|
+
*/
|
|
37
|
+
export declare function listTranscripts(workspaceRoot: string): TranscriptSummary[];
|
|
38
|
+
/**
|
|
39
|
+
* Read all transcript entries for a session, in order. Used by `/resume`
|
|
40
|
+
* to seed the Agent's chatHistory.
|
|
41
|
+
*/
|
|
42
|
+
export declare function loadTranscript(workspaceRoot: string, sessionKey: string): TranscriptEntry[];
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { decodeSessionKey, encodeSessionKey, getCliStateDir, getSessionStateDir, isPathInside, } from './cliState.js';
|
|
4
|
+
const SECRET_TOKEN_PATTERNS = [
|
|
5
|
+
/\bbr_[A-Za-z0-9._-]{8,}\b/g,
|
|
6
|
+
/\bsk-[A-Za-z0-9._-]{8,}\b/g,
|
|
7
|
+
];
|
|
8
|
+
const TRANSCRIPT_FILE = 'transcript.jsonl';
|
|
9
|
+
export function appendTranscriptEntry(workspaceRoot, sessionKey, entry) {
|
|
10
|
+
const filePath = getTranscriptPath(workspaceRoot, sessionKey);
|
|
11
|
+
const payload = redactTranscriptEntry({
|
|
12
|
+
...entry,
|
|
13
|
+
timestamp: entry.timestamp ?? new Date().toISOString(),
|
|
14
|
+
});
|
|
15
|
+
// Dedup consecutive identical user prompts (e.g. arrow-up + Enter to repeat
|
|
16
|
+
// the same prompt) so /transcript and /resume don't accumulate identical
|
|
17
|
+
// replay-spam entries. Only applied to role='user' entries — assistant +
|
|
18
|
+
// tool replies legitimately differ between turns even when the prompt is
|
|
19
|
+
// the same.
|
|
20
|
+
if (payload.role === 'user' && isConsecutiveDuplicate(filePath, payload)) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Check if the last line of the transcript file matches the new entry on
|
|
27
|
+
* role + content. Bounded read (last ~32KB) so the dedup check stays cheap
|
|
28
|
+
* even for huge transcripts; consecutive duplicates that span beyond that
|
|
29
|
+
* window weren't going to be visually adjacent anyway.
|
|
30
|
+
*/
|
|
31
|
+
function isConsecutiveDuplicate(filePath, candidate) {
|
|
32
|
+
try {
|
|
33
|
+
if (!fs.existsSync(filePath))
|
|
34
|
+
return false;
|
|
35
|
+
const stat = fs.statSync(filePath);
|
|
36
|
+
if (stat.size === 0)
|
|
37
|
+
return false;
|
|
38
|
+
const start = Math.max(0, stat.size - 32 * 1024);
|
|
39
|
+
const fd = fs.openSync(filePath, 'r');
|
|
40
|
+
try {
|
|
41
|
+
const buf = Buffer.alloc(stat.size - start);
|
|
42
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
43
|
+
const tail = buf.toString('utf8').trimEnd();
|
|
44
|
+
if (!tail)
|
|
45
|
+
return false;
|
|
46
|
+
const lastLine = tail.slice(tail.lastIndexOf('\n') + 1);
|
|
47
|
+
const last = JSON.parse(lastLine);
|
|
48
|
+
return last.role === candidate.role && last.content === candidate.content;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
fs.closeSync(fd);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Any read/parse failure: don't dedup — write the entry normally.
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function readTranscriptEntries(workspaceRoot, sessionKey, limit = 40) {
|
|
60
|
+
const filePath = resolveExistingTranscriptPath(workspaceRoot, sessionKey);
|
|
61
|
+
if (!filePath) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const lines = fs.readFileSync(filePath, 'utf8').trim().split('\n').filter(Boolean);
|
|
65
|
+
// A single corrupted line — most often the tail line from a Ctrl-C mid-write
|
|
66
|
+
// because appendFileSync is not atomic for large payloads — must not crash
|
|
67
|
+
// /resume or anything else that scans the transcript. Tolerate bad lines
|
|
68
|
+
// and warn once; the visible-entry slice handles the rest.
|
|
69
|
+
const entries = [];
|
|
70
|
+
let dropped = 0;
|
|
71
|
+
for (const line of lines.slice(Math.max(0, lines.length - limit))) {
|
|
72
|
+
try {
|
|
73
|
+
entries.push(JSON.parse(line));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
dropped++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (dropped > 0) {
|
|
80
|
+
console.warn(`[brainrouter] dropped ${dropped} unparseable transcript line(s) in ${filePath}`);
|
|
81
|
+
}
|
|
82
|
+
return entries;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the transcript path for writes. Always uses the per-session bucket
|
|
86
|
+
* at `<state>/sessions/<encodedKey>/transcript.jsonl` so each chat session
|
|
87
|
+
* keeps its history co-located with its goal/plan/etc.
|
|
88
|
+
*/
|
|
89
|
+
export function getTranscriptPath(workspaceRoot, sessionKey) {
|
|
90
|
+
const sessionDir = getSessionStateDir(workspaceRoot, sessionKey);
|
|
91
|
+
const filePath = path.join(sessionDir, TRANSCRIPT_FILE);
|
|
92
|
+
if (!isPathInside(sessionDir, filePath)) {
|
|
93
|
+
throw new Error('Transcript path escapes session directory.');
|
|
94
|
+
}
|
|
95
|
+
return filePath;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Read-side resolver: prefer the per-session bucket, fall back to the legacy
|
|
99
|
+
* `transcripts/<encodedKey>.jsonl` location so `/resume` still works for
|
|
100
|
+
* sessions captured by older builds.
|
|
101
|
+
*/
|
|
102
|
+
function resolveExistingTranscriptPath(workspaceRoot, sessionKey) {
|
|
103
|
+
const sessionPath = getTranscriptPath(workspaceRoot, sessionKey);
|
|
104
|
+
if (fs.existsSync(sessionPath))
|
|
105
|
+
return sessionPath;
|
|
106
|
+
const legacy = path.join(getCliStateDir(workspaceRoot), 'transcripts', `${encodeSessionKey(sessionKey)}.jsonl`);
|
|
107
|
+
if (fs.existsSync(legacy))
|
|
108
|
+
return legacy;
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
export function redactText(value) {
|
|
112
|
+
const redactedAssignments = value.replace(/((?:"(?:apiKey|api_key|BRAINROUTER_API_KEY|OPENAI_API_KEY)"|(?:apiKey|api_key|BRAINROUTER_API_KEY|OPENAI_API_KEY))\s*[:=]\s*)("[^"\n]*"|'[^'\n]*'|[^\s,\]}]+)/gi, '$1"[REDACTED]"');
|
|
113
|
+
return SECRET_TOKEN_PATTERNS.reduce((text, pattern) => text.replace(pattern, '[REDACTED]'), redactedAssignments);
|
|
114
|
+
}
|
|
115
|
+
export function redactTranscriptEntry(entry) {
|
|
116
|
+
return JSON.parse(redactText(JSON.stringify(entry)));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* List all persisted transcripts under the workspace, newest first. Scans the
|
|
120
|
+
* new `sessions/<encodedKey>/transcript.jsonl` layout AND the legacy
|
|
121
|
+
* `transcripts/<encodedKey>.jsonl` files so the picker shows every session
|
|
122
|
+
* even after the upgrade. Per-session files always win on dedupe.
|
|
123
|
+
*/
|
|
124
|
+
export function listTranscripts(workspaceRoot) {
|
|
125
|
+
const stateDir = getCliStateDir(workspaceRoot);
|
|
126
|
+
const seen = new Map();
|
|
127
|
+
// New layout: sessions/<encodedKey>/transcript.jsonl
|
|
128
|
+
const sessionsDir = path.join(stateDir, 'sessions');
|
|
129
|
+
if (fs.existsSync(sessionsDir)) {
|
|
130
|
+
for (const entry of fs.readdirSync(sessionsDir, { withFileTypes: true })) {
|
|
131
|
+
if (!entry.isDirectory())
|
|
132
|
+
continue;
|
|
133
|
+
const sessionDir = path.join(sessionsDir, entry.name);
|
|
134
|
+
const transcriptPath = path.join(sessionDir, TRANSCRIPT_FILE);
|
|
135
|
+
if (!fs.existsSync(transcriptPath))
|
|
136
|
+
continue;
|
|
137
|
+
const sessionKey = decodeSessionKey(entry.name);
|
|
138
|
+
const summary = summarizeTranscript(transcriptPath, sessionKey, sessionDir);
|
|
139
|
+
seen.set(sessionKey, summary);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Legacy layout: transcripts/<encodedKey>.jsonl
|
|
143
|
+
const legacyDir = path.join(stateDir, 'transcripts');
|
|
144
|
+
if (fs.existsSync(legacyDir)) {
|
|
145
|
+
for (const fileName of fs.readdirSync(legacyDir)) {
|
|
146
|
+
if (!fileName.endsWith('.jsonl'))
|
|
147
|
+
continue;
|
|
148
|
+
const encoded = fileName.slice(0, -'.jsonl'.length);
|
|
149
|
+
const sessionKey = decodeSessionKey(encoded);
|
|
150
|
+
if (seen.has(sessionKey))
|
|
151
|
+
continue;
|
|
152
|
+
const filePath = path.join(legacyDir, fileName);
|
|
153
|
+
seen.set(sessionKey, summarizeTranscript(filePath, sessionKey));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return Array.from(seen.values()).sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
|
157
|
+
}
|
|
158
|
+
function summarizeTranscript(filePath, sessionKey, sessionDir) {
|
|
159
|
+
let modifiedAt = new Date(0).toISOString();
|
|
160
|
+
let turnCount = 0;
|
|
161
|
+
let firstUserMessage;
|
|
162
|
+
try {
|
|
163
|
+
modifiedAt = fs.statSync(filePath).mtime.toISOString();
|
|
164
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
|
|
165
|
+
turnCount = lines.length;
|
|
166
|
+
for (const line of lines) {
|
|
167
|
+
try {
|
|
168
|
+
const entry = JSON.parse(line);
|
|
169
|
+
if (entry.role === 'user' && typeof entry.content === 'string' && entry.content.trim()) {
|
|
170
|
+
firstUserMessage = entry.content.toString().replace(/\s+/g, ' ').slice(0, 120);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch { /* skip malformed */ }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch { /* unreadable file */ }
|
|
178
|
+
return {
|
|
179
|
+
sessionKey,
|
|
180
|
+
fileName: path.basename(filePath),
|
|
181
|
+
modifiedAt,
|
|
182
|
+
turnCount,
|
|
183
|
+
firstUserMessage,
|
|
184
|
+
sessionDir,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Read all transcript entries for a session, in order. Used by `/resume`
|
|
189
|
+
* to seed the Agent's chatHistory.
|
|
190
|
+
*/
|
|
191
|
+
export function loadTranscript(workspaceRoot, sessionKey) {
|
|
192
|
+
return readTranscriptEntries(workspaceRoot, sessionKey, Number.MAX_SAFE_INTEGER);
|
|
193
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type PlanItemStatus = 'pending' | 'in_progress' | 'completed';
|
|
2
|
+
export interface PlanItem {
|
|
3
|
+
step: string;
|
|
4
|
+
status: PlanItemStatus;
|
|
5
|
+
}
|
|
6
|
+
export interface PlanState {
|
|
7
|
+
explanation?: string;
|
|
8
|
+
updatedAt: string;
|
|
9
|
+
items: PlanItem[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Durable per-session plan. Lives at
|
|
13
|
+
* <workspace>/.brainrouter/cli/sessions/<encodedKey>/tasks.json
|
|
14
|
+
*
|
|
15
|
+
* Legacy callers that don't pass a sessionKey read/write the older workspace-
|
|
16
|
+
* level `tasks.json` so existing workspaces keep their plan after the upgrade.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readPlan(workspaceRoot: string, sessionKey?: string): PlanState;
|
|
19
|
+
export declare function updatePlan(workspaceRoot: string, input: {
|
|
20
|
+
explanation?: string;
|
|
21
|
+
plan: PlanItem[];
|
|
22
|
+
}, sessionKey?: string): PlanState;
|
|
23
|
+
export declare function formatPlan(state: PlanState): string;
|