/**
* Conversation Manager
*
* Task 2.4: Conversational Interface Integration
* Constraint: Separate identity/alignment/approval → Unified conversational interface
*
* Witness Outcome: Natural conversation: "Who are you?" → "I'm X" → "Do Y" → "Aligned, proceeding"
*
* Central gateway for all tool interactions. Manages conversation state,
* negotiates intent, and enforces alignment before execution.
*/
import { Conversation, type ConversationState } from './conversation-migration.js';
import { AlignmentDetector, type AlignmentCheck } from './alignment-detector.js';
import type { Tool, ToolClass, ToolContext, ToolResult } from './tool-interface.js';
import { SupabaseConversationStore } from './supabase-conversation-store.js';
import { ConversationStore } from './conversation-store.js';
import { ToolRegistry } from './tool-registry.js';
import { IntentRouter } from './intent-router.js';
import { SharedContext } from './shared-context.js';
export interface NegotiationResult {
success: boolean;
output?: any;
error?: string;
requiresApproval?: boolean;
approvalReason?: string;
alignment?: string;
}
/**
* Conversation Manager State (for hot-reload)
*/
export interface ConversationManagerState {
conversations: Array<{ id: string; state: ConversationState }>;
toolInstances: Array<{ conversationId: string; toolName: string }>;
}
/**
* Conversation Manager
*
* Manages active conversations and acts as negotiation gateway between
* MCP requests and tool execution. Enforces WHO/WHAT/HOW dimensions.
*
* Supports hot-reload with state migration.
*
* M3: Now includes persistent conversation storage via SupabaseConversationStore (cloud-hosted PostgreSQL).
* M5: Multi-tool orchestration through registry + router + shared context
*/
export class ConversationManager {
private conversations: Map<string, Conversation> = new Map();
private alignmentDetector: AlignmentDetector;
private toolInstances: Map<string, Map<string, Tool>> = new Map(); // M5: conversationId -> toolName -> Tool
private store: SupabaseConversationStore | ConversationStore;
// M5: Multi-tool infrastructure
public registry: ToolRegistry; // Public for testing
private router: IntentRouter;
private toolsLoadedPromise: Promise<void>;
constructor(store?: SupabaseConversationStore | ConversationStore) {
this.alignmentDetector = new AlignmentDetector();
// M5: Allow passing custom store for testing (e.g., in-memory ConversationStore)
// Default to SupabaseConversationStore for production
this.store = store || new SupabaseConversationStore();
// M5: Initialize multi-tool infrastructure
this.registry = new ToolRegistry();
this.router = new IntentRouter(this.registry);
// M5: Load all tools from dist/tools/ (async, non-blocking)
this.toolsLoadedPromise = this.registry.loadTools().catch(err => {
console.error('[ConversationManager] Failed to load tools:', err);
throw err;
});
}
/**
* M5: Wait for tools to finish loading (for testing)
*/
async waitForToolsLoaded(): Promise<void> {
await this.toolsLoadedPromise;
}
/**
* Get or create conversation
*
* M3: Loads conversation from persistent store (Supabase cloud) if it exists,
* otherwise creates new conversation and saves to store.
*
* M5: No longer requires toolName parameter (multi-tool conversations)
*/
async getOrCreate(conversationId: string, toolName?: string): Promise<Conversation> {
// Check in-memory cache first
let conversation = this.conversations.get(conversationId);
if (conversation) {
return conversation;
}
// Try to load from persistent store (cloud)
const storedState = await this.store.getConversation(conversationId);
if (storedState) {
// M5: Restore from persistence (toolName from stored state)
const restoredToolName = storedState.identity?.toolName || toolName || 'unknown';
conversation = new Conversation(conversationId, restoredToolName);
conversation.setState(storedState);
this.conversations.set(conversationId, conversation);
console.error(`[ConversationManager] Restored conversation from Supabase: ${conversationId}`);
} else {
// Create new conversation (M5: toolName optional, will be set by first tool)
conversation = new Conversation(conversationId, toolName || 'unknown');
this.conversations.set(conversationId, conversation);
// Save to persistent store (cloud)
await this.store.saveConversation(conversation.getState());
console.error(`[ConversationManager] Created conversation in Supabase: ${conversationId}`);
}
return conversation;
}
/**
* M5: Negotiate and execute tool action (multi-tool orchestration)
* M6: Extended to support hierarchical tool spawning
*
* This is the central gateway for all tool interactions. It:
* 1. Routes action to appropriate tool via IntentRouter
* 2. Loads conversation state (WHO/WHAT/HOW)
* 3. Handles special actions (identity, upgrade, list-tools)
* 4. Checks alignment
* 5. Checks per-tool permissions (with M6 inheritance)
* 6. Executes with shared context + M6 spawning capability
* 7. Records intent in history
*
* Signature changed: toolClass removed, uses registry + router instead
*/
async negotiate(
conversationId: string,
action: string,
args?: any,
explicitTool?: string, // Optional override for testing
m6Context?: { // M6: Hierarchical context
parentTool?: string;
depth?: number;
callChain?: string[];
}
): Promise<NegotiationResult> {
const conversation = await this.getOrCreate(conversationId);
// M6: Recursion depth check (Task 6.3)
const depth = m6Context?.depth || 0;
const maxDepth = 5; // Configurable limit
if (depth > maxDepth) {
return {
success: false,
error: `Maximum tool spawning depth exceeded (${depth} > ${maxDepth}). Possible infinite recursion.`,
};
}
// M6: Circular dependency detection (Task 6.3)
const callChain = m6Context?.callChain || [];
// Check for circular dependency before routing
// (We check against the action's target tool name after routing)
// M5: Handle special multi-tool actions
if (action === 'list-tools') {
return this.handleListTools();
}
// M5: Handle permission upgrades before routing (special syntax)
if (action.startsWith('upgrade:')) {
// Format: upgrade:tool-name:level-N
return await this.handleToolPermissionUpgrade(conversation, action, undefined);
}
// M5.5: Handle conversation meta actions (Task 5.11)
if (action.startsWith('conversation:')) {
return await this.handleConversationMeta(conversationId, action, args);
}
// M5: Route to tool via capability matching
const routingDecision = this.router.route(action, explicitTool);
if (!routingDecision) {
return {
success: false,
error: `No tool found for action: ${action}. Use 'list-tools' to see available tools.`,
};
}
const toolName = routingDecision.toolName;
// M6: Check for circular dependency now that we know the target tool
if (callChain.includes(toolName)) {
return {
success: false,
error: `Circular dependency detected: ${[...callChain, toolName].join(' → ')}`,
};
}
const toolClass = this.registry.getTool(toolName);
if (!toolClass) {
return {
success: false,
error: `Tool not found in registry: ${toolName}`,
};
}
console.error(`[ConversationManager] Routed ${action} → ${toolName} (confidence: ${routingDecision.confidence.toFixed(2)})`);
// M5: Get or create tool instance for this conversation + tool
let conversationTools = this.toolInstances.get(conversationId);
if (!conversationTools) {
conversationTools = new Map();
this.toolInstances.set(conversationId, conversationTools);
}
let tool = conversationTools.get(toolName);
if (!tool) {
tool = new toolClass();
conversationTools.set(toolName, tool);
console.error(`[ConversationManager] Created tool instance: ${toolName} for conversation ${conversationId}`);
}
// Handle special actions
if (action === 'identity' || action === 'who') {
return this.handleIdentityQuery(conversation, toolName);
}
if (action === 'evaluate' || action.startsWith('what-if:')) {
// Hypothetical evaluation - "What do you think about X?"
const hypotheticalAction = action.startsWith('what-if:')
? action.substring(8)
: (args as any)?.action || (args as any)?.hypothetical;
return this.handleHypotheticalEvaluation(hypotheticalAction, args);
}
// Check alignment (Task 2.2)
const alignmentCheck = this.alignmentDetector.checkAlignment(action, args);
if (alignmentCheck.action === 'deny') {
console.error(`[ConversationManager] Denied: ${action} - ${alignmentCheck.reason}`);
return {
success: false,
error: `Denied: ${alignmentCheck.reason}`,
alignment: alignmentCheck.alignment,
};
}
// M5/M6: Per-tool permission check with inheritance (Task 6.2)
const state = conversation.getState();
let currentLevel = this.getToolPermissionLevel(state, toolName);
const requiredLevel = alignmentCheck.requiredLevel;
// M6: Permission inheritance - child tools inherit parent's level
if (m6Context?.parentTool) {
const parentLevel = this.getToolPermissionLevel(state, m6Context.parentTool);
if (parentLevel > currentLevel) {
console.error(`[ConversationManager] M6 Permission inheritance: ${toolName} inherits level ${parentLevel} from ${m6Context.parentTool}`);
// Auto-upgrade child to inherited level
if (!state.toolPermissions) {
state.toolPermissions = {};
}
state.toolPermissions[toolName] = {
level: parentLevel,
upgradedAt: Date.now(),
inheritedFrom: m6Context.parentTool,
};
currentLevel = parentLevel;
// Persist the permission inheritance
await this.store.saveConversation(state);
}
}
if (currentLevel < requiredLevel) {
console.error(`[ConversationManager] Insufficient permission for ${toolName}: requires level ${requiredLevel}, current level ${currentLevel}`);
return {
success: false,
requiresApproval: true,
approvalReason: `Permission upgrade required for ${toolName}: level ${currentLevel} → ${requiredLevel}`,
alignment: alignmentCheck.alignment,
output: `Permission upgrade required for ${toolName} to perform "${action}"\n\nCurrent level: ${currentLevel}\nRequired level: ${requiredLevel}\n\nTo upgrade: call with action "upgrade:${toolName}:level-${requiredLevel}"`,
};
}
// M5: Execute tool with shared context
const sharedContext = SharedContext.deserialize(state.sharedContext || { resources: [] });
// M6: Build ToolContext with orchestration capabilities (Task 6.1)
const context: ToolContext = {
conversationId,
alignmentCheck,
sharedContext, // M5: Pass shared context
toolName, // M5: Pass tool name for resource creation
args, // M5: Pass args for tool execution
// M6: Tool spawning capability
toolRegistry: this.registry,
conversationManager: this,
parentTool: m6Context?.parentTool,
depth: depth,
callChain: [...callChain, toolName],
};
const result = await tool.execute(action, context);
// M5: Record intent with tool name
this.recordIntent(conversation, action, toolName, alignmentCheck.alignment);
// M5: Persist shared context changes
state.sharedContext = sharedContext.serialize();
conversation.setState(state);
// M3: Persist state after execution (cloud)
await this.store.saveConversation(state);
return {
success: result.success,
output: result.output,
error: result.error,
alignment: alignmentCheck.alignment,
};
}
/**
* M5: Handle list-tools query
*/
private handleListTools(): NegotiationResult {
const tools = this.registry.listToolMetadata();
return {
success: true,
output: {
count: tools.length,
tools: tools.map(t => ({
name: t.name,
version: t.version,
capabilities: t.capabilities,
})),
},
alignment: 'aligned',
};
}
/**
* M5.5: Handle conversation meta actions (Task 5.11)
*
* Provides visibility into conversation lifecycle:
* - conversation:status - Current conversation metadata
* - conversation:list - All conversations
* - conversation:switch - How to switch conversations
*/
private async handleConversationMeta(
conversationId: string,
action: string,
args?: any
): Promise<NegotiationResult> {
const subAction = action.substring(13); // Remove 'conversation:' prefix
switch (subAction) {
case 'status':
return await this.handleConversationStatus(conversationId);
case 'list':
return await this.handleConversationList();
case 'switch':
return this.handleConversationSwitch(args);
default:
return {
success: false,
error: `Unknown conversation action: ${subAction}. Available: status, list, switch`,
};
}
}
/**
* M5.5: Get current conversation status
*/
private async handleConversationStatus(conversationId: string): Promise<NegotiationResult> {
const conversation = await this.getOrCreate(conversationId);
const state = conversation.getState();
// Count resources in shared context
const resourceCount = state.sharedContext?.resources?.length || 0;
// M6: Return full permission objects (not just levels) to show inheritance
const toolPermissions = state.toolPermissions || {};
// Get tools that have been used (from intent history)
const toolsUsed = new Set<string>();
for (const intent of state.intentHistory) {
if (intent.toolName) {
toolsUsed.add(intent.toolName);
}
}
return {
success: true,
output: {
conversationId,
identity: state.identity,
stats: {
intentCount: state.intentHistory.length,
resourceCount,
toolCount: toolsUsed.size,
permissionCount: Object.keys(toolPermissions).length,
},
toolPermissions,
toolsUsed: Array.from(toolsUsed),
message: `Conversation: ${conversationId}\n${toolsUsed.size} tools active, ${resourceCount} resources, ${state.intentHistory.length} intents`,
},
alignment: 'aligned',
};
}
/**
* M5.5: List all conversations
*/
private async handleConversationList(): Promise<NegotiationResult> {
const conversationIds = await this.store.listConversations();
// Get metadata for each conversation
const conversations = [];
for (const id of conversationIds) {
const state = await this.store.getConversation(id);
if (state) {
const resourceCount = state.sharedContext?.resources?.length || 0;
const toolsUsed = new Set(state.intentHistory.map(i => i.toolName).filter(Boolean));
conversations.push({
id,
identity: state.identity,
intentCount: state.intentHistory.length,
resourceCount,
toolCount: toolsUsed.size,
});
}
}
return {
success: true,
output: {
count: conversations.length,
conversations,
message: `Found ${conversations.length} conversation(s)`,
},
alignment: 'aligned',
};
}
/**
* M5.5: Handle conversation switch
*
* Note: Actual switching is done by passing conversationId parameter to tool calls.
* This action just documents how to switch.
*/
private handleConversationSwitch(args?: any): NegotiationResult {
const targetId = args?.conversationId || args?.id;
if (!targetId) {
return {
success: true,
output: {
message: `To switch conversations, pass the conversationId parameter in your next tool call:\n\n{
action: "your-action",
conversationId: "target-conversation-id",
...
}\n\nUse "conversation:list" to see all available conversations.`,
},
alignment: 'aligned',
};
}
return {
success: true,
output: {
message: `To switch to conversation "${targetId}", pass conversationId parameter:\n\n{
action: "your-action",
conversationId: "${targetId}",
...
}\n\nAll subsequent calls with this conversationId will operate in that context.`,
},
alignment: 'aligned',
};
}
/**
* Handle identity query (Task 2.1)
* M5: Updated to show active tool
*/
private handleIdentityQuery(conversation: Conversation, toolName: string): NegotiationResult {
const metadata = this.registry.getToolMetadata(toolName);
if (!metadata) {
return {
success: false,
error: `Tool not found: ${toolName}`,
};
}
console.error(`[ConversationManager] Identity query: ${metadata.name} v${metadata.version}`);
return {
success: true,
output: {
name: metadata.name,
version: metadata.version,
capabilities: metadata.capabilities,
message: `I'm ${metadata.name} (v${metadata.version})`,
},
alignment: 'aligned',
};
}
/**
* M5: Get current permission level for specific tool
*/
private getToolPermissionLevel(state: ConversationState, toolName: string): number {
// M5: Check per-tool permissions first
if (state.toolPermissions && state.toolPermissions[toolName]) {
return state.toolPermissions[toolName].level;
}
// M4 backward compatibility: use global currentLevel if it exists
if (state.currentLevel !== undefined) {
return state.currentLevel;
}
// Default: level 1 (read-only)
return 1;
}
/**
* M5: Handle per-tool permission upgrade
*/
private async handleToolPermissionUpgrade(
conversation: Conversation,
action: string,
defaultToolName?: string
): Promise<NegotiationResult> {
const state = conversation.getState();
// Parse action: upgrade:tool-name:level-N or upgrade:level-N
let toolName: string | undefined = defaultToolName;
let targetLevel: number;
const parts = action.split(':');
if (parts.length === 3 && parts[2].startsWith('level-')) {
// Format: upgrade:tool-name:level-N
toolName = parts[1];
targetLevel = parseInt(parts[2].substring(6), 10);
} else if (parts.length === 2 && parts[1].startsWith('level-')) {
// Format: upgrade:level-N (backward compat, uses routed tool or requires explicit tool)
targetLevel = parseInt(parts[1].substring(6), 10);
if (!defaultToolName) {
return {
success: false,
error: 'Invalid upgrade format. Use: upgrade:tool-name:level-N',
};
}
} else {
return {
success: false,
error: 'Invalid upgrade format. Use: upgrade:tool-name:level-N or upgrade:level-N',
};
}
// Validate target level
if (isNaN(targetLevel) || targetLevel < 1 || targetLevel > 3) {
return {
success: false,
error: `Invalid permission level: ${targetLevel}. Valid levels: 1 (read), 2 (write), 3 (execute)`,
};
}
// Ensure toolName is defined
if (!toolName) {
return {
success: false,
error: 'Tool name is required for permission upgrade',
};
}
const currentLevel = this.getToolPermissionLevel(state, toolName);
// Check if upgrade is needed
if (targetLevel <= currentLevel) {
return {
success: true,
output: `${toolName} already at level ${currentLevel}. No upgrade needed.`,
alignment: 'aligned',
};
}
// M5: Initialize toolPermissions if not exists
if (!state.toolPermissions) {
state.toolPermissions = {};
}
// Upgrade tool permission
state.toolPermissions[toolName] = {
level: targetLevel,
upgradedAt: Date.now(),
};
// Record in permission history
state.permissionHistory.push({
toolName, // M5: Track which tool
level: targetLevel,
scope: 'global',
grantedAt: Date.now(),
});
conversation.setState(state);
await this.store.saveConversation(state);
const levelNames: Record<number, string> = {
1: 'read-only',
2: 'write',
3: 'execute',
};
console.error(`[ConversationManager] Permission upgraded for ${toolName}: ${currentLevel} → ${targetLevel}`);
return {
success: true,
output: `${toolName} upgraded: level ${currentLevel} → ${targetLevel} (${levelNames[targetLevel]})\n\nYou can now perform level ${targetLevel} operations with ${toolName}.`,
alignment: 'aligned',
};
}
/**
* Handle hypothetical evaluation (M2 completion)
*
* Allows asking "What do you think about X?" without executing
*/
private handleHypotheticalEvaluation(hypotheticalAction: string, args?: any): NegotiationResult {
if (!hypotheticalAction) {
return {
success: false,
error: 'Hypothetical evaluation requires an action to evaluate. Use: evaluate with action="<action>" or what-if:<action>',
};
}
console.error(`[ConversationManager] Evaluating hypothetical: ${hypotheticalAction}`);
// Run alignment check without executing
const alignmentCheck = this.alignmentDetector.checkAlignment(hypotheticalAction, args);
const evaluation = {
hypotheticalAction,
alignment: alignmentCheck.alignment,
reason: alignmentCheck.reason,
wouldProceed: alignmentCheck.action === 'proceed',
requiresApproval: alignmentCheck.action === 'request_approval',
wouldBeDenied: alignmentCheck.action === 'deny',
};
let message = `Hypothetical evaluation for "${hypotheticalAction}":\n`;
if (evaluation.wouldBeDenied) {
message += `❌ Would be DENIED: ${evaluation.reason}`;
} else if (evaluation.requiresApproval) {
message += `⚠️ Would require APPROVAL: ${evaluation.reason}`;
} else {
message += `✅ Would PROCEED: Action is aligned with constraints`;
}
return {
success: true,
output: {
...evaluation,
message,
},
alignment: alignmentCheck.alignment,
};
}
/**
* Record intent in conversation history (Task 2.2)
* M5: Updated to track tool name
*/
private recordIntent(conversation: Conversation, action: string, toolName: string, alignment: string): void {
const state = conversation.getState();
state.intentHistory.push({
action,
toolName, // M5: Track which tool executed
alignment,
timestamp: Date.now(),
});
conversation.setState(state);
console.error(`[ConversationManager] Recorded intent: ${action} (${alignment})`);
}
/**
* Migrate tool instance during hot-reload
*/
migrateToolInstance(conversationId: string, newToolClass: ToolClass): void {
const conversation = this.conversations.get(conversationId);
if (!conversation) {
return;
}
// Create new tool instance
const newTool = new newToolClass();
// Update conversation identity
const state = conversation.getState();
state.identity = {
toolName: newToolClass.identity.name,
version: newToolClass.identity.version,
capabilities: newToolClass.identity.capabilities,
};
conversation.setState(state);
// M5: Replace tool instance in nested Map structure
let conversationTools = this.toolInstances.get(conversationId);
if (!conversationTools) {
conversationTools = new Map();
this.toolInstances.set(conversationId, conversationTools);
}
conversationTools.set(newToolClass.identity.name, newTool);
console.error(`[ConversationManager] Migrated tool instance: ${conversationId} / ${newToolClass.identity.name}`);
}
/**
* Get active conversations (for debugging)
*/
getActiveConversations(): string[] {
return Array.from(this.conversations.keys());
}
/**
* Get conversation state (for debugging)
*/
getConversationState(conversationId: string): ConversationState | undefined {
const conversation = this.conversations.get(conversationId);
return conversation?.getState();
}
/**
* Serialize state for hot-reload
*/
getState(): ConversationManagerState {
const conversations = Array.from(this.conversations.entries()).map(([id, conv]) => ({
id,
state: conv.getState(),
}));
// M5: Flatten nested Map structure for serialization
const toolInstances: Array<{ conversationId: string; toolName: string }> = [];
for (const [conversationId, toolMap] of this.toolInstances.entries()) {
for (const toolName of toolMap.keys()) {
toolInstances.push({ conversationId, toolName });
}
}
return {
conversations,
toolInstances,
};
}
/**
* Restore from state after hot-reload
*/
static fromState(state: ConversationManagerState): ConversationManager {
const manager = new ConversationManager();
// Restore conversations
for (const { id, state: convState } of state.conversations) {
const conversation = new Conversation(id, convState.identity.toolName);
conversation.setState(convState);
manager.conversations.set(id, conversation);
}
console.error(`[ConversationManager] Restored ${state.conversations.length} conversations from state`);
// Note: toolInstances are NOT restored - they will be recreated on next request
// This is intentional - tool classes may have changed during hot-reload
return manager;
}
}