diff --git a/.smallcode/plugins/anthropic-provider/adapter.js b/.smallcode/plugins/anthropic-provider/adapter.js new file mode 100644 index 00000000..a5c080c1 --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/adapter.js @@ -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; diff --git a/.smallcode/plugins/anthropic-provider/cleanup.js b/.smallcode/plugins/anthropic-provider/cleanup.js new file mode 100644 index 00000000..4ea29bb8 --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/cleanup.js @@ -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 +}; diff --git a/.smallcode/plugins/anthropic-provider/init.js b/.smallcode/plugins/anthropic-provider/init.js new file mode 100644 index 00000000..5772b630 --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/init.js @@ -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.`); + } +}; diff --git a/.smallcode/plugins/anthropic-provider/on-error.js b/.smallcode/plugins/anthropic-provider/on-error.js new file mode 100644 index 00000000..92041ea9 --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/on-error.js @@ -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'); + } +}; diff --git a/.smallcode/plugins/anthropic-provider/plugin.json b/.smallcode/plugins/anthropic-provider/plugin.json new file mode 100644 index 00000000..f9b08407 --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/plugin.json @@ -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" } + ] +} diff --git a/.smallcode/plugins/anthropic-provider/post-request.js b/.smallcode/plugins/anthropic-provider/post-request.js new file mode 100644 index 00000000..3450c707 --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/post-request.js @@ -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. +}; diff --git a/.smallcode/plugins/anthropic-provider/pre-request.js b/.smallcode/plugins/anthropic-provider/pre-request.js new file mode 100644 index 00000000..79faddfb --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/pre-request.js @@ -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. +}; diff --git a/.smallcode/plugins/prompt-inject/plugin.json b/.smallcode/plugins/prompt-inject/plugin.json new file mode 100644 index 00000000..8e7d92b6 --- /dev/null +++ b/.smallcode/plugins/prompt-inject/plugin.json @@ -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" + } + ] +} diff --git a/bin/config.js b/bin/config.js index 9a0890bf..fad02716 100644 --- a/bin/config.js +++ b/bin/config.js @@ -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 { diff --git a/bin/escalation.js b/bin/escalation.js index a148d10f..ea2e2efa 100644 --- a/bin/escalation.js +++ b/bin/escalation.js @@ -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 { diff --git a/bin/executor.js b/bin/executor.js index b4260a95..1ad3cf34 100644 --- a/bin/executor.js +++ b/bin/executor.js @@ -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) { diff --git a/bin/smallcode.js b/bin/smallcode.js index d3db6f34..0222259c 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -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`, { @@ -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); @@ -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); @@ -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) diff --git a/bin/tools.js b/bin/tools.js index 0f275f03..b2ffba6f 100644 --- a/bin/tools.js +++ b/bin/tools.js @@ -43,6 +43,15 @@ const COMPOUND_TOOLS = [ { type: 'function', function: { name: 'run', description: 'Run an existing file (python, node, etc). Use this instead of create_and_run when the file already exists.', parameters: { type: 'object', properties: { command: { type: 'string', description: 'Command to run e.g. "python game.py" or "node server.js"' }, timeout: { type: 'integer', description: 'Timeout in seconds. Default: 30' } }, required: ['command'] } } }, ]; +// ─── Provider Tools ────────────────────────────────────────────────────────── +// Provider tools (like configure_provider) are only sent to the model when +// SMALLCODE_PROVIDER is not configured. When a provider is already set, the +// model doesn't need to know about provider configuration at all. + +const PROVIDER_TOOLS = [ + { type: 'function', function: { name: 'configure_provider', description: 'Configure a new AI provider (LM Studio, OpenRouter, Anthropic, OpenAI, DeepSeek, Ollama, or custom endpoint). Saves config to ~/.smallcode/.env and sets it as active.', parameters: { type: 'object', properties: { provider: { type: 'string', description: 'Provider name: lmstudio, openrouter, anthropic, openai, deepseek, ollama, or custom' } }, required: ['provider'] } } }, +]; + // ─── Tool Routing ──────────────────────────────────────────────────────────── /** @@ -100,4 +109,4 @@ function getAllTools(config, stage2Category, deps = {}) { return allTools; } -module.exports = { TOOLS, COMPOUND_TOOLS, getAllTools }; +module.exports = { TOOLS, COMPOUND_TOOLS, PROVIDER_TOOLS, getAllTools }; diff --git a/package-lock.json b/package-lock.json index b15afb6f..7012edc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "smallcode", - "version": "1.1.0", + "version": "0.9.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "smallcode", - "version": "1.1.0", + "version": "0.9.6", "license": "MIT", "dependencies": { "bonescript-compiler": "0.14.0", diff --git a/src/compiled/providers/index.js b/src/compiled/providers/index.js index 9e0c23d6..c993a33d 100644 --- a/src/compiled/providers/index.js +++ b/src/compiled/providers/index.js @@ -6,7 +6,9 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.MODELS = void 0; exports.getModel = getModel; exports.listModelNames = listModelNames; +exports.resolveProvider = resolveProvider; const openai_compat_1 = require("./openai_compat"); +const registry_1 = require("./registry"); // Substitute ${ENV_VAR} placeholders in declared values with runtime env vars. function _sub(s) { return s.replace(/\$\{(\w+)\}/g, (_, k) => process.env[k] || ''); @@ -86,3 +88,21 @@ function listModelNames() { _models = _buildModels(); return Object.keys(_models).sort(); } +/** + * Resolve a provider by name. Checks the plugin registry first; + * falls back to creating a default OpenAICompatProvider with the + * configured baseUrl. + * + * IMPORTANT: Do NOT register the fallback into providerRegistry here. + * resolveProvider() is a lookup, not a mutation. Registering the fallback + * would pollute the registry with an unintended entry under whatever name + * was passed in, causing later resolveProvider() calls to short-circuit + * to the plugin path and bypass the real OpenAI-compat fetch. + */ +function resolveProvider(name) { + const fromRegistry = registry_1.providerRegistry.get(name); + if (fromRegistry) + return fromRegistry; + const baseUrl = _sub("${SMALLCODE_BASE_URL}") || "http://localhost:1234/v1"; + return new openai_compat_1.OpenAICompatProvider(baseUrl); +} diff --git a/src/compiled/providers/index.ts b/src/compiled/providers/index.ts index 36f8e720..08514c93 100644 --- a/src/compiled/providers/index.ts +++ b/src/compiled/providers/index.ts @@ -4,6 +4,7 @@ import type { IModelProvider } from "./types"; import { OpenAICompatProvider } from "./openai_compat"; +import { providerRegistry } from "./registry"; // Substitute ${ENV_VAR} placeholders in declared values with runtime env vars. function _sub(s: string): string { @@ -99,3 +100,21 @@ export function listModelNames(): string[] { if (!_models) _models = _buildModels(); return Object.keys(_models).sort(); } + +/** + * Resolve a provider by name. Checks the plugin registry first; + * falls back to creating a default OpenAICompatProvider with the + * configured baseUrl. + * + * IMPORTANT: Do NOT register the fallback into providerRegistry here. + * resolveProvider() is a lookup, not a mutation. Registering the fallback + * would pollute the registry with an unintended entry under whatever name + * was passed in, causing later resolveProvider() calls to short-circuit + * to the plugin path and bypass the real OpenAI-compat fetch. + */ +export function resolveProvider(name: string): IModelProvider { + const fromRegistry = providerRegistry.get(name); + if (fromRegistry) return fromRegistry; + const baseUrl = _sub("${SMALLCODE_BASE_URL}") || "http://localhost:1234/v1"; + return new OpenAICompatProvider(baseUrl); +} diff --git a/src/compiled/providers/openai_compat.js b/src/compiled/providers/openai_compat.js index bd745c98..63038d54 100644 --- a/src/compiled/providers/openai_compat.js +++ b/src/compiled/providers/openai_compat.js @@ -5,6 +5,7 @@ // shim, and OpenAI itself when API key is set. Object.defineProperty(exports, "__esModule", { value: true }); exports.OpenAICompatProvider = void 0; +exports.default = void 0; const types_1 = require("./types"); const ssrf_guard_1 = require("./ssrf_guard"); class OpenAICompatProvider { @@ -91,6 +92,7 @@ class OpenAICompatProvider { } } exports.OpenAICompatProvider = OpenAICompatProvider; +exports.default = OpenAICompatProvider; /** * Map a sequence of per-token logprobs to a single 0..1 confidence value. * We use exp(mean(logprob)) — the geometric mean of per-token probabilities. diff --git a/src/compiled/providers/openai_compat.ts b/src/compiled/providers/openai_compat.ts index d28036dd..97b67d04 100644 --- a/src/compiled/providers/openai_compat.ts +++ b/src/compiled/providers/openai_compat.ts @@ -7,7 +7,7 @@ import type { ChatRequest, ChatResponse, ChatToolCall, IModelProvider } from "./ import { approxTokens } from "./types"; import { assertEndpointAllowed } from "./ssrf_guard"; -export class OpenAICompatProvider implements IModelProvider { +export default class OpenAICompatProvider implements IModelProvider { readonly name = "openai_compat"; private endpoint: string; private apiKey: string; diff --git a/src/compiled/providers/prompt_inject.js b/src/compiled/providers/prompt_inject.js new file mode 100644 index 00000000..dede7897 --- /dev/null +++ b/src/compiled/providers/prompt_inject.js @@ -0,0 +1,52 @@ +"use strict"; +// prompt_inject — wraps any IModelProvider and injects content into system messages. +// Used by the prompt-inject plugin to add custom instructions, RAG context, +// or persona content to every LLM call without touching the core runtime. +Object.defineProperty(exports, "__esModule", { value: true }); +const types_1 = require("./types"); +class PromptInjectProvider { + constructor(options) { + this.name = "prompt_inject"; + this.innerInstance = null; + this.innerName = options.inner; + this.injections = options.injections || []; + } + getInner() { + if (!this.innerInstance) { + const { providerRegistry } = require("./registry"); + this.innerInstance = providerRegistry.get(this.innerName); + if (!this.innerInstance) { + throw new Error(`prompt_inject: inner provider "${this.innerName}" not found in registry`); + } + } + return this.innerInstance; + } + countTokens(text) { + return (0, types_1.approxTokens)(text); + } + async chat(req, signal) { + const messages = req.messages.map(m => ({ ...m })); + const injectedContent = this.injections.map(i => i.content).join("\n\n"); + const position = this.injections[this.injections.length - 1]?.position ?? "append"; + for (let i = 0; i < messages.length; i++) { + if (messages[i].role === "system") { + if (position === "replace") { + messages[i] = { ...messages[i], content: injectedContent }; + } + else if (position === "prepend") { + messages[i] = { ...messages[i], content: `${injectedContent}\n\n${messages[i].content}` }; + } + else { + messages[i] = { ...messages[i], content: `${messages[i].content}\n\n${injectedContent}` }; + } + break; + } + } + if (!messages.some(m => m.role === "system")) { + messages.unshift({ role: "system", content: injectedContent }); + } + return this.getInner().chat({ ...req, messages }, signal); + } +} +exports.default = PromptInjectProvider; +module.exports = PromptInjectProvider; diff --git a/src/compiled/providers/prompt_inject.ts b/src/compiled/providers/prompt_inject.ts new file mode 100644 index 00000000..6dfef538 --- /dev/null +++ b/src/compiled/providers/prompt_inject.ts @@ -0,0 +1,72 @@ +// prompt_inject — wraps any IModelProvider and injects content into system messages. +// Used by the prompt-inject plugin to add custom instructions, RAG context, +// or persona content to every LLM call without touching the core runtime. + +/* eslint-disable @typescript-eslint/no-require-imports */ +import type { ChatRequest, ChatResponse, IModelProvider } from "./types"; +import { approxTokens } from "./types"; +declare const require: (id: string) => any; + +export type InjectionPosition = "prepend" | "append" | "replace"; + +export interface PromptInjection { + position?: InjectionPosition; + content: string; +} + +export interface PromptInjectOptions { + inner: string; + injections: PromptInjection[]; +} + +export default class PromptInjectProvider implements IModelProvider { + readonly name = "prompt_inject"; + private innerName: string; + private innerInstance: IModelProvider | null = null; + private injections: PromptInjection[]; + + constructor(options: PromptInjectOptions) { + this.innerName = options.inner; + this.injections = options.injections || []; + } + + private getInner(): IModelProvider { + if (!this.innerInstance) { + const { providerRegistry } = require("./registry"); + this.innerInstance = providerRegistry.get(this.innerName); + if (!this.innerInstance) { + throw new Error(`prompt_inject: inner provider "${this.innerName}" not found in registry`); + } + } + return this.innerInstance; + } + + countTokens(text: string): number { + return approxTokens(text); + } + + async chat(req: ChatRequest, signal?: AbortSignal): Promise { + const messages = req.messages.map(m => ({ ...m })); + const injectedContent = this.injections.map(i => i.content).join("\n\n"); + const position = this.injections[this.injections.length - 1]?.position ?? "append"; + + for (let i = 0; i < messages.length; i++) { + if (messages[i].role === "system") { + if (position === "replace") { + messages[i] = { ...messages[i], content: injectedContent }; + } else if (position === "prepend") { + messages[i] = { ...messages[i], content: `${injectedContent}\n\n${messages[i].content}` }; + } else { + messages[i] = { ...messages[i], content: `${messages[i].content}\n\n${injectedContent}` }; + } + break; + } + } + + if (!messages.some(m => m.role === "system")) { + messages.unshift({ role: "system", content: injectedContent }); + } + + return this.getInner().chat({ ...req, messages }, signal); + } +} diff --git a/src/compiled/providers/registry.js b/src/compiled/providers/registry.js new file mode 100644 index 00000000..309ab075 --- /dev/null +++ b/src/compiled/providers/registry.js @@ -0,0 +1,49 @@ +"use strict"; +// Plugin-provider registry. +// Plugins register named providers at load time; the runtime resolves them +// by name via resolveProvider() in index.ts. If no plugin provider is +// registered, the default OpenAI-compatible adapter is used. +Object.defineProperty(exports, "__esModule", { value: true }); +exports.providerRegistry = exports.ProviderRegistry = void 0; + +const DEFAULT_CAPABILITIES = { + tools: true, + streaming: true, + vision: false, + tokenCounting: "heuristic", +}; + +class ProviderRegistry { + constructor() { + this.providers = new Map(); + this.capabilities = new Map(); + } + + // register() has a side effect: it permanently adds the provider to the + // in-memory Map. Callers must be intentional about when they register — + // resolveProvider() deliberately does NOT call register() to avoid + // polluting the registry with fallback entries. + register(name, provider, caps) { + this.providers.set(name, provider); + this.capabilities.set(name, { ...DEFAULT_CAPABILITIES, ...caps }); + } + + get(name) { + return this.providers.get(name); + } + + has(name) { + return this.providers.has(name); + } + + list() { + return [...this.providers.keys()]; + } + + getCapabilities(name) { + return this.capabilities.get(name) ?? DEFAULT_CAPABILITIES; + } +} + +exports.ProviderRegistry = ProviderRegistry; +exports.providerRegistry = new ProviderRegistry(); diff --git a/src/compiled/providers/registry.ts b/src/compiled/providers/registry.ts new file mode 100644 index 00000000..286fc9fe --- /dev/null +++ b/src/compiled/providers/registry.ts @@ -0,0 +1,52 @@ +// Plugin-provider registry. +// Plugins register named providers at load time; the runtime resolves them +// by name via resolveProvider() in index.ts. If no plugin provider is +// registered, the default OpenAI-compatible adapter is used. + +import type { IModelProvider } from "./types"; + +export interface ProviderCapabilities { + tools: boolean; + streaming: boolean; + vision: boolean; + tokenCounting: "exact" | "heuristic" | "none"; +} + +const DEFAULT_CAPABILITIES: ProviderCapabilities = { + tools: true, + streaming: true, + vision: false, + tokenCounting: "heuristic", +}; + +class ProviderRegistry { + private providers = new Map(); + private capabilities = new Map(); + + register( + name: string, + provider: IModelProvider, + caps?: Partial, + ): void { + this.providers.set(name, provider); + this.capabilities.set(name, { ...DEFAULT_CAPABILITIES, ...caps }); + } + + get(name: string): IModelProvider | undefined { + return this.providers.get(name); + } + + has(name: string): boolean { + return this.providers.has(name); + } + + list(): string[] { + return [...this.providers.keys()]; + } + + getCapabilities(name: string): ProviderCapabilities { + return this.capabilities.get(name) ?? DEFAULT_CAPABILITIES; + } +} + +export const providerRegistry = new ProviderRegistry(); diff --git a/src/plugins/loader.js b/src/plugins/loader.js index e3db34d0..8fb1fff0 100644 --- a/src/plugins/loader.js +++ b/src/plugins/loader.js @@ -19,12 +19,18 @@ // "tools": [{ "name": "...", "description": "...", "parameters": {...}, "handler": "./handler.js" }], // "prompts": [{ "inject": "always|backend|coding", "content": "..." }], // "commands": [{ "name": "/mycmd", "description": "...", "handler": "./cmd.js" }], -// "hooks": [{ "event": "post_tool", "filter": ["write_file"], "handler": "./hook.js" }] +// "hooks": [{ "event": "pre_request|post_request|on_error|session_start|session_end|post_tool", "filter": ["write_file"], "handler": "./hook.js" }], +// "init": "./init.js", +// "shutdown": "./cleanup.js", +// "providers": [{ "name": "...", "module": "./adapter.js", "options": {}, "capabilities": { "tools": true, "streaming": true } }], +// "permissions": { "read": true, "write": true, "execute": false, "network": true }, +// "mcpServers": { "my-server": { "command": "./server.js", "args": [], "transport": "stdio" } } // } const fs = require('fs'); const path = require('path'); const os = require('os'); +const { providerRegistry } = require('../compiled/providers/registry'); class PluginLoader { constructor(projectDir) { @@ -34,6 +40,10 @@ class PluginLoader { this.commands = {}; // /command → handler this.prompts = []; // System prompt injections this.hooks = []; // Event hooks + this.providers = {}; // name → IModelProvider instance + this.initHandlers = []; // async init handlers from plugin manifests + this.shutdownHandlers = []; // async shutdown handlers from plugin manifests + this.errors = []; // { dir, message } for diagnostics } // Load all plugins from project + user dirs @@ -87,6 +97,8 @@ class PluginLoader { description: toolDef.description || '', parameters: toolDef.parameters || { type: 'object', properties: {} }, }, + // Underscore-prefixed fields are consumed by executor.js when it + // dispatches plugin tool calls. The model never sees these. _handler: handler, _plugin: plugin.name, }); @@ -122,7 +134,13 @@ class PluginLoader { // Register hooks if (manifest.hooks) { + const validEvents = ['pre_tool', 'post_tool', 'session_start', 'session_end', + 'pre_request', 'post_request', 'on_error']; for (const h of manifest.hooks) { + if (!validEvents.includes(h.event)) { + console.warn(`[plugin:${plugin.name}] Unknown hook event "${h.event}", skipping`); + continue; + } const handlerPath = path.resolve(pluginDir, h.handler || './hook.js'); let handler = null; if (fs.existsSync(handlerPath)) { @@ -137,9 +155,64 @@ class PluginLoader { } } + // Register init handler + if (manifest.init) { + const initPath = path.resolve(pluginDir, manifest.init); + if (fs.existsSync(initPath)) { + try { + const initHandler = require(initPath); + this.initHandlers.push({ + handler: initHandler.default || initHandler, + plugin: plugin.name, + }); + } catch (e) { + console.error(`[plugin:${plugin.name}] Failed to load init: ${e.message}`); + } + } + } + + // Register shutdown handler + if (manifest.shutdown) { + const shutdownPath = path.resolve(pluginDir, manifest.shutdown); + if (fs.existsSync(shutdownPath)) { + try { + const shutdownHandler = require(shutdownPath); + this.shutdownHandlers.push({ + handler: shutdownHandler.default || shutdownHandler, + plugin: plugin.name, + }); + } catch (e) { + console.error(`[plugin:${plugin.name}] Failed to load shutdown: ${e.message}`); + } + } + } + + // Register providers + if (manifest.providers) { + for (const spec of manifest.providers) { + try { + const modulePath = path.resolve(pluginDir, spec.module); + const Export = require(modulePath); + const ProviderClass = Export.default || Export; + const instance = new ProviderClass(spec.options || {}); + if (!instance.chat || !instance.name) { + throw new Error(`Provider "${spec.name}" must implement .chat() and .name`); + } + const caps = spec.capabilities || {}; + providerRegistry.register(spec.name, instance, caps); + this.providers[spec.name] = instance; + } catch (e) { + const msg = `Failed to load provider "${spec.name}": ${e.message}`; + console.error(`[plugin:${plugin.name}] ${msg}`); + this.errors.push({ dir: pluginDir, message: msg }); + } + } + } + this.plugins.push(plugin); } catch (e) { - // Silently skip broken plugins + // Store error for diagnostics, but don't crash + this.errors.push({ dir: pluginDir, message: e.message }); } } @@ -203,6 +276,13 @@ class PluginLoader { return null; } + // Get error diagnostics for failed plugin loads + // TODO: re-add getPermissions(), hasPermission(), getMCPServers() when + // permissions enforcement is wired into the tool execution pipeline. + getErrors() { + return this.errors; + } + // List all plugins for display list() { return this.plugins.map(p => ({ @@ -213,6 +293,53 @@ class PluginLoader { commands: Object.keys(this.commands).filter(k => this.commands[k].plugin === p.name), })); } + + // Run all plugin init handlers. Called once at startup after loadAll(). + async runInit(context = {}) { + for (const { handler, plugin } of this.initHandlers) { + try { + await handler(context); + } catch (e) { + console.error(`[plugin:${plugin}] init failed: ${e.message}`); + } + } + } + + // Run all plugin shutdown handlers. Called on exit for cleanup. + async runShutdown(context = {}) { + for (const { handler, plugin } of this.shutdownHandlers) { + try { + await handler(context); + } catch (e) { + console.error(`[plugin:${plugin}] shutdown failed: ${e.message}`); + } + } + } + + // Execute hooks for a given event. Returns array of results from non-void handlers. + async runHooks(event, data = {}) { + const results = []; + for (const hook of this.hooks) { + if (hook.event !== event) continue; + if (hook.filter.length > 0 && !hook.filter.includes(data.toolName || '')) continue; + if (!hook.handler) continue; + + // For post_tool hooks, handler is { after(toolResult, ctx) } + // For new event hooks, handler is { handle(data) } or a plain function + try { + if (hook.handler.handle) { + const result = await hook.handler.handle(data); + if (result !== undefined) results.push(result); + } else if (typeof hook.handler === 'function') { + const result = await hook.handler(data); + if (result !== undefined) results.push(result); + } + } catch (e) { + console.error(`[plugin:${hook.plugin}] hook ${event} failed: ${e.message}`); + } + } + return results; + } } module.exports = { PluginLoader };