diff --git a/.gitignore b/.gitignore index 5fd364643..823414852 100644 --- a/.gitignore +++ b/.gitignore @@ -101,16 +101,18 @@ claude-flow.config.json .swarm/ .hive-mind/ .claude-flow/ -memory/ -coordination/ -memory/claude-flow-data.json -memory/sessions/* -!memory/sessions/README.md -memory/agents/* -!memory/agents/README.md -coordination/memory_bank/* -coordination/subtasks/* -coordination/orchestration/* +# Only ignore top-level memory/coordination data dirs, not src/memory or +# packages/*/memory which are committed source code. +/memory/ +/coordination/ +/memory/claude-flow-data.json +/memory/sessions/* +!/memory/sessions/README.md +/memory/agents/* +!/memory/agents/README.md +/coordination/memory_bank/* +/coordination/subtasks/* +/coordination/orchestration/* *.db *.db-journal *.db-wal diff --git a/agentic-flow/package.json b/agentic-flow/package.json index 29032351e..78d8e26cb 100644 --- a/agentic-flow/package.json +++ b/agentic-flow/package.json @@ -184,6 +184,7 @@ "sql.js": "^1.11.0" }, "overrides": { + "protobufjs": ">=7.5.5", "@xenova/transformers": { "sharp": "$sharp" } diff --git a/agentic-flow/src/agentdb/index.ts b/agentic-flow/src/agentdb/index.ts index e401b44b5..3c70ad658 100644 --- a/agentic-flow/src/agentdb/index.ts +++ b/agentic-flow/src/agentdb/index.ts @@ -26,6 +26,22 @@ export { CausalRecall } from 'agentdb'; export { NightlyLearner } from 'agentdb'; export { ExplainableRecall } from 'agentdb'; +// Controller activation registry (issue #146 Gap 2). Exported as a no-op +// re-export so it works whether the installed agentdb publishes the registry +// natively or not — the local `prerequisites.ts` shim below provides a +// subset for older agentdb versions. +export { + controllerPrerequisites, + noArgControllers, + getControllerPrerequisite, + filterBySafety +} from './prerequisites.js'; +export type { + ControllerPrerequisite, + ControllerRequirement, + ControllerSafety +} from './prerequisites.js'; + // Note: These are custom types not exported from agentdb v1.3.9 // Users should import from agentdb directly if needed // export type { LearningSystem } from 'agentdb/...'; diff --git a/agentic-flow/src/agentdb/prerequisites.ts b/agentic-flow/src/agentdb/prerequisites.ts new file mode 100644 index 000000000..11f6d8c52 --- /dev/null +++ b/agentic-flow/src/agentdb/prerequisites.ts @@ -0,0 +1,208 @@ +/** + * Controller Prerequisites — local shim for `agentic-flow/agentdb`. + * + * Mirrors the registry that lives in the upstream `agentdb` package + * (`agentdb/dist/controllers/prerequisites.js`). Exposing it through the + * `agentic-flow/agentdb` re-export means consumers (e.g. ruflo) can rely on + * a single import surface regardless of which agentdb version is installed. + * + * Issue #146 Gap 2. + */ + +export type ControllerRequirement = + | 'database' + | 'embedder' + | 'vectorBackend' + | 'graphBackend' + | 'learningBackend' + | 'config' + | 'wasm' + | 'networkEndpoint'; + +export type ControllerSafety = 'pure' | 'opens-resource' | 'opens-network'; + +export interface ControllerPrerequisite { + name: string; + requirements: ControllerRequirement[]; + optional: ControllerRequirement[]; + arity: number; + safety: ControllerSafety; + description: string; +} + +export const controllerPrerequisites: readonly ControllerPrerequisite[] = Object.freeze([ + { + name: 'AttentionService', + requirements: ['config'], + optional: ['wasm'], + arity: 1, + safety: 'opens-resource', + description: 'Self / cross / multi-head attention over embeddings.' + }, + { + name: 'CausalMemoryGraph', + requirements: ['database'], + optional: ['embedder', 'graphBackend', 'vectorBackend', 'config'], + arity: 5, + safety: 'opens-resource', + description: 'Causal edge graph over memories.' + }, + { + name: 'CausalRecall', + requirements: ['database', 'embedder'], + optional: ['vectorBackend', 'config'], + arity: 4, + safety: 'pure', + description: 'Causal-uplift reranker.' + }, + { + name: 'ContextSynthesizer', + requirements: ['database'], + optional: ['embedder', 'config'], + arity: 3, + safety: 'pure', + description: 'Synthesises retrieved memories into context.' + }, + { + name: 'EmbeddingService', + requirements: ['config'], + optional: [], + arity: 1, + safety: 'pure', + description: 'Text → vector embedder.' + }, + { + name: 'EnhancedEmbeddingService', + requirements: ['config'], + optional: [], + arity: 1, + safety: 'pure', + description: 'EmbeddingService with caching and provider fallback.' + }, + { + name: 'ExplainableRecall', + requirements: ['database', 'embedder'], + optional: ['vectorBackend'], + arity: 3, + safety: 'pure', + description: 'Recall with feature attributions.' + }, + { + name: 'HNSWIndex', + requirements: ['database'], + optional: ['config'], + arity: 2, + safety: 'opens-resource', + description: 'On-disk HNSW vector index.' + }, + { + name: 'LearningSystem', + requirements: ['database', 'embedder'], + optional: ['vectorBackend', 'config'], + arity: 4, + safety: 'pure', + description: 'Online learning consolidator.' + }, + { + name: 'MMRDiversityRanker', + requirements: [], + optional: ['config'], + arity: 1, + safety: 'pure', + description: 'MMR diversity reranker.' + }, + { + name: 'MemoryController', + requirements: [], + optional: ['vectorBackend', 'config'], + arity: 2, + safety: 'pure', + description: 'High-level memory orchestration.' + }, + { + name: 'MetadataFilter', + requirements: [], + optional: [], + arity: 0, + safety: 'pure', + description: 'Pure utility for metadata predicates.' + }, + { + name: 'NightlyLearner', + requirements: ['database', 'embedder'], + optional: ['config'], + arity: 3, + safety: 'pure', + description: 'Background consolidation pipeline.' + }, + { + name: 'QUICClient', + requirements: ['config', 'networkEndpoint'], + optional: [], + arity: 1, + safety: 'opens-network', + description: 'QUIC sync client.' + }, + { + name: 'QUICServer', + requirements: ['config'], + optional: [], + arity: 1, + safety: 'opens-network', + description: 'QUIC sync server.' + }, + { + name: 'ReasoningBank', + requirements: ['database', 'embedder'], + optional: ['vectorBackend', 'graphBackend', 'config'], + arity: 5, + safety: 'pure', + description: 'Reasoning memory facade.' + }, + { + name: 'ReflexionMemory', + requirements: ['database', 'embedder'], + optional: ['vectorBackend', 'learningBackend', 'graphBackend'], + arity: 5, + safety: 'pure', + description: 'Episodic replay memory.' + }, + { + name: 'SkillLibrary', + requirements: ['database', 'embedder'], + optional: ['vectorBackend', 'graphBackend', 'config'], + arity: 5, + safety: 'pure', + description: 'Reusable skill registry.' + }, + { + name: 'SyncCoordinator', + requirements: ['config'], + optional: ['networkEndpoint'], + arity: 1, + safety: 'opens-network', + description: 'Multi-peer QUIC sync coordinator.' + }, + { + name: 'WASMVectorSearch', + requirements: ['wasm'], + optional: ['config'], + arity: 1, + safety: 'opens-resource', + description: 'Pure-WASM vector search index.' + } +]); + +export const noArgControllers: readonly ControllerPrerequisite[] = Object.freeze( + controllerPrerequisites.filter(c => c.requirements.length === 0) +); + +export function getControllerPrerequisite(name: string): ControllerPrerequisite | null { + return controllerPrerequisites.find(c => c.name === name) ?? null; +} + +export function filterBySafety( + safety: readonly ControllerSafety[] +): readonly ControllerPrerequisite[] { + return controllerPrerequisites.filter(c => safety.includes(c.safety)); +} diff --git a/agentic-flow/src/cli/config-wizard.ts b/agentic-flow/src/cli/config-wizard.ts index 3b73de049..36364a58e 100644 --- a/agentic-flow/src/cli/config-wizard.ts +++ b/agentic-flow/src/cli/config-wizard.ts @@ -22,61 +22,76 @@ const CONFIG_DEFINITIONS: ConfigValue[] = [ value: '', description: 'Anthropic API key for Claude models (sk-ant-...)', required: false, - validation: (val) => val.startsWith('sk-ant-') || 'Must start with sk-ant-' + validation: (val) => val.startsWith('sk-ant-') || 'Must start with sk-ant-', }, { key: 'OPENROUTER_API_KEY', value: '', description: 'OpenRouter API key for alternative models (sk-or-v1-...)', required: false, - validation: (val) => val.startsWith('sk-or-') || 'Must start with sk-or-' + validation: (val) => val.startsWith('sk-or-') || 'Must start with sk-or-', }, { key: 'GOOGLE_GEMINI_API_KEY', value: '', description: 'Google Gemini API key for Gemini models', - required: false + required: false, + }, + { + key: 'OLLAMA_API_KEY', + value: '', + description: 'Ollama Cloud API key (optional for self-hosted; required for ollama.com Cloud)', + required: false, + }, + { + key: 'OLLAMA_BASE_URL', + value: 'http://localhost:11434', + description: 'Ollama base URL (e.g. https://ollama.com or http://localhost:11434)', + required: false, + validation: (val) => /^https?:\/\/.+/.test(val) || 'Must be a valid http(s) URL', }, { key: 'COMPLETION_MODEL', value: 'claude-sonnet-4-5-20250929', description: 'Default model for completions', - required: false + required: false, }, { key: 'PROVIDER', value: 'anthropic', - description: 'Default provider (anthropic, openrouter, gemini, onnx)', + description: 'Default provider (anthropic, openrouter, gemini, onnx, ollama)', required: false, - validation: (val) => ['anthropic', 'openrouter', 'gemini', 'onnx'].includes(val) || 'Must be anthropic, openrouter, gemini, or onnx' + validation: (val) => + ['anthropic', 'openrouter', 'gemini', 'onnx', 'ollama'].includes(val) || + 'Must be anthropic, openrouter, gemini, onnx, or ollama', }, { key: 'AGENTS_DIR', value: '', description: 'Custom agents directory path (optional)', - required: false + required: false, }, { key: 'PROXY_PORT', value: '3000', description: 'Proxy server port for OpenRouter', required: false, - validation: (val) => !isNaN(parseInt(val)) || 'Must be a number' + validation: (val) => !isNaN(parseInt(val)) || 'Must be a number', }, { key: 'USE_OPENROUTER', value: 'false', description: 'Force OpenRouter usage (true/false)', required: false, - validation: (val) => ['true', 'false'].includes(val) || 'Must be true or false' + validation: (val) => ['true', 'false'].includes(val) || 'Must be true or false', }, { key: 'USE_ONNX', value: 'false', description: 'Use ONNX local inference (true/false)', required: false, - validation: (val) => ['true', 'false'].includes(val) || 'Must be true or false' - } + validation: (val) => ['true', 'false'].includes(val) || 'Must be true or false', + }, ]; export class ConfigWizard { @@ -91,7 +106,7 @@ export class ConfigWizard { private loadExistingConfig(): void { if (existsSync(this.envPath)) { const content = readFileSync(this.envPath, 'utf-8'); - content.split('\n').forEach(line => { + content.split('\n').forEach((line) => { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { const [key, ...valueParts] = trimmed.split('='); @@ -109,10 +124,10 @@ export class ConfigWizard { '# Agentic Flow Configuration', '# Generated by agentic-flow config wizard', `# Created: ${new Date().toISOString()}`, - '' + '', ]; - CONFIG_DEFINITIONS.forEach(def => { + CONFIG_DEFINITIONS.forEach((def) => { const value = this.currentConfig.get(def.key) || def.value; if (value) { lines.push(`# ${def.description}`); @@ -123,7 +138,7 @@ export class ConfigWizard { // Preserve other env vars not in CONFIG_DEFINITIONS this.currentConfig.forEach((value, key) => { - if (!CONFIG_DEFINITIONS.find(d => d.key === key)) { + if (!CONFIG_DEFINITIONS.find((d) => d.key === key)) { lines.push(`${key}=${value}`); } }); @@ -139,7 +154,7 @@ export class ConfigWizard { const rl = createInterface({ input: process.stdin, - output: process.stdout + output: process.stdout, }); const question = (prompt: string): Promise => { @@ -152,7 +167,9 @@ export class ConfigWizard { for (const def of CONFIG_DEFINITIONS) { const currentValue = this.currentConfig.get(def.key) || def.value; const displayValue = currentValue - ? (def.key.includes('KEY') ? `${currentValue.substring(0, 15)}...` : currentValue) + ? def.key.includes('KEY') + ? `${currentValue.substring(0, 15)}...` + : currentValue : '(not set)'; console.log(`\nšŸ“ ${def.key}`); @@ -196,7 +213,6 @@ export class ConfigWizard { // Show summary this.showSummary(); - } finally { rl.close(); } @@ -204,10 +220,12 @@ export class ConfigWizard { // Direct CLI mode - set single value set(key: string, value: string): void { - const def = CONFIG_DEFINITIONS.find(d => d.key === key); + const def = CONFIG_DEFINITIONS.find((d) => d.key === key); if (!def) { - throw new Error(`Unknown configuration key: ${key}\nAvailable keys: ${CONFIG_DEFINITIONS.map(d => d.key).join(', ')}`); + throw new Error( + `Unknown configuration key: ${key}\nAvailable keys: ${CONFIG_DEFINITIONS.map((d) => d.key).join(', ')}` + ); } // Validate @@ -253,10 +271,12 @@ export class ConfigWizard { list(): void { console.log('\nšŸ“‹ Current Configuration:\n'); - CONFIG_DEFINITIONS.forEach(def => { + CONFIG_DEFINITIONS.forEach((def) => { const value = this.currentConfig.get(def.key); const displayValue = value - ? (def.key.includes('KEY') ? `${value.substring(0, 15)}...` : value) + ? def.key.includes('KEY') + ? `${value.substring(0, 15)}...` + : value : '(not set)'; console.log(`${def.key.padEnd(25)} = ${displayValue}`); @@ -272,18 +292,21 @@ export class ConfigWizard { const hasAnthropic = this.currentConfig.has('ANTHROPIC_API_KEY'); const hasOpenRouter = this.currentConfig.has('OPENROUTER_API_KEY'); const hasGemini = this.currentConfig.has('GOOGLE_GEMINI_API_KEY'); + const hasOllama = + this.currentConfig.has('OLLAMA_API_KEY') || this.currentConfig.has('OLLAMA_BASE_URL'); const provider = this.currentConfig.get('PROVIDER') || 'anthropic'; console.log('Providers configured:'); console.log(` ${hasAnthropic ? 'āœ…' : 'āŒ'} Anthropic (Claude)`); console.log(` ${hasOpenRouter ? 'āœ…' : 'āŒ'} OpenRouter (Alternative models)`); console.log(` ${hasGemini ? 'āœ…' : 'āŒ'} Gemini (Google AI)`); + console.log(` ${hasOllama ? 'āœ…' : 'āŒ'} Ollama (Cloud or self-hosted, OpenAI-compat)`); console.log(` āš™ļø ONNX (Local inference) - always available`); console.log(''); console.log(`Default provider: ${provider}`); console.log(''); - if (!hasAnthropic && !hasOpenRouter && !hasGemini) { + if (!hasAnthropic && !hasOpenRouter && !hasGemini && !hasOllama) { console.log('āš ļø Warning: No API keys configured!'); console.log(' You can use ONNX local inference, but quality may be limited.'); console.log(' Run with --provider onnx to use local inference.\n'); @@ -299,7 +322,7 @@ export class ConfigWizard { reset(): void { console.log('āš ļø Resetting configuration to defaults...'); this.currentConfig.clear(); - CONFIG_DEFINITIONS.forEach(def => { + CONFIG_DEFINITIONS.forEach((def) => { if (def.value) { this.currentConfig.set(def.key, def.value); } @@ -396,8 +419,10 @@ AVAILABLE CONFIGURATION KEYS: ANTHROPIC_API_KEY - Anthropic API key (sk-ant-...) OPENROUTER_API_KEY - OpenRouter API key (sk-or-v1-...) GOOGLE_GEMINI_API_KEY - Google Gemini API key + OLLAMA_API_KEY - Ollama Cloud API key (optional for self-hosted) + OLLAMA_BASE_URL - Ollama base URL (default: http://localhost:11434) COMPLETION_MODEL - Default model name - PROVIDER - Default provider (anthropic, openrouter, gemini, onnx) + PROVIDER - Default provider (anthropic, openrouter, gemini, onnx, ollama) AGENTS_DIR - Custom agents directory PROXY_PORT - Proxy server port (default: 3000) USE_OPENROUTER - Force OpenRouter (true/false) @@ -410,7 +435,7 @@ For more information: https://github.com/ruvnet/agentic-flow // If run directly if (import.meta.url === `file://${process.argv[1]}`) { const args = process.argv.slice(2); - handleConfigCommand(args).catch(err => { + handleConfigCommand(args).catch((err) => { console.error(err.message); process.exit(1); }); diff --git a/agentic-flow/src/core/agentdb-wrapper-enhanced.ts b/agentic-flow/src/core/agentdb-wrapper-enhanced.ts index 54754c121..146bd940c 100644 --- a/agentic-flow/src/core/agentdb-wrapper-enhanced.ts +++ b/agentic-flow/src/core/agentdb-wrapper-enhanced.ts @@ -261,14 +261,29 @@ export class EnhancedAgentDBWrapper { throw new Error('GraphNeuralNetwork not found in @ruvector/gnn'); } - // Create GNN with configured layers - const layers = []; - for (let i = 0; i < (this.config.gnnConfig?.numLayers || 3); i++) { - layers.push({ - inFeatures: i === 0 ? this.dimension : this.config.gnnConfig!.hiddenDim!, - outFeatures: this.config.gnnConfig?.hiddenDim || 256, - numHeads: this.config.gnnConfig?.numHeads || 8, - }); + // Pre-validate the heads/dimension alignment that @ruvector/gnn + // RuvectorLayer panics on (issue #118 / ruvector#216). The constraint + // is `EMBEDDING_DIM % num_heads == 0` per layer; if violated, the + // native side panics through the FFI boundary and tears down the + // process. We refuse to construct in that case and disable GNN. + const numLayers = this.config.gnnConfig?.numLayers || 3; + const numHeads = this.config.gnnConfig?.numHeads || 8; + const hiddenDim = this.config.gnnConfig?.hiddenDim || 256; + + const layers: Array<{ inFeatures: number; outFeatures: number; numHeads: number }> = []; + for (let i = 0; i < numLayers; i++) { + const inFeatures = i === 0 ? this.dimension : hiddenDim; + const outFeatures = hiddenDim; + + if (inFeatures % numHeads !== 0 || outFeatures % numHeads !== 0) { + throw new Error( + `GNN layer ${i} dimensions (in=${inFeatures}, out=${outFeatures}) ` + + `must both be divisible by numHeads=${numHeads} (RuvectorLayer ` + + `would panic). Adjust gnnConfig.numHeads or hiddenDim.` + ); + } + + layers.push({ inFeatures, outFeatures, numHeads }); } this.gnnService = new GraphNeuralNetwork({ layers }); diff --git a/agentic-flow/src/core/gnn-wrapper.ts b/agentic-flow/src/core/gnn-wrapper.ts index 5192704cb..a9c21b4ed 100644 --- a/agentic-flow/src/core/gnn-wrapper.ts +++ b/agentic-flow/src/core/gnn-wrapper.ts @@ -132,6 +132,27 @@ export class RuvectorLayer { outputDim: number, activation: 'relu' | 'tanh' | 'sigmoid' | 'none' = 'relu' ) { + // Issue #118/#119 — validate constructor arguments instead of letting + // an upstream native panic tear down the FFI / process. Callers + // sometimes pass `config.layers` (an array or count) where the + // constructor expects an integer dimension; surface that as a typed + // error rather than a panic. + if (typeof inputDim !== 'number' || !Number.isFinite(inputDim) || inputDim <= 0 || !Number.isInteger(inputDim)) { + throw new TypeError( + `RuvectorLayer: inputDim must be a positive integer, got ${typeof inputDim} ${String(inputDim)}` + ); + } + if (typeof outputDim !== 'number' || !Number.isFinite(outputDim) || outputDim <= 0 || !Number.isInteger(outputDim)) { + throw new TypeError( + `RuvectorLayer: outputDim must be a positive integer, got ${typeof outputDim} ${String(outputDim)}` + ); + } + if (!['relu', 'tanh', 'sigmoid', 'none'].includes(activation)) { + throw new TypeError( + `RuvectorLayer: activation must be one of relu|tanh|sigmoid|none, got ${String(activation)}` + ); + } + this.inputDim = inputDim; this.outputDim = outputDim; this.activation = activation; diff --git a/agentic-flow/src/index.ts b/agentic-flow/src/index.ts index ac5dbd864..decd75db4 100644 --- a/agentic-flow/src/index.ts +++ b/agentic-flow/src/index.ts @@ -1,30 +1,33 @@ // Apply AgentDB runtime patch before any imports -import "./utils/agentdb-runtime-patch.js"; - -import "dotenv/config"; -import { webResearchAgent } from "./agents/webResearchAgent.js"; -import { codeReviewAgent } from "./agents/codeReviewAgent.js"; -import { dataAgent } from "./agents/dataAgent.js"; -import { claudeAgent } from "./agents/claudeAgent.js"; -import { logger } from "./utils/logger.js"; -import { startHealthServer } from "./health.js"; -import { parseArgs, printHelp, validateOptions } from "./utils/cli.js"; -import { getAgent, listAgents } from "./utils/agentLoader.js"; -import { handleMCPCommand } from "./utils/mcpCommands.js"; -import { handleReasoningBankCommand } from "./utils/reasoningbankCommands.js"; +import './utils/agentdb-runtime-patch.js'; + +import 'dotenv/config'; +import { realpathSync } from 'node:fs'; +import { resolve as pathResolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { webResearchAgent } from './agents/webResearchAgent.js'; +import { codeReviewAgent } from './agents/codeReviewAgent.js'; +import { dataAgent } from './agents/dataAgent.js'; +import { claudeAgent } from './agents/claudeAgent.js'; +import { logger } from './utils/logger.js'; +import { startHealthServer } from './health.js'; +import { parseArgs, printHelp, validateOptions } from './utils/cli.js'; +import { getAgent, listAgents } from './utils/agentLoader.js'; +import { handleMCPCommand } from './utils/mcpCommands.js'; +import { handleReasoningBankCommand } from './utils/reasoningbankCommands.js'; // Re-export ReasoningBank plugin for npm package users -export * as reasoningbank from "./reasoningbank/index.js"; +export * as reasoningbank from './reasoningbank/index.js'; async function runParallelMode() { - const topic = process.env.TOPIC ?? "migrate payments service"; - const codeDiff = process.env.DIFF ?? "feat: add payments router and mandate checks"; - const datasetHint = process.env.DATASET ?? "monthly tx volume, refunds, chargebacks"; + const topic = process.env.TOPIC ?? 'migrate payments service'; + const codeDiff = process.env.DIFF ?? 'feat: add payments router and mandate checks'; + const datasetHint = process.env.DATASET ?? 'monthly tx volume, refunds, chargebacks'; logger.info('Starting parallel agent execution', { topic, diff: codeDiff.substring(0, 50), - dataset: datasetHint + dataset: datasetHint, }); // Stream handler for real-time output @@ -38,32 +41,44 @@ async function runParallelMode() { const startTime = Date.now(); const [researchOut, reviewOut, dataOut] = await Promise.all([ webResearchAgent(`Give me context and risks about: ${topic}`, streamHandler('RESEARCH')), - codeReviewAgent(`Review this diff at a high level and propose tests:\n${codeDiff}`, streamHandler('CODE_REVIEW')), - dataAgent(`Analyze ${datasetHint} and report key stats.`, streamHandler('DATA')) + codeReviewAgent( + `Review this diff at a high level and propose tests:\n${codeDiff}`, + streamHandler('CODE_REVIEW') + ), + dataAgent(`Analyze ${datasetHint} and report key stats.`, streamHandler('DATA')), ]); const totalDuration = Date.now() - startTime; logger.info('All agents completed', { totalDuration, agentCount: 3, - avgDuration: Math.round(totalDuration / 3) + avgDuration: Math.round(totalDuration / 3), }); // Basic reconcile step const summary = [ - "=== RESEARCH ===", - researchOut.output?.trim() ?? "", - "=== CODE REVIEW ===", - reviewOut.output?.trim() ?? "", - "=== DATA ===", - dataOut.output?.trim() ?? "" - ].join("\n"); + '=== RESEARCH ===', + researchOut.output?.trim() ?? '', + '=== CODE REVIEW ===', + reviewOut.output?.trim() ?? '', + '=== DATA ===', + dataOut.output?.trim() ?? '', + ].join('\n'); console.log(summary); } -async function runAgentMode(agentName: string, task: string, stream: boolean, modelOverride?: string) { - logger.info('Running agent mode', { agent: agentName, task: task.substring(0, 100), model: modelOverride || 'default' }); +async function runAgentMode( + agentName: string, + task: string, + stream: boolean, + modelOverride?: string +) { + logger.info('Running agent mode', { + agent: agentName, + task: task.substring(0, 100), + model: modelOverride || 'default', + }); // Load the specified agent const agent = getAgent(agentName); @@ -73,7 +88,7 @@ async function runAgentMode(agentName: string, task: string, stream: boolean, mo logger.error('Agent not found', { agent: agentName }); console.error(`\nāŒ Agent '${agentName}' not found.\n`); console.error('Available agents:'); - availableAgents.slice(0, 20).forEach(a => { + availableAgents.slice(0, 20).forEach((a) => { console.error(` • ${a.name}: ${a.description.substring(0, 80)}...`); }); if (availableAgents.length > 20) { @@ -91,23 +106,31 @@ async function runAgentMode(agentName: string, task: string, stream: boolean, mo console.log('ā³ Running...\n'); // Enhanced stream handler that writes to stderr for progress and stdout for content - const streamHandler = stream ? (chunk: string) => { - // Write progress indicators (timestamps, tool calls) to stderr - if (chunk.startsWith('\n[') || chunk.startsWith('[') || chunk.includes('šŸ”') || chunk.includes('āœ…') || chunk.includes('āŒ')) { - process.stderr.write(chunk); - } else { - // Write text content to stdout - process.stdout.write(chunk); - } - - // Force flush to ensure immediate visibility - if (process.stdout.uncork) { - process.stdout.uncork(); - } - if (process.stderr.uncork) { - process.stderr.uncork(); - } - } : undefined; + const streamHandler = stream + ? (chunk: string) => { + // Write progress indicators (timestamps, tool calls) to stderr + if ( + chunk.startsWith('\n[') || + chunk.startsWith('[') || + chunk.includes('šŸ”') || + chunk.includes('āœ…') || + chunk.includes('āŒ') + ) { + process.stderr.write(chunk); + } else { + // Write text content to stdout + process.stdout.write(chunk); + } + + // Force flush to ensure immediate visibility + if (process.stdout.uncork) { + process.stdout.uncork(); + } + if (process.stderr.uncork) { + process.stderr.uncork(); + } + } + : undefined; // Use Claude Agent SDK with in-SDK MCP server and optional model override logger.info('Using Claude Agent SDK with in-SDK MCP server', { modelOverride }); @@ -120,7 +143,11 @@ async function runAgentMode(agentName: string, task: string, stream: boolean, mo console.log('\n═══════════════════════════════════════\n'); } - logger.info('Agent mode completed', { agent: agentName, outputLength: result.output.length, model: modelOverride || 'default' }); + logger.info('Agent mode completed', { + agent: agentName, + outputLength: result.output.length, + model: modelOverride || 'default', + }); } function runListMode() { @@ -131,7 +158,7 @@ function runListMode() { // Group by category (based on directory structure) const grouped = new Map(); - agents.forEach(agent => { + agents.forEach((agent) => { const parts = agent.filePath.split('/'); const category = parts[parts.length - 2] || 'other'; if (!grouped.has(category)) { @@ -145,7 +172,7 @@ function runListMode() { .sort(([a], [b]) => a.localeCompare(b)) .forEach(([category, categoryAgents]) => { console.log(`\n${category.toUpperCase()}:`); - categoryAgents.forEach(agent => { + categoryAgents.forEach((agent) => { console.log(` ${agent.name.padEnd(30)} ${agent.description.substring(0, 80)}`); }); }); @@ -236,7 +263,28 @@ async function main() { } } -main().catch(err => { - console.error(err); - process.exit(1); -}); +// Library-safe entrypoint: only run the CLI/agent flow when this file is the +// process entry point (e.g. `node dist/index.js` or `npx agentic-flow`). +// When imported as a module (`import 'agentic-flow'`), expose exports without +// triggering CLI parsing, the health server, or agent execution. +function isCliEntry(): boolean { + try { + const argv1 = process.argv[1]; + if (!argv1) return false; + const selfPath = fileURLToPath(import.meta.url); + const realArgv = realpathSync(pathResolve(argv1)); + const realSelf = realpathSync(selfPath); + return realArgv === realSelf; + } catch { + return false; + } +} + +if (isCliEntry()) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} + +export { main }; diff --git a/agentic-flow/src/mcp/claudeFlowSdkServer.ts b/agentic-flow/src/mcp/claudeFlowSdkServer.ts index 775de3a49..b619d96f1 100644 --- a/agentic-flow/src/mcp/claudeFlowSdkServer.ts +++ b/agentic-flow/src/mcp/claudeFlowSdkServer.ts @@ -5,7 +5,38 @@ import { execSync } from 'child_process'; import { readFileSync, writeFileSync } from 'fs'; import { extname } from 'path'; import { logger } from '../utils/logger.js'; -import { AgentBooster } from 'agent-booster'; + +// agent-booster is an optional sibling package — load it lazily so a missing +// dep does not break top-level imports of agentic-flow (issue #102). +type AgentBoosterCtor = new (opts: { confidenceThreshold?: number }) => { + apply(input: any): Promise<{ + success: boolean; + output: string; + latency: number; + confidence: number; + strategy: string; + }>; +}; + +let _AgentBooster: AgentBoosterCtor | null = null; +async function loadAgentBooster(): Promise { + if (_AgentBooster) return _AgentBooster; + try { + const mod: any = await import('agent-booster'); + const ctor: AgentBoosterCtor | undefined = + mod.AgentBooster ?? mod.default?.AgentBooster ?? mod.default; + if (!ctor) { + throw new Error("'agent-booster' loaded but does not export AgentBooster"); + } + _AgentBooster = ctor; + return ctor; + } catch (err: any) { + throw new Error( + `Agent Booster is unavailable (optional package 'agent-booster' not installed). ` + + `Install it with: npm install agent-booster. Underlying: ${err?.message || err}` + ); + } +} /** * Create an in-SDK MCP server that provides claude-flow memory and coordination tools @@ -24,7 +55,7 @@ export const claudeFlowSdkServer = createSdkMcpServer({ key: z.string().describe('Memory key'), value: z.string().describe('Value to store'), namespace: z.string().optional().default('default').describe('Memory namespace'), - ttl: z.number().optional().describe('Time-to-live in seconds') + ttl: z.number().optional().describe('Time-to-live in seconds'), }, async ({ key, value, namespace, ttl }) => { try { @@ -34,19 +65,23 @@ export const claudeFlowSdkServer = createSdkMcpServer({ logger.info('Memory stored successfully', { key }); return { - content: [{ - type: 'text', - text: `āœ… Stored successfully\nšŸ“ Key: ${key}\nšŸ“¦ Namespace: ${namespace}\nšŸ’¾ Size: ${value.length} bytes` - }] + content: [ + { + type: 'text', + text: `āœ… Stored successfully\nšŸ“ Key: ${key}\nšŸ“¦ Namespace: ${namespace}\nšŸ’¾ Size: ${value.length} bytes`, + }, + ], }; } catch (error: any) { logger.error('Failed to store memory', { error: error.message }); return { - content: [{ - type: 'text', - text: `āŒ Failed to store: ${error.message}` - }], - isError: true + content: [ + { + type: 'text', + text: `āŒ Failed to store: ${error.message}`, + }, + ], + isError: true, }; } } @@ -58,7 +93,7 @@ export const claudeFlowSdkServer = createSdkMcpServer({ 'Retrieve a value from persistent memory', { key: z.string().describe('Memory key'), - namespace: z.string().optional().default('default').describe('Memory namespace') + namespace: z.string().optional().default('default').describe('Memory namespace'), }, async ({ key, namespace }) => { try { @@ -66,18 +101,22 @@ export const claudeFlowSdkServer = createSdkMcpServer({ const result = execSync(cmd, { encoding: 'utf-8' }); return { - content: [{ - type: 'text', - text: `āœ… Retrieved:\n${result}` - }] + content: [ + { + type: 'text', + text: `āœ… Retrieved:\n${result}`, + }, + ], }; } catch (error: any) { return { - content: [{ - type: 'text', - text: `āŒ Failed to retrieve: ${error.message}` - }], - isError: true + content: [ + { + type: 'text', + text: `āŒ Failed to retrieve: ${error.message}`, + }, + ], + isError: true, }; } } @@ -90,7 +129,7 @@ export const claudeFlowSdkServer = createSdkMcpServer({ { pattern: z.string().describe('Search pattern (supports wildcards)'), namespace: z.string().optional().describe('Memory namespace to search in'), - limit: z.number().optional().default(10).describe('Maximum results to return') + limit: z.number().optional().default(10).describe('Maximum results to return'), }, async ({ pattern, namespace, limit }) => { try { @@ -98,18 +137,22 @@ export const claudeFlowSdkServer = createSdkMcpServer({ const result = execSync(cmd, { encoding: 'utf-8' }); return { - content: [{ - type: 'text', - text: `šŸ” Search results:\n${result}` - }] + content: [ + { + type: 'text', + text: `šŸ” Search results:\n${result}`, + }, + ], }; } catch (error: any) { return { - content: [{ - type: 'text', - text: `āŒ Search failed: ${error.message}` - }], - isError: true + content: [ + { + type: 'text', + text: `āŒ Search failed: ${error.message}`, + }, + ], + isError: true, }; } } @@ -122,7 +165,11 @@ export const claudeFlowSdkServer = createSdkMcpServer({ { topology: z.enum(['mesh', 'hierarchical', 'ring', 'star']).describe('Swarm topology'), maxAgents: z.number().optional().default(8).describe('Maximum number of agents'), - strategy: z.enum(['balanced', 'specialized', 'adaptive']).optional().default('balanced').describe('Agent distribution strategy') + strategy: z + .enum(['balanced', 'specialized', 'adaptive']) + .optional() + .default('balanced') + .describe('Agent distribution strategy'), }, async ({ topology, maxAgents, strategy }) => { try { @@ -130,18 +177,22 @@ export const claudeFlowSdkServer = createSdkMcpServer({ const result = execSync(cmd, { encoding: 'utf-8' }); return { - content: [{ - type: 'text', - text: `šŸš€ Swarm initialized:\n${result}` - }] + content: [ + { + type: 'text', + text: `šŸš€ Swarm initialized:\n${result}`, + }, + ], }; } catch (error: any) { return { - content: [{ - type: 'text', - text: `āŒ Swarm init failed: ${error.message}` - }], - isError: true + content: [ + { + type: 'text', + text: `āŒ Swarm init failed: ${error.message}`, + }, + ], + isError: true, }; } } @@ -152,9 +203,11 @@ export const claudeFlowSdkServer = createSdkMcpServer({ 'agent_spawn', 'Spawn a new agent in the swarm', { - type: z.enum(['researcher', 'coder', 'analyst', 'optimizer', 'coordinator']).describe('Agent type'), + type: z + .enum(['researcher', 'coder', 'analyst', 'optimizer', 'coordinator']) + .describe('Agent type'), capabilities: z.array(z.string()).optional().describe('Agent capabilities'), - name: z.string().optional().describe('Custom agent name') + name: z.string().optional().describe('Custom agent name'), }, async ({ type, capabilities, name }) => { try { @@ -164,18 +217,22 @@ export const claudeFlowSdkServer = createSdkMcpServer({ const result = execSync(cmd, { encoding: 'utf-8' }); return { - content: [{ - type: 'text', - text: `šŸ¤– Agent spawned:\n${result}` - }] + content: [ + { + type: 'text', + text: `šŸ¤– Agent spawned:\n${result}`, + }, + ], }; } catch (error: any) { return { - content: [{ - type: 'text', - text: `āŒ Agent spawn failed: ${error.message}` - }], - isError: true + content: [ + { + type: 'text', + text: `āŒ Agent spawn failed: ${error.message}`, + }, + ], + isError: true, }; } } @@ -187,9 +244,17 @@ export const claudeFlowSdkServer = createSdkMcpServer({ 'Orchestrate a complex task across the swarm', { task: z.string().describe('Task description or instructions'), - strategy: z.enum(['parallel', 'sequential', 'adaptive']).optional().default('adaptive').describe('Execution strategy'), - priority: z.enum(['low', 'medium', 'high', 'critical']).optional().default('medium').describe('Task priority'), - maxAgents: z.number().optional().describe('Maximum agents to use for this task') + strategy: z + .enum(['parallel', 'sequential', 'adaptive']) + .optional() + .default('adaptive') + .describe('Execution strategy'), + priority: z + .enum(['low', 'medium', 'high', 'critical']) + .optional() + .default('medium') + .describe('Task priority'), + maxAgents: z.number().optional().describe('Maximum agents to use for this task'), }, async ({ task, strategy, priority, maxAgents }) => { try { @@ -198,18 +263,22 @@ export const claudeFlowSdkServer = createSdkMcpServer({ const result = execSync(cmd, { encoding: 'utf-8' }); return { - content: [{ - type: 'text', - text: `⚔ Task orchestrated:\n${result}` - }] + content: [ + { + type: 'text', + text: `⚔ Task orchestrated:\n${result}`, + }, + ], }; } catch (error: any) { return { - content: [{ - type: 'text', - text: `āŒ Task orchestration failed: ${error.message}` - }], - isError: true + content: [ + { + type: 'text', + text: `āŒ Task orchestration failed: ${error.message}`, + }, + ], + isError: true, }; } } @@ -220,7 +289,7 @@ export const claudeFlowSdkServer = createSdkMcpServer({ 'swarm_status', 'Get current swarm status and metrics', { - verbose: z.boolean().optional().default(false).describe('Include detailed metrics') + verbose: z.boolean().optional().default(false).describe('Include detailed metrics'), }, async ({ verbose }) => { try { @@ -228,18 +297,22 @@ export const claudeFlowSdkServer = createSdkMcpServer({ const result = execSync(cmd, { encoding: 'utf-8' }); return { - content: [{ - type: 'text', - text: `šŸ“Š Swarm status:\n${result}` - }] + content: [ + { + type: 'text', + text: `šŸ“Š Swarm status:\n${result}`, + }, + ], }; } catch (error: any) { return { - content: [{ - type: 'text', - text: `āŒ Status check failed: ${error.message}` - }], - isError: true + content: [ + { + type: 'text', + text: `āŒ Status check failed: ${error.message}`, + }, + ], + isError: true, }; } } @@ -248,17 +321,21 @@ export const claudeFlowSdkServer = createSdkMcpServer({ // Agent Booster - Ultra-fast code editing tool( 'agent_booster_edit_file', - 'Ultra-fast code editing (352x faster than cloud APIs, $0 cost). Apply precise code edits using Agent Booster\'s local WASM engine.', + "Ultra-fast code editing (352x faster than cloud APIs, $0 cost). Apply precise code edits using Agent Booster's local WASM engine.", { target_filepath: z.string().describe('Path of the file to modify'), instructions: z.string().describe('Description of what changes to make'), code_edit: z.string().describe('The new code or edit to apply'), - language: z.string().optional().describe('Programming language (auto-detected if not provided)') + language: z + .string() + .optional() + .describe('Programming language (auto-detected if not provided)'), }, async ({ target_filepath, instructions, code_edit, language }) => { try { - // Initialize Agent Booster - const booster = new AgentBooster({ confidenceThreshold: 0.5 }); + // Initialize Agent Booster (lazy load to keep top-level import safe) + const Ctor = await loadAgentBooster(); + const booster = new Ctor({ confidenceThreshold: 0.5 }); // Read original file const originalCode = readFileSync(target_filepath, 'utf8'); @@ -273,7 +350,7 @@ export const claudeFlowSdkServer = createSdkMcpServer({ language: lang, target_filepath, instructions: code_edit, - code_edit + code_edit, } as any); // Write if successful @@ -282,26 +359,31 @@ export const claudeFlowSdkServer = createSdkMcpServer({ } return { - content: [{ - type: 'text', - text: `⚔ Agent Booster Edit Result:\n` + - `šŸ“ File: ${target_filepath}\n` + - `āœ… Success: ${result.success}\n` + - `ā±ļø Latency: ${result.latency}ms\n` + - `šŸŽÆ Confidence: ${(result.confidence * 100).toFixed(1)}%\n` + - `šŸ”§ Strategy: ${result.strategy}\n` + - `šŸ“Š Speedup: ~${Math.round(352 / result.latency)}x vs cloud APIs\n` + - `šŸ’° Cost: $0 (vs ~$0.01 for cloud API)\n\n` + - `${result.success ? '✨ Edit applied successfully!' : 'āŒ Edit failed - check confidence score'}` - }] + content: [ + { + type: 'text', + text: + `⚔ Agent Booster Edit Result:\n` + + `šŸ“ File: ${target_filepath}\n` + + `āœ… Success: ${result.success}\n` + + `ā±ļø Latency: ${result.latency}ms\n` + + `šŸŽÆ Confidence: ${(result.confidence * 100).toFixed(1)}%\n` + + `šŸ”§ Strategy: ${result.strategy}\n` + + `šŸ“Š Speedup: ~${Math.round(352 / result.latency)}x vs cloud APIs\n` + + `šŸ’° Cost: $0 (vs ~$0.01 for cloud API)\n\n` + + `${result.success ? '✨ Edit applied successfully!' : 'āŒ Edit failed - check confidence score'}`, + }, + ], }; } catch (error: any) { return { - content: [{ - type: 'text', - text: `āŒ Agent Booster edit failed: ${error.message}` - }], - isError: true + content: [ + { + type: 'text', + text: `āŒ Agent Booster edit failed: ${error.message}`, + }, + ], + isError: true, }; } } @@ -312,16 +394,21 @@ export const claudeFlowSdkServer = createSdkMcpServer({ 'agent_booster_batch_edit', 'Apply multiple code edits in parallel using Agent Booster. Perfect for multi-file refactoring.', { - edits: z.array(z.object({ - target_filepath: z.string(), - instructions: z.string(), - code_edit: z.string(), - language: z.string().optional() - })).describe('Array of edit operations to apply') + edits: z + .array( + z.object({ + target_filepath: z.string(), + instructions: z.string(), + code_edit: z.string(), + language: z.string().optional(), + }) + ) + .describe('Array of edit operations to apply'), }, async ({ edits }) => { try { - const booster = new AgentBooster({ confidenceThreshold: 0.5 }); + const Ctor = await loadAgentBooster(); + const booster = new Ctor({ confidenceThreshold: 0.5 }); let successCount = 0; let totalLatency = 0; const results: string[] = []; @@ -336,7 +423,7 @@ export const claudeFlowSdkServer = createSdkMcpServer({ language: lang, target_filepath: edit.target_filepath, instructions: edit.code_edit, - code_edit: edit.code_edit + code_edit: edit.code_edit, } as any); if (result.success) { @@ -345,37 +432,44 @@ export const claudeFlowSdkServer = createSdkMcpServer({ } totalLatency += result.latency; - results.push(` ${result.success ? 'āœ…' : 'āŒ'} ${edit.target_filepath} (${result.latency}ms, ${(result.confidence * 100).toFixed(0)}%)`); + results.push( + ` ${result.success ? 'āœ…' : 'āŒ'} ${edit.target_filepath} (${result.latency}ms, ${(result.confidence * 100).toFixed(0)}%)` + ); } const avgLatency = totalLatency / edits.length; const avgSpeedup = Math.round(352 / avgLatency); return { - content: [{ - type: 'text', - text: `⚔ Agent Booster Batch Edit Results:\n\n` + - `šŸ“Š Summary:\n` + - ` Total edits: ${edits.length}\n` + - ` Successful: ${successCount}\n` + - ` Failed: ${edits.length - successCount}\n` + - ` Total time: ${totalLatency.toFixed(1)}ms\n` + - ` Avg latency: ${avgLatency.toFixed(1)}ms\n` + - ` Avg speedup: ~${avgSpeedup}x vs cloud APIs\n` + - ` Cost savings: ~$${(edits.length * 0.01).toFixed(2)}\n\n` + - `šŸ“ Results:\n${results.join('\n')}` - }] + content: [ + { + type: 'text', + text: + `⚔ Agent Booster Batch Edit Results:\n\n` + + `šŸ“Š Summary:\n` + + ` Total edits: ${edits.length}\n` + + ` Successful: ${successCount}\n` + + ` Failed: ${edits.length - successCount}\n` + + ` Total time: ${totalLatency.toFixed(1)}ms\n` + + ` Avg latency: ${avgLatency.toFixed(1)}ms\n` + + ` Avg speedup: ~${avgSpeedup}x vs cloud APIs\n` + + ` Cost savings: ~$${(edits.length * 0.01).toFixed(2)}\n\n` + + `šŸ“ Results:\n${results.join('\n')}`, + }, + ], }; } catch (error: any) { return { - content: [{ - type: 'text', - text: `āŒ Batch edit failed: ${error.message}` - }], - isError: true + content: [ + { + type: 'text', + text: `āŒ Batch edit failed: ${error.message}`, + }, + ], + isError: true, }; } } - ) - ] + ), + ], }); diff --git a/agentic-flow/src/mcp/tools/agent-booster-tools.ts b/agentic-flow/src/mcp/tools/agent-booster-tools.ts index 452ec7a17..c1fa0db58 100644 --- a/agentic-flow/src/mcp/tools/agent-booster-tools.ts +++ b/agentic-flow/src/mcp/tools/agent-booster-tools.ts @@ -5,15 +5,45 @@ * Uses Agent Booster's local WASM engine for sub-millisecond transformations */ -import { AgentBooster } from 'agent-booster'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import type { MCPTool } from './sona-tools.js'; -import type { MorphApplyRequest, MorphApplyResponse } from 'agent-booster'; +// agent-booster types are optional — we re-declare a structural subset so this +// file builds even when the optional sibling package is not installed (#102). +type MorphApplyResponse = { + output: string; + success: boolean; + latency: number; + tokens?: { input: number; output: number }; + confidence: number; + strategy: string; + error?: string; + metadata?: any; +}; + +type AgentBoosterInstance = { + apply(input: any): Promise; +}; -// Initialize Agent Booster instance -const booster = new AgentBooster({ - confidenceThreshold: 0.5, - maxChunks: 100 -}); +let _booster: AgentBoosterInstance | null = null; +async function getBooster(): Promise { + if (_booster) return _booster; + const mod: any = await import('agent-booster').catch((err: any) => { + throw new Error( + `Agent Booster optional package not installed: ${err?.message || err}. ` + + `Run: npm install agent-booster` + ); + }); + const Ctor = mod.AgentBooster ?? mod.default?.AgentBooster ?? mod.default; + if (!Ctor) { + throw new Error("'agent-booster' loaded but does not export AgentBooster"); + } + _booster = new Ctor({ + confidenceThreshold: 0.5, + maxChunks: 100, + }); + return _booster; +} /** * Agent Booster MCP Tools @@ -21,33 +51,36 @@ const booster = new AgentBooster({ export const agentBoosterMCPTools: MCPTool[] = [ { name: 'agent_booster_edit_file', - description: 'Ultra-fast code editing (352x faster than cloud APIs, $0 cost). Apply precise code edits using Agent Booster\'s local WASM engine. Use "// ... existing code ..." markers for unchanged sections.', + description: + 'Ultra-fast code editing (352x faster than cloud APIs, $0 cost). Apply precise code edits using Agent Booster\'s local WASM engine. Use "// ... existing code ..." markers for unchanged sections.', inputSchema: { type: 'object', properties: { target_filepath: { type: 'string', - description: 'Path of the file to modify' + description: 'Path of the file to modify', }, instructions: { type: 'string', - description: 'First-person instruction (e.g., "I will add error handling")' + description: 'First-person instruction (e.g., "I will add error handling")', }, code_edit: { type: 'string', - description: 'Precise code lines to edit, using "// ... existing code ..." for unchanged sections' + description: + 'Precise code lines to edit, using "// ... existing code ..." for unchanged sections', }, language: { type: 'string', - description: 'Programming language (auto-detected from file extension if not provided)' - } + description: 'Programming language (auto-detected from file extension if not provided)', + }, }, - required: ['target_filepath', 'instructions', 'code_edit'] - } + required: ['target_filepath', 'instructions', 'code_edit'], + }, }, { name: 'agent_booster_batch_edit', - description: 'Apply multiple code edits in a single operation (ultra-fast batch processing). Perfect for multi-file refactoring.', + description: + 'Apply multiple code edits in a single operation (ultra-fast batch processing). Perfect for multi-file refactoring.', inputSchema: { type: 'object', properties: { @@ -59,42 +92,44 @@ export const agentBoosterMCPTools: MCPTool[] = [ properties: { target_filepath: { type: 'string', - description: 'File path' + description: 'File path', }, instructions: { type: 'string', - description: 'First-person instruction' + description: 'First-person instruction', }, code_edit: { type: 'string', - description: 'Code edit with markers' + description: 'Code edit with markers', }, language: { type: 'string', - description: 'Programming language' - } + description: 'Programming language', + }, }, - required: ['target_filepath', 'instructions', 'code_edit'] - } - } + required: ['target_filepath', 'instructions', 'code_edit'], + }, + }, }, - required: ['edits'] - } + required: ['edits'], + }, }, { name: 'agent_booster_parse_markdown', - description: 'Parse markdown code blocks with filepath= and instruction= metadata, then apply all edits. Compatible with LLM-generated multi-file refactoring outputs.', + description: + 'Parse markdown code blocks with filepath= and instruction= metadata, then apply all edits. Compatible with LLM-generated multi-file refactoring outputs.', inputSchema: { type: 'object', properties: { markdown: { type: 'string', - description: 'Markdown text containing code blocks with filepath= and instruction= metadata' - } + description: + 'Markdown text containing code blocks with filepath= and instruction= metadata', + }, }, - required: ['markdown'] - } - } + required: ['markdown'], + }, + }, ]; /** @@ -110,9 +145,6 @@ export const agentBoosterMCPHandlers = { code_edit: string; language?: string; }): Promise => { - const fs = require('fs/promises'); - const path = require('path'); - try { // Read current file content const originalCode = await fs.readFile(params.target_filepath, 'utf8'); @@ -121,13 +153,14 @@ export const agentBoosterMCPHandlers = { const language = params.language || path.extname(params.target_filepath).slice(1); // Apply edit using Agent Booster - use any for flexible signature + const booster = await getBooster(); const result = await booster.apply({ code: originalCode, edit: params.code_edit, language, target_filepath: params.target_filepath, instructions: params.code_edit, - code_edit: params.code_edit + code_edit: params.code_edit, } as any); // Write modified code if successful @@ -143,8 +176,8 @@ export const agentBoosterMCPHandlers = { instructions: params.instructions, language, originalSize: originalCode.length, - modifiedSize: result.output.length - } + modifiedSize: result.output.length, + }, } as any; } catch (error: any) { return { @@ -154,7 +187,7 @@ export const agentBoosterMCPHandlers = { tokens: { input: 0, output: 0 }, confidence: 0, strategy: 'failed', - error: error.message + error: error.message, } as any; } }, @@ -171,8 +204,6 @@ export const agentBoosterMCPHandlers = { }>; }): Promise<{ results: MorphApplyResponse[]; summary: any }> => { const results: MorphApplyResponse[] = []; - const fs = require('fs/promises'); - const path = require('path'); let totalLatency = 0; let successCount = 0; @@ -187,13 +218,14 @@ export const agentBoosterMCPHandlers = { const language = edit.language || path.extname(edit.target_filepath).slice(1); // Apply edit using Agent Booster - use any for flexible signature + const booster = await getBooster(); const result = await booster.apply({ code: originalCode, edit: edit.code_edit, language, target_filepath: edit.target_filepath, instructions: edit.code_edit, - code_edit: edit.code_edit + code_edit: edit.code_edit, } as any); // Write modified code if successful @@ -209,8 +241,8 @@ export const agentBoosterMCPHandlers = { ...result, metadata: { filepath: edit.target_filepath, - instructions: edit.instructions - } + instructions: edit.instructions, + }, } as any); } catch (error: any) { results.push({ @@ -222,8 +254,8 @@ export const agentBoosterMCPHandlers = { strategy: 'failed', error: error.message, metadata: { - filepath: edit.target_filepath - } + filepath: edit.target_filepath, + }, } as any); } } @@ -237,8 +269,8 @@ export const agentBoosterMCPHandlers = { totalLatency, avgLatency: totalLatency / params.edits.length, totalBytes, - speedupVsCloud: Math.round(352 / (totalLatency / params.edits.length)) - } + speedupVsCloud: Math.round(352 / (totalLatency / params.edits.length)), + }, }; }, @@ -249,7 +281,8 @@ export const agentBoosterMCPHandlers = { markdown: string; }): Promise<{ results: MorphApplyResponse[]; summary: any }> => { // Parse markdown code blocks - const codeBlockRegex = /```(\w+)?\s+filepath=["']([^"']+)["']\s+instruction=["']([^"']+)["']\s*\n([\s\S]*?)```/g; + const codeBlockRegex = + /```(\w+)?\s+filepath=["']([^"']+)["']\s+instruction=["']([^"']+)["']\s*\n([\s\S]*?)```/g; const edits: Array<{ target_filepath: string; instructions: string; @@ -263,7 +296,7 @@ export const agentBoosterMCPHandlers = { language: match[1], target_filepath: match[2], instructions: match[3], - code_edit: match[4].trim() + code_edit: match[4].trim(), }); } @@ -274,14 +307,14 @@ export const agentBoosterMCPHandlers = { total: 0, successful: 0, failed: 0, - error: 'No code blocks found with required metadata' - } + error: 'No code blocks found with required metadata', + }, }; } // Apply all edits return agentBoosterMCPHandlers.agent_booster_batch_edit({ edits }); - } + }, }; /** @@ -294,16 +327,26 @@ export function getAgentBoosterStats() { performance: { avgLatency: '1ms', speedup: '352x vs cloud APIs', - costSavings: '$240/month' + costSavings: '$240/month', }, features: { local: true, offline: true, privacy: 'Complete (no data sent to cloud)', languages: [ - 'javascript', 'typescript', 'python', 'rust', 'go', - 'java', 'c', 'cpp', 'ruby', 'php', 'swift', 'kotlin' - ] - } + 'javascript', + 'typescript', + 'python', + 'rust', + 'go', + 'java', + 'c', + 'cpp', + 'ruby', + 'php', + 'swift', + 'kotlin', + ], + }, }; } diff --git a/agentic-flow/src/memory/SharedMemoryPool.ts b/agentic-flow/src/memory/SharedMemoryPool.ts new file mode 100644 index 000000000..6b9bf82f1 --- /dev/null +++ b/agentic-flow/src/memory/SharedMemoryPool.ts @@ -0,0 +1,318 @@ +/** + * SharedMemoryPool — singleton resource pool that backs HybridReasoningBank + * and AdvancedMemorySystem. + * + * Centralises a single SQLite database handle (better-sqlite3) and a single + * `EmbeddingService` instance so multiple memory consumers share the same + * underlying tables / embedding cache without conflicting writes. + * + * The pool is constructed lazily on first `getInstance()` call. All heavy + * resources (sqlite handle, embedder pipeline) are created on demand inside + * `ensureInitialized()` so simply importing this module is side-effect free. + * + * Used by: + * - reasoningbank/HybridBackend.ts (HybridReasoningBank) + * - reasoningbank/AdvancedMemory.ts (AdvancedMemorySystem) + * + * Fixes issue #102 — this file was previously imported but missing on disk, + * which broke `import 'agentic-flow'` at the top level. + */ + +import { mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { EmbeddingService } from 'agentdb'; + +type DatabaseHandle = any; +type EmbedderHandle = EmbeddingService; + +export interface SharedMemoryPoolOptions { + /** SQLite database path. Defaults to `~/.agentic-flow/reasoningbank.db`. */ + dbPath?: string; + /** Embedding model id. Defaults to `Xenova/all-MiniLM-L6-v2`. */ + embeddingModel?: string; + /** Embedding vector dimension. Defaults to 384 (MiniLM-L6). */ + embeddingDimension?: number; + /** Embedding provider. Defaults to `'transformers'`. */ + embeddingProvider?: 'transformers' | 'openai' | 'local'; +} + +const DEFAULT_OPTIONS: Required = { + dbPath: join(homedir(), '.agentic-flow', 'reasoningbank.db'), + embeddingModel: 'Xenova/all-MiniLM-L6-v2', + embeddingDimension: 384, + embeddingProvider: 'transformers', +}; + +interface CacheEntry { + value: T; + expiresAt: number; +} + +export interface SharedMemoryPoolStats { + initialized: boolean; + dbPath: string; + embeddingModel: string; + embeddingDimension: number; + cache: { + entries: number; + hits: number; + misses: number; + evictions: number; + }; +} + +export class SharedMemoryPool { + private static _instance: SharedMemoryPool | null = null; + + private readonly options: Required; + private db: DatabaseHandle | null = null; + private embedder: EmbedderHandle | null = null; + private initPromise: Promise | null = null; + + // Lightweight in-process query cache used by HybridReasoningBank to avoid + // re-running the same retrieval across consumers in one session. + private cache = new Map>(); + private cacheStats = { hits: 0, misses: 0, evictions: 0 }; + + private constructor(options: SharedMemoryPoolOptions = {}) { + this.options = { ...DEFAULT_OPTIONS, ...options }; + } + + /** + * Singleton accessor. The first caller wins for option overrides; later + * callers always get the existing pool. To reconfigure, call `reset()`. + */ + static getInstance(options?: SharedMemoryPoolOptions): SharedMemoryPool { + if (!SharedMemoryPool._instance) { + SharedMemoryPool._instance = new SharedMemoryPool(options); + } + return SharedMemoryPool._instance; + } + + /** Tear down the singleton — primarily for tests. */ + static reset(): void { + if (SharedMemoryPool._instance) { + SharedMemoryPool._instance.close(); + } + SharedMemoryPool._instance = null; + } + + /** + * Idempotently ensure the database and embedder are ready. Subsequent calls + * return the same in-flight promise so concurrent consumers share init. + */ + async ensureInitialized(): Promise { + if (this.db && this.embedder) return; + if (!this.initPromise) { + this.initPromise = this.initialize(); + } + return this.initPromise; + } + + private async initialize(): Promise { + // Dynamic import keeps node:fs, better-sqlite3, and the embedding pipeline + // out of the import graph for callers that never use them. + const Database = await loadBetterSqlite3(); + + mkdirSync(dirname(this.options.dbPath), { recursive: true }); + this.db = new Database(this.options.dbPath); + // Reasonable defaults for an embedded reasoning database. + this.db.pragma('journal_mode = WAL'); + this.db.pragma('foreign_keys = ON'); + this.db.pragma('synchronous = NORMAL'); + + this.applySchema(this.db); + + this.embedder = new EmbeddingService({ + model: this.options.embeddingModel, + dimension: this.options.embeddingDimension, + provider: this.options.embeddingProvider, + }); + await this.embedder.initialize(); + } + + /** Apply the minimum schema that ReflexionMemory / SkillLibrary require. */ + private applySchema(db: DatabaseHandle): void { + db.exec(` + CREATE TABLE IF NOT EXISTS episodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + session_id TEXT NOT NULL, + task TEXT NOT NULL, + input TEXT, + output TEXT, + critique TEXT, + reward REAL NOT NULL, + success INTEGER NOT NULL, + latency_ms INTEGER, + tokens_used INTEGER, + tags TEXT, + metadata TEXT + ); + + CREATE TABLE IF NOT EXISTS episode_embeddings ( + episode_id INTEGER PRIMARY KEY, + embedding BLOB NOT NULL, + FOREIGN KEY (episode_id) REFERENCES episodes(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_episodes_task ON episodes(task); + CREATE INDEX IF NOT EXISTS idx_episodes_ts ON episodes(ts); + CREATE INDEX IF NOT EXISTS idx_episodes_success ON episodes(success); + + CREATE TABLE IF NOT EXISTS skills ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + name TEXT NOT NULL, + description TEXT, + precondition TEXT, + action TEXT, + outcome TEXT, + success_rate REAL, + uses INTEGER DEFAULT 0, + tags TEXT, + metadata TEXT, + UNIQUE(name) + ); + + CREATE TABLE IF NOT EXISTS skill_embeddings ( + skill_id INTEGER PRIMARY KEY, + embedding BLOB NOT NULL, + FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS causal_edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + from_memory_id INTEGER NOT NULL, + from_memory_type TEXT NOT NULL, + to_memory_id INTEGER NOT NULL, + to_memory_type TEXT NOT NULL, + similarity REAL, + uplift REAL, + confidence REAL, + sample_size INTEGER, + metadata TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_causal_from ON causal_edges(from_memory_id, from_memory_type); + CREATE INDEX IF NOT EXISTS idx_causal_to ON causal_edges(to_memory_id, to_memory_type); + `); + } + + /** + * Synchronous accessor for the database handle. Throws if init hasn't run — + * call `ensureInitialized()` first. Provided for compatibility with + * controllers that take a `Database` instance in their constructor. + */ + getDatabase(): DatabaseHandle { + if (!this.db) { + throw new Error( + 'SharedMemoryPool: database not initialised. Call ensureInitialized() first.' + ); + } + return this.db; + } + + /** + * Synchronous accessor for the embedder. Throws if init hasn't run. + */ + getEmbedder(): EmbedderHandle { + if (!this.embedder) { + throw new Error( + 'SharedMemoryPool: embedder not initialised. Call ensureInitialized() first.' + ); + } + return this.embedder; + } + + /** + * Cache a query result with a TTL (milliseconds). Keys are arbitrary + * strings; consumers (HybridReasoningBank) typically encode the query + * shape into the key. + */ + cacheQuery(key: string, value: T, ttlMs: number): void { + if (!key || ttlMs <= 0) return; + this.cache.set(key, { + value: value as unknown, + expiresAt: Date.now() + ttlMs, + }); + } + + /** + * Read a cached query result. Returns the cached value if present and not + * expired; lazily evicts expired entries on lookup. + * + * The default `T` is `any` for ergonomic interop with the existing + * HybridReasoningBank call sites that expect a loose return type. + * Pass an explicit type parameter (`getCachedQuery(...)`) when + * you want stricter typing. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getCachedQuery(key: string): T | undefined { + const entry = this.cache.get(key); + if (!entry) { + this.cacheStats.misses++; + return undefined; + } + if (entry.expiresAt < Date.now()) { + this.cache.delete(key); + this.cacheStats.evictions++; + this.cacheStats.misses++; + return undefined; + } + this.cacheStats.hits++; + return entry.value as T; + } + + /** Drop all cached query results. */ + invalidateCache(): void { + this.cacheStats.evictions += this.cache.size; + this.cache.clear(); + } + + /** Diagnostic stats for telemetry / health endpoints. */ + getStats(): SharedMemoryPoolStats { + return { + initialized: this.db !== null && this.embedder !== null, + dbPath: this.options.dbPath, + embeddingModel: this.options.embeddingModel, + embeddingDimension: this.options.embeddingDimension, + cache: { + entries: this.cache.size, + hits: this.cacheStats.hits, + misses: this.cacheStats.misses, + evictions: this.cacheStats.evictions, + }, + }; + } + + /** Close the underlying database handle and clear cached state. */ + close(): void { + try { + this.db?.close?.(); + } catch { + /* swallow — closing twice is benign */ + } + this.db = null; + this.embedder = null; + this.initPromise = null; + this.cache.clear(); + this.cacheStats = { hits: 0, misses: 0, evictions: 0 }; + } +} + +async function loadBetterSqlite3(): Promise { + // better-sqlite3 is a heavy native module; load it lazily so the rest of the + // package can be imported even when this optional dep is unavailable. + try { + const mod: any = await import('better-sqlite3'); + return mod.default ?? mod; + } catch (err: any) { + throw new Error( + `SharedMemoryPool requires 'better-sqlite3' but it could not be loaded: ${err?.message || err}. ` + + `Install it with: npm install better-sqlite3` + ); + } +} diff --git a/agentic-flow/src/optimizations/agent-booster-migration.ts b/agentic-flow/src/optimizations/agent-booster-migration.ts index a239797b7..af6a7d7b7 100644 --- a/agentic-flow/src/optimizations/agent-booster-migration.ts +++ b/agentic-flow/src/optimizations/agent-booster-migration.ts @@ -9,9 +9,38 @@ * Impact: All code editing operations */ -import { readFileSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { AgentBooster as AgentBoosterEngine } from 'agent-booster'; +import { writeFileSync } from 'fs'; + +// agent-booster is an optional sibling package — keep typing local so +// `import 'agentic-flow'` succeeds without the package installed (#102). +type AgentBoosterEngine = { + apply(input: any): Promise<{ + success: boolean; + output: string; + latency: number; + confidence: number; + strategy: string; + }>; +}; + +let _AgentBoosterCtor: + | (new (opts: { confidenceThreshold?: number; maxChunks?: number }) => AgentBoosterEngine) + | null = null; + +async function loadAgentBoosterCtor() { + if (_AgentBoosterCtor) return _AgentBoosterCtor; + const mod: any = await import('agent-booster').catch((err: any) => { + throw new Error( + `Optional package 'agent-booster' is not installed: ${err?.message || err}. ` + + `Run: npm install agent-booster` + ); + }); + _AgentBoosterCtor = mod.AgentBooster ?? mod.default?.AgentBooster ?? mod.default; + if (!_AgentBoosterCtor) { + throw new Error("'agent-booster' loaded but does not export AgentBooster"); + } + return _AgentBoosterCtor; +} interface AgentBoosterConfig { enabled: boolean; @@ -45,7 +74,8 @@ interface EditResult { */ export class AgentBoosterMigration { private config: AgentBoosterConfig; - private boosterEngine: AgentBoosterEngine; + private boosterEngine: AgentBoosterEngine | null = null; + private boosterEnginePromise: Promise | null = null; private stats: { totalEdits: number; boosterEdits: number; @@ -61,29 +91,41 @@ export class AgentBoosterMigration { fallback: true, maxFileSize: 10 * 1024 * 1024, // 10MB supportedLanguages: [ - 'typescript', 'javascript', 'python', 'java', 'cpp', 'c', - 'rust', 'go', 'ruby', 'php', 'swift', 'kotlin', 'scala', - 'haskell', 'elixir', 'clojure', 'r', 'julia', 'dart' + 'typescript', + 'javascript', + 'python', + 'java', + 'cpp', + 'c', + 'rust', + 'go', + 'ruby', + 'php', + 'swift', + 'kotlin', + 'scala', + 'haskell', + 'elixir', + 'clojure', + 'r', + 'julia', + 'dart', ], performance: { targetSpeedupFactor: 352, - maxLatencyMs: 1 + maxLatencyMs: 1, }, - ...config + ...config, }; - // Initialize real Agent Booster engine - this.boosterEngine = new AgentBoosterEngine({ - confidenceThreshold: 0.5, - maxChunks: 100 - }); - + // Defer Agent Booster engine initialization until first use so the + // optional dependency can be loaded lazily. this.stats = { totalEdits: 0, boosterEdits: 0, traditionalEdits: 0, totalSavingsMs: 0, - costSavings: 0 + costSavings: 0, }; } @@ -130,18 +172,32 @@ export class AgentBoosterMigration { /** * Edit using Agent Booster (352x faster) */ + private async getBoosterEngine(): Promise { + if (this.boosterEngine) return this.boosterEngine; + if (!this.boosterEnginePromise) { + this.boosterEnginePromise = (async () => { + const Ctor = await loadAgentBoosterCtor(); + const engine = new Ctor({ confidenceThreshold: 0.5, maxChunks: 100 }); + this.boosterEngine = engine; + return engine; + })(); + } + return this.boosterEnginePromise; + } + private async editWithAgentBooster(edit: CodeEdit, startTime: number): Promise { try { const bytesProcessed = Buffer.byteLength(edit.newContent, 'utf8'); // Call REAL Agent Booster WASM engine - use any for flexible signature - const result = await this.boosterEngine.apply({ + const engine = await this.getBoosterEngine(); + const result = await engine.apply({ code: edit.oldContent, edit: edit.newContent, language: edit.language, target_filepath: edit.filePath || '', instructions: edit.newContent, - code_edit: edit.newContent + code_edit: edit.newContent, } as any); // Write the edit if successful @@ -155,7 +211,7 @@ export class AgentBoosterMigration { // Update stats this.stats.boosterEdits++; - this.stats.totalSavingsMs += (traditionalTime - executionTimeMs); + this.stats.totalSavingsMs += traditionalTime - executionTimeMs; this.stats.costSavings += this.calculateCostSavings(traditionalTime, executionTimeMs); return { @@ -163,7 +219,7 @@ export class AgentBoosterMigration { executionTimeMs, speedupFactor, method: 'agent-booster', - bytesProcessed + bytesProcessed, }; } catch (error) { // Fallback to traditional if enabled @@ -199,7 +255,7 @@ export class AgentBoosterMigration { executionTimeMs, speedupFactor: 1, method: 'traditional', - bytesProcessed + bytesProcessed, }; } @@ -236,19 +292,22 @@ export class AgentBoosterMigration { * Get current statistics */ getStats() { - const avgSpeedupFactor = this.stats.boosterEdits > 0 - ? 352 // Agent Booster constant speedup - : 1; + const avgSpeedupFactor = + this.stats.boosterEdits > 0 + ? 352 // Agent Booster constant speedup + : 1; - const monthlySavings = this.stats.costSavings > 0 - ? (this.stats.costSavings / this.stats.totalEdits) * 3000 // Assuming 3000 edits/month - : 0; + const monthlySavings = + this.stats.costSavings > 0 + ? (this.stats.costSavings / this.stats.totalEdits) * 3000 // Assuming 3000 edits/month + : 0; return { ...this.stats, avgSpeedupFactor, monthlySavings: monthlySavings.toFixed(2), - boosterAdoptionRate: ((this.stats.boosterEdits / this.stats.totalEdits) * 100).toFixed(1) + '%' + boosterAdoptionRate: + ((this.stats.boosterEdits / this.stats.totalEdits) * 100).toFixed(1) + '%', }; } @@ -296,7 +355,7 @@ export class AgentBoosterMigration { * Sleep helper */ private sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } /** @@ -304,9 +363,7 @@ export class AgentBoosterMigration { */ async batchEdit(edits: CodeEdit[]): Promise { // Process edits in parallel for maximum performance - const results = await Promise.all( - edits.map(edit => this.editCode(edit)) - ); + const results = await Promise.all(edits.map((edit) => this.editCode(edit))); return results; } @@ -326,7 +383,7 @@ export class AgentBoosterMigration { return { filesProcessed: 1000, totalSpeedup: 352, - estimatedMonthlySavings: 240 + estimatedMonthlySavings: 240, }; } } @@ -349,7 +406,7 @@ export async function editCode( filePath, oldContent, newContent, - language + language, }); } @@ -379,20 +436,20 @@ export async function exampleUsage() { filePath: '/tmp/file1.ts', oldContent: 'old1', newContent: 'new1', - language: 'typescript' + language: 'typescript', }, { filePath: '/tmp/file2.js', oldContent: 'old2', newContent: 'new2', - language: 'javascript' + language: 'javascript', }, { filePath: '/tmp/file3.py', oldContent: 'old3', newContent: 'new3', - language: 'python' - } + language: 'python', + }, ]; const batchResults = await agentBoosterMigration.batchEdit(batchEdits); diff --git a/agentic-flow/src/router/index.ts b/agentic-flow/src/router/index.ts index c39127d2b..2a259d87c 100644 --- a/agentic-flow/src/router/index.ts +++ b/agentic-flow/src/router/index.ts @@ -29,16 +29,11 @@ export type { MonitoringConfig, CacheConfig, RouterMetrics, - ProviderError + ProviderError, } from './types.js'; // Model mappings -export { - CLAUDE_MODELS, - mapModelId, - getModelName, - listModels -} from './model-mapping.js'; +export { CLAUDE_MODELS, mapModelId, getModelName, listModels } from './model-mapping.js'; export type { ModelMapping } from './model-mapping.js'; // Providers @@ -46,3 +41,4 @@ export { OpenRouterProvider } from './providers/openrouter.js'; export { AnthropicProvider } from './providers/anthropic.js'; export { GeminiProvider } from './providers/gemini.js'; export { ONNXLocalProvider } from './providers/onnx-local.js'; +export { OllamaProvider } from './providers/ollama.js'; diff --git a/agentic-flow/src/router/providers/ollama.ts b/agentic-flow/src/router/providers/ollama.ts new file mode 100644 index 000000000..06225485f --- /dev/null +++ b/agentic-flow/src/router/providers/ollama.ts @@ -0,0 +1,272 @@ +// Ollama provider - OpenAI-compatible chat completions +// Works with both ollama.com Cloud (requires OLLAMA_API_KEY) and self-hosted +// (typically http://localhost:11434, no API key required). +import axios, { AxiosInstance } from 'axios'; +import { + LLMProvider, + ChatParams, + ChatResponse, + StreamChunk, + ProviderConfig, + ProviderError, + ContentBlock, +} from '../types.js'; + +const DEFAULT_BASE_URL = 'http://localhost:11434'; + +export class OllamaProvider implements LLMProvider { + name = 'ollama'; + type = 'ollama' as const; + supportsStreaming = true; + supportsTools = true; + supportsMCP = false; // Requires translation + + private client: AxiosInstance; + private config: ProviderConfig; + + constructor(config: ProviderConfig) { + this.config = config; + + const baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''); + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // API key is optional for self-hosted Ollama; required for ollama.com Cloud + if (config.apiKey) { + headers['Authorization'] = `Bearer ${config.apiKey}`; + } + + this.client = axios.create({ + baseURL: baseUrl, + headers, + timeout: config.timeout || 180000, + }); + } + + validateCapabilities(features: string[]): boolean { + const supported = ['chat', 'streaming', 'tools']; + return features.every((f) => supported.includes(f)); + } + + async chat(params: ChatParams): Promise { + try { + const body = this.formatRequest(params, false); + const response = await this.client.post('/v1/chat/completions', body); + return this.formatResponse(response.data, params.model); + } catch (error: any) { + throw this.handleError(error); + } + } + + async *stream(params: ChatParams): AsyncGenerator { + try { + const body = this.formatRequest(params, true); + const response = await this.client.post('/v1/chat/completions', body, { + responseType: 'stream', + }); + + let buffer = ''; + for await (const chunk of response.data) { + buffer += chunk.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data: ')) continue; + const data = trimmed.slice(6); + if (data === '[DONE]') { + yield { type: 'message_stop' }; + return; + } + try { + const parsed = JSON.parse(data); + const out = this.formatStreamChunk(parsed); + if (out) yield out; + } catch { + // skip malformed chunks + } + } + } + } catch (error: any) { + throw this.handleError(error); + } + } + + private formatRequest(params: ChatParams, stream: boolean): any { + const messages = params.messages.map((msg) => ({ + role: msg.role, + content: + typeof msg.content === 'string' + ? msg.content + : msg.content + .map((block) => { + if (block.type === 'text') return block.text || ''; + if (block.type === 'tool_use') + return JSON.stringify({ + tool: block.name, + input: block.input, + }); + if (block.type === 'tool_result') + return typeof block.content === 'string' + ? block.content + : JSON.stringify(block.content); + return ''; + }) + .join('\n'), + })); + + const body: any = { + model: params.model, + messages, + temperature: params.temperature ?? 0.7, + stream, + }; + + if (params.maxTokens) { + body.max_tokens = params.maxTokens; + } + + if (params.tools && params.tools.length > 0) { + body.tools = params.tools.map((tool) => ({ + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.input_schema, + }, + })); + + if (params.toolChoice) { + if (params.toolChoice === 'auto' || params.toolChoice === 'none') { + body.tool_choice = params.toolChoice; + } else if (typeof params.toolChoice === 'object') { + body.tool_choice = { + type: 'function', + function: { name: params.toolChoice.name }, + }; + } + } + } + + return body; + } + + private formatResponse(data: any, model: string): ChatResponse { + const choice = data.choices?.[0]; + if (!choice) { + throw new Error(`Ollama returned no choices: ${JSON.stringify(data).slice(0, 200)}`); + } + + const message = choice.message ?? {}; + const content: ContentBlock[] = []; + + if (message.content) { + content.push({ type: 'text', text: String(message.content) }); + } + + if (Array.isArray(message.tool_calls)) { + for (const tc of message.tool_calls) { + const fnName = tc.function?.name; + const fnArgs = tc.function?.arguments; + if (!fnName) continue; + let parsedInput: unknown = {}; + if (typeof fnArgs === 'string') { + try { + parsedInput = JSON.parse(fnArgs); + } catch { + parsedInput = { raw: fnArgs }; + } + } else if (fnArgs && typeof fnArgs === 'object') { + parsedInput = fnArgs; + } + content.push({ + type: 'tool_use', + id: tc.id || `ollama-${Date.now()}-${content.length}`, + name: fnName, + input: parsedInput, + }); + } + } + + return { + id: data.id || `ollama-${Date.now()}`, + model: data.model || model, + content, + stopReason: this.mapFinishReason(choice.finish_reason), + usage: { + inputTokens: data.usage?.prompt_tokens ?? 0, + outputTokens: data.usage?.completion_tokens ?? 0, + }, + metadata: { + provider: 'ollama', + cost: 0, // self-hosted = free; cloud users track via their plan + latency: 0, // set by router + }, + }; + } + + private formatStreamChunk(data: any): StreamChunk | null { + const choice = data.choices?.[0]; + if (!choice) return null; + const delta = choice.delta; + + if (delta?.content) { + return { + type: 'content_block_delta', + delta: { type: 'text_delta', text: delta.content }, + }; + } + + if (delta?.tool_calls?.length) { + const tc = delta.tool_calls[0]; + return { + type: 'content_block_delta', + delta: { + type: 'input_json_delta', + partial_json: tc.function?.arguments || '', + }, + }; + } + + if (choice.finish_reason) { + return { + type: 'message_stop', + usage: data.usage + ? { + inputTokens: data.usage.prompt_tokens || 0, + outputTokens: data.usage.completion_tokens || 0, + } + : undefined, + }; + } + + return null; + } + + private mapFinishReason(reason?: string): ChatResponse['stopReason'] { + switch (reason) { + case 'stop': + return 'end_turn'; + case 'length': + return 'max_tokens'; + case 'tool_calls': + return 'tool_use'; + default: + return 'end_turn'; + } + } + + private handleError(error: any): ProviderError { + const wrapped = new Error( + `Ollama provider error: ${error?.response?.data?.error?.message || error.message}` + ) as ProviderError; + wrapped.provider = 'ollama'; + wrapped.statusCode = error?.response?.status; + // Network / 5xx are retryable; 4xx (other than 429) are not + const status = wrapped.statusCode ?? 0; + wrapped.retryable = !status || status >= 500 || status === 429; + return wrapped; + } +} diff --git a/agentic-flow/src/router/router.ts b/agentic-flow/src/router/router.ts index d94694f7c..3a052935f 100644 --- a/agentic-flow/src/router/router.ts +++ b/agentic-flow/src/router/router.ts @@ -10,12 +10,13 @@ import { StreamChunk, ProviderType, RouterMetrics, - ProviderError + ProviderError, } from './types.js'; import { OpenRouterProvider } from './providers/openrouter.js'; import { AnthropicProvider } from './providers/anthropic.js'; import { ONNXLocalProvider } from './providers/onnx-local.js'; import { GeminiProvider } from './providers/gemini.js'; +import { OllamaProvider } from './providers/ollama.js'; export class ModelRouter { private config: RouterConfig; @@ -35,7 +36,7 @@ export class ModelRouter { join(homedir(), '.agentic-flow', 'router.config.json'), join(process.cwd(), 'router.config.json'), join(process.cwd(), 'config', 'router.config.json'), - join(process.cwd(), 'router.config.example.json') + join(process.cwd(), 'router.config.example.json'), ].filter(Boolean) as string[]; for (const path of paths) { @@ -58,14 +59,14 @@ export class ModelRouter { version: '1.0', defaultProvider: (process.env.PROVIDER as any) || 'anthropic', routing: { mode: 'manual' as const }, - providers: {} as any + providers: {} as any, }; // Add Anthropic if API key exists if (process.env.ANTHROPIC_API_KEY) { config.providers.anthropic = { apiKey: process.env.ANTHROPIC_API_KEY, - baseUrl: process.env.ANTHROPIC_BASE_URL + baseUrl: process.env.ANTHROPIC_BASE_URL, }; } @@ -73,21 +74,29 @@ export class ModelRouter { if (process.env.OPENROUTER_API_KEY) { config.providers.openrouter = { apiKey: process.env.OPENROUTER_API_KEY, - baseUrl: process.env.OPENROUTER_BASE_URL + baseUrl: process.env.OPENROUTER_BASE_URL, }; } // Add Gemini if API key exists if (process.env.GOOGLE_GEMINI_API_KEY) { config.providers.gemini = { - apiKey: process.env.GOOGLE_GEMINI_API_KEY + apiKey: process.env.GOOGLE_GEMINI_API_KEY, + }; + } + + // Add Ollama if API key OR explicit base URL is set (self-hosted needs no key) + if (process.env.OLLAMA_API_KEY || process.env.OLLAMA_BASE_URL) { + config.providers.ollama = { + apiKey: process.env.OLLAMA_API_KEY, + baseUrl: process.env.OLLAMA_BASE_URL, }; } // ONNX is always available (no API key needed) config.providers.onnx = { modelPath: process.env.ONNX_MODEL_PATH, - executionProviders: ['cpu'] + executionProviders: ['cpu'], }; return config as RouterConfig; @@ -103,7 +112,7 @@ export class ModelRouter { } if (Array.isArray(obj)) { - return obj.map(item => this.substituteEnvVars(item)); + return obj.map((item) => this.substituteEnvVars(item)); } if (obj && typeof obj === 'object') { @@ -146,10 +155,12 @@ export class ModelRouter { if (this.config.providers.onnx) { try { const provider = new ONNXLocalProvider({ - modelPath: this.config.providers.onnx.modelPath || './models/phi-4/cpu_and_mobile/cpu-int4-rtn-block-32-acc-level-4/model.onnx', + modelPath: + this.config.providers.onnx.modelPath || + './models/phi-4/cpu_and_mobile/cpu-int4-rtn-block-32-acc-level-4/model.onnx', executionProviders: this.config.providers.onnx.executionProviders || ['cpu'], maxTokens: this.config.providers.onnx.maxTokens || 100, - temperature: this.config.providers.onnx.temperature || 0.7 + temperature: this.config.providers.onnx.temperature || 0.7, }); this.providers.set('onnx', provider); if (verbose) console.log('āœ… ONNX Local provider initialized'); @@ -169,8 +180,16 @@ export class ModelRouter { } } - // TODO: Initialize other providers (OpenAI, Ollama, LiteLLM) - // Will be implemented in Phase 1 + // Initialize Ollama (OpenAI-compat, self-hosted or ollama.com Cloud) + if (this.config.providers.ollama) { + try { + const provider = new OllamaProvider(this.config.providers.ollama); + this.providers.set('ollama', provider); + if (verbose) console.log('āœ… Ollama provider initialized'); + } catch (error) { + if (verbose) console.error('āŒ Failed to initialize Ollama:', error); + } + } } private initializeMetrics(): RouterMetrics { @@ -179,7 +198,7 @@ export class ModelRouter { totalCost: 0, totalTokens: { input: 0, output: 0 }, providerBreakdown: {}, - agentBreakdown: {} + agentBreakdown: {}, }; } @@ -197,7 +216,7 @@ export class ModelRouter { response.metadata = { ...response.metadata, provider: provider.name, - latency: Date.now() - startTime + latency: Date.now() - startTime, }; return response; @@ -213,13 +232,9 @@ export class ModelRouter { throw new Error(`Provider ${provider.name} does not support streaming`); } - try { - const iterator = provider.stream(params); - for await (const chunk of iterator) { - yield chunk; - } - } catch (error) { - throw error; + const iterator = provider.stream(params); + for await (const chunk of iterator) { + yield chunk; } } @@ -230,7 +245,9 @@ export class ModelRouter { if (forcedProvider) { return forcedProvider; } - console.warn(`āš ļø Requested provider '${params.provider}' not available, falling back to routing logic`); + console.warn( + `āš ļø Requested provider '${params.provider}' not available, falling back to routing logic` + ); } const routingMode = this.config.routing?.mode || 'manual'; @@ -383,14 +400,15 @@ export class ModelRouter { requests: 0, cost: 0, avgLatency: 0, - errors: 0 + errors: 0, }; } const breakdown = this.metrics.providerBreakdown[providerName]; breakdown.requests++; breakdown.cost += response.metadata?.cost || 0; - breakdown.avgLatency = (breakdown.avgLatency * (breakdown.requests - 1) + latency) / breakdown.requests; + breakdown.avgLatency = + (breakdown.avgLatency * (breakdown.requests - 1) + latency) / breakdown.requests; // Agent breakdown if (agentType) { diff --git a/agentic-flow/tests/issue-fixes.test.ts b/agentic-flow/tests/issue-fixes.test.ts new file mode 100644 index 000000000..e1d35724e --- /dev/null +++ b/agentic-flow/tests/issue-fixes.test.ts @@ -0,0 +1,280 @@ +/** + * Verification tests for the GitHub issues fixed across commits c0ce2ba and + * 88b86f7. Each describe() block targets a specific issue number and + * exercises the public observable behaviour the issue called out. + * + * These tests run with `vitest` from the agentic-flow directory: + * cd agentic-flow && npx vitest run tests/issue-fixes.test.ts + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { execSync } from 'node:child_process'; + +const REPO_ROOT = resolve(__dirname, '../..'); +const PKG_INNER = resolve(__dirname, '..'); + +describe('issue #145: protobufjs CVE override', () => { + it('root package.json declares an overrides entry forcing protobufjs >= 7.5.5', () => { + const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf-8')); + expect(pkg.overrides).toBeDefined(); + expect(pkg.overrides.protobufjs).toBeDefined(); + // Accept either a min-spec like ">=7.5.5" or an explicit safe version. + const ver = String(pkg.overrides.protobufjs); + expect(/(>=\s*7\.5\.5|^7\.|^8\.|^9\.)/.test(ver)).toBe(true); + }); + + it('inner agentic-flow/package.json also declares the override', () => { + const pkg = JSON.parse(readFileSync(join(PKG_INNER, 'package.json'), 'utf-8')); + expect(pkg.overrides).toBeDefined(); + expect(pkg.overrides.protobufjs).toBeDefined(); + }); + + it('npm ls protobufjs reports no vulnerable (<7.5.5) versions', () => { + let out = ''; + try { + out = execSync('npm ls protobufjs --all 2>/dev/null || true', { + cwd: REPO_ROOT, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + }); + } catch { + // npm ls exits non-zero on extraneous deps; the output is what we want. + } + // Find every "protobufjs@" mention and assert each is >=7.5.5. + const matches = out.match(/protobufjs@[\d.]+/g) ?? []; + expect(matches.length).toBeGreaterThan(0); + for (const m of matches) { + const v = m.split('@')[1]; + const [maj, min, patch] = v.split('.').map((s) => parseInt(s, 10)); + const ok = + maj > 7 || (maj === 7 && (min > 5 || (min === 5 && patch >= 5))); + expect(ok, `Found vulnerable ${m}`).toBe(true); + } + }); +}); + +describe('issue #146 Gap 1: Ollama provider whitelist', () => { + it('config-wizard.ts source whitelists OLLAMA_API_KEY and OLLAMA_BASE_URL', () => { + const src = readFileSync(join(PKG_INNER, 'src/cli/config-wizard.ts'), 'utf-8'); + expect(src).toMatch(/OLLAMA_API_KEY/); + expect(src).toMatch(/OLLAMA_BASE_URL/); + }); + + it('config-wizard.ts validates PROVIDER=ollama', () => { + const src = readFileSync(join(PKG_INNER, 'src/cli/config-wizard.ts'), 'utf-8'); + // Look for the provider validator that includes 'ollama' + expect(src).toMatch(/['"]ollama['"]/); + }); + + it('Ollama provider is exported from the router barrel', async () => { + const mod: any = await import('../src/router/index.js'); + expect(mod.OllamaProvider).toBeDefined(); + expect(typeof mod.OllamaProvider).toBe('function'); + }); + + it('OllamaProvider type marker is "ollama"', async () => { + const { OllamaProvider } = await import('../src/router/providers/ollama.js'); + const p = new OllamaProvider({ baseUrl: 'http://localhost:11434' }); + expect(p.name).toBe('ollama'); + expect(p.type).toBe('ollama'); + expect(p.supportsStreaming).toBe(true); + }); +}); + +describe('issue #146 Gap 2: agentdb controller prerequisites registry', () => { + it('exports the registry from agentic-flow/agentdb', async () => { + const mod: any = await import('../src/agentdb/index.js'); + expect(Array.isArray(mod.controllerPrerequisites)).toBe(true); + expect(mod.controllerPrerequisites.length).toBeGreaterThan(10); + expect(typeof mod.getControllerPrerequisite).toBe('function'); + expect(typeof mod.filterBySafety).toBe('function'); + }); + + it('every entry has the documented shape', async () => { + const { controllerPrerequisites } = await import('../src/agentdb/index.js'); + for (const c of controllerPrerequisites) { + expect(typeof c.name).toBe('string'); + expect(c.name.length).toBeGreaterThan(0); + expect(Array.isArray(c.requirements)).toBe(true); + expect(Array.isArray(c.optional)).toBe(true); + expect(typeof c.arity).toBe('number'); + expect(['pure', 'opens-resource', 'opens-network']).toContain(c.safety); + expect(typeof c.description).toBe('string'); + } + }); + + it('noArgControllers contains only zero-requirement entries', async () => { + const { noArgControllers, controllerPrerequisites } = await import('../src/agentdb/index.js'); + expect(noArgControllers.length).toBeGreaterThan(0); + for (const c of noArgControllers) { + expect(c.requirements.length).toBe(0); + } + // And it actually filters from the parent list (no extras). + for (const c of noArgControllers) { + expect(controllerPrerequisites.find((x: any) => x.name === c.name)).toBeDefined(); + } + }); + + it('filterBySafety("pure") only returns pure controllers', async () => { + const { filterBySafety } = await import('../src/agentdb/index.js'); + const pure = filterBySafety(['pure']); + expect(pure.length).toBeGreaterThan(0); + for (const c of pure) expect(c.safety).toBe('pure'); + }); + + it('getControllerPrerequisite returns null for unknown names', async () => { + const { getControllerPrerequisite } = await import('../src/agentdb/index.js'); + expect(getControllerPrerequisite('NotARealController')).toBeNull(); + }); +}); + +describe('issue #102 / #110: top-level import is library-safe', () => { + it('subprocess `import("agentic-flow")` resolves and exits cleanly', () => { + // This catches both #102 (missing dep) and #110 (auto-running CLI). The + // child must exit on its own; if main() ran, it would block on + // healthServer / agent execution and the child would never exit. + // + // The probe must live inside REPO_ROOT so Node can resolve the + // `agentic-flow` bare specifier via the repo's node_modules. + const probeDir = join(REPO_ROOT, '.tmp-import-probe'); + try { + execSync(`mkdir -p ${JSON.stringify(probeDir)}`); + const scriptPath = join(probeDir, 'probe.mjs'); + writeFileSync( + scriptPath, + ` + const t0 = Date.now(); + import('agentic-flow').then((m) => { + const ms = Date.now() - t0; + const exposed = Object.keys(m).join(','); + process.stdout.write('OK ' + ms + ' ' + exposed); + setTimeout(() => process.exit(0), 100); + }).catch((e) => { + process.stderr.write('FAIL ' + e.message); + process.exit(2); + }); + `, + 'utf-8', + ); + const out = execSync(`node ${JSON.stringify(scriptPath)}`, { + cwd: REPO_ROOT, + encoding: 'utf-8', + timeout: 20000, + }); + expect(out).toMatch(/^OK \d+ /); + // We expect at least the `main` export to be present. + expect(out).toMatch(/main/); + } finally { + rmSync(probeDir, { recursive: true, force: true }); + } + }); + + it('SharedMemoryPool source exists and exports the singleton API', () => { + const src = readFileSync(join(PKG_INNER, 'src/memory/SharedMemoryPool.ts'), 'utf-8'); + expect(src).toMatch(/class SharedMemoryPool/); + expect(src).toMatch(/static\s+getInstance/); + expect(src).toMatch(/getDatabase\(/); + expect(src).toMatch(/getEmbedder\(/); + expect(src).toMatch(/ensureInitialized\(/); + }); + + it('main entrypoint guards CLI execution with isCliEntry()', () => { + const src = readFileSync(join(PKG_INNER, 'src/index.ts'), 'utf-8'); + expect(src).toMatch(/isCliEntry/); + // The unconditional `main().catch(...)` invocation should be gone — i.e. + // the only `main()` call site is now inside the `if (isCliEntry())` guard. + // We assert the guard is present and `main()` is not called outside it. + const guardedCallRe = /if\s*\(\s*isCliEntry\(\)\s*\)\s*\{[^}]*main\(\)/s; + expect(src).toMatch(guardedCallRe); + }); + + it('agent-booster imports are dynamic / lazy in the three callsites', () => { + const files = [ + 'src/mcp/claudeFlowSdkServer.ts', + 'src/mcp/tools/agent-booster-tools.ts', + 'src/optimizations/agent-booster-migration.ts', + ]; + for (const f of files) { + const src = readFileSync(join(PKG_INNER, f), 'utf-8'); + // No top-level `import { ... } from 'agent-booster'` lines + expect(src).not.toMatch(/^\s*import\s+\{[^}]*\}\s+from\s+['"]agent-booster['"]/m); + // But there IS a dynamic `import('agent-booster')` + expect(src).toMatch(/import\(\s*['"]agent-booster['"]\s*\)/); + } + }); +}); + +describe('issue #128 / #129: ReflexionMemory persistence + rebuild', () => { + it('ReflexionMemory.ts source exposes dualWriteEpisodeToSQL helper', () => { + const src = readFileSync( + join(REPO_ROOT, 'packages/agentdb/src/controllers/ReflexionMemory.ts'), + 'utf-8', + ); + expect(src).toMatch(/dualWriteEpisodeToSQL/); + // Both graph code paths invoke it after their primary write. + const calls = src.match(/this\.dualWriteEpisodeToSQL\(/g) ?? []; + expect(calls.length).toBeGreaterThanOrEqual(2); + }); + + it('ReflexionMemory exposes a public rebuildIndex() method', () => { + const src = readFileSync( + join(REPO_ROOT, 'packages/agentdb/src/controllers/ReflexionMemory.ts'), + 'utf-8', + ); + expect(src).toMatch(/async\s+rebuildIndex\s*\(/); + expect(src).toMatch(/from episodes/i); + expect(src).toMatch(/episode_embeddings/); + }); + + it('rebuildIndex re-inserts through the public vectorBackend API, not getInner()', () => { + // The whole point of #129's fix is to NOT call inner.insert(). Verify + // the rebuildIndex path uses vectorBackend.insert(...) directly. + const src = readFileSync( + join(REPO_ROOT, 'packages/agentdb/src/controllers/ReflexionMemory.ts'), + 'utf-8', + ); + // Find the rebuildIndex method body and check usage inside it. + const rebuildBody = src.split(/async\s+rebuildIndex\s*\(/)[1] ?? ''; + expect(rebuildBody).toMatch(/this\.vectorBackend\.insert/); + expect(rebuildBody).not.toMatch(/getInner\(\)\.insert/); + }); +}); + +describe('issue #118 / #119: GNN RuvectorLayer pre-validation', () => { + it('RuvectorLayer constructor rejects non-integer inputDim', async () => { + const { RuvectorLayer } = await import('../src/core/gnn-wrapper.js'); + expect(() => new (RuvectorLayer as any)([1, 2, 3], 4)).toThrow(/inputDim/); + expect(() => new (RuvectorLayer as any)('foo', 4)).toThrow(/inputDim/); + expect(() => new (RuvectorLayer as any)(0, 4)).toThrow(/inputDim/); + expect(() => new (RuvectorLayer as any)(-1, 4)).toThrow(/inputDim/); + expect(() => new (RuvectorLayer as any)(1.5, 4)).toThrow(/inputDim/); + }); + + it('RuvectorLayer constructor rejects bad outputDim and activation', async () => { + const { RuvectorLayer } = await import('../src/core/gnn-wrapper.js'); + expect(() => new (RuvectorLayer as any)(4, 'bad')).toThrow(/outputDim/); + expect(() => new (RuvectorLayer as any)(4, -2)).toThrow(/outputDim/); + expect(() => new (RuvectorLayer as any)(4, 4, 'banana' as any)).toThrow(/activation/); + }); + + it('RuvectorLayer constructs cleanly with valid args', async () => { + const { RuvectorLayer } = await import('../src/core/gnn-wrapper.js'); + const layer = new RuvectorLayer(8, 4, 'relu'); + expect(layer.getWeights().length).toBe(4); + expect(layer.getWeights()[0].length).toBe(8); + expect(layer.forward([1, 0, 0, 0, 0, 0, 0, 0]).length).toBe(4); + }); + + it('agentdb-wrapper-enhanced pre-validates EMBEDDING_DIM % numHeads invariant', () => { + const src = readFileSync( + join(PKG_INNER, 'src/core/agentdb-wrapper-enhanced.ts'), + 'utf-8', + ); + // Look for the divisibility check we added. + expect(src).toMatch(/% numHeads !== 0|% numHeads\s*!==\s*0/); + // And the matching error message points users at the right config knobs. + expect(src).toMatch(/RuvectorLayer would panic|must both be divisible/); + }); +}); diff --git a/package-lock.json b/package-lock.json index e59b672da..52f544652 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1123,14 +1123,10 @@ "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">=18" } @@ -1302,30 +1298,6 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "optional": true }, - "node_modules/@grpc/proto-loader/node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", - "hasInstallScript": true, - "optional": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/@grpc/proto-loader/node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -1433,13 +1405,9 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "funding": { "url": "https://opencollective.com/libvips" } @@ -1448,13 +1416,9 @@ "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3424,36 +3388,6 @@ "node": ">=14" } }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "optional": true - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", - "hasInstallScript": true, - "optional": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/@opentelemetry/propagation-utils": { "version": "0.30.16", "resolved": "https://registry.npmjs.org/@opentelemetry/propagation-utils/-/propagation-utils-0.30.16.tgz", @@ -4038,70 +3972,6 @@ "url": "https://opencollective.com/pkgr" } }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.43", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", @@ -4112,27 +3982,19 @@ "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@ruvector/attention": { "version": "0.1.4", @@ -4151,73 +4013,49 @@ "version": "0.1.31", "resolved": "https://registry.npmjs.org/@ruvector/attention-darwin-arm64/-/attention-darwin-arm64-0.1.31.tgz", "integrity": "sha512-9gOug/T0q5OCR65g1AKxWaJSTqTMa7kSAFH1sH2VeoNzqWr++EEOTE68vtajYSpNvM+r8788ehApCqjVW5mhHA==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "optional": true, - "os": [ - "darwin" - ] + "os": ["darwin"] }, "node_modules/@ruvector/attention-darwin-x64": { "version": "0.1.31", "resolved": "https://registry.npmjs.org/@ruvector/attention-darwin-x64/-/attention-darwin-x64-0.1.31.tgz", "integrity": "sha512-nBxQYRPU3+Z3Q8Qq4BFr/NTgw9QKjoh04vqYzECfJQJyyleIpDPWJpCwU3nhDy+4yMLdBQpQa8HYr2/C9rHCnQ==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "darwin" - ] + "os": ["darwin"] }, "node_modules/@ruvector/attention-linux-x64-gnu": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@ruvector/attention-linux-x64-gnu/-/attention-linux-x64-gnu-0.1.4.tgz", "integrity": "sha512-PVztbN8/BpFfaskmy9CtVylJ5/YhLfTQrh1ipuOVihmkqs0IQO5rXUOhZ5HhQ998id/MAFf/qf72TouqXRtS8w==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@ruvector/attention-win32-x64-msvc": { "version": "0.1.31", "resolved": "https://registry.npmjs.org/@ruvector/attention-win32-x64-msvc/-/attention-win32-x64-msvc-0.1.31.tgz", "integrity": "sha512-eRtFASeJhWCiYC+Lcyg1pwQV+ky6PciVj+o56BchpA+36SLEvYPziIAaJ8KYaMp3zVAFadNiM3DYCmvjxB/n6A==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "win32" - ] + "os": ["win32"] }, "node_modules/@ruvector/attention/node_modules/@ruvector/attention-darwin-x64": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@ruvector/attention-darwin-x64/-/attention-darwin-x64-0.1.4.tgz", "integrity": "sha512-JKpYFtHPlXrxNtOWfQiMLGJzOzBxZS7fmLzcesOl9/VNQKlCLkC8JB0SCsA0ZlION2CFBJhburYg9j0knWgWqw==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "darwin" - ] + "os": ["darwin"] }, "node_modules/@ruvector/attention/node_modules/@ruvector/attention-win32-x64-msvc": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@ruvector/attention-win32-x64-msvc/-/attention-win32-x64-msvc-0.1.4.tgz", "integrity": "sha512-DZOrU2J1dKphv1vJyce3pvx0RY6asEGU2Er7xkxQIBpckyEhBGKV5P2tuFbv0M1IzQ1FNQxRX8uv1bpgmJIA+g==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "win32" - ] + "os": ["win32"] }, "node_modules/@ruvector/core": { "version": "0.1.30", @@ -4255,13 +4093,9 @@ "version": "0.1.23", "resolved": "https://registry.npmjs.org/@ruvector/gnn-linux-x64-gnu/-/gnn-linux-x64-gnu-0.1.23.tgz", "integrity": "sha512-q4PaqwVW0LIDHfqL05z8KV+0+uFZRI10/82fTCKnQutZNJkCITl2oetBbmtOuA5JxEp3hQJgsOnZOe0eXdw2uQ==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10" } @@ -4270,13 +4104,9 @@ "version": "0.1.23", "resolved": "https://registry.npmjs.org/@ruvector/gnn-linux-x64-musl/-/gnn-linux-x64-musl-0.1.23.tgz", "integrity": "sha512-cxw3HleWD8i2hQpvoXuARuDyZiwksOp3eGdaxEjJUf9GiMi7edL5PsSTiXATy4wCvprM04IUJir2ave/ar/zPg==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10" } @@ -4300,13 +4130,9 @@ "version": "0.1.15", "resolved": "https://registry.npmjs.org/@ruvector/graph-node-linux-x64-gnu/-/graph-node-linux-x64-gnu-0.1.15.tgz", "integrity": "sha512-k2mSf7hymGTTVi34f0/Nsbf3BBZerLAYcgzr1RQQJKPe2u2pMCBBxQt8lFUfUGXcbDNR2l+5w7K4IXx5X8YBSg==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 18" } @@ -4330,13 +4156,9 @@ "version": "0.1.15", "resolved": "https://registry.npmjs.org/@ruvector/router-linux-x64-gnu/-/router-linux-x64-gnu-0.1.15.tgz", "integrity": "sha512-dhx6zy/V82TMsyU8BFl9jaaWB+2Q8KJhjBilUKxjg0qRiTX1VHC+LPl9Y5StAD9S3/aGKmAgX2ceJozfRlfxzg==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">=18.0.0" } @@ -4368,13 +4190,9 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/@ruvector/ruvllm-linux-x64-gnu/-/ruvllm-linux-x64-gnu-0.2.0.tgz", "integrity": "sha512-8JHH7ft00rqCRyiP0wuAThOVwSodj0gaTZcG1jBBqtnwApmdmTWN8EpuffrHHhhRO+Uvny6T5r7TiAh+Xu+QuQ==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 18" } @@ -4495,25 +4313,17 @@ "version": "0.1.5", "resolved": "https://registry.npmjs.org/@ruvector/sona-linux-x64-gnu/-/sona-linux-x64-gnu-0.1.5.tgz", "integrity": "sha512-fmYlrJx9IQBHxeNX2Tu0ihHr3xsb3NwhgMmasOTOHBv/42fnxuuYrwhfHtMA6J6JtjyVxoBw1XLD6QJy4Y9BsA==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@ruvector/sona-linux-x64-musl": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/@ruvector/sona-linux-x64-musl/-/sona-linux-x64-musl-0.1.5.tgz", "integrity": "sha512-q0hzfilUUCQNsx2QTZ0H07FKuwdfaxh7mNuv01oq5fGCC/0beBENiL5UKxm0YHtTmMPNze83Vwp1fGT9ObOQ3g==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", @@ -4692,14 +4502,10 @@ "version": "1.13.5", "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.5.tgz", "integrity": "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "license": "Apache-2.0 AND MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">=10" } @@ -4708,14 +4514,10 @@ "version": "1.13.5", "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.5.tgz", "integrity": "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "license": "Apache-2.0 AND MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">=10" } @@ -5043,12 +4845,6 @@ "@types/koa": "*" } }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT" - }, "node_modules/@types/memcached": { "version": "2.2.10", "resolved": "https://registry.npmjs.org/@types/memcached/-/memcached-2.2.10.tgz", @@ -6825,9 +6621,7 @@ "node_modules/browserify/node_modules/concat-stream": { "version": "1.6.2", "dev": true, - "engines": [ - "node >= 0.8" - ], + "engines": ["node >= 0.8"], "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -7409,9 +7203,7 @@ "node_modules/concat-stream": { "version": "2.0.0", "dev": true, - "engines": [ - "node >= 6.0" - ], + "engines": ["node >= 6.0"], "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -10426,9 +10218,7 @@ "node_modules/insert-module-globals/node_modules/concat-stream": { "version": "1.6.2", "dev": true, - "engines": [ - "node >= 0.8" - ], + "engines": ["node >= 0.8"], "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -11599,9 +11389,7 @@ "node_modules/jsonparse": { "version": "1.3.1", "dev": true, - "engines": [ - "node >= 0.2.0" - ], + "engines": ["node >= 0.2.0"], "license": "MIT" }, "node_modules/JSONStream": { @@ -12531,9 +12319,7 @@ "node_modules/module-deps/node_modules/concat-stream": { "version": "1.6.2", "dev": true, - "engines": [ - "node >= 0.8" - ], + "engines": ["node >= 0.8"], "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -13006,11 +12792,7 @@ "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==", "license": "MIT", "optional": true, - "os": [ - "win32", - "darwin", - "linux" - ], + "os": ["win32", "darwin", "linux"], "dependencies": { "onnxruntime-common": "~1.14.0" } @@ -13725,31 +13507,25 @@ } }, "node_modules/protobufjs": { - "version": "6.11.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz", - "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.3.tgz", + "integrity": "sha512-LBYnMWkKLB8fE/ljROPDbCl7mgLSlI+oBe1fAAr5MTqFg4TIi0tYrVVurJvQggOjnUYMQtEZBjrej59ojMNTHQ==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.1", "@types/node": ">=13.7.0", - "long": "^4.0.0" + "long": "^5.0.0" }, - "bin": { - "pbjs": "bin/pbjs", - "pbts": "bin/pbts" + "engines": { + "node": ">=12.0.0" } }, + "node_modules/protobufjs/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -14359,13 +14135,9 @@ "version": "0.1.29", "resolved": "https://registry.npmjs.org/ruvector-core-linux-x64-gnu/-/ruvector-core-linux-x64-gnu-0.1.29.tgz", "integrity": "sha512-GcYCVNRbAmiXmEaMNvVnA78ZIM469H0VwP2JFGfibwiSASBgWGX3BT+mqflX+gtBynbtQqslj8XcqVdDxEkEBg==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/ruvector/node_modules/cli-cursor": { "version": "3.1.0", diff --git a/package.json b/package.json index 953d7d7d6..fbe4e94f4 100644 --- a/package.json +++ b/package.json @@ -275,5 +275,8 @@ "@ruvector/attention-darwin-x64": "^0.1.1", "@ruvector/attention-linux-x64-gnu": "^0.1.1", "@ruvector/attention-win32-x64-msvc": "^0.1.1" + }, + "overrides": { + "protobufjs": ">=7.5.5" } } diff --git a/packages/agentdb/src/controllers/ReflexionMemory.ts b/packages/agentdb/src/controllers/ReflexionMemory.ts index 88343f41a..106f87c2a 100644 --- a/packages/agentdb/src/controllers/ReflexionMemory.ts +++ b/packages/agentdb/src/controllers/ReflexionMemory.ts @@ -76,6 +76,13 @@ export class ReflexionMemory { /** * Store a new episode with its critique and outcome * Invalidates relevant cache entries + * + * Persistence guarantee (issue #128 fix): + * storeEpisode() always writes to the SQLite `episodes` and + * `episode_embeddings` tables when a SQL-capable connection is present, + * even when a graph or vector backend handles the primary index. This + * ensures data survives process restarts (graph/vector backends are + * often in-memory or rebuildable from SQL). */ async storeEpisode(episode: Episode): Promise { // Invalidate episode caches on write @@ -113,6 +120,10 @@ export class ReflexionMemory { // Register mapping for later use by CausalMemoryGraph NodeIdMapper.getInstance().register(numericId, nodeId); + // #128: dual-write to SQL for restart-safe persistence. Graph adapter + // index is typically in-memory; SQL is the durable record. + this.dualWriteEpisodeToSQL(episode, taskEmbedding); + return numericId; } @@ -151,6 +162,9 @@ export class ReflexionMemory { // Register mapping for later use by CausalMemoryGraph NodeIdMapper.getInstance().register(numericId, nodeId); + // #128: dual-write to SQL for restart-safe persistence. + this.dualWriteEpisodeToSQL(episode, taskEmbedding); + return numericId; } @@ -1111,4 +1125,174 @@ export class ReflexionMemory { } }); } + + /** + * Dual-write helper for issue #128. + * + * Inserts the given episode into the SQLite `episodes` and + * `episode_embeddings` tables when the underlying connection supports it. + * Used when a graph or vector backend handles the primary index but the + * caller still wants restart-safe persistence. + * + * Errors are swallowed and logged — the primary write to the graph/vector + * backend has already succeeded, and we don't want to fail the caller if + * SQL persistence is unavailable (e.g. read-only DB, schema not present). + */ + private dualWriteEpisodeToSQL(episode: Episode, embedding?: Float32Array): void { + if (!this.db || typeof this.db.prepare !== 'function') return; + try { + const stmt = this.db.prepare(` + INSERT INTO episodes ( + session_id, task, input, output, critique, reward, success, + latency_ms, tokens_used, tags, metadata + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const tags = episode.tags ? JSON.stringify(episode.tags) : null; + const metadata = episode.metadata ? JSON.stringify(episode.metadata) : null; + const result = stmt.run( + episode.sessionId, + episode.task, + episode.input ?? null, + episode.output ?? null, + episode.critique ?? null, + episode.reward, + episode.success ? 1 : 0, + episode.latencyMs ?? null, + episode.tokensUsed ?? null, + tags, + metadata + ); + if (embedding) { + const episodeId = normalizeRowId(result.lastInsertRowid); + this.storeEmbedding(episodeId, embedding); + } + } catch (err) { + // The expected failure here is "no such table: episodes" on databases + // that intentionally don't carry the v1 schema. Don't escalate — the + // primary backend already has the data. + const msg = err instanceof Error ? err.message : String(err); + if (!/no such table/i.test(msg)) { + // eslint-disable-next-line no-console + console.warn('[ReflexionMemory] dualWriteEpisodeToSQL failed:', msg); + } + } + } + + /** + * Rebuild the in-memory vector index from the SQLite `episodes` / + * `episode_embeddings` tables, going through the proper backend channels + * so `retrieveRelevant()` sees the data afterwards. + * + * Fixes the user's recovery use-case from issue #129: rather than calling + * `vectorBackend.getInner().insert()` directly (which bypasses the + * GuardedBackend wrapper and confuses retrieveRelevant), call this method + * to re-hydrate the vector and graph indices from durable storage. + * + * Returns the number of episodes re-indexed. No-ops cleanly when the SQL + * tables are empty or absent. + */ + async rebuildIndex(options: { fromTimestamp?: number } = {}): Promise { + if (!this.db || typeof this.db.prepare !== 'function') return 0; + + const where = options.fromTimestamp !== undefined ? 'WHERE e.ts >= ?' : ''; + const params: any[] = options.fromTimestamp !== undefined ? [options.fromTimestamp] : []; + let rows: any[] = []; + try { + const stmt = this.db.prepare(` + SELECT + e.id, e.ts, e.session_id, e.task, e.input, e.output, e.critique, + e.reward, e.success, e.latency_ms, e.tokens_used, e.tags, e.metadata, + ee.embedding + FROM episodes e + LEFT JOIN episode_embeddings ee ON ee.episode_id = e.id + ${where} + ORDER BY e.id ASC + `); + rows = stmt.all(...params) as any[]; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/no such table/i.test(msg)) return 0; + throw err; + } + + let reindexed = 0; + for (const row of rows) { + const episode: Episode = { + id: row.id, + ts: row.ts, + sessionId: row.session_id, + task: row.task, + input: row.input ?? undefined, + output: row.output ?? undefined, + critique: row.critique ?? undefined, + reward: row.reward, + success: row.success === 1, + latencyMs: row.latency_ms ?? undefined, + tokensUsed: row.tokens_used ?? undefined, + tags: row.tags ? JSON.parse(row.tags) : undefined, + metadata: row.metadata ? JSON.parse(row.metadata) : undefined, + }; + + // Prefer the stored embedding; fall back to recomputing if missing. + let embedding: Float32Array | undefined; + if (row.embedding) { + const buf: Buffer = row.embedding; + embedding = new Float32Array(buf.buffer, buf.byteOffset, buf.length / 4); + } else { + embedding = await this.embedder.embed(this.buildEpisodeText(episode)); + } + + // Re-insert through the proper public APIs so the GuardedBackend + // wrapper, graph adapter, and HNSW all stay in sync. + if (this.vectorBackend) { + try { + this.vectorBackend.insert(String(row.id), embedding, { + type: 'episode', + sessionId: episode.sessionId, + reward: episode.reward, + success: episode.success, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // eslint-disable-next-line no-console + console.warn(`[ReflexionMemory] rebuildIndex: vector insert failed for ${row.id}: ${msg}`); + } + } + + if (this.graphBackend && 'storeEpisode' in this.graphBackend) { + const adapter = this.graphBackend as any as GraphDatabaseAdapter; + try { + await adapter.storeEpisode( + { + id: `episode-${row.id}`, + sessionId: episode.sessionId, + task: episode.task, + reward: episode.reward, + success: episode.success, + input: episode.input, + output: episode.output, + critique: episode.critique, + createdAt: (episode.ts ?? Date.now() / 1000) * 1000, + tokensUsed: episode.tokensUsed, + latencyMs: episode.latencyMs, + }, + embedding + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // eslint-disable-next-line no-console + console.warn(`[ReflexionMemory] rebuildIndex: graph store failed for ${row.id}: ${msg}`); + } + } + + reindexed++; + } + + // Bust any stale read caches so the next retrieveRelevant sees the + // freshly-rebuilt index. + this.queryCache.invalidateCategory('episodes'); + this.queryCache.invalidateCategory('task-stats'); + + return reindexed; + } } diff --git a/packages/agentdb/src/controllers/index.ts b/packages/agentdb/src/controllers/index.ts index faf9a500e..6ac97bfc3 100644 --- a/packages/agentdb/src/controllers/index.ts +++ b/packages/agentdb/src/controllers/index.ts @@ -70,3 +70,16 @@ export type { MultiHeadAttentionResult, MultiHeadMemoryEntry } from './attention/index.js'; + +// Controller activation registry (issue #146 Gap 2) +export { + controllerPrerequisites, + noArgControllers, + getControllerPrerequisite, + filterBySafety +} from './prerequisites.js'; +export type { + ControllerPrerequisite, + ControllerRequirement, + ControllerSafety +} from './prerequisites.js'; diff --git a/packages/agentdb/src/controllers/prerequisites.ts b/packages/agentdb/src/controllers/prerequisites.ts new file mode 100644 index 000000000..243ac3230 --- /dev/null +++ b/packages/agentdb/src/controllers/prerequisites.ts @@ -0,0 +1,283 @@ +/** + * Controller Prerequisites Registry + * + * Documents which agentdb controllers can be auto-activated by downstream + * consumers (e.g. ruflo's memory bridge) versus which require external + * dependencies that the embedding host has to supply. + * + * Issue #146 Gap 2 — downstream consumers were reading dist source to discover + * which controllers were safe to default-construct. This module hoists that + * information into the public API so callers can do: + * + * ```ts + * import { controllerPrerequisites } from 'agentdb'; + * + * const safe = controllerPrerequisites.filter(c => c.requirements.length === 0); + * // → controllers whose constructor needs no external resources + * ``` + * + * The data is hand-curated and tracked alongside controller source files so + * adding a new controller forces a registry update in the same change. + */ + +/** + * What a controller needs at construction time. Anything in `requirements` + * has to be produced by the host before the controller can be instantiated. + */ +export type ControllerRequirement = + | 'database' // SQLite handle (better-sqlite3 / sql.js Database) + | 'embedder' // EmbeddingService (or compatible) + | 'vectorBackend' // VectorBackend implementation + | 'graphBackend' // External graph DB connection + | 'learningBackend' // LearningBackend implementation + | 'config' // Mandatory configuration object (no defaults) + | 'wasm' // WASM runtime / native binding + | 'networkEndpoint'; // Reachable QUIC / HTTP peer + +/** Activation safety: what happens when this controller is constructed. */ +export type ControllerSafety = + /** Constructor itself is pure — no I/O, threads, or network. */ + | 'pure' + /** Constructor opens a file handle / WASM module / process resource. */ + | 'opens-resource' + /** Constructor performs a network operation (e.g. binds a socket). */ + | 'opens-network'; + +export interface ControllerPrerequisite { + /** Controller class name as exported from `agentdb`. */ + name: string; + /** + * Required external resources. When empty, the controller can be + * default-constructed with no host-supplied arguments. + */ + requirements: ControllerRequirement[]; + /** Optional resources — controller works without them but is degraded. */ + optional: ControllerRequirement[]; + /** Constructor arity (positional args). Useful for reflection-style wiring. */ + arity: number; + /** What happens when this controller is instantiated. */ + safety: ControllerSafety; + /** Short human description for tooling output. */ + description: string; +} + +/** + * Authoritative list of agentdb controllers and their construction needs. + * + * Order is alphabetical for easy diffs. Entries match the export names from + * `controllers/index.ts`. + */ +export const controllerPrerequisites: readonly ControllerPrerequisite[] = Object.freeze([ + { + name: 'AttentionService', + requirements: ['config'], + optional: ['wasm'], + arity: 1, + safety: 'opens-resource', + description: 'Self / cross / multi-head attention over embeddings (uses @ruvector/attention WASM when present).' + }, + { + name: 'CausalMemoryGraph', + requirements: ['database'], + optional: ['embedder', 'graphBackend', 'vectorBackend', 'config'], + arity: 5, + safety: 'opens-resource', + description: 'Causal edge graph over memories. Needs a database; graphBackend lets you swap to an external graph DB.' + }, + { + name: 'CausalRecall', + requirements: ['database', 'embedder'], + optional: ['vectorBackend', 'config'], + arity: 4, + safety: 'pure', + description: 'Causal-uplift reranker for recall queries.' + }, + { + name: 'ContextSynthesizer', + requirements: ['database'], + optional: ['embedder', 'config'], + arity: 3, + safety: 'pure', + description: 'Synthesises retrieved memories into a coherent context window.' + }, + { + name: 'CrossAttentionController', + requirements: ['config'], + optional: ['wasm'], + arity: 1, + safety: 'opens-resource', + description: 'Cross-attention controller (per-head context fusion).' + }, + { + name: 'EmbeddingService', + requirements: ['config'], + optional: [], + arity: 1, + safety: 'pure', + description: 'Text → vector embedder (transformers.js / OpenAI / local).' + }, + { + name: 'EnhancedEmbeddingService', + requirements: ['config'], + optional: [], + arity: 1, + safety: 'pure', + description: 'EmbeddingService with caching, batching, and provider fallback.' + }, + { + name: 'ExplainableRecall', + requirements: ['database', 'embedder'], + optional: ['vectorBackend'], + arity: 3, + safety: 'pure', + description: 'Recall layer that emits feature attributions per match.' + }, + { + name: 'HNSWIndex', + requirements: ['database'], + optional: ['config'], + arity: 2, + safety: 'opens-resource', + description: 'On-disk HNSW vector index. Loads native hnswlib when available.' + }, + { + name: 'LearningSystem', + requirements: ['database', 'embedder'], + optional: ['vectorBackend', 'config'], + arity: 4, + safety: 'pure', + description: 'Online learner that consolidates patterns and skills.' + }, + { + name: 'MMRDiversityRanker', + requirements: [], + optional: ['config'], + arity: 1, + safety: 'pure', + description: 'Maximal Marginal Relevance diversity reranker.' + }, + { + name: 'MemoryController', + requirements: [], + optional: ['vectorBackend', 'config'], + arity: 2, + safety: 'pure', + description: 'High-level memory orchestration over a vector backend (works in-memory if backend omitted).' + }, + { + name: 'MetadataFilter', + requirements: [], + optional: [], + arity: 0, + safety: 'pure', + description: 'Pure utility for filtering memories by metadata predicates.' + }, + { + name: 'MultiHeadAttentionController', + requirements: ['config'], + optional: ['wasm'], + arity: 1, + safety: 'opens-resource', + description: 'Multi-head attention controller built on AttentionService.' + }, + { + name: 'NightlyLearner', + requirements: ['database', 'embedder'], + optional: ['config'], + arity: 3, + safety: 'pure', + description: 'Background consolidation pipeline (causal edges, A/B uplift, skill distillation).' + }, + { + name: 'QUICClient', + requirements: ['config', 'networkEndpoint'], + optional: [], + arity: 1, + safety: 'opens-network', + description: 'QUIC sync client. Reaches out to a peer at construction time.' + }, + { + name: 'QUICServer', + requirements: ['config'], + optional: [], + arity: 1, + safety: 'opens-network', + description: 'QUIC sync server. Binds a UDP socket at construction time.' + }, + { + name: 'ReasoningBank', + requirements: ['database', 'embedder'], + optional: ['vectorBackend', 'graphBackend', 'config'], + arity: 5, + safety: 'pure', + description: 'Top-level reasoning memory facade combining reflexion / causal / skill controllers.' + }, + { + name: 'ReflexionMemory', + requirements: ['database', 'embedder'], + optional: ['vectorBackend', 'learningBackend', 'graphBackend'], + arity: 5, + safety: 'pure', + description: 'Episodic replay memory (Reflexion paper). Stores critiques + outcomes.' + }, + { + name: 'SelfAttentionController', + requirements: ['config'], + optional: ['wasm'], + arity: 1, + safety: 'opens-resource', + description: 'Self-attention controller (intra-context salience).' + }, + { + name: 'SkillLibrary', + requirements: ['database', 'embedder'], + optional: ['vectorBackend', 'graphBackend', 'config'], + arity: 5, + safety: 'pure', + description: 'Reusable skill registry with similarity-based retrieval.' + }, + { + name: 'SyncCoordinator', + requirements: ['config'], + optional: ['networkEndpoint'], + arity: 1, + safety: 'opens-network', + description: 'Coordinates multi-peer QUIC sync.' + }, + { + name: 'WASMVectorSearch', + requirements: ['wasm'], + optional: ['config'], + arity: 1, + safety: 'opens-resource', + description: 'Pure-WASM vector search index (no native deps).' + } +]); + +/** + * Convenience: controllers safe to default-construct (no required resources). + * These are what downstream "auto-activate" passes can enable without host + * cooperation. + */ +export const noArgControllers: readonly ControllerPrerequisite[] = Object.freeze( + controllerPrerequisites.filter(c => c.requirements.length === 0) +); + +/** Look up a controller's prerequisites by name. Returns null if unknown. */ +export function getControllerPrerequisite(name: string): ControllerPrerequisite | null { + return controllerPrerequisites.find(c => c.name === name) ?? null; +} + +/** + * Filter controllers by safety class. Useful for hosts that want to enable + * everything except network-touching controllers, for example: + * + * ```ts + * const offlineSafe = filterBySafety(['pure', 'opens-resource']); + * ``` + */ +export function filterBySafety( + safety: readonly ControllerSafety[] +): readonly ControllerPrerequisite[] { + return controllerPrerequisites.filter(c => safety.includes(c.safety)); +}