@kinqs/brainrouter-cli 0.3.6 → 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/README.md +29 -52
- package/agents/architect.json +18 -0
- package/agents/explorer.json +18 -0
- package/agents/reviewer.json +18 -0
- package/agents/verifier.json +18 -0
- package/agents/worker.json +18 -0
- 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 +34 -1
- package/dist/agent/agent.js +372 -79
- 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.d.ts +20 -0
- package/dist/cli/banner.js +47 -14
- package/dist/cli/cliPrompt.d.ts +40 -3
- package/dist/cli/cliPrompt.js +117 -25
- package/dist/cli/commands/_context.d.ts +3 -1
- package/dist/cli/commands/_helpers.d.ts +1 -1
- package/dist/cli/commands/config.d.ts +46 -0
- package/dist/cli/commands/config.js +1042 -0
- package/dist/cli/commands/init.d.ts +20 -0
- package/dist/cli/commands/init.js +64 -0
- package/dist/cli/commands/login.d.ts +13 -0
- package/dist/cli/commands/login.js +179 -0
- package/dist/cli/commands/mcp.d.ts +13 -11
- package/dist/cli/commands/mcp.js +261 -74
- package/dist/cli/commands/mcpInstall.d.ts +20 -0
- package/dist/cli/commands/mcpInstall.js +87 -0
- package/dist/cli/commands/orchestration.js +51 -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 +119 -60
- package/dist/cli/commands/workflow.d.ts +2 -0
- package/dist/cli/commands/workflow.js +54 -8
- package/dist/cli/ink/ChatApp.d.ts +206 -0
- package/dist/cli/ink/ChatApp.js +493 -0
- package/dist/cli/ink/Frame.d.ts +26 -0
- package/dist/cli/ink/Frame.js +5 -0
- package/dist/cli/ink/Picker.d.ts +71 -0
- package/dist/cli/ink/Picker.js +168 -0
- package/dist/cli/ink/SlashPalette.d.ts +51 -0
- package/dist/cli/ink/SlashPalette.js +136 -0
- package/dist/cli/ink/TextField.d.ts +34 -0
- package/dist/cli/ink/TextField.js +47 -0
- package/dist/cli/ink/WizardApp.d.ts +7 -0
- package/dist/cli/ink/WizardApp.js +422 -0
- package/dist/cli/ink/ambientChat.d.ts +34 -0
- package/dist/cli/ink/ambientChat.js +7 -0
- package/dist/cli/ink/consoleCapture.d.ts +11 -0
- package/dist/cli/ink/consoleCapture.js +33 -0
- package/dist/cli/ink/markdownRender.d.ts +41 -0
- package/dist/cli/ink/markdownRender.js +278 -0
- package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
- package/dist/cli/ink/renderWithResizeClear.js +33 -0
- package/dist/cli/ink/runChat.d.ts +34 -0
- package/dist/cli/ink/runChat.js +682 -0
- package/dist/cli/ink/runPicker.d.ts +31 -0
- package/dist/cli/ink/runPicker.js +139 -0
- package/dist/cli/ink/runSlashPalette.d.ts +23 -0
- package/dist/cli/ink/runSlashPalette.js +33 -0
- package/dist/cli/ink/runWizard.d.ts +22 -0
- package/dist/cli/ink/runWizard.js +133 -0
- package/dist/cli/ink/stdinHandoff.d.ts +51 -0
- package/dist/cli/ink/stdinHandoff.js +78 -0
- package/dist/cli/ink/toolFormat.d.ts +75 -0
- package/dist/cli/ink/toolFormat.js +206 -0
- package/dist/cli/ink/useTerminalSize.d.ts +35 -0
- package/dist/cli/ink/useTerminalSize.js +26 -0
- package/dist/cli/repl.d.ts +25 -3
- package/dist/cli/repl.js +52 -714
- package/dist/cli/slashSuggest.d.ts +32 -0
- package/dist/cli/slashSuggest.js +146 -0
- package/dist/cli/wizard/modelsApi.d.ts +72 -0
- package/dist/cli/wizard/modelsApi.js +166 -0
- package/dist/cli/wizard/picker.d.ts +202 -0
- package/dist/cli/wizard/picker.js +547 -0
- package/dist/cli/wizard/providers.d.ts +86 -0
- package/dist/cli/wizard/providers.js +190 -0
- package/dist/cli/wizard/runner.d.ts +13 -0
- package/dist/cli/wizard/runner.js +488 -0
- package/dist/cli/wizard/types.d.ts +122 -0
- package/dist/cli/wizard/types.js +109 -0
- package/dist/config/config.d.ts +13 -1
- package/dist/config/config.js +45 -3
- package/dist/index.js +157 -206
- package/dist/memory/briefing.d.ts +1 -1
- package/dist/memory/briefing.js +4 -4
- package/dist/memory/consolidation.d.ts +1 -1
- package/dist/orchestration/agentRegistry.d.ts +36 -0
- package/dist/orchestration/agentRegistry.js +64 -0
- package/dist/orchestration/orchestrator.d.ts +7 -0
- package/dist/orchestration/orchestrator.js +2 -0
- package/dist/orchestration/tools.d.ts +105 -3
- package/dist/orchestration/tools.js +167 -8
- package/dist/prompt/skillCatalog.d.ts +11 -0
- package/dist/prompt/skillCatalog.js +134 -0
- package/dist/prompt/skillRunner.d.ts +2 -2
- package/dist/prompt/skillRunner.js +2 -31
- package/dist/prompt/systemPrompt.js +7 -2
- 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 +14 -11
- package/dist/runtime/mcpPool.d.ts +170 -0
- package/dist/runtime/mcpPool.js +442 -0
- package/dist/runtime/mcpUtils.d.ts +17 -1
- 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 +14 -5
- package/.env.example +0 -116
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
5
|
+
const WORKSPACE_SKILL_ROOTS = ['skills', '.brainrouter/skills'];
|
|
6
|
+
export function listFilesystemSkills(workspaceRoot) {
|
|
7
|
+
const seen = new Map();
|
|
8
|
+
for (const root of skillSearchRoots(workspaceRoot)) {
|
|
9
|
+
if (!fs.existsSync(root))
|
|
10
|
+
continue;
|
|
11
|
+
const scope = inferRootScope(root, workspaceRoot);
|
|
12
|
+
for (const filePath of findSkillFiles(root)) {
|
|
13
|
+
const parsed = parseSkillFile(filePath);
|
|
14
|
+
if (!parsed)
|
|
15
|
+
continue;
|
|
16
|
+
const rel = path.relative(root, filePath);
|
|
17
|
+
const category = rel.split(path.sep)[0] || 'uncategorized';
|
|
18
|
+
if (!seen.has(parsed.name)) {
|
|
19
|
+
seen.set(parsed.name, {
|
|
20
|
+
name: parsed.name,
|
|
21
|
+
category,
|
|
22
|
+
description: parsed.description,
|
|
23
|
+
scope,
|
|
24
|
+
source: 'filesystem',
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return Array.from(seen.values()).sort(sortSkills);
|
|
30
|
+
}
|
|
31
|
+
export function mergeSkillLists(primary, fallback) {
|
|
32
|
+
const merged = new Map();
|
|
33
|
+
for (const skill of primary) {
|
|
34
|
+
merged.set(skill.name, { ...skill, source: skill.source ?? 'mcp' });
|
|
35
|
+
}
|
|
36
|
+
for (const skill of fallback) {
|
|
37
|
+
if (!merged.has(skill.name))
|
|
38
|
+
merged.set(skill.name, skill);
|
|
39
|
+
}
|
|
40
|
+
return Array.from(merged.values()).sort(sortSkills);
|
|
41
|
+
}
|
|
42
|
+
export function sortSkills(a, b) {
|
|
43
|
+
return (a.category ?? '').localeCompare(b.category ?? '') || a.name.localeCompare(b.name);
|
|
44
|
+
}
|
|
45
|
+
export function skillSearchRoots(workspaceRoot) {
|
|
46
|
+
const roots = [];
|
|
47
|
+
for (const sub of WORKSPACE_SKILL_ROOTS)
|
|
48
|
+
roots.push(path.join(workspaceRoot, sub));
|
|
49
|
+
const mcpPkgDir = resolveInstalledMcpPackageDir();
|
|
50
|
+
if (mcpPkgDir) {
|
|
51
|
+
roots.push(path.join(mcpPkgDir, 'skills'));
|
|
52
|
+
const monorepoRoot = path.dirname(mcpPkgDir);
|
|
53
|
+
roots.push(path.join(monorepoRoot, 'skills'));
|
|
54
|
+
}
|
|
55
|
+
return [...new Set(roots.map((root) => path.resolve(root)))];
|
|
56
|
+
}
|
|
57
|
+
function resolveInstalledMcpPackageDir() {
|
|
58
|
+
try {
|
|
59
|
+
const pkgJsonPath = requireFromHere.resolve('@kinqs/brainrouter-mcp-server/package.json');
|
|
60
|
+
return path.dirname(pkgJsonPath);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function inferRootScope(root, workspaceRoot) {
|
|
67
|
+
const resolvedWorkspace = path.resolve(workspaceRoot);
|
|
68
|
+
const resolvedRoot = path.resolve(root);
|
|
69
|
+
if (resolvedRoot.startsWith(path.join(resolvedWorkspace, '.brainrouter')))
|
|
70
|
+
return 'local';
|
|
71
|
+
if (isBrainRouterRepoRoot(path.dirname(resolvedRoot)))
|
|
72
|
+
return 'global';
|
|
73
|
+
return resolvedRoot.startsWith(resolvedWorkspace) ? 'local' : 'global';
|
|
74
|
+
}
|
|
75
|
+
function isBrainRouterRepoRoot(root) {
|
|
76
|
+
return (fs.existsSync(path.join(root, 'brainrouter', 'package.json')) &&
|
|
77
|
+
fs.existsSync(path.join(root, 'brainrouter-cli', 'package.json')) &&
|
|
78
|
+
fs.existsSync(path.join(root, 'skills')));
|
|
79
|
+
}
|
|
80
|
+
function findSkillFiles(root) {
|
|
81
|
+
const results = [];
|
|
82
|
+
function walk(current, depth) {
|
|
83
|
+
if (depth < 0)
|
|
84
|
+
return;
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
if (entry.name === 'node_modules' || entry.name === '.git')
|
|
94
|
+
continue;
|
|
95
|
+
const full = path.join(current, entry.name);
|
|
96
|
+
if (entry.isDirectory())
|
|
97
|
+
walk(full, depth - 1);
|
|
98
|
+
else if (entry.isFile() && entry.name === 'SKILL.md')
|
|
99
|
+
results.push(full);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
walk(root, 5);
|
|
103
|
+
return results;
|
|
104
|
+
}
|
|
105
|
+
function parseSkillFile(filePath) {
|
|
106
|
+
let raw;
|
|
107
|
+
try {
|
|
108
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
114
|
+
const block = frontmatter?.[1] ?? '';
|
|
115
|
+
const name = readYamlScalar(block, 'name') ?? path.basename(path.dirname(filePath));
|
|
116
|
+
const description = readYamlScalar(block, 'description') ?? firstParagraph(raw);
|
|
117
|
+
if (!name)
|
|
118
|
+
return undefined;
|
|
119
|
+
return { name, description };
|
|
120
|
+
}
|
|
121
|
+
function readYamlScalar(block, key) {
|
|
122
|
+
const match = block.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
|
|
123
|
+
if (!match?.[1])
|
|
124
|
+
return undefined;
|
|
125
|
+
return match[1].trim().replace(/^['"]|['"]$/g, '');
|
|
126
|
+
}
|
|
127
|
+
function firstParagraph(raw) {
|
|
128
|
+
const withoutFrontmatter = raw.replace(/^---\r?\n[\s\S]*?\r?\n---/, '').trim();
|
|
129
|
+
const line = withoutFrontmatter
|
|
130
|
+
.split(/\r?\n/)
|
|
131
|
+
.map((part) => part.trim())
|
|
132
|
+
.find((part) => part && !part.startsWith('#'));
|
|
133
|
+
return line;
|
|
134
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { McpClient } from '../runtime/mcpUtils.js';
|
|
2
2
|
export interface SkillResolution {
|
|
3
3
|
name: string;
|
|
4
4
|
body: string;
|
|
@@ -24,7 +24,7 @@ export declare const SLASH_TO_SKILL: Record<string, string>;
|
|
|
24
24
|
* server has loaded, including their own private skills), falls back to a
|
|
25
25
|
* local filesystem scan of `skills/` for when the MCP tool is unavailable.
|
|
26
26
|
*/
|
|
27
|
-
export declare function resolveSkill(mcpClient:
|
|
27
|
+
export declare function resolveSkill(mcpClient: McpClient, name: string, workspaceRoot: string, section?: RunSkillOptions['section']): Promise<SkillResolution>;
|
|
28
28
|
/**
|
|
29
29
|
* Build the user prompt that asks the agent to execute a skill. The skill
|
|
30
30
|
* body is embedded so the agent does not need to round-trip through
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
const requireFromHere = createRequire(import.meta.url);
|
|
3
|
+
import { skillSearchRoots } from './skillCatalog.js';
|
|
5
4
|
/**
|
|
6
5
|
* Slash-command → skill mapping. Each entry names the skill catalogued in
|
|
7
6
|
* the BrainRouter skills/ folder (and exposed by the MCP server) that the
|
|
@@ -21,7 +20,6 @@ export const SLASH_TO_SKILL = {
|
|
|
21
20
|
'/refactor': 'code-simplification',
|
|
22
21
|
'/test': 'testing-skill',
|
|
23
22
|
};
|
|
24
|
-
const WORKSPACE_SKILL_ROOTS = ['skills', '.brainrouter/skills'];
|
|
25
23
|
/**
|
|
26
24
|
* Resolve a skill by name. Prefers the MCP server (so users get whatever the
|
|
27
25
|
* server has loaded, including their own private skills), falls back to a
|
|
@@ -61,34 +59,7 @@ function readSkillFromFilesystem(workspaceRoot, name) {
|
|
|
61
59
|
}
|
|
62
60
|
return undefined;
|
|
63
61
|
}
|
|
64
|
-
|
|
65
|
-
* Roots to search for SKILL.md when the MCP server is unavailable. Includes:
|
|
66
|
-
* - the user's workspace (so per-project skills win locally)
|
|
67
|
-
* - the installed @kinqs/brainrouter-mcp-server package directory (so the canonical
|
|
68
|
-
* BrainRouter catalogue is found even when MCP is down, because prepack
|
|
69
|
-
* bundles `skills/` into the published package)
|
|
70
|
-
* - the monorepo root (when running from source during development)
|
|
71
|
-
*/
|
|
72
|
-
function skillSearchRoots(workspaceRoot) {
|
|
73
|
-
const roots = [];
|
|
74
|
-
for (const sub of WORKSPACE_SKILL_ROOTS) {
|
|
75
|
-
roots.push(path.join(workspaceRoot, sub));
|
|
76
|
-
}
|
|
77
|
-
const mcpPkgDir = resolveInstalledMcpPackageDir();
|
|
78
|
-
if (mcpPkgDir)
|
|
79
|
-
roots.push(path.join(mcpPkgDir, 'skills'));
|
|
80
|
-
return roots;
|
|
81
|
-
}
|
|
82
|
-
function resolveInstalledMcpPackageDir() {
|
|
83
|
-
try {
|
|
84
|
-
const pkgJsonPath = requireFromHere.resolve('@kinqs/brainrouter-mcp-server/package.json');
|
|
85
|
-
return path.dirname(pkgJsonPath);
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
return undefined;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
function findSkillDir(rootDir, skillName, depth = 3) {
|
|
62
|
+
function findSkillDir(rootDir, skillName, depth = 5) {
|
|
92
63
|
if (depth < 0)
|
|
93
64
|
return undefined;
|
|
94
65
|
let entries;
|
|
@@ -77,7 +77,11 @@ function clarifyOverlay(activeSkill) {
|
|
|
77
77
|
function isBrainOnline(connectedTools) {
|
|
78
78
|
if (!connectedTools)
|
|
79
79
|
return true;
|
|
80
|
-
|
|
80
|
+
// Match bare `memory_recall` and the canonical single-underscore prefixed
|
|
81
|
+
// form `mcp_<server>_memory_recall` (pool normalises any legacy
|
|
82
|
+
// double-underscore emissions at the boundary — 0.3.8-R5).
|
|
83
|
+
return connectedTools.some((tool) => tool === 'memory_recall' ||
|
|
84
|
+
(tool.startsWith('mcp_') && tool.endsWith('memory_recall')));
|
|
81
85
|
}
|
|
82
86
|
function brainOfflineNotice() {
|
|
83
87
|
return [
|
|
@@ -123,7 +127,8 @@ export function buildSystemPrompt(context) {
|
|
|
123
127
|
brainOnline ? memoryFirstSection() : brainOfflineNotice(),
|
|
124
128
|
'',
|
|
125
129
|
'## Multi-agent orchestration',
|
|
126
|
-
'-
|
|
130
|
+
'- Delegation order: direct answer → direct tool → `task_agent` for needed child results → `delegate_agent` when you can keep working. `spawn_agent` / `spawn_agents` are low-level compatibility/batch tools.',
|
|
131
|
+
'- Roles: explorer, architect, reviewer, worker, verifier. Omit `role` in `spawn_agents` to auto-route from the leading verb; use `route_agent` for a dry run.',
|
|
127
132
|
'- Fan-out triggers: phrasings like "everything", "all", "in 1 go", "in parallel", "thoroughly", "comprehensive", "across the codebase" → ALWAYS `spawn_agents` with ≥3 children. One tool call + "what next?" is NOT acceptable for those prompts.',
|
|
128
133
|
'- Use `wait_agent` / `wait_agents` to drain before yielding. Synthesize child outputs in your own words — never claim work is done just because a child returned.',
|
|
129
134
|
'',
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { LLMConfig } from '../config/config.js';
|
|
2
|
+
import type { EffortLevel } from '../state/preferencesStore.js';
|
|
3
|
+
/**
|
|
4
|
+
* Route to the native adapter when the profile is Anthropic AND the
|
|
5
|
+
* endpoint hostname is `api.anthropic.com`, OR the explicit
|
|
6
|
+
* `BRAINROUTER_ANTHROPIC_NATIVE=1` override is set (for vended /
|
|
7
|
+
* reverse-proxied endpoints that still speak the native shape).
|
|
8
|
+
*
|
|
9
|
+
* Anything else — including `provider:'anthropic'` pointed at an
|
|
10
|
+
* OpenAI-compat gateway — stays on the existing OpenAI path so we
|
|
11
|
+
* don't break the OpenRouter / Anthropic-compat-shim flows.
|
|
12
|
+
*/
|
|
13
|
+
export declare function shouldUseAnthropicNative(config: LLMConfig, env?: NodeJS.ProcessEnv): boolean;
|
|
14
|
+
export interface AnthropicBuildOptions {
|
|
15
|
+
effort?: EffortLevel;
|
|
16
|
+
cacheEnabled?: boolean;
|
|
17
|
+
maxTokens?: number;
|
|
18
|
+
thinkingBudgetTokens?: number;
|
|
19
|
+
}
|
|
20
|
+
interface AnthropicMessage {
|
|
21
|
+
role: 'user' | 'assistant';
|
|
22
|
+
content: any[];
|
|
23
|
+
}
|
|
24
|
+
interface AnthropicTool {
|
|
25
|
+
name: string;
|
|
26
|
+
description: string;
|
|
27
|
+
input_schema: any;
|
|
28
|
+
cache_control?: {
|
|
29
|
+
type: 'ephemeral';
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export interface AnthropicRequestPayload {
|
|
33
|
+
model: string;
|
|
34
|
+
max_tokens: number;
|
|
35
|
+
system?: any;
|
|
36
|
+
messages: AnthropicMessage[];
|
|
37
|
+
tools?: AnthropicTool[];
|
|
38
|
+
thinking?: {
|
|
39
|
+
type: 'enabled';
|
|
40
|
+
budget_tokens: number;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Pure transform: BrainRouter chat history (OpenAI shape) →
|
|
45
|
+
* Anthropic `/v1/messages` request body.
|
|
46
|
+
*
|
|
47
|
+
* Invariants enforced here so callers don't need to know the Anthropic
|
|
48
|
+
* rules:
|
|
49
|
+
* - The system message (first message with role:'system') is hoisted
|
|
50
|
+
* to the top-level `system` field and dropped from `messages`.
|
|
51
|
+
* - Consecutive `tool` role entries are merged into one synthetic
|
|
52
|
+
* `user` message whose content is an array of `tool_result` blocks.
|
|
53
|
+
* - Assistant messages with `tool_calls` emit a content array that
|
|
54
|
+
* interleaves text (when present) and `tool_use` blocks. The
|
|
55
|
+
* OpenAI tool_call.id is reused as the Anthropic tool_use.id —
|
|
56
|
+
* callers must echo it back on the matching tool_result.
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildAnthropicRequest(config: LLMConfig, messages: any[], tools: any[], options?: AnthropicBuildOptions): AnthropicRequestPayload;
|
|
59
|
+
export interface AnthropicParsedResponse {
|
|
60
|
+
content: string;
|
|
61
|
+
toolCalls?: Array<{
|
|
62
|
+
id: string;
|
|
63
|
+
type: 'function';
|
|
64
|
+
function: {
|
|
65
|
+
name: string;
|
|
66
|
+
arguments: string;
|
|
67
|
+
};
|
|
68
|
+
}>;
|
|
69
|
+
usage?: {
|
|
70
|
+
prompt_tokens?: number;
|
|
71
|
+
completion_tokens?: number;
|
|
72
|
+
};
|
|
73
|
+
thinking?: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Pure transform: Anthropic response body → BrainRouter's internal
|
|
77
|
+
* `ChatResponse` shape. tool_use blocks become OpenAI-style toolCalls
|
|
78
|
+
* with the Anthropic id preserved verbatim, and `input` is re-serialized
|
|
79
|
+
* to the `function.arguments` JSON string the agent loop expects.
|
|
80
|
+
*/
|
|
81
|
+
export declare function parseAnthropicResponse(data: any): AnthropicParsedResponse;
|
|
82
|
+
export interface CallAnthropicOptions extends AnthropicBuildOptions {
|
|
83
|
+
onThinking?: (text: string) => void;
|
|
84
|
+
}
|
|
85
|
+
export declare function callAnthropic(config: LLMConfig, messages: any[], tools: any[], options?: CallAnthropicOptions): Promise<{
|
|
86
|
+
content: string;
|
|
87
|
+
toolCalls: {
|
|
88
|
+
id: string;
|
|
89
|
+
type: "function";
|
|
90
|
+
function: {
|
|
91
|
+
name: string;
|
|
92
|
+
arguments: string;
|
|
93
|
+
};
|
|
94
|
+
}[] | undefined;
|
|
95
|
+
usage: {
|
|
96
|
+
prompt_tokens?: number;
|
|
97
|
+
completion_tokens?: number;
|
|
98
|
+
} | undefined;
|
|
99
|
+
}>;
|
|
100
|
+
export {};
|
|
@@ -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;
|