@kinqs/brainrouter-cli 0.3.7 → 0.3.8
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/changelog/0.2.0.md +15 -0
- package/changelog/0.3.0.md +20 -0
- package/changelog/0.3.1.md +22 -0
- package/changelog/0.3.2.md +15 -0
- package/changelog/0.3.3.md +19 -0
- package/changelog/0.3.4.md +20 -0
- package/changelog/0.3.5.md +9 -0
- package/changelog/0.3.6.md +9 -0
- package/changelog/0.3.7.md +20 -0
- package/changelog/0.3.8.md +30 -0
- package/changelog/README.md +41 -0
- package/dist/agent/agent.d.ts +22 -0
- package/dist/agent/agent.js +259 -82
- package/dist/agent/toolCallRecovery.d.ts +57 -0
- package/dist/agent/toolCallRecovery.js +130 -0
- package/dist/agent/toolSafety.d.ts +17 -0
- package/dist/agent/toolSafety.js +102 -0
- package/dist/cli/banner.js +2 -2
- package/dist/cli/cliPrompt.js +65 -0
- package/dist/cli/commands/config.js +1 -1
- package/dist/cli/commands/mcp.d.ts +1 -1
- package/dist/cli/commands/mcp.js +29 -7
- package/dist/cli/commands/mcpInstall.d.ts +20 -0
- package/dist/cli/commands/mcpInstall.js +87 -0
- package/dist/cli/commands/orchestration.js +33 -0
- package/dist/cli/commands/releaseNotes.d.ts +24 -0
- package/dist/cli/commands/releaseNotes.js +109 -0
- package/dist/cli/commands/schedule.d.ts +18 -0
- package/dist/cli/commands/schedule.js +189 -0
- package/dist/cli/commands/ui.js +2 -2
- package/dist/cli/ink/Picker.d.ts +6 -0
- package/dist/cli/ink/Picker.js +41 -6
- package/dist/cli/ink/runChat.js +112 -1
- package/dist/cli/ink/toolFormat.d.ts +11 -9
- package/dist/cli/ink/toolFormat.js +42 -16
- package/dist/cli/repl.d.ts +1 -1
- package/dist/cli/repl.js +9 -2
- package/dist/config/config.d.ts +1 -1
- package/dist/index.js +10 -1
- package/dist/memory/briefing.js +4 -4
- package/dist/orchestration/tools.d.ts +95 -2
- package/dist/orchestration/tools.js +119 -4
- package/dist/prompt/systemPrompt.js +5 -4
- package/dist/runtime/anthropicAdapter.d.ts +100 -0
- package/dist/runtime/anthropicAdapter.js +293 -0
- package/dist/runtime/cronParser.d.ts +23 -0
- package/dist/runtime/cronParser.js +122 -0
- package/dist/runtime/mcpClient.js +1 -1
- package/dist/runtime/mcpPool.d.ts +8 -0
- package/dist/runtime/mcpPool.js +19 -0
- package/dist/runtime/mcpUtils.d.ts +14 -0
- package/dist/runtime/mcpUtils.js +23 -0
- package/dist/runtime/scheduleTicker.d.ts +33 -0
- package/dist/runtime/scheduleTicker.js +99 -0
- package/dist/runtime/vendorSnippets.d.ts +45 -0
- package/dist/runtime/vendorSnippets.js +153 -0
- package/dist/state/scheduleStore.d.ts +37 -0
- package/dist/state/scheduleStore.js +64 -0
- package/package.json +7 -4
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// 0.3.8-I6: Native Anthropic `/v1/messages` adapter.
|
|
2
|
+
//
|
|
3
|
+
// BrainRouter's agent loop keeps chat history in OpenAI's shape
|
|
4
|
+
// (`{role:'system'|'user'|'assistant'|'tool', content, tool_calls?,
|
|
5
|
+
// tool_call_id?, name?}`) because that's the schema every other vendor
|
|
6
|
+
// in the catalog already speaks. This module is the ONE place that hides
|
|
7
|
+
// Anthropic's asymmetric shape from the rest of the codebase:
|
|
8
|
+
//
|
|
9
|
+
// - `system` is a top-level field, not a `messages[]` entry.
|
|
10
|
+
// - Tool results come back as `tool_result` blocks WRAPPED in a `user`
|
|
11
|
+
// message — there is no `tool` role.
|
|
12
|
+
// - Multiple pending tool_results must collapse into one user turn
|
|
13
|
+
// with a content array, not one user message per result.
|
|
14
|
+
// - `tool_use` ids are vendor-assigned and must round-trip verbatim.
|
|
15
|
+
// - `max_tokens` is REQUIRED (OpenAI treats it as optional).
|
|
16
|
+
// - Prompt caching breakpoints and extended thinking are first-class
|
|
17
|
+
// request fields, not headers.
|
|
18
|
+
//
|
|
19
|
+
// Streaming is out of scope for this PR — the agent loop still polls
|
|
20
|
+
// non-streaming responses.
|
|
21
|
+
import { acquireLLMSlot } from './llmSemaphore.js';
|
|
22
|
+
const ANTHROPIC_API_VERSION = '2023-06-01';
|
|
23
|
+
/**
|
|
24
|
+
* Route to the native adapter when the profile is Anthropic AND the
|
|
25
|
+
* endpoint hostname is `api.anthropic.com`, OR the explicit
|
|
26
|
+
* `BRAINROUTER_ANTHROPIC_NATIVE=1` override is set (for vended /
|
|
27
|
+
* reverse-proxied endpoints that still speak the native shape).
|
|
28
|
+
*
|
|
29
|
+
* Anything else — including `provider:'anthropic'` pointed at an
|
|
30
|
+
* OpenAI-compat gateway — stays on the existing OpenAI path so we
|
|
31
|
+
* don't break the OpenRouter / Anthropic-compat-shim flows.
|
|
32
|
+
*/
|
|
33
|
+
export function shouldUseAnthropicNative(config, env = process.env) {
|
|
34
|
+
if (config.provider !== 'anthropic')
|
|
35
|
+
return false;
|
|
36
|
+
if (env.BRAINROUTER_ANTHROPIC_NATIVE === '1')
|
|
37
|
+
return true;
|
|
38
|
+
const endpoint = config.endpoint ?? 'https://api.anthropic.com/v1';
|
|
39
|
+
try {
|
|
40
|
+
const host = new URL(endpoint).hostname.toLowerCase();
|
|
41
|
+
return host === 'api.anthropic.com' || host.endsWith('.anthropic.com');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function modelDefaultMaxTokens(model) {
|
|
48
|
+
const m = model.toLowerCase();
|
|
49
|
+
if (m.includes('haiku'))
|
|
50
|
+
return 2048;
|
|
51
|
+
return 4096;
|
|
52
|
+
}
|
|
53
|
+
function supportsExtendedThinking(model) {
|
|
54
|
+
// Sonnet 4.x / Opus 4.x families. Strip any vendor prefix.
|
|
55
|
+
const m = model.toLowerCase().split('/').pop() ?? '';
|
|
56
|
+
return /claude-(?:[a-z0-9.-]*-)?(sonnet|opus)-4/.test(m);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Pure transform: BrainRouter chat history (OpenAI shape) →
|
|
60
|
+
* Anthropic `/v1/messages` request body.
|
|
61
|
+
*
|
|
62
|
+
* Invariants enforced here so callers don't need to know the Anthropic
|
|
63
|
+
* rules:
|
|
64
|
+
* - The system message (first message with role:'system') is hoisted
|
|
65
|
+
* to the top-level `system` field and dropped from `messages`.
|
|
66
|
+
* - Consecutive `tool` role entries are merged into one synthetic
|
|
67
|
+
* `user` message whose content is an array of `tool_result` blocks.
|
|
68
|
+
* - Assistant messages with `tool_calls` emit a content array that
|
|
69
|
+
* interleaves text (when present) and `tool_use` blocks. The
|
|
70
|
+
* OpenAI tool_call.id is reused as the Anthropic tool_use.id —
|
|
71
|
+
* callers must echo it back on the matching tool_result.
|
|
72
|
+
*/
|
|
73
|
+
export function buildAnthropicRequest(config, messages, tools, options = {}) {
|
|
74
|
+
let systemText;
|
|
75
|
+
const out = [];
|
|
76
|
+
let pendingToolResults = null;
|
|
77
|
+
const flushToolResults = () => {
|
|
78
|
+
if (pendingToolResults && pendingToolResults.length > 0) {
|
|
79
|
+
out.push({ role: 'user', content: pendingToolResults });
|
|
80
|
+
}
|
|
81
|
+
pendingToolResults = null;
|
|
82
|
+
};
|
|
83
|
+
for (const m of messages) {
|
|
84
|
+
if (m.role === 'system') {
|
|
85
|
+
// First system message wins; later ones are concatenated so
|
|
86
|
+
// tagged system prompts (replaceTaggedSystemMessage) still flow.
|
|
87
|
+
const text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
|
|
88
|
+
systemText = systemText ? `${systemText}\n\n${text}` : text;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (m.role === 'tool') {
|
|
92
|
+
const block = {
|
|
93
|
+
type: 'tool_result',
|
|
94
|
+
tool_use_id: m.tool_call_id,
|
|
95
|
+
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
|
96
|
+
};
|
|
97
|
+
if (!pendingToolResults)
|
|
98
|
+
pendingToolResults = [];
|
|
99
|
+
pendingToolResults.push(block);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
flushToolResults();
|
|
103
|
+
if (m.role === 'assistant') {
|
|
104
|
+
const blocks = [];
|
|
105
|
+
const text = typeof m.content === 'string' ? m.content : '';
|
|
106
|
+
if (text)
|
|
107
|
+
blocks.push({ type: 'text', text });
|
|
108
|
+
if (Array.isArray(m.tool_calls)) {
|
|
109
|
+
for (const tc of m.tool_calls) {
|
|
110
|
+
let input = {};
|
|
111
|
+
const raw = tc?.function?.arguments;
|
|
112
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
113
|
+
try {
|
|
114
|
+
input = JSON.parse(raw);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
input = { _raw: raw };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else if (raw && typeof raw === 'object') {
|
|
121
|
+
input = raw;
|
|
122
|
+
}
|
|
123
|
+
blocks.push({
|
|
124
|
+
type: 'tool_use',
|
|
125
|
+
id: tc.id,
|
|
126
|
+
name: tc.function?.name,
|
|
127
|
+
input,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// An assistant turn with NO content + no tool_calls is dropped —
|
|
132
|
+
// Anthropic rejects empty assistant turns.
|
|
133
|
+
if (blocks.length > 0)
|
|
134
|
+
out.push({ role: 'assistant', content: blocks });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// user
|
|
138
|
+
const userText = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
|
|
139
|
+
out.push({ role: 'user', content: [{ type: 'text', text: userText }] });
|
|
140
|
+
}
|
|
141
|
+
flushToolResults();
|
|
142
|
+
const body = {
|
|
143
|
+
model: config.model,
|
|
144
|
+
max_tokens: options.maxTokens ?? modelDefaultMaxTokens(config.model),
|
|
145
|
+
messages: out,
|
|
146
|
+
};
|
|
147
|
+
if (systemText) {
|
|
148
|
+
if (options.cacheEnabled) {
|
|
149
|
+
body.system = [{ type: 'text', text: systemText, cache_control: { type: 'ephemeral' } }];
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
body.system = systemText;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (tools.length > 0) {
|
|
156
|
+
body.tools = tools.map((t) => ({
|
|
157
|
+
name: t.name,
|
|
158
|
+
description: t.description || '',
|
|
159
|
+
input_schema: t.inputSchema || { type: 'object', properties: {} },
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
// Cache breakpoint on the last assistant message (its last block) so
|
|
163
|
+
// every subsequent turn reads the prior context from cache.
|
|
164
|
+
if (options.cacheEnabled) {
|
|
165
|
+
for (let i = body.messages.length - 1; i >= 0; i--) {
|
|
166
|
+
const msg = body.messages[i];
|
|
167
|
+
if (msg.role === 'assistant' && Array.isArray(msg.content) && msg.content.length > 0) {
|
|
168
|
+
const last = msg.content[msg.content.length - 1];
|
|
169
|
+
last.cache_control = { type: 'ephemeral' };
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (options.effort === 'high' &&
|
|
175
|
+
supportsExtendedThinking(config.model)) {
|
|
176
|
+
body.thinking = {
|
|
177
|
+
type: 'enabled',
|
|
178
|
+
budget_tokens: options.thinkingBudgetTokens ?? 8000,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return body;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Pure transform: Anthropic response body → BrainRouter's internal
|
|
185
|
+
* `ChatResponse` shape. tool_use blocks become OpenAI-style toolCalls
|
|
186
|
+
* with the Anthropic id preserved verbatim, and `input` is re-serialized
|
|
187
|
+
* to the `function.arguments` JSON string the agent loop expects.
|
|
188
|
+
*/
|
|
189
|
+
export function parseAnthropicResponse(data) {
|
|
190
|
+
if (!data || typeof data !== 'object') {
|
|
191
|
+
throw new Error(`Anthropic response was not a JSON object: ${JSON.stringify(data).slice(0, 400)}`);
|
|
192
|
+
}
|
|
193
|
+
if (data.type === 'error' || data.error) {
|
|
194
|
+
const err = data.error ?? data;
|
|
195
|
+
const msg = typeof err === 'string' ? err : (err.message ?? JSON.stringify(err));
|
|
196
|
+
throw new Error(`Anthropic API error: ${msg}`);
|
|
197
|
+
}
|
|
198
|
+
const blocks = Array.isArray(data.content) ? data.content : [];
|
|
199
|
+
const textParts = [];
|
|
200
|
+
const thinkingParts = [];
|
|
201
|
+
const toolCalls = [];
|
|
202
|
+
for (const b of blocks) {
|
|
203
|
+
if (!b || typeof b !== 'object')
|
|
204
|
+
continue;
|
|
205
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
206
|
+
textParts.push(b.text);
|
|
207
|
+
}
|
|
208
|
+
else if (b.type === 'thinking' && typeof b.thinking === 'string') {
|
|
209
|
+
thinkingParts.push(b.thinking);
|
|
210
|
+
}
|
|
211
|
+
else if (b.type === 'tool_use') {
|
|
212
|
+
toolCalls.push({
|
|
213
|
+
id: b.id,
|
|
214
|
+
type: 'function',
|
|
215
|
+
function: {
|
|
216
|
+
name: b.name,
|
|
217
|
+
arguments: JSON.stringify(b.input ?? {}),
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const usage = data.usage
|
|
223
|
+
? {
|
|
224
|
+
prompt_tokens: data.usage.input_tokens,
|
|
225
|
+
completion_tokens: data.usage.output_tokens,
|
|
226
|
+
}
|
|
227
|
+
: undefined;
|
|
228
|
+
const result = {
|
|
229
|
+
content: textParts.join(''),
|
|
230
|
+
usage,
|
|
231
|
+
};
|
|
232
|
+
if (toolCalls.length > 0)
|
|
233
|
+
result.toolCalls = toolCalls;
|
|
234
|
+
if (thinkingParts.length > 0)
|
|
235
|
+
result.thinking = thinkingParts.join('');
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
export async function callAnthropic(config, messages, tools, options = {}) {
|
|
239
|
+
const rawEndpoint = config.endpoint || 'https://api.anthropic.com/v1';
|
|
240
|
+
const endpoint = rawEndpoint.replace(/\/+$/, '').replace(/\/messages$/, '');
|
|
241
|
+
const apiKey = config.apiKey || process.env.ANTHROPIC_API_KEY || '';
|
|
242
|
+
if (!apiKey) {
|
|
243
|
+
throw new Error('Anthropic API key is required (set ANTHROPIC_API_KEY or config.llm.apiKey).');
|
|
244
|
+
}
|
|
245
|
+
const cacheEnabled = process.env.BRAINROUTER_ANTHROPIC_CACHE === '1';
|
|
246
|
+
const body = buildAnthropicRequest(config, messages, tools, {
|
|
247
|
+
...options,
|
|
248
|
+
cacheEnabled: options.cacheEnabled ?? cacheEnabled,
|
|
249
|
+
});
|
|
250
|
+
const headers = {
|
|
251
|
+
'Content-Type': 'application/json',
|
|
252
|
+
'x-api-key': apiKey,
|
|
253
|
+
'anthropic-version': ANTHROPIC_API_VERSION,
|
|
254
|
+
};
|
|
255
|
+
const timeoutMs = Number(process.env.BRAINROUTER_LLM_TIMEOUT_MS || 120000);
|
|
256
|
+
const controller = new AbortController();
|
|
257
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
258
|
+
const release = await acquireLLMSlot();
|
|
259
|
+
let res;
|
|
260
|
+
try {
|
|
261
|
+
res = await fetch(`${endpoint}/messages`, {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
headers,
|
|
264
|
+
body: JSON.stringify(body),
|
|
265
|
+
signal: controller.signal,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
release();
|
|
270
|
+
if (err?.name === 'AbortError') {
|
|
271
|
+
throw new Error(`Anthropic request timed out after ${timeoutMs}ms.`);
|
|
272
|
+
}
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
clearTimeout(timeout);
|
|
277
|
+
}
|
|
278
|
+
release();
|
|
279
|
+
if (!res.ok) {
|
|
280
|
+
const errText = await res.text();
|
|
281
|
+
throw new Error(`Anthropic API error: ${res.status} ${res.statusText} - ${errText}`);
|
|
282
|
+
}
|
|
283
|
+
const data = await res.json();
|
|
284
|
+
const parsed = parseAnthropicResponse(data);
|
|
285
|
+
if (parsed.thinking && options.onThinking) {
|
|
286
|
+
options.onThinking(parsed.thinking);
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
content: parsed.content,
|
|
290
|
+
toolCalls: parsed.toolCalls,
|
|
291
|
+
usage: parsed.usage,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal 5-field cron parser — `minute hour dom month dow`.
|
|
3
|
+
*
|
|
4
|
+
* Vendored intentionally (no node-cron) to keep the dependency surface
|
|
5
|
+
* small and the semantics predictable. Supports `*`, comma lists,
|
|
6
|
+
* ranges (`1-5`), and steps (`15`, `0-30/10`). No seconds, no Quartz
|
|
7
|
+
* extensions, no `@reboot` macros.
|
|
8
|
+
*/
|
|
9
|
+
export interface CronExpr {
|
|
10
|
+
minute: Set<number>;
|
|
11
|
+
hour: Set<number>;
|
|
12
|
+
dom: Set<number>;
|
|
13
|
+
month: Set<number>;
|
|
14
|
+
dow: Set<number>;
|
|
15
|
+
raw: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function parseCron(expr: string): CronExpr | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* First firing instant strictly AFTER `after`. Walks the calendar
|
|
20
|
+
* forward, jumping months/days when fields don't match instead of
|
|
21
|
+
* scanning minute-by-minute.
|
|
22
|
+
*/
|
|
23
|
+
export declare function nextCronFire(cron: CronExpr, after: Date): Date;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal 5-field cron parser — `minute hour dom month dow`.
|
|
3
|
+
*
|
|
4
|
+
* Vendored intentionally (no node-cron) to keep the dependency surface
|
|
5
|
+
* small and the semantics predictable. Supports `*`, comma lists,
|
|
6
|
+
* ranges (`1-5`), and steps (`15`, `0-30/10`). No seconds, no Quartz
|
|
7
|
+
* extensions, no `@reboot` macros.
|
|
8
|
+
*/
|
|
9
|
+
const FIELD_RANGES = [
|
|
10
|
+
{ min: 0, max: 59 }, // minute
|
|
11
|
+
{ min: 0, max: 23 }, // hour
|
|
12
|
+
{ min: 1, max: 31 }, // day of month
|
|
13
|
+
{ min: 1, max: 12 }, // month
|
|
14
|
+
{ min: 0, max: 7 }, // day of week (0 or 7 = Sunday)
|
|
15
|
+
];
|
|
16
|
+
function parseField(raw, min, max) {
|
|
17
|
+
if (raw.length === 0)
|
|
18
|
+
return undefined;
|
|
19
|
+
const out = new Set();
|
|
20
|
+
for (const part of raw.split(',')) {
|
|
21
|
+
if (!part)
|
|
22
|
+
return undefined;
|
|
23
|
+
let step = 1;
|
|
24
|
+
let range = part;
|
|
25
|
+
const slash = part.indexOf('/');
|
|
26
|
+
if (slash >= 0) {
|
|
27
|
+
const s = Number(part.slice(slash + 1));
|
|
28
|
+
if (!Number.isInteger(s) || s < 1)
|
|
29
|
+
return undefined;
|
|
30
|
+
step = s;
|
|
31
|
+
range = part.slice(0, slash);
|
|
32
|
+
}
|
|
33
|
+
let lo;
|
|
34
|
+
let hi;
|
|
35
|
+
if (range === '*' || range === '') {
|
|
36
|
+
lo = min;
|
|
37
|
+
hi = max;
|
|
38
|
+
}
|
|
39
|
+
else if (range.includes('-')) {
|
|
40
|
+
const [a, b] = range.split('-');
|
|
41
|
+
lo = Number(a);
|
|
42
|
+
hi = Number(b);
|
|
43
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi))
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const v = Number(range);
|
|
48
|
+
if (!Number.isInteger(v))
|
|
49
|
+
return undefined;
|
|
50
|
+
lo = v;
|
|
51
|
+
hi = v;
|
|
52
|
+
}
|
|
53
|
+
if (lo < min || hi > max || lo > hi)
|
|
54
|
+
return undefined;
|
|
55
|
+
for (let i = lo; i <= hi; i += step)
|
|
56
|
+
out.add(i);
|
|
57
|
+
}
|
|
58
|
+
return out.size > 0 ? out : undefined;
|
|
59
|
+
}
|
|
60
|
+
export function parseCron(expr) {
|
|
61
|
+
if (typeof expr !== 'string')
|
|
62
|
+
return undefined;
|
|
63
|
+
const trimmed = expr.trim();
|
|
64
|
+
if (!trimmed)
|
|
65
|
+
return undefined;
|
|
66
|
+
const fields = trimmed.split(/\s+/);
|
|
67
|
+
if (fields.length !== 5)
|
|
68
|
+
return undefined;
|
|
69
|
+
const parsed = fields.map((f, i) => parseField(f, FIELD_RANGES[i].min, FIELD_RANGES[i].max));
|
|
70
|
+
if (parsed.some((p) => !p))
|
|
71
|
+
return undefined;
|
|
72
|
+
const [minute, hour, dom, month, dowRaw] = parsed;
|
|
73
|
+
// Normalize dow: 7 → 0 so Sunday has one representation.
|
|
74
|
+
const dow = new Set();
|
|
75
|
+
for (const v of dowRaw)
|
|
76
|
+
dow.add(v === 7 ? 0 : v);
|
|
77
|
+
return { minute, hour, dom, month, dow, raw: trimmed };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* First firing instant strictly AFTER `after`. Walks the calendar
|
|
81
|
+
* forward, jumping months/days when fields don't match instead of
|
|
82
|
+
* scanning minute-by-minute.
|
|
83
|
+
*/
|
|
84
|
+
export function nextCronFire(cron, after) {
|
|
85
|
+
const d = new Date(after.getTime());
|
|
86
|
+
d.setSeconds(0, 0);
|
|
87
|
+
d.setMinutes(d.getMinutes() + 1);
|
|
88
|
+
const domRestricted = cron.dom.size !== 31;
|
|
89
|
+
const dowRestricted = cron.dow.size !== 7;
|
|
90
|
+
// Safety cap — 8 years of day-level iterations is plenty; if we exhaust
|
|
91
|
+
// it the expression is effectively impossible (e.g. Feb 30).
|
|
92
|
+
for (let i = 0; i < 366 * 8; i++) {
|
|
93
|
+
if (!cron.month.has(d.getMonth() + 1)) {
|
|
94
|
+
d.setDate(1);
|
|
95
|
+
d.setMonth(d.getMonth() + 1);
|
|
96
|
+
d.setHours(0, 0, 0, 0);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const domOk = cron.dom.has(d.getDate());
|
|
100
|
+
const dowOk = cron.dow.has(d.getDay());
|
|
101
|
+
// Vixie-cron semantics: when both dom and dow are restricted, EITHER
|
|
102
|
+
// match counts. Otherwise both must match (the unrestricted field is
|
|
103
|
+
// effectively `*` and always matches).
|
|
104
|
+
const dateMatches = domRestricted && dowRestricted ? (domOk || dowOk) : (domOk && dowOk);
|
|
105
|
+
if (!dateMatches) {
|
|
106
|
+
d.setDate(d.getDate() + 1);
|
|
107
|
+
d.setHours(0, 0, 0, 0);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!cron.hour.has(d.getHours())) {
|
|
111
|
+
d.setHours(d.getHours() + 1);
|
|
112
|
+
d.setMinutes(0, 0, 0);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!cron.minute.has(d.getMinutes())) {
|
|
116
|
+
d.setMinutes(d.getMinutes() + 1, 0, 0);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
return d;
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`cron next-fire search exhausted for "${cron.raw}"`);
|
|
122
|
+
}
|
|
@@ -23,7 +23,7 @@ export class McpClientWrapper {
|
|
|
23
23
|
identity = 'unknown';
|
|
24
24
|
serverName;
|
|
25
25
|
constructor() {
|
|
26
|
-
this.client = new Client({ name: 'brainrouter-cli', version: '0.3.
|
|
26
|
+
this.client = new Client({ name: 'brainrouter-cli', version: '0.3.8' }, { capabilities: {} });
|
|
27
27
|
}
|
|
28
28
|
/** Whether this wrapper has an active MCP transport. */
|
|
29
29
|
isConnected() {
|
|
@@ -52,6 +52,14 @@ export type McpServerStatus = {
|
|
|
52
52
|
* mode.
|
|
53
53
|
*/
|
|
54
54
|
export declare function selectMcpServerIds(servers: Record<string, ServerConfig>, activeServer?: string, requestedProfile?: string): string[];
|
|
55
|
+
/**
|
|
56
|
+
* 0.3.8-R5 — Single-underscore `mcp_<server>_<tool>` is the canonical
|
|
57
|
+
* tool-name shape across the CLI. Any legacy double-underscore
|
|
58
|
+
* `mcp__<server>__<tool>` form that arrives at the pool boundary
|
|
59
|
+
* (e.g. through an external surface or older skill) is collapsed here
|
|
60
|
+
* so downstream code can assume one convention.
|
|
61
|
+
*/
|
|
62
|
+
export declare function normalizeMcpToolName(name: string): string;
|
|
55
63
|
export declare class McpClientPool {
|
|
56
64
|
/** serverId → connected wrapper. */
|
|
57
65
|
private clients;
|
package/dist/runtime/mcpPool.js
CHANGED
|
@@ -24,6 +24,22 @@ export function selectMcpServerIds(servers, activeServer, requestedProfile) {
|
|
|
24
24
|
return identity !== 'brainrouter' || id === activeBrainrouter;
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* 0.3.8-R5 — Single-underscore `mcp_<server>_<tool>` is the canonical
|
|
29
|
+
* tool-name shape across the CLI. Any legacy double-underscore
|
|
30
|
+
* `mcp__<server>__<tool>` form that arrives at the pool boundary
|
|
31
|
+
* (e.g. through an external surface or older skill) is collapsed here
|
|
32
|
+
* so downstream code can assume one convention.
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeMcpToolName(name) {
|
|
35
|
+
if (!name.startsWith('mcp__'))
|
|
36
|
+
return name;
|
|
37
|
+
const rest = name.slice('mcp__'.length);
|
|
38
|
+
const sep = rest.indexOf('__');
|
|
39
|
+
if (sep < 0)
|
|
40
|
+
return name;
|
|
41
|
+
return `mcp_${rest.slice(0, sep)}_${rest.slice(sep + 2)}`;
|
|
42
|
+
}
|
|
27
43
|
function isBrainrouterOwnedTool(name) {
|
|
28
44
|
return name.startsWith('memory_') ||
|
|
29
45
|
[
|
|
@@ -286,6 +302,9 @@ export class McpClientPool {
|
|
|
286
302
|
}
|
|
287
303
|
/** Internal — map a name (prefixed OR raw) to a concrete server + tool. */
|
|
288
304
|
resolveToolCall(name) {
|
|
305
|
+
// Back-compat: any legacy double-underscore form is collapsed first so
|
|
306
|
+
// the rest of the resolver only deals with the canonical shape.
|
|
307
|
+
name = normalizeMcpToolName(name);
|
|
289
308
|
// Fast path: exact prefixed form match in the index.
|
|
290
309
|
if (name.startsWith('mcp_')) {
|
|
291
310
|
const direct = this.prefixedToServer.get(name);
|
|
@@ -36,3 +36,17 @@ export declare function callMcpTool<T = any>(client: McpClient, name: string, ar
|
|
|
36
36
|
* to UUIDs or namespacing per-role) is a one-file edit, not a sweep.
|
|
37
37
|
*/
|
|
38
38
|
export declare function childSessionKey(parentSessionKey: string, childId: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Discovery helper for `Set<string>` tool-name lookups across MCP prefix
|
|
41
|
+
* conventions. The pool normalises tool names to single-underscore
|
|
42
|
+
* `mcp_<server>_<tool>` at the boundary (0.3.8-R5), so a discovery check
|
|
43
|
+
* for "is `memory_recall` exposed?" must match either the bare name (raw
|
|
44
|
+
* single-server flow) or any `mcp_<server>_memory_recall` (post-pool
|
|
45
|
+
* normalisation).
|
|
46
|
+
*
|
|
47
|
+
* Use this anywhere a caller previously did `toolNames.has('memory_recall')` —
|
|
48
|
+
* those bare-name checks silently miss prefixed names and were the root
|
|
49
|
+
* cause of `🧠 Briefing: 0 records from (none)` even with the brain MCP
|
|
50
|
+
* connected.
|
|
51
|
+
*/
|
|
52
|
+
export declare function hasMcpTool(toolNames: Set<string>, bareName: string): boolean;
|
package/dist/runtime/mcpUtils.js
CHANGED
|
@@ -62,3 +62,26 @@ export async function callMcpTool(client, name, args) {
|
|
|
62
62
|
export function childSessionKey(parentSessionKey, childId) {
|
|
63
63
|
return `${parentSessionKey}:child:${childId}`;
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Discovery helper for `Set<string>` tool-name lookups across MCP prefix
|
|
67
|
+
* conventions. The pool normalises tool names to single-underscore
|
|
68
|
+
* `mcp_<server>_<tool>` at the boundary (0.3.8-R5), so a discovery check
|
|
69
|
+
* for "is `memory_recall` exposed?" must match either the bare name (raw
|
|
70
|
+
* single-server flow) or any `mcp_<server>_memory_recall` (post-pool
|
|
71
|
+
* normalisation).
|
|
72
|
+
*
|
|
73
|
+
* Use this anywhere a caller previously did `toolNames.has('memory_recall')` —
|
|
74
|
+
* those bare-name checks silently miss prefixed names and were the root
|
|
75
|
+
* cause of `🧠 Briefing: 0 records from (none)` even with the brain MCP
|
|
76
|
+
* connected.
|
|
77
|
+
*/
|
|
78
|
+
export function hasMcpTool(toolNames, bareName) {
|
|
79
|
+
if (toolNames.has(bareName))
|
|
80
|
+
return true;
|
|
81
|
+
const suffix = `_${bareName}`;
|
|
82
|
+
for (const name of toolNames) {
|
|
83
|
+
if (name.startsWith('mcp_') && name.endsWith(suffix))
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process ticker that fires due `/schedule` jobs.
|
|
3
|
+
*
|
|
4
|
+
* Singleton per CLI process. Wakes every 30s (override via
|
|
5
|
+
* `BRAINROUTER_SCHEDULE_TICK_MS`), reloads the persisted store, fires
|
|
6
|
+
* any jobs whose `nextRun` is now in the past, then advances `nextRun`
|
|
7
|
+
* past `now()` so a missed window only fires ONCE (catch-up
|
|
8
|
+
* idempotency).
|
|
9
|
+
*
|
|
10
|
+
* No daemon — the ticker dies with the REPL. Schedules that miss
|
|
11
|
+
* because the CLI was closed are caught on the next REPL boot via the
|
|
12
|
+
* same "advance past now" rule.
|
|
13
|
+
*/
|
|
14
|
+
import { type ScheduleRecord } from '../state/scheduleStore.js';
|
|
15
|
+
export interface ScheduleTickerOptions {
|
|
16
|
+
workspaceRoot: string;
|
|
17
|
+
sessionKey: string;
|
|
18
|
+
/** Called when a schedule is due. Fire-and-forget. */
|
|
19
|
+
fire: (command: string, schedule: ScheduleRecord) => void;
|
|
20
|
+
/** Override the wake interval. Defaults to env or 30 000 ms. */
|
|
21
|
+
intervalMs?: number;
|
|
22
|
+
/** Injected clock for tests. */
|
|
23
|
+
now?: () => number;
|
|
24
|
+
/** Called when a fire is skipped because the expression is unparseable. */
|
|
25
|
+
onError?: (msg: string) => void;
|
|
26
|
+
}
|
|
27
|
+
export interface ScheduleTickerHandle {
|
|
28
|
+
stop: () => void;
|
|
29
|
+
/** Force one immediate scan (tests + boot catch-up). */
|
|
30
|
+
tickNow: () => void;
|
|
31
|
+
}
|
|
32
|
+
export declare function isScheduleTickerRunning(): boolean;
|
|
33
|
+
export declare function startScheduleTicker(opts: ScheduleTickerOptions): ScheduleTickerHandle;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process ticker that fires due `/schedule` jobs.
|
|
3
|
+
*
|
|
4
|
+
* Singleton per CLI process. Wakes every 30s (override via
|
|
5
|
+
* `BRAINROUTER_SCHEDULE_TICK_MS`), reloads the persisted store, fires
|
|
6
|
+
* any jobs whose `nextRun` is now in the past, then advances `nextRun`
|
|
7
|
+
* past `now()` so a missed window only fires ONCE (catch-up
|
|
8
|
+
* idempotency).
|
|
9
|
+
*
|
|
10
|
+
* No daemon — the ticker dies with the REPL. Schedules that miss
|
|
11
|
+
* because the CLI was closed are caught on the next REPL boot via the
|
|
12
|
+
* same "advance past now" rule.
|
|
13
|
+
*/
|
|
14
|
+
import { loadSchedules, recordFire, removeSchedule, setScheduleEnabled } from '../state/scheduleStore.js';
|
|
15
|
+
import { parseCron, nextCronFire } from './cronParser.js';
|
|
16
|
+
const DEFAULT_INTERVAL_MS = 30_000;
|
|
17
|
+
const MIN_INTERVAL_MS = 100;
|
|
18
|
+
let active = null;
|
|
19
|
+
export function isScheduleTickerRunning() {
|
|
20
|
+
return active !== null;
|
|
21
|
+
}
|
|
22
|
+
export function startScheduleTicker(opts) {
|
|
23
|
+
if (active)
|
|
24
|
+
return active;
|
|
25
|
+
const envOverride = Number(process.env.BRAINROUTER_SCHEDULE_TICK_MS);
|
|
26
|
+
const rawInterval = opts.intervalMs ?? (Number.isFinite(envOverride) && envOverride > 0 ? envOverride : DEFAULT_INTERVAL_MS);
|
|
27
|
+
const interval = Math.max(MIN_INTERVAL_MS, rawInterval);
|
|
28
|
+
const now = opts.now ?? (() => Date.now());
|
|
29
|
+
let stopped = false;
|
|
30
|
+
let timer = null;
|
|
31
|
+
const scan = () => {
|
|
32
|
+
if (stopped)
|
|
33
|
+
return;
|
|
34
|
+
const t = now();
|
|
35
|
+
let schedules;
|
|
36
|
+
try {
|
|
37
|
+
schedules = loadSchedules(opts.workspaceRoot);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
opts.onError?.(`load failed: ${err.message}`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
for (const s of schedules) {
|
|
44
|
+
if (!s.enabled)
|
|
45
|
+
continue;
|
|
46
|
+
if (s.owner !== opts.sessionKey)
|
|
47
|
+
continue;
|
|
48
|
+
const nextMs = Date.parse(s.nextRun);
|
|
49
|
+
if (!Number.isFinite(nextMs) || nextMs > t)
|
|
50
|
+
continue;
|
|
51
|
+
try {
|
|
52
|
+
opts.fire(s.command, s);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
opts.onError?.(`fire failed for ${s.id}: ${err.message}`);
|
|
56
|
+
}
|
|
57
|
+
if (s.kind === 'once') {
|
|
58
|
+
removeSchedule(opts.workspaceRoot, s.id);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
// Cron: advance nextRun strictly past `t`. parseCron should always
|
|
62
|
+
// succeed (we validated on add), but tolerate corruption by
|
|
63
|
+
// disabling rather than spinning forever on a past nextRun.
|
|
64
|
+
const cron = parseCron(s.expr);
|
|
65
|
+
if (!cron) {
|
|
66
|
+
opts.onError?.(`cron expression invalid; disabling ${s.id}: ${s.expr}`);
|
|
67
|
+
try {
|
|
68
|
+
setScheduleEnabled(opts.workspaceRoot, s.id, false);
|
|
69
|
+
}
|
|
70
|
+
catch { /* swallow */ }
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const next = nextCronFire(cron, new Date(t));
|
|
74
|
+
recordFire(opts.workspaceRoot, s.id, new Date(t), next.toISOString());
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const tick = () => {
|
|
78
|
+
if (stopped)
|
|
79
|
+
return;
|
|
80
|
+
scan();
|
|
81
|
+
if (stopped)
|
|
82
|
+
return;
|
|
83
|
+
timer = setTimeout(tick, interval);
|
|
84
|
+
};
|
|
85
|
+
// Defer the first scan to the next macrotask so the caller finishes
|
|
86
|
+
// wiring (e.g. Ink's `onReady`) before any fire callback runs.
|
|
87
|
+
timer = setTimeout(tick, 0);
|
|
88
|
+
active = {
|
|
89
|
+
stop: () => {
|
|
90
|
+
stopped = true;
|
|
91
|
+
if (timer)
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
timer = null;
|
|
94
|
+
active = null;
|
|
95
|
+
},
|
|
96
|
+
tickNow: scan,
|
|
97
|
+
};
|
|
98
|
+
return active;
|
|
99
|
+
}
|