@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.
Files changed (129) hide show
  1. package/README.md +29 -52
  2. package/agents/architect.json +18 -0
  3. package/agents/explorer.json +18 -0
  4. package/agents/reviewer.json +18 -0
  5. package/agents/verifier.json +18 -0
  6. package/agents/worker.json +18 -0
  7. package/changelog/0.2.0.md +15 -0
  8. package/changelog/0.3.0.md +20 -0
  9. package/changelog/0.3.1.md +22 -0
  10. package/changelog/0.3.2.md +15 -0
  11. package/changelog/0.3.3.md +19 -0
  12. package/changelog/0.3.4.md +20 -0
  13. package/changelog/0.3.5.md +9 -0
  14. package/changelog/0.3.6.md +9 -0
  15. package/changelog/0.3.7.md +20 -0
  16. package/changelog/0.3.8.md +30 -0
  17. package/changelog/README.md +41 -0
  18. package/dist/agent/agent.d.ts +34 -1
  19. package/dist/agent/agent.js +372 -79
  20. package/dist/agent/toolCallRecovery.d.ts +57 -0
  21. package/dist/agent/toolCallRecovery.js +130 -0
  22. package/dist/agent/toolSafety.d.ts +17 -0
  23. package/dist/agent/toolSafety.js +102 -0
  24. package/dist/cli/banner.d.ts +20 -0
  25. package/dist/cli/banner.js +47 -14
  26. package/dist/cli/cliPrompt.d.ts +40 -3
  27. package/dist/cli/cliPrompt.js +117 -25
  28. package/dist/cli/commands/_context.d.ts +3 -1
  29. package/dist/cli/commands/_helpers.d.ts +1 -1
  30. package/dist/cli/commands/config.d.ts +46 -0
  31. package/dist/cli/commands/config.js +1042 -0
  32. package/dist/cli/commands/init.d.ts +20 -0
  33. package/dist/cli/commands/init.js +64 -0
  34. package/dist/cli/commands/login.d.ts +13 -0
  35. package/dist/cli/commands/login.js +179 -0
  36. package/dist/cli/commands/mcp.d.ts +13 -11
  37. package/dist/cli/commands/mcp.js +261 -74
  38. package/dist/cli/commands/mcpInstall.d.ts +20 -0
  39. package/dist/cli/commands/mcpInstall.js +87 -0
  40. package/dist/cli/commands/orchestration.js +51 -0
  41. package/dist/cli/commands/releaseNotes.d.ts +24 -0
  42. package/dist/cli/commands/releaseNotes.js +109 -0
  43. package/dist/cli/commands/schedule.d.ts +18 -0
  44. package/dist/cli/commands/schedule.js +189 -0
  45. package/dist/cli/commands/ui.js +119 -60
  46. package/dist/cli/commands/workflow.d.ts +2 -0
  47. package/dist/cli/commands/workflow.js +54 -8
  48. package/dist/cli/ink/ChatApp.d.ts +206 -0
  49. package/dist/cli/ink/ChatApp.js +493 -0
  50. package/dist/cli/ink/Frame.d.ts +26 -0
  51. package/dist/cli/ink/Frame.js +5 -0
  52. package/dist/cli/ink/Picker.d.ts +71 -0
  53. package/dist/cli/ink/Picker.js +168 -0
  54. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  55. package/dist/cli/ink/SlashPalette.js +136 -0
  56. package/dist/cli/ink/TextField.d.ts +34 -0
  57. package/dist/cli/ink/TextField.js +47 -0
  58. package/dist/cli/ink/WizardApp.d.ts +7 -0
  59. package/dist/cli/ink/WizardApp.js +422 -0
  60. package/dist/cli/ink/ambientChat.d.ts +34 -0
  61. package/dist/cli/ink/ambientChat.js +7 -0
  62. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  63. package/dist/cli/ink/consoleCapture.js +33 -0
  64. package/dist/cli/ink/markdownRender.d.ts +41 -0
  65. package/dist/cli/ink/markdownRender.js +278 -0
  66. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  67. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  68. package/dist/cli/ink/runChat.d.ts +34 -0
  69. package/dist/cli/ink/runChat.js +682 -0
  70. package/dist/cli/ink/runPicker.d.ts +31 -0
  71. package/dist/cli/ink/runPicker.js +139 -0
  72. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  73. package/dist/cli/ink/runSlashPalette.js +33 -0
  74. package/dist/cli/ink/runWizard.d.ts +22 -0
  75. package/dist/cli/ink/runWizard.js +133 -0
  76. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  77. package/dist/cli/ink/stdinHandoff.js +78 -0
  78. package/dist/cli/ink/toolFormat.d.ts +75 -0
  79. package/dist/cli/ink/toolFormat.js +206 -0
  80. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  81. package/dist/cli/ink/useTerminalSize.js +26 -0
  82. package/dist/cli/repl.d.ts +25 -3
  83. package/dist/cli/repl.js +52 -714
  84. package/dist/cli/slashSuggest.d.ts +32 -0
  85. package/dist/cli/slashSuggest.js +146 -0
  86. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  87. package/dist/cli/wizard/modelsApi.js +166 -0
  88. package/dist/cli/wizard/picker.d.ts +202 -0
  89. package/dist/cli/wizard/picker.js +547 -0
  90. package/dist/cli/wizard/providers.d.ts +86 -0
  91. package/dist/cli/wizard/providers.js +190 -0
  92. package/dist/cli/wizard/runner.d.ts +13 -0
  93. package/dist/cli/wizard/runner.js +488 -0
  94. package/dist/cli/wizard/types.d.ts +122 -0
  95. package/dist/cli/wizard/types.js +109 -0
  96. package/dist/config/config.d.ts +13 -1
  97. package/dist/config/config.js +45 -3
  98. package/dist/index.js +157 -206
  99. package/dist/memory/briefing.d.ts +1 -1
  100. package/dist/memory/briefing.js +4 -4
  101. package/dist/memory/consolidation.d.ts +1 -1
  102. package/dist/orchestration/agentRegistry.d.ts +36 -0
  103. package/dist/orchestration/agentRegistry.js +64 -0
  104. package/dist/orchestration/orchestrator.d.ts +7 -0
  105. package/dist/orchestration/orchestrator.js +2 -0
  106. package/dist/orchestration/tools.d.ts +105 -3
  107. package/dist/orchestration/tools.js +167 -8
  108. package/dist/prompt/skillCatalog.d.ts +11 -0
  109. package/dist/prompt/skillCatalog.js +134 -0
  110. package/dist/prompt/skillRunner.d.ts +2 -2
  111. package/dist/prompt/skillRunner.js +2 -31
  112. package/dist/prompt/systemPrompt.js +7 -2
  113. package/dist/runtime/anthropicAdapter.d.ts +100 -0
  114. package/dist/runtime/anthropicAdapter.js +293 -0
  115. package/dist/runtime/cronParser.d.ts +23 -0
  116. package/dist/runtime/cronParser.js +122 -0
  117. package/dist/runtime/mcpClient.js +14 -11
  118. package/dist/runtime/mcpPool.d.ts +170 -0
  119. package/dist/runtime/mcpPool.js +442 -0
  120. package/dist/runtime/mcpUtils.d.ts +17 -1
  121. package/dist/runtime/mcpUtils.js +23 -0
  122. package/dist/runtime/scheduleTicker.d.ts +33 -0
  123. package/dist/runtime/scheduleTicker.js +99 -0
  124. package/dist/runtime/vendorSnippets.d.ts +45 -0
  125. package/dist/runtime/vendorSnippets.js +153 -0
  126. package/dist/state/scheduleStore.d.ts +37 -0
  127. package/dist/state/scheduleStore.js +64 -0
  128. package/package.json +14 -5
  129. 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 { McpClientWrapper } from '../runtime/mcpClient.js';
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: McpClientWrapper, name: string, workspaceRoot: string, section?: RunSkillOptions['section']): Promise<SkillResolution>;
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 { createRequire } from 'node:module';
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
- return connectedTools.includes('memory_recall');
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
- '- Delegate parallel, bounded work via `spawn_agent` (one) or `spawn_agents` (batch). Roles: explorer (read-only investigation), architect (design alternatives), reviewer (code review), worker (write access), verifier (tests/checks). Omit `role` in `spawn_agents` to auto-route from the leading verb; use `route_agent` for a dry run.',
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;