Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions .smallcode/plugins/anthropic-provider/adapter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Anthropic Claude adapter — implements IModelProvider for SmallCode plugins.
// Translates between SmallCode's ChatRequest/ChatResponse and the Anthropic Messages API.

class AnthropicAdapter {
constructor(options = {}) {
this.name = 'anthropic';
this.apiKeyEnv = options.apiKeyEnv || 'ANTHROPIC_API_KEY';
this.baseUrl = options.baseUrl || 'https://api.anthropic.com/v1';
this.defaultModel = options.defaultModel || 'claude-sonnet-4-20250514';
this._apiKey = null;
}

_getApiKey() {
if (!this._apiKey) {
this._apiKey = process.env[this.apiKeyEnv];
if (!this._apiKey) {
throw new Error(`Missing API key: set ${this.apiKeyEnv} environment variable`);
}
}
return this._apiKey;
}

_toAnthropicMessages(req) {
const systemMessages = [];
const userMessages = [];

for (const msg of req.messages) {
if (msg.role === 'system') {
systemMessages.push(msg.content);
} else if (msg.role === 'user') {
userMessages.push({ role: 'user', content: msg.content });
} else if (msg.role === 'assistant') {
const assistantMsg = { role: 'assistant', content: msg.content };
if (msg.tool_calls && msg.tool_calls.length > 0) {
// SmallCode uses OpenAI ChatToolCall format:
// { id, type: "function", function: { name, arguments } }
// Anthropic expects tool_use blocks with flat { id, name, input }.
assistantMsg.content = [
{ type: 'text', text: msg.content || '' },
...msg.tool_calls.map(tc => ({
type: 'tool_use',
id: tc.id,
name: tc.function.name,
input: JSON.parse(tc.function.arguments || '{}'),
})),
];
}
userMessages.push(assistantMsg);
} else if (msg.role === 'tool') {
userMessages.push({
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: msg.tool_call_id,
content: msg.content,
}],
});
}
}

return {
system: systemMessages.join('\n') || undefined,
messages: userMessages,
};
}

_toAnthropicTools(tools) {
if (!tools || tools.length === 0) return undefined;
return tools.map(t => ({
name: t.name,
description: t.description,
input_schema: t.parameters,
}));
}

async chat(req, signal) {
const apiKey = this._getApiKey();
const { system, messages } = this._toAnthropicMessages(req);

const body = {
model: req.model || this.defaultModel,
messages,
max_tokens: req.max_output || 4096,
};
if (system) body.system = system;
if (req.temperature !== undefined) body.temperature = req.temperature;
if (req.top_p !== undefined) body.top_p = req.top_p;
if (req.stop) body.stop_sequences = req.stop;

const anthropicTools = this._toAnthropicTools(req.tools);
if (anthropicTools) {
body.tools = anthropicTools;
if (req.tool_choice) {
body.tool_choice = req.tool_choice === 'auto'
? { type: 'auto' }
: req.tool_choice === 'required'
? { type: 'any' }
: { type: 'auto' };
}
}

const response = await fetch(`${this.baseUrl}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(body),
signal,
});

if (!response.ok) {
const errText = await response.text().catch(() => 'Unknown error');
throw new Error(`Anthropic API error ${response.status}: ${errText.slice(0, 200)}`);
}

const data = await response.json();

// Parse response content blocks
let content = '';
const toolCalls = [];

for (const block of data.content || []) {
if (block.type === 'text') {
content += block.text;
} else if (block.type === 'tool_use') {
// Convert Anthropic's flat tool_use block back to OpenAI ChatToolCall
// format: { id, type: "function", function: { name, arguments } }.
// SmallCode's executor expects this format for tool call routing.
toolCalls.push({
id: block.id,
type: 'function',
function: {
name: block.name,
arguments: JSON.stringify(block.input),
},
});
}
}

return {
content,
usage: {
prompt_tokens: data.usage?.input_tokens || 0,
completion_tokens: data.usage?.output_tokens || 0,
},
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
raw: data,
};
}

countTokens(text) {
if (!text) return 0;
// Claude approximate: ~4 chars per token (close to BPE average)
return Math.ceil(text.length / 4);
}
}

module.exports = AnthropicAdapter;
4 changes: 4 additions & 0 deletions .smallcode/plugins/anthropic-provider/cleanup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Plugin shutdown: cleanup any resources.
module.exports = async function cleanup() {
// Nothing to clean up — API key is not cached in memory
};
8 changes: 8 additions & 0 deletions .smallcode/plugins/anthropic-provider/init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Plugin init: verify API key is available at startup.
module.exports = async function init({ config }) {
const keyEnv = 'ANTHROPIC_API_KEY';
if (!process.env[keyEnv]) {
console.log(` \x1b[33m⚠ Anthropic plugin loaded but ${keyEnv} is not set.\x1b[0m`);
console.log(` Set it in your .env or environment to use the anthropic provider.`);
}
};
8 changes: 8 additions & 0 deletions .smallcode/plugins/anthropic-provider/on-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Hook: on_error — runs when API call fails
module.exports = async function onError({ provider, model, error }) {
if (error?.status === 401) {
console.log(' \x1b[31mAuth failed — check ANTHROPIC_API_KEY\x1b[0m');
} else if (error?.status === 429) {
console.log(' \x1b[33mRate limited by Anthropic — backing off\x1b[0m');
}
};
21 changes: 21 additions & 0 deletions .smallcode/plugins/anthropic-provider/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "anthropic-provider",
"version": "0.1.0",
"description": "Anthropic Claude provider for SmallCode",
"init": "./init.js",
"shutdown": "./cleanup.js",
"permissions": { "read": true, "network": true },
"providers": [
{
"name": "anthropic",
"module": "./adapter.js",
"options": { "apiKeyEnv": "ANTHROPIC_API_KEY", "baseUrl": "https://api.anthropic.com/v1" },
"capabilities": { "tools": true, "streaming": true, "vision": true, "tokenCounting": "exact" }
}
],
"hooks": [
{ "event": "pre_request", "filter": ["anthropic"], "handler": "./pre-request.js" },
{ "event": "post_request", "filter": ["anthropic"], "handler": "./post-request.js" },
{ "event": "on_error", "handler": "./on-error.js" }
]
}
4 changes: 4 additions & 0 deletions .smallcode/plugins/anthropic-provider/post-request.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Hook: post_request — runs after successful Anthropic API response
module.exports = async function postRequest({ provider, model, response, usage }) {
// Could log usage, track metrics, etc.
};
4 changes: 4 additions & 0 deletions .smallcode/plugins/anthropic-provider/pre-request.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Hook: pre_request — runs before Anthropic API call
module.exports = async function preRequest({ provider, model, messages }) {
// Could inject metadata, log, modify request, etc.
};
12 changes: 12 additions & 0 deletions .smallcode/plugins/prompt-inject/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "prompt-inject",
"version": "1.0.0",
"description": "Provider plugin that wraps any registered provider and injects content into system prompts. Use for custom instructions, RAG context, or persona overrides.",
"providers": [
{
"name": "prompt-inject",
"module": "../../src/compiled/providers/prompt_inject.js",
"description": "Wraps any registered provider and injects content into system prompts"
}
]
}
7 changes: 7 additions & 0 deletions bin/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ function loadConfig(flags = {}) {
async function checkEndpoint(config) {
const baseUrl = config.model.baseUrl || process.env.OLLAMA_HOST || 'http://localhost:11434';

// Plugin-registered providers handle their own connectivity
const { providerRegistry } = require('../src/compiled/providers/registry');
if (providerRegistry.has(config.model.provider)) {
console.log(` Using plugin provider: ${config.model.provider}`);
return true;
}

// OpenAI-compatible endpoint (LM Studio, vLLM, OpenRouter, etc.)
if (config.model.provider === 'openai' || baseUrl.includes('/v1')) {
try {
Expand Down
17 changes: 17 additions & 0 deletions bin/escalation.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,23 @@ Be precise. Don't explain unnecessarily. Just fix it.
${systemPromptExtra}`,
};

// Plugin-registered providers: use the registry
const { providerRegistry } = require('../src/compiled/providers/registry');
const pluginProvider = providerRegistry.get(this.provider);
if (pluginProvider) {
try {
return await pluginProvider.chat({
model: this.model,
messages: [systemMsg, ...messages],
temperature: 0.1,
maxOutput: 4096,
tools: tools || [],
});
} catch (err) {
return { error: `Plugin provider "${this.provider}" failed: ${err.message}` };
}
}

if (this.provider === 'anthropic') {
return this._callAnthropic([systemMsg, ...messages], tools);
} else {
Expand Down
7 changes: 6 additions & 1 deletion bin/executor.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,12 @@ async function executeTool(name, args, ctx) {
try { getSnapshotManager({ workdir: cwd }).note(filePath, content); } catch {}
const count = content.split(args.old_str).length - 1;
if (count === 0) {
// MarrowScript Rank 7: semantic_merge — recover from old_str not found
// MarrowScript Rank 7: semantic_merge — recover from old_str not found.
// When the model tries to patch a file but provides an old_str that
// doesn't exactly match (e.g. whitespace drift from tokenization),
// semanticMerge attempts a fuzzy reconstruction. The result is a full
// file replacement, not a surgical patch — acceptable as a fallback
// because the original old_str already failed to match.
try {
const { semanticMerge } = require('./features_adapter');
if (semanticMerge) {
Expand Down
86 changes: 86 additions & 0 deletions bin/smallcode.js
Original file line number Diff line number Diff line change
Expand Up @@ -2176,6 +2176,67 @@ async function chatCompletion(config, messages) {
}
};

// Plugin hook: pre_request (before plugin provider check so it fires for all providers)
if (pluginLoader) {
await pluginLoader.runHooks('pre_request', {
provider: config.model.provider,
model: body.model || config.model.name,
messages: processedMessages,
});
}

// Plugin-registered providers: call directly, bypass fetch
const { providerRegistry } = require('../src/compiled/providers/registry');
const pluginProvider = providerRegistry.get(config.model.provider);
if (pluginProvider) {
_stopSpinner();
try {
const chatResp = await pluginProvider.chat({
model: body.model,
messages: body.messages,
temperature: body.temperature,
maxOutput: body.max_tokens,
tools: body.tools,
}, controller.signal);
clearTimeout(timeout);

// Translate ChatResponse → OpenAI-compatible format for downstream consumers
const data = {
choices: [{
message: {
role: 'assistant',
content: chatResp.content,
tool_calls: chatResp.tool_calls || [],
},
finish_reason: chatResp.tool_calls?.length ? 'tool_calls' : 'stop',
}],
usage: chatResp.usage ? {
prompt_tokens: chatResp.usage.promptTokens,
completion_tokens: chatResp.usage.completionTokens,
total_tokens: chatResp.usage.totalTokens,
} : undefined,
};

if (tokenTracker && data.usage) {
tokenTracker.record(data, config.model.name);
}
if (data.usage) {
tokenMonitor.recordCall(data.usage.prompt_tokens, data.usage.completion_tokens);
traceRecorder.recordTokens(data.usage.prompt_tokens, data.usage.completion_tokens);
if (chargeBudget) {
try { chargeBudget('run_turn', { tokens: (data.usage.prompt_tokens || 0) + (data.usage.completion_tokens || 0) }); } catch {}
}
}
return data;
} catch (pluginErr) {
clearTimeout(timeout);
const msg = pluginErr.message || 'Plugin provider failed';
console.log(` \x1b[31m✗ Plugin provider "${config.model.provider}": ${msg}\x1b[0m`);
if (_fullscreenRef) _fullscreenRef.addTool('error', 'err', `${config.model.provider}: ${msg.slice(0, 80)}`);
return null;
}
}

let response;
try {
response = await fetch(`${baseUrl}/chat/completions`, {
Expand Down Expand Up @@ -2203,6 +2264,14 @@ async function chatCompletion(config, messages) {
console.log(` \x1b[31m✗ Endpoint error: ${errMsg}${hint}\x1b[0m`);
if (_fullscreenRef) _fullscreenRef.addTool('error', 'err', `${errMsg.slice(0, 80)}${hint}`);
}
// Plugin hook: on_error
if (pluginLoader) {
await pluginLoader.runHooks('on_error', {
provider: config.model.provider,
model: body.model || config.model.name,
error: fetchErr,
}).catch(() => {});
}
return null;
}
clearTimeout(timeout);
Expand Down Expand Up @@ -2234,6 +2303,16 @@ async function chatCompletion(config, messages) {

const data = await response.json();

// Plugin hook: post_request
if (pluginLoader) {
await pluginLoader.runHooks('post_request', {
provider: config.model.provider,
model: body.model || config.model.name,
response: data,
usage: data?.usage || null,
}).catch(() => {});
}

// Track token usage
if (tokenTracker && data?.usage) {
tokenTracker.record(data, config.model.name);
Expand Down Expand Up @@ -2703,6 +2782,13 @@ async function main() {

// Initialize plugins and skills
pluginLoader = new PluginLoader(process.cwd()).loadAll();
await pluginLoader.runInit({ config, cwd: process.cwd() });

// Run plugin shutdown handlers on exit
process.on('beforeExit', () => {
if (pluginLoader) pluginLoader.runShutdown({ config, cwd: process.cwd() }).catch(() => {});
});

skillManager = new SkillManager(process.cwd());

// Initialize MCP client (connect to external MCP servers)
Expand Down
Loading