/**
* Tool Registry with Hot-Reload
*
* M1 Task 1.1: Tool Registry with Hot-Reload
* M5 Task 5.1: Multi-Tool Discovery
*
* Constraint (M1): Static tool loading → Dynamic tool class registry
* Constraint (M5): Single tool → Multiple tools with discovery
*
* Witness Outcome (M1): Tool code updated → reload signal → new tool class active without restart
* Witness Outcome (M5): "List available tools" → System returns all registered tools with capabilities
*/
import { pathToFileURL, fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { readdir } from 'node:fs/promises';
import type { ToolClass } from './tool-interface.js';
import type { Conversation } from './conversation-migration.js';
export interface ToolMetadata {
name: string;
version: string;
capabilities: string[];
toolClass: ToolClass;
}
export class ToolRegistry {
private toolClasses: Map<string, ToolClass> = new Map();
private toolMetadata: Map<string, ToolMetadata> = new Map(); // M5: Metadata cache
private activeConversations: Map<string, Conversation> = new Map();
private toolsDir: string; // M5: Tools directory path
constructor(toolsDir?: string) {
// M5: Use module-relative path (same pattern as M1)
const thisFile = fileURLToPath(import.meta.url);
const thisDir = dirname(thisFile);
this.toolsDir = toolsDir || join(thisDir, '..', 'tools');
console.error(`[ToolRegistry] Initialized with tools directory: ${this.toolsDir}`);
}
/**
* M5: Load all tools from tools directory
*
* Discovers all .js files in tools directory and loads them.
*/
async loadTools(): Promise<void> {
try {
const files = await readdir(this.toolsDir);
const toolFiles = files.filter(file => file.endsWith('.js'));
console.error(`[ToolRegistry] Found ${toolFiles.length} tool files`);
for (const file of toolFiles) {
const toolName = file.replace('.js', '');
try {
await this.loadToolByName(toolName);
} catch (err) {
console.error(`[ToolRegistry] Failed to load tool ${toolName}:`, err);
}
}
console.error(`[ToolRegistry] Loaded ${this.toolClasses.size} tools: ${Array.from(this.toolClasses.keys()).join(', ')}`);
} catch (err) {
console.error('[ToolRegistry] Failed to read tools directory:', err);
throw err;
}
}
/**
* M5: Load tool by name and cache metadata
*/
private async loadToolByName(toolName: string): Promise<void> {
const toolClass = await this.loadToolClass(toolName);
// Cache tool class
this.toolClasses.set(toolName, toolClass);
// M5: Cache metadata for discovery
const metadata: ToolMetadata = {
name: toolClass.identity.name,
version: toolClass.identity.version,
capabilities: toolClass.identity.capabilities,
toolClass,
};
this.toolMetadata.set(toolName, metadata);
console.error(`[ToolRegistry] Loaded: ${toolName} v${toolClass.identity.version} (${toolClass.identity.capabilities.join(', ')})`);
}
/**
* M1: Register a tool class (backward compatibility)
*/
registerTool(toolClass: ToolClass): void {
const identity = toolClass.identity;
this.toolClasses.set(identity.name, toolClass);
// M5: Also cache metadata
this.toolMetadata.set(identity.name, {
name: identity.name,
version: identity.version,
capabilities: identity.capabilities,
toolClass,
});
console.error(`[ToolRegistry] Registered tool: ${identity.name} v${identity.version}`);
}
/**
* Reload a tool class (M1 Task 1.1)
*
* Graceful handoff:
* 1. Load new tool class
* 2. Migrate active conversations
* 3. Replace old class with new class
*
* M5: Also updates metadata cache
*/
async reloadTool(toolName: string): Promise<void> {
const startTime = Date.now();
console.error(`[ToolRegistry] Reloading tool: ${toolName}`);
// Get old class
const oldClass = this.toolClasses.get(toolName);
if (!oldClass) {
throw new Error(`Tool not found: ${toolName}`);
}
// Load new class
const newClass = await this.loadToolClass(toolName);
// Migrate active conversations (M1 Task 1.3)
for (const [convId, conv] of this.activeConversations.entries()) {
if (conv.toolName === toolName) {
conv.migrate(newClass);
console.error(`[ToolRegistry] Migrated conversation: ${convId}`);
}
}
// Replace old class
this.toolClasses.set(toolName, newClass);
// M5: Update metadata cache
this.toolMetadata.set(toolName, {
name: newClass.identity.name,
version: newClass.identity.version,
capabilities: newClass.identity.capabilities,
toolClass: newClass,
});
const latency = Date.now() - startTime;
console.error(`[ToolRegistry] Reload complete: ${toolName} (${latency}ms)`);
// Witness: Reload latency <100ms
if (latency > 100) {
console.warn(`[ToolRegistry] WARNING: Reload latency exceeded 100ms: ${latency}ms`);
}
}
/**
* Load tool class dynamically with cache busting
*
* Uses dynamic import() with timestamp query parameter to bypass Node's
* module cache, enabling true hot-reload of tool classes.
*/
private async loadToolClass(toolName: string): Promise<ToolClass> {
// Calculate path fresh every call from import.meta.url
// This ensures correct resolution even during hot-reload
const thisFile = fileURLToPath(import.meta.url);
const thisDir = dirname(thisFile);
const toolPath = join(thisDir, '..', 'tools', `${toolName}.js`);
const toolUrl = pathToFileURL(toolPath).href;
// Cache busting: append timestamp to force fresh module load
const cacheBustedUrl = `${toolUrl}?t=${Date.now()}`;
console.error(`[ToolRegistry] Loading tool from: ${cacheBustedUrl}`);
try {
// Dynamic ES module import
const module = await import(cacheBustedUrl);
// Get the default export (the tool class)
const toolClass = module.default;
if (!toolClass) {
throw new Error(`No default export found in ${toolName}`);
}
// Validate tool class has required identity
if (!toolClass.identity) {
throw new Error(`Tool class ${toolName} missing static 'identity' property`);
}
return toolClass as ToolClass;
} catch (error) {
console.error(`[ToolRegistry] Failed to load tool ${toolName}:`, error);
throw error;
}
}
/**
* Get registered tool
*/
getTool(toolName: string): ToolClass | undefined {
return this.toolClasses.get(toolName);
}
/**
* M5: Get tool metadata
*/
getToolMetadata(toolName: string): ToolMetadata | undefined {
return this.toolMetadata.get(toolName);
}
/**
* List all registered tools
*/
listTools(): string[] {
return Array.from(this.toolClasses.keys());
}
/**
* M5: List all tool metadata
*/
listToolMetadata(): ToolMetadata[] {
return Array.from(this.toolMetadata.values());
}
/**
* M5: Find tools by capability
*/
findToolsByCapability(capability: string): ToolMetadata[] {
return Array.from(this.toolMetadata.values()).filter(tool =>
tool.capabilities.includes(capability)
);
}
/**
* M5: Check if tool exists
*/
hasTool(toolName: string): boolean {
return this.toolClasses.has(toolName);
}
/**
* M5: Get tool count
*/
getToolCount(): number {
return this.toolClasses.size;
}
}