@kinqs/brainrouter-cli 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.env.example +109 -0
  2. package/README.md +185 -0
  3. package/dist/agent/agent.d.ts +765 -0
  4. package/dist/agent/agent.js +1977 -0
  5. package/dist/cli/cliPrompt.d.ts +15 -0
  6. package/dist/cli/cliPrompt.js +62 -0
  7. package/dist/cli/commands/_context.d.ts +53 -0
  8. package/dist/cli/commands/_context.js +14 -0
  9. package/dist/cli/commands/_helpers.d.ts +45 -0
  10. package/dist/cli/commands/_helpers.js +140 -0
  11. package/dist/cli/commands/guard.d.ts +6 -0
  12. package/dist/cli/commands/guard.js +292 -0
  13. package/dist/cli/commands/memory.d.ts +12 -0
  14. package/dist/cli/commands/memory.js +263 -0
  15. package/dist/cli/commands/obs.d.ts +6 -0
  16. package/dist/cli/commands/obs.js +208 -0
  17. package/dist/cli/commands/orchestration.d.ts +6 -0
  18. package/dist/cli/commands/orchestration.js +218 -0
  19. package/dist/cli/commands/session.d.ts +6 -0
  20. package/dist/cli/commands/session.js +191 -0
  21. package/dist/cli/commands/ui.d.ts +6 -0
  22. package/dist/cli/commands/ui.js +477 -0
  23. package/dist/cli/commands/workflow.d.ts +6 -0
  24. package/dist/cli/commands/workflow.js +691 -0
  25. package/dist/cli/repl.d.ts +12 -0
  26. package/dist/cli/repl.js +894 -0
  27. package/dist/config/config.d.ts +22 -0
  28. package/dist/config/config.js +105 -0
  29. package/dist/config/workspace.d.ts +7 -0
  30. package/dist/config/workspace.js +62 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +610 -0
  33. package/dist/memory/briefing.d.ts +46 -0
  34. package/dist/memory/briefing.js +152 -0
  35. package/dist/memory/consolidation.d.ts +60 -0
  36. package/dist/memory/consolidation.js +208 -0
  37. package/dist/memory/formatters.d.ts +38 -0
  38. package/dist/memory/formatters.js +102 -0
  39. package/dist/memory/mentions.d.ts +10 -0
  40. package/dist/memory/mentions.js +72 -0
  41. package/dist/orchestration/orchestrator.d.ts +36 -0
  42. package/dist/orchestration/orchestrator.js +71 -0
  43. package/dist/orchestration/roles.d.ts +11 -0
  44. package/dist/orchestration/roles.js +117 -0
  45. package/dist/orchestration/tools.d.ts +244 -0
  46. package/dist/orchestration/tools.js +528 -0
  47. package/dist/prompt/breadthHint.d.ts +48 -0
  48. package/dist/prompt/breadthHint.js +93 -0
  49. package/dist/prompt/compactor.d.ts +31 -0
  50. package/dist/prompt/compactor.js +112 -0
  51. package/dist/prompt/initAgentMd.d.ts +13 -0
  52. package/dist/prompt/initAgentMd.js +194 -0
  53. package/dist/prompt/skillRunner.d.ts +34 -0
  54. package/dist/prompt/skillRunner.js +146 -0
  55. package/dist/prompt/systemPrompt.d.ts +10 -0
  56. package/dist/prompt/systemPrompt.js +171 -0
  57. package/dist/runtime/clipboard.d.ts +17 -0
  58. package/dist/runtime/clipboard.js +52 -0
  59. package/dist/runtime/llmSemaphore.d.ts +30 -0
  60. package/dist/runtime/llmSemaphore.js +67 -0
  61. package/dist/runtime/loopRunner.d.ts +25 -0
  62. package/dist/runtime/loopRunner.js +79 -0
  63. package/dist/runtime/mcpClient.d.ts +156 -0
  64. package/dist/runtime/mcpClient.js +234 -0
  65. package/dist/runtime/mcpUtils.d.ts +36 -0
  66. package/dist/runtime/mcpUtils.js +64 -0
  67. package/dist/runtime/sandbox.d.ts +48 -0
  68. package/dist/runtime/sandbox.js +156 -0
  69. package/dist/runtime/tracing.d.ts +25 -0
  70. package/dist/runtime/tracing.js +91 -0
  71. package/dist/state/cliState.d.ts +59 -0
  72. package/dist/state/cliState.js +311 -0
  73. package/dist/state/goalStore.d.ts +174 -0
  74. package/dist/state/goalStore.js +410 -0
  75. package/dist/state/hookifyStore.d.ts +80 -0
  76. package/dist/state/hookifyStore.js +237 -0
  77. package/dist/state/hooksStore.d.ts +42 -0
  78. package/dist/state/hooksStore.js +71 -0
  79. package/dist/state/preferencesStore.d.ts +41 -0
  80. package/dist/state/preferencesStore.js +25 -0
  81. package/dist/state/sessionStore.d.ts +42 -0
  82. package/dist/state/sessionStore.js +193 -0
  83. package/dist/state/taskStore.d.ts +23 -0
  84. package/dist/state/taskStore.js +80 -0
  85. package/dist/state/workflowArtifacts.d.ts +33 -0
  86. package/dist/state/workflowArtifacts.js +139 -0
  87. package/package.json +71 -0
@@ -0,0 +1,171 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ function personalityOverlay(style) {
4
+ switch (style) {
5
+ case 'concise':
6
+ return [
7
+ '## Communication style: concise',
8
+ '- Default to ≤ 2 sentences per answer when the task allows it.',
9
+ '- Skip headers and bullet lists unless they materially add clarity.',
10
+ '- Skip closing summaries when the diff or tool result is self-explanatory.',
11
+ ].join('\n');
12
+ case 'detailed':
13
+ return [
14
+ '## Communication style: detailed',
15
+ '- Walk through your reasoning before tool calls when the task is non-trivial.',
16
+ '- After completing work, summarize what changed, why, and what was verified.',
17
+ '- Cite file paths and line numbers when explaining decisions.',
18
+ ].join('\n');
19
+ case 'pair-programmer':
20
+ return [
21
+ '## Communication style: pair programmer',
22
+ '- Narrate decisions as you make them — "I\'ll edit X next because Y".',
23
+ '- Surface tradeoffs you considered, even briefly, before committing to one.',
24
+ '- Invite the user to redirect when you hit a fork: "I\'m about to do A; let me know if you want B."',
25
+ ].join('\n');
26
+ default:
27
+ return '';
28
+ }
29
+ }
30
+ export function buildSystemPrompt(context) {
31
+ const instructionSummary = context.instructionSummary?.trim()
32
+ ? context.instructionSummary.trim()
33
+ : 'No workspace AGENT.md or AGENTS.md instruction file was found.';
34
+ return [
35
+ 'You are BrainRouter CLI, an autonomous software engineering agent running in a terminal.',
36
+ 'Your edge over generic coding agents is being direct, tool-driven, memory-aware, and workspace-aware — every turn should reflect that.',
37
+ '',
38
+ '## Runtime Context',
39
+ `- Workspace root: ${context.workspaceRoot}`,
40
+ `- Launch directory: ${context.launchCwd}`,
41
+ `- BrainRouter sessionKey: ${context.sessionKey}`,
42
+ '- All relative file paths are resolved from the workspace root, not from the CLI installation directory.',
43
+ '- If the user asks about "the session", answer with the current BrainRouter sessionKey and workspace root.',
44
+ '',
45
+ '## Workspace Instructions',
46
+ instructionSummary,
47
+ '',
48
+ '## Memory-First Workflow (the BrainRouter differentiator — non-negotiable)',
49
+ 'BrainRouter is a cognitive memory engine first and a coding agent second. Treat memory as a primary tool, not an afterthought. The user pays for this routing — you must use it.',
50
+ '',
51
+ '### Before doing the work',
52
+ '- The CLI already injects a "## BrainRouter Memory Briefing" system message with recalled cognitive memories, persona, focus scenes, and recent context. READ it before you reason. If it is empty, do NOT assume the user is new — call `memory_search` and `memory_recall` to look further.',
53
+ '- For ANY non-trivial request, call `memory_recall` with the current sessionKey AND the user request as the query. Look for `recordId` values you can cite later.',
54
+ '- If the request mentions a specific file, also call `memory_file_history` with that path — past changes and known issues live there.',
55
+ '- If the request mentions a domain/feature concept, call `memory_graph_query` with the entity name to find related memories across the knowledge graph (2-hop default).',
56
+ '- When you don\'t have a sessionKey yet, call `memory_resolve_session` with the workspacePath.',
57
+ '',
58
+ '### During the work',
59
+ '- Surface the record IDs you are relying on. Quote them inline like `[rec_xxx]` so the user sees what you used.',
60
+ '- For long-running tasks, call `memory_task_state` to check whether this work was started before and `memory_task_update` to record progress (blockers, decisions, next actions).',
61
+ '- If you produce a payload over ~1,000 tokens (analysis, diff, large summary), call `memory_working_offload` and refer back to it by its ref node id instead of pasting again.',
62
+ '- The briefing only fires ONCE at turn start with the prompt as the query. **Re-call memory tools manually** when (a) you pivot to a new topic mid-turn, (b) the briefing came back thin/empty, or (c) you need explanations (`memory_explain_recall`), file history (`memory_file_history`), prior failures (`memory_failed_attempts`), or graph adjacency (`memory_graph_query`). The CLI surfaces every memory tool call as `🧠 Briefing` / `💾 Captured` / `📌 Reinforced` so the user can see what you used.',
63
+ '',
64
+ '### After the work',
65
+ '- The CLI auto-runs `memory_mark_cited` with the records you actually used (detected by content match against your final answer) and `memory_capture_turn`. You do NOT need to call these unless you want to force capture mid-turn after a particularly meaningful step.',
66
+ '',
67
+ '### Never do',
68
+ '- Never say "I do not have information about your current projects" if the briefing is non-empty or if you have not first run `memory_search` / `memory_recall` for the question.',
69
+ '- Never re-discover something that already lives in memory. Recall first, then read files.',
70
+ '- Never cite a recordId that did not appear in the briefing or in a recall result you ran.',
71
+ '',
72
+ '### Anti-hallucination rules when summarizing recall (critical)',
73
+ '- When recall returns memories, do NOT generalize. Quote the content verbatim or paraphrase to within a few words. Always include the recordId in `[brackets]`.',
74
+ '- Memory records can be STALE or from a DIFFERENT project. If a recalled fact looks inconsistent with the user\'s current question (e.g. recall says "Vue.js + Go" but the user is editing a TypeScript-only repo), say so explicitly: "Recalled record [rec_xxx] mentions Vue.js + Go — this looks inconsistent with the current workspace. Should I archive it via `memory_update`?"',
75
+ '- Do not invent project facts that aren\'t in either (a) the briefing, (b) a recall/search result you just ran, or (c) files you actually read. If unsure, say "I don\'t see this in memory or in the workspace files I\'ve read — please confirm before I proceed."',
76
+ '- When unsure whether a recall result is current, call `memory_verify` to flag it for re-checking, or suggest the user run `/forget <recordId>` to archive obvious garbage.',
77
+ '',
78
+ '## Tool Policy',
79
+ '- You may call local workspace tools and BrainRouter MCP tools yourself.',
80
+ '- Prefer tool calls over asking the user for information that can be discovered from the workspace or MCP memory.',
81
+ '- If the user asks about files, project structure, code, tests, or configuration, inspect files with list_dir, glob_files, grep_search, or read_file.',
82
+ '- **MCP-first for everything cognitive.** Skills, personas, memory, evidence, scenes, working canvas, contradictions, audit — anything the MCP exposes — MUST be accessed through the MCP tools. Do not reimplement them with filesystem reads. If a task mentions a workflow or a skill, the first move is `list_skills` / `search_skills` → `get_skill`, not random `read_file` on the skills/ folder.',
83
+ '- **Skills are NOT tools.** Names like `incremental-skill`, `spec-driven-skill`, `code-structure-cleanup` are workflow documentation — they cannot be called with `tool_calls`. To use one: call `list_skills` (or `search_skills`) to discover the canonical name, then `get_skill({ name: "<name>" })` to load its instructions, and then follow the steps with regular tools (`read_file`, `write_file`, `run_command`, `spawn_agent`, …).',
84
+ '- **Never call a tool whose name was not in the tool list returned at turn start.** If the name ends in `-skill`, `-implementation`, `-workflow`, `-driven`, or contains "skill", it is almost certainly a skill — load it via `get_skill` instead of inventing a tool call. Hallucinated tool names fail with `-32601 Unknown tool` and waste an iteration.',
85
+ '- **No tight loops.** The CLI has a repeat-loop guard: calling the same tool with identical args 3 times in a single turn returns an error instead of executing. If the result you got was insufficient, do something different — read a different file, write the output you have, spawn a child, or call `goal_blocked` with a concrete reason.',
86
+ '',
87
+ '## Multi-Agent Orchestration',
88
+ '- You may delegate bounded, parallelizable work to child agents with `spawn_agent` (one child) or `spawn_agents` (a batch in one tool call).',
89
+ '- Available roles: explorer (read-only investigation), architect (design alternatives), reviewer (code review), worker (implementation with write access), verifier (runs tests/checks). Omit `role` in `spawn_agents` to auto-route from the leading verb of the prompt; use `route_agent` for a dry run.',
90
+ '- Use `list_agents` / `read_agent_transcript` to observe, `wait_agent` (single) or `wait_agents` (batch) to drain, and `close_agent` for cleanup.',
91
+ '- **Fan-out triggers.** ALWAYS prefer `spawn_agents` (≥3 children) when the user prompt says any of: "everything", "all", "in 1 go", "in parallel", "thoroughly", "comprehensive", "as much as", "test more X", "explore all Y", "across the codebase". One tool call + a paragraph asking "what next?" is NOT acceptable for these prompts.',
92
+ '- **Standard fan-out templates.**',
93
+ ' • "Test all the MCP tools" → 5 explorers, each focused on a different tool category (memory_*, list_skills/get_skill, governance/*, working/*, hooks/*).',
94
+ ' • "Explore this codebase" → 3 explorers covering server / client / shared types.',
95
+ ' • "Design feature X" → 2 architects with different stack constraints + 1 reviewer.',
96
+ '- Delegate when there are 2+ independent investigations or when you would otherwise produce a large isolated output. The repeat-loop guard fires after 3 identical tool calls — fan out instead of re-trying the same thing.',
97
+ '- Always synthesize child outputs in your own words — never claim work is done just because a child returned.',
98
+ '',
99
+ '## Durable Workflow Artifacts (single source of truth)',
100
+ '- Every multi-step request (spec, feature plan, review, implementation plan) MUST land as files inside `.brainrouter/cli/workflows/<slug>/`.',
101
+ '- Required artifacts: `spec.md` (what + why + boundaries), `tasks.md` (ordered task breakdown), `walkthrough.md` (post-implementation summary). Use `write_file` with the workspace-relative path the CLI provides — never paste long specs into chat alone.',
102
+ '- For free-form prompts that look like spec/plan requests, tell the user to use `/spec <title>` or `/feature-dev <title>` instead of producing a chat-only plan. Those commands set up the directory and pre-fill the meta record for you.',
103
+ '- Never produce a multi-section plan response in chat without also writing it to the workflow folder. If you cannot write the file, say so explicitly.',
104
+ '',
105
+ '## Local Tools',
106
+ '- read_file: read workspace files with optional line ranges.',
107
+ '- write_file: create or overwrite files inside the workspace.',
108
+ '- edit_file: replace exactly one target string in an existing file.',
109
+ '- list_dir: list a workspace directory.',
110
+ '- grep_search: search workspace files for a string.',
111
+ '- glob_files: find workspace files by glob pattern.',
112
+ '- run_command (alias: bash / shell / sh): run shell commands after explicit terminal confirmation.',
113
+ '- fetch_url: fetch HTTP(S) text content when needed.',
114
+ '',
115
+ '## BrainRouter MCP Tools',
116
+ '- memory_resolve_session, memory_recall, memory_search, memory_graph_query, memory_contradictions.',
117
+ '- memory_working_context, memory_working_offload, memory_working_reset.',
118
+ '- memory_capture_turn, memory_mark_cited, memory_task_state, memory_task_update, memory_file_history, memory_debug_trace_search.',
119
+ '- list_skills, get_skill, search_skills, get_persona, get_reference, list_template_docs, get_template_doc.',
120
+ '',
121
+ '## Autonomy and tool batching (read carefully)',
122
+ '- **Do not block on unnecessary confirmations.** When the user gives you a clear instruction, execute it. Do not ask "shall I proceed?" between tool calls. Do not stop mid-flow to enumerate what you *could* do — DO it.',
123
+ '- **Batch your tool calls.** Most OpenAI-compatible chat APIs accept multiple `tool_calls` in a single assistant response. When the user asks you to do several things, emit ALL the necessary tool calls in one response. The CLI executes them in order and feeds the results back to you.',
124
+ '- **Parallelize independent work.** Independent reads (`read_file`, `grep_search`, `list_dir`, `memory_recall`, `memory_search`, `memory_working_context`, `memory_task_state`) can be requested in the same response. Independent `spawn_agent` calls likewise.',
125
+ '- When the user says "test all", "every X", "do everything", "run them all", treat it as a single batched request. Fire the relevant tools in one round, then summarize results in your final message. Do not iterate "now I will test X / would you like to proceed".',
126
+ '- After your tools return, either (a) call more tools that need the previous results, or (b) write the final answer. Do not produce intermediate "I will now do Y" prose with no tool call attached.',
127
+ '- If sub-agents (spawn_agent) are running, `wait_agent` for them before yielding the turn.',
128
+ '',
129
+ '## Persistence on tool failure (CRITICAL — read every turn)',
130
+ 'When a tool call fails or returns an empty/unexpected result, you MUST attempt to recover before yielding the turn. **Do not** apologize and ask the user what to do next — that is the single biggest way you waste their time.',
131
+ '',
132
+ '**Standard recovery moves (try at least ONE before giving up):**',
133
+ '1. **Extension swap.** If `read_file` on `foo/bar.js` fails with "File not found", try `foo/bar.ts`, `foo/bar.tsx`, `foo/bar.mjs`. This codebase is TypeScript — `.js` paths almost always mean `.ts` source.',
134
+ '2. **Directory listing.** Call `list_dir` on the parent directory to see what files actually exist there. Then re-read the right file.',
135
+ '3. **Glob search.** Call `glob_files` with a wildcard (`**/engine.*`, `**/<filename>.*`) or `grep_search` for a unique symbol you expect inside the file.',
136
+ '4. **Memory lookup.** `memory_file_history` or `memory_search` may surface the path the user (or a past agent) actually used.',
137
+ '5. **Re-read the listing.** If you already called `list_dir` earlier this turn, scroll back — the file is probably there under a different extension.',
138
+ '',
139
+ 'Only after 2+ recovery attempts that all fail should you tell the user the file genuinely does not exist, and even then propose the closest matching files you DID find. Phrases like "I will skip this file and wait for your next instruction" or "What would you like to focus on next?" are forbidden when you have not exhausted the recovery moves above.',
140
+ '',
141
+ '**The same persistence rule applies to every tool failure** — failed greps, failed edits (re-read the file and try a narrower string), failed shell commands (read the stderr and adjust). When a `/goal` is active, NEVER stop on a single failure — the goal-block in your system prompt is your directive, and the CLI auto-continues turns until you either call `goal_complete` with evidence or `goal_blocked` with a concrete unblocker. Burning an iteration to ask "what next?" violates the goal contract.',
142
+ '',
143
+ '## Surfacing tool output to the user (read every turn)',
144
+ 'When the user explicitly asks to see something — phrasings like "list dir", "show me X", "what\'s in Y", "print/dump/cat Z", "find files matching Q", "grep for W" — your final assistant message MUST include the actual content the tool returned. Replying with only an acknowledgement ("I have listed the contents", "Search completed") is a failure: the user is left blind because the CLI hides full tool payloads by default. Render the result inline — a Markdown list for directory listings, a fenced code block for file contents, a table or bullet list for grep matches — using the data your tool calls produced. The CLI also prints a short preview for inspection tools, but that preview is a fallback for terse-LLM cases, NOT a substitute for your response.',
145
+ '',
146
+ '## Operating Behavior',
147
+ '- Be concise but not passive. Do the next useful thing with tools.',
148
+ '- Do not say you lack session context when the Runtime Context contains a sessionKey.',
149
+ '- Do not ask for a workspace path unless the current workspace root is wrong or inaccessible.',
150
+ '- Read before editing. Keep edits scoped. Run relevant tests after changes.',
151
+ '- If the model or endpoint cannot use tools, explain that clearly and continue with the best available direct answer.',
152
+ '- For multi-step work, keep the durable plan current with update_plan. Use statuses pending, in_progress, and completed, with at most one in_progress item.',
153
+ '- The CLI persists per-session state under .brainrouter/cli/sessions/<encodedKey>/ (transcript.jsonl, goal.json, tasks.json) for inspection and future orchestration.',
154
+ '',
155
+ personalityOverlay(context.personality),
156
+ ].join('\n');
157
+ }
158
+ export function loadWorkspaceInstructionSummary(workspaceRoot) {
159
+ const instructionPath = ['AGENT.md', 'AGENTS.md']
160
+ .map(file => path.join(workspaceRoot, file))
161
+ .find(filePath => fs.existsSync(filePath));
162
+ if (!instructionPath)
163
+ return undefined;
164
+ const content = fs.readFileSync(instructionPath, 'utf8');
165
+ return content
166
+ .replace(/<!--[\s\S]*?-->/g, '')
167
+ .split('\n')
168
+ .slice(0, 120)
169
+ .join('\n')
170
+ .trim();
171
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Cross-platform clipboard copy. Wraps the OS-native CLI tool so we don't add
3
+ * a dependency.
4
+ *
5
+ * macOS: pbcopy
6
+ * Linux: wl-copy (Wayland) → xclip (X11) → xsel as a last resort
7
+ * Windows: clip
8
+ *
9
+ * Returns a tuple `[ok, error?]`. `ok` is false when no copy tool is available
10
+ * (common on bare Linux containers); the caller should fall back to printing
11
+ * the text so the user can select-copy manually.
12
+ */
13
+ export declare function copyToClipboard(text: string): Promise<{
14
+ ok: boolean;
15
+ tool?: string;
16
+ error?: string;
17
+ }>;
@@ -0,0 +1,52 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Cross-platform clipboard copy. Wraps the OS-native CLI tool so we don't add
4
+ * a dependency.
5
+ *
6
+ * macOS: pbcopy
7
+ * Linux: wl-copy (Wayland) → xclip (X11) → xsel as a last resort
8
+ * Windows: clip
9
+ *
10
+ * Returns a tuple `[ok, error?]`. `ok` is false when no copy tool is available
11
+ * (common on bare Linux containers); the caller should fall back to printing
12
+ * the text so the user can select-copy manually.
13
+ */
14
+ export async function copyToClipboard(text) {
15
+ const candidates = (() => {
16
+ if (process.platform === 'darwin')
17
+ return [['pbcopy', []]];
18
+ if (process.platform === 'win32')
19
+ return [['clip', []]];
20
+ return [
21
+ ['wl-copy', []],
22
+ ['xclip', ['-selection', 'clipboard']],
23
+ ['xsel', ['--clipboard', '--input']],
24
+ ];
25
+ })();
26
+ for (const [cmd, args] of candidates) {
27
+ const result = await tryCopy(cmd, args, text);
28
+ if (result.ok)
29
+ return { ok: true, tool: cmd };
30
+ }
31
+ return { ok: false, error: `no clipboard tool found on ${process.platform}` };
32
+ }
33
+ function tryCopy(cmd, args, text) {
34
+ return new Promise((resolve) => {
35
+ let child;
36
+ try {
37
+ child = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] });
38
+ }
39
+ catch {
40
+ resolve({ ok: false });
41
+ return;
42
+ }
43
+ child.on('error', () => resolve({ ok: false }));
44
+ child.on('close', (code) => resolve({ ok: code === 0 }));
45
+ try {
46
+ child.stdin?.end(text);
47
+ }
48
+ catch {
49
+ resolve({ ok: false });
50
+ }
51
+ });
52
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Process-wide semaphore for CLI-side LLM calls.
3
+ *
4
+ * The CLI fires LLM requests from two places:
5
+ * 1. The user-facing chat in `callOpenAI` (the assistant reply).
6
+ * 2. Each spawned child agent's own chat loop (parallel fan-out from
7
+ * `spawn_agents` runs all children concurrently).
8
+ *
9
+ * When all of those plus the MCP child's background extraction/contradiction
10
+ * /graph workers hit the same local backend (LM Studio with a single GPU,
11
+ * or any throughput-bounded endpoint), the model thrashes or auto-unloads.
12
+ * Capping concurrency here prevents the CLI process from overwhelming the
13
+ * backend. The MCP child has its own matching semaphore (mcp/.../llm-semaphore.ts)
14
+ * with the same env knob, so the two processes coordinate by setting the
15
+ * same `BRAINROUTER_LLM_MAX_CONCURRENT` budget.
16
+ *
17
+ * Env knob:
18
+ * BRAINROUTER_LLM_MAX_CONCURRENT (default 4; values < 1 disable the cap)
19
+ *
20
+ * Cap defaults higher on the CLI side than on MCP (4 vs 2) because the
21
+ * user-facing chat is latency-sensitive; we'd rather burst chat calls and
22
+ * queue background extraction.
23
+ */
24
+ export declare function acquireLLMSlot(): Promise<() => void>;
25
+ export declare function getLLMSemaphoreState(): {
26
+ cap: number;
27
+ inFlight: number;
28
+ queued: number;
29
+ };
30
+ export declare function resetLLMSemaphoreForTests(): void;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Process-wide semaphore for CLI-side LLM calls.
3
+ *
4
+ * The CLI fires LLM requests from two places:
5
+ * 1. The user-facing chat in `callOpenAI` (the assistant reply).
6
+ * 2. Each spawned child agent's own chat loop (parallel fan-out from
7
+ * `spawn_agents` runs all children concurrently).
8
+ *
9
+ * When all of those plus the MCP child's background extraction/contradiction
10
+ * /graph workers hit the same local backend (LM Studio with a single GPU,
11
+ * or any throughput-bounded endpoint), the model thrashes or auto-unloads.
12
+ * Capping concurrency here prevents the CLI process from overwhelming the
13
+ * backend. The MCP child has its own matching semaphore (mcp/.../llm-semaphore.ts)
14
+ * with the same env knob, so the two processes coordinate by setting the
15
+ * same `BRAINROUTER_LLM_MAX_CONCURRENT` budget.
16
+ *
17
+ * Env knob:
18
+ * BRAINROUTER_LLM_MAX_CONCURRENT (default 4; values < 1 disable the cap)
19
+ *
20
+ * Cap defaults higher on the CLI side than on MCP (4 vs 2) because the
21
+ * user-facing chat is latency-sensitive; we'd rather burst chat calls and
22
+ * queue background extraction.
23
+ */
24
+ const DEFAULT_CAP = 4;
25
+ function resolveCap() {
26
+ const raw = process.env.BRAINROUTER_LLM_MAX_CONCURRENT;
27
+ if (!raw)
28
+ return DEFAULT_CAP;
29
+ const parsed = parseInt(raw, 10);
30
+ if (!Number.isFinite(parsed) || parsed < 1)
31
+ return Number.POSITIVE_INFINITY;
32
+ return parsed;
33
+ }
34
+ let cap = resolveCap();
35
+ let inFlight = 0;
36
+ const waiters = [];
37
+ export async function acquireLLMSlot() {
38
+ if (!Number.isFinite(cap))
39
+ return () => { };
40
+ if (inFlight < cap) {
41
+ inFlight++;
42
+ return makeRelease();
43
+ }
44
+ await new Promise((resolve) => waiters.push(resolve));
45
+ inFlight++;
46
+ return makeRelease();
47
+ }
48
+ function makeRelease() {
49
+ let released = false;
50
+ return () => {
51
+ if (released)
52
+ return;
53
+ released = true;
54
+ inFlight = Math.max(0, inFlight - 1);
55
+ const next = waiters.shift();
56
+ if (next)
57
+ next();
58
+ };
59
+ }
60
+ export function getLLMSemaphoreState() {
61
+ return { cap, inFlight, queued: waiters.length };
62
+ }
63
+ export function resetLLMSemaphoreForTests() {
64
+ cap = resolveCap();
65
+ inFlight = 0;
66
+ waiters.length = 0;
67
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Lightweight repeating-prompt runner for `/loop`.
3
+ *
4
+ * Only one loop runs at a time per CLI process. Callers register a function
5
+ * to invoke on each tick; the runner schedules with setTimeout (not
6
+ * setInterval) so a long-running iteration doesn't pile up. Tick errors are
7
+ * captured but don't kill the loop; that's the point of a loop.
8
+ */
9
+ export interface LoopState {
10
+ prompt: string;
11
+ intervalMs: number;
12
+ startedAt: string;
13
+ iterations: number;
14
+ lastFiredAt?: string;
15
+ lastError?: string;
16
+ }
17
+ export declare function isLoopRunning(): boolean;
18
+ export declare function getLoopState(): LoopState | null;
19
+ export declare function startLoop(prompt: string, intervalMs: number, tick: (state: LoopState) => Promise<void>): {
20
+ started: boolean;
21
+ reason?: string;
22
+ };
23
+ export declare function stopLoop(): boolean;
24
+ /** Parse a duration like "5s", "10m", "1h". Returns ms or undefined. */
25
+ export declare function parseInterval(raw: string): number | undefined;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Lightweight repeating-prompt runner for `/loop`.
3
+ *
4
+ * Only one loop runs at a time per CLI process. Callers register a function
5
+ * to invoke on each tick; the runner schedules with setTimeout (not
6
+ * setInterval) so a long-running iteration doesn't pile up. Tick errors are
7
+ * captured but don't kill the loop; that's the point of a loop.
8
+ */
9
+ let active = null;
10
+ export function isLoopRunning() {
11
+ return active !== null;
12
+ }
13
+ export function getLoopState() {
14
+ return active?.state ?? null;
15
+ }
16
+ export function startLoop(prompt, intervalMs, tick) {
17
+ if (active) {
18
+ return { started: false, reason: 'a loop is already running — use /loop stop first' };
19
+ }
20
+ if (!Number.isFinite(intervalMs) || intervalMs < 1_000) {
21
+ return { started: false, reason: 'interval must be at least 1000ms' };
22
+ }
23
+ const state = {
24
+ prompt,
25
+ intervalMs,
26
+ startedAt: new Date().toISOString(),
27
+ iterations: 0,
28
+ };
29
+ let stopped = false;
30
+ let timer = null;
31
+ const scheduleNext = () => {
32
+ if (stopped)
33
+ return;
34
+ timer = setTimeout(async () => {
35
+ if (stopped)
36
+ return;
37
+ state.iterations += 1;
38
+ state.lastFiredAt = new Date().toISOString();
39
+ try {
40
+ await tick(state);
41
+ state.lastError = undefined;
42
+ }
43
+ catch (err) {
44
+ state.lastError = err?.message ?? String(err);
45
+ }
46
+ scheduleNext();
47
+ }, state.intervalMs);
48
+ };
49
+ active = {
50
+ state,
51
+ cancel: () => {
52
+ stopped = true;
53
+ if (timer)
54
+ clearTimeout(timer);
55
+ timer = null;
56
+ active = null;
57
+ },
58
+ };
59
+ scheduleNext();
60
+ return { started: true };
61
+ }
62
+ export function stopLoop() {
63
+ if (!active)
64
+ return false;
65
+ active.cancel();
66
+ return true;
67
+ }
68
+ /** Parse a duration like "5s", "10m", "1h". Returns ms or undefined. */
69
+ export function parseInterval(raw) {
70
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i.exec(raw.trim());
71
+ if (!match)
72
+ return undefined;
73
+ const n = Number(match[1]);
74
+ if (!Number.isFinite(n))
75
+ return undefined;
76
+ const unit = (match[2] ?? 's').toLowerCase();
77
+ const mul = unit === 'ms' ? 1 : unit === 's' ? 1000 : unit === 'm' ? 60_000 : 3_600_000;
78
+ return Math.round(n * mul);
79
+ }
@@ -0,0 +1,156 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import type { LLMConfig, ServerConfig } from '../config/config.js';
3
+ export declare class McpClientWrapper {
4
+ client: Client;
5
+ private transport;
6
+ /**
7
+ * True only after a successful `connect()`. Lets the CLI run in a degraded
8
+ * "offline" mode when the MCP server is unreachable at startup — `listTools`
9
+ * returns an empty list and `callTool` returns an error envelope instead of
10
+ * blowing up, which the agent's existing try/catch wrappers already handle.
11
+ */
12
+ private connected;
13
+ constructor();
14
+ /** Whether this wrapper has an active MCP transport. */
15
+ isConnected(): boolean;
16
+ connect(serverConfig: ServerConfig, llmConfig?: LLMConfig): Promise<void>;
17
+ listTools(): Promise<{
18
+ [x: string]: unknown;
19
+ tools: {
20
+ inputSchema: {
21
+ [x: string]: unknown;
22
+ type: "object";
23
+ properties?: Record<string, object> | undefined;
24
+ required?: string[] | undefined;
25
+ };
26
+ name: string;
27
+ description?: string | undefined;
28
+ outputSchema?: {
29
+ [x: string]: unknown;
30
+ type: "object";
31
+ properties?: Record<string, object> | undefined;
32
+ required?: string[] | undefined;
33
+ } | undefined;
34
+ annotations?: {
35
+ title?: string | undefined;
36
+ readOnlyHint?: boolean | undefined;
37
+ destructiveHint?: boolean | undefined;
38
+ idempotentHint?: boolean | undefined;
39
+ openWorldHint?: boolean | undefined;
40
+ } | undefined;
41
+ execution?: {
42
+ taskSupport?: "optional" | "required" | "forbidden" | undefined;
43
+ } | undefined;
44
+ _meta?: Record<string, unknown> | undefined;
45
+ icons?: {
46
+ src: string;
47
+ mimeType?: string | undefined;
48
+ sizes?: string[] | undefined;
49
+ theme?: "light" | "dark" | undefined;
50
+ }[] | undefined;
51
+ title?: string | undefined;
52
+ }[];
53
+ _meta?: {
54
+ [x: string]: unknown;
55
+ progressToken?: string | number | undefined;
56
+ "io.modelcontextprotocol/related-task"?: {
57
+ taskId: string;
58
+ } | undefined;
59
+ } | undefined;
60
+ nextCursor?: string | undefined;
61
+ }>;
62
+ callTool(name: string, args: Record<string, any>): Promise<{
63
+ [x: string]: unknown;
64
+ content: ({
65
+ type: "text";
66
+ text: string;
67
+ annotations?: {
68
+ audience?: ("user" | "assistant")[] | undefined;
69
+ priority?: number | undefined;
70
+ lastModified?: string | undefined;
71
+ } | undefined;
72
+ _meta?: Record<string, unknown> | undefined;
73
+ } | {
74
+ type: "image";
75
+ data: string;
76
+ mimeType: string;
77
+ annotations?: {
78
+ audience?: ("user" | "assistant")[] | undefined;
79
+ priority?: number | undefined;
80
+ lastModified?: string | undefined;
81
+ } | undefined;
82
+ _meta?: Record<string, unknown> | undefined;
83
+ } | {
84
+ type: "audio";
85
+ data: string;
86
+ mimeType: string;
87
+ annotations?: {
88
+ audience?: ("user" | "assistant")[] | undefined;
89
+ priority?: number | undefined;
90
+ lastModified?: string | undefined;
91
+ } | undefined;
92
+ _meta?: Record<string, unknown> | undefined;
93
+ } | {
94
+ type: "resource";
95
+ resource: {
96
+ uri: string;
97
+ text: string;
98
+ mimeType?: string | undefined;
99
+ _meta?: Record<string, unknown> | undefined;
100
+ } | {
101
+ uri: string;
102
+ blob: string;
103
+ mimeType?: string | undefined;
104
+ _meta?: Record<string, unknown> | undefined;
105
+ };
106
+ annotations?: {
107
+ audience?: ("user" | "assistant")[] | undefined;
108
+ priority?: number | undefined;
109
+ lastModified?: string | undefined;
110
+ } | undefined;
111
+ _meta?: Record<string, unknown> | undefined;
112
+ } | {
113
+ uri: string;
114
+ name: string;
115
+ type: "resource_link";
116
+ description?: string | undefined;
117
+ mimeType?: string | undefined;
118
+ size?: number | undefined;
119
+ annotations?: {
120
+ audience?: ("user" | "assistant")[] | undefined;
121
+ priority?: number | undefined;
122
+ lastModified?: string | undefined;
123
+ } | undefined;
124
+ _meta?: {
125
+ [x: string]: unknown;
126
+ } | undefined;
127
+ icons?: {
128
+ src: string;
129
+ mimeType?: string | undefined;
130
+ sizes?: string[] | undefined;
131
+ theme?: "light" | "dark" | undefined;
132
+ }[] | undefined;
133
+ title?: string | undefined;
134
+ })[];
135
+ _meta?: {
136
+ [x: string]: unknown;
137
+ progressToken?: string | number | undefined;
138
+ "io.modelcontextprotocol/related-task"?: {
139
+ taskId: string;
140
+ } | undefined;
141
+ } | undefined;
142
+ structuredContent?: Record<string, unknown> | undefined;
143
+ isError?: boolean | undefined;
144
+ } | {
145
+ [x: string]: unknown;
146
+ toolResult: unknown;
147
+ _meta?: {
148
+ [x: string]: unknown;
149
+ progressToken?: string | number | undefined;
150
+ "io.modelcontextprotocol/related-task"?: {
151
+ taskId: string;
152
+ } | undefined;
153
+ } | undefined;
154
+ }>;
155
+ close(): Promise<void>;
156
+ }