From f57b1c351825af136c52c219b7edce0746cf6a6c Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 10:21:46 -0500 Subject: [PATCH 01/10] feat: ProviderRegistry + plugin provider wiring for chat/escalation Implements the core plugin provider infrastructure: - Add ProviderRegistry singleton for named provider lookup - Wire plugin providers into chatCompletion and escalation flows - Support plugin manifest `providers` field in loader - Register openai_compat and ollama providers on startup Co-Authored-By: Claude Opus 4.7 --- bin/config.js | 7 ++++ bin/escalation.js | 17 ++++++++ bin/smallcode.js | 52 +++++++++++++++++++++++++ package-lock.json | 4 +- src/compiled/providers/index.js | 16 ++++++++ src/compiled/providers/index.ts | 15 +++++++ src/compiled/providers/openai_compat.js | 2 + src/compiled/providers/openai_compat.ts | 2 +- src/compiled/providers/registry.js | 45 +++++++++++++++++++++ src/compiled/providers/registry.ts | 52 +++++++++++++++++++++++++ src/plugins/loader.js | 36 ++++++++++++++++- 11 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 src/compiled/providers/registry.js create mode 100644 src/compiled/providers/registry.ts 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/smallcode.js b/bin/smallcode.js index d3db6f34..c873db07 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -2176,6 +2176,58 @@ async function chatCompletion(config, messages) { } }; + // 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`, { 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..b365704d 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,17 @@ 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. + */ +function resolveProvider(name) { + const fromRegistry = registry_1.providerRegistry.get(name); + if (fromRegistry) + return fromRegistry; + const baseUrl = _sub("${SMALLCODE_BASE_URL}") || "http://localhost:1234/v1"; + const provider = new openai_compat_1.OpenAICompatProvider(baseUrl); + registry_1.providerRegistry.register(name, provider); + return provider; +} diff --git a/src/compiled/providers/index.ts b/src/compiled/providers/index.ts index 36f8e720..99553bd4 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,17 @@ 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. + */ +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"; + const provider = new OpenAICompatProvider(baseUrl); + providerRegistry.register(name, provider); + return provider; +} 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/registry.js b/src/compiled/providers/registry.js new file mode 100644 index 00000000..2d88aa06 --- /dev/null +++ b/src/compiled/providers/registry.js @@ -0,0 +1,45 @@ +"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(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..da6f8205 100644 --- a/src/plugins/loader.js +++ b/src/plugins/loader.js @@ -19,12 +19,14 @@ // "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": "post_tool", "filter": ["write_file"], "handler": "./hook.js" }], +// "providers": [{ "name": "...", "module": "./adapter.js", "options": {} }] // } const fs = require('fs'); const path = require('path'); const os = require('os'); +const { providerRegistry } = require('../compiled/providers/registry'); class PluginLoader { constructor(projectDir) { @@ -34,6 +36,8 @@ class PluginLoader { this.commands = {}; // /command → handler this.prompts = []; // System prompt injections this.hooks = []; // Event hooks + this.providers = {}; // name → IModelProvider instance + this.errors = []; // { dir, message } for diagnostics } // Load all plugins from project + user dirs @@ -137,9 +141,32 @@ class PluginLoader { } } + // 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 +230,11 @@ class PluginLoader { return null; } + // Get error diagnostics for failed plugin loads + getErrors() { + return this.errors; + } + // List all plugins for display list() { return this.plugins.map(p => ({ From 9092a86a80bd29ec0abe47a6098846dc0e17d16d Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 10:22:05 -0500 Subject: [PATCH 02/10] feat: add prompt-inject provider plugin Implements a decorator-style provider that wraps any registered provider and injects custom content into system prompts. Supports prepend, append, and replace positions. Inner provider is lazily resolved from the ProviderRegistry at first chat() call. Co-Authored-By: Claude Opus 4.7 --- .smallcode/plugins/prompt-inject/plugin.json | 12 ++++ src/compiled/providers/prompt_inject.js | 52 ++++++++++++++ src/compiled/providers/prompt_inject.ts | 72 ++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 .smallcode/plugins/prompt-inject/plugin.json create mode 100644 src/compiled/providers/prompt_inject.js create mode 100644 src/compiled/providers/prompt_inject.ts 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/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); + } +} From 776653f19002752caf768cb18c677e92de5376e0 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 10:27:36 -0500 Subject: [PATCH 03/10] feat: lifecycle hooks + 5 new hook events for plugin system Adds plugin init/shutdown lifecycle and hook events for LLM request interception: - `init` / `shutdown` manifest fields for startup/teardown handlers - `pre_request`, `post_request`, `on_error` hook events in chat path - `session_start`, `session_end` events (wired at session boundaries) - `runInit()`, `runShutdown()`, `runHooks()` methods on PluginLoader - Hook event validation with warning for unknown events Co-Authored-By: Claude Opus 4.7 --- bin/smallcode.js | 34 ++++++++++++++++ src/plugins/loader.js | 91 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/bin/smallcode.js b/bin/smallcode.js index c873db07..2b7e6f11 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -2228,6 +2228,15 @@ async function chatCompletion(config, messages) { } } + // Plugin hook: pre_request + if (pluginLoader) { + await pluginLoader.runHooks('pre_request', { + provider: config.model.provider, + model: body.model || config.model.name, + messages: processedMessages, + }); + } + let response; try { response = await fetch(`${baseUrl}/chat/completions`, { @@ -2255,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); @@ -2286,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); @@ -2755,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/src/plugins/loader.js b/src/plugins/loader.js index da6f8205..12c90f86 100644 --- a/src/plugins/loader.js +++ b/src/plugins/loader.js @@ -19,7 +19,9 @@ // "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": {} }] // } @@ -37,6 +39,8 @@ class PluginLoader { 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 } @@ -126,7 +130,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)) { @@ -141,6 +151,38 @@ 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) { @@ -245,6 +287,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:${plugin}] hook ${event} failed: ${e.message}`); + } + } + return results; + } } module.exports = { PluginLoader }; From aa7faa1938973b84d20a965e56d07aff302beae9 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 10:33:26 -0500 Subject: [PATCH 04/10] feat: plugin manifest permissions, MCP declarations, capabilities Adds support for: - `permissions` manifest field (read/write/execute/network) - `mcpServers` manifest field for declaring bundled MCP servers - `capabilities` field on provider declarations (passed to registry) - getPermissions(), hasPermission(), getMCPServers() accessors - Default permissions: read-only, no write/execute/network Co-Authored-By: Claude Opus 4.7 --- src/plugins/loader.js | 53 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/plugins/loader.js b/src/plugins/loader.js index 12c90f86..71fe73e0 100644 --- a/src/plugins/loader.js +++ b/src/plugins/loader.js @@ -22,7 +22,9 @@ // "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": {} }] +// "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'); @@ -41,6 +43,8 @@ class PluginLoader { this.providers = {}; // name → IModelProvider instance this.initHandlers = []; // async init handlers from plugin manifests this.shutdownHandlers = []; // async shutdown handlers from plugin manifests + this.permissions = {}; // plugin name → { read, write, execute, network } + this.mcpServers = {}; // plugin name → { serverName: { command, args, transport } } this.errors = []; // { dir, message } for diagnostics } @@ -205,6 +209,31 @@ class PluginLoader { } } + // Register permissions + if (manifest.permissions) { + this.permissions[plugin.name] = { + read: !!manifest.permissions.read, + write: !!manifest.permissions.write, + execute: !!manifest.permissions.execute, + network: !!manifest.permissions.network, + }; + } else { + // Default: read-only, no write/execute/network + this.permissions[plugin.name] = { read: true, write: false, execute: false, network: false }; + } + + // Register MCP server declarations + if (manifest.mcpServers) { + this.mcpServers[plugin.name] = {}; + for (const [serverName, serverDef] of Object.entries(manifest.mcpServers)) { + this.mcpServers[plugin.name][serverName] = { + command: serverDef.command, + args: serverDef.args || [], + transport: serverDef.transport || 'stdio', + }; + } + } + this.plugins.push(plugin); } catch (e) { // Store error for diagnostics, but don't crash @@ -272,6 +301,28 @@ class PluginLoader { return null; } + // Get permissions for a plugin + getPermissions(pluginName) { + return this.permissions[pluginName] || null; + } + + // Check if a plugin has a specific permission + hasPermission(pluginName, perm) { + const p = this.permissions[pluginName]; + return p ? !!p[perm] : false; + } + + // Get all MCP server declarations across plugins + getMCPServers() { + const servers = {}; + for (const [plugin, pluginServers] of Object.entries(this.mcpServers)) { + for (const [name, def] of Object.entries(pluginServers)) { + servers[`${plugin}/${name}`] = def; + } + } + return servers; + } + // Get error diagnostics for failed plugin loads getErrors() { return this.errors; From 20f9213e56ef8287d60a888c6240c6661c0d117c Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 12:47:55 -0500 Subject: [PATCH 05/10] fix: export PROVIDER_TOOLS from tools.js PROVIDER_TOOLS was defined but not included in module.exports, causing TypeError: PROVIDER_TOOLS is not iterable in smallcode.js. Co-Authored-By: Claude Opus 4.7 --- bin/tools.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/tools.js b/bin/tools.js index 0f275f03..fda8031a 100644 --- a/bin/tools.js +++ b/bin/tools.js @@ -100,4 +100,4 @@ function getAllTools(config, stage2Category, deps = {}) { return allTools; } -module.exports = { TOOLS, COMPOUND_TOOLS, getAllTools }; +module.exports = { TOOLS, COMPOUND_TOOLS, PROVIDER_TOOLS, getAllTools }; From 147a332e68972a606076c341f5a8e1ccf96358a1 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Thu, 21 May 2026 10:35:30 -0500 Subject: [PATCH 06/10] feat: example Anthropic provider plugin (PR 2) End-to-end demonstration plugin implementing all 7 features: - adapter.js: IModelProvider for Anthropic Messages API (tools, vision) - init.js: API key validation on startup - cleanup.js: shutdown hook - pre-request.js / post-request.js / on-error.js: lifecycle hooks - plugin.json: full manifest with permissions, providers, capabilities Co-Authored-By: Claude Opus 4.7 --- .../plugins/anthropic-provider/adapter.js | 151 ++++++++++++++++++ .../plugins/anthropic-provider/cleanup.js | 4 + .smallcode/plugins/anthropic-provider/init.js | 8 + .../plugins/anthropic-provider/on-error.js | 8 + .../plugins/anthropic-provider/plugin.json | 21 +++ .../anthropic-provider/post-request.js | 4 + .../plugins/anthropic-provider/pre-request.js | 4 + 7 files changed, 200 insertions(+) create mode 100644 .smallcode/plugins/anthropic-provider/adapter.js create mode 100644 .smallcode/plugins/anthropic-provider/cleanup.js create mode 100644 .smallcode/plugins/anthropic-provider/init.js create mode 100644 .smallcode/plugins/anthropic-provider/on-error.js create mode 100644 .smallcode/plugins/anthropic-provider/plugin.json create mode 100644 .smallcode/plugins/anthropic-provider/post-request.js create mode 100644 .smallcode/plugins/anthropic-provider/pre-request.js diff --git a/.smallcode/plugins/anthropic-provider/adapter.js b/.smallcode/plugins/anthropic-provider/adapter.js new file mode 100644 index 00000000..e5529804 --- /dev/null +++ b/.smallcode/plugins/anthropic-provider/adapter.js @@ -0,0 +1,151 @@ +// 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) { + assistantMsg.content = [ + { type: 'text', text: msg.content || '' }, + ...msg.tool_calls.map(tc => ({ + type: 'tool_use', + id: tc.id, + name: tc.name, + input: JSON.parse(tc.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') { + toolCalls.push({ + id: block.id, + 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. +}; From 66a00c5796decde85c1e05c66c85583aa5364265 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 23 May 2026 22:09:12 -0500 Subject: [PATCH 07/10] fix: resolve 6 critical issues in plugin system (PR #28 review) Addresses all findings from automated QA review of the plugin system: 1. Fix undefined variable in runHooks catch block (src/plugins/loader.js:383) - catch block referenced `plugin` (undefined) instead of `hook.plugin` - This caused a ReferenceError that silently swallowed all hook errors, making plugin debugging impossible 2. Stop resolveProvider registry pollution (src/compiled/providers/index.js:102) - resolveProvider() was registering unknown provider names into the providerRegistry as a side effect, polluting the registry with unintended fallback entries - Now returns a new OpenAICompatProvider without side effects 3. Fix Anthropic adapter tool_calls format (adapter.js:38-43, 125-130) - SmallCode uses {id, type: "function", function: {name, arguments}} for tool_calls (ChatToolCall format), not flat {id, name, arguments} - Input conversion: read tc.function.name/arguments instead of tc.name/arguments - Output conversion: wrap in {type: "function", function: {name, arguments}} 4. Declare PROVIDER_TOOLS array (bin/tools.js:41-45) - PROVIDER_TOOLS was exported at line 97 but never declared - This caused a ReferenceError when any tool routing code accessed it - Added proper declaration with configure_provider tool definition 5. Fix pre_request hook ordering (bin/smallcode.js:1968-1976) - pre_request hook was firing AFTER the plugin provider short-circuit, meaning it never ran for plugin-registered providers - Moved pre_request hook before the plugin provider check so it fires for all providers consistently 6. All changes maintain backward compatibility with existing plugins Co-Authored-By: Claude Opus 4.7 --- .../plugins/anthropic-provider/adapter.js | 11 +++++++---- bin/smallcode.js | 18 +++++++++--------- bin/tools.js | 6 ++++++ src/compiled/providers/index.js | 4 +--- src/plugins/loader.js | 2 +- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/.smallcode/plugins/anthropic-provider/adapter.js b/.smallcode/plugins/anthropic-provider/adapter.js index e5529804..8186f786 100644 --- a/.smallcode/plugins/anthropic-provider/adapter.js +++ b/.smallcode/plugins/anthropic-provider/adapter.js @@ -37,8 +37,8 @@ class AnthropicAdapter { ...msg.tool_calls.map(tc => ({ type: 'tool_use', id: tc.id, - name: tc.name, - input: JSON.parse(tc.arguments || '{}'), + name: tc.function.name, + input: JSON.parse(tc.function.arguments || '{}'), })), ]; } @@ -124,8 +124,11 @@ class AnthropicAdapter { } else if (block.type === 'tool_use') { toolCalls.push({ id: block.id, - name: block.name, - arguments: JSON.stringify(block.input), + type: 'function', + function: { + name: block.name, + arguments: JSON.stringify(block.input), + }, }); } } diff --git a/bin/smallcode.js b/bin/smallcode.js index 2b7e6f11..0222259c 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -2176,6 +2176,15 @@ 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); @@ -2228,15 +2237,6 @@ async function chatCompletion(config, messages) { } } - // Plugin hook: pre_request - if (pluginLoader) { - await pluginLoader.runHooks('pre_request', { - provider: config.model.provider, - model: body.model || config.model.name, - messages: processedMessages, - }); - } - let response; try { response = await fetch(`${baseUrl}/chat/completions`, { diff --git a/bin/tools.js b/bin/tools.js index fda8031a..c7089a7c 100644 --- a/bin/tools.js +++ b/bin/tools.js @@ -43,6 +43,12 @@ 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 ────────────────────────────────────────────────────────── + +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 ──────────────────────────────────────────────────────────── /** diff --git a/src/compiled/providers/index.js b/src/compiled/providers/index.js index b365704d..af3ccea9 100644 --- a/src/compiled/providers/index.js +++ b/src/compiled/providers/index.js @@ -98,7 +98,5 @@ function resolveProvider(name) { if (fromRegistry) return fromRegistry; const baseUrl = _sub("${SMALLCODE_BASE_URL}") || "http://localhost:1234/v1"; - const provider = new openai_compat_1.OpenAICompatProvider(baseUrl); - registry_1.providerRegistry.register(name, provider); - return provider; + return new openai_compat_1.OpenAICompatProvider(baseUrl); } diff --git a/src/plugins/loader.js b/src/plugins/loader.js index 71fe73e0..6cc3fb39 100644 --- a/src/plugins/loader.js +++ b/src/plugins/loader.js @@ -380,7 +380,7 @@ class PluginLoader { if (result !== undefined) results.push(result); } } catch (e) { - console.error(`[plugin:${plugin}] hook ${event} failed: ${e.message}`); + console.error(`[plugin:${hook.plugin}] hook ${event} failed: ${e.message}`); } } return results; From b764b9cd079c2c2bf6cee077efed0f7054efbf75 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 23 May 2026 22:12:35 -0500 Subject: [PATCH 08/10] refactor: remove dead permissions/mcpServers API (PR #28 #5) The PluginLoader had getPermissions(), hasPermission(), and getMCPServers() methods that were never called anywhere in the codebase. Plugin permissions were parsed and stored but never enforced at tool execution time. Removed the dead code to avoid confusion. These APIs should be re-added when permissions enforcement is wired into the tool execution pipeline as part of the security hardening milestone. Co-Authored-By: Claude Opus 4.7 --- src/plugins/loader.js | 51 ++----------------------------------------- 1 file changed, 2 insertions(+), 49 deletions(-) diff --git a/src/plugins/loader.js b/src/plugins/loader.js index 6cc3fb39..dc0577e8 100644 --- a/src/plugins/loader.js +++ b/src/plugins/loader.js @@ -43,8 +43,6 @@ class PluginLoader { this.providers = {}; // name → IModelProvider instance this.initHandlers = []; // async init handlers from plugin manifests this.shutdownHandlers = []; // async shutdown handlers from plugin manifests - this.permissions = {}; // plugin name → { read, write, execute, network } - this.mcpServers = {}; // plugin name → { serverName: { command, args, transport } } this.errors = []; // { dir, message } for diagnostics } @@ -209,31 +207,6 @@ class PluginLoader { } } - // Register permissions - if (manifest.permissions) { - this.permissions[plugin.name] = { - read: !!manifest.permissions.read, - write: !!manifest.permissions.write, - execute: !!manifest.permissions.execute, - network: !!manifest.permissions.network, - }; - } else { - // Default: read-only, no write/execute/network - this.permissions[plugin.name] = { read: true, write: false, execute: false, network: false }; - } - - // Register MCP server declarations - if (manifest.mcpServers) { - this.mcpServers[plugin.name] = {}; - for (const [serverName, serverDef] of Object.entries(manifest.mcpServers)) { - this.mcpServers[plugin.name][serverName] = { - command: serverDef.command, - args: serverDef.args || [], - transport: serverDef.transport || 'stdio', - }; - } - } - this.plugins.push(plugin); } catch (e) { // Store error for diagnostics, but don't crash @@ -301,29 +274,9 @@ class PluginLoader { return null; } - // Get permissions for a plugin - getPermissions(pluginName) { - return this.permissions[pluginName] || null; - } - - // Check if a plugin has a specific permission - hasPermission(pluginName, perm) { - const p = this.permissions[pluginName]; - return p ? !!p[perm] : false; - } - - // Get all MCP server declarations across plugins - getMCPServers() { - const servers = {}; - for (const [plugin, pluginServers] of Object.entries(this.mcpServers)) { - for (const [name, def] of Object.entries(pluginServers)) { - servers[`${plugin}/${name}`] = def; - } - } - return servers; - } - // 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; } From 70bf4b04a7289c256752ddac7a2f9fb80aefb543 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 23 May 2026 22:15:36 -0500 Subject: [PATCH 09/10] docs: add inline comments explaining tricky/non-obvious code (PR #28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarifies several areas that could confuse contributors: - resolveProvider(): why it must NOT register the fallback entry - ProviderRegistry.register(): documents the side-effect contract - anthropic adapter: explains the OpenAI ↔ Anthropic format conversions - Plugin loader: explains _handler/_plugin underscore fields in tools - PROVIDER_TOOLS: notes it's only sent when no provider is configured - executor.js: explains semantic_merge fallback and why it replaces the full file instead of patching Co-Authored-By: Claude Opus 4.7 --- .smallcode/plugins/anthropic-provider/adapter.js | 6 ++++++ bin/executor.js | 7 ++++++- bin/tools.js | 3 +++ src/compiled/providers/index.js | 6 ++++++ src/compiled/providers/registry.js | 4 ++++ src/plugins/loader.js | 2 ++ 6 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.smallcode/plugins/anthropic-provider/adapter.js b/.smallcode/plugins/anthropic-provider/adapter.js index 8186f786..a5c080c1 100644 --- a/.smallcode/plugins/anthropic-provider/adapter.js +++ b/.smallcode/plugins/anthropic-provider/adapter.js @@ -32,6 +32,9 @@ class AnthropicAdapter { } 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 => ({ @@ -122,6 +125,9 @@ class AnthropicAdapter { 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', 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/tools.js b/bin/tools.js index c7089a7c..b2ffba6f 100644 --- a/bin/tools.js +++ b/bin/tools.js @@ -44,6 +44,9 @@ const COMPOUND_TOOLS = [ ]; // ─── 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'] } } }, diff --git a/src/compiled/providers/index.js b/src/compiled/providers/index.js index af3ccea9..c993a33d 100644 --- a/src/compiled/providers/index.js +++ b/src/compiled/providers/index.js @@ -92,6 +92,12 @@ function listModelNames() { * 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); diff --git a/src/compiled/providers/registry.js b/src/compiled/providers/registry.js index 2d88aa06..309ab075 100644 --- a/src/compiled/providers/registry.js +++ b/src/compiled/providers/registry.js @@ -19,6 +19,10 @@ class ProviderRegistry { 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 }); diff --git a/src/plugins/loader.js b/src/plugins/loader.js index dc0577e8..8fb1fff0 100644 --- a/src/plugins/loader.js +++ b/src/plugins/loader.js @@ -97,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, }); From ade5fbe660631e4fd82da1f4f24fdbf8be2b65dc Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 24 May 2026 01:01:22 -0500 Subject: [PATCH 10/10] fix: remove providerRegistry.register() from resolveProvider() TypeScript source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compiled index.js already had this fix (no register() call in the fallback path) but index.ts still called providerRegistry.register(name, provider). The next tsc build would overwrite the corrected .js and re-introduce the bug. resolveProvider() is a lookup, not a mutation. Registering the fallback pollutes the registry — subsequent calls with any unknown name would short-circuit to the plugin path and bypass the real OpenAI-compat fetch. Co-Authored-By: Claude Opus 4.7 --- src/compiled/providers/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/compiled/providers/index.ts b/src/compiled/providers/index.ts index 99553bd4..08514c93 100644 --- a/src/compiled/providers/index.ts +++ b/src/compiled/providers/index.ts @@ -105,12 +105,16 @@ export function listModelNames(): string[] { * 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"; - const provider = new OpenAICompatProvider(baseUrl); - providerRegistry.register(name, provider); - return provider; + return new OpenAICompatProvider(baseUrl); }