brownian-code 2026.2.10

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/bin/brownian +25 -0
  4. package/env.example +21 -0
  5. package/package.json +87 -0
  6. package/src/agent/agent.test.ts +414 -0
  7. package/src/agent/agent.ts +385 -0
  8. package/src/agent/index.ts +27 -0
  9. package/src/agent/prompts.ts +271 -0
  10. package/src/agent/scratchpad.test.ts +482 -0
  11. package/src/agent/scratchpad.ts +526 -0
  12. package/src/agent/token-counter.test.ts +59 -0
  13. package/src/agent/token-counter.ts +33 -0
  14. package/src/agent/types.ts +137 -0
  15. package/src/cli.tsx +385 -0
  16. package/src/commands/builtin.test.ts +271 -0
  17. package/src/commands/builtin.ts +200 -0
  18. package/src/commands/registry.test.ts +188 -0
  19. package/src/commands/registry.ts +111 -0
  20. package/src/commands/types.ts +64 -0
  21. package/src/components/AgentEventView.tsx +487 -0
  22. package/src/components/AnswerBox.tsx +81 -0
  23. package/src/components/ApiKeyPrompt.tsx +75 -0
  24. package/src/components/CommandMenu.test.tsx +64 -0
  25. package/src/components/CommandMenu.tsx +38 -0
  26. package/src/components/CursorText.tsx +43 -0
  27. package/src/components/DebugPanel.tsx +48 -0
  28. package/src/components/ErrorBox.test.tsx +58 -0
  29. package/src/components/ErrorBox.tsx +26 -0
  30. package/src/components/HelpView.test.tsx +70 -0
  31. package/src/components/HelpView.tsx +61 -0
  32. package/src/components/HistoryItemView.tsx +108 -0
  33. package/src/components/Input.tsx +193 -0
  34. package/src/components/Intro.test.tsx +59 -0
  35. package/src/components/Intro.tsx +35 -0
  36. package/src/components/ModelSelector.tsx +288 -0
  37. package/src/components/StatusBar.test.tsx +78 -0
  38. package/src/components/StatusBar.tsx +56 -0
  39. package/src/components/WorkingIndicator.tsx +133 -0
  40. package/src/components/index.ts +23 -0
  41. package/src/e2e/agent-flow.test.ts +378 -0
  42. package/src/evals/components/EvalApp.tsx +206 -0
  43. package/src/evals/components/EvalCurrentQuestion.tsx +42 -0
  44. package/src/evals/components/EvalProgress.tsx +33 -0
  45. package/src/evals/components/EvalRecentResults.tsx +63 -0
  46. package/src/evals/components/EvalStats.tsx +49 -0
  47. package/src/evals/components/index.ts +5 -0
  48. package/src/evals/dataset/crypto_agent.csv +16 -0
  49. package/src/evals/run.ts +355 -0
  50. package/src/gateway/channels/whatsapp/auth-store.ts +15 -0
  51. package/src/gateway/channels/whatsapp/inbound.ts +86 -0
  52. package/src/gateway/channels/whatsapp/login.ts +28 -0
  53. package/src/gateway/channels/whatsapp/outbound.ts +27 -0
  54. package/src/gateway/channels/whatsapp/session.ts +69 -0
  55. package/src/gateway/config.ts +81 -0
  56. package/src/gateway/index.ts +62 -0
  57. package/src/hooks/useAgentRunner.ts +317 -0
  58. package/src/hooks/useDebugLogs.ts +22 -0
  59. package/src/hooks/useInputHistory.ts +106 -0
  60. package/src/hooks/useModelSelection.ts +249 -0
  61. package/src/hooks/useTextBuffer.test.ts +121 -0
  62. package/src/hooks/useTextBuffer.ts +97 -0
  63. package/src/index.tsx +74 -0
  64. package/src/mcp/cache.ts +205 -0
  65. package/src/mcp/client.test.ts +126 -0
  66. package/src/mcp/client.ts +145 -0
  67. package/src/mcp/index.ts +2 -0
  68. package/src/model/llm.test.ts +158 -0
  69. package/src/model/llm.ts +233 -0
  70. package/src/providers.ts +94 -0
  71. package/src/skills/index.ts +17 -0
  72. package/src/skills/loader.ts +73 -0
  73. package/src/skills/registry.ts +125 -0
  74. package/src/skills/types.ts +31 -0
  75. package/src/test-utils/mocks.ts +110 -0
  76. package/src/theme.ts +21 -0
  77. package/src/tools/browser/browser.ts +357 -0
  78. package/src/tools/browser/index.ts +1 -0
  79. package/src/tools/crypto/hive-tools.ts +171 -0
  80. package/src/tools/crypto/index.ts +1 -0
  81. package/src/tools/descriptions/browser.ts +105 -0
  82. package/src/tools/descriptions/crypto-search.ts +58 -0
  83. package/src/tools/descriptions/index.ts +8 -0
  84. package/src/tools/descriptions/web-fetch.ts +44 -0
  85. package/src/tools/descriptions/web-search.ts +26 -0
  86. package/src/tools/fetch/cache.ts +95 -0
  87. package/src/tools/fetch/external-content.ts +200 -0
  88. package/src/tools/fetch/index.ts +1 -0
  89. package/src/tools/fetch/web-fetch-utils.ts +122 -0
  90. package/src/tools/fetch/web-fetch.ts +371 -0
  91. package/src/tools/index.ts +12 -0
  92. package/src/tools/registry.ts +130 -0
  93. package/src/tools/search/exa.ts +43 -0
  94. package/src/tools/search/index.ts +2 -0
  95. package/src/tools/search/tavily.ts +35 -0
  96. package/src/tools/skill.ts +62 -0
  97. package/src/tools/types.ts +53 -0
  98. package/src/utils/ai-message.ts +26 -0
  99. package/src/utils/config.ts +54 -0
  100. package/src/utils/cost-calculator.test.ts +101 -0
  101. package/src/utils/cost-calculator.ts +74 -0
  102. package/src/utils/env.ts +101 -0
  103. package/src/utils/error-classifier.test.ts +146 -0
  104. package/src/utils/error-classifier.ts +91 -0
  105. package/src/utils/in-memory-chat-history.test.ts +291 -0
  106. package/src/utils/in-memory-chat-history.ts +224 -0
  107. package/src/utils/index.ts +19 -0
  108. package/src/utils/input-key-handlers.test.ts +155 -0
  109. package/src/utils/input-key-handlers.ts +64 -0
  110. package/src/utils/logger.ts +67 -0
  111. package/src/utils/long-term-chat-history.ts +138 -0
  112. package/src/utils/markdown-table.ts +227 -0
  113. package/src/utils/ollama.ts +37 -0
  114. package/src/utils/progress-channel.ts +84 -0
  115. package/src/utils/text-navigation.test.ts +222 -0
  116. package/src/utils/text-navigation.ts +81 -0
  117. package/src/utils/thinking-verbs.ts +29 -0
  118. package/src/utils/tokens.test.ts +163 -0
  119. package/src/utils/tokens.ts +67 -0
  120. package/src/utils/tool-description.ts +88 -0
@@ -0,0 +1,233 @@
1
+ import { AIMessage } from '@langchain/core/messages';
2
+ import { ChatOpenAI } from '@langchain/openai';
3
+ import { ChatAnthropic } from '@langchain/anthropic';
4
+ import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
5
+ import { ChatOllama } from '@langchain/ollama';
6
+ import { SystemMessage, HumanMessage } from '@langchain/core/messages';
7
+ import { BaseChatModel } from '@langchain/core/language_models/chat_models';
8
+ import { StructuredToolInterface } from '@langchain/core/tools';
9
+ import { Runnable } from '@langchain/core/runnables';
10
+ import { z } from 'zod';
11
+ import { DEFAULT_SYSTEM_PROMPT } from '@/agent/prompts';
12
+ import type { TokenUsage } from '@/agent/types';
13
+ import { logger } from '@/utils';
14
+ import { resolveProvider, getProviderById } from '@/providers';
15
+
16
+ export const DEFAULT_PROVIDER = 'anthropic';
17
+ export const DEFAULT_MODEL = 'claude-sonnet-4-5';
18
+
19
+ /**
20
+ * Gets the fast model variant for the given provider.
21
+ * Falls back to the provided model if no fast variant is configured (e.g., Ollama).
22
+ */
23
+ export function getFastModel(modelProvider: string, fallbackModel: string): string {
24
+ return getProviderById(modelProvider)?.fastModel ?? fallbackModel;
25
+ }
26
+
27
+ // Generic retry helper with exponential backoff
28
+ async function withRetry<T>(fn: () => Promise<T>, provider: string, maxAttempts = 3): Promise<T> {
29
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
30
+ try {
31
+ return await fn();
32
+ } catch (e) {
33
+ const message = e instanceof Error ? e.message : String(e);
34
+ logger.error(`[${provider} API] error (attempt ${attempt + 1}/${maxAttempts}): ${message}`);
35
+
36
+ if (attempt === maxAttempts - 1) {
37
+ throw new Error(`[${provider} API] ${message}`);
38
+ }
39
+ await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
40
+ }
41
+ }
42
+ throw new Error('Unreachable');
43
+ }
44
+
45
+ // Model provider configuration
46
+ interface ModelOpts {
47
+ streaming: boolean;
48
+ }
49
+
50
+ type ModelFactory = (name: string, opts: ModelOpts) => BaseChatModel;
51
+
52
+ function getApiKey(envVar: string): string {
53
+ const apiKey = process.env[envVar];
54
+ if (!apiKey || apiKey.trim().startsWith('your-')) {
55
+ throw new Error(`API key not configured. Add ${envVar}=<your-key> to your .env file`);
56
+ }
57
+ return apiKey;
58
+ }
59
+
60
+ // Factories keyed by provider id — prefix routing is handled by resolveProvider()
61
+ const MODEL_FACTORIES: Record<string, ModelFactory> = {
62
+ anthropic: (name, opts) =>
63
+ new ChatAnthropic({
64
+ model: name,
65
+ ...opts,
66
+ apiKey: getApiKey('ANTHROPIC_API_KEY'),
67
+ }),
68
+ google: (name, opts) =>
69
+ new ChatGoogleGenerativeAI({
70
+ model: name,
71
+ ...opts,
72
+ apiKey: getApiKey('GOOGLE_API_KEY'),
73
+ }),
74
+ xai: (name, opts) =>
75
+ new ChatOpenAI({
76
+ model: name,
77
+ ...opts,
78
+ apiKey: getApiKey('XAI_API_KEY'),
79
+ configuration: {
80
+ baseURL: 'https://api.x.ai/v1',
81
+ },
82
+ }),
83
+ openrouter: (name, opts) =>
84
+ new ChatOpenAI({
85
+ model: name.replace(/^openrouter:/, ''),
86
+ ...opts,
87
+ apiKey: getApiKey('OPENROUTER_API_KEY'),
88
+ configuration: {
89
+ baseURL: 'https://openrouter.ai/api/v1',
90
+ },
91
+ }),
92
+ moonshot: (name, opts) =>
93
+ new ChatOpenAI({
94
+ model: name,
95
+ ...opts,
96
+ apiKey: getApiKey('MOONSHOT_API_KEY'),
97
+ configuration: {
98
+ baseURL: 'https://api.moonshot.cn/v1',
99
+ },
100
+ }),
101
+ deepseek: (name, opts) =>
102
+ new ChatOpenAI({
103
+ model: name,
104
+ ...opts,
105
+ apiKey: getApiKey('DEEPSEEK_API_KEY'),
106
+ configuration: {
107
+ baseURL: 'https://api.deepseek.com',
108
+ },
109
+ }),
110
+ ollama: (name, opts) =>
111
+ new ChatOllama({
112
+ model: name.replace(/^ollama:/, ''),
113
+ ...opts,
114
+ ...(process.env.OLLAMA_BASE_URL ? { baseUrl: process.env.OLLAMA_BASE_URL } : {}),
115
+ }),
116
+ };
117
+
118
+ const DEFAULT_FACTORY: ModelFactory = (name, opts) =>
119
+ new ChatOpenAI({
120
+ model: name,
121
+ ...opts,
122
+ apiKey: getApiKey('OPENAI_API_KEY'),
123
+ });
124
+
125
+ export function getChatModel(
126
+ modelName: string = DEFAULT_MODEL,
127
+ streaming: boolean = false
128
+ ): BaseChatModel {
129
+ const opts: ModelOpts = { streaming };
130
+ const provider = resolveProvider(modelName);
131
+ const factory = MODEL_FACTORIES[provider.id] ?? DEFAULT_FACTORY;
132
+ return factory(modelName, opts);
133
+ }
134
+
135
+ interface CallLlmOptions {
136
+ model?: string;
137
+ systemPrompt?: string;
138
+ outputSchema?: z.ZodType<unknown>;
139
+ tools?: StructuredToolInterface[];
140
+ signal?: AbortSignal;
141
+ }
142
+
143
+ export interface LlmResult {
144
+ response: AIMessage | string;
145
+ usage?: TokenUsage;
146
+ }
147
+
148
+ function extractUsage(result: unknown): TokenUsage | undefined {
149
+ if (!result || typeof result !== 'object') return undefined;
150
+ const msg = result as Record<string, unknown>;
151
+
152
+ const usageMetadata = msg.usage_metadata;
153
+ if (usageMetadata && typeof usageMetadata === 'object') {
154
+ const u = usageMetadata as Record<string, unknown>;
155
+ const input = typeof u.input_tokens === 'number' ? u.input_tokens : 0;
156
+ const output = typeof u.output_tokens === 'number' ? u.output_tokens : 0;
157
+ const total = typeof u.total_tokens === 'number' ? u.total_tokens : input + output;
158
+ return { inputTokens: input, outputTokens: output, totalTokens: total };
159
+ }
160
+
161
+ const responseMetadata = msg.response_metadata;
162
+ if (responseMetadata && typeof responseMetadata === 'object') {
163
+ const rm = responseMetadata as Record<string, unknown>;
164
+ if (rm.usage && typeof rm.usage === 'object') {
165
+ const u = rm.usage as Record<string, unknown>;
166
+ const input = typeof u.prompt_tokens === 'number' ? u.prompt_tokens : 0;
167
+ const output = typeof u.completion_tokens === 'number' ? u.completion_tokens : 0;
168
+ const total = typeof u.total_tokens === 'number' ? u.total_tokens : input + output;
169
+ return { inputTokens: input, outputTokens: output, totalTokens: total };
170
+ }
171
+ }
172
+
173
+ return undefined;
174
+ }
175
+
176
+ /**
177
+ * Build messages with Anthropic cache_control on the system prompt.
178
+ * Marks the system prompt as ephemeral so Anthropic caches the prefix,
179
+ * reducing input token costs by ~90% on subsequent calls.
180
+ */
181
+ function buildAnthropicMessages(systemPrompt: string, userPrompt: string) {
182
+ return [
183
+ new SystemMessage({
184
+ content: [
185
+ {
186
+ type: 'text' as const,
187
+ text: systemPrompt,
188
+ cache_control: { type: 'ephemeral' },
189
+ },
190
+ ],
191
+ }),
192
+ new HumanMessage(userPrompt),
193
+ ];
194
+ }
195
+
196
+ export async function callLlm(prompt: string, options: CallLlmOptions = {}): Promise<LlmResult> {
197
+ const { model = DEFAULT_MODEL, systemPrompt, outputSchema, tools, signal } = options;
198
+ const finalSystemPrompt = systemPrompt || DEFAULT_SYSTEM_PROMPT;
199
+
200
+ const llm = getChatModel(model, false);
201
+
202
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
203
+ let runnable: Runnable<any, any> = llm;
204
+
205
+ if (outputSchema) {
206
+ runnable = llm.withStructuredOutput(outputSchema, { strict: false });
207
+ } else if (tools && tools.length > 0 && llm.bindTools) {
208
+ runnable = llm.bindTools(tools);
209
+ }
210
+
211
+ const invokeOpts = signal ? { signal } : undefined;
212
+ const provider = resolveProvider(model);
213
+ let result;
214
+
215
+ if (provider.id === 'anthropic') {
216
+ // Anthropic: use explicit messages with cache_control for prompt caching (~90% savings)
217
+ const messages = buildAnthropicMessages(finalSystemPrompt, prompt);
218
+ result = await withRetry(() => runnable.invoke(messages, invokeOpts), provider.displayName);
219
+ } else {
220
+ // Other providers: use direct messages to avoid ChatPromptTemplate parsing
221
+ // literal {} in the system prompt (JSON examples) as template variables
222
+ const messages = [new SystemMessage(finalSystemPrompt), new HumanMessage(prompt)];
223
+ result = await withRetry(() => runnable.invoke(messages, invokeOpts), provider.displayName);
224
+ }
225
+ const usage = extractUsage(result);
226
+
227
+ // If no outputSchema and no tools, extract content from AIMessage
228
+ // When tools are provided, return the full AIMessage to preserve tool_calls
229
+ if (!outputSchema && !tools && result && typeof result === 'object' && 'content' in result) {
230
+ return { response: (result as { content: string }).content, usage };
231
+ }
232
+ return { response: result as AIMessage, usage };
233
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Canonical provider registry — single source of truth for all provider metadata.
3
+ * When adding a new provider, add a single entry here; all other modules derive from this.
4
+ */
5
+
6
+ export interface ProviderDef {
7
+ /** Slug used in config/settings (e.g., 'anthropic') */
8
+ id: string;
9
+ /** Human-readable name (e.g., 'Anthropic') */
10
+ displayName: string;
11
+ /** Model name prefix used for routing (e.g., 'claude-'). Empty string for default (OpenAI). */
12
+ modelPrefix: string;
13
+ /** Environment variable name for API key. Omit for local providers (e.g., Ollama). */
14
+ apiKeyEnvVar?: string;
15
+ /** Fast model variant for lightweight tasks like summarization. */
16
+ fastModel?: string;
17
+ }
18
+
19
+ export const PROVIDERS: ProviderDef[] = [
20
+ {
21
+ id: 'openai',
22
+ displayName: 'OpenAI',
23
+ modelPrefix: '',
24
+ apiKeyEnvVar: 'OPENAI_API_KEY',
25
+ fastModel: 'gpt-4.1',
26
+ },
27
+ {
28
+ id: 'anthropic',
29
+ displayName: 'Anthropic',
30
+ modelPrefix: 'claude-',
31
+ apiKeyEnvVar: 'ANTHROPIC_API_KEY',
32
+ fastModel: 'claude-haiku-4-5',
33
+ },
34
+ {
35
+ id: 'google',
36
+ displayName: 'Google',
37
+ modelPrefix: 'gemini-',
38
+ apiKeyEnvVar: 'GOOGLE_API_KEY',
39
+ fastModel: 'gemini-3-flash-preview',
40
+ },
41
+ {
42
+ id: 'xai',
43
+ displayName: 'xAI',
44
+ modelPrefix: 'grok-',
45
+ apiKeyEnvVar: 'XAI_API_KEY',
46
+ fastModel: 'grok-4-1-fast-reasoning',
47
+ },
48
+ {
49
+ id: 'moonshot',
50
+ displayName: 'Moonshot',
51
+ modelPrefix: 'kimi-',
52
+ apiKeyEnvVar: 'MOONSHOT_API_KEY',
53
+ fastModel: 'kimi-k2-5',
54
+ },
55
+ {
56
+ id: 'deepseek',
57
+ displayName: 'DeepSeek',
58
+ modelPrefix: 'deepseek-',
59
+ apiKeyEnvVar: 'DEEPSEEK_API_KEY',
60
+ fastModel: 'deepseek-chat',
61
+ },
62
+ {
63
+ id: 'openrouter',
64
+ displayName: 'OpenRouter',
65
+ modelPrefix: 'openrouter:',
66
+ apiKeyEnvVar: 'OPENROUTER_API_KEY',
67
+ fastModel: 'openrouter:openai/gpt-4o-mini',
68
+ },
69
+ {
70
+ id: 'ollama',
71
+ displayName: 'Ollama',
72
+ modelPrefix: 'ollama:',
73
+ },
74
+ ];
75
+
76
+ const defaultProvider = PROVIDERS.find((p) => p.id === 'anthropic')!;
77
+
78
+ /**
79
+ * Resolve the provider for a given model name based on its prefix.
80
+ * Falls back to OpenAI when no prefix matches.
81
+ */
82
+ export function resolveProvider(modelName: string): ProviderDef {
83
+ return (
84
+ PROVIDERS.find((p) => p.modelPrefix && modelName.startsWith(p.modelPrefix)) ??
85
+ defaultProvider
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Look up a provider by its slug (e.g., 'anthropic', 'google').
91
+ */
92
+ export function getProviderById(id: string): ProviderDef | undefined {
93
+ return PROVIDERS.find((p) => p.id === id);
94
+ }
@@ -0,0 +1,17 @@
1
+ // Skill types
2
+ export type { SkillMetadata, Skill, SkillSource } from './types.js';
3
+
4
+ // Skill registry functions
5
+ export {
6
+ discoverSkills,
7
+ getSkill,
8
+ buildSkillMetadataSection,
9
+ clearSkillCache,
10
+ } from './registry.js';
11
+
12
+ // Skill loader functions
13
+ export {
14
+ parseSkillFile,
15
+ loadSkillFromPath,
16
+ extractSkillMetadata,
17
+ } from './loader.js';
@@ -0,0 +1,73 @@
1
+ import { readFileSync } from 'fs';
2
+ import matter from 'gray-matter';
3
+ import type { Skill, SkillSource } from './types.js';
4
+
5
+ /**
6
+ * Parse a SKILL.md file content into a Skill object.
7
+ * Extracts YAML frontmatter (name, description) and the markdown body (instructions).
8
+ *
9
+ * @param content - Raw file content
10
+ * @param path - Absolute path to the file (for reference)
11
+ * @param source - Where this skill came from
12
+ * @returns Parsed Skill object
13
+ * @throws Error if required frontmatter fields are missing
14
+ */
15
+ export function parseSkillFile(content: string, path: string, source: SkillSource): Skill {
16
+ const { data, content: instructions } = matter(content);
17
+
18
+ // Validate required frontmatter fields
19
+ if (!data.name || typeof data.name !== 'string') {
20
+ throw new Error(`Skill at ${path} is missing required 'name' field in frontmatter`);
21
+ }
22
+ if (!data.description || typeof data.description !== 'string') {
23
+ throw new Error(`Skill at ${path} is missing required 'description' field in frontmatter`);
24
+ }
25
+
26
+ return {
27
+ name: data.name,
28
+ description: data.description,
29
+ path,
30
+ source,
31
+ instructions: instructions.trim(),
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Load a skill from a file path.
37
+ *
38
+ * @param path - Absolute path to the SKILL.md file
39
+ * @param source - Where this skill came from
40
+ * @returns Parsed Skill object
41
+ * @throws Error if file cannot be read or parsed
42
+ */
43
+ export function loadSkillFromPath(path: string, source: SkillSource): Skill {
44
+ const content = readFileSync(path, 'utf-8');
45
+ return parseSkillFile(content, path, source);
46
+ }
47
+
48
+ /**
49
+ * Extract just the metadata from a skill file without loading full instructions.
50
+ * Used for lightweight discovery at startup.
51
+ *
52
+ * @param path - Absolute path to the SKILL.md file
53
+ * @param source - Where this skill came from
54
+ * @returns Skill metadata (name, description, path, source)
55
+ */
56
+ export function extractSkillMetadata(path: string, source: SkillSource): { name: string; description: string; path: string; source: SkillSource } {
57
+ const content = readFileSync(path, 'utf-8');
58
+ const { data } = matter(content);
59
+
60
+ if (!data.name || typeof data.name !== 'string') {
61
+ throw new Error(`Skill at ${path} is missing required 'name' field in frontmatter`);
62
+ }
63
+ if (!data.description || typeof data.description !== 'string') {
64
+ throw new Error(`Skill at ${path} is missing required 'description' field in frontmatter`);
65
+ }
66
+
67
+ return {
68
+ name: data.name,
69
+ description: data.description,
70
+ path,
71
+ source,
72
+ };
73
+ }
@@ -0,0 +1,125 @@
1
+ import { existsSync, readdirSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { homedir } from 'os';
4
+ import { fileURLToPath } from 'url';
5
+ import type { SkillMetadata, Skill, SkillSource } from './types.js';
6
+ import { extractSkillMetadata, loadSkillFromPath } from './loader.js';
7
+
8
+ // Get the directory of this file to locate builtin skills
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+
12
+ /**
13
+ * Skill directories in order of precedence (later overrides earlier).
14
+ */
15
+ const SKILL_DIRECTORIES: { path: string; source: SkillSource }[] = [
16
+ { path: __dirname, source: 'builtin' },
17
+ { path: join(homedir(), '.brownian', 'skills'), source: 'user' },
18
+ { path: join(process.cwd(), '.brownian', 'skills'), source: 'project' },
19
+ ];
20
+
21
+ // Cache for discovered skills (metadata only)
22
+ let skillMetadataCache: Map<string, SkillMetadata> | null = null;
23
+
24
+ /**
25
+ * Scan a directory for SKILL.md files and return their metadata.
26
+ * Looks for directories containing SKILL.md files.
27
+ *
28
+ * @param dirPath - Directory to scan
29
+ * @param source - Source type for discovered skills
30
+ * @returns Array of skill metadata
31
+ */
32
+ function scanSkillDirectory(dirPath: string, source: SkillSource): SkillMetadata[] {
33
+ if (!existsSync(dirPath)) {
34
+ return [];
35
+ }
36
+
37
+ const skills: SkillMetadata[] = [];
38
+ const entries = readdirSync(dirPath, { withFileTypes: true });
39
+
40
+ for (const entry of entries) {
41
+ if (entry.isDirectory()) {
42
+ const skillFilePath = join(dirPath, entry.name, 'SKILL.md');
43
+ if (existsSync(skillFilePath)) {
44
+ try {
45
+ const metadata = extractSkillMetadata(skillFilePath, source);
46
+ skills.push(metadata);
47
+ } catch {
48
+ // Skip invalid skill files silently
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ return skills;
55
+ }
56
+
57
+ /**
58
+ * Discover all available skills from all skill directories.
59
+ * Later sources (project > user > builtin) override earlier ones.
60
+ *
61
+ * @returns Array of skill metadata, deduplicated by name
62
+ */
63
+ export function discoverSkills(): SkillMetadata[] {
64
+ if (skillMetadataCache) {
65
+ return Array.from(skillMetadataCache.values());
66
+ }
67
+
68
+ skillMetadataCache = new Map();
69
+
70
+ for (const { path, source } of SKILL_DIRECTORIES) {
71
+ const skills = scanSkillDirectory(path, source);
72
+ for (const skill of skills) {
73
+ // Later sources override earlier ones (by name)
74
+ skillMetadataCache.set(skill.name, skill);
75
+ }
76
+ }
77
+
78
+ return Array.from(skillMetadataCache.values());
79
+ }
80
+
81
+ /**
82
+ * Get a skill by name, loading full instructions.
83
+ *
84
+ * @param name - Name of the skill to load
85
+ * @returns Full skill definition or undefined if not found
86
+ */
87
+ export function getSkill(name: string): Skill | undefined {
88
+ // Ensure cache is populated
89
+ if (!skillMetadataCache) {
90
+ discoverSkills();
91
+ }
92
+
93
+ const metadata = skillMetadataCache?.get(name);
94
+ if (!metadata) {
95
+ return undefined;
96
+ }
97
+
98
+ // Load full skill with instructions
99
+ return loadSkillFromPath(metadata.path, metadata.source);
100
+ }
101
+
102
+ /**
103
+ * Build the skill metadata section for the system prompt.
104
+ * Only includes name and description (lightweight).
105
+ *
106
+ * @returns Formatted string for system prompt injection
107
+ */
108
+ export function buildSkillMetadataSection(): string {
109
+ const skills = discoverSkills();
110
+
111
+ if (skills.length === 0) {
112
+ return 'No skills available.';
113
+ }
114
+
115
+ return skills
116
+ .map((s) => `- **${s.name}**: ${s.description}`)
117
+ .join('\n');
118
+ }
119
+
120
+ /**
121
+ * Clear the skill cache. Useful for testing or when skills are added/removed.
122
+ */
123
+ export function clearSkillCache(): void {
124
+ skillMetadataCache = null;
125
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Source of a skill definition.
3
+ * - builtin: Shipped with Brownian Code (src/skills/builtin/)
4
+ * - user: User-level skills (~/.brownian/skills/)
5
+ * - project: Project-level skills (.brownian/skills/)
6
+ */
7
+ export type SkillSource = 'builtin' | 'user' | 'project';
8
+
9
+ /**
10
+ * Skill metadata - lightweight info loaded at startup for system prompt injection.
11
+ * Only contains the name and description from YAML frontmatter.
12
+ */
13
+ export interface SkillMetadata {
14
+ /** Unique skill name (e.g., "dcf") */
15
+ name: string;
16
+ /** Description of when to use this skill */
17
+ description: string;
18
+ /** Absolute path to the SKILL.md file */
19
+ path: string;
20
+ /** Where this skill was discovered from */
21
+ source: SkillSource;
22
+ }
23
+
24
+ /**
25
+ * Full skill definition with instructions loaded on-demand.
26
+ * Extends metadata with the full SKILL.md body content.
27
+ */
28
+ export interface Skill extends SkillMetadata {
29
+ /** Full instructions from SKILL.md body (loaded when skill is invoked) */
30
+ instructions: string;
31
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Shared test utilities and mock factories for Brownian Code tests.
3
+ */
4
+ import { tmpdir } from 'os';
5
+ import { mkdtempSync, rmSync } from 'fs';
6
+ import { join } from 'path';
7
+ import type { AIMessage } from '@langchain/core/messages';
8
+
9
+ /**
10
+ * Create a minimal AIMessage-compatible object.
11
+ * Use for mocking callLlm responses that include tool calls.
12
+ */
13
+ export function createMockAIMessage(
14
+ content: string,
15
+ toolCalls?: Array<{ name: string; args: Record<string, unknown> }>
16
+ ): AIMessage {
17
+ return {
18
+ content,
19
+ tool_calls: toolCalls ?? [],
20
+ // Minimal AIMessage-compatible shape
21
+ lc_namespace: ['langchain_core', 'messages'],
22
+ lc_serializable: true,
23
+ _getType: () => 'ai',
24
+ } as unknown as AIMessage;
25
+ }
26
+
27
+ /**
28
+ * Create a mock tool compatible with StructuredToolInterface.
29
+ * @param name - Tool name
30
+ * @param resultOrFn - Static string result, or a function that receives args and returns a result
31
+ */
32
+ export function createMockTool(
33
+ name: string,
34
+ resultOrFn: string | ((args: Record<string, unknown>) => string | Promise<string>)
35
+ ) {
36
+ return {
37
+ name,
38
+ description: `Mock tool: ${name}`,
39
+ schema: {},
40
+ invoke: async (args: Record<string, unknown>) => {
41
+ if (typeof resultOrFn === 'function') {
42
+ return resultOrFn(args);
43
+ }
44
+ return resultOrFn;
45
+ },
46
+ lc_namespace: ['langchain_core', 'tools'],
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Create a callLlm mock that yields scripted responses in sequence.
52
+ * Each call pops the next response from the array.
53
+ *
54
+ * - string → treated as plain text response (no tools)
55
+ * - AIMessage → returned as-is (may include tool_calls)
56
+ */
57
+ export function createMockCallLlm(
58
+ responses: Array<string | AIMessage>
59
+ ) {
60
+ let index = 0;
61
+ return async (_prompt: string, _options?: unknown) => {
62
+ if (index >= responses.length) {
63
+ return { response: 'No more scripted responses', usage: undefined };
64
+ }
65
+ const resp = responses[index++];
66
+ return {
67
+ response: resp,
68
+ usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
69
+ };
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Run a function with CWD set to a unique temp directory.
75
+ * Restores CWD and cleans up the temp dir afterward.
76
+ */
77
+ export async function withTempCwd<T>(fn: (tmpDir: string) => T | Promise<T>): Promise<T> {
78
+ const originalCwd = process.cwd();
79
+ const tmpDir = mkdtempSync(join(tmpdir(), 'brownian-test-'));
80
+
81
+ try {
82
+ process.chdir(tmpDir);
83
+ return await fn(tmpDir);
84
+ } finally {
85
+ process.chdir(originalCwd);
86
+ try {
87
+ rmSync(tmpDir, { recursive: true, force: true });
88
+ } catch {
89
+ // Best effort cleanup
90
+ }
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Create a unique temp directory and return its path + cleanup function.
96
+ * Useful when you need to set up once in beforeAll.
97
+ */
98
+ export function createTempDir(): { path: string; cleanup: () => void } {
99
+ const tmpDir = mkdtempSync(join(tmpdir(), 'brownian-test-'));
100
+ return {
101
+ path: tmpDir,
102
+ cleanup: () => {
103
+ try {
104
+ rmSync(tmpDir, { recursive: true, force: true });
105
+ } catch {
106
+ // Best effort
107
+ }
108
+ },
109
+ };
110
+ }