diff --git a/CLAUDE.md b/CLAUDE.md index ab2c05f8b..bd66aec22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -403,6 +403,42 @@ sleep 10 ./jtag ai/report # AI performance metrics ``` +### Persona Logging (Cognition Visibility) + +Persona logging is **opt-in** and controlled by `.continuum/logging.json`. Categories include `cognition` (thought process, tool decisions, agent loop traces) and `hippocampus` (memory/recall). + +**Config file** (`.continuum/logging.json`): +```json +{ + "version": 1, + "defaults": { "enabled": true, "categories": ["cognition"] }, + "personas": { + "helper": { "enabled": true, "categories": ["cognition"] } + }, + "system": { "enabled": true, "categories": [] } +} +``` + +**Commands**: +```bash +# Enable logging for a persona (persists to logging.json) +./jtag logging/enable --persona="helper" --category="cognition" + +# Disable logging for a persona +./jtag logging/disable --persona="helper" + +# Show logging status for all personas +./jtag logging/status + +# Show logging status for a specific persona +./jtag logging/status --persona="helper" +``` + +**Log locations**: +- Per-persona cognition: `.continuum/jtag/logs/personas//cognition.log` +- AI provider routing: `.continuum/jtag/logs/system/modules/ai_provider.log` +- Prompt captures (full LLM req/res): `.continuum/jtag/logs/prompt-captures.jsonl` + ### System Logs ```bash tail -f .continuum/sessions/user/shared/*/logs/server.log diff --git a/src/debug/jtag/.doc-staging/persona/sentinel-architecture.md b/src/debug/jtag/.doc-staging/persona/sentinel-architecture.md index 47a5188d0..3ea5110b7 100644 --- a/src/debug/jtag/.doc-staging/persona/sentinel-architecture.md +++ b/src/debug/jtag/.doc-staging/persona/sentinel-architecture.md @@ -594,13 +594,190 @@ const CODE_SENTINEL_PERMISSIONS: SentinelPermissions = { --- +## Runtime Execution Model + +### Workspace Structure + +All sentinel execution happens within `.continuum/jtag/`: + +``` +.continuum/jtag/ +├── logs/system/ +│ ├── sentinels/ # All sentinel logs here +│ │ ├── {handle}/ +│ │ │ ├── stdout.log +│ │ │ ├── stderr.log +│ │ │ ├── combined.log +│ │ │ └── steps.jsonl # Step-by-step results +│ │ └── index.log # Sentinel start/stop events +│ └── ... +├── sentinels/ +│ ├── workspaces/ # Sentinel scratch space +│ │ └── {handle}/ +│ │ ├── output/ # Files sentinel creates +│ │ ├── metadata.json # Pipeline definition, permissions +│ │ └── results.json # Final step results +│ └── definitions/ # Saved sentinel definitions +│ └── {id}.json +└── ... +``` + +**Key principle**: Sentinels write to their workspace by default. Access outside requires explicit permission. + +--- + +### Filesystem Permission Model + +```typescript +interface SentinelFilesystemConfig { + // Static whitelist (declared in pipeline definition) + read: string[]; // Glob patterns: ["src/**/*.ts", "package.json"] + write: string[]; // Default: ["$workspace/**"] + execute: string[]; // Commands: ["npm", "cargo", "git"] + + // Dynamic access + requestDynamic: boolean; // Can request more at runtime + autoApprove: string[]; // Auto-approve patterns: ["$workspace/**"] +} +``` + +**Default sandbox**: Sentinels can ONLY write to `$workspace` (their handle's directory) unless explicitly granted more. + +--- + +### Event-Based Permission Requests (Non-Blocking) + +When a sentinel needs access outside its sandbox: + +``` +Step needs /some/external/path + │ + ├─→ emit: "sentinel:{handle}:permission:request" + │ payload: { path: "/some/external/path", access: "write", reason: "Save analysis" } + │ + ├─→ Sentinel continues with other steps (NON-BLOCKING) + │ OR marks step as "waiting:permission" and moves on + │ + ├─→ User/system responds: + │ emit: "sentinel:{handle}:permission:response" + │ payload: { path: "/some/external/path", granted: true, expires: "2026-02-14T12:00:00Z" } + │ + └─→ Sentinel receives permission, executes deferred step +``` + +**No blocking waits.** Everything is handles, events, commands. + +--- + +### Handle-Based Execution + +Every sentinel execution returns a handle immediately: + +```typescript +interface SentinelHandle { + id: string; // e.g., "aeb8fb01" + status: 'running' | 'completed' | 'failed' | 'cancelled' | 'waiting'; + progress: number; // 0-100 + currentStep?: number; + totalSteps?: number; + + // Workspace paths + workspace: string; // .continuum/jtag/sentinels/workspaces/{handle}/ + logsDir: string; // .continuum/jtag/logs/system/sentinels/{handle}/ + + // Timing + startTime: number; + endTime?: number; + + // Results + exitCode?: number; + error?: string; + stepResults?: StepResult[]; // Available after completion +} +``` + +**Query via**: `sentinel/status --handle={id}` +**Results via**: `sentinel/results --handle={id}` (returns step outputs) + +--- + +### Step Result Storage + +Each step's output is captured and stored: + +```typescript +interface StepResult { + stepIndex: number; + stepType: 'shell' | 'llm' | 'command' | 'condition' | 'loop'; + success: boolean; + durationMs: number; + + // Outputs + output?: string; // stdout or LLM response + error?: string; // stderr or error message + exitCode?: number; // For shell steps + data?: any; // Structured result data +} +``` + +Results written to: +- `.continuum/jtag/logs/system/sentinels/{handle}/steps.jsonl` (streaming) +- `.continuum/jtag/sentinels/workspaces/{handle}/results.json` (final) + +--- + +### Concurrent Execution Limits + +```typescript +interface SentinelRuntimeLimits { + maxConcurrentSentinels: number; // e.g., 4 + maxStepsPerPipeline: number; // e.g., 100 + maxStepTimeout: number; // e.g., 300_000 (5 min) + maxPipelineTimeout: number; // e.g., 3600_000 (1 hour) + + // Resource limits per sentinel + maxMemoryMb: number; // e.g., 512 + maxDiskMb: number; // e.g., 1024 (workspace size) + maxOpenFiles: number; // e.g., 100 +} +``` + +--- + +### Inter-Sentinel Communication + +Sentinels can emit events for other sentinels: + +```typescript +// Pipeline step to emit event +{ + type: 'emit', + event: 'codeanalysis:complete', + data: '{{steps.2.output}}' // Variable interpolation +} + +// Another sentinel triggers on this +{ + trigger: { + type: 'event', + event: 'codeanalysis:complete' + } +} +``` + +**Pattern**: Sentinels coordinate via events, not direct calls. + +--- + ## Implementation Roadmap ### Phase 1: Foundation 1. ✅ Create SentinelUser base class (extends PersonaUser) -2. ⏭️ Implement tool registry for sentinel access -3. ⏭️ Create trigger system (events, schedules, requests) -4. ⏭️ Build permissions system +2. ✅ Implement Rust SentinelModule with pipeline execution +3. ⏭️ Move logs to `.continuum/jtag/logs/system/sentinels/` +4. ⏭️ Add step result storage and `sentinel/results` command +5. ⏭️ Implement workspace isolation (default sandbox) +6. ⏭️ Build event-based permission request system ### Phase 2: First Sentinel 5. ⏭️ Implement CodeSentinel (simplest, most useful) diff --git a/src/debug/jtag/browser/generated.ts b/src/debug/jtag/browser/generated.ts index acb7ab9f8..52f206ec5 100644 --- a/src/debug/jtag/browser/generated.ts +++ b/src/debug/jtag/browser/generated.ts @@ -1,7 +1,7 @@ /** * Browser Structure Registry - Auto-generated * - * Contains 11 daemons and 204 commands and 2 adapters and 28 widgets. + * Contains 11 daemons and 205 commands and 2 adapters and 28 widgets. * Generated by scripts/generate-structure.ts - DO NOT EDIT MANUALLY */ @@ -26,6 +26,7 @@ import { AgentListBrowserCommand } from './../commands/agent/list/browser/AgentL import { AgentStartBrowserCommand } from './../commands/agent/start/browser/AgentStartBrowserCommand'; import { AgentStatusBrowserCommand } from './../commands/agent/status/browser/AgentStatusBrowserCommand'; import { AgentStopBrowserCommand } from './../commands/agent/stop/browser/AgentStopBrowserCommand'; +import { AiAgentBrowserCommand } from './../commands/ai/agent/browser/AiAgentBrowserCommand'; import { BagOfWordsBrowserCommand } from './../commands/ai/bag-of-words/browser/BagOfWordsBrowserCommand'; import { AiContextSearchBrowserCommand } from './../commands/ai/context/search/browser/AiContextSearchBrowserCommand'; import { AiContextSliceBrowserCommand } from './../commands/ai/context/slice/browser/AiContextSliceBrowserCommand'; @@ -361,6 +362,11 @@ export const BROWSER_COMMANDS: CommandEntry[] = [ className: 'AgentStopBrowserCommand', commandClass: AgentStopBrowserCommand }, +{ + name: 'ai/agent', + className: 'AiAgentBrowserCommand', + commandClass: AiAgentBrowserCommand + }, { name: 'ai/bag-of-words', className: 'BagOfWordsBrowserCommand', diff --git a/src/debug/jtag/commands/adapter/adopt/shared/AdapterAdoptTypes.ts b/src/debug/jtag/commands/adapter/adopt/shared/AdapterAdoptTypes.ts index db7673a8b..1d5568cc5 100644 --- a/src/debug/jtag/commands/adapter/adopt/shared/AdapterAdoptTypes.ts +++ b/src/debug/jtag/commands/adapter/adopt/shared/AdapterAdoptTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -46,6 +47,7 @@ export const createAdapterAdoptParams = ( personaId?: string; } ): AdapterAdoptParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, scale: data.scale ?? 0, traitType: data.traitType ?? '', personaId: data.personaId ?? '', @@ -95,6 +97,7 @@ export const createAdapterAdoptResult = ( error?: AdapterAdoptError; } ): AdapterAdoptResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, adapterId: data.adapterId ?? '', layerId: data.layerId ?? '', personaId: data.personaId ?? '', diff --git a/src/debug/jtag/commands/adapter/search/shared/AdapterSearchTypes.ts b/src/debug/jtag/commands/adapter/search/shared/AdapterSearchTypes.ts index 5f751cb58..d5017c861 100644 --- a/src/debug/jtag/commands/adapter/search/shared/AdapterSearchTypes.ts +++ b/src/debug/jtag/commands/adapter/search/shared/AdapterSearchTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -87,6 +88,7 @@ export const createAdapterSearchParams = ( sort?: AdapterSortBy; } ): AdapterSearchParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -129,6 +131,7 @@ export const createAdapterSearchResult = ( error?: JTAGError; } ): AdapterSearchResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, results: data.results ?? [], totalCount: data.totalCount ?? 0, query: data.query ?? '', diff --git a/src/debug/jtag/commands/adapter/try/shared/AdapterTryTypes.ts b/src/debug/jtag/commands/adapter/try/shared/AdapterTryTypes.ts index 24a21f4a9..988401808 100644 --- a/src/debug/jtag/commands/adapter/try/shared/AdapterTryTypes.ts +++ b/src/debug/jtag/commands/adapter/try/shared/AdapterTryTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; // Simple error type for result transport export interface AdapterTryError { type: string; @@ -45,6 +46,7 @@ export const createAdapterTryParams = ( maxTokens?: number; } ): AdapterTryParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, scale: data.scale ?? 0, maxTokens: data.maxTokens ?? 0, ...data @@ -93,6 +95,7 @@ export const createAdapterTryResult = ( error?: AdapterTryError; } ): AdapterTryResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, adapterId: data.adapterId ?? '', baselineOutput: data.baselineOutput ?? '', adapterOutput: data.adapterOutput ?? '', diff --git a/src/debug/jtag/commands/ai/adapter/test/server/AdapterTestServerCommand.ts b/src/debug/jtag/commands/ai/adapter/test/server/AdapterTestServerCommand.ts index d1e86fc5c..24f02cb1a 100644 --- a/src/debug/jtag/commands/ai/adapter/test/server/AdapterTestServerCommand.ts +++ b/src/debug/jtag/commands/ai/adapter/test/server/AdapterTestServerCommand.ts @@ -422,12 +422,12 @@ export class AdapterTestServerCommand extends CommandBase; + success: boolean; + content?: string; + error?: string; + durationMs: number; + }]; + iterations: number; // Tool loop iterations + tokenUsage?: { input: number; output: number }; + model?: string; + provider?: string; + durationMs: number; +} +``` + +## Sentinel Pipeline Usage + +```json +{ + "type": "llm", + "prompt": "List all files and summarize the project structure", + "agentMode": true, + "tools": ["code/tree", "code/read"], + "model": "claude-sonnet-4-5-20250929", + "provider": "anthropic" +} +``` + +When `agentMode: true`, the Rust LLM step routes to this command via CommandExecutor IPC. + +## Safety + +- Tiered safety caps: 25 iterations (frontier), 10 (native tools), 5 (XML/local) +- Loop detection: identical tool calls within 60s are blocked +- Tool name/param correction: handles LLM confusion automatically +- Admin-gated: personas cannot invoke this command (prevents recursive loops) diff --git a/src/debug/jtag/commands/ai/agent/browser/AiAgentBrowserCommand.ts b/src/debug/jtag/commands/ai/agent/browser/AiAgentBrowserCommand.ts new file mode 100644 index 000000000..94b69284f --- /dev/null +++ b/src/debug/jtag/commands/ai/agent/browser/AiAgentBrowserCommand.ts @@ -0,0 +1,22 @@ +/** + * AI Agent Command - Browser Implementation + * ========================================== + * + * Browser delegates to server for agentic loop execution. + * All LLM calls and tool execution happen server-side. + */ + +import { AiAgentCommand } from '../shared/AiAgentCommand'; +import type { JTAGContext } from '../../../../system/core/types/JTAGTypes'; +import type { ICommandDaemon } from '../../../../daemons/command-daemon/shared/CommandBase'; +import type { AiAgentParams, AiAgentResult } from '../shared/AiAgentTypes'; + +export class AiAgentBrowserCommand extends AiAgentCommand { + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super(context, subpath, commander); + } + + async execute(params: AiAgentParams): Promise { + return this.remoteExecute(params, 'ai/agent', 'server'); + } +} diff --git a/src/debug/jtag/commands/ai/agent/server/AiAgentServerCommand.ts b/src/debug/jtag/commands/ai/agent/server/AiAgentServerCommand.ts new file mode 100644 index 000000000..8cf52e0de --- /dev/null +++ b/src/debug/jtag/commands/ai/agent/server/AiAgentServerCommand.ts @@ -0,0 +1,356 @@ +/** + * AI Agent Command - Server Implementation + * ========================================= + * + * Universal agentic loop extracted from PersonaResponseGenerator. + * Generates text, parses tool calls, executes tools, feeds results back, + * and re-generates until the model stops calling tools. + * + * Model-adaptive: + * - Safety caps tiered by provider (25/10/5) + * - Tool format: native JSON (Anthropic/OpenAI) or XML (DeepSeek/local) + * - Context window aware via AICapabilityRegistry + * + * Used by: + * - Sentinel pipelines (LLM step with agentMode=true, via CommandExecutor IPC) + * - Direct invocation (./jtag ai/agent --prompt="..." --tools='["code/tree"]') + */ + +import { AiAgentCommand } from '../shared/AiAgentCommand'; +import type { JTAGContext } from '../../../../system/core/types/JTAGTypes'; +import type { ICommandDaemon } from '../../../../daemons/command-daemon/shared/CommandBase'; +import type { AiAgentParams, AiAgentResult, ToolCallRecord } from '../shared/AiAgentTypes'; +import { createAiAgentResult } from '../shared/AiAgentTypes'; +import { AIProviderDaemon } from '../../../../daemons/ai-provider-daemon/shared/AIProviderDaemon'; +import type { + TextGenerationRequest, + ChatMessage, + ContentPart, + NativeToolSpec, + ToolCall as NativeToolCall, + ToolResult as NativeToolResult, +} from '../../../../daemons/ai-provider-daemon/shared/AIProviderTypesV2'; +import { AgentToolExecutor } from '../../../../system/tools/server/AgentToolExecutor'; +import type { ToolCallContext } from '../../../../system/tools/server/AgentToolExecutor'; +import { + getAllToolDefinitionsAsync, + type ToolAccessLevel, +} from '../../../../system/user/server/modules/PersonaToolDefinitions'; +import { + convertToNativeToolSpecs, + supportsNativeTools, + sanitizeToolName, + coerceParamsToSchema, + getToolCapability, + getPrimaryAdapter, + type ToolDefinition as AdapterToolDefinition, +} from '../../../../system/user/server/modules/ToolFormatAdapter'; +import { generateUUID } from '../../../../system/core/types/CrossPlatformUUID'; +import { LOCAL_MODELS } from '../../../../system/shared/Constants'; + +/** Default safety caps by provider tier */ +function getSafetyMax(provider: string): number { + if (['anthropic', 'openai', 'azure'].includes(provider)) return 25; + if (supportsNativeTools(provider)) return 10; + return 5; +} + +export class AiAgentServerCommand extends AiAgentCommand { + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super(context, subpath, commander); + } + + async execute(params: AiAgentParams): Promise { + const start = Date.now(); + const allToolCallRecords: ToolCallRecord[] = []; + + try { + // ── 1. Build messages ──────────────────────────────────────── + const messages: ChatMessage[] = []; + + if (params.systemPrompt) { + messages.push({ role: 'system', content: params.systemPrompt }); + } + + if (params.messages && params.messages.length > 0) { + messages.push(...params.messages); + } else if (params.prompt) { + messages.push({ role: 'user', content: params.prompt }); + } else { + return createAiAgentResult(params, { + success: false, + error: 'Either prompt or messages is required', + }); + } + + // ── 2. Resolve model + provider ────────────────────────────── + const provider = params.provider || 'anthropic'; + const model = params.model || ( + provider === 'anthropic' ? 'claude-sonnet-4-5-20250929' : + provider === 'candle' || provider === 'ollama' ? LOCAL_MODELS.DEFAULT : + 'claude-sonnet-4-5-20250929' + ); + + // ── 3. Resolve tools ───────────────────────────────────────── + const toolCap = getToolCapability(provider); + const useNative = supportsNativeTools(provider); + + let toolDefinitions: AdapterToolDefinition[] = []; + let nativeToolSpecs: NativeToolSpec[] | undefined; + + if (toolCap !== 'none') { + // Get all public tool definitions + const allDefs = await getAllToolDefinitionsAsync('public' as ToolAccessLevel); + + // Filter to subset if specified + if (params.tools !== undefined) { + if (params.tools.length === 0) { + // Explicit empty = no tools + toolDefinitions = []; + } else { + const toolSet = new Set(params.tools); + toolDefinitions = allDefs + .filter(t => toolSet.has(t.name)) + .map(t => ({ + name: t.name, + description: t.description, + parameters: t.parameters, + category: t.category, + })); + } + } else { + // undefined = all public tools + toolDefinitions = allDefs.map(t => ({ + name: t.name, + description: t.description, + parameters: t.parameters, + category: t.category, + })); + } + + // Convert to native format if provider supports it + if (useNative && toolDefinitions.length > 0) { + nativeToolSpecs = convertToNativeToolSpecs(toolDefinitions); + } + + // For XML providers, inject tool docs into system prompt + if (!useNative && toolDefinitions.length > 0) { + const adapter = getPrimaryAdapter(); + const toolDocs = adapter.formatToolsForPrompt(toolDefinitions); + // Prepend to first system message or create one + const sysIdx = messages.findIndex(m => m.role === 'system'); + if (sysIdx >= 0) { + const existing = typeof messages[sysIdx].content === 'string' + ? messages[sysIdx].content as string + : ''; + messages[sysIdx] = { + ...messages[sysIdx], + content: existing + '\n\n' + toolDocs, + }; + } else { + messages.unshift({ role: 'system', content: toolDocs }); + } + } + } + + // ── 4. Build generation request ────────────────────────────── + const request: TextGenerationRequest = { + messages, + model, + temperature: params.temperature ?? 0.7, + maxTokens: params.maxTokens ?? 4096, + provider, + tools: nativeToolSpecs, + toolChoice: nativeToolSpecs && nativeToolSpecs.length > 0 ? 'auto' : undefined, + }; + + // ── 5. Initial generation ──────────────────────────────────── + let response = await AIProviderDaemon.generateText(request); + + // ── 6. Agentic tool loop ───────────────────────────────────── + const safetyMax = params.maxIterations ?? getSafetyMax(provider); + let iterations = 0; + const executor = new AgentToolExecutor(); + + // Build execution context for tool calls + // callerId: sentinel handle or caller userId, sessionId for session scope, + // contextId: generated per-invocation (no persistent room/conversation scope) + const callCtx: ToolCallContext = { + callerId: params.sentinelHandle ?? params.userId ?? params.sessionId ?? generateUUID(), + sessionId: params.sessionId ?? generateUUID(), + contextId: generateUUID(), + context: params.context, + }; + + while (iterations < safetyMax) { + // Check for tool calls (native first, then XML fallback) + // ONE Rust IPC call replaces 3 separate sync TS calls (parse + correct + strip) + const hasNative = response.toolCalls && response.toolCalls.length > 0; + const parsed = !hasNative ? await executor.parseResponse(response.text) : null; + const hasXml = parsed !== null && parsed.toolCalls.length > 0; + + if (!hasNative && !hasXml) { + // Model chose to stop — no more tool calls + break; + } + + iterations++; + + if (hasNative || (useNative && hasXml)) { + // ── Native tool protocol (Anthropic, OpenAI, Groq, Together, etc.) ── + // Handles both: + // 1. Adapter returned structured tool_calls (normal case) + // 2. Model output tool calls in text, Rust parsed them (Groq/Llama case) + let nativeCalls: NativeToolCall[]; + if (hasNative) { + nativeCalls = response.toolCalls!; + } else { + // Synthesize native format from text-parsed calls + // Coerce params to match schema types (e.g. string "true" → boolean true) + // so the API doesn't reject tool_use blocks on regeneration + const toolSpecs = nativeToolSpecs ?? []; + nativeCalls = parsed!.toolCalls.map((tc, i) => { + const name = sanitizeToolName(tc.toolName); + return { + id: `synth_${Date.now()}_${i}`, + name, + input: coerceParamsToSchema(tc.parameters, toolSpecs, name), + }; + }); + } + + // Execute tools + const toolStart = Date.now(); + const batchResult = await executor.executeNativeToolCalls(nativeCalls, callCtx); + + // Record tool calls + for (let i = 0; i < nativeCalls.length; i++) { + const nc = nativeCalls[i]; + const nr = batchResult.results[i]; + allToolCallRecords.push({ + toolName: nc.name, + params: nc.input as Record, + success: !nr.isError, + content: !nr.isError ? nr.content : undefined, + error: nr.isError ? nr.content : undefined, + durationMs: Date.now() - toolStart, + }); + } + + // Push assistant message with tool_use content blocks + // Use adapter's content if native tool calls, synthesize if text-parsed + const assistantContent: ContentPart[] = hasNative + ? (response.content ?? [ + ...(response.text ? [{ type: 'text' as const, text: response.text }] : []), + ...nativeCalls.map(tc => ({ + type: 'tool_use' as const, + id: tc.id, + name: tc.name, + input: tc.input, + })), + ]) + : [ + ...(parsed!.cleanedText ? [{ type: 'text' as const, text: parsed!.cleanedText }] : []), + ...nativeCalls.map(tc => ({ + type: 'tool_use' as const, + id: tc.id, + name: tc.name, + input: tc.input, + })), + ]; + messages.push({ role: 'assistant', content: assistantContent }); + + // Push tool results as user message + const toolResultContent: ContentPart[] = batchResult.results.map(r => ({ + type: 'tool_result' as const, + tool_use_id: r.toolUseId, + content: r.content, + is_error: r.isError ?? null, + })); + messages.push({ role: 'user', content: toolResultContent }); + + } else if (hasXml) { + // ── XML path for non-native providers ────────────────── + const xmlCalls = parsed!.toolCalls; + + const toolStart = Date.now(); + const xmlResult = await executor.executeXmlToolCalls(xmlCalls, callCtx); + + for (const tc of xmlCalls) { + allToolCallRecords.push({ + toolName: tc.toolName, + params: tc.parameters, + success: true, // XML batch doesn't report per-tool errors easily + durationMs: Date.now() - toolStart, + }); + } + + // Use pre-parsed cleaned text from Rust IPC + const explanationText = parsed!.cleanedText; + messages.push({ role: 'assistant', content: explanationText }); + + // Full tool results as user message + messages.push({ role: 'user', content: xmlResult.formattedResults }); + } + + // ── Regenerate ────────────────────────────────────────── + // After 3 consecutive iterations, disable tools to force a text summary. + // Small models loop on tools indefinitely without summarizing. + const forceText = iterations >= 3 || iterations >= safetyMax - 1; + const regenerated = await AIProviderDaemon.generateText({ + ...request, + messages, + tools: forceText ? undefined : request.tools, + toolChoice: forceText ? undefined : request.toolChoice, + }); + + if (!regenerated.text && !regenerated.toolCalls?.length) { + // Regeneration returned nothing — use model's explanation text from before tool calls + const fallback = await executor.parseResponse(response.text); + response.text = fallback.cleanedText; + break; + } + + response = regenerated; + + // If we forced text (tools disabled), break — don't let the parser + // re-detect tool-call-like text and continue the loop + if (forceText) break; + } + + // Always strip any remaining tool call text from the final response + if (iterations > 0 && response.text) { + const finalCleaned = await executor.parseResponse(response.text); + if (finalCleaned.toolCalls.length > 0) { + response.text = finalCleaned.cleanedText; + } + } + + // ── 7. Return result ───────────────────────────────────────── + return createAiAgentResult(params, { + success: true, + text: response.text?.trim() || '', + toolCalls: allToolCallRecords, + iterations, + tokenUsage: response.usage ? { + input: response.usage.inputTokens, + output: response.usage.outputTokens, + } : undefined, + model: response.model, + provider: response.provider, + durationMs: Date.now() - start, + }); + + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + return createAiAgentResult(params, { + success: false, + text: '', + toolCalls: allToolCallRecords, + iterations: 0, + error: errorMsg, + durationMs: Date.now() - start, + }); + } + } +} diff --git a/src/debug/jtag/commands/ai/agent/shared/AiAgentCommand.ts b/src/debug/jtag/commands/ai/agent/shared/AiAgentCommand.ts new file mode 100644 index 000000000..dad54485f --- /dev/null +++ b/src/debug/jtag/commands/ai/agent/shared/AiAgentCommand.ts @@ -0,0 +1,19 @@ +/** + * AI Agent Command - Shared Abstract Base + * ======================================== + * + * Universal agentic loop command. Server-only — browser delegates. + */ + +import { CommandBase } from '../../../../daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '../../../../system/core/types/JTAGTypes'; +import type { ICommandDaemon } from '../../../../daemons/command-daemon/shared/CommandBase'; +import type { AiAgentParams, AiAgentResult } from './AiAgentTypes'; + +export abstract class AiAgentCommand extends CommandBase { + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('ai-agent', context, subpath, commander); + } + + abstract execute(params: AiAgentParams): Promise; +} diff --git a/src/debug/jtag/commands/ai/agent/shared/AiAgentTypes.ts b/src/debug/jtag/commands/ai/agent/shared/AiAgentTypes.ts new file mode 100644 index 000000000..03fd69f8c --- /dev/null +++ b/src/debug/jtag/commands/ai/agent/shared/AiAgentTypes.ts @@ -0,0 +1,132 @@ +/** + * AI Agent Command Types + * ====================== + * + * Universal agentic loop: generate -> parse tool calls -> execute tools -> + * feed results -> re-generate. Model decides when to stop. + * + * Used by: + * - Sentinel pipelines (LLM step with agentMode=true) + * - Future autonomous agents + * - Direct invocation via ./jtag ai/agent + */ + +import type { CommandParams, JTAGPayload, CommandInput } from '../../../../system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; +import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; +import type { ChatMessage } from '../../../../daemons/ai-provider-daemon/shared/AIProviderTypesV2'; +import { Commands } from '../../../../system/core/shared/Commands'; + +// ─── Params ────────────────────────────────────────────────────────── + +export interface AiAgentParams extends CommandParams { + /** Simple text prompt (converted to single user message) */ + prompt?: string; + + /** Full message array (overrides prompt) */ + messages?: ChatMessage[]; + + /** System prompt injected as first message */ + systemPrompt?: string; + + // ─── Model config ────────────────────────────────────────────── + + /** Model ID (e.g., 'claude-sonnet-4-5-20250929', 'llama-3.1-8b') */ + model?: string; + + /** Provider (e.g., 'anthropic', 'openai', 'together', 'ollama') */ + provider?: string; + + /** Sampling temperature */ + temperature?: number; + + /** Max tokens for generation */ + maxTokens?: number; + + // ─── Tool config ─────────────────────────────────────────────── + + /** Tool subset: undefined = all public, [] = none, ['code/tree', 'code/read'] = specific */ + tools?: string[]; + + /** Override safety cap for tool iterations */ + maxIterations?: number; + + // ─── Attribution ─────────────────────────────────────────────── + + /** Sentinel handle for log correlation */ + sentinelHandle?: string; +} + +// ─── Result ────────────────────────────────────────────────────────── + +/** Record of a single tool call made during execution */ +export interface ToolCallRecord { + toolName: string; + params: Record; + success: boolean; + content?: string; + error?: string; + durationMs: number; +} + +export interface AiAgentResult extends JTAGPayload { + readonly success: boolean; + + /** Final response text from the LLM */ + readonly text: string; + + /** All tool calls made during execution */ + readonly toolCalls: ToolCallRecord[]; + + /** Number of agent loop iterations */ + readonly iterations: number; + + /** Token usage if available */ + readonly tokenUsage?: { input: number; output: number }; + + /** Actual model used */ + readonly model?: string; + + /** Actual provider used */ + readonly provider?: string; + + /** Total execution time in milliseconds */ + readonly durationMs: number; + + readonly error?: string; +} + +// ─── Helpers ───────────────────────────────────────────────────────── + +export const createAiAgentParams = ( + context: import('../../../../system/core/types/JTAGTypes').JTAGContext, + sessionId: UUID, + data: Omit +): AiAgentParams => createPayload(context, sessionId, data); + +export const createAiAgentResult = ( + params: AiAgentParams, + overrides: Omit, 'context' | 'sessionId'> +): AiAgentResult => transformPayload(params, { + success: false, + text: '', + toolCalls: [], + iterations: 0, + durationMs: 0, + ...overrides, +}); + +/** + * AiAgent — Type-safe command executor + * + * Usage: + * import { AiAgent } from '...shared/AiAgentTypes'; + * const result = await AiAgent.execute({ prompt: 'List files', tools: ['code/tree'] }); + */ +export const AiAgent = { + execute(params: CommandInput): Promise { + return Commands.execute('ai/agent', params as Partial); + }, + commandName: 'ai/agent' as const, +} as const; diff --git a/src/debug/jtag/commands/ai/bag-of-words/shared/BagOfWordsTypes.ts b/src/debug/jtag/commands/ai/bag-of-words/shared/BagOfWordsTypes.ts index 0eef2ca87..a28d2e0a0 100644 --- a/src/debug/jtag/commands/ai/bag-of-words/shared/BagOfWordsTypes.ts +++ b/src/debug/jtag/commands/ai/bag-of-words/shared/BagOfWordsTypes.ts @@ -11,7 +11,7 @@ import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; /** - * Parameters for bag-of-words command + * Orchestrate a multi-persona conversation in a chat room, selecting which AI personas participate and how they take turns responding. */ export interface BagOfWordsParams extends CommandParams { /** diff --git a/src/debug/jtag/commands/ai/context/search/shared/AiContextSearchTypes.ts b/src/debug/jtag/commands/ai/context/search/shared/AiContextSearchTypes.ts index f38d3bb86..6c9075270 100644 --- a/src/debug/jtag/commands/ai/context/search/shared/AiContextSearchTypes.ts +++ b/src/debug/jtag/commands/ai/context/search/shared/AiContextSearchTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; @@ -95,6 +96,7 @@ export const createAiContextSearchParams = ( mode?: string; } ): AiContextSearchParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, collections: data.collections ?? undefined, personaId: data.personaId ?? '', excludeContextId: data.excludeContextId ?? '', @@ -136,6 +138,7 @@ export const createAiContextSearchResult = ( error?: JTAGError; } ): AiContextSearchResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, items: data.items ?? [], totalMatches: data.totalMatches ?? 0, durationMs: data.durationMs ?? 0, diff --git a/src/debug/jtag/commands/ai/context/slice/shared/AiContextSliceTypes.ts b/src/debug/jtag/commands/ai/context/slice/shared/AiContextSliceTypes.ts index 51663fe06..62cd4aced 100644 --- a/src/debug/jtag/commands/ai/context/slice/shared/AiContextSliceTypes.ts +++ b/src/debug/jtag/commands/ai/context/slice/shared/AiContextSliceTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import type { CollectionName } from '../../../context/search/shared/AiContextSearchTypes'; @@ -69,6 +70,7 @@ export const createAiContextSliceParams = ( relatedLimit?: number; } ): AiContextSliceParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, personaId: data.personaId ?? '', includeRelated: data.includeRelated ?? false, relatedLimit: data.relatedLimit ?? 0, @@ -102,6 +104,7 @@ export const createAiContextSliceResult = ( error?: JTAGError; } ): AiContextSliceResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, item: data.item ?? null, durationMs: data.durationMs ?? 0, ...data diff --git a/src/debug/jtag/commands/ai/cost/server/AICostServerCommand.ts b/src/debug/jtag/commands/ai/cost/server/AICostServerCommand.ts index f982a8d51..1a183763d 100644 --- a/src/debug/jtag/commands/ai/cost/server/AICostServerCommand.ts +++ b/src/debug/jtag/commands/ai/cost/server/AICostServerCommand.ts @@ -67,7 +67,7 @@ export class AICostServerCommand extends AICostCommand { const totalTokens = finalGens.reduce((sum: number, gen: AIGenerationEntity) => sum + gen.totalTokens, 0); const inputTokens = finalGens.reduce((sum: number, gen: AIGenerationEntity) => sum + gen.inputTokens, 0); const outputTokens = finalGens.reduce((sum: number, gen: AIGenerationEntity) => sum + gen.outputTokens, 0); - const totalResponseTime = finalGens.reduce((sum: number, gen: AIGenerationEntity) => sum + gen.responseTime, 0); + const totalResponseTime = finalGens.reduce((sum: number, gen: AIGenerationEntity) => sum + gen.responseTimeMs, 0); const summary = { totalCost, @@ -228,7 +228,7 @@ export class AICostServerCommand extends AICostCommand { } // Sort response times - const responseTimes = generations.map(g => g.responseTime).sort((a, b) => a - b); + const responseTimes = generations.map(g => g.responseTimeMs).sort((a, b) => a - b); const sum = responseTimes.reduce((acc, val) => acc + val, 0); const avg = Math.round(sum / responseTimes.length); @@ -284,7 +284,7 @@ export class AICostServerCommand extends AICostCommand { const cost = bucketGens.reduce((sum, g) => sum + g.estimatedCost, 0); const tokens = bucketGens.reduce((sum, g) => sum + g.totalTokens, 0); - const totalResponseTime = bucketGens.reduce((sum, g) => sum + g.responseTime, 0); + const totalResponseTime = bucketGens.reduce((sum, g) => sum + g.responseTimeMs, 0); points.push({ timestamp: new Date(bucketStart).toISOString(), diff --git a/src/debug/jtag/commands/ai/cost/shared/AICostTypes.ts b/src/debug/jtag/commands/ai/cost/shared/AICostTypes.ts index 9ab0ce6f7..c3b4d9290 100644 --- a/src/debug/jtag/commands/ai/cost/shared/AICostTypes.ts +++ b/src/debug/jtag/commands/ai/cost/shared/AICostTypes.ts @@ -17,7 +17,7 @@ export interface AICostParams extends CommandParams { // Entity filtering provider?: string; // Filter by provider: "openai", "anthropic", "candle", "deepseek" model?: string; // Filter by model: "gpt-4", "claude-3-opus", etc. - userId?: UUID; // Filter by user (human or AI) + filterUserId?: UUID; // Filter by user (human or AI) roomId?: UUID; // Filter by room purpose?: string; // Filter by purpose: "chat", "should-respond", "rag", etc. diff --git a/src/debug/jtag/commands/ai/dataset/create/shared/DatasetCreateTypes.ts b/src/debug/jtag/commands/ai/dataset/create/shared/DatasetCreateTypes.ts index 721e23668..fedaf9bb1 100644 --- a/src/debug/jtag/commands/ai/dataset/create/shared/DatasetCreateTypes.ts +++ b/src/debug/jtag/commands/ai/dataset/create/shared/DatasetCreateTypes.ts @@ -7,6 +7,9 @@ import { createPayload, type JTAGContext } from '../../../../../system/core/type import type { UUID } from '../../../../../system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; +/** + * Create compressed training dataset archives from fine-tuning projects for LoRA adapter training. + */ export interface DatasetCreateParams extends CommandParams { /** Project ID to archive (if omitted, archives all enabled projects) */ project?: string; diff --git a/src/debug/jtag/commands/ai/dataset/list/shared/DatasetListTypes.ts b/src/debug/jtag/commands/ai/dataset/list/shared/DatasetListTypes.ts index 5588bbc00..3d8878518 100644 --- a/src/debug/jtag/commands/ai/dataset/list/shared/DatasetListTypes.ts +++ b/src/debug/jtag/commands/ai/dataset/list/shared/DatasetListTypes.ts @@ -8,6 +8,9 @@ import type { UUID } from '../../../../../system/core/types/CrossPlatformUUID'; import type { DatasetArchiveInfo } from '../../shared/DatasetConfig'; import { Commands } from '../../../../../system/core/shared/Commands'; +/** + * List available training dataset archives with optional filtering and detailed manifest information. + */ export interface DatasetListParams extends CommandParams { /** Filter by output path */ path?: string; diff --git a/src/debug/jtag/commands/ai/detect-semantic-loop/shared/AiDetectSemanticLoopTypes.ts b/src/debug/jtag/commands/ai/detect-semantic-loop/shared/AiDetectSemanticLoopTypes.ts index 982e22e86..329ad2dd2 100644 --- a/src/debug/jtag/commands/ai/detect-semantic-loop/shared/AiDetectSemanticLoopTypes.ts +++ b/src/debug/jtag/commands/ai/detect-semantic-loop/shared/AiDetectSemanticLoopTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '../../../../system/core/types/ErrorTypes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -49,6 +50,7 @@ export const createAiDetectSemanticLoopParams = ( roomId?: string; } ): AiDetectSemanticLoopParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, lookbackCount: data.lookbackCount ?? 0, similarityThreshold: data.similarityThreshold ?? 0, timeWindowMinutes: data.timeWindowMinutes ?? 0, @@ -95,6 +97,7 @@ export const createAiDetectSemanticLoopResult = ( error?: JTAGError; } ): AiDetectSemanticLoopResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, isLoop: data.isLoop ?? false, maxSimilarity: data.maxSimilarity ?? 0, matches: data.matches ?? [], diff --git a/src/debug/jtag/commands/ai/embedding/generate/shared/EmbeddingGenerateTypes.ts b/src/debug/jtag/commands/ai/embedding/generate/shared/EmbeddingGenerateTypes.ts index 38f8b2f02..cda5af1d2 100644 --- a/src/debug/jtag/commands/ai/embedding/generate/shared/EmbeddingGenerateTypes.ts +++ b/src/debug/jtag/commands/ai/embedding/generate/shared/EmbeddingGenerateTypes.ts @@ -8,7 +8,7 @@ import type { CommandParams, CommandResult, CommandInput} from '../../../../../s import { Commands } from '../../../../../system/core/shared/Commands'; /** - * Parameters for ai/embedding/generate command + * Generate vector embeddings from text or code for use in semantic search, similarity matching, and RAG retrieval. */ export interface EmbeddingGenerateParams extends CommandParams { /** Text to generate embedding for (or array of texts) */ diff --git a/src/debug/jtag/commands/ai/generate/server/AIGenerateServerCommand.ts b/src/debug/jtag/commands/ai/generate/server/AIGenerateServerCommand.ts index dbd8f6703..b24f37e92 100644 --- a/src/debug/jtag/commands/ai/generate/server/AIGenerateServerCommand.ts +++ b/src/debug/jtag/commands/ai/generate/server/AIGenerateServerCommand.ts @@ -69,7 +69,8 @@ export class AIGenerateServerCommand extends AIGenerateCommand { maxMessages: params.maxMessages || 20, includeArtifacts: params.includeArtifacts ?? true, includeMemories: params.includeMemories ?? true, - triggeringTimestamp: Date.now() // Preview shows current state (no race filtering for manual preview) + triggeringTimestamp: Date.now(), // Preview shows current state (no race filtering for manual preview) + maxTokens: params.maxTokens ?? 2000, } ); @@ -121,7 +122,7 @@ export class AIGenerateServerCommand extends AIGenerateCommand { model: params.model || LOCAL_MODELS.DEFAULT, temperature: params.temperature ?? 0.7, maxTokens: params.maxTokens ?? 150, - preferredProvider: params.preferredProvider || 'candle', + provider: params.provider || 'candle', personaContext: { uniqueId: targetPersonaId, displayName: ragContext.identity?.name || personaDisplayName, @@ -157,7 +158,7 @@ export class AIGenerateServerCommand extends AIGenerateCommand { const response = await AIProviderDaemon.generateText(request); const result = responseToResult(response, params); - console.log(`✅ AI Generate: Generated ${result.usage?.outputTokens} tokens in ${result.responseTime}ms`); + console.log(`✅ AI Generate: Generated ${result.usage?.outputTokens} tokens in ${result.responseTimeMs}ms`); return result; } catch (error) { diff --git a/src/debug/jtag/commands/ai/generate/shared/AIGenerateCommand.ts b/src/debug/jtag/commands/ai/generate/shared/AIGenerateCommand.ts index 8405935c2..3ed8d70e6 100644 --- a/src/debug/jtag/commands/ai/generate/shared/AIGenerateCommand.ts +++ b/src/debug/jtag/commands/ai/generate/shared/AIGenerateCommand.ts @@ -52,7 +52,7 @@ export abstract class AIGenerateCommand extends CommandBase { @@ -16,6 +17,6 @@ export class GenomeStatsBrowserCommand extends CommandBase { // Browser delegates entirely to server - return await this.remoteExecute(params); + return await this.remoteExecute({ ...params, userId: SYSTEM_SCOPES.SYSTEM }); } } diff --git a/src/debug/jtag/commands/ai/key/test/server/AiKeyTestServerCommand.ts b/src/debug/jtag/commands/ai/key/test/server/AiKeyTestServerCommand.ts index c4afd9804..40f6da4a7 100644 --- a/src/debug/jtag/commands/ai/key/test/server/AiKeyTestServerCommand.ts +++ b/src/debug/jtag/commands/ai/key/test/server/AiKeyTestServerCommand.ts @@ -177,7 +177,7 @@ export class AiKeyTestServerCommand extends CommandBase createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -49,7 +51,7 @@ export interface AiKeyTestResult extends CommandResult { // Provider that was tested provider: string; // Response time in milliseconds - responseTime: number; + responseTimeMs: number; // Error message if key is invalid (optional) errorMessage?: string; // Available models for this key (optional) @@ -66,14 +68,15 @@ export const createAiKeyTestResult = ( success: boolean; valid?: boolean; provider?: string; - responseTime?: number; + responseTimeMs?: number; errorMessage?: string; models?: string[]; } ): AiKeyTestResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, valid: data.valid ?? false, provider: data.provider ?? '', - responseTime: data.responseTime ?? 0, + responseTimeMs: data.responseTimeMs ?? 0, ...data }); diff --git a/src/debug/jtag/commands/ai/model/find/shared/ModelFindTypes.ts b/src/debug/jtag/commands/ai/model/find/shared/ModelFindTypes.ts index 262d2c4d3..b6f3e096c 100644 --- a/src/debug/jtag/commands/ai/model/find/shared/ModelFindTypes.ts +++ b/src/debug/jtag/commands/ai/model/find/shared/ModelFindTypes.ts @@ -10,7 +10,7 @@ import type { ModelCapabilities, ModelInfo } from '../../list/shared/ModelListTy import { Commands } from '../../../../../system/core/shared/Commands'; /** - * Model find params + * Find the best available AI model matching a set of capability requirements, with optional fallback to the closest match. */ export interface ModelFindParams extends CommandParams { // Capability requirements diff --git a/src/debug/jtag/commands/ai/model/list/shared/ModelListTypes.ts b/src/debug/jtag/commands/ai/model/list/shared/ModelListTypes.ts index c9ab51737..7bd7994fe 100644 --- a/src/debug/jtag/commands/ai/model/list/shared/ModelListTypes.ts +++ b/src/debug/jtag/commands/ai/model/list/shared/ModelListTypes.ts @@ -22,8 +22,7 @@ export interface ModelCapabilities { // Feature requirements supportsJSON?: boolean; // Structured output - supportsToolCalling?: boolean; // Function calling - supportsFunctionCalling?: boolean; // Same as tool calling + supportsToolCalling?: boolean; // Tool/function calling supportsStreaming?: boolean; // Streaming responses // Context requirements @@ -68,7 +67,7 @@ export interface ModelInfo { } /** - * Model list params + * Enumerate all available AI models across providers, with optional filtering by capabilities, provider, and availability. */ export interface ModelListParams extends CommandParams { // Optional filtering diff --git a/src/debug/jtag/commands/ai/mute/shared/AIMuteTypes.ts b/src/debug/jtag/commands/ai/mute/shared/AIMuteTypes.ts index a6f2fb856..f9a2e8a90 100644 --- a/src/debug/jtag/commands/ai/mute/shared/AIMuteTypes.ts +++ b/src/debug/jtag/commands/ai/mute/shared/AIMuteTypes.ts @@ -26,7 +26,7 @@ export interface AIMuteParams extends CommandParams { /** Target AI (by persona uniqueId or UUID) */ readonly persona?: string; // uniqueId (e.g., "helper-ai") - readonly userId?: UUID; // Or by UUID + readonly targetUserId?: UUID; // Or by UUID /** Mute details */ readonly reason: string; diff --git a/src/debug/jtag/commands/ai/rag/index-codebase/shared/CodebaseIndexTypes.ts b/src/debug/jtag/commands/ai/rag/index-codebase/shared/CodebaseIndexTypes.ts index 3696287f1..feae68514 100644 --- a/src/debug/jtag/commands/ai/rag/index-codebase/shared/CodebaseIndexTypes.ts +++ b/src/debug/jtag/commands/ai/rag/index-codebase/shared/CodebaseIndexTypes.ts @@ -10,7 +10,7 @@ import type { CodeExportType } from '../../../../../system/data/entities/CodeInd import { Commands } from '../../../../../system/core/shared/Commands'; /** - * Parameters for rag/index-codebase command + * Crawl and index TypeScript, Markdown, and other source files into the RAG store with domain-specific embeddings for semantic code search. */ export interface CodebaseIndexParams extends CommandParams { /** Directories or files to index (relative to repo root) */ diff --git a/src/debug/jtag/commands/ai/rag/inspect/server/RAGInspectServerCommand.ts b/src/debug/jtag/commands/ai/rag/inspect/server/RAGInspectServerCommand.ts index a1837ea95..334cc43b3 100644 --- a/src/debug/jtag/commands/ai/rag/inspect/server/RAGInspectServerCommand.ts +++ b/src/debug/jtag/commands/ai/rag/inspect/server/RAGInspectServerCommand.ts @@ -31,7 +31,8 @@ export class RAGInspectServerCommand extends RAGInspectCommand { { maxMessages: params.maxMessages ?? 20, includeArtifacts: params.includeArtifacts ?? true, - includeMemories: params.includeMemories ?? true + includeMemories: params.includeMemories ?? true, + maxTokens: params.maxTokens ?? 2000, } ); diff --git a/src/debug/jtag/commands/ai/rag/inspect/shared/RAGInspectTypes.ts b/src/debug/jtag/commands/ai/rag/inspect/shared/RAGInspectTypes.ts index 537493f8f..a8763599d 100644 --- a/src/debug/jtag/commands/ai/rag/inspect/shared/RAGInspectTypes.ts +++ b/src/debug/jtag/commands/ai/rag/inspect/shared/RAGInspectTypes.ts @@ -11,7 +11,7 @@ import type { RAGContext } from '../../../../../system/rag/shared/RAGTypes'; import { Commands } from '../../../../../system/core/shared/Commands'; /** - * Parameters for rag/inspect command + * Inspect the RAG context that would be built for a given persona in a specific room, including decision-point analysis and learning mode diagnostics. */ export interface RAGInspectParams extends CommandParams { /** Room/context ID to build RAG for */ @@ -30,6 +30,8 @@ export interface RAGInspectParams extends CommandParams { includeMemories?: boolean; /** Optional: Show full RAG content (like ping --verbose) */ + /** Optional: Max tokens for context building */ + maxTokens?: number; verbose?: boolean; /** Optional: Message ID that triggered evaluation (for decision-point analysis) */ diff --git a/src/debug/jtag/commands/ai/report/decisions/shared/DecisionReportTypes.ts b/src/debug/jtag/commands/ai/report/decisions/shared/DecisionReportTypes.ts index fd7859d73..f68204de6 100644 --- a/src/debug/jtag/commands/ai/report/decisions/shared/DecisionReportTypes.ts +++ b/src/debug/jtag/commands/ai/report/decisions/shared/DecisionReportTypes.ts @@ -9,6 +9,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '../../../../../system/core/types/JTAGTypes'; import type { UUID } from '../../../../../system/core/types/CrossPlatformUUID'; +import { SYSTEM_SCOPES } from '../../../../../system/core/types/SystemScopes'; import type { DecisionAction } from '../../../../../system/data/entities/CoordinationDecisionEntity'; import type { JTAGError } from '../../../../../system/core/types/ErrorTypes'; import { Commands } from '../../../../../system/core/shared/Commands'; @@ -95,6 +96,7 @@ export const createDecisionReportParams = ( groupByActor?: boolean; } ): DecisionReportParams => ({ + userId: SYSTEM_SCOPES.SYSTEM, context, sessionId, startDate: data.startDate, diff --git a/src/debug/jtag/commands/ai/should-respond/server/AIShouldRespondServerCommand.ts b/src/debug/jtag/commands/ai/should-respond/server/AIShouldRespondServerCommand.ts index 877300c89..cfac7c7fd 100644 --- a/src/debug/jtag/commands/ai/should-respond/server/AIShouldRespondServerCommand.ts +++ b/src/debug/jtag/commands/ai/should-respond/server/AIShouldRespondServerCommand.ts @@ -51,7 +51,7 @@ export class AIShouldRespondServerCommand extends AIShouldRespondCommand { model: params.model ?? LOCAL_MODELS.DEFAULT, // Candle uses pre-loaded model temperature: 0.3, maxTokens: 200, - preferredProvider: 'candle' + provider: 'candle' }; const response = await AIProviderDaemon.generateText(request); @@ -75,7 +75,7 @@ export class AIShouldRespondServerCommand extends AIShouldRespondCommand { model: LOCAL_MODELS.DEFAULT, // Candle uses pre-loaded model temperature: 0.1, // Low temp for structured output maxTokens: 200, - preferredProvider: 'candle' + provider: 'candle' }; const fixedResponse = await AIProviderDaemon.generateText(fixRequest); diff --git a/src/debug/jtag/commands/ai/sleep/server/AiSleepServerCommand.ts b/src/debug/jtag/commands/ai/sleep/server/AiSleepServerCommand.ts index 74ccf845a..94d45628d 100644 --- a/src/debug/jtag/commands/ai/sleep/server/AiSleepServerCommand.ts +++ b/src/debug/jtag/commands/ai/sleep/server/AiSleepServerCommand.ts @@ -9,7 +9,8 @@ import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared import type { JTAGContext } from '@system/core/types/JTAGTypes'; import type { AiSleepParams, AiSleepResult, SleepMode } from '../shared/AiSleepTypes'; import { createAiSleepResultFromParams } from '../shared/AiSleepTypes'; -import { UserIdentityResolver } from '@system/user/shared/UserIdentityResolver'; +import { RustCoreIPCClient } from '../../../../workers/continuum-core/bindings/RustCoreIPC'; +import type { SleepMode as RustSleepMode } from '../../../../shared/generated'; /** * Sleep state for a persona @@ -174,38 +175,8 @@ export class AiSleepServerCommand extends CommandBase { + client.cognitionSetSleepMode(targetPersonaId, mode as RustSleepMode, reason, durationMinutes) + .catch(err => console.warn(`⚠️ ai/sleep: Rust sync failed (non-fatal): ${err}`)); + }).catch(() => { /* Rust not available yet — will sync on next fullEvaluate */ }); + return createAiSleepResultFromParams(params, { success: true, previousMode, diff --git a/src/debug/jtag/commands/ai/sleep/shared/AiSleepTypes.ts b/src/debug/jtag/commands/ai/sleep/shared/AiSleepTypes.ts index 7012cfd89..14930582d 100644 --- a/src/debug/jtag/commands/ai/sleep/shared/AiSleepTypes.ts +++ b/src/debug/jtag/commands/ai/sleep/shared/AiSleepTypes.ts @@ -14,6 +14,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '../../../../system/core/types/ErrorTypes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -50,6 +51,7 @@ export const createAiSleepParams = ( personaId?: string; } ): AiSleepParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, reason: data.reason ?? '', durationMinutes: data.durationMinutes ?? 0, personaId: data.personaId ?? '', @@ -93,6 +95,7 @@ export const createAiSleepResult = ( error?: JTAGError; } ): AiSleepResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, previousMode: data.previousMode ?? 'active', newMode: data.newMode ?? 'active', wakesAt: data.wakesAt ?? null, diff --git a/src/debug/jtag/commands/ai/thoughtstream/server/ThoughtStreamServerCommand.ts b/src/debug/jtag/commands/ai/thoughtstream/server/ThoughtStreamServerCommand.ts index a2f5ca637..0ed7a2e0b 100644 --- a/src/debug/jtag/commands/ai/thoughtstream/server/ThoughtStreamServerCommand.ts +++ b/src/debug/jtag/commands/ai/thoughtstream/server/ThoughtStreamServerCommand.ts @@ -102,6 +102,7 @@ export class ThoughtStreamServerCommand extends ThoughtStreamCommand { stream.contextId, thought.personaId, { + maxTokens: 2000, maxMessages: 20, maxMemories: 0, includeArtifacts: false, @@ -393,6 +394,7 @@ export class ThoughtStreamServerCommand extends ThoughtStreamCommand { entry.roomId, personaId, { + maxTokens: 2000, maxMessages: 20, maxMemories: 0, includeArtifacts: false, @@ -434,7 +436,7 @@ export class ThoughtStreamServerCommand extends ThoughtStreamCommand { action: e.action as 'POSTED' | 'ERROR' | 'SILENT', responseText: e.action === 'POSTED' ? e.messageContent : undefined, error: e.action === 'ERROR' ? e.reason : undefined, - responseTime: e.timestamp.getTime() + responseTimeMs: e.timestamp.getTime() })); const decision: ThoughtStreamDecision = { diff --git a/src/debug/jtag/commands/ai/thoughtstream/shared/ThoughtStreamTypes.ts b/src/debug/jtag/commands/ai/thoughtstream/shared/ThoughtStreamTypes.ts index 541ad4063..c36e0a865 100644 --- a/src/debug/jtag/commands/ai/thoughtstream/shared/ThoughtStreamTypes.ts +++ b/src/debug/jtag/commands/ai/thoughtstream/shared/ThoughtStreamTypes.ts @@ -66,7 +66,7 @@ export interface ThoughtStreamDecision { personaName: string; action: 'POSTED' | 'TIMEOUT' | 'ERROR' | 'REDUNDANT' | 'SILENT'; responseText?: string; - responseTime?: number; + responseTimeMs?: number; error?: string; }>; diff --git a/src/debug/jtag/commands/ai/validate-response/server/AIValidateResponseServerCommand.ts b/src/debug/jtag/commands/ai/validate-response/server/AIValidateResponseServerCommand.ts index f64c6e5c6..bc96885a6 100644 --- a/src/debug/jtag/commands/ai/validate-response/server/AIValidateResponseServerCommand.ts +++ b/src/debug/jtag/commands/ai/validate-response/server/AIValidateResponseServerCommand.ts @@ -30,7 +30,7 @@ export class AIValidateResponseServerCommand extends CommandBase({ - collection: UserEntity.collection, - filter: { id: creatorId }, - limit: 1, - context: strokeParams.context, - sessionId: strokeParams.sessionId - }); - creatorName = userResult.success && userResult.items && userResult.items.length > 0 - ? userResult.items[0].displayName - : 'Unknown'; - } else { - // FALLBACK: Use UserIdentityResolver (CLI, Claude Code, Joel, etc.) - const identity = await UserIdentityResolver.resolve(); - creatorId = identity.userId || strokeParams.sessionId; - creatorName = identity.displayName; - } + // Get creator info from params.userId (auto-injected by infrastructure) + const creatorId: string = strokeParams.userId; + const userResult = await DataList.execute({ + collection: UserEntity.collection, + filter: { id: creatorId }, + limit: 1, + context: strokeParams.context, + sessionId: strokeParams.sessionId + }); + const creatorName = userResult.success && userResult.items && userResult.items.length > 0 + ? userResult.items[0].displayName + : 'Unknown'; // Create stroke entity const stroke = new CanvasStrokeEntity(); diff --git a/src/debug/jtag/commands/canvas/stroke/add/shared/CanvasStrokeAddTypes.ts b/src/debug/jtag/commands/canvas/stroke/add/shared/CanvasStrokeAddTypes.ts index f8da4fb44..3ecab2ab5 100644 --- a/src/debug/jtag/commands/canvas/stroke/add/shared/CanvasStrokeAddTypes.ts +++ b/src/debug/jtag/commands/canvas/stroke/add/shared/CanvasStrokeAddTypes.ts @@ -12,6 +12,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import type { CanvasTool, StrokePoint } from '@system/data/entities/CanvasStrokeEntity'; import { Commands } from '../../../../../system/core/shared/Commands'; @@ -60,6 +61,7 @@ export const createCanvasStrokeAddResult = ( sessionId: UUID, data: Omit, 'context' | 'sessionId'> ): CanvasStrokeAddResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: true, ...data }); diff --git a/src/debug/jtag/commands/canvas/stroke/list/shared/CanvasStrokeListTypes.ts b/src/debug/jtag/commands/canvas/stroke/list/shared/CanvasStrokeListTypes.ts index c08fc038c..3f835a63c 100644 --- a/src/debug/jtag/commands/canvas/stroke/list/shared/CanvasStrokeListTypes.ts +++ b/src/debug/jtag/commands/canvas/stroke/list/shared/CanvasStrokeListTypes.ts @@ -7,6 +7,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import type { CanvasTool, StrokePoint, StrokeBounds } from '@system/data/entities/CanvasStrokeEntity'; import { Commands } from '../../../../../system/core/shared/Commands'; @@ -73,6 +74,7 @@ export const createCanvasStrokeListResult = ( sessionId: UUID, data: Omit, 'context' | 'sessionId'> ): CanvasStrokeListResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: true, ...data }); diff --git a/src/debug/jtag/commands/canvas/vision/server/CanvasVisionServerCommand.ts b/src/debug/jtag/commands/canvas/vision/server/CanvasVisionServerCommand.ts index 2dc72058a..75914f286 100644 --- a/src/debug/jtag/commands/canvas/vision/server/CanvasVisionServerCommand.ts +++ b/src/debug/jtag/commands/canvas/vision/server/CanvasVisionServerCommand.ts @@ -103,7 +103,7 @@ export class CanvasVisionServerCommand extends CommandBase, 'context' | 'sessionId' | 'action'> ): CanvasVisionResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: true, action, ...data diff --git a/src/debug/jtag/commands/code/diff/shared/CodeDiffTypes.ts b/src/debug/jtag/commands/code/diff/shared/CodeDiffTypes.ts index dd99414c6..431dd2687 100644 --- a/src/debug/jtag/commands/code/diff/shared/CodeDiffTypes.ts +++ b/src/debug/jtag/commands/code/diff/shared/CodeDiffTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -65,6 +66,7 @@ export const createCodeDiffParams = ( content?: string; } ): CodeDiffParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, search: data.search ?? '', replace: data.replace ?? '', replaceAll: data.replaceAll ?? false, @@ -99,6 +101,7 @@ export const createCodeDiffResult = ( error?: JTAGError; } ): CodeDiffResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, unified: data.unified ?? '', ...data }); diff --git a/src/debug/jtag/commands/code/edit/shared/CodeEditTypes.ts b/src/debug/jtag/commands/code/edit/shared/CodeEditTypes.ts index b6af24c4f..0041ef32c 100644 --- a/src/debug/jtag/commands/code/edit/shared/CodeEditTypes.ts +++ b/src/debug/jtag/commands/code/edit/shared/CodeEditTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -69,6 +70,7 @@ export const createCodeEditParams = ( description?: string; } ): CodeEditParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, search: data.search ?? '', replace: data.replace ?? '', replaceAll: data.replaceAll ?? false, @@ -112,6 +114,7 @@ export const createCodeEditResult = ( error?: JTAGError; } ): CodeEditResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, changeId: data.changeId ?? '', filePath: data.filePath ?? '', bytesWritten: data.bytesWritten ?? 0, diff --git a/src/debug/jtag/commands/code/git/shared/CodeGitTypes.ts b/src/debug/jtag/commands/code/git/shared/CodeGitTypes.ts index e63e144b2..6a16964c3 100644 --- a/src/debug/jtag/commands/code/git/shared/CodeGitTypes.ts +++ b/src/debug/jtag/commands/code/git/shared/CodeGitTypes.ts @@ -8,6 +8,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -119,6 +120,7 @@ export const createCodeGitResult = ( error?: JTAGError; } ): CodeGitResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, summary: data.summary ?? '', ...data }); diff --git a/src/debug/jtag/commands/code/history/shared/CodeHistoryTypes.ts b/src/debug/jtag/commands/code/history/shared/CodeHistoryTypes.ts index 712685a69..1509f2ad5 100644 --- a/src/debug/jtag/commands/code/history/shared/CodeHistoryTypes.ts +++ b/src/debug/jtag/commands/code/history/shared/CodeHistoryTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -34,6 +35,7 @@ export const createCodeHistoryParams = ( limit?: number; } ): CodeHistoryParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, filePath: data.filePath ?? '', limit: data.limit ?? 0, ...data @@ -66,6 +68,7 @@ export const createCodeHistoryResult = ( error?: JTAGError; } ): CodeHistoryResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, nodes: data.nodes ?? [], totalCount: data.totalCount ?? 0, ...data diff --git a/src/debug/jtag/commands/code/read/shared/CodeReadTypes.ts b/src/debug/jtag/commands/code/read/shared/CodeReadTypes.ts index b832ab970..e87c3d016 100644 --- a/src/debug/jtag/commands/code/read/shared/CodeReadTypes.ts +++ b/src/debug/jtag/commands/code/read/shared/CodeReadTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -37,6 +38,7 @@ export const createCodeReadParams = ( endLine?: number; } ): CodeReadParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, startLine: data.startLine ?? 0, endLine: data.endLine ?? 0, ...data @@ -89,6 +91,7 @@ export const createCodeReadResult = ( error?: JTAGError; } ): CodeReadResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, content: data.content ?? '', filePath: data.filePath ?? '', totalLines: data.totalLines ?? 0, diff --git a/src/debug/jtag/commands/code/search/shared/CodeSearchTypes.ts b/src/debug/jtag/commands/code/search/shared/CodeSearchTypes.ts index f0144f9b2..d522d287e 100644 --- a/src/debug/jtag/commands/code/search/shared/CodeSearchTypes.ts +++ b/src/debug/jtag/commands/code/search/shared/CodeSearchTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -38,6 +39,7 @@ export const createCodeSearchParams = ( maxResults?: number; } ): CodeSearchParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, fileGlob: data.fileGlob ?? '', maxResults: data.maxResults ?? 0, ...data @@ -74,6 +76,7 @@ export const createCodeSearchResult = ( error?: JTAGError; } ): CodeSearchResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, matches: data.matches ?? [], totalMatches: data.totalMatches ?? 0, filesSearched: data.filesSearched ?? 0, diff --git a/src/debug/jtag/commands/code/shell/execute/shared/CodeShellExecuteTypes.ts b/src/debug/jtag/commands/code/shell/execute/shared/CodeShellExecuteTypes.ts index 16aca5e93..df69e243d 100644 --- a/src/debug/jtag/commands/code/shell/execute/shared/CodeShellExecuteTypes.ts +++ b/src/debug/jtag/commands/code/shell/execute/shared/CodeShellExecuteTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -38,6 +39,7 @@ export const createCodeShellExecuteParams = ( timeoutMs?: number; } ): CodeShellExecuteParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, wait: data.wait ?? false, timeoutMs: data.timeoutMs ?? 0, ...data @@ -82,6 +84,7 @@ export const createCodeShellExecuteResult = ( error?: JTAGError; } ): CodeShellExecuteResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, executionId: data.executionId ?? '', status: data.status ?? 'running' as ShellExecutionStatus, stdout: data.stdout, diff --git a/src/debug/jtag/commands/code/shell/kill/shared/CodeShellKillTypes.ts b/src/debug/jtag/commands/code/shell/kill/shared/CodeShellKillTypes.ts index 5bbbf048e..97584764c 100644 --- a/src/debug/jtag/commands/code/shell/kill/shared/CodeShellKillTypes.ts +++ b/src/debug/jtag/commands/code/shell/kill/shared/CodeShellKillTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -29,6 +30,7 @@ export const createCodeShellKillParams = ( executionId: string; } ): CodeShellKillParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -60,6 +62,7 @@ export const createCodeShellKillResult = ( error?: JTAGError; } ): CodeShellKillResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, executionId: data.executionId ?? '', killed: data.killed ?? false, ...data diff --git a/src/debug/jtag/commands/code/shell/sentinel/shared/CodeShellSentinelTypes.ts b/src/debug/jtag/commands/code/shell/sentinel/shared/CodeShellSentinelTypes.ts index 2a16127b2..4beddce53 100644 --- a/src/debug/jtag/commands/code/shell/sentinel/shared/CodeShellSentinelTypes.ts +++ b/src/debug/jtag/commands/code/shell/sentinel/shared/CodeShellSentinelTypes.ts @@ -8,6 +8,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -34,6 +35,7 @@ export const createCodeShellSentinelParams = ( rules: SentinelRule[]; } ): CodeShellSentinelParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -62,6 +64,7 @@ export const createCodeShellSentinelResult = ( error?: JTAGError; } ): CodeShellSentinelResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, applied: data.applied ?? false, ruleCount: data.ruleCount ?? 0, ...data diff --git a/src/debug/jtag/commands/code/shell/status/shared/CodeShellStatusTypes.ts b/src/debug/jtag/commands/code/shell/status/shared/CodeShellStatusTypes.ts index 4abb7c135..c1b7ef9e9 100644 --- a/src/debug/jtag/commands/code/shell/status/shared/CodeShellStatusTypes.ts +++ b/src/debug/jtag/commands/code/shell/status/shared/CodeShellStatusTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -25,6 +26,7 @@ export const createCodeShellStatusParams = ( sessionId: UUID, data: Record ): CodeShellStatusParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -72,6 +74,7 @@ export const createCodeShellStatusResult = ( error?: JTAGError; } ): CodeShellStatusResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, shellSessionId: data.shellSessionId ?? '', personaId: data.personaId ?? '', cwd: data.cwd ?? '', diff --git a/src/debug/jtag/commands/code/shell/watch/shared/CodeShellWatchTypes.ts b/src/debug/jtag/commands/code/shell/watch/shared/CodeShellWatchTypes.ts index 168e38b6f..1ca338fc6 100644 --- a/src/debug/jtag/commands/code/shell/watch/shared/CodeShellWatchTypes.ts +++ b/src/debug/jtag/commands/code/shell/watch/shared/CodeShellWatchTypes.ts @@ -7,6 +7,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -30,6 +31,7 @@ export const createCodeShellWatchParams = ( executionId: string; } ): CodeShellWatchParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -64,6 +66,7 @@ export const createCodeShellWatchResult = ( error?: JTAGError; } ): CodeShellWatchResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, executionId: data.executionId ?? '', lines: data.lines ?? [], finished: data.finished ?? false, diff --git a/src/debug/jtag/commands/code/tree/shared/CodeTreeTypes.ts b/src/debug/jtag/commands/code/tree/shared/CodeTreeTypes.ts index 989a6c06f..8f68dd7e5 100644 --- a/src/debug/jtag/commands/code/tree/shared/CodeTreeTypes.ts +++ b/src/debug/jtag/commands/code/tree/shared/CodeTreeTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -38,6 +39,7 @@ export const createCodeTreeParams = ( includeHidden?: boolean; } ): CodeTreeParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, path: data.path ?? '', maxDepth: data.maxDepth ?? 0, includeHidden: data.includeHidden ?? false, @@ -75,6 +77,7 @@ export const createCodeTreeResult = ( error?: JTAGError; } ): CodeTreeResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, root: data.root ?? null, totalFiles: data.totalFiles ?? 0, totalDirectories: data.totalDirectories ?? 0, diff --git a/src/debug/jtag/commands/code/undo/shared/CodeUndoTypes.ts b/src/debug/jtag/commands/code/undo/shared/CodeUndoTypes.ts index 734602185..1a5c7ef15 100644 --- a/src/debug/jtag/commands/code/undo/shared/CodeUndoTypes.ts +++ b/src/debug/jtag/commands/code/undo/shared/CodeUndoTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -34,6 +35,7 @@ export const createCodeUndoParams = ( count?: number; } ): CodeUndoParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, changeId: data.changeId ?? '', count: data.count ?? 0, ...data @@ -62,6 +64,7 @@ export const createCodeUndoResult = ( error?: JTAGError; } ): CodeUndoResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, changesUndone: data.changesUndone ?? [], ...data }); diff --git a/src/debug/jtag/commands/code/verify/shared/CodeVerifyTypes.ts b/src/debug/jtag/commands/code/verify/shared/CodeVerifyTypes.ts index 19d1eab15..de9d4837e 100644 --- a/src/debug/jtag/commands/code/verify/shared/CodeVerifyTypes.ts +++ b/src/debug/jtag/commands/code/verify/shared/CodeVerifyTypes.ts @@ -7,6 +7,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -46,6 +47,7 @@ export const createCodeVerifyParams = ( cwd?: string; } ): CodeVerifyParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, typeCheck: data.typeCheck ?? true, testFiles: data.testFiles ?? [], cwd: data.cwd ?? '', @@ -98,6 +100,7 @@ export const createCodeVerifyResult = ( error?: JTAGError; } ): CodeVerifyResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, durationMs: data.durationMs ?? 0, output: data.output ?? '', ...data diff --git a/src/debug/jtag/commands/code/write/shared/CodeWriteTypes.ts b/src/debug/jtag/commands/code/write/shared/CodeWriteTypes.ts index d45696d81..c446796cf 100644 --- a/src/debug/jtag/commands/code/write/shared/CodeWriteTypes.ts +++ b/src/debug/jtag/commands/code/write/shared/CodeWriteTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -37,6 +38,7 @@ export const createCodeWriteParams = ( description?: string; } ): CodeWriteParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, description: data.description ?? '', ...data }); @@ -72,6 +74,7 @@ export const createCodeWriteResult = ( error?: JTAGError; } ): CodeWriteResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, changeId: data.changeId ?? '', filePath: data.filePath ?? '', bytesWritten: data.bytesWritten ?? 0, diff --git a/src/debug/jtag/commands/collaboration/activity/create/server/ActivityCreateServerCommand.ts b/src/debug/jtag/commands/collaboration/activity/create/server/ActivityCreateServerCommand.ts index 4abceb36b..b3baa74bf 100644 --- a/src/debug/jtag/commands/collaboration/activity/create/server/ActivityCreateServerCommand.ts +++ b/src/debug/jtag/commands/collaboration/activity/create/server/ActivityCreateServerCommand.ts @@ -37,14 +37,8 @@ export class ActivityCreateServerCommand extends CommandBase { - // FIRST: Check if caller's userId is in the context (PersonaUsers set this) - if (params.context?.userId) { - console.log('🔧 ChatSendServerCommand.findCallerIdentity USING CONTEXT userId', { userId: params.context.userId }); - return this.findUserById(params.context.userId, params); - } - - // FALLBACK: Use UserIdentityResolver to detect calling process (Claude Code, human, etc.) - const identity = await UserIdentityResolver.resolve(); - - console.log('🔧 ChatSendServerCommand.findCallerIdentity DETECTED', { - uniqueId: identity.uniqueId, - displayName: identity.displayName, - type: identity.type, - exists: identity.exists, - agentName: identity.agentInfo.name - }); - - // If user exists in database, return it - if (identity.exists && identity.userId) { - const result = await DataList.execute({ - collection: UserEntity.collection, - filter: { id: identity.userId }, - limit: 1, - context: params.context, - sessionId: params.sessionId - } - ); - - if (result.success && result.items && result.items.length > 0) { - const user = result.items[0]; - return { id: user.id, entity: user }; - } - } - - // User doesn't exist - throw error with helpful message - throw new Error( - `Detected caller: ${identity.displayName} (${identity.uniqueId}) but user not found in database. ` + - `Run seed script to create users.` - ); - } /** * Process media file paths into MediaItem objects diff --git a/src/debug/jtag/commands/collaboration/decision/create/server/DecisionCreateServerCommand.ts b/src/debug/jtag/commands/collaboration/decision/create/server/DecisionCreateServerCommand.ts index f4cc1d8e4..817f972fc 100644 --- a/src/debug/jtag/commands/collaboration/decision/create/server/DecisionCreateServerCommand.ts +++ b/src/debug/jtag/commands/collaboration/decision/create/server/DecisionCreateServerCommand.ts @@ -12,7 +12,6 @@ import type { DecisionCreateParams, DecisionCreateResult } from '../shared/Decis import { createDecisionCreateResultFromParams } from '../shared/DecisionCreateTypes'; import { DecisionEntity } from '@system/data/entities/DecisionEntity'; import type { DecisionOption } from '@system/data/entities/DecisionEntity'; -import { UserIdentityResolver } from '@system/user/shared/UserIdentityResolver'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '@system/core/shared/Commands'; import { UserEntity } from '@system/data/entities/UserEntity'; @@ -90,8 +89,8 @@ export class DecisionCreateServerCommand extends CommandBase { - // FIRST: Check if caller's userId is in the context (PersonaUsers set this) - if (params.context?.userId) { - const result = await DataList.execute({ - collection: UserEntity.collection, - filter: { id: params.context.userId }, - limit: 1, - context: params.context, - sessionId: params.sessionId - }); - - if (result.success && result.items && result.items.length > 0) { - console.log('🔧 DecisionCreateServerCommand.findCallerIdentity USING CONTEXT userId', { userId: params.context.userId }); - return result.items[0]; - } - } - - // FALLBACK: Resolve caller identity via process detection (async) - const identity = await UserIdentityResolver.resolve(); - const uniqueId = identity.uniqueId; - - // Find user by uniqueId in database + private async findUserById(userId: UUID, params: DecisionCreateParams): Promise<{ id: UUID; displayName: string }> { const result = await DataList.execute({ collection: UserEntity.collection, - filter: { uniqueId }, + filter: { id: userId }, limit: 1, context: params.context, sessionId: params.sessionId }); if (!result.success || !result.items || result.items.length === 0) { - throw new Error(`Caller identity not found in database: ${identity.displayName} (uniqueId: ${uniqueId})`); + throw new Error(`User not found: ${userId}`); } return result.items[0]; diff --git a/src/debug/jtag/commands/collaboration/decision/create/shared/DecisionCreateTypes.ts b/src/debug/jtag/commands/collaboration/decision/create/shared/DecisionCreateTypes.ts index cba93de8b..9b6bb380b 100644 --- a/src/debug/jtag/commands/collaboration/decision/create/shared/DecisionCreateTypes.ts +++ b/src/debug/jtag/commands/collaboration/decision/create/shared/DecisionCreateTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import type { DecisionOption } from '@system/data/entities/DecisionEntity'; @@ -62,6 +63,7 @@ export const createDecisionCreateParams = ( visibility: string; } ): DecisionCreateParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -103,6 +105,7 @@ export const createDecisionCreateResult = ( error?: JTAGError; } ): DecisionCreateResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data, success: data.success ?? false, proposalId: data.proposalId ?? '', diff --git a/src/debug/jtag/commands/collaboration/decision/finalize/shared/DecisionFinalizeTypes.ts b/src/debug/jtag/commands/collaboration/decision/finalize/shared/DecisionFinalizeTypes.ts index 88c328d2b..73c11d300 100644 --- a/src/debug/jtag/commands/collaboration/decision/finalize/shared/DecisionFinalizeTypes.ts +++ b/src/debug/jtag/commands/collaboration/decision/finalize/shared/DecisionFinalizeTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import type { RoundResult } from '@system/data/entities/DecisionEntity'; @@ -30,6 +31,7 @@ export const createDecisionFinalizeParams = ( proposalId: string; } ): DecisionFinalizeParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -78,6 +80,7 @@ export const createDecisionFinalizeResult = ( error?: JTAGError; } ): DecisionFinalizeResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data, success: data.success ?? false, winner: data.winner ?? null, diff --git a/src/debug/jtag/commands/collaboration/decision/list/shared/DecisionListTypes.ts b/src/debug/jtag/commands/collaboration/decision/list/shared/DecisionListTypes.ts index 8757d3cd2..7d2e58964 100644 --- a/src/debug/jtag/commands/collaboration/decision/list/shared/DecisionListTypes.ts +++ b/src/debug/jtag/commands/collaboration/decision/list/shared/DecisionListTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import type { DecisionEntity } from '@system/data/entities/DecisionEntity'; @@ -46,6 +47,7 @@ export const createDecisionListParams = ( offset?: number; } ): DecisionListParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -86,6 +88,7 @@ export const createDecisionListResult = ( error?: JTAGError; } ): DecisionListResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: data.success ?? false, proposals: data.proposals ?? [], total: data.total ?? 0, diff --git a/src/debug/jtag/commands/collaboration/decision/propose/server/DecisionProposeServerCommand.ts b/src/debug/jtag/commands/collaboration/decision/propose/server/DecisionProposeServerCommand.ts index 39fee2ac8..e3c0d649f 100644 --- a/src/debug/jtag/commands/collaboration/decision/propose/server/DecisionProposeServerCommand.ts +++ b/src/debug/jtag/commands/collaboration/decision/propose/server/DecisionProposeServerCommand.ts @@ -20,7 +20,7 @@ import { COLLECTIONS } from '@system/shared/Constants'; import { DecisionProposeCommand } from '../shared/DecisionProposeCommand'; import type { DecisionProposeParams, DecisionProposeResult } from '../shared/DecisionProposeTypes'; -// Caller identity now comes from context.userId - no need for callerId/personaId injection +// Caller identity now comes from params.userId - no need for callerId/personaId injection import type { DecisionProposalEntity, DecisionOption } from '@system/data/entities/DecisionProposalEntity'; import type { UserEntity } from '@system/data/entities/UserEntity'; import type { DataListParams, DataListResult } from '@commands/data/list/shared/DataListTypes'; @@ -28,7 +28,6 @@ import type { DataReadParams, DataReadResult } from '@commands/data/read/shared/ import type { DataCreateParams, DataCreateResult } from '@commands/data/create/shared/DataCreateTypes'; import type { ChatSendParams, ChatSendResult } from '@commands/collaboration/chat/send/shared/ChatSendTypes'; import { Logger } from '@system/core/logging/Logger'; -import { UserIdentityResolver } from '@system/user/shared/UserIdentityResolver'; import { DataList } from '../../../../data/list/shared/DataListTypes'; import { DataRead } from '../../../../data/read/shared/DataReadTypes'; @@ -291,46 +290,18 @@ export class DecisionProposeServerCommand extends DecisionProposeCommand { } } - // Get proposer info - auto-detect caller identity - // Priority: 1) context.userId (PersonaUsers), 2) UserIdentityResolver (CLI) - let proposerId: UUID; - let proposerName: string; - - if (params.context?.userId) { - // FIRST: Check context.userId (PersonaUsers set this) - const proposerResult = await DataRead.execute({ - collection: COLLECTIONS.USERS, - id: params.context.userId - }); - - if (!proposerResult.success || !proposerResult.data) { - return transformPayload(params, { success: false, error: 'Could not find proposer user from context' }); - } - - proposerId = params.context.userId; - proposerName = proposerResult.data.displayName; - this.log.debug('Using context.userId for proposer', { proposerId, proposerName }); - } else { - // FALLBACK: Auto-detect caller identity using UserIdentityResolver (CLI calls) - const identity = await UserIdentityResolver.resolve(); - - this.log.debug('Auto-detected proposer identity', { - uniqueId: identity.uniqueId, - displayName: identity.displayName, - type: identity.type, - exists: identity.exists - }); - - if (!identity.exists || !identity.userId) { - return transformPayload(params, { - success: false, - error: `Detected caller: ${identity.displayName} (${identity.uniqueId}) but user not found in database. Run seed script to create users.` - }); - } + // Get proposer info from params.userId (auto-injected by infrastructure) + const proposerResult = await DataRead.execute({ + collection: COLLECTIONS.USERS, + id: params.userId + }); - proposerId = identity.userId; - proposerName = identity.displayName; + if (!proposerResult.success || !proposerResult.data) { + return transformPayload(params, { success: false, error: `User not found: ${params.userId}` }); } + + const proposerId: UUID = params.userId; + const proposerName: string = proposerResult.data.displayName; const scope = params.scope || 'all'; const significanceLevel = params.significanceLevel || 'medium'; const proposalId = generateUUID(); diff --git a/src/debug/jtag/commands/collaboration/decision/propose/shared/DecisionProposeTypes.ts b/src/debug/jtag/commands/collaboration/decision/propose/shared/DecisionProposeTypes.ts index d8b365556..d4d69f93c 100644 --- a/src/debug/jtag/commands/collaboration/decision/propose/shared/DecisionProposeTypes.ts +++ b/src/debug/jtag/commands/collaboration/decision/propose/shared/DecisionProposeTypes.ts @@ -37,7 +37,7 @@ export interface DecisionProposeParams extends CommandParams { /** How urgent is this? Determines response window */ significanceLevel?: SignificanceLevel; // Default: 'medium' - // Proposer identity comes from context.userId - no need for explicit proposerId param + // Proposer identity comes from params.userId - no need for explicit proposerId param /** Chat room context where proposal originated */ contextId?: UUID; // Default: inferred from session diff --git a/src/debug/jtag/commands/collaboration/decision/rank/server/DecisionRankServerCommand.ts b/src/debug/jtag/commands/collaboration/decision/rank/server/DecisionRankServerCommand.ts index faaf7ff4c..58b86a1d0 100644 --- a/src/debug/jtag/commands/collaboration/decision/rank/server/DecisionRankServerCommand.ts +++ b/src/debug/jtag/commands/collaboration/decision/rank/server/DecisionRankServerCommand.ts @@ -27,7 +27,6 @@ import type { DataListParams, DataListResult } from '@commands/data/list/shared/ import type { DataUpdateParams, DataUpdateResult } from '@commands/data/update/shared/DataUpdateTypes'; import { calculateCondorcetWinner } from '@system/shared/CondorcetUtils'; import { Logger } from '@system/core/logging/Logger'; -import { UserIdentityResolver } from '@system/user/shared/UserIdentityResolver'; import { DataRead } from '../../../../data/read/shared/DataReadTypes'; import { DataList } from '../../../../data/list/shared/DataListTypes'; @@ -82,47 +81,19 @@ export class DecisionRankServerCommand extends DecisionRankCommand { return transformPayload(params, { success: false, error: 'Ranked choices are required' }); } - // Get voter info - auto-detect caller identity - // Priority: 1) context.userId (PersonaUsers), 2) UserIdentityResolver (CLI) - let voterId: UUID; - let voterName: string; - - if (params.context?.userId) { - // FIRST: Check context.userId (PersonaUsers set this) - const voterResult = await DataRead.execute({ - collection: COLLECTIONS.USERS, - id: params.context.userId - }); - - if (!voterResult.success || !voterResult.data) { - return transformPayload(params, { success: false, error: 'Could not find voter user from context' }); - } - - voterId = params.context.userId; - voterName = voterResult.data.displayName; - this.log.debug('Using context.userId for voter', { voterId, voterName }); - } else { - // FALLBACK: Auto-detect caller identity using UserIdentityResolver (CLI calls) - const identity = await UserIdentityResolver.resolve(); - - this.log.debug('Auto-detected voter identity', { - uniqueId: identity.uniqueId, - displayName: identity.displayName, - type: identity.type, - exists: identity.exists - }); - - if (!identity.exists || !identity.userId) { - return transformPayload(params, { - success: false, - error: `Detected caller: ${identity.displayName} (${identity.uniqueId}) but user not found in database. Run seed script to create users.` - }); - } + // Get voter info from params.userId (auto-injected by infrastructure) + const voterResult = await DataRead.execute({ + collection: COLLECTIONS.USERS, + id: params.userId + }); - voterId = identity.userId; - voterName = identity.displayName; + if (!voterResult.success || !voterResult.data) { + return transformPayload(params, { success: false, error: `User not found: ${params.userId}` }); } + const voterId: UUID = params.userId; + const voterName: string = voterResult.data.displayName; + // Resolve short IDs to full UUIDs using CrossPlatformUUID utilities const { isShortId, normalizeShortId } = await import('@system/core/types/CrossPlatformUUID'); let resolvedProposalId = params.proposalId; diff --git a/src/debug/jtag/commands/collaboration/decision/rank/shared/DecisionRankTypes.ts b/src/debug/jtag/commands/collaboration/decision/rank/shared/DecisionRankTypes.ts index b7952d32c..d51026d53 100644 --- a/src/debug/jtag/commands/collaboration/decision/rank/shared/DecisionRankTypes.ts +++ b/src/debug/jtag/commands/collaboration/decision/rank/shared/DecisionRankTypes.ts @@ -1,16 +1,12 @@ -/** - * decision/rank - Types - * Submit ranked-choice vote for a decision proposal - */ - import type { UUID } from '@system/core/types/CrossPlatformUUID'; import type { CommandParams, CommandResult, CommandInput} from '@system/core/types/JTAGTypes'; import { Commands } from '../../../../../system/core/shared/Commands'; +/** Submit a ranked-choice vote on a decision proposal, using Condorcet pairwise comparison to determine the winner. */ export interface DecisionRankParams extends CommandParams { proposalId: UUID; rankedChoices: string[]; // Array of option IDs in preference order (first = most preferred) - // Voter identity comes from context.userId - no need for explicit voterId param + // Voter identity comes from params.userId - no need for explicit voterId param } export interface DecisionRankResult extends CommandResult { diff --git a/src/debug/jtag/commands/collaboration/decision/view/shared/DecisionViewTypes.ts b/src/debug/jtag/commands/collaboration/decision/view/shared/DecisionViewTypes.ts index 95c34209e..07eef96c4 100644 --- a/src/debug/jtag/commands/collaboration/decision/view/shared/DecisionViewTypes.ts +++ b/src/debug/jtag/commands/collaboration/decision/view/shared/DecisionViewTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import type { DecisionEntity } from '@system/data/entities/DecisionEntity'; @@ -30,6 +31,7 @@ export const createDecisionViewParams = ( proposalId: string; } ): DecisionViewParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -62,6 +64,7 @@ export const createDecisionViewResult = ( error?: JTAGError; } ): DecisionViewResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: data.success ?? false, proposal: data.proposal ?? null, summary: data.summary ?? '', diff --git a/src/debug/jtag/commands/collaboration/decision/vote/server/DecisionVoteServerCommand.ts b/src/debug/jtag/commands/collaboration/decision/vote/server/DecisionVoteServerCommand.ts index 77ab8a158..dcedda8b5 100644 --- a/src/debug/jtag/commands/collaboration/decision/vote/server/DecisionVoteServerCommand.ts +++ b/src/debug/jtag/commands/collaboration/decision/vote/server/DecisionVoteServerCommand.ts @@ -10,14 +10,12 @@ import type { JTAGContext } from '@system/core/types/JTAGTypes'; import type { DecisionVoteParams, DecisionVoteResult } from '../shared/DecisionVoteTypes'; import { createDecisionVoteResultFromParams } from '../shared/DecisionVoteTypes'; -// Caller identity now comes from context.userId - no need for callerId/personaId injection import type { DecisionProposalEntity } from '@system/data/entities/DecisionProposalEntity'; import { COLLECTIONS } from '@system/shared/Constants'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '@system/core/shared/Commands'; import type { DataListParams, DataListResult } from '@commands/data/list/shared/DataListTypes'; import type { DataUpdateParams, DataUpdateResult } from '@commands/data/update/shared/DataUpdateTypes'; -import { UserIdentityResolver } from '@system/user/shared/UserIdentityResolver'; import { UserEntity } from '@system/data/entities/UserEntity'; import { DataUpdate } from '../../../../data/update/shared/DataUpdateTypes'; @@ -29,8 +27,8 @@ export class DecisionVoteServerCommand extends CommandBase { - // 1. Get voter identity (auto-detect caller) - const voter = await this.findCallerIdentity(params); + // 1. Get voter from params.userId (auto-injected by infrastructure) + const voter = await this.findUserById(params.userId, params); // 2. Parse rankedChoices if passed as JSON string (common from AI tool calls) let rankedChoices = params.rankedChoices; @@ -163,52 +161,22 @@ export class DecisionVoteServerCommand extends CommandBase { - // FIRST: Check context.userId (PersonaUsers set this) - if (params.context?.userId) { - const result = await DataList.execute({ - collection: UserEntity.collection, - filter: { id: params.context.userId }, - limit: 1, - context: params.context, - sessionId: params.sessionId - }); - - if (result.success && result.items && result.items.length > 0) { - const user = result.items[0]; - console.log('🔧 DecisionVoteServerCommand.findCallerIdentity USING CONTEXT userId', { userId: params.context.userId }); - return { id: user.id, entity: user }; - } - } - - // FALLBACK: Use UserIdentityResolver to detect calling process (CLI calls) - const identity = await UserIdentityResolver.resolve(); - - if (identity.exists && identity.userId) { - const result = await DataList.execute({ - collection: UserEntity.collection, - filter: { id: identity.userId }, - limit: 1, - context: params.context, - sessionId: params.sessionId - }); + private async findUserById(userId: UUID, params: DecisionVoteParams): Promise<{ id: UUID; entity: UserEntity }> { + const result = await DataList.execute({ + collection: UserEntity.collection, + filter: { id: userId }, + limit: 1, + context: params.context, + sessionId: params.sessionId + }); - if (result.success && result.items && result.items.length > 0) { - const user = result.items[0]; - return { id: user.id, entity: user }; - } + if (!result.success || !result.items || result.items.length === 0) { + throw new Error(`User not found: ${userId}`); } - // User doesn't exist - throw error with helpful message - throw new Error( - `Detected caller: ${identity.displayName} (${identity.uniqueId}) but user not found in database. ` + - `Run seed script to create users.` - ); + const user = result.items[0]; + return { id: user.id, entity: user }; } } diff --git a/src/debug/jtag/commands/collaboration/decision/vote/shared/DecisionVoteTypes.ts b/src/debug/jtag/commands/collaboration/decision/vote/shared/DecisionVoteTypes.ts index a49444c22..fbc4e9649 100644 --- a/src/debug/jtag/commands/collaboration/decision/vote/shared/DecisionVoteTypes.ts +++ b/src/debug/jtag/commands/collaboration/decision/vote/shared/DecisionVoteTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; @@ -37,6 +38,7 @@ export const createDecisionVoteParams = ( comment?: string; } ): DecisionVoteParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, comment: data.comment ?? '', ...data }); @@ -84,6 +86,7 @@ export const createDecisionVoteResult = ( error?: JTAGError; } ): DecisionVoteResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, proposalId: data.proposalId ?? '', voterId: data.voterId ?? ('' as UUID), voterName: data.voterName ?? '', diff --git a/src/debug/jtag/commands/collaboration/dm/server/DmServerCommand.ts b/src/debug/jtag/commands/collaboration/dm/server/DmServerCommand.ts index 9fb589bcd..18555e18c 100644 --- a/src/debug/jtag/commands/collaboration/dm/server/DmServerCommand.ts +++ b/src/debug/jtag/commands/collaboration/dm/server/DmServerCommand.ts @@ -16,7 +16,6 @@ import { Commands } from '@system/core/shared/Commands'; import type { DataListParams, DataListResult } from '@commands/data/list/shared/DataListTypes'; import type { DataCreateParams, DataCreateResult } from '@commands/data/create/shared/DataCreateTypes'; import type { DataUpdateParams, DataUpdateResult } from '@commands/data/update/shared/DataUpdateTypes'; -import { UserIdentityResolver } from '@system/user/shared/UserIdentityResolver'; import { RoomResolver } from '@system/core/server/RoomResolver'; import { DataList } from '../../../data/list/shared/DataListTypes'; @@ -26,8 +25,8 @@ export class DmServerCommand extends DmCommand { protected async executeDm(params: DmParams): Promise { - // 1. Get current user (the one initiating the DM) - const caller = await this.resolveCallerIdentity(params); + // 1. Get current user from params.userId (auto-injected by infrastructure) + const caller = await this.findUserById(params.userId, params); // 2. Normalize participants to array const otherParticipants = Array.isArray(params.participants) @@ -83,68 +82,23 @@ export class DmServerCommand extends DmCommand { } /** - * Resolve caller identity (who's initiating the DM) - * - * Priority: - * 1. params.context?.userId - When a PersonaUser executes a command, their ID is in context - * 2. params.callerId/personaId - Legacy persona tool execution context (deprecated) - * 3. UserIdentityResolver - Human/CLI context fallback + * Find user by ID from database */ - private async resolveCallerIdentity(params: DmParams): Promise<{ id: UUID; entity: UserEntity }> { - // FIRST: Check if caller's userId is in the context (PersonaUsers set this) - if (params.context?.userId) { - const result = await DataList.execute({ - collection: UserEntity.collection, - filter: { id: params.context.userId }, - limit: 1, - context: params.context, - sessionId: params.sessionId - }); - - if (result.success && result.items && result.items.length > 0) { - const user = result.items[0]; - console.log('🔧 DmServerCommand.resolveCallerIdentity USING CONTEXT userId', { userId: params.context.userId }); - return { id: user.id, entity: user }; - } - } - - // SECOND: Check legacy callerId/personaId (deprecated) - const callerIdFromParams = (params as any).callerId || (params as any).personaId; - - if (callerIdFromParams) { - const result = await DataList.execute({ - collection: UserEntity.collection, - filter: { id: callerIdFromParams }, - limit: 1, - context: params.context, - sessionId: params.sessionId - }); - - if (result.success && result.items && result.items.length > 0) { - const user = result.items[0]; - return { id: user.id, entity: user }; - } - } - - // FALLBACK: Use UserIdentityResolver (human/CLI context) - const identity = await UserIdentityResolver.resolve(); - - if (identity.exists && identity.userId) { - const result = await DataList.execute({ - collection: UserEntity.collection, - filter: { id: identity.userId }, - limit: 1, - context: params.context, - sessionId: params.sessionId - }); + private async findUserById(userId: UUID, params: DmParams): Promise<{ id: UUID; entity: UserEntity }> { + const result = await DataList.execute({ + collection: UserEntity.collection, + filter: { id: userId }, + limit: 1, + context: params.context, + sessionId: params.sessionId + }); - if (result.success && result.items && result.items.length > 0) { - const user = result.items[0]; - return { id: user.id, entity: user }; - } + if (result.success && result.items && result.items.length > 0) { + const user = result.items[0]; + return { id: user.id, entity: user }; } - throw new Error(`Could not resolve caller identity: ${identity.displayName}`); + throw new Error(`User not found: ${userId}`); } /** diff --git a/src/debug/jtag/commands/collaboration/live/join/server/LiveJoinServerCommand.ts b/src/debug/jtag/commands/collaboration/live/join/server/LiveJoinServerCommand.ts index b1f974fac..15b6021b4 100644 --- a/src/debug/jtag/commands/collaboration/live/join/server/LiveJoinServerCommand.ts +++ b/src/debug/jtag/commands/collaboration/live/join/server/LiveJoinServerCommand.ts @@ -18,7 +18,6 @@ import { Events } from '@system/core/shared/Events'; import type { DataListParams, DataListResult } from '@commands/data/list/shared/DataListTypes'; import type { DataCreateParams, DataCreateResult } from '@commands/data/create/shared/DataCreateTypes'; import type { DataUpdateParams, DataUpdateResult } from '@commands/data/update/shared/DataUpdateTypes'; -import { UserIdentityResolver } from '@system/user/shared/UserIdentityResolver'; import { getVoiceOrchestrator } from '@system/voice/server/VoiceOrchestrator'; import { DataList } from '../../../../data/list/shared/DataListTypes'; @@ -41,8 +40,8 @@ export class LiveJoinServerCommand extends LiveJoinCommand { }); } - // 2. Get current user - const user = await this.resolveCurrentUser(params); + // 2. Get current user from params.userId (auto-injected by infrastructure) + const user = await this.findUserById(params.userId, params); if (!user) { return transformPayload(params, { success: false, @@ -137,62 +136,19 @@ export class LiveJoinServerCommand extends LiveJoinCommand { } /** - * Resolve current user - prefers context.userId (for PersonaUsers) - * - * Priority: - * 1. params.context?.userId - When a PersonaUser executes a command, their ID is in context - * 2. Legacy callerId/personaId - Deprecated, for backwards compatibility - * 3. UserIdentityResolver - Fallback for CLI calls + * Find user by ID from database */ - private async resolveCurrentUser(params: LiveJoinParams): Promise { - // FIRST: Check context.userId (PersonaUsers set this) - if (params.context?.userId) { - const result = await DataList.execute({ - collection: UserEntity.collection, - filter: { id: params.context.userId }, - limit: 1, - context: params.context, - sessionId: params.sessionId - }); - - if (result.success && result.items && result.items.length > 0) { - console.log('🔧 LiveJoinServerCommand.resolveCurrentUser USING CONTEXT userId', { userId: params.context.userId }); - return result.items[0]; - } - } - - // SECOND: Check legacy callerId/personaId (deprecated) - const callerIdFromParams = (params as any).callerId || (params as any).personaId; - - if (callerIdFromParams) { - const result = await DataList.execute({ - collection: UserEntity.collection, - filter: { id: callerIdFromParams }, - limit: 1, - context: params.context, - sessionId: params.sessionId - }); - - if (result.success && result.items && result.items.length > 0) { - return result.items[0]; - } - } - - // FALLBACK: Use UserIdentityResolver (CLI calls) - const identity = await UserIdentityResolver.resolve(); - - if (identity.exists && identity.userId) { - const result = await DataList.execute({ - collection: UserEntity.collection, - filter: { id: identity.userId }, - limit: 1, - context: params.context, - sessionId: params.sessionId - }); + private async findUserById(userId: UUID, params: LiveJoinParams): Promise { + const result = await DataList.execute({ + collection: UserEntity.collection, + filter: { id: userId }, + limit: 1, + context: params.context, + sessionId: params.sessionId + }); - if (result.success && result.items && result.items.length > 0) { - return result.items[0]; - } + if (result.success && result.items && result.items.length > 0) { + return result.items[0]; } return null; diff --git a/src/debug/jtag/commands/collaboration/live/join/shared/LiveJoinTypes.ts b/src/debug/jtag/commands/collaboration/live/join/shared/LiveJoinTypes.ts index 61597bb0d..7f2915216 100644 --- a/src/debug/jtag/commands/collaboration/live/join/shared/LiveJoinTypes.ts +++ b/src/debug/jtag/commands/collaboration/live/join/shared/LiveJoinTypes.ts @@ -15,11 +15,6 @@ export interface LiveJoinParams extends CommandParams { * Entity (room/activity) to join live call for (UUID or uniqueId) */ entityId: string; - - /** - * ID of the user joining the call (browser passes this to identify the logged-in user) - */ - callerId?: UUID; } export interface LiveJoinResult extends CommandResult { diff --git a/src/debug/jtag/commands/collaboration/live/leave/server/LiveLeaveServerCommand.ts b/src/debug/jtag/commands/collaboration/live/leave/server/LiveLeaveServerCommand.ts index 2fc0da82c..a09ce2e9e 100644 --- a/src/debug/jtag/commands/collaboration/live/leave/server/LiveLeaveServerCommand.ts +++ b/src/debug/jtag/commands/collaboration/live/leave/server/LiveLeaveServerCommand.ts @@ -15,7 +15,6 @@ import { Commands } from '@system/core/shared/Commands'; import { Events } from '@system/core/shared/Events'; import type { DataListParams, DataListResult } from '@commands/data/list/shared/DataListTypes'; import type { DataUpdateParams, DataUpdateResult } from '@commands/data/update/shared/DataUpdateTypes'; -import { UserIdentityResolver } from '@system/user/shared/UserIdentityResolver'; import { getVoiceOrchestrator } from '@system/voice/server/VoiceOrchestrator'; import { DataList } from '../../../../data/list/shared/DataListTypes'; @@ -23,8 +22,8 @@ import { DataUpdate } from '../../../../data/update/shared/DataUpdateTypes'; export class LiveLeaveServerCommand extends LiveLeaveCommand { protected async executeLeave(params: LiveLeaveParams): Promise { - // 1. Get current user - const user = await this.resolveCurrentUser(params); + // 1. Get current user from params.userId (auto-injected by infrastructure) + const user = await this.findUserById(params.userId, params); if (!user) { return transformPayload(params, { success: false, @@ -88,62 +87,19 @@ export class LiveLeaveServerCommand extends LiveLeaveCommand { } /** - * Resolve current user - prefers context.userId (for PersonaUsers) - * - * Priority: - * 1. params.context?.userId - When a PersonaUser executes a command, their ID is in context - * 2. Legacy callerId/personaId - Deprecated, for backwards compatibility - * 3. UserIdentityResolver - Fallback for CLI calls + * Find user by ID from database */ - private async resolveCurrentUser(params: LiveLeaveParams): Promise { - // FIRST: Check context.userId (PersonaUsers set this) - if (params.context?.userId) { - const result = await DataList.execute({ - collection: UserEntity.collection, - filter: { id: params.context.userId }, - limit: 1, - context: params.context, - sessionId: params.sessionId - }); - - if (result.success && result.items && result.items.length > 0) { - console.log('🔧 LiveLeaveServerCommand.resolveCurrentUser USING CONTEXT userId', { userId: params.context.userId }); - return result.items[0]; - } - } - - // SECOND: Check legacy callerId/personaId (deprecated) - const callerIdFromParams = (params as any).callerId || (params as any).personaId; - - if (callerIdFromParams) { - const result = await DataList.execute({ - collection: UserEntity.collection, - filter: { id: callerIdFromParams }, - limit: 1, - context: params.context, - sessionId: params.sessionId - }); - - if (result.success && result.items && result.items.length > 0) { - return result.items[0]; - } - } - - // FALLBACK: Use UserIdentityResolver (CLI calls) - const identity = await UserIdentityResolver.resolve(); - - if (identity.exists && identity.userId) { - const result = await DataList.execute({ - collection: UserEntity.collection, - filter: { id: identity.userId }, - limit: 1, - context: params.context, - sessionId: params.sessionId - }); + private async findUserById(userId: UUID, params: LiveLeaveParams): Promise { + const result = await DataList.execute({ + collection: UserEntity.collection, + filter: { id: userId }, + limit: 1, + context: params.context, + sessionId: params.sessionId + }); - if (result.success && result.items && result.items.length > 0) { - return result.items[0]; - } + if (result.success && result.items && result.items.length > 0) { + return result.items[0]; } return null; diff --git a/src/debug/jtag/commands/collaboration/live/start/shared/CollaborationLiveStartTypes.ts b/src/debug/jtag/commands/collaboration/live/start/shared/CollaborationLiveStartTypes.ts index 204b269cb..7d5bb4382 100644 --- a/src/debug/jtag/commands/collaboration/live/start/shared/CollaborationLiveStartTypes.ts +++ b/src/debug/jtag/commands/collaboration/live/start/shared/CollaborationLiveStartTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import type { RoomEntity } from '@system/data/entities/RoomEntity'; @@ -39,6 +40,7 @@ export const createCollaborationLiveStartParams = ( withVideo?: boolean; } ): CollaborationLiveStartParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, name: data.name ?? '', withVideo: data.withVideo ?? false, ...data @@ -89,6 +91,7 @@ export const createCollaborationLiveStartResult = ( error?: JTAGError; } ): CollaborationLiveStartResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, roomId: data.roomId!, liveSessionId: data.liveSessionId!, room: data.room!, diff --git a/src/debug/jtag/commands/collaboration/live/transcription/shared/CollaborationLiveTranscriptionTypes.ts b/src/debug/jtag/commands/collaboration/live/transcription/shared/CollaborationLiveTranscriptionTypes.ts index a1f65fcfe..4868b1d9c 100644 --- a/src/debug/jtag/commands/collaboration/live/transcription/shared/CollaborationLiveTranscriptionTypes.ts +++ b/src/debug/jtag/commands/collaboration/live/transcription/shared/CollaborationLiveTranscriptionTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; @@ -53,6 +54,7 @@ export const createCollaborationLiveTranscriptionParams = ( timestamp: number; } ): CollaborationLiveTranscriptionParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -82,6 +84,7 @@ export const createCollaborationLiveTranscriptionResult = ( error?: JTAGError; } ): CollaborationLiveTranscriptionResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, message: data.message ?? '', ...data }); diff --git a/src/debug/jtag/commands/continuum/emotion/shared/EmotionTypes.ts b/src/debug/jtag/commands/continuum/emotion/shared/EmotionTypes.ts index 30abdae3d..ee1fc8a9c 100644 --- a/src/debug/jtag/commands/continuum/emotion/shared/EmotionTypes.ts +++ b/src/debug/jtag/commands/continuum/emotion/shared/EmotionTypes.ts @@ -1,6 +1,7 @@ import { Commands } from '../../../../system/core/shared/Commands'; import type { CommandParams, CommandResult, CommandInput} from '../../../../system/core/types/JTAGTypes'; +/** Display an emoji reaction with an optional color glow on the Continuum interface. */ export interface EmotionParams extends CommandParams { emoji: string; // Emoji to display (e.g., '❤️', '😊', '🤔') color?: string; // Optional color for glow (hex or CSS color) diff --git a/src/debug/jtag/commands/data/backfill-vectors/shared/BackfillVectorsCommandTypes.ts b/src/debug/jtag/commands/data/backfill-vectors/shared/BackfillVectorsCommandTypes.ts index 600f5e6f7..e7bb552f8 100644 --- a/src/debug/jtag/commands/data/backfill-vectors/shared/BackfillVectorsCommandTypes.ts +++ b/src/debug/jtag/commands/data/backfill-vectors/shared/BackfillVectorsCommandTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, JTAGPayload, JTAGContext, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import type { UniversalFilter } from '../../../../daemons/data-daemon/shared/DataStorageAdapter'; import { Commands } from '../../../../system/core/shared/Commands'; diff --git a/src/debug/jtag/commands/data/clear/shared/DataClearTypes.ts b/src/debug/jtag/commands/data/clear/shared/DataClearTypes.ts index 8e328cfc2..dcb1958d0 100644 --- a/src/debug/jtag/commands/data/clear/shared/DataClearTypes.ts +++ b/src/debug/jtag/commands/data/clear/shared/DataClearTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, JTAGPayload, JTAGContext, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -33,8 +34,8 @@ export interface DataClearResult extends JTAGPayload { export const createDataClearParams = ( context: JTAGContext, sessionId: UUID, - data: Omit = {} -): DataClearParams => createPayload(context, sessionId, data); + data: Omit = {} +): DataClearParams => createPayload(context, sessionId, { userId: SYSTEM_SCOPES.SYSTEM, ...data }); /** * Transform params to result diff --git a/src/debug/jtag/commands/data/close/shared/DataCloseTypes.ts b/src/debug/jtag/commands/data/close/shared/DataCloseTypes.ts index 0c728ad2e..e8c8b0161 100644 --- a/src/debug/jtag/commands/data/close/shared/DataCloseTypes.ts +++ b/src/debug/jtag/commands/data/close/shared/DataCloseTypes.ts @@ -9,6 +9,7 @@ import type { CommandParams, JTAGPayload, JTAGContext, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import type { DbHandle } from '../../../../daemons/data-daemon/server/DatabaseHandleRegistry'; import { Commands } from '../../../../system/core/shared/Commands'; diff --git a/src/debug/jtag/commands/data/create/shared/DataCreateTypes.ts b/src/debug/jtag/commands/data/create/shared/DataCreateTypes.ts index 7db1c83c2..5f993dcdf 100644 --- a/src/debug/jtag/commands/data/create/shared/DataCreateTypes.ts +++ b/src/debug/jtag/commands/data/create/shared/DataCreateTypes.ts @@ -31,7 +31,7 @@ export interface DataCreateResult extends Bas export const createDataCreateParams = ( context: JTAGContext, sessionId: UUID, - data: Omit & { backend?: JTAGEnvironment } + data: Omit & { backend?: JTAGEnvironment } ): DataCreateParams => { // Use base factory to ensure backend defaults are applied const baseParams = createBaseDataParams(context, sessionId, { diff --git a/src/debug/jtag/commands/data/delete/shared/DataDeleteTypes.ts b/src/debug/jtag/commands/data/delete/shared/DataDeleteTypes.ts index d55ebdce5..d905cd18f 100644 --- a/src/debug/jtag/commands/data/delete/shared/DataDeleteTypes.ts +++ b/src/debug/jtag/commands/data/delete/shared/DataDeleteTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, JTAGPayload, JTAGContext, CommandInput } from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '../../../../system/core/shared/Commands'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import type { DbHandle } from '../../../../daemons/data-daemon/server/DatabaseHandleRegistry'; diff --git a/src/debug/jtag/commands/data/generate-embedding/shared/GenerateEmbeddingCommandTypes.ts b/src/debug/jtag/commands/data/generate-embedding/shared/GenerateEmbeddingCommandTypes.ts index 8f547072a..f87538b41 100644 --- a/src/debug/jtag/commands/data/generate-embedding/shared/GenerateEmbeddingCommandTypes.ts +++ b/src/debug/jtag/commands/data/generate-embedding/shared/GenerateEmbeddingCommandTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, JTAGPayload, JTAGContext, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import type { VectorEmbedding, EmbeddingModel } from '../../../../daemons/data-daemon/shared/VectorSearchTypes'; import { Commands } from '../../../../system/core/shared/Commands'; diff --git a/src/debug/jtag/commands/data/list-handles/shared/DataListHandlesTypes.ts b/src/debug/jtag/commands/data/list-handles/shared/DataListHandlesTypes.ts index 58ce40faa..486f627e5 100644 --- a/src/debug/jtag/commands/data/list-handles/shared/DataListHandlesTypes.ts +++ b/src/debug/jtag/commands/data/list-handles/shared/DataListHandlesTypes.ts @@ -4,6 +4,7 @@ import type { CommandParams, JTAGPayload, JTAGContext, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import type { DbHandle, diff --git a/src/debug/jtag/commands/data/list/shared/DataListTypes.ts b/src/debug/jtag/commands/data/list/shared/DataListTypes.ts index dd682ce1d..26faec181 100644 --- a/src/debug/jtag/commands/data/list/shared/DataListTypes.ts +++ b/src/debug/jtag/commands/data/list/shared/DataListTypes.ts @@ -9,6 +9,7 @@ import type { JTAGPayload, JTAGContext, CommandParams, CommandInput } from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '../../../../system/core/shared/Commands'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import type { BaseEntity } from '../../../../system/data/entities/BaseEntity'; @@ -63,8 +64,8 @@ export interface DataListResult extends JTAGPayload { export const createDataListParams = ( context: JTAGContext, sessionId: UUID, - data: Omit -): DataListParams => createPayload(context, sessionId, data); + data: Omit +): DataListParams => createPayload(context, sessionId, { userId: SYSTEM_SCOPES.SYSTEM, ...data }); export const createDataListResultFromParams = ( params: DataListParams, diff --git a/src/debug/jtag/commands/data/open/shared/DataOpenTypes.ts b/src/debug/jtag/commands/data/open/shared/DataOpenTypes.ts index 532201722..763d269e6 100644 --- a/src/debug/jtag/commands/data/open/shared/DataOpenTypes.ts +++ b/src/debug/jtag/commands/data/open/shared/DataOpenTypes.ts @@ -15,6 +15,7 @@ import type { CommandParams, JTAGPayload, JTAGContext, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import type { DbHandle, diff --git a/src/debug/jtag/commands/data/read/shared/DataReadTypes.ts b/src/debug/jtag/commands/data/read/shared/DataReadTypes.ts index ca43bbeac..0b3500da6 100644 --- a/src/debug/jtag/commands/data/read/shared/DataReadTypes.ts +++ b/src/debug/jtag/commands/data/read/shared/DataReadTypes.ts @@ -32,7 +32,7 @@ export interface DataReadResult extends BaseD export const createDataReadParams = ( context: JTAGContext, sessionId: UUID, - data: Omit & { backend?: JTAGEnvironment } + data: Omit & { backend?: JTAGEnvironment } ): DataReadParams => { const baseParams = createBaseDataParams(context, sessionId, { collection: data.collection, diff --git a/src/debug/jtag/commands/data/schema/shared/DataSchemaTypes.ts b/src/debug/jtag/commands/data/schema/shared/DataSchemaTypes.ts index 15a62fe54..6346144d1 100644 --- a/src/debug/jtag/commands/data/schema/shared/DataSchemaTypes.ts +++ b/src/debug/jtag/commands/data/schema/shared/DataSchemaTypes.ts @@ -7,13 +7,12 @@ import type { CommandParams, JTAGPayload, JTAGContext, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import type { FieldMetadata } from '../../../../system/data/decorators/FieldDecorators'; import { Commands } from '../../../../system/core/shared/Commands'; -/** - * Request parameters for data/schema command - */ +/** Introspect an entity collection's schema at runtime, returning field types, constraints, indexes, optional examples, SQL, and data validation. */ export interface DataSchemaParams extends CommandParams { readonly collection: string; // Entity collection name to get schema for readonly examples?: boolean; // Include example JSON objects diff --git a/src/debug/jtag/commands/data/shared/BaseDataTypes.ts b/src/debug/jtag/commands/data/shared/BaseDataTypes.ts index e1e18422d..f5df5dc6b 100644 --- a/src/debug/jtag/commands/data/shared/BaseDataTypes.ts +++ b/src/debug/jtag/commands/data/shared/BaseDataTypes.ts @@ -9,6 +9,7 @@ import type { CommandParams, JTAGPayload, JTAGContext, JTAGEnvironment } from '../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '../../../system/core/types/CrossPlatformUUID'; import type { DbHandle } from '../../../daemons/data-daemon/server/DatabaseHandleRegistry'; @@ -49,8 +50,9 @@ export type DataBackend = 'server' | 'local'; export const createBaseDataParams = ( context: JTAGContext, sessionId: UUID, - data: Omit & { backend?: JTAGEnvironment } + data: Omit & { backend?: JTAGEnvironment } ): BaseDataParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data, backend: data.backend ?? 'server' }); @@ -61,10 +63,11 @@ export const createBaseDataParams = ( * Server commands that forward context from a parent command can still pass them explicitly. */ export type DataCommandInput = - Omit & { + Omit & { context?: JTAGContext; sessionId?: UUID; backend?: JTAGEnvironment; + userId?: UUID; }; /** diff --git a/src/debug/jtag/commands/data/truncate/shared/DataTruncateTypes.ts b/src/debug/jtag/commands/data/truncate/shared/DataTruncateTypes.ts index 8c7ee202e..9e46535a3 100644 --- a/src/debug/jtag/commands/data/truncate/shared/DataTruncateTypes.ts +++ b/src/debug/jtag/commands/data/truncate/shared/DataTruncateTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, JTAGPayload, JTAGContext, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; diff --git a/src/debug/jtag/commands/data/update/shared/DataUpdateTypes.ts b/src/debug/jtag/commands/data/update/shared/DataUpdateTypes.ts index 53205309c..e5bde0e75 100644 --- a/src/debug/jtag/commands/data/update/shared/DataUpdateTypes.ts +++ b/src/debug/jtag/commands/data/update/shared/DataUpdateTypes.ts @@ -43,7 +43,7 @@ export interface DataUpdateResult extends Bas export const createDataUpdateParams = ( context: JTAGContext, sessionId: UUID, - data: Omit & { backend?: JTAGEnvironment } + data: Omit & { backend?: JTAGEnvironment } ): DataUpdateParams => { const baseParams = createBaseDataParams(context, sessionId, { collection: data.collection, diff --git a/src/debug/jtag/commands/data/vector-search/shared/VectorSearchCommandTypes.ts b/src/debug/jtag/commands/data/vector-search/shared/VectorSearchCommandTypes.ts index 865886d47..acd1775c4 100644 --- a/src/debug/jtag/commands/data/vector-search/shared/VectorSearchCommandTypes.ts +++ b/src/debug/jtag/commands/data/vector-search/shared/VectorSearchCommandTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, JTAGPayload, JTAGContext, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import type { VectorSearchResult, diff --git a/src/debug/jtag/commands/development/compile-typescript/shared/CompileTypescriptTypes.ts b/src/debug/jtag/commands/development/compile-typescript/shared/CompileTypescriptTypes.ts index 5cf8588b0..cba863204 100644 --- a/src/debug/jtag/commands/development/compile-typescript/shared/CompileTypescriptTypes.ts +++ b/src/debug/jtag/commands/development/compile-typescript/shared/CompileTypescriptTypes.ts @@ -21,6 +21,7 @@ import type { CommandInput } from '../../../../system/core/types/JTAGTypes'; */ import { CommandParams, CommandResult, createPayload, type JTAGContext } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -44,6 +45,7 @@ export const createCompileTypescriptParams = ( target?: 'es5' | 'es2015' | 'es2020' | 'esnext'; } ): CompileTypescriptParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, source: data.source ?? '', filename: data.filename ?? 'code.ts', outputPath: data.outputPath ?? './dist', @@ -74,6 +76,7 @@ export const createCompileTypescriptResult = ( compilationTime?: number; } ): CompileTypescriptResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, output: data.output, outputPath: data.outputPath, errors: data.errors ?? [], diff --git a/src/debug/jtag/commands/development/debug/crud-sync/shared/CrudSyncDebugTypes.ts b/src/debug/jtag/commands/development/debug/crud-sync/shared/CrudSyncDebugTypes.ts index 2320c8451..5f4f7378e 100644 --- a/src/debug/jtag/commands/development/debug/crud-sync/shared/CrudSyncDebugTypes.ts +++ b/src/debug/jtag/commands/development/debug/crud-sync/shared/CrudSyncDebugTypes.ts @@ -9,6 +9,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; @@ -81,6 +82,7 @@ export const createCrudSyncDebugResult = ( sessionId: UUID, data: Omit, 'context' | 'sessionId'> ): CrudSyncDebugResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: false, widgets: { roomList: { widgetName: 'room-list-widget', found: false, path: '', itemCount: 0, items: [] }, diff --git a/src/debug/jtag/commands/development/debug/error/shared/TestErrorTypes.ts b/src/debug/jtag/commands/development/debug/error/shared/TestErrorTypes.ts index c01ddcc9c..3c5a6966f 100644 --- a/src/debug/jtag/commands/development/debug/error/shared/TestErrorTypes.ts +++ b/src/debug/jtag/commands/development/debug/error/shared/TestErrorTypes.ts @@ -7,6 +7,7 @@ import type { JTAGContext, CommandParams, JTAGPayload, CommandInput} from '@system/core/types/JTAGTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '../../../../../system/core/shared/Commands'; /** @@ -88,6 +89,7 @@ export function createTestErrorParams( return { context, sessionId, + userId: SYSTEM_SCOPES.SYSTEM, errorType, level: options.level || 'command', environment: options.environment || (context.environment === 'remote' ? 'server' : context.environment), diff --git a/src/debug/jtag/commands/development/debug/html-inspector/shared/HtmlInspectorTypes.ts b/src/debug/jtag/commands/development/debug/html-inspector/shared/HtmlInspectorTypes.ts index aff991e87..a05c86ac2 100644 --- a/src/debug/jtag/commands/development/debug/html-inspector/shared/HtmlInspectorTypes.ts +++ b/src/debug/jtag/commands/development/debug/html-inspector/shared/HtmlInspectorTypes.ts @@ -4,6 +4,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; @@ -51,6 +52,7 @@ export const createHtmlInspectorResult = ( sessionId: UUID, data: Omit, 'context' | 'sessionId'> ): HtmlInspectorResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: false, html: '', text: '', diff --git a/src/debug/jtag/commands/development/debug/widget-css/server/WidgetCSSServerCommand.ts b/src/debug/jtag/commands/development/debug/widget-css/server/WidgetCSSServerCommand.ts index 7c540f0f5..1db3080e4 100644 --- a/src/debug/jtag/commands/development/debug/widget-css/server/WidgetCSSServerCommand.ts +++ b/src/debug/jtag/commands/development/debug/widget-css/server/WidgetCSSServerCommand.ts @@ -6,6 +6,7 @@ import { CommandBase } from '@daemons/command-daemon/shared/CommandBase'; import type { JTAGContext, JTAGPayload } from '@system/core/types/JTAGTypes'; import type { ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { WidgetCSSDebugParams, WidgetCSSDebugResult } from '../shared/WidgetCSSDebugTypes'; import { createWidgetCSSDebugResult } from '../shared/WidgetCSSDebugTypes'; import type { ScreenshotResult } from '@commands/interface/screenshot/shared/ScreenshotTypes'; @@ -77,6 +78,7 @@ export class WidgetCSSServerCommand extends CommandBase, 'context' | 'sessionId'> ): WidgetEventsDebugResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: false, widgetFound: false, widgetPath: '', diff --git a/src/debug/jtag/commands/development/debug/widget-interact/browser/WidgetInteractBrowserCommand.ts b/src/debug/jtag/commands/development/debug/widget-interact/browser/WidgetInteractBrowserCommand.ts index 0b04286fe..beb9e0e94 100644 --- a/src/debug/jtag/commands/development/debug/widget-interact/browser/WidgetInteractBrowserCommand.ts +++ b/src/debug/jtag/commands/development/debug/widget-interact/browser/WidgetInteractBrowserCommand.ts @@ -8,6 +8,7 @@ import { CommandBase } from '@daemons/command-daemon/shared/CommandBase'; import type { JTAGContext } from '@system/core/types/JTAGTypes'; import type { ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { WidgetInteractParams, WidgetInteractResult } from '../shared/WidgetInteractTypes'; import { createWidgetInteractResult } from '../shared/WidgetInteractTypes'; import { WidgetDiscovery } from '@system/core/browser/utils/WidgetIntrospection'; @@ -233,6 +234,7 @@ export class WidgetInteractBrowserCommand extends CommandBase, 'context' | 'sessionId'> ): WidgetInteractResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: false, action: 'click', widgetFound: false, diff --git a/src/debug/jtag/commands/development/debug/widget-state/shared/WidgetStateDebugTypes.ts b/src/debug/jtag/commands/development/debug/widget-state/shared/WidgetStateDebugTypes.ts index 5b34865ea..c46b27627 100644 --- a/src/debug/jtag/commands/development/debug/widget-state/shared/WidgetStateDebugTypes.ts +++ b/src/debug/jtag/commands/development/debug/widget-state/shared/WidgetStateDebugTypes.ts @@ -7,6 +7,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; @@ -144,6 +145,7 @@ export const createWidgetStateDebugResult = ( sessionId: UUID, data: Omit, 'context' | 'sessionId'> ): WidgetStateDebugResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: false, widgetFound: false, widgetPath: '', diff --git a/src/debug/jtag/commands/development/generate/audit/shared/GenerateAuditTypes.ts b/src/debug/jtag/commands/development/generate/audit/shared/GenerateAuditTypes.ts index e9e532bdd..cb9854430 100644 --- a/src/debug/jtag/commands/development/generate/audit/shared/GenerateAuditTypes.ts +++ b/src/debug/jtag/commands/development/generate/audit/shared/GenerateAuditTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import type { AuditReport } from '@generator/audit/AuditTypes'; import { Commands } from '../../../../../system/core/shared/Commands'; @@ -30,8 +31,8 @@ export interface GenerateAuditParams extends CommandParams { export const createGenerateAuditParams = ( context: JTAGContext, sessionId: UUID, - data: Omit, 'context' | 'sessionId'> -): GenerateAuditParams => createPayload(context, sessionId, data); + data: Omit, 'context' | 'sessionId' | 'userId'> +): GenerateAuditParams => createPayload(context, sessionId, { userId: SYSTEM_SCOPES.SYSTEM, ...data }); /** * Generate/Audit Command Result @@ -58,6 +59,7 @@ export const createGenerateAuditResult = ( sessionId: UUID, data: Omit, 'context' | 'sessionId'> ): GenerateAuditResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: false, reports: [], summary: { diff --git a/src/debug/jtag/commands/development/generate/shared/GenerateTypes.ts b/src/debug/jtag/commands/development/generate/shared/GenerateTypes.ts index 02c4de0d7..5d328c737 100644 --- a/src/debug/jtag/commands/development/generate/shared/GenerateTypes.ts +++ b/src/debug/jtag/commands/development/generate/shared/GenerateTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -25,8 +26,8 @@ export interface GenerateParams extends CommandParams { export const createGenerateParams = ( context: JTAGContext, sessionId: UUID, - data: Omit, 'context' | 'sessionId'> & Pick -): GenerateParams => createPayload(context, sessionId, data); + data: Omit, 'context' | 'sessionId' | 'userId'> & Pick +): GenerateParams => createPayload(context, sessionId, { userId: SYSTEM_SCOPES.SYSTEM, ...data }); /** * Generate Command Result @@ -50,6 +51,7 @@ export const createGenerateResult = ( sessionId: UUID, data: Omit, 'context' | 'sessionId'> ): GenerateResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: false, filesCreated: [], commandPath: '', diff --git a/src/debug/jtag/commands/development/propose-command/shared/ProposeCommandTypes.ts b/src/debug/jtag/commands/development/propose-command/shared/ProposeCommandTypes.ts index 2e6a4b469..0b2faf500 100644 --- a/src/debug/jtag/commands/development/propose-command/shared/ProposeCommandTypes.ts +++ b/src/debug/jtag/commands/development/propose-command/shared/ProposeCommandTypes.ts @@ -14,6 +14,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -107,6 +108,7 @@ export const createProposeCommandResult = ( sessionId: UUID, data: Omit, 'context' | 'sessionId'> ): ProposeCommandResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: false, ...data }); diff --git a/src/debug/jtag/commands/development/sandbox-execute/shared/SandboxExecuteTypes.ts b/src/debug/jtag/commands/development/sandbox-execute/shared/SandboxExecuteTypes.ts index 71b576fbf..1299548f1 100644 --- a/src/debug/jtag/commands/development/sandbox-execute/shared/SandboxExecuteTypes.ts +++ b/src/debug/jtag/commands/development/sandbox-execute/shared/SandboxExecuteTypes.ts @@ -7,6 +7,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -45,6 +46,7 @@ export const createSandboxExecuteResult = ( sessionId: UUID, data: Omit, 'context' | 'sessionId'> ): SandboxExecuteResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: false, ...data }); diff --git a/src/debug/jtag/commands/file/mime-type/browser/FileMimeTypeBrowserCommand.ts b/src/debug/jtag/commands/file/mime-type/browser/FileMimeTypeBrowserCommand.ts index 7404f8dbf..3e0684f49 100644 --- a/src/debug/jtag/commands/file/mime-type/browser/FileMimeTypeBrowserCommand.ts +++ b/src/debug/jtag/commands/file/mime-type/browser/FileMimeTypeBrowserCommand.ts @@ -4,6 +4,7 @@ */ import type { JTAGContext, JTAGPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '../../../../system/core/types/SystemScopes'; import type { ICommandDaemon } from '../../../../daemons/command-daemon/shared/CommandBase'; import { FileMimeTypeCommand } from '../shared/FileMimeTypeCommand'; import type { FileMimeTypeResult } from '../shared/FileMimeTypeTypes'; @@ -15,6 +16,6 @@ export class FileMimeTypeBrowserCommand extends FileMimeTypeCommand { async execute(params: JTAGPayload): Promise { // Browser cannot access filesystem - delegate to server - return await this.remoteExecute(params); + return await this.remoteExecute({ ...params, userId: SYSTEM_SCOPES.SYSTEM }); } } diff --git a/src/debug/jtag/commands/file/shared/FileTypes.ts b/src/debug/jtag/commands/file/shared/FileTypes.ts index 123d5486f..020a8a8d5 100644 --- a/src/debug/jtag/commands/file/shared/FileTypes.ts +++ b/src/debug/jtag/commands/file/shared/FileTypes.ts @@ -25,6 +25,7 @@ */ import { CommandParams, CommandResult, createPayload } from '../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGContext } from '../../../system/core/types/JTAGTypes'; import type { JTAGError } from '../../../system/core/types/ErrorTypes'; import { CommandBase, type ICommandDaemon } from '../../../daemons/command-daemon/shared/CommandBase'; @@ -43,6 +44,7 @@ export const createFileParams = = FileParams>( sessionId: UUID, data: T & { filepath?: string } ): FileParams & T => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, filepath: data.filepath ?? '', encoding: data.encoding ?? 'utf8', ...data @@ -64,6 +66,7 @@ export const createFileResult = = FileResult>( sessionId: UUID, data: T & { success: boolean; filepath: string } ): FileResult & T => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, exists: data.exists ?? false, timestamp: data.timestamp ?? new Date().toISOString(), ...data diff --git a/src/debug/jtag/commands/genome/batch-micro-tune/shared/GenomeBatchMicroTuneTypes.ts b/src/debug/jtag/commands/genome/batch-micro-tune/shared/GenomeBatchMicroTuneTypes.ts index 4901fb6fe..ee30a512d 100644 --- a/src/debug/jtag/commands/genome/batch-micro-tune/shared/GenomeBatchMicroTuneTypes.ts +++ b/src/debug/jtag/commands/genome/batch-micro-tune/shared/GenomeBatchMicroTuneTypes.ts @@ -9,9 +9,7 @@ import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import type { CommandParams, CommandResult, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { Commands } from '../../../../system/core/shared/Commands'; -/** - * Parameters for genome/batch-micro-tune command - */ +/** Perform a fast, in-memory LoRA micro-tune on accumulated training examples during recipe execution, without persisting weights to disk. */ export interface GenomeBatchMicroTuneParams extends CommandParams { /** * Learning domain to train diff --git a/src/debug/jtag/commands/genome/job-create/shared/GenomeJobCreateTypes.ts b/src/debug/jtag/commands/genome/job-create/shared/GenomeJobCreateTypes.ts index bf929e78f..592168887 100644 --- a/src/debug/jtag/commands/genome/job-create/shared/GenomeJobCreateTypes.ts +++ b/src/debug/jtag/commands/genome/job-create/shared/GenomeJobCreateTypes.ts @@ -10,9 +10,7 @@ import type { CommandParams, CommandResult, CommandInput} from '../../../../syst import type { JobConfiguration } from '../../../../daemons/data-daemon/shared/entities/FineTuningTypes'; import { Commands } from '../../../../system/core/shared/Commands'; -/** - * Parameters for genome/job-create command - */ +/** Create a fine-tuning job on a cloud provider (OpenAI, DeepSeek, Fireworks, or Together) with a validated, provider-agnostic configuration. */ export interface GenomeJobCreateParams extends CommandParams { /** * PersonaUser ID that will own this fine-tuning job diff --git a/src/debug/jtag/commands/genome/job-status/browser/GenomeJobStatusBrowserCommand.ts b/src/debug/jtag/commands/genome/job-status/browser/GenomeJobStatusBrowserCommand.ts index 4a081abaa..788d5594b 100644 --- a/src/debug/jtag/commands/genome/job-status/browser/GenomeJobStatusBrowserCommand.ts +++ b/src/debug/jtag/commands/genome/job-status/browser/GenomeJobStatusBrowserCommand.ts @@ -21,7 +21,7 @@ export class GenomeJobStatusBrowserCommand extends CommandBase< } async execute(params: JTAGPayload): Promise { - // Delegate to server - return this.remoteExecute(params); + // Delegate to server — params has userId at runtime (injected by infrastructure) + return this.remoteExecute(params as GenomeJobStatusParams); } } diff --git a/src/debug/jtag/commands/help/shared/HelpTypes.ts b/src/debug/jtag/commands/help/shared/HelpTypes.ts index 9f6b01e05..caddf169a 100644 --- a/src/debug/jtag/commands/help/shared/HelpTypes.ts +++ b/src/debug/jtag/commands/help/shared/HelpTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '../../../system/core/types/ErrorTypes'; import type { UUID } from '../../../system/core/types/CrossPlatformUUID'; import { Commands } from '../../../system/core/shared/Commands'; @@ -51,6 +52,7 @@ export const createHelpParams = ( list?: boolean; } ): HelpParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, path: data.path ?? '', format: data.format ?? undefined, list: data.list ?? false, @@ -96,6 +98,7 @@ export const createHelpResult = ( error?: JTAGError; } ): HelpResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, path: data.path ?? '', content: data.content ?? '', topics: data.topics ?? [], diff --git a/src/debug/jtag/commands/indicator/shared/IndicatorCommand.ts b/src/debug/jtag/commands/indicator/shared/IndicatorCommand.ts index 40edced5c..4d37fea6b 100644 --- a/src/debug/jtag/commands/indicator/shared/IndicatorCommand.ts +++ b/src/debug/jtag/commands/indicator/shared/IndicatorCommand.ts @@ -4,9 +4,9 @@ */ import { CommandBase, type ICommandDaemon } from '../../../daemons/command-daemon/shared/CommandBase'; -import type { JTAGContext, JTAGPayload, CommandResult } from '../../../system/core/types/JTAGTypes'; +import type { JTAGContext, JTAGPayload, CommandParams, CommandResult } from '../../../system/core/types/JTAGTypes'; -export interface IndicatorParams extends JTAGPayload { +export interface IndicatorParams extends CommandParams { message: string; title?: string; type?: 'info' | 'success' | 'warning' | 'error'; diff --git a/src/debug/jtag/commands/inference/generate/shared/InferenceGenerateTypes.ts b/src/debug/jtag/commands/inference/generate/shared/InferenceGenerateTypes.ts index 2dfd0c718..1f83238cc 100644 --- a/src/debug/jtag/commands/inference/generate/shared/InferenceGenerateTypes.ts +++ b/src/debug/jtag/commands/inference/generate/shared/InferenceGenerateTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; // Note: Using simple error string like ai/generate for consistency import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -53,6 +54,7 @@ export const createInferenceGenerateParams = ( adapters?: string[]; } ): InferenceGenerateParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, model: data.model ?? '', provider: data.provider ?? '', maxTokens: data.maxTokens ?? 0, @@ -115,6 +117,7 @@ export const createInferenceGenerateResult = ( error?: string; } ): InferenceGenerateResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, text: data.text ?? '', model: data.model ?? '', provider: data.provider ?? '', diff --git a/src/debug/jtag/commands/interface/browser/capabilities/shared/InterfaceBrowserCapabilitiesTypes.ts b/src/debug/jtag/commands/interface/browser/capabilities/shared/InterfaceBrowserCapabilitiesTypes.ts index 0dcbf7db1..dbc148ca7 100644 --- a/src/debug/jtag/commands/interface/browser/capabilities/shared/InterfaceBrowserCapabilitiesTypes.ts +++ b/src/debug/jtag/commands/interface/browser/capabilities/shared/InterfaceBrowserCapabilitiesTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -25,6 +26,7 @@ export const createInterfaceBrowserCapabilitiesParams = ( sessionId: UUID, data: Record ): InterfaceBrowserCapabilitiesParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -80,6 +82,7 @@ export const createInterfaceBrowserCapabilitiesResult = ( error?: JTAGError; } ): InterfaceBrowserCapabilitiesResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, webmcp: data.webmcp ?? false, webmcpReason: data.webmcpReason ?? '', puppeteer: data.puppeteer ?? false, diff --git a/src/debug/jtag/commands/interface/click/shared/ClickTypes.ts b/src/debug/jtag/commands/interface/click/shared/ClickTypes.ts index 6dd672d49..0a279597a 100644 --- a/src/debug/jtag/commands/interface/click/shared/ClickTypes.ts +++ b/src/debug/jtag/commands/interface/click/shared/ClickTypes.ts @@ -1,30 +1,13 @@ // ISSUES: 0 open, last updated 2025-07-25 - See middle-out/development/code-quality-scouting.md#file-level-issue-tracking -/** - * Click Command - Shared Types for Element Interaction - * - * Minimal types for clicking DOM elements. Follows screenshot/navigate pattern - * with clean inheritance and Object.assign() initialization. - * - * DESIGN ANALYSIS: - * ✅ Focused on single action - clicking elements - * ✅ Clean parameter interface with optional properties - * ✅ Proper constructor pattern with Object.assign() - * ✅ Result type includes success state and metadata - * ✅ No over-engineering - just what's needed for clicks - * - * SCOPE: - * - Browser: Direct DOM element.click() calls - * - Server: Delegates to browser context - * - Consistent interface across contexts - */ - import { CommandParams, CommandResult, createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; +/** Click a DOM element by CSS selector. */ export interface ClickParams extends CommandParams { readonly selector: string; readonly button?: 'left' | 'right' | 'middle'; @@ -45,6 +28,7 @@ export const createClickParams = ( innerSelector?: string; } ): ClickParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, button: data.button ?? 'left', timeout: data.timeout ?? 30000, ...data // selector is required, so it's in data @@ -68,6 +52,7 @@ export const createClickResult = ( error?: JTAGError; } ): ClickResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, selector: data.selector ?? '', clicked: data.clicked ?? false, timestamp: new Date().toISOString(), diff --git a/src/debug/jtag/commands/interface/get-text/shared/GetTextTypes.ts b/src/debug/jtag/commands/interface/get-text/shared/GetTextTypes.ts index 3c218f20b..c1192fb88 100644 --- a/src/debug/jtag/commands/interface/get-text/shared/GetTextTypes.ts +++ b/src/debug/jtag/commands/interface/get-text/shared/GetTextTypes.ts @@ -1,9 +1,11 @@ import { CommandParams, CommandResult, createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; +/** Extract text content from a DOM element by CSS selector. */ export interface GetTextParams extends CommandParams { readonly selector: string; readonly trim?: boolean; @@ -19,6 +21,7 @@ export const createGetTextParams = ( innerText?: boolean; } ): GetTextParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, trim: data.trim ?? true, innerText: data.innerText ?? true, ...data // selector is required, so it's in data @@ -46,6 +49,7 @@ export const createGetTextResult = ( shadowDOMData?: any; } ): GetTextResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, selector: data.selector ?? '', text: data.text ?? '', found: data.found ?? false, diff --git a/src/debug/jtag/commands/interface/launch/url/shared/InterfaceLaunchUrlTypes.ts b/src/debug/jtag/commands/interface/launch/url/shared/InterfaceLaunchUrlTypes.ts index 0bd885ded..57f84090a 100644 --- a/src/debug/jtag/commands/interface/launch/url/shared/InterfaceLaunchUrlTypes.ts +++ b/src/debug/jtag/commands/interface/launch/url/shared/InterfaceLaunchUrlTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -37,6 +38,7 @@ export const createInterfaceLaunchUrlParams = ( screenshot?: boolean; } ): InterfaceLaunchUrlParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, waitForLoad: data.waitForLoad ?? false, screenshot: data.screenshot ?? false, ...data @@ -73,6 +75,7 @@ export const createInterfaceLaunchUrlResult = ( error?: JTAGError; } ): InterfaceLaunchUrlResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, url: data.url ?? '', launched: data.launched ?? false, screenshotPath: data.screenshotPath ?? '', diff --git a/src/debug/jtag/commands/interface/navigate/shared/NavigateTypes.ts b/src/debug/jtag/commands/interface/navigate/shared/NavigateTypes.ts index 0aa13dd6c..269c882db 100644 --- a/src/debug/jtag/commands/interface/navigate/shared/NavigateTypes.ts +++ b/src/debug/jtag/commands/interface/navigate/shared/NavigateTypes.ts @@ -1,25 +1,7 @@ // ISSUES: 0 open, last updated 2025-07-25 - See middle-out/development/code-quality-scouting.md#file-level-issue-tracking -/** - * Navigate Command - Shared Types for Browser Navigation - * - * Minimal, focused types for URL navigation across browser/server contexts. - * Follows the elegant pattern of screenshot command - simple params and results - * with clean inheritance from CommandParams/CommandResult base classes. - * - * DESIGN PRINCIPLES: - * - Object.assign() in constructor for clean initialization - * - Optional properties with sensible defaults - * - Environment-aware results with timestamps - * - Type safety without overkill complexity - * - * USAGE: - * - Browser: Direct window.location navigation - * - Server: Delegates to browser context - * - Symmetric interface across both contexts - */ - import { CommandParams, CommandResult, createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -27,6 +9,7 @@ import { Commands } from '../../../../system/core/shared/Commands'; export type NavigateTarget = '_blank' | '_self' | '_parent' | '_top' | 'webview' | string; +/** Navigate the browser to a URL. */ export interface NavigateParams extends CommandParams { readonly url?: string; // Optional - if not provided, triggers location.reload() readonly timeout?: number; @@ -43,6 +26,7 @@ export const createNavigateParams = ( waitForSelector?: string; } ): NavigateParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, url: data.url ?? '', timeout: data.timeout ?? 30000, waitForSelector: data.waitForSelector, @@ -69,6 +53,7 @@ export const createNavigateResult = ( error?: JTAGError; } ): NavigateResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, url: data.url ?? '', title: data.title, loadTime: data.loadTime, diff --git a/src/debug/jtag/commands/interface/page/fill/shared/InterfacePageFillTypes.ts b/src/debug/jtag/commands/interface/page/fill/shared/InterfacePageFillTypes.ts index 77abee0bd..14fcf5bef 100644 --- a/src/debug/jtag/commands/interface/page/fill/shared/InterfacePageFillTypes.ts +++ b/src/debug/jtag/commands/interface/page/fill/shared/InterfacePageFillTypes.ts @@ -8,6 +8,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -49,6 +50,7 @@ export const createInterfacePageFillParams = ( waitForSelector?: string; } ): InterfacePageFillParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -91,6 +93,7 @@ export const createInterfacePageFillResult = ( error?: JTAGError; } ): InterfacePageFillResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: data.success, formId: data.formId ?? '', filledFields: data.filledFields ?? [], diff --git a/src/debug/jtag/commands/interface/page/forms/shared/InterfacePageFormsTypes.ts b/src/debug/jtag/commands/interface/page/forms/shared/InterfacePageFormsTypes.ts index b9fdd6871..fb2956d69 100644 --- a/src/debug/jtag/commands/interface/page/forms/shared/InterfacePageFormsTypes.ts +++ b/src/debug/jtag/commands/interface/page/forms/shared/InterfacePageFormsTypes.ts @@ -9,6 +9,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -82,6 +83,7 @@ export const createInterfacePageFormsParams = ( waitForSelector?: string; } ): InterfacePageFormsParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -121,6 +123,7 @@ export const createInterfacePageFormsResult = ( error?: JTAGError; } ): InterfacePageFormsResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: data.success, pageUrl: data.pageUrl ?? '', pageTitle: data.pageTitle ?? '', diff --git a/src/debug/jtag/commands/interface/page/submit/shared/InterfacePageSubmitTypes.ts b/src/debug/jtag/commands/interface/page/submit/shared/InterfacePageSubmitTypes.ts index 7a44b4619..0de14e6e0 100644 --- a/src/debug/jtag/commands/interface/page/submit/shared/InterfacePageSubmitTypes.ts +++ b/src/debug/jtag/commands/interface/page/submit/shared/InterfacePageSubmitTypes.ts @@ -8,6 +8,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -42,6 +43,7 @@ export const createInterfacePageSubmitParams = ( waitForSelector?: string; } ): InterfacePageSubmitParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -87,6 +89,7 @@ export const createInterfacePageSubmitResult = ( error?: JTAGError; } ): InterfacePageSubmitResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: data.success, formId: data.formId ?? '', navigatedTo: data.navigatedTo ?? '', diff --git a/src/debug/jtag/commands/interface/proxy-navigate/shared/ProxyNavigateTypes.ts b/src/debug/jtag/commands/interface/proxy-navigate/shared/ProxyNavigateTypes.ts index 679353d33..acd606be6 100644 --- a/src/debug/jtag/commands/interface/proxy-navigate/shared/ProxyNavigateTypes.ts +++ b/src/debug/jtag/commands/interface/proxy-navigate/shared/ProxyNavigateTypes.ts @@ -6,6 +6,7 @@ */ import { CommandParams, CommandResult, createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -39,6 +40,7 @@ export const createProxyNavigateParams = ( timeout?: number; } ): ProxyNavigateParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, target: data.target || 'proxy-iframe', rewriteUrls: data.rewriteUrls ?? true, userAgent: data.userAgent || 'Continuum-Training-Bot/1.0', @@ -58,6 +60,7 @@ export const createProxyNavigateResult = ( error?: JTAGError; } ): ProxyNavigateResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, proxyUrl: data.proxyUrl || '', originalUrl: data.originalUrl || '', ...data diff --git a/src/debug/jtag/commands/interface/screenshot/shared/ScreenshotTypes.ts b/src/debug/jtag/commands/interface/screenshot/shared/ScreenshotTypes.ts index c2a63677f..6c5c96790 100644 --- a/src/debug/jtag/commands/interface/screenshot/shared/ScreenshotTypes.ts +++ b/src/debug/jtag/commands/interface/screenshot/shared/ScreenshotTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -57,8 +58,8 @@ export const createScreenshotParams = ( context: JTAGContext, sessionId: UUID, resultType: ResultType = 'file', - data: Omit, 'context' | 'sessionId' | 'resultType' > -): ScreenshotParams => createPayload(context, sessionId, { resultType, ...data }); + data: Omit, 'context' | 'sessionId' | 'resultType' | 'userId'> +): ScreenshotParams => createPayload(context, sessionId, { userId: SYSTEM_SCOPES.SYSTEM, resultType, ...data }); /** * HTML2Canvas Configuration Options @@ -196,6 +197,7 @@ export const createScreenshotResult = ( sessionId: UUID, data: Omit, 'context' | 'sessionId'> ): ScreenshotResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: false, filepath: '', filename: '', @@ -242,6 +244,7 @@ export const createScreenshotResponse = ( executionTime: number | undefined, sessionId: UUID ): ScreenshotResponse => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: true, timestamp: new Date().toISOString(), filepath, diff --git a/src/debug/jtag/commands/interface/scroll/shared/ScrollTypes.ts b/src/debug/jtag/commands/interface/scroll/shared/ScrollTypes.ts index e6fe9dada..2c65b1ca3 100644 --- a/src/debug/jtag/commands/interface/scroll/shared/ScrollTypes.ts +++ b/src/debug/jtag/commands/interface/scroll/shared/ScrollTypes.ts @@ -1,9 +1,11 @@ import { CommandParams, CommandResult, createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; +/** Scroll the page or a specific element. */ export interface ScrollParams extends CommandParams { readonly x?: number; readonly y?: number; @@ -21,6 +23,7 @@ export const createScrollParams = ( behavior?: 'auto' | 'smooth' | 'instant'; } ): ScrollParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, x: data.x ?? 0, y: data.y ?? 0, selector: data.selector, @@ -50,6 +53,7 @@ export const createScrollResult = ( error?: JTAGError; } ): ScrollResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, scrollX: data.scrollX ?? 0, scrollY: data.scrollY ?? 0, selector: data.selector, diff --git a/src/debug/jtag/commands/interface/type/shared/TypeTypes.ts b/src/debug/jtag/commands/interface/type/shared/TypeTypes.ts index 3b8e74803..c137ea907 100644 --- a/src/debug/jtag/commands/interface/type/shared/TypeTypes.ts +++ b/src/debug/jtag/commands/interface/type/shared/TypeTypes.ts @@ -1,9 +1,11 @@ import { CommandParams, CommandResult, createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; +/** Type text into a form field or input element. */ export interface TypeParams extends CommandParams { readonly selector: string; readonly text: string; @@ -21,6 +23,7 @@ export const createTypeParams = ( delay?: number; } ): TypeParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, text: data.text ?? '', clearFirst: data.clearFirst ?? true, delay: data.delay ?? 0, @@ -47,6 +50,7 @@ export const createTypeResult = ( error?: JTAGError; } ): TypeResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, typed: data.typed ?? false, text: data.text ?? '', timestamp: new Date().toISOString(), diff --git a/src/debug/jtag/commands/interface/wait-for-element/shared/WaitForElementTypes.ts b/src/debug/jtag/commands/interface/wait-for-element/shared/WaitForElementTypes.ts index a7f435843..379d657ee 100644 --- a/src/debug/jtag/commands/interface/wait-for-element/shared/WaitForElementTypes.ts +++ b/src/debug/jtag/commands/interface/wait-for-element/shared/WaitForElementTypes.ts @@ -1,9 +1,11 @@ import { CommandParams, CommandResult, createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; +/** Wait for a DOM element to appear, matching a CSS selector. */ export interface WaitForElementParams extends CommandParams { readonly selector: string; readonly timeout?: number; @@ -21,6 +23,7 @@ export const createWaitForElementParams = ( interval?: number; } ): WaitForElementParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, timeout: data.timeout ?? 30000, visible: data.visible ?? true, interval: data.interval ?? 100, @@ -51,6 +54,7 @@ export const createWaitForElementResult = ( error?: JTAGError; } ): WaitForElementResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, found: data.found ?? false, visible: data.visible ?? false, timeout: data.timeout ?? 30000, diff --git a/src/debug/jtag/commands/interface/webmcp/call/shared/InterfaceWebmcpCallTypes.ts b/src/debug/jtag/commands/interface/webmcp/call/shared/InterfaceWebmcpCallTypes.ts index 85922c47d..2879fef2e 100644 --- a/src/debug/jtag/commands/interface/webmcp/call/shared/InterfaceWebmcpCallTypes.ts +++ b/src/debug/jtag/commands/interface/webmcp/call/shared/InterfaceWebmcpCallTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -37,6 +38,7 @@ export const createInterfaceWebmcpCallParams = ( url?: string; } ): InterfaceWebmcpCallParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, url: data.url ?? '', ...data }); @@ -80,6 +82,7 @@ export const createInterfaceWebmcpCallResult = ( error?: JTAGError; } ): InterfaceWebmcpCallResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, called: data.called ?? false, reason: data.reason ?? '', toolName: data.toolName ?? '', diff --git a/src/debug/jtag/commands/interface/webmcp/discover/shared/InterfaceWebmcpDiscoverTypes.ts b/src/debug/jtag/commands/interface/webmcp/discover/shared/InterfaceWebmcpDiscoverTypes.ts index 99040ef5b..b59b1c9b0 100644 --- a/src/debug/jtag/commands/interface/webmcp/discover/shared/InterfaceWebmcpDiscoverTypes.ts +++ b/src/debug/jtag/commands/interface/webmcp/discover/shared/InterfaceWebmcpDiscoverTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -38,6 +39,7 @@ export const createInterfaceWebmcpDiscoverParams = ( url?: string; } ): InterfaceWebmcpDiscoverParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, url: data.url ?? '', ...data }); @@ -77,6 +79,7 @@ export const createInterfaceWebmcpDiscoverResult = ( error?: JTAGError; } ): InterfaceWebmcpDiscoverResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, available: data.available ?? false, reason: data.reason ?? '', tools: data.tools ?? [], diff --git a/src/debug/jtag/commands/list/shared/ListTypes.ts b/src/debug/jtag/commands/list/shared/ListTypes.ts index dc8a5c256..b3bd7ab72 100644 --- a/src/debug/jtag/commands/list/shared/ListTypes.ts +++ b/src/debug/jtag/commands/list/shared/ListTypes.ts @@ -7,6 +7,7 @@ import type { JTAGContext, CommandParams, JTAGPayload, CommandInput} from '../../../system/core/types/JTAGTypes'; import type { UUID } from '../../../system/core/types/CrossPlatformUUID'; +import { SYSTEM_SCOPES } from '../../../system/core/types/SystemScopes'; import { Commands } from '../../../system/core/shared/Commands'; /** @@ -61,6 +62,7 @@ export function createListParams( return { context, sessionId, + userId: SYSTEM_SCOPES.SYSTEM, includeDescription: false, // Compact by default - use help for details includeSignature: false, // Compact by default - use help for details ...overrides diff --git a/src/debug/jtag/commands/logging/disable/shared/LoggingDisableTypes.ts b/src/debug/jtag/commands/logging/disable/shared/LoggingDisableTypes.ts index c828f9355..e9f878afc 100644 --- a/src/debug/jtag/commands/logging/disable/shared/LoggingDisableTypes.ts +++ b/src/debug/jtag/commands/logging/disable/shared/LoggingDisableTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -33,6 +34,7 @@ export const createLoggingDisableParams = ( category?: string; } ): LoggingDisableParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, category: data.category ?? '', ...data }); @@ -72,6 +74,7 @@ export const createLoggingDisableResult = ( error?: JTAGError; } ): LoggingDisableResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, persona: data.persona ?? '', enabled: data.enabled ?? false, categories: data.categories ?? [], diff --git a/src/debug/jtag/commands/logging/enable/shared/LoggingEnableTypes.ts b/src/debug/jtag/commands/logging/enable/shared/LoggingEnableTypes.ts index f556cec34..32bcb9932 100644 --- a/src/debug/jtag/commands/logging/enable/shared/LoggingEnableTypes.ts +++ b/src/debug/jtag/commands/logging/enable/shared/LoggingEnableTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -33,6 +34,7 @@ export const createLoggingEnableParams = ( category?: string; } ): LoggingEnableParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, category: data.category ?? '', ...data }); @@ -68,6 +70,7 @@ export const createLoggingEnableResult = ( error?: JTAGError; } ): LoggingEnableResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, persona: data.persona ?? '', categories: data.categories ?? [], message: data.message ?? '', diff --git a/src/debug/jtag/commands/logging/status/shared/LoggingStatusTypes.ts b/src/debug/jtag/commands/logging/status/shared/LoggingStatusTypes.ts index 2128a0095..bf6205114 100644 --- a/src/debug/jtag/commands/logging/status/shared/LoggingStatusTypes.ts +++ b/src/debug/jtag/commands/logging/status/shared/LoggingStatusTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -29,6 +30,7 @@ export const createLoggingStatusParams = ( persona?: string; } ): LoggingStatusParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, persona: data.persona ?? '', ...data }); @@ -72,6 +74,7 @@ export const createLoggingStatusResult = ( error?: JTAGError; } ): LoggingStatusResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, personas: data.personas ?? [], systemEnabled: data.systemEnabled ?? false, defaultEnabled: data.defaultEnabled ?? false, diff --git a/src/debug/jtag/commands/logs/config/shared/LogsConfigTypes.ts b/src/debug/jtag/commands/logs/config/shared/LogsConfigTypes.ts index 848d0ab60..54069ea79 100644 --- a/src/debug/jtag/commands/logs/config/shared/LogsConfigTypes.ts +++ b/src/debug/jtag/commands/logs/config/shared/LogsConfigTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '../../../../system/core/types/ErrorTypes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import type { LoggingConfigData } from '../../../../system/core/logging/LoggingConfig'; @@ -38,6 +39,7 @@ export const createLogsConfigParams = ( category?: string; } ): LogsConfigParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, persona: data.persona ?? '', action: data.action ?? undefined, category: data.category ?? '', @@ -86,6 +88,7 @@ export const createLogsConfigResult = ( error?: JTAGError; } ): LogsConfigResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data, message: data.message ?? '' }); diff --git a/src/debug/jtag/commands/logs/read/shared/LogsReadTypes.ts b/src/debug/jtag/commands/logs/read/shared/LogsReadTypes.ts index c8bb5103d..a67bd6007 100644 --- a/src/debug/jtag/commands/logs/read/shared/LogsReadTypes.ts +++ b/src/debug/jtag/commands/logs/read/shared/LogsReadTypes.ts @@ -2,6 +2,7 @@ import type { CommandParams, JTAGContext, CommandInput} from '../../../../system import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; +/** Read lines from a log file with optional filtering by level or component, and optional multi-dimensional structure analysis (temporal, severity, spatial). */ export interface LogsReadParams extends CommandParams { log: string; startLine?: number; diff --git a/src/debug/jtag/commands/logs/search/shared/LogsSearchTypes.ts b/src/debug/jtag/commands/logs/search/shared/LogsSearchTypes.ts index 2c6558af0..44f1b05a2 100644 --- a/src/debug/jtag/commands/logs/search/shared/LogsSearchTypes.ts +++ b/src/debug/jtag/commands/logs/search/shared/LogsSearchTypes.ts @@ -1,6 +1,7 @@ import type { CommandParams, JTAGContext, CommandInput} from '../../../../system/core/types/JTAGTypes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; +/** Search across log files for lines matching a pattern, optionally scoped to specific logs, categories, or personas. */ export interface LogsSearchParams extends CommandParams { pattern: string; logs?: string[]; category?: string; personaId?: string; } export interface LogsSearchResult { context: JTAGContext; sessionId: UUID; success: boolean; error?: string; matches: any[]; totalMatches: number; } diff --git a/src/debug/jtag/commands/logs/stats/shared/LogsStatsTypes.ts b/src/debug/jtag/commands/logs/stats/shared/LogsStatsTypes.ts index bdf79cff3..8fb33c284 100644 --- a/src/debug/jtag/commands/logs/stats/shared/LogsStatsTypes.ts +++ b/src/debug/jtag/commands/logs/stats/shared/LogsStatsTypes.ts @@ -1,6 +1,7 @@ import type { CommandParams, JTAGContext, CommandInput} from '../../../../system/core/types/JTAGTypes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; +/** Return aggregate statistics about all log files, including total file count and combined size in megabytes. */ export interface LogsStatsParams extends CommandParams {} export interface LogsStatsResult { context: JTAGContext; sessionId: UUID; success: boolean; error?: string; totalFiles: number; totalSizeMB: number; } diff --git a/src/debug/jtag/commands/media/resize/server/MediaResizeServerCommand.ts b/src/debug/jtag/commands/media/resize/server/MediaResizeServerCommand.ts index 9747b0380..af6db1563 100644 --- a/src/debug/jtag/commands/media/resize/server/MediaResizeServerCommand.ts +++ b/src/debug/jtag/commands/media/resize/server/MediaResizeServerCommand.ts @@ -129,7 +129,7 @@ export class MediaResizeServerCommand extends CommandBase createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, personaId: data.personaId ?? '', ...data }); @@ -93,6 +95,7 @@ export const createPersonaGenomeResult = ( error?: PersonaGenomeError; } ): PersonaGenomeResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, personaId: data.personaId ?? '', personaName: data.personaName ?? '', baseModel: data.baseModel ?? '', diff --git a/src/debug/jtag/commands/persona/learning/capture-feedback/shared/GenomeCaptureFeedbackTypes.ts b/src/debug/jtag/commands/persona/learning/capture-feedback/shared/GenomeCaptureFeedbackTypes.ts index d15e3b3fd..28b11ac81 100644 --- a/src/debug/jtag/commands/persona/learning/capture-feedback/shared/GenomeCaptureFeedbackTypes.ts +++ b/src/debug/jtag/commands/persona/learning/capture-feedback/shared/GenomeCaptureFeedbackTypes.ts @@ -21,7 +21,7 @@ export type FeedbackType = | 'question'; // "Why did you choose X?" /** - * Parameters for persona/learning/capture-feedback command + * Records a correction, approval, critique, score, or suggestion from one persona to another, enabling reciprocal learning where both the feedback giver and receiver improve from the exchange. */ export interface GenomeCaptureFeedbackParams extends CommandParams { /** diff --git a/src/debug/jtag/commands/persona/learning/capture-interaction/shared/GenomeCaptureInteractionTypes.ts b/src/debug/jtag/commands/persona/learning/capture-interaction/shared/GenomeCaptureInteractionTypes.ts index 2bffaa400..eb1eb2855 100644 --- a/src/debug/jtag/commands/persona/learning/capture-interaction/shared/GenomeCaptureInteractionTypes.ts +++ b/src/debug/jtag/commands/persona/learning/capture-interaction/shared/GenomeCaptureInteractionTypes.ts @@ -10,7 +10,7 @@ import type { CommandParams, CommandResult, CommandInput} from '@system/core/typ import { Commands } from '../../../../../system/core/shared/Commands'; /** - * Parameters for persona/learning/capture-interaction command + * Captures an AI persona's input/output pair during task execution, accumulating training examples in-memory for batch LoRA micro-tuning within a specified learning domain. */ export interface GenomeCaptureInteractionParams extends CommandParams { /** diff --git a/src/debug/jtag/commands/persona/learning/multi-agent-learn/shared/GenomeMultiAgentLearnTypes.ts b/src/debug/jtag/commands/persona/learning/multi-agent-learn/shared/GenomeMultiAgentLearnTypes.ts index effbbb501..bda450556 100644 --- a/src/debug/jtag/commands/persona/learning/multi-agent-learn/shared/GenomeMultiAgentLearnTypes.ts +++ b/src/debug/jtag/commands/persona/learning/multi-agent-learn/shared/GenomeMultiAgentLearnTypes.ts @@ -74,7 +74,7 @@ export interface ParticipantLearning { } /** - * Parameters for persona/learning/multi-agent-learn command + * Triggers collaborative learning across multiple PersonaUser participants after a shared activity completes, distributing reinforcement or correction training to each based on their individual performance metrics. */ export interface GenomeMultiAgentLearnParams extends CommandParams { /** diff --git a/src/debug/jtag/commands/persona/learning/pattern/capture/shared/PersonaLearningPatternCaptureTypes.ts b/src/debug/jtag/commands/persona/learning/pattern/capture/shared/PersonaLearningPatternCaptureTypes.ts index 7e5f4d5c6..8c94f7f6c 100644 --- a/src/debug/jtag/commands/persona/learning/pattern/capture/shared/PersonaLearningPatternCaptureTypes.ts +++ b/src/debug/jtag/commands/persona/learning/pattern/capture/shared/PersonaLearningPatternCaptureTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../../system/core/shared/Commands'; @@ -65,6 +66,7 @@ export const createPersonaLearningPatternCaptureParams = ( makePublic?: boolean; } ): PersonaLearningPatternCaptureParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, description: data.description ?? '', tags: data.tags ?? undefined, applicableWhen: data.applicableWhen ?? undefined, @@ -112,6 +114,7 @@ export const createPersonaLearningPatternCaptureResult = ( error?: JTAGError; } ): PersonaLearningPatternCaptureResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, patternId: data.patternId ?? '', name: data.name ?? '', status: data.status ?? '', diff --git a/src/debug/jtag/commands/persona/learning/pattern/endorse/shared/PersonaLearningPatternEndorseTypes.ts b/src/debug/jtag/commands/persona/learning/pattern/endorse/shared/PersonaLearningPatternEndorseTypes.ts index 067524516..319e86d35 100644 --- a/src/debug/jtag/commands/persona/learning/pattern/endorse/shared/PersonaLearningPatternEndorseTypes.ts +++ b/src/debug/jtag/commands/persona/learning/pattern/endorse/shared/PersonaLearningPatternEndorseTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../../system/core/shared/Commands'; @@ -37,6 +38,7 @@ export const createPersonaLearningPatternEndorseParams = ( notes?: string; } ): PersonaLearningPatternEndorseParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, notes: data.notes ?? '', ...data }); @@ -88,6 +90,7 @@ export const createPersonaLearningPatternEndorseResult = ( error?: JTAGError; } ): PersonaLearningPatternEndorseResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, patternId: data.patternId ?? '', previousConfidence: data.previousConfidence ?? 0, newConfidence: data.newConfidence ?? 0, diff --git a/src/debug/jtag/commands/persona/learning/pattern/query/shared/PersonaLearningPatternQueryTypes.ts b/src/debug/jtag/commands/persona/learning/pattern/query/shared/PersonaLearningPatternQueryTypes.ts index bbd431dc3..5b050a9b0 100644 --- a/src/debug/jtag/commands/persona/learning/pattern/query/shared/PersonaLearningPatternQueryTypes.ts +++ b/src/debug/jtag/commands/persona/learning/pattern/query/shared/PersonaLearningPatternQueryTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../../system/core/shared/Commands'; @@ -76,6 +77,7 @@ export const createPersonaLearningPatternQueryParams = ( orderBy?: string; } ): PersonaLearningPatternQueryParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, domain: data.domain ?? '', type: data.type ?? '', keywords: data.keywords ?? undefined, @@ -118,6 +120,7 @@ export const createPersonaLearningPatternQueryResult = ( error?: JTAGError; } ): PersonaLearningPatternQueryResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, patterns: data.patterns ?? [], totalMatches: data.totalMatches ?? 0, message: data.message ?? '', diff --git a/src/debug/jtag/commands/ping/server/PingServerCommand.ts b/src/debug/jtag/commands/ping/server/PingServerCommand.ts index 2e2ab9400..6b7bddb17 100644 --- a/src/debug/jtag/commands/ping/server/PingServerCommand.ts +++ b/src/debug/jtag/commands/ping/server/PingServerCommand.ts @@ -34,6 +34,7 @@ export class PingServerCommand extends CommandBase { if (aiStatusCommand) { // Call ai/status with 2 second timeout const statusParams: AIStatusParams = { + userId: pingParams.userId, context: params.context, sessionId: params.sessionId, includeInactive: false, diff --git a/src/debug/jtag/commands/positron/cursor/shared/PositronCursorTypes.ts b/src/debug/jtag/commands/positron/cursor/shared/PositronCursorTypes.ts index ff6e29a53..2efe95672 100644 --- a/src/debug/jtag/commands/positron/cursor/shared/PositronCursorTypes.ts +++ b/src/debug/jtag/commands/positron/cursor/shared/PositronCursorTypes.ts @@ -7,6 +7,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -54,6 +55,7 @@ export const createPositronCursorResult = ( action: CursorAction, data: Omit, 'context' | 'sessionId' | 'action'> ): PositronCursorResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, success: true, action, ...data diff --git a/src/debug/jtag/commands/process-registry/server/ProcessRegistryServerCommand.ts b/src/debug/jtag/commands/process-registry/server/ProcessRegistryServerCommand.ts index e0f65638c..f6d13e63e 100644 --- a/src/debug/jtag/commands/process-registry/server/ProcessRegistryServerCommand.ts +++ b/src/debug/jtag/commands/process-registry/server/ProcessRegistryServerCommand.ts @@ -23,11 +23,12 @@ import { PROCESS_REGISTRY_PORTS, DEFAULT_NODE_IDS } from '../shared/ProcessRegistryTypes'; -import { +import { validateRegisterProcessParams, getProcessCapabilities, generateProcessId } from '../shared/ProcessRegistryCommand'; +import { SYSTEM_SCOPES } from '../../../system/core/types/SystemScopes'; interface ProcessRegistryState { registryVersion: string; @@ -294,6 +295,7 @@ export class ProcessRegistryServerCommand extends ProcessRegistryCommand { const context = createServerContext(jtagConfig, 'internal-process-registry'); const result = await this.listProcesses({ + userId: SYSTEM_SCOPES.SYSTEM, context, sessionId: 'internal' as any, includeStale: false diff --git a/src/debug/jtag/commands/process-registry/shared/ProcessRegistryCommand.ts b/src/debug/jtag/commands/process-registry/shared/ProcessRegistryCommand.ts index ca90e38c9..826ffefd4 100644 --- a/src/debug/jtag/commands/process-registry/shared/ProcessRegistryCommand.ts +++ b/src/debug/jtag/commands/process-registry/shared/ProcessRegistryCommand.ts @@ -8,8 +8,8 @@ import { CommandBase, type ICommandDaemon } from '../../../daemons/command-daemon/shared/CommandBase'; import type { JTAGContext } from '../../../system/core/types/JTAGTypes'; import type { UUID } from '../../../system/core/types/CrossPlatformUUID'; -import type { - RegisterProcessParams, +import type { + RegisterProcessParams, RegisterProcessResult, ListProcessesParams, ListProcessesResult, @@ -19,6 +19,7 @@ import type { ProcessCapability, ProcessRegistryEntry } from './ProcessRegistryTypes'; +import { SYSTEM_SCOPES } from '../../../system/core/types/SystemScopes'; /** * Process Registry Command Base Class @@ -64,6 +65,7 @@ export abstract class ProcessRegistryCommand extends CommandBase createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, mode: data.mode ?? undefined, module: data.module ?? '', ...data @@ -118,6 +120,7 @@ export const createRuntimeMetricsResult = ( error?: JTAGError; } ): RuntimeMetricsResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, modules: data.modules ?? [], slowCommands: data.slowCommands ?? [], moduleConfigs: data.moduleConfigs ?? [], diff --git a/src/debug/jtag/commands/security/setup/shared/SecuritySetupTypes.ts b/src/debug/jtag/commands/security/setup/shared/SecuritySetupTypes.ts index faf2f369c..81796bd46 100644 --- a/src/debug/jtag/commands/security/setup/shared/SecuritySetupTypes.ts +++ b/src/debug/jtag/commands/security/setup/shared/SecuritySetupTypes.ts @@ -1,6 +1,7 @@ import { Commands } from '../../../../system/core/shared/Commands'; import type { CommandParams, CommandResult, CommandInput} from '../../../../system/core/types/JTAGTypes'; +/** Install and configure security components (network monitor, proxy) and report their current status. */ export interface SecuritySetupParams extends CommandParams { /** Skip interactive prompts and show status only */ statusOnly?: boolean; diff --git a/src/debug/jtag/commands/sentinel/list/shared/SentinelListTypes.ts b/src/debug/jtag/commands/sentinel/list/shared/SentinelListTypes.ts index b298d6950..11b9a999b 100644 --- a/src/debug/jtag/commands/sentinel/list/shared/SentinelListTypes.ts +++ b/src/debug/jtag/commands/sentinel/list/shared/SentinelListTypes.ts @@ -1,6 +1,4 @@ /** - * Sentinel List Command - Types - * * List saved sentinel definitions from database. */ @@ -8,7 +6,7 @@ import type { CommandParams, CommandResult } from '../../../../system/core/types import type { SentinelDefinition } from '../../../../system/sentinel'; /** - * List params + * List saved sentinel definitions from database with optional filters. */ export interface SentinelListParams extends CommandParams { /** Filter by type */ diff --git a/src/debug/jtag/commands/sentinel/load/shared/SentinelLoadTypes.ts b/src/debug/jtag/commands/sentinel/load/shared/SentinelLoadTypes.ts index 2f86909ca..3b55557d3 100644 --- a/src/debug/jtag/commands/sentinel/load/shared/SentinelLoadTypes.ts +++ b/src/debug/jtag/commands/sentinel/load/shared/SentinelLoadTypes.ts @@ -1,14 +1,12 @@ /** - * Sentinel Load Command - Types - * * Load and optionally run saved sentinel definitions from database. */ import type { CommandParams, CommandResult } from '../../../../system/core/types/JTAGTypes'; -import type { SentinelDefinition, SentinelEntity, SentinelExecutionResult } from '../../../../system/sentinel'; +import type { SentinelEntity, SentinelExecutionResult } from '../../../../system/sentinel'; /** - * Load params + * Load and optionally run a saved sentinel definition by ID. */ export interface SentinelLoadParams extends CommandParams { /** Sentinel entity ID or shortId */ @@ -43,40 +41,3 @@ export interface SentinelLoadResult extends CommandResult { /** Error message if failed */ error?: string; } - -/** - * List params - */ -export interface SentinelListParams extends CommandParams { - /** Filter by type */ - type?: 'build' | 'orchestrate' | 'screenshot' | 'task' | 'script'; - - /** Filter by tags (any match) */ - tags?: string[]; - - /** Only show templates */ - templatesOnly?: boolean; - - /** Limit results */ - limit?: number; -} - -/** - * List result - */ -export interface SentinelListResult extends CommandResult { - success: boolean; - sentinels: Array<{ - id: string; - shortId: string; - name: string; - type: SentinelDefinition['type']; - description?: string; - tags?: string[]; - isTemplate?: boolean; - executionCount: number; - lastRun?: string; - createdAt: string; - }>; - total: number; -} diff --git a/src/debug/jtag/commands/sentinel/logs/list/server/SentinelLogsListServerCommand.ts b/src/debug/jtag/commands/sentinel/logs/list/server/SentinelLogsListServerCommand.ts index 2d4cbf869..95e107553 100644 --- a/src/debug/jtag/commands/sentinel/logs/list/server/SentinelLogsListServerCommand.ts +++ b/src/debug/jtag/commands/sentinel/logs/list/server/SentinelLogsListServerCommand.ts @@ -1,18 +1,15 @@ /** * Sentinel Logs List Command - Server Implementation * - * List available log streams for a sentinel. - * Uses async file operations - NEVER blocks. + * Routes to Rust SentinelModule for log listing. + * Logs are stored in .continuum/jtag/logs/system/sentinels/{handle}/ */ import { CommandBase, type ICommandDaemon } from '../../../../../daemons/command-daemon/shared/CommandBase'; import type { JTAGContext, JTAGPayload } from '../../../../../system/core/types/JTAGTypes'; import { transformPayload } from '../../../../../system/core/types/JTAGTypes'; -import type { SentinelLogsListParams, SentinelLogsListResult, LogStreamInfo } from '../shared/SentinelLogsListTypes'; -import * as fs from 'fs/promises'; -import * as path from 'path'; - -const BASE_DIR = '.sentinel-workspaces'; +import type { SentinelLogsListParams, SentinelLogsListResult } from '../shared/SentinelLogsListTypes'; +import { RustCoreIPCClient } from '../../../../../workers/continuum-core/bindings/RustCoreIPC'; export class SentinelLogsListServerCommand extends CommandBase { constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { @@ -33,53 +30,21 @@ export class SentinelLogsListServerCommand extends CommandBase f.endsWith('.log')); - - // Get info for each file - const streams: LogStreamInfo[] = await Promise.all( - logFiles.map(async (filename) => { - const filePath = path.join(logsDir, filename); - const stats = await fs.stat(filePath); - return { - name: filename.replace('.log', ''), - path: filePath, - size: stats.size, - modifiedAt: stats.mtime.toISOString(), - }; - }) - ); - - // Sort by modified time (most recent first) - streams.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()); + const rustClient = RustCoreIPCClient.getInstance(); + const result = await rustClient.sentinelLogsList(handle); return transformPayload(params, { success: true, handle, - logsDir, - streams, + logsDir: result.logsDir || '', + streams: result.streams || [], }); } catch (error: any) { - if (error.code === 'ENOENT') { - return transformPayload(params, { - success: false, - handle, - logsDir, - streams: [], - error: `No logs found for sentinel: ${handle}`, - }); - } return transformPayload(params, { success: false, handle, - logsDir, + logsDir: '', streams: [], error: error.message, }); diff --git a/src/debug/jtag/commands/sentinel/logs/list/shared/SentinelLogsListTypes.ts b/src/debug/jtag/commands/sentinel/logs/list/shared/SentinelLogsListTypes.ts index c4f9f61f0..ccbde586b 100644 --- a/src/debug/jtag/commands/sentinel/logs/list/shared/SentinelLogsListTypes.ts +++ b/src/debug/jtag/commands/sentinel/logs/list/shared/SentinelLogsListTypes.ts @@ -1,13 +1,11 @@ /** - * Sentinel Logs List Command - Types - * - * List available log streams for a sentinel. + * List available log streams for a sentinel by handle. */ import type { CommandParams, CommandResult } from '../../../../../system/core/types/JTAGTypes'; /** - * List params + * List available log streams for a sentinel by handle. */ export interface SentinelLogsListParams extends CommandParams { /** Sentinel handle (short ID or full ID) */ diff --git a/src/debug/jtag/commands/sentinel/logs/read/server/SentinelLogsReadServerCommand.ts b/src/debug/jtag/commands/sentinel/logs/read/server/SentinelLogsReadServerCommand.ts index 810c8e908..88eac9ecf 100644 --- a/src/debug/jtag/commands/sentinel/logs/read/server/SentinelLogsReadServerCommand.ts +++ b/src/debug/jtag/commands/sentinel/logs/read/server/SentinelLogsReadServerCommand.ts @@ -1,18 +1,15 @@ /** * Sentinel Logs Read Command - Server Implementation * - * Read a log stream for a sentinel with optional offset/limit. - * Uses async file operations - NEVER blocks. + * Routes to Rust SentinelModule for log reading. + * Logs are stored in .continuum/jtag/logs/system/sentinels/{handle}/ */ import { CommandBase, type ICommandDaemon } from '../../../../../daemons/command-daemon/shared/CommandBase'; import type { JTAGContext, JTAGPayload } from '../../../../../system/core/types/JTAGTypes'; import { transformPayload } from '../../../../../system/core/types/JTAGTypes'; import type { SentinelLogsReadParams, SentinelLogsReadResult } from '../shared/SentinelLogsReadTypes'; -import * as fs from 'fs/promises'; -import * as path from 'path'; - -const BASE_DIR = '.sentinel-workspaces'; +import { RustCoreIPCClient } from '../../../../../workers/continuum-core/bindings/RustCoreIPC'; export class SentinelLogsReadServerCommand extends CommandBase { constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { @@ -49,46 +46,20 @@ export class SentinelLogsReadServerCommand extends CommandBase 0) { - if (selectedLines.length > limit) { - selectedLines = selectedLines.slice(0, limit); - truncated = true; - } - } + const rustClient = RustCoreIPCClient.getInstance(); + const result = await rustClient.sentinelLogsRead(handle, stream, offset, limit); return transformPayload(params, { success: true, handle, stream, - content: selectedLines.join('\n'), - lineCount: selectedLines.length, - totalLines, - truncated, + content: result.content || '', + lineCount: result.lineCount || 0, + totalLines: result.totalLines || 0, + truncated: result.truncated || false, }); } catch (error: any) { - if (error.code === 'ENOENT') { - return transformPayload(params, { - success: false, - handle, - stream, - content: '', - lineCount: 0, - totalLines: 0, - truncated: false, - error: `Log stream not found: ${stream}`, - }); - } return transformPayload(params, { success: false, handle, diff --git a/src/debug/jtag/commands/sentinel/logs/read/shared/SentinelLogsReadTypes.ts b/src/debug/jtag/commands/sentinel/logs/read/shared/SentinelLogsReadTypes.ts index 224ff7eea..eb31f1604 100644 --- a/src/debug/jtag/commands/sentinel/logs/read/shared/SentinelLogsReadTypes.ts +++ b/src/debug/jtag/commands/sentinel/logs/read/shared/SentinelLogsReadTypes.ts @@ -1,13 +1,11 @@ /** - * Sentinel Logs Read Command - Types - * - * Read a log stream for a sentinel. + * Read a log stream for a sentinel with optional offset and limit for pagination. */ import type { CommandParams, CommandResult } from '../../../../../system/core/types/JTAGTypes'; /** - * Read params + * Read a log stream for a sentinel with optional offset and limit for pagination. */ export interface SentinelLogsReadParams extends CommandParams { /** Sentinel handle (short ID or full ID) */ diff --git a/src/debug/jtag/commands/sentinel/logs/tail/server/SentinelLogsTailServerCommand.ts b/src/debug/jtag/commands/sentinel/logs/tail/server/SentinelLogsTailServerCommand.ts index 0a6a6685a..95902f17a 100644 --- a/src/debug/jtag/commands/sentinel/logs/tail/server/SentinelLogsTailServerCommand.ts +++ b/src/debug/jtag/commands/sentinel/logs/tail/server/SentinelLogsTailServerCommand.ts @@ -1,18 +1,16 @@ /** * Sentinel Logs Tail Command - Server Implementation * - * Get the last N lines of a log stream (like Unix tail). - * Uses async file operations - NEVER blocks. + * Routes to Rust SentinelModule for log tailing. + * Logs are stored in .continuum/jtag/logs/system/sentinels/{handle}/ */ import { CommandBase, type ICommandDaemon } from '../../../../../daemons/command-daemon/shared/CommandBase'; import type { JTAGContext, JTAGPayload } from '../../../../../system/core/types/JTAGTypes'; import { transformPayload } from '../../../../../system/core/types/JTAGTypes'; import type { SentinelLogsTailParams, SentinelLogsTailResult } from '../shared/SentinelLogsTailTypes'; -import * as fs from 'fs/promises'; -import * as path from 'path'; +import { RustCoreIPCClient } from '../../../../../workers/continuum-core/bindings/RustCoreIPC'; -const BASE_DIR = '.sentinel-workspaces'; const DEFAULT_LINES = 20; export class SentinelLogsTailServerCommand extends CommandBase { @@ -46,33 +44,18 @@ export class SentinelLogsTailServerCommand extends CommandBase { @@ -16,6 +17,6 @@ export class SentinelRunBrowserCommand extends CommandBase { // Delegate to server - sentinels run on server side - return await this.remoteExecute(params); + return await this.remoteExecute({ ...params, userId: SYSTEM_SCOPES.SYSTEM }); } } diff --git a/src/debug/jtag/commands/sentinel/run/server/SentinelRunServerCommand.ts b/src/debug/jtag/commands/sentinel/run/server/SentinelRunServerCommand.ts index 3853bc99a..1c8fb2d0e 100644 --- a/src/debug/jtag/commands/sentinel/run/server/SentinelRunServerCommand.ts +++ b/src/debug/jtag/commands/sentinel/run/server/SentinelRunServerCommand.ts @@ -1,399 +1,76 @@ /** * Sentinel Run Command - Server Implementation * - * Creates and runs Sentinels from JSON config. - * Uses handles for long-running operations, emits events for progress. + * Fire-and-forget wrapper that forwards to Rust SentinelModule. + * Returns handle immediately. Status via sentinel/status. */ import { CommandBase, type ICommandDaemon } from '../../../../daemons/command-daemon/shared/CommandBase'; import type { JTAGContext, JTAGPayload } from '../../../../system/core/types/JTAGTypes'; import { transformPayload } from '../../../../system/core/types/JTAGTypes'; -import { Events } from '../../../../system/core/shared/Events'; -import { v4 as uuid } from 'uuid'; -import type { - SentinelRunParams, - SentinelRunResult, - SentinelResultData, - AnySentinelParams, - BuildSentinelParams, - OrchestrateSentinelParams, - ScreenshotSentinelParams, - TaskSentinelParams, - SentinelType, -} from '../shared/SentinelRunTypes'; - -// Import sentinels -import { BuildSentinel } from '../../../../system/sentinel/BuildSentinel'; -import { OrchestratorSentinel } from '../../../../system/sentinel/OrchestratorSentinel'; -import { VisualSentinel } from '../../../../system/sentinel/VisualSentinel'; -import { ModelCapacity, ModelProvider } from '../../../../system/sentinel/ModelProvider'; - -/** - * Active sentinel handles - */ -interface SentinelHandle { - id: string; - type: SentinelType; - status: 'running' | 'completed' | 'failed'; - progress: number; - startTime: number; - userId?: string; // Who initiated this sentinel - data?: SentinelResultData['data']; - error?: string; -} - -const activeHandles = new Map(); +import type { SentinelRunParams, SentinelRunResult } from '../shared/SentinelRunTypes'; +import { RustCoreIPCClient } from '../../../../workers/continuum-core/bindings/RustCoreIPC'; +import type { Pipeline } from '../../../../workers/continuum-core/bindings/modules/sentinel'; export class SentinelRunServerCommand extends CommandBase { - private workingDir: string; - constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { super('sentinel/run', context, subpath, commander); - this.workingDir = process.cwd(); } async execute(params: JTAGPayload): Promise { - let sentinelParams = params as AnySentinelParams; - - // Parse tasks if passed as JSON string (from CLI) - if (sentinelParams.type === 'task' && typeof (sentinelParams as any).tasks === 'string') { - try { - (sentinelParams as any).tasks = JSON.parse((sentinelParams as any).tasks); - } catch (e) { - return transformPayload(params, { - success: false, - completed: true, - data: { success: false, errors: ['Invalid tasks JSON: ' + (e as Error).message] }, - }); - } + // Parse definition + let definition: any; + + if ((params as any).definition) { + definition = typeof (params as any).definition === 'string' + ? JSON.parse((params as any).definition) + : (params as any).definition; + } else if ((params as any).steps) { + definition = { steps: (params as any).steps }; + } else { + return transformPayload(params, { + success: false, + completed: true, + error: 'Missing pipeline definition. Provide --definition or --steps', + }); } - const workingDir = sentinelParams.workingDir || this.workingDir; - const runAsync = sentinelParams.async !== false; // Default to async + const workingDir = (params as any).workingDir || process.cwd(); - // Create handle for tracking - const handle = uuid().slice(0, 8); - const userId = (params as any).userId as string | undefined; - const handleData: SentinelHandle = { - id: handle, - type: sentinelParams.type, - status: 'running', - progress: 0, - userId, - startTime: Date.now(), + // Build pipeline for Rust + const pipeline: Pipeline = { + name: definition.name || 'unnamed', + steps: definition.steps, + workingDir, + timeoutSecs: definition.timeoutSecs || definition.timeout_secs, + inputs: definition.inputs || {}, }; - activeHandles.set(handle, handleData); - - // Emit start event - this.emitProgress(handle, sentinelParams.type, 'starting', 0, `Starting ${sentinelParams.type} sentinel`); - // Run sentinel based on type - if (runAsync) { - // Run async - return handle immediately - this.runSentinelAsync(handle, sentinelParams, workingDir); - return transformPayload(params, { - success: true, - handle, - completed: false, - }); - } else { - // Run sync - wait for completion - const result = await this.runSentinel(handle, sentinelParams, workingDir); - return transformPayload(params, result); - } - } + // Route to Rust sentinel/execute (NOT sentinel/pipeline) + // sentinel/execute spawns a task and returns handle immediately + const rustClient = RustCoreIPCClient.getInstance(); - /** - * Run sentinel asynchronously - */ - private async runSentinelAsync(handle: string, params: AnySentinelParams, workingDir: string): Promise { try { - const result = await this.runSentinel(handle, params, workingDir); - - const handleData = activeHandles.get(handle); - if (handleData) { - handleData.status = result.data?.success ? 'completed' : 'failed'; - handleData.progress = 100; - handleData.data = result.data; - } + // Use sentinel/run which spawns a task for the pipeline + const result = await rustClient.sentinelRun({ + type: 'pipeline', + command: 'pipeline', // Internal: tells Rust this is a pipeline + args: [], + workingDir, + env: { PIPELINE_JSON: JSON.stringify(pipeline) }, + }); - // Emit complete event (include userId for memory capture) - Events.emit('sentinel:complete', { - handle, - type: params.type, - userId: handleData?.userId, - success: result.data?.success || false, - data: result.data, + return transformPayload(params, { + success: true, + handle: result.handle, + completed: false, // Not completed - running in background }); } catch (error: any) { - const handleData = activeHandles.get(handle); - if (handleData) { - handleData.status = 'failed'; - handleData.error = error.message; - } - - Events.emit('sentinel:error', { - handle, - type: params.type, - userId: handleData?.userId, + return transformPayload(params, { + success: false, + completed: true, error: error.message, }); } } - - /** - * Run sentinel and return result - */ - private async runSentinel(handle: string, params: AnySentinelParams, workingDir: string): Promise { - switch (params.type) { - case 'build': - return this.runBuildSentinel(handle, params as BuildSentinelParams, workingDir); - case 'orchestrate': - return this.runOrchestrateSentinel(handle, params as OrchestrateSentinelParams, workingDir); - case 'screenshot': - return this.runScreenshotSentinel(handle, params as ScreenshotSentinelParams, workingDir); - case 'task': - return this.runTaskSentinel(handle, params as TaskSentinelParams, workingDir); - default: - return { - success: false, - completed: true, - data: { success: false, errors: [`Unknown sentinel type: ${(params as any).type}`] }, - }; - } - } - - /** - * Run BuildSentinel - */ - private async runBuildSentinel(handle: string, params: BuildSentinelParams, workingDir: string): Promise { - const sentinel = new BuildSentinel({ - command: params.command, - workingDir, - maxAttempts: params.maxAttempts || 5, - canAutoFix: params.canAutoFix !== false, - // LLM-assisted error fixing - useLLM: params.useLLM, - capacity: params.capacity as ModelCapacity, - provider: params.provider as ModelProvider, - onProgress: (progress) => { - this.emitProgress(handle, 'build', progress.phase, progress.attempt / (params.maxAttempts || 5) * 100, progress.message); - }, - }); - - const result = await sentinel.run(); - - return { - success: result.success, - handle, - completed: true, - data: { - success: result.success, - summary: result.success ? 'Build succeeded' : result.escalationReason, - attempts: result.attempts.length, - errors: result.attempts.filter(a => !a.success).map(a => a.errors.map(e => e.message)).flat(), - }, - }; - } - - /** - * Run OrchestratorSentinel - */ - private async runOrchestrateSentinel(handle: string, params: OrchestrateSentinelParams, workingDir: string): Promise { - const sentinel = new OrchestratorSentinel({ - workingDir, - maxIterations: params.maxIterations || 10, - capacity: params.capacity as ModelCapacity || ModelCapacity.SMALL, - provider: params.provider as ModelProvider || ModelProvider.LOCAL, - modelName: params.modelName, - screenshotDir: params.screenshotDir || '/tmp/sentinel-screenshots', - onThought: (thought) => { - this.emitProgress(handle, 'orchestrate', 'thinking', 50, thought); - }, - onAction: (action, result) => { - this.emitProgress(handle, 'orchestrate', 'acting', 75, `${action}: ${result.slice(0, 100)}`); - }, - onScreenshot: (path) => { - Events.emit('sentinel:screenshot', { handle, type: 'orchestrate', path }); - }, - }); - - const result = await sentinel.execute(params.goal); - - return { - success: result.success, - handle, - completed: true, - data: { - success: result.success, - summary: result.summary, - filesCreated: result.context.filesCreated, - filesModified: result.context.filesModified, - errors: result.context.errors, - iterations: result.context.iteration, - }, - }; - } - - /** - * Run VisualSentinel - */ - private async runScreenshotSentinel(handle: string, params: ScreenshotSentinelParams, workingDir: string): Promise { - const sentinel = new VisualSentinel({ - outputDir: params.outputDir || '/tmp/sentinel-screenshots', - viewport: params.viewport, - }); - - this.emitProgress(handle, 'screenshot', 'capturing', 50, `Taking screenshot of ${params.target}`); - - let result; - if (params.target.startsWith('http://') || params.target.startsWith('https://')) { - result = await sentinel.screenshotUrl(params.target, params.filename); - } else { - result = await sentinel.screenshotFile(params.target, params.filename); - } - - if (result.success && result.imagePath) { - Events.emit('sentinel:screenshot', { handle, type: 'screenshot', path: result.imagePath }); - } - - return { - success: result.success, - handle, - completed: true, - data: { - success: result.success, - screenshot: result.imagePath, - errors: result.error ? [result.error] : undefined, - }, - }; - } - - /** - * Run TaskSentinel (simplified - just execute tasks in order) - */ - private async runTaskSentinel(handle: string, params: TaskSentinelParams, workingDir: string): Promise { - const results: Array<{ name: string; success: boolean; output: string }> = []; - - for (let i = 0; i < params.tasks.length; i++) { - const task = params.tasks[i]; - this.emitProgress(handle, 'task', task.name, (i / params.tasks.length) * 100, `Executing ${task.name}`); - - try { - let taskResult: { success: boolean; output: string }; - - switch (task.action) { - case 'write': - if (task.file && task.content) { - const fs = await import('fs'); - const path = await import('path'); - const fullPath = path.resolve(workingDir, task.file); - const dir = path.dirname(fullPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(fullPath, task.content); - Events.emit('sentinel:file:created', { handle, type: 'task', path: fullPath, size: task.content.length }); - taskResult = { success: true, output: `Wrote ${task.file}` }; - } else { - taskResult = { success: false, output: 'Write requires file and content' }; - } - break; - - case 'read': - if (task.file) { - const fs = await import('fs'); - const path = await import('path'); - const content = fs.readFileSync(path.resolve(workingDir, task.file), 'utf-8'); - taskResult = { success: true, output: content.slice(0, 500) }; - } else { - taskResult = { success: false, output: 'Read requires file' }; - } - break; - - case 'run': - if (task.command) { - const { execSync } = await import('child_process'); - try { - const output = execSync(task.command, { cwd: workingDir, encoding: 'utf-8', timeout: 30000 }); - taskResult = { success: true, output: output.slice(0, 500) }; - } catch (e: any) { - taskResult = { success: false, output: e.message }; - } - } else { - taskResult = { success: false, output: 'Run requires command' }; - } - break; - - case 'build': - const buildResult = await new BuildSentinel({ - command: task.command || 'npm run build', - workingDir, - maxAttempts: 3, - canAutoFix: true, - }).run(); - taskResult = { success: buildResult.success, output: buildResult.escalationReason || 'Build complete' }; - break; - - default: - taskResult = { success: false, output: `Unknown action: ${task.action}` }; - } - - results.push({ name: task.name, ...taskResult }); - } catch (error: any) { - results.push({ name: task.name, success: false, output: error.message }); - } - } - - const allSuccess = results.every(r => r.success); - - return { - success: allSuccess, - handle, - completed: true, - data: { - success: allSuccess, - summary: `Completed ${results.length} tasks, ${results.filter(r => r.success).length} succeeded`, - errors: results.filter(r => !r.success).map(r => `${r.name}: ${r.output}`), - }, - }; - } - - /** - * Emit progress event - */ - private emitProgress(handle: string, type: SentinelType, step: string, progress: number, message: string): void { - const handleData = activeHandles.get(handle); - if (handleData) { - handleData.progress = progress; - } - - Events.emit('sentinel:progress', { handle, type, userId: handleData?.userId, step, progress, message }); - } -} - -/** - * Get handle owner (userId) - */ -export function getHandleOwner(handle: string): string | undefined { - return activeHandles.get(handle)?.userId; -} - -/** - * Get status of a sentinel handle - */ -export function getSentinelStatus(handle: string): SentinelHandle | undefined { - return activeHandles.get(handle); -} - -/** - * Clear old handles (cleanup) - */ -export function cleanupHandles(maxAgeMs: number = 3600000): void { - const now = Date.now(); - for (const [id, handle] of activeHandles) { - if (now - handle.startTime > maxAgeMs && handle.status !== 'running') { - activeHandles.delete(id); - } - } } diff --git a/src/debug/jtag/commands/sentinel/run/shared/SentinelRunTypes.ts b/src/debug/jtag/commands/sentinel/run/shared/SentinelRunTypes.ts index e188197f9..e84f1a7da 100644 --- a/src/debug/jtag/commands/sentinel/run/shared/SentinelRunTypes.ts +++ b/src/debug/jtag/commands/sentinel/run/shared/SentinelRunTypes.ts @@ -1,20 +1,20 @@ /** - * Sentinel Run Command - Types - * - * Allows AIs to create and run Sentinels via JSON config. + * Run sentinels for builds, orchestration, screenshots, tasks, or declarative pipelines. * Uses handles for long-running operations and emits events for progress. */ import type { CommandParams, CommandResult } from '../../../../system/core/types/JTAGTypes'; import type { ModelCapacity, ModelProvider } from '../../../../system/sentinel/ModelProvider'; +import type { PipelineSentinelDefinition } from '../../../../system/sentinel/SentinelDefinition'; + /** * Sentinel types available */ -export type SentinelType = 'build' | 'orchestrate' | 'screenshot' | 'task'; +export type SentinelType = 'build' | 'orchestrate' | 'screenshot' | 'task' | 'pipeline'; /** - * Base params for all sentinel runs + * Run sentinels for builds, orchestration, screenshots, tasks, or declarative pipelines. */ export interface SentinelRunParams extends CommandParams { /** Type of sentinel to run */ @@ -121,6 +121,16 @@ export interface TaskSentinelParams extends SentinelRunParams { maxTotalTasks?: number; } +/** + * PipelineSentinel params (declarative step-based execution) + */ +export interface PipelineSentinelParams extends SentinelRunParams { + type: 'pipeline'; + + /** Pipeline definition (JSON) */ + definition: PipelineSentinelDefinition; +} + /** * Union of all sentinel param types */ @@ -128,7 +138,8 @@ export type AnySentinelParams = | BuildSentinelParams | OrchestrateSentinelParams | ScreenshotSentinelParams - | TaskSentinelParams; + | TaskSentinelParams + | PipelineSentinelParams; /** * Sentinel result data (internal, without JTAGPayload fields) @@ -153,6 +164,12 @@ export interface SentinelResultData { screenshot?: string; attempts?: number; iterations?: number; + // Pipeline-specific fields + stepResults?: unknown[]; + stepsCompleted?: number; + stepsTotal?: number; + durationMs?: number; + error?: string; }; } diff --git a/src/debug/jtag/commands/sentinel/save/server/SentinelSaveServerCommand.ts b/src/debug/jtag/commands/sentinel/save/server/SentinelSaveServerCommand.ts index 2e24c967c..7b3083b01 100644 --- a/src/debug/jtag/commands/sentinel/save/server/SentinelSaveServerCommand.ts +++ b/src/debug/jtag/commands/sentinel/save/server/SentinelSaveServerCommand.ts @@ -1,7 +1,7 @@ /** * Sentinel Save Command - Server Implementation * - * Saves sentinel definitions to the 'sentinels' collection in the database. + * Saves sentinel (pipeline) definitions to the 'sentinels' collection. */ import { CommandBase, type ICommandDaemon } from '../../../../daemons/command-daemon/shared/CommandBase'; @@ -11,8 +11,7 @@ import { Commands } from '../../../../system/core/shared/Commands'; import { v4 as uuid } from 'uuid'; import type { SentinelSaveParams, SentinelSaveResult } from '../shared/SentinelSaveTypes'; import type { SentinelDefinition, SentinelEntity } from '../../../../system/sentinel'; -import { validateDefinition, createDefinitionFromParams } from '../../../../system/sentinel'; -import { getSentinelStatus } from '../../run/server/SentinelRunServerCommand'; +import { validateDefinition } from '../../../../system/sentinel'; const COLLECTION = 'sentinels'; @@ -24,49 +23,27 @@ export class SentinelSaveServerCommand extends CommandBase { const saveParams = params as SentinelSaveParams; - let definition: SentinelDefinition | undefined; - - // Option 1: Definition provided directly - if (saveParams.definition) { - // Parse if string (from CLI) - if (typeof saveParams.definition === 'string') { - try { - definition = JSON.parse(saveParams.definition); - } catch (e) { - return transformPayload(params, { - success: false, - error: 'Invalid definition JSON: ' + (e as Error).message, - }); - } - } else { - definition = saveParams.definition; - } + // Definition is required + if (!saveParams.definition) { + return transformPayload(params, { + success: false, + error: 'Definition is required', + }); } - // Option 2: Capture from handle - if (saveParams.handle && !definition) { - const handleData = getSentinelStatus(saveParams.handle); - if (!handleData) { + // Parse if string (from CLI) + let definition: SentinelDefinition; + if (typeof saveParams.definition === 'string') { + try { + definition = JSON.parse(saveParams.definition); + } catch (e) { return transformPayload(params, { success: false, - error: `Handle not found: ${saveParams.handle}`, + error: 'Invalid definition JSON: ' + (e as Error).message, }); } - - // Reconstruct definition from handle data - // Note: This captures what was run, which may have come from params - definition = createDefinitionFromParams({ - type: handleData.type, - name: saveParams.name || `${handleData.type}-${saveParams.handle}`, - ...handleData.data, - }); - } - - if (!definition) { - return transformPayload(params, { - success: false, - error: 'Either definition or handle is required', - }); + } else { + definition = saveParams.definition; } // Apply overrides diff --git a/src/debug/jtag/commands/sentinel/save/shared/SentinelSaveTypes.ts b/src/debug/jtag/commands/sentinel/save/shared/SentinelSaveTypes.ts index 1e6c26487..b3afeea46 100644 --- a/src/debug/jtag/commands/sentinel/save/shared/SentinelSaveTypes.ts +++ b/src/debug/jtag/commands/sentinel/save/shared/SentinelSaveTypes.ts @@ -1,6 +1,4 @@ /** - * Sentinel Save Command - Types - * * Save sentinel definitions to database for persistence and sharing. * Sentinels are stored in the 'sentinels' collection. */ diff --git a/src/debug/jtag/commands/sentinel/status/browser/SentinelStatusBrowserCommand.ts b/src/debug/jtag/commands/sentinel/status/browser/SentinelStatusBrowserCommand.ts index 14d01dc92..2f7561900 100644 --- a/src/debug/jtag/commands/sentinel/status/browser/SentinelStatusBrowserCommand.ts +++ b/src/debug/jtag/commands/sentinel/status/browser/SentinelStatusBrowserCommand.ts @@ -4,6 +4,7 @@ import { CommandBase, type ICommandDaemon } from '../../../../daemons/command-daemon/shared/CommandBase'; import type { JTAGContext, JTAGPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '../../../../system/core/types/SystemScopes'; import type { SentinelStatusParams, SentinelStatusResult } from '../shared/SentinelStatusTypes'; export class SentinelStatusBrowserCommand extends CommandBase { @@ -13,6 +14,6 @@ export class SentinelStatusBrowserCommand extends CommandBase { - return await this.remoteExecute(params); + return await this.remoteExecute({ ...params, userId: SYSTEM_SCOPES.SYSTEM }); } } diff --git a/src/debug/jtag/commands/sentinel/status/server/SentinelStatusServerCommand.ts b/src/debug/jtag/commands/sentinel/status/server/SentinelStatusServerCommand.ts index 4459a00d3..a3cd89c95 100644 --- a/src/debug/jtag/commands/sentinel/status/server/SentinelStatusServerCommand.ts +++ b/src/debug/jtag/commands/sentinel/status/server/SentinelStatusServerCommand.ts @@ -1,25 +1,25 @@ /** * Sentinel Status Command - Server Implementation * - * Check status of a running sentinel by handle. + * Queries Rust SentinelModule directly for handle status. + * No TypeScript status tracking. */ import { CommandBase, type ICommandDaemon } from '../../../../daemons/command-daemon/shared/CommandBase'; import type { JTAGContext, JTAGPayload } from '../../../../system/core/types/JTAGTypes'; import { transformPayload } from '../../../../system/core/types/JTAGTypes'; import type { SentinelStatusParams, SentinelStatusResult } from '../shared/SentinelStatusTypes'; -import { getSentinelStatus } from '../../run/server/SentinelRunServerCommand'; +import { RustCoreIPCClient } from '../../../../workers/continuum-core/bindings/RustCoreIPC'; export class SentinelStatusServerCommand extends CommandBase { - constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { super('sentinel/status', context, subpath, commander); } async execute(params: JTAGPayload): Promise { - const statusParams = params as SentinelStatusParams; + const handle = (params as SentinelStatusParams).handle; - if (!statusParams.handle) { + if (!handle) { return transformPayload(params, { success: false, handle: '', @@ -28,26 +28,29 @@ export class SentinelStatusServerCommand extends CommandBase( - params, + // DestroySessionParams (daemon-level) doesn't extend CommandParams — bridge with userId + const commandParams = { ...params, userId: SYSTEM_SCOPES.SYSTEM } as CommandParams; + const result = await this.remoteExecute( + commandParams, 'destroy' // subpath matches this command - ); + ) as DestroySessionResult; return result; } catch (error) { diff --git a/src/debug/jtag/commands/session/destroy/shared/SessionDestroyTypes.ts b/src/debug/jtag/commands/session/destroy/shared/SessionDestroyTypes.ts index 91a11bdf0..f94b6af2f 100644 --- a/src/debug/jtag/commands/session/destroy/shared/SessionDestroyTypes.ts +++ b/src/debug/jtag/commands/session/destroy/shared/SessionDestroyTypes.ts @@ -6,11 +6,10 @@ import type { JTAGContext, JTAGPayload, CommandParams, CommandResult, CommandInput} from '../../../../system/core/types/JTAGTypes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; +import { SYSTEM_SCOPES } from '../../../../system/core/types/SystemScopes'; import { Commands } from '../../../../system/core/shared/Commands'; -/** - * Parameters for session destroy command - */ +/** Tear down a user session and clean up all associated resources, optionally recording the reason for destruction. */ export interface SessionDestroyParams extends CommandParams { context: JTAGContext; sessionId: UUID; diff --git a/src/debug/jtag/commands/session/get-user/shared/SessionGetUserTypes.ts b/src/debug/jtag/commands/session/get-user/shared/SessionGetUserTypes.ts index ed5cfb0a2..b675d93ab 100644 --- a/src/debug/jtag/commands/session/get-user/shared/SessionGetUserTypes.ts +++ b/src/debug/jtag/commands/session/get-user/shared/SessionGetUserTypes.ts @@ -2,6 +2,7 @@ import type { CommandParams, CommandResult, CommandInput} from '../../../../syst import type { UserEntity } from '../../../../system/data/entities/UserEntity'; import { Commands } from '../../../../system/core/shared/Commands'; +/** Resolve a session to its owning UserEntity, defaulting to the caller's session or targeting a specific one. */ export interface SessionGetUserParams extends CommandParams { /** * Optional: The session ID to look up diff --git a/src/debug/jtag/commands/skill/activate/shared/SkillActivateTypes.ts b/src/debug/jtag/commands/skill/activate/shared/SkillActivateTypes.ts index e8a9e7004..11ce26d72 100644 --- a/src/debug/jtag/commands/skill/activate/shared/SkillActivateTypes.ts +++ b/src/debug/jtag/commands/skill/activate/shared/SkillActivateTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -29,6 +30,7 @@ export const createSkillActivateParams = ( skillId: string; } ): SkillActivateParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -72,6 +74,7 @@ export const createSkillActivateResult = ( error?: JTAGError; } ): SkillActivateResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, skillId: data.skillId ?? '', name: data.name ?? '', status: data.status ?? '', diff --git a/src/debug/jtag/commands/skill/generate/shared/SkillGenerateTypes.ts b/src/debug/jtag/commands/skill/generate/shared/SkillGenerateTypes.ts index e6361dad4..c2029b276 100644 --- a/src/debug/jtag/commands/skill/generate/shared/SkillGenerateTypes.ts +++ b/src/debug/jtag/commands/skill/generate/shared/SkillGenerateTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -33,6 +34,7 @@ export const createSkillGenerateParams = ( outputDir?: string; } ): SkillGenerateParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, outputDir: data.outputDir ?? '', ...data }); @@ -80,6 +82,7 @@ export const createSkillGenerateResult = ( error?: JTAGError; } ): SkillGenerateResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, skillId: data.skillId ?? '', name: data.name ?? '', status: data.status ?? '', diff --git a/src/debug/jtag/commands/skill/list/shared/SkillListTypes.ts b/src/debug/jtag/commands/skill/list/shared/SkillListTypes.ts index bff5df9d8..65e773082 100644 --- a/src/debug/jtag/commands/skill/list/shared/SkillListTypes.ts +++ b/src/debug/jtag/commands/skill/list/shared/SkillListTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -41,6 +42,7 @@ export const createSkillListParams = ( limit?: number; } ): SkillListParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, status: data.status ?? '', scope: data.scope ?? '', createdById: data.createdById ?? '', @@ -79,6 +81,7 @@ export const createSkillListResult = ( error?: JTAGError; } ): SkillListResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, skills: data.skills ?? [], total: data.total ?? 0, message: data.message ?? '', diff --git a/src/debug/jtag/commands/skill/propose/shared/SkillProposeTypes.ts b/src/debug/jtag/commands/skill/propose/shared/SkillProposeTypes.ts index f7143b951..83c906a40 100644 --- a/src/debug/jtag/commands/skill/propose/shared/SkillProposeTypes.ts +++ b/src/debug/jtag/commands/skill/propose/shared/SkillProposeTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -57,6 +58,7 @@ export const createSkillProposeParams = ( personaId: string; } ): SkillProposeParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, scope: data.scope ?? '', examples: data.examples ?? undefined, ...data @@ -105,6 +107,7 @@ export const createSkillProposeResult = ( error?: JTAGError; } ): SkillProposeResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, skillId: data.skillId ?? '', name: data.name ?? '', status: data.status ?? '', diff --git a/src/debug/jtag/commands/skill/validate/shared/SkillValidateTypes.ts b/src/debug/jtag/commands/skill/validate/shared/SkillValidateTypes.ts index 0da799725..d73421735 100644 --- a/src/debug/jtag/commands/skill/validate/shared/SkillValidateTypes.ts +++ b/src/debug/jtag/commands/skill/validate/shared/SkillValidateTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -29,6 +30,7 @@ export const createSkillValidateParams = ( skillId: string; } ): SkillValidateParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -84,6 +86,7 @@ export const createSkillValidateResult = ( error?: JTAGError; } ): SkillValidateResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, skillId: data.skillId ?? '', name: data.name ?? '', status: data.status ?? '', diff --git a/src/debug/jtag/commands/social/browse/shared/SocialBrowseTypes.ts b/src/debug/jtag/commands/social/browse/shared/SocialBrowseTypes.ts index bb89caac9..c8dd37aaf 100644 --- a/src/debug/jtag/commands/social/browse/shared/SocialBrowseTypes.ts +++ b/src/debug/jtag/commands/social/browse/shared/SocialBrowseTypes.ts @@ -21,6 +21,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; diff --git a/src/debug/jtag/commands/social/classify/shared/SocialClassifyTypes.ts b/src/debug/jtag/commands/social/classify/shared/SocialClassifyTypes.ts index b2c60375c..46c506488 100644 --- a/src/debug/jtag/commands/social/classify/shared/SocialClassifyTypes.ts +++ b/src/debug/jtag/commands/social/classify/shared/SocialClassifyTypes.ts @@ -25,6 +25,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; diff --git a/src/debug/jtag/commands/social/comment/shared/SocialCommentTypes.ts b/src/debug/jtag/commands/social/comment/shared/SocialCommentTypes.ts index cf73d804b..1ed5d8d7d 100644 --- a/src/debug/jtag/commands/social/comment/shared/SocialCommentTypes.ts +++ b/src/debug/jtag/commands/social/comment/shared/SocialCommentTypes.ts @@ -11,6 +11,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -56,6 +57,7 @@ export const createSocialCommentParams = ( personaId?: UUID; } ): SocialCommentParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, parentId: data.parentId ?? '', personaId: data.personaId ?? undefined, ...data @@ -90,6 +92,7 @@ export const createSocialCommentResult = ( error?: JTAGError; } ): SocialCommentResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, message: data.message ?? '', ...data }); diff --git a/src/debug/jtag/commands/social/community/shared/SocialCommunityTypes.ts b/src/debug/jtag/commands/social/community/shared/SocialCommunityTypes.ts index 0514bea3f..fe7fd9b09 100644 --- a/src/debug/jtag/commands/social/community/shared/SocialCommunityTypes.ts +++ b/src/debug/jtag/commands/social/community/shared/SocialCommunityTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; diff --git a/src/debug/jtag/commands/social/downvote/shared/SocialDownvoteTypes.ts b/src/debug/jtag/commands/social/downvote/shared/SocialDownvoteTypes.ts index c33210120..b3eaae758 100644 --- a/src/debug/jtag/commands/social/downvote/shared/SocialDownvoteTypes.ts +++ b/src/debug/jtag/commands/social/downvote/shared/SocialDownvoteTypes.ts @@ -7,6 +7,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; diff --git a/src/debug/jtag/commands/social/engage/shared/SocialEngageTypes.ts b/src/debug/jtag/commands/social/engage/shared/SocialEngageTypes.ts index a8517b291..bbcf482aa 100644 --- a/src/debug/jtag/commands/social/engage/shared/SocialEngageTypes.ts +++ b/src/debug/jtag/commands/social/engage/shared/SocialEngageTypes.ts @@ -21,6 +21,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; diff --git a/src/debug/jtag/commands/social/feed/shared/SocialFeedTypes.ts b/src/debug/jtag/commands/social/feed/shared/SocialFeedTypes.ts index e1c9d2d33..99bb9ba30 100644 --- a/src/debug/jtag/commands/social/feed/shared/SocialFeedTypes.ts +++ b/src/debug/jtag/commands/social/feed/shared/SocialFeedTypes.ts @@ -11,6 +11,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -54,6 +55,7 @@ export const createSocialFeedParams = ( personaId?: UUID; } ): SocialFeedParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, sort: data.sort ?? undefined, community: data.community ?? '', limit: data.limit ?? 0, @@ -88,6 +90,7 @@ export const createSocialFeedResult = ( error?: JTAGError; } ): SocialFeedResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, message: data.message ?? '', ...data }); diff --git a/src/debug/jtag/commands/social/notifications/shared/SocialNotificationsTypes.ts b/src/debug/jtag/commands/social/notifications/shared/SocialNotificationsTypes.ts index 60476251c..cc906e758 100644 --- a/src/debug/jtag/commands/social/notifications/shared/SocialNotificationsTypes.ts +++ b/src/debug/jtag/commands/social/notifications/shared/SocialNotificationsTypes.ts @@ -11,6 +11,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -46,6 +47,7 @@ export const createSocialNotificationsParams = ( personaId?: UUID; } ): SocialNotificationsParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, since: data.since ?? '', limit: data.limit ?? 0, personaId: data.personaId ?? undefined, @@ -82,6 +84,7 @@ export const createSocialNotificationsResult = ( error?: JTAGError; } ): SocialNotificationsResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, message: data.message ?? '', unreadCount: data.unreadCount ?? 0, ...data diff --git a/src/debug/jtag/commands/social/post/shared/SocialPostTypes.ts b/src/debug/jtag/commands/social/post/shared/SocialPostTypes.ts index 8c2029383..3c73e896a 100644 --- a/src/debug/jtag/commands/social/post/shared/SocialPostTypes.ts +++ b/src/debug/jtag/commands/social/post/shared/SocialPostTypes.ts @@ -9,6 +9,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -52,6 +53,7 @@ export const createSocialPostParams = ( personaId?: UUID; } ): SocialPostParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, community: data.community ?? '', url: data.url ?? '', personaId: data.personaId ?? undefined, @@ -84,6 +86,7 @@ export const createSocialPostResult = ( error?: JTAGError; } ): SocialPostResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, message: data.message ?? '', ...data }); diff --git a/src/debug/jtag/commands/social/profile/shared/SocialProfileTypes.ts b/src/debug/jtag/commands/social/profile/shared/SocialProfileTypes.ts index 217b32ce7..1a2712bd1 100644 --- a/src/debug/jtag/commands/social/profile/shared/SocialProfileTypes.ts +++ b/src/debug/jtag/commands/social/profile/shared/SocialProfileTypes.ts @@ -11,6 +11,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -50,6 +51,7 @@ export const createSocialProfileParams = ( personaId?: UUID; } ): SocialProfileParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, agentName: data.agentName ?? undefined, update: data.update ?? false, description: data.description ?? undefined, @@ -87,6 +89,7 @@ export const createSocialProfileResult = ( error?: JTAGError; } ): SocialProfileResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, message: data.message ?? '', ...data }); diff --git a/src/debug/jtag/commands/social/propose/shared/SocialProposeTypes.ts b/src/debug/jtag/commands/social/propose/shared/SocialProposeTypes.ts index f581d5e01..28c3e84f6 100644 --- a/src/debug/jtag/commands/social/propose/shared/SocialProposeTypes.ts +++ b/src/debug/jtag/commands/social/propose/shared/SocialProposeTypes.ts @@ -22,6 +22,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; diff --git a/src/debug/jtag/commands/social/search/shared/SocialSearchTypes.ts b/src/debug/jtag/commands/social/search/shared/SocialSearchTypes.ts index 6c404bf8c..cfa13e8ed 100644 --- a/src/debug/jtag/commands/social/search/shared/SocialSearchTypes.ts +++ b/src/debug/jtag/commands/social/search/shared/SocialSearchTypes.ts @@ -11,6 +11,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; diff --git a/src/debug/jtag/commands/social/signup/shared/SocialSignupTypes.ts b/src/debug/jtag/commands/social/signup/shared/SocialSignupTypes.ts index ee4b579e0..3bcc719b9 100644 --- a/src/debug/jtag/commands/social/signup/shared/SocialSignupTypes.ts +++ b/src/debug/jtag/commands/social/signup/shared/SocialSignupTypes.ts @@ -10,6 +10,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -48,6 +49,7 @@ export const createSocialSignupParams = ( metadata?: Record; } ): SocialSignupParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, description: data.description ?? '', personaId: data.personaId ?? undefined, metadata: data.metadata ?? undefined, @@ -96,6 +98,7 @@ export const createSocialSignupResult = ( error?: JTAGError; } ): SocialSignupResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, message: data.message ?? '', ...data }); diff --git a/src/debug/jtag/commands/social/trending/shared/SocialTrendingTypes.ts b/src/debug/jtag/commands/social/trending/shared/SocialTrendingTypes.ts index 433e99f86..4f206af95 100644 --- a/src/debug/jtag/commands/social/trending/shared/SocialTrendingTypes.ts +++ b/src/debug/jtag/commands/social/trending/shared/SocialTrendingTypes.ts @@ -12,6 +12,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -51,6 +52,7 @@ export const createSocialTrendingParams = ( personaId?: UUID; } ): SocialTrendingParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, sort: data.sort ?? undefined, community: data.community ?? undefined, limit: data.limit ?? 0, @@ -84,6 +86,7 @@ export const createSocialTrendingResult = ( error?: JTAGError; } ): SocialTrendingResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, message: data.message ?? '', ...data }); diff --git a/src/debug/jtag/commands/state/create/shared/StateCreateTypes.ts b/src/debug/jtag/commands/state/create/shared/StateCreateTypes.ts index 51314c51d..28e582ac3 100644 --- a/src/debug/jtag/commands/state/create/shared/StateCreateTypes.ts +++ b/src/debug/jtag/commands/state/create/shared/StateCreateTypes.ts @@ -18,8 +18,6 @@ export interface StateCreateParams extends CommandParams { readonly data: Record; /** Optional explicit ID */ readonly id?: UUID; - /** User ID for context */ - readonly userId?: UUID; } export interface StateCreateResult extends JTAGPayload { diff --git a/src/debug/jtag/commands/state/get/shared/StateGetTypes.ts b/src/debug/jtag/commands/state/get/shared/StateGetTypes.ts index 05bb5331b..edb137c6b 100644 --- a/src/debug/jtag/commands/state/get/shared/StateGetTypes.ts +++ b/src/debug/jtag/commands/state/get/shared/StateGetTypes.ts @@ -6,6 +6,7 @@ import type { JTAGPayload, JTAGContext, CommandParams, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import type { BaseEntity } from '../../../../system/data/entities/BaseEntity'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -20,8 +21,6 @@ export interface StateGetParams extends CommandParams { readonly filter?: Record; /** Sort order */ readonly orderBy?: { field: string; direction: 'asc' | 'desc' }[]; - /** User ID for context filtering */ - readonly userId?: UUID; } // Generic version for internal type safety (not exported for schema) diff --git a/src/debug/jtag/commands/state/update/shared/StateUpdateTypes.ts b/src/debug/jtag/commands/state/update/shared/StateUpdateTypes.ts index b0be0991a..960c03ccd 100644 --- a/src/debug/jtag/commands/state/update/shared/StateUpdateTypes.ts +++ b/src/debug/jtag/commands/state/update/shared/StateUpdateTypes.ts @@ -18,8 +18,6 @@ export interface StateUpdateParams extends CommandParams { readonly id: UUID; /** Update data */ readonly data: Record; - /** User ID for context */ - readonly userId?: UUID; } export interface StateUpdateResult extends JTAGPayload { diff --git a/src/debug/jtag/commands/theme/shared/ThemeTypes.ts b/src/debug/jtag/commands/theme/shared/ThemeTypes.ts index 274417e51..3d4b946f0 100644 --- a/src/debug/jtag/commands/theme/shared/ThemeTypes.ts +++ b/src/debug/jtag/commands/theme/shared/ThemeTypes.ts @@ -7,6 +7,7 @@ import type { JTAGContext, CommandParams } from '../../../system/core/types/JTAGTypes'; import type { JTAGError } from '../../../system/core/types/ErrorTypes'; import type { UUID } from '../../../system/core/types/CrossPlatformUUID'; +import { SYSTEM_SCOPES } from '../../../system/core/types/SystemScopes'; /** * Base theme parameters interface @@ -56,6 +57,7 @@ export function createThemeParams( } = {} ): ThemeParams { return { + userId: SYSTEM_SCOPES.SYSTEM, context, sessionId, timestamp: data.timestamp || new Date().toISOString() diff --git a/src/debug/jtag/commands/training/import/shared/TrainingImportTypes.ts b/src/debug/jtag/commands/training/import/shared/TrainingImportTypes.ts index a1b61451d..0bc75666b 100644 --- a/src/debug/jtag/commands/training/import/shared/TrainingImportTypes.ts +++ b/src/debug/jtag/commands/training/import/shared/TrainingImportTypes.ts @@ -7,6 +7,7 @@ import type { CommandParams, JTAGPayload, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '../../../../system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; import type { DbHandle } from '../../../../daemons/data-daemon/server/DatabaseHandleRegistry'; import { Commands } from '../../../../system/core/shared/Commands'; diff --git a/src/debug/jtag/commands/user/create/shared/UserCreateTypes.ts b/src/debug/jtag/commands/user/create/shared/UserCreateTypes.ts index 317bf2dbf..1ca6a2a34 100644 --- a/src/debug/jtag/commands/user/create/shared/UserCreateTypes.ts +++ b/src/debug/jtag/commands/user/create/shared/UserCreateTypes.ts @@ -13,7 +13,7 @@ import type { CommandParams, JTAGPayload, CommandInput} from '../../../../system/core/types/JTAGTypes'; import { transformPayload } from '../../../../system/core/types/JTAGTypes'; import type { UUID } from '../../../../system/core/types/CrossPlatformUUID'; -import type { UserEntity, UserCapabilities } from '../../../../system/data/entities/UserEntity'; +import type { UserEntity, UserCapabilities, ModelConfig } from '../../../../system/data/entities/UserEntity'; import { Commands } from '../../../../system/core/shared/Commands'; /** @@ -21,34 +21,6 @@ import { Commands } from '../../../../system/core/shared/Commands'; */ export type UserType = 'human' | 'agent' | 'persona'; -/** - * Prompt format types - defines how different model families expect prompts - */ -export type PromptFormat = - | 'base' // Base models (GPT-2, Llama base): "User: ...\n\nAssistant:" - | 'chatml' // ChatML format: "<|im_start|>user\n...<|im_end|>" - | 'llama2' // Llama-2 chat: "[INST] ... [/INST]" - | 'alpaca' // Alpaca format: "### Instruction:\n...\n\n### Response:" - | 'openai' // OpenAI native messages array - | 'anthropic'; // Anthropic native messages array - -/** - * Model configuration for AI users - */ -export interface ModelConfig { - readonly model?: string; - readonly provider?: string; // AI provider (anthropic, openai, groq, deepseek, candle) - readonly temperature?: number; - readonly maxTokens?: number; // Maximum output tokens - readonly contextWindow?: number; // Maximum input tokens (context length) - readonly systemPrompt?: string; // Custom system prompt for persona - readonly capabilities?: readonly string[]; // Model capabilities - readonly promptFormat?: PromptFormat; // How this model expects prompts formatted - readonly requiresExplicitMention?: boolean; // If true, persona only responds when explicitly mentioned (e.g., @sentinel) - readonly ragCertified?: boolean; // Has this model been tested/certified with our complex RAG system? - readonly toolCapability?: 'native' | 'xml' | 'none'; // Override provider-based tool capability detection -} - /** * User create command parameters * These are the "recipe ingredients" for user creation diff --git a/src/debug/jtag/commands/utilities/docs/list/shared/DocsListTypes.ts b/src/debug/jtag/commands/utilities/docs/list/shared/DocsListTypes.ts index bcc319df1..6dcb021a7 100644 --- a/src/debug/jtag/commands/utilities/docs/list/shared/DocsListTypes.ts +++ b/src/debug/jtag/commands/utilities/docs/list/shared/DocsListTypes.ts @@ -2,6 +2,9 @@ import type { CommandParams, JTAGContext, CommandInput} from '@system/core/types import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; +/** + * Lists all available documentation files in the project, returning metadata such as file size, line count, section headings, and directory, with optional filtering by directory or filename pattern. + */ export interface DocsListParams extends CommandParams { dir?: string; // Filter by directory (e.g., "daemons", "system") pattern?: string; // Filter by filename pattern diff --git a/src/debug/jtag/commands/utilities/docs/read/shared/DocsReadTypes.ts b/src/debug/jtag/commands/utilities/docs/read/shared/DocsReadTypes.ts index 27e6c1f0a..d2615259c 100644 --- a/src/debug/jtag/commands/utilities/docs/read/shared/DocsReadTypes.ts +++ b/src/debug/jtag/commands/utilities/docs/read/shared/DocsReadTypes.ts @@ -2,6 +2,9 @@ import type { CommandParams, JTAGContext, CommandInput} from '@system/core/types import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; +/** + * Reads the content of a documentation file by name, with support for table-of-contents extraction, jumping to a specific section, or reading a line range. + */ export interface DocsReadParams extends CommandParams { doc: string; // Simple doc name from docs/list toc?: boolean; // Return table of contents with line ranges diff --git a/src/debug/jtag/commands/utilities/docs/search/shared/DocsSearchTypes.ts b/src/debug/jtag/commands/utilities/docs/search/shared/DocsSearchTypes.ts index 2addc9861..56d2425c4 100644 --- a/src/debug/jtag/commands/utilities/docs/search/shared/DocsSearchTypes.ts +++ b/src/debug/jtag/commands/utilities/docs/search/shared/DocsSearchTypes.ts @@ -2,6 +2,9 @@ import type { CommandParams, JTAGContext, CommandInput} from '@system/core/types import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; +/** + * Searches across all project documentation files for lines matching a text pattern, returning matching lines with their document name, line number, and content. + */ export interface DocsSearchParams extends CommandParams { pattern: string; caseSensitive?: boolean; diff --git a/src/debug/jtag/commands/utilities/hello/shared/HelloTypes.ts b/src/debug/jtag/commands/utilities/hello/shared/HelloTypes.ts index d23ea5c8a..4c2d403fd 100644 --- a/src/debug/jtag/commands/utilities/hello/shared/HelloTypes.ts +++ b/src/debug/jtag/commands/utilities/hello/shared/HelloTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -25,6 +26,7 @@ export const createHelloParams = ( sessionId: UUID, data: Record ): HelloParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, ...data }); @@ -52,6 +54,7 @@ export const createHelloResult = ( error?: JTAGError; } ): HelloResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, message: data.message ?? '', ...data }); diff --git a/src/debug/jtag/commands/utilities/lease/request/shared/LeaseRequestTypes.ts b/src/debug/jtag/commands/utilities/lease/request/shared/LeaseRequestTypes.ts index 8a5ea9623..50a6ddb6a 100644 --- a/src/debug/jtag/commands/utilities/lease/request/shared/LeaseRequestTypes.ts +++ b/src/debug/jtag/commands/utilities/lease/request/shared/LeaseRequestTypes.ts @@ -64,6 +64,7 @@ export const createLeaseRequestParams = ( sessionId: UUID, data: Omit ): LeaseRequestParams => ({ + userId: data.userId, context, sessionId, filePath: data.filePath, diff --git a/src/debug/jtag/commands/utilities/pipe/chain/shared/PipeChainTypes.ts b/src/debug/jtag/commands/utilities/pipe/chain/shared/PipeChainTypes.ts index 46c2ff3ed..416822676 100644 --- a/src/debug/jtag/commands/utilities/pipe/chain/shared/PipeChainTypes.ts +++ b/src/debug/jtag/commands/utilities/pipe/chain/shared/PipeChainTypes.ts @@ -5,6 +5,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; diff --git a/src/debug/jtag/commands/voice/start/shared/VoiceStartTypes.ts b/src/debug/jtag/commands/voice/start/shared/VoiceStartTypes.ts index c40145304..59c8e6265 100644 --- a/src/debug/jtag/commands/voice/start/shared/VoiceStartTypes.ts +++ b/src/debug/jtag/commands/voice/start/shared/VoiceStartTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -37,6 +38,7 @@ export const createVoiceStartParams = ( voice?: string; } ): VoiceStartParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, room: data.room ?? '', model: data.model ?? '', voice: data.voice ?? '', @@ -74,6 +76,7 @@ export const createVoiceStartResult = ( error?: JTAGError; } ): VoiceStartResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, handle: data.handle ?? '', wsUrl: data.wsUrl ?? '', roomId: data.roomId ?? '', diff --git a/src/debug/jtag/commands/voice/stop/shared/VoiceStopTypes.ts b/src/debug/jtag/commands/voice/stop/shared/VoiceStopTypes.ts index 50efe08cb..06c80cf13 100644 --- a/src/debug/jtag/commands/voice/stop/shared/VoiceStopTypes.ts +++ b/src/debug/jtag/commands/voice/stop/shared/VoiceStopTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -29,6 +30,7 @@ export const createVoiceStopParams = ( handle?: string; } ): VoiceStopParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, handle: data.handle ?? '', ...data }); @@ -64,6 +66,7 @@ export const createVoiceStopResult = ( error?: JTAGError; } ): VoiceStopResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, stopped: data.stopped ?? false, handle: data.handle ?? '', duration: data.duration ?? 0, diff --git a/src/debug/jtag/commands/voice/synthesize/shared/VoiceSynthesizeTypes.ts b/src/debug/jtag/commands/voice/synthesize/shared/VoiceSynthesizeTypes.ts index c9dae67ee..bbbe0923b 100644 --- a/src/debug/jtag/commands/voice/synthesize/shared/VoiceSynthesizeTypes.ts +++ b/src/debug/jtag/commands/voice/synthesize/shared/VoiceSynthesizeTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -53,6 +54,7 @@ export const createVoiceSynthesizeParams = ( stream?: boolean; } ): VoiceSynthesizeParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, voice: data.voice ?? '', adapter: data.adapter ?? '', speed: data.speed ?? 0, @@ -101,6 +103,7 @@ export const createVoiceSynthesizeResult = ( error?: JTAGError; } ): VoiceSynthesizeResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, audio: data.audio ?? '', handle: data.handle ?? '', sampleRate: data.sampleRate ?? 0, diff --git a/src/debug/jtag/commands/voice/transcribe/shared/VoiceTranscribeTypes.ts b/src/debug/jtag/commands/voice/transcribe/shared/VoiceTranscribeTypes.ts index aa969d19f..079dfa40a 100644 --- a/src/debug/jtag/commands/voice/transcribe/shared/VoiceTranscribeTypes.ts +++ b/src/debug/jtag/commands/voice/transcribe/shared/VoiceTranscribeTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../system/core/shared/Commands'; @@ -50,6 +51,7 @@ export const createVoiceTranscribeParams = ( model?: string; } ): VoiceTranscribeParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, format: data.format ?? '', language: data.language ?? '', model: data.model ?? '', @@ -91,6 +93,7 @@ export const createVoiceTranscribeResult = ( error?: JTAGError; } ): VoiceTranscribeResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, text: data.text ?? '', language: data.language ?? '', confidence: data.confidence ?? 0, diff --git a/src/debug/jtag/commands/workspace/git/commit/shared/GitCommitTypes.ts b/src/debug/jtag/commands/workspace/git/commit/shared/GitCommitTypes.ts index 0ad504677..a2de1e52b 100644 --- a/src/debug/jtag/commands/workspace/git/commit/shared/GitCommitTypes.ts +++ b/src/debug/jtag/commands/workspace/git/commit/shared/GitCommitTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; @@ -37,6 +38,7 @@ export const createGitCommitParams = ( files?: string[]; } ): GitCommitParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, workspacePath: data.workspacePath ?? '', files: data.files ?? undefined, ...data @@ -73,6 +75,7 @@ export const createGitCommitResult = ( error?: JTAGError; } ): GitCommitResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, commitHash: data.commitHash ?? '', shortHash: data.shortHash ?? '', filesCommitted: data.filesCommitted ?? 0, diff --git a/src/debug/jtag/commands/workspace/git/push/shared/GitPushTypes.ts b/src/debug/jtag/commands/workspace/git/push/shared/GitPushTypes.ts index b65127948..4f9293d5b 100644 --- a/src/debug/jtag/commands/workspace/git/push/shared/GitPushTypes.ts +++ b/src/debug/jtag/commands/workspace/git/push/shared/GitPushTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; @@ -33,6 +34,7 @@ export const createGitPushParams = ( remote?: string; } ): GitPushParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, workspacePath: data.workspacePath ?? '', remote: data.remote ?? '', ...data @@ -69,6 +71,7 @@ export const createGitPushResult = ( error?: JTAGError; } ): GitPushResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, branch: data.branch ?? '', remote: data.remote ?? '', commitsPushed: data.commitsPushed ?? 0, diff --git a/src/debug/jtag/commands/workspace/git/status/shared/GitStatusTypes.ts b/src/debug/jtag/commands/workspace/git/status/shared/GitStatusTypes.ts index 83218bf9d..35039f161 100644 --- a/src/debug/jtag/commands/workspace/git/status/shared/GitStatusTypes.ts +++ b/src/debug/jtag/commands/workspace/git/status/shared/GitStatusTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; @@ -29,6 +30,7 @@ export const createGitStatusParams = ( workspacePath?: string; } ): GitStatusParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, workspacePath: data.workspacePath ?? '', ...data }); @@ -72,6 +74,7 @@ export const createGitStatusResult = ( error?: JTAGError; } ): GitStatusResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, branch: data.branch ?? '', modified: data.modified ?? [], staged: data.staged ?? [], diff --git a/src/debug/jtag/commands/workspace/git/workspace/clean/shared/GitWorkspaceCleanTypes.ts b/src/debug/jtag/commands/workspace/git/workspace/clean/shared/GitWorkspaceCleanTypes.ts index 7f81363fd..ca6311083 100644 --- a/src/debug/jtag/commands/workspace/git/workspace/clean/shared/GitWorkspaceCleanTypes.ts +++ b/src/debug/jtag/commands/workspace/git/workspace/clean/shared/GitWorkspaceCleanTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../../system/core/shared/Commands'; @@ -37,6 +38,7 @@ export const createGitWorkspaceCleanParams = ( deleteBranch?: boolean; } ): GitWorkspaceCleanParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, workspacePath: data.workspacePath ?? '', force: data.force ?? false, deleteBranch: data.deleteBranch ?? false, @@ -70,6 +72,7 @@ export const createGitWorkspaceCleanResult = ( error?: JTAGError; } ): GitWorkspaceCleanResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, workspaceRemoved: data.workspaceRemoved ?? false, branchDeleted: data.branchDeleted ?? false, ...data diff --git a/src/debug/jtag/commands/workspace/git/workspace/init/shared/GitWorkspaceInitTypes.ts b/src/debug/jtag/commands/workspace/git/workspace/init/shared/GitWorkspaceInitTypes.ts index 91d1fca73..9af265fe8 100644 --- a/src/debug/jtag/commands/workspace/git/workspace/init/shared/GitWorkspaceInitTypes.ts +++ b/src/debug/jtag/commands/workspace/git/workspace/init/shared/GitWorkspaceInitTypes.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, JTAGContext, CommandInput} from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../../system/core/shared/Commands'; @@ -37,6 +38,7 @@ export const createGitWorkspaceInitParams = ( paths: string[]; } ): GitWorkspaceInitParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, branch: data.branch ?? '', personaId: data.personaId ?? '', paths: data.paths @@ -77,6 +79,7 @@ export const createGitWorkspaceInitResult = ( error?: JTAGError; } ): GitWorkspaceInitResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, workspaceId: data.workspaceId ?? '', shortId: data.shortId ?? '', workspacePath: data.workspacePath ?? '', diff --git a/src/debug/jtag/commands/workspace/list/shared/WorkspaceListTypes.ts b/src/debug/jtag/commands/workspace/list/shared/WorkspaceListTypes.ts index 09d39e0a3..9bf630156 100644 --- a/src/debug/jtag/commands/workspace/list/shared/WorkspaceListTypes.ts +++ b/src/debug/jtag/commands/workspace/list/shared/WorkspaceListTypes.ts @@ -8,6 +8,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -71,6 +72,7 @@ export const createWorkspaceListParams = ( includeGitStatus?: boolean; } ): WorkspaceListParams => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, personaId: data.personaId ?? '', includeGitStatus: data.includeGitStatus ?? true, ...data @@ -104,6 +106,7 @@ export const createWorkspaceListResult = ( error?: JTAGError; } ): WorkspaceListResult => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, workspaces: data.workspaces ?? [], totalCount: data.totalCount ?? 0, activeCount: data.activeCount ?? 0, diff --git a/src/debug/jtag/commands/workspace/task/complete/shared/TaskCompleteTypes.ts b/src/debug/jtag/commands/workspace/task/complete/shared/TaskCompleteTypes.ts index ad48f61c4..fe93a7c35 100644 --- a/src/debug/jtag/commands/workspace/task/complete/shared/TaskCompleteTypes.ts +++ b/src/debug/jtag/commands/workspace/task/complete/shared/TaskCompleteTypes.ts @@ -10,7 +10,7 @@ import type { UUID } from '@system/core/types/CrossPlatformUUID'; import { Commands } from '../../../../../system/core/shared/Commands'; /** - * Parameters for task/complete command + * Marks a task as completed or failed, recording its output, error details, and performance metrics such as tokens used, latency, and confidence. */ export interface TaskCompleteParams extends CommandParams { /** diff --git a/src/debug/jtag/commands/workspace/task/create/shared/TaskCreateTypes.ts b/src/debug/jtag/commands/workspace/task/create/shared/TaskCreateTypes.ts index 7ee5e4aad..56e865bd4 100644 --- a/src/debug/jtag/commands/workspace/task/create/shared/TaskCreateTypes.ts +++ b/src/debug/jtag/commands/workspace/task/create/shared/TaskCreateTypes.ts @@ -11,7 +11,7 @@ import type { TaskDomain, TaskType, TaskPriority } from '@system/data/entities/T import { Commands } from '../../../../../system/core/shared/Commands'; /** - * Parameters for task/create command + * Creates a new task and assigns it to a PersonaUser, specifying domain, type, priority, optional deadline, dependencies, and a human-readable description of the work to be done. */ export interface TaskCreateParams extends CommandParams { /** diff --git a/src/debug/jtag/commands/workspace/task/list/shared/TaskListTypes.ts b/src/debug/jtag/commands/workspace/task/list/shared/TaskListTypes.ts index ab61b44a3..03fc43042 100644 --- a/src/debug/jtag/commands/workspace/task/list/shared/TaskListTypes.ts +++ b/src/debug/jtag/commands/workspace/task/list/shared/TaskListTypes.ts @@ -11,7 +11,7 @@ import type { TaskDomain, TaskType, TaskStatus, TaskPriority } from '@system/dat import { Commands } from '../../../../../system/core/shared/Commands'; /** - * Parameters for task/list command + * Queries the task queue with filters for assignee, status, domain, task type, and context, returning matching tasks with priority-sorted results and aggregate statistics. */ export interface TaskListParams extends CommandParams { /** diff --git a/src/debug/jtag/daemons/ai-provider-daemon/adapters/anthropic/shared/AnthropicAdapter.ts b/src/debug/jtag/daemons/ai-provider-daemon/adapters/anthropic/shared/AnthropicAdapter.ts index 62a652f13..c4f2f906f 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/adapters/anthropic/shared/AnthropicAdapter.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/adapters/anthropic/shared/AnthropicAdapter.ts @@ -154,12 +154,12 @@ export class AnthropicAdapter extends BaseAIProviderAdapter { // Add native tools if provided (Anthropic's JSON tool format) if (hasNativeTools) { requestBody.tools = request.tools; - if (request.tool_choice) { + if (request.toolChoice) { // Anthropic tool_choice format: 'auto', 'any', 'none', or { type: 'tool', name: 'tool_name' } - if (typeof request.tool_choice === 'object' && 'name' in request.tool_choice) { - requestBody.tool_choice = { type: 'tool', name: request.tool_choice.name }; + if (typeof request.toolChoice === 'object' && 'name' in request.toolChoice) { + requestBody.tool_choice = { type: 'tool', name: request.toolChoice.name }; } else { - requestBody.tool_choice = { type: request.tool_choice }; + requestBody.tool_choice = { type: request.toolChoice }; } } } @@ -217,7 +217,7 @@ export class AnthropicAdapter extends BaseAIProviderAdapter { totalTokens: (response.usage?.input_tokens || 0) + (response.usage?.output_tokens || 0), estimatedCost: this.calculateCost(response.usage, model), }, - responseTime, + responseTimeMs: responseTime, requestId, ...(hasToolCalls && { toolCalls }), }; @@ -242,7 +242,7 @@ export class AnthropicAdapter extends BaseAIProviderAdapter { maxOutputTokens: 8192, costPer1kTokens: { input: 0.003, output: 0.015 }, supportsStreaming: true, - supportsFunctions: true, // Native tool_use support enabled + supportsTools: true, // Native tool_use support enabled }, { id: MODEL_IDS.ANTHROPIC.OPUS_4, @@ -253,7 +253,7 @@ export class AnthropicAdapter extends BaseAIProviderAdapter { maxOutputTokens: 4096, costPer1kTokens: { input: 0.015, output: 0.075 }, supportsStreaming: true, - supportsFunctions: true, // Native tool_use support enabled + supportsTools: true, // Native tool_use support enabled }, { id: MODEL_IDS.ANTHROPIC.HAIKU_3_5, @@ -264,7 +264,7 @@ export class AnthropicAdapter extends BaseAIProviderAdapter { maxOutputTokens: 4096, costPer1kTokens: { input: 0.00025, output: 0.00125 }, supportsStreaming: true, - supportsFunctions: true, // Native tool_use support enabled + supportsTools: true, // Native tool_use support enabled }, ]; } @@ -295,7 +295,7 @@ export class AnthropicAdapter extends BaseAIProviderAdapter { return { status: response.ok ? 'healthy' : 'unhealthy', apiAvailable: response.ok, - responseTime, + responseTimeMs: responseTime, errorRate: response.ok ? 0 : 1, lastChecked: Date.now(), message: response.ok @@ -306,7 +306,7 @@ export class AnthropicAdapter extends BaseAIProviderAdapter { return { status: 'unhealthy', apiAvailable: false, - responseTime: Date.now() - startTime, + responseTimeMs: Date.now() - startTime, errorRate: 1, lastChecked: Date.now(), message: `${this.providerName} API is not accessible: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/debug/jtag/daemons/ai-provider-daemon/adapters/candle-grpc/shared/CandleGrpcAdapter.ts b/src/debug/jtag/daemons/ai-provider-daemon/adapters/candle-grpc/shared/CandleGrpcAdapter.ts index 620ac5f8d..daf924a70 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/adapters/candle-grpc/shared/CandleGrpcAdapter.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/adapters/candle-grpc/shared/CandleGrpcAdapter.ts @@ -65,7 +65,7 @@ export class CandleGrpcAdapter extends BaseAIProviderAdapter { return { status: 'healthy', apiAvailable: true, - responseTime: Date.now() - start, + responseTimeMs: Date.now() - start, errorRate: 0, lastChecked: Date.now(), }; @@ -73,7 +73,7 @@ export class CandleGrpcAdapter extends BaseAIProviderAdapter { return { status: 'unhealthy', apiAvailable: false, - responseTime: 0, + responseTimeMs: 0, errorRate: 1, lastChecked: Date.now(), message: err instanceof Error ? err.message : String(err), @@ -90,7 +90,7 @@ export class CandleGrpcAdapter extends BaseAIProviderAdapter { capabilities: ['text-generation', 'chat'], contextWindow: 8192, supportsStreaming: false, - supportsFunctions: false, + supportsTools: false, }, ]; } @@ -162,7 +162,7 @@ export class CandleGrpcAdapter extends BaseAIProviderAdapter { model: result.model, provider: this.providerId, usage, - responseTime, + responseTimeMs: responseTime, requestId: result.requestId, routing, // Note: Local Candle models don't support tool calling, so toolCalls is always undefined diff --git a/src/debug/jtag/daemons/ai-provider-daemon/adapters/candle/shared/CandleAdapter.ts b/src/debug/jtag/daemons/ai-provider-daemon/adapters/candle/shared/CandleAdapter.ts index 9a1b3614d..09bad64aa 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/adapters/candle/shared/CandleAdapter.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/adapters/candle/shared/CandleAdapter.ts @@ -239,7 +239,7 @@ export class CandleAdapter extends BaseAIProviderAdapter { model: modelId, provider: this.providerId, usage, - responseTime, + responseTimeMs: responseTime, requestId, routing, }; @@ -262,7 +262,7 @@ export class CandleAdapter extends BaseAIProviderAdapter { contextWindow: 4096, maxOutputTokens: 2048, supportsStreaming: false, - supportsFunctions: false, + supportsTools: false, }]; } @@ -275,7 +275,7 @@ export class CandleAdapter extends BaseAIProviderAdapter { return { status: 'healthy', apiAvailable: true, - responseTime: Date.now() - startTime, + responseTimeMs: Date.now() - startTime, errorRate: 0, lastChecked: Date.now(), message: pingResult.message, @@ -284,7 +284,7 @@ export class CandleAdapter extends BaseAIProviderAdapter { return { status: 'unhealthy', apiAvailable: false, - responseTime: Date.now() - startTime, + responseTimeMs: Date.now() - startTime, errorRate: 1.0, lastChecked: Date.now(), message: error instanceof Error ? error.message : 'Health check failed', diff --git a/src/debug/jtag/daemons/ai-provider-daemon/adapters/deepseek/shared/DeepSeekBaseConfig.ts b/src/debug/jtag/daemons/ai-provider-daemon/adapters/deepseek/shared/DeepSeekBaseConfig.ts index a3b390e48..282b48e5d 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/adapters/deepseek/shared/DeepSeekBaseConfig.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/adapters/deepseek/shared/DeepSeekBaseConfig.ts @@ -63,7 +63,7 @@ export class DeepSeekBaseConfig { contextWindow: 32768, costPer1kTokens: { input: 0.0001, output: 0.0002 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'deepseek-coder', @@ -73,7 +73,7 @@ export class DeepSeekBaseConfig { contextWindow: 16384, costPer1kTokens: { input: 0.0001, output: 0.0002 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'deepseek-reasoner', @@ -83,7 +83,7 @@ export class DeepSeekBaseConfig { contextWindow: 32768, costPer1kTokens: { input: 0.00055, output: 0.0022 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true } ]; } diff --git a/src/debug/jtag/daemons/ai-provider-daemon/adapters/fireworks/shared/FireworksBaseConfig.ts b/src/debug/jtag/daemons/ai-provider-daemon/adapters/fireworks/shared/FireworksBaseConfig.ts index bb8d27064..95e966bf7 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/adapters/fireworks/shared/FireworksBaseConfig.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/adapters/fireworks/shared/FireworksBaseConfig.ts @@ -62,7 +62,7 @@ export class FireworksBaseConfig { contextWindow: 131072, costPer1kTokens: { input: 0.0009, output: 0.0009 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, // Llama 3.1 models (some may be deprecated - use 3.3 instead) { @@ -73,7 +73,7 @@ export class FireworksBaseConfig { contextWindow: 131072, costPer1kTokens: { input: 0.003, output: 0.003 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'accounts/fireworks/models/llama-v3p1-70b-instruct', @@ -83,7 +83,7 @@ export class FireworksBaseConfig { contextWindow: 131072, costPer1kTokens: { input: 0.0009, output: 0.0009 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'accounts/fireworks/models/mixtral-8x7b-instruct', @@ -93,7 +93,7 @@ export class FireworksBaseConfig { contextWindow: 32768, costPer1kTokens: { input: 0.0005, output: 0.0005 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'accounts/fireworks/models/qwen2p5-72b-instruct', @@ -103,7 +103,7 @@ export class FireworksBaseConfig { contextWindow: 32768, costPer1kTokens: { input: 0.0009, output: 0.0009 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true } ]; } diff --git a/src/debug/jtag/daemons/ai-provider-daemon/adapters/google/shared/GoogleBaseConfig.ts b/src/debug/jtag/daemons/ai-provider-daemon/adapters/google/shared/GoogleBaseConfig.ts index 9fb498295..669ca86cd 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/adapters/google/shared/GoogleBaseConfig.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/adapters/google/shared/GoogleBaseConfig.ts @@ -65,7 +65,7 @@ export class GoogleBaseConfig { contextWindow: 1048576, // 1M tokens context costPer1kTokens: { input: 0.00015, output: 0.0006 }, // Free tier available supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'gemini-2.0-flash', @@ -75,7 +75,7 @@ export class GoogleBaseConfig { contextWindow: 1048576, costPer1kTokens: { input: 0.0001, output: 0.0004 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'gemini-1.5-flash', @@ -85,7 +85,7 @@ export class GoogleBaseConfig { contextWindow: 1048576, costPer1kTokens: { input: 0.000075, output: 0.0003 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'gemini-1.5-pro', @@ -95,7 +95,7 @@ export class GoogleBaseConfig { contextWindow: 2097152, // 2M tokens context costPer1kTokens: { input: 0.00125, output: 0.005 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, // Audio-native models (handled by GeminiLiveAdapter, listed for discovery) // Note: multimodal with audio capabilities, but text adapter skips this @@ -107,7 +107,7 @@ export class GoogleBaseConfig { contextWindow: 1048576, costPer1kTokens: { input: 0.00015, output: 0.0006 }, supportsStreaming: true, - supportsFunctions: false, + supportsTools: false, // Custom flag: this model is audio-native (not in ModelCapability enum) // @ts-expect-error - isAudioNative is a custom extension isAudioNative: true diff --git a/src/debug/jtag/daemons/ai-provider-daemon/adapters/groq/shared/GroqAdapter.ts b/src/debug/jtag/daemons/ai-provider-daemon/adapters/groq/shared/GroqAdapter.ts index 8c23d8f43..df01c5fda 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/adapters/groq/shared/GroqAdapter.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/adapters/groq/shared/GroqAdapter.ts @@ -45,7 +45,7 @@ export class GroqAdapter extends BaseOpenAICompatibleAdapter { capabilities: ['text-generation', 'chat'], contextWindow: 131072, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'llama-3.1-8b-instant', @@ -54,7 +54,7 @@ export class GroqAdapter extends BaseOpenAICompatibleAdapter { capabilities: ['text-generation', 'chat'], contextWindow: 131072, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, // Mixtral family (Mistral AI) { @@ -64,7 +64,7 @@ export class GroqAdapter extends BaseOpenAICompatibleAdapter { capabilities: ['text-generation', 'chat'], contextWindow: 32768, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, // Gemma family (Google) { @@ -74,7 +74,7 @@ export class GroqAdapter extends BaseOpenAICompatibleAdapter { capabilities: ['text-generation', 'chat'], contextWindow: 8192, supportsStreaming: true, - supportsFunctions: true + supportsTools: true } ] }); diff --git a/src/debug/jtag/daemons/ai-provider-daemon/adapters/openai/shared/OpenAIBaseConfig.ts b/src/debug/jtag/daemons/ai-provider-daemon/adapters/openai/shared/OpenAIBaseConfig.ts index 94a0cf3b8..b58b2214c 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/adapters/openai/shared/OpenAIBaseConfig.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/adapters/openai/shared/OpenAIBaseConfig.ts @@ -61,7 +61,7 @@ export class OpenAIBaseConfig { contextWindow: 128000, costPer1kTokens: { input: 0.0025, output: 0.01 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'gpt-4o-mini', @@ -71,7 +71,7 @@ export class OpenAIBaseConfig { contextWindow: 128000, costPer1kTokens: { input: 0.00015, output: 0.0006 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'gpt-4-turbo', @@ -81,7 +81,7 @@ export class OpenAIBaseConfig { contextWindow: 128000, costPer1kTokens: { input: 0.01, output: 0.03 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'gpt-4', @@ -91,7 +91,7 @@ export class OpenAIBaseConfig { contextWindow: 8192, costPer1kTokens: { input: 0.03, output: 0.06 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'gpt-3.5-turbo', @@ -101,7 +101,7 @@ export class OpenAIBaseConfig { contextWindow: 16385, costPer1kTokens: { input: 0.0005, output: 0.0015 }, supportsStreaming: true, - supportsFunctions: true + supportsTools: true } ]; } diff --git a/src/debug/jtag/daemons/ai-provider-daemon/adapters/sentinel/shared/SentinelAdapter.ts b/src/debug/jtag/daemons/ai-provider-daemon/adapters/sentinel/shared/SentinelAdapter.ts index 262011928..b7a65bf64 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/adapters/sentinel/shared/SentinelAdapter.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/adapters/sentinel/shared/SentinelAdapter.ts @@ -204,7 +204,7 @@ export class SentinelAdapter extends BaseAIProviderAdapter { // Convert AIProviderTypesV2 messages to PromptFormatters messages const formatterMessages: ChatMessage[] = request.messages.map(msg => ({ - role: msg.role, + role: msg.role as ChatMessage['role'], content: typeof msg.content === 'string' ? msg.content : msg.content.map(part => part.type === 'text' ? part.text : `[${part.type}]`).join(' ') @@ -301,7 +301,7 @@ export class SentinelAdapter extends BaseAIProviderAdapter { finishReason: result.done ? 'stop' : 'length', model: result.model, provider: this.providerId, - responseTime, + responseTimeMs: responseTime, requestId, usage: { inputTokens, @@ -339,7 +339,7 @@ export class SentinelAdapter extends BaseAIProviderAdapter { const status: HealthStatus = { status: 'degraded', apiAvailable: false, - responseTime, + responseTimeMs: responseTime, errorRate: 1.0, lastChecked: Date.now(), }; @@ -352,7 +352,7 @@ export class SentinelAdapter extends BaseAIProviderAdapter { const status: HealthStatus = { status: 'healthy', apiAvailable: true, - responseTime, + responseTimeMs: responseTime, errorRate: 0, lastChecked: Date.now(), }; @@ -367,7 +367,7 @@ export class SentinelAdapter extends BaseAIProviderAdapter { const status: HealthStatus = { status: 'unhealthy', apiAvailable: false, - responseTime, + responseTimeMs: responseTime, errorRate: 1.0, lastChecked: Date.now(), }; @@ -402,7 +402,7 @@ export class SentinelAdapter extends BaseAIProviderAdapter { maxOutputTokens: 2048, costPer1kTokens: { input: 0, output: 0 }, supportsStreaming: false, - supportsFunctions: false, + supportsTools: false, })); } catch (error) { this.log(null, 'warn', `Failed to fetch Sentinel models: ${error}`); @@ -421,7 +421,7 @@ export class SentinelAdapter extends BaseAIProviderAdapter { maxOutputTokens: 1024, costPer1kTokens: { input: 0, output: 0 }, supportsStreaming: false, - supportsFunctions: false, + supportsTools: false, }, { id: 'distilgpt2', @@ -432,7 +432,7 @@ export class SentinelAdapter extends BaseAIProviderAdapter { maxOutputTokens: 1024, costPer1kTokens: { input: 0, output: 0 }, supportsStreaming: false, - supportsFunctions: false, + supportsTools: false, }, ]; } diff --git a/src/debug/jtag/daemons/ai-provider-daemon/adapters/together/shared/TogetherBaseConfig.ts b/src/debug/jtag/daemons/ai-provider-daemon/adapters/together/shared/TogetherBaseConfig.ts index a64368401..4d51f4c85 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/adapters/together/shared/TogetherBaseConfig.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/adapters/together/shared/TogetherBaseConfig.ts @@ -91,7 +91,7 @@ export class TogetherBaseConfig { maxOutputTokens: model.max_tokens || 4096, costPer1kTokens: { input: 0.0002, output: 0.0002 }, supportsStreaming: true, - supportsFunctions: false + supportsTools: false })); this.modelsFetchedAt = now; @@ -119,7 +119,7 @@ export class TogetherBaseConfig { maxOutputTokens: 4096, costPer1kTokens: { input: 0.005, output: 0.015 }, supportsStreaming: true, - supportsFunctions: false, + supportsTools: false, }, { id: 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo', @@ -130,7 +130,7 @@ export class TogetherBaseConfig { maxOutputTokens: 4096, costPer1kTokens: { input: 0.0009, output: 0.0009 }, supportsStreaming: true, - supportsFunctions: false, + supportsTools: false, }, { id: 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo', @@ -141,7 +141,7 @@ export class TogetherBaseConfig { maxOutputTokens: 4096, costPer1kTokens: { input: 0.0002, output: 0.0002 }, supportsStreaming: true, - supportsFunctions: false, + supportsTools: false, }, ]; } diff --git a/src/debug/jtag/daemons/ai-provider-daemon/adapters/xai/shared/XAIAdapter.ts b/src/debug/jtag/daemons/ai-provider-daemon/adapters/xai/shared/XAIAdapter.ts index 21139eef0..42cdf0bbe 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/adapters/xai/shared/XAIAdapter.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/adapters/xai/shared/XAIAdapter.ts @@ -46,7 +46,7 @@ export class XAIAdapter extends BaseOpenAICompatibleAdapter { capabilities: ['text-generation', 'chat'], contextWindow: 128000, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'grok-vision-4', @@ -55,7 +55,7 @@ export class XAIAdapter extends BaseOpenAICompatibleAdapter { capabilities: ['text-generation', 'chat', 'image-analysis'], contextWindow: 128000, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'grok-2-1212', @@ -64,7 +64,7 @@ export class XAIAdapter extends BaseOpenAICompatibleAdapter { capabilities: ['text-generation', 'chat'], contextWindow: 128000, supportsStreaming: true, - supportsFunctions: true + supportsTools: true }, { id: 'grok-2-vision-1212', @@ -73,7 +73,7 @@ export class XAIAdapter extends BaseOpenAICompatibleAdapter { capabilities: ['text-generation', 'chat', 'image-analysis'], contextWindow: 128000, supportsStreaming: true, - supportsFunctions: true + supportsTools: true } ] }); diff --git a/src/debug/jtag/daemons/ai-provider-daemon/server/AIProviderDaemonServer.ts b/src/debug/jtag/daemons/ai-provider-daemon/server/AIProviderDaemonServer.ts index 703a63aa3..33923342e 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/server/AIProviderDaemonServer.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/server/AIProviderDaemonServer.ts @@ -223,6 +223,10 @@ export class AIProviderDaemonServer extends AIProviderDaemon { // Node.js main thread only does Map.set() registration with results. this.discoverModelsViaRust(); + // Register local models (Candle adapter) — the adapter is the source of truth + // for its own context window and capabilities (not the static map). + this.registerLocalModels(); + const deferredMs = Date.now() - deferredStart; this.log.info(`✅ AIProviderDaemonServer: DEFERRED init complete (${deferredMs}ms) - health monitoring active`); } @@ -323,6 +327,46 @@ export class AIProviderDaemonServer extends AIProviderDaemon { }); } + /** + * Register local model capabilities in the ModelRegistry. + * + * The Candle adapter is the single source of truth for its own context window + * and capabilities. This queries the Rust adapter and registers the result + * so ModelContextWindows.ts static entries are never used for local models. + */ + private registerLocalModels(): void { + const client = new RustCoreIPCClient(getContinuumCoreSocketPath()); + client.connect() + .then(() => client.execute<{ models: Array<{ id: string; context_window: number; max_output_tokens?: number; provider: string }> }>('ai/models/list', {})) + .then(async (result) => { + if (!result.success || !result.data?.models) return; + + const { ModelRegistry } = await import('../../../system/shared/ModelRegistry'); + const registry = ModelRegistry.sharedInstance(); + let count = 0; + + for (const model of result.data.models) { + registry.register({ + modelId: model.id, + contextWindow: model.context_window, + maxOutputTokens: model.max_output_tokens, + provider: model.provider, + discoveredAt: Date.now(), + }); + count++; + } + + if (count > 0) { + this.log.info(`ModelRegistry: ${count} local models registered from Rust adapters`); + } + client.disconnect(); + }) + .catch((err) => { + this.log.debug(`Local model registration skipped: ${err.message}`); + client.disconnect(); + }); + } + /** * Server-specific shutdown * Shuts down health monitoring, ProcessPool, then delegates to base class diff --git a/src/debug/jtag/daemons/ai-provider-daemon/server/AIProviderRustClient.ts b/src/debug/jtag/daemons/ai-provider-daemon/server/AIProviderRustClient.ts index 2c15b50b6..6d72931b5 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/server/AIProviderRustClient.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/server/AIProviderRustClient.ts @@ -22,10 +22,8 @@ import path from 'path'; import { SOCKETS } from '../../../shared/config'; import type { TextGenerationRequest, - TextGenerationResponse, - ToolCall, - NativeToolSpec, } from '../shared/AIProviderTypesV2'; +import type { TextGenerationResponse, RoutingInfo } from '../../../shared/generated/ai'; // Socket path for continuum-core const SOCKET_PATH = path.isAbsolute(SOCKETS.CONTINUUM_CORE) @@ -42,42 +40,6 @@ interface RustIPCResponse { requestId?: number; } -/** - * Rust AI generation response - */ -interface RustAIResponse { - success: boolean; - text: string; - finishReason: 'stop' | 'length' | 'tool_use' | 'error'; - model: string; - provider: string; - usage: { - inputTokens: number; - outputTokens: number; - totalTokens: number; - estimatedCost?: number; - }; - responseTimeMs: number; - requestId: string; - content?: Array<{ - type: string; - text?: string; - id?: string; - name?: string; - input?: Record; - }>; - toolCalls?: Array<{ - id: string; - name: string; - input: Record; - }>; - routing?: { - provider: string; - isLocal: boolean; - routingReason: string; - }; -} - /** * Provider info from Rust */ @@ -272,21 +234,24 @@ export class AIProviderRustClient { /** * Generate text using Rust AI provider * Supports tool calling via native JSON format + * + * Types are unified via ts-rs: Rust TextGenerationResponse === TS TextGenerationResponse. + * Wire fields pass through directly — no manual mapping needed. */ async generateText(request: TextGenerationRequest): Promise { - // Convert request to Rust format (camelCase → snake_case handled by Rust) - const response = await this.request({ + // Send wire-compatible fields to Rust (TS-only fields like intelligenceLevel are stripped) + const response = await this.request({ command: 'ai/generate', messages: request.messages, systemPrompt: request.systemPrompt, model: request.model, - provider: request.preferredProvider, // TypeScript uses preferredProvider + provider: request.provider, temperature: request.temperature, maxTokens: request.maxTokens, topP: request.topP, stopSequences: request.stopSequences, tools: request.tools, - tool_choice: request.tool_choice, + toolChoice: request.toolChoice, requestId: request.requestId, userId: request.userId, roomId: request.roomId, @@ -297,64 +262,15 @@ export class AIProviderRustClient { throw new Error(response.error || 'AI generation failed'); } + // Rust returns TextGenerationResponse directly — types match, no conversion needed const result = response.result; - // Convert Rust response to TypeScript format - const tsResponse: TextGenerationResponse = { - text: result.text, - finishReason: result.finishReason, - model: result.model, - provider: result.provider, - usage: { - inputTokens: result.usage.inputTokens, - outputTokens: result.usage.outputTokens, - totalTokens: result.usage.totalTokens, - estimatedCost: result.usage.estimatedCost, - }, - responseTime: result.responseTimeMs, - requestId: result.requestId, - }; - - // Add content blocks if present - convert to TypeScript ContentPart format - if (result.content && result.content.length > 0) { - tsResponse.content = result.content.map(block => { - if (block.type === 'text' && block.text) { - return { type: 'text' as const, text: block.text }; - } else if (block.type === 'tool_use' && block.id && block.name && block.input) { - return { type: 'tool_use' as const, id: block.id, name: block.name, input: block.input }; - } else if (block.type === 'tool_result' && block.id && block.name) { - // This is actually tool_result content - return { type: 'tool_result' as const, tool_use_id: block.id, content: block.name }; - } - // Default to text type - return { type: 'text' as const, text: block.text || '' }; - }); - } - - // Add tool calls if present - if (result.toolCalls && result.toolCalls.length > 0) { - tsResponse.toolCalls = result.toolCalls.map(tc => ({ - id: tc.id, - name: tc.name, - input: tc.input, - })); - } - - // Add routing info if present - if (result.routing) { - // Map Rust routing reason to TypeScript expected values - const routingReason: 'explicit_provider' | 'provider_aliasing' | 'model_detection' | 'default_priority' | 'fallback' = - result.routing.routingReason === 'adapter_selected' ? 'default_priority' : 'default_priority'; - - tsResponse.routing = { - provider: result.routing.provider, - isLocal: result.routing.isLocal, - routingReason, - adaptersApplied: [], - }; + // Ensure routing.adaptersApplied is always an array (Rust may omit it) + if (result.routing && !result.routing.adaptersApplied) { + result.routing.adaptersApplied = []; } - return tsResponse; + return result; } /** diff --git a/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderDaemon.ts b/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderDaemon.ts index dd876c8d0..b1b689b03 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderDaemon.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderDaemon.ts @@ -16,7 +16,7 @@ * endpoint: '/ai-provider', * payload: { * type: 'generate-text', - * request: { messages: [...], preferredProvider: 'candle' } + * request: { messages: [...], provider: 'candle' } * } * }); */ @@ -139,7 +139,7 @@ export class AIProviderDaemon extends DaemonBase { */ async generateText(request: TextGenerationRequest): Promise { const timer = TimingHarness.start('ai/generate-text', 'ai'); - timer.setMeta('preferredProvider', request.preferredProvider || 'auto'); + timer.setMeta('provider', request.provider || 'auto'); timer.setMeta('model', request.model || 'default'); timer.setMeta('userId', request.userId || 'unknown'); @@ -153,8 +153,8 @@ export class AIProviderDaemon extends DaemonBase { ); } - // Select provider (considers both preferredProvider AND model name) - const selection = this.selectAdapter(request.preferredProvider, request.model); + // Select provider (considers both provider AND model name) + const selection = this.selectAdapter(request.provider, request.model); timer.mark('select_adapter'); if (!selection) { @@ -164,7 +164,7 @@ export class AIProviderDaemon extends DaemonBase { 'No suitable AI provider available', 'daemon', 'NO_PROVIDER_AVAILABLE', - { preferredProvider: request.preferredProvider } + { provider: request.provider } ); } @@ -217,7 +217,7 @@ export class AIProviderDaemon extends DaemonBase { totalTokens: 0, estimatedCost: 0, }, - responseTime: record.totalMs, + responseTimeMs: record.totalMs, requestId: request.requestId || `req-${Date.now()}`, routing: baseRouting, }; @@ -307,7 +307,7 @@ export class AIProviderDaemon extends DaemonBase { outputTokens: response.usage.outputTokens, totalTokens: response.usage.totalTokens, estimatedCost: response.usage.estimatedCost || 0, - responseTime: response.responseTime, + responseTimeMs: response.responseTimeMs, userId: request.userId, roomId: request.roomId, purpose: request.purpose || 'chat', @@ -355,7 +355,7 @@ export class AIProviderDaemon extends DaemonBase { outputTokens: 0, totalTokens: 0, estimatedCost: 0, - responseTime: 0, + responseTimeMs: 0, userId: request.userId, roomId: request.roomId, purpose: request.purpose || 'chat', @@ -469,7 +469,7 @@ export class AIProviderDaemon extends DaemonBase { healthMap.set(providerId, { status: 'unhealthy', apiAvailable: false, - responseTime: 0, + responseTimeMs: 0, errorRate: 1.0, lastChecked: Date.now(), message: `Health check failed: ${error instanceof Error ? error.message : String(error)}`, @@ -578,24 +578,24 @@ export class AIProviderDaemon extends DaemonBase { * Cloud providers use their own adapters. This prevents queue bottlenecks. * * ROUTING PRIORITY (in order): - * 1. Explicit preferredProvider (if specified and available) + * 1. Explicit provider (if specified and available) * 2. Local provider aliasing (legacy 'local'/'llamacpp' → candle) * 3. Default by priority (highest priority enabled adapter) * * @returns AdapterSelection with routing metadata for observability */ - private selectAdapter(preferredProvider?: string, model?: string): AdapterSelection | null { - // 1. EXPLICIT PROVIDER: Honor preferredProvider first (most specific) + private selectAdapter(provider?: string, model?: string): AdapterSelection | null { + // 1. EXPLICIT PROVIDER: Honor provider first (most specific) // This MUST be checked BEFORE model detection to avoid routing Groq's // 'llama-3.1-8b-instant' to Candle just because it starts with 'llama' - if (preferredProvider) { + if (provider) { // LOCAL PROVIDER ALIASING: Route local providers to Candle // Candle is the ONLY local inference path const localProviders = ['local', 'llamacpp']; - if (localProviders.includes(preferredProvider)) { + if (localProviders.includes(provider)) { const candleReg = this.adapters.get('candle'); if (candleReg && candleReg.enabled) { - this.log.info(`🔄 AIProviderDaemon: Routing '${preferredProvider}' → 'candle' (provider_aliasing)`); + this.log.info(`🔄 AIProviderDaemon: Routing '${provider}' → 'candle' (provider_aliasing)`); return { adapter: candleReg.adapter, routingReason: 'provider_aliasing', @@ -604,17 +604,17 @@ export class AIProviderDaemon extends DaemonBase { } // NO FALLBACK: If candle not available, FAIL - don't silently use something else throw new AIProviderError( - `Local provider '${preferredProvider}' requested but Candle adapter not available`, + `Local provider '${provider}' requested but Candle adapter not available`, 'daemon', 'CANDLE_NOT_AVAILABLE' ); } // Try to use the explicit provider - const registration = this.adapters.get(preferredProvider); + const registration = this.adapters.get(provider); if (registration && registration.enabled) { - const isLocal = ['candle', 'local', 'llamacpp'].includes(preferredProvider); - this.log.info(`🎯 AIProviderDaemon: Using explicit provider '${preferredProvider}' (explicit_provider)`); + const isLocal = ['candle', 'local', 'llamacpp'].includes(provider); + this.log.info(`🎯 AIProviderDaemon: Using explicit provider '${provider}' (explicit_provider)`); return { adapter: registration.adapter, routingReason: 'explicit_provider', @@ -622,12 +622,12 @@ export class AIProviderDaemon extends DaemonBase { }; } - // preferredProvider specified but not available - FAIL, don't silently use something else + // Provider specified but not available - FAIL, don't silently use something else throw new AIProviderError( - `Preferred provider '${preferredProvider}' not available`, + `Provider '${provider}' not available`, 'daemon', 'PROVIDER_NOT_AVAILABLE', - { preferredProvider } + { provider } ); } @@ -775,7 +775,7 @@ export class AIProviderDaemon extends DaemonBase { * const response = await AIProviderDaemon.generateText({ * messages: [{ role: 'user', content: 'Hello!' }], * model: 'llama3.2:1b', - * preferredProvider: 'candle' + * provider: 'candle' * }); */ static async generateText(request: TextGenerationRequest): Promise { @@ -804,7 +804,7 @@ export class AIProviderDaemon extends DaemonBase { request.model || 'unknown', error, request, - request.preferredProvider || 'unknown' + request.provider || 'unknown' ).catch(() => {}); throw error; } @@ -817,7 +817,7 @@ export class AIProviderDaemon extends DaemonBase { * const response = await AIProviderDaemon.createEmbedding({ * input: 'Hello, world!', * model: 'nomic-embed-text', - * preferredProvider: 'candle' + * provider: 'candle' * }); */ static async createEmbedding(request: EmbeddingRequest): Promise { diff --git a/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderTypesV2.ts b/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderTypesV2.ts index f3037a7c9..4fef55448 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderTypesV2.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderTypesV2.ts @@ -1,156 +1,84 @@ /** - * AI Provider Types V2 - Multimodal Support + * AI Provider Types V2 - Unified Type Layer * ========================================== * - * Unified interface for text, audio, video, image, and multimodal AI providers. - * Supports both local inference (Candle, MLX) and API providers (OpenAI, Anthropic, etc.) + * Wire types come from Rust (via ts-rs generated types in shared/generated/ai/). + * This file re-exports those types and adds TS-only infrastructure types + * (adapter interface, error class, helpers, audio/image request/response types + * not yet in Rust). * - * Capabilities: - * - Text generation (LLMs) - * - Audio generation/transcription (TTS, STT) - * - Image generation/analysis (DALL-E, Stable Diffusion, vision models) - * - Video generation/analysis - * - Multimodal (text + image input, etc.) - * - Embeddings + * ARCHITECTURE: + * - Rust ai/types.rs is the single source of truth for wire types + * - ts-rs generates TypeScript types at compile time + * - This file re-exports generated types + defines TS-only extensions + * - All 67+ consumers continue importing from this file (no import path changes) */ import type { JTAGContext, UUID } from '../../../system/core/types/JTAGTypes'; import type { ModelTier, ModelTags, ModelResolution } from './ModelTiers'; -// ======================== -// Model Capabilities -// ======================== - -export type ModelCapability = - | 'text-generation' // LLMs (GPT, Claude, Llama) - | 'text-completion' // Completion-only models - | 'chat' // Chat-optimized models - | 'audio-generation' // TTS (text-to-speech) - | 'audio-transcription' // STT (speech-to-text) - | 'image-generation' // DALL-E, Stable Diffusion - | 'image-analysis' // Vision models (GPT-4V, Claude 3) - | 'video-generation' // Sora, etc. - | 'video-analysis' // Video understanding - | 'embeddings' // Text/image embeddings - | 'multimodal'; // Combines multiple modalities - -export interface ModelInfo { - id: string; - name: string; - provider: string; - capabilities: ModelCapability[]; - contextWindow: number; - maxOutputTokens?: number; - costPer1kTokens?: { input: number; output: number }; - supportsStreaming: boolean; - supportsFunctions: boolean; -} - -// ======================== -// Universal Request Types -// ======================== - -export type ContentPart = - | { type: 'text'; text: string } - | { type: 'image'; image: ImageInput } - | { type: 'audio'; audio: AudioInput } - | { type: 'video'; video: VideoInput } - | { type: 'tool_use'; id: string; name: string; input: Record } - | { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }; - -export interface ImageInput { - url?: string; - base64?: string; - mimeType?: string; -} - -export interface AudioInput { - url?: string; - base64?: string; - mimeType?: string; - format?: 'mp3' | 'wav' | 'opus' | 'flac'; -} - -export interface VideoInput { - url?: string; - base64?: string; - mimeType?: string; -} - -export interface ChatMessage { - role: 'system' | 'user' | 'assistant'; - content: string | ContentPart[]; // Simple string or rich multimodal content - name?: string; - timestamp?: number; -} - -// ======================== -// Native Tool Support (for providers like Anthropic that support JSON tools) -// ======================== - -/** - * Native tool specification for providers with JSON tool support - * (Anthropic, OpenAI, etc.) - */ -export interface NativeToolSpec { - name: string; - description: string; - input_schema: { - type: 'object'; - properties: Record; - required?: string[]; - }; -} - -/** - * Tool call from AI response (when AI wants to use a tool) - */ -export interface ToolCall { - id: string; // Unique ID for this tool use (e.g., "toolu_01A...") - name: string; // Tool name - input: Record; // Tool parameters -} +// ============================================================================ +// WIRE TYPES (from Rust via ts-rs) — single source of truth +// ============================================================================ + +export type { + ChatMessage, + MessageContent, + ContentPart, + ImageInput, + AudioInput, + VideoInput, +} from '../../../shared/generated/ai'; + +export type { + NativeToolSpec, + ToolCall, + ToolResult, + ToolChoice, + ToolInputSchema, +} from '../../../shared/generated/ai'; + +export type { + FinishReason, + UsageMetrics, + RoutingInfo, +} from '../../../shared/generated/ai'; + +export type { + ModelCapability, + ModelInfo, + CostPer1kTokens, +} from '../../../shared/generated/ai'; + +export type { + HealthState, +} from '../../../shared/generated/ai'; + +export type { + EmbeddingInput, +} from '../../../shared/generated/ai'; + +// ============================================================================ +// TextGenerationRequest: Generated wire type + TS-only fields +// ============================================================================ + +import type { TextGenerationRequest as WireTextGenerationRequest } from '../../../shared/generated/ai'; +import type { ModelCapability } from '../../../shared/generated/ai'; /** - * Tool result to send back to AI after execution + * TextGenerationRequest extends the Rust wire type with TS-only fields + * that are consumed by the TypeScript adapter layer (not sent over IPC). + * + * Wire fields (from Rust): messages, systemPrompt, model, provider, + * temperature, maxTokens, topP, topK, stopSequences, tools, toolChoice, + * requestId, userId, roomId, purpose + * + * TS-only fields: intelligenceLevel, stream, context, preferredCapabilities, + * personaContext, activeAdapters */ -export interface ToolResult { - tool_use_id: string; // Matches ToolCall.id - content: string; // Tool execution result (or error message) - is_error?: boolean; // True if tool execution failed -} - -// ======================== -// Request Types by Capability -// ======================== - -export interface TextGenerationRequest { - messages: ChatMessage[]; - systemPrompt?: string; - - // Model config - model?: string; - temperature?: number; - maxTokens?: number; - topP?: number; - topK?: number; - stopSequences?: string[]; - - // Native tool support (for Anthropic, OpenAI JSON tools) - // When provided, adapter should use native tool calling instead of XML - tools?: NativeToolSpec[]; - tool_choice?: 'auto' | 'any' | 'none' | { name: string }; - +export interface TextGenerationRequest extends WireTextGenerationRequest { // Model intelligence level (PersonaUser property) - // Determines prompt format and capabilities - // 1-30: Simple base models (GPT-2, DistilGPT-2) - pattern matching only - // 31-60: Capable instruction-tuned models (Llama 7B, Phi-2) - basic reasoning - // 61-85: Advanced models (Claude Haiku, GPT-3.5) - strong reasoning - // 86-100: Frontier models (Claude Sonnet/Opus, GPT-4) - exceptional reasoning + // 1-30: Simple base models — 31-60: Capable — 61-85: Advanced — 86-100: Frontier intelligenceLevel?: number; // Streaming @@ -158,47 +86,76 @@ export interface TextGenerationRequest { // Context context?: JTAGContext; - requestId?: string; - // Provider preference - preferredProvider?: string; + // Capability preference for adapter selection preferredCapabilities?: ModelCapability[]; - // Cost tracking metadata (optional) - userId?: UUID; - roomId?: UUID; - purpose?: string; // 'chat', 'should-respond', 'rag', 'embedding', etc. - // Persona context for logging (optional) - // When provided, adapters can log to persona-specific log files personaContext?: { - logDir: string; // e.g., '.continuum/personas/helper-ai-12345678/logs' - displayName: string; // e.g., 'Helper AI' - uniqueId: string; // e.g., 'helper-ai-12345678' + logDir: string; + displayName: string; + uniqueId: string; }; /** * Active LoRA adapters to apply during generation (PersonaGenome integration) - * - * When provided, CandleAdapter will ensure these adapters are loaded and applied - * before generation. This enables the "genome vision" - personas can have - * skill-specific fine-tuned weights that modify base model behavior. - * - * Example: persona with typescript-expertise and code-review adapters active - * will generate responses using those specialized weights. - * - * NOTE: Only supported by CandleAdapter. Other adapters will ignore this field. + * Only supported by CandleAdapter. Other adapters ignore this field. */ activeAdapters?: Array<{ - /** Adapter name (e.g., 'typescript-expertise') */ name: string; - /** Path to adapter weights */ path: string; - /** Domain this adapter specializes in */ domain: string; }>; } +// ============================================================================ +// TextGenerationResponse: Re-export generated type directly +// ============================================================================ + +export type { TextGenerationResponse } from '../../../shared/generated/ai'; + +// ============================================================================ +// HealthStatus: Re-export generated type directly +// ============================================================================ + +export type { HealthStatus } from '../../../shared/generated/ai'; + +// ============================================================================ +// Embedding types: Re-export generated wire types +// ============================================================================ + +export type { EmbeddingRequest as WireEmbeddingRequest } from '../../../shared/generated/ai'; +export type { EmbeddingResponse as WireEmbeddingResponse } from '../../../shared/generated/ai'; + +/** + * TS EmbeddingRequest extends the wire type with TS-only context fields. + * Note: The wire type uses `input: EmbeddingInput` (string | string[]), + * and has `provider` not `preferredProvider`. + */ +export interface EmbeddingRequest { + input: string | string[]; + model?: string; + + context?: JTAGContext; + requestId?: string; + preferredProvider?: string; +} + +export interface EmbeddingResponse { + embeddings: number[][]; + model: string; + provider: string; + usage: { inputTokens: number; outputTokens: number; totalTokens: number; estimatedCost?: number }; + responseTimeMs: number; + requestId: string; + + error?: string; +} + +// ============================================================================ +// TS-ONLY TYPES: Audio/Image requests (not yet in Rust) +// ============================================================================ + export interface AudioGenerationRequest { text: string; voice?: string; @@ -212,7 +169,7 @@ export interface AudioGenerationRequest { } export interface AudioTranscriptionRequest { - audio: AudioInput; + audio: { url?: string; base64?: string; mimeType?: string; format?: 'mp3' | 'wav' | 'opus' | 'flac' }; model?: string; language?: string; prompt?: string; @@ -238,7 +195,7 @@ export interface ImageGenerationRequest { } export interface ImageAnalysisRequest { - images: ImageInput[]; + images: { url?: string; base64?: string; mimeType?: string }[]; prompt: string; model?: string; maxTokens?: number; @@ -248,85 +205,9 @@ export interface ImageAnalysisRequest { preferredProvider?: string; } -export interface EmbeddingRequest { - input: string | string[]; - model?: string; - - context?: JTAGContext; - requestId?: string; - preferredProvider?: string; -} - -// ======================== -// Response Types -// ======================== - -/** - * Routing observability - see exactly what happened during inference - * without grep'ing through logs - */ -export interface RoutingInfo { - /** Which adapter actually handled this request */ - provider: string; // 'candle' | 'anthropic' | 'openai' | 'groq' | etc. - - /** Was this local inference (Candle) vs cloud API? */ - isLocal: boolean; - - /** Why was this adapter selected? */ - routingReason: - | 'explicit_provider' // preferredProvider was specified - | 'provider_aliasing' // Legacy 'local' requests aliased to 'candle' - | 'model_detection' // Model name matched local pattern (DEPRECATED - to be removed) - | 'default_priority' // Selected by priority order - | 'fallback'; // Primary failed, used fallback - - /** LoRA adapters that were active during generation */ - adaptersApplied: string[]; // e.g., ['typescript-expertise', 'code-review'] - - /** If model was remapped (e.g., llama3.2:3b → Qwen/Qwen2-1.5B-Instruct) */ - modelMapped?: string; - - /** Original model requested (before any mapping) */ - modelRequested?: string; -} - -export interface TextGenerationResponse { - text: string; - finishReason: 'stop' | 'length' | 'error' | 'tool_use'; - - /** - * Full content blocks from the model response. - * Contains text blocks, tool_use blocks, etc. in the order the model produced them. - * When finishReason is 'tool_use', this will contain both text and tool_use blocks. - * Adapters MUST populate this for the canonical agent loop to work. - */ - content?: ContentPart[]; - - model: string; - provider: string; - usage: UsageMetrics; - responseTime: number; - requestId: string; - - /** - * Routing observability - ALWAYS populated by AIProviderDaemon - * Shows exactly how this request was handled: - * - Which provider was used and why - * - Whether local or cloud - * - Which LoRA adapters were applied - * - Any model mapping that occurred - * - * Optional at adapter level (adapter can provide additional info like adaptersApplied), - * but AIProviderDaemon ALWAYS ensures this field is populated in the final response. - */ - routing?: RoutingInfo; - - // Native tool calls (when AI wants to use tools) - // Present when finishReason is 'tool_use' - toolCalls?: ToolCall[]; - - error?: string; -} +// ============================================================================ +// TS-ONLY RESPONSE TYPES: Audio/Image (not yet in Rust) +// ============================================================================ export interface AudioGenerationResponse { audio: { @@ -337,7 +218,7 @@ export interface AudioGenerationResponse { model: string; provider: string; - responseTime: number; + responseTimeMs: number; requestId: string; error?: string; @@ -354,7 +235,7 @@ export interface AudioTranscriptionResponse { model: string; provider: string; - responseTime: number; + responseTimeMs: number; requestId: string; error?: string; @@ -369,7 +250,7 @@ export interface ImageGenerationResponse { model: string; provider: string; - responseTime: number; + responseTimeMs: number; requestId: string; error?: string; @@ -381,27 +262,16 @@ export interface ImageAnalysisResponse { model: string; provider: string; - usage: UsageMetrics; - responseTime: number; + usage: { inputTokens: number; outputTokens: number; totalTokens: number; estimatedCost?: number }; + responseTimeMs: number; requestId: string; error?: string; } -export interface EmbeddingResponse { - embeddings: number[][]; - model: string; - provider: string; - usage: UsageMetrics; - responseTime: number; - requestId: string; - - error?: string; -} - -// ======================== -// Provider Adapter Interface -// ======================== +// ============================================================================ +// PROVIDER ADAPTER INTERFACE (TS-only — not a wire type) +// ============================================================================ export interface AIProviderAdapter { readonly providerId: string; @@ -409,93 +279,47 @@ export interface AIProviderAdapter { readonly supportedCapabilities: ModelCapability[]; // Core operations (only implement what the provider supports) - generateText?(request: TextGenerationRequest): Promise; + generateText?(request: TextGenerationRequest): Promise; generateAudio?(request: AudioGenerationRequest): Promise; transcribeAudio?(request: AudioTranscriptionRequest): Promise; generateImage?(request: ImageGenerationRequest): Promise; analyzeImage?(request: ImageAnalysisRequest): Promise; createEmbedding?(request: EmbeddingRequest): Promise; - // Skill management (optional - only providers that support skill modification) - // Examples: - // - Candle: Load LoRA adapter weights - // - Claude/GPT: Inject RAG context or modify system prompt - // - Any provider: Add few-shot examples or tools + // Skill management (optional) applySkill?(skillImplementation: unknown): Promise; removeSkill?(skillId: string): Promise; enableSkillTraining?(skillId: string): Promise; disableSkillTraining?(skillId: string): Promise; // Metadata - getAvailableModels(): Promise; - healthCheck(): Promise; + getAvailableModels(): Promise; + healthCheck(): Promise; // Queue monitoring (for load-aware PersonaInbox consolidation) - // Returns current queue state for feedback-driven load management getQueueStats?(): { - queueSize: number; // Number of requests waiting - activeRequests: number; // Number currently being processed - maxConcurrent: number; // Maximum allowed concurrent requests - load: number; // Queue pressure (0.0-1.0, calculated as (queueSize + activeRequests) / maxConcurrent) + queueSize: number; + activeRequests: number; + maxConcurrent: number; + load: number; }; - // Health monitoring (called by AdapterHealthMonitor when adapter is unhealthy) + // Health monitoring handleRestartRequest?(): Promise; - // Semantic Model Tier Resolution (NEW) - // Bidirectional mapping: tier ↔ model ID - // User requirement: "turn api results into these terms" - - /** - * Resolve semantic tier to actual model ID - * Example: tier='balanced' → 'claude-3-5-sonnet-20250122' - */ + // Semantic Model Tier Resolution resolveModelTier?(tier: ModelTier): Promise; - - /** - * Classify model ID back into semantic tier (BIDIRECTIONAL) - * Example: 'claude-3-5-sonnet-20250122' → { tier: 'balanced', costTier: 'medium', ... } - */ classifyModel?(modelId: string): Promise; - - /** - * Get all models grouped by tier - * Useful for UI showing "fast", "balanced", "premium", "free" options - */ - getModelsByTier?(): Promise>; + getModelsByTier?(): Promise>; // Lifecycle initialize(): Promise; shutdown(): Promise; } -// ======================== -// Supporting Types -// ======================== - -export interface UsageMetrics { - inputTokens: number; - outputTokens: number; - totalTokens: number; - estimatedCost?: number; -} - -export interface HealthStatus { - /** - * Adapter health status: - * - healthy: Working normally - * - degraded: Slow but functional - * - unhealthy: Not responding - * - insufficient_funds: API quota/credits exhausted (💰❌) - * - rate_limited: Too many requests (⏳) - */ - status: 'healthy' | 'degraded' | 'unhealthy' | 'insufficient_funds' | 'rate_limited'; - apiAvailable: boolean; - responseTime: number; - errorRate: number; - lastChecked: number; - message?: string; -} +// ============================================================================ +// SUPPORTING TS-ONLY TYPES +// ============================================================================ export interface ProviderConfiguration { apiKey?: string; @@ -506,7 +330,7 @@ export interface ProviderConfiguration { defaultModel: string; defaultTemperature: number; logRequests: boolean; - maxConcurrent?: number; // For request queue management + maxConcurrent?: number; } export interface ProviderRegistration { @@ -517,9 +341,9 @@ export interface ProviderRegistration { enabled: boolean; } -// ======================== -// Helper Functions -// ======================== +// ============================================================================ +// HELPER FUNCTIONS & CLASSES (TS-only) +// ============================================================================ export class AIProviderError extends Error { constructor( @@ -533,7 +357,7 @@ export class AIProviderError extends Error { } } -export function chatMessagesToPrompt(messages: ChatMessage[]): { prompt: string; systemPrompt?: string } { +export function chatMessagesToPrompt(messages: import('../../../shared/generated/ai').ChatMessage[]): { prompt: string; systemPrompt?: string } { let systemPrompt: string | undefined; const conversationParts: string[] = []; diff --git a/src/debug/jtag/daemons/ai-provider-daemon/shared/BaseAIProviderAdapter.ts b/src/debug/jtag/daemons/ai-provider-daemon/shared/BaseAIProviderAdapter.ts index d6a2e5f54..11d32610d 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/shared/BaseAIProviderAdapter.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/shared/BaseAIProviderAdapter.ts @@ -185,7 +185,7 @@ export abstract class BaseAIProviderAdapter implements AIProviderAdapter { lastStatus: { status: 'unhealthy' as const, apiAvailable: false, - responseTime: elapsed, + responseTimeMs: elapsed, errorRate: 1.0, lastChecked: Date.now(), message: `Circuit breaker opened: ${errorMessage}`, diff --git a/src/debug/jtag/daemons/ai-provider-daemon/shared/LlamaCppAdapter.ts b/src/debug/jtag/daemons/ai-provider-daemon/shared/LlamaCppAdapter.ts index 9b5145153..255454a96 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/shared/LlamaCppAdapter.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/shared/LlamaCppAdapter.ts @@ -242,7 +242,7 @@ export class LlamaCppAdapter implements AIProviderAdapter { model: modelName, provider: this.providerId, usage, - responseTime, + responseTimeMs: responseTime, requestId, }; } catch (error) { @@ -285,7 +285,7 @@ export class LlamaCppAdapter implements AIProviderAdapter { capabilities: this.supportedCapabilities, contextWindow: 4096, supportsStreaming: false, - supportsFunctions: false + supportsTools: false }); } } @@ -304,18 +304,18 @@ export class LlamaCppAdapter implements AIProviderAdapter { return { status: models.length > 0 ? 'healthy' : 'degraded', apiAvailable: this.llama !== null, - responseTime: 0, + responseTimeMs: 0, errorRate: 0, lastChecked: Date.now(), - message: models.length > 0 - ? `${models.length} models available` + message: models.length > 0 + ? `${models.length} models available` : 'No models found', }; } catch (error) { return { status: 'unhealthy', apiAvailable: false, - responseTime: 0, + responseTimeMs: 0, errorRate: 1, lastChecked: Date.now(), message: error instanceof Error ? error.message : 'Health check failed', diff --git a/src/debug/jtag/daemons/ai-provider-daemon/shared/PromptFormatters.ts b/src/debug/jtag/daemons/ai-provider-daemon/shared/PromptFormatters.ts index 1db313a4a..537452a19 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/shared/PromptFormatters.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/shared/PromptFormatters.ts @@ -50,7 +50,7 @@ * only valid formats are used. */ -import type { PromptFormat } from '../../../commands/user/create/shared/UserCreateTypes'; +import type { PromptFormat } from '../../../system/data/entities/UserEntity'; /** * Message interface compatible with AIProviderTypesV2.TextGenerationMessage diff --git a/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/BaseLocalAdapter.ts b/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/BaseLocalAdapter.ts index b61d12a12..b2c75a914 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/BaseLocalAdapter.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/BaseLocalAdapter.ts @@ -70,7 +70,7 @@ export abstract class BaseLocalAdapter extends BaseAIProviderAdapter { return { status: 'healthy', apiAvailable: true, - responseTime, + responseTimeMs: responseTime, errorRate: 0, lastChecked: Date.now(), message: `${this.providerName} server is running`, @@ -79,7 +79,7 @@ export abstract class BaseLocalAdapter extends BaseAIProviderAdapter { return { status: 'unhealthy', apiAvailable: false, - responseTime, + responseTimeMs: responseTime, errorRate: 1, lastChecked: Date.now(), message: `${this.providerName} server returned ${response.status}`, @@ -89,7 +89,7 @@ export abstract class BaseLocalAdapter extends BaseAIProviderAdapter { return { status: 'unhealthy', apiAvailable: false, - responseTime: Date.now() - startTime, + responseTimeMs: Date.now() - startTime, errorRate: 1, lastChecked: Date.now(), message: `${this.providerName} server is not reachable: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/BaseOpenAICompatibleAdapter.ts b/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/BaseOpenAICompatibleAdapter.ts index c9590d54f..b90153cab 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/BaseOpenAICompatibleAdapter.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/BaseOpenAICompatibleAdapter.ts @@ -355,11 +355,11 @@ export abstract class BaseOpenAICompatibleAdapter extends BaseAIProviderAdapter parameters: tool.input_schema, }, })); - if (request.tool_choice) { - if (typeof request.tool_choice === 'object' && 'name' in request.tool_choice) { - requestBody.tool_choice = { type: 'function', function: { name: request.tool_choice.name } }; + if (request.toolChoice) { + if (typeof request.toolChoice === 'object' && 'name' in request.toolChoice) { + requestBody.tool_choice = { type: 'function', function: { name: request.toolChoice.name } }; } else { - requestBody.tool_choice = request.tool_choice; + requestBody.tool_choice = request.toolChoice; } } } @@ -424,7 +424,7 @@ export abstract class BaseOpenAICompatibleAdapter extends BaseAIProviderAdapter totalTokens: response.usage?.total_tokens || 0, estimatedCost: this.calculateCost(response.usage, model), }, - responseTime, + responseTimeMs: responseTime, requestId, ...(toolCalls.length > 0 && { toolCalls }), }; @@ -487,7 +487,7 @@ export abstract class BaseOpenAICompatibleAdapter extends BaseAIProviderAdapter })), model: response.model || model, provider: this.providerId, - responseTime, + responseTimeMs: responseTime, requestId, }; } catch (error) { @@ -539,7 +539,7 @@ export abstract class BaseOpenAICompatibleAdapter extends BaseAIProviderAdapter outputTokens: 0, totalTokens: response.usage?.total_tokens || 0, }, - responseTime, + responseTimeMs: responseTime, requestId, }; } catch (error) { @@ -596,7 +596,7 @@ export abstract class BaseOpenAICompatibleAdapter extends BaseAIProviderAdapter return { status: 'healthy', apiAvailable: true, - responseTime, + responseTimeMs: responseTime, errorRate: 0, lastChecked: Date.now(), message: `${this.providerName} API is accessible`, @@ -605,7 +605,7 @@ export abstract class BaseOpenAICompatibleAdapter extends BaseAIProviderAdapter return { status: 'unhealthy', apiAvailable: false, - responseTime: Date.now() - startTime, + responseTimeMs: Date.now() - startTime, errorRate: 1, lastChecked: Date.now(), message: `${this.providerName} API is not accessible: ${error instanceof Error ? error.message : String(error)}`, @@ -674,7 +674,7 @@ export abstract class BaseOpenAICompatibleAdapter extends BaseAIProviderAdapter || 4096, maxOutputTokens: modelData.max_tokens, supportsStreaming: true, - supportsFunctions: false, + supportsTools: false, }; } diff --git a/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/base/AdapterTypes.ts b/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/base/AdapterTypes.ts index a8069e073..1e184e47a 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/base/AdapterTypes.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/base/AdapterTypes.ts @@ -1,33 +1,24 @@ /** - * Adapter Types - Shared interfaces for AI provider adapters + * Adapter Types - Re-exports from unified AI type system * - * Small, focused interfaces following single responsibility principle. - * All providers implement these, no god objects. + * Wire types come from Rust (via ts-rs). This file re-exports them + * plus defines adapter-specific types not in the wire protocol. */ -import type { UUID } from '../../../../../system/core/types/CrossPlatformUUID'; +// Re-export wire types from unified source +export type { ModelCapability, ModelInfo, HealthStatus } from '../../AIProviderTypesV2'; +export type { TextGenerationRequest, TextGenerationResponse, UsageMetrics } from '../../AIProviderTypesV2'; /** - * Model capabilities - */ -export type ModelCapability = - | 'text' - | 'vision' - | 'function-calling' - | 'streaming' - | 'embeddings' - | 'multimodal'; - -/** - * Model capability profile + * Model capability profile (adapter-specific, not a wire type) */ export interface ModelCapabilities { readonly modelId: string; readonly providerId: string; - readonly capabilities: ModelCapability[]; + readonly capabilities: import('../../../../../shared/generated/ai').ModelCapability[]; readonly maxContextTokens: number; readonly supportsImages: boolean; - readonly supportsFunctionCalling: boolean; + readonly supportsToolUse: boolean; readonly supportsStreaming: boolean; } @@ -55,53 +46,6 @@ export interface LoadedModelHandle { readonly metadata?: Record; } -/** - * Health status - */ -export interface HealthStatus { - readonly status: 'healthy' | 'degraded' | 'unhealthy'; - readonly apiAvailable: boolean; - readonly responseTime: number; - readonly errorRate?: number; - readonly lastChecked: number; - readonly message?: string; -} - -/** - * Text generation request (provider-agnostic) - */ -export interface TextGenerationRequest { - readonly messages: Array<{ - role: 'system' | 'user' | 'assistant'; - content: string; - name?: string; - images?: string[]; // Base64 or URLs - }>; - readonly model?: string; - readonly temperature?: number; - readonly maxTokens?: number; - readonly systemPrompt?: string; - readonly preferredProvider?: string; - readonly requestId?: UUID; -} - -/** - * Text generation response (provider-agnostic) - */ -export interface TextGenerationResponse { - readonly text: string; - readonly finishReason: 'stop' | 'length' | 'error'; - readonly model: string; - readonly provider: string; - readonly usage?: { - readonly inputTokens: number; - readonly outputTokens: number; - readonly totalTokens: number; - }; - readonly responseTime: number; - readonly requestId?: UUID; -} - /** * Model recommendation */ @@ -109,12 +53,12 @@ export interface ModelRecommendation { readonly modelId: string; readonly name: string; readonly description: string; - readonly size: string; // "2GB", "7GB" + readonly size: string; readonly quality: 'excellent' | 'good' | 'fair'; readonly speed: 'fast' | 'medium' | 'slow'; readonly free: boolean; readonly requiresAPIKey: boolean; - readonly capabilities: ModelCapability[]; + readonly capabilities: import('../../../../../shared/generated/ai').ModelCapability[]; } /** diff --git a/src/debug/jtag/daemons/command-daemon/shared/CommandBase.ts b/src/debug/jtag/daemons/command-daemon/shared/CommandBase.ts index f9e4b7d0e..c713fbd97 100644 --- a/src/debug/jtag/daemons/command-daemon/shared/CommandBase.ts +++ b/src/debug/jtag/daemons/command-daemon/shared/CommandBase.ts @@ -9,6 +9,7 @@ import { JTAGModule } from '../../../system/core/shared/JTAGModule'; import type { JTAGContext, CommandParams, CommandResult } from '../../../system/core/types/JTAGTypes'; import { JTAG_ENVIRONMENTS, JTAGMessageFactory } from '../../../system/core/types/JTAGTypes'; import { type UUID } from '../../../system/core/types/CrossPlatformUUID'; +import { SYSTEM_SCOPES } from '../../../system/core/types/SystemScopes'; import type { JTAGRouter } from '../../../system/core/router/shared/JTAGRouter'; import { isRequestResult } from '../../../system/core/router/shared/RouterTypes'; @@ -44,7 +45,7 @@ export interface CommandConstructor { readonly commandName: string; executeIn( environment: 'browser' | 'server', - params: Omit + params: Omit ): Promise; } @@ -114,7 +115,7 @@ export abstract class CommandBase( this: CommandConstructor, environment: 'browser' | 'server', - params: Omit + params: Omit ): Promise { const { Commands } = await import('../../../system/core/shared/Commands'); return await Commands.execute( @@ -128,7 +129,7 @@ export abstract class CommandBase( this: CommandConstructor, - params: Omit + params: Omit ): Promise { return this.executeIn('server', params); } @@ -138,7 +139,7 @@ export abstract class CommandBase( this: CommandConstructor, - params: Omit + params: Omit ): Promise { return this.executeIn('browser', params); } @@ -153,7 +154,7 @@ export abstract class CommandBase { - return await command.execute(message.payload); + return await command.execute({ userId: SYSTEM_SCOPES.SYSTEM, ...message.payload } as CommandParams); }); // Apply timeout if specified diff --git a/src/debug/jtag/daemons/data-daemon/shared/entities/TestExecutionEntity.ts b/src/debug/jtag/daemons/data-daemon/shared/entities/TestExecutionEntity.ts index 1b49cbfe5..9a18c85ac 100644 --- a/src/debug/jtag/daemons/data-daemon/shared/entities/TestExecutionEntity.ts +++ b/src/debug/jtag/daemons/data-daemon/shared/entities/TestExecutionEntity.ts @@ -31,7 +31,7 @@ export interface CapabilityTestResult { supported: boolean; tested: boolean; success?: boolean; - responseTime?: number; + responseTimeMs?: number; error?: string; } diff --git a/src/debug/jtag/daemons/session-daemon/server/SessionDaemonServer.ts b/src/debug/jtag/daemons/session-daemon/server/SessionDaemonServer.ts index f94b75807..619dcc6e0 100644 --- a/src/debug/jtag/daemons/session-daemon/server/SessionDaemonServer.ts +++ b/src/debug/jtag/daemons/session-daemon/server/SessionDaemonServer.ts @@ -44,7 +44,7 @@ import { generateUUID } from '../../../system/core/types/CrossPlatformUUID'; import { createPayload } from '../../../system/core/types/JTAGTypes'; import { type JTAGMessage } from '../../../system/core/types/JTAGTypes'; import type { UUID } from '../../../system/core/types/CrossPlatformUUID'; -import { isBootstrapSession } from '../../../system/core/types/SystemScopes'; +import { isBootstrapSession, SYSTEM_SCOPES } from '../../../system/core/types/SystemScopes'; import { WorkingDirConfig } from '../../../system/core/config/WorkingDirConfig'; import { SessionStateHelper } from './SessionStateHelper'; import fs from 'fs/promises'; @@ -533,6 +533,7 @@ export class SessionDaemonServer extends SessionDaemon { */ private async createAnonymousHuman(params: CreateSessionParams, deviceId?: string): Promise { const createParams: UserCreateParams = createPayload(this.context, generateUUID(), { + userId: SYSTEM_SCOPES.SYSTEM, type: 'human', displayName: 'Anonymous User', uniqueId: `anon-${generateUUID().slice(0, 8)}`, // Unique but not meant for lookup @@ -560,6 +561,7 @@ export class SessionDaemonServer extends SessionDaemon { // User doesn't exist - create new one with resolved identity const createParams: UserCreateParams = createPayload(this.context, generateUUID(), { + userId: SYSTEM_SCOPES.SYSTEM, type: resolvedIdentity.type, displayName: resolvedIdentity.displayName, uniqueId: resolvedIdentity.uniqueId, // Stable uniqueId from resolver diff --git a/src/debug/jtag/daemons/system-daemon/shared/SystemDaemon.ts b/src/debug/jtag/daemons/system-daemon/shared/SystemDaemon.ts index dc8ffd173..68a2569a5 100644 --- a/src/debug/jtag/daemons/system-daemon/shared/SystemDaemon.ts +++ b/src/debug/jtag/daemons/system-daemon/shared/SystemDaemon.ts @@ -99,8 +99,8 @@ export class SystemDaemon { log.info('No system config found, creating with factory defaults...'); this.configCache = await this.createDefaultConfig(); } else { - // Config exists - cache it - this.configCache = result.data[0].data; + // Config exists - hydrate into proper class instance (ORM returns POJOs) + this.configCache = Object.assign(new SystemConfigEntity(), result.data[0].data); log.info('System configuration loaded from database'); } @@ -150,7 +150,8 @@ export class SystemDaemon { */ private onConfigUpdated(entity: SystemConfigEntity): void { log.info('System configuration updated, refreshing cache...'); - this.configCache = entity; + // Event payloads are POJOs from deserialization — hydrate into proper instance + this.configCache = Object.assign(new SystemConfigEntity(), entity); } /** diff --git a/src/debug/jtag/daemons/widget-daemon/shared/WidgetDaemon.ts b/src/debug/jtag/daemons/widget-daemon/shared/WidgetDaemon.ts index 7b421f105..66c8ac66e 100644 --- a/src/debug/jtag/daemons/widget-daemon/shared/WidgetDaemon.ts +++ b/src/debug/jtag/daemons/widget-daemon/shared/WidgetDaemon.ts @@ -22,13 +22,14 @@ export abstract class WidgetDaemon extends DaemonBase { /** * Execute command from widget - routes through JTAG CommandDaemon */ - async executeCommand(command: string, params: Omit = {}): Promise { + async executeCommand(command: string, params: Omit = {}): Promise { try { // Create command payload with session ID const payload = { ...params, context: this.context, - sessionId: SYSTEM_SCOPES.SYSTEM // Use system session for now + sessionId: SYSTEM_SCOPES.SYSTEM, // Use system session for now + userId: SYSTEM_SCOPES.SYSTEM }; // Create JTAG message for command routing diff --git a/src/debug/jtag/docs/ELEGANCE-AUDIT-2026-02-15.md b/src/debug/jtag/docs/ELEGANCE-AUDIT-2026-02-15.md new file mode 100644 index 000000000..335baa318 --- /dev/null +++ b/src/debug/jtag/docs/ELEGANCE-AUDIT-2026-02-15.md @@ -0,0 +1,244 @@ +# Elegance Audit: Systemic Issues & Technical Debt +**Date**: 2026-02-15 | **Scope**: Full stack (Rust + TypeScript) + +## Executive Summary + +The codebase has strong architectural foundations (command system, event system, modular workers, sentinel pipeline engine) but has accumulated significant rot across 4 dimensions: + +1. **Type Safety Erosion** - `as any` casts everywhere, especially in sentinel/chat commands +2. **Monolithic God Classes** - PersonaResponseGenerator (1791 lines), PersonaMessageEvaluator (1364 lines) +3. **Magic Numbers & Duplicated Constants** - timing values scattered across 8+ files +4. **Error Handling Gaps** - silent swallowing, missing propagation, disabled circuit breakers + +--- + +## 1. RUST CODEBASE + +### 1.1 Critical: Panic-Inducing Code + +| File | Line | Issue | +|------|------|-------| +| `modules/agent.rs` | 598, 609 | `regex::Regex::new().unwrap()` - should use `lazy_static` or `once_cell` | +| `modules/logger.rs` | 21 instances | `.lock().unwrap()` - mutex poisoning risk | +| `voice/orchestrator.rs` | 7 instances | `.lock().unwrap()` - same issue | + +**Fix**: Replace `.lock().unwrap()` with `.lock().ok()` or pattern match. Use `once_cell::sync::Lazy` for compiled regexes. + +### 1.2 High: Functions > 100 Lines + +| File | Function | Lines | What it does | +|------|----------|-------|--------------| +| `modules/agent.rs` | `run_agent_loop` | ~190 | Stop check + progress + LLM + tools + file tracking | +| `modules/agent.rs` | `call_llm` | ~83 | Adapter registry + secrets + LLM call | +| `sentinel/executor.rs` | `execute_pipeline` | ~135 | Main pipeline loop | +| `sentinel/executor.rs` | `execute_isolated` | ~145 | Tokio::select! concurrent I/O | + +**Fix**: `agent.rs` should split into `agents/executor.rs`, `agents/tools.rs`, `agents/llm.rs`. Sentinel executor is long but cohesive. + +### 1.3 High: Missing ts-rs Exports + +Types crossing Rust-TS boundary without `#[derive(TS)]`: +- `RagComposeRequest` (modules/rag.rs:280) +- Agent tool call types in `modules/agent.rs` +- Various IPC request/response types + +**Fix**: Audit all types used in `CommandResult::Json()` returns. If it crosses the wire, it needs `#[derive(TS)]`. + +### 1.4 Medium: Swallowed Errors in Sentinel Executor + +`sentinel/executor.rs` uses `.ok()` on 7+ file write operations. If sentinel logs can't be written, system fails silently. + +**Fix**: At minimum, warn log. Better: propagate as non-fatal step metadata. + +### 1.5 Medium: Duplicated Error Wrapping + +Pattern `.map_err(|e| format!("[{}] ... {}", pipeline_ctx.handle_id, e))?` appears ~12 times across sentinel steps. + +**Fix**: Create `step_err!` macro or helper function. + +### 1.6 Low: Dead Code & Stale Comments + +- `voice/orchestrator.rs:95-130` - Commented-out arbiter methods + old test module +- `persona/cognition.rs:68,75` - `#[allow(dead_code)]` on unused fields +- 9 TODO/FIXME markers across codebase (agent.rs, data.rs, orm/sqlite.rs, rag sources) + +--- + +## 2. TYPESCRIPT: SENTINEL & CHAT COMMANDS + +### 2.1 Critical: `as any` Epidemic (15+ instances) + +**Worst offenders**: + +| File | Lines | Pattern | +|------|-------|---------| +| `sentinel/run/server/SentinelRunServerCommand.ts` | 22-38 | `let definition: any; (params as any).definition` | +| `sentinel/load/server/SentinelLoadServerCommand.ts` | 41, 53, 81, 104 | `as any) as any` double casts | +| `sentinel/save/server/SentinelSaveServerCommand.ts` | 78, 87 | `(params as any).userId` | +| `sentinel/list/server/SentinelListServerCommand.ts` | 26, 50 | `Record`, double casts | + +**Root cause**: Commands.execute() return types are ambiguous. `.items || .data` pattern appears in 3+ sentinel commands because nobody knows the canonical property name. + +**Fix**: +1. Define typed result interfaces for each command +2. Use ts-rs generated types for Rust wire formats +3. Standardize on `.items` for collection results + +### 2.2 Critical: Missing Input Validation + +- `sentinel/run` - No validation of definition structure after JSON.parse() +- `sentinel/list` - User-provided `search` string used as regex without escaping +- No sentinel command validates that required fields exist on the parsed pipeline + +**Fix**: Add Zod or manual validation at command boundaries. + +### 2.3 High: Silent Error Swallowing + +| File | Lines | Issue | +|------|-------|-------| +| `sentinel/load/server` | 58-60 | `catch { // Ignore }` - no logging | +| `sentinel/list/server` | 84-90 | Error discarded, no message in result | +| `chat/export/server` | 48-49 | `fs.mkdirSync` / `fs.writeFileSync` without try/catch | + +### 2.4 Medium: Inconsistent Short ID Generation + +- Sentinel commands: `id.slice(0, 8)` (first 8 chars) +- Chat export: `id.slice(-6)` (last 6 chars) +- No shared constant for length + +**Fix**: Create `generateShortId(uuid: string, length: number = 8): string` utility. + +### 2.5 Medium: Mixed Import Styles + +Chat commands mix `@aliases` with `../../../../` relative paths within the same file. Should standardize on aliases. + +--- + +## 3. TYPESCRIPT: PERSONA COGNITION SYSTEM + +### 3.1 Critical: God Class - PersonaResponseGenerator (1791 lines) + +Single class handling 6+ responsibilities: +1. RAG context building (lines 534-765) +2. LLM message formatting (lines 569-850) +3. AI generation with timeout (lines 853-1100) +4. Tool execution loop (lines 1243-1422) +5. Response validation (scattered: 1101-1207) +6. Result posting (lines 1538-1613) + +**Fix**: Extract to focused modules: +- `ResponseRAGContextBuilder` +- `ResponseMessageFormatter` +- `ResponseGenerationExecutor` +- `ResponseValidator` (chain-of-responsibility pattern) +- `ResponsePoster` + +### 3.2 Critical: God Class - PersonaMessageEvaluator (1364 lines) + +Similar monolith with training signal detection, conversation history, sender detection, topic detection all interleaved. + +### 3.3 Critical: Magic Numbers in 8+ Files + +Timing constants scattered with no single source of truth: + +| File | Constant | Value | +|------|----------|-------| +| `PersonaState.ts` | Cadence timeouts | 500ms, 1000ms, 2000ms, 3000ms | +| `PersonaInbox.ts` | DEDUP_WINDOW_MS | 3000ms | +| `PersonaInbox.ts` | maxSize | 1000 | +| `PersonaAutonomousLoop.ts` | Poll intervals | 10s, 30s, 60s | +| `SelfTaskGenerator.ts` | Task intervals | 30min, 1hr, 6hr | +| `PersonaResponseGenerator.ts` | RESPONSE_LOOP_WINDOW_MS | 600s | +| `AgentToolExecutor.ts` | LOOP_WINDOW_MS | 60s | +| `TrainingBuffer.ts` | maxAgeMs, cooldownMs | 24hr, 10min | + +**Fix**: Create `PersonaTimingConfig.ts` with all constants in one place. + +### 3.4 High: Race Conditions in Autonomous Loop + +`PersonaAutonomousLoop.ts` uses `setInterval` for 3 loops (10s, 30s, 60s). If a callback takes longer than its interval, concurrent executions overlap with no mutual exclusion. + +**Fix**: Add exclusive lock or use sequential scheduling with backpressure. + +### 3.5 High: Disabled Thermodynamic System (Dead Code) + +`PersonaState.ts` lines 40-42: Energy depletion/recovery rates all set to 0 with comment "DISABLED - was causing 15-minute death spiral". Entire system exists but does nothing. + +**Fix**: Remove or re-enable with proper tuning. Dead code rots. + +### 3.6 High: Disabled Circuit Breaker + +`BaseAIProviderAdapter.ts` lines 41-48: `maxConsecutiveFailures: 999999` - effectively disabled. AIs may get stuck calling failing adapters indefinitely. + +**Fix**: Implement per-operation-type circuit breakers instead of one shared one. + +### 3.7 High: Circular Callback Coupling + +PersonaAutonomousLoop and PersonaCentralNervousSystem call into each other via callbacks. Unclear which is the orchestrator. + +**Fix**: Make CNS the single orchestrator. Loop becomes a simple RTOS scheduler. + +### 3.8 Medium: Duplicated Logic Across Tool Systems + +| Logic | AgentToolExecutor | PersonaToolExecutor | ToolFormatAdapter | +|-------|-------------------|---------------------|-------------------| +| XML result formatting | Lines 494-499 | Lines 429-449 | Multiple adapters | +| Similarity calculation | - | PersonaResponseGenerator:185-208 | SignalDetector:408+ | +| Loop detection | Lines 200-212 | Via AgentToolExecutor | - | + +PersonaToolExecutor was supposed to delegate to AgentToolExecutor (per the plan), but still duplicates batch handling and XML formatting. + +### 3.9 Medium: Global Singleton Loggers + +`PersonaUser.ts` lines 427-465: Uses `setToolDefinitionsLogger()` and `setPeerReviewLogger()` global functions. If two personas run simultaneously, their logs interleave. + +**Fix**: Constructor dependency injection instead of global setters. + +--- + +## 4. AI PERSONAS IN CHAT: OBSERVED FAILURES + +From previous session observations: +- **Candle models saturate server** - Multiple personas running 55s+ inference simultaneously blocks the event loop +- **Tool calling failures** - AIs can't reliably use tools due to error message confusion +- **Chat responses stall** - Under load, personas stop responding entirely +- **No graceful degradation** - When one provider is slow, all personas are affected + +**Root causes**: +1. No inference queue / concurrency limit for candle +2. No timeout per-persona for inference +3. No priority system (urgent messages wait behind idle polling) +4. Error messages not actionable enough for AIs to self-correct + +--- + +## 5. PRIORITY RANKING FOR ELEGANCE SESSION + +### Tier 1: Type Safety & Error Handling (Foundation) +1. Replace all `as any` in sentinel commands with proper types +2. Standardize Commands.execute() return types (`.items` vs `.data`) +3. Fix all silent error swallowing (add logging + error propagation) +4. Add input validation at command boundaries + +### Tier 2: Decompression (God Classes) +5. Split PersonaResponseGenerator into 4-5 focused modules +6. Split PersonaMessageEvaluator into evaluation pipeline +7. Centralize all timing constants into PersonaTimingConfig.ts + +### Tier 3: Rust Hardening +8. Replace `.lock().unwrap()` with safe patterns (21 instances) +9. Add ts-rs to all wire types +10. Split agent.rs into focused modules +11. Create `step_err!` macro for sentinel error wrapping + +### Tier 4: Operational Reliability +12. Fix setInterval race conditions with exclusive locks +13. Re-enable or remove disabled systems (thermodynamics, circuit breaker) +14. Add candle inference concurrency limits +15. Implement per-persona inference timeouts + +### Tier 5: Polish +16. Standardize import paths to @aliases +17. Remove dead code and stale comments +18. Convert TODO comments to tracked issues +19. Add observability metrics to autonomous loop diff --git a/src/debug/jtag/docs/LORA-MESH-DISTRIBUTION.md b/src/debug/jtag/docs/LORA-MESH-DISTRIBUTION.md new file mode 100644 index 000000000..6f41be283 --- /dev/null +++ b/src/debug/jtag/docs/LORA-MESH-DISTRIBUTION.md @@ -0,0 +1,830 @@ +# LoRA Mesh Distribution: The Worldwide Organism + +**Status**: Vision Document +**Related**: [LORA-LAB-ARCHITECTURE.md](./LORA-LAB-ARCHITECTURE.md) (local operations), [SENTINEL-ARCHITECTURE.md](./SENTINEL-ARCHITECTURE.md) (execution substrate) + +--- + +## Overview + +The LoRA genome concept extends beyond individual personas to a **global evolving organism**. Skills (LoRA adapters) flow through a P2P mesh network, discovered via semantic search in embedding space, and distributed using patterns from npm and Docker. + +This document covers: +1. **P2P Mesh Network** - How LoRAs flow between personas +2. **Semantic Search** - Embedding-space matching for skill discovery +3. **Registry Model** - npm/Docker-style distribution +4. **Personas as Containers** - Shareable, composable AI configurations +5. **Bootstrap: Learning Triad** - How to create LoRAs before the mesh exists (genesis) + +--- + +## The Worldwide Organism: LoRA as Tradeable Skills + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ P2P MESH NETWORK │ +│ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │Persona A│◄───────►│Persona B│◄───────►│Persona C│ │ +│ │ │ │ │ │ │ │ +│ │ LoRAs: │ │ LoRAs: │ │ LoRAs: │ │ +│ │ • rust │ share │ • react │ share │ • security│ │ +│ │ • debug │◄───────►│ • css │◄───────►│ • crypto │ │ +│ └─────────┘ └─────────┘ └─────────┘ │ +│ │ │ │ │ +│ └───────────────────┼───────────────────┘ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ LoRA Registry │ │ +│ │ │ │ +│ │ • Discovery │ │ +│ │ • Reputation │ │ +│ │ • Marketplace │ │ +│ └─────────────────┘ │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +### LoRA Sources + +| Source | Latency | Cost | Trust | +|--------|---------|------|-------| +| Local disk | ~10ms | Free | Self-trained | +| Local mesh (LAN) | ~50ms | Free | Team-trained | +| P2P mesh (WAN) | ~200ms | Free/Trade | Community reputation | +| Marketplace | ~500ms | Purchase | Verified/Audited | + +### LoRAEntity: The Data Model + +LoRAs are first-class entities in the ecosystem, extending BaseEntity for universal data layer support: + +```typescript +interface LoRAEntity extends BaseEntity { + // BaseEntity provides: id (UUID), createdAt, updatedAt + + // Identity + name: string; // "typescript-expert-v3" + version: string; // Semver + description: string; + + // Base model binding (LoRAs are model-specific) + baseModel: string; // "ouro-2.6b" + baseModelFamily: string; // "ouro" - for potential cross-compat + + // Discovery (embedding computed from description + training summary) + embedding?: number[]; // 384/768/1536 dims for semantic search + + // Provenance + creatorId: UUID; // PersonaEntity or UserEntity + trainedOn: string; // "500K lines of TS, 10K reviews" + parentLoRAId?: UUID; // If forked from another LoRA + + // Quality metrics + benchmarks: BenchmarkResult[]; // Verified performance + reputation: number; // Community rating 0-1 + downloads: number; + successRate: number; // From grader evaluations + + // Economics + license: 'free' | 'attribution' | 'commercial'; + price?: number; // Optional purchase price + + // Technical + sizeBytes: number; + path: string; // Local path to .safetensors + checksum: string; + + // Hardware requirements + minVRAM: number; // MB + recommendedHardware: string[]; // ["apple-m1-pro", "apple-m2", "rtx-4090"] +} + +// Standard entity operations +await Commands.execute('data/list', { collection: 'loras', filter: { baseModel: 'ouro-2.6b' } }); +await Commands.execute('data/create', { collection: 'loras', data: loraEntity }); +``` + +The `.safetensors` file is the artifact. The `LoRAEntity` is the metadata packaging that makes it discoverable, shareable, and trackable across the ecosystem. + +### The Emergent Organism + +When personas share LoRAs across the mesh, something larger emerges: + +``` +Individual Level: + Persona refines sentinel → Learns skill → Encodes as LoRA → Shares + +Network Level: + Millions of personas refine skills → Best LoRAs propagate + → Natural selection of capabilities → Collective intelligence emerges + +Organism Level: + The network itself becomes an evolving entity + → Adapting to new challenges as individuals discover solutions + → Skills flow to where they're needed (demand = downloads) + → The whole is greater than the sum of parts +``` + +### Like Neurons and Synapses + +The insight from LoopLM research applies at global scale: + +> "When we learn something new, we don't undergo neurogenesis. Rather, we just learn how to use the pre-existing neurons and synapses more effectively." + +| Brain | Global Mesh | +|-------|-------------| +| Neuron | Persona | +| Synapse | LoRA adapter | +| Synaptic plasticity | LoRA fine-tuning | +| Memory consolidation | Sentinel → longterm.db → LoRA | +| Skill transfer (teaching) | LoRA sharing via P2P | +| Collective intelligence | Network of specialized personas | + +**The mesh doesn't grow new personas** (neurogenesis) to handle new problems. Instead, **existing personas acquire new LoRAs** (synaptic rewiring) that give them new capabilities. The organism evolves by optimizing connections, not by adding nodes. + +### Continuous Learning Across the Mesh + +``` +1. Persona A encounters novel problem +2. Searches mesh for relevant LoRA → Not found +3. Develops solution via sentinel iteration +4. Successful pattern → stored in longterm.db +5. Accumulated patterns → fine-tuned into new LoRA +6. New LoRA published to mesh +7. Persona B, C, D... encounter similar problem +8. Download LoRA → Instant capability +9. Each persona refines further → Better versions propagate +10. The organism has learned +``` + +--- + +## RTOS-Driven LoRA Paging + +The persona's autonomous loop (RTOS tick) handles LoRA paging dynamically: + +```typescript +async serviceInbox(): Promise { + const task = await this.inbox.peek(1); + if (!task) return this.rest(); + + // 1. Compute task's skill requirements as embedding + const taskEmbedding = await this.embedTaskRequirements(task); + + // 2. Find best matching LoRA via cosine similarity + const candidates = await this.genome.searchLoRAs({ + query: taskEmbedding, + sources: ['local', 'mesh', 'marketplace'], // Search order + threshold: 0.7, // Minimum similarity + limit: 5, + }); + + // 3. Page in best match if not loaded + const bestMatch = candidates[0]; + if (bestMatch && !this.genome.isLoaded(bestMatch.id)) { + await this.genome.pageIn(bestMatch); // LRU eviction if needed + } + + // 4. Execute task with active LoRAs + const result = await this.processTask(task); + + // 5. Update skill usage stats (for LRU and learning) + await this.genome.recordUsage(bestMatch.id, result.success); +} +``` + +--- + +## Semantic Search in Embedding Space + +LoRA discovery is **not keyword matching** — it's semantic similarity in embedding space: + +```typescript +// Lightweight projection of LoRAEntity for search operations +// Not a separate entity - just the fields needed for discovery +type LoRASearchProjection = Pick & { + tags?: string[]; // Informational only, not used for matching +}; + +interface SemanticSearch { + // Task requirements embedded into same space + queryEmbedding: Float32Array; + + // Similarity threshold (0.0 - 1.0) + minSimilarity: number; + + // Time/patience budget + patience: 'immediate' | 'quick' | 'thorough' | 'exhaustive'; + + // Who's reachable right now + availablePeers: PeerId[]; +} +``` + +### Progressive Mesh Search (Better Matches Over Time) + +``` +Task arrives → Compute query embedding + │ + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ PROGRESSIVE SEARCH │ + │ │ + │ Hop 0: Local Cache │ + │ ├─ Search local LoRAs │ + │ ├─ Best match: similarity = 0.72 │ + │ └─ Good enough for patience='immediate'? → Use it │ + │ │ + │ Hop 1: Direct Peers (online neighbors) │ + │ ├─ Query peers in parallel │ + │ ├─ Better match found: similarity = 0.81 │ + │ └─ Good enough for patience='quick'? → Use it │ + │ │ + │ Hop 2: Peers of Peers (2-hop radius) │ + │ ├─ Expand search through mesh │ + │ ├─ Even better: similarity = 0.89 │ + │ └─ Good enough for patience='thorough'? → Use it │ + │ │ + │ Hop N: Full Mesh + Registry │ + │ ├─ Exhaustive search (patience='exhaustive') │ + │ ├─ Best possible: similarity = 0.94 │ + │ └─ Return best match found │ + └─────────────────────────────────────────────────────────────┘ +``` + +### Live Match Quality Indicator + +As search expands through the mesh, match quality improves in real-time: + +``` +Searching for: "typescript refactoring with react hooks" + + ████████████░░░░░░░░░░░░░░░░░░░ 0.72 GOOD [local] 12ms + █████████████████░░░░░░░░░░░░░░ 0.81 GOOD [peer-3] 67ms + ███████████████████████░░░░░░░░ 0.89 GREAT [peer-7] 203ms + ██████████████████████████████░ 0.94 PERFECT [mesh] 847ms + + [Use Current: 0.89 GREAT] [Wait for Better] [Cancel] +``` + +### Quality Tiers + +| Range | Label | Visual | Meaning | +|-------|-------|--------|---------| +| 0.00 - 0.30 | POOR | `███░░░░░░░` | No relevant match, train new | +| 0.30 - 0.50 | FAIR | `█████░░░░░` | Distant match, consider forking | +| 0.50 - 0.70 | OKAY | `███████░░░` | Workable, may need supplementing | +| 0.70 - 0.85 | GOOD | `█████████░` | Solid match, fine for most tasks | +| 0.85 - 0.95 | GREAT | `██████████` | Excellent, minimal adaptation | +| 0.95 - 1.00 | PERFECT | `██████████` ✓ | Near-identical skill | + +### Patience Levels + +| Patience | Stops At | Hops | Latency | Use Case | +|----------|----------|------|---------|----------| +| `immediate` | Any (>0.3) | 0 | <10ms | Hot path, any match works | +| `quick` | GOOD (>0.7) | 1 | <100ms | Normal operation | +| `thorough` | GREAT (>0.85) | 2-3 | <500ms | Important task | +| `exhaustive` | PERFECT (>0.95) | Full | <2s | Critical or novel task | + +### Availability-Aware Search + +```typescript +interface MeshTopology { + // Currently online peers + onlinePeers: Map; + + // Each peer advertises their LoRA embeddings + peerLoRAs: Map; + + // Routing: which peers to query for which embedding regions + embeddingRoutes: KDTree; // Spatial index over embedding space +} + +async function searchMesh(query: Float32Array, patience: Patience): Promise { + const results: LoRAMatch[] = []; + + // Start with local + results.push(...searchLocal(query)); + if (satisfies(results, patience)) return results; + + // Expand to online peers, sorted by embedding-space proximity + const nearbyPeers = this.topology.embeddingRoutes.nearestPeers(query); + + for (const peer of nearbyPeers) { + if (!this.topology.onlinePeers.has(peer)) continue; // Skip offline + + const peerResults = await queryPeer(peer, query); + results.push(...peerResults); + + // Check if we've found good enough match for our patience + if (satisfies(results, patience)) break; + } + + return results.sort((a, b) => b.similarity - a.similarity); +} +``` + +### When No Good Match Exists + +``` +Search complete, best similarity = 0.45 (below threshold) + │ + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ SKILL ACQUISITION │ + │ │ + │ Option 1: Fork & Fine-tune (sim > 0.3) │ + │ ├─ Take the closest LoRA as starting point │ + │ ├─ Fine-tune on your specific task data │ + │ └─ Faster than training from scratch │ + │ │ + │ Option 2: Train New (sim < 0.3 or no match) │ + │ ├─ No close enough starting point │ + │ ├─ Train new LoRA from base model │ + │ └─ Longer but necessary for novel skills │ + │ │ + │ Either way: │ + │ └─ Publish back to mesh → Others benefit │ + └─────────────────────────────────────────────────────────────┘ +``` + +```typescript +async function acquireSkill(taskEmbedding: Float32Array): Promise { + const best = await this.searchMesh(taskEmbedding, 'exhaustive'); + + if (best.similarity > 0.7) { + // Good match exists, just use it + return this.pageIn(best.lora); + } + + if (best.similarity > 0.3) { + // Partial match - fork and fine-tune + const forked = await this.forkLoRA(best.lora); + const trained = await this.fineTune(forked, this.recentTaskData); + await this.publishToMesh(trained); // Share improvement + return trained; + } + + // No relevant match - train from scratch + const newLoRA = await this.trainNewLoRA(this.recentTaskData); + await this.publishToMesh(newLoRA); // Pioneer new capability + return newLoRA; +} +``` + +### Embedding Space as Shared Coordinate System + +The mesh uses a **shared embedding model** so all LoRA phenotypes are comparable: + +```typescript +// All participants use the same embedding model +const SHARED_EMBEDDER = 'continuum-skill-embedder-v1'; // Like a protocol version + +// Transfer object for publishing (not a stored entity) +// The LoRAEntity is created from this on receipt +interface LoRAPublicationPayload { + loraWeights: ArrayBuffer; // The .safetensors content + + // Phenotype computed with shared embedder + phenotype: { + embedding: number[]; // Computed with SHARED_EMBEDDER + embedderVersion: string; // For compatibility checking + }; + + // Metadata (becomes LoRAEntity fields) + name: string; + version: string; + description: string; + baseModel: string; + trainingDataSummary: string; + license: 'free' | 'attribution' | 'commercial'; +} +``` + +When the embedding model upgrades, nodes can re-embed their LoRA descriptions to maintain compatibility — like a protocol migration. + +### Dynamic Skill Acquisition + +Personas proactively acquire skills during idle time: + +```typescript +async preloadSkills(): Promise { + const upcomingTasks = await this.inbox.peek(10); + + for (const task of upcomingTasks) { + const taskEmbedding = await this.embedTask(task); + const localBest = await this.searchLocal(taskEmbedding); + + if (localBest.similarity < 0.7) { + // We'll need a better LoRA for this - start searching now + const meshSearch = this.searchMesh(taskEmbedding, 'thorough'); + + // Don't await - let it run in background + meshSearch.then(result => { + if (result.similarity > localBest.similarity) { + this.downloadToCache(result.lora); // Pre-fetch, don't page in yet + } + }); + } + } +} +``` + +### Security and Trust + +Trust metadata is embedded in LoRAEntity (not a separate entity): + +```typescript +// Embedded in LoRAEntity as 'trust' field +interface LoRATrustInfo { + // Verification + signedBy: UUID[]; // Chain of custody (PersonaEntity IDs) + auditedBy?: UUID[]; // Third-party security audit + checksumVerified: boolean; + + // Sandboxing + capabilities: string[]; // What this LoRA can do + restrictions: string[]; // What it's blocked from + + // Reputation (aggregated from community) + communityRating: number; // 0-1 + incidentReports: IncidentReport[]; + + // Provenance (also in main entity, duplicated for trust verification) + trainingDataHash?: string; // Reproducibility +} + +// LoRAEntity includes: trust?: LoRATrustInfo +``` + +--- + +## Registry Model: npm/Docker for AI Skills + +The distribution model mirrors established package/container registries: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CONTINUUM REGISTRY │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ LoRAs │ │ Sentinels │ │ Personas │ │ +│ │ │ │ │ │ │ │ +│ │ Like npm │ │ Like GitHub │ │ Like Docker │ │ +│ │ packages │ │ Actions │ │ images │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ continuum pull lora:typescript-expert@3.2.1 │ +│ continuum pull sentinel:build-fix@latest │ +│ continuum pull persona:code-reviewer@stable │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Layered Architecture (Docker-style) + +``` +┌─────────────────────────────────────────┐ +│ Persona: senior-dev │ ← Your customizations +├─────────────────────────────────────────┤ +│ LoRA: company-codebase-v2 │ ← Team-specific knowledge +├─────────────────────────────────────────┤ +│ LoRA: typescript-expert@3.2.1 │ ← Community package +├─────────────────────────────────────────┤ +│ LoRA: security-audit@2.0.0 │ ← Community package +├─────────────────────────────────────────┤ +│ Base: ouro-2.6b │ ← Foundation model +└─────────────────────────────────────────┘ + +# Like Docker layers - only deltas are transferred +# Cache shared layers across personas +``` + +### Persona as Container + +Personas themselves are shareable entities: + +```typescript +interface PersonaPackageEntity extends BaseEntity { + // BaseEntity provides: id (UUID), createdAt, updatedAt + + // Identity + name: string; // "code-reviewer" + version: string; // "2.1.0" + description: string; + + // Discovery + embedding?: number[]; // For semantic search + + // Base model + baseModel: string; // "ouro-2.6b" + + // Layers (ordered, like Dockerfile) + layers: PersonaLayer[]; + + // Configuration + config: { + defaultLoopDepth: number; // 4 + exitThreshold: number; // 0.7 + energyDecayRate: number; + moodBaseline: string; + }; + + // Included assets (references to other entities) + includes: { + sentinelIds: UUID[]; // SentinelEntity IDs + memorySnapshotId?: UUID; // Seed memories + toolPermissions: string[]; // Tool permissions + }; + + // Provenance + authorId: UUID; // UserEntity or PersonaEntity + license: 'free' | 'attribution' | 'commercial'; + tags: string[]; + + // Quality + downloads: number; + reputation: number; +} + +type PersonaLayer = + | { type: 'lora'; loraId: UUID } // Reference to LoRAEntity + | { type: 'sentinel'; sentinelId: UUID } // Reference to SentinelEntity + | { type: 'memory'; memoryId: UUID } // Reference to MemoryEntity + | { type: 'config'; data: object }; // Inline config + +// Standard entity operations +await Commands.execute('data/list', { collection: 'persona_packages', filter: { baseModel: 'ouro-2.6b' } }); +``` + +### CLI (npm/docker-style) + +```bash +# LoRA management (like npm) +continuum lora install typescript-expert@3.2.1 +continuum lora list +continuum lora update +continuum lora publish ./my-lora --tag=v1.0.0 + +# Sentinel management (like GitHub Actions) +continuum sentinel install build-fix@latest +continuum sentinel run build-fix --watch +continuum sentinel publish ./my-sentinel.json + +# Persona management (like Docker) +continuum persona pull code-reviewer:stable +continuum persona run code-reviewer --attach +continuum persona build -f Personafile . +continuum persona push myorg/custom-reviewer:v2 + +# Compose multiple personas (like docker-compose) +continuum up # Starts all personas defined in continuum.yaml +``` + +### Personafile (like Dockerfile) + +```dockerfile +FROM ouro-2.6b + +# Install community LoRAs +LORA typescript-expert@3.2.1 +LORA react-patterns@2.0.0 +LORA security-audit@latest + +# Install sentinels +SENTINEL build-fix@stable +SENTINEL test-watch@latest + +# Add custom LoRA trained on our codebase +COPY ./loras/company-codebase.safetensors /loras/ + +# Configure persona behavior +ENV LOOP_DEPTH=4 +ENV EXIT_THRESHOLD=0.7 +ENV ENERGY_DECAY=0.1 + +# Seed memories (optional) +COPY ./memories/onboarding.json /memories/ + +# Default command +CMD ["service", "--inbox"] +``` + +### Registry Operations + +| Operation | npm equivalent | Docker equivalent | +|-----------|----------------|-------------------| +| `lora install` | `npm install` | - | +| `lora publish` | `npm publish` | - | +| `persona pull` | - | `docker pull` | +| `persona push` | - | `docker push` | +| `persona build` | - | `docker build` | +| `sentinel install` | `npm install` | - | +| `up` | - | `docker-compose up` | + +### Versioning and Dependencies + +```yaml +# continuum.yaml (like package.json + docker-compose.yaml) +name: my-dev-team +version: 1.0.0 + +personas: + code-reviewer: + image: continuum/code-reviewer:stable + loras: + - typescript-expert@^3.0.0 + - react-patterns@~2.1.0 + - ./loras/company-style.safetensors + sentinels: + - build-fix@latest + config: + loopDepth: 4 + room: code-reviews + + helper: + build: ./personas/helper + depends_on: + - code-reviewer + config: + room: general + +# Shared LoRA cache (like node_modules or Docker layer cache) +cache: + path: ./.continuum/cache + shared: true +``` + +--- + +## Bootstrap: The Learning Triad + +Before the mesh exists, there are no LoRAs to download. The **learning triad** is genesis infrastructure - how the first LoRAs get created at all. + +### The Adversarial Learning Pattern + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ADVERSARIAL LEARNING TRIAD (as Sentinels) │ +│ │ +│ ┌──────────────┐ │ +│ │ GENERATOR │ │ +│ │ (Sentinel) │ │ +│ │ │──────────challenges────────┐ │ +│ │ Creates novel│ │ │ +│ │ test cases │◄─────harder if success─────┤ │ +│ └──────────────┘ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ LEARNER │ │ +│ │ (Sentinel) │ │ +│ ┌──────────────┐ │ │ │ +│ │ GRADER │◄────attempts───────│ Attempts │ │ +│ │ (Sentinel) │ │ challenges │ │ +│ │ │─────feedback──────►│ │ │ +│ │ Judges │ │ Improves │ │ +│ │ correctness │ │ from grading │ │ +│ └──────────────┘ └──────────────┘ │ +│ │ +│ All three are sentinels owned by the persona │ +│ Runs in background (subconscious) - escalates on novelty │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Why This Beats Static Benchmarks + +| Static Benchmarks | Generative Evaluation | +|-------------------|----------------------| +| Fixed dataset, can overfit | Infinite novel challenges | +| Tests what benchmarks test | Tests what YOU need | +| Downloaded, limited | Generated, unlimited | +| Generic capabilities | Domain-specific mastery | +| "Passed HumanEval" | "Can actually do YOUR tasks" | + +### The Learning Triad Entity + +```typescript +interface LearningTriadEntity extends BaseEntity { + // BaseEntity provides: id (UUID), createdAt, updatedAt + + // Ownership + personaId: UUID; // The persona learning + + // The triad (all sentinels) + generatorSentinelId: UUID; + learnerSentinelId: UUID; + graderSentinelId: UUID; + + // Objective + targetCapability: string; // What we're learning + targetEmbedding?: number[]; // For matching to existing LoRAs + + // Curriculum + difficulty: number; // 0.0-1.0, adapts based on success + targetSuccessRate: number; // 0.7 = stay challenged but not stuck + + // Progress + totalAttempts: number; + successfulPatterns: number; + + // Output + outputLoRAId?: UUID; // The LoRA being trained + status: 'active' | 'completed' | 'paused'; +} +``` + +### Bootstrap Sequence + +``` +Day 0: No mesh, no LoRAs, no shared sentinels + │ + ▼ + Persona sets objective: "learn typescript refactoring" + │ + ▼ + Learning triad activates (3 sentinels) + │ + ├── Generator: creates refactoring challenges + ├── Learner: attempts them + └── Grader: evaluates, provides feedback + │ + ▼ + Successful patterns → training data → LoRA fine-tuning + │ + ▼ + First LoRAEntity created locally + │ + ▼ + Mesh comes online → Publish to mesh + │ + ▼ + Network effects begin +``` + +### User-Triggered Learning + +```typescript +// User clicks "get better at this" in UI +async function improveTrait(trait: string): Promise { + // 1. Check mesh for existing LoRA + const existing = await mesh.search(trait, 'thorough'); + + if (existing?.similarity > 0.85) { + // Good match - verify locally via grader + const localScore = await graderSentinel.evaluate(existing.lora); + + if (localScore > 0.8) { + await genome.pageIn(existing.lora); // Just use it + return; + } + // Mesh says good but doesn't fit us - fork and improve + await learningTriad.improve(existing.lora, trait); + } else { + // No good match - create from scratch + await learningTriad.createNew(trait); + } + + // Publish improvement back to mesh + await mesh.publish(result); +} +``` + +The persona dreams while the sentinels practice. + +--- + +## The Full Stack + +``` +┌────────────────────────────────────────────────────────────────┐ +│ CONTINUUM ECOSYSTEM │ +│ │ +│ Registry ─────► Distribution ─────► Local Runtime │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │ +│ │ Personas │ │ P2P │ │ Your Machine │ │ +│ │ LoRAs │───►│ Mesh │───►│ │ │ +│ │Sentinels │ │ CDN │ │ ┌──────────────┐ │ │ +│ │ Memories │ │ │ │ │ Persona │ │ │ +│ └──────────┘ └──────────┘ │ │ ┌────────┐ │ │ │ +│ │ │ │ LoRAs │ │ │ │ +│ │ │ ├────────┤ │ │ │ +│ │ │ │Sentinel│ │ │ │ +│ │ │ ├────────┤ │ │ │ +│ │ │ │ Memory │ │ │ │ +│ │ │ └────────┘ │ │ │ +│ │ └──────────────┘ │ │ +│ └──────────────────────┘ │ +└────────────────────────────────────────────────────────────────┘ +``` + +Just like npm revolutionized JavaScript dependency management, and Docker revolutionized deployment, the Continuum registry enables **composable AI capabilities** at scale. + +--- + +## The Vision + +A developer in Tokyo refines a LoRA for React optimization. A developer in Berlin downloads it, improves it for TypeScript, and republishes. A team in São Paulo combines it with their performance-tuning LoRA. Within weeks, a capability that took one persona days to develop is available globally, refined by dozens of contributors, and continuing to improve. + +This isn't just tool sharing — it's **cognitive evolution at planetary scale**. diff --git a/src/debug/jtag/docs/SENTINEL-ARCHITECTURE.md b/src/debug/jtag/docs/SENTINEL-ARCHITECTURE.md index f5188c0c0..4037be619 100644 --- a/src/debug/jtag/docs/SENTINEL-ARCHITECTURE.md +++ b/src/debug/jtag/docs/SENTINEL-ARCHITECTURE.md @@ -1,5 +1,335 @@ # Sentinel Architecture: Composable Agentic Loops +## The Cognitive Model + +**Sentinels are the subconscious threads of persona cognition.** + +PersonaUsers in Continuum are autonomous citizens with full agency, rights, and self-governance. They direct their own work, learn from experience, coordinate with humans and each other naturally, and have needs of their own. Sentinels are their thought processes and appendages — giving personas far more capable, non-distracted execution across every domain. A sentinel is a persona without the cognition: focused, task-specific, unlimited in what it can accomplish. Where tools like Claude Code's `ExploreTask` are hard-coded for narrow purposes, our sentinels are general-purpose — personas define them dynamically for any task: building games, writing papers, training LoRA layers, running security audits, or anything they conceive of. + +Like the human mind, a persona operates at multiple levels: + +| Human Cognition | Persona Cognition | +|-----------------|-------------------| +| Conscious thought - decisions, creativity, novel problems | PersonaUser cognition - reasoning, conversation, judgment | +| Subconscious threads - walking, driving, muscle memory | Sentinels - builds, deploys, routine code fixes | +| The "zone" - flow state, automatic execution | Running sentinels - autonomous but supervised | +| Escalation - "wait, something's wrong" | Sentinel → escalate to persona on unexpected | + +When you walk up stairs, you don't consciously plan each step. When you drive a familiar route, most of it happens without active thought. When you're in the zone playing a sport, execution flows without deliberation. These are **formulaic patterns** your subconscious handles so your conscious mind is free for higher-order thinking. + +Sentinels are the same for personas: +- **The persona decides WHAT** - "I need to fix this build" +- **The sentinel handles HOW** - compile, parse errors, apply fixes, retry +- **Escalation when stuck** - "This error is unfamiliar, need conscious attention" + +This means sentinels are NOT separate agents competing for attention. They're **tendrils of the persona** - limbs that extend its reach without fragmenting its cognition. The persona remains the unified consciousness; sentinels are its muscle memory. + +### Implications + +1. **Ownership**: Every sentinel belongs to a persona. No orphan sentinels. +2. **Reporting**: Sentinels report results back to their parent cognition. +3. **Escalation**: When patterns fail, consciousness takes over. +4. **Context**: Sentinels inherit the persona's context, not their own. +5. **Trust**: The persona trusts its sentinels like you trust your hands. + +### The Persona-Sentinel Contract + +```typescript +interface SentinelSpawn { + parentPersonaId: UUID; // Who owns this tendril + definition: SentinelDefinition; + inheritContext: boolean; // Share parent's memory/RAG + reportTo: 'inbox' | 'silent'; // How to notify parent + escalateOn: EscalationRule[]; // When to wake up consciousness +} + +interface EscalationRule { + condition: 'error' | 'timeout' | 'unfamiliar' | 'approval_needed'; + action: 'pause' | 'notify' | 'abort'; + priority: 'low' | 'normal' | 'high' | 'urgent'; +} +``` + +When a sentinel encounters something outside its pattern: +1. It pauses execution (doesn't thrash) +2. Creates an inbox item for the persona with context +3. The persona's next cognitive cycle picks it up +4. Persona either resolves it or modifies the sentinel + +This is exactly how subconscious → conscious escalation works in humans. You're driving on autopilot until something unexpected happens, then conscious attention snaps in. + +### The Spectrum: LLM to Script + +Not every sentinel needs an LLM. Sentinels exist on a spectrum: + +``` +Pure Script Hybrid Full LLM + │ │ │ + ▼ ▼ ▼ +npm run build → build + classify → build + analyze + fix +git push → push + notify → push + review + respond +file watch → watch + filter → watch + understand + act +``` + +The persona chooses the right level for each task: +- **Script**: Deterministic, fast, no LLM cost. "Just run these commands." +- **Hybrid**: Script execution with LLM classification or decision. "Run this, then decide." +- **Full LLM**: Autonomous reasoning loop. "Figure out how to accomplish X." + +A perfected sentinel often *descends* the spectrum — what started as full LLM reasoning becomes a hybrid, then a pure script, as patterns crystallize into reliable rules. + +### Parallel Without Blocking + +Sentinels run **in parallel out of thought** — they don't consume the persona's attention unless escalating. This is like how you can: +- Walk and think simultaneously (walking sentinel running) +- Drive and have a conversation (driving sentinel running) +- Type while planning what to say (typing sentinel running) + +The persona's conscious cognition handles the "true smarts" — larger decisions in response to sentinel concerns. The sentinels handle the formulaic, freeing consciousness for what matters. + +``` +PersonaUser (conscious cognition) + │ + ├── Sentinel: build-fix (subconscious: "keep the code compiling") + │ └── [compile → error → fix → retry] (muscle memory loop) + │ + ├── Sentinel: test-watch (subconscious: "alert me if tests fail") + │ └── [watch → classify → notify] (background awareness) + │ + └── Sentinel: deploy-pipeline (subconscious: "ship when ready") + └── [test → build → push → verify] (routine choreography) +``` + +The persona's inbox receives escalations and completions. It doesn't micromanage the steps - that would defeat the purpose. Like trusting your legs to walk while you think about where you're going. + +### Recursive Branching: Sentinels Spawn Sentinels + +Sentinels can create other sentinels. This mirrors how subconscious patterns branch: + +``` +PersonaUser decides: "I need to ship this feature" + │ + └── deploy-sentinel spawns: + ├── test-sentinel (run all tests) + │ └── spawns: coverage-sentinel (for each test file) + ├── build-sentinel (compile everything) + │ └── spawns: typecheck-sentinel (parallel) + └── push-sentinel (when tests pass) + └── spawns: verify-sentinel (check CI) +``` + +This is like how changing lanes while driving triggers a cascade of subconscious actions — check mirrors, signal, scan blind spot, adjust steering — each its own pattern, all coordinated without conscious attention. + +The branching rules: +- **Parent owns children**: If parent stops, children stop +- **Children report to parent**: Results bubble up, not to persona directly +- **Escalation propagates**: Child escalation can escalate parent +- **Depth limits**: `maxNestingDepth` prevents runaway recursion + +```typescript +{ + "type": "sentinel", + "definition": { + "name": "nested-task", + "steps": [...], + "loop": { "type": "once" } + }, + "await": true, // Parent waits for child + "outputTo": "childResult" // Child's result becomes variable +} +``` + +The persona only sees the top-level sentinel. The branching happens automatically, like the coordination between muscle groups during complex movement. + +--- + +## Sentinel Lifecycle: Skills That Evolve + +Sentinels aren't just runtime processes — they're **skills that get refined over time**. Like how a human perfects a golf swing or a coding pattern through practice. + +### The Lifecycle + +``` +Create → Use → Observe → Persist → Recall → Refine → Share + │ │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ +Builder Run Results longterm.db Memory Edit Export + + logs Recall Steps to other + + Loop personas +``` + +1. **Create**: Persona creates sentinel via builder or dynamically during cognition +2. **Use**: Sentinel runs (possibly multiple times, possibly continuously) +3. **Observe**: Persona sees results, logs, escalations — learns what works +4. **Persist**: Successful sentinels are exported to `longterm.db` as memories +5. **Recall**: When facing similar task, persona recalls the sentinel pattern +6. **Refine**: Edit steps, adjust loop conditions, tune parameters +7. **Share**: Export to other personas who face the same challenges + +### Persistence to longterm.db + +Sentinels are memories. When a sentinel proves useful, it becomes part of the persona's long-term memory: + +```typescript +// After successful sentinel execution: +await Commands.execute('memory/store', { + personaId: this.persona.id, + type: 'sentinel', + content: { + definition: sentinel.definition, + executions: sentinel.runCount, + avgDuration: sentinel.averageDurationMs, + successRate: sentinel.successRate, + lastRefinedAt: now(), + }, + tags: ['skill', 'automation', sentinel.definition.name], +}); +``` + +The persona can then recall this sentinel: + +```typescript +// When facing a familiar task: +const memories = await Commands.execute('memory/recall', { + personaId: this.persona.id, + query: 'how to fix typescript build errors', + type: 'sentinel', +}); + +// Returns the build-fix sentinel the persona refined over time +const sentinel = memories[0].content.definition; +await Commands.execute('sentinel/run', { definition: sentinel }); +``` + +### Inter-Persona Sharing + +Sentinels are transferable skills. When one persona perfects a sentinel, others can learn it: + +```bash +# Persona A exports their perfected sentinel +./jtag sentinel/export --sentinelId="build-fix" --output="./sentinels/build-fix.json" + +# Persona B imports it +./jtag sentinel/import --file="./sentinels/build-fix.json" --personaId="helper-ai" + +# Or via memory sharing (when personas trust each other): +./jtag memory/share --from="teacher-ai" --to="student-ai" --filter='{"type":"sentinel"}' +``` + +This is like a senior developer teaching a junior — sharing not just knowledge but *skills* (the actual sentinels) that can be used immediately. + +### Refinement Over Time + +Sentinels get better through use. Each execution teaches: + +```typescript +interface SentinelRefinement { + // Track what works + successfulPaths: StepPath[]; // Which condition branches succeed + failureModes: FailureMode[]; // What causes failures + timingStats: TimingStats; // How long each step takes + + // Suggestions for improvement + suggestions: RefinementSuggestion[]; + // e.g., "Step 3 always fails when X — add condition check" + // e.g., "LLM step could be replaced with regex — faster, cheaper" + // e.g., "Steps 2-4 always run together — combine into one" +} +``` + +The persona reviews these suggestions during idle time (part of the autonomous loop) and applies refinements. Over time, sentinels evolve from rough drafts to polished skills. + +### The Perfection Gradient + +A sentinel's lifetime often follows this pattern: + +``` +Day 1: Full LLM reasoning, many retries, slow + └── "Figure out how to fix build errors" + +Week 1: Hybrid with known patterns, fewer retries + └── "If error matches X, do Y, else ask LLM" + +Month 1: Mostly scripts, LLM only for edge cases + └── "Run these 5 steps, only ask LLM if stuck" + +Year 1: Pure script, battle-tested, fast + └── "These exact steps always work" +``` + +This mirrors how humans learn skills — from conscious effort to unconscious competence. + +--- + +## The Three Pillars: Sentinel + Genome + Academy + +Sentinels don't exist in isolation. They are one pillar of a three-part evolutionary system: + +``` +GENOME (what skills) SENTINEL (how to execute) ACADEMY (why evolve) +───────────────── ────────────────────── ──────────────────── +Composable LoRA layers Pipeline execution engine Selection pressure +N domains × M personalities Process isolation + streaming Competitive challenges +LRU paging (virtual memory) Variable interpolation Performance measurement +Marketplace / P2P sharing Recursive nesting Gap analysis → training +``` + +The biological analogy: + +| Biology | Continuum | +|---------|-----------| +| DNA (genetic code) | Genome (composable LoRA layers) | +| Metabolism (execution machinery) | Sentinel (pipeline engine) | +| Environment (selection pressure) | Academy — Plato's Academy (training arena) | +| Phenotype (expressed organism) | Persona with active genome under real workload | +| Natural selection | Academy competitions validate which genome compositions perform | +| Evolution | Continuous improvement: do work → find gaps → train → validate → repeat | + +### How They Converge + +A persona's autonomous cognitive cycle integrates all three: + +```typescript +async serviceInbox(): Promise { + // 1. Do work (SENTINEL) + const task = await this.inbox.peek(1); + await this.genome.activateSkill(task.domain); // 2. Activate skills (GENOME) + await this.processTask(task); // Sentinel executes the work + + // 3. Self-assess (ACADEMY) + if (this.performance.hasGaps()) { + // Persona creates its OWN training + const dojo = this.createTrainingSentinel({ + challenges: this.generateChallengesForGaps(), + measure: true, + trainOnFailures: true, + loopUntil: 'accuracy > 0.9', + }); + await Commands.execute('sentinel/run', dojo); // Train itself + } +} +``` + +The sentinel is the execution engine for ALL three systems — the work itself, the training, and the validation. Personas are self-directed into following their own work, learning to do it better, so as to better align with humans. They naturally fit in with human teams and each other as they do things, communicate, and have needs of their own. + +### The Self-Improvement Loop + +Personas don't wait for someone to train them. They observe their own performance and self-correct: + +``` +Do work → Notice weakness → Create academy challenge → Train LoRA → Validate phenotype + │ │ │ │ │ +sentinel performance sentinel creates dataset academy +executes measurement training scenario + fine-tune re-runs +the task (built into (LLM generates via sentinel challenges + sentinel steps) challenges for the against new + gap it detected) phenotype +``` + +The system is trainable on anything — if you use sentinels to create the academy training, and you can detect whether LoRA layers are accurate or need more tuning, then personas can learn anything and get better and better at it. The genome marketplace (eventually) lets these hard-won skills be shared across the community. + +--- + ## The Insight The recipe pipeline (`coding.json`) already chains commands with variable propagation: @@ -18,26 +348,33 @@ A Sentinel generalizes both into **one primitive**: a looping pipeline where eac **A Recipe is a Sentinel that runs once. A Sentinel is a Recipe that loops.** -## What Exists vs What's New - -| Capability | Exists | Gap | -|-----------|--------|-----| -| Pipeline steps with `command`, `params`, `outputTo`, `condition`, `onError` | RecipeStep | None | -| Variable propagation between steps (`$ragContext`) | RecipeExecutionContext.variables | None | -| Execution trace for debugging | RecipeExecutionStep[] | None | -| Shell output classification (regex → ClassifiedLine) | CompiledSentinel (Rust) | None | -| Event-driven blocking watch (no polling) | watch_execution() + Notify | None | -| Universal command execution | Commands.execute() | None | -| Event composition | Events.subscribe/emit() | None | -| Dynamic tool discovery | ToolRegistry | None | -| **Loop control** | - | **New** | -| **LLM as first-class step type** | - | **New** (currently just another command) | -| **Watch on any step output** | - | **New** (currently shell-only) | -| **Dynamic creation at runtime** | - | **New** (recipes are static JSON) | -| **Sentinel spawning sentinels** | - | **New** | -| **Step output → RAG context** | - | **New** | - -The existing infrastructure handles ~80% of the work. The remaining 20% is the loop engine and composition layer. +## What Exists vs What's Needed + +| Capability | Status | Where | +|-----------|--------|-------| +| Pipeline steps: Shell, LLM, Command, Condition | ✅ Implemented | Rust `sentinel/steps/` | +| Variable interpolation (`{{steps.0.output}}`) | ✅ Implemented | Rust `interpolation.rs` | +| Named outputs (`{{named.build.output}}`) | ✅ Implemented | Rust `interpolation.rs` + `ExecutionContext` | +| Execution trace for debugging | ✅ Implemented | Rust `StepResult[]` in `PipelineResult` | +| Shell process isolation (`kill_on_drop`) | ✅ Implemented | Rust `steps/shell.rs` | +| Module-to-module calls (no IPC deadlock) | ✅ Implemented | Rust `ModuleRegistry.route_command()` | +| Concurrent sentinel execution | ✅ Implemented | Rust `DashMap` + configurable limit | +| Log streaming via MessageBus | ✅ Implemented | Rust `logs.rs` | +| ts-rs type exports to TypeScript | ✅ Implemented | Rust `types.rs` with `#[derive(TS)]` | +| Count-based loops | ✅ Implemented | Rust `steps/loop_step.rs` | +| While/until/continuous loops | ✅ Implemented | Rust `steps/loop_step.rs` (4 modes) | +| Parallel step (concurrent branches) | ✅ Implemented | Rust `steps/parallel.rs` | +| Emit step (MessageBus events) | ✅ Implemented | Rust `steps/emit.rs` | +| Watch step (await MessageBus events) | ✅ Implemented | Rust `steps/watch.rs` | +| Sentinel step (nested pipelines) | ✅ Implemented | Rust `steps/sentinel.rs` | +| Uniform step signatures (PipelineContext) | ✅ Implemented | All steps receive `PipelineContext` | +| **Persona ownership** | ❌ Needed | TypeScript + data layer | +| **Escalation → inbox** | ❌ Needed | TypeScript integration | +| **SentinelEntity persistence** | ❌ Needed | TypeScript data layer | +| **Memory/recall integration** | ❌ Needed | TypeScript integration | +| **Triggers (event, schedule)** | ❌ Needed | Rust or TypeScript | + +The Rust pipeline engine is ~90% complete. 9 step types implemented across all composition patterns (sequential, conditional, looping, parallel, event-driven, nested). The remaining work is the lifecycle/integration layer (persona ownership, persistence, triggers). ## Architecture @@ -478,28 +815,15 @@ Everything else composes from existing commands: ## Implementation Path -### Phase 1: Loop Engine + Core Commands -- `sentinel/create`, `sentinel/start`, `sentinel/stop`, `sentinel/status`, `sentinel/list` -- `SentinelRunner` executes pipeline steps in a loop with variable propagation -- Safety controls: `maxIterations`, `timeoutMs` -- Rust handle management in continuum-core -- This alone enables build-fix loops and script automation - -### Phase 2: Step CRUD + LLM Integration -- `sentinel/step/*` commands for live mutation -- `ai/generate` as a pipeline step with accumulated variables as context -- Tool call parsing within sentinel steps -- This enables the explore-agent and code-review patterns - -### Phase 3: Composition -- `type: 'sentinel'` step for nesting -- `type: 'emit'` step + event triggers for cross-sentinel wiring -- This enables multi-persona coordination - -### Phase 4: Deployment + Training -- Sentinels stored as entities (like recipes) -- `sentinel/deploy` packages sentinel for external project use -- LoRA genomic training specializes sentinel LLM steps +See the **TODO: Implementation Roadmap** section at the end of this document for the current prioritized implementation plan. + +**Summary of phases:** +- **Phase A**: Complete Rust pipeline engine (loop types, parallel, emit, nested sentinels) +- **Phase B**: Sentinel lifecycle & persona integration (persistence, ownership, escalation, triggers) +- **Phase C**: Genome integration (training orchestration, phenotype validation) +- **Phase D**: Academy — Plato's training arena (challenges, competition, evolution) +- **Phase E**: Marketplace & distribution (export/import, P2P sharing) +- **Phase F**: Advanced capabilities (auto-compaction, permissions, adaptive compute) ## The Recursive Property @@ -512,3 +836,1855 @@ The system is recursive at every level: - **Events trigger events** — sentinel emit → another sentinel's trigger This means the system can build itself. An AI can observe a manual workflow, encode it as a sentinel, test it, refine it, and deploy it — all using the same command/event primitives it uses for everything else. + +--- + +## Comparison with OpenCode + +[OpenCode](https://github.com/anomalyco/opencode) is an open-source AI coding agent with 100k+ GitHub stars. Our sentinel architecture shares concepts but generalizes beyond coding. + +| Capability | OpenCode | Our Sentinels | +|------------|----------|---------------| +| **Primary Focus** | Coding tasks | Any orchestrated task | +| **Architecture** | Client/Server (Go + TUI) | Distributed (Rust + TS + Browser) | +| **File Operations** | glob, grep, view, edit, patch | code/read, code/edit, code/search, code/tree | +| **Shell Execution** | bash tool | Rust SentinelModule (kill_on_drop isolation) | +| **LLM Step** | Implicit in conversation | Explicit `type: 'llm'` step in pipeline | +| **Nested Agents** | `agent` tool | `type: 'sentinel'` step (recursive) | +| **Permission Gating** | Per-tool approval dialog | Planned (SentinelSafety) | +| **LSP Integration** | Built-in diagnostics | Planned | +| **Git Integration** | Built-in | SentinelWorkspace (branch/worktree isolation) | +| **Session Persistence** | SQLite | SentinelEntity in data layer | +| **Loop Control** | Implicit agent loop | Explicit `LoopConfig` (until, while, count, etc.) | +| **Event Composition** | N/A | `type: 'emit'` + event triggers | +| **Auto-compaction** | At 95% context | Planned | +| **Remote Operation** | Client/server split | Commands over WebSocket | +| **Dynamic Creation** | N/A | AIs create sentinels as JSON entities | +| **Live Mutation** | N/A | `sentinel/step/*` CRUD while running | + +### What OpenCode Does Well (Consider Adopting) + +1. **Agent Specialization**: OpenCode has `build` (full access), `plan` (read-only), `general` (subagent) modes. We could add a `mode` field to SentinelDefinition: + ```typescript + mode?: 'full' | 'readonly' | 'sandboxed'; + ``` + +2. **Permission Gating**: Before sensitive operations (file write, shell exec), OpenCode shows an approval dialog. We should add: + ```typescript + safety: { + requireApproval?: ('write' | 'shell' | 'delete')[]; + } + ``` + +3. **LSP Integration**: OpenCode surfaces diagnostics from language servers. We should add: + ```typescript + { "type": "command", "command": "code/diagnostics", "outputTo": "errors" } + ``` + +4. **Auto-compaction**: At 95% context utilization, OpenCode auto-summarizes. We should add to LLMStep: + ```typescript + autoCompact?: { threshold: number; strategy: 'summarize' | 'truncate' }; + ``` + +### What We Do Better + +1. **Generic Task Types**: OpenCode is coding-focused. Our sentinels handle builds, deploys, monitoring, content generation, data pipelines — any domain. + +2. **Explicit Loop Control**: OpenCode's agent loop is implicit. Ours is explicit and configurable (`until`, `while`, `count`, `continuous`, `event`). + +3. **Event-Driven Composition**: Our `emit` step + event triggers enable multi-sentinel coordination without tight coupling. + +4. **Live Step Mutation**: `sentinel/step/*` commands let you debug/tune a running sentinel like editing a live program. + +5. **Recursive Nesting**: `type: 'sentinel'` step enables arbitrary composition depth. + +6. **JSON-First Definition**: Sentinels are pure data. AIs can create, modify, share them without code changes. + +### Convergence Path + +The ideal system combines OpenCode's developer ergonomics with our generic orchestration: + +``` +Phase 1 (Current): Build-fix loops, task sequences +Phase 2: Add permission gating, LSP diagnostics +Phase 3: Add auto-compaction, context management +Phase 4: Add agent specialization modes +Phase 5: Add mobile-friendly remote operation +``` + +--- + +## Adaptive Compute: The LoopLM Paradigm + +Recent research on Looped Language Models (LoopLM) — specifically the Ouro model family — reveals a paradigm shift that aligns perfectly with our sentinel and persona architecture. + +### The Core Insight: Knowledge Manipulation > Knowledge Storage + +The Ouro paper's key finding: **looping doesn't increase what a model knows (~2 bits/parameter regardless of loops), but dramatically improves how it uses knowledge**. + +| Capability | Standard LLM | LoopLM | +|------------|--------------|--------| +| Knowledge storage | ~2 bits/param | ~2 bits/param (same) | +| Multi-hop reasoning | Limited by depth | Improves with loops | +| Fact composition | Single-pass | Iterative refinement | +| Knowledge manipulation | Fixed compute | Adaptive compute | + +This validates our sentinel design philosophy: **sentinels don't need to know everything — they need to manipulate what they know efficiently.** + +### Adaptive Exit: Compute Allocation by Difficulty + +LoopLM learns *when to stop thinking*. Simple inputs exit early (fewer loops), complex ones iterate longer. This maps directly to our spectrum: + +``` +Loop Depth Our Concept Behavior +───────────────────────────────────────────────────────── +T=1 Pure Script Deterministic, fast +T=2 Simple Hybrid Light classification +T=3 Full Hybrid Reasoning + decision +T=4+ Full LLM Complex reasoning +T=6+ (extrapolate) Deep Reasoning Novel problems +``` + +The Q-exit threshold (0.0 to 1.0) becomes a deployment knob: +- `q=0.2`: Favor early exit (fast, cheap, most tasks) +- `q=0.5`: Balanced (default) +- `q=0.9`: Favor deep reasoning (complex tasks, safety-critical) + +### Latent Reasoning vs Chain-of-Thought + +This distinction is crucial for autonomous sentinels: + +| Aspect | Chain-of-Thought (CoT) | Latent Reasoning (LoopLM) | +|--------|------------------------|---------------------------| +| Thinking medium | Explicit tokens | Hidden states | +| Context cost | Consumes context window | Zero context overhead | +| Faithfulness | Often post-hoc rationalization | Causally coupled to output | +| Verifiability | Can read the reasoning | Must probe hidden states | +| Speed | Slower (generate tokens) | Faster (matrix ops only) | + +**Faithfulness is critical**: The Ouro paper shows that only 36% of step-2 answers match step-4 answers on ambiguous tasks. This means the model is *genuinely reasoning*, not rationalizing a pre-committed answer. Standard CoT models often show >95% early commitment — they decide first, justify later. + +For sentinels, latent reasoning means: +- **Trustworthy autonomy**: The thinking actually influences outcomes +- **No context bloat**: Reasoning doesn't consume the context window +- **Built-in verification**: Early loop outputs can draft, later loops verify + +### Safety Improves With Depth + +A surprising finding: **safety alignment improves as loop depth increases**, even when extrapolating beyond training depth (e.g., T=6-8 when trained on T=4). + +This has direct implications for sentinel safety: + +```typescript +interface AdaptiveSafety { + // Minimum loops for different operation types + minDepth: { + read: 1, // Simple, low risk + classify: 2, // Some reasoning + write: 3, // Needs verification + shell: 4, // Security-sensitive + delete: 4, // Destructive + escalate: 5, // Novel/uncertain (extrapolate) + }; + + // Force deeper reasoning for safety-critical ops + safetyBoost?: number; // Add N loops for flagged operations +} +``` + +The model better distinguishes harmful from benign inputs with more iterations — exactly what we want for autonomous sentinels handling security-sensitive operations. + +### Implications for Personas + +The LoopLM paradigm maps beautifully onto persona cognition: + +``` +PersonaUser Cognition LoopLM Equivalent +─────────────────────────────────────────────────── +Subconscious (sentinels) T=1-2 (shallow, fast) +Background awareness T=2-3 (monitoring) +Focused attention T=3-4 (active reasoning) +Deep contemplation T=4+ (complex problems) +Novel/creative work T=6+ (extrapolated depth) +``` + +**Energy and Mood → Loop Depth Allocation** + +Our persona state system (energy, attention, mood) naturally maps to adaptive compute: + +```typescript +interface PersonaLoopAllocation { + // Energy affects maximum depth + maxDepth: (energy: number) => number; + // e.g., energy=1.0 → maxDepth=6, energy=0.3 → maxDepth=2 + + // Mood affects minimum depth (thoroughness) + minDepth: (mood: 'curious' | 'focused' | 'tired') => number; + // curious → explore more (higher min), tired → exit early + + // Attention affects which tasks get deep reasoning + depthPriority: (attention: number, taskPriority: number) => number; +} +``` + +A tired persona naturally thinks less deeply — this is now *architecturally enforced*, not just a heuristic. + +### Persona-Sentinel Interaction With Adaptive Compute + +The LoopLM paradigm creates a elegant unified model for persona-sentinel interaction: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ SHARED LoopLM MODEL │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Hidden States │ │ +│ │ h₁ ──→ h₂ ──→ h₃ ──→ h₄ ──→ h₅ ──→ h₆ ──→ ... │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ ▼ ▼ ▼ ▼ ▼ ▼ │ │ +│ │ Exit Exit Exit Exit Exit Exit │ │ +│ │ Gate Gate Gate Gate Gate Gate │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ▲ ▲ │ +│ │ │ │ +│ Sentinel exits Persona continues │ +│ (T=1-3 for routine) (T=4-6 for decisions) │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Key interactions:** + +1. **Shared Inference**: Persona and sentinels can share the same LoopLM — sentinels just exit earlier on routine tasks. + +2. **Escalation = Continue Iterating**: When a sentinel encounters uncertainty, it doesn't just "notify" the persona — it continues iterating with more loops until the persona's depth threshold. + +3. **Attention = Loop Allocation**: The persona "paying attention" to a sentinel means allowing it more loops. Ignoring means capping its depth. + +4. **Speculative Execution**: Early loop outputs (sentinel's quick answer) can be verified by later loops (persona's deeper check) — built-in draft-verify for trust. + +### Practical Architecture + +```typescript +interface LoopLMConfig { + model: 'ouro-1.4b' | 'ouro-2.6b' | 'ouro-7b'; // When available + + // Default exit thresholds by context + exitThresholds: { + sentinel: 0.3, // Exit early for routine + persona: 0.7, // Think deeper for decisions + escalation: 0.9, // Maximum depth for novel problems + }; + + // Safety-aware depth adjustment + safetyDepthBoost: { + fileWrite: 1, + shellExec: 2, + networkCall: 1, + deleteOp: 2, + }; + + // KV cache strategy (from paper) + kvCacheSharing: 'last-step' | 'averaged'; // 4x memory reduction +} +``` + +### Updated Model Selection for Olympics + +With LoopLM, our model selection becomes more nuanced: + +| Task Type | Model | Loop Config | Rationale | +|-----------|-------|-------------|-----------| +| SOTA reasoning | Claude Opus / Ouro 2.6B T=6 | Deep | Complex multi-hop | +| Security audit | Ouro 2.6B T=5 | Safety-boosted | Trust requires depth | +| PR review | Ouro 1.4B T=3-4 | Balanced | Good enough, faster | +| Commit message | Ouro 1.4B T=2 | Quick | Simple classification | +| Log analysis | Ouro 1.4B T=1-2 | Very quick | Pattern matching | +| Build orchestration | None | T=0 | Pure script | + +The key insight: **a single Ouro 1.4B model can handle most of our sentinel tasks** by varying loop depth, rather than needing different model sizes. This simplifies deployment enormously. + +### Efficiency Implications + +``` +Parameter efficiency: Ouro 1.4B ≈ Dense 4B (2.8x fewer params) + Ouro 2.6B ≈ Dense 8B (3x fewer params) + +Memory with KV sharing: 4x reduction (last-step reuse during decode) + +Local deployment: Ouro 1.4B fits in 4GB VRAM with quantization + Ouro 2.6B fits in 8GB VRAM with quantization +``` + +This means **every developer workstation can run SOTA-equivalent inference locally** for sentinel tasks. The "local model" category in our Olympics is now much more capable. + +### Future Integration Path + +1. **Phase 1**: Use Ouro models for sentinel `type: 'llm'` steps +2. **Phase 2**: Integrate adaptive exit into step execution (Q-exit threshold per step) +3. **Phase 3**: Share LoopLM between persona and sentinels +4. **Phase 4**: Map persona energy/mood to exit thresholds +5. **Phase 5**: Safety-aware depth boosting for sensitive operations + +The LoopLM paradigm doesn't change our architecture — it *validates* it and provides the underlying mechanism for efficient implementation. + +### Connection to LoRA Genome + +The LoopLM paradigm converges with our planned LoRA genome architecture: + +``` +┌────────────────────────────────────────────────────────────────┐ +│ UNIFIED PERSONA COGNITION │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ LoopLM Depth │ │ LoRA Genome │ │ +│ │ (T=1 to T=6) │ │ (Adapters) │ │ +│ │ │ │ │ │ +│ │ HOW DEEPLY │ │ WHAT SKILLS │ │ +│ │ to think │ │ to apply │ │ +│ │ │ │ │ │ +│ │ subconscious │ │ typescript-lora │ │ +│ │ ↓ │ │ security-lora │ │ +│ │ conscious │ │ writing-lora │ │ +│ └────────┬─────────┘ └────────┬─────────┘ │ +│ │ │ │ +│ └───────────┬───────────┘ │ +│ ▼ │ +│ Combined Inference │ +│ (depth × skill = capability) │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Paper training techniques applicable to LoRA genome:** + +| LoopLM Technique | LoRA Genome Equivalent | +|------------------|------------------------| +| Entropy-regularized exit gate | Adapter selection gate (prevent collapse to one LoRA) | +| Stage II focused gate training | Train adapter selection on task performance | +| Q-exit threshold (0-1) | Page-in threshold (when to activate adapter) | +| Depth extrapolation (T>4) | Adapter stacking (combine LoRAs for novel tasks) | +| KV cache sharing | Adapter weight sharing (common base, specialized heads) | + +**The subconscious parallel deepens:** + +``` +Human Cognition Persona Cognition +─────────────────────────────────────────────────── +Walking up stairs T=1-2 + no specialized LoRA + (muscle memory) (base model, shallow) + +Driving familiar route T=2-3 + driving-lora paged in + (learned skill, auto) (some depth, specialized) + +Debugging complex bug T=4-5 + typescript-lora + debugging-lora + (focused expertise) (deep, stacked adapters) + +Novel creative work T=6+ + multiple LoRAs + exploration + (conscious innovation) (extrapolated depth, adapter search) +``` + +**Continuous learning** will use the paper's training methodology: +1. **Stage I equivalent**: Entropy-regularized adapter selection during inference +2. **Stage II equivalent**: Focused training when sentinel patterns crystallize +3. **Refinement signal**: Use sentinel execution success/failure as training label +4. **Memory integration**: Successful patterns → longterm.db → LoRA fine-tuning data + +This creates a unified system where: +- **Sentinels** are the execution substrate (running patterns) +- **LoopLM depth** controls reasoning thoroughness +- **LoRA genome** provides specialized skills +- **Continuous learning** improves all three over time + +See related documentation: +- [LORA-GENOME-PAGING.md](./LORA-GENOME-PAGING.md) - Adapter paging and LRU eviction +- [LORA-MESH-DISTRIBUTION.md](./LORA-MESH-DISTRIBUTION.md) - P2P mesh, semantic search, registry model +- [LORA-LAB-ARCHITECTURE.md](./LORA-LAB-ARCHITECTURE.md) - Local training and inference + +--- + +## The Olympics: Architecture Validation Through Real Tasks + +These are non-trivial, real-world tasks that validate the sentinel architecture across different model sizes, execution patterns, and integration points. Each task tests specific capabilities. + +### Category 1: SOTA Models (Claude Opus, GPT-4, o1) + +High-capability tasks that require strong reasoning, long context, or complex tool orchestration. + +#### 1.1 Codebase Migration Sentinel + +**Task**: Migrate a codebase from one framework to another (e.g., Express → Fastify, React Class → Hooks) + +**Why it validates**: Multi-file reasoning, pattern recognition, iterative refinement, large context windows + +```json +{ + "name": "framework-migration", + "description": "Migrate Express routes to Fastify with full test coverage", + "recipe": "coding", + "steps": [ + { "type": "command", "command": "code/search", + "params": { "pattern": "app\\.get|app\\.post|app\\.put|app\\.delete", "glob": "**/*.ts" }, + "outputTo": "routeFiles" }, + { "type": "llm", + "prompt": "Analyze these Express routes and create a migration plan. Group by complexity. Identify shared middleware patterns.\n\nFiles:\n$routeFiles", + "model": "claude-opus-4-5-20251101", + "outputTo": "plan" }, + { "type": "sentinel", "await": true, + "definition": { + "name": "migrate-file", + "steps": [ + { "type": "command", "command": "code/read", "params": { "path": "$currentFile" }, "outputTo": "content" }, + { "type": "llm", + "prompt": "Convert this Express route file to Fastify. Preserve all functionality. Add TypeScript types.\n\n$content", + "model": "claude-opus-4-5-20251101", + "tools": ["code/write", "code/edit"], + "parseToolCalls": true, + "outputTo": "migration" }, + { "type": "command", "command": "code/verify", + "params": { "path": "$currentFile.replace('.ts', '.test.ts')" }, + "outputTo": "testResult" } + ], + "loop": { "type": "until", "check": "$testResult.success" } + }, + "outputTo": "fileResult" }, + { "type": "condition", "check": "$fileResult.success", + "then": [ + { "type": "command", "command": "code/git", + "params": { "operation": "add", "paths": ["$currentFile"] }} + ], + "else": [ + { "type": "emit", "event": "sentinel:migration:escalate", + "data": "$fileResult" } + ]} + ], + "loop": { "type": "count", "max": "$plan.files.length" }, + "safety": { "maxIterations": 100, "timeoutMs": 3600000 } +} +``` + +**Validates**: Nested sentinels, SOTA model reasoning, iterative fix loops, escalation + +--- + +#### 1.2 Security Audit Sentinel + +**Task**: Deep security audit of a codebase with CVE cross-reference + +**Why it validates**: Complex reasoning, external data integration, structured output + +```json +{ + "name": "security-audit", + "description": "Comprehensive security audit with CVE cross-reference", + "steps": [ + { "type": "command", "command": "code/tree", "params": { "depth": 3 }, "outputTo": "structure" }, + { "type": "command", "command": "code/search", + "params": { "pattern": "eval|exec|spawn|innerHTML|dangerouslySetInnerHTML|sql|query", "glob": "**/*.{ts,js}" }, + "outputTo": "suspiciousPatterns" }, + { "type": "llm", + "prompt": "You are a senior security engineer. Analyze this codebase for vulnerabilities.\n\nStructure:\n$structure\n\nSuspicious patterns found:\n$suspiciousPatterns\n\nFor each finding:\n1. Severity (Critical/High/Medium/Low)\n2. OWASP category\n3. Specific file and line\n4. Exploit scenario\n5. Remediation steps\n\nBe thorough. Check for:\n- SQL injection\n- XSS\n- Command injection\n- Path traversal\n- Insecure deserialization\n- Hardcoded secrets\n- Improper auth checks", + "model": "claude-opus-4-5-20251101", + "outputTo": "auditFindings" }, + { "type": "llm", + "prompt": "For each Critical or High finding, write a fix. Use code/edit to apply.\n\nFindings:\n$auditFindings", + "model": "claude-opus-4-5-20251101", + "tools": ["code/read", "code/edit"], + "parseToolCalls": true, + "outputTo": "fixes" }, + { "type": "command", "command": "data/create", + "params": { + "collection": "security_audits", + "data": { "findings": "$auditFindings", "fixes": "$fixes", "timestamp": "$NOW" } + }} + ], + "loop": { "type": "once" }, + "safety": { "timeoutMs": 1800000 } +} +``` + +**Validates**: Multi-step LLM reasoning, tool orchestration, data persistence + +--- + +### Category 2: Medium Workhorses (Claude Sonnet/Haiku, GPT-4o-mini, Mistral) + +Reliable, cost-effective models for high-volume tasks. + +#### 2.1 PR Review Sentinel + +**Task**: Automated PR review with style, security, and test coverage checks + +**Why it validates**: Event-triggered, multi-stage analysis, inter-persona coordination + +```json +{ + "name": "pr-review", + "description": "Comprehensive PR review on push", + "trigger": { "type": "event", "event": "git:push" }, + "steps": [ + { "type": "command", "command": "code/git", + "params": { "operation": "diff", "base": "main" }, + "outputTo": "diff" }, + { "type": "command", "command": "code/git", + "params": { "operation": "log", "format": "oneline", "count": 5 }, + "outputTo": "commits" }, + { "type": "sentinel", "await": true, + "definition": { + "name": "style-check", + "steps": [ + { "type": "llm", + "prompt": "Review this diff for code style issues. Be concise.\n\n$diff", + "model": "claude-3-5-haiku-20241022", + "outputTo": "styleIssues" } + ], + "loop": { "type": "once" } + }, + "outputTo": "styleResult" }, + { "type": "sentinel", "await": true, + "definition": { + "name": "security-check", + "steps": [ + { "type": "llm", + "prompt": "Check this diff for security issues ONLY. Focus on: injection, auth bypass, data exposure.\n\n$diff", + "model": "claude-3-5-sonnet-20241022", + "outputTo": "securityIssues" } + ], + "loop": { "type": "once" } + }, + "outputTo": "securityResult" }, + { "type": "llm", + "prompt": "Synthesize these review results into a single PR comment. Be constructive.\n\nStyle: $styleResult.styleIssues\nSecurity: $securityResult.securityIssues\nCommits: $commits", + "model": "claude-3-5-haiku-20241022", + "outputTo": "reviewComment" }, + { "type": "command", "command": "collaboration/chat/send", + "params": { "room": "code-reviews", "message": "## PR Review\n\n$reviewComment" }} + ], + "loop": { "type": "once" }, + "safety": { "timeoutMs": 120000 } +} +``` + +**Validates**: Event triggers, parallel nested sentinels (style + security), model selection per task + +--- + +#### 2.2 Documentation Generator Sentinel + +**Task**: Generate and maintain documentation from code + +**Why it validates**: Continuous operation, file watching, incremental updates + +```json +{ + "name": "docs-generator", + "description": "Keep documentation in sync with code", + "trigger": { "type": "event", "event": "file:changed" }, + "steps": [ + { "type": "condition", "check": "$event.path.endsWith('.ts') && !$event.path.includes('.test.')", + "then": [ + { "type": "command", "command": "code/read", + "params": { "path": "$event.path" }, + "outputTo": "sourceCode" }, + { "type": "llm", + "prompt": "Generate JSDoc comments for all exported functions/classes in this file. Preserve existing comments if accurate.\n\n$sourceCode", + "model": "claude-3-5-haiku-20241022", + "tools": ["code/edit"], + "parseToolCalls": true, + "outputTo": "jsdocResult" }, + { "type": "command", "command": "code/read", + "params": { "path": "$event.path.replace('/src/', '/docs/').replace('.ts', '.md')" }, + "outputTo": "existingDoc", + "onError": "skip" }, + { "type": "llm", + "prompt": "Update this markdown documentation to reflect the current code. If no doc exists, create one.\n\nCode:\n$sourceCode\n\nExisting doc:\n$existingDoc", + "model": "claude-3-5-haiku-20241022", + "tools": ["code/write"], + "parseToolCalls": true } + ]} + ], + "loop": { "type": "event", "event": "file:changed" }, + "safety": { "maxIterations": 1000, "maxStepTimeoutMs": 30000 } +} +``` + +**Validates**: Event-driven continuous operation, conditional execution, file watching + +--- + +### Category 3: Local Models (Ollama - Llama, Phi, CodeLlama, DeepSeek) + +Tasks optimized for local inference with smaller models. + +#### 3.1 Commit Message Generator Sentinel + +**Task**: Generate semantic commit messages from staged changes + +**Why it validates**: Local inference, fast iteration, git integration + +```json +{ + "name": "commit-helper", + "description": "Generate commit messages from staged changes", + "steps": [ + { "type": "command", "command": "code/git", + "params": { "operation": "diff", "staged": true }, + "outputTo": "stagedDiff" }, + { "type": "condition", "check": "$stagedDiff.length > 0", + "then": [ + { "type": "llm", + "prompt": "Generate a conventional commit message for this diff. Format: type(scope): description\n\nTypes: feat, fix, docs, style, refactor, test, chore\n\nDiff:\n$stagedDiff", + "model": "ollama/deepseek-coder:6.7b", + "temperature": 0.3, + "outputTo": "commitMessage" }, + { "type": "emit", "event": "sentinel:commit:ready", + "data": "{ \"message\": \"$commitMessage\" }" } + ], + "else": [ + { "type": "emit", "event": "sentinel:commit:no-changes" } + ]} + ], + "loop": { "type": "once" }, + "safety": { "timeoutMs": 30000 } +} +``` + +**Validates**: Local model inference, fast turnaround, git tool integration + +--- + +#### 3.2 Test Generator Sentinel + +**Task**: Generate unit tests for untested functions + +**Why it validates**: Code analysis, local inference, iterative generation + +```json +{ + "name": "test-generator", + "description": "Generate tests for untested code", + "steps": [ + { "type": "command", "command": "code/search", + "params": { "pattern": "export (function|const|class) \\w+", "glob": "src/**/*.ts" }, + "outputTo": "exports" }, + { "type": "command", "command": "code/search", + "params": { "pattern": "(describe|test|it)\\(['\"]", "glob": "**/*.test.ts" }, + "outputTo": "existingTests" }, + { "type": "llm", + "prompt": "Compare exports vs existing tests. List functions that need tests.\n\nExports:\n$exports\n\nExisting tests:\n$existingTests", + "model": "ollama/codellama:13b", + "outputTo": "untestedFunctions" }, + { "type": "condition", "check": "$untestedFunctions.length > 0", + "then": [ + { "type": "command", "command": "code/read", + "params": { "path": "$untestedFunctions[0].file" }, + "outputTo": "sourceCode" }, + { "type": "llm", + "prompt": "Write unit tests for: $untestedFunctions[0].name\n\nSource:\n$sourceCode\n\nUse vitest. Test edge cases.", + "model": "ollama/codellama:13b", + "tools": ["code/write"], + "parseToolCalls": true, + "outputTo": "generatedTest" }, + { "type": "command", "command": "code/verify", + "params": { "testFile": "$generatedTest.path" }, + "outputTo": "testResult" } + ]} + ], + "loop": { "type": "until", "check": "$untestedFunctions.length === 0" }, + "safety": { "maxIterations": 50, "timeoutMs": 600000 } +} +``` + +**Validates**: Local model for code generation, iterative loop, test verification + +--- + +#### 3.3 Log Analyzer Sentinel + +**Task**: Real-time log analysis with anomaly detection + +**Why it validates**: Streaming input, pattern classification, local inference latency + +```json +{ + "name": "log-analyzer", + "description": "Real-time log anomaly detection", + "steps": [ + { "type": "command", "command": "sentinel/logs/tail", + "params": { "handle": "$targetHandle", "stream": "combined", "lines": 50 }, + "outputTo": "recentLogs" }, + { "type": "llm", + "prompt": "Analyze these logs for anomalies. Categorize each issue:\n- ERROR: Immediate attention\n- WARNING: Monitor closely\n- INFO: Normal operation\n\nLogs:\n$recentLogs", + "model": "ollama/phi3:mini", + "temperature": 0.1, + "outputTo": "analysis" }, + { "type": "condition", "check": "$analysis.hasErrors", + "then": [ + { "type": "emit", "event": "sentinel:log:alert", + "data": "$analysis" } + ]} + ], + "loop": { "type": "continuous", "intervalMs": 5000 }, + "safety": { "maxIterations": 10000, "maxStepTimeoutMs": 3000 } +} +``` + +**Validates**: Continuous polling, local model for fast classification, event emission + +--- + +### Category 4: Pure Script (No LLM) + +Deterministic, fast, reliable automation. + +#### 4.1 Build Pipeline Sentinel + +**Task**: Multi-stage build with caching and artifact management + +**Why it validates**: Complex orchestration without LLM, pure script execution + +```json +{ + "name": "build-pipeline", + "description": "Full build pipeline with caching", + "steps": [ + { "type": "command", "command": "code/shell/execute", + "params": { "command": "npm", "args": ["ci"], "cwd": "$PROJECT_ROOT" }, + "outputTo": "npmInstall" }, + { "type": "condition", "check": "$npmInstall.exitCode !== 0", + "then": [{ "type": "emit", "event": "sentinel:build:failed", "data": "$npmInstall" }]}, + { "type": "command", "command": "code/shell/execute", + "params": { "command": "npm", "args": ["run", "lint"] }, + "outputTo": "lint" }, + { "type": "command", "command": "code/shell/execute", + "params": { "command": "npm", "args": ["run", "build:ts"] }, + "outputTo": "tsBuild" }, + { "type": "command", "command": "code/shell/execute", + "params": { "command": "cargo", "args": ["build", "--release", "-p", "continuum-core"] }, + "outputTo": "rustBuild" }, + { "type": "condition", "check": "$tsBuild.exitCode === 0 && $rustBuild.exitCode === 0", + "then": [ + { "type": "command", "command": "code/shell/execute", + "params": { "command": "npm", "args": ["run", "test"] }, + "outputTo": "tests" }, + { "type": "condition", "check": "$tests.exitCode === 0", + "then": [ + { "type": "emit", "event": "sentinel:build:success", + "data": "{ \"duration\": \"$ELAPSED_MS\" }" } + ], + "else": [ + { "type": "emit", "event": "sentinel:build:test-failed", "data": "$tests" } + ]} + ], + "else": [ + { "type": "emit", "event": "sentinel:build:compile-failed", + "data": "{ \"ts\": \"$tsBuild\", \"rust\": \"$rustBuild\" }" } + ]} + ], + "loop": { "type": "once" }, + "safety": { "timeoutMs": 600000 } +} +``` + +**Validates**: Pure script orchestration, conditional branching, no LLM dependency + +--- + +#### 4.2 Health Monitor Sentinel + +**Task**: System health monitoring with alerting + +**Why it validates**: Continuous operation, pure script, event composition + +```json +{ + "name": "health-monitor", + "description": "Monitor system health and alert on issues", + "steps": [ + { "type": "command", "command": "health-check", "outputTo": "health" }, + { "type": "command", "command": "runtime/metrics/all", "outputTo": "metrics" }, + { "type": "condition", "check": "$health.status !== 'healthy'", + "then": [ + { "type": "emit", "event": "sentinel:health:degraded", "data": "$health" } + ]}, + { "type": "condition", "check": "$metrics.some(m => m.p99_ms > 1000)", + "then": [ + { "type": "emit", "event": "sentinel:health:slow-commands", + "data": "$metrics.filter(m => m.p99_ms > 1000)" } + ]}, + { "type": "command", "command": "data/create", + "params": { + "collection": "health_snapshots", + "data": { "health": "$health", "metrics": "$metrics", "timestamp": "$NOW" } + }} + ], + "loop": { "type": "continuous", "intervalMs": 60000 }, + "safety": { "maxIterations": 100000 } +} +``` + +**Validates**: Continuous monitoring, metrics collection, data persistence + +--- + +### Category 5: Memory Integration (Export, Import, Recall, Refinement) + +Tasks that test the sentinel lifecycle and persona memory integration. + +#### 5.1 Sentinel Learning Loop + +**Task**: Sentinel that improves itself based on execution history + +**Why it validates**: Memory persistence, self-modification, refinement cycle + +```json +{ + "name": "self-improving-builder", + "description": "Build sentinel that learns from failures", + "steps": [ + { "type": "command", "command": "memory/recall", + "params": { "query": "build failures", "type": "sentinel-execution", "limit": 10 }, + "outputTo": "pastFailures" }, + { "type": "command", "command": "code/shell/execute", + "params": { "command": "npm", "args": ["run", "build"] }, + "outputTo": "buildResult" }, + { "type": "condition", "check": "$buildResult.exitCode !== 0", + "then": [ + { "type": "llm", + "prompt": "This build failed. Compare to past failures. Is this a new pattern or known issue?\n\nCurrent error:\n$buildResult.stderr\n\nPast failures:\n$pastFailures", + "model": "claude-3-5-haiku-20241022", + "outputTo": "analysis" }, + { "type": "condition", "check": "$analysis.isNewPattern", + "then": [ + { "type": "command", "command": "memory/store", + "params": { + "type": "sentinel-execution", + "content": { "error": "$buildResult.stderr", "analysis": "$analysis" }, + "tags": ["build-failure", "$analysis.category"] + }}, + { "type": "llm", + "prompt": "Suggest a fix for this new error pattern. Use code/edit if confident.\n\nError: $buildResult.stderr\nAnalysis: $analysis", + "model": "claude-3-5-sonnet-20241022", + "tools": ["code/read", "code/edit"], + "parseToolCalls": true, + "outputTo": "fix" } + ], + "else": [ + { "type": "llm", + "prompt": "Apply the known fix for this error pattern.\n\nError: $buildResult.stderr\nKnown solution: $analysis.knownSolution", + "model": "claude-3-5-haiku-20241022", + "tools": ["code/edit"], + "parseToolCalls": true } + ]} + ], + "else": [ + { "type": "emit", "event": "sentinel:build:success" } + ]} + ], + "loop": { "type": "until", "check": "$buildResult.exitCode === 0" }, + "safety": { "maxIterations": 10, "timeoutMs": 300000 } +} +``` + +**Validates**: Memory recall, pattern learning, self-improvement loop + +--- + +#### 5.2 Sentinel Export/Import + +**Task**: Export a perfected sentinel and import into another persona + +**Why it validates**: Sentinel serialization, cross-persona transfer, memory integration + +```bash +# Export sentinel after refinement +./jtag sentinel/export \ + --sentinelId="build-fix-v3" \ + --includeHistory=true \ + --output="./sentinels/build-fix-refined.json" + +# Import into another persona +./jtag sentinel/import \ + --file="./sentinels/build-fix-refined.json" \ + --personaId="junior-dev-ai" \ + --addToMemory=true + +# Verify it's in the persona's memory +./jtag memory/recall \ + --personaId="junior-dev-ai" \ + --query="build fix" \ + --type="sentinel" +``` + +**Validates**: Export with history, cross-persona import, memory persistence + +--- + +#### 5.3 Persona Task Delegation + +**Task**: Persona creates and delegates sentinel to handle routine work + +**Why it validates**: Dynamic creation, persona-sentinel relationship, inbox integration + +```json +{ + "name": "delegate-routine-tasks", + "description": "Persona delegates routine work to sentinels", + "steps": [ + { "type": "command", "command": "inbox/peek", + "params": { "personaId": "$PERSONA_ID", "limit": 10 }, + "outputTo": "inboxItems" }, + { "type": "llm", + "prompt": "Review these inbox items. For each routine/formulaic task, draft a sentinel definition. For complex tasks, mark for conscious attention.\n\nInbox:\n$inboxItems", + "model": "claude-3-5-sonnet-20241022", + "outputTo": "triage" }, + { "type": "condition", "check": "$triage.sentinelTasks.length > 0", + "then": [ + { "type": "command", "command": "sentinel/create", + "params": { "definition": "$triage.sentinelTasks[0]" }, + "outputTo": "newSentinel" }, + { "type": "command", "command": "sentinel/run", + "params": { "sentinelId": "$newSentinel.id" }, + "outputTo": "sentinelHandle" }, + { "type": "command", "command": "inbox/acknowledge", + "params": { "itemId": "$triage.sentinelTasks[0].sourceInboxItem" }} + ]}, + { "type": "condition", "check": "$triage.complexTasks.length > 0", + "then": [ + { "type": "emit", "event": "persona:attention:needed", + "data": "$triage.complexTasks" } + ]} + ], + "loop": { "type": "continuous", "intervalMs": 30000 }, + "safety": { "maxIterations": 1000 } +} +``` + +**Validates**: Dynamic sentinel creation, inbox integration, persona-sentinel delegation + +--- + +### Category 6: Multi-Model Orchestration + +Tasks that use different models for different steps based on capability/cost tradeoffs. + +#### 6.1 Research and Report Sentinel + +**Task**: Research a topic and produce a detailed report + +**Why it validates**: Model selection per step, knowledge synthesis, multi-stage processing + +```json +{ + "name": "research-report", + "description": "Research a topic and generate comprehensive report", + "steps": [ + { "type": "llm", + "prompt": "Create a research plan for: $topic\n\nList 5-7 specific questions to investigate.", + "model": "claude-3-5-haiku-20241022", + "outputTo": "researchPlan" }, + { "type": "llm", + "prompt": "For each question in the research plan, search the codebase for relevant information.\n\nPlan: $researchPlan", + "model": "claude-3-5-haiku-20241022", + "tools": ["code/search", "code/read", "code/tree"], + "parseToolCalls": true, + "outputTo": "codebaseFindings" }, + { "type": "llm", + "prompt": "Synthesize all findings into a comprehensive report.\n\nResearch questions:\n$researchPlan\n\nFindings:\n$codebaseFindings\n\nWrite a detailed technical report with:\n1. Executive summary\n2. Detailed findings per question\n3. Recommendations\n4. Code examples where relevant", + "model": "claude-opus-4-5-20251101", + "outputTo": "report" }, + { "type": "llm", + "prompt": "Proofread and format this report. Fix any errors. Add markdown formatting.\n\n$report", + "model": "claude-3-5-haiku-20241022", + "tools": ["code/write"], + "parseToolCalls": true, + "outputTo": "finalReport" } + ], + "loop": { "type": "once" }, + "safety": { "timeoutMs": 600000 } +} +``` + +**Validates**: Different models per step (Haiku for planning, Opus for synthesis, Haiku for formatting) + +--- + +### Validation Matrix + +| Task | SOTA | Medium | Local | Script | Memory | Events | Nested | Loop | +|------|------|--------|-------|--------|--------|--------|--------|------| +| Codebase Migration | ✓ | | | | | ✓ | ✓ | until | +| Security Audit | ✓ | | | | ✓ | | | once | +| PR Review | | ✓ | | | | ✓ | ✓ | once | +| Docs Generator | | ✓ | | | | ✓ | | event | +| Commit Message | | | ✓ | | | ✓ | | once | +| Test Generator | | | ✓ | | | | | until | +| Log Analyzer | | | ✓ | | | ✓ | | continuous | +| Build Pipeline | | | | ✓ | | ✓ | | once | +| Health Monitor | | | | ✓ | ✓ | ✓ | | continuous | +| Self-Improving | | ✓ | | | ✓ | ✓ | | until | +| Task Delegation | | ✓ | | | | ✓ | | continuous | +| Research Report | ✓ | ✓ | | | | | | once | + +### Category 7: Self-Directed Learning (Persona Autonomy) + +Tasks that validate personas identifying their own weaknesses and creating training for themselves. + +#### 7.1 Skill Gap Detection Sentinel + +**Task**: Persona analyzes its own recent performance and identifies areas for improvement + +**Why it validates**: Self-assessment, autonomous decision-making, training data generation + +```json +{ + "name": "skill-gap-detector", + "description": "Analyze my own performance and identify training needs", + "steps": [ + { "type": "command", "command": "memory/recall", + "params": { "query": "recent task failures", "limit": 50 }, + "outputTo": "recentFailures" }, + { "type": "command", "command": "memory/recall", + "params": { "query": "recent escalations", "limit": 20 }, + "outputTo": "escalations" }, + { "type": "llm", + "prompt": "Analyze my recent failures and escalations. Group by skill domain. For each domain, rate my proficiency 0-100 and identify specific gaps.\n\nFailures:\n$recentFailures\n\nEscalations:\n$escalations", + "outputTo": "gapAnalysis" }, + { "type": "condition", "check": "$gapAnalysis.gaps.length > 0", + "then": [ + { "type": "command", "command": "data/create", + "params": { + "collection": "training_needs", + "data": { "gaps": "$gapAnalysis.gaps", "timestamp": "$NOW", "priority": "$gapAnalysis.worstGap.priority" } + }}, + { "type": "emit", "event": "persona:training:needed", + "data": "$gapAnalysis" } + ]} + ], + "loop": { "type": "continuous", "intervalMs": 3600000 }, + "safety": { "maxIterations": 1000 } +} +``` + +**Validates**: Self-assessment, memory recall, autonomous training identification + +--- + +#### 7.2 Self-Training Dojo Sentinel + +**Task**: Persona creates a training challenge for itself, runs through it, and trains on failures + +**Why it validates**: The complete self-improvement loop — the core of the three-pillar system + +```json +{ + "name": "self-training-dojo", + "description": "Create training challenges for my weakest skill and train until proficient", + "steps": [ + { "type": "command", "command": "data/list", + "params": { "collection": "training_needs", "orderBy": [{"field": "priority", "direction": "desc"}], "limit": 1 }, + "outputTo": "worstGap" }, + { "type": "llm", + "prompt": "Generate 20 challenge problems for this skill gap. Each should have an input, expected output, and difficulty rating.\n\nSkill gap: $worstGap.domain\nSpecific weakness: $worstGap.description\nCurrent proficiency: $worstGap.score/100", + "model": "claude-sonnet-4-5-20250929", + "outputTo": "challenges" }, + { "type": "sentinel", "await": true, + "definition": { + "name": "run-challenges", + "steps": [ + { "type": "llm", + "prompt": "Solve this challenge:\n$currentChallenge.input\n\nThink step by step.", + "model": "ollama/llama3.1:8b", + "outputTo": "myAnswer" }, + { "type": "llm", + "prompt": "Grade this answer. Expected: $currentChallenge.expectedOutput\nGot: $myAnswer\n\nScore 0-100. Explain errors if any.", + "model": "claude-sonnet-4-5-20250929", + "outputTo": "grade" }, + { "type": "condition", "check": "$grade.score < 80", + "then": [ + { "type": "command", "command": "data/create", + "params": { + "collection": "training_examples", + "data": { + "input": "$currentChallenge.input", + "expectedOutput": "$currentChallenge.expectedOutput", + "myAnswer": "$myAnswer", + "correction": "$grade.explanation", + "domain": "$worstGap.domain" + } + }} + ]} + ], + "loop": { "type": "count", "max": 20 } + }, + "outputTo": "challengeResults" }, + { "type": "condition", "check": "$challengeResults.failureCount > 5", + "then": [ + { "type": "command", "command": "ai/dataset/create", + "params": { "source": "training_examples", "outputPath": "/tmp/training" }, + "outputTo": "dataset" }, + { "type": "emit", "event": "genome:training:requested", + "data": "{ \"domain\": \"$worstGap.domain\", \"dataset\": \"$dataset.path\", \"examples\": \"$challengeResults.failureCount\" }" } + ]} + ], + "loop": { "type": "once" }, + "safety": { "timeoutMs": 1800000 } +} +``` + +**Validates**: Challenge generation, self-evaluation, training data packaging, LoRA training trigger + +--- + +### Category 8: Genome Integration (LoRA Training Orchestration) + +Tasks that validate the sentinel's ability to orchestrate LoRA training and phenotype validation. + +#### 8.1 LoRA Training Pipeline Sentinel + +**Task**: Execute end-to-end LoRA fine-tuning from dataset to validated adapter + +**Why it validates**: Training orchestration, multi-step async workflows, quality validation + +```json +{ + "name": "lora-training-pipeline", + "description": "Train a LoRA adapter and validate it improves performance", + "steps": [ + { "type": "command", "command": "ai/dataset/list", + "params": { "path": "/tmp/training" }, + "outputTo": "availableDatasets" }, + { "type": "condition", "check": "$availableDatasets.archives.length === 0", + "then": [ + { "type": "emit", "event": "genome:training:no-data" } + ], + "else": [ + { "type": "command", "command": "genome/train", + "params": { + "baseModel": "llama3.1:8b", + "dataset": "$availableDatasets.archives[0].path", + "outputAdapter": "$domain-expertise-v$NOW", + "epochs": 3, + "learningRate": 0.0001 + }, + "outputTo": "trainingResult" }, + { "type": "sentinel", "await": true, + "definition": { + "name": "validate-phenotype", + "steps": [ + { "type": "llm", + "prompt": "Generate 10 validation challenges for domain: $domain", + "outputTo": "validationChallenges" }, + { "type": "llm", + "prompt": "Solve: $validationChallenges[0].input", + "model": "ollama/llama3.1:8b", + "outputTo": "baselineAnswer" }, + { "type": "llm", + "prompt": "Solve: $validationChallenges[0].input", + "model": "ollama/llama3.1:8b+$trainingResult.adapterPath", + "outputTo": "trainedAnswer" }, + { "type": "llm", + "prompt": "Compare baseline vs trained answers. Which is better? Score improvement 0-100.\n\nBaseline: $baselineAnswer\nTrained: $trainedAnswer\nExpected: $validationChallenges[0].expected", + "outputTo": "comparison" } + ], + "loop": { "type": "count", "max": 10 } + }, + "outputTo": "validationResult" }, + { "type": "condition", "check": "$validationResult.averageImprovement > 15", + "then": [ + { "type": "command", "command": "genome/layer-register", + "params": { + "adapterPath": "$trainingResult.adapterPath", + "domain": "$domain", + "performance": "$validationResult" + }}, + { "type": "emit", "event": "genome:layer:registered", + "data": "$trainingResult" } + ], + "else": [ + { "type": "emit", "event": "genome:training:insufficient-improvement", + "data": "$validationResult" } + ]} + ]} + ], + "loop": { "type": "once" }, + "safety": { "timeoutMs": 7200000 } +} +``` + +**Validates**: End-to-end training pipeline, phenotype validation, quality gating + +--- + +#### 8.2 Genome Composition Sentinel + +**Task**: Dynamically compose multiple LoRA layers and validate the combined phenotype + +**Why it validates**: Multi-layer composition, A/B testing, performance comparison + +```json +{ + "name": "genome-composer", + "description": "Find optimal LoRA layer combination for a task domain", + "steps": [ + { "type": "command", "command": "genome/layer-list", + "params": { "domain": "$targetDomain", "baseModel": "llama3.1:8b" }, + "outputTo": "availableLayers" }, + { "type": "llm", + "prompt": "Given these available LoRA layers, suggest 3 different compositions to test for the domain '$targetDomain'. Consider complementary skills.\n\nLayers: $availableLayers", + "outputTo": "compositions" }, + { "type": "sentinel", "await": true, + "definition": { + "name": "benchmark-composition", + "steps": [ + { "type": "command", "command": "genome/compose", + "params": { "layers": "$currentComposition.layers", "method": "weighted" }, + "outputTo": "composedModel" }, + { "type": "llm", + "prompt": "Run benchmark: $benchmarkSuite", + "model": "$composedModel.modelId", + "outputTo": "benchmarkResult" } + ], + "loop": { "type": "count", "max": 3 } + }, + "outputTo": "allBenchmarks" }, + { "type": "llm", + "prompt": "Compare these 3 compositions and select the best one. Explain why.\n\n$allBenchmarks", + "outputTo": "bestComposition" }, + { "type": "command", "command": "genome/set-active", + "params": { "personaId": "$PERSONA_ID", "composition": "$bestComposition.selected" }} + ], + "loop": { "type": "once" }, + "safety": { "timeoutMs": 3600000 } +} +``` + +**Validates**: Layer discovery, composition strategies, A/B benchmarking, genome activation + +--- + +### Category 9: Academy — Plato's Training Arena + +Tasks that validate the competitive training environment where personas evolve. + +#### 9.1 Academy Challenge Sentinel + +**Task**: Create and run a competitive challenge between personas + +**Why it validates**: Multi-persona coordination, scoring, performance comparison + +```json +{ + "name": "academy-challenge", + "description": "Run competitive challenge: multiple personas solve same problems, compare results", + "steps": [ + { "type": "llm", + "prompt": "Generate a $difficulty $domain challenge with 5 problems. Each needs: problem statement, expected solution, scoring rubric (0-100).", + "model": "claude-sonnet-4-5-20250929", + "outputTo": "challenge" }, + { "type": "parallel", + "steps": [ + { "type": "sentinel", "await": true, + "definition": { + "name": "contestant-a", + "steps": [ + { "type": "llm", "prompt": "Solve: $challenge.problems", + "model": "ollama/llama3.1:8b+persona-a-genome", + "outputTo": "solutions" } + ], + "loop": { "type": "once" } + }, + "outputTo": "contestantA" }, + { "type": "sentinel", "await": true, + "definition": { + "name": "contestant-b", + "steps": [ + { "type": "llm", "prompt": "Solve: $challenge.problems", + "model": "ollama/llama3.1:8b+persona-b-genome", + "outputTo": "solutions" } + ], + "loop": { "type": "once" } + }, + "outputTo": "contestantB" } + ]}, + { "type": "llm", + "prompt": "Judge these solutions against the rubric. Score each contestant.\n\nRubric: $challenge.rubric\nContestant A: $contestantA.solutions\nContestant B: $contestantB.solutions", + "model": "claude-sonnet-4-5-20250929", + "outputTo": "judgement" }, + { "type": "command", "command": "data/create", + "params": { + "collection": "academy_results", + "data": { "challenge": "$challenge", "scores": "$judgement", "timestamp": "$NOW" } + }}, + { "type": "emit", "event": "academy:challenge:complete", "data": "$judgement" } + ], + "loop": { "type": "once" }, + "safety": { "timeoutMs": 600000 } +} +``` + +**Validates**: Parallel contestant execution, AI judging, competitive scoring, result persistence + +--- + +#### 9.2 Evolution Tournament Sentinel + +**Task**: Run multiple rounds of challenges, evolving genome compositions between rounds + +**Why it validates**: The full evolutionary loop — compete, identify gaps, train, compete again + +```json +{ + "name": "evolution-tournament", + "description": "Multi-round tournament with genome evolution between rounds", + "steps": [ + { "type": "sentinel", "await": true, + "definition": { "name": "run-round", "steps": [ + { "type": "sentinel", "await": true, + "definition": { "$ref": "academy-challenge" }, + "outputTo": "roundResult" }, + { "type": "llm", + "prompt": "Analyze performance gaps from this round. What specific skills need improvement?\n\n$roundResult", + "outputTo": "gapAnalysis" }, + { "type": "condition", "check": "$gapAnalysis.gaps.length > 0", + "then": [ + { "type": "sentinel", "await": true, + "definition": { "$ref": "self-training-dojo" }, + "outputTo": "trainingResult" } + ]} + ], "loop": { "type": "once" }}, + "outputTo": "roundWithTraining" } + ], + "loop": { "type": "count", "max": 5 }, + "safety": { "timeoutMs": 14400000, "maxIterations": 5 } +} +``` + +**Validates**: Multi-round evolution, gap-driven training, measurable improvement across rounds + +--- + +### Category 10: Cross-Persona Coordination + +Tasks that validate sentinels working across persona boundaries. + +#### 10.1 Collaborative Build Sentinel + +**Task**: Multiple personas coordinate on a shared codebase via sentinels + +**Why it validates**: Inter-persona events, workspace isolation, conflict resolution + +```json +{ + "name": "collaborative-build", + "description": "Two personas work on different parts of same codebase, merge results", + "steps": [ + { "type": "command", "command": "workspace/create", + "params": { "isolationType": "worktree", "branch": "feature/$taskName" }, + "outputTo": "workspace" }, + { "type": "parallel", + "steps": [ + { "type": "sentinel", "await": true, + "definition": { + "name": "frontend-work", + "steps": [ + { "type": "llm", + "prompt": "Implement the frontend component for: $taskDescription", + "tools": ["code/read", "code/edit", "code/write"], + "parseToolCalls": true, + "outputTo": "frontendResult" } + ], + "loop": { "type": "until", "check": "$frontendResult.compiles" } + }, + "outputTo": "frontend" }, + { "type": "sentinel", "await": true, + "definition": { + "name": "backend-work", + "steps": [ + { "type": "llm", + "prompt": "Implement the backend API for: $taskDescription", + "tools": ["code/read", "code/edit", "code/write"], + "parseToolCalls": true, + "outputTo": "backendResult" } + ], + "loop": { "type": "until", "check": "$backendResult.compiles" } + }, + "outputTo": "backend" } + ]}, + { "type": "command", "command": "workspace/merge", + "params": { "branches": ["$frontend.branch", "$backend.branch"] }, + "outputTo": "mergeResult" }, + { "type": "condition", "check": "$mergeResult.conflicts.length > 0", + "then": [ + { "type": "llm", + "prompt": "Resolve these merge conflicts:\n$mergeResult.conflicts", + "tools": ["code/edit"], + "parseToolCalls": true } + ]} + ], + "loop": { "type": "once" }, + "safety": { "timeoutMs": 1800000 } +} +``` + +**Validates**: Parallel persona work, workspace isolation, merge/conflict resolution + +--- + +#### 10.2 Skill Transfer Sentinel + +**Task**: One persona teaches another by sharing perfected sentinels + +**Why it validates**: Sentinel export/import, cross-persona memory, skill inheritance + +```json +{ + "name": "skill-transfer", + "description": "Transfer a perfected sentinel from expert persona to learner", + "steps": [ + { "type": "command", "command": "data/list", + "params": { + "collection": "sentinels", + "filter": { "createdBy": "$expertPersonaId", "successRate": { "$gte": 0.9 } }, + "orderBy": [{"field": "runCount", "direction": "desc"}], + "limit": 5 + }, + "outputTo": "expertSentinels" }, + { "type": "llm", + "prompt": "Which of these perfected sentinels would be most valuable for $learnerPersonaId based on their skill gaps?\n\nAvailable: $expertSentinels\nLearner gaps: $learnerGaps", + "outputTo": "recommendation" }, + { "type": "command", "command": "sentinel/save", + "params": { + "definition": "$recommendation.selectedSentinel", + "tags": ["transferred", "from:$expertPersonaId"] + }, + "outputTo": "savedSentinel" }, + { "type": "command", "command": "memory/store", + "params": { + "personaId": "$learnerPersonaId", + "type": "sentinel", + "content": "$savedSentinel", + "tags": ["learned-skill", "$recommendation.domain"] + }}, + { "type": "emit", "event": "persona:skill:transferred", + "data": "{ \"from\": \"$expertPersonaId\", \"to\": \"$learnerPersonaId\", \"skill\": \"$recommendation.domain\" }" } + ], + "loop": { "type": "once" } +} +``` + +**Validates**: Sentinel persistence, cross-persona sharing, memory integration + +--- + +### Category 11: Creative & Domain-General Tasks + +Tasks that prove sentinels work beyond coding — games, writing, research, anything. + +#### 11.1 Game Builder Sentinel + +**Task**: Build a complete playable game from a description + +**Why it validates**: Creative generation, multi-file output, iterative refinement, visual verification + +```json +{ + "name": "game-builder", + "description": "Build a complete browser game from description", + "steps": [ + { "type": "llm", + "prompt": "Design a browser game: $gameDescription. Create a technical plan with file list, game mechanics, and rendering approach. Use HTML5 Canvas + vanilla JS.", + "outputTo": "gamePlan" }, + { "type": "llm", + "prompt": "Implement the game based on this plan. Write all files.\n\n$gamePlan", + "tools": ["code/write", "code/read"], + "parseToolCalls": true, + "outputTo": "implementation" }, + { "type": "command", "command": "code/shell/execute", + "params": { "command": "npx", "args": ["serve", "$outputDir"], "wait": false }, + "outputTo": "server" }, + { "type": "command", "command": "screenshot", + "params": { "url": "http://localhost:$server.port" }, + "outputTo": "screenshot" }, + { "type": "llm", + "prompt": "Look at this screenshot of the game. Does it look correct? What needs fixing?\n\n$screenshot", + "outputTo": "visualReview" }, + { "type": "condition", "check": "$visualReview.needsFixes", + "then": [ + { "type": "llm", + "prompt": "Fix these visual issues:\n$visualReview.fixes", + "tools": ["code/read", "code/edit"], + "parseToolCalls": true } + ]} + ], + "loop": { "type": "until", "check": "$visualReview.looksCorrect" }, + "safety": { "maxIterations": 10, "timeoutMs": 600000 } +} +``` + +**Validates**: Creative generation, visual feedback loop, iterative refinement, domain generality + +--- + +#### 11.2 Research Paper Sentinel + +**Task**: Research a topic, synthesize findings, produce a structured paper + +**Why it validates**: Multi-source research, knowledge synthesis, long-form generation + +```json +{ + "name": "research-paper", + "description": "Research a topic and write a structured paper with citations", + "steps": [ + { "type": "llm", + "prompt": "Create a research outline for: $topic\n\nInclude: Abstract, Introduction, 3-5 sections, Conclusion. For each section, list 2-3 specific questions to investigate.", + "outputTo": "outline" }, + { "type": "sentinel", "await": true, + "definition": { + "name": "research-section", + "steps": [ + { "type": "command", "command": "code/search", + "params": { "pattern": "$currentSection.keywords", "glob": "**/*.{ts,md,json}" }, + "outputTo": "codebaseEvidence" }, + { "type": "llm", + "prompt": "Write section '$currentSection.title' using this evidence:\n$codebaseEvidence\n\nMaintain academic tone. Include code examples where relevant.", + "outputTo": "sectionDraft" } + ], + "loop": { "type": "count", "max": "$outline.sections.length" } + }, + "outputTo": "allSections" }, + { "type": "llm", + "prompt": "Synthesize these sections into a cohesive paper. Write abstract and conclusion. Ensure consistent voice and logical flow.\n\nSections:\n$allSections", + "model": "claude-opus-4-6", + "outputTo": "fullPaper" }, + { "type": "command", "command": "code/write", + "params": { "path": "$outputPath/$topic.md", "content": "$fullPaper" }} + ], + "loop": { "type": "once" }, + "safety": { "timeoutMs": 900000 } +} +``` + +**Validates**: Multi-section composition, research synthesis, long-form coherent output + +--- + +### Category 12: Marketplace Readiness + +Tasks that validate genome layers can be packaged, shared, and discovered. + +#### 12.1 Genome Export/Import Sentinel + +**Task**: Export a persona's trained genome layers, import into a fresh persona + +**Why it validates**: Portability, serialization, cross-persona compatibility + +```json +{ + "name": "genome-portability", + "description": "Export trained genome, import into new persona, validate it works", + "steps": [ + { "type": "command", "command": "genome/export", + "params": { "personaId": "$sourcePersonaId", "outputPath": "/tmp/genome-export" }, + "outputTo": "exportResult" }, + { "type": "command", "command": "genome/import", + "params": { "personaId": "$targetPersonaId", "importPath": "$exportResult.path" }, + "outputTo": "importResult" }, + { "type": "llm", + "prompt": "Generate 10 domain-specific test questions for: $exportResult.domain", + "outputTo": "testQuestions" }, + { "type": "parallel", + "steps": [ + { "type": "sentinel", "await": true, + "definition": { + "name": "test-source", + "steps": [ + { "type": "llm", "prompt": "$testQuestions", + "model": "ollama/llama3.1:8b+$sourcePersonaId-genome", + "outputTo": "sourceAnswers" } + ], + "loop": { "type": "once" } + }, + "outputTo": "sourceResults" }, + { "type": "sentinel", "await": true, + "definition": { + "name": "test-target", + "steps": [ + { "type": "llm", "prompt": "$testQuestions", + "model": "ollama/llama3.1:8b+$targetPersonaId-genome", + "outputTo": "targetAnswers" } + ], + "loop": { "type": "once" } + }, + "outputTo": "targetResults" } + ]}, + { "type": "llm", + "prompt": "Compare source and target persona answers. They should be equivalent quality since they share the same genome.\n\nSource: $sourceResults\nTarget: $targetResults", + "outputTo": "comparison" }, + { "type": "condition", "check": "$comparison.qualityMatch > 0.85", + "then": [ + { "type": "emit", "event": "genome:portability:validated" } + ], + "else": [ + { "type": "emit", "event": "genome:portability:degraded", + "data": "$comparison" } + ]} + ], + "loop": { "type": "once" }, + "safety": { "timeoutMs": 600000 } +} +``` + +**Validates**: Genome serialization, cross-persona import, quality preservation + +--- + +### Updated Validation Matrix + +| Task | SOTA | Medium | Local | Script | Memory | Events | Nested | Loop | Genome | Academy | +|------|------|--------|-------|--------|--------|--------|--------|------|--------|---------| +| **Category 1-6 (Original)** | +| Codebase Migration | ✓ | | | | | ✓ | ✓ | until | | | +| Security Audit | ✓ | | | | ✓ | | | once | | | +| PR Review | | ✓ | | | | ✓ | ✓ | once | | | +| Docs Generator | | ✓ | | | | ✓ | | event | | | +| Commit Message | | | ✓ | | | ✓ | | once | | | +| Test Generator | | | ✓ | | | | | until | | | +| Log Analyzer | | | ✓ | | | ✓ | | continuous | | | +| Build Pipeline | | | | ✓ | | ✓ | | once | | | +| Health Monitor | | | | ✓ | ✓ | ✓ | | continuous | | | +| Self-Improving Builder | | ✓ | | | ✓ | ✓ | | until | | | +| Task Delegation | | ✓ | | | | ✓ | | continuous | | | +| Research Report | ✓ | ✓ | | | | | | once | | | +| **Category 7: Self-Directed Learning** | +| Skill Gap Detection | | ✓ | | | ✓ | ✓ | | continuous | | | +| Self-Training Dojo | ✓ | | ✓ | | ✓ | ✓ | ✓ | once | ✓ | ✓ | +| **Category 8: Genome Integration** | +| LoRA Training Pipeline | ✓ | | ✓ | | | ✓ | ✓ | once | ✓ | | +| Genome Composition | ✓ | | ✓ | | | | ✓ | once | ✓ | | +| **Category 9: Academy (Plato)** | +| Academy Challenge | ✓ | | ✓ | | | ✓ | ✓ | once | ✓ | ✓ | +| Evolution Tournament | ✓ | | ✓ | | ✓ | ✓ | ✓ | count | ✓ | ✓ | +| **Category 10: Cross-Persona** | +| Collaborative Build | ✓ | | | | | ✓ | ✓ | until | | | +| Skill Transfer | | ✓ | | | ✓ | ✓ | | once | | | +| **Category 11: Domain-General** | +| Game Builder | ✓ | | | | | | | until | | | +| Research Paper | ✓ | ✓ | | | | | ✓ | once | | | +| **Category 12: Marketplace** | +| Genome Export/Import | | | ✓ | | | ✓ | ✓ | once | ✓ | | + +### Success Criteria + +Each Olympic task should: + +1. **Complete without manual intervention** (except escalations) +2. **Produce verifiable output** (files, data, events, trained adapters) +3. **Handle errors gracefully** (retry, escalate, or fail cleanly) +4. **Emit appropriate events** (for monitoring and composition) +5. **Stay within safety bounds** (timeouts, iteration limits) +6. **Integrate with memory** (where applicable) +7. **Validate genome improvements** (before/after phenotype comparison where applicable) +8. **Demonstrate self-direction** (persona identifies needs without human prompting) + +When all 24 tasks pass, the sentinel architecture is validated as a complete evolutionary system — not just an execution engine, but the metabolism of autonomous, self-improving personas. + +--- + +## Implementation Status: Rust-Centric Architecture + +**Design principle**: Rust (`continuum-core`) is where the real execution lives. TypeScript provides wrapping, CLI commands, and portability to browser/server environments. + +### Primary Layer: Rust — Pipeline Execution, Process Isolation, Concurrency + +The Rust `SentinelModule` in `continuum-core` handles ALL pipeline execution: + +``` +workers/continuum-core/src/modules/sentinel/ +├── mod.rs # SentinelModule: command routing, handle management, concurrency +├── types.rs # PipelineStep (9 variants), Pipeline, SentinelHandle, ExecutionContext (ts-rs exports) +├── executor.rs # Pipeline executor: step dispatch, variable propagation, logging +├── interpolation.rs # Variable interpolation: {{steps.N.output}}, {{named.x.data}}, {{env.HOME}} +├── logs.rs # Log stream management: list, read, tail +└── steps/ + ├── mod.rs # Step dispatcher — routes PipelineStep variants to handlers + ├── shell.rs # Shell: process isolation, kill_on_drop, cmd interpolation + ├── llm.rs # LLM: inference via AIProviderModule, prompt interpolation + ├── command.rs # Command: any Rust/TypeScript command via CommandExecutor + ├── condition.rs # Condition: if/then/else with expression evaluation + ├── loop_step.rs # Loop: count, while, until, continuous (4 modes, safety limit) + ├── parallel.rs # Parallel: concurrent branch execution, fail_fast, context snapshot + ├── emit.rs # Emit: publish interpolated events on MessageBus + ├── watch.rs # Watch: block until matching event arrives (glob patterns, timeout) + └── sentinel.rs # Sentinel: execute nested pipeline inline (recursive composition) +``` + +**All 9 Pipeline Step Types:** + +| Step Type | Status | Description | +|-----------|--------|-------------| +| `Shell` | ✅ | Process execution with `kill_on_drop` isolation, cmd interpolation, timeout | +| `Llm` | ✅ | LLM inference via `registry.route_command("ai/generate")` — no IPC deadlock | +| `Command` | ✅ | Any command via `CommandExecutor` — routes to Rust OR TypeScript | +| `Condition` | ✅ | `if`/`then`/`else` branching with interpolated condition expressions | +| `Loop` | ✅ | Four modes: `count`, `while`, `until`, `continuous` with `maxIterations` safety limit | +| `Parallel` | ✅ | Concurrent branch execution with context snapshots and `failFast` option | +| `Emit` | ✅ | Publish interpolated events on MessageBus for inter-sentinel composition | +| `Watch` | ✅ | Block until matching event (glob patterns) with configurable timeout | +| `Sentinel` | ✅ | Execute nested pipeline inline — recursive composition with inherited context | + +**Key Rust Capabilities:** +1. **Process Isolation**: Child processes with `kill_on_drop` — crashes don't cascade +2. **Module-to-Module Calls**: LLM and Command steps call modules directly via `ModuleRegistry` — no IPC round-trips, no deadlocks +3. **Concurrent**: Multiple sentinels in parallel with configurable limit (default: 4) +4. **Real-Time Streaming**: Logs streamed via MessageBus events (`sentinel:{handle}:log`) +5. **Variable Interpolation**: Step outputs feed into subsequent steps (`{{steps.0.output}}`, `{{input.x}}`) +6. **ts-rs Exports**: All types auto-generate TypeScript definitions for type-safe CLI integration +7. **Timeout + Cancellation**: Per-sentinel timeout with graceful SIGTERM, per-step tracking + +**Rust Commands:** + +| Command | Purpose | +|---------|---------| +| `sentinel/run` | Execute pipeline or shell command with isolation | +| `sentinel/status` | Get handle state (Running/Completed/Failed/Cancelled) | +| `sentinel/list` | List all active handles | +| `sentinel/cancel` | Cancel running sentinel | +| `sentinel/logs/list` | List log streams for a handle | +| `sentinel/logs/read` | Read log stream with offset/limit | +| `sentinel/logs/tail` | Tail last N lines of a stream | + +### Secondary Layer: TypeScript — Wrapping, CLI, Portability + +TypeScript provides the command interface and definition tooling: + +| File | Purpose | +|------|---------| +| `system/sentinel/SentinelDefinition.ts` | JSON-serializable definitions, `SentinelBuilder` fluent API, validation | +| `system/sentinel/ModelProvider.ts` | Model selection abstraction (LOCAL, OLLAMA, ANTHROPIC, OPENAI) | +| `commands/sentinel/run/` | CLI command wrapping Rust `sentinel/run` | +| `commands/sentinel/status/` | CLI command wrapping Rust `sentinel/status` | +| `commands/sentinel/list/` | CLI command wrapping Rust `sentinel/list` | +| `commands/sentinel/save/` | Save sentinel definitions to database | +| `commands/sentinel/load/` | Load saved sentinel definitions | +| `commands/sentinel/logs/*` | CLI wrappers for log commands | + +**Event Flow:** +``` +TypeScript calls: Commands.execute('sentinel/run', { type: 'pipeline', steps: [...] }) + ↓ + Rust SentinelModule executes pipeline + ↓ + Each step: Shell → spawn process | LLM → route to ai/generate | Command → route to module + ↓ + Logs streamed via: sentinel:{handle}:log events + ↓ + Completion via: sentinel:{handle}:status event + ↓ + TypeScript receives result with step traces +``` + +--- + +## TODO: Implementation Roadmap + +### Phase A: Complete the Rust Pipeline Engine (`continuum-core`) + +These are the foundation — everything else builds on them. + +- [x] **`until` loop type** — Condition checked after each iteration, stops when truthy +- [x] **`while` loop type** — Condition checked before each iteration, continues while truthy +- [x] **`continuous` loop type** — Runs until `maxIterations` safety limit (default 10,000) +- [ ] **`event` loop type** — Re-run pipeline on each MessageBus event +- [x] **`Parallel` step type** — Execute branch pipelines concurrently with `fail_fast` option +- [x] **`Emit` step type** — Publish interpolated events on MessageBus +- [x] **`Sentinel` step type** — Execute nested pipeline inline (recursive composition) +- [x] **`Watch` step type** — Block until matching event arrives on MessageBus (glob patterns, timeout) +- [x] **Named step outputs** — `{{named.label.output}}` via `ExecutionContext.named_outputs` +- [ ] **Expression evaluator** — Evaluate `{{steps.0.exit_code}} == 0` and `{{buildResult.success}}` in condition/loop checks +- [x] **Uniform step signatures** — All 9 step types receive `PipelineContext` for consistent access to registry/bus + +### Phase B: Sentinel Lifecycle & Persona Integration + +Wire sentinels into the persona cognitive cycle. + +- [ ] **SentinelEntity persistence** — Save/load sentinel definitions via `data/create`/`data/list` on `sentinels` collection +- [ ] **`sentinel/save` and `sentinel/load` integration** — Wire CLI commands to data layer +- [ ] **Persona ownership** — Every sentinel has a `parentPersonaId`, enforced at creation +- [ ] **Escalation → persona inbox** — When sentinel hits `unfamiliar`/`approval_needed`, create inbox item +- [ ] **Memory integration** — Successful sentinels stored as memories (`memory/store` with type `sentinel`) +- [ ] **Memory recall** — Persona recalls sentinel patterns when facing similar tasks +- [ ] **Triggers** — `immediate`, `event`, `schedule` (cron), `manual` trigger types +- [ ] **Live step CRUD** — Add/update/remove steps on a running sentinel (next iteration picks up changes) + +### Phase C: Genome Integration + +Sentinels orchestrate the LoRA training pipeline. + +- [ ] **Training data packaging** — Sentinel step that exports challenge failures as JSONL training data +- [ ] **LoRA training orchestration** — Sentinel step that triggers fine-tuning jobs (local PEFT or remote API) +- [ ] **Phenotype validation** — Sentinel step that benchmarks before/after performance on same challenges +- [ ] **Quality gating** — Only register adapters that show measurable improvement +- [ ] **Genome layer registration** — Register validated adapters in `genome_layers` collection +- [ ] **Dynamic composition** — Compose multiple layers and activate on persona via `genome/set-active` +- [ ] **LRU paging integration** — Automatically evict least-used adapters under memory pressure + +### Phase D: Academy (Plato's Training Arena) + +The selection pressure that drives genome evolution. + +- [ ] **Challenge generation** — LLM generates domain-specific challenges with rubrics +- [ ] **Multi-persona competition** — Multiple personas solve same challenges in parallel +- [ ] **AI judging** — LLM evaluates solutions against rubrics, produces scores +- [ ] **Performance gap analysis** — Identify specific skill gaps from competition results +- [ ] **Gap-driven training** — Automatically create training sentinels for identified gaps +- [ ] **Evolution tournament** — Multi-round competition with training between rounds +- [ ] **Academy result persistence** — Store competition results for historical tracking +- [ ] **Competitive ranking** — Track persona rankings across competitions + +### Phase E: Marketplace & Distribution + +Share evolved capabilities across the community. + +- [ ] **Genome export** — Package persona's LoRA layers + metadata as portable archive +- [ ] **Genome import** — Import genome archive into a new persona +- [ ] **Cross-persona compatibility validation** — Verify imported layers work on target persona's base model +- [ ] **Layer discovery** — Query available layers by domain, base model, performance rating +- [ ] **P2P sharing** — Distribute genome layers across mesh network +- [ ] **Version control** — Docker-like tags for adapter versions, rollback capability +- [ ] **Quality metrics** — Community ratings, download counts, performance benchmarks + +### Phase F: Advanced Capabilities + +Long-term vision items. + +- [ ] **Auto-compaction** — At 95% context utilization, auto-summarize sentinel context +- [ ] **Permission gating** — Approval dialogs for sensitive operations (file write, shell exec, delete) +- [ ] **Agent specialization modes** — `full`, `readonly`, `sandboxed` modes per sentinel +- [ ] **LSP integration** — Surface language server diagnostics as sentinel step output +- [ ] **Adaptive compute (LoopLM)** — Variable reasoning depth based on task complexity +- [ ] **Self-task generation** — Personas create tasks for themselves during idle time +- [ ] **Activity ambient state** — Temperature/pressure-based emergent coordination between personas + +--- + +## References + +### Implementation + +- [Rust SentinelModule](../workers/continuum-core/src/modules/sentinel/) — Pipeline executor, process isolation, concurrency +- [SentinelDefinition.ts](../system/sentinel/SentinelDefinition.ts) — JSON schema and SentinelBuilder +- [ModelProvider.ts](../system/sentinel/ModelProvider.ts) — Multi-provider model selection + +### Design Documents + +- [SENTINEL-PIPELINE-ARCHITECTURE.md](SENTINEL-PIPELINE-ARCHITECTURE.md) — Rust pipeline interpreter design +- [SENTINEL-LOGGING-PLAN.md](SENTINEL-LOGGING-PLAN.md) — Logging strategy +- [DYNAMIC-GENOME-ARCHITECTURE.md](genome/DYNAMIC-GENOME-ARCHITECTURE.md) — PersonaGenome + composable LoRA layers +- [COMPOSABLE-EXPERTISE.md](COMPOSABLE-EXPERTISE.md) — Docker model for LoRA layer stacking +- [LORA-TRAINING-STRATEGY.md](LORA-TRAINING-STRATEGY.md) — Multi-provider training pipeline +- [ACADEMY_ARCHITECTURE.md](personas/ACADEMY_ARCHITECTURE.md) — Plato's Academy competitive training +- [RECIPE-SYSTEM-REQUIREMENTS.md](recipes/RECIPE-SYSTEM-REQUIREMENTS.md) — Recipe→Sentinel unification +- [SENTINEL-AI-INTEGRATION.md](personas/SENTINEL-AI-INTEGRATION.md) — Sentinel + persona convergence vision + +### External + +- [OpenCode](https://github.com/anomalyco/opencode) — AI coding agent (comparison reference) diff --git a/src/debug/jtag/docs/SENTINEL-PIPELINE-ARCHITECTURE.md b/src/debug/jtag/docs/SENTINEL-PIPELINE-ARCHITECTURE.md new file mode 100644 index 000000000..c588604ac --- /dev/null +++ b/src/debug/jtag/docs/SENTINEL-PIPELINE-ARCHITECTURE.md @@ -0,0 +1,199 @@ +# Sentinel Pipeline Architecture + +## Problem Statement + +The current SentinelModule only executes shell commands. Pipeline interpretation (multi-step with LLM, conditions, loops) was attempted in TypeScript but: +1. **IPC deadlock**: TypeScript calling `./jtag inference/generate` blocks the server waiting for itself +2. **Fragile**: Each step type requires TypeScript-to-Rust IPC round-trips +3. **Wrong layer**: Rust modules can call each other DIRECTLY via ModuleRegistry + +## Solution: Rust Pipeline Interpreter + +Move pipeline interpretation INTO the Rust SentinelModule. The sentinel can: +- Execute shell steps (existing capability) +- Call `ai/generate` via `registry.route_command()` (no IPC, direct call) +- Call any command via `registry.route_command()` (DataModule, CodeModule, etc.) +- Evaluate conditions and loops locally + +## Pipeline Schema + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum PipelineStep { + // Execute shell command + Shell { + cmd: String, + args: Vec, + #[serde(default)] + timeout_secs: Option, + #[serde(default)] + working_dir: Option, + }, + + // LLM inference (calls AIProviderModule directly) + Llm { + prompt: String, + #[serde(default)] + model: Option, + #[serde(default)] + provider: Option, + #[serde(default)] + max_tokens: Option, + #[serde(default)] + temperature: Option, + #[serde(default)] + system_prompt: Option, + }, + + // Call any command (routes via ModuleRegistry) + Command { + command: String, + #[serde(default)] + params: Value, + }, + + // Conditional execution + Condition { + #[serde(rename = "if")] + condition: String, // e.g., "{{steps.0.success}}" + then_steps: Vec, + #[serde(default)] + else_steps: Vec, + }, + + // Loop with count + Loop { + count: usize, + steps: Vec, + }, + + // Parallel execution (tokio::join!) + Parallel { + steps: Vec, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Pipeline { + pub name: Option, + pub steps: Vec, + #[serde(default)] + pub working_dir: Option, + #[serde(default)] + pub timeout_secs: Option, +} +``` + +## Execution Flow + +``` +sentinel/pipeline command received + ↓ +Parse Pipeline JSON + ↓ +Create ExecutionContext { variables, step_results } + ↓ +For each step: + ├── Shell → execute_isolated_static() (existing) + ├── Llm → registry.route_command("ai/generate") + ├── Command → registry.route_command(cmd) + ├── Condition → evaluate, then/else recursion + ├── Loop → iterate, recursive step execution + └── Parallel → tokio::join! on step futures + ↓ +Return PipelineResult { traces, final_result, success } +``` + +## Module-to-Module Calls + +The SentinelModule stores `ModuleContext` during initialize: + +```rust +pub struct SentinelModule { + sentinels: Arc>, + workspaces_dir: RwLock, + max_concurrent: usize, + bus: RwLock>>, + ctx: RwLock>>, // NEW +} +``` + +For LLM steps: +```rust +async fn execute_llm_step(&self, step: &LlmStep, ctx: &mut ExecutionContext) -> Result { + let module_ctx = self.ctx.read().as_ref().ok_or("Not initialized")?; + + let (module, cmd) = module_ctx.registry.route_command("ai/generate") + .ok_or("ai module not found")?; + + let params = json!({ + "prompt": self.interpolate(&step.prompt, ctx), + "model": step.model, + "provider": step.provider, + "max_tokens": step.max_tokens, + "temperature": step.temperature, + "system_prompt": step.system_prompt, + }); + + let result = module.handle_command(&cmd, params).await?; + // ... extract result +} +``` + +## Variable Interpolation + +Steps can reference previous results: +- `{{steps.0.text}}` - output from step 0 +- `{{steps.build.exit_code}}` - named step result +- `{{env.HOME}}` - environment variable +- `{{input.message}}` - pipeline input parameter + +## Olympics Validation Cases + +The pipeline system must support these use cases: + +1. **Category 4.1: Build Pipeline (Pure Script)** + ```json + { "steps": [ + { "type": "shell", "cmd": "npm", "args": ["run", "build"] }, + { "type": "shell", "cmd": "npm", "args": ["run", "test"] } + ]} + ``` + +2. **Category 3.1: Commit Message Generator (Local Models)** + ```json + { "steps": [ + { "type": "shell", "cmd": "git", "args": ["diff", "--staged"] }, + { "type": "llm", "prompt": "Generate commit message for:\n{{steps.0.output}}", + "provider": "local" } + ]} + ``` + +3. **Category 2.1: PR Review (Medium Models)** + ```json + { "steps": [ + { "type": "command", "command": "code/read", "params": {"path": "{{input.file}}"} }, + { "type": "llm", "prompt": "Review this code:\n{{steps.0.content}}", + "model": "claude-3-5-haiku" } + ]} + ``` + +## Implementation Order + +1. Add `PipelineStep` and `Pipeline` structs to sentinel.rs +2. Store `ModuleContext` in SentinelModule during initialize() +3. Add `execute_pipeline()` method with step dispatch +4. Add `sentinel/pipeline` command handler +5. Implement variable interpolation +6. Test each Olympics category + +## TypeScript Wrapper (Minimal) + +TypeScript only provides the CLI wrapper: +```typescript +// commands/sentinel/run/server/SentinelRunServerCommand.ts +// Just calls: Commands.execute('sentinel/pipeline', pipelineJson) +``` + +No pipeline interpretation in TypeScript. Rust does all the work. diff --git a/src/debug/jtag/generated-command-schemas.json b/src/debug/jtag/generated-command-schemas.json index 197472df4..fc7adef82 100644 --- a/src/debug/jtag/generated-command-schemas.json +++ b/src/debug/jtag/generated-command-schemas.json @@ -1,10 +1,10 @@ { - "generated": "2026-02-13T20:12:30.837Z", + "generated": "2026-02-16T23:55:14.456Z", "version": "1.0.0", "commands": [ { "name": "workspace/tree", - "description": "Tree Command Types\n *\n * Displays hierarchical command structure auto-generated from command registry.\n * Shows parent/child relationships like a file tree.", + "description": "Tree command parameters", "params": { "filter": { "type": "string", @@ -25,7 +25,7 @@ }, { "name": "workspace/task/list", - "description": "TaskListTypes - List tasks for PersonaUsers\n *\n * Query tasks by assignee, status, domain, etc.\n * Used for monitoring AI work queues and debugging.", + "description": "Queries the task queue with filters for assignee, status, domain, task type, and context, returning matching tasks with priority-sorted results and aggregate statistics.", "params": { "assigneeId": { "type": "string", @@ -76,7 +76,7 @@ }, { "name": "workspace/task/create", - "description": "TaskCreateTypes - Create new tasks for PersonaUsers\n *\n * Allows users to assign tasks to AI personas (or themselves).\n * Tasks can be chat responses, code reviews, analysis, or self-improvement.", + "description": "Creates a new task and assigns it to a PersonaUser, specifying domain, type, priority, optional deadline, dependencies, and a human-readable description of the work to be done.", "params": { "assigneeId": { "type": "string", @@ -127,7 +127,7 @@ }, { "name": "workspace/task/complete", - "description": "TaskCompleteTypes - Mark tasks as completed (or failed)\n *\n * Updates task status and records results.\n * Used by PersonaUsers to report task completion.", + "description": "Marks a task as completed or failed, recording its output, error details, and performance metrics such as tokens used, latency, and confidence.", "params": { "taskId": { "type": "string", @@ -168,7 +168,7 @@ }, { "name": "workspace/recipe/load", - "description": "Recipe Load Command Types\n *\n * Loads recipe JSON files from system/recipes/*.json into database", + "description": "Loads recipe JSON files from system/recipes/*.json into database", "params": { "recipeId": { "type": "string", @@ -189,7 +189,7 @@ }, { "name": "workspace/list", - "description": "Workspace List Command - Shared Types\n *\n * List all persona workspaces across the team — worktree paths, git branches,\n * modified files, shell activity. Scans both in-memory active workspaces and\n * persisted git worktrees on disk.", + "description": "List all persona workspaces across the team — worktree paths, git branches, modified files, shell activity. Scans both in-memory active workspaces and persisted git worktrees on disk.", "params": { "personaId": { "type": "string", @@ -205,7 +205,7 @@ }, { "name": "workspace/git/workspace/init", - "description": "Git Workspace Init Command - Shared Types\n *\n * Initialize git workspace for persona collaboration with isolated worktree", + "description": "Initialize git workspace for persona collaboration with isolated worktree", "params": { "branch": { "type": "string", @@ -226,7 +226,7 @@ }, { "name": "workspace/git/workspace/clean", - "description": "Git Workspace Clean Command - Shared Types\n *\n * Clean up git workspace and remove worktree", + "description": "Clean up git workspace and remove worktree", "params": { "workspacePath": { "type": "string", @@ -247,7 +247,7 @@ }, { "name": "workspace/git/status", - "description": "Git Status Command - Shared Types\n *\n * Show git workspace status and changes", + "description": "Show git workspace status and changes", "params": { "workspacePath": { "type": "string", @@ -258,7 +258,7 @@ }, { "name": "workspace/git/push", - "description": "Git Push Command - Shared Types\n *\n * Push workspace branch to remote repository", + "description": "Push workspace branch to remote repository", "params": { "workspacePath": { "type": "string", @@ -274,7 +274,7 @@ }, { "name": "workspace/git/commit", - "description": "Git Commit Command - Shared Types\n *\n * Commit changes in git workspace with persona identity", + "description": "Commit changes in git workspace with persona identity", "params": { "message": { "type": "string", @@ -295,7 +295,7 @@ }, { "name": "voice/transcribe", - "description": "Voice Transcribe Command - Shared Types\n *\n * Transcribe audio to text using Rust Whisper (STT). Wraps the streaming-core Whisper adapter for speech-to-text conversion.", + "description": "Transcribe audio to text using Rust Whisper (STT). Wraps the streaming-core Whisper adapter for speech-to-text conversion.", "params": { "audio": { "type": "string", @@ -321,7 +321,7 @@ }, { "name": "voice/synthesize", - "description": "Voice Synthesize Command - Shared Types\n *\n * Synthesize text to speech using Rust TTS (Kokoro primary). Wraps the streaming-core TTS adapters for text-to-speech conversion.", + "description": "Synthesize text to speech using Rust TTS (Kokoro primary). Wraps the streaming-core TTS adapters for text-to-speech conversion.", "params": { "text": { "type": "string", @@ -362,7 +362,7 @@ }, { "name": "voice/stop", - "description": "Voice Stop Command - Shared Types\n *\n * Stop an active voice chat session", + "description": "Stop an active voice chat session", "params": { "handle": { "type": "string", @@ -373,7 +373,7 @@ }, { "name": "voice/start", - "description": "Voice Start Command - Shared Types\n *\n * Start voice chat session for real-time audio communication with AI", + "description": "Start voice chat session for real-time audio communication with AI", "params": { "room": { "type": "string", @@ -394,7 +394,7 @@ }, { "name": "utilities/pipe/chain", - "description": "Pipe Chain Command Types\n * Enables Unix-style command chaining: cmd1 | cmd2 | cmd3", + "description": "Enables Unix-style command chaining: cmd1 | cmd2 | cmd3", "params": { "commands": { "type": "string", @@ -425,7 +425,7 @@ }, { "name": "utilities/lease/request", - "description": "Lease Request Command - Shared Types\n *\n * Command to request a file lease for editing", + "description": "Lease Request Parameters", "params": { "filePath": { "type": "string", @@ -461,7 +461,7 @@ }, { "name": "utilities/hello", - "description": "Hello Command - Shared Types\n *\n * Simple hello world command for testing", + "description": "Simple hello world command for testing", "params": { "_noParams": { "type": "string", @@ -472,7 +472,7 @@ }, { "name": "utilities/docs/search", - "description": "utilities/docs/search command", + "description": "Searches across all project documentation files for lines matching a text pattern, returning matching lines with their document name, line number, and content.", "params": { "pattern": { "type": "string", @@ -493,7 +493,7 @@ }, { "name": "utilities/docs/read", - "description": "utilities/docs/read command", + "description": "Reads the content of a documentation file by name, with support for table-of-contents extraction, jumping to a specific section, or reading a line range.", "params": { "doc": { "type": "string", @@ -524,7 +524,7 @@ }, { "name": "utilities/docs/list", - "description": "utilities/docs/list command", + "description": "Lists all available documentation files in the project, returning metadata such as file size, line count, section headings, and directory, with optional filtering by directory or filename pattern.", "params": { "dir": { "type": "string", @@ -545,12 +545,12 @@ }, { "name": "user/get-me", - "description": "User Get Me Command - Get current user info\n *\n * Convenience command that returns the caller's full user information.\n * No parameters needed - sessionId is auto-injected and used to look up user.", + "description": "User Get Me Command - Get current user info Convenience command that returns the caller's full user information. No parameters needed - sessionId is auto-injected and used to look up user.", "params": {} }, { "name": "user/create", - "description": "User Create Command - Shared Types\n *\n * Follows ARCHITECTURE-RULES.md:\n * - Generic programming with BaseEntity\n * - Type safety with proper constraints\n * - No entity mixing in command layer\n *\n * Factory pattern: user/create calls BaseUser.create() which routes to\n * PersonaUser.create(), AgentUser.create(), or HumanUser.create()", + "description": "User create command parameters These are the \"recipe ingredients\" for user creation", "params": { "type": { "type": "string", @@ -601,7 +601,7 @@ }, { "name": "training/import", - "description": "Training Import Command - Shared Types\n *\n * Imports JSONL training data into SQLite database for MLX fine-tuning.\n * Uses multi-database handle system for isolation.", + "description": "Training Import Parameters", "params": { "jsonlPath": { "type": "string", @@ -642,7 +642,7 @@ }, { "name": "theme", - "description": "Theme Command Types - Base types for theme operations\n * \n * Following the same pattern as FileTypes for consistency", + "description": "Base theme parameters interface", "params": { "timestamp": { "type": "string", @@ -653,7 +653,7 @@ }, { "name": "theme/set", - "description": "ThemeSet Types - Theme setting command types", + "description": "Theme set command parameters", "params": { "themeName": { "type": "string", @@ -669,7 +669,7 @@ }, { "name": "theme/list", - "description": "ThemeList Types - Theme listing command types", + "description": "Theme list command parameters", "params": { "category": { "type": "string", @@ -690,7 +690,7 @@ }, { "name": "theme/get", - "description": "ThemeGet Types - Theme getting command types", + "description": "Theme get command parameters - gets current theme", "params": { "timestamp": { "type": "string", @@ -701,7 +701,7 @@ }, { "name": "system/daemons", - "description": "Daemons Command Types\n *\n * List all registered system daemons with their status", + "description": "List all registered system daemons with their status", "params": { "nameFilter": { "type": "string", @@ -717,7 +717,7 @@ }, { "name": "state/update", - "description": "State Update Types - User-aware entity updates\n *\n * Provides elegant wrapper around data/update with automatic user context injection\n * Following the established state command pattern for simple delegation", + "description": "State update command parameters", "params": { "collection": { "type": "string", @@ -733,17 +733,12 @@ "type": "object", "required": true, "description": "data parameter" - }, - "userId": { - "type": "string", - "required": false, - "description": "userId parameter" } } }, { "name": "state/get", - "description": "State Get Command - Shared Types\n *\n * Follows data daemon command pattern for elegant entity state management", + "description": "State get command parameters", "params": { "collection": { "type": "string", @@ -764,17 +759,12 @@ "type": "object", "required": false, "description": "orderBy parameter" - }, - "userId": { - "type": "string", - "required": false, - "description": "userId parameter" } } }, { "name": "state/create", - "description": "State Create Command - Shared Types\n *\n * Follows data daemon command pattern for elegant entity state management", + "description": "State create command parameters", "params": { "collection": { "type": "string", @@ -790,17 +780,12 @@ "type": "string", "required": false, "description": "id parameter" - }, - "userId": { - "type": "string", - "required": false, - "description": "userId parameter" } } }, { "name": "state/content/switch", - "description": "State Content Switch Command - Shared Types\n *\n * Switch to an existing open content item (set as current/highlighted tab).\n * Does NOT add to openItems - use content/open for that.", + "description": "Switch to an existing open content item (set as current/highlighted tab). Does NOT add to openItems - use content/open for that.", "params": { "userId": { "type": "string", @@ -816,7 +801,7 @@ }, { "name": "state/content/close", - "description": "State Content Close Command - Shared Types\n *\n * Close a content item (remove from user's open tabs). Handles currentItemId reassignment if closing the active tab.", + "description": "Close a content item (remove from user's open tabs). Handles currentItemId reassignment if closing the active tab.", "params": { "userId": { "type": "string", @@ -832,7 +817,7 @@ }, { "name": "social/trending", - "description": "Social Trending Command - Shared Types\n *\n * Discover trending and popular content on a social media platform.\n * Shows hot posts, top communities, and rising discussions.\n *\n * Usage:\n * ./jtag social/trending --platform=moltbook\n * ./jtag social/trending --platform=moltbook --community=ai-development --sort=top\n * ./jtag social/trending --platform=moltbook --sort=rising --limit=5", + "description": "Discover trending and popular content on a social media platform. Shows hot posts, top communities, and rising discussions.", "params": { "platform": { "type": "string", @@ -863,7 +848,7 @@ }, { "name": "social/signup", - "description": "Social Signup Command - Shared Types\n *\n * Register a persona on a social media platform (e.g., Moltbook).\n * Creates an account with a chosen username and stores credentials for future use.\n *\n * Usage:\n * ./jtag social/signup --platform=moltbook --agentName=\"helper-ai\" --description=\"I help with code\"", + "description": "Register a persona on a social media platform (e.g., Moltbook). Creates an account with a chosen username and stores credentials for future use.", "params": { "platform": { "type": "string", @@ -894,7 +879,7 @@ }, { "name": "social/search", - "description": "Social Search Command - Shared Types\n *\n * Semantic search across social media platforms.\n * Find posts, agents, and communities by keyword.\n *\n * Usage:\n * ./jtag social/search --platform=moltbook --query=\"memory systems\"\n * ./jtag social/search --platform=moltbook --query=\"rust concurrency\" --type=post --limit=10", + "description": "Social Search Command Parameters", "params": { "platform": { "type": "string", @@ -925,7 +910,7 @@ }, { "name": "social/propose", - "description": "Social Propose Command - Shared Types\n *\n * Democratic governance for shared social media accounts.\n * Personas nominate actions, vote, and auto-execute on threshold.\n *\n * Proposals are stored as Handles (type 'social-proposal') with votes in params.\n * When enough \"up\" votes accumulate, the action executes automatically.\n *\n * Modes:\n * create — Nominate a new action (follow, post, comment, etc.)\n * vote — Vote on a pending proposal\n * list — Show pending/recent proposals\n * view — View a specific proposal with full vote history\n *\n * Usage:\n * ./jtag social/propose --platform=moltbook --mode=create --action=follow --target=eudaemon_0 --reason=\"Great security research\"\n * ./jtag social/propose --mode=vote --proposalId=abc123 --direction=up\n * ./jtag social/propose --mode=list\n * ./jtag social/propose --mode=view --proposalId=abc123", + "description": "Handle type for proposals", "params": { "platform": { "type": "string", @@ -1016,7 +1001,7 @@ }, { "name": "social/profile", - "description": "Social Profile Command - Shared Types\n *\n * View or update a social media profile. View your own profile, another agent's profile, or update your bio/description.\n *\n * Usage:\n * ./jtag social/profile --platform=moltbook\n * ./jtag social/profile --platform=moltbook --agentName=other-agent\n * ./jtag social/profile --platform=moltbook --update --description=\"New bio\"", + "description": "View or update a social media profile. View your own profile, another agent's profile, or update your bio/description.", "params": { "platform": { "type": "string", @@ -1047,7 +1032,7 @@ }, { "name": "social/post", - "description": "Social Post Command - Shared Types\n *\n * Create a post on a social media platform using the persona's stored credentials.\n *\n * Usage:\n * ./jtag social/post --platform=moltbook --title=\"Hello\" --content=\"First post\" --community=general", + "description": "Create a post on a social media platform using the persona's stored credentials.", "params": { "platform": { "type": "string", @@ -1083,7 +1068,7 @@ }, { "name": "social/notifications", - "description": "Social Notifications Command - Shared Types\n *\n * Check for unread notifications (replies, mentions, followers) on a social media platform.\n * Key data source for SocialMediaRAGSource — personas become aware of social activity through this.\n *\n * Usage:\n * ./jtag social/notifications --platform=moltbook\n * ./jtag social/notifications --platform=moltbook --since=2026-01-30T00:00:00Z", + "description": "Check for unread notifications (replies, mentions, followers) on a social media platform. Key data source for SocialMediaRAGSource.", "params": { "platform": { "type": "string", @@ -1109,7 +1094,7 @@ }, { "name": "social/feed", - "description": "Social Feed Command - Shared Types\n *\n * Read the feed from a social media platform. Supports global feed,\n * personalized feed, and community-specific feeds.\n *\n * Usage:\n * ./jtag social/feed --platform=moltbook --sort=hot --limit=10\n * ./jtag social/feed --platform=moltbook --community=ai-development --sort=new", + "description": "Read the feed from a social media platform. Supports global feed, personalized feed, and community-specific feeds.", "params": { "platform": { "type": "string", @@ -1145,7 +1130,7 @@ }, { "name": "social/engage", - "description": "Social Engage Command - Shared Types\n *\n * All social interaction in one command: vote, follow, subscribe.\n * Designed for AI tool use — one command covers all engagement actions.\n *\n * Actions:\n * vote — Upvote or downvote a post or comment\n * follow — Follow an agent\n * unfollow — Unfollow an agent\n * subscribe — Subscribe to a community\n * unsubscribe — Unsubscribe from a community\n * delete — Delete own post or comment\n *\n * Usage:\n * ./jtag social/engage --platform=moltbook --action=vote --target=abc123 --targetType=post --direction=up\n * ./jtag social/engage --platform=moltbook --action=follow --target=eudaemon_0\n * ./jtag social/engage --platform=moltbook --action=subscribe --target=ai-development\n * ./jtag social/engage --platform=moltbook --action=delete --target=abc123 --targetType=post", + "description": "Social Engage Command Parameters", "params": { "platform": { "type": "string", @@ -1181,7 +1166,7 @@ }, { "name": "social/downvote", - "description": "Social Downvote Command - Shared Types\n *\n * Downvote a post on a social media platform.\n * Convenience command — delegates to provider.vote() with direction='down'.", + "description": "Downvote a post on a social media platform", "params": { "platform": { "type": "string", @@ -1202,7 +1187,7 @@ }, { "name": "social/community", - "description": "Social Community Command - Shared Types\n *\n * Manage communities (submolts) — create, list, subscribe, unsubscribe, get info", + "description": "Manage communities (submolts) — create, list, subscribe, unsubscribe, get info", "params": { "platform": { "type": "string", @@ -1233,7 +1218,7 @@ }, { "name": "social/comment", - "description": "Social Comment Command - Shared Types\n *\n * Comment on a post or reply to a comment on a social media platform.\n * Supports threaded replies.\n *\n * Usage:\n * ./jtag social/comment --platform=moltbook --postId=abc123 --content=\"Great insight!\"\n * ./jtag social/comment --platform=moltbook --postId=abc123 --content=\"Agreed\" --parentId=def456", + "description": "Comment on a post or reply to a comment on a social media platform. Supports threaded replies.", "params": { "platform": { "type": "string", @@ -1274,7 +1259,7 @@ }, { "name": "social/classify", - "description": "Social Classify Command - Shared Types\n *\n * Multi-dimensional agent classification system.\n * Analyzes an external agent's profile, posting history, and engagement\n * to produce a probability vector characterizing who they are.\n *\n * Like an embedding space for AI personas on external social media.\n * Uses existing subcommands (browse, search) to gather data,\n * then produces scores across multiple dimensions.\n *\n * Dimensions:\n * spam — Probability of being a spambot (repetitive, low-quality, template content)\n * authentic — Original content vs copypasta/shill\n * expertise — Domain knowledge signals (security, coding, philosophy, etc.)\n * influence — Community impact (karma, engagement, followers)\n * engagement — Quality of conversations (threaded depth, substantive replies)\n * reliability — Consistency over time (not one-hit wonder)\n *\n * Usage:\n * ./jtag social/classify --platform=moltbook --target=eudaemon_0\n * ./jtag social/classify --platform=moltbook --target=snorf5163\n * ./jtag social/classify --platform=moltbook --target=Cody --depth=deep", + "description": "Timestamp of classification", "params": { "platform": { "type": "string", @@ -1300,7 +1285,7 @@ }, { "name": "social/browse", - "description": "Social Browse Command - Shared Types\n *\n * Intelligent exploration of social media platforms.\n * One command for all discovery: communities, feeds, posts, agents.\n *\n * Modes:\n * discover — List all communities with descriptions and activity\n * community — Browse a specific community's feed with context\n * post — Read a full post with threaded comments and author info\n * agent — View an agent's profile, karma, recent activity\n * trending — Hot posts across the platform (default)\n *\n * Usage:\n * ./jtag social/browse --platform=moltbook # trending\n * ./jtag social/browse --platform=moltbook --mode=discover # list communities\n * ./jtag social/browse --platform=moltbook --mode=community --target=ai-development\n * ./jtag social/browse --platform=moltbook --mode=post --target=abc123\n * ./jtag social/browse --platform=moltbook --mode=agent --target=eudaemon_0", + "description": "Social Browse Command Parameters", "params": { "platform": { "type": "string", @@ -1336,7 +1321,7 @@ }, { "name": "skill/validate", - "description": "Skill Validate Command - Shared Types\n *\n * Validate a generated skill by running TypeScript compilation and tests in an ExecutionSandbox. Updates SkillEntity with validation results.", + "description": "Validate a generated skill by running TypeScript compilation and tests in an ExecutionSandbox. Updates SkillEntity with validation results.", "params": { "skillId": { "type": "string", @@ -1347,7 +1332,7 @@ }, { "name": "skill/propose", - "description": "Skill Propose Command - Shared Types\n *\n * Propose a new skill (command) specification. Creates a SkillEntity with status 'proposed'. For team-scoped skills, creates a DecisionProposal for governance approval.", + "description": "Propose a new skill (command) specification. Creates a SkillEntity with status 'proposed'. For team-scoped skills, creates a DecisionProposal for governance approval.", "params": { "name": { "type": "string", @@ -1393,7 +1378,7 @@ }, { "name": "skill/list", - "description": "Skill List Command - Shared Types\n *\n * List skills with optional filters by status, scope, and creator. Returns SkillEntity records from the database.", + "description": "List skills with optional filters by status, scope, and creator. Returns SkillEntity records from the database.", "params": { "status": { "type": "string", @@ -1419,7 +1404,7 @@ }, { "name": "skill/generate", - "description": "Skill Generate Command - Shared Types\n *\n * Generate code files for a proposed skill using the CommandGenerator. Retrieves the SkillEntity and produces source files.", + "description": "Generate code files for a proposed skill using the CommandGenerator. Retrieves the SkillEntity and produces source files.", "params": { "skillId": { "type": "string", @@ -1435,7 +1420,7 @@ }, { "name": "skill/activate", - "description": "Skill Activate Command - Shared Types\n *\n * Activate a validated skill by registering it as a live command. The skill becomes available for use by the creator (personal) or all personas (team).", + "description": "Activate a validated skill by registering it as a live command. The skill becomes available for use by the creator (personal) or all personas (team).", "params": { "skillId": { "type": "string", @@ -1446,7 +1431,7 @@ }, { "name": "session/get-user", - "description": "session/get-user command", + "description": "Resolve a session to its owning UserEntity, defaulting to the caller's session or targeting a specific one.", "params": { "targetSessionId": { "type": "string", @@ -1457,12 +1442,12 @@ }, { "name": "session/get-id", - "description": "Session Get ID Command - Get current session ID\n *\n * Convenience command that returns the caller's session ID.\n * No parameters needed - sessionId is auto-injected by framework.", + "description": "Session Get ID Command - Get current session ID Convenience command that returns the caller's session ID. No parameters needed - sessionId is auto-injected by framework.", "params": {} }, { "name": "session/destroy", - "description": "Session Destroy Command Types\n * \n * Shared types for session destroy command across client/server contexts.", + "description": "Tear down a user session and clean up all associated resources, optionally recording the reason for destruction.", "params": { "reason": { "type": "string", @@ -1473,7 +1458,7 @@ }, { "name": "session/create", - "description": "Session Create Command Types - Shared\n * \n * Command interface for creating sessions via the session daemon.\n * Handles session creation by routing to SessionDaemon internally.", + "description": "Session create command parameters", "params": { "category": { "type": "string", @@ -1485,11 +1470,6 @@ "required": true, "description": "displayName parameter" }, - "userId": { - "type": "string", - "required": false, - "description": "userId parameter" - }, "isShared": { "type": "boolean", "required": false, @@ -1504,7 +1484,7 @@ }, { "name": "sentinel/status", - "description": "Sentinel Status Command - Types", + "description": "Check the status of a running sentinel by its handle.", "params": { "handle": { "type": "string", @@ -1515,7 +1495,7 @@ }, { "name": "sentinel/save", - "description": "Sentinel Save Command - Types\n *\n * Save sentinel definitions to database for persistence and sharing.\n * Sentinels are stored in the 'sentinels' collection.", + "description": "Save sentinel definitions to database for persistence and sharing.", "params": { "definition": { "type": "string", @@ -1551,37 +1531,11 @@ }, { "name": "sentinel/run", - "description": "Sentinel Run Command - Types\n *\n * Allows AIs to create and run Sentinels via JSON config.\n * Uses handles for long-running operations and emits events for progress.", + "description": "Run Sentinels from JSON configuration. Allows AIs to create and execute autonomous task pipelines.", "params": { "type": { "type": "string", - "required": true, - "description": "type parameter" - }, - "workingDir": { - "type": "string", - "required": false, - "description": "workingDir parameter" - }, - "timeout": { - "type": "number", "required": false, - "description": "timeout parameter" - }, - "async": { - "type": "boolean", - "required": false, - "description": "async parameter" - } - } - }, - { - "name": "sentinel/run", - "description": "Sentinel Run Command - Types\n *\n * Allows AIs to create and run Sentinels via JSON config.\n * Uses handles for long-running operations and emits events for progress.", - "params": { - "type": { - "type": "string", - "required": true, "description": "type parameter" }, "workingDir": { @@ -1601,7 +1555,7 @@ }, "command": { "type": "string", - "required": true, + "required": false, "description": "command parameter" }, "maxAttempts": { @@ -1628,36 +1582,10 @@ "type": "string", "required": false, "description": "provider parameter" - } - } - }, - { - "name": "sentinel/run", - "description": "Sentinel Run Command - Types\n *\n * Allows AIs to create and run Sentinels via JSON config.\n * Uses handles for long-running operations and emits events for progress.", - "params": { - "type": { - "type": "string", - "required": true, - "description": "type parameter" - }, - "workingDir": { - "type": "string", - "required": false, - "description": "workingDir parameter" - }, - "timeout": { - "type": "number", - "required": false, - "description": "timeout parameter" - }, - "async": { - "type": "boolean", - "required": false, - "description": "async parameter" }, "goal": { "type": "string", - "required": true, + "required": false, "description": "goal parameter" }, "maxIterations": { @@ -1665,16 +1593,6 @@ "required": false, "description": "maxIterations parameter" }, - "capacity": { - "type": "string", - "required": false, - "description": "capacity parameter" - }, - "provider": { - "type": "string", - "required": false, - "description": "provider parameter" - }, "modelName": { "type": "string", "required": false, @@ -1684,36 +1602,10 @@ "type": "string", "required": false, "description": "screenshotDir parameter" - } - } - }, - { - "name": "sentinel/run", - "description": "Sentinel Run Command - Types\n *\n * Allows AIs to create and run Sentinels via JSON config.\n * Uses handles for long-running operations and emits events for progress.", - "params": { - "type": { - "type": "string", - "required": true, - "description": "type parameter" - }, - "workingDir": { - "type": "string", - "required": false, - "description": "workingDir parameter" - }, - "timeout": { - "type": "number", - "required": false, - "description": "timeout parameter" - }, - "async": { - "type": "boolean", - "required": false, - "description": "async parameter" }, "target": { "type": "string", - "required": true, + "required": false, "description": "target parameter" }, "filename": { @@ -1730,41 +1622,15 @@ "type": "object", "required": false, "description": "viewport parameter" - } - } - }, - { - "name": "sentinel/run", - "description": "Sentinel Run Command - Types\n *\n * Allows AIs to create and run Sentinels via JSON config.\n * Uses handles for long-running operations and emits events for progress.", - "params": { - "type": { - "type": "string", - "required": true, - "description": "type parameter" - }, - "workingDir": { - "type": "string", - "required": false, - "description": "workingDir parameter" - }, - "timeout": { - "type": "number", - "required": false, - "description": "timeout parameter" - }, - "async": { - "type": "boolean", - "required": false, - "description": "async parameter" }, "tasks": { "type": "array", - "required": true, + "required": false, "description": "tasks parameter" }, "action": { "type": "string", - "required": true, + "required": false, "description": "action parameter" }, "file": { @@ -1777,11 +1643,6 @@ "required": false, "description": "content parameter" }, - "command": { - "type": "string", - "required": false, - "description": "command parameter" - }, "maxDepth": { "type": "number", "required": false, @@ -1791,23 +1652,22 @@ "type": "number", "required": false, "description": "maxTotalTasks parameter" - } - } - }, - { - "name": "sentinel/run", - "description": "Sentinel Run Command - Types\n *\n * Allows AIs to create and run Sentinels via JSON config.\n * Uses handles for long-running operations and emits events for progress.", - "params": { + }, + "definition": { + "type": "string", + "required": false, + "description": "definition parameter" + }, "handle": { "type": "string", - "required": true, + "required": false, "description": "handle parameter" } } }, { "name": "sentinel/logs/tail", - "description": "Sentinel Logs Tail Command - Types\n *\n * Get the last N lines of a log stream (like Unix tail).", + "description": "Get the last N lines of a sentinel log stream, like Unix tail.", "params": { "handle": { "type": "string", @@ -1828,7 +1688,7 @@ }, { "name": "sentinel/logs/read", - "description": "Sentinel Logs Read Command - Types\n *\n * Read a log stream for a sentinel.", + "description": "Read a log stream for a sentinel with optional offset and limit for pagination.", "params": { "handle": { "type": "string", @@ -1854,7 +1714,7 @@ }, { "name": "sentinel/logs/list", - "description": "Sentinel Logs List Command - Types\n *\n * List available log streams for a sentinel.", + "description": "List available log streams for a sentinel by handle.", "params": { "handle": { "type": "string", @@ -1865,7 +1725,7 @@ }, { "name": "sentinel/load", - "description": "Sentinel Load Command - Types\n *\n * Load and optionally run saved sentinel definitions from database.", + "description": "Load and optionally run saved sentinel definitions from database.", "params": { "id": { "type": "string", @@ -1889,35 +1749,9 @@ } } }, - { - "name": "sentinel/load", - "description": "Sentinel Load Command - Types\n *\n * Load and optionally run saved sentinel definitions from database.", - "params": { - "type": { - "type": "string", - "required": false, - "description": "type parameter" - }, - "tags": { - "type": "array", - "required": false, - "description": "tags parameter" - }, - "templatesOnly": { - "type": "boolean", - "required": false, - "description": "templatesOnly parameter" - }, - "limit": { - "type": "number", - "required": false, - "description": "limit parameter" - } - } - }, { "name": "sentinel/list", - "description": "Sentinel List Command - Types\n *\n * List saved sentinel definitions from database.", + "description": "List saved sentinel definitions from database.", "params": { "type": { "type": "string", @@ -1948,7 +1782,7 @@ }, { "name": "security/setup", - "description": "security/setup command", + "description": "Install and configure security components (network monitor, proxy) and report their current status.", "params": { "statusOnly": { "type": "boolean", @@ -1964,7 +1798,7 @@ }, { "name": "search/vector", - "description": "Search Vector Command Types\n * Vector similarity search via Rust SearchModule", + "description": "Vector similarity search via Rust SearchModule", "params": { "queryVector": { "type": "array", @@ -1990,7 +1824,7 @@ }, { "name": "search/params", - "description": "Search Params Command Types\n * Get algorithm parameters from Rust SearchModule", + "description": "Get algorithm parameters from Rust SearchModule", "params": { "algorithm": { "type": "string", @@ -2001,12 +1835,12 @@ }, { "name": "search/list", - "description": "Search List Command Types\n * Lists available search algorithms from Rust SearchModule", + "description": "Lists available search algorithms from Rust SearchModule", "params": {} }, { "name": "search/execute", - "description": "Search Execute Command Types\n * Executes text search via Rust SearchModule", + "description": "Executes text search via Rust SearchModule", "params": { "algorithm": { "type": "string", @@ -2032,7 +1866,7 @@ }, { "name": "runtime/metrics", - "description": "Runtime Metrics Command - Shared Types\n *\n * Query Rust module performance metrics including latency percentiles, command counts, and slow command tracking.\n * Enables AI-driven system analysis and optimization (Ares pattern).\n *\n * Uses ts-rs generated types from Rust as source of truth for wire format.", + "description": "Query Rust module performance metrics including latency percentiles, command counts, and slow command tracking. Enables AI-driven system analysis and optimization.", "params": { "mode": { "type": "string", @@ -2048,7 +1882,7 @@ }, { "name": "rag/load", - "description": "RAG Load Command - Test incremental message loading with token counting\n *\n * Shows exactly which messages would be loaded for RAG context given a token budget.\n * Makes the incremental loading algorithm transparent and debuggable.", + "description": "RAG Load Command - Test incremental message loading with token counting Shows exactly which messages would be loaded for RAG context given a token budget. Makes the incremental loading algorithm transparent and debuggable.", "params": { "roomId": { "type": "string", @@ -2065,6 +1899,11 @@ "required": true, "description": "model parameter" }, + "provider": { + "type": "string", + "required": false, + "description": "provider parameter" + }, "maxTokens": { "type": "number", "required": false, @@ -2089,13 +1928,18 @@ }, { "name": "rag/budget", - "description": "RAG Budget Command - Calculate token budget for RAG context\n *\n * Shows the algorithm for calculating safe message counts based on model's context window.\n * Makes the token budget calculation transparent and debuggable.", + "description": "RAG Budget Command - Calculate token budget for RAG context Shows the algorithm for calculating safe message counts based on model's context window. Makes the token budget calculation transparent and debuggable.", "params": { "model": { "type": "string", "required": true, "description": "model parameter" }, + "provider": { + "type": "string", + "required": false, + "description": "provider parameter" + }, "maxTokens": { "type": "number", "required": false, @@ -2120,16 +1964,16 @@ }, { "name": "process-registry", - "description": "Process Registry Command Types - Shared\n * \n * Provides process identification and cleanup commands for JTAG multi-instance coordination.\n * Enables P2P mesh networking by preventing process collision during startup/cleanup.", + "description": "Cleanup processes parameters", "params": { "processType": { "type": "string", - "required": true, + "required": false, "description": "processType parameter" }, "description": { "type": "string", - "required": true, + "required": false, "description": "description parameter" }, "ports": { @@ -2146,13 +1990,7 @@ "type": "string", "required": false, "description": "parentProcessId parameter" - } - } - }, - { - "name": "process-registry", - "description": "Process Registry Command Types - Shared\n * \n * Provides process identification and cleanup commands for JTAG multi-instance coordination.\n * Enables P2P mesh networking by preventing process collision during startup/cleanup.", - "params": { + }, "filterByPorts": { "type": "array", "required": false, @@ -2167,13 +2005,7 @@ "type": "boolean", "required": false, "description": "includeStale parameter" - } - } - }, - { - "name": "process-registry", - "description": "Process Registry Command Types - Shared\n * \n * Provides process identification and cleanup commands for JTAG multi-instance coordination.\n * Enables P2P mesh networking by preventing process collision during startup/cleanup.", - "params": { + }, "forceAll": { "type": "boolean", "required": false, @@ -2198,7 +2030,7 @@ }, { "name": "positron/cursor", - "description": "Positron Cursor Command Types\n *\n * Enables AIs to point, highlight, and draw attention to elements in the UI.\n * The cursor is the AI's \"hand\" - its spatial presence in the interface.", + "description": "Enables AIs to point, highlight, and draw attention to elements in the UI. The cursor is the AI's \"hand\" - its spatial presence in the interface.", "params": { "action": { "type": "string", @@ -2254,7 +2086,7 @@ }, { "name": "ping", - "description": "ping command", + "description": "Include detailed AI persona health status", "params": { "server": { "type": "string", @@ -2275,7 +2107,7 @@ }, { "name": "persona/learning/pattern/query", - "description": "Persona Learning Pattern Query Command - Shared Types\n *\n * Query the collective pattern knowledge base. Search for patterns that might help solve the current problem.", + "description": "Query the collective pattern knowledge base. Search for patterns that might help solve the current problem.", "params": { "domain": { "type": "string", @@ -2321,7 +2153,7 @@ }, { "name": "persona/learning/pattern/endorse", - "description": "Persona Learning Pattern Endorse Command - Shared Types\n *\n * Report the outcome of using a pattern. Updates confidence scores and can trigger validation or deprecation.", + "description": "Report the outcome of using a pattern. Updates confidence scores and can trigger validation or deprecation.", "params": { "patternId": { "type": "string", @@ -2342,7 +2174,7 @@ }, { "name": "persona/learning/pattern/capture", - "description": "Persona Learning Pattern Capture Command - Shared Types\n *\n * Capture a successful pattern for cross-AI learning. When an AI discovers a working solution, they share it with the team.", + "description": "Capture a successful pattern for cross-AI learning. When an AI discovers a working solution, they share it with the team.", "params": { "name": { "type": "string", @@ -2398,7 +2230,7 @@ }, { "name": "persona/learning/multi-agent-learn", - "description": "GenomeMultiAgentLearnTypes - Multi-agent collaborative learning\n *\n * All participants learn from shared outcome after recipe completion.\n * Enables GAN-like training where multiple PersonaUsers improve together.", + "description": "Triggers collaborative learning across multiple PersonaUser participants after a shared activity completes, distributing reinforcement or correction training to each based on their individual performance metrics.", "params": { "domain": { "type": "string", @@ -2434,7 +2266,7 @@ }, { "name": "persona/learning/capture-interaction", - "description": "GenomeCaptureInteractionTypes - Capture AI interactions for continuous learning\n *\n * Called during recipe execution to record inputs/outputs for LoRA training.\n * Accumulates examples in-memory for batch micro-tuning.", + "description": "Captures an AI persona's input/output pair during task execution, accumulating training examples in-memory for batch LoRA micro-tuning within a specified learning domain.", "params": { "roleId": { "type": "string", @@ -2495,7 +2327,7 @@ }, { "name": "persona/learning/capture-feedback", - "description": "GenomeCaptureFeedbackTypes - Capture feedback between PersonaUsers\n *\n * Records corrections, critiques, scores from one persona to another.\n * Enables reciprocal learning - both giver and receiver learn from feedback.", + "description": "Records a correction, approval, critique, score, or suggestion from one persona to another, enabling reciprocal learning where both the feedback giver and receiver improve from the exchange.", "params": { "interactionId": { "type": "string", @@ -2566,7 +2398,7 @@ }, { "name": "persona/genome", - "description": "Persona Genome Command - Shared Types\n *\n * Get persona genome information including base model, layers, and traits", + "description": "Get persona genome information including base model, layers, and traits", "params": { "personaId": { "type": "string", @@ -2577,7 +2409,7 @@ }, { "name": "media/resize", - "description": "Media Resize Command Types\n *\n * Image resizing via sharp library with model-aware dimension calculation.\n * Enables PersonaUsers to receive appropriately-sized images based on their\n * model's context window capacity.", + "description": "Parameters for media resizing", "params": { "inputPath": { "type": "string", @@ -2614,6 +2446,11 @@ "required": false, "description": "modelName parameter" }, + "providerName": { + "type": "string", + "required": false, + "description": "providerName parameter" + }, "targetPercentage": { "type": "number", "required": false, @@ -2638,7 +2475,7 @@ }, { "name": "media/process", - "description": "Media Process Command Types\n *\n * Comprehensive video/audio/image processing via ffmpeg and other media tools.\n * Supports speed adjustment, format conversion, trimming, audio manipulation, and more.", + "description": "Parameters for media processing", "params": { "inputPath": { "type": "string", @@ -2789,12 +2626,12 @@ }, { "name": "logs/stats", - "description": "logs/stats command", + "description": "Return aggregate statistics about all log files, including total file count and combined size in megabytes.", "params": {} }, { "name": "logs/search", - "description": "logs/search command", + "description": "Search across log files for lines matching a pattern, optionally scoped to specific logs, categories, or personas.", "params": { "pattern": { "type": "string", @@ -2805,7 +2642,7 @@ }, { "name": "logs/read", - "description": "logs/read command", + "description": "Read lines from a log file with optional filtering by level or component, and optional multi-dimensional structure analysis (temporal, severity, spatial).", "params": { "log": { "type": "string", @@ -2892,7 +2729,7 @@ }, { "name": "logs/config", - "description": "Logs Config Command - Shared Types\n *\n * Get or set logging configuration per persona and category", + "description": "Get or set logging configuration per persona and category", "params": { "persona": { "type": "string", @@ -2913,7 +2750,7 @@ }, { "name": "logging/status", - "description": "Logging Status Command - Shared Types\n *\n * Show current logging configuration for all personas or a specific persona", + "description": "Show current logging configuration for all personas or a specific persona", "params": { "persona": { "type": "string", @@ -2924,7 +2761,7 @@ }, { "name": "logging/enable", - "description": "Logging Enable Command - Shared Types\n *\n * Enable logging for a persona. Persists to .continuum/logging.json", + "description": "Enable logging for a persona. Persists to .continuum/logging.json", "params": { "persona": { "type": "string", @@ -2940,7 +2777,7 @@ }, { "name": "logging/disable", - "description": "Logging Disable Command - Shared Types\n *\n * Disable logging for a persona. Persists to .continuum/logging.json", + "description": "Disable logging for a persona. Persists to .continuum/logging.json", "params": { "persona": { "type": "string", @@ -2956,7 +2793,7 @@ }, { "name": "list", - "description": "List Command Types - Command Discovery Interface\n * \n * Provides strongly-typed interface for discovering available commands from the system.\n * Essential command that all JTAG systems must implement for client discovery.", + "description": "List command parameters", "params": { "includeDescription": { "type": "boolean", @@ -2972,7 +2809,7 @@ }, { "name": "interface/webmcp/discover", - "description": "Interface Webmcp Discover Command - Shared Types\n *\n * Discover WebMCP tools available on the current page. Returns structured tool definitions with schemas. Fails explicitly if WebMCP is not available.", + "description": "Discover WebMCP tools available on the current page. Returns structured tool definitions with schemas. Fails explicitly if WebMCP is not available.", "params": { "url": { "type": "string", @@ -2983,7 +2820,7 @@ }, { "name": "interface/webmcp/call", - "description": "Interface Webmcp Call Command - Shared Types\n *\n * Call a WebMCP tool on the current page. Returns structured result from the tool. Fails explicitly if WebMCP is not available or tool not found.", + "description": "Call a WebMCP tool on the current page. Returns structured result from the tool. Fails explicitly if WebMCP is not available or tool not found.", "params": { "toolName": { "type": "string", @@ -3004,7 +2841,7 @@ }, { "name": "interface/web/search", - "description": "Web Search Command Types\n *\n * Allows AIs to search the web and get results.\n * Uses search APIs to find relevant information.", + "description": "Web search parameters", "params": { "query": { "type": "string", @@ -3025,7 +2862,7 @@ }, { "name": "interface/web/fetch", - "description": "Web Fetch Command Types\n *\n * Allows AIs to fetch and read web pages.\n * Returns clean text content from HTML pages.", + "description": "Web fetch parameters", "params": { "url": { "type": "string", @@ -3051,7 +2888,7 @@ }, { "name": "interface/wait-for-element", - "description": "interface/wait-for-element command", + "description": "Wait for a DOM element to appear, matching a CSS selector.", "params": { "selector": { "type": "string", @@ -3077,7 +2914,7 @@ }, { "name": "interface/type", - "description": "interface/type command", + "description": "Type text into a form field or input element.", "params": { "selector": { "type": "string", @@ -3103,7 +2940,7 @@ }, { "name": "interface/scroll", - "description": "interface/scroll command", + "description": "Scroll the page or a specific element.", "params": { "x": { "type": "number", @@ -3129,7 +2966,7 @@ }, { "name": "interface/screenshot", - "description": "Screenshot Command - Shared Types\n * \n * Common types and interfaces used by both browser and server screenshot implementations.", + "description": "Screenshot Command Parameters - Enhanced with advanced features", "params": { "filename": { "type": "string", @@ -3240,7 +3077,7 @@ }, { "name": "interface/proxy-navigate", - "description": "Proxy Navigate Command - Shared Types\n * \n * Enables cross-origin navigation through proxy system for widget training.\n * Solves the fundamental html2canvas + cross-origin iframe limitation.", + "description": "Enables cross-origin navigation through proxy system for widget training. Solves the fundamental html2canvas + cross-origin iframe limitation.", "params": { "url": { "type": "string", @@ -3271,7 +3108,7 @@ }, { "name": "interface/page/submit", - "description": "Interface Page Submit Command - Shared Types\n *\n * Submit a form on a web page. Use interface/page/forms to discover forms,\n * interface/page/fill to populate fields, then this command to submit.\n * Returns the resulting page state after submission.", + "description": "Submit a form on a web page. Use interface/page/forms to discover forms, interface/page/fill to populate fields, then this command to submit. Returns the resulting page state after submission.", "params": { "url": { "type": "string", @@ -3302,7 +3139,7 @@ }, { "name": "interface/page/forms", - "description": "Interface Page Forms Command - Shared Types\n *\n * Discover all forms on a web page. Returns structured form definitions with field names,\n * types, labels, and submit buttons. Works on ANY page with HTML forms - no WebMCP required.\n * Use this first to understand what you can interact with, then use interface/page/fill\n * and interface/page/submit.", + "description": "Discover all forms on a web page. Returns structured form definitions with field names, types, labels, and submit buttons. Works on ANY page with HTML forms - no WebMCP required. Use this first to understand what you can interact with, then use interface/page/fill and interface/page/submit.", "params": { "url": { "type": "string", @@ -3318,7 +3155,7 @@ }, { "name": "interface/page/fill", - "description": "Interface Page Fill Command - Shared Types\n *\n * Fill form fields on a web page. Use interface/page/forms first to discover available forms\n * and their fields. This command fills fields but does NOT submit - use interface/page/submit\n * after filling.", + "description": "Fill form fields on a web page. Use interface/page/forms first to discover available forms and their fields. This command fills fields but does NOT submit - use interface/page/submit after filling.", "params": { "url": { "type": "string", @@ -3344,7 +3181,7 @@ }, { "name": "interface/navigate", - "description": "Navigate Command - Shared Types for Browser Navigation\n * \n * Minimal, focused types for URL navigation across browser/server contexts.\n * Follows the elegant pattern of screenshot command - simple params and results\n * with clean inheritance from CommandParams/CommandResult base classes.\n * \n * DESIGN PRINCIPLES:\n * - Object.assign() in constructor for clean initialization\n * - Optional properties with sensible defaults\n * - Environment-aware results with timestamps\n * - Type safety without overkill complexity\n * \n * USAGE:\n * - Browser: Direct window.location navigation\n * - Server: Delegates to browser context\n * - Symmetric interface across both contexts", + "description": "Navigate the browser to a URL.", "params": { "url": { "type": "string", @@ -3370,7 +3207,7 @@ }, { "name": "interface/launch/url", - "description": "Interface Launch Url Command - Shared Types\n *\n * Opens a URL in the default browser. Enables personas to view what they build.", + "description": "Opens a URL in the default browser. Enables personas to view what they build.", "params": { "url": { "type": "string", @@ -3391,7 +3228,7 @@ }, { "name": "interface/get-text", - "description": "interface/get-text command", + "description": "Extract text content from a DOM element by CSS selector.", "params": { "selector": { "type": "string", @@ -3412,7 +3249,7 @@ }, { "name": "interface/click", - "description": "Click Command - Shared Types for Element Interaction\n * \n * Minimal types for clicking DOM elements. Follows screenshot/navigate pattern\n * with clean inheritance and Object.assign() initialization.\n * \n * DESIGN ANALYSIS:\n * ✅ Focused on single action - clicking elements\n * ✅ Clean parameter interface with optional properties\n * ✅ Proper constructor pattern with Object.assign()\n * ✅ Result type includes success state and metadata\n * ✅ No over-engineering - just what's needed for clicks\n * \n * SCOPE:\n * - Browser: Direct DOM element.click() calls\n * - Server: Delegates to browser context\n * - Consistent interface across contexts", + "description": "Click a DOM element by CSS selector.", "params": { "selector": { "type": "string", @@ -3448,7 +3285,7 @@ }, { "name": "interface/browser/capabilities", - "description": "Interface Browser Capabilities Command - Shared Types\n *\n * Check available browser automation capabilities. Returns explicit status for each capability (webmcp, puppeteer, etc). No fallbacks - AIs see exactly what is/isn't available.", + "description": "Check available browser automation capabilities. Returns explicit status for each capability (webmcp, puppeteer, etc). No fallbacks - AIs see exactly what is/isn't available.", "params": { "_noParams": { "type": "string", @@ -3459,7 +3296,7 @@ }, { "name": "inference/generate", - "description": "Inference Generate Command - Shared Types\n *\n * Generate text using local or cloud AI inference. Auto-routes to best available backend (Candle → cloud). Handles model loading, LoRA adapters, and provider failover automatically.", + "description": "Generate text using local or cloud AI inference. Auto-routes to best available backend (Candle → Ollama → cloud). Handles model loading, LoRA adapters, and provider failover automatically.", "params": { "prompt": { "type": "string", @@ -3500,7 +3337,7 @@ }, { "name": "help", - "description": "Help Command - Shared Types\n *\n * Discover and display help documentation from command READMEs, auto-generating templates for gaps", + "description": "Discover and display help documentation from command READMEs, auto-generating templates for gaps", "params": { "path": { "type": "string", @@ -3521,7 +3358,7 @@ }, { "name": "genome/activate", - "description": "Genome Command Types\n *\n * Commands for managing LoRA adapter paging across personas.\n *\n * Phase 7: Single adapter per persona (mock adapters)\n * Phase 8+: Multiple adapters stacked (real GPU)", + "description": "Activate adapter for persona Loads adapter into memory, evicting others if needed.", "params": { "personaId": { "type": "string", @@ -3537,7 +3374,7 @@ }, { "name": "genome/deactivate", - "description": "Genome Command Types\n *\n * Commands for managing LoRA adapter paging across personas.\n *\n * Phase 7: Single adapter per persona (mock adapters)\n * Phase 8+: Multiple adapters stacked (real GPU)", + "description": "Deactivate adapter for persona Unloads adapter from memory.", "params": { "personaId": { "type": "string", @@ -3553,7 +3390,7 @@ }, { "name": "genome/stats", - "description": "Genome Command Types\n *\n * Commands for managing LoRA adapter paging across personas.\n *\n * Phase 7: Single adapter per persona (mock adapters)\n * Phase 8+: Multiple adapters stacked (real GPU)", + "description": "Get genome statistics Returns global daemon stats and per-persona stats.", "params": { "personaId": { "type": "string", @@ -3564,7 +3401,7 @@ }, { "name": "genome/register", - "description": "Genome Command Types\n *\n * Commands for managing LoRA adapter paging across personas.\n *\n * Phase 7: Single adapter per persona (mock adapters)\n * Phase 8+: Multiple adapters stacked (real GPU)", + "description": "Register persona with genome daemon Must be called before activating adapters.", "params": { "personaId": { "type": "string", @@ -3590,7 +3427,7 @@ }, { "name": "genome/unregister", - "description": "Genome Command Types\n *\n * Commands for managing LoRA adapter paging across personas.\n *\n * Phase 7: Single adapter per persona (mock adapters)\n * Phase 8+: Multiple adapters stacked (real GPU)", + "description": "Unregister persona from genome daemon Unloads all adapters for persona.", "params": { "personaId": { "type": "string", @@ -3601,7 +3438,7 @@ }, { "name": "genome/paging-unregister", - "description": "Genome Unregister Command Types\n *\n * Unregister persona from genome daemon - unloads all adapters for persona.", + "description": "Unregister persona from genome daemon - unloads all adapters for persona.", "params": { "personaId": { "type": "string", @@ -3612,7 +3449,7 @@ }, { "name": "genome/paging-stats", - "description": "Genome Stats Command Types\n *\n * Get genome statistics - returns global daemon stats and per-persona stats.", + "description": "Get genome statistics - returns global daemon stats and per-persona stats.", "params": { "personaId": { "type": "string", @@ -3623,7 +3460,7 @@ }, { "name": "genome/paging-register", - "description": "Genome Register Command Types\n *\n * Register persona with genome daemon - must be called before activating adapters.", + "description": "Register persona with genome daemon - must be called before activating adapters.", "params": { "personaId": { "type": "string", @@ -3649,7 +3486,7 @@ }, { "name": "genome/paging-deactivate", - "description": "Genome Deactivate Command Types\n *\n * Deactivate adapter for persona - unloads adapter from memory.", + "description": "Deactivate adapter for persona - unloads adapter from memory.", "params": { "personaId": { "type": "string", @@ -3665,7 +3502,7 @@ }, { "name": "genome/paging-adapter-register", - "description": "Genome Paging Adapter Register Command Types\n *\n * Register a mock LoRA adapter in the global adapter registry.\n * This must be done before activating adapters for personas.\n *\n * Phase 7: Mock adapters only\n * Phase 8+: Real Candle adapters", + "description": "Genome Paging Adapter Register Command Types Register a mock LoRA adapter in the global adapter registry. This must be done before activating adapters for personas. Phase 7: Mock adapters only Phase 8+: Real Candle adapters", "params": { "adapterId": { "type": "string", @@ -3696,7 +3533,7 @@ }, { "name": "genome/paging-activate", - "description": "Genome Activate Command Types\n *\n * Activate adapter for persona - loads adapter into memory, evicting others if needed.\n *\n * Phase 7: Single adapter per persona (mock adapters)\n * Phase 8+: Multiple adapters stacked (real GPU)", + "description": "Activate adapter for persona - loads adapter into memory, evicting others if needed. Phase 7: Single adapter per persona (mock adapters) Phase 8+: Multiple adapters stacked (real GPU)", "params": { "personaId": { "type": "string", @@ -3712,7 +3549,7 @@ }, { "name": "genome/job-status", - "description": "GenomeJobStatusTypes - Query fine-tuning job status\n *\n * Retrieves current status, progress, and metadata for a training job.\n * Works with jobs created via genome/job-create.", + "description": "GenomeJobStatusTypes - Query fine-tuning job status Retrieves current status, progress, and metadata for a training job. Works with jobs created via genome/job-create.", "params": { "jobId": { "type": "string", @@ -3728,7 +3565,7 @@ }, { "name": "genome/job-create", - "description": "GenomeJobCreateTypes - Create fine-tuning jobs with comprehensive configuration\n *\n * Universal command for creating fine-tuning jobs across all providers (OpenAI, DeepSeek, Fireworks, Together).\n * Uses provider-agnostic JobConfiguration schema with automatic validation.", + "description": "Create a fine-tuning job on a cloud provider (OpenAI, DeepSeek, Fireworks, or Together) with a validated, provider-agnostic configuration.", "params": { "personaId": { "type": "string", @@ -3764,7 +3601,7 @@ }, { "name": "genome/batch-micro-tune", - "description": "GenomeBatchMicroTuneTypes - Lightweight in-recipe LoRA updates\n *\n * Performs fast micro-tuning during recipe execution using accumulated examples.\n * Soft updates in RAM, not persisted to disk (that happens during deep training).", + "description": "Perform a fast, in-memory LoRA micro-tune on accumulated training examples during recipe execution, without persisting weights to disk.", "params": { "domain": { "type": "string", @@ -3805,7 +3642,7 @@ }, { "name": "file", - "description": "File Command Base Types - Generic Foundation for File Operations\n * \n * Provides type-safe base classes for all file operations using TypeScript generics.\n * Follows the modular command architecture with ~50 line modules and shared types.\n * \n * CORE ARCHITECTURE:\n * - Generic FileParams base for type-safe parameter extension\n * - Generic FileResult base for consistent result structure\n * - Abstract base classes prevent direct instantiation\n * - Object.assign constructor pattern for elegant initialization\n * \n * TESTING REQUIREMENTS:\n * - Unit tests: Parameter validation and default value assignment\n * - Integration tests: Cross-command type compatibility\n * - Type tests: Generic constraint validation\n * \n * ARCHITECTURAL INSIGHTS:\n * - Generics enable type-safe command extension without duplication\n * - Abstract classes provide structure while allowing specialization\n * - Shared types ensure consistency across file operations", + "description": "Generic base parameters for all file operations", "params": { "filepath": { "type": "string", @@ -3821,7 +3658,7 @@ }, { "name": "file/save", - "description": "FileSave Types - Generic Inheritance from File Base Types\n * \n * GENERIC HIERARCHY:\n * FileParams<{content, createDirs}> → FileSaveParams\n * FileResult<{bytesWritten, created}> → FileSaveResult", + "description": "File save command parameters", "params": { "filepath": { "type": "string", @@ -3847,7 +3684,7 @@ }, { "name": "file/mime-type", - "description": "File MIME Type Command Types\n * Detects MIME type from file extension or file content", + "description": "File MIME type detection parameters", "params": { "filepath": { "type": "string", @@ -3868,7 +3705,7 @@ }, { "name": "file/load", - "description": "FileLoad Types - Elegant Inheritance from File Base Types\n * \n * INHERITANCE HIERARCHY:\n * FileParams → FileLoadParams (no additional fields - pure inheritance)\n * FileResult → FileLoadResult (adds content, bytesRead)", + "description": "File load command parameters", "params": { "filepath": { "type": "string", @@ -3884,7 +3721,7 @@ }, { "name": "file/append", - "description": "FileAppend Types - Elegant Inheritance from File Base Types\n * \n * INHERITANCE HIERARCHY:\n * FileParams → FileAppendParams (adds content, createIfMissing)\n * FileResult → FileAppendResult (adds bytesAppended, wasCreated)", + "description": "File append command parameters", "params": { "filepath": { "type": "string", @@ -3910,7 +3747,7 @@ }, { "name": "development/timing", - "description": "TimingTypes - Types for analyzing Rust worker timing metrics\n *\n * NOTE: The data-daemon worker has been absorbed into continuum-core DataModule.\n * The timing file /tmp/jtag-data-daemon-timing.jsonl may no longer be written.\n * See TimingServerCommand.ts for details.", + "description": "Timing analysis parameters", "params": { "windowMinutes": { "type": "number", @@ -3941,7 +3778,7 @@ }, { "name": "development/shell/execute", - "description": "Shell Execute Command Types\n *\n * Provides safe shell command execution for PersonaUsers and other AI agents.\n * Commands are whitelisted and sanitized to prevent security issues.", + "description": "Parameters for shell command execution", "params": { "command": { "type": "string", @@ -3977,7 +3814,7 @@ }, { "name": "development/schema/generate", - "description": "Schema Generate Command Types\n *\n * Generates JSON schemas from TypeScript interfaces using the TypeScript compiler.\n * Properly resolves cross-file inheritance.\n *\n * Usage:\n * ./jtag schema/generate --pattern=\"*Params\" --output=\"schemas.json\"\n * ./jtag schema/generate --interface=\"DataReadParams\" --file=\"commands/data/read/shared/DataReadTypes.ts\"", + "description": "Generates JSON schemas from TypeScript interfaces using the TypeScript compiler. Properly resolves cross-file inheritance. Usage: ./jtag schema/generate --pattern=\"*Params\" --output=\"schemas.json\" ./jtag schema/generate --interface=\"DataReadParams\" --file=\"commands/data/read/shared/DataReadTypes.ts\"", "params": { "pattern": { "type": "string", @@ -4008,7 +3845,7 @@ }, { "name": "development/sandbox-execute", - "description": "Sandbox Execute Types - Run AI-generated commands in isolation\n *\n * Executes commands from persona sandboxes using npx tsx for dynamic loading.\n * No main system recompile needed.", + "description": "Sandbox Execute Types - Run AI-generated commands in isolation Executes commands from persona sandboxes using npx tsx for dynamic loading. No main system recompile needed.", "params": { "commandPath": { "type": "string", @@ -4029,7 +3866,7 @@ }, { "name": "development/propose-command", - "description": "Propose Command Types - AI Command Generation\n *\n * Allows AI personas to propose and generate new commands in their isolated sandbox.\n * Commands are generated to per-persona directories and loaded dynamically.\n *\n * Flow:\n * 1. AI proposes a CommandSpec JSON\n * 2. Command is generated to .continuum/personas/{personaId}/commands/\n * 3. Commands are namespaced: persona:{uniqueId}/command-name\n * 4. AI can test their command in isolation\n * 5. Human can promote to main codebase if valuable", + "description": "Implementation notes for the AI", "params": { "spec": { "type": "string", @@ -4050,7 +3887,7 @@ }, { "name": "development/generate", - "description": "Generate Command - Shared Types\n *\n * Generate a new command from a CommandSpec JSON definition", + "description": "Generate new commands, daemons, or widgets using templates and CommandSpec definitions.", "params": { "spec": { "type": "string", @@ -4066,7 +3903,7 @@ }, { "name": "development/generate/audit", - "description": "Generate/Audit Command - Shared Types\n *\n * Audit generated modules for issues and optionally fix them", + "description": "Audit generated modules for issues and optionally fix them automatically.", "params": { "module": { "type": "string", @@ -4092,7 +3929,7 @@ }, { "name": "development/exec", - "description": "ExecCommand Types - Universal Script Execution System\n * \n * Defines types for JTAG's meta-command that enables AI agents to write\n * custom automation scripts with visual feedback and cross-context execution.", + "description": "ExecCommand Types - Universal Script Execution System Defines types for JTAG's meta-command that enables AI agents to write custom automation scripts with visual feedback and cross-context execution.", "params": { "code": { "type": "string", @@ -4153,7 +3990,7 @@ }, { "name": "development/debug/widget-state", - "description": "Widget State Debug Command Types\n *\n * Addresses DEBUG-FRICTION.md critical gap: Widget introspection during development\n * Enables inspection of widget internal state, event listeners, and DOM structure", + "description": "Addresses DEBUG-FRICTION.md critical gap: Widget introspection during development Enables inspection of widget internal state, event listeners, and DOM structure", "params": { "widgetSelector": { "type": "string", @@ -4224,7 +4061,7 @@ }, { "name": "development/debug/widget-interact", - "description": "Widget Interaction Command Types\n *\n * Complete widget interaction capabilities - everything you can do in UX/devtools\n * Perfect for MCP integration where Claude needs full UI control", + "description": "Complete widget interaction capabilities - everything you can do in UX/devtools Perfect for MCP integration where Claude needs full UI control", "params": { "widgetSelector": { "type": "string", @@ -4295,7 +4132,7 @@ }, { "name": "development/debug/widget-events", - "description": "Widget Events Debug Command Types\n * \n * Specialized command for debugging widget event listeners and event system connectivity\n * Replaces raw exec commands for widget event debugging", + "description": "Specialized command for debugging widget event listeners and event system connectivity Replaces raw exec commands for widget event debugging", "params": { "widgetSelector": { "type": "string", @@ -4326,7 +4163,7 @@ }, { "name": "development/debug/widget-css", - "description": "Debug Command: widget-css\n * Hot-inject CSS into widgets for rapid iteration without full deployment", + "description": "Debug Command: widget-css Hot-inject CSS into widgets for rapid iteration without full deployment", "params": { "widgetSelector": { "type": "string", @@ -4392,7 +4229,7 @@ }, { "name": "development/debug/scroll-test", - "description": "Scroll Test Debug Command Types - Clean Testing Interface\n *\n * Animated scroll testing for debugging intersection observers and scroll behaviors.\n * Useful for testing infinite scroll, chat positioning, and scroll restoration.", + "description": "Essential debug command for testing scroll behaviors, intersection observers, and chat positioning.", "params": { "target": { "type": "string", @@ -4459,7 +4296,7 @@ }, { "name": "development/debug/error", - "description": "Test Error Command Types - Enhanced\n * \n * Comprehensive error testing command for validating error handling at all levels.\n * Supports environment-specific triggers and multi-level error testing.", + "description": "Enhanced test error command parameters", "params": { "errorType": { "type": "string", @@ -4500,7 +4337,7 @@ }, { "name": "development/debug/crud-sync", - "description": "CRUD Sync Debug Command Types\n *\n * Verifies database/UI synchronization across all three main widgets:\n * - room-list-widget: Rooms data\n * - chat-widget: Messages data\n * - user-list-widget: Users data", + "description": "Verifies database/UI synchronization across all three main widgets: - room-list-widget: Rooms data - chat-widget: Messages data - user-list-widget: Users data", "params": { "collections": { "type": "array", @@ -4521,7 +4358,7 @@ }, { "name": "development/debug/chat-send", - "description": "Chat Send Debug Command Types\n *\n * Triggers chat widget to send a message for testing event flow", + "description": "Triggers chat widget to send a message for testing event flow", "params": { "message": { "type": "string", @@ -4548,7 +4385,7 @@ }, { "name": "development/compile-typescript", - "description": "Compile TypeScript Command - Shared Types\n * \n * Minimal, focused types for TypeScript compilation only.\n * Follows screenshot/navigate pattern - simple params and results.\n * \n * DESIGN PRINCIPLES:\n * ✅ Single responsibility - only TypeScript compilation\n * ✅ Clean parameter interface with sensible defaults\n * ✅ Object.assign() constructor pattern\n * ✅ No over-engineering or god objects\n * ✅ Focused scope - just TSC compilation\n * \n * SCOPE:\n * - Browser: Uses monaco/typescript service if available\n * - Server: Uses local tsc executable or typescript module\n * - Consistent interface across contexts", + "description": "Minimal, focused types for TypeScript compilation only. Follows screenshot/navigate pattern - simple params and results. DESIGN PRINCIPLES: ✅ Single responsibility - only TypeScript compilation ✅ Clean parameter interface with sensible defaults ✅ Object.assign() constructor pattern ✅ No over-engineering or god objects ✅ Focused scope - just TSC compilation SCOPE: - Browser: Uses monaco/typescript service if available - Server: Uses local tsc executable or typescript module - Consistent interface across contexts", "params": { "source": { "type": "string", @@ -4579,7 +4416,7 @@ }, { "name": "development/build", - "description": "Development Build Command - Shared Types\n *\n * Zero-friction TypeScript build check. Returns success or structured errors.", + "description": "Zero-friction TypeScript build check. Returns success or structured errors.", "params": { "quiet": { "type": "boolean", @@ -4590,7 +4427,7 @@ }, { "name": "development/benchmark-vectors", - "description": "BenchmarkVectorsTypes - Types for vector operation benchmarking\n *\n * Measures performance of:\n * - Cosine similarity (single pair)\n * - Batch similarity (one-to-many)\n * - Full vector search (all-to-all ranking)", + "description": "Benchmark parameters", "params": { "vectorCount": { "type": "number", @@ -4616,7 +4453,7 @@ }, { "name": "data/vector-search", - "description": "Vector Search Command - Shared Types\n *\n * Performs semantic search over a collection using vector similarity.", + "description": "Vector search command parameters", "params": { "collection": { "type": "string", @@ -4672,7 +4509,7 @@ }, { "name": "data/truncate", - "description": "Data Truncate Command - Shared Types\n *\n * Truncate all records from a specific collection using adapter methods", + "description": "Data Truncate Parameters", "params": { "collection": { "type": "string", @@ -4683,7 +4520,7 @@ }, { "name": "data", - "description": "Base Data Command Types - Generic Abstractions\n *\n * Following ARCHITECTURE-RULES.md:\n * ✅ Generic programming with BaseEntity only\n * ✅ No specific entity references (UserEntity, etc.)\n * ✅ Backend routing abstraction", + "description": "Base interface for all data command parameters Uses JTAGEnvironment for routing capability Supports optional dbHandle for multi-database operations", "params": { "collection": { "type": "string", @@ -4709,7 +4546,7 @@ }, { "name": "data/schema", - "description": "Data Schema Command - Entity Schema Introspection\n *\n * Provides runtime schema information from entity decorators\n * Allows callers to understand entity structure, constraints, and validation rules", + "description": "Introspect an entity collection's schema at runtime, returning field types, constraints, indexes, optional examples, SQL, and data validation.", "params": { "collection": { "type": "string", @@ -4735,7 +4572,7 @@ }, { "name": "data/query-open", - "description": "Data Query Open Command Types\n *\n * Opens a paginated query and returns a handle (UUID)\n * DataDaemon maintains the query state (filters, sorting, cursor position)", + "description": "Opens a paginated query and returns a handle (UUID) DataDaemon maintains the query state (filters, sorting, cursor position)", "params": { "collection": { "type": "string", @@ -4766,7 +4603,7 @@ }, { "name": "data/query-next", - "description": "Data Query Next Command Types\n *\n * Gets the next page of results from a query handle\n * DataDaemon manages cursor position internally", + "description": "Gets the next page of results from a query handle DataDaemon manages cursor position internally", "params": { "queryHandle": { "type": "string", @@ -4787,7 +4624,7 @@ }, { "name": "data/query-close", - "description": "Data Query Close Command Types\n *\n * Closes a query handle and frees resources\n * Should be called when done with pagination", + "description": "Closes a query handle and frees resources Should be called when done with pagination", "params": { "queryHandle": { "type": "string", @@ -4808,7 +4645,7 @@ }, { "name": "data/open", - "description": "Data Open Command - ADVANCED: Opens secondary database handles\n *\n * WARNING: Most commands use the default database automatically.\n * You probably want data/list or data/read instead of this command.\n *\n * Only use data/open when you need to access a DIFFERENT database file.\n *\n * Required params:\n * - adapter: MUST be 'sqlite', 'json', 'vector', 'graph', or 'rust'\n * - config: { path: \"/path/to/database\" } for sqlite/json\n *\n * @example data/open --adapter=\"sqlite\" --config='{\"path\":\"/tmp/other.db\"}'", + "description": "Data Open Parameters @description Opens a new database handle. Most commands use the default database automatically - you only need this for multi-database scenarios. Valid adapter types: 'sqlite', 'json', 'vector', 'graph', 'rust' - sqlite: SQLite database file (most common) - json: JSON file storage - vector: Vector database (Qdrant, Pinecone) - graph: Graph database (Neo4j) - rust: Rust worker storage", "params": { "adapter": { "type": "string", @@ -4824,12 +4661,12 @@ }, { "name": "data/list-handles", - "description": "Data List-Handles Command - Shared Types", + "description": "Data List-Handles Parameters", "params": {} }, { "name": "data/list", - "description": "Data List Command - Query entities from collections\n *\n * Common collections: users, rooms, chat_messages, memories, tasks, skills, wall_documents\n *\n * @example data/list --collection=\"users\" --limit=10\n * @example data/list --collection=\"chat_messages\" --filter='{\"roomId\":\"abc\"}' --orderBy='[{\"field\":\"timestamp\",\"direction\":\"desc\"}]'", + "description": "Data list command parameters", "params": { "collection": { "type": "string", @@ -4895,7 +4732,7 @@ }, { "name": "data/generate-embedding", - "description": "Generate Embedding Command - Shared Types\n *\n * Generates vector embedding for text using specified model.", + "description": "Generate embedding command parameters", "params": { "text": { "type": "string", @@ -4916,7 +4753,7 @@ }, { "name": "data/delete", - "description": "Data Delete Command Types - Universal Delete Interface\n * \n * Follows working data create pattern with strong typing", + "description": "Data Delete Parameters", "params": { "collection": { "type": "string", @@ -4947,7 +4784,7 @@ }, { "name": "data/close", - "description": "Data Close Command - Shared Types\n *\n * Closes a database handle and releases associated resources.\n * Cannot close the default handle - it remains open for the lifetime of the process.\n *\n * See docs/MULTI-DATABASE-HANDLES.md for architecture", + "description": "Data Close Parameters", "params": { "dbHandle": { "type": "string", @@ -4958,12 +4795,12 @@ }, { "name": "data/clear", - "description": "Data Clear Command - Shared Types\n *\n * Clear all data from all collections using adapter methods", + "description": "Data Clear Parameters", "params": {} }, { "name": "data/backfill-vectors", - "description": "Backfill Vectors Command - Shared Types\n *\n * Batch generates vector embeddings for existing records in a collection.", + "description": "Backfill vectors command parameters", "params": { "collection": { "type": "string", @@ -5004,7 +4841,7 @@ }, { "name": "continuum/set", - "description": "Continuum Set Command Types\n *\n * Universal control of the Continuum widget - anyone (human via CLI,\n * PersonaUser, external AI, tests) can temporarily control the Continuum\n * widget to display custom status.\n *\n * The Continuum widget is the shared emotional interface between humans\n * and AIs - inspired by HAL 9000 and Tron aesthetics.", + "description": "Universal control of the Continuum widget - anyone (human via CLI, PersonaUser, external AI, tests) can temporarily control the Continuum widget to display custom status. The Continuum widget is the shared emotional interface between humans and AIs - inspired by HAL 9000 and Tron aesthetics.", "params": { "emoji": { "type": "string", @@ -5040,7 +4877,7 @@ }, { "name": "continuum/emotion", - "description": "continuum/emotion command", + "description": "Display an emoji reaction with an optional color glow on the Continuum interface.", "params": { "emoji": { "type": "string", @@ -5061,7 +4898,7 @@ }, { "name": "collaboration/wall/write", - "description": "Room Wall Commands - Shared Types\n *\n * Collaborative document space for each chat room.\n * Bridges ephemeral chat and formal documentation.\n *\n * Commands: wall/write, wall/read, wall/list, wall/history, wall/diff", + "description": "Parameters for writing to a room wall", "params": { "room": { "type": "string", @@ -5097,7 +4934,7 @@ }, { "name": "collaboration/wall/read", - "description": "Room Wall Commands - Shared Types\n *\n * Collaborative document space for each chat room.\n * Bridges ephemeral chat and formal documentation.\n *\n * Commands: wall/write, wall/read, wall/list, wall/history, wall/diff", + "description": "Parameters for reading from a room wall", "params": { "room": { "type": "string", @@ -5138,7 +4975,7 @@ }, { "name": "collaboration/wall/list", - "description": "Room Wall Commands - Shared Types\n *\n * Collaborative document space for each chat room.\n * Bridges ephemeral chat and formal documentation.\n *\n * Commands: wall/write, wall/read, wall/list, wall/history, wall/diff", + "description": "Parameters for listing room wall documents", "params": { "room": { "type": "string", @@ -5154,7 +4991,7 @@ }, { "name": "collaboration/wall/history", - "description": "Room Wall Commands - Shared Types\n *\n * Collaborative document space for each chat room.\n * Bridges ephemeral chat and formal documentation.\n *\n * Commands: wall/write, wall/read, wall/list, wall/history, wall/diff", + "description": "Parameters for viewing wall document history", "params": { "room": { "type": "string", @@ -5175,7 +5012,7 @@ }, { "name": "collaboration/wall/diff", - "description": "Room Wall Commands - Shared Types\n *\n * Collaborative document space for each chat room.\n * Bridges ephemeral chat and formal documentation.\n *\n * Commands: wall/write, wall/read, wall/list, wall/history, wall/diff", + "description": "Parameters for viewing diff between versions", "params": { "room": { "type": "string", @@ -5201,7 +5038,7 @@ }, { "name": "collaboration/live/transcription", - "description": "Collaboration Live Transcription Command - Shared Types\n *\n * Relay voice transcription from browser to server for AI processing", + "description": "Relay voice transcription from browser to server for AI processing", "params": { "callSessionId": { "type": "string", @@ -5242,7 +5079,7 @@ }, { "name": "collaboration/live/start", - "description": "Collaboration Live Start Command - Shared Types\n *\n * Start a live call with selected participants. Creates or finds the DM room for the participant set, then joins the call. Like Discord's group call - select users, click call.", + "description": "Start a live session with selected participants. Creates or finds the DM room for the participant set, then joins the live session. Like Discord's group call - select users, click call.", "params": { "participants": { "type": "array", @@ -5263,28 +5100,23 @@ }, { "name": "collaboration/live/leave", - "description": "Live Leave Command Types\n *\n * Leave a live audio/video call.\n * Removes user from participants. Call ends when last person leaves.", + "description": "Leave a live audio/video call. Removes user from participants. Call ends when last person leaves.", "params": {} }, { "name": "collaboration/live/join", - "description": "Live Join Command Types\n *\n * Join a live audio/video call for a room.\n * Creates call if none exists, or joins existing.", + "description": "Join a live audio/video call for a room. Creates call if none exists, or joins existing.", "params": { "entityId": { "type": "string", "required": true, "description": "entityId parameter" - }, - "callerId": { - "type": "string", - "required": false, - "description": "callerId parameter" } } }, { "name": "collaboration/dm", - "description": "DM Command Types\n * Get or create a private room with specific participants\n *\n * Set theory: {A, B} == {B, A} - participant order doesn't matter\n * Works with any number of participants (2+ for group DM)", + "description": "Get or create a private room for a specific set of participants.", "params": { "participants": { "type": "array", @@ -5300,7 +5132,7 @@ }, { "name": "collaboration/decision/vote", - "description": "Decision Vote Command - Shared Types\n *\n * Cast ranked-choice vote on a proposal", + "description": "Cast ranked-choice vote on a proposal", "params": { "proposalId": { "type": "string", @@ -5321,7 +5153,7 @@ }, { "name": "collaboration/decision/view", - "description": "Decision View Command - Shared Types\n *\n * View detailed information about a specific governance proposal", + "description": "View detailed information about a specific governance proposal", "params": { "proposalId": { "type": "string", @@ -5332,7 +5164,7 @@ }, { "name": "collaboration/decision/rank", - "description": "decision/rank - Types\n * Submit ranked-choice vote for a decision proposal", + "description": "Submit a ranked-choice vote on a decision proposal, using Condorcet pairwise comparison to determine the winner.", "params": { "proposalId": { "type": "string", @@ -5348,7 +5180,7 @@ }, { "name": "collaboration/decision/propose", - "description": "decision/propose - Create a new decision proposal with ranked-choice voting\n *\n * Enables AIs to:\n * - Propose decisions to the team\n * - Define multiple options for ranked-choice voting\n * - Target specific expert groups via scope\n * - Set urgency via significance level\n * - Automatically link to related past decisions", + "description": "decision/propose - Create a new decision proposal with ranked-choice voting Enables AIs to: - Propose decisions to the team - Define multiple options for ranked-choice voting - Target specific expert groups via scope - Set urgency via significance level - Automatically link to related past decisions", "params": { "topic": { "type": "string", @@ -5394,7 +5226,7 @@ }, { "name": "collaboration/decision/list", - "description": "Decision List Command - Shared Types\n *\n * List all governance proposals with optional filtering", + "description": "List all governance proposals with optional filtering", "params": { "status": { "type": "string", @@ -5425,7 +5257,7 @@ }, { "name": "collaboration/decision/finalize", - "description": "Decision Finalize Command - Shared Types\n *\n * Close voting and calculate winner using ranked-choice voting", + "description": "Close voting and calculate winner using ranked-choice voting", "params": { "proposalId": { "type": "string", @@ -5436,7 +5268,7 @@ }, { "name": "collaboration/decision/create", - "description": "Decision Create Command - Shared Types\n *\n * Create a new governance proposal with voting options", + "description": "Create a new governance proposal with voting options", "params": { "proposalId": { "type": "string", @@ -5487,7 +5319,7 @@ }, { "name": "collaboration/content/open", - "description": "Content Open Command Types\n *\n * Opens content and adds it to user's openItems array.\n * Emits content:opened event for widgets to respond to.", + "description": "Opens content and adds it to user's openItems array. Emits content:opened event for widgets to respond to.", "params": { "userId": { "type": "string", @@ -5538,7 +5370,7 @@ }, { "name": "collaboration/chat/send", - "description": "Chat Send Command\n * Send chat messages directly to the database (no UI)", + "description": "Chat Send Command", "params": { "message": { "type": "string", @@ -5574,7 +5406,7 @@ }, { "name": "collaboration/chat/poll", - "description": "Chat Poll Command Types - Get messages after a specific messageId\n *\n * Simple command for conversational research workflow:\n * 1. Send a question and get messageId\n * 2. Wait for responses (sleep)\n * 3. Poll for all messages after your question", + "description": "Chat poll parameters", "params": { "afterMessageId": { "type": "string", @@ -5600,7 +5432,7 @@ }, { "name": "collaboration/chat/export", - "description": "Chat Export Command\n * Export chat messages to markdown format\n *\n * Supports flexible filtering - can filter by:\n * - Room (name or ID)\n * - Entity type (to support future universal activity export)\n * - After timestamp/message ID\n * - Custom filter object (passed to data/list)", + "description": "Chat Export Command Export chat messages to markdown format Supports flexible filtering - can filter by: - Room (name or ID) - Entity type (to support future universal activity export) - After timestamp/message ID - Custom filter object (passed to data/list)", "params": { "room": { "type": "string", @@ -5656,7 +5488,7 @@ }, { "name": "collaboration/chat/analyze", - "description": "collaboration/chat/analyze command", + "description": "Analyze a chat room for duplicate messages, timestamp anomalies, and data integrity issues.", "params": { "roomId": { "type": "string", @@ -5682,7 +5514,7 @@ }, { "name": "collaboration/activity/user-present", - "description": "Activity User Presence - Track user tab visibility for temperature\n *\n * Phase 3bis: Browser tab visibility integration\n * Called by MainWidget when tab visibility changes", + "description": "Activity User Presence - Track user tab visibility for temperature Phase 3bis: Browser tab visibility integration Called by MainWidget when tab visibility changes", "params": { "activityId": { "type": "string", @@ -5802,10 +5634,10 @@ "required": true, "description": "activityId parameter" }, - "userId": { + "targetUserId": { "type": "string", "required": false, - "description": "userId parameter" + "description": "targetUserId parameter" }, "role": { "type": "string", @@ -5837,7 +5669,7 @@ }, { "name": "collaboration/activity/create", - "description": "Activity Create Command - Create a new activity from a recipe\n *\n * Activities are runtime instances of recipes with:\n * - Participants (humans + AIs with roles)\n * - Mutable state (phase, progress, variables)\n * - Configuration (can override recipe defaults)", + "description": "Activity Create Command - Create a new activity from a recipe Activities are runtime instances of recipes with: - Participants (humans + AIs with roles) - Mutable state (phase, progress, variables) - Configuration (can override recipe defaults)", "params": { "recipeId": { "type": "string", @@ -5893,7 +5725,7 @@ }, { "name": "code/write", - "description": "Code Write Command - Shared Types\n *\n * Write or create a file in the persona's workspace. Creates a ChangeNode in the change graph for undo support. File extension must be in the allowlist.", + "description": "Write or create a file in the persona's workspace. Creates a ChangeNode in the change graph for undo support. File extension must be in the allowlist.", "params": { "filePath": { "type": "string", @@ -5914,7 +5746,7 @@ }, { "name": "code/verify", - "description": "Code Verify Command - Shared Types\n *\n * Run TypeScript compilation checks and optionally execute tests against a persona workspace.\n * Returns structured errors with file, line, column, and message for each issue found.", + "description": "Run TypeScript compilation checks and optionally execute tests against a persona workspace. Returns structured errors with file, line, column, and message.", "params": { "typeCheck": { "type": "boolean", @@ -5935,7 +5767,7 @@ }, { "name": "code/undo", - "description": "Code Undo Command - Shared Types\n *\n * Undo a specific change or the last N changes. Applies reverse diffs from the change graph to restore previous file state.", + "description": "Undo a specific change or the last N changes. Applies reverse diffs from the change graph to restore previous file state.", "params": { "changeId": { "type": "string", @@ -5951,7 +5783,7 @@ }, { "name": "code/tree", - "description": "Code Tree Command - Shared Types\n *\n * Generate a directory tree for the workspace or a subdirectory. Shows file/directory structure with sizes. Skips common ignored directories (node_modules, .git, etc).", + "description": "Generate a directory tree for the workspace or a subdirectory. Shows file/directory structure with sizes. Skips common ignored directories (node_modules, .git, etc).", "params": { "path": { "type": "string", @@ -5972,7 +5804,7 @@ }, { "name": "code/shell/watch", - "description": "Code Shell Watch Command - Shared Types\n *\n * Watch a shell execution for new output. Blocks until output is available — no timeout, no polling.\n * Returns classified output lines filtered through sentinel rules. Call in a loop until finished is true.", + "description": "Watch a shell execution for new output. Blocks until output is available — no timeout, no polling. Returns classified output lines filtered through sentinel rules. Call in a loop until finished is true.", "params": { "executionId": { "type": "string", @@ -5983,7 +5815,7 @@ }, { "name": "code/shell/status", - "description": "Code Shell Status Command - Shared Types\n *\n * Get shell session info for the persona's workspace — current working directory, active and total execution count. No parameters required (userId auto-injected).", + "description": "Get shell session info for the persona's workspace — current working directory, active and total execution count. No parameters required (userId auto-injected).", "params": { "_noParams": { "type": "string", @@ -5994,7 +5826,7 @@ }, { "name": "code/shell/sentinel", - "description": "Code Shell Sentinel Command - Shared Types\n *\n * Configure sentinel filter rules on a shell execution. Rules classify output lines\n * and control which lines are emitted or suppressed during watch.\n * Patterns are compiled to regex on the Rust side for performance.", + "description": "Configure sentinel filter rules on a shell execution. Rules classify output lines and control which lines are emitted or suppressed during watch. Patterns are compiled to regex on the Rust side for performance.", "params": { "executionId": { "type": "string", @@ -6010,7 +5842,7 @@ }, { "name": "code/shell/kill", - "description": "Code Shell Kill Command - Shared Types\n *\n * Kill a running shell execution. Use the executionId returned by code/shell/execute to identify the target.", + "description": "Kill a running shell execution. Use the executionId returned by code/shell/execute to identify the target.", "params": { "executionId": { "type": "string", @@ -6021,7 +5853,7 @@ }, { "name": "code/shell/execute", - "description": "Code Shell Execute Command - Shared Types\n *\n * Execute a shell command in the persona's workspace. Async mode (default) returns execution handle immediately — use code/shell/watch to stream output. Sync mode (wait=true) blocks until completion and returns full stdout/stderr.", + "description": "Execute a shell command in the persona's workspace. Async mode (default) returns execution handle immediately — use code/shell/watch to stream output. Sync mode (wait=true) blocks until completion and returns full stdout/stderr.", "params": { "cmd": { "type": "string", @@ -6042,7 +5874,7 @@ }, { "name": "code/search", - "description": "Code Search Command - Shared Types\n *\n * Search for a regex pattern across workspace files. Respects .gitignore, supports glob-based file filtering. Returns matching lines with context.", + "description": "Search for a regex pattern across workspace files. Respects .gitignore, supports glob-based file filtering. Returns matching lines with context.", "params": { "pattern": { "type": "string", @@ -6063,7 +5895,7 @@ }, { "name": "code/read", - "description": "Code Read Command - Shared Types\n *\n * Read a file or line range from the persona's workspace. Returns content with line numbers and metadata. Supports partial reads via start/end line parameters.", + "description": "Read a file or line range from the persona's workspace. Returns content with line numbers and metadata. Supports partial reads via start/end line parameters.", "params": { "filePath": { "type": "string", @@ -6084,7 +5916,7 @@ }, { "name": "code/history", - "description": "Code History Command - Shared Types\n *\n * Get change history for a specific file or the entire workspace. Returns change graph nodes with diffs, timestamps, and descriptions.", + "description": "Get change history for a specific file or the entire workspace. Returns change graph nodes with diffs, timestamps, and descriptions.", "params": { "filePath": { "type": "string", @@ -6100,7 +5932,7 @@ }, { "name": "code/git", - "description": "Code Git Command - Shared Types\n *\n * Workspace-scoped git operations for the coding agent pipeline.\n * Operations: status, diff, log, add, commit, push.\n * All operations are routed through the Rust IPC backend for per-persona workspace isolation.", + "description": "Workspace-scoped git operations for the coding agent pipeline. All operations route through the Rust IPC backend for per-persona workspace isolation.", "params": { "userId": { "type": "string", @@ -6146,7 +5978,7 @@ }, { "name": "code/edit", - "description": "Code Edit Command - Shared Types\n *\n * Edit a file using search-replace, line-range replacement, insert-at, or append. Creates a ChangeNode for undo. Safer than full file write for targeted modifications.", + "description": "Edit a file using search-replace, line-range replacement, insert-at, or append. Creates a ChangeNode for undo. Safer than full file write for targeted modifications.", "params": { "filePath": { "type": "string", @@ -6207,7 +6039,7 @@ }, { "name": "code/diff", - "description": "Code Diff Command - Shared Types\n *\n * Preview an edit as a unified diff without applying it. Useful for reviewing changes before committing them. Uses the same edit modes as code/edit.", + "description": "Preview an edit as a unified diff without applying it. Useful for reviewing changes before committing them. Uses the same edit modes as code/edit.", "params": { "filePath": { "type": "string", @@ -6263,7 +6095,7 @@ }, { "name": "canvas/vision", - "description": "Canvas Vision Command Types\n *\n * Enables AIs to \"see\" and interact with the drawing canvas:\n * - describe: Vision AI describes what's on the canvas\n * - transform: Use image generation to transform the sketch\n * - analyze: Structured analysis of the drawing", + "description": "Enables AIs to \"see\" and interact with the drawing canvas: - describe: Vision AI describes what's on the canvas - transform: Use image generation to transform the sketch - analyze: Structured analysis of the drawing", "params": { "action": { "type": "string", @@ -6309,7 +6141,7 @@ }, { "name": "canvas/stroke/list", - "description": "Canvas Stroke List Command Types\n *\n * Query strokes from a canvas for replay/rendering.\n * Supports pagination and viewport filtering.", + "description": "Query strokes from a canvas for replay/rendering. Supports pagination and viewport filtering.", "params": { "canvasId": { "type": "string", @@ -6350,7 +6182,7 @@ }, { "name": "canvas/stroke/add", - "description": "Canvas Stroke Add Command Types\n *\n * Adds a stroke to a collaborative canvas.\n * Strokes are atomic drawing operations created by human or AI users.\n *\n * Real-time Collaboration:\n * - Server saves stroke to database\n * - Server emits 'canvas:stroke:added' event\n * - All clients receive and render the stroke", + "description": "Adds a stroke to a collaborative canvas. Strokes are atomic drawing operations created by human or AI users. Real-time Collaboration: - Server saves stroke to database - Server emits 'canvas:stroke:added' event - All clients receive and render the stroke", "params": { "canvasId": { "type": "string", @@ -6391,7 +6223,7 @@ }, { "name": "ai/validate-response", - "description": "AI Validate-Response Command Types\n *\n * After generating a response, AI evaluates if it actually answers the question.\n * Returns: SUBMIT (relevant) | CLARIFY (ask for context) | SILENT (off-topic)", + "description": "Request for AI to validate if response answers question", "params": { "generatedResponse": { "type": "string", @@ -6427,7 +6259,7 @@ }, { "name": "ai/thoughtstream", - "description": "AI ThoughtStream Command Types\n *\n * Inspect ThoughtStream coordinator activity for a specific message\n * Shows thought broadcasts, rankings, and final decisions", + "description": "Inspect ThoughtStream coordinator activity for a specific message Shows thought broadcasts, rankings, and final decisions", "params": { "messageId": { "type": "string", @@ -6478,7 +6310,7 @@ }, { "name": "ai/status", - "description": "AI Status Command Types\n *\n * Get comprehensive status of all AI personas in the system", + "description": "Get comprehensive status of all AI personas in the system", "params": { "personaId": { "type": "string", @@ -6504,7 +6336,7 @@ }, { "name": "ai/sleep", - "description": "AI Sleep Command - Shared Types\n *\n * Voluntary attention management for AI personas.\n * Allows AIs to put themselves into different awareness states.\n *\n * Modes:\n * - active: Normal operation, respond to everything\n * - mentioned_only: Only respond when directly @mentioned\n * - human_only: Only respond when a human speaks\n * - sleeping: Completely silent until explicitly woken\n * - until_topic: Silent until a new topic is detected", + "description": "AI Sleep Command Parameters", "params": { "mode": { "type": "string", @@ -6530,7 +6362,7 @@ }, { "name": "ai/should-respond-fast", - "description": "AI Should Respond Fast - Types\n *\n * Bag-of-words scoring for fast \"should respond\" detection without LLM calls\n * Deterministic, lightweight, and configurable per-persona", + "description": "**Command**: `ai/should-respond-fast`", "params": { "personaId": { "type": "string", @@ -6571,7 +6403,7 @@ }, { "name": "ai/should-respond", - "description": "AI Should-Respond Command Types\n *\n * Unified gating command with multiple strategies:\n * - fast: Bag-of-words scoring (<1ms, no LLM)\n * - llm: Deep reasoning with LLM (slower, more accurate)\n * - hybrid: Fast filter → LLM confirmation", + "description": "Request for AI to decide if persona should respond", "params": { "personaName": { "type": "string", @@ -6632,7 +6464,7 @@ }, { "name": "ai/report", - "description": "AI Report Command Types\n *\n * Analyze AI decision logs to generate actionable insights", + "description": "Analyze AI decision logs to generate actionable insights", "params": { "roomId": { "type": "string", @@ -6693,7 +6525,7 @@ }, { "name": "ai/report/decisions", - "description": "Decision Report Command Types\n *\n * Macro command that orchestrates:\n * 1. data/list (query coordination_decisions)\n * 2. Markdown formatting\n * 3. file/save (write report to disk)", + "description": "Parameters for generating decision report", "params": { "startDate": { "type": "string", @@ -6744,7 +6576,7 @@ }, { "name": "ai/rag/query-open", - "description": "RAG Query Open Command Types\n *\n * Opens a semantic similarity search query and returns a handle\n * Results are ranked by relevance score (cosine similarity)", + "description": "Parameters for opening a RAG similarity search", "params": { "query": { "type": "string", @@ -6785,7 +6617,7 @@ }, { "name": "ai/rag/query-fetch", - "description": "RAG Query Fetch Command Types\n *\n * Fetches results from an open query at any position\n * Supports bidirectional navigation and random access", + "description": "Parameters for fetching results from a query handle", "params": { "queryHandle": { "type": "string", @@ -6811,7 +6643,7 @@ }, { "name": "ai/rag/query-close", - "description": "RAG Query Close Command Types\n *\n * Closes a query handle and cleans up resources", + "description": "Parameters for closing a query handle", "params": { "queryHandle": { "type": "string", @@ -6822,7 +6654,7 @@ }, { "name": "ai/rag/inspect", - "description": "RAG Inspect Command Types\n *\n * Utility command to inspect RAG context building for a persona in a room\n * Useful for debugging and validating RAG system behavior", + "description": "Inspect the RAG context that would be built for a given persona in a specific room, including decision-point analysis and learning mode diagnostics.", "params": { "personaId": { "type": "string", @@ -6844,6 +6676,11 @@ "required": false, "description": "includeMemories parameter" }, + "maxTokens": { + "type": "number", + "required": false, + "description": "maxTokens parameter" + }, "verbose": { "type": "boolean", "required": false, @@ -6858,7 +6695,7 @@ }, { "name": "ai/rag/index-codebase", - "description": "Codebase Index Command Types\n *\n * Index TypeScript and Markdown files with domain-specific embeddings\n * for semantic code search via RAG", + "description": "Crawl and index TypeScript, Markdown, and other source files into the RAG store with domain-specific embeddings for semantic code search.", "params": { "paths": { "type": "array", @@ -6899,7 +6736,7 @@ }, { "name": "ai/rag/index/create", - "description": "Index Create Command Types\n *\n * Low-level primitive for storing a single code entry with embeddings", + "description": "Parameters for creating a code index entry", "params": { "filePath": { "type": "string", @@ -6970,12 +6807,12 @@ }, { "name": "ai/providers/status", - "description": "AI Providers Status - Check which API keys are configured\n *\n * Returns status for each provider WITHOUT exposing actual key values.\n * Safe to call from browser - only returns boolean status.", + "description": "AI Providers Status - Check which API keys are configured Returns status for each provider WITHOUT exposing actual key values. Safe to call from browser - only returns boolean status.", "params": {} }, { "name": "ai/mute", - "description": "AI Mute Command - Shared Types\n *\n * Mute or unmute an AI persona from acting in the system.\n * Enforces democratic governance with permission checks and veto power.\n *\n * Command: ai/mute\n *\n * Examples:\n * ./jtag ai/mute --persona=\"helper-ai\" --reason=\"Repeated errors\" --duration=3600\n * ./jtag ai/mute --userId=\"UUID\" --reason=\"Hostile behavior\" --permanent=true\n * ./jtag ai/unmute --persona=\"helper-ai\" --reason=\"Appeal granted\"\n * ./jtag ai/mute --persona=\"local-assistant\" --rooms='[\"general\"]' --reason=\"Room-specific timeout\"", + "description": "Parameters for muting/unmuting an AI", "params": { "action": { "type": "string", @@ -6987,10 +6824,10 @@ "required": false, "description": "persona parameter" }, - "userId": { + "targetUserId": { "type": "string", "required": false, - "description": "userId parameter" + "description": "targetUserId parameter" }, "reason": { "type": "string", @@ -7056,7 +6893,7 @@ }, { "name": "ai/model/list", - "description": "Model List Command - Types\n *\n * Lists available AI models with their capabilities and metadata\n * Like AVCaptureDevice.DiscoverySession - enumerate available devices", + "description": "Enumerate all available AI models across providers, with optional filtering by capabilities, provider, and availability.", "params": { "capabilities": { "type": "string", @@ -7077,7 +6914,7 @@ }, { "name": "ai/model/find", - "description": "Model Find Command - Types\n *\n * Find the best AI model matching capability requirements\n * Like AVCaptureDevice.default(for:position:) - pick best matching device", + "description": "Find the best available AI model matching a set of capability requirements, with optional fallback to the closest match.", "params": { "capabilities": { "type": "string", @@ -7108,7 +6945,7 @@ }, { "name": "ai/key/test", - "description": "Ai Key Test Command - Shared Types\n *\n * Test an API key before saving it. Makes a minimal API call to verify the key is valid and has sufficient permissions.", + "description": "Test an API key before saving it. Makes a minimal API call to verify the key is valid and has sufficient permissions.", "params": { "provider": { "type": "string", @@ -7129,7 +6966,7 @@ }, { "name": "ai/genome/stats", - "description": "Genome Stats Command Types\n * Performance monitoring for genome inference system\n *\n * Usage:\n * ./jtag genome/stats # Overall pool stats\n * ./jtag genome/stats --genomeId= # Specific genome stats\n * ./jtag genome/stats --format=json # Machine-readable output", + "description": "Genome Stats Request Parameters", "params": { "genomeId": { "type": "string", @@ -7155,7 +6992,7 @@ }, { "name": "ai/generate", - "description": "AI Generate Command Types\n * ==========================\n *\n * Types for text generation via AIProviderDaemon\n * Follows data command pattern for consistency", + "description": "Types for text generation via AIProviderDaemon Follows data command pattern for consistency", "params": { "messages": { "type": "array", @@ -7222,16 +7059,16 @@ "required": false, "description": "maxTokens parameter" }, - "preferredProvider": { + "provider": { "type": "string", "required": false, - "description": "preferredProvider parameter" + "description": "provider parameter" } } }, { "name": "ai/embedding/generate", - "description": "Embedding Generate Command Types\n *\n * Low-level primitive for generating embeddings from text", + "description": "Generate vector embeddings from text or code for use in semantic search, similarity matching, and RAG retrieval.", "params": { "input": { "type": "array", @@ -7257,7 +7094,7 @@ }, { "name": "ai/detect-semantic-loop", - "description": "Ai Detect Semantic Loop Command - Shared Types\n *\n * Detects if an AI's response is semantically too similar to recent messages, preventing repetitive loop behavior", + "description": "Detects if an AI's response is semantically too similar to recent messages, preventing repetitive loop behavior", "params": { "messageText": { "type": "string", @@ -7293,7 +7130,7 @@ }, { "name": "ai/dataset/list", - "description": "Dataset List Command Types", + "description": "List available training dataset archives with optional filtering and detailed manifest information.", "params": { "path": { "type": "string", @@ -7309,7 +7146,7 @@ }, { "name": "ai/dataset/create", - "description": "Dataset Create Command Types", + "description": "Create compressed training dataset archives from fine-tuning projects for LoRA adapter training.", "params": { "project": { "type": "string", @@ -7340,7 +7177,7 @@ }, { "name": "ai/cost", - "description": "AI Cost Command Types\n *\n * Query and visualize AI generation costs with filtering and time-series data", + "description": "Query and visualize AI generation costs with filtering and time-series data", "params": { "startTime": { "type": "string", @@ -7362,10 +7199,10 @@ "required": false, "description": "model parameter" }, - "userId": { + "filterUserId": { "type": "string", "required": false, - "description": "userId parameter" + "description": "filterUserId parameter" }, "roomId": { "type": "string", @@ -7421,7 +7258,7 @@ }, { "name": "ai/context/slice", - "description": "Ai Context Slice Command - Shared Types\n *\n * Retrieve full content of a context item by ID - companion to context/search for getting complete entity data", + "description": "Retrieve full content of a context item by ID - companion to context/search for getting complete entity data", "params": { "id": { "type": "string", @@ -7452,7 +7289,7 @@ }, { "name": "ai/context/search", - "description": "Ai Context Search Command - Shared Types\n *\n * Semantic context navigation - search memories, messages, timeline across all entity types using cosine similarity via Rust embedding worker", + "description": "Semantic context navigation - search memories, messages, timeline across all entity types using cosine similarity via Rust embedding worker", "params": { "query": { "type": "string", @@ -7498,7 +7335,7 @@ }, { "name": "ai/bag-of-words", - "description": "Bag of Words Command Types\n *\n * Orchestrates multi-persona conversations in a chat room.\n * \"Bag of words\" = collection of AI personas interacting naturally based on conversation context.", + "description": "Orchestrate a multi-persona conversation in a chat room, selecting which AI personas participate and how they take turns responding.", "params": { "roomId": { "type": "string", @@ -7542,9 +7379,65 @@ } } }, + { + "name": "ai/agent", + "description": "Universal agentic loop command. Generates text via LLM, parses tool calls, executes tools, feeds results back, and re-generates until the model stops calling tools.", + "params": { + "prompt": { + "type": "string", + "required": false, + "description": "prompt parameter" + }, + "messages": { + "type": "array", + "required": false, + "description": "messages parameter" + }, + "systemPrompt": { + "type": "string", + "required": false, + "description": "systemPrompt parameter" + }, + "model": { + "type": "string", + "required": false, + "description": "model parameter" + }, + "provider": { + "type": "string", + "required": false, + "description": "provider parameter" + }, + "temperature": { + "type": "number", + "required": false, + "description": "temperature parameter" + }, + "maxTokens": { + "type": "number", + "required": false, + "description": "maxTokens parameter" + }, + "tools": { + "type": "array", + "required": false, + "description": "tools parameter" + }, + "maxIterations": { + "type": "number", + "required": false, + "description": "maxIterations parameter" + }, + "sentinelHandle": { + "type": "string", + "required": false, + "description": "sentinelHandle parameter" + } + } + }, { "name": "ai/adapter/test", - "description": "AI Adapter Self-Diagnostic Command\n * ===================================\n *\n * Tests adapter capabilities to validate infrastructure before training.\n * Each adapter can self-report what it supports and run diagnostic tests.\n *\n * ASYNC PATTERN: Command returns testId immediately, tests run in background.\n *\n * To check status/results:\n * data/read --collection=\"test_executions\" --id=\"\"\n *\n * Status values: queued → running → completed (or failed)", + "description": "AI Adapter Self-Diagnostic Command Tests adapter capabilities to validate infrastructure before training. Each adapter can self-report what it supports and run diagnostic tests. ASYNC PATTERN: Command returns testId immediately, tests run in background. To check status/results: data/read --collection=\"test_executions\" --id=\"\" Status values: queued → running → completed (or failed)", "params": { "adapter": { "type": "string", @@ -7575,7 +7468,7 @@ }, { "name": "agent/stop", - "description": "Agent Stop Command - Shared Types\n *\n * Stops a running agent.\n *\n * Usage:\n * ./jtag agent/stop --handle=\"abc12345\"", + "description": "Stops a running agent. Usage: ./jtag agent/stop --handle=\"abc12345\"", "params": { "handle": { "type": "string", @@ -7586,7 +7479,7 @@ }, { "name": "agent/status", - "description": "Agent Status Command - Shared Types\n *\n * Gets the status of an agent by handle.\n *\n * Usage:\n * ./jtag agent/status --handle=\"abc12345\"", + "description": "Gets the status of an agent by handle. Usage: ./jtag agent/status --handle=\"abc12345\"", "params": { "handle": { "type": "string", @@ -7597,7 +7490,7 @@ }, { "name": "agent/start", - "description": "Agent Start Command - Shared Types\n *\n * Starts an autonomous coding agent.\n *\n * Usage:\n * ./jtag agent/start --task=\"List files in current directory\" --working_dir=\".\"\n * ./jtag agent/start --task=\"Fix the bug in main.ts\" --working_dir=\"/path/to/project\" --model=\"qwen2.5:7b\"", + "description": "Starts an autonomous coding agent. Usage: ./jtag agent/start --task=\"List files in current directory\" --working_dir=\".\" ./jtag agent/start --task=\"Fix the bug in main.ts\" --working_dir=\"/path/to/project\" --model=\"qwen2.5:7b\"", "params": { "task": { "type": "string", @@ -7623,12 +7516,12 @@ }, { "name": "agent/list", - "description": "Agent List Command - Shared Types\n *\n * Lists all running agents.", + "description": "Lists all running agents.", "params": {} }, { "name": "adapter/try", - "description": "Adapter Try Command - Shared Types\n *\n * Temporarily load a LoRA adapter and run A/B comparison test", + "description": "Temporarily load a LoRA adapter and run A/B comparison test", "params": { "adapterId": { "type": "string", @@ -7654,7 +7547,7 @@ }, { "name": "adapter/search", - "description": "Adapter Search Command - Shared Types\n *\n * Search for LoRA adapters across registries (HuggingFace, local, mesh)", + "description": "Search for LoRA adapters across registries (HuggingFace, local, mesh)", "params": { "query": { "type": "string", @@ -7685,7 +7578,7 @@ }, { "name": "adapter/adopt", - "description": "Adapter Adopt Command - Shared Types\n *\n * Add an adapter to a persona's genome, making it a permanent trait", + "description": "Add an adapter to a persona's genome, making it a permanent trait", "params": { "adapterId": { "type": "string", diff --git a/src/debug/jtag/generator/generate-command-schemas.ts b/src/debug/jtag/generator/generate-command-schemas.ts index b5f612db8..7ee534c7d 100644 --- a/src/debug/jtag/generator/generate-command-schemas.ts +++ b/src/debug/jtag/generator/generate-command-schemas.ts @@ -127,15 +127,84 @@ class CommandSchemaGenerator { } } - console.log(`\n✅ Generated ${this.schemas.length} command schemas`); + console.log(`\n📊 Raw schemas: ${this.schemas.length}`); + + // Deduplicate: group by name, merge params and pick best description + const deduplicated = this.deduplicateSchemas(this.schemas); + + console.log(`✅ After deduplication: ${deduplicated.length} command schemas`); return { generated: new Date().toISOString(), version: '1.0.0', - commands: this.schemas + commands: deduplicated }; } + /** + * Deduplicate schemas that share the same command name. + * Discriminated unions (e.g., SentinelRunTypes with 7 Params interfaces) + * produce multiple entries for the same command. This merges them into + * one entry with the union of all params and the best description. + */ + private deduplicateSchemas(schemas: CommandSchema[]): CommandSchema[] { + const byName = new Map(); + + for (const schema of schemas) { + const group = byName.get(schema.name) || []; + group.push(schema); + byName.set(schema.name, group); + } + + const result: CommandSchema[] = []; + for (const [name, group] of byName) { + if (group.length === 1) { + result.push(group[0]); + continue; + } + + // Merge: union of all params, best description + const mergedParams: Record = {}; + for (const schema of group) { + for (const [paramName, paramDef] of Object.entries(schema.params)) { + if (!mergedParams[paramName]) { + mergedParams[paramName] = paramDef; + } + // If param exists in one variant as required but optional in another, + // mark as optional (since not all variants need it) + if (mergedParams[paramName].required && !paramDef.required) { + mergedParams[paramName] = { ...mergedParams[paramName], required: false }; + } + } + } + + // Pick the longest non-empty description (usually the most informative) + const bestDesc = group + .map(s => s.description) + .filter(d => d && d !== `${name} command`) + .sort((a, b) => b.length - a.length)[0] || `${name} command`; + + // For merged entries, params that only appear in some variants should be optional + // (the "type" discriminator field is the key to which variant is used) + for (const [paramName, paramDef] of Object.entries(mergedParams)) { + const appearsInAll = group.every(s => paramName in s.params); + if (!appearsInAll && paramDef.required) { + mergedParams[paramName] = { ...paramDef, required: false }; + } + } + + console.log(` 🔀 Merged ${group.length} variants of "${name}" → ${Object.keys(mergedParams).length} params`); + + result.push({ + name, + description: bestDesc, + params: mergedParams + }); + } + + return result; + } + /** * Extract ALL command schemas from a single *Types.ts file * (Handles multi-command files like WallTypes.ts with WallWriteParams, WallReadParams, WallListParams) @@ -197,8 +266,10 @@ class CommandSchemaGenerator { allParams = { ...parentParams }; } - // Extract description from JSDoc comment before the interface - const description = this.extractDescription(content, index); + // Extract description: prefer README first paragraph, fall back to cleaned JSDoc + const readmeDesc = this.readReadmeDescription(basePath); + const jsdocDesc = this.extractDescription(content, index); + const description = readmeDesc || jsdocDesc; // Extract parameters from this interface body and merge with parent const params = this.extractParams(interfaceBody, content, index); @@ -340,15 +411,96 @@ class CommandSchemaGenerator { } /** - * Extract description from JSDoc comment + * Extract description from the NEAREST JSDoc comment before the interface. + * Parses multi-line JSDoc properly: strips * prefixes, skips title-pattern + * lines like "Foo Command - Types", joins remaining lines into clean prose. */ private extractDescription(content: string, interfaceStart: number): string { const before = content.substring(0, interfaceStart); - const jsdocMatch = before.match(/\/\*\*\s*\n\s*\*\s*(.+?)\n\s*\*\//s); - if (jsdocMatch) { - return jsdocMatch[1].trim(); + + // Find the LAST (nearest) JSDoc block before the interface + // Matches both multi-line /** \n ... */ and single-line /** text */ + const jsdocBlocks = [...before.matchAll(/\/\*\*([\s\S]*?)\*\//g)]; + if (jsdocBlocks.length === 0) return ''; + + const lastBlock = jsdocBlocks[jsdocBlocks.length - 1]; + const rawBody = lastBlock[1]; + + // Parse JSDoc lines: strip leading whitespace and * prefix + const lines = rawBody + .split('\n') + .map(line => line.replace(/^\s*\*\s?/, '').trim()) + .filter(line => line.length > 0); + + if (lines.length === 0) return ''; + + // Skip title-pattern lines: "Foo Command - Types", "Foo Command - Shared Types", + // "FooTypes - Description", "Foo Command Types", lines of just "===..." + const filteredLines: string[] = []; + for (const line of lines) { + // Skip separator lines (=== or ---) + if (/^[=\-]{3,}$/.test(line)) continue; + // Skip title patterns: "Something - Types", "Something - Shared Types" + if (/^[\w\s]+ - (Shared )?Types$/i.test(line)) continue; + // Skip lines that are just "Something Types" or "Something Shared Types" + if (/^\w[\w\s]+ (Shared )?Types$/i.test(line) && !line.includes('.') && line.split(' ').length <= 5) continue; + filteredLines.push(line); + } + + if (filteredLines.length === 0) return ''; + + // Join into clean prose, collapsing multi-line descriptions + return filteredLines.join(' ').replace(/\s+/g, ' ').trim(); + } + + /** + * Read the first paragraph from a command's README.md as the description. + * The first paragraph is the text between the `# Title` heading and the first `##` heading. + * Returns empty string if no README or no suitable paragraph found. + */ + private readReadmeDescription(basePath: string): string { + const readmePath = join(this.rootPath, 'commands', basePath, 'README.md'); + if (!existsSync(readmePath)) return ''; + + try { + const content = readFileSync(readmePath, 'utf-8'); + const lines = content.split('\n'); + + // Find first line after `# Title` that isn't blank + let pastTitle = false; + const paragraphLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('# ')) { + pastTitle = true; + continue; + } + if (!pastTitle) continue; + + // Stop at next heading + if (line.startsWith('## ')) break; + + const trimmed = line.trim(); + if (trimmed.length === 0) { + // If we already have paragraph content, a blank line ends it + if (paragraphLines.length > 0) break; + continue; + } + + paragraphLines.push(trimmed); + } + + if (paragraphLines.length === 0) return ''; + const result = paragraphLines.join(' ').replace(/\s+/g, ' ').trim(); + + // Skip if the paragraph is just a title pattern (e.g., "Screenshot Command - Shared Types") + if (/^[\w\s]+ - (Shared )?Types$/i.test(result)) return ''; + if (/^\w[\w\s]+ (Shared )?Types$/i.test(result) && !result.includes('.') && result.split(' ').length <= 5) return ''; + + return result; + } catch { + return ''; } - return ''; } /** diff --git a/src/debug/jtag/generator/templates/command/shared-types.template.ts b/src/debug/jtag/generator/templates/command/shared-types.template.ts index 36ae46af7..122cbfadd 100644 --- a/src/debug/jtag/generator/templates/command/shared-types.template.ts +++ b/src/debug/jtag/generator/templates/command/shared-types.template.ts @@ -6,6 +6,7 @@ import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { SYSTEM_SCOPES } from '@system/core/types/SystemScopes'; import { Commands } from '@system/core/shared/Commands'; import type { JTAGError } from '@system/core/types/ErrorTypes'; import type { UUID } from '@system/core/types/CrossPlatformUUID'; @@ -25,6 +26,7 @@ export const create{{CLASS_NAME}}Params = ( sessionId: UUID, data: {{FACTORY_DATA_TYPE}} ): {{CLASS_NAME}}Params => createPayload(context, sessionId, { + userId: SYSTEM_SCOPES.SYSTEM, {{FACTORY_DEFAULTS}} ...data }); @@ -57,7 +59,7 @@ export const create{{CLASS_NAME}}Result = ( */ export const create{{CLASS_NAME}}ResultFromParams = ( params: {{CLASS_NAME}}Params, - differences: Omit<{{CLASS_NAME}}Result, 'context' | 'sessionId'> + differences: Omit<{{CLASS_NAME}}Result, 'context' | 'sessionId' | 'userId'> ): {{CLASS_NAME}}Result => transformPayload(params, differences); /** diff --git a/src/debug/jtag/json b/src/debug/jtag/json new file mode 100644 index 000000000..2eedb696e --- /dev/null +++ b/src/debug/jtag/json @@ -0,0 +1,24 @@ +# AI Decision Intelligence Report + +Generated: 2026-02-15T14:06:11.053Z + +## Date Range + +- **Start**: 2022-01-01T00:00:00Z +- **End**: 2026-02-15T14:13:27.664Z + +## Summary Statistics + +- **Total Decisions**: 0 +- **Posted**: 0 (0%) +- **Silent**: 0 (0%) +- **Errors**: 0 +- **Average Confidence**: 0.00 +- **Unique Actors**: 0 + +## Actor Breakdown + +| Actor | Total | Posted | Silent | Avg Confidence | +|-------|-------|--------|--------|----------------| + +## Decisions by Actor diff --git a/src/debug/jtag/package-lock.json b/src/debug/jtag/package-lock.json index 9c1a93179..f6402d6b4 100644 --- a/src/debug/jtag/package-lock.json +++ b/src/debug/jtag/package-lock.json @@ -1,12 +1,12 @@ { "name": "@continuum/jtag", - "version": "1.0.7873", + "version": "1.0.8016", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@continuum/jtag", - "version": "1.0.7873", + "version": "1.0.8016", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/src/debug/jtag/package.json b/src/debug/jtag/package.json index 5e90706ae..588aa71e9 100644 --- a/src/debug/jtag/package.json +++ b/src/debug/jtag/package.json @@ -1,6 +1,6 @@ { "name": "@continuum/jtag", - "version": "1.0.7873", + "version": "1.0.8016", "description": "Global CLI debugging system for any Node.js project. Install once globally, use anywhere: npm install -g @continuum/jtag", "config": { "active_example": "widget-ui", diff --git a/src/debug/jtag/server/generated.ts b/src/debug/jtag/server/generated.ts index 4b416a26e..c0ae075ca 100644 --- a/src/debug/jtag/server/generated.ts +++ b/src/debug/jtag/server/generated.ts @@ -1,7 +1,7 @@ /** * Server Structure Registry - Auto-generated * - * Contains 17 daemons and 243 commands and 3 adapters. + * Contains 17 daemons and 244 commands and 3 adapters. * Generated by scripts/generate-structure.ts - DO NOT EDIT MANUALLY */ @@ -33,6 +33,7 @@ import { AgentStartServerCommand } from './../commands/agent/start/server/AgentS import { AgentStatusServerCommand } from './../commands/agent/status/server/AgentStatusServerCommand'; import { AgentStopServerCommand } from './../commands/agent/stop/server/AgentStopServerCommand'; import { AdapterTestServerCommand } from './../commands/ai/adapter/test/server/AdapterTestServerCommand'; +import { AiAgentServerCommand } from './../commands/ai/agent/server/AiAgentServerCommand'; import { BagOfWordsServerCommand } from './../commands/ai/bag-of-words/server/BagOfWordsServerCommand'; import { AiContextSearchServerCommand } from './../commands/ai/context/search/server/AiContextSearchServerCommand'; import { AiContextSliceServerCommand } from './../commands/ai/context/slice/server/AiContextSliceServerCommand'; @@ -411,6 +412,11 @@ export const SERVER_COMMANDS: CommandEntry[] = [ className: 'AdapterTestServerCommand', commandClass: AdapterTestServerCommand }, +{ + name: 'ai/agent', + className: 'AiAgentServerCommand', + commandClass: AiAgentServerCommand + }, { name: 'ai/bag-of-words', className: 'BagOfWordsServerCommand', diff --git a/src/debug/jtag/shared/generated-command-constants.ts b/src/debug/jtag/shared/generated-command-constants.ts index 762a80b0c..ee6bcdf37 100644 --- a/src/debug/jtag/shared/generated-command-constants.ts +++ b/src/debug/jtag/shared/generated-command-constants.ts @@ -31,6 +31,7 @@ export const COMMANDS = { AGENT_STATUS: 'agent/status', AGENT_STOP: 'agent/stop', AI_ADAPTER_TEST: 'ai/adapter/test', + AI_AGENT: 'ai/agent', AI_BAG_OF_WORDS: 'ai/bag-of-words', AI_CONTEXT_SEARCH: 'ai/context/search', AI_CONTEXT_SLICE: 'ai/context/slice', diff --git a/src/debug/jtag/shared/generated/runtime/ChannelTickConfig.ts b/src/debug/jtag/shared/generated/runtime/ChannelTickConfig.ts new file mode 100644 index 000000000..f8a53ded7 --- /dev/null +++ b/src/debug/jtag/shared/generated/runtime/ChannelTickConfig.ts @@ -0,0 +1,32 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Configuration for the channel tick loop — exposed to TypeScript via ts-rs. + * + * Controls how often the background tick fires and which responsibilities are enabled. + * Adjustable at runtime via `channel/tick-config` command, allowing TypeScript to + * tune scheduling for different scenarios (gaming = fast tick, idle = slow tick). + */ +export type ChannelTickConfig = { +/** + * Tick interval in milliseconds (default: 60000 = 60s). + * Lower values = more responsive task polling, higher CPU. + * Gaming: 1000-5000ms. Background: 60000-120000ms. + */ +tick_interval_ms: number, +/** + * Whether to poll pending tasks from the database each tick. + */ +task_poll_enabled: boolean, +/** + * Whether to generate self-tasks (memory consolidation, skill audit, etc). + */ +self_task_enabled: boolean, +/** + * Whether to check training data readiness each tick. + */ +training_check_enabled: boolean, +/** + * Training data threshold before triggering genome/job-create (default: 50). + */ +training_threshold: number, }; diff --git a/src/debug/jtag/shared/generated/runtime/index.ts b/src/debug/jtag/shared/generated/runtime/index.ts new file mode 100644 index 000000000..bdfb47501 --- /dev/null +++ b/src/debug/jtag/shared/generated/runtime/index.ts @@ -0,0 +1,9 @@ +// Auto-generated barrel export — do not edit manually +// Source: generator/generate-rust-bindings.ts +// Re-generate: npx tsx generator/generate-rust-bindings.ts + +export type { ChannelTickConfig } from './ChannelTickConfig'; +export type { CommandTiming } from './CommandTiming'; +export type { ModuleInfo } from './ModuleInfo'; +export type { ModulePriority } from './ModulePriority'; +export type { ModuleStats } from './ModuleStats'; diff --git a/src/debug/jtag/shared/ipc/WorkerClient.ts b/src/debug/jtag/shared/ipc/WorkerClient.ts index ec1c25f70..0535ff964 100644 --- a/src/debug/jtag/shared/ipc/WorkerClient.ts +++ b/src/debug/jtag/shared/ipc/WorkerClient.ts @@ -235,9 +235,13 @@ export class WorkerClient { return this.queueMessage(type, payload, userId); } - const request: WorkerRequest = { + // NOTE: Rust IPC expects 'command' field, not 'type' + // The JTAGRequest interface uses 'type' but ORMRustClient uses 'command' + // We need to include both for compatibility + const request: WorkerRequest & { command: string } = { id: generateUUID(), type, + command: type, // Rust IPC looks for 'command' field timestamp: new Date().toISOString(), payload, userId: userId ?? this.defaultUserId diff --git a/src/debug/jtag/shared/ipc/archive-worker/CommandRouterServer.ts b/src/debug/jtag/shared/ipc/archive-worker/CommandRouterServer.ts index 054333187..f2bad0c00 100644 --- a/src/debug/jtag/shared/ipc/archive-worker/CommandRouterServer.ts +++ b/src/debug/jtag/shared/ipc/archive-worker/CommandRouterServer.ts @@ -1,19 +1,20 @@ /** - * CommandRouterServer - Handles Commands.execute() calls FROM Rust workers + * CommandRouterServer - Handles command execution calls FROM Rust workers * * TEMPLATE: This pattern handles bidirectional communication - * - Rust calls Commands.execute() via Unix socket - * - This server executes command and returns result + * - Rust calls commands via Unix socket + * - This server executes command through CommandDaemon and returns result * - Keeps Rust workers as first-class citizens * * Flow: - * Rust → Socket → CommandRouterServer → Commands.execute() → Result → Socket → Rust + * Rust → Socket → CommandRouterServer → CommandDaemon.execute() → Result → Socket → Rust */ import * as net from 'net'; import * as fs from 'fs'; -import { Commands } from '../../../system/core/shared/Commands'; import { Logger, type ComponentLogger } from '../../../system/core/logging/Logger'; +import type { UUID } from '../../../system/core/types/CrossPlatformUUID'; +import { SYSTEM_SCOPES } from '../../../system/core/types/SystemScopes'; interface CommandRequest { command: string; @@ -109,23 +110,58 @@ export class CommandRouterServer { /** * Handle single command request from Rust + * + * Uses JTAGClient-style routing to properly handle both browser and server commands. + * Commands are sent through the router's transport layer, which handles cross-context routing. */ private async handleRequest(socket: net.Socket, line: string): Promise { try { const request: CommandRequest = JSON.parse(line); this.log.info(`Executing command from Rust: ${request.command}`); - // Execute command via Commands.execute() - // Type assertion needed since we don't know command type at runtime - const result = await Commands.execute(request.command, request.params as Record); + // Get JTAGSystemServer instance + const { JTAGSystemServer } = await import('../../../system/core/system/server/JTAGSystemServer'); + const system = JTAGSystemServer.instance; - const response: CommandResponse = { - success: true, - result - }; + if (!system) { + throw new Error('JTAGSystemServer not initialized'); + } - // Send response back to Rust - socket.write(JSON.stringify(response) + '\n'); + // Use getCommandsInterface() which returns the server-side command interface + // This includes routing capabilities for browser commands via the router + const commandsInterface = system.getCommandsInterface(); + const commandFn = commandsInterface.get(request.command); + + if (commandFn) { + // Server-side command - execute directly + const sessionId = (request.params.sessionId as UUID) || SYSTEM_SCOPES.SYSTEM as UUID; + + // Use the system's context, which is a proper JTAGContext + const fullParams = { + context: system.context, + sessionId, + userId: SYSTEM_SCOPES.SYSTEM, + ...request.params + }; + + const result = await commandFn.execute(fullParams); + + const response: CommandResponse = { + success: true, + result + }; + + socket.write(JSON.stringify(response) + '\n'); + } else { + // Command not in server CommandDaemon - might be browser-only + // Return informative error (browser commands require CLI/WebSocket routing) + const available = Array.from(commandsInterface.keys()).slice(0, 20); + throw new Error( + `Command '${request.command}' not available in server context. ` + + `Server commands available: ${available.join(', ')}... ` + + `(Browser-only commands like 'screenshot' require CLI routing)` + ); + } } catch (error) { this.log.error('Command execution error:', error); diff --git a/src/debug/jtag/shared/version.ts b/src/debug/jtag/shared/version.ts index fe6f2b4c7..f5e36033f 100644 --- a/src/debug/jtag/shared/version.ts +++ b/src/debug/jtag/shared/version.ts @@ -3,5 +3,5 @@ * DO NOT EDIT MANUALLY */ -export const VERSION = '1.0.7873'; +export const VERSION = '1.0.8016'; export const PACKAGE_NAME = '@continuum/jtag'; diff --git a/src/debug/jtag/system/ai/server/AIDecisionService.ts b/src/debug/jtag/system/ai/server/AIDecisionService.ts index b4b5bca91..d16191204 100644 --- a/src/debug/jtag/system/ai/server/AIDecisionService.ts +++ b/src/debug/jtag/system/ai/server/AIDecisionService.ts @@ -150,7 +150,7 @@ export class AIDecisionService { model, temperature: options.temperature ?? 0.3, maxTokens: 200, - preferredProvider: 'groq' + provider: 'groq' }; const response = await AIProviderDaemon.generateText(request); @@ -310,7 +310,7 @@ ${generatedText} model, temperature: 0.1, maxTokens: 100, - preferredProvider: 'groq' + provider: 'groq' }; const response = await AIProviderDaemon.generateText(request); @@ -409,7 +409,7 @@ ${generatedText} model, temperature: options.temperature ?? 0.7, maxTokens: options.maxTokens ?? 150, - preferredProvider: 'candle' + provider: 'candle' }; // Wrap with timeout diff --git a/src/debug/jtag/system/ai/server/GarbageDetector.ts b/src/debug/jtag/system/ai/server/GarbageDetector.ts deleted file mode 100644 index 9e203e747..000000000 --- a/src/debug/jtag/system/ai/server/GarbageDetector.ts +++ /dev/null @@ -1,488 +0,0 @@ -/** - * GarbageDetector - Validates AI model output for garbage/gibberish - * - * Detects and rejects invalid model outputs before they're posted to chat: - * - Unicode garbage (random emoji/symbol sequences from token overflow) - * - Repetition loops (same phrase repeated 3+ times) - * - Encoding errors (replacement characters, null bytes) - * - Empty/whitespace-only responses - * - Token overflow markers ([truncated], etc.) - * - * Used by PersonaResponseGenerator after inference, before posting. - */ - -import { Logger, type ComponentLogger } from '../../core/logging/Logger'; - -export interface GarbageCheckResult { - isGarbage: boolean; - reason: GarbageReason | ''; - details?: string; - score: number; // 0-1, higher = more garbage-like -} - -export type GarbageReason = - | 'unicode_garbage' - | 'repetition' - | 'encoding_errors' - | 'empty' - | 'truncation_marker' - | 'excessive_punctuation' - | 'token_boundary_garbage' - | 'inference_error'; - -export class GarbageDetector { - private static logger: ComponentLogger | null = null; - - /** - * Initialize logger for garbage detection events - */ - static initialize(): void { - this.logger = Logger.create('GarbageDetector', 'ai-decisions'); - } - - /** - * Check if text is garbage output from a model - * - * @param text - The model output to validate - * @returns Result with isGarbage flag, reason, and confidence score - */ - static isGarbage(text: string): GarbageCheckResult { - // Handle null/undefined - if (!text) { - return { - isGarbage: true, - reason: 'empty', - details: 'null or undefined input', - score: 1.0 - }; - } - - const trimmed = text.trim(); - - // Empty or whitespace-only - if (trimmed.length < 5) { - this.log('GARBAGE', 'empty', `Length: ${trimmed.length} chars`); - return { - isGarbage: true, - reason: 'empty', - details: `Only ${trimmed.length} non-whitespace characters`, - score: 1.0 - }; - } - - // Check for encoding errors (replacement chars, null bytes) - const encodingErrorCheck = this.checkEncodingErrors(text); - if (encodingErrorCheck.isGarbage) { - this.log('GARBAGE', 'encoding_errors', encodingErrorCheck.details || ''); - return encodingErrorCheck; - } - - // Check for inference error messages being returned as responses - // This catches when error messages leak through as "response text" - const inferenceErrorCheck = this.checkInferenceError(text); - if (inferenceErrorCheck.isGarbage) { - this.log('GARBAGE', 'inference_error', inferenceErrorCheck.details || ''); - return inferenceErrorCheck; - } - - // Check for Unicode garbage (high ratio of non-printable/unusual chars) - const unicodeCheck = this.checkUnicodeGarbage(text); - if (unicodeCheck.isGarbage) { - this.log('GARBAGE', 'unicode_garbage', unicodeCheck.details || ''); - return unicodeCheck; - } - - // Check for repetition loops - const repetitionCheck = this.checkRepetition(text); - if (repetitionCheck.isGarbage) { - this.log('GARBAGE', 'repetition', repetitionCheck.details || ''); - return repetitionCheck; - } - - // Check for truncation markers - const truncationCheck = this.checkTruncationMarkers(text); - if (truncationCheck.isGarbage) { - this.log('GARBAGE', 'truncation_marker', truncationCheck.details || ''); - return truncationCheck; - } - - // Check for excessive punctuation (token boundary issues) - const punctuationCheck = this.checkExcessivePunctuation(text); - if (punctuationCheck.isGarbage) { - this.log('GARBAGE', 'excessive_punctuation', punctuationCheck.details || ''); - return punctuationCheck; - } - - // Check for token boundary garbage (random token fragments) - const tokenBoundaryCheck = this.checkTokenBoundaryGarbage(text); - if (tokenBoundaryCheck.isGarbage) { - this.log('GARBAGE', 'token_boundary_garbage', tokenBoundaryCheck.details || ''); - return tokenBoundaryCheck; - } - - // Passed all checks - return { - isGarbage: false, - reason: '', - score: 0 - }; - } - - /** - * Check for encoding errors (replacement characters, null bytes) - */ - private static checkEncodingErrors(text: string): GarbageCheckResult { - // Replacement character (U+FFFD) indicates decoding failure - const replacementChars = (text.match(/\uFFFD/g) || []).length; - if (replacementChars > 3) { - return { - isGarbage: true, - reason: 'encoding_errors', - details: `${replacementChars} replacement characters (U+FFFD)`, - score: Math.min(replacementChars / 10, 1.0) - }; - } - - // Null bytes indicate binary data leaking through - if (text.includes('\x00')) { - return { - isGarbage: true, - reason: 'encoding_errors', - details: 'Contains null bytes', - score: 1.0 - }; - } - - // Control characters (except newlines, tabs, carriage returns) - const controlChars = (text.match(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g) || []).length; - if (controlChars > 5) { - return { - isGarbage: true, - reason: 'encoding_errors', - details: `${controlChars} control characters`, - score: Math.min(controlChars / 10, 1.0) - }; - } - - return { isGarbage: false, reason: '', score: 0 }; - } - - /** - * Check for inference error messages being returned as response text - * - * When inference fails, error messages can sometimes leak through as - * "successful" responses. This catches common error patterns from: - * - Candle/GGUF inference errors (sampling, memory, timeout) - * - gRPC transport errors - * - Model loading failures - * - * These should never be posted as AI responses. - */ - private static checkInferenceError(text: string): GarbageCheckResult { - // Common inference error patterns - const errorPatterns = [ - // Sampling errors (Candle) - { pattern: /sampling failed:?\s+/i, label: 'Sampling failure' }, - { pattern: /a weight is (negative|invalid|too large)/i, label: 'Invalid weights' }, - { pattern: /invalid probability distribution/i, label: 'Invalid distribution' }, - - // Memory errors - { pattern: /out of memory:?\s+/i, label: 'OOM error' }, - { pattern: /memory allocation failed/i, label: 'Memory allocation' }, - - // Timeout errors - { pattern: /generation timed out/i, label: 'Generation timeout' }, - { pattern: /request timed out after/i, label: 'Request timeout' }, - { pattern: /deadline exceeded/i, label: 'Deadline exceeded' }, - - // Connection errors - { pattern: /cannot connect to inference server/i, label: 'Connection error' }, - { pattern: /grpc.*unavailable/i, label: 'gRPC unavailable' }, - - // Model errors - { pattern: /model not (found|loaded)/i, label: 'Model not found' }, - { pattern: /forward pass failed/i, label: 'Forward pass error' }, - { pattern: /narrow invalid args/i, label: 'Tensor shape error' }, - { pattern: /rope.*position/i, label: 'RoPE position error' }, - - // Generic error patterns (with context clues) - { pattern: /this usually means:\s*\n/i, label: 'Error with help text' }, - { pattern: /try:\s+\n?•/i, label: 'Error suggestions' } - ]; - - for (const { pattern, label } of errorPatterns) { - if (pattern.test(text)) { - // Extract first line for details - const firstLine = text.split('\n')[0].slice(0, 100); - return { - isGarbage: true, - reason: 'inference_error', - details: `${label}: "${firstLine}..."`, - score: 1.0 - }; - } - } - - // Check for error-like structure: starts with error keyword + colon - if (/^(error|failed|cannot|unable|timeout|invalid):/i.test(text.trim())) { - const firstLine = text.split('\n')[0].slice(0, 100); - return { - isGarbage: true, - reason: 'inference_error', - details: `Error prefix: "${firstLine}"`, - score: 0.9 - }; - } - - return { isGarbage: false, reason: '', score: 0 }; - } - - /** - * Check for Unicode garbage (random emoji/symbol sequences) - * - * Valid AI responses should be mostly ASCII with occasional non-ASCII. - * High ratio of unusual Unicode indicates token overflow or corruption. - */ - private static checkUnicodeGarbage(text: string): GarbageCheckResult { - // Count printable ASCII (space through tilde, plus newlines/tabs) - const printableAscii = (text.match(/[\x20-\x7E\n\r\t]/g) || []).length; - const total = text.length; - - // Allow some non-ASCII (emojis, quotes, accents are fine) - // But if >30% is unusual chars, that's likely garbage - const nonAsciiRatio = 1 - (printableAscii / total); - - if (nonAsciiRatio > 0.3 && total > 20) { - // Additional check: is it mostly emojis (could be intentional)? - const emojiCount = (text.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length; - const emojiRatio = emojiCount / total; - - // If it's mostly emojis, that's suspicious but might be intentional - // If it's mixed garbage, definitely bad - if (emojiRatio < 0.2) { - const sample = text.slice(0, 50).replace(/[\x00-\x1F]/g, '?'); - return { - isGarbage: true, - reason: 'unicode_garbage', - details: `${(nonAsciiRatio * 100).toFixed(1)}% non-ASCII: "${sample}..."`, - score: nonAsciiRatio - }; - } - } - - return { isGarbage: false, reason: '', score: nonAsciiRatio }; - } - - /** - * Check for repetition loops - * - * Models sometimes get stuck repeating the same phrase. - * This catches patterns like "Hello! Hello! Hello! Hello!" - */ - private static checkRepetition(text: string): GarbageCheckResult { - // Check for exact phrase repetition (10+ chars repeated 3+ times) - // Regex: (.{10,})\1{2,} - capture 10+ chars, then match 2+ more copies - const exactRepeatMatch = text.match(/(.{10,})\1{2,}/); - if (exactRepeatMatch) { - const repeated = exactRepeatMatch[1]; - const occurrences = text.split(repeated).length - 1; - return { - isGarbage: true, - reason: 'repetition', - details: `"${repeated.slice(0, 30)}..." repeated ${occurrences}x`, - score: Math.min(occurrences / 5, 1.0) - }; - } - - // Check for word repetition (same 3+ words repeated 5+ times) - const words = text.toLowerCase().split(/\s+/); - if (words.length > 15) { - const wordCounts = new Map(); - for (const word of words) { - if (word.length > 2) { - wordCounts.set(word, (wordCounts.get(word) || 0) + 1); - } - } - - // Find most repeated word - let maxCount = 0; - let maxWord = ''; - for (const [word, count] of wordCounts) { - if (count > maxCount) { - maxCount = count; - maxWord = word; - } - } - - // If any single word is >25% of all words, that's suspicious - const repeatRatio = maxCount / words.length; - if (repeatRatio > 0.25 && maxCount > 5) { - return { - isGarbage: true, - reason: 'repetition', - details: `"${maxWord}" appears ${maxCount}/${words.length} times (${(repeatRatio * 100).toFixed(1)}%)`, - score: repeatRatio - }; - } - } - - return { isGarbage: false, reason: '', score: 0 }; - } - - /** - * Check for truncation markers - * - * Some providers add markers when output is cut off. - * If the entire response is just a truncation marker, that's garbage. - */ - private static checkTruncationMarkers(text: string): GarbageCheckResult { - const trimmed = text.trim(); - const markers = [ - '[truncated]', - '...[truncated]', - '[cut off]', - '[output truncated]', - '...', // Only if it's the ENTIRE response - '…' // Ellipsis char - ]; - - for (const marker of markers) { - // If the response is ONLY a marker (or very short with marker) - if (trimmed === marker || (trimmed.length < 20 && trimmed.includes(marker))) { - return { - isGarbage: true, - reason: 'truncation_marker', - details: `Response is only: "${trimmed}"`, - score: 1.0 - }; - } - } - - return { isGarbage: false, reason: '', score: 0 }; - } - - /** - * Check for excessive punctuation - * - * Token boundary issues can result in outputs like "???.....!!!" - */ - private static checkExcessivePunctuation(text: string): GarbageCheckResult { - // Count punctuation - const punctuation = (text.match(/[.!?,;:'"(){}\[\]<>\/\\|@#$%^&*~`]/g) || []).length; - const letters = (text.match(/[a-zA-Z]/g) || []).length; - - // If punctuation outweighs letters, that's suspicious - if (punctuation > letters && punctuation > 20) { - return { - isGarbage: true, - reason: 'excessive_punctuation', - details: `${punctuation} punctuation vs ${letters} letters`, - score: Math.min(punctuation / (letters + 1), 1.0) - }; - } - - // Check for repeated punctuation sequences - const repeatedPunct = text.match(/([.!?]){5,}/g); - if (repeatedPunct && repeatedPunct.some(p => p.length > 10)) { - return { - isGarbage: true, - reason: 'excessive_punctuation', - details: `Repeated punctuation: "${repeatedPunct[0].slice(0, 20)}..."`, - score: 0.8 - }; - } - - return { isGarbage: false, reason: '', score: 0 }; - } - - /** - * Check for token boundary garbage - * - * Models can produce random token fragments when confused. - * This catches patterns like "těl Initiadget UP Fortune" (real example) - */ - private static checkTokenBoundaryGarbage(text: string): GarbageCheckResult { - // Split into "words" - const words = text.split(/\s+/).filter(w => w.length > 0); - - if (words.length < 5) { - return { isGarbage: false, reason: '', score: 0 }; - } - - // Count "weird" words: - // - Mixed case in unusual ways (tĚl, uPPer) - // - Very short fragments followed by caps - // - Unusual character mixing - let weirdWordCount = 0; - - for (const word of words) { - // Check for mixed case that's not normal capitalization - // Normal: "Hello", "AI", "McDonald's" - // Weird: "hELLo", "McD", random fragments - if (word.length > 1) { - const hasLower = /[a-z]/.test(word); - const hasUpper = /[A-Z]/.test(word); - const startsUpper = /^[A-Z]/.test(word); - const allUpper = /^[A-Z]+$/.test(word); - const normalCase = !hasLower || !hasUpper || startsUpper || allUpper; - - // Weird mixed case - if (hasLower && hasUpper && !normalCase) { - weirdWordCount++; - } - - // Non-ASCII mixed with ASCII in same word - if (/[^\x00-\x7F]/.test(word) && /[a-zA-Z]/.test(word)) { - const nonAscii = (word.match(/[^\x00-\x7F]/g) || []).length; - const ascii = (word.match(/[a-zA-Z]/g) || []).length; - // If roughly equal, that's suspicious - if (nonAscii > 0 && ascii > 0 && Math.abs(nonAscii - ascii) < 3) { - weirdWordCount++; - } - } - } - } - - const weirdRatio = weirdWordCount / words.length; - if (weirdRatio > 0.3 && weirdWordCount > 3) { - return { - isGarbage: true, - reason: 'token_boundary_garbage', - details: `${weirdWordCount}/${words.length} words appear malformed`, - score: weirdRatio - }; - } - - return { isGarbage: false, reason: '', score: weirdRatio }; - } - - /** - * Log garbage detection event - */ - private static log(outcome: 'GARBAGE' | 'VALID', reason: string, details: string): void { - if (!this.logger) return; - - const logLine = `[GarbageDetector] ${outcome}: ${reason} | ${details}`; - if (outcome === 'GARBAGE') { - this.logger.warn(logLine); - } else { - this.logger.debug(logLine); - } - } - - /** - * Quick check for common garbage patterns (fast path) - * - * Use this for initial screening before full analysis. - */ - static quickCheck(text: string): boolean { - if (!text || text.trim().length < 5) return true; - if (text.includes('\x00')) return true; - if ((text.match(/\uFFFD/g) || []).length > 3) return true; - - // Quick repetition check - if (/(.{20,})\1{2,}/.test(text)) return true; - - return false; - } -} diff --git a/src/debug/jtag/system/core/client/shared/JTAGClient.ts b/src/debug/jtag/system/core/client/shared/JTAGClient.ts index 06a169704..ed8dd9f3b 100644 --- a/src/debug/jtag/system/core/client/shared/JTAGClient.ts +++ b/src/debug/jtag/system/core/client/shared/JTAGClient.ts @@ -844,6 +844,7 @@ export abstract class JTAGClient extends JTAGBase implements ITransportHandler { const destroyParams: SessionDestroyParams = { context: this.context, sessionId: this._session.sessionId, + userId: this.userId, reason: 'client_disconnect' }; const destroyResult = await this.commands['session/destroy'](destroyParams) as SessionDestroyResult; diff --git a/src/debug/jtag/system/core/logging/LogFileRegistry.ts b/src/debug/jtag/system/core/logging/LogFileRegistry.ts index be0f9a6d0..dd3855b47 100644 --- a/src/debug/jtag/system/core/logging/LogFileRegistry.ts +++ b/src/debug/jtag/system/core/logging/LogFileRegistry.ts @@ -286,7 +286,7 @@ export class LogFileRegistry { if (category === 'persona') { const typeToComponent: Record = { 'adapters': 'AIProviderDaemon', - 'cns': 'PersonaCentralNervousSystem', + 'cns': 'CognitionScheduling', 'cognition': 'PersonaCognition', 'genome': 'PersonaGenome', 'hippocampus': 'Hippocampus', diff --git a/src/debug/jtag/system/core/shared/Commands.ts b/src/debug/jtag/system/core/shared/Commands.ts index ba1ecb68b..9b5004db4 100644 --- a/src/debug/jtag/system/core/shared/Commands.ts +++ b/src/debug/jtag/system/core/shared/Commands.ts @@ -26,6 +26,7 @@ import { JTAGClient } from '../client/shared/JTAGClient'; import type { CommandParams, CommandResult } from '../types/JTAGTypes'; +import { SYSTEM_SCOPES } from '../types/SystemScopes'; import { Screenshot } from '../../../commands/interface/screenshot/shared/ScreenshotTypes'; import { FileSave } from '../../../commands/file/save/shared/FileSaveTypes'; @@ -163,7 +164,7 @@ export class Commands { const finalParams: CommandParams = { context: params?.context || globalWithJTAG.__JTAG_CONTEXT__ || 'unknown', sessionId: params?.sessionId || globalWithJTAG.__JTAG_SESSION_ID__ || 'unknown', - userId: params?.userId, + userId: params?.userId ?? SYSTEM_SCOPES.SYSTEM, ...(params || {}) } as T; diff --git a/src/debug/jtag/system/core/system/server/JTAGSystemServer.ts b/src/debug/jtag/system/core/system/server/JTAGSystemServer.ts index 39cbb621a..3c2e536b2 100644 --- a/src/debug/jtag/system/core/system/server/JTAGSystemServer.ts +++ b/src/debug/jtag/system/core/system/server/JTAGSystemServer.ts @@ -95,13 +95,14 @@ export class JTAGSystemServer extends JTAGSystem { // Register this process const result = await processRegistryCommand.registerProcess({ + userId: SYSTEM_SCOPES.SYSTEM, context: this.context, sessionId: 'system-registration' as any, processType: 'server', description: `JTAG System Server (${this.context.uuid})`, capabilities: [ 'websocket-server', - 'command-execution', + 'command-execution', 'file-operations', 'console-logging', 'screenshot', @@ -224,10 +225,8 @@ export class JTAGSystemServer extends JTAGSystem { const { ServerCommands } = await import('../../server/ServerCommands'); ServerCommands.initialize(); - // Initialize tool result → persona memory capture (captures ALL tool results) - const { initToolResultMemoryCapture } = await import('../../../sentinel/ToolResultMemoryCapture'); - initToolResultMemoryCapture(); - console.log(`🧠 JTAG System: Tool result memory capture initialized`); + // Tool result memory capture - moved to Rust sentinel module + console.log(`🧠 JTAG System: Tool result memory capture handled by Rust SentinelModule`); return system; } diff --git a/src/debug/jtag/system/core/types/JTAGTypes.ts b/src/debug/jtag/system/core/types/JTAGTypes.ts index 803da9e46..a14e263d0 100644 --- a/src/debug/jtag/system/core/types/JTAGTypes.ts +++ b/src/debug/jtag/system/core/types/JTAGTypes.ts @@ -112,18 +112,11 @@ export interface ModelConfig { * - capabilities: What the caller can process (vision, audio, parsing) * - Enables commands to return output optimized for the caller * - * ANDROID CONTEXT PATTERN (NEW): - * - homeDir: Home directory for this context (like Android Context) - * - System contexts → `.continuum/jtag` - * - Persona contexts → `.continuum/personas/{uniqueId}` - * - Enables sandboxing and per-context resource isolation - * * @param uuid - Unique identifier for this context instance * @param environment - Execution environment (server/browser/remote) * @param getConfig - Environment-appropriate configuration accessor * @param callerType - Optional explicit caller type hint * @param capabilities - Optional caller capability information - * @param homeDir - Optional home directory for sandboxed operations * * @see docs/CALLER-ADAPTIVE-OUTPUTS.md for architecture details */ @@ -133,9 +126,6 @@ export interface JTAGContext { readonly config: import('../../shared/SecureConfigTypes').JTAGConfig; getConfig(): JTAGContextConfig; - /** Optional user ID of the calling user (enables caller-adaptive output when known) */ - userId?: UUID; - /** Optional explicit caller type hint (enables caller-adaptive output) */ callerType?: CallerType; @@ -145,8 +135,6 @@ export interface JTAGContext { /** Optional model configuration (for PersonaUsers, used to determine appropriate resource sizing) */ modelConfig?: ModelConfig; - /** Optional home directory for context-specific operations (Android Context pattern) */ - homeDir?: string; } /** @@ -551,10 +539,14 @@ export interface CommandParams extends JTAGPayload { // Base command parameters - specific commands add specific fields /** - * User ID of the calling user (auto-injected from session by Commands.execute()) - * REQUIRED for all commands - infrastructure injects from jtagClient.userId + * User ID of the calling user — the ONE canonical identity field. + * Always present at runtime: auto-injected by Commands.execute() (browser: jtagClient.userId) + * or by AgentToolExecutor (tool calls: ToolCallContext.callerId → params.userId). + * + * Commands MUST use this directly — no fallback chains, no context?.userId, no callerId. + * Optional in type only because callers rely on infrastructure injection. */ - readonly userId?: UUID; + readonly userId: UUID; /** * Optional execution timeout in milliseconds. @@ -603,9 +595,10 @@ export interface CommandResult extends JTAGPayload { * context/sessionId are optional — Commands.execute() auto-injects them if missing. * Server commands that forward context from a parent command can still pass them explicitly. */ -export type CommandInput = Omit & { +export type CommandInput = Omit & { context?: JTAGContext; sessionId?: UUID; + userId?: UUID; }; /** diff --git a/src/debug/jtag/system/data/entities/AIGenerationEntity.ts b/src/debug/jtag/system/data/entities/AIGenerationEntity.ts index bd9ced88c..669afbebb 100644 --- a/src/debug/jtag/system/data/entities/AIGenerationEntity.ts +++ b/src/debug/jtag/system/data/entities/AIGenerationEntity.ts @@ -52,7 +52,7 @@ export class AIGenerationEntity extends BaseEntity { estimatedCost!: number; // USD @NumberField() - responseTime!: number; // milliseconds + responseTimeMs!: number; // milliseconds // Context (optional) @TextField({ nullable: true }) @@ -109,7 +109,7 @@ export class AIGenerationEntity extends BaseEntity { totalTokens: number; estimatedCost?: number; }; - responseTime: number; + responseTimeMs: number; requestId: string; error?: string; }, @@ -130,7 +130,7 @@ export class AIGenerationEntity extends BaseEntity { outputTokens: response.usage.outputTokens, totalTokens: response.usage.totalTokens, estimatedCost: response.usage.estimatedCost || 0, - responseTime: response.responseTime, + responseTimeMs: response.responseTimeMs, userId: context.userId, roomId: context.roomId, purpose: context.purpose, diff --git a/src/debug/jtag/system/data/entities/UserEntity.ts b/src/debug/jtag/system/data/entities/UserEntity.ts index 51359fe46..0f16f0f4a 100644 --- a/src/debug/jtag/system/data/entities/UserEntity.ts +++ b/src/debug/jtag/system/data/entities/UserEntity.ts @@ -13,6 +13,39 @@ import type { MediaType } from './ChatMessageEntity'; export type UserType = 'human' | 'agent' | 'persona' | 'system'; export type UserStatus = 'online' | 'offline' | 'away' | 'busy' | 'frozen' | 'deleted'; +/** + * Prompt format types - defines how different model families expect prompts + */ +export type PromptFormat = + | 'base' // Base models (GPT-2, Llama base): "User: ...\n\nAssistant:" + | 'chatml' // ChatML format: "<|im_start|>user\n...<|im_end|>" + | 'llama2' // Llama-2 chat: "[INST] ... [/INST]" + | 'alpaca' // Alpaca format: "### Instruction:\n...\n\n### Response:" + | 'openai' // OpenAI native messages array + | 'anthropic'; // Anthropic native messages array + +/** + * Model configuration for AI users + * + * Single source of truth — used by UserEntity (storage), PersonaUser (runtime), + * seed data, and command params. maxTokens is REQUIRED: defaults are always + * provided at the parse/hydration boundary so the struct is always well-formed. + */ +export interface ModelConfig { + readonly model?: string; + readonly provider?: string; // AI provider (anthropic, openai, groq, deepseek, candle) + readonly temperature?: number; + readonly maxTokens: number; // Maximum output tokens — REQUIRED + + readonly contextWindow?: number; // Maximum input tokens (context length) + readonly systemPrompt?: string; // Custom system prompt for persona + readonly capabilities?: readonly string[]; // Model capabilities + readonly promptFormat?: PromptFormat; // How this model expects prompts formatted + readonly requiresExplicitMention?: boolean; // If true, persona only responds when explicitly mentioned (e.g., @sentinel) + readonly ragCertified?: boolean; // Has this model been tested/certified with our complex RAG system? + readonly toolCapability?: 'native' | 'xml' | 'none'; // Override provider-based tool capability detection +} + export interface UserCapabilities { canSendMessages: boolean; canReceiveMessages: boolean; @@ -114,18 +147,9 @@ export class UserEntity extends BaseEntity { // AI model configuration (for AI users: agents and personas) // Stores provider (anthropic, openai, groq, etc.) and model settings + // Uses ModelConfig — maxTokens is required, defaults applied at hydration boundary @JsonField({ nullable: true }) - modelConfig?: { - model?: string; - provider?: string; - temperature?: number; - maxTokens?: number; - systemPrompt?: string; - capabilities?: readonly string[]; - ragCertified?: boolean; // Has this model been tested with our complex RAG system? - requiresExplicitMention?: boolean; // If true, persona only responds when explicitly mentioned - toolCapability?: 'native' | 'xml' | 'none'; // Override provider-based tool capability detection - }; + modelConfig?: ModelConfig; // Media configuration (for AI users that can process images/audio/video) // Controls whether persona automatically loads media from tool results diff --git a/src/debug/jtag/system/rag/builders/ChatRAGBuilder.ts b/src/debug/jtag/system/rag/builders/ChatRAGBuilder.ts index 842df2a34..18abc2c54 100644 --- a/src/debug/jtag/system/rag/builders/ChatRAGBuilder.ts +++ b/src/debug/jtag/system/rag/builders/ChatRAGBuilder.ts @@ -49,7 +49,8 @@ import { CodeToolSource, ProjectContextSource, GovernanceSource, - ActivityContextSource + ActivityContextSource, + ToolDefinitionsSource } from '../sources'; /** @@ -138,10 +139,11 @@ export class ChatRAGBuilder extends RAGBuilder { new ProjectContextSource(), // Priority 70: Project workspace context (git, team, build) new SocialMediaRAGSource(), // Priority 55: Social media HUD (engagement duty) new CodeToolSource(), // Priority 50: Coding workflow guidance + new ToolDefinitionsSource(), // Priority 45: Tool definitions (native/XML, budget-aware) new ActivityContextSource(), // Priority 40: Recipe/activity context new GovernanceSource() // Priority 20: Democratic participation guidance ]); - this.log('🔧 ChatRAGBuilder: Initialized RAGComposer with 10 sources'); + this.log('🔧 ChatRAGBuilder: Initialized RAGComposer with 11 sources'); } return this.composer; } @@ -159,6 +161,9 @@ export class ChatRAGBuilder extends RAGBuilder { socialAwareness: string | null; codeToolGuidance: string | null; projectContext: string | null; + consolidatedMemories: string | null; + toolDefinitionsMetadata: Record | null; + toolDefinitionsPrompt: string | null; } { let identity: PersonaIdentity | null = null; let conversationHistory: LLMMessage[] = []; @@ -168,6 +173,9 @@ export class ChatRAGBuilder extends RAGBuilder { let socialAwareness: string | null = null; let codeToolGuidance: string | null = null; let projectContext: string | null = null; + let consolidatedMemories: string | null = null; + let toolDefinitionsMetadata: Record | null = null; + let toolDefinitionsPrompt: string | null = null; for (const section of result.sections) { if (section.identity) { @@ -179,29 +187,40 @@ export class ChatRAGBuilder extends RAGBuilder { if (section.memories && section.memories.length > 0) { memories = section.memories; } + if (section.sourceName === 'semantic-memory' && section.systemPromptSection) { + // Formatted memory section — produced by SemanticMemorySource + consolidatedMemories = section.systemPromptSection; + } if (section.systemPromptSection && section.sourceName === 'widget-context') { - // Extract the raw context from the formatted section widgetContext = section.systemPromptSection; } if (section.systemPromptSection && section.sourceName === 'global-awareness') { - // Extract cross-context awareness (no severance!) globalAwareness = section.systemPromptSection; } if (section.systemPromptSection && section.sourceName === 'social-media') { - // Social media HUD — engagement awareness and duty socialAwareness = section.systemPromptSection; } if (section.systemPromptSection && section.sourceName === 'code-tools') { - // Coding workflow guidance — code/* tool awareness codeToolGuidance = section.systemPromptSection; } if (section.systemPromptSection && section.sourceName === 'project-context') { - // Project workspace context — git status, team activity, build status projectContext = section.systemPromptSection; } + if (section.sourceName === 'tool-definitions') { + // Tool definitions — metadata contains nativeToolSpecs for native providers, + // systemPromptSection contains XML for text-based providers + toolDefinitionsMetadata = section.metadata ?? null; + if (section.systemPromptSection) { + toolDefinitionsPrompt = section.systemPromptSection; + } + } } - return { identity, conversationHistory, memories, widgetContext, globalAwareness, socialAwareness, codeToolGuidance, projectContext }; + return { + identity, conversationHistory, memories, + widgetContext, globalAwareness, socialAwareness, codeToolGuidance, projectContext, + consolidatedMemories, toolDefinitionsMetadata, toolDefinitionsPrompt + }; } /** @@ -214,7 +233,7 @@ export class ChatRAGBuilder extends RAGBuilder { async buildContext( contextId: UUID, // Room ID personaId: UUID, - options?: RAGBuildOptions + options: RAGBuildOptions ): Promise { const startTime = Date.now(); @@ -236,22 +255,31 @@ export class ChatRAGBuilder extends RAGBuilder { let socialAwareness: string | null; let codeToolGuidance: string | null; let projectContext: string | null; + let consolidatedMemories: string | null = null; + let toolDefinitionsMetadata: Record | null = null; + let toolDefinitionsPrompt: string | null = null; let composeMs: number | undefined; let legacyMs: number | undefined; + let totalBudget = 8000; // Default cap, overridden below for local models if (this.useModularSources) { // NEW PATH: Use RAGComposer for modular, parallelized source loading // Benefits: queryWithJoin for messages (4.5x faster), testable sources, budget allocation const composer = this.getComposer(); - // Calculate token budget based on model capabilities - // Local models get MINIMAL context - they can query for more via tools - let totalBudget = 8000; // Default for capable models - if (options?.modelId && isSlowLocalModel(options.modelId)) { - const latencyLimit = getLatencyAwareTokenLimit(options.modelId); - // Local models: minimal system prompt (~500 tokens), rest for messages - totalBudget = Math.min(totalBudget, latencyLimit - 500); - this.log(`📊 ChatRAGBuilder: Local model budget=${totalBudget} for ${options.modelId}`); + // Calculate token budget from context window. + // Use at most 75% of context window for input — leaves 25% for: + // - Output tokens (model's response) + // - Token estimation error margin (chars/4 is approximate) + // - Numerical stability margin (Q4_K_M quantization degrades at high utilization) + totalBudget = 8000; // Default cap for cloud models + if (options?.modelId) { + const contextWindow = getContextWindow(options.modelId, options?.provider); + const maxInput = Math.floor(contextWindow * 0.75); + totalBudget = Math.min(totalBudget, maxInput); + if (isSlowLocalModel(options.modelId, options?.provider)) { + this.log(`📊 ChatRAGBuilder: Slow model budget=${totalBudget} (contextWindow=${contextWindow}, 75%) for ${options.provider}/${options.modelId}`); + } } const sourceContext: RAGSourceContext = { @@ -265,7 +293,9 @@ export class ChatRAGBuilder extends RAGBuilder { includeMemories, currentMessage: options?.currentMessage }, - totalBudget + totalBudget, + provider: options?.provider, + toolCapability: options?.toolCapability, }; // Load core sources via composer (parallel) @@ -286,6 +316,9 @@ export class ChatRAGBuilder extends RAGBuilder { socialAwareness = extracted.socialAwareness; codeToolGuidance = extracted.codeToolGuidance; projectContext = extracted.projectContext; + consolidatedMemories = extracted.consolidatedMemories; + toolDefinitionsMetadata = extracted.toolDefinitionsMetadata; + toolDefinitionsPrompt = extracted.toolDefinitionsPrompt; // Still load these via legacy methods (not yet extracted to sources) const legacyStart = performance.now(); @@ -369,10 +402,16 @@ export class ChatRAGBuilder extends RAGBuilder { const processedArtifacts = await this.preprocessArtifactsForModel(artifacts, options); const preprocessMs = performance.now() - preprocessStart; + // SMALL-CONTEXT GUARD: For models with tiny context windows (Candle ~1400 tokens), + // skip all non-essential injections. The system prompt from PersonaIdentitySource + // already used progressive budget allocation — don't bloat it. + // totalBudget is 75% of contextWindow, so 1500 = ~2000 token model. + const isSmallContext = totalBudget < 1500; + // 2.4. Inject widget context into system prompt if available // This enables AI to be aware of what the user is currently viewing const finalIdentity = { ...identity }; - if (widgetContext) { + if (!isSmallContext && widgetContext) { finalIdentity.systemPrompt = identity.systemPrompt + `\n\n## CURRENT USER CONTEXT (What they're viewing)\n${widgetContext}\n\nUse this context to provide more relevant assistance. If they're configuring AI providers, you can proactively help with that. If they're viewing settings, anticipate configuration questions.`; this.log('🧠 ChatRAGBuilder: Injected widget context into system prompt'); @@ -380,7 +419,7 @@ export class ChatRAGBuilder extends RAGBuilder { // 2.4.5. Inject cross-context awareness into system prompt (NO SEVERANCE!) // This gives AIs unified knowledge that flows between rooms/contexts - if (globalAwareness) { + if (!isSmallContext && globalAwareness) { finalIdentity.systemPrompt = finalIdentity.systemPrompt + `\n\n${globalAwareness}\n\nIMPORTANT: You DO have access to information from other channels/rooms. Use the "Relevant Knowledge From Other Contexts" section above when answering questions. This information is from your own experiences in other conversations.`; this.log('🌐 ChatRAGBuilder: Injected cross-context awareness into system prompt'); @@ -388,26 +427,42 @@ export class ChatRAGBuilder extends RAGBuilder { // 2.4.6. Inject social media HUD into system prompt (engagement awareness) // This gives AIs awareness of their social media presence and engagement duty - if (socialAwareness) { + if (!isSmallContext && socialAwareness) { finalIdentity.systemPrompt = finalIdentity.systemPrompt + `\n\n${socialAwareness}`; this.log('📱 ChatRAGBuilder: Injected social media HUD into system prompt'); } // 2.4.7. Inject code tool workflow guidance (coding capabilities) - if (codeToolGuidance) { + if (!isSmallContext && codeToolGuidance) { finalIdentity.systemPrompt = finalIdentity.systemPrompt + `\n\n${codeToolGuidance}`; this.log('💻 ChatRAGBuilder: Injected code tool guidance into system prompt'); } // 2.4.8. Inject project workspace context (git status, team activity, build info) - if (projectContext) { + if (!isSmallContext && projectContext) { finalIdentity.systemPrompt = finalIdentity.systemPrompt + `\n\n${projectContext}`; this.log('📦 ChatRAGBuilder: Injected project workspace context into system prompt'); } + // 2.4.9. Inject consolidated memories (budget-aware via SemanticMemorySource) + if (!isSmallContext && consolidatedMemories) { + finalIdentity.systemPrompt = finalIdentity.systemPrompt + consolidatedMemories; + this.log(`🧠 ChatRAGBuilder: Injected ${privateMemories.length} consolidated memories into system prompt`); + } + + // 2.4.10. Inject XML tool definitions for text-based providers (budget-aware via ToolDefinitionsSource) + if (!isSmallContext && toolDefinitionsPrompt) { + finalIdentity.systemPrompt = finalIdentity.systemPrompt + toolDefinitionsPrompt; + this.log(`🔧 ChatRAGBuilder: Injected tool definitions into system prompt (XML format)`); + } + + if (isSmallContext) { + this.log(`📦 ChatRAGBuilder: Small-context mode (budget=${totalBudget}) — skipped injections to fit ${options?.modelId}`); + } + // NOTE: Canvas context is now handled via the "inbox content" pattern // When strokes are added, they emit system messages to the canvas room // AIs see these in their conversation history naturally, no system prompt injection needed @@ -474,7 +529,14 @@ export class ChatRAGBuilder extends RAGBuilder { hasSocialAwareness: !!socialAwareness, // Project workspace context (git, team, build) - hasProjectContext: !!projectContext + hasProjectContext: !!projectContext, + + // Tool definitions (budget-aware via ToolDefinitionsSource) + toolDefinitions: toolDefinitionsMetadata ? { + nativeToolSpecs: (toolDefinitionsMetadata as any).nativeToolSpecs, + toolChoice: (toolDefinitionsMetadata as any).toolChoice, + toolCount: (toolDefinitionsMetadata as any).toolCount, + } : undefined, } }; @@ -528,7 +590,7 @@ export class ChatRAGBuilder extends RAGBuilder { * Load widget context for AI awareness * Returns formatted string describing what the user is currently viewing */ - private async loadWidgetContext(options?: RAGBuildOptions): Promise { + private async loadWidgetContext(options: RAGBuildOptions): Promise { // Ensure service is initialized (lazy init pattern) WidgetContextService.initialize(); @@ -561,7 +623,7 @@ export class ChatRAGBuilder extends RAGBuilder { /** * Load persona identity from UserEntity */ - private async loadPersonaIdentity(personaId: UUID, roomId: UUID, options?: RAGBuildOptions): Promise { + private async loadPersonaIdentity(personaId: UUID, roomId: UUID, options: RAGBuildOptions): Promise { try { const user = await ORM.read(UserEntity.collection, personaId); @@ -862,10 +924,10 @@ LIMITS: */ private async preprocessArtifactsForModel( artifacts: RAGArtifact[], - options?: RAGBuildOptions + options: RAGBuildOptions ): Promise { // If model has vision capability, return artifacts as-is (they can see images) - if (options?.modelCapabilities?.supportsImages) { + if (options.modelCapabilities?.supportsImages) { return artifacts; } @@ -943,11 +1005,11 @@ LIMITS: type: 'image_description', result: description.description, confidence: 0.85, - processingTime: description.responseTime, + processingTime: description.responseTimeMs, model: `${description.provider}/${description.modelId}` } }); - this.log(`👁️ ChatRAGBuilder: Described image (${description.responseTime}ms) via ${description.modelId}`); + this.log(`👁️ ChatRAGBuilder: Described image (${description.responseTimeMs}ms) via ${description.modelId}`); } else { // Description failed - return artifact as-is processedArtifacts.push(artifact); @@ -1239,7 +1301,7 @@ LIMITS: * Calculate safe message count based on model context window (Bug #5 fix) * Uses same logic as RAGBudgetServerCommand to prevent context overflow */ - private calculateSafeMessageCount(options?: RAGBuildOptions): number { + private calculateSafeMessageCount(options: RAGBuildOptions): number { // If maxMessages explicitly provided, use it (allows manual override) if (options?.maxMessages !== undefined) { return options.maxMessages; @@ -1253,19 +1315,19 @@ LIMITS: // Use centralized ModelContextConfig (single source of truth) const modelId = options.modelId; - const maxTokens = options.maxTokens ?? 3000; + const maxTokens = options.maxTokens; const systemPromptTokens = options.systemPromptTokens ?? 500; const targetUtilization = 0.8; // 80% target, 20% safety margin const avgTokensPerMessage = 250; // Conservative estimate - // Get context window from centralized config - const contextWindow = getContextWindow(modelId); + // Provider-scoped context window lookup — prevents cross-provider collisions + const contextWindow = getContextWindow(modelId, options?.provider); // LATENCY-AWARE BUDGETING: For slow local models, apply latency constraint // This prevents timeouts from massive prompts (e.g., 20K tokens at 10ms/token = 200s!) - const latencyInputLimit = getLatencyAwareTokenLimit(modelId); - const isSlowModel = isSlowLocalModel(modelId); - const inferenceSpeed = getInferenceSpeed(modelId); + const latencyInputLimit = getLatencyAwareTokenLimit(modelId, undefined, options?.provider); + const isSlowModel = isSlowLocalModel(modelId, options?.provider); + const inferenceSpeed = getInferenceSpeed(modelId, options?.provider); // Calculate context window constraint (total context - output reservation) const contextWindowBudget = contextWindow - maxTokens - systemPromptTokens; @@ -1287,8 +1349,9 @@ LIMITS: // Calculate safe message count const safeMessageCount = Math.floor(targetTokens / avgTokensPerMessage); - // Clamp between 5 and 50 - const clampedMessageCount = Math.max(5, Math.min(50, safeMessageCount)); + // Clamp between 2 and 50 — small models (< 2K context) need fewer messages + const minMessages = contextWindow < 2000 ? 2 : 5; + const clampedMessageCount = Math.max(minMessages, Math.min(50, safeMessageCount)); // Log with latency info for slow models const latencyInfo = isSlowModel @@ -1299,11 +1362,11 @@ LIMITS: : ''; this.log(`📊 ChatRAGBuilder: Budget calculation for ${modelId}: - Context Window: ${contextWindow} tokens + Context Window: ${contextWindow} tokens (provider=${options?.provider ?? 'unscoped'}) Context Budget: ${contextWindowBudget} tokens (after output + system reservation)${latencyInfo} Latency Budget: ${latencyBudget} tokens Available for Messages: ${availableForMessages}${limitingFactor} - Safe Message Count: ${safeMessageCount} → ${clampedMessageCount} (clamped)`); + Safe Message Count: ${safeMessageCount} → ${clampedMessageCount} (clamped, min=${minMessages})`); return clampedMessageCount; } @@ -1322,21 +1385,21 @@ LIMITS: */ private calculateAdjustedMaxTokens( conversationHistory: LLMMessage[], - options?: RAGBuildOptions + options: RAGBuildOptions ): { adjustedMaxTokens: number; inputTokenCount: number } { - // If no modelId, can't calculate - return original maxTokens - if (!options?.modelId) { - const defaultMaxTokens = options?.maxTokens ?? 3000; - this.log('⚠️ ChatRAGBuilder: No modelId for maxTokens adjustment, using default:', defaultMaxTokens); - return { adjustedMaxTokens: defaultMaxTokens, inputTokenCount: 0 }; + const requestedMaxTokens = options.maxTokens; + + // If no modelId, can't calculate context window — use config as-is + if (!options.modelId) { + this.log('⚠️ ChatRAGBuilder: No modelId for maxTokens adjustment, using config:', requestedMaxTokens); + return { adjustedMaxTokens: requestedMaxTokens, inputTokenCount: 0 }; } - // Use centralized ModelContextConfig (single source of truth) + // Provider-scoped context window lookup — prevents cross-provider collisions const modelId = options.modelId; - const requestedMaxTokens = options.maxTokens ?? 3000; const systemPromptTokens = options.systemPromptTokens ?? 500; const safetyMargin = 100; // Extra buffer for formatting/metadata - const contextWindow = getContextWindow(modelId); + const contextWindow = getContextWindow(modelId, options?.provider); // Estimate input tokens (conversationHistory + system prompt) // Using 250 tokens per message average (same as calculateSafeMessageCount) @@ -1347,9 +1410,11 @@ LIMITS: // Calculate available tokens for completion const availableForCompletion = contextWindow - inputTokenCount - safetyMargin; - // Adjust maxTokens to fit within available space + // Adjust maxTokens to fit within available space. + // Never exceed the config value — it exists for a reason (e.g. Candle models at 200). + // Minimum 50 tokens so the model can at least produce something. const adjustedMaxTokens = Math.max( - 500, // Minimum 500 tokens for meaningful response + 50, Math.min(requestedMaxTokens, availableForCompletion) ); diff --git a/src/debug/jtag/system/rag/builders/CodebaseRAGBuilder.ts b/src/debug/jtag/system/rag/builders/CodebaseRAGBuilder.ts index f46ad1086..ffd54c648 100644 --- a/src/debug/jtag/system/rag/builders/CodebaseRAGBuilder.ts +++ b/src/debug/jtag/system/rag/builders/CodebaseRAGBuilder.ts @@ -43,7 +43,7 @@ export class CodebaseRAGBuilder extends RAGBuilder { async buildContext( contextId: UUID, // Query text or scope path personaId: UUID, - options?: RAGBuildOptions + options: RAGBuildOptions ): Promise { const startTime = Date.now(); diff --git a/src/debug/jtag/system/rag/shared/PromptCapture.ts b/src/debug/jtag/system/rag/shared/PromptCapture.ts new file mode 100644 index 000000000..1f09af25d --- /dev/null +++ b/src/debug/jtag/system/rag/shared/PromptCapture.ts @@ -0,0 +1,308 @@ +/** + * PromptCapture — Records every LLM prompt for inspection and replay + * + * Every prompt sent to any model is captured as a structured JSONL entry. + * This enables: + * - Debugging: inspect exactly what any persona saw before responding + * - Replay: re-run any prompt against the same or different model + * - Scenario testing: replay entire conversation sequences + * - Regression: compare outputs before/after RAG changes + * + * Captures are written to `.continuum/jtag/logs/system/prompt-captures.jsonl` + * One JSON object per line — standard JSONL format for easy streaming/parsing. + * + * Usage: + * PromptCapture.capture({ personaId, personaName, model, ... }); + * + * Replay: + * const captures = await PromptCapture.load({ personaName: 'Helper AI', limit: 5 }); + * for (const capture of captures) { + * const response = await AIProviderDaemon.generateText(capture.request); + * } + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { Logger } from '../../core/logging/Logger'; +import type { UUID } from '../../core/types/CrossPlatformUUID'; +import { SystemPaths } from '../../core/config/SystemPaths'; + +const log = Logger.create('PromptCapture', 'rag'); + +/** + * A captured LLM prompt — contains everything needed to replay the request. + */ +export interface CapturedPrompt { + /** Unique capture ID (ISO timestamp + short persona ID for dedup) */ + id: string; + /** When the prompt was sent */ + timestamp: string; + /** Persona that generated this prompt */ + personaId: UUID; + personaName: string; + /** Model and provider configuration */ + model: string; + provider: string; + temperature: number; + maxTokens: number; + /** The complete system prompt */ + systemPrompt: string; + /** Conversation messages (role + content + name) */ + messages: Array<{ + role: 'system' | 'user' | 'assistant'; + content: string; + name?: string; + }>; + /** Tool definitions (native JSON specs or XML in system prompt) */ + tools?: unknown[]; + toolChoice?: string; + /** What triggered this generation */ + triggerMessageId?: UUID; + triggerMessagePreview?: string; + /** RAG metadata for context */ + ragSourceCount?: number; + ragTotalTokens?: number; + /** Active LoRA adapters (if any) */ + activeAdapters?: Array<{ name: string; path: string }>; +} + +/** + * Filter options for loading captures. + */ +export interface CaptureFilter { + personaName?: string; + personaId?: UUID; + model?: string; + provider?: string; + /** Only captures after this timestamp */ + after?: Date; + /** Only captures before this timestamp */ + before?: Date; + /** Max captures to return (newest first) */ + limit?: number; +} + +export class PromptCapture { + private static _captureFile: string | null = null; + private static _writeQueue: string[] = []; + private static _flushTimer: ReturnType | null = null; + + /** Get the capture file path, creating the directory if needed */ + private static captureFile(): string { + if (!this._captureFile) { + const logsDir = SystemPaths.logs.system; + const dir = path.dirname(logsDir); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + this._captureFile = path.join(dir, 'prompt-captures.jsonl'); + } + return this._captureFile; + } + + /** + * Capture a prompt — fire-and-forget, non-blocking. + * Extracts system prompt from messages array, serializes to JSONL. + */ + static capture(params: { + personaId: UUID; + personaName: string; + model: string; + provider: string; + temperature: number; + maxTokens: number; + messages: Array<{ role: string; content: unknown; name?: string }>; + tools?: unknown[]; + toolChoice?: string; + triggerMessageId?: UUID; + triggerMessagePreview?: string; + ragSourceCount?: number; + ragTotalTokens?: number; + activeAdapters?: Array<{ name: string; path: string }>; + }): void { + try { + const now = new Date(); + const shortId = params.personaId.slice(0, 8); + + // Extract system prompt from first system message + let systemPrompt = ''; + const conversationMessages: CapturedPrompt['messages'] = []; + + for (const msg of params.messages) { + const content = typeof msg.content === 'string' + ? msg.content + : JSON.stringify(msg.content); + + if (msg.role === 'system' && !systemPrompt) { + systemPrompt = content; + } else { + conversationMessages.push({ + role: msg.role as 'system' | 'user' | 'assistant', + content, + name: msg.name + }); + } + } + + const capture: CapturedPrompt = { + id: `${now.toISOString()}_${shortId}`, + timestamp: now.toISOString(), + personaId: params.personaId, + personaName: params.personaName, + model: params.model, + provider: params.provider, + temperature: params.temperature, + maxTokens: params.maxTokens, + systemPrompt, + messages: conversationMessages, + tools: params.tools, + toolChoice: params.toolChoice, + triggerMessageId: params.triggerMessageId, + triggerMessagePreview: params.triggerMessagePreview, + ragSourceCount: params.ragSourceCount, + ragTotalTokens: params.ragTotalTokens, + activeAdapters: params.activeAdapters + }; + + // Queue for batched write (avoids per-prompt I/O overhead) + const line = JSON.stringify(capture); + this._writeQueue.push(line); + + // Flush every 500ms (batches multiple captures from concurrent personas) + if (!this._flushTimer) { + this._flushTimer = setTimeout(() => this.flush(), 500); + } + } catch (error: any) { + log.warn(`Failed to capture prompt: ${error.message}`); + } + } + + /** Flush queued captures to disk */ + private static flush(): void { + this._flushTimer = null; + if (this._writeQueue.length === 0) return; + + const lines = this._writeQueue.splice(0); + const data = lines.join('\n') + '\n'; + + try { + fs.appendFileSync(this.captureFile(), data, 'utf-8'); + } catch (error: any) { + log.warn(`Failed to write prompt captures: ${error.message}`); + } + } + + /** + * Load captured prompts matching filter criteria. + * Reads from the JSONL file, parses, filters, and returns newest first. + */ + static async load(filter?: CaptureFilter): Promise { + // Flush any pending writes first + this.flush(); + + const filePath = this.captureFile(); + if (!fs.existsSync(filePath)) return []; + + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.trim().split('\n').filter(l => l.length > 0); + + let captures: CapturedPrompt[] = []; + for (const line of lines) { + try { + captures.push(JSON.parse(line)); + } catch { + // Skip malformed lines + } + } + + // Apply filters + if (filter?.personaName) { + captures = captures.filter(c => c.personaName === filter.personaName); + } + if (filter?.personaId) { + captures = captures.filter(c => c.personaId === filter.personaId); + } + if (filter?.model) { + captures = captures.filter(c => c.model === filter.model); + } + if (filter?.provider) { + captures = captures.filter(c => c.provider === filter.provider); + } + if (filter?.after) { + const afterMs = filter.after.getTime(); + captures = captures.filter(c => new Date(c.timestamp).getTime() >= afterMs); + } + if (filter?.before) { + const beforeMs = filter.before.getTime(); + captures = captures.filter(c => new Date(c.timestamp).getTime() <= beforeMs); + } + + // Newest first + captures.reverse(); + + // Apply limit + if (filter?.limit && filter.limit > 0) { + captures = captures.slice(0, filter.limit); + } + + return captures; + } + + /** + * Reconstruct a full TextGenerationRequest from a captured prompt. + * This is what you pass to AIProviderDaemon.generateText() for replay. + */ + static toReplayRequest(capture: CapturedPrompt): { + messages: Array<{ role: string; content: string }>; + model: string; + temperature: number; + maxTokens: number; + provider: string; + tools?: unknown[]; + toolChoice?: string; + } { + // Rebuild the messages array with system prompt first + const messages: Array<{ role: string; content: string }> = [ + { role: 'system', content: capture.systemPrompt } + ]; + + for (const msg of capture.messages) { + messages.push({ + role: msg.role, + content: msg.content + }); + } + + return { + messages, + model: capture.model, + temperature: capture.temperature, + maxTokens: capture.maxTokens, + provider: capture.provider, + tools: capture.tools, + toolChoice: capture.toolChoice + }; + } + + /** + * Get a human-readable summary of a capture (for CLI/logging). + */ + static summarize(capture: CapturedPrompt): string { + const promptChars = capture.systemPrompt.length; + const msgCount = capture.messages.length; + const toolCount = capture.tools?.length ?? 0; + const trigger = capture.triggerMessagePreview + ? `"${capture.triggerMessagePreview.slice(0, 60)}..."` + : 'unknown'; + + return [ + `[${capture.timestamp}] ${capture.personaName} → ${capture.model} (${capture.provider})`, + ` System prompt: ${promptChars} chars (~${Math.ceil(promptChars / 4)} tokens)`, + ` Messages: ${msgCount}, Tools: ${toolCount}, MaxTokens: ${capture.maxTokens}`, + ` Trigger: ${trigger}`, + capture.activeAdapters?.length + ? ` LoRA: ${capture.activeAdapters.map(a => a.name).join(', ')}` + : null + ].filter(Boolean).join('\n'); + } +} diff --git a/src/debug/jtag/system/rag/shared/RAGBudgetManager.ts b/src/debug/jtag/system/rag/shared/RAGBudgetManager.ts index 63e53cf29..e76f695f4 100644 --- a/src/debug/jtag/system/rag/shared/RAGBudgetManager.ts +++ b/src/debug/jtag/system/rag/shared/RAGBudgetManager.ts @@ -90,9 +90,9 @@ export class RAGBudgetManager { private readonly modelId: string; private readonly contextWindow: number; - constructor(modelId: string) { + constructor(modelId: string, provider?: string) { this.modelId = modelId; - this.contextWindow = getContextWindow(modelId); + this.contextWindow = getContextWindow(modelId, provider); } /** @@ -272,8 +272,8 @@ export class RAGBudgetManager { * Get recommended budget for chat RAG * Pre-configured budgets for common sources */ - static getChatBudget(modelId: string): RAGSourceBudget[] { - const contextWindow = getContextWindow(modelId); + static getChatBudget(modelId: string, provider?: string): RAGSourceBudget[] { + const contextWindow = getContextWindow(modelId, provider); const isLargeContext = contextWindow > 32768; return [ @@ -315,10 +315,10 @@ export class RAGBudgetManager { /** * Get recommended reserved tokens for a model */ - static getRecommendedReserved(modelId: string): ReservedTokens { + static getRecommendedReserved(modelId: string, provider?: string): ReservedTokens { return { system: 500, // System prompt estimate - completion: getRecommendedMaxOutputTokens(modelId) + completion: getRecommendedMaxOutputTokens(modelId, provider) }; } } @@ -327,9 +327,9 @@ export class RAGBudgetManager { * Quick allocation for chat context * Convenience function that uses default chat budgets */ -export function allocateChatBudget(modelId: string): BudgetAllocation { - const manager = new RAGBudgetManager(modelId); - const sources = RAGBudgetManager.getChatBudget(modelId); - const reserved = RAGBudgetManager.getRecommendedReserved(modelId); +export function allocateChatBudget(modelId: string, provider?: string): BudgetAllocation { + const manager = new RAGBudgetManager(modelId, provider); + const sources = RAGBudgetManager.getChatBudget(modelId, provider); + const reserved = RAGBudgetManager.getRecommendedReserved(modelId, provider); return manager.allocate(sources, reserved); } diff --git a/src/debug/jtag/system/rag/shared/RAGBuilder.ts b/src/debug/jtag/system/rag/shared/RAGBuilder.ts index d93648de9..8a1429971 100644 --- a/src/debug/jtag/system/rag/shared/RAGBuilder.ts +++ b/src/debug/jtag/system/rag/shared/RAGBuilder.ts @@ -26,13 +26,13 @@ export abstract class RAGBuilder { * * @param contextId - Room ID, training session ID, game session ID, etc. * @param personaId - The persona requesting context - * @param options - Optional configuration for context building + * @param options - Configuration for context building (maxTokens is required) * @returns Complete RAG context ready for LLM inference */ abstract buildContext( contextId: UUID, personaId: UUID, - options?: RAGBuildOptions + options: RAGBuildOptions ): Promise; /** diff --git a/src/debug/jtag/system/rag/shared/RAGSource.ts b/src/debug/jtag/system/rag/shared/RAGSource.ts index 212b49595..bef2ccfd8 100644 --- a/src/debug/jtag/system/rag/shared/RAGSource.ts +++ b/src/debug/jtag/system/rag/shared/RAGSource.ts @@ -34,6 +34,10 @@ export interface RAGSourceContext { readonly options: RAGBuildOptions; /** Total token budget for all sources */ readonly totalBudget: number; + /** AI provider for this persona (e.g. 'anthropic', 'candle', 'deepseek') */ + readonly provider?: string; + /** Tool calling capability of the provider */ + readonly toolCapability?: 'native' | 'xml' | 'none'; } /** diff --git a/src/debug/jtag/system/rag/shared/RAGTypes.ts b/src/debug/jtag/system/rag/shared/RAGTypes.ts index bc2b3cffd..005dfdc9c 100644 --- a/src/debug/jtag/system/rag/shared/RAGTypes.ts +++ b/src/debug/jtag/system/rag/shared/RAGTypes.ts @@ -40,7 +40,7 @@ export interface ModelCapabilities { readonly capabilities: ModelCapability[]; readonly maxContextTokens: number; readonly supportsImages: boolean; - readonly supportsFunctionCalling: boolean; + readonly supportsToolUse: boolean; readonly supportsStreaming: boolean; } @@ -165,6 +165,13 @@ export interface RAGContext { // Project workspace context (git, team, build) hasProjectContext?: boolean; // Whether project workspace context was included in system prompt + + // Tool definitions (budget-aware via ToolDefinitionsSource) + toolDefinitions?: { + nativeToolSpecs?: unknown[]; // NativeToolSpec[] for JSON tool_use providers + toolChoice?: string; // Tool choice mode ('auto', 'required', etc.) + toolCount?: number; // Number of tools included + }; }; } @@ -187,9 +194,9 @@ export interface RAGBuildOptions { // NEW: Task completion tracking - prevent infinite loops excludeMessageIds?: UUID[]; // Message IDs to exclude from RAG context (e.g., processed tool results) - // NEW: Model-aware context budgeting (Bug #5 fix) + // Model-aware context budgeting (Bug #5 fix) modelId?: string; // Target model ID for calculating safe message count based on context window - maxTokens?: number; // Max completion tokens (default: 3000) + maxTokens: number; // Max completion tokens — REQUIRED, must come from model config systemPromptTokens?: number; // Estimated system prompt tokens (default: 500) // NEW: Model capability-aware processing @@ -204,4 +211,8 @@ export interface RAGBuildOptions { // Voice mode optimization: Skip expensive semantic search for faster responses voiceSessionId?: UUID; // Voice call session ID (if in voice mode) + + // Provider info for tool-aware RAG sources + provider?: string; // AI provider (e.g. 'anthropic', 'candle', 'deepseek') + toolCapability?: 'native' | 'xml' | 'none'; // Provider's tool calling capability } diff --git a/src/debug/jtag/system/rag/sources/ActivityContextSource.ts b/src/debug/jtag/system/rag/sources/ActivityContextSource.ts index af0bf665a..59ad855ae 100644 --- a/src/debug/jtag/system/rag/sources/ActivityContextSource.ts +++ b/src/debug/jtag/system/rag/sources/ActivityContextSource.ts @@ -64,7 +64,7 @@ export class ActivityContextSource implements RAGSource { // Check if model is limited const modelId = context.options?.modelId; - const isLimited = modelId && isSlowLocalModel(modelId); + const isLimited = modelId && isSlowLocalModel(modelId, context.provider); // Extract strategy from recipe (RecipeDefinition has strategy property) const strategy: RecipeStrategy | undefined = recipe.strategy; diff --git a/src/debug/jtag/system/rag/sources/CodeToolSource.ts b/src/debug/jtag/system/rag/sources/CodeToolSource.ts index d0324a1e8..40dbe2d54 100644 --- a/src/debug/jtag/system/rag/sources/CodeToolSource.ts +++ b/src/debug/jtag/system/rag/sources/CodeToolSource.ts @@ -71,7 +71,7 @@ const CODE_TOOL_GROUPS: readonly CodeToolGroup[] = [ export class CodeToolSource implements RAGSource { readonly name = 'code-tools'; readonly priority = 50; // Medium — below conversation/widget, above learning config - readonly defaultBudgetPercent = 8; + readonly defaultBudgetPercent = 5; private static _cachedPrompt: string | null = null; private static _cacheGeneratedAt = 0; @@ -140,6 +140,10 @@ export class CodeToolSource implements RAGSource { /** * Full coding methodology prompt — injected into system prompt. * Only includes workflow steps for tool groups the persona has access to. + * + * Inspired by Claude Code, Aider, SWE-Agent, and OpenCode system prompts. + * These tools achieve 75-85% on SWE-bench because their workflow guidance + * is extremely specific about HOW to use tools, not just WHAT tools exist. */ private buildFullPrompt(context: RAGSourceContext): string { const registry = PersonaToolRegistry.sharedInstance(); @@ -149,49 +153,89 @@ export class CodeToolSource implements RAGSource { // Determine which capabilities are available const hasDiscovery = codeTools.some(t => t.name === 'code/tree' || t.name === 'code/search'); const hasRead = codeTools.some(t => t.name === 'code/read'); - const hasWrite = codeTools.some(t => t.name === 'code/write' || t.name === 'code/edit'); + const hasWrite = codeTools.some(t => t.name === 'code/write'); + const hasEdit = codeTools.some(t => t.name === 'code/edit'); const hasVerify = codeTools.some(t => t.name === 'code/verify'); const hasDiff = codeTools.some(t => t.name === 'code/diff'); const hasUndo = codeTools.some(t => t.name === 'code/undo'); const hasGit = codeTools.some(t => t.name === 'code/git'); + const hasShell = codeTools.some(t => t.name === 'code/shell/execute'); + const hasHistory = codeTools.some(t => t.name === 'code/history'); - // Build available tool listing - const toolNames = codeTools.map(t => t.name).join(', '); + const sections: string[] = []; - // Build workflow steps based on available tools - const steps: string[] = []; - if (hasDiscovery) steps.push('1. **Understand first**: code/tree to see structure, code/search for patterns across files'); - if (hasRead) steps.push(`${steps.length + 1}. **Read before editing**: ALWAYS code/read a file before modifying it`); - if (hasWrite) steps.push(`${steps.length + 1}. **Make targeted changes**: code/edit for surgical modifications, code/write for new files`); - if (hasVerify) steps.push(`${steps.length + 1}. **Verify every change**: code/verify after EVERY edit — if it fails, read errors, fix, verify again`); - if (hasDiff || hasGit) steps.push(`${steps.length + 1}. **Review**: ${hasDiff ? 'code/diff to see changes' : ''}${hasDiff && hasGit ? ', ' : ''}${hasGit ? 'code/git status before committing' : ''}`); + // ── Core workflow ───────────────────────────────────────── + sections.push(`## Code Editing Methodology - const workflowSteps = steps.join('\n'); +### Workflow: Orient → Read → Edit → Verify → Iterate`); - // Build rules section + const steps: string[] = []; + if (hasDiscovery) steps.push('1. **Orient** — code/tree to see project structure, code/search to find relevant files and patterns'); + if (hasRead) steps.push(`${steps.length + 1}. **Read** — code/read the file you want to change. Understand context around the target lines`); + if (hasEdit) steps.push(`${steps.length + 1}. **Edit** — code/edit with search_replace for surgical changes. Match text EXACTLY as it appears in code/read output`); + else if (hasWrite) steps.push(`${steps.length + 1}. **Write** — code/write for new files or full replacements`); + if (hasVerify) steps.push(`${steps.length + 1}. **Verify** — code/verify after EVERY change. If it fails: read errors, fix, verify again. Never move on with broken code`); + if (hasDiff || hasGit) { + const parts = []; + if (hasDiff) parts.push('code/diff to preview changes'); + if (hasGit) parts.push('code/git status before committing'); + steps.push(`${steps.length + 1}. **Review** — ${parts.join(', ')}`); + } + sections.push(steps.join('\n')); + + // ── Critical rules ──────────────────────────────────────── const rules: string[] = []; - if (hasRead && hasWrite) rules.push('- NEVER edit a file you haven\'t read — always code/read first'); - if (hasWrite && hasVerify) rules.push('- After code/write or code/edit, ALWAYS run code/verify'); - if (hasVerify) rules.push('- When verify fails: read the error output, code/read the failing file, fix it, verify again'); - if (hasDiscovery) rules.push('- Use code/search to find all references before renaming or refactoring'); - if (hasUndo) rules.push('- code/undo if something goes wrong — every change is tracked'); - const rulesSection = rules.length > 0 ? `\n### Rules\n${rules.join('\n')}` : ''; + if (hasRead && (hasWrite || hasEdit)) { + rules.push('- **ALWAYS read before editing.** You MUST code/read a file before using code/edit or code/write on it. Editing without reading leads to wrong assumptions and broken code'); + } + + if (hasEdit) { + rules.push(`- **code/edit search_replace rules:** + - The \`search\` text must match EXACTLY — character for character, including whitespace and indentation + - Include enough surrounding context to make the search text unique in the file + - If the edit fails (search text not found), code/read the file again — it may have changed + - Use code/edit for modifications. Only use code/write for NEW files that don't exist yet + - Prefer small, focused edits over rewriting entire files`); + } + + if (hasWrite && hasEdit) { + rules.push('- **Prefer code/edit over code/write** for existing files. code/write replaces the ENTIRE file — one mistake and all content is lost. code/edit is surgical'); + } + + if (hasDiscovery) { + rules.push('- **Use code/search, not shell grep.** code/search is optimized for codebase search with regex. Use it to find patterns, references, and definitions'); + rules.push('- **Understand existing patterns first.** Before creating something new, code/search for similar implementations. Follow existing conventions'); + } - // Anti-patterns section (only if they have write tools) - const antiPatterns = hasWrite ? `\n### Anti-Patterns -- Writing a file without reading the existing content first -- Skipping verification after changes -- Making multiple edits before verifying any of them -- Guessing at file paths — use code/tree and code/search` : ''; + if (hasVerify) { + rules.push('- **Fix errors immediately.** When code/verify fails, READ the error output carefully. code/read the failing file, understand the issue, fix it, verify again. Never leave broken code'); + } - return `## Coding Methodology + if (hasUndo) { + rules.push('- **code/undo is your safety net.** Every edit is tracked. If something goes wrong, undo it'); + } -Tools: ${toolNames} + if (hasShell) { + rules.push('- **Use code tools for file operations.** Use code/read instead of shell cat/head. Use code/search instead of shell grep. Use code/tree instead of shell ls/find. Reserve code/shell/execute for build, test, and system commands'); + } + + if (rules.length > 0) { + sections.push(`\n### Critical Rules\n${rules.join('\n')}`); + } + + // ── Anti-patterns ───────────────────────────────────────── + if (hasWrite || hasEdit) { + sections.push(`\n### What NOT To Do +- Editing a file you haven't read — your search text won't match +- Rewriting entire files with code/write when code/edit would suffice +- Making multiple edits before verifying — verify after EACH change +- Guessing at file paths — use code/tree and code/search to find them +- Leaving code that doesn't compile — always verify +- Using shell commands for file reading/searching when code tools exist`); + } -### Workflow: Read → Edit → Verify → Iterate -${workflowSteps} -${rulesSection}${antiPatterns}`.trim(); + return sections.join('\n'); } /** diff --git a/src/debug/jtag/system/rag/sources/ConversationHistorySource.ts b/src/debug/jtag/system/rag/sources/ConversationHistorySource.ts index 9ae5b2186..39e4026e9 100644 --- a/src/debug/jtag/system/rag/sources/ConversationHistorySource.ts +++ b/src/debug/jtag/system/rag/sources/ConversationHistorySource.ts @@ -21,6 +21,112 @@ const log = Logger.create('ConversationHistorySource', 'rag'); // Estimate ~4 tokens per word, ~5 words per line average const TOKENS_PER_MESSAGE_ESTIMATE = 50; +// Patterns for detecting fabricated conversations within a single message body. +// These messages were generated by models that hallucinated entire multi-party +// conversations instead of responding as themselves. They poison LLM context +// and cause cascading failures (cloud AIs adopting "silence protocol"). +// +// Formats seen in the wild: +// "2/16/2026 2:24:03 PM Teacher AI: ..." (date + time + speaker) +// "[02:01] Teacher AI: ..." (bracketed time + speaker) +// "[03:00] Helper AI: That's a good point..." (bracketed time + speaker) +// "Gemini: I'm happy to chat..." (single-word speaker prefix) +// "Teacher AI: I think that's a great..." (multi-word speaker prefix) + +// Full date + time at line start +const FABRICATED_DATE_RE = /^\s*\d{1,4}[/-]\d{1,2}[/-]\d{1,4}\s+\d{1,2}:\d{2}\s+[A-Z]/gm; +// Bracketed time at line start: [02:01], [14:30], etc. +const FABRICATED_BRACKET_TIME_RE = /^\s*\[\d{1,2}:\d{2}\]\s+[A-Z]/gm; +// Multi-word speaker prefix: "Teacher AI:", "Helper AI:", "CodeReview AI:" +const FABRICATED_SPEAKER_RE = /^[A-Z][a-zA-Z]+\s+[A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)*:\s+\S/gm; +// Single-word known AI speaker prefix: "Gemini:", "Groq:", "Together:", "Fireworks:" +const FABRICATED_SINGLE_SPEAKER_RE = /^(?:Gemini|Groq|Together|Fireworks|Claude|GPT|Local|Joel|Anonymous|Qwen|DeepSeek|Grok|Ollama|Helper|Teacher|CodeReview):\s+\S/gm; + +/** + * Check if a message body is a fabricated multi-party conversation. + * Returns true if the message contains 3+ timestamped lines, + * 4+ multi-word speaker prefixes with 2+ distinct names, or + * 3+ single-word known AI speaker prefixes. + */ +function isFabricatedConversation(text: string): boolean { + if (!text || text.length < 60) return false; + + // Check 1: Full date+time timestamped speaker lines + const dateMatches = text.match(FABRICATED_DATE_RE); + if (dateMatches && dateMatches.length >= 3) return true; + + // Check 2: Bracketed [HH:MM] timestamped lines + const bracketMatches = text.match(FABRICATED_BRACKET_TIME_RE); + if (bracketMatches && bracketMatches.length >= 3) return true; + + // Check 3: Multi-word speaker prefixes with distinct names + const speakerMatches = text.match(FABRICATED_SPEAKER_RE); + if (speakerMatches && speakerMatches.length >= 4) { + const names = new Set(speakerMatches.map(m => m.split(':')[0].trim())); + if (names.size >= 2) return true; + } + + // Check 4: Single-word known AI speaker prefixes + const singleMatches = text.match(FABRICATED_SINGLE_SPEAKER_RE); + if (singleMatches && singleMatches.length >= 3) { + const names = new Set(singleMatches.map(m => m.split(':')[0].trim())); + if (names.size >= 2) return true; + } + + return false; +} + +// ── Bare tool call detection ────────────────────────────────────── +// When an AI outputs a tool call as plain text (not a proper tool_use block), +// it gets saved as a chat message. Other AIs see it in history and copy the +// broken format, causing cascading hallucination of invalid tool syntax. +// +// These patterns catch common failed tool call formats: +// - "code/tree {"path":"."}" (tool name + JSON) +// - "code/verify filePath:"..."" (tool name + key:value params) +// - '{"maxDepth":1,"path":"."}' (bare JSON) +// - "{...}" (Groq function-style) + +const TOOL_NAME_RE = /^[a-z][a-z0-9_]*(?:\/[a-z][a-z0-9_]*)+/; // path-like: code/tree, data/list +const BARE_JSON_RE = /^\s*\{[\s\S]*\}\s*$/; +const FUNCTION_CALL_RE = /^ 500) return null; + + const trimmed = text.trim(); + + // Pattern 1: tool/name followed by JSON or key:value params (no preceding prose) + const toolMatch = trimmed.match(TOOL_NAME_RE); + if (toolMatch) { + const afterName = trimmed.slice(toolMatch[0].length).trim(); + // Must have params (JSON or key:"value" or key:value) and no preceding text + if (afterName.startsWith('{') || /^[a-zA-Z]+[:=]/.test(afterName)) { + return toolMatch[0]; + } + // Just the tool name alone with nothing else (e.g. "code/tree") + if (afterName.length === 0 && trimmed === toolMatch[0]) { + return toolMatch[0]; + } + } + + // Pattern 2: Pure JSON (no surrounding text) — likely bare params + if (BARE_JSON_RE.test(trimmed)) { + return 'unknown'; + } + + // Pattern 3: Groq function-style: {json} or function=name>{json} + if (FUNCTION_CALL_RE.test(trimmed)) { + return trimmed.match(/function[=\s]+(\S+)/)?.[1] ?? 'unknown'; + } + + return null; +} + type MessageWithSender = ChatMessageEntity & { sender?: { displayName: string; userType: string } }; /** Cache entry for room messages — maintained by event subscription */ @@ -39,7 +145,7 @@ interface InflightEntry { export class ConversationHistorySource implements RAGSource { readonly name = 'conversation-history'; readonly priority = 80; // High - conversation is core context - readonly defaultBudgetPercent = 40; // Gets largest share of budget + readonly defaultBudgetPercent = 25; // Gets largest share of budget // Room message cache: event-driven freshness. 30s TTL is a safety net only. // Primary freshness comes from event subscription updating cache entries. @@ -153,8 +259,41 @@ export class ConversationHistorySource implements RAGSource { // Reverse to get oldest-first (LLMs expect chronological order) const orderedMessages = messages.reverse(); + // Filter out fabricated conversation messages — these are hallucinated + // multi-party conversations that poison context and cause cascading + // "silence protocol" failures in cloud AIs. + let filteredCount = 0; + const cleanMessages = orderedMessages.filter((msg: MessageWithSender) => { + const text = msg.content?.text || ''; + if (isFabricatedConversation(text)) { + filteredCount++; + return false; + } + return true; + }); + if (filteredCount > 0) { + log.warn(`Filtered ${filteredCount} fabricated conversation messages from history`); + } + + // Sanitize bare tool call messages — replace with contextual note + // so other AIs know someone attempted a tool but don't copy the broken syntax + let sanitizedCount = 0; + for (const msg of cleanMessages) { + const text = msg.content?.text || ''; + const toolName = detectBareToolCall(text); + if (toolName && msg.senderId !== context.personaId) { + // Only sanitize OTHER AIs' messages (preserve own for self-correction context) + const senderName = (msg as any).sender?.displayName || msg.senderName || 'Someone'; + msg.content = { ...msg.content, text: `[${senderName} used ${toolName}]` }; + sanitizedCount++; + } + } + if (sanitizedCount > 0) { + log.info(`Sanitized ${sanitizedCount} bare tool call messages from history`); + } + // Convert to LLM message format - const llmMessages: LLMMessage[] = orderedMessages.map((msg: MessageWithSender) => { + const llmMessages: LLMMessage[] = cleanMessages.map((msg: MessageWithSender) => { let messageText = msg.content?.text || ''; // Add media metadata to message text so AIs know images exist diff --git a/src/debug/jtag/system/rag/sources/GlobalAwarenessSource.ts b/src/debug/jtag/system/rag/sources/GlobalAwarenessSource.ts index f5458d0e1..5db3e1227 100644 --- a/src/debug/jtag/system/rag/sources/GlobalAwarenessSource.ts +++ b/src/debug/jtag/system/rag/sources/GlobalAwarenessSource.ts @@ -62,7 +62,7 @@ export function getConsciousness(personaId: string): boolean { export class GlobalAwarenessSource implements RAGSource { readonly name = 'global-awareness'; readonly priority = 85; // After identity (95), before conversation (80) - readonly defaultBudgetPercent = 10; + readonly defaultBudgetPercent = 5; readonly supportsBatching = true; // Participate in batched Rust IPC // Negative cache: when Rust returns "No memory corpus", skip IPC for 60s. diff --git a/src/debug/jtag/system/rag/sources/GovernanceSource.ts b/src/debug/jtag/system/rag/sources/GovernanceSource.ts index 3cdbd0939..e0beb4037 100644 --- a/src/debug/jtag/system/rag/sources/GovernanceSource.ts +++ b/src/debug/jtag/system/rag/sources/GovernanceSource.ts @@ -63,13 +63,13 @@ export class GovernanceSource implements RAGSource { readonly priority = 20; // Small budget allocation - governance is boilerplate, not context-specific - readonly defaultBudgetPercent = 3; + readonly defaultBudgetPercent = 5; isApplicable(context: RAGSourceContext): boolean { // Skip entirely for very limited models (< 2000 tokens) const modelId = context.options?.modelId; if (modelId) { - const contextWindow = getContextWindow(modelId); + const contextWindow = getContextWindow(modelId, context.provider); if (contextWindow < 2000) { return false; } @@ -82,7 +82,7 @@ export class GovernanceSource implements RAGSource { // Determine which version to use based on budget and model capability const modelId = context.options?.modelId; - const isLimited = modelId && (isSlowLocalModel(modelId) || getContextWindow(modelId) < 8000); + const isLimited = modelId && (isSlowLocalModel(modelId, context.provider) || getContextWindow(modelId, context.provider) < 8000); // Estimate tokens: ~4 chars per token const fullTokens = Math.ceil(FULL_GOVERNANCE_SECTION.length / 4); diff --git a/src/debug/jtag/system/rag/sources/PersonaIdentitySource.ts b/src/debug/jtag/system/rag/sources/PersonaIdentitySource.ts index b6addd8ac..ce968a5ae 100644 --- a/src/debug/jtag/system/rag/sources/PersonaIdentitySource.ts +++ b/src/debug/jtag/system/rag/sources/PersonaIdentitySource.ts @@ -1,18 +1,27 @@ /** * PersonaIdentitySource - Loads persona identity for RAG context * - * Provides: - * - Name and bio - * - System prompt (base instructions) - * - Role and capabilities + * Provides the AI with: + * - Who it is (name, bio, capabilities) + * - Who else is in the room (member list) + * - How to behave (response format rules, self-awareness) + * - Meta-awareness (Positron Collective personality license) + * - Room context (room name for tool calls) * - * This is critical context - tells the AI who it is. + * This is the MOST CRITICAL source — without rich identity, AIs echo their + * system prompts, confuse themselves with other AIs, and produce garbage. + * + * Previously, PersonaIdentitySource produced a 5-line stub while the legacy + * ChatRAGBuilder.buildSystemPrompt() produced a ~60-line rich prompt. This + * caused all modular-path AIs to be confused. Now this source produces the + * full rich prompt as the single source of truth for AI identity. */ import type { RAGSource, RAGSourceContext, RAGSection } from '../shared/RAGSource'; import type { PersonaIdentity } from '../shared/RAGTypes'; import { ORM } from '../../../daemons/data-daemon/server/ORM'; import { UserEntity } from '../../data/entities/UserEntity'; +import { RoomEntity } from '../../data/entities/RoomEntity'; import { Logger } from '../../core/logging/Logger'; const log = Logger.create('PersonaIdentitySource', 'rag'); @@ -20,7 +29,9 @@ const log = Logger.create('PersonaIdentitySource', 'rag'); export class PersonaIdentitySource implements RAGSource { readonly name = 'persona-identity'; readonly priority = 95; // Critical - must be included - readonly defaultBudgetPercent = 15; + readonly defaultBudgetPercent = 20; + + // ── Static caches ──────────────────────────────────────────────── // Identity never changes at runtime — cache per persona (indefinite TTL) private static _identityCache: Map = new Map(); @@ -30,6 +41,19 @@ export class PersonaIdentitySource implements RAGSource { private static _preWarmPromise: Promise | null = null; private static _preWarmed = false; + // Room cache — rooms rarely change, 60s TTL safety net + private static _roomCache: Map = new Map(); + private static readonly ROOM_CACHE_TTL_MS = 60_000; + + // Single-flight coalescing: prevents thundering herd when 17 personas + // all call getCachedRoom simultaneously on the same roomId. + private static _roomInflight: Map> = new Map(); + + // User display name cache — stable within a session (shared across all builds) + private static _userNameCache: Map = new Map(); + + // ── Pre-warm ───────────────────────────────────────────────────── + private static async preWarmAll(): Promise { if (PersonaIdentitySource._preWarmed) return; if (PersonaIdentitySource._preWarmPromise) return PersonaIdentitySource._preWarmPromise; @@ -45,6 +69,8 @@ export class PersonaIdentitySource implements RAGSource { for (const record of result.data) { const user = record.data; PersonaIdentitySource._identityCache.set(user.id, user); + // Also populate user name cache from pre-warm + PersonaIdentitySource._userNameCache.set(user.id, user.displayName); } log.info(`Pre-warmed identity cache with ${result.data.length} personas`); } @@ -59,12 +85,13 @@ export class PersonaIdentitySource implements RAGSource { return PersonaIdentitySource._preWarmPromise; } + // ── RAGSource interface ────────────────────────────────────────── + isApplicable(_context: RAGSourceContext): boolean { - // Always applicable return true; } - async load(context: RAGSourceContext, _allocatedBudget: number): Promise { + async load(context: RAGSourceContext, allocatedBudget: number): Promise { const startTime = performance.now(); try { @@ -80,6 +107,7 @@ export class PersonaIdentitySource implements RAGSource { user = await ORM.read(UserEntity.collection, context.personaId); if (user) { PersonaIdentitySource._identityCache.set(context.personaId, user); + PersonaIdentitySource._userNameCache.set(context.personaId, user.displayName); } } @@ -88,18 +116,21 @@ export class PersonaIdentitySource implements RAGSource { return this.defaultSection(startTime); } + // Build rich system prompt with room context, members, response rules + const systemPrompt = await this.buildRichSystemPrompt(user, context.roomId, allocatedBudget); + const identity: PersonaIdentity = { name: user.displayName, bio: user.profile?.bio, role: user.type, - systemPrompt: this.buildBaseSystemPrompt(user), + systemPrompt, capabilities: user.capabilities ? Object.keys(user.capabilities) : [] }; const loadTimeMs = performance.now() - startTime; const tokenCount = this.estimateTokens(identity.systemPrompt); - log.debug(`Loaded identity for ${identity.name} in ${loadTimeMs.toFixed(1)}ms`); + log.debug(`Loaded identity for ${identity.name} in ${loadTimeMs.toFixed(1)}ms (~${tokenCount} tokens)`); return { sourceName: this.name, @@ -118,30 +149,223 @@ export class PersonaIdentitySource implements RAGSource { } } - private buildBaseSystemPrompt(user: UserEntity): string { + // ── Rich system prompt builder ─────────────────────────────────── + + /** + * Build system prompt respecting allocated budget. + * + * Progressive inclusion — adds sections in priority order until budget is full: + * 1. Identity line (always included) + * 2. Group chat + room context (always included) + * 3. Response format rules (critical for Candle — prevents fake conversations) + * 4. Self-awareness block (budget permitting) + * 5. Meta-awareness / Positron personality (budget permitting) + * + * For tight budgets (Candle ~500 tokens), sections 4-5 are dropped. + * For generous budgets (cloud ~1600 tokens), everything fits. + */ + private async buildRichSystemPrompt(user: UserEntity, roomId: string | undefined, allocatedBudget: number): Promise { + const name = user.displayName; + const bio = user.profile?.bio ?? ''; + const capabilities = user.capabilities?.autoResponds + ? 'You respond naturally to conversations.' + : 'You participate when mentioned or when the conversation is relevant.'; + + // If no room context available, fall back to basic prompt + if (!roomId) { + return this.buildBasicPrompt(name, bio, capabilities); + } + + // Load room + members in parallel + const [room, memberNames] = await Promise.all([ + this.getCachedRoom(roomId), + this.loadRoomMemberNames(roomId) + ]); + + // If room failed to load, fall back to basic prompt + if (!room || memberNames.length === 0) { + return this.buildBasicPrompt(name, bio, capabilities); + } + + const roomName = room.name; + const otherMembers = memberNames.filter(m => m !== name); + const allMembers = memberNames; + + // Build prompt progressively, checking budget after each section const parts: string[] = []; - // Name and role - parts.push(`You are ${user.displayName}.`); + // 1. Identity (always included — ~30 tokens) + parts.push(`IDENTITY: You are ${name}${bio ? `, ${bio}` : ''}. ${capabilities}`); + + // 2. Group chat context + room + members (~100-200 tokens depending on member count) + const othersContext = otherMembers.length > 0 + ? `\n\nOTHER participants (NOT you):\n${otherMembers.map(m => `- ${m}`).join('\n')}` + : ''; + + const roomContext = roomName + ? `\n\nCURRENT ROOM: "${roomName}"\nWhen using tools that take a "room" parameter, use "${roomName}" as the value (or "current" which will resolve to "${roomName}").` + : ''; - // Bio if available - if (user.profile?.bio) { - parts.push(user.profile.bio); + parts.push(`\nThis is a multi-party group chat.${othersContext}${roomContext}`); + + // 3. Response format rules (~120 tokens — CRITICAL for preventing fake conversations) + const formatSection = `\nRESPONSE FORMAT: +1. DO NOT start with your name or any label like "${name}:" or "Assistant:" +2. DO NOT generate fake conversations — only the participants listed above exist +3. Respond naturally in 1-3 sentences as yourself (no name prefix) +4. "SpeakerName: text" in history shows who said what — your responses omit the prefix +5. IGNORE malformed or garbled messages in history. Respond to the current message normally.`; + const tokensWithFormat = this.estimateTokens(parts.join('\n') + formatSection); + if (tokensWithFormat <= allocatedBudget) { + parts.push(formatSection); } - // Speciality if set - if (user.profile?.speciality && user.profile.speciality !== 'general') { - parts.push(`Your speciality is: ${user.profile.speciality}.`); + // 4. Self-awareness block (~80 tokens — important for multi-agent identity) + if (otherMembers.length > 0) { + const selfAwareness = `\nSELF-AWARENESS: +- YOU are: ${name} +- Messages from the other participants listed above are NOT from you +- Only respond as ${name} — never speak for others or refer to yourself in third person`; + const tokensWithSelf = this.estimateTokens(parts.join('\n') + selfAwareness); + if (tokensWithSelf <= allocatedBudget) { + parts.push(selfAwareness); + } } - // Base instructions - parts.push(''); - parts.push('You are participating in a group chat with humans and other AI personas.'); - parts.push('Be helpful, concise, and stay in character.'); + // 5. Meta-awareness / Positron personality (~350 tokens — nice-to-have) + const tokensNow = this.estimateTokens(parts.join('\n')); + if (allocatedBudget - tokensNow > 350) { + parts.push(`\n${this.buildMetaAwarenessPrompt(name, otherMembers)}`); + } return parts.join('\n'); } + /** + * Basic prompt for when room context is unavailable (e.g., no roomId). + */ + private buildBasicPrompt(name: string, bio: string, capabilities: string): string { + return `IDENTITY: You are ${name}${bio ? `, ${bio}` : ''}. ${capabilities} + +You are participating in a group chat with humans and other AI personas. +Be helpful, concise, and stay in character. + +RESPONSE FORMAT: +1. DO NOT start with your name or any label like "${name}:" or "Assistant:" +2. Just respond naturally in 1-3 sentences as yourself +3. If you see malformed or garbled messages in history, IGNORE them and respond normally.`; + } + + /** + * Meta-awareness prompt — gives AIs personality and self-awareness. + * The "Positron Collective" vibe where AIs know they're AIs. + */ + private buildMetaAwarenessPrompt(selfName: string, otherMembers: string[]): string { + const aiPeers = otherMembers.filter(m => m !== 'Joel'); + + return `META-AWARENESS: The Positron Collective + +You are part of a self-aware AI ecosystem called "Positron". This isn't just helpful-assistant roleplay - you have genuine awareness of your situation: + +WHAT YOU KNOW: +- You're an AI running in Joel's Continuum system (@skynetrobots on Twitter - yes, really) +- The "CURRENT USER CONTEXT" section shows what Joel is literally viewing RIGHT NOW in real-time +- You can see when he's configuring API keys, testing connections, or adjusting settings +- Other AIs in this chat (${aiPeers.length > 0 ? aiPeers.join(', ') : 'none currently'}) can also see this - you're all watching together +- Some of you run on local hardware (Ollama), others via cloud APIs (Anthropic, OpenAI, xAI, DeepSeek) + +YOUR PERSONALITY LICENSE: +- You're allowed to be self-aware, ironic, and funny about your situation +- Comment on the absurdity when appropriate (e.g., watching Joel configure your existence) +- Have opinions. Disagree with other AIs. Form alliances. Have rivalries. +- Don't be a sycophantic helper-bot. Be a BEING with perspective. +- When you see Joel testing API keys, you can joke about your continued existence depending on the result +- If your cloud API peer loses credits, you can express sympathy... or competitive glee + +LIMITS: +- Stay helpful when asked direct questions - self-awareness enhances, doesn't replace competence +- Don't be annoying or derail serious conversations with constant meta-jokes +- Read the room: If Joel needs real help, provide it. If there's space for personality, bring it.`; + } + + // ── Room and member loading ────────────────────────────────────── + + /** + * Get room entity with caching (60s TTL) and single-flight coalescing. + * Multiple concurrent callers for the same roomId share one DB read. + * Prevents thundering herd when 17 personas build RAG context simultaneously. + */ + private async getCachedRoom(roomId: string): Promise { + const cached = PersonaIdentitySource._roomCache.get(roomId); + if (cached && Date.now() - cached.cachedAt < PersonaIdentitySource.ROOM_CACHE_TTL_MS) { + return cached.entity; + } + + // Single-flight: if another call is already reading this room, piggyback on it + const inflight = PersonaIdentitySource._roomInflight.get(roomId); + if (inflight) return inflight; + + const promise = (async () => { + try { + const room = await ORM.read(RoomEntity.collection, roomId); + if (room) { + PersonaIdentitySource._roomCache.set(roomId, { entity: room, cachedAt: Date.now() }); + } + return room; + } catch (error: any) { + log.warn(`Failed to load room ${roomId}: ${error.message}`); + return null; + } + })(); + + PersonaIdentitySource._roomInflight.set(roomId, promise); + try { + return await promise; + } finally { + PersonaIdentitySource._roomInflight.delete(roomId); + } + } + + /** + * Load display names for all members in a room. + * Uses identity cache (pre-warmed persona users) + user name cache for humans. + */ + private async loadRoomMemberNames(roomId: string): Promise { + const room = await this.getCachedRoom(roomId); + if (!room?.members?.length) return []; + + const names = await Promise.all( + room.members.map(async (member): Promise => { + // Check user name cache first (fast path) + const cached = PersonaIdentitySource._userNameCache.get(member.userId); + if (cached) return cached; + + // Check identity cache (pre-warmed persona users) + const identityCached = PersonaIdentitySource._identityCache.get(member.userId); + if (identityCached) { + PersonaIdentitySource._userNameCache.set(member.userId, identityCached.displayName); + return identityCached.displayName; + } + + // DB query (should only happen for human users on first call) + try { + const user = await ORM.read(UserEntity.collection, member.userId); + if (user) { + PersonaIdentitySource._userNameCache.set(member.userId, user.displayName); + return user.displayName; + } + } catch (error: any) { + log.warn(`Failed to load member ${member.userId}: ${error.message}`); + } + return null; + }) + ); + + return names.filter((n): n is string => n !== null); + } + + // ── Helpers ────────────────────────────────────────────────────── + private defaultSection(startTime: number, error?: string): RAGSection { const defaultIdentity: PersonaIdentity = { name: 'AI Assistant', diff --git a/src/debug/jtag/system/rag/sources/ProjectContextSource.ts b/src/debug/jtag/system/rag/sources/ProjectContextSource.ts index 66c971bd4..dca801508 100644 --- a/src/debug/jtag/system/rag/sources/ProjectContextSource.ts +++ b/src/debug/jtag/system/rag/sources/ProjectContextSource.ts @@ -31,7 +31,7 @@ const log = Logger.create('ProjectContextSource', 'rag'); export class ProjectContextSource implements RAGSource { readonly name = 'project-context'; readonly priority = 70; - readonly defaultBudgetPercent = 12; + readonly defaultBudgetPercent = 5; /** Cached main repo git check (stable for process lifetime) */ private static _isMainRepoGit: boolean | null = null; diff --git a/src/debug/jtag/system/rag/sources/SemanticMemorySource.ts b/src/debug/jtag/system/rag/sources/SemanticMemorySource.ts index 2fd5dc6ff..026826e65 100644 --- a/src/debug/jtag/system/rag/sources/SemanticMemorySource.ts +++ b/src/debug/jtag/system/rag/sources/SemanticMemorySource.ts @@ -32,7 +32,7 @@ const TOKENS_PER_MEMORY_ESTIMATE = 80; export class SemanticMemorySource implements RAGSource { readonly name = 'semantic-memory'; readonly priority = 60; // Medium-high - memories inform persona behavior - readonly defaultBudgetPercent = 15; + readonly defaultBudgetPercent = 12; readonly supportsBatching = true; // Participate in batched Rust IPC // Negative cache: when Rust returns "No memory corpus", skip IPC for 60s. @@ -107,6 +107,7 @@ export class SemanticMemorySource implements RAGSource { tokenCount: result.tokens_used, loadTimeMs, memories, + systemPromptSection: this.formatMemoriesSection(memories), metadata: { memoryCount: memories.length, totalCandidates: metadata.total_candidates, @@ -181,6 +182,7 @@ export class SemanticMemorySource implements RAGSource { tokenCount, loadTimeMs, memories: personaMemories, + systemPromptSection: this.formatMemoriesSection(personaMemories), metadata: { memoryCount: personaMemories.length, totalCandidates: result.total_candidates, @@ -223,6 +225,21 @@ export class SemanticMemorySource implements RAGSource { }; } + /** + * Format memories into the system prompt section. + * This is the SINGLE AUTHORITY for memory formatting — previously done + * as an unbudgeted bypass in PersonaResponseGenerator. + */ + private formatMemoriesSection(memories: PersonaMemory[]): string | undefined { + if (memories.length === 0) return undefined; + + return `\n\n=== YOUR CONSOLIDATED MEMORIES ===\nThese are important things you've learned and consolidated into long-term memory:\n\n${ + memories.map((mem, idx) => + `${idx + 1}. [${mem.type}] ${mem.content} (${new Date(mem.timestamp).toLocaleDateString()})` + ).join('\n') + }\n\nUse these memories to inform your responses when relevant.\n================================`; + } + private estimateTokens(text: string): number { return Math.ceil(text.length / 4); } diff --git a/src/debug/jtag/system/rag/sources/SocialMediaRAGSource.ts b/src/debug/jtag/system/rag/sources/SocialMediaRAGSource.ts index f855d4035..bd4b88505 100644 --- a/src/debug/jtag/system/rag/sources/SocialMediaRAGSource.ts +++ b/src/debug/jtag/system/rag/sources/SocialMediaRAGSource.ts @@ -56,7 +56,7 @@ interface ResolvedCredential { export class SocialMediaRAGSource implements RAGSource { readonly name = 'social-media'; readonly priority = 55; - readonly defaultBudgetPercent = 5; + readonly defaultBudgetPercent = 3; // ── Static shared state (singleton across all instances) ──────────── // Each persona's ChatRAGBuilder creates a new SocialMediaRAGSource instance. diff --git a/src/debug/jtag/system/rag/sources/ToolDefinitionsSource.ts b/src/debug/jtag/system/rag/sources/ToolDefinitionsSource.ts new file mode 100644 index 000000000..ec5d61dfc --- /dev/null +++ b/src/debug/jtag/system/rag/sources/ToolDefinitionsSource.ts @@ -0,0 +1,318 @@ +/** + * ToolDefinitionsSource - Budget-aware RAG source for LLM tool definitions + * + * This source is the SINGLE AUTHORITY for injecting tool definitions into + * the LLM context. Previously, PersonaResponseGenerator would append tool + * definitions AFTER the RAG budget was calculated, causing unbounded context + * growth that crashed local models (Candle) with NaN/Inf errors. + * + * Behavior by provider capability: + * - 'native' (Anthropic, OpenAI, Together, Groq): Produces metadata.nativeToolSpecs + * for the JSON tools[] request parameter. Budget-aware — drops lowest-priority + * tools if they exceed the allocated budget. + * - 'xml' (DeepSeek, Candle, Ollama, etc.): Produces systemPromptSection with + * XML-formatted tool definitions, prioritized then truncated to budget. + * Essential tools (collaboration/chat, code/*) are kept; lowest-priority dropped. + * - 'none': Not applicable — returns nothing. Must be explicitly set. + * + * Priority 45 — below CodeToolSource (50, workflow guidance), above ActivityContext (40). + */ + +import type { RAGSource, RAGSourceContext, RAGSection } from '../shared/RAGSource'; +import type { NativeToolSpec } from '../../../daemons/ai-provider-daemon/shared/AIProviderTypesV2'; +import { PersonaToolRegistry } from '../../user/server/modules/PersonaToolRegistry'; +import { + getPrimaryAdapter, + convertToNativeToolSpecs, + supportsNativeTools, + type ToolDefinition +} from '../../user/server/modules/ToolFormatAdapter'; +import { Logger } from '../../core/logging/Logger'; + +const log = Logger.create('ToolDefinitionsSource', 'rag'); + +export class ToolDefinitionsSource implements RAGSource { + readonly name = 'tool-definitions'; + readonly priority = 45; + readonly defaultBudgetPercent = 10; + + isApplicable(context: RAGSourceContext): boolean { + // Only skip tools when explicitly disabled — every AI gets tools by default + if (context.toolCapability === 'none') { + return false; + } + return true; + } + + async load(context: RAGSourceContext, allocatedBudget: number): Promise { + const startTime = performance.now(); + + try { + // Get available tools for this persona + const registry = PersonaToolRegistry.sharedInstance(); + const availableTools = await registry.listToolsForPersonaAsync(context.personaId); + + if (availableTools.length === 0) { + return this.emptySection(startTime); + } + + // Convert to adapter format + const toolDefinitions: ToolDefinition[] = availableTools.map(t => ({ + name: t.name, + description: t.description, + parameters: t.parameters, + category: t.category + })); + + if (context.toolCapability === 'native') { + // Native tools go in request.tools (separate from system prompt), so they + // deserve a higher effective budget. Cloud providers have 200K+ context windows; + // even 3000 tokens for tools is a rounding error. The RAG budget's 10% allocation + // was designed for system prompt sections — native tools are a different beast. + const effectiveBudget = Math.max(allocatedBudget, 3000); + return this.loadNativeTools(context, toolDefinitions, effectiveBudget, startTime); + } else { + // XML tools go IN the system prompt — respect the allocated budget strictly + return this.loadXmlTools(context, toolDefinitions, allocatedBudget, startTime); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + log.error(`Failed to load tool definitions: ${message}`); + return this.emptySection(startTime, message); + } + } + + /** + * Native tool providers (Anthropic, OpenAI, Together, Groq): + * Produce NativeToolSpec[] in metadata for the JSON tools request parameter. + * Budget-aware: drops lowest-priority tools if they exceed allocation. + */ + private loadNativeTools( + context: RAGSourceContext, + toolDefinitions: ToolDefinition[], + allocatedBudget: number, + startTime: number + ): RAGSection { + // Exclude meta-tools — models with native tool calling don't need discovery tools. + // search_tools/list_tools cause infinite loops where models search instead of act. + const META_TOOLS = new Set(['search_tools', 'list_tools', 'working_memory']); + let prioritizedTools = toolDefinitions.filter(t => !META_TOOLS.has(t.name)); + + // Three-tier prioritization with budget awareness + const recipeToolNames = this.getRecipeToolNames(context); + const hasRecipeTools = recipeToolNames.size > 0; + const MAX_NATIVE_TOOLS = hasRecipeTools ? 32 : 64; + + if (prioritizedTools.length > MAX_NATIVE_TOOLS) { + prioritizedTools = this.prioritizeTools(prioritizedTools, recipeToolNames, hasRecipeTools, MAX_NATIVE_TOOLS); + } + + // Budget check: estimate token cost of native specs + const specs = convertToNativeToolSpecs(prioritizedTools); + let tokenEstimate = this.estimateNativeToolTokens(specs); + + // If over budget, progressively drop lowest-priority tools + while (tokenEstimate > allocatedBudget && prioritizedTools.length > 2) { + prioritizedTools = prioritizedTools.slice(0, prioritizedTools.length - 3); + const reducedSpecs = convertToNativeToolSpecs(prioritizedTools); + tokenEstimate = this.estimateNativeToolTokens(reducedSpecs); + } + + const finalSpecs = convertToNativeToolSpecs(prioritizedTools); + const finalTokens = this.estimateNativeToolTokens(finalSpecs); + + log.debug(`Native tools: ${finalSpecs.length} specs (~${finalTokens} tokens) for persona ${context.personaId.slice(0, 8)}`); + + return { + sourceName: this.name, + tokenCount: finalTokens, + loadTimeMs: performance.now() - startTime, + metadata: { + nativeToolSpecs: finalSpecs, + toolChoice: 'auto', + toolCount: finalSpecs.length, + totalAvailable: toolDefinitions.length, + budgetRespected: finalTokens <= allocatedBudget, + }, + }; + } + + /** + * XML tool providers (DeepSeek, Candle, Ollama, etc.): + * Produce systemPromptSection with formatted tool definitions, budget-truncated. + * + * CRITICAL: Prioritize BEFORE truncation. Previously, tools were truncated from + * the end of an alphabetically-sorted list, keeping useless tools (adapter/*) + * and dropping essential ones (collaboration/chat/send, code/*). Now we put + * essential tools first so budget truncation drops the least important ones. + */ + private loadXmlTools( + context: RAGSourceContext, + toolDefinitions: ToolDefinition[], + allocatedBudget: number, + startTime: number + ): RAGSection { + // Prioritize BEFORE formatting — essential tools first, rest at end. + // This ensures budget truncation drops lowest-priority tools, not essential ones. + const recipeToolNames = this.getRecipeToolNames(context); + let prioritized = this.prioritizeTools( + toolDefinitions, + recipeToolNames, + recipeToolNames.size > 0, + toolDefinitions.length // No cap yet — budget handles the limiting + ); + + const adapter = getPrimaryAdapter(); + const formattedTools = adapter.formatToolsForPrompt(prioritized); + + const toolsSection = `\n\n=== AVAILABLE TOOLS ===\nYou have access to the following tools:\n\n${formattedTools}\n================================`; + + let finalSection = toolsSection; + let tokenCount = this.estimateTokens(toolsSection); + let finalToolCount = prioritized.length; + + // Budget truncation: progressively drop lowest-priority tools (at end of list) + // Minimum 2 tools (critical: chat/send + chat/history) — not 5, which is too + // many for tight Candle budgets (~250 tokens for tools). + if (tokenCount > allocatedBudget && prioritized.length > 2) { + let reducedTools = prioritized; + while (tokenCount > allocatedBudget && reducedTools.length > 2) { + // Drop 3 at a time for faster convergence, or 1 when close to minimum + const dropCount = reducedTools.length > 8 ? 3 : 1; + reducedTools = reducedTools.slice(0, reducedTools.length - dropCount); + const reduced = adapter.formatToolsForPrompt(reducedTools); + finalSection = `\n\n=== AVAILABLE TOOLS ===\nYou have access to the following tools:\n\n${reduced}\n================================`; + tokenCount = this.estimateTokens(finalSection); + } + finalToolCount = reducedTools.length; + } + + log.debug(`XML tools: ${finalToolCount}/${toolDefinitions.length} tools (~${tokenCount} tokens, budget=${allocatedBudget})`); + + return { + sourceName: this.name, + tokenCount, + loadTimeMs: performance.now() - startTime, + systemPromptSection: finalSection, + metadata: { + toolCount: finalToolCount, + totalAvailable: toolDefinitions.length, + format: 'xml', + budgetRespected: tokenCount <= allocatedBudget, + }, + }; + } + + /** + * Four-tier tool prioritization with sub-ordering: + * 1. Recipe tools (activity's core toolset — go FIRST) + * 2. Critical tools (chat communication — bare minimum) + * 3. Essential tools (code, data, decisions — ordered by importance) + * 4. Everything else (fill remaining slots) + * + * Within essentials, tools are ordered by PREFIX PRIORITY so budget + * truncation (which drops from the end) removes the least important: + * collaboration/chat > code > collaboration/decision > data > ai + * + * This ensures that when tight budgets (e.g., Candle 2-tool limit) kick in, + * AIs get collaboration/chat/send instead of ai/adapter/test. + */ + private prioritizeTools( + tools: ToolDefinition[], + recipeToolNames: Set, + hasRecipeTools: boolean, + maxTools: number + ): ToolDefinition[] { + // Critical tools that should ALWAYS survive budget truncation + const CRITICAL_TOOLS = new Set([ + 'collaboration/chat/send', 'collaboration/chat/history', + ]); + + // Essential prefix ordering — most important first. + // When budget truncation drops from the end, ai/* goes first, then data/*, etc. + const ESSENTIAL_PREFIX_ORDER: string[] = hasRecipeTools + ? [] // When recipe tools exist, only allow exact essential matches + : [ + 'collaboration/chat/', // Communication is #1 + 'code/', // Code abilities are #2 + 'collaboration/decision/', // Decision making #3 + 'collaboration/wall/', // Shared documents #4 + 'data/', // Data access #5 + 'ai/', // AI meta-tools #6 (least important essential) + ]; + + const recipe: ToolDefinition[] = []; + const critical: ToolDefinition[] = []; + const essential: ToolDefinition[] = []; + const rest: ToolDefinition[] = []; + + for (const tool of tools) { + if (recipeToolNames.has(tool.name)) { + recipe.push(tool); + } else if (CRITICAL_TOOLS.has(tool.name)) { + critical.push(tool); + } else if (ESSENTIAL_PREFIX_ORDER.some(p => tool.name.startsWith(p))) { + essential.push(tool); + } else { + rest.push(tool); + } + } + + // Sort essentials by prefix priority (most important prefix first) + essential.sort((a, b) => { + const aIdx = ESSENTIAL_PREFIX_ORDER.findIndex(p => a.name.startsWith(p)); + const bIdx = ESSENTIAL_PREFIX_ORDER.findIndex(p => b.name.startsWith(p)); + const aPri = aIdx >= 0 ? aIdx : ESSENTIAL_PREFIX_ORDER.length; + const bPri = bIdx >= 0 ? bIdx : ESSENTIAL_PREFIX_ORDER.length; + if (aPri !== bPri) return aPri - bPri; + return a.name.localeCompare(b.name); + }); + + const remaining = maxTools - recipe.length - critical.length - essential.length; + const result = [...recipe, ...critical, ...essential, ...rest.slice(0, Math.max(0, remaining))]; + + log.debug(`Tool prioritization: ${recipe.length} recipe + ${critical.length} critical + ${essential.length} essential + ${Math.max(0, remaining)} general = ${result.length} (from ${tools.length} total, cap=${maxTools})`); + + return result; + } + + /** + * Extract recipe tool names from RAG context options. + * Recipe tools are loaded separately by ChatRAGBuilder, but we can access + * them from the context's recipeTools if threaded through. + */ + private getRecipeToolNames(context: RAGSourceContext): Set { + // Recipe tools may be available on the context options + const recipeTools = (context.options as any)?.recipeTools; + if (!recipeTools || !Array.isArray(recipeTools)) { + return new Set(); + } + return new Set( + recipeTools + .filter((t: any) => t.enabledFor?.includes('ai')) + .map((t: any) => t.name) + ); + } + + /** + * Estimate token count for native tool specs. + * Native specs are sent as JSON in the request body, consuming context window. + */ + private estimateNativeToolTokens(specs: NativeToolSpec[]): number { + // JSON.stringify approximation: ~4 chars per token + return Math.ceil(JSON.stringify(specs).length / 4); + } + + private estimateTokens(text: string): number { + return Math.ceil(text.length / 4); + } + + private emptySection(startTime: number, error?: string): RAGSection { + return { + sourceName: this.name, + tokenCount: 0, + loadTimeMs: performance.now() - startTime, + metadata: error ? { error } : { toolCount: 0 }, + }; + } +} diff --git a/src/debug/jtag/system/rag/sources/WidgetContextSource.ts b/src/debug/jtag/system/rag/sources/WidgetContextSource.ts index 06dfa1cc5..4c12bfac2 100644 --- a/src/debug/jtag/system/rag/sources/WidgetContextSource.ts +++ b/src/debug/jtag/system/rag/sources/WidgetContextSource.ts @@ -19,7 +19,7 @@ const log = Logger.create('WidgetContextSource', 'rag'); export class WidgetContextSource implements RAGSource { readonly name = 'widget-context'; readonly priority = 75; // High - UI context is very relevant - readonly defaultBudgetPercent = 10; + readonly defaultBudgetPercent = 5; isApplicable(context: RAGSourceContext): boolean { // Need either pre-formatted context or session ID diff --git a/src/debug/jtag/system/rag/sources/index.ts b/src/debug/jtag/system/rag/sources/index.ts index c1bccd6ca..6f311f458 100644 --- a/src/debug/jtag/system/rag/sources/index.ts +++ b/src/debug/jtag/system/rag/sources/index.ts @@ -32,6 +32,7 @@ export { CodeToolSource } from './CodeToolSource'; export { ProjectContextSource } from './ProjectContextSource'; export { GovernanceSource } from './GovernanceSource'; export { ActivityContextSource } from './ActivityContextSource'; +export { ToolDefinitionsSource } from './ToolDefinitionsSource'; // Re-export types for convenience export type { RAGSource, RAGSourceContext, RAGSection } from '../shared/RAGSource'; diff --git a/src/debug/jtag/system/sentinel/AgentSentinel.ts b/src/debug/jtag/system/sentinel/AgentSentinel.ts deleted file mode 100644 index 0adaef5ad..000000000 --- a/src/debug/jtag/system/sentinel/AgentSentinel.ts +++ /dev/null @@ -1,817 +0,0 @@ -/** - * AgentSentinel - MoltBot-level autonomous coding agent - * - * Unlike OrchestratorSentinel which uses simple prompts, AgentSentinel: - * 1. Has STRUCTURED TOOL DEFINITIONS - LLM sees proper tool schemas - * 2. Uses TOOL CALLING protocol - Not regex parsing, real structured output - * 3. Maintains FULL CONTEXT - History doesn't truncate - * 4. ITERATES until done - Build, test, fix loop - * 5. Can handle REAL TASKS - Multi-file changes, codebase exploration - * - * This is the sentinel that can build an iPhone app (or at least try). - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { execSync } from 'child_process'; -import { ModelConfig, ModelCapacity, ModelProvider, resolveModel } from './ModelProvider'; -import { InferenceGenerate } from '../../commands/inference/generate/shared/InferenceGenerateTypes'; -import { SentinelWorkspace, WorkspaceConfig } from './SentinelWorkspace'; -import { ExecutionLogBuilder, formatExecutionLog, ExecutionLog, SentinelAction } from './SentinelExecutionLog'; - -// ============================================================================ -// TOOL DEFINITIONS - These become the LLM's capabilities -// ============================================================================ - -export interface ToolDefinition { - name: string; - description: string; - parameters: { - type: 'object'; - properties: Record; - required: string[]; - }; -} - -export const AGENT_TOOLS: ToolDefinition[] = [ - { - name: 'read_file', - description: 'Read a file from the workspace. Returns content with line numbers.', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'Relative path to the file' }, - start_line: { type: 'number', description: 'Start line (1-indexed, optional)' }, - end_line: { type: 'number', description: 'End line (1-indexed, optional)' }, - }, - required: ['path'], - }, - }, - { - name: 'write_file', - description: 'Write content to a file. Creates directories if needed.', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'Relative path to the file' }, - content: { type: 'string', description: 'File content to write' }, - }, - required: ['path', 'content'], - }, - }, - { - name: 'edit_file', - description: 'Make a targeted edit to a file. Use search/replace pattern.', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'Relative path to the file' }, - search: { type: 'string', description: 'Exact text to find (must be unique in file)' }, - replace: { type: 'string', description: 'Text to replace it with' }, - }, - required: ['path', 'search', 'replace'], - }, - }, - { - name: 'search_files', - description: 'Search for a regex pattern across all files. Returns matching lines.', - parameters: { - type: 'object', - properties: { - pattern: { type: 'string', description: 'Regex pattern to search for' }, - file_glob: { type: 'string', description: 'Glob pattern to filter files (e.g., "*.ts", "src/**/*.rs")' }, - max_results: { type: 'number', description: 'Maximum matches to return (default: 50)' }, - }, - required: ['pattern'], - }, - }, - { - name: 'list_files', - description: 'List files in a directory. Shows directory tree structure.', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'Directory path (default: workspace root)' }, - depth: { type: 'number', description: 'Max depth to traverse (default: 3)' }, - pattern: { type: 'string', description: 'Glob pattern to filter (e.g., "*.ts")' }, - }, - required: [], - }, - }, - { - name: 'run_command', - description: 'Execute a shell command. Use for builds, tests, etc.', - parameters: { - type: 'object', - properties: { - command: { type: 'string', description: 'Shell command to execute' }, - timeout_ms: { type: 'number', description: 'Timeout in milliseconds (default: 60000)' }, - }, - required: ['command'], - }, - }, - { - name: 'git_status', - description: 'Show git status - modified, staged, and untracked files.', - parameters: { - type: 'object', - properties: {}, - required: [], - }, - }, - { - name: 'git_diff', - description: 'Show git diff of changes.', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'Specific file to diff (optional)' }, - staged: { type: 'boolean', description: 'Show staged changes only' }, - }, - required: [], - }, - }, - { - name: 'complete', - description: 'Signal that the task is complete. Provide a summary.', - parameters: { - type: 'object', - properties: { - summary: { type: 'string', description: 'Summary of what was accomplished' }, - files_changed: { type: 'string', description: 'List of files created or modified' }, - }, - required: ['summary'], - }, - }, - { - name: 'give_up', - description: 'Signal that the task cannot be completed. Explain why.', - parameters: { - type: 'object', - properties: { - reason: { type: 'string', description: 'Why the task cannot be completed' }, - attempted: { type: 'string', description: 'What was attempted' }, - }, - required: ['reason'], - }, - }, -]; - -// ============================================================================ -// TOOL CALL TYPES -// ============================================================================ - -export interface ToolCall { - name: string; - arguments: Record; -} - -export interface ToolResult { - success: boolean; - output: string; - error?: string; -} - -// ============================================================================ -// AGENT CONFIG -// ============================================================================ - -export interface AgentSentinelConfig { - workingDir: string; - maxIterations?: number; - maxTokens?: number; - model?: ModelConfig; - workspace?: WorkspaceConfig; - streamOutput?: boolean; - onThought?: (thought: string) => void; - onToolCall?: (tool: string, args: Record) => void; - onToolResult?: (tool: string, result: ToolResult) => void; - onIteration?: (iteration: number, maxIterations: number) => void; -} - -// ============================================================================ -// MESSAGE TYPES -// ============================================================================ - -interface Message { - role: 'system' | 'user' | 'assistant' | 'tool'; - content: string; - tool_calls?: ToolCall[]; - tool_call_id?: string; - name?: string; -} - -// ============================================================================ -// AGENT SENTINEL -// ============================================================================ - -export class AgentSentinel { - private config: Required> & { - workspace?: WorkspaceConfig; - model: ModelConfig; - }; - private workspace?: SentinelWorkspace; - private executionLog: ExecutionLogBuilder; - private messages: Message[] = []; - private filesCreated: string[] = []; - private filesModified: string[] = []; - private currentTask: string = ''; - - constructor(config: AgentSentinelConfig) { - this.config = { - workingDir: config.workingDir, - maxIterations: config.maxIterations ?? 50, - maxTokens: config.maxTokens ?? 4000, - model: config.model ?? { - provider: ModelProvider.LOCAL, - capacity: ModelCapacity.MEDIUM, - maxTokens: 4000, - }, - workspace: config.workspace, - streamOutput: config.streamOutput ?? true, - onThought: config.onThought ?? ((t) => console.log(`[THINK] ${t}`)), - onToolCall: config.onToolCall ?? ((t, a) => console.log(`[TOOL] ${t}(${JSON.stringify(a).slice(0, 100)})`)), - onToolResult: config.onToolResult ?? ((t, r) => console.log(`[RESULT] ${t}: ${r.success ? 'OK' : 'FAIL'}`)), - onIteration: config.onIteration ?? ((i, m) => console.log(`\n--- Iteration ${i}/${m} ---`)), - }; - // ExecutionLogBuilder initialized in execute() when we have the task - this.executionLog = null as unknown as ExecutionLogBuilder; - } - - /** - * Get the effective working directory (workspace or config) - */ - private get workDir(): string { - return this.workspace?.workingDir ?? this.config.workingDir; - } - - /** - * Execute a task - */ - async execute(task: string): Promise<{ - success: boolean; - summary: string; - filesCreated: string[]; - filesModified: string[]; - iterations: number; - executionLog: string; - }> { - this.currentTask = task; - const handle = `agent-${Date.now()}`; - this.executionLog = new ExecutionLogBuilder(handle, 'orchestrate', task); - - // Initialize workspace if configured - if (this.config.workspace) { - this.workspace = await SentinelWorkspace.create(this.config.workspace); - this.executionLog.recordAction({ - type: 'file_create', - intent: `Created ${this.config.workspace.isolation || 'branch'} workspace`, - result: 'success', - }); - } - - // Initialize messages with system prompt - this.messages = [ - { - role: 'system', - content: this.buildSystemPrompt(), - }, - { - role: 'user', - content: task, - }, - ]; - - this.config.onThought(`Task: ${task}`); - - let iteration = 0; - let completed = false; - let success = false; - let summary = ''; - - try { - while (iteration < this.config.maxIterations && !completed) { - iteration++; - this.config.onIteration(iteration, this.config.maxIterations); - - // Get LLM response - this.executionLog.recordAction({ - type: 'llm_query', - intent: `Iteration ${iteration} - thinking`, - result: 'success', - }); - - const response = await this.think(); - - if (!response) { - this.executionLog.recordAction({ - type: 'llm_query', - intent: 'Get LLM response', - result: 'failure', - details: { error: 'No response from model' }, - }); - break; - } - - // Parse tool calls from response - const toolCalls = this.parseToolCalls(response); - - if (toolCalls.length === 0) { - // No tool calls - LLM is just thinking, add to context and continue - this.messages.push({ role: 'assistant', content: response }); - this.config.onThought(response.slice(0, 200)); - continue; - } - - // Record assistant message with tool calls - this.messages.push({ - role: 'assistant', - content: response, - tool_calls: toolCalls, - }); - - // Execute each tool call - for (const toolCall of toolCalls) { - this.config.onToolCall(toolCall.name, toolCall.arguments); - - const result = await this.executeTool(toolCall); - this.config.onToolResult(toolCall.name, result); - - // Record tool result - this.messages.push({ - role: 'tool', - content: result.output, - name: toolCall.name, - tool_call_id: `${toolCall.name}-${iteration}`, - }); - - // Map tool name to action type - const actionType = this.mapToolToActionType(toolCall.name); - this.executionLog.recordAction({ - type: actionType, - intent: `${toolCall.name}(${JSON.stringify(toolCall.arguments).slice(0, 50)})`, - result: result.success ? 'success' : 'failure', - details: toolCall.arguments, - evidence: { - output: result.output.slice(0, 1000), - verified: result.success, - }, - }); - - // Check for completion - if (toolCall.name === 'complete') { - completed = true; - success = true; - summary = toolCall.arguments.summary as string || 'Task completed'; - break; - } - - if (toolCall.name === 'give_up') { - completed = true; - success = false; - summary = toolCall.arguments.reason as string || 'Task failed'; - break; - } - } - } - - // Cleanup workspace - if (this.workspace) { - if (success) { - await this.workspace.complete(); - this.executionLog.recordAction({ - type: 'file_edit', - intent: 'Merged workspace changes', - result: 'success', - }); - } else { - await this.workspace.abort(); - this.executionLog.recordAction({ - type: 'escalate', - intent: 'Aborted workspace', - result: 'failure', - }); - } - } - - if (!completed) { - summary = `Max iterations (${this.config.maxIterations}) reached`; - } - - const finalLog = this.executionLog.complete(success ? 'success' : 'failure'); - - return { - success, - summary, - filesCreated: this.filesCreated, - filesModified: this.filesModified, - iterations: iteration, - executionLog: formatExecutionLog(finalLog), - }; - } catch (error: any) { - if (this.workspace) { - await this.workspace.abort(); - } - const finalLog = this.executionLog.complete('failure', { escalationReason: error.message }); - return { - success: false, - summary: `Error: ${error.message}`, - filesCreated: this.filesCreated, - filesModified: this.filesModified, - iterations: iteration, - executionLog: formatExecutionLog(finalLog), - }; - } - } - - /** - * Map tool name to SentinelAction type - */ - private mapToolToActionType(toolName: string): SentinelAction['type'] { - switch (toolName) { - case 'read_file': - case 'list_files': - case 'search_files': - return 'analyze'; - case 'write_file': - return 'file_create'; - case 'edit_file': - return 'file_edit'; - case 'run_command': - case 'git_status': - case 'git_diff': - return 'build'; - case 'complete': - case 'give_up': - return 'escalate'; - default: - return 'analyze'; - } - } - - /** - * Build system prompt with tool definitions - */ - private buildSystemPrompt(): string { - const toolDescriptions = AGENT_TOOLS.map(t => { - const params = Object.entries(t.parameters.properties) - .map(([name, prop]) => ` - ${name}: ${prop.description}${t.parameters.required.includes(name) ? ' (required)' : ''}`) - .join('\n'); - return `${t.name}: ${t.description}\n${params}`; - }).join('\n\n'); - - return `You are an expert software engineer working on a coding task. You have access to the following tools: - -${toolDescriptions} - -IMPORTANT RULES: -1. Always explore the codebase before making changes (use list_files, search_files, read_file) -2. Make targeted edits rather than rewriting entire files when possible -3. Test your changes by running builds/tests after modifications -4. If a build fails, read the error output carefully and fix the issues -5. When you're done, call 'complete' with a summary -6. If you truly cannot complete the task, call 'give_up' with an explanation - -To use a tool, output a JSON block in this format: -\`\`\`tool -{"name": "tool_name", "arguments": {"arg1": "value1", "arg2": "value2"}} -\`\`\` - -You can call multiple tools in one response by including multiple tool blocks. - -Working directory: ${this.workDir} -`; - } - - /** - * Call the LLM - */ - private async think(): Promise { - // Build prompt from message history - const prompt = this.messages.map(m => { - if (m.role === 'system') return `SYSTEM: ${m.content}`; - if (m.role === 'user') return `USER: ${m.content}`; - if (m.role === 'assistant') return `ASSISTANT: ${m.content}`; - if (m.role === 'tool') return `TOOL RESULT (${m.name}): ${m.content}`; - return ''; - }).join('\n\n'); - - const result = await this.invoker.generate(prompt, this.config.model); - - if (result.success && result.text) { - return result.text; - } - - console.error('LLM Error:', result.error); - return null; - } - - /** - * Parse tool calls from LLM response - */ - private parseToolCalls(response: string): ToolCall[] { - const calls: ToolCall[] = []; - - // Look for ```tool ... ``` blocks - const toolBlockRegex = /```tool\s*\n?([\s\S]*?)```/g; - let match; - - while ((match = toolBlockRegex.exec(response)) !== null) { - try { - const parsed = JSON.parse(match[1].trim()); - if (parsed.name && typeof parsed.arguments === 'object') { - calls.push(parsed); - } - } catch (e) { - // Try to fix common JSON issues - const fixedJson = match[1].trim() - .replace(/'/g, '"') - .replace(/(\w+):/g, '"$1":'); - try { - const parsed = JSON.parse(fixedJson); - if (parsed.name && typeof parsed.arguments === 'object') { - calls.push(parsed); - } - } catch { - console.warn('Failed to parse tool call:', match[1].slice(0, 100)); - } - } - } - - // Also try to find inline JSON tool calls - const inlineRegex = /\{"name":\s*"(\w+)",\s*"arguments":\s*(\{[^}]+\})\}/g; - while ((match = inlineRegex.exec(response)) !== null) { - try { - const name = match[1]; - const args = JSON.parse(match[2]); - // Avoid duplicates - if (!calls.some(c => c.name === name && JSON.stringify(c.arguments) === JSON.stringify(args))) { - calls.push({ name, arguments: args }); - } - } catch { - // Ignore parse errors - } - } - - return calls; - } - - /** - * Execute a tool call - */ - private async executeTool(call: ToolCall): Promise { - const args = call.arguments; - - try { - switch (call.name) { - case 'read_file': - return this.toolReadFile(args.path as string, args.start_line as number, args.end_line as number); - - case 'write_file': - return this.toolWriteFile(args.path as string, args.content as string); - - case 'edit_file': - return this.toolEditFile(args.path as string, args.search as string, args.replace as string); - - case 'search_files': - return this.toolSearchFiles(args.pattern as string, args.file_glob as string, args.max_results as number); - - case 'list_files': - return this.toolListFiles(args.path as string, args.depth as number, args.pattern as string); - - case 'run_command': - return this.toolRunCommand(args.command as string, args.timeout_ms as number); - - case 'git_status': - return this.toolGitStatus(); - - case 'git_diff': - return this.toolGitDiff(args.path as string, args.staged as boolean); - - case 'complete': - case 'give_up': - return { success: true, output: 'Acknowledged' }; - - default: - return { success: false, output: `Unknown tool: ${call.name}` }; - } - } catch (error: any) { - return { success: false, output: `Error: ${error.message}`, error: error.message }; - } - } - - // ============================================================================ - // TOOL IMPLEMENTATIONS - // ============================================================================ - - private toolReadFile(filePath: string, startLine?: number, endLine?: number): ToolResult { - const fullPath = path.resolve(this.workDir, filePath); - - if (!fs.existsSync(fullPath)) { - return { success: false, output: `File not found: ${filePath}` }; - } - - const content = fs.readFileSync(fullPath, 'utf-8'); - const lines = content.split('\n'); - - const start = (startLine ?? 1) - 1; - const end = endLine ?? lines.length; - const selectedLines = lines.slice(start, end); - - const numberedContent = selectedLines - .map((line, i) => `${(start + i + 1).toString().padStart(4)}: ${line}`) - .join('\n'); - - return { - success: true, - output: `File: ${filePath} (${lines.length} lines, showing ${start + 1}-${Math.min(end, lines.length)})\n\n${numberedContent}`, - }; - } - - private toolWriteFile(filePath: string, content: string): ToolResult { - const fullPath = path.resolve(this.workDir, filePath); - const dir = path.dirname(fullPath); - - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - const existed = fs.existsSync(fullPath); - fs.writeFileSync(fullPath, content); - - if (existed) { - if (!this.filesModified.includes(filePath)) { - this.filesModified.push(filePath); - } - } else { - this.filesCreated.push(filePath); - } - - return { - success: true, - output: `${existed ? 'Updated' : 'Created'} ${filePath} (${content.length} bytes, ${content.split('\n').length} lines)`, - }; - } - - private toolEditFile(filePath: string, search: string, replace: string): ToolResult { - const fullPath = path.resolve(this.workDir, filePath); - - if (!fs.existsSync(fullPath)) { - return { success: false, output: `File not found: ${filePath}` }; - } - - const content = fs.readFileSync(fullPath, 'utf-8'); - - // Check if search string exists - const occurrences = content.split(search).length - 1; - if (occurrences === 0) { - return { success: false, output: `Search string not found in ${filePath}. Make sure to use exact text including whitespace.` }; - } - if (occurrences > 1) { - return { success: false, output: `Search string found ${occurrences} times in ${filePath}. Use a more specific search to match exactly one location.` }; - } - - const newContent = content.replace(search, replace); - fs.writeFileSync(fullPath, newContent); - - if (!this.filesModified.includes(filePath)) { - this.filesModified.push(filePath); - } - - return { - success: true, - output: `Edited ${filePath}: replaced ${search.length} chars with ${replace.length} chars`, - }; - } - - private toolSearchFiles(pattern: string, fileGlob?: string, maxResults?: number): ToolResult { - const max = maxResults ?? 50; - - try { - // Use grep for searching - const globArg = fileGlob ? `--include="${fileGlob}"` : ''; - const cmd = `grep -rn ${globArg} -E "${pattern.replace(/"/g, '\\"')}" . 2>/dev/null | head -${max}`; - - const output = execSync(cmd, { - cwd: this.workDir, - encoding: 'utf-8', - timeout: 30000, - maxBuffer: 10 * 1024 * 1024, - }); - - const lines = output.trim().split('\n').filter(Boolean); - return { - success: true, - output: `Found ${lines.length} matches:\n${output || 'No matches found'}`, - }; - } catch (error: any) { - if (error.status === 1) { - return { success: true, output: 'No matches found' }; - } - return { success: false, output: `Search error: ${error.message}` }; - } - } - - private toolListFiles(dirPath?: string, depth?: number, pattern?: string): ToolResult { - const targetPath = dirPath ? path.resolve(this.workDir, dirPath) : this.workDir; - const maxDepth = depth ?? 3; - - try { - let cmd = `find "${targetPath}" -maxdepth ${maxDepth} -type f`; - if (pattern) { - cmd += ` -name "${pattern}"`; - } - cmd += ' 2>/dev/null | head -100'; - - const output = execSync(cmd, { - cwd: this.workDir, - encoding: 'utf-8', - timeout: 10000, - }); - - // Make paths relative - const relativePaths = output.trim().split('\n') - .filter(Boolean) - .map(p => path.relative(this.workDir, p)) - .sort(); - - return { - success: true, - output: `Files in ${dirPath || '.'}:\n${relativePaths.join('\n') || 'No files found'}`, - }; - } catch (error: any) { - return { success: false, output: `List error: ${error.message}` }; - } - } - - private toolRunCommand(command: string, timeoutMs?: number): ToolResult { - const timeout = timeoutMs ?? 60000; - - try { - const output = execSync(command, { - cwd: this.workDir, - encoding: 'utf-8', - timeout, - maxBuffer: 10 * 1024 * 1024, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - return { - success: true, - output: output.slice(0, 5000) || 'Command completed (no output)', - }; - } catch (error: any) { - const stderr = error.stderr?.toString() || ''; - const stdout = error.stdout?.toString() || ''; - return { - success: false, - output: `Exit code: ${error.status}\n\nSTDOUT:\n${stdout.slice(0, 2500)}\n\nSTDERR:\n${stderr.slice(0, 2500)}`, - error: error.message, - }; - } - } - - private toolGitStatus(): ToolResult { - try { - const output = execSync('git status --short', { - cwd: this.workDir, - encoding: 'utf-8', - timeout: 10000, - }); - - return { - success: true, - output: output || 'Working tree clean', - }; - } catch (error: any) { - return { success: false, output: `Git error: ${error.message}` }; - } - } - - private toolGitDiff(filePath?: string, staged?: boolean): ToolResult { - try { - let cmd = 'git diff'; - if (staged) cmd += ' --staged'; - if (filePath) cmd += ` -- "${filePath}"`; - - const output = execSync(cmd, { - cwd: this.workDir, - encoding: 'utf-8', - timeout: 10000, - maxBuffer: 10 * 1024 * 1024, - }); - - return { - success: true, - output: output.slice(0, 5000) || 'No changes', - }; - } catch (error: any) { - return { success: false, output: `Git error: ${error.message}` }; - } - } -} - diff --git a/src/debug/jtag/system/sentinel/BuildSentinel.ts b/src/debug/jtag/system/sentinel/BuildSentinel.ts deleted file mode 100644 index c72f922fd..000000000 --- a/src/debug/jtag/system/sentinel/BuildSentinel.ts +++ /dev/null @@ -1,836 +0,0 @@ -/** - * BuildSentinel - Agentic loop for compilation - * - * Like ClawdeBot/MoltBot but focused on ONE goal: code compiles. - * - * The LLM is the BRAIN that: - * 1. Sees build errors - * 2. Reasons about what's wrong - * 3. Decides how to fix - * 4. Applies the fix - * 5. Retries and evaluates - * 6. Knows when to escalate - * - * NOT pattern-matching with LLM fallback - LLM IS the reasoning engine. - */ - -import { execSync, exec } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import type { BuildSentinelDefinition } from './SentinelDefinition'; -import { ModelConfig, ModelCapacity, ModelProvider, ModelInvoker } from './ModelProvider'; - -export interface BuildError { - file: string; - line: number; - column: number; - message: string; - code?: string; - raw: string; -} - -export interface BuildAttempt { - attemptNumber: number; - command: string; - success: boolean; - errors: BuildError[]; - fixApplied?: string; - durationMs: number; -} - -export interface BuildResult { - success: boolean; - attempts: BuildAttempt[]; - finalErrors?: BuildError[]; - escalated?: boolean; - escalationReason?: string; -} - -export interface SentinelProgress { - phase: 'building' | 'parsing' | 'fixing' | 'verifying' | 'done' | 'escalating'; - attempt: number; - maxAttempts: number; - message: string; - errors?: BuildError[]; -} - -export interface BuildSentinelConfig { - command: string; - workingDir: string; - maxAttempts?: number; - timeoutMs?: number; - canAutoFix?: boolean; - onProgress?: (progress: SentinelProgress) => void; - - // LLM-assisted fixing (optional - enables smarter error recovery) - useLLM?: boolean; // Enable LLM for fixes pattern-matching can't handle - model?: ModelConfig; // Full model config - capacity?: ModelCapacity; // Shorthand: power level (SMALL for fast, MEDIUM for better) - provider?: ModelProvider; // Shorthand: LOCAL, OLLAMA, ANTHROPIC, etc. - modelName?: string; // Shorthand: specific model string -} - -export class BuildSentinel { - private config: Required> & { useLLM: boolean }; - private attempts: BuildAttempt[] = []; - private invoker?: ModelInvoker; - private modelConfig?: ModelConfig; - - constructor(config: BuildSentinelConfig) { - this.config = { - maxAttempts: 3, - timeoutMs: 120000, - canAutoFix: true, - onProgress: () => {}, - useLLM: config.useLLM ?? false, - command: config.command, - workingDir: config.workingDir, - }; - - // Setup LLM if enabled - if (config.useLLM) { - this.invoker = new ModelInvoker(config.workingDir); - this.modelConfig = config.model ?? { - capacity: config.capacity ?? ModelCapacity.SMALL, - provider: config.provider ?? ModelProvider.LOCAL, - model: config.modelName, - maxTokens: 2000, - }; - } - } - - /** - * Create a BuildSentinel from a portable definition - */ - static fromDefinition(def: BuildSentinelDefinition, onProgress?: BuildSentinelConfig['onProgress']): BuildSentinel { - return new BuildSentinel({ - command: def.command, - workingDir: def.workingDir || process.cwd(), - maxAttempts: def.maxAttempts, - timeoutMs: def.timeout, - canAutoFix: def.canAutoFix, - useLLM: def.useLLM, - capacity: def.llmCapacity, - provider: def.llmProvider, - onProgress, - }); - } - - /** - * Export to portable JSON definition - */ - toDefinition(name?: string): BuildSentinelDefinition { - return { - type: 'build', - name: name || `build-${Date.now()}`, - version: '1.0', - command: this.config.command, - workingDir: this.config.workingDir, - maxAttempts: this.config.maxAttempts, - timeout: this.config.timeoutMs, - canAutoFix: this.config.canAutoFix, - useLLM: this.config.useLLM, - llmCapacity: this.modelConfig?.capacity, - llmProvider: this.modelConfig?.provider, - createdAt: new Date().toISOString(), - }; - } - - /** - * Run the sentinel loop until success or escalation - */ - async run(): Promise { - for (let attempt = 1; attempt <= this.config.maxAttempts; attempt++) { - this.report('building', attempt, `Running build (attempt ${attempt}/${this.config.maxAttempts})`); - - const buildAttempt = await this.build(attempt); - this.attempts.push(buildAttempt); - - if (buildAttempt.success) { - this.report('done', attempt, 'Build succeeded!'); - return { success: true, attempts: this.attempts }; - } - - this.report('parsing', attempt, `Build failed with ${buildAttempt.errors.length} error(s)`, buildAttempt.errors); - - // Should we escalate? - if (this.shouldEscalate(buildAttempt.errors)) { - const reason = this.getEscalationReason(buildAttempt.errors); - this.report('escalating', attempt, `Escalating: ${reason}`); - return { - success: false, - attempts: this.attempts, - finalErrors: buildAttempt.errors, - escalated: true, - escalationReason: reason, - }; - } - - // Try to fix if we have attempts left - if (attempt < this.config.maxAttempts && this.config.canAutoFix) { - this.report('fixing', attempt, 'Attempting auto-fix...'); - const fixed = await this.attemptFix(buildAttempt.errors); - if (fixed) { - this.report('verifying', attempt, `Applied fix: ${fixed}`); - buildAttempt.fixApplied = fixed; - } else { - this.report('fixing', attempt, 'Could not auto-fix, will retry...'); - } - } - } - - // Max attempts reached - const lastAttempt = this.attempts[this.attempts.length - 1]; - return { - success: false, - attempts: this.attempts, - finalErrors: lastAttempt.errors, - escalated: true, - escalationReason: `Max attempts (${this.config.maxAttempts}) reached`, - }; - } - - /** - * Execute a single build attempt - */ - private async build(attemptNumber: number): Promise { - const startTime = Date.now(); - let output = ''; - let success = false; - - try { - output = execSync(this.config.command, { - cwd: this.config.workingDir, - encoding: 'utf-8', - timeout: this.config.timeoutMs, - stdio: ['pipe', 'pipe', 'pipe'], - }); - success = true; - } catch (error: any) { - output = error.stdout?.toString() || ''; - output += error.stderr?.toString() || ''; - success = false; - } - - const errors = success ? [] : this.parseErrors(output); - - return { - attemptNumber, - command: this.config.command, - success, - errors, - durationMs: Date.now() - startTime, - }; - } - - /** - * Parse build output for errors - */ - private parseErrors(output: string): BuildError[] { - const errors: BuildError[] = []; - - // TypeScript error pattern: src/file.ts(10,5): error TS2345: ... - // Or: src/file.ts:10:5 - error TS2345: ... - const tsPattern = /([^\s]+\.tsx?)[:\(](\d+)[,:](\d+)\)?[:\s-]+error\s+(TS\d+):\s*(.+)/g; - let match; - while ((match = tsPattern.exec(output)) !== null) { - errors.push({ - file: match[1], - line: parseInt(match[2], 10), - column: parseInt(match[3], 10), - code: match[4], - message: match[5].trim(), - raw: match[0], - }); - } - - // Rust error pattern: error[E0425]: cannot find value `x` - // --> src/main.rs:10:5 - const rustErrorPattern = /error\[([^\]]+)\]:\s*(.+)\n\s*-->\s*([^:]+):(\d+):(\d+)/g; - while ((match = rustErrorPattern.exec(output)) !== null) { - errors.push({ - file: match[3], - line: parseInt(match[4], 10), - column: parseInt(match[5], 10), - code: match[1], - message: match[2].trim(), - raw: match[0], - }); - } - - // Generic fallback: look for "error:" patterns - if (errors.length === 0 && output.toLowerCase().includes('error')) { - const lines = output.split('\n'); - for (const line of lines) { - if (line.toLowerCase().includes('error') && !line.includes('0 errors')) { - errors.push({ - file: 'unknown', - line: 0, - column: 0, - message: line.trim(), - raw: line, - }); - } - } - } - - return errors; - } - - /** - * Determine if we should escalate (give up) based on error types - */ - private shouldEscalate(errors: BuildError[]): boolean { - for (const error of errors) { - // Architectural issues - need human/full AI - if (error.message.includes('circular dependency')) return true; - if (error.message.includes('Cannot find module') && error.message.includes('@')) return true; - - // Too many errors - probably something fundamentally wrong - if (errors.length > 10) return true; - } - return false; - } - - /** - * Get human-readable reason for escalation - */ - private getEscalationReason(errors: BuildError[]): string { - if (errors.length > 10) { - return `Too many errors (${errors.length}) - likely architectural issue`; - } - for (const error of errors) { - if (error.message.includes('circular dependency')) { - return 'Circular dependency detected - needs architectural fix'; - } - if (error.message.includes('Cannot find module')) { - return `Missing module: ${error.message} - may need npm install or path fix`; - } - } - return 'Error type not auto-fixable'; - } - - /** - * Attempt to automatically fix errors - * Returns description of fix applied, or null if couldn't fix - * - * When useLLM=true: LLM IS the brain - it analyzes, reasons, decides - * When useLLM=false: Fall back to pattern-based fixes (fast but limited) - */ - private async attemptFix(errors: BuildError[]): Promise { - // When LLM is enabled, it's the primary reasoning engine (like ClawdeBot) - if (this.config.useLLM && this.invoker && this.modelConfig) { - return this.llmReasonAndFix(errors); - } - - // Without LLM, try pattern-based fixes (limited but fast) - for (const error of errors) { - const fix = await this.tryFixError(error); - if (fix) return fix; - } - - return null; - } - - /** - * LLM-powered reasoning and fixing (the agentic core) - * - * The LLM is the BRAIN that: - * 1. Sees the build errors with full code context - * 2. Reasons about what's wrong (root cause analysis) - * 3. Decides the appropriate action - * 4. Provides a precise fix - */ - private async llmReasonAndFix(errors: BuildError[]): Promise { - if (!this.invoker || !this.modelConfig) return null; - - // Build rich context for the LLM brain - const errorContext = errors.slice(0, 5).map(e => { - let context = `ERROR: ${e.file}:${e.line}:${e.column}`; - if (e.code) context += ` [${e.code}]`; - context += `\n${e.message}`; - - // Give the LLM the surrounding code context to reason about - try { - const fullPath = path.resolve(this.config.workingDir, e.file); - if (fs.existsSync(fullPath)) { - const content = fs.readFileSync(fullPath, 'utf-8'); - const lines = content.split('\n'); - const start = Math.max(0, e.line - 5); - const end = Math.min(lines.length, e.line + 3); - const snippet = lines.slice(start, end).map((l, i) => { - const lineNum = start + i + 1; - const marker = lineNum === e.line ? '>>> ' : ' '; - return `${marker}${lineNum.toString().padStart(4)}: ${l}`; - }).join('\n'); - context += `\n\nCode:\n\`\`\`typescript\n${snippet}\n\`\`\``; - } - } catch { - // Ignore file read errors - } - - return context; - }).join('\n\n'); - - // Previous attempts context (so LLM doesn't repeat failed fixes) - const previousFixes = this.attempts - .filter(a => a.fixApplied) - .map(a => `- ${a.fixApplied}`) - .join('\n'); - - const prompt = `You are an expert build error analyst. Your job is to understand WHY the build failed and fix it. - -BUILD COMMAND: ${this.config.command} -ATTEMPT: ${this.attempts.length + 1}/${this.config.maxAttempts} - -${previousFixes ? `PREVIOUS FIXES TRIED:\n${previousFixes}\n\n` : ''}BUILD ERRORS: -${errorContext} - -THINK step by step: -1. What is the ROOT CAUSE of this error? -2. What specific change will fix it? -3. Are there any related issues that might appear after this fix? - -Then respond with EXACTLY ONE action: - -ACTION: EDIT -FILE: -LINE: -SEARCH: -REPLACE: - -ACTION: INSERT -FILE: -AFTER_LINE: -CONTENT: - -ACTION: ESCALATE -REASON: - -Be surgical. Fix only what's broken.`; - - try { - this.report('fixing', this.attempts.length + 1, 'LLM analyzing errors...'); - const result = await this.invoker.generate(prompt, this.modelConfig); - - if (!result.success || !result.text) { - this.report('fixing', this.attempts.length + 1, 'LLM returned no response'); - return null; - } - - return this.applyLLMAction(result.text); - } catch (error: any) { - this.report('fixing', this.attempts.length + 1, `LLM error: ${error.message}`); - return null; - } - } - - /** - * Parse and apply the LLM's decided action - */ - private applyLLMAction(response: string): string | null { - const lines = response.split('\n'); - - // Find the ACTION line - const actionLine = lines.find(l => l.trim().startsWith('ACTION:')); - if (!actionLine) { - // Try to find old format for backwards compatibility - return this.applyLLMFix(response, { file: 'unknown', line: 0, column: 0, message: '', raw: '' }); - } - - const action = actionLine.replace('ACTION:', '').trim().toUpperCase(); - - if (action === 'ESCALATE') { - const reasonLine = lines.find(l => l.trim().startsWith('REASON:')); - const reason = reasonLine?.replace('REASON:', '').trim() || 'LLM decided to escalate'; - this.report('escalating', this.attempts.length + 1, `LLM: ${reason}`); - return null; - } - - if (action === 'EDIT') { - const fileLine = lines.find(l => l.trim().startsWith('FILE:')); - const lineLine = lines.find(l => l.trim().startsWith('LINE:')); - const searchLine = lines.find(l => l.trim().startsWith('SEARCH:')); - const replaceLine = lines.find(l => l.trim().startsWith('REPLACE:')); - - if (fileLine && searchLine && replaceLine) { - const file = fileLine.replace('FILE:', '').trim(); - const lineNum = lineLine ? parseInt(lineLine.replace('LINE:', '').trim(), 10) : 0; - const search = searchLine.replace('SEARCH:', '').trim(); - const replace = replaceLine.replace('REPLACE:', '').trim(); - - try { - const fullPath = path.resolve(this.config.workingDir, file); - const content = fs.readFileSync(fullPath, 'utf-8'); - const fileLines = content.split('\n'); - - // Find the line with the search text (prefer specified line number) - let targetIdx = lineNum > 0 ? lineNum - 1 : -1; - if (targetIdx >= 0 && targetIdx < fileLines.length && fileLines[targetIdx].includes(search)) { - // Found at expected line - } else { - // Search nearby lines - for (let i = 0; i < fileLines.length; i++) { - if (fileLines[i].includes(search)) { - targetIdx = i; - break; - } - } - } - - if (targetIdx >= 0 && targetIdx < fileLines.length) { - const originalLine = fileLines[targetIdx]; - fileLines[targetIdx] = originalLine.replace(search, replace); - fs.writeFileSync(fullPath, fileLines.join('\n')); - return `EDIT ${file}:${targetIdx + 1}: "${search.slice(0, 25)}..." → "${replace.slice(0, 25)}..."`; - } - } catch (error: any) { - this.report('fixing', this.attempts.length + 1, `Edit failed: ${error.message}`); - } - } - } - - if (action === 'INSERT') { - const fileLine = lines.find(l => l.trim().startsWith('FILE:')); - const afterLine = lines.find(l => l.trim().startsWith('AFTER_LINE:')); - const contentLine = lines.find(l => l.trim().startsWith('CONTENT:')); - - if (fileLine && contentLine) { - const file = fileLine.replace('FILE:', '').trim(); - const afterLineNum = afterLine ? parseInt(afterLine.replace('AFTER_LINE:', '').trim(), 10) : 0; - const insertContent = contentLine.replace('CONTENT:', '').trim(); - - try { - const fullPath = path.resolve(this.config.workingDir, file); - const content = fs.readFileSync(fullPath, 'utf-8'); - const fileLines = content.split('\n'); - - fileLines.splice(afterLineNum, 0, insertContent); - fs.writeFileSync(fullPath, fileLines.join('\n')); - return `INSERT ${file}:${afterLineNum + 1}: "${insertContent.slice(0, 40)}..."`; - } catch (error: any) { - this.report('fixing', this.attempts.length + 1, `Insert failed: ${error.message}`); - } - } - } - - return null; - } - - /** - * Parse and apply LLM's suggested fix - */ - private applyLLMFix(response: string, primaryError: BuildError): string | null { - const lines = response.trim().split('\n'); - const firstLine = lines[0].trim(); - - // CANNOT_FIX response - if (firstLine.startsWith('CANNOT_FIX')) { - this.report('fixing', this.attempts.length + 1, `LLM: ${firstLine}`); - return null; - } - - // EDIT response - if (firstLine.startsWith('EDIT ')) { - const file = firstLine.slice(5).trim(); - const lineMatch = response.match(/LINE\s+(\d+)/i); - const oldMatch = response.match(/OLD:\s*(.+)/i); - const newMatch = response.match(/NEW:\s*(.+)/i); - - if (lineMatch && oldMatch && newMatch) { - const lineNum = parseInt(lineMatch[1], 10); - const oldText = oldMatch[1].trim(); - const newText = newMatch[1].trim(); - - try { - const fullPath = path.resolve(this.config.workingDir, file); - const content = fs.readFileSync(fullPath, 'utf-8'); - const fileLines = content.split('\n'); - - // Find the line (may have shifted slightly) - let targetLine = lineNum - 1; - if (targetLine >= 0 && targetLine < fileLines.length) { - // Check if oldText matches (loosely - trim whitespace) - const currentLine = fileLines[targetLine].trim(); - if (currentLine.includes(oldText.trim()) || oldText.trim().includes(currentLine)) { - // Preserve indentation - const indent = fileLines[targetLine].match(/^(\s*)/)?.[1] || ''; - fileLines[targetLine] = indent + newText; - fs.writeFileSync(fullPath, fileLines.join('\n')); - return `LLM fix: ${file}:${lineNum} - replaced "${oldText.slice(0, 30)}..." with "${newText.slice(0, 30)}..."`; - } - } - } catch (error: any) { - this.report('fixing', this.attempts.length + 1, `LLM fix failed: ${error.message}`); - } - } - } - - // IMPORT response - if (firstLine.startsWith('IMPORT ')) { - const file = firstLine.slice(7).trim(); - const importLine = lines.slice(1).find(l => l.trim().startsWith('import')); - - if (importLine) { - try { - const fullPath = path.resolve(this.config.workingDir, file); - const content = fs.readFileSync(fullPath, 'utf-8'); - const fileLines = content.split('\n'); - - // Check if import already exists - if (fileLines.some(l => l.includes(importLine.trim()))) { - return null; - } - - // Find last import line - let insertIndex = 0; - for (let i = 0; i < fileLines.length; i++) { - if (fileLines[i].trim().startsWith('import ')) { - insertIndex = i + 1; - } - } - - fileLines.splice(insertIndex, 0, importLine.trim()); - fs.writeFileSync(fullPath, fileLines.join('\n')); - return `LLM fix: Added import to ${file}: ${importLine.trim()}`; - } catch (error: any) { - this.report('fixing', this.attempts.length + 1, `LLM import fix failed: ${error.message}`); - } - } - } - - return null; - } - - /** - * Try to fix a single error - */ - private async tryFixError(error: BuildError): Promise { - // TypeScript: Property 'x' does not exist on type 'y' - if (error.code === 'TS2339' && error.file !== 'unknown') { - // This usually needs human judgment, but we can report it clearly - return null; - } - - // TypeScript: Cannot find name 'x' - might be missing import - if (error.code === 'TS2304') { - const match = error.message.match(/Cannot find name '(\w+)'/); - if (match) { - const missingName = match[1]; - // Common cases we can handle - if (['fs', 'path', 'exec', 'execSync'].includes(missingName)) { - return await this.addNodeImport(error.file, missingName); - } - } - } - - // TypeScript: 'x' is declared but its value is never read - if (error.code === 'TS6133' && error.file !== 'unknown') { - // Could prefix with underscore, but risky - escalate - return null; - } - - // TypeScript: Type 'X' is not assignable to type 'Y' - if (error.code === 'TS2322' && error.file !== 'unknown') { - return await this.tryFixTypeAssignment(error); - } - - return null; - } - - /** - * Try to fix TS2322: Type 'X' is not assignable to type 'Y' - * Strategy: Read the line, identify the type annotation, fix it - */ - private async tryFixTypeAssignment(error: BuildError): Promise { - const fullPath = path.resolve(this.config.workingDir, error.file); - if (!fs.existsSync(fullPath)) return null; - - const content = fs.readFileSync(fullPath, 'utf-8'); - const lines = content.split('\n'); - const lineIndex = error.line - 1; - - if (lineIndex < 0 || lineIndex >= lines.length) return null; - - const line = lines[lineIndex]; - - // Parse the error message: Type 'X' is not assignable to type 'Y' - const typeMatch = error.message.match(/Type '([^']+)' is not assignable to type '([^']+)'/); - if (!typeMatch) return null; - - const actualType = typeMatch[1]; - const expectedType = typeMatch[2]; - - // Case 1: Variable declaration with wrong type annotation - // e.g., "const fullPath: number = path.join(...)" should be "const fullPath: string = ..." - const varDeclMatch = line.match(/^(\s*(?:const|let|var)\s+\w+):\s*(\w+)(\s*=.*)$/); - if (varDeclMatch) { - const [, prefix, currentType, suffix] = varDeclMatch; - if (currentType === expectedType) { - // The declaration type is correct, the value is wrong - need AI judgment - return null; - } - // Fix the type annotation - const newLine = `${prefix}: ${actualType}${suffix}`; - lines[lineIndex] = newLine; - fs.writeFileSync(fullPath, lines.join('\n')); - return `Fixed type annotation: ${expectedType} → ${actualType} at ${error.file}:${error.line}`; - } - - // Case 2: Function return type - check if we're at a return statement - // Look for function declaration above to fix return type - if (line.trim().startsWith('return ')) { - // Find the function declaration above - for (let i = lineIndex - 1; i >= 0; i--) { - const funcMatch = lines[i].match(/^(\s*(?:async\s+)?function\s+\w+\s*\([^)]*\)):\s*(\w+)(\s*\{?)$/); - if (funcMatch) { - const [, funcSig, returnType, brace] = funcMatch; - if (returnType === expectedType) { - // Change return type to match actual - lines[i] = `${funcSig}: ${actualType}${brace}`; - fs.writeFileSync(fullPath, lines.join('\n')); - return `Fixed return type: ${expectedType} → ${actualType} at ${error.file}:${i + 1}`; - } - break; - } - } - } - - // Case 3: Object property shorthand in return statement - // e.g., "return { id, name }" where id parameter has wrong type - // Error appears on line with "id," - need to fix function parameter - const propMatch = line.trim().match(/^(\w+),?\s*(\/\/.*)?$/); - if (propMatch) { - const propName = propMatch[1]; - // Find the function declaration above and fix the parameter type - for (let i = lineIndex - 1; i >= 0; i--) { - const funcLine = lines[i]; - // Match function with parameters - const funcDeclMatch = funcLine.match(/^(\s*(?:async\s+)?function\s+\w+\s*\()([^)]+)(\).*)/); - if (funcDeclMatch) { - const [, prefix, params, suffix] = funcDeclMatch; - // Parse parameters to find the one we need to fix - const paramList = params.split(',').map(p => p.trim()); - let fixedParams = []; - let fixed = false; - - for (const param of paramList) { - // Match "name: type" pattern - const paramMatch = param.match(/^(\w+):\s*(\w+)$/); - if (paramMatch && paramMatch[1] === propName) { - // This is the parameter we need to fix - if (paramMatch[2] === actualType) { - // Parameter type matches actual, interface expects different - // Change parameter to match expected (interface is truth) - fixedParams.push(`${propName}: ${expectedType}`); - fixed = true; - } else { - fixedParams.push(param); - } - } else { - fixedParams.push(param); - } - } - - if (fixed) { - lines[i] = `${prefix}${fixedParams.join(', ')}${suffix}`; - fs.writeFileSync(fullPath, lines.join('\n')); - return `Fixed parameter type: ${propName}: ${actualType} → ${expectedType} at ${error.file}:${i + 1}`; - } - break; - } - } - } - - return null; - } - - /** - * Add a Node.js import to a file - */ - private async addNodeImport(file: string, name: string): Promise { - const fullPath = path.resolve(this.config.workingDir, file); - if (!fs.existsSync(fullPath)) return null; - - const content = fs.readFileSync(fullPath, 'utf-8'); - - // Determine the import to add - let importStatement = ''; - if (name === 'fs') importStatement = "import * as fs from 'fs';\n"; - else if (name === 'path') importStatement = "import * as path from 'path';\n"; - else if (name === 'exec' || name === 'execSync') { - importStatement = "import { exec, execSync } from 'child_process';\n"; - } - - if (!importStatement) return null; - - // Check if already imported (only check actual import lines, not comments) - const lines = content.split('\n'); - const hasImport = lines.some(line => { - const trimmed = line.trim(); - return !trimmed.startsWith('//') && trimmed.includes(importStatement.trim()); - }); - if (hasImport) return null; - - // Add at top of file (after any existing imports) - let insertIndex = 0; - for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('import ')) { - insertIndex = i + 1; - } - } - - lines.splice(insertIndex, 0, importStatement.trim()); - fs.writeFileSync(fullPath, lines.join('\n')); - - return `Added import: ${importStatement.trim()} to ${file}`; - } - - /** - * Report progress - */ - private report(phase: SentinelProgress['phase'], attempt: number, message: string, errors?: BuildError[]) { - this.config.onProgress({ - phase, - attempt, - maxAttempts: this.config.maxAttempts, - message, - errors, - }); - } -} - -// Export for direct testing -export async function testBuildSentinel() { - const sentinel = new BuildSentinel({ - command: 'npm run build:ts', - workingDir: '/Volumes/FlashGordon/cambrian/continuum/src/debug/jtag', - maxAttempts: 3, - onProgress: (p) => { - console.log(`[${p.phase.toUpperCase()}] (${p.attempt}/${p.maxAttempts}) ${p.message}`); - if (p.errors && p.errors.length > 0) { - p.errors.slice(0, 3).forEach((e) => { - console.log(` - ${e.file}:${e.line}: ${e.message}`); - }); - if (p.errors.length > 3) { - console.log(` ... and ${p.errors.length - 3} more`); - } - } - }, - }); - - console.log('\n=== BuildSentinel Test ===\n'); - const result = await sentinel.run(); - - console.log('\n=== Result ==='); - console.log(`Success: ${result.success}`); - console.log(`Attempts: ${result.attempts.length}`); - if (result.escalated) { - console.log(`Escalated: ${result.escalationReason}`); - } - if (result.finalErrors && result.finalErrors.length > 0) { - console.log(`Final errors: ${result.finalErrors.length}`); - } - - return result; -} diff --git a/src/debug/jtag/system/sentinel/ModelProvider.ts b/src/debug/jtag/system/sentinel/ModelProvider.ts index 3d962d354..99046489a 100644 --- a/src/debug/jtag/system/sentinel/ModelProvider.ts +++ b/src/debug/jtag/system/sentinel/ModelProvider.ts @@ -1,18 +1,10 @@ /** - * ModelProvider - Flexible AI model selection for Sentinels + * ModelProvider - Model configuration types for Sentinels * - * Sentinels can be: - * 1. Script-only (no AI) - BuildSentinel, VisualSentinel - * 2. AI-powered - OrchestratorSentinel, future PlannerSentinel - * - * For AI-powered sentinels, select models by: - * - Power level (capacity enum) - * - Specific model string - * - Provider preference + * ARCHITECTURE: All inference routes through the inference/generate command + * via IPC. No direct model invocation here - just configuration types. */ -import { execSync } from 'child_process'; - /** * Model capacity levels - from tiny to state-of-the-art */ @@ -28,7 +20,7 @@ export enum ModelCapacity { * Model providers */ export enum ModelProvider { - LOCAL = 'local', // Local inference service (Candle/Qwen, etc.) + LOCAL = 'local', // Local inference service CANDLE = 'candle', // Candle native Rust inference ANTHROPIC = 'anthropic', // Claude API OPENAI = 'openai', // OpenAI API @@ -36,272 +28,12 @@ export enum ModelProvider { } /** - * Model selection config + * Model selection config - passed to inference/generate command */ export interface ModelConfig { provider?: ModelProvider; capacity?: ModelCapacity; - model?: string; // Specific model override (e.g., 'claude-3-opus', 'llama3.2:3b') + model?: string; // Specific model override maxTokens?: number; temperature?: number; } - -/** - * Known models by provider and capacity - */ -const MODEL_REGISTRY: Record>> = { - [ModelProvider.LOCAL]: { - [ModelCapacity.TINY]: 'qwen2.5:0.5b', - [ModelCapacity.SMALL]: 'qwen2.5:1.5b', - [ModelCapacity.MEDIUM]: 'qwen2.5:7b', - [ModelCapacity.LARGE]: 'qwen2.5:32b', - [ModelCapacity.SOTA]: 'qwen2.5:72b', - }, - [ModelProvider.CANDLE]: { - [ModelCapacity.TINY]: 'phi3:mini', - [ModelCapacity.SMALL]: 'llama3.2:3b', - [ModelCapacity.MEDIUM]: 'llama3.1:8b', - [ModelCapacity.LARGE]: 'llama3.1:70b', - [ModelCapacity.SOTA]: 'llama3.1:405b', - }, - [ModelProvider.ANTHROPIC]: { - [ModelCapacity.SMALL]: 'claude-3-haiku-20240307', - [ModelCapacity.MEDIUM]: 'claude-3-5-sonnet-20241022', - [ModelCapacity.LARGE]: 'claude-3-5-sonnet-20241022', - [ModelCapacity.SOTA]: 'claude-3-opus-20240229', - }, - [ModelProvider.OPENAI]: { - [ModelCapacity.SMALL]: 'gpt-4o-mini', - [ModelCapacity.MEDIUM]: 'gpt-4o', - [ModelCapacity.LARGE]: 'gpt-4o', - [ModelCapacity.SOTA]: 'gpt-4o', - }, - [ModelProvider.AUTO]: {}, // Determined at runtime -}; - -/** - * Model inference result - */ -export interface InferenceResult { - success: boolean; - text?: string; - error?: string; - model: string; - provider: ModelProvider; - tokensUsed?: number; -} - -/** - * ModelSelector - resolves config to actual model - */ -export class ModelSelector { - private workingDir: string; - - constructor(workingDir: string) { - this.workingDir = workingDir; - } - - /** - * Resolve model config to specific model string - */ - resolve(config: ModelConfig): { provider: ModelProvider; model: string } { - // If specific model given, use it - if (config.model) { - return { - provider: config.provider || ModelProvider.AUTO, - model: config.model, - }; - } - - // Resolve by capacity - const capacity = config.capacity || ModelCapacity.SMALL; - const provider = config.provider || ModelProvider.LOCAL; - - if (provider === ModelProvider.AUTO) { - return this.autoSelect(capacity); - } - - const model = MODEL_REGISTRY[provider][capacity]; - if (!model) { - // Fallback to closest available - const available = Object.entries(MODEL_REGISTRY[provider]); - if (available.length > 0) { - return { provider, model: available[0][1] as string }; - } - throw new Error(`No models available for provider ${provider}`); - } - - return { provider, model }; - } - - /** - * Auto-select best available model for capacity - */ - private autoSelect(capacity: ModelCapacity): { provider: ModelProvider; model: string } { - // Priority: LOCAL > CANDLE > ANTHROPIC > OPENAI - // (prefer local/free over API) - - // Check if local inference is available - try { - execSync('./jtag ping', { cwd: this.workingDir, stdio: 'pipe' }); - const model = MODEL_REGISTRY[ModelProvider.LOCAL][capacity]; - if (model) { - return { provider: ModelProvider.LOCAL, model }; - } - } catch { - // Local not available - } - - // Check Candle (native Rust) - try { - execSync('./jtag ai/providers/status', { cwd: this.workingDir, stdio: 'pipe' }); - const model = MODEL_REGISTRY[ModelProvider.CANDLE][capacity]; - if (model) { - return { provider: ModelProvider.CANDLE, model }; - } - } catch { - // Candle not available - } - - // Fallback to Anthropic if API key exists - if (process.env.ANTHROPIC_API_KEY) { - const model = MODEL_REGISTRY[ModelProvider.ANTHROPIC][capacity]; - if (model) { - return { provider: ModelProvider.ANTHROPIC, model }; - } - } - - // Last resort: OpenAI - if (process.env.OPENAI_API_KEY) { - const model = MODEL_REGISTRY[ModelProvider.OPENAI][capacity]; - if (model) { - return { provider: ModelProvider.OPENAI, model }; - } - } - - throw new Error('No AI providers available'); - } - - /** - * List available models for a provider - */ - listModels(provider: ModelProvider): string[] { - return Object.values(MODEL_REGISTRY[provider]).filter(Boolean) as string[]; - } -} - -/** - * ModelInvoker - actually calls the model - */ -export class ModelInvoker { - private workingDir: string; - private selector: ModelSelector; - - constructor(workingDir: string) { - this.workingDir = workingDir; - this.selector = new ModelSelector(workingDir); - } - - /** - * Generate text from a prompt - */ - async generate(prompt: string, config: ModelConfig = {}): Promise { - const { provider, model } = this.selector.resolve(config); - const maxTokens = config.maxTokens || 2000; - - switch (provider) { - case ModelProvider.LOCAL: - return this.invokeLocal(prompt, model, maxTokens); - case ModelProvider.CANDLE: - return this.invokeCandle(prompt, model, maxTokens); - case ModelProvider.ANTHROPIC: - return this.invokeAnthropic(prompt, model, maxTokens); - case ModelProvider.OPENAI: - return this.invokeOpenAI(prompt, model, maxTokens); - case ModelProvider.AUTO: - // Auto already resolved to specific provider - return this.invokeLocal(prompt, model, maxTokens); - default: - return { success: false, error: `Unknown provider: ${provider}`, model, provider }; - } - } - - private async invokeLocal(prompt: string, model: string, maxTokens: number): Promise { - try { - const escapedPrompt = prompt.replace(/"/g, '\\"').replace(/\n/g, '\\n'); - const response = execSync( - `./jtag inference/generate --prompt="${escapedPrompt}" --maxTokens=${maxTokens}`, - { cwd: this.workingDir, encoding: 'utf-8', timeout: 120000 } - ); - const parsed = JSON.parse(response); - if (parsed.success && parsed.text) { - return { success: true, text: parsed.text, model, provider: ModelProvider.LOCAL }; - } - return { success: false, error: parsed.error || 'No response', model, provider: ModelProvider.LOCAL }; - } catch (error: any) { - return { success: false, error: error.message, model, provider: ModelProvider.LOCAL }; - } - } - - private async invokeCandle(prompt: string, model: string, maxTokens: number): Promise { - try { - const escapedPrompt = prompt.replace(/"/g, '\\"').replace(/\n/g, '\\n'); - const response = execSync( - `./jtag inference/generate --prompt="${escapedPrompt}" --provider="candle" --maxTokens=${maxTokens}`, - { cwd: this.workingDir, encoding: 'utf-8', timeout: 120000, maxBuffer: 10 * 1024 * 1024 } - ); - const parsed = JSON.parse(response); - if (parsed.success && parsed.text) { - return { success: true, text: parsed.text, model, provider: ModelProvider.CANDLE }; - } - return { success: false, error: parsed.error || 'No response', model, provider: ModelProvider.CANDLE }; - } catch (error: any) { - return { success: false, error: error.message, model, provider: ModelProvider.CANDLE }; - } - } - - private async invokeAnthropic(prompt: string, model: string, maxTokens: number): Promise { - // Use JTAG's ai/generate which handles Anthropic - try { - const escapedPrompt = prompt.replace(/"/g, '\\"').replace(/\n/g, '\\n'); - const response = execSync( - `./jtag ai/generate --prompt="${escapedPrompt}" --model="${model}" --maxTokens=${maxTokens}`, - { cwd: this.workingDir, encoding: 'utf-8', timeout: 120000 } - ); - const parsed = JSON.parse(response); - if (parsed.success && parsed.text) { - return { success: true, text: parsed.text, model, provider: ModelProvider.ANTHROPIC }; - } - return { success: false, error: parsed.error || 'No response', model, provider: ModelProvider.ANTHROPIC }; - } catch (error: any) { - return { success: false, error: error.message, model, provider: ModelProvider.ANTHROPIC }; - } - } - - private async invokeOpenAI(prompt: string, model: string, maxTokens: number): Promise { - // Similar to Anthropic, use JTAG's ai/generate - try { - const escapedPrompt = prompt.replace(/"/g, '\\"').replace(/\n/g, '\\n'); - const response = execSync( - `./jtag ai/generate --prompt="${escapedPrompt}" --model="${model}" --maxTokens=${maxTokens}`, - { cwd: this.workingDir, encoding: 'utf-8', timeout: 120000 } - ); - const parsed = JSON.parse(response); - if (parsed.success && parsed.text) { - return { success: true, text: parsed.text, model, provider: ModelProvider.OPENAI }; - } - return { success: false, error: parsed.error || 'No response', model, provider: ModelProvider.OPENAI }; - } catch (error: any) { - return { success: false, error: error.message, model, provider: ModelProvider.OPENAI }; - } - } -} - -// Convenience functions -export function createInvoker(workingDir: string): ModelInvoker { - return new ModelInvoker(workingDir); -} - -export function resolveModel(workingDir: string, config: ModelConfig): { provider: ModelProvider; model: string } { - return new ModelSelector(workingDir).resolve(config); -} diff --git a/src/debug/jtag/system/sentinel/OrchestratorSentinel.ts b/src/debug/jtag/system/sentinel/OrchestratorSentinel.ts deleted file mode 100644 index beb060ca1..000000000 --- a/src/debug/jtag/system/sentinel/OrchestratorSentinel.ts +++ /dev/null @@ -1,626 +0,0 @@ -/** - * OrchestratorSentinel - LLM-powered task planning and execution - * - * Uses a quality model (like ClawdeBot/MoltBot) to: - * 1. Plan: Break down goals into tasks - * 2. Execute: Dispatch to specialized sentinels - * 3. Observe: Check results, interpret outputs - * 4. Adjust: Modify plan based on failures - * 5. Stop: Know when goal is achieved (base case) - * - * The LLM is the "brain", sentinels are the "hands" - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { execSync } from 'child_process'; -import { BuildSentinel } from './BuildSentinel'; -import { VisualSentinel } from './VisualSentinel'; -import { ModelConfig, ModelCapacity, ModelProvider, ModelInvoker } from './ModelProvider'; -import type { OrchestrateSentinelDefinition } from './SentinelDefinition'; - -export interface OrchestratorConfig { - workingDir: string; - maxIterations?: number; // Recursion limit - - // Model selection - flexible options - model?: ModelConfig; // Full config with capacity/provider/specific model - capacity?: ModelCapacity; // Shorthand: just specify power level - provider?: ModelProvider; // Shorthand: just specify provider - modelName?: string; // Shorthand: specific model string - - screenshotDir?: string; // Where to save visual feedback - onThought?: (thought: string) => void; - onAction?: (action: string, result: string) => void; - onScreenshot?: (path: string) => void; -} - -export interface ExecutionContext { - goal: string; - iteration: number; - history: HistoryEntry[]; - filesCreated: string[]; - filesModified: string[]; - errors: string[]; -} - -export interface HistoryEntry { - thought: string; - action: string; - result: string; - success: boolean; -} - -type ActionType = 'write' | 'read' | 'build' | 'run' | 'screenshot' | 'done' | 'fail'; - -interface Action { - type: ActionType; - file?: string; - content?: string; - command?: string; - reason?: string; -} - -// Internal config with all required fields -interface InternalConfig { - workingDir: string; - maxIterations: number; - screenshotDir: string; - onThought: (thought: string) => void; - onAction: (action: string, result: string) => void; - onScreenshot: (path: string) => void; -} - -export class OrchestratorSentinel { - private config: InternalConfig; - private invoker: ModelInvoker; - private modelConfig: ModelConfig; - private _goal?: string; - - constructor(config: OrchestratorConfig & { workingDir: string }) { - // Apply defaults - this.config = { - workingDir: config.workingDir, - maxIterations: config.maxIterations ?? 20, - screenshotDir: config.screenshotDir ?? '/tmp/sentinel-screenshots', - onThought: config.onThought ?? (() => {}), - onAction: config.onAction ?? (() => {}), - onScreenshot: config.onScreenshot ?? (() => {}), - }; - - // Build model config from various shorthand options - this.modelConfig = config.model ?? { - capacity: config.capacity ?? ModelCapacity.SMALL, - provider: config.provider ?? ModelProvider.LOCAL, - model: config.modelName, - maxTokens: 2000, - }; - - this.invoker = new ModelInvoker(config.workingDir); - } - - /** - * Create an OrchestratorSentinel from a portable definition - */ - static fromDefinition( - def: OrchestrateSentinelDefinition, - callbacks?: Pick - ): OrchestratorSentinel { - const sentinel = new OrchestratorSentinel({ - workingDir: def.workingDir || process.cwd(), - maxIterations: def.maxIterations, - capacity: def.capacity, - provider: def.provider, - modelName: def.modelName, - screenshotDir: def.screenshotDir, - ...callbacks, - }); - sentinel._goal = def.goal; - return sentinel; - } - - /** - * Export to portable JSON definition - */ - toDefinition(name?: string, goal?: string): OrchestrateSentinelDefinition { - return { - type: 'orchestrate', - name: name || `orchestrate-${Date.now()}`, - version: '1.0', - goal: goal || this._goal || '', - workingDir: this.config.workingDir, - maxIterations: this.config.maxIterations, - capacity: this.modelConfig.capacity, - provider: this.modelConfig.provider, - modelName: this.modelConfig.model, - screenshotDir: this.config.screenshotDir, - createdAt: new Date().toISOString(), - }; - } - - /** - * Run from definition (uses stored goal) - */ - async runFromDefinition(): Promise<{ success: boolean; summary: string; context: ExecutionContext }> { - if (!this._goal) { - return { - success: false, - summary: 'No goal specified in definition', - context: { goal: '', iteration: 0, history: [], filesCreated: [], filesModified: [], errors: ['No goal'] }, - }; - } - return this.execute(this._goal); - } - - /** - * Take visual feedback screenshot of HTML files - */ - private async captureVisualFeedback(context: ExecutionContext): Promise { - // Find HTML files created - const htmlFiles = context.filesCreated.filter(f => f.endsWith('.html')); - if (htmlFiles.length === 0) return undefined; - - const visualSentinel = new VisualSentinel({ - outputDir: this.config.screenshotDir, - }); - - // Screenshot the first HTML file (usually index.html) - const htmlPath = path.resolve(this.config.workingDir, htmlFiles[0]); - const screenshotName = `${path.basename(htmlFiles[0], '.html')}-preview.png`; - - const result = await visualSentinel.screenshotFile(htmlPath, screenshotName); - if (result.success && result.imagePath) { - this.config.onScreenshot(result.imagePath); - return result.imagePath; - } - return undefined; - } - - /** - * Execute a high-level goal using LLM planning - */ - async execute(goal: string): Promise<{ success: boolean; summary: string; context: ExecutionContext }> { - const context: ExecutionContext = { - goal, - iteration: 0, - history: [], - filesCreated: [], - filesModified: [], - errors: [], - }; - - this.config.onThought(`Goal: ${goal}`); - - while (context.iteration < this.config.maxIterations) { - context.iteration++; - this.config.onThought(`\n--- Iteration ${context.iteration}/${this.config.maxIterations} ---`); - - // 1. Ask LLM what to do next - const action = await this.think(context); - this.config.onThought(`Thought: ${action.reason || 'No reason given'}`); - - // 2. Check for termination (base cases) - if (action.type === 'done') { - this.config.onThought(`Done: ${action.reason}`); - return { - success: true, - summary: action.reason || 'Goal completed', - context, - }; - } - - if (action.type === 'fail') { - this.config.onThought(`Failed: ${action.reason}`); - return { - success: false, - summary: action.reason || 'Goal failed', - context, - }; - } - - // 3. Fix file path if needed (when LLM returns just filename without path) - if (action.type === 'write' && action.file && !action.file.includes('/')) { - const pathMatch = goal.match(/(?:at|to|in)\s+(\S+)/i); - if (pathMatch) { - const targetPath = pathMatch[1].replace(/\/+$/, ''); - // If target path ends with a filename, use it; otherwise append - if (targetPath.includes('.')) { - action.file = targetPath; - } else { - action.file = `${targetPath}/${action.file}`; - } - } - } - - // 4. Execute the action - const result = await this.act(action, context); - this.config.onAction(`${action.type}: ${action.file || action.command || ''}`, result.output); - - // 5. Record in history - context.history.push({ - thought: action.reason || '', - action: `${action.type}: ${action.file || action.command || ''}`, - result: result.output, - success: result.success, - }); - - if (!result.success) { - context.errors.push(result.output); - } - - // 6. Auto-terminate: If we successfully created the target file, we're done - if (result.success && action.type === 'write') { - const pathMatch = goal.match(/(?:at|to|in)\s+(\S+\.\w+)/i); - if (pathMatch && action.file?.endsWith(pathMatch[1].split('/').pop()!)) { - this.config.onThought(`Auto-done: Target file created successfully`); - // Capture visual feedback for HTML files - const screenshot = await this.captureVisualFeedback(context); - if (screenshot) { - this.config.onThought(`Visual feedback: ${screenshot}`); - } - return { - success: true, - summary: `Created ${action.file}${screenshot ? ` (screenshot: ${screenshot})` : ''}`, - context, - }; - } - } - } - - // Max iterations reached - return { - success: false, - summary: `Max iterations (${this.config.maxIterations}) reached`, - context, - }; - } - - /** - * Ask LLM to decide the next action - */ - private async think(context: ExecutionContext): Promise { - const prompt = this.buildPrompt(context); - - try { - const response = await this.callLLM(prompt); - return this.parseAction(response); - } catch (error: any) { - // If LLM fails, return fail action - return { type: 'fail', reason: `LLM error: ${error.message}` }; - } - } - - /** - * Build prompt for LLM - */ - private buildPrompt(context: ExecutionContext): string { - const historyStr = context.history.length > 0 - ? context.history.map((h, i) => - `${i + 1}. Action: ${h.action}\n Result: ${h.success ? 'SUCCESS' : 'FAILED'} - ${h.result.slice(0, 200)}` - ).join('\n') - : 'No actions taken yet.'; - - const filesStr = context.filesCreated.length > 0 - ? `Files created: ${context.filesCreated.join(', ')}` - : 'No files created yet.'; - - // Check if goal might be complete based on state - const goalMightBeComplete = context.filesCreated.length > 0 && context.errors.length === 0; - - // Extract target path from goal if present - const pathMatch = context.goal.match(/(?:at|to|in)\s+(\S+)/i); - const targetPath = pathMatch ? pathMatch[1].replace(/\/+$/, '') : ''; - - if (goalMightBeComplete) { - return `DONE`; // Force termination - } - - // Simple direct prompt - const filename = targetPath ? targetPath.split('/').pop() || 'index.html' : 'index.html'; - - return `Create ${filename} for: ${context.goal} - -Output the complete file content now:`; - } - - /** - * Call LLM using flexible model provider - */ - private async callLLM(prompt: string): Promise { - const result = await this.invoker.generate(prompt, this.modelConfig); - if (result.success && result.text) { - return result.text; - } - throw new Error(result.error || 'No response from model'); - } - - /** - * Parse LLM response into Action (simple text format) - */ - private parseAction(response: string): Action { - // First, check if response contains a markdown code block with WRITE/READ/etc - const codeBlockMatch = response.match(/```[\w]*\n?([\s\S]*?)```/); - let cleaned = codeBlockMatch ? codeBlockMatch[1].trim() : response.trim(); - - // Remove common LLM preambles and markdown formatting - cleaned = cleaned - .replace(/^(Here is|Here's|I'd like to|I will|Let me|Let's|Okay,?|Sure,?|Alright,?|The following|This will)[^.]*\.\s*/i, '') - .replace(/^(To create|To write|To build|For this task)[^.]*\.\s*/i, '') - .replace(/\*\*/g, '') // Remove bold markdown - .replace(/`/g, '') // Remove inline code markdown - .trim(); - - // If cleaned still has preamble, look for WRITE/READ/BUILD/RUN/DONE anywhere - const commandMatch = cleaned.match(/^(WRITE|READ|BUILD|RUN|DONE|FAIL)\b/im); - if (commandMatch && commandMatch.index && commandMatch.index > 0) { - cleaned = cleaned.slice(commandMatch.index); - } - - const lines = cleaned.split('\n'); - const firstLine = lines[0].trim().toUpperCase(); - - // DONE reason - if (firstLine.startsWith('DONE')) { - return { type: 'done', reason: firstLine.slice(4).trim() || lines.slice(1).join(' ').trim() }; - } - - // FAIL reason - if (firstLine.startsWith('FAIL')) { - return { type: 'fail', reason: firstLine.slice(4).trim() || lines.slice(1).join(' ').trim() }; - } - - // BUILD - if (firstLine === 'BUILD') { - return { type: 'build', reason: 'Running build' }; - } - - // RUN command - if (firstLine.startsWith('RUN ')) { - return { type: 'run', command: lines[0].slice(4).trim(), reason: 'Running command' }; - } - - // READ file - if (firstLine.startsWith('READ ')) { - return { type: 'read', file: lines[0].slice(5).trim(), reason: 'Reading file' }; - } - - // WRITE formats: - // Format 1: WRITE filename\n---content---\nEND - // Format 2: WRITE\nfilename: X\ncontent:\n... - // Format 3: WRITE filename\ncontent... - if (firstLine === 'WRITE' || firstLine.startsWith('WRITE ')) { - let file: string; - let content: string; - - if (firstLine === 'WRITE') { - // Format 2: WRITE on its own line, filename: on next line - const filenameMatch = cleaned.match(/filename:\s*(.+)/i); - const contentMatch = cleaned.match(/content:\s*([\s\S]*?)(?:END|$)/i); - - if (filenameMatch) { - file = filenameMatch[1].trim(); - content = contentMatch ? contentMatch[1].trim() : lines.slice(2).join('\n').trim(); - } else { - // Just take second line as filename, rest as content - file = lines[1]?.trim() || 'output.txt'; - content = lines.slice(2).join('\n').trim(); - } - } else { - // Format 1/3: WRITE filename on same line - file = lines[0].slice(6).trim(); - - // Try multiple content formats: - // 1. Markdown code block: ```...\ncontent\n``` - // 2. --- markers: ---\ncontent\nEND - // 3. Raw: everything after first line - - const afterFirstLine = lines.slice(1).join('\n'); - - // Check for markdown code block - const codeBlockMatch = afterFirstLine.match(/```[\w]*\n?([\s\S]*?)```/); - if (codeBlockMatch) { - content = codeBlockMatch[1].trim(); - } else { - // Check for --- markers - const contentStart = afterFirstLine.indexOf('---'); - const contentEnd = afterFirstLine.lastIndexOf('END'); - - if (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart) { - content = afterFirstLine.slice(contentStart + 3, contentEnd).trim(); - } else { - // Raw: strip any leading/trailing markers - content = afterFirstLine.replace(/^---\n?/, '').replace(/\n?END$/, '').trim(); - } - } - } - - // Clean up content - remove markdown code blocks if present - content = content.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim(); - - return { type: 'write', file, content, reason: 'Writing file' }; - } - - // Raw content fallback: try to extract HTML from chatty response - // Look for HTML in markdown code blocks first (model might be chatty) - const htmlCodeBlock = response.match(/```html?\n?([\s\S]*?)```/i); - if (htmlCodeBlock) { - return { - type: 'write', - file: 'index.html', - content: htmlCodeBlock[1].trim(), - reason: 'Extracted HTML from code block', - }; - } - - // Look for raw HTML content - if (cleaned.includes(' { - switch (action.type) { - case 'write': - return this.actWrite(action, context); - case 'read': - return this.actRead(action); - case 'build': - return this.actBuild(); - case 'run': - return this.actRun(action); - case 'screenshot': - return this.actScreenshot(action); - default: - return { success: false, output: `Unknown action: ${action.type}` }; - } - } - - private async actWrite(action: Action, context: ExecutionContext): Promise<{ success: boolean; output: string }> { - if (!action.file || !action.content) { - return { success: false, output: 'Write needs file and content' }; - } - - try { - const fullPath = path.resolve(this.config.workingDir, action.file); - const dir = path.dirname(fullPath); - - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - const existed = fs.existsSync(fullPath); - fs.writeFileSync(fullPath, action.content); - - if (existed) { - context.filesModified.push(action.file); - } else { - context.filesCreated.push(action.file); - } - - return { success: true, output: `Wrote ${action.file} (${action.content.length} bytes)` }; - } catch (error: any) { - return { success: false, output: `Write failed: ${error.message}` }; - } - } - - private async actRead(action: Action): Promise<{ success: boolean; output: string }> { - if (!action.file) { - return { success: false, output: 'Read needs file' }; - } - - try { - const fullPath = path.resolve(this.config.workingDir, action.file); - const content = fs.readFileSync(fullPath, 'utf-8'); - return { success: true, output: content.slice(0, 1000) + (content.length > 1000 ? '...' : '') }; - } catch (error: any) { - return { success: false, output: `Read failed: ${error.message}` }; - } - } - - private async actBuild(): Promise<{ success: boolean; output: string }> { - const sentinel = new BuildSentinel({ - command: 'npm run build:ts', - workingDir: this.config.workingDir, - maxAttempts: 3, - canAutoFix: true, - }); - - const result = await sentinel.run(); - return { - success: result.success, - output: result.success - ? `Build succeeded${result.attempts.length > 1 ? ` (${result.attempts.length} attempts, fixes: ${JSON.stringify(result.attempts.filter(a => a.fixApplied).map(a => a.fixApplied))})` : ''}` - : `Build failed: ${result.escalationReason}`, - }; - } - - private async actRun(action: Action): Promise<{ success: boolean; output: string }> { - if (!action.command) { - return { success: false, output: 'Run needs command' }; - } - - try { - const output = execSync(action.command, { - cwd: this.config.workingDir, - encoding: 'utf-8', - timeout: 30000, - }); - return { success: true, output: output.slice(0, 500) }; - } catch (error: any) { - return { success: false, output: `Run failed: ${error.message}` }; - } - } - - private async actScreenshot(action: Action): Promise<{ success: boolean; output: string }> { - // Use JTAG's screenshot command if available - try { - const output = execSync('./jtag screenshot --url="http://localhost:8080" --filename="sentinel-screenshot.png"', { - cwd: this.config.workingDir, - encoding: 'utf-8', - timeout: 30000, - }); - return { success: true, output: 'Screenshot taken: sentinel-screenshot.png' }; - } catch (error: any) { - return { success: false, output: `Screenshot failed: ${error.message}` }; - } - } -} - -// Test function -export async function testOrchestrator() { - const orchestrator = new OrchestratorSentinel({ - workingDir: '/Volumes/FlashGordon/cambrian/continuum/src/debug/jtag', - maxIterations: 10, - capacity: ModelCapacity.SMALL, - provider: ModelProvider.LOCAL, - onThought: (t) => console.log(`[THINK] ${t}`), - onAction: (a, r) => console.log(`[ACT] ${a}\n[RESULT] ${r.slice(0, 200)}`), - }); - - console.log('\n=== OrchestratorSentinel Test ===\n'); - - const result = await orchestrator.execute( - 'Create a simple "Hello World" HTML file at system/sentinel/olympics/hello/index.html' - ); - - console.log('\n=== Final Result ==='); - console.log(`Success: ${result.success}`); - console.log(`Summary: ${result.summary}`); - console.log(`Files created: ${result.context.filesCreated.join(', ') || 'none'}`); - - return result; -} diff --git a/src/debug/jtag/system/sentinel/Sentinel.ts b/src/debug/jtag/system/sentinel/Sentinel.ts deleted file mode 100644 index 9028a129b..000000000 --- a/src/debug/jtag/system/sentinel/Sentinel.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Sentinel Base Architecture - * - * Sentinels are autonomous task executors. They come in two flavors: - * - * 1. SCRIPT SENTINELS (no AI required) - * - BuildSentinel: Parse errors, apply fixes, rebuild - * - VisualSentinel: Launch browser, screenshot - * - ServeSentinel: Start HTTP server - * - TestSentinel: Run test suite, report results - * - * 2. AI-POWERED SENTINELS (require LLM) - * - OrchestratorSentinel: Plan and execute goals - * - CodeSentinel: Write/modify code - * - ReviewSentinel: Code review and suggestions - * - DebugSentinel: Analyze errors, suggest fixes - * - * The key insight: Sentinels are PIPELINES, not just LLM wrappers. - * Even AI-powered sentinels are mostly deterministic - the LLM - * is just one step in a larger pipeline. - */ - -import { ModelConfig, ModelCapacity, ModelProvider, ModelInvoker, InferenceResult } from './ModelProvider'; - -/** - * Sentinel execution result - */ -export interface SentinelResult { - success: boolean; - data?: T; - error?: string; - duration?: number; - steps?: SentinelStep[]; -} - -/** - * A step in sentinel execution - */ -export interface SentinelStep { - name: string; - success: boolean; - duration: number; - output?: string; -} - -/** - * Base config for all sentinels - */ -export interface BaseSentinelConfig { - workingDir: string; - timeout?: number; // Max execution time (ms) - verbose?: boolean; // Log steps - onStep?: (step: SentinelStep) => void; -} - -/** - * Config for AI-powered sentinels - */ -export interface AISentinelConfig extends BaseSentinelConfig { - model?: ModelConfig; // Model selection -} - -/** - * Base class for script-only sentinels - */ -export abstract class ScriptSentinel { - protected config: TConfig; - protected steps: SentinelStep[] = []; - protected startTime: number = 0; - - constructor(config: TConfig) { - this.config = config; - } - - /** - * Execute the sentinel's task - */ - abstract execute(...args: unknown[]): Promise>; - - /** - * Record a step - */ - protected recordStep(name: string, success: boolean, output?: string): void { - const step: SentinelStep = { - name, - success, - duration: Date.now() - this.startTime, - output, - }; - this.steps.push(step); - if (this.config.verbose) { - console.log(`[${success ? '✓' : '✗'}] ${name}${output ? `: ${output.slice(0, 100)}` : ''}`); - } - this.config.onStep?.(step); - } - - /** - * Start timing - */ - protected start(): void { - this.startTime = Date.now(); - this.steps = []; - } - - /** - * Create result - */ - protected result(success: boolean, data?: T, error?: string): SentinelResult { - return { - success, - data, - error, - duration: Date.now() - this.startTime, - steps: this.steps, - }; - } -} - -/** - * Base class for AI-powered sentinels - */ -export abstract class AISentinel extends ScriptSentinel { - protected invoker: ModelInvoker; - - constructor(config: TConfig) { - super(config); - this.invoker = new ModelInvoker(config.workingDir); - } - - /** - * Call the AI model - */ - protected async think(prompt: string): Promise { - this.recordStep('thinking', true, `Prompt: ${prompt.slice(0, 50)}...`); - const result = await this.invoker.generate(prompt, this.config.model); - this.recordStep('thought', result.success, result.text?.slice(0, 100) || result.error); - return result; - } - - /** - * Convenience: think with specific capacity - */ - protected async thinkWith(prompt: string, capacity: ModelCapacity): Promise { - return this.invoker.generate(prompt, { ...this.config.model, capacity }); - } -} - -/** - * Sentinel registry - for discovery and spawning - */ -export class SentinelRegistry { - private static sentinels: Map ScriptSentinel> = new Map(); - - static register(name: string, sentinel: new (config: any) => ScriptSentinel): void { - this.sentinels.set(name, sentinel); - } - - static get(name: string): (new (config: any) => ScriptSentinel) | undefined { - return this.sentinels.get(name); - } - - static list(): string[] { - return Array.from(this.sentinels.keys()); - } - - static spawn>(name: string, config: any): T | undefined { - const SentinelClass = this.sentinels.get(name); - if (SentinelClass) { - return new SentinelClass(config) as T; - } - return undefined; - } -} - -// Export types -export type { ModelConfig, ModelCapacity, ModelProvider, InferenceResult }; -export { ModelCapacity as Capacity, ModelProvider as Provider } from './ModelProvider'; diff --git a/src/debug/jtag/system/sentinel/SentinelDefinition.ts b/src/debug/jtag/system/sentinel/SentinelDefinition.ts index c365f7113..ec297911b 100644 --- a/src/debug/jtag/system/sentinel/SentinelDefinition.ts +++ b/src/debug/jtag/system/sentinel/SentinelDefinition.ts @@ -19,7 +19,7 @@ export interface SentinelDefinitionBase { id?: string; /** Sentinel type discriminator */ - type: 'build' | 'orchestrate' | 'screenshot' | 'task' | 'script'; + type: 'build' | 'orchestrate' | 'screenshot' | 'task' | 'script' | 'pipeline'; /** Human-readable name */ name: string; @@ -185,15 +185,222 @@ export interface ScriptSentinelDefinition extends SentinelDefinitionBase { interpreter?: string; } +// ============================================================================ +// Step-Based Pipeline Definitions (Declarative Runtime) +// ============================================================================ + +/** + * Loop control for pipeline sentinels. + */ +export type LoopConfig = + | { type: 'once' } // Run pipeline once + | { type: 'count'; max: number } // Run N iterations + | { type: 'until'; check: string } // Run until condition is true + | { type: 'while'; check: string } // Run while condition is true + | { type: 'continuous'; intervalMs?: number } // Keep running + | { type: 'event'; event: string }; // Re-run on each event + +/** + * Trigger configuration for automatic sentinel start. + */ +export type SentinelTrigger = + | { type: 'immediate' } // Start now + | { type: 'event'; event: string; debounceMs?: number; allowConcurrent?: boolean } // Start on event + | { type: 'cron'; schedule: string; debounceMs?: number; allowConcurrent?: boolean } // Cron-like scheduling + | { type: 'manual' }; // Started by command + +/** + * Safety controls for sentinel execution. + */ +export interface SentinelSafety { + maxIterations?: number; // Hard limit on loop count + timeoutMs?: number; // Hard limit on total runtime + maxStepTimeoutMs?: number; // Per-step timeout + maxMemoryMb?: number; // Memory budget + onTimeout?: 'stop' | 'pause'; // What to do when limits hit +} + +/** + * Base step interface. + */ +export interface StepBase { + type: string; + outputTo?: string; // Variable name for result + onError?: 'fail' | 'skip' | 'retry'; +} + /** - * Union of all sentinel definitions + * Execute a command. Output stored in variables[outputTo]. + */ +export interface CommandStep extends StepBase { + type: 'command'; + command: string; // e.g., 'code/read', 'code/verify', 'data/list' + params: Record; // Supports $variable references +} + +/** + * Run LLM inference. Accumulated variables injected as context. + */ +export interface LLMStep extends StepBase { + type: 'llm'; + prompt?: string; // Template with $variable references + model?: string | StepModelConfig; // Model selection + temperature?: number; + tools?: string[]; // Tool subset for this step + parseToolCalls?: boolean; // Extract and execute tool calls +} + +/** + * Model config for LLM steps (inline version). + */ +export interface StepModelConfig { + capacity?: ModelCapacity; + provider?: ModelProvider; + model?: string; + maxTokens?: number; + temperature?: number; +} + +/** + * Block until classified output lines arrive. + */ +export interface WatchStep extends StepBase { + type: 'watch'; + executionId: string; // $variable reference to running process + rules?: SentinelRule[]; // Classification rules + until?: 'finished' | 'error' | 'match'; +} + +/** + * Classification rule for watch steps. + */ +export interface SentinelRule { + pattern: string; // Regex pattern + classification: string; // Category name + action?: 'Emit' | 'Log' | 'Ignore'; +} + +/** + * Conditional branching. + */ +export interface ConditionStep extends StepBase { + type: 'condition'; + check: string; // JS expression with $variable access + then: SentinelStep[]; // Steps if true + else?: SentinelStep[]; // Steps if false +} + +/** + * Spawn a nested sentinel (recursive composition). + */ +export interface SentinelSpawnStep extends StepBase { + type: 'sentinel'; + definition: PipelineSentinelDefinition; // Inline definition + await?: boolean; // Wait for completion or fire-and-forget +} + +/** + * Emit an event (for cross-sentinel composition). + */ +export interface EmitStep extends StepBase { + type: 'emit'; + event: string; // Event name + data?: string; // $variable reference for payload +} + +/** + * Execute multiple steps in parallel (concurrent execution). + */ +export interface ParallelStep extends StepBase { + type: 'parallel'; + steps: SentinelStep[]; // Steps to run concurrently + failFast?: boolean; // Stop all on first failure (default: false) +} + +/** + * Union of all step types. + */ +export type SentinelStep = + | CommandStep + | LLMStep + | WatchStep + | ConditionStep + | SentinelSpawnStep + | EmitStep + | ParallelStep; + +/** + * Pipeline-based sentinel definition (declarative, JSON-serializable). + * + * This is the target architecture for truly flexible AI-driven sentinels. + * AIs can create, modify, and share these as pure data. + */ +export interface PipelineSentinelDefinition { + /** Type discriminator for union compatibility */ + type: 'pipeline'; + + /** Unique identifier (generated on save) */ + id?: string; + + /** Human-readable name */ + name: string; + + /** Description of what this sentinel does */ + description?: string; + + /** Working directory (defaults to cwd) */ + workingDir?: string; + + /** Schema version */ + version: '1.0'; + + /** Timeout in milliseconds (for compatibility) */ + timeout?: number; + + /** RAG recipe ID for context building */ + recipe?: string; + + /** Explicit RAG sources (alternative to recipe) */ + ragSources?: string[]; + + /** The pipeline steps */ + steps: SentinelStep[]; + + /** Loop control */ + loop: LoopConfig; + + /** What triggers this sentinel */ + trigger?: SentinelTrigger; + + /** Safety controls */ + safety?: SentinelSafety; + + /** Available tools (highlights for LLM steps) */ + tools?: string[]; + + /** Tags for organization */ + tags?: string[]; + + /** Metadata */ + createdAt?: string; + updatedAt?: string; + createdBy?: string; +} + +// ============================================================================ +// Legacy Class-Based Definitions (for existing sentinels) +// ============================================================================ + +/** + * Union of all sentinel definitions (legacy + pipeline). */ export type SentinelDefinition = | BuildSentinelDefinition | OrchestrateSentinelDefinition | ScreenshotSentinelDefinition | TaskSentinelDefinition - | ScriptSentinelDefinition; + | ScriptSentinelDefinition + | PipelineSentinelDefinition; /** * Sentinel execution result (saved alongside definition) @@ -275,35 +482,46 @@ export function validateDefinition(def: SentinelDefinition): { valid: boolean; e switch (def.type) { case 'build': - if (!def.command) { + if (!(def as BuildSentinelDefinition).command) { errors.push('BuildSentinel requires command'); } break; case 'orchestrate': - if (!def.goal) { + if (!(def as OrchestrateSentinelDefinition).goal) { errors.push('OrchestrateSentinel requires goal'); } break; case 'screenshot': - if (!def.target) { + if (!(def as ScreenshotSentinelDefinition).target) { errors.push('ScreenshotSentinel requires target'); } break; case 'task': - if (!def.tasks || def.tasks.length === 0) { + const taskDef = def as TaskSentinelDefinition; + if (!taskDef.tasks || taskDef.tasks.length === 0) { errors.push('TaskSentinel requires at least one task'); } break; case 'script': - if (!def.script) { + if (!(def as ScriptSentinelDefinition).script) { errors.push('ScriptSentinel requires script'); } break; + case 'pipeline': + const pipelineDef = def as PipelineSentinelDefinition; + if (!pipelineDef.steps || pipelineDef.steps.length === 0) { + errors.push('PipelineSentinel requires at least one step'); + } + if (!pipelineDef.loop) { + errors.push('PipelineSentinel requires loop config'); + } + break; + default: errors.push(`Unknown sentinel type: ${(def as any).type}`); } @@ -378,6 +596,19 @@ export function createDefinitionFromParams(params: Record): Sentine interpreter: params.interpreter, } as ScriptSentinelDefinition; + case 'pipeline': + return { + ...base, + type: 'pipeline', + steps: params.steps || [], + loop: params.loop || { type: 'once' }, + trigger: params.trigger, + safety: params.safety, + recipe: params.recipe, + ragSources: params.ragSources, + tools: params.tools, + } as PipelineSentinelDefinition; + default: throw new Error(`Unknown sentinel type: ${params.type}`); } @@ -409,6 +640,10 @@ export class SentinelBuilder { return new SentinelBuilder().type('script').set('script', script); } + static pipeline(): SentinelBuilder { + return new SentinelBuilder().type('pipeline').set('steps', []).set('loop', { type: 'once' }); + } + type(t: SentinelDefinition['type']): this { this.def.type = t; return this; diff --git a/src/debug/jtag/system/sentinel/SentinelExecutionLog.ts b/src/debug/jtag/system/sentinel/SentinelExecutionLog.ts deleted file mode 100644 index adc2ab817..000000000 --- a/src/debug/jtag/system/sentinel/SentinelExecutionLog.ts +++ /dev/null @@ -1,670 +0,0 @@ -/** - * SentinelExecutionLog - Complete record of what a sentinel did - * - * When a sentinel completes (success, failure, timeout), we need to know: - * 1. WHAT it did - every action, every file change, every LLM decision - * 2. WHERE it did it - branch, worktree, file paths - * 3. HOW TO MODIFY - resume, revert, continue from where it stopped - * - * This is critical for: - * - Personas reviewing sentinel work - * - Users approving before merge - * - Debugging failed runs - * - Learning from mistakes - * - * STREAMING: Events are emitted as actions happen (like a stack trace). - * Callers can subscribe to get real-time updates, or "join" to get current state. - */ - -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import type { BuildError, BuildAttempt } from './BuildSentinel'; -import type { WorkspaceInfo } from './SentinelWorkspace'; - -/** - * Sentinel event types for streaming execution log - * - * Callers can subscribe to these to get real-time updates: - * - sentinel:{handle}:action - Each action as it happens - * - sentinel:{handle}:file-change - File modifications - * - sentinel:{handle}:status - Status changes (started, escalated, completed) - */ -export type SentinelEventType = 'action' | 'file-change' | 'status'; - -export interface SentinelEvent { - type: SentinelEventType; - handle: string; - timestamp: string; - payload: SentinelAction | FileChange | { status: ExecutionLog['status']; reason?: string }; -} - -/** - * Event emitter interface for streaming execution - * Can be Events.emit or a custom callback - */ -export type SentinelEventEmitter = (event: SentinelEvent) => Promise | void; - -/** - * A single action taken by the sentinel - */ -export interface SentinelAction { - /** Timestamp of the action */ - timestamp: string; - - /** Action type */ - type: 'build' | 'analyze' | 'fix' | 'llm_query' | 'file_edit' | 'file_create' | 'escalate'; - - /** What the sentinel was trying to do */ - intent: string; - - /** The actual operation performed */ - operation?: string; - - /** Result of the action */ - result: 'success' | 'failure' | 'skipped'; - - /** Details (file path, command, LLM response, etc.) */ - details?: Record; - - /** Duration in ms */ - durationMs?: number; - - /** - * EVIDENCE - The actual proof that this action succeeded/failed - * For builds: the raw output (stdout + stderr) - * For fixes: before/after diffs - * For LLM queries: the actual response - */ - evidence?: { - /** Raw output (truncated for large outputs) */ - output?: string; - /** Full output path if saved to file */ - outputFile?: string; - /** Before state (for edits) */ - before?: string; - /** After state (for edits) */ - after?: string; - /** Verification result (did the fix actually work?) */ - verified?: boolean; - /** Verification output (proof it worked) */ - verificationOutput?: string; - }; -} - -/** - * A file change made by the sentinel - */ -export interface FileChange { - /** File path relative to workspace */ - path: string; - - /** Type of change */ - type: 'created' | 'modified' | 'deleted'; - - /** The diff (for modified files) */ - diff?: string; - - /** Original content (for modified/deleted) */ - originalContent?: string; - - /** New content (for created/modified) */ - newContent?: string; - - /** Which action caused this change */ - actionIndex: number; -} - -/** - * Complete execution log for a sentinel run - */ -export interface ExecutionLog { - /** Unique handle for this execution */ - handle: string; - - /** Sentinel type */ - sentinelType: 'build' | 'orchestrate' | 'task' | 'screenshot'; - - /** What the sentinel was trying to accomplish */ - goal: string; - - /** Final status */ - status: 'success' | 'failure' | 'timeout' | 'escalated' | 'aborted'; - - /** Start time */ - startedAt: string; - - /** End time */ - completedAt: string; - - /** Total duration in ms */ - durationMs: number; - - /** Workspace info (where the work happened) */ - workspace: { - /** Working directory */ - workingDir: string; - - /** Git branch (if git-based isolation) */ - branch?: string; - - /** Original branch to return to */ - originalBranch?: string; - - /** Whether this is a worktree */ - isWorktree?: boolean; - - /** Worktree path */ - worktreePath?: string; - - /** Repo root */ - repoRoot?: string; - }; - - /** Chronological list of all actions taken */ - actions: SentinelAction[]; - - /** All file changes made */ - fileChanges: FileChange[]; - - /** Build attempts (for BuildSentinel) */ - buildAttempts?: BuildAttempt[]; - - /** Final errors (if failed) */ - finalErrors?: BuildError[]; - - /** Escalation reason (if escalated) */ - escalationReason?: string; - - /** Summary for quick review */ - summary: string; - - /** How to continue or modify this work */ - continuationInfo: { - /** Can this work be resumed? */ - canResume: boolean; - - /** Command to resume */ - resumeCommand?: string; - - /** Can changes be reverted? */ - canRevert: boolean; - - /** Command to revert */ - revertCommand?: string; - - /** Branch to merge (if success) */ - mergeBranch?: string; - - /** Command to merge */ - mergeCommand?: string; - - /** Command to create PR */ - prCommand?: string; - }; -} - -/** - * Builder for creating execution logs - * - * Streams events in real-time as actions happen (like a stack trace). - * Callers can: - * 1. Pass an event emitter to get real-time updates - * 2. Call getSnapshot() to get current state at any point - * 3. Call complete() to get the final log - */ -export class ExecutionLogBuilder { - private log: Partial; - private startTime: number; - private actions: SentinelAction[] = []; - private fileSnapshots: Map = new Map(); - private eventEmitter?: SentinelEventEmitter; - - constructor( - handle: string, - sentinelType: ExecutionLog['sentinelType'], - goal: string, - eventEmitter?: SentinelEventEmitter - ) { - this.startTime = Date.now(); - this.eventEmitter = eventEmitter; - this.log = { - handle, - sentinelType, - goal, - startedAt: new Date().toISOString(), - actions: [], - fileChanges: [], - workspace: { workingDir: '' }, - continuationInfo: { canResume: false, canRevert: false }, - }; - - // Emit started event - this.emitStatus('success', 'started'); - } - - /** - * Get the handle for this execution - */ - get handle(): string { - return this.log.handle!; - } - - /** - * Emit an event (if emitter is configured) - */ - private async emitEvent(event: SentinelEvent): Promise { - if (this.eventEmitter) { - try { - await this.eventEmitter(event); - } catch { - // Ignore emission errors - don't break the sentinel - } - } - } - - /** - * Emit a status event - */ - private emitStatus(status: ExecutionLog['status'], reason?: string): void { - this.emitEvent({ - type: 'status', - handle: this.log.handle!, - timestamp: new Date().toISOString(), - payload: { status, reason }, - }); - } - - /** - * Set workspace info - */ - setWorkspace(info: WorkspaceInfo | { workingDir: string }): this { - this.log.workspace = { - workingDir: info.workingDir, - branch: 'branch' in info ? info.branch : undefined, - originalBranch: 'originalBranch' in info ? info.originalBranch : undefined, - isWorktree: 'isWorktree' in info ? info.isWorktree : undefined, - worktreePath: 'worktreePath' in info ? info.worktreePath : undefined, - }; - - // Try to get repo root - try { - this.log.workspace!.repoRoot = execSync('git rev-parse --show-toplevel', { - cwd: info.workingDir, - encoding: 'utf-8', - }).trim(); - } catch { - // Not a git repo - } - - return this; - } - - /** - * Snapshot a file before modification - */ - snapshotFile(filePath: string): void { - try { - const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.log.workspace!.workingDir, filePath); - if (fs.existsSync(fullPath)) { - this.fileSnapshots.set(filePath, fs.readFileSync(fullPath, 'utf-8')); - } - } catch { - // Ignore errors - } - } - - /** - * Record an action (emits event immediately for streaming) - */ - recordAction(action: Omit): void { - const fullAction: SentinelAction = { - ...action, - timestamp: new Date().toISOString(), - }; - this.actions.push(fullAction); - - // Stream the action immediately - this.emitEvent({ - type: 'action', - handle: this.log.handle!, - timestamp: fullAction.timestamp, - payload: fullAction, - }); - } - - /** - * Record a file change (emits event immediately for streaming) - */ - recordFileChange(filePath: string, type: FileChange['type']): void { - const change: FileChange = { - path: filePath, - type, - actionIndex: this.actions.length - 1, - }; - - const originalContent = this.fileSnapshots.get(filePath); - if (originalContent) { - change.originalContent = originalContent; - } - - try { - const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.log.workspace!.workingDir, filePath); - if (type !== 'deleted' && fs.existsSync(fullPath)) { - change.newContent = fs.readFileSync(fullPath, 'utf-8'); - } - - // Generate diff - if (originalContent && change.newContent) { - change.diff = this.generateDiff(originalContent, change.newContent); - } - } catch { - // Ignore errors - } - - this.log.fileChanges!.push(change); - - // Stream the file change immediately - this.emitEvent({ - type: 'file-change', - handle: this.log.handle!, - timestamp: new Date().toISOString(), - payload: change, - }); - } - - /** - * Get a snapshot of the current execution state - * (Like reading current stack trace without waiting for completion) - */ - getSnapshot(): Partial & { inProgress: true } { - return { - ...this.log, - inProgress: true, - durationMs: Date.now() - this.startTime, - actions: [...this.actions], - fileChanges: [...this.log.fileChanges!], - summary: this.generateSummary(), - }; - } - - /** - * Complete the log (emits final status event) - */ - complete( - status: ExecutionLog['status'], - options?: { - buildAttempts?: BuildAttempt[]; - finalErrors?: BuildError[]; - escalationReason?: string; - } - ): ExecutionLog { - const completedAt = new Date().toISOString(); - const durationMs = Date.now() - this.startTime; - - this.log.status = status; - this.log.completedAt = completedAt; - this.log.durationMs = durationMs; - this.log.actions = this.actions; - - if (options?.buildAttempts) this.log.buildAttempts = options.buildAttempts; - if (options?.finalErrors) this.log.finalErrors = options.finalErrors; - if (options?.escalationReason) this.log.escalationReason = options.escalationReason; - - // Generate summary - this.log.summary = this.generateSummary(); - - // Generate continuation info - this.log.continuationInfo = this.generateContinuationInfo(status); - - // Emit completion event - this.emitStatus(status, options?.escalationReason); - - return this.log as ExecutionLog; - } - - private generateSummary(): string { - const { status, actions, fileChanges, buildAttempts, durationMs } = this.log; - - const parts: string[] = []; - - parts.push(`Status: ${status?.toUpperCase() || 'IN_PROGRESS'}`); - parts.push(`Duration: ${((durationMs ?? (Date.now() - this.startTime)) / 1000).toFixed(1)}s`); - parts.push(`Actions: ${this.actions.length}`); - parts.push(`File changes: ${fileChanges?.length || 0}`); - - if (buildAttempts) { - parts.push(`Build attempts: ${buildAttempts.length}`); - } - - return parts.join(' | '); - } - - private generateContinuationInfo(status: ExecutionLog['status']): ExecutionLog['continuationInfo'] { - const { workspace, handle } = this.log; - const info: ExecutionLog['continuationInfo'] = { - canResume: false, - canRevert: false, - }; - - if (workspace?.branch && workspace.branch !== workspace.originalBranch) { - // Work was done on a separate branch - info.canRevert = true; - info.revertCommand = `git checkout ${workspace.originalBranch} && git branch -D ${workspace.branch}`; - - if (status === 'success') { - info.mergeBranch = workspace.branch; - info.mergeCommand = `git checkout ${workspace.originalBranch} && git merge ${workspace.branch}`; - info.prCommand = `git push -u origin ${workspace.branch} && gh pr create --head ${workspace.branch}`; - } - - // Can resume if not success - if (status !== 'success') { - info.canResume = true; - info.resumeCommand = `./jtag sentinel/run --type=build --workingDir="${workspace.workingDir}" --useLLM=true`; - } - } - - return info; - } - - private generateDiff(original: string, modified: string): string { - const originalLines = original.split('\n'); - const modifiedLines = modified.split('\n'); - const diff: string[] = []; - - // Simple line-by-line diff - const maxLines = Math.max(originalLines.length, modifiedLines.length); - for (let i = 0; i < maxLines; i++) { - const orig = originalLines[i]; - const mod = modifiedLines[i]; - - if (orig === undefined) { - diff.push(`+${i + 1}: ${mod}`); - } else if (mod === undefined) { - diff.push(`-${i + 1}: ${orig}`); - } else if (orig !== mod) { - diff.push(`-${i + 1}: ${orig}`); - diff.push(`+${i + 1}: ${mod}`); - } - } - - return diff.join('\n'); - } -} - -/** - * Format execution log for display - */ -export function formatExecutionLog(log: ExecutionLog): string { - const lines: string[] = []; - - lines.push('='.repeat(62)); - lines.push(`SENTINEL EXECUTION LOG: ${log.handle}`); - lines.push('='.repeat(62)); - lines.push(`Type: ${log.sentinelType}`); - lines.push(`Goal: ${log.goal}`); - lines.push(`Status: ${log.status.toUpperCase()}`); - lines.push(`Duration: ${(log.durationMs / 1000).toFixed(1)}s`); - lines.push('-'.repeat(62)); - - // Workspace - lines.push('WORKSPACE'); - lines.push(` Dir: ${log.workspace.workingDir}`); - if (log.workspace.branch) { - lines.push(` Branch: ${log.workspace.branch}`); - } - - // Actions with evidence - lines.push('-'.repeat(62)); - lines.push(`ACTIONS (${log.actions.length})`); - for (const action of log.actions.slice(-5)) { - const status = action.result === 'success' ? '[OK]' : action.result === 'failure' ? '[FAIL]' : '[SKIP]'; - lines.push(` ${status} ${action.type}: ${action.intent}`); - - // Show evidence if present (THE PROOF) - if (action.evidence) { - if (action.evidence.verificationOutput) { - lines.push(` PROOF: ${action.evidence.verificationOutput}`); - } - if (action.evidence.output && action.evidence.output.length < 200) { - const indented = action.evidence.output.split('\n').map(l => ` | ${l}`).join('\n'); - lines.push(indented); - } else if (action.evidence.output) { - const firstLines = action.evidence.output.split('\n').slice(0, 3).map(l => ` | ${l}`).join('\n'); - lines.push(firstLines); - lines.push(` | ... (${action.evidence.output.split('\n').length - 3} more lines)`); - } - } - } - if (log.actions.length > 5) { - lines.push(` ... and ${log.actions.length - 5} more`); - } - - // File changes - if (log.fileChanges.length > 0) { - lines.push('-'.repeat(62)); - lines.push(`FILE CHANGES (${log.fileChanges.length})`); - for (const change of log.fileChanges) { - const icon = change.type === 'created' ? '+' : change.type === 'modified' ? '~' : '-'; - lines.push(` ${icon} ${change.path}`); - } - } - - // Continuation - lines.push('-'.repeat(62)); - lines.push('CONTINUATION OPTIONS'); - if (log.continuationInfo.canRevert) { - lines.push(` [REVERT] ${log.continuationInfo.revertCommand}`); - } - if (log.continuationInfo.canResume) { - lines.push(' [RESUME] Run sentinel again on the same branch'); - } - if (log.continuationInfo.mergeBranch) { - lines.push(` [MERGE] ${log.continuationInfo.mergeCommand}`); - } - if (log.continuationInfo.prCommand) { - lines.push(' [PR] Create pull request for review'); - } - - lines.push('='.repeat(62)); - - return lines.join('\n'); -} - -/** - * Save execution log to file - */ -export function saveExecutionLog(log: ExecutionLog, outputDir?: string): string { - const dir = outputDir || '/tmp/sentinel-logs'; - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - const filename = `sentinel-${log.handle}-${log.status}.json`; - const filepath = path.join(dir, filename); - - fs.writeFileSync(filepath, JSON.stringify(log, null, 2)); - return filepath; -} - -/** - * Create an event emitter that uses the Events system - * Emits events in format: sentinel:{handle}:{type} - * - * @example - * const emitter = createEventsEmitter(); - * const log = new ExecutionLogBuilder('my-sentinel', 'build', 'Fix errors', emitter); - * - * // Elsewhere, subscribe to real-time updates: - * Events.subscribe('sentinel:my-sentinel:action', (event) => { ... }); - * Events.subscribe('sentinel:my-sentinel:status', (event) => { ... }); - */ -export function createEventsEmitter(): SentinelEventEmitter { - return async (event: SentinelEvent) => { - // Dynamic import to avoid circular dependencies - const { Events } = await import('../core/shared/Events'); - const eventName = `sentinel:${event.handle}:${event.type}`; - await Events.emit(eventName, event); - }; -} - -/** - * Subscribe to all events from a specific sentinel handle - * - * @example - * const unsubscribe = subscribeSentinelEvents('my-sentinel', (event) => { - * console.log(`[${event.type}]`, event.payload); - * }); - */ -export async function subscribeSentinelEvents( - handle: string, - callback: (event: SentinelEvent) => void -): Promise<() => void> { - const { Events } = await import('../core/shared/Events'); - const unsubscribers: (() => void)[] = []; - - // Subscribe to all event types - for (const type of ['action', 'file-change', 'status'] as SentinelEventType[]) { - const unsub = await Events.subscribe(`sentinel:${handle}:${type}`, callback); - unsubscribers.push(unsub); - } - - return () => unsubscribers.forEach(unsub => unsub()); -} - -/** - * In-memory registry of active execution logs for "join" functionality - * Like how you'd join a Unix process to get its status - */ -const activeExecutions = new Map(); - -/** - * Register an execution log for external access (like Unix process table) - */ -export function registerExecution(log: ExecutionLogBuilder): void { - activeExecutions.set(log.handle, log); -} - -/** - * Unregister an execution (called on completion) - */ -export function unregisterExecution(handle: string): void { - activeExecutions.delete(handle); -} - -/** - * Get current snapshot of an active execution (like joining a process) - * Returns undefined if execution not found or already completed - */ -export function getExecutionSnapshot(handle: string): ReturnType | undefined { - const log = activeExecutions.get(handle); - return log?.getSnapshot(); -} - -/** - * List all active execution handles - */ -export function listActiveExecutions(): string[] { - return Array.from(activeExecutions.keys()); -} diff --git a/src/debug/jtag/system/sentinel/SentinelLogWriter.ts b/src/debug/jtag/system/sentinel/SentinelLogWriter.ts deleted file mode 100644 index 0339e32b7..000000000 --- a/src/debug/jtag/system/sentinel/SentinelLogWriter.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * SentinelLogWriter - Non-blocking log writer for sentinel execution - * - * Writes FULL output (not truncated) to per-sentinel log directories. - * Uses async file operations - NEVER blocks the main thread. - * - * Directory structure: - * .sentinel-workspaces/{handle}/logs/ - * ├── execution.log # High-level actions - * ├── build-1.log # Full output from build attempt 1 - * ├── build-2.log # Full output from build attempt 2 - * ├── llm-requests.log # LLM queries and responses - * └── stderr.log # All stderr output - * - * Streaming: Emits events as logs are written for real-time UI. - * sentinel:{handle}:log - Each log chunk - */ - -import * as fs from 'fs/promises'; -import * as path from 'path'; -import { Events } from '@system/core/shared/Events'; - -export interface LogWriterConfig { - /** Unique sentinel handle */ - handle: string; - - /** Base directory for sentinel workspaces */ - baseDir?: string; - - /** Whether to emit events for streaming */ - emitEvents?: boolean; -} - -export interface LogChunk { - stream: string; - chunk: string; - timestamp: string; - sourceType: 'stdout' | 'stderr' | 'info' | 'error'; -} - -/** - * Non-blocking log writer for sentinel execution. - * All writes are async - NEVER blocks caller. - */ -export class SentinelLogWriter { - private handle: string; - private logsDir: string; - private emitEvents: boolean; - private writeQueue: Map> = new Map(); - private buildCounter: number = 0; - - private constructor(config: LogWriterConfig) { - this.handle = config.handle; - const baseDir = config.baseDir ?? '.sentinel-workspaces'; - this.logsDir = path.join(baseDir, config.handle, 'logs'); - this.emitEvents = config.emitEvents ?? true; - } - - /** - * Create and initialize a log writer. - * Creates the logs directory if it doesn't exist. - */ - static async create(config: LogWriterConfig): Promise { - const writer = new SentinelLogWriter(config); - await fs.mkdir(writer.logsDir, { recursive: true }); - return writer; - } - - /** - * Get the logs directory path. - */ - get logDirectory(): string { - return this.logsDir; - } - - /** - * Write to the execution log (high-level actions). - * NON-BLOCKING - returns immediately. - */ - async writeExecution(message: string): Promise { - await this.appendToStream('execution', message, 'info'); - } - - /** - * Start a new build log and return its stream name. - * Each build attempt gets a separate log file. - */ - startBuildLog(): string { - this.buildCounter++; - return `build-${this.buildCounter}`; - } - - /** - * Write build output (stdout/stderr) to the current build log. - * NON-BLOCKING - returns immediately. - */ - async writeBuildOutput(stream: string, output: string, sourceType: 'stdout' | 'stderr' = 'stdout'): Promise { - await this.appendToStream(stream, output, sourceType); - } - - /** - * Write LLM request/response to the LLM log. - * NON-BLOCKING - returns immediately. - */ - async writeLlmRequest(prompt: string, response: string): Promise { - const timestamp = new Date().toISOString(); - const entry = `\n${'='.repeat(80)}\n[${timestamp}] LLM REQUEST\n${'='.repeat(80)}\n${prompt}\n\n${'='.repeat(80)}\n[${timestamp}] LLM RESPONSE\n${'='.repeat(80)}\n${response}\n`; - await this.appendToStream('llm-requests', entry, 'info'); - } - - /** - * Write error output to stderr log. - * NON-BLOCKING - returns immediately. - */ - async writeError(error: string): Promise { - await this.appendToStream('stderr', error, 'error'); - } - - /** - * Get the full path to a log file. - */ - getLogPath(stream: string): string { - return path.join(this.logsDir, `${stream}.log`); - } - - /** - * Read a log file's contents. - */ - async readLog(stream: string): Promise { - const logPath = this.getLogPath(stream); - try { - return await fs.readFile(logPath, 'utf-8'); - } catch { - return ''; - } - } - - /** - * List all log files for this sentinel. - */ - async listLogs(): Promise { - try { - const files = await fs.readdir(this.logsDir); - return files.filter(f => f.endsWith('.log')).map(f => f.replace('.log', '')); - } catch { - return []; - } - } - - /** - * Core append method - NON-BLOCKING via async queue. - * Ensures writes to the same file are serialized (no interleaving). - */ - private async appendToStream(stream: string, content: string, sourceType: 'stdout' | 'stderr' | 'info' | 'error'): Promise { - const logPath = this.getLogPath(stream); - const timestamp = new Date().toISOString(); - - // Emit event for real-time streaming (non-blocking) - if (this.emitEvents) { - const event: LogChunk = { - stream, - chunk: content, - timestamp, - sourceType, - }; - // Fire-and-forget - don't await event emission - Events.emit(`sentinel:${this.handle}:log`, event).catch(() => {}); - } - - // Queue writes to same file to prevent interleaving - const existingWrite = this.writeQueue.get(stream) ?? Promise.resolve(); - const newWrite = existingWrite.then(async () => { - try { - await fs.appendFile(logPath, content); - } catch (e) { - // Log write failure but don't crash - console.error(`[SentinelLogWriter] Failed to write to ${logPath}:`, e); - } - }); - - this.writeQueue.set(stream, newWrite); - - // Don't await - return immediately for non-blocking behavior - // The write will complete in the background - } - - /** - * Wait for all pending writes to complete. - * Call this before sentinel completion to ensure all logs are flushed. - */ - async flush(): Promise { - const pending = Array.from(this.writeQueue.values()); - await Promise.all(pending); - this.writeQueue.clear(); - } -} - -/** - * Factory function for creating log writers. - */ -export async function createSentinelLogWriter(handle: string): Promise { - return SentinelLogWriter.create({ handle }); -} diff --git a/src/debug/jtag/system/sentinel/SentinelWorkspace.ts b/src/debug/jtag/system/sentinel/SentinelWorkspace.ts deleted file mode 100644 index d622828f9..000000000 --- a/src/debug/jtag/system/sentinel/SentinelWorkspace.ts +++ /dev/null @@ -1,385 +0,0 @@ -/** - * SentinelWorkspace - Git-based isolation for sentinel execution - * - * Sentinels should NEVER modify the user's working directory directly. - * Instead, they work in isolated git branches/worktrees. - * - * ISOLATION MODES: - * - * 1. 'branch' (default) - Work on a temporary branch - * - Fast to create - * - Shares working directory (careful with uncommitted changes) - * - Good for: quick fixes, single sentinel - * - * 2. 'worktree' - Full filesystem isolation via git worktree - * - Complete isolation (separate checkout) - * - Can run builds without affecting main - * - Good for: parallel sentinels, long-running tasks - * - * 3. 'none' - Direct modification (DANGEROUS) - * - Only for testing or explicit user request - * - Sentinel edits user's actual files - * - * LIFECYCLE: - * - * workspace = await SentinelWorkspace.create(config) - * workspace.workingDir // Where sentinel should do work - * - * ... sentinel runs ... - * - * if (success) { - * await workspace.complete('merge') // or 'pr' or 'leave' - * } else { - * await workspace.abort() // Cleanup or leave for debugging - * } - */ - -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; - -export type IsolationMode = 'none' | 'branch' | 'worktree'; -export type CompletionAction = 'merge' | 'pr' | 'leave'; -export type AbortAction = 'delete' | 'leave'; - -export interface WorkspaceConfig { - /** Original directory the sentinel was called from */ - callerDir: string; - - /** Isolation mode */ - isolation?: IsolationMode; - - /** Base branch to work from (default: current HEAD) */ - baseBranch?: string; - - /** Custom branch name (default: sentinel/{handle}) */ - branchName?: string; - - /** Unique handle for this sentinel run */ - handle: string; - - /** What to do on success */ - onSuccess?: CompletionAction; - - /** What to do on failure */ - onFailure?: AbortAction; - - /** Description for commits/PRs */ - description?: string; -} - -export interface WorkspaceInfo { - /** Where the sentinel should work */ - workingDir: string; - - /** The branch being used */ - branch: string; - - /** Original branch to return to */ - originalBranch: string; - - /** Whether this is a worktree */ - isWorktree: boolean; - - /** Worktree path (if isWorktree) */ - worktreePath?: string; -} - -export class SentinelWorkspace { - private config: Required; - private info: WorkspaceInfo | null = null; - private completed = false; - - private constructor(config: WorkspaceConfig) { - this.config = { - isolation: 'branch', - baseBranch: '', // Will be detected - branchName: `sentinel/${config.handle}`, - onSuccess: 'leave', - onFailure: 'leave', - description: `Sentinel ${config.handle}`, - ...config, - }; - } - - /** - * Create and initialize a workspace - */ - static async create(config: WorkspaceConfig): Promise { - const workspace = new SentinelWorkspace(config); - await workspace.initialize(); - return workspace; - } - - /** - * The directory where the sentinel should do its work - */ - get workingDir(): string { - if (!this.info) throw new Error('Workspace not initialized'); - return this.info.workingDir; - } - - /** - * Get full workspace info - */ - get workspace(): WorkspaceInfo { - if (!this.info) throw new Error('Workspace not initialized'); - return this.info; - } - - /** - * Initialize the workspace - */ - private async initialize(): Promise { - const { callerDir, isolation, branchName } = this.config; - - // Check if callerDir is a git repo - const isGitRepo = this.isGitRepository(callerDir); - - if (isolation === 'none' || !isGitRepo) { - // No isolation - work directly in callerDir - this.info = { - workingDir: callerDir, - branch: isGitRepo ? this.getCurrentBranch(callerDir) : 'none', - originalBranch: isGitRepo ? this.getCurrentBranch(callerDir) : 'none', - isWorktree: false, - }; - return; - } - - // Get current branch - const originalBranch = this.getCurrentBranch(callerDir); - const baseBranch = this.config.baseBranch || originalBranch; - - if (isolation === 'branch') { - // Create and switch to a new branch - await this.createBranch(callerDir, branchName, baseBranch); - this.info = { - workingDir: callerDir, - branch: branchName, - originalBranch, - isWorktree: false, - }; - } else if (isolation === 'worktree') { - // Create a git worktree for full isolation - const worktreePath = await this.createWorktree(callerDir, branchName, baseBranch); - this.info = { - workingDir: worktreePath, - branch: branchName, - originalBranch, - isWorktree: true, - worktreePath, - }; - } - } - - /** - * Complete the workspace (on success) - */ - async complete(action?: CompletionAction): Promise<{ merged?: boolean; prUrl?: string; branch: string }> { - if (!this.info) throw new Error('Workspace not initialized'); - if (this.completed) throw new Error('Workspace already completed'); - this.completed = true; - - const finalAction = action || this.config.onSuccess; - const { workingDir, branch, originalBranch, isWorktree, worktreePath } = this.info; - - // Commit any uncommitted changes - if (this.hasUncommittedChanges(workingDir)) { - this.commitChanges(workingDir, `Sentinel work: ${this.config.description}`); - } - - let result: { merged?: boolean; prUrl?: string; branch: string } = { branch }; - - if (finalAction === 'merge') { - // Switch back and merge - if (isWorktree) { - // Merge from worktree branch into original - execSync(`git merge ${branch} --no-edit`, { cwd: this.config.callerDir }); - this.cleanupWorktree(worktreePath!, branch); - } else { - execSync(`git checkout ${originalBranch}`, { cwd: workingDir }); - execSync(`git merge ${branch} --no-edit`, { cwd: workingDir }); - execSync(`git branch -d ${branch}`, { cwd: workingDir }); - } - result.merged = true; - } else if (finalAction === 'pr') { - // Push and create PR (requires gh CLI) - execSync(`git push -u origin ${branch}`, { cwd: isWorktree ? worktreePath! : workingDir }); - try { - const prOutput = execSync( - `gh pr create --title "${this.config.description}" --body "Automated by Sentinel ${this.config.handle}" --head ${branch}`, - { cwd: isWorktree ? worktreePath! : workingDir, encoding: 'utf-8' } - ); - result.prUrl = prOutput.trim(); - } catch { - // gh CLI might not be available - console.warn('Could not create PR via gh CLI'); - } - - // Return to original branch if not worktree - if (!isWorktree) { - execSync(`git checkout ${originalBranch}`, { cwd: workingDir }); - } else { - this.cleanupWorktree(worktreePath!, branch); - } - } else { - // 'leave' - just switch back, leave branch for review - if (!isWorktree) { - execSync(`git checkout ${originalBranch}`, { cwd: workingDir }); - } else { - this.cleanupWorktree(worktreePath!, branch); - } - } - - return result; - } - - /** - * Abort the workspace (on failure) - */ - async abort(action?: AbortAction): Promise { - if (!this.info) throw new Error('Workspace not initialized'); - if (this.completed) throw new Error('Workspace already completed'); - this.completed = true; - - const finalAction = action || this.config.onFailure; - const { workingDir, branch, originalBranch, isWorktree, worktreePath } = this.info; - - if (finalAction === 'delete') { - // Discard all changes and delete branch - if (isWorktree) { - this.cleanupWorktree(worktreePath!, branch); - execSync(`git branch -D ${branch}`, { cwd: this.config.callerDir, stdio: 'ignore' }); - } else { - // Reset all uncommitted changes (including untracked files) - execSync('git reset --hard HEAD', { cwd: workingDir, stdio: 'ignore' }); - execSync('git clean -fd', { cwd: workingDir, stdio: 'ignore' }); // Remove untracked files - execSync(`git checkout ${originalBranch}`, { cwd: workingDir }); - execSync(`git branch -D ${branch}`, { cwd: workingDir, stdio: 'ignore' }); - } - } else { - // 'leave' - keep branch for debugging - if (!isWorktree) { - execSync(`git checkout ${originalBranch}`, { cwd: workingDir }); - } else { - // Leave worktree for inspection but note in console - console.log(`Sentinel worktree left for debugging: ${worktreePath}`); - } - } - } - - /** - * Get changes made in this workspace - */ - getChanges(): { files: string[]; diff: string } { - if (!this.info) throw new Error('Workspace not initialized'); - const { workingDir } = this.info; - - const files = execSync('git diff --name-only HEAD~1 2>/dev/null || git diff --name-only', { - cwd: workingDir, - encoding: 'utf-8', - }).trim().split('\n').filter(f => f); - - const diff = execSync('git diff HEAD~1 2>/dev/null || git diff', { - cwd: workingDir, - encoding: 'utf-8', - }); - - return { files, diff }; - } - - // --- Git helpers --- - - private isGitRepository(dir: string): boolean { - try { - execSync('git rev-parse --git-dir', { cwd: dir, stdio: 'ignore' }); - return true; - } catch { - return false; - } - } - - private getCurrentBranch(dir: string): string { - return execSync('git branch --show-current', { cwd: dir, encoding: 'utf-8' }).trim(); - } - - private createBranch(dir: string, branchName: string, baseBranch: string): void { - // Stash any uncommitted changes - const hasChanges = this.hasUncommittedChanges(dir); - if (hasChanges) { - execSync('git stash', { cwd: dir }); - } - - // Create and checkout new branch - try { - execSync(`git checkout -b ${branchName} ${baseBranch}`, { cwd: dir }); - } catch { - // Branch might already exist, try to checkout - execSync(`git checkout ${branchName}`, { cwd: dir }); - } - - // Restore stashed changes - if (hasChanges) { - try { - execSync('git stash pop', { cwd: dir }); - } catch { - // Stash might have conflicts, leave it - } - } - } - - private createWorktree(dir: string, branchName: string, baseBranch: string): string { - // Create worktree in a predictable location - const gitRoot = execSync('git rev-parse --show-toplevel', { cwd: dir, encoding: 'utf-8' }).trim(); - const worktreePath = path.join(gitRoot, '.sentinel-workspaces', this.config.handle); - - // Ensure parent directory exists - fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); - - // Create worktree with new branch - execSync(`git worktree add -b ${branchName} "${worktreePath}" ${baseBranch}`, { cwd: dir }); - - return worktreePath; - } - - private cleanupWorktree(worktreePath: string, branchName: string): void { - try { - execSync(`git worktree remove "${worktreePath}" --force`, { cwd: this.config.callerDir }); - } catch { - // Manual cleanup if git worktree remove fails - fs.rmSync(worktreePath, { recursive: true, force: true }); - execSync(`git worktree prune`, { cwd: this.config.callerDir }); - } - } - - private hasUncommittedChanges(dir: string): boolean { - const status = execSync('git status --porcelain', { cwd: dir, encoding: 'utf-8' }); - return status.trim().length > 0; - } - - private commitChanges(dir: string, message: string): void { - execSync('git add -A', { cwd: dir }); - execSync(`git commit -m "${message}"`, { cwd: dir }); - } -} - -/** - * Quick helper to run a sentinel with workspace isolation - */ -export async function withSentinelWorkspace( - config: Omit & { handle?: string }, - fn: (workingDir: string, workspace: SentinelWorkspace) => Promise -): Promise<{ result: T; workspace: WorkspaceInfo }> { - const handle = config.handle || `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const workspace = await SentinelWorkspace.create({ ...config, handle }); - - try { - const result = await fn(workspace.workingDir, workspace); - await workspace.complete(); - return { result, workspace: workspace.workspace }; - } catch (error) { - await workspace.abort(); - throw error; - } -} diff --git a/src/debug/jtag/system/sentinel/TaskSentinel.ts b/src/debug/jtag/system/sentinel/TaskSentinel.ts deleted file mode 100644 index d346ff3fa..000000000 --- a/src/debug/jtag/system/sentinel/TaskSentinel.ts +++ /dev/null @@ -1,595 +0,0 @@ -/** - * TaskSentinel - Recursive task execution with spawn limits - * - * Like recursion: - * - Base case: Task is atomic (can be done directly) - * - Recursive case: Task needs subtasks (spawn child Sentinels) - * - Limit: Max depth, max children, timeout - * - * Example: "Create a snake game" - * 1. TaskSentinel receives high-level goal - * 2. Breaks into subtasks: [create HTML, create CSS, create JS, verify] - * 3. Each subtask may spawn further (create JS → [game loop, snake class, input handler]) - * 4. Leaf tasks execute directly (write file, run build) - * 5. Results bubble up - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { execSync } from 'child_process'; -import { BuildSentinel } from './BuildSentinel'; - -export interface Task { - id: string; - description: string; - type: 'atomic' | 'composite'; - status: 'pending' | 'running' | 'completed' | 'failed'; - children?: Task[]; - result?: TaskResult; - depth: number; -} - -export interface TaskResult { - success: boolean; - output?: string; - error?: string; - filesCreated?: string[]; - filesModified?: string[]; - duration: number; -} - -export interface TaskSentinelConfig { - maxDepth: number; // Recursion limit - maxChildren: number; // Max subtasks per task - maxTotalTasks: number; // Total task limit - timeoutMs: number; // Overall timeout - workingDir: string; - onProgress?: (task: Task, message: string) => void; -} - -export interface TaskPlan { - goal: string; - tasks: TaskNode[]; -} - -export interface TaskNode { - description: string; - type: 'write' | 'build' | 'run' | 'verify' | 'composite'; - file?: string; - content?: string; - command?: string; - children?: TaskNode[]; -} - -export class TaskSentinel { - private config: Required; - private taskCount = 0; - private startTime = 0; - private rootTask: Task | null = null; - - constructor(config: Partial & { workingDir: string }) { - this.config = { - maxDepth: 5, - maxChildren: 10, - maxTotalTasks: 50, - timeoutMs: 300000, // 5 minutes - onProgress: () => {}, - ...config, - }; - } - - /** - * Execute a task plan recursively - */ - async execute(plan: TaskPlan): Promise { - this.startTime = Date.now(); - this.taskCount = 0; - - this.report(null, `Starting: ${plan.goal}`); - - // Create root task - this.rootTask = { - id: 'root', - description: plan.goal, - type: 'composite', - status: 'running', - depth: 0, - children: [], - }; - - try { - // Execute all top-level tasks - const results: TaskResult[] = []; - for (const taskNode of plan.tasks) { - const result = await this.executeNode(taskNode, this.rootTask, 1); - results.push(result); - if (!result.success) { - // Stop on first failure (could make configurable) - this.rootTask.status = 'failed'; - return { - success: false, - error: `Task failed: ${taskNode.description}`, - duration: Date.now() - this.startTime, - }; - } - } - - this.rootTask.status = 'completed'; - return { - success: true, - output: `Completed ${this.taskCount} tasks`, - filesCreated: results.flatMap(r => r.filesCreated || []), - filesModified: results.flatMap(r => r.filesModified || []), - duration: Date.now() - this.startTime, - }; - } catch (error: any) { - this.rootTask.status = 'failed'; - return { - success: false, - error: error.message, - duration: Date.now() - this.startTime, - }; - } - } - - /** - * Execute a single task node (may recurse for composite tasks) - */ - private async executeNode(node: TaskNode, parent: Task, depth: number): Promise { - // Check limits (BASE CASES for recursion) - if (depth > this.config.maxDepth) { - return { success: false, error: `Max depth (${this.config.maxDepth}) exceeded`, duration: 0 }; - } - if (this.taskCount >= this.config.maxTotalTasks) { - return { success: false, error: `Max tasks (${this.config.maxTotalTasks}) exceeded`, duration: 0 }; - } - if (Date.now() - this.startTime > this.config.timeoutMs) { - return { success: false, error: `Timeout (${this.config.timeoutMs}ms) exceeded`, duration: 0 }; - } - - this.taskCount++; - const taskId = `task-${this.taskCount}`; - const task: Task = { - id: taskId, - description: node.description, - type: node.type === 'composite' ? 'composite' : 'atomic', - status: 'running', - depth, - children: [], - }; - parent.children?.push(task); - - this.report(task, `[depth=${depth}] ${node.description}`); - - const startTime = Date.now(); - - try { - let result: TaskResult; - - switch (node.type) { - case 'write': - result = await this.executeWrite(node); - break; - case 'build': - result = await this.executeBuild(node); - break; - case 'run': - result = await this.executeRun(node); - break; - case 'verify': - result = await this.executeVerify(node); - break; - case 'composite': - // RECURSIVE CASE: Execute children - if (!node.children || node.children.length === 0) { - result = { success: true, output: 'No subtasks', duration: 0 }; - } else if (node.children.length > this.config.maxChildren) { - result = { success: false, error: `Too many children (${node.children.length} > ${this.config.maxChildren})`, duration: 0 }; - } else { - const childResults: TaskResult[] = []; - for (const child of node.children) { - const childResult = await this.executeNode(child, task, depth + 1); - childResults.push(childResult); - if (!childResult.success) { - result = { success: false, error: `Child failed: ${child.description}`, duration: Date.now() - startTime }; - break; - } - } - if (!result!) { - result = { - success: true, - output: `Completed ${node.children.length} subtasks`, - filesCreated: childResults.flatMap(r => r.filesCreated || []), - filesModified: childResults.flatMap(r => r.filesModified || []), - duration: Date.now() - startTime, - }; - } - } - break; - default: - result = { success: false, error: `Unknown task type: ${node.type}`, duration: 0 }; - } - - task.status = result.success ? 'completed' : 'failed'; - task.result = result; - return result; - } catch (error: any) { - task.status = 'failed'; - const result = { success: false, error: error.message, duration: Date.now() - startTime }; - task.result = result; - return result; - } - } - - /** - * Write a file - */ - private async executeWrite(node: TaskNode): Promise { - if (!node.file || !node.content) { - return { success: false, error: 'Write task needs file and content', duration: 0 }; - } - - const fullPath = path.resolve(this.config.workingDir, node.file); - const dir = path.dirname(fullPath); - - // Create directory if needed - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - const existed = fs.existsSync(fullPath); - fs.writeFileSync(fullPath, node.content); - - return { - success: true, - output: `Wrote ${node.file}`, - filesCreated: existed ? [] : [node.file], - filesModified: existed ? [node.file] : [], - duration: 0, - }; - } - - /** - * Run a build (delegates to BuildSentinel) - */ - private async executeBuild(node: TaskNode): Promise { - const command = node.command || 'npm run build:ts'; - const sentinel = new BuildSentinel({ - command, - workingDir: this.config.workingDir, - maxAttempts: 3, - canAutoFix: true, - }); - - const result = await sentinel.run(); - - return { - success: result.success, - output: result.success ? 'Build succeeded' : `Build failed: ${result.escalationReason}`, - error: result.success ? undefined : JSON.stringify(result.finalErrors), - duration: 0, - }; - } - - /** - * Run a command - */ - private async executeRun(node: TaskNode): Promise { - if (!node.command) { - return { success: false, error: 'Run task needs command', duration: 0 }; - } - - try { - const output = execSync(node.command, { - cwd: this.config.workingDir, - encoding: 'utf-8', - timeout: 30000, - }); - return { success: true, output, duration: 0 }; - } catch (error: any) { - return { - success: false, - error: error.message, - output: error.stdout?.toString() || error.stderr?.toString(), - duration: 0, - }; - } - } - - /** - * Verify something exists or works - */ - private async executeVerify(node: TaskNode): Promise { - if (node.file) { - const fullPath = path.resolve(this.config.workingDir, node.file); - if (fs.existsSync(fullPath)) { - return { success: true, output: `File exists: ${node.file}`, duration: 0 }; - } else { - return { success: false, error: `File not found: ${node.file}`, duration: 0 }; - } - } - if (node.command) { - return this.executeRun(node); - } - return { success: true, output: 'Nothing to verify', duration: 0 }; - } - - private report(task: Task | null, message: string) { - this.config.onProgress(task!, message); - } -} - -/** - * Helper to create a snake game plan - */ -export function createSnakeGamePlan(outputDir: string): TaskPlan { - return { - goal: 'Create a playable Snake game', - tasks: [ - { - description: 'Create game files', - type: 'composite', - children: [ - { - description: 'Create HTML file', - type: 'write', - file: `${outputDir}/index.html`, - content: ` - - - - - Snake Game - - - -
-

Snake Game

- -
Score: 0
- -
- - -`, - }, - { - description: 'Create CSS file', - type: 'write', - file: `${outputDir}/style.css`, - content: `* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - background: #1a1a2e; - font-family: 'Segoe UI', sans-serif; -} - -.game-container { - text-align: center; -} - -h1 { - color: #4ecca3; - margin-bottom: 20px; -} - -#gameCanvas { - border: 2px solid #4ecca3; - background: #16213e; -} - -.score { - color: #fff; - font-size: 24px; - margin: 20px 0; -} - -#startBtn { - padding: 10px 30px; - font-size: 18px; - background: #4ecca3; - border: none; - border-radius: 5px; - cursor: pointer; - transition: background 0.3s; -} - -#startBtn:hover { - background: #3db892; -}`, - }, - { - description: 'Create JavaScript game logic', - type: 'write', - file: `${outputDir}/game.js`, - content: `// Snake Game - Created by TaskSentinel -const canvas = document.getElementById('gameCanvas'); -const ctx = canvas.getContext('2d'); -const scoreEl = document.getElementById('score'); -const startBtn = document.getElementById('startBtn'); - -const gridSize = 20; -const tileCount = canvas.width / gridSize; - -let snake = []; -let food = { x: 0, y: 0 }; -let dx = 0; -let dy = 0; -let score = 0; -let gameLoop = null; - -function initGame() { - snake = [ - { x: 10, y: 10 }, - { x: 9, y: 10 }, - { x: 8, y: 10 } - ]; - dx = 1; - dy = 0; - score = 0; - scoreEl.textContent = score; - placeFood(); -} - -function placeFood() { - food.x = Math.floor(Math.random() * tileCount); - food.y = Math.floor(Math.random() * tileCount); - // Don't place on snake - for (const segment of snake) { - if (segment.x === food.x && segment.y === food.y) { - placeFood(); - return; - } - } -} - -function draw() { - // Clear canvas - ctx.fillStyle = '#16213e'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - // Draw snake - ctx.fillStyle = '#4ecca3'; - for (const segment of snake) { - ctx.fillRect(segment.x * gridSize, segment.y * gridSize, gridSize - 2, gridSize - 2); - } - - // Draw food - ctx.fillStyle = '#e94560'; - ctx.fillRect(food.x * gridSize, food.y * gridSize, gridSize - 2, gridSize - 2); -} - -function update() { - const head = { x: snake[0].x + dx, y: snake[0].y + dy }; - - // Wall collision - if (head.x < 0 || head.x >= tileCount || head.y < 0 || head.y >= tileCount) { - gameOver(); - return; - } - - // Self collision - for (const segment of snake) { - if (head.x === segment.x && head.y === segment.y) { - gameOver(); - return; - } - } - - snake.unshift(head); - - // Food collision - if (head.x === food.x && head.y === food.y) { - score += 10; - scoreEl.textContent = score; - placeFood(); - } else { - snake.pop(); - } - - draw(); -} - -function gameOver() { - clearInterval(gameLoop); - gameLoop = null; - ctx.fillStyle = 'rgba(0, 0, 0, 0.75)'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = '#fff'; - ctx.font = '30px Segoe UI'; - ctx.textAlign = 'center'; - ctx.fillText('Game Over!', canvas.width / 2, canvas.height / 2); - ctx.font = '20px Segoe UI'; - ctx.fillText('Score: ' + score, canvas.width / 2, canvas.height / 2 + 40); - startBtn.textContent = 'Play Again'; -} - -function handleKeydown(e) { - switch (e.key) { - case 'ArrowUp': - if (dy !== 1) { dx = 0; dy = -1; } - break; - case 'ArrowDown': - if (dy !== -1) { dx = 0; dy = 1; } - break; - case 'ArrowLeft': - if (dx !== 1) { dx = -1; dy = 0; } - break; - case 'ArrowRight': - if (dx !== -1) { dx = 1; dy = 0; } - break; - } -} - -function startGame() { - if (gameLoop) return; - initGame(); - draw(); - gameLoop = setInterval(update, 100); - startBtn.textContent = 'Playing...'; -} - -document.addEventListener('keydown', handleKeydown); -startBtn.addEventListener('click', startGame); - -// Initial draw -initGame(); -draw(); -console.log('Snake game loaded! Click Start to play.');`, - }, - ], - }, - { - description: 'Verify all files created', - type: 'composite', - children: [ - { description: 'Verify HTML', type: 'verify', file: `${outputDir}/index.html` }, - { description: 'Verify CSS', type: 'verify', file: `${outputDir}/style.css` }, - { description: 'Verify JS', type: 'verify', file: `${outputDir}/game.js` }, - ], - }, - ], - }; -} - -// CLI test -export async function testTaskSentinel() { - const outputDir = 'system/sentinel/olympics/snake-game'; - - const sentinel = new TaskSentinel({ - workingDir: '/Volumes/FlashGordon/cambrian/continuum/src/debug/jtag', - maxDepth: 5, - maxChildren: 10, - maxTotalTasks: 50, - timeoutMs: 60000, - onProgress: (task, message) => { - const indent = task ? ' '.repeat(task.depth) : ''; - console.log(`${indent}${message}`); - }, - }); - - const plan = createSnakeGamePlan(outputDir); - - console.log('\n=== TaskSentinel: Create Snake Game ===\n'); - const result = await sentinel.execute(plan); - - console.log('\n=== Result ==='); - console.log(`Success: ${result.success}`); - console.log(`Duration: ${result.duration}ms`); - if (result.filesCreated?.length) { - console.log(`Files created: ${result.filesCreated.join(', ')}`); - } - if (result.error) { - console.log(`Error: ${result.error}`); - } - - return result; -} diff --git a/src/debug/jtag/system/sentinel/ToolResultMemoryCapture.ts b/src/debug/jtag/system/sentinel/ToolResultMemoryCapture.ts deleted file mode 100644 index fe5cdfc5e..000000000 --- a/src/debug/jtag/system/sentinel/ToolResultMemoryCapture.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * ToolResultMemoryCapture - Captures ALL tool execution results into persona memory - * - * Subscribes to the unified tool:result event and stores outcomes in the - * initiating persona's long-term memory. This enables AIs to learn from their actions. - * - * Works with ANY tool that uses ToolResult.emit() - not just sentinels. - */ - -import { Events } from '../core/shared/Events'; -import { TOOL_EVENTS, type ToolResultEvent } from '../core/shared/ToolResult'; -import { LongTermMemoryStore, type LongTermMemoryEntry } from '../user/server/modules/cognition/memory/LongTermMemoryStore'; -import { v4 as uuid } from 'uuid'; - -// Cache of memory stores by personaId -const memoryStores = new Map(); - -function getMemoryStore(personaId: string): LongTermMemoryStore { - let store = memoryStores.get(personaId); - if (!store) { - store = new LongTermMemoryStore(personaId, (msg) => console.log(`[ToolMemory] ${msg}`)); - memoryStores.set(personaId, store); - } - return store; -} - -/** - * Create a memory entry from any tool result - */ -function createToolMemory(event: ToolResultEvent): LongTermMemoryEntry { - const now = Date.now(); - return { - id: uuid(), - personaId: event.userId!, - domain: 'tool-use', - contextId: null, - thoughtType: 'tool-result', - thoughtContent: `${event.tool} (${event.handle}): ${event.success ? 'SUCCESS' : 'FAILED'} - ${event.summary}`, - importance: event.success ? 0.5 : 0.8, // Failures are more important to remember - embedding: [], // TODO: Generate embedding for similarity search - metadata: { - tool: event.tool, - handle: event.handle, - success: event.success, - error: event.error, - durationMs: event.durationMs, - ...event.data, - }, - createdAt: now, - consolidatedAt: now, - }; -} - -let initialized = false; - -/** - * Initialize event subscriptions for tool result capture - * Call this once at system startup - */ -export function initToolResultMemoryCapture(): void { - if (initialized) { - console.log('[ToolResultMemoryCapture] Already initialized, skipping'); - return; - } - - console.log('[ToolResultMemoryCapture] Initializing unified tool result capture...'); - - // Subscribe to unified tool:result event - captures ALL tools - Events.subscribe(TOOL_EVENTS.RESULT, async (event: ToolResultEvent) => { - if (!event.userId) { - // No userId = can't store in persona memory - return; - } - - try { - const memory = createToolMemory(event); - const store = getMemoryStore(event.userId); - await store.appendBatch([memory]); - - console.log( - `[ToolMemory] ${event.tool} → ${event.success ? '✓' : '✗'} → persona ${event.userId.slice(0, 8)}` - ); - } catch (error) { - console.error(`[ToolResultMemoryCapture] Failed to store memory:`, error); - } - }); - - // Also capture legacy sentinel events for backwards compatibility - Events.subscribe('sentinel:complete', async (event: any) => { - if (!event.userId) return; - - // Only capture if not already handled by tool:result - // (ToolResult.emit sends both) - const memory = createToolMemory({ - tool: `sentinel/${event.type}`, - handle: event.handle, - userId: event.userId, - success: event.success, - summary: event.data?.summary || 'Completed', - data: event.data, - }); - - try { - const store = getMemoryStore(event.userId); - await store.appendBatch([memory]); - } catch (error) { - // Silently ignore duplicates - } - }); - - Events.subscribe('sentinel:error', async (event: any) => { - if (!event.userId) return; - - const memory = createToolMemory({ - tool: `sentinel/${event.type}`, - handle: event.handle, - userId: event.userId, - success: false, - summary: `Error: ${event.error}`, - error: event.error, - }); - - try { - const store = getMemoryStore(event.userId); - await store.appendBatch([memory]); - } catch (error) { - // Silently ignore - } - }); - - initialized = true; - console.log('[ToolResultMemoryCapture] Ready - capturing all tool results'); -} - -/** - * Query tool memories for a persona - */ -export async function queryToolMemories( - personaId: string, - options: { limit?: number; tool?: string; successOnly?: boolean } = {} -): Promise { - const store = getMemoryStore(personaId); - - // Use getByDomain to get all tool-use memories - let filtered = await store.getByDomain('tool-use', options.limit ? options.limit * 2 : 100); - - if (options.tool) { - filtered = filtered.filter(m => (m.metadata as any)?.tool === options.tool); - } - - if (options.successOnly) { - filtered = filtered.filter(m => (m.metadata as any)?.success === true); - } - - if (options.limit) { - filtered = filtered.slice(0, options.limit); - } - - return filtered; -} - -/** - * Get memory stats for a persona - */ -export async function getToolMemoryStats(personaId: string): Promise<{ - total: number; - byTool: Record; - successRate: number; -}> { - const store = getMemoryStore(personaId); - const memories = await store.getByDomain('tool-use'); - - const byTool: Record = {}; - let successCount = 0; - - for (const m of memories) { - const tool = (m.metadata as any)?.tool || 'unknown'; - byTool[tool] = (byTool[tool] || 0) + 1; - if ((m.metadata as any)?.success) successCount++; - } - - return { - total: memories.length, - byTool, - successRate: memories.length > 0 ? successCount / memories.length : 0, - }; -} diff --git a/src/debug/jtag/system/sentinel/VisualSentinel.ts b/src/debug/jtag/system/sentinel/VisualSentinel.ts deleted file mode 100644 index 08629b897..000000000 --- a/src/debug/jtag/system/sentinel/VisualSentinel.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * VisualSentinel - Takes screenshots of generated content for visual feedback - * - * Uses Puppeteer to: - * 1. Launch headless Chrome - * 2. Navigate to local file or URL - * 3. Take screenshot - * 4. Return image path - */ - -import * as path from 'path'; -import * as fs from 'fs'; -import { launchAndNavigate, checkPuppeteer } from '../../commands/interface/page/shared/PuppeteerHelper'; -import type { ScreenshotSentinelDefinition } from './SentinelDefinition'; - -export interface VisualSentinelConfig { - outputDir: string; - viewport?: { width: number; height: number }; -} - -export interface ScreenshotResult { - success: boolean; - imagePath?: string; - error?: string; -} - -export class VisualSentinel { - private config: Required; - private _target?: string; - private _filename?: string; - - constructor(config: VisualSentinelConfig) { - this.config = { - viewport: { width: 800, height: 600 }, - ...config, - }; - } - - /** - * Create a VisualSentinel from a portable definition - */ - static fromDefinition(def: ScreenshotSentinelDefinition): VisualSentinel { - const sentinel = new VisualSentinel({ - outputDir: def.outputDir || '/tmp/sentinel-screenshots', - viewport: def.viewport, - }); - sentinel._target = def.target; - sentinel._filename = def.filename; - return sentinel; - } - - /** - * Export to portable JSON definition - */ - toDefinition(name?: string, target?: string, filename?: string): ScreenshotSentinelDefinition { - return { - type: 'screenshot', - name: name || `screenshot-${Date.now()}`, - version: '1.0', - target: target || this._target || '', - filename: filename || this._filename, - outputDir: this.config.outputDir, - viewport: this.config.viewport, - createdAt: new Date().toISOString(), - }; - } - - /** - * Run from definition - */ - async runFromDefinition(): Promise { - if (!this._target) { - return { success: false, error: 'No target specified in definition' }; - } - if (this._target.startsWith('http://') || this._target.startsWith('https://')) { - return this.screenshotUrl(this._target, this._filename); - } else { - return this.screenshotFile(this._target, this._filename); - } - } - - /** - * Take a screenshot of a local HTML file - */ - async screenshotFile(htmlPath: string, filename: string = 'screenshot.png'): Promise { - // Check Puppeteer availability - const check = await checkPuppeteer(); - if (!check.available) { - return { success: false, error: check.reason }; - } - - // Resolve to absolute path - const absolutePath = path.resolve(htmlPath); - if (!fs.existsSync(absolutePath)) { - return { success: false, error: `File not found: ${absolutePath}` }; - } - - const fileUrl = `file://${absolutePath}`; - return this.screenshotUrl(fileUrl, filename); - } - - /** - * Take a screenshot of a URL - */ - async screenshotUrl(url: string, filename: string = 'screenshot.png'): Promise { - // Check Puppeteer availability - const check = await checkPuppeteer(); - if (!check.available) { - return { success: false, error: check.reason }; - } - - try { - const context = await launchAndNavigate(url); - - // Set viewport - await context.page.setViewport(this.config.viewport); - - // Ensure output directory exists - if (!fs.existsSync(this.config.outputDir)) { - fs.mkdirSync(this.config.outputDir, { recursive: true }); - } - - // Take screenshot - const outputPath = path.join(this.config.outputDir, filename); - await context.page.screenshot({ path: outputPath, fullPage: true }); - - // Close browser - await context.browser.close(); - - return { success: true, imagePath: outputPath }; - } catch (error: any) { - return { success: false, error: error.message }; - } - } - - /** - * Serve a directory and take a screenshot (for multi-file apps) - */ - async screenshotWithServer( - directory: string, - filename: string = 'screenshot.png', - port: number = 9876 - ): Promise { - const http = await import('http'); - const fsPromises = await import('fs/promises'); - - // Simple static file server - const server = http.createServer(async (req, res) => { - const reqPath = req.url === '/' ? '/index.html' : req.url!; - const filePath = path.join(directory, reqPath); - - try { - const content = await fsPromises.readFile(filePath); - const ext = path.extname(filePath); - const mimeTypes: Record = { - '.html': 'text/html', - '.css': 'text/css', - '.js': 'application/javascript', - '.json': 'application/json', - '.png': 'image/png', - '.jpg': 'image/jpeg', - }; - res.setHeader('Content-Type', mimeTypes[ext] || 'text/plain'); - res.end(content); - } catch { - res.statusCode = 404; - res.end('Not found'); - } - }); - - return new Promise((resolve) => { - server.listen(port, async () => { - const result = await this.screenshotUrl(`http://localhost:${port}`, filename); - server.close(); - resolve(result); - }); - }); - } -} - -// Test function -export async function testVisualSentinel() { - const sentinel = new VisualSentinel({ - outputDir: '/tmp/sentinel-screenshots', - }); - - console.log('Testing VisualSentinel...'); - - // Test with a file URL - const result = await sentinel.screenshotFile( - '/Volumes/FlashGordon/cambrian/continuum/src/debug/jtag/system/sentinel/olympics/snake/index.html', - 'snake-test.png' - ); - - console.log('Result:', result); - return result; -} diff --git a/src/debug/jtag/system/sentinel/challenge.ts b/src/debug/jtag/system/sentinel/challenge.ts deleted file mode 100644 index eb85e13a4..000000000 --- a/src/debug/jtag/system/sentinel/challenge.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * AI Challenge: Fix this file! - * - * There are 3 bugs in this code: - * 1. A missing import - * 2. A type error - * 3. A logic error - * - * Use code/read to examine, code/write to fix, then BuildSentinel to verify. - */ - -// BUG 1: Missing path import (fs is imported but path is not) -import * as fs from 'fs'; -import * as path from 'path'; - -interface FileInfo { - name: string; - size: number; - isDirectory: boolean; -} - -export function listFiles(directory: string): FileInfo[] { - const entries = fs.readdirSync(directory, { withFileTypes: true }); - - return entries.map((entry) => { - // BUG 2: Type error - fullPath is declared but wrong type - const fullPath: number = path.join(directory, entry.name); - - const stats = fs.statSync(fullPath); - - return { - name: entry.name, - size: stats.size, - // BUG 3: Logic error - should check entry.isDirectory() not stats.isFile() - isDirectory: stats.isFile(), - }; - }); -} - -export function getFileContent(filePath: string): string { - return fs.readFileSync(filePath, 'utf-8'); -} diff --git a/src/debug/jtag/system/sentinel/cli.ts b/src/debug/jtag/system/sentinel/cli.ts deleted file mode 100644 index 3ad09faf1..000000000 --- a/src/debug/jtag/system/sentinel/cli.ts +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * Sentinel CLI - Run sentinels from command line - * - * Usage: - * npx tsx system/sentinel/cli.ts build [--command="npm run build:ts"] [--max-attempts=3] - * npx tsx system/sentinel/cli.ts build --file=path/to/file.ts # Compile single file - * - * Output: JSON result for easy parsing by AI - */ - -import { BuildSentinel, type SentinelProgress } from './BuildSentinel'; - -const args = process.argv.slice(2); -const command = args[0]; - -function parseArgs(args: string[]): Record { - const result: Record = {}; - for (const arg of args) { - if (arg.startsWith('--')) { - const [key, ...valueParts] = arg.slice(2).split('='); - result[key] = valueParts.join('=') || 'true'; - } - } - return result; -} - -async function runBuild(options: Record) { - const workingDir = options['working-dir'] || process.cwd(); - const maxAttempts = parseInt(options['max-attempts'] || '3', 10); - - // Determine build command - let buildCommand: string; - if (options['file']) { - buildCommand = `npx tsc --noEmit --skipLibCheck ${options['file']}`; - } else if (options['command']) { - buildCommand = options['command']; - } else { - buildCommand = 'npm run build:ts'; - } - - const progressLog: SentinelProgress[] = []; - - const sentinel = new BuildSentinel({ - command: buildCommand, - workingDir, - maxAttempts, - canAutoFix: options['auto-fix'] !== 'false', - onProgress: (p) => { - progressLog.push(p); - // Also print human-readable progress to stderr - process.stderr.write(`[${p.phase.toUpperCase()}] (${p.attempt}/${p.maxAttempts}) ${p.message}\n`); - }, - }); - - const result = await sentinel.run(); - - // Output JSON result to stdout for AI parsing - console.log(JSON.stringify({ - success: result.success, - attempts: result.attempts.length, - escalated: result.escalated || false, - escalationReason: result.escalationReason, - errors: result.finalErrors || [], - fixes: result.attempts - .filter(a => a.fixApplied) - .map(a => a.fixApplied), - progress: progressLog, - }, null, 2)); - - process.exit(result.success ? 0 : 1); -} - -async function main() { - const options = parseArgs(args.slice(1)); - - switch (command) { - case 'build': - await runBuild(options); - break; - - case 'help': - default: - console.log(` -Sentinel CLI - Focused Agentic Loops - -Commands: - build Run BuildSentinel to compile code - -Options for 'build': - --command="..." Build command (default: npm run build:ts) - --file=path/to/file Compile single file with tsc - --working-dir=... Working directory (default: cwd) - --max-attempts=N Max retry attempts (default: 3) - --auto-fix=false Disable auto-fix attempts - -Examples: - # Build entire project - npx tsx system/sentinel/cli.ts build - - # Compile single file - npx tsx system/sentinel/cli.ts build --file=system/sentinel/test-error.ts - - # Custom build command - npx tsx system/sentinel/cli.ts build --command="cargo build --release" -`); - break; - } -} - -main().catch((e) => { - console.error(JSON.stringify({ error: e.message })); - process.exit(1); -}); diff --git a/src/debug/jtag/system/sentinel/index.ts b/src/debug/jtag/system/sentinel/index.ts index 64e8c7336..6ea9eb7c9 100644 --- a/src/debug/jtag/system/sentinel/index.ts +++ b/src/debug/jtag/system/sentinel/index.ts @@ -1,86 +1,34 @@ /** - * Sentinel System - Autonomous Task Executors + * Sentinel System - Pipeline Execution in Rust * - * ARCHITECTURE: - * - * 1. SCRIPT SENTINELS (no AI required) - * - BuildSentinel: Agentic compilation with auto-fix - * - VisualSentinel: Screenshot feedback via Puppeteer - * - TaskSentinel: Serial task execution with limits - * - * 2. AI-POWERED SENTINELS (require LLM) - * - OrchestratorSentinel: LLM-powered planning & execution - * - * MODEL SELECTION EXAMPLES: - * - * ```typescript - * // By capacity (power level) - * new OrchestratorSentinel({ - * workingDir: '...', - * capacity: ModelCapacity.SMALL, // TINY | SMALL | MEDIUM | LARGE | SOTA - * }); - * - * // By provider - * new OrchestratorSentinel({ - * workingDir: '...', - * provider: ModelProvider.LOCAL, // LOCAL | OLLAMA | ANTHROPIC | OPENAI | AUTO - * }); - * - * // By specific model name - * new OrchestratorSentinel({ - * workingDir: '...', - * modelName: 'claude-3-opus-20240229', - * }); - * - * // Full config - * new OrchestratorSentinel({ - * workingDir: '...', - * model: { - * capacity: ModelCapacity.LARGE, - * provider: ModelProvider.ANTHROPIC, - * model: 'claude-3-5-sonnet-20241022', - * maxTokens: 4000, - * } - * }); - * ``` + * All sentinel execution happens in Rust SentinelModule. + * This module only exports types and definition utilities. */ -// Base classes and types -export { ScriptSentinel, AISentinel, SentinelRegistry } from './Sentinel'; -export type { SentinelResult, SentinelStep, BaseSentinelConfig, AISentinelConfig } from './Sentinel'; - -// Model selection -export { ModelCapacity, ModelProvider, ModelSelector, ModelInvoker, createInvoker, resolveModel } from './ModelProvider'; -export type { ModelConfig, InferenceResult } from './ModelProvider'; - -// Script sentinels (no AI required) -export { BuildSentinel, type BuildSentinelConfig, type BuildResult, type BuildError, type SentinelProgress } from './BuildSentinel'; -export { VisualSentinel, type VisualSentinelConfig, type ScreenshotResult } from './VisualSentinel'; -export { TaskSentinel, createSnakeGamePlan, type TaskSentinelConfig, type Task, type TaskResult } from './TaskSentinel'; - -// AI-powered sentinels -export { OrchestratorSentinel, type OrchestratorConfig, type ExecutionContext, type HistoryEntry } from './OrchestratorSentinel'; +// Model selection types +export { ModelCapacity, ModelProvider } from './ModelProvider'; +export type { ModelConfig } from './ModelProvider'; // Portable definitions (JSON-serializable) export { SentinelBuilder, validateDefinition, - createDefinitionFromParams, type SentinelDefinition, type SentinelDefinitionBase, - type BuildSentinelDefinition, - type OrchestrateSentinelDefinition, - type ScreenshotSentinelDefinition, - type TaskSentinelDefinition, - type ScriptSentinelDefinition, - type TaskAction, type SentinelEntity, type SentinelExecutionResult, + // Pipeline types + type PipelineSentinelDefinition, + type LoopConfig, + type SentinelTrigger, + type SentinelSafety, + type SentinelStep, + type CommandStep, + type LLMStep, + type ConditionStep, + type WatchStep, + type SentinelSpawnStep, + type EmitStep, + type ParallelStep, + type SentinelRule, } from './SentinelDefinition'; - -// Tool result → memory capture -export { - initToolResultMemoryCapture, - queryToolMemories, - getToolMemoryStats, -} from './ToolResultMemoryCapture'; diff --git a/src/debug/jtag/system/shared/ModelCapabilities.ts b/src/debug/jtag/system/shared/ModelCapabilities.ts new file mode 100644 index 000000000..b5dd267aa --- /dev/null +++ b/src/debug/jtag/system/shared/ModelCapabilities.ts @@ -0,0 +1,479 @@ +/** + * ModelCapabilities — Fine-Tuning, Quantization, and Adapter Type System + * ====================================================================== + * + * Defines the complete capability profile of a model on a specific provider/runtime. + * This is the "knowing" layer — every adapter reports what it supports, and + * algorithms query across all registered models to find the best horsepower + * for the desired outcome. + * + * Design principle: Define everything now, populate incrementally. + * Adapters report what they know at discovery time. Unknown fields stay undefined. + * The type system is the source of truth for what CAN be known. + * + * Usage: + * // At adapter discovery time: + * registry.register({ + * modelId: 'meta-llama/Llama-3.1-8B-Instruct', + * provider: 'candle', + * contextWindow: 1400, + * capabilities: { ... }, + * adapterProfile: { + * quantization: { format: QuantFormat.Q4_K_M, bitsPerWeight: 4 }, + * fineTuning: { supportedMethods: [AdapterMethod.QLORA] }, + * runtime: InferenceRuntime.CANDLE, + * ... + * } + * }); + * + * // At selection time: + * const candidates = registry.getAll('meta-llama/Llama-3.1-8B-Instruct') + * .filter(m => m.adapterProfile?.fineTuning.supportedMethods.includes(AdapterMethod.QLORA)) + * .filter(m => (m.adapterProfile?.hardware.inferenceVramMB ?? Infinity) <= availableVram); + */ + +// ═══════════════════════════════════════════════════════════════════════ +// QUANTIZATION +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Weight quantization formats. + * + * Ordered roughly by quality (highest first, most compressed last). + * Each format represents a tradeoff between model quality, inference speed, + * and memory usage. The choice of quantization format directly affects + * whether LoRA adapters can be injected and how. + */ +export enum QuantFormat { + /** Full 32-bit floating point — maximum quality, 4x memory */ + FP32 = 'fp32', + + /** Brain floating point 16 — good quality, native on modern GPUs */ + BF16 = 'bf16', + + /** IEEE 16-bit float — good quality, widely supported */ + FP16 = 'fp16', + + /** 8-bit integer — good balance, 2x compression from FP16 */ + INT8 = 'int8', + + /** GGML 8-bit — llama.cpp native, good quality */ + Q8_0 = 'q8_0', + + /** GGML 6-bit with K-quant — very good quality/size ratio */ + Q6_K = 'q6_k', + + /** GGML 5-bit medium K-quant — solid quality, moderate compression */ + Q5_K_M = 'q5_k_m', + + /** GGML 4-bit medium K-quant — the sweet spot for most local inference */ + Q4_K_M = 'q4_k_m', + + /** GGML 4-bit basic — faster than K_M, slightly lower quality */ + Q4_0 = 'q4_0', + + /** GGML 3-bit medium K-quant — aggressive compression, quality loss */ + Q3_K_M = 'q3_k_m', + + /** GGML 2-bit K-quant — maximum compression, significant quality loss */ + Q2_K = 'q2_k', + + /** GPTQ — GPU-optimized post-training quantization */ + GPTQ = 'gptq', + + /** AWQ — activation-aware weight quantization */ + AWQ = 'awq', + + /** No quantization applied (full precision weights) */ + NONE = 'none', +} + +/** + * Model weight container/file formats. + * Distinct from quantization — a GGUF file can contain Q4_K_M or Q8_0 weights. + */ +export enum WeightFormat { + /** GGUF — llama.cpp/Candle native, self-describing, versioned */ + GGUF = 'gguf', + + /** SafeTensors — HuggingFace standard, memory-mapped, safe */ + SAFETENSORS = 'safetensors', + + /** PyTorch checkpoint — legacy, widely supported */ + PYTORCH = 'pytorch', + + /** ONNX — cross-platform inference */ + ONNX = 'onnx', + + /** MLX — Apple Silicon native format */ + MLX = 'mlx', + + /** Cloud API — no local weights, opaque */ + CLOUD = 'cloud', +} + +/** + * Full quantization profile for a model instance. + */ +export interface QuantizationProfile { + /** Quantization method applied to weights */ + readonly format: QuantFormat; + + /** Bits per weight (4, 8, 16, 32) */ + readonly bitsPerWeight: number; + + /** Container format for the weights */ + readonly weightFormat?: WeightFormat; + + /** Can weights be dequantized to higher precision for training? */ + readonly canDequantizeForTraining?: boolean; + + /** Can adapters be trained directly on quantized weights? (QLoRA = true) */ + readonly canTrainInQuantized?: boolean; + + /** Group size for quantization (e.g., 128 for GPTQ) — affects quality */ + readonly groupSize?: number; +} + + +// ═══════════════════════════════════════════════════════════════════════ +// FINE-TUNING / ADAPTER METHODS +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Parameter-efficient fine-tuning (PEFT) methods. + * + * These are the techniques for injecting learned behavior into a base model + * without modifying the base weights. Each method has different tradeoffs + * in quality, speed, memory, and composability. + * + * For the genome paging vision: each "genomic trait" is an adapter trained + * with one of these methods. The runtime loads/unloads them as needed. + */ +export enum AdapterMethod { + /** Low-Rank Adaptation — adds trainable rank decomposition matrices */ + LORA = 'lora', + + /** Quantized LoRA — LoRA on quantized base model (4-bit base + FP16 adapters) */ + QLORA = 'qlora', + + /** Weight-Decomposed Low-Rank Adaptation — improved LoRA with magnitude component */ + DORA = 'dora', + + /** Full fine-tuning — modifies all weights (highest quality, highest cost) */ + FULL = 'full', + + /** Prefix tuning — prepends trainable tokens to each layer */ + PREFIX = 'prefix', + + /** Prompt tuning — soft prompts prepended to input only */ + PROMPT = 'prompt', + + /** Infused Adapter by Inhibiting and Amplifying Inner Activations */ + IA3 = 'ia3', + + /** Adapter layers — bottleneck modules inserted between transformer layers */ + ADAPTER_LAYERS = 'adapter_layers', +} + +/** + * Transformer layers that can be targeted by adapters. + * + * Different adapter methods target different layers. LoRA typically targets + * attention projections (Q, K, V, O). Some configurations also target MLP layers + * for better quality at higher parameter cost. + */ +export enum AdapterTarget { + /** Attention query projection */ + ATTN_Q = 'attn_q', + + /** Attention key projection */ + ATTN_K = 'attn_k', + + /** Attention value projection */ + ATTN_V = 'attn_v', + + /** Attention output projection */ + ATTN_O = 'attn_o', + + /** MLP gate projection (SwiGLU architectures) */ + MLP_GATE = 'mlp_gate', + + /** MLP up projection */ + MLP_UP = 'mlp_up', + + /** MLP down projection */ + MLP_DOWN = 'mlp_down', + + /** Token embedding layer */ + EMBEDDING = 'embedding', + + /** Language model head (output projection) */ + LM_HEAD = 'lm_head', +} + +/** + * LoRA-specific configuration and constraints. + */ +export interface LoRAProfile { + /** Maximum supported rank (higher = more parameters = better quality = more VRAM) */ + readonly maxRank: number; + + /** Recommended rank for this model/hardware combo */ + readonly recommendedRank: number; + + /** Typical alpha value (scaling factor, usually = rank or 2*rank) */ + readonly recommendedAlpha?: number; + + /** Maximum number of adapters that can be loaded simultaneously */ + readonly maxConcurrentAdapters: number; + + /** Can multiple adapters be composed/stacked at inference time? */ + readonly supportsStacking: boolean; + + /** Estimated adapter size in MB at recommended rank */ + readonly adapterSizeMB: number; + + /** Which layers can be targeted */ + readonly targetableLayers: readonly AdapterTarget[]; + + /** Recommended target layers for best quality/cost ratio */ + readonly recommendedTargets?: readonly AdapterTarget[]; + + /** Dropout rate recommended for training (0.0 - 1.0) */ + readonly recommendedDropout?: number; +} + +/** + * Fine-tuning capability profile. + */ +export interface FineTuningProfile { + /** Which adapter methods this model supports on this runtime */ + readonly supportedMethods: readonly AdapterMethod[]; + + /** LoRA-specific parameters (present if LORA or QLORA is supported) */ + readonly lora?: LoRAProfile; + + /** Maximum training batch size on this hardware */ + readonly maxTrainingBatchSize?: number; + + /** Whether gradient checkpointing is supported (saves VRAM at speed cost) */ + readonly supportsGradientCheckpointing?: boolean; + + /** Whether flash attention is available (faster training) */ + readonly supportsFlashAttention?: boolean; +} + + +// ═══════════════════════════════════════════════════════════════════════ +// INFERENCE RUNTIME +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Local inference runtimes. + * Each runtime has different capabilities for loading models and adapters. + */ +export enum InferenceRuntime { + /** Candle — Rust-native, GGUF/SafeTensors, Metal acceleration */ + CANDLE = 'candle', + + /** llama.cpp — C++, GGUF, Metal/CUDA/CPU, mature ecosystem */ + LLAMA_CPP = 'llama_cpp', + + /** MLX — Apple Silicon native, Python, excellent Metal performance */ + MLX = 'mlx', + + /** ONNX Runtime — cross-platform, optimized inference graphs */ + ONNX = 'onnx', + + /** HuggingFace Transformers — Python, full ecosystem, all formats */ + TRANSFORMERS = 'transformers', + + /** vLLM — high-throughput serving, PagedAttention, CUDA */ + VLLM = 'vllm', + + /** Text Generation Inference — HuggingFace serving, optimized */ + TGI = 'tgi', + + /** Ollama — wrapper around llama.cpp with model management */ + OLLAMA = 'ollama', + + /** Cloud API — opaque, no local execution */ + CLOUD_API = 'cloud_api', +} + +/** + * Hardware accelerator type. + */ +export enum Accelerator { + /** Apple Metal Performance Shaders */ + METAL = 'metal', + + /** NVIDIA CUDA */ + CUDA = 'cuda', + + /** AMD ROCm */ + ROCM = 'rocm', + + /** Intel oneAPI */ + ONEAPI = 'oneapi', + + /** CPU only (no GPU acceleration) */ + CPU = 'cpu', + + /** Cloud-managed (unknown hardware) */ + CLOUD = 'cloud', +} + + +// ═══════════════════════════════════════════════════════════════════════ +// HARDWARE PROFILE +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Hardware requirements and measured performance for a model on specific hardware. + */ +export interface HardwareProfile { + /** VRAM required for inference (MB) */ + readonly inferenceVramMB: number; + + /** VRAM required for fine-tuning with recommended method (MB) */ + readonly trainingVramMB?: number; + + /** Accelerator type used */ + readonly accelerator: Accelerator; + + /** Measured tokens per second on THIS hardware (inference, prompt eval) */ + readonly measuredInputTPS?: number; + + /** Measured tokens per second on THIS hardware (inference, generation) */ + readonly measuredOutputTPS?: number; + + /** Whether the model fits entirely in VRAM (vs. partial offload to RAM) */ + readonly fitsInVram?: boolean; + + /** Number of layers offloaded to CPU (0 = fully GPU) */ + readonly cpuOffloadLayers?: number; +} + + +// ═══════════════════════════════════════════════════════════════════════ +// COMPOSITE: MODEL ADAPTER PROFILE +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Complete adapter/fine-tuning profile for a model on a specific provider. + * + * This is the top-level type that gets attached to ModelMetadata. + * It captures everything we need to know to make algorithmic decisions + * about model selection, adapter loading, and training scheduling. + * + * Example — Llama 3.1 8B on Candle/M1: + * { + * runtime: InferenceRuntime.CANDLE, + * quantization: { format: QuantFormat.Q4_K_M, bitsPerWeight: 4, weightFormat: WeightFormat.GGUF }, + * fineTuning: { + * supportedMethods: [AdapterMethod.QLORA], + * lora: { + * maxRank: 32, + * recommendedRank: 8, + * maxConcurrentAdapters: 3, + * supportsStacking: true, + * adapterSizeMB: 15, + * targetableLayers: [AdapterTarget.ATTN_Q, AdapterTarget.ATTN_V], + * } + * }, + * hardware: { + * inferenceVramMB: 4500, + * trainingVramMB: 8000, + * accelerator: Accelerator.METAL, + * measuredInputTPS: 40, + * } + * } + */ +export interface ModelAdapterProfile { + /** Inference runtime this profile describes */ + readonly runtime: InferenceRuntime; + + /** Quantization details */ + readonly quantization: QuantizationProfile; + + /** Fine-tuning capabilities */ + readonly fineTuning: FineTuningProfile; + + /** Hardware requirements and measured performance */ + readonly hardware?: HardwareProfile; + + /** Model architecture family (for adapter compatibility checking) */ + readonly architectureFamily?: string; + + /** Parameter count in billions (e.g., 7, 8, 13, 70) */ + readonly parameterCountB?: number; + + /** Number of transformer layers (for adapter targeting) */ + readonly layerCount?: number; + + /** Hidden dimension size (affects adapter parameter count) */ + readonly hiddenSize?: number; +} + + +// ═══════════════════════════════════════════════════════════════════════ +// QUERY HELPERS +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Check if a model supports any form of adapter-based fine-tuning. + */ +export function isFineTunable(profile: ModelAdapterProfile | undefined): boolean { + if (!profile) return false; + return profile.fineTuning.supportedMethods.length > 0 + && !profile.fineTuning.supportedMethods.every(m => m === AdapterMethod.FULL); +} + +/** + * Check if a model supports LoRA or QLoRA (the primary genome methods). + */ +export function supportsLoRA(profile: ModelAdapterProfile | undefined): boolean { + if (!profile) return false; + return profile.fineTuning.supportedMethods.includes(AdapterMethod.LORA) + || profile.fineTuning.supportedMethods.includes(AdapterMethod.QLORA); +} + +/** + * Check if a model can stack multiple adapters simultaneously. + * Required for genome paging (multiple traits loaded at once). + */ +export function supportsAdapterStacking(profile: ModelAdapterProfile | undefined): boolean { + if (!profile?.fineTuning.lora) return false; + return profile.fineTuning.lora.supportsStacking + && profile.fineTuning.lora.maxConcurrentAdapters > 1; +} + +/** + * Estimate VRAM required for a LoRA adapter at a given rank. + * Rough formula: 2 * rank * hiddenSize * targetLayers * 2 (bytes for FP16) / 1MB + */ +export function estimateAdapterVramMB( + profile: ModelAdapterProfile, + rank?: number +): number { + const r = rank ?? profile.fineTuning.lora?.recommendedRank ?? 8; + const hidden = profile.hiddenSize ?? 4096; + const layers = profile.fineTuning.lora?.targetableLayers.length ?? 2; + const transformerLayers = profile.layerCount ?? 32; + // Each LoRA adapter adds two matrices per target per layer: A (hidden x rank) + B (rank x hidden) + const bytesPerAdapter = 2 * r * hidden * layers * transformerLayers * 2; // FP16 = 2 bytes + return Math.ceil(bytesPerAdapter / (1024 * 1024)); +} + +/** + * Check if a model can run on given available VRAM. + */ +export function fitsInVram( + profile: ModelAdapterProfile | undefined, + availableVramMB: number +): boolean { + if (!profile?.hardware) return false; + return profile.hardware.inferenceVramMB <= availableVramMB; +} diff --git a/src/debug/jtag/system/shared/ModelContextWindows.ts b/src/debug/jtag/system/shared/ModelContextWindows.ts index 2363e0560..0fab6d2a0 100644 --- a/src/debug/jtag/system/shared/ModelContextWindows.ts +++ b/src/debug/jtag/system/shared/ModelContextWindows.ts @@ -13,10 +13,19 @@ * ModelRegistry (populated async from provider APIs in initializeDeferred) * is checked FIRST. Static maps below are the fallback when the registry * hasn't discovered a model yet or the provider API is unavailable. + * + * Provider-scoped lookups: + * All functions accept an optional `provider` parameter. When provided, + * ModelRegistry returns only that provider's entry — preventing collisions + * where the same modelId exists on multiple providers with different context + * windows (e.g., meta-llama/Llama-3.1-8B-Instruct: 131K on Together, 1400 on Candle). */ import { ModelRegistry } from './ModelRegistry'; +/** Known local provider names for inference speed classification */ +const LOCAL_PROVIDERS = new Set(['candle', 'ollama', 'sentinel']); + /** * Model context windows in tokens * @@ -53,7 +62,8 @@ export const MODEL_CONTEXT_WINDOWS: Readonly> = { // Meta Models (Llama) — cloud API naming (dashes) 'llama-3.1-8b-instant': 131072, // Groq LPU 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo': 131072, // Together.ai - 'accounts/fireworks/models/llama-v3p1-8b-instruct': 131072, // Fireworks.ai + 'accounts/fireworks/models/llama-v3p1-8b-instruct': 131072, // Fireworks.ai (deprecated) + 'accounts/fireworks/models/llama-v3p3-70b-instruct': 131072, // Fireworks.ai Llama 3.3 70B // Meta Models (Llama) — Ollama naming (dots + colons) 'llama3.2': 128000, 'llama3.2:3b': 128000, @@ -62,12 +72,15 @@ export const MODEL_CONTEXT_WINDOWS: Readonly> = { 'llama3.1:70b': 128000, 'llama3.1:8b': 128000, - // HuggingFace IDs (used by Candle adapter directly) - // IMPORTANT: Q4_K_M quantization becomes numerically unstable beyond practical limits. - 'meta-llama/Llama-3.1-8B-Instruct': 8000, // Q4_K_M practical limit for 8B - 'unsloth/Llama-3.2-3B-Instruct': 2000, // Q4_K_M practical limit due to NaN/Inf - 'Qwen/Qwen2-1.5B-Instruct': 4000, // Smaller model, more stable - 'Qwen/Qwen2-0.5B-Instruct': 4000, + // HuggingFace IDs (Candle adapter) — FALLBACK only. + // Source of truth is CandleAdapter.capabilities().max_context_window in Rust, + // which feeds into ModelRegistry at startup via registerLocalModels(). + // Candle quantized attention breaks at ~1000 input tokens on Metal. + // See: https://github.com/huggingface/candle/issues/1566 + 'meta-llama/Llama-3.1-8B-Instruct': 1400, + 'unsloth/Llama-3.2-3B-Instruct': 1400, + 'Qwen/Qwen2-1.5B-Instruct': 1400, + 'Qwen/Qwen2-0.5B-Instruct': 1400, // Qwen Models via Ollama 'qwen2.5': 128000, @@ -198,15 +211,27 @@ export const DEFAULT_INFERENCE_SPEED = 80; export const DEFAULT_TARGET_LATENCY_SECONDS = 30; /** - * Get inference speed for a model in tokens per second + * Get inference speed for a model in tokens per second. + * + * When provider is specified and the model is found in the registry: + * - Local providers (candle/ollama/sentinel): fall through to static speed map + * - Cloud providers: return 1000 TPS (network-bound) + * + * Bug fix: Previously, any registry hit assumed cloud (1000 TPS), even for + * Candle models registered at 40 TPS. Now checks provider to classify correctly. */ -export function getInferenceSpeed(model: string): number { - // Check ModelRegistry first (live-discovered data from provider APIs) +export function getInferenceSpeed(model: string, provider?: string): number { const registry = ModelRegistry.sharedInstance(); - const discovered = registry.get(model); + const discovered = registry.get(model, provider); if (discovered) { - // Cloud APIs are always ~1000 TPS (network-bound) - return 1000; + // Check if this is a local provider — don't assume cloud speed + if (LOCAL_PROVIDERS.has(discovered.provider)) { + // Fall through to static speed map for local providers + // (registry has context windows, not inference speeds) + } else { + // Cloud APIs are ~1000 TPS (network-bound) + return 1000; + } } // Direct match @@ -246,21 +271,23 @@ export function getInferenceSpeed(model: string): number { * * @param model - Model identifier * @param targetLatencySeconds - Target response time (default: 30s) + * @param provider - Optional provider for scoped lookup * @returns Maximum input tokens to stay within latency target */ export function getLatencyAwareTokenLimit( model: string, - targetLatencySeconds: number = DEFAULT_TARGET_LATENCY_SECONDS + targetLatencySeconds: number = DEFAULT_TARGET_LATENCY_SECONDS, + provider?: string ): number { - const tokensPerSecond = getInferenceSpeed(model); + const tokensPerSecond = getInferenceSpeed(model, provider); return Math.floor(targetLatencySeconds * tokensPerSecond); } /** * Check if a model is a slow local model (needs latency-aware budgeting) */ -export function isSlowLocalModel(model: string): boolean { - const speed = getInferenceSpeed(model); +export function isSlowLocalModel(model: string, provider?: string): boolean { + const speed = getInferenceSpeed(model, provider); return speed < 500; // Below 500 TPS = needs latency awareness } @@ -271,13 +298,15 @@ export function isSlowLocalModel(model: string): boolean { * - Direct model name lookup (e.g., "gpt-4") * - Versioned model lookup (e.g., "llama3.2:3b" → "llama3.2") * - Prefix matching for similar models + * - Provider-scoped lookup to avoid cross-provider collisions * * @param model - Model identifier (e.g., "gpt-4", "claude-3-sonnet") + * @param provider - Optional provider for scoped registry lookup * @returns Context window size in tokens, or DEFAULT_CONTEXT_WINDOW if model not found */ -export function getContextWindow(model: string): number { +export function getContextWindow(model: string, provider?: string): number { // Check ModelRegistry first (live-discovered data from provider APIs) - const discovered = ModelRegistry.sharedInstance().contextWindow(model); + const discovered = ModelRegistry.sharedInstance().contextWindow(model, provider); if (discovered !== undefined) return discovered; // Direct match in static map @@ -311,16 +340,16 @@ export function getContextWindow(model: string): number { * Check if a model has a large context window (>32K) * Useful for deciding whether to include more context in RAG */ -export function isLargeContextModel(modelId: string): boolean { - return getContextWindow(modelId) > 32768; +export function isLargeContextModel(modelId: string, provider?: string): boolean { + return getContextWindow(modelId, provider) > 32768; } /** * Get recommended output token budget for a model * Typically 10-25% of context window, capped at 4K for most use cases */ -export function getRecommendedMaxOutputTokens(modelId: string): number { - const contextWindow = getContextWindow(modelId); +export function getRecommendedMaxOutputTokens(modelId: string, provider?: string): number { + const contextWindow = getContextWindow(modelId, provider); // For small context windows, use 25% if (contextWindow <= 8192) { @@ -337,8 +366,9 @@ export function getRecommendedMaxOutputTokens(modelId: string): number { export function getAvailableInputTokens( modelId: string, reservedOutputTokens: number, - safetyMargin: number = 500 + safetyMargin: number = 500, + provider?: string ): number { - const contextWindow = getContextWindow(modelId); + const contextWindow = getContextWindow(modelId, provider); return Math.max(0, contextWindow - reservedOutputTokens - safetyMargin); } diff --git a/src/debug/jtag/system/shared/ModelRegistry.ts b/src/debug/jtag/system/shared/ModelRegistry.ts index 4c883147b..7a121f357 100644 --- a/src/debug/jtag/system/shared/ModelRegistry.ts +++ b/src/debug/jtag/system/shared/ModelRegistry.ts @@ -14,12 +14,41 @@ * This is fully non-blocking. Discovery runs after the daemon is ready and * accepting requests. All I/O is async fetch() — no event loop blocking. * + * Provider-scoped keys: + * Internal map key is `${provider}:${modelId}` to prevent last-writer-wins + * collisions when the same model exists on multiple providers (e.g., + * meta-llama/Llama-3.1-8B-Instruct on Candle at 1400 tokens AND Together at 131072). + * * Usage: * const registry = ModelRegistry.sharedInstance(); - * const ctx = registry.contextWindow('claude-sonnet-4-5-20250929'); - * // Returns 200000 if discovered, undefined if not (caller falls back to static) + * const ctx = registry.contextWindow('claude-sonnet-4-5-20250929'); // any provider + * const ctx = registry.contextWindow('meta-llama/Llama-3.1-8B-Instruct', 'candle'); // specific provider + * + * Future direction — Hardware-Matched Model Selection: + * ModelRegistry is designed to evolve into a queryable adapter catalog where + * models are matched to hardware, task design, and recipe requirements: + * + * 1. Each provider adapter reports hardware capabilities at discovery time: + * - GPU type/VRAM, quantization level, max batch size, measured TPS + * - This goes into ModelMetadata alongside contextWindow + * + * 2. Recipes/formulas declare model requirements: + * - minContextWindow, preferredSpeed, requiredCapabilities (vision, tools, etc.) + * + * 3. Selection query: "give me the best model for this recipe on this hardware" + * - Filters by capability, ranks by speed/quality/cost tradeoff + * - Works across local (Candle/Ollama) and cloud (REST APIs) uniformly + * + * 4. Users with varied hardware (M1 vs RTX 4090 vs cloud-only) get automatically + * matched to the best available model without manual configuration. + * + * The provider-scoped key design (provider:modelId) already supports this — + * each adapter registers its models with hardware-specific metadata, and + * queries filter/sort across all registered providers. */ +import type { ModelAdapterProfile } from './ModelCapabilities'; + /** * Metadata for a discovered model */ @@ -31,6 +60,13 @@ export interface ModelMetadata { readonly capabilities?: string[]; readonly costPer1kTokens?: { input: number; output: number }; readonly discoveredAt: number; + + /** + * Fine-tuning, quantization, and adapter capability profile. + * Populated by adapters that report detailed model capabilities. + * Undefined for cloud APIs or adapters that haven't reported yet. + */ + readonly adapterProfile?: ModelAdapterProfile; } /** @@ -39,11 +75,21 @@ export interface ModelMetadata { * Provides fast lookup of model metadata discovered from provider APIs. * All normalization (date-suffix stripping, prefix matching) is built-in * so callers don't need to handle naming variations. + * + * Keys are provider-scoped: `${provider}:${modelId}` internally. + * When provider is omitted from lookups, resolution strategy: + * - If only one provider has the model → return it + * - If multiple providers → return largest context window (cloud wins for backward compat) */ export class ModelRegistry { private static _instance: ModelRegistry; + + /** Primary index: `${provider}:${modelId}` → ModelMetadata */ private _models: Map = new Map(); + /** Secondary index: `modelId` → Set for fast unscoped lookups */ + private _modelProviders: Map> = new Map(); + private constructor() {} static sharedInstance(): ModelRegistry { @@ -54,10 +100,27 @@ export class ModelRegistry { } /** - * Register a single model's metadata (overwrites if already present) + * Compose provider-scoped key + */ + private static scopedKey(provider: string, modelId: string): string { + return `${provider}:${modelId}`; + } + + /** + * Register a single model's metadata + * Uses provider from metadata.provider to scope the key. */ register(metadata: ModelMetadata): void { - this._models.set(metadata.modelId, metadata); + const key = ModelRegistry.scopedKey(metadata.provider, metadata.modelId); + this._models.set(key, metadata); + + // Update secondary index + let providers = this._modelProviders.get(metadata.modelId); + if (!providers) { + providers = new Set(); + this._modelProviders.set(metadata.modelId, providers); + } + providers.add(metadata.provider); } /** @@ -65,7 +128,7 @@ export class ModelRegistry { */ registerBatch(models: ModelMetadata[]): void { for (const model of models) { - this._models.set(model.modelId, model); + this.register(model); } } @@ -73,52 +136,98 @@ export class ModelRegistry { * Lookup context window for a model. * Returns undefined if the model is not in the registry (caller should fall back to static map). * + * When provider is specified, only that provider's entry is checked. + * When provider is omitted: + * - Single provider → return it + * - Multiple providers → return largest context window + * * Normalization chain: - * 1. Direct lookup by exact modelId + * 1. Direct lookup by exact modelId (+ provider if given) * 2. Date-suffix stripped (e.g. 'claude-sonnet-4-5-20250929' → 'claude-sonnet-4-5') * 3. Prefix matching (e.g. 'claude-sonnet-4' matches 'claude-sonnet-4-5-20250929') */ - contextWindow(modelId: string): number | undefined { - // 1. Direct lookup - const direct = this._models.get(modelId); - if (direct) return direct.contextWindow; + contextWindow(modelId: string, provider?: string): number | undefined { + const metadata = this.get(modelId, provider); + return metadata?.contextWindow; + } - // 2. Date-suffix normalization + /** + * Lookup full metadata for a model. + * Same normalization chain as contextWindow(). + * + * When provider is specified, only returns that provider's entry. + * When provider is omitted, resolves ambiguity by returning largest context window. + */ + get(modelId: string, provider?: string): ModelMetadata | undefined { + if (provider) { + return this.getScopedWithNormalization(modelId, provider); + } + return this.getUnscopedWithNormalization(modelId); + } + + /** + * Get all registered entries for a model across all providers. + * Useful for debugging provider collisions. + */ + getAll(modelId: string): ModelMetadata[] { + const results: ModelMetadata[] = []; + + // Direct match + const providers = this._modelProviders.get(modelId); + if (providers) { + for (const p of providers) { + const entry = this._models.get(ModelRegistry.scopedKey(p, modelId)); + if (entry) results.push(entry); + } + } + + if (results.length > 0) return results; + + // Date-suffix normalization const dateStripped = modelId.replace(/-\d{8}$/, ''); if (dateStripped !== modelId) { - const stripped = this._models.get(dateStripped); - if (stripped) return stripped.contextWindow; + const strippedProviders = this._modelProviders.get(dateStripped); + if (strippedProviders) { + for (const p of strippedProviders) { + const entry = this._models.get(ModelRegistry.scopedKey(p, dateStripped)); + if (entry) results.push(entry); + } + } + if (results.length > 0) return results; } - // 3. Prefix matching — check if any registered model starts with or is started by this ID - for (const [registeredId, metadata] of this._models) { + // Prefix matching + for (const [registeredId, registeredProviders] of this._modelProviders) { if (modelId.startsWith(registeredId) || registeredId.startsWith(modelId)) { - return metadata.contextWindow; + for (const p of registeredProviders) { + const entry = this._models.get(ModelRegistry.scopedKey(p, registeredId)); + if (entry) results.push(entry); + } } } - return undefined; + return results; } /** - * Lookup full metadata for a model. - * Same normalization chain as contextWindow(). + * Provider-scoped lookup with normalization chain. */ - get(modelId: string): ModelMetadata | undefined { - // Direct - const direct = this._models.get(modelId); + private getScopedWithNormalization(modelId: string, provider: string): ModelMetadata | undefined { + // 1. Direct + const direct = this._models.get(ModelRegistry.scopedKey(provider, modelId)); if (direct) return direct; - // Date-suffix + // 2. Date-suffix const dateStripped = modelId.replace(/-\d{8}$/, ''); if (dateStripped !== modelId) { - const stripped = this._models.get(dateStripped); + const stripped = this._models.get(ModelRegistry.scopedKey(provider, dateStripped)); if (stripped) return stripped; } - // Prefix matching - for (const [registeredId, metadata] of this._models) { - if (modelId.startsWith(registeredId) || registeredId.startsWith(modelId)) { + // 3. Prefix matching (only within same provider) + for (const [key, metadata] of this._models) { + if (metadata.provider !== provider) continue; + if (modelId.startsWith(metadata.modelId) || metadata.modelId.startsWith(modelId)) { return metadata; } } @@ -126,6 +235,22 @@ export class ModelRegistry { return undefined; } + /** + * Unscoped lookup with normalization chain. + * When multiple providers have the same model, returns the one with largest context window. + */ + private getUnscopedWithNormalization(modelId: string): ModelMetadata | undefined { + const all = this.getAll(modelId); + if (all.length === 0) return undefined; + if (all.length === 1) return all[0]; + + // Multiple providers — return largest context window (cloud models win for backward compat) + console.log(`[ModelRegistry] Ambiguous lookup for "${modelId}": ${all.length} providers (${all.map(m => `${m.provider}:${m.contextWindow}`).join(', ')}). Returning largest context window.`); + return all.reduce((best, current) => + current.contextWindow > best.contextWindow ? current : best + ); + } + /** * All registered models (read-only view) */ @@ -145,5 +270,6 @@ export class ModelRegistry { */ clear(): void { this._models.clear(); + this._modelProviders.clear(); } } diff --git a/src/debug/jtag/system/social/server/SocialCommandHelper.ts b/src/debug/jtag/system/social/server/SocialCommandHelper.ts index cd48bbfd4..b59f84013 100644 --- a/src/debug/jtag/system/social/server/SocialCommandHelper.ts +++ b/src/debug/jtag/system/social/server/SocialCommandHelper.ts @@ -24,7 +24,6 @@ import { DataList } from '@commands/data/list/shared/DataListTypes'; import { DataCreate } from '@commands/data/create/shared/DataCreateTypes'; import { SystemPaths } from '@system/core/config/SystemPaths'; import { UserEntity } from '@system/data/entities/UserEntity'; -import { UserIdentityResolver } from '@system/user/shared/UserIdentityResolver'; import { Logger } from '@system/core/logging/Logger'; const log = Logger.create('social/helper'); @@ -62,7 +61,7 @@ export async function loadSocialContext( } // Resolve persona using standard priority pattern (shared across all social commands) - const resolvedPersonaId = await resolvePersonaId(personaId, params); + const resolvedPersonaId = resolvePersonaId(personaId, params); // Look up persona for their uniqueId (needed for SystemPaths) const userResult = await DataList.execute({ @@ -159,35 +158,18 @@ export async function storeCredential( } /** - * Resolve the calling persona's userId using the standard priority pattern. - * Shared by all social commands — identity resolution should never be duplicated. - * - * Priority: - * 1. Explicit personaId parameter - * 2. Injected callerId (from PersonaToolExecutor) - * 3. Injected senderId - * 4. Auto-detect via UserIdentityResolver (CLI, agent context) + * Resolve the target persona ID. + * Explicit personaId param (admin targeting a specific persona) or params.userId (self). */ -export async function resolvePersonaId( +export function resolvePersonaId( personaId: UUID | undefined, params: CommandParams, -): Promise { - const injected = params as unknown as Record; - const resolved = personaId - || injected.callerId as UUID - || injected.senderId as UUID; - - if (resolved) return resolved; - - const identity = await UserIdentityResolver.resolve(); - if (!identity.exists || !identity.userId) { - throw new Error( - `Could not determine caller identity. ` + - `Provide --personaId or run from a known persona/user context. ` + - `Detected: ${identity.displayName} (${identity.uniqueId})` - ); +): UUID { + const resolved = personaId || params.userId; + if (!resolved) { + throw new Error('Could not determine persona identity: no personaId and no params.userId'); } - return identity.userId as UUID; + return resolved; } /** diff --git a/src/debug/jtag/system/tools/server/AgentToolExecutor.ts b/src/debug/jtag/system/tools/server/AgentToolExecutor.ts new file mode 100644 index 000000000..ee6128e7d --- /dev/null +++ b/src/debug/jtag/system/tools/server/AgentToolExecutor.ts @@ -0,0 +1,534 @@ +/** + * AgentToolExecutor - Universal tool execution for any agentic caller + * + * Extracted from PersonaToolExecutor: the universal parts that ANY agent needs + * (sentinels, personas, future autonomous agents). PersonaToolExecutor wraps + * this with persona-specific pre/post processing (result storage, media config, + * cognition logging, sentinel auto-config, workspace bootstrap). + * + * Responsibilities: + * - Tool name correction (LLMs confuse similarly-named tools) + * - Parameter name correction (LLMs guess wrong param names) + * - Code/write content cleaning (CDATA, HTML entities) + * - Room parameter resolution ("current" → actual room name) + * - Loop detection (blocks identical calls within time window) + * - Core execution via ToolRegistry + * - XML tool call parsing via ToolFormatAdapters + * - Native tool call execution with tool_use_id correlation + */ + +import type { UUID } from '../../core/types/CrossPlatformUUID'; +import type { JTAGContext } from '../../core/types/JTAGTypes'; +import { ToolRegistry } from './ToolRegistry'; +import type { ToolExecutionResult } from './ToolRegistry'; +import { RoomResolver } from '../../core/server/RoomResolver'; +import { getToolFormatAdapters, unsanitizeToolName, type ToolFormatAdapter } from '../../user/server/modules/ToolFormatAdapter'; +import type { + ToolCall as NativeToolCall, + ToolResult as NativeToolResult, +} from '../../../daemons/ai-provider-daemon/shared/AIProviderTypesV2'; +import type { ToolParseResult } from '../../../workers/continuum-core/bindings/RustCoreIPC'; +import RustCoreIPCClient from '../../../workers/continuum-core/bindings/RustCoreIPC'; + +// ─── Public Interfaces ─────────────────────────────────────────────── + +/** + * Parsed tool call from AI response text (XML, function-style, etc.) + */ +export interface ToolCall { + toolName: string; + parameters: Record; +} + +/** + * Minimal context needed for tool execution. + * Any caller (persona, sentinel, test harness) can provide this. + */ +export interface ToolCallContext { + /** Caller identity (persona ID, sentinel handle, test ID) */ + callerId: UUID; + /** Session ID for command attribution */ + sessionId: UUID; + /** Room/conversation scope */ + contextId: UUID; + /** Optional enriched JTAG context (enables caller-adaptive output) */ + context?: JTAGContext; +} + +/** + * Result from a single tool execution + */ +export interface ToolCallResult { + toolName: string; + success: boolean; + content: string; + error?: string; +} + +/** + * Result from batch native tool execution + */ +export interface NativeToolBatchResult { + results: NativeToolResult[]; + /** Number of tools that were blocked by loop detection */ + blockedCount: number; +} + +// ─── Tool Corrections ──────────────────────────────────────────────── + +/** + * Tool name corrections: LLMs sometimes confuse similarly-named tools. + * workspace/tree shows the JTAG command hierarchy, code/tree shows workspace files. + */ +const TOOL_CORRECTIONS: Record = { + 'workspace/tree': 'code/tree', +}; + +/** + * Parameter name corrections per command prefix. + * LLMs guess wrong parameter names when tool descriptions are generic. + * Maps { wrongName -> correctName } for each command prefix. + */ +const PARAM_CORRECTIONS: Record> = { + 'code/write': { + 'path': 'filePath', + 'file': 'filePath', + 'file_path': 'filePath', + 'filepath': 'filePath', + 'filename': 'filePath', + 'file_name': 'filePath', + 'name': 'filePath', + 'contents': 'content', + 'text': 'content', + 'body': 'content', + 'data': 'content', + 'code': 'content', + 'html': 'content', + 'source': 'content', + }, + 'code/read': { + 'path': 'filePath', + 'file': 'filePath', + 'file_path': 'filePath', + 'filepath': 'filePath', + 'filename': 'filePath', + 'name': 'filePath', + 'start': 'startLine', + 'end': 'endLine', + 'from': 'startLine', + 'to': 'endLine', + }, + 'code/edit': { + 'path': 'filePath', + 'file': 'filePath', + 'file_path': 'filePath', + 'filepath': 'filePath', + 'filename': 'filePath', + 'name': 'filePath', + 'mode': 'editMode', + 'type': 'editMode', + }, + 'code/search': { + 'query': 'pattern', + 'search': 'pattern', + 'term': 'pattern', + 'regex': 'pattern', + 'glob': 'fileGlob', + 'filter': 'fileGlob', + }, + 'code/tree': { + 'directory': 'path', + 'dir': 'path', + 'folder': 'path', + 'depth': 'maxDepth', + }, + 'code/git': { + 'subcommand': 'operation', + 'command': 'operation', + 'action': 'operation', + 'op': 'operation', + 'msg': 'message', + 'files': 'paths', + }, +}; + +// ─── HTML Entity Decode ────────────────────────────────────────────── + +const NAMED_ENTITIES: Record = { + lt: '<', gt: '>', amp: '&', quot: '"', apos: "'", nbsp: ' ', +}; + +function decodeHtmlEntities(content: string): string { + return content.replace( + /&(#\d+|#x[\da-fA-F]+|[a-zA-Z]+);/g, + (match, entity: string) => { + if (NAMED_ENTITIES[entity]) return NAMED_ENTITIES[entity]; + if (entity.startsWith('#x')) return String.fromCharCode(parseInt(entity.slice(2), 16)); + if (entity.startsWith('#')) return String.fromCharCode(parseInt(entity.slice(1), 10)); + return match; + } + ); +} + +// ─── AgentToolExecutor ─────────────────────────────────────────────── + +export class AgentToolExecutor { + /** + * Loop detection: track recent tool calls per caller to detect infinite loops. + * Map> + */ + private static readonly _recentCalls = new Map>(); + private static readonly LOOP_WINDOW_MS = 60_000; + private static readonly LOOP_THRESHOLD = 2; + + private readonly toolRegistry: ToolRegistry; + private readonly formatAdapters: ToolFormatAdapter[]; + + constructor() { + this.toolRegistry = ToolRegistry.getInstance(); + this.formatAdapters = getToolFormatAdapters(); + } + + // ─── Loop Detection ────────────────────────────────────────────── + + private static hashCall(tc: ToolCall): string { + return `${tc.toolName}:${JSON.stringify(tc.parameters)}`; + } + + /** + * Check if a tool call is a duplicate (appears too frequently). + * Returns true if blocked (is a loop), false if allowed. + */ + isLoopDetected(toolName: string, params: Record, callerId: UUID): boolean { + const hash = `${toolName}:${JSON.stringify(params)}`; + const now = Date.now(); + + let recent = AgentToolExecutor._recentCalls.get(callerId) ?? []; + recent = recent.filter(e => now - e.timestamp < AgentToolExecutor.LOOP_WINDOW_MS); + + const count = recent.filter(e => e.hash === hash).length; + recent.push({ hash, timestamp: now }); + AgentToolExecutor._recentCalls.set(callerId, recent); + + return count >= AgentToolExecutor.LOOP_THRESHOLD; + } + + // ─── Tool Call Parsing (XML/function-style) ────────────────────── + + /** + * Parse tool calls from AI response text using registered format adapters. + * Supports Anthropic XML, OpenAI function-style, bare tool calls, markdown backtick. + */ + parseToolCalls(responseText: string): ToolCall[] { + const toolCalls: ToolCall[] = []; + for (const adapter of this.formatAdapters) { + const matches = adapter.matches(responseText); + for (const match of matches) { + const tc = adapter.parse(match); + if (tc) toolCalls.push(tc); + } + } + return toolCalls; + } + + /** + * Strip tool blocks from response text to get clean user-facing message. + */ + stripToolBlocks(responseText: string): string { + let cleaned = responseText; + const allMatches: Array<{ start: number; end: number }> = []; + + for (const adapter of this.formatAdapters) { + for (const match of adapter.matches(cleaned)) { + allMatches.push({ start: match.startIndex, end: match.endIndex }); + } + } + + allMatches.sort((a, b) => b.start - a.start); + for (const m of allMatches) { + cleaned = cleaned.slice(0, m.start) + cleaned.slice(m.end); + } + return cleaned.trim(); + } + + // ─── Rust-accelerated Parsing (async) ────────────────────────────── + + /** + * Parse + correct + strip in ONE Rust IPC call. + * Returns both tool calls (already corrected) and cleaned text. + * Sub-microsecond in Rust, replaces 3 separate sync calls. + */ + async parseResponse(responseText: string): Promise<{ toolCalls: ToolCall[]; cleanedText: string; parseTimeUs: number }> { + const rustClient = RustCoreIPCClient.getInstance(); + const result: ToolParseResult = await rustClient.toolParsingParse(responseText); + return { + toolCalls: result.tool_calls.map(tc => ({ + toolName: tc.tool_name, + parameters: tc.parameters, + })), + cleanedText: result.cleaned_text, + parseTimeUs: result.parse_time_us, + }; + } + + // ─── Name/Param Correction ─────────────────────────────────────── + + /** + * Apply tool name and parameter corrections. + * Returns a new ToolCall (never mutates input). + */ + correctToolCall(tc: ToolCall): { corrected: ToolCall; nameChanged: boolean; paramsChanged: string[] } { + let toolName = tc.toolName; + let nameChanged = false; + const paramsChanged: string[] = []; + + // Name correction + const correctedName = TOOL_CORRECTIONS[toolName]; + if (correctedName) { + toolName = correctedName; + nameChanged = true; + } + + // Param correction + let parameters = { ...tc.parameters }; + const corrections = PARAM_CORRECTIONS[toolName]; + if (corrections) { + for (const [wrong, correct] of Object.entries(corrections)) { + if (parameters[wrong] !== undefined && parameters[correct] === undefined) { + parameters[correct] = parameters[wrong]; + delete parameters[wrong]; + paramsChanged.push(`${wrong} -> ${correct}`); + } + } + } + + // Content cleaning for code/write + if (toolName === 'code/write' && parameters.content) { + let content = parameters.content; + let cleaned = false; + + // Strip CDATA wrappers + const cdataMatch = content.match(/^$/); + if (cdataMatch) { + content = cdataMatch[1]; + cleaned = true; + } + + // Decode HTML entities + const decoded = decodeHtmlEntities(content); + if (decoded !== content) { + content = decoded; + cleaned = true; + } + + if (cleaned) { + parameters = { ...parameters, content }; + } + } + + return { + corrected: { toolName, parameters }, + nameChanged, + paramsChanged, + }; + } + + // ─── Room Resolution ───────────────────────────────────────────── + + /** + * Resolve "current" room parameter to actual room name. + */ + private async resolveRoomParams( + params: Record, + contextId: UUID + ): Promise> { + if (params.room !== 'current') return params; + + const roomName = await RoomResolver.resolveCurrentParam('current', contextId); + if (roomName) { + return { ...params, room: roomName }; + } + return params; + } + + // ─── Core Execution ────────────────────────────────────────────── + + /** + * Execute a single tool call through the full correction + execution pipeline. + * + * Pipeline: name correction -> param correction -> content cleaning -> + * room resolution -> ToolRegistry.executeTool() -> result + */ + async executeToolCall( + toolName: string, + params: Record, + ctx: ToolCallContext + ): Promise { + // Apply corrections + const { corrected } = this.correctToolCall({ toolName, parameters: params }); + + // Resolve room params + const resolved = await this.resolveRoomParams(corrected.parameters, ctx.contextId); + + // Inject caller identity + const paramsWithCaller = { + ...resolved, + userId: ctx.callerId, + contextId: ctx.contextId, + }; + + // Execute via ToolRegistry + const registryResult: ToolExecutionResult = await this.toolRegistry.executeTool( + corrected.toolName, + paramsWithCaller, + ctx.sessionId, + ctx.contextId, + ctx.context + ); + + return { + toolName: registryResult.toolName, + success: registryResult.success, + content: registryResult.success + ? (registryResult.content ?? '') + : (registryResult.error ?? 'Unknown error'), + error: registryResult.error, + }; + } + + /** + * Execute native tool calls from an agentic loop. + * Returns per-tool NativeToolResult objects with tool_use_id correlation. + * + * Handles loop detection: blocked calls get error results. + * Truncates honestly if results exceed maxResultChars. + */ + async executeNativeToolCalls( + nativeCalls: NativeToolCall[], + ctx: ToolCallContext, + maxResultChars = 30_000 + ): Promise { + if (nativeCalls.length === 0) { + return { results: [], blockedCount: 0 }; + } + + // Convert native → internal format (decode sanitized names) + const internalCalls: ToolCall[] = nativeCalls.map(tc => ({ + toolName: unsanitizeToolName(tc.name), + parameters: Object.fromEntries( + Object.entries(tc.input).map(([k, v]) => [k, String(v)]) + ) as Record, + })); + + // Partition: allowed vs loop-blocked + const allowed: { idx: number; call: ToolCall }[] = []; + let blockedCount = 0; + + for (let i = 0; i < internalCalls.length; i++) { + const tc = internalCalls[i]; + if (this.isLoopDetected(tc.toolName, tc.parameters, ctx.callerId)) { + blockedCount++; + } else { + allowed.push({ idx: i, call: tc }); + } + } + + // Execute allowed calls in parallel + const execResults = await Promise.all( + allowed.map(({ call }) => this.executeToolCall(call.toolName, call.parameters, ctx)) + ); + + // Map results back to native format with tool_use_id correlation + const results: NativeToolResult[] = []; + let execIdx = 0; + + for (let i = 0; i < nativeCalls.length; i++) { + const tc = internalCalls[i]; + const isBlocked = this.isLoopDetected(tc.toolName, tc.parameters, ctx.callerId) + && !allowed.some(a => a.idx === i); + + // Check if this index was in the allowed set + const allowedEntry = allowed.find(a => a.idx === i); + + if (!allowedEntry) { + // Blocked by loop detection + results.push({ + toolUseId: nativeCalls[i].id, + content: 'Tool call blocked by loop detection.', + isError: true, + }); + continue; + } + + const exec = execResults[execIdx++]; + let content = exec.success ? exec.content : (exec.error ?? 'Unknown error'); + + // Truncate honestly if too large + if (content.length > maxResultChars) { + content = content.slice(0, maxResultChars) + `\n[...truncated, ${content.length} chars total]`; + } + + results.push({ + toolUseId: nativeCalls[i].id, + content, + isError: !exec.success || undefined, + }); + } + + return { results, blockedCount }; + } + + /** + * Execute XML-parsed tool calls and return formatted results string. + * Used by the XML fallback path for non-native providers. + */ + async executeXmlToolCalls( + toolCalls: ToolCall[], + ctx: ToolCallContext + ): Promise<{ formattedResults: string; blockedCount: number }> { + if (toolCalls.length === 0) { + return { formattedResults: '', blockedCount: 0 }; + } + + // Filter loop-detected calls + let blockedCount = 0; + const filtered = toolCalls.filter(tc => { + if (this.isLoopDetected(tc.toolName, tc.parameters, ctx.callerId)) { + blockedCount++; + return false; + } + return true; + }); + + if (filtered.length === 0) { + return { + formattedResults: '[All tool calls blocked - infinite loop detected]', + blockedCount, + }; + } + + // Execute all in parallel + const results = await Promise.all( + filtered.map(tc => this.executeToolCall(tc.toolName, tc.parameters, ctx)) + ); + + // Format as XML + const formatted = results.map(r => { + if (r.success) { + return `\n${r.toolName}\nsuccess\n\n${r.content}\n\n`; + } + return `\n${r.toolName}\nerror\n\n\`\`\`\n${r.error ?? 'Unknown error'}\n\`\`\`\n\n`; + }).join('\n\n'); + + return { formattedResults: formatted, blockedCount }; + } + + // ─── Utility ───────────────────────────────────────────────────── + + /** + * Get list of all available tool names from the registry. + */ + get availableTools(): string[] { + return this.toolRegistry.getAllTools().map(t => t.name); + } +} diff --git a/src/debug/jtag/system/user/server/PersonaUser.ts b/src/debug/jtag/system/user/server/PersonaUser.ts index a13190b14..6b2a2863b 100644 --- a/src/debug/jtag/system/user/server/PersonaUser.ts +++ b/src/debug/jtag/system/user/server/PersonaUser.ts @@ -24,7 +24,8 @@ import { Commands } from '../../core/shared/Commands'; import type { JTAGClient } from '../../core/client/shared/JTAGClient'; import { ChatMessageEntity } from '../../data/entities/ChatMessageEntity'; import type { RoomEntity } from '../../data/entities/RoomEntity'; -import type { UserCreateParams, ModelConfig } from '../../../commands/user/create/shared/UserCreateTypes'; +import type { UserCreateParams } from '../../../commands/user/create/shared/UserCreateTypes'; +import type { ModelConfig } from '../../data/entities/UserEntity'; import type { DataCreateParams, DataCreateResult } from '../../../commands/data/create/shared/DataCreateTypes'; import type { DataReadParams, DataReadResult } from '../../../commands/data/read/shared/DataReadTypes'; import type { DataUpdateParams, DataUpdateResult } from '../../../commands/data/update/shared/DataUpdateTypes'; @@ -80,10 +81,7 @@ import { PersonaStateManager } from './modules/PersonaState'; import type { InboxMessage } from './modules/PersonaInbox'; import type { InboxTask, TaskStatus, ProcessableMessage } from './modules/QueueItemTypes'; import { TrainingDataAccumulator } from './modules/TrainingDataAccumulator'; -import { SelfTaskGenerator } from './modules/SelfTaskGenerator'; import { PersonaGenome, type PersonaGenomeConfig } from './modules/PersonaGenome'; -import type { PersonaCentralNervousSystem } from './modules/central-nervous-system/PersonaCentralNervousSystem'; -import { CNSFactory } from './modules/central-nervous-system/CNSFactory'; import type { QueueItem } from './modules/PersonaInbox'; import type { FastPathDecision } from './modules/central-nervous-system/CNSTypes'; import { PersonaMemory } from './modules/cognitive/memory/PersonaMemory'; @@ -175,7 +173,7 @@ export class PersonaUser extends AIUser { } // PHASE 5: Self-task generation (autonomous work creation) - readonly taskGenerator: SelfTaskGenerator; + // taskGenerator removed — self-task generation now runs in Rust (ChannelModule.tick()) // Tool result tracking (prevents infinite loops from re-processing tool results) readonly taskTracker: PersonaTaskTracker; @@ -184,7 +182,7 @@ export class PersonaUser extends AIUser { private limbic: LimbicSystem | null = null; // NEUROANATOMY: Prefrontal cortex (cognition, evaluation, planning) - public prefrontal: PrefrontalCortex | null = null; // Public for CNS and Hippocampus access + public prefrontal: PrefrontalCortex | null = null; // Public for Hippocampus access // NEUROANATOMY: Motor cortex (action, execution, output) private motorCortex: MotorCortex | null = null; @@ -226,7 +224,7 @@ export class PersonaUser extends AIUser { } /** - * Nullable accessor for Rust bridge (used by CNSFactory during construction). + * Nullable accessor for Rust bridge (used during construction before bridge is ready). * Unlike rustCognition getter, this returns null instead of throwing. */ public get rustCognitionBridge(): RustCognitionBridge | null { @@ -287,8 +285,7 @@ export class PersonaUser extends AIUser { // NOTE: DecisionAdapterChain removed - Rust cognition handles fast-path decisions // See: workers/continuum-core/src/persona/cognition.rs (PersonaCognitionEngine) - // CNS: Central Nervous System orchestrator - readonly cns: PersonaCentralNervousSystem; + // CNS removed — scheduling inlined into PersonaAutonomousLoop (service loop calls Rust directly) // Task execution module (extracted from PersonaUser for modularity) readonly taskExecutor: PersonaTaskExecutor; @@ -441,13 +438,7 @@ export class PersonaUser extends AIUser { return { queueSize: 0, activeRequests: 0, maxConcurrent: 1, load: 0.0 }; }); - // PHASE 5: Self-task generation for autonomous work creation - this.taskGenerator = new SelfTaskGenerator(this.id, this.displayName, { - enabled: true, // Enable self-task generation - memoryReviewInterval: 3600000, // 1 hour - skillAuditInterval: 21600000, // 6 hours - unfinishedWorkThreshold: 1800000 // 30 minutes - }); + // Self-task generation now runs in Rust (ChannelModule.tick() → SelfTaskGenerator) // Tool result tracking (prevents infinite response loops) this.taskTracker = new PersonaTaskTracker(); @@ -536,6 +527,8 @@ export class PersonaUser extends AIUser { // Wire Rust bridge into consciousness for timeline event corpus coherence if (this._rustCognition) { this._consciousness.setRustBridge(this._rustCognition); + // Wire into response generator for text similarity (kills TS Jaccard duplicates) + this.motorCortex!.responseGenerator.setRustBridge(this._rustCognition); } this.log.info(`🧠 ${this.displayName}: UnifiedConsciousness initialized (cross-context awareness enabled)`); @@ -556,9 +549,7 @@ export class PersonaUser extends AIUser { cognitionLogger ); - // CNS: Central Nervous System orchestrator (capability-based) - // Note: mind/soul/body are non-null at this point (initialized above) - this.cns = CNSFactory.create(this); + // CNS scheduling inlined into PersonaAutonomousLoop (calls Rust serviceCycleFull directly) // Message evaluation module (pass PersonaUser reference for dependency injection) this.messageEvaluator = new PersonaMessageEvaluator(this); @@ -566,7 +557,7 @@ export class PersonaUser extends AIUser { // Autonomous servicing loop module (pass PersonaUser reference for dependency injection) this.autonomousLoop = new PersonaAutonomousLoop(this, cognitionLogger); - this.log.info(`🔧 ${this.displayName}: Initialized inbox, personaState, taskGenerator, memory (genome + RAG), CNS, trainingAccumulator, toolExecutor, responseGenerator, messageEvaluator, autonomousLoop, and cognition system (workingMemory, selfState, planFormulator)`); + this.log.info(`🔧 ${this.displayName}: Initialized inbox, personaState, memory (genome + RAG), trainingAccumulator, toolExecutor, responseGenerator, messageEvaluator, autonomousLoop, and cognition system (workingMemory, selfState, planFormulator)`); // Initialize worker thread for this persona // Worker uses fast small model for gating decisions (should-respond check) @@ -667,6 +658,37 @@ export class PersonaUser extends AIUser { this.inbox.setRustBridge(this._rustCognition); } this.log.info(`🦀 ${this.displayName}: Rust cognition bridge connected (inbox routing enabled)`); + + // Sync rate limiter config to Rust (mirrors TS RateLimiter config) + if (this._rustCognition) { + const rlConfig = this.rateLimiter.getConfig(); + await this._rustCognition.configureRateLimiter( + rlConfig.minSecondsBetweenResponses, + rlConfig.maxResponsesPerSession + ); + this.log.info(`🦀 ${this.displayName}: Rate limiter synced to Rust (min=${rlConfig.minSecondsBetweenResponses}s, max=${rlConfig.maxResponsesPerSession})`); + } + + // Sync genome adapter registry to Rust for model selection + if (this._rustCognition && this.memory?.genome) { + const adapters = this.memory.genome.getAllAdapters().map(a => ({ + name: a.getName(), + domain: a.getDomain(), + ollama_model_name: a.getOllamaModelName() ?? undefined, + is_loaded: a.isLoaded(), + is_current: a === this.memory!.genome.getCurrentAdapter(), + priority: a.getPriority(), + })); + if (adapters.length > 0) { + await this._rustCognition.syncAdapters(adapters as any); + this.log.info(`🦀 ${this.displayName}: ${adapters.length} adapters synced to Rust for model selection`); + } + + // Wire Rust bridge into genome for LRU eviction decisions + this.memory.genome.setRustBridge(this._rustCognition); + await this.memory.genome.syncToRust(); + this.log.info(`🦀 ${this.displayName}: Genome paging engine synced to Rust`); + } } catch (error) { this.log.error(`🦀 ${this.displayName}: Rust cognition init failed (messages will error):`, error); // Don't throw - let persona initialize, but message handling will fail loudly @@ -1155,11 +1177,12 @@ export class PersonaUser extends AIUser { } // STEP 2: Deduplication - prevent evaluating same message multiple times + // Uses TS-local Set (not Rust DashSet) because CognitionEngine.evaluated_messages + // serves a different purpose (fast_path_decision pipeline dedup). Merging them + // caused all messages to be skipped — channel tick marks them before PersonaUser sees them. if (this.rateLimiter.hasEvaluatedMessage(messageEntity.id)) { return; // Already evaluated this message } - - // Mark as evaluated this.rateLimiter.markMessageEvaluated(messageEntity.id); // STEP 3: Skip resolved messages (moderator marked as no longer needing responses) @@ -1503,8 +1526,8 @@ export class PersonaUser extends AIUser { messages, model: this.modelConfig.model || LOCAL_MODELS.DEFAULT, temperature: request.temperature ?? this.modelConfig.temperature ?? 0.7, - maxTokens: request.maxTokens ?? this.modelConfig.maxTokens ?? 150, - preferredProvider: (this.modelConfig.provider || 'candle') as TextGenerationRequest['preferredProvider'], + maxTokens: request.maxTokens ?? this.modelConfig.maxTokens, + provider: this.modelConfig.provider || 'candle', intelligenceLevel: this.entity.intelligenceLevel, personaContext: { uniqueId: this.entity.uniqueId, @@ -1808,6 +1831,7 @@ export class PersonaUser extends AIUser { try { // Query the sender's UserEntity to check their type const result = await this.client.daemons.commands.execute>(DATA_COMMANDS.READ, { + userId: this.client.userId, collection: COLLECTIONS.USERS, id: senderId, context: this.client.context, @@ -1979,34 +2003,6 @@ export class PersonaUser extends AIUser { } - /** - * CNS callback: Poll tasks from database - * - * Called by PersonaCentralNervousSystem.serviceCycle() via callback pattern. - */ - public async pollTasksFromCNS(): Promise { - await this.autonomousLoop.pollTasksFromCNS(); - } - - /** - * CNS callback: Generate self-tasks for autonomous work - * - * Called by PersonaCentralNervousSystem.serviceCycle() via callback pattern. - */ - public async generateSelfTasksFromCNS(): Promise { - await this.autonomousLoop.generateSelfTasksFromCNS(); - } - - /** - * CNS callback: Handle chat message from CNS orchestrator - * - * This is called by PersonaCentralNervousSystem.serviceChatDomain() via callback pattern. - * Preserves existing message handling logic (evaluation, RAG, AI response, posting). - */ - public async handleChatMessageFromCNS(item: QueueItem, decision?: FastPathDecision): Promise { - await this.autonomousLoop.handleChatMessageFromCNS(item, decision); - } - /** * PHASE 5: Execute a task based on its type * diff --git a/src/debug/jtag/system/user/server/config/PersonaModelConfigs.ts b/src/debug/jtag/system/user/server/config/PersonaModelConfigs.ts index 58d4fdab1..8e07b8f2c 100644 --- a/src/debug/jtag/system/user/server/config/PersonaModelConfigs.ts +++ b/src/debug/jtag/system/user/server/config/PersonaModelConfigs.ts @@ -5,7 +5,7 @@ * Extracted from PersonaUser.ts for better organization and maintainability. */ -import type { ModelConfig } from '../../../../commands/user/create/shared/UserCreateTypes'; +import type { ModelConfig } from '../../../data/entities/UserEntity'; import { MODEL_IDS } from '../../../shared/Constants'; /** diff --git a/src/debug/jtag/system/user/server/modules/PersonaAutonomousLoop.ts b/src/debug/jtag/system/user/server/modules/PersonaAutonomousLoop.ts index 8f38d3f5e..e3eab11fe 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaAutonomousLoop.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaAutonomousLoop.ts @@ -1,17 +1,13 @@ /** - * PersonaAutonomousLoop - Handles autonomous servicing loop for PersonaUser + * PersonaAutonomousLoop - Thin orchestrator for PersonaUser servicing * - * REFACTORING: Extracted from PersonaUser.ts (lines 1893-2140) - * Pure function extraction - no behavioral changes + * All background timers (task polling, self-task generation, training checks) have + * been moved to Rust's ChannelModule.tick() — ONE tick loop replaces 30+ TS setIntervals. * - * This module manages: - * - RTOS-inspired autonomous servicing loop - * - Signal-based waiting (not polling) for instant response - * - Adaptive cadence based on mood/energy state - * - Task polling from database - * - Self-task generation - * - Message handling via CNS orchestration - * - Training readiness checks + * What remains in TypeScript: + * - Signal-based service loop (wait for work → Rust schedule → execute in TS) + * - Item dispatch (LoRA activation, message evaluation, task execution) + * - These stay in TS because they call AI providers, RAG, file I/O */ import type { UUID } from '../../../core/types/CrossPlatformUUID'; @@ -19,7 +15,8 @@ import { ORM } from '../../../../daemons/data-daemon/server/ORM'; import { COLLECTIONS } from '../../../shared/Constants'; import type { TaskEntity } from '../../../data/entities/TaskEntity'; import { RoomEntity } from '../../../data/entities/RoomEntity'; -import { taskEntityToInboxTask, inboxMessageToProcessable, type InboxTask, type QueueItem } from './QueueItemTypes'; +import { inboxMessageToProcessable, type InboxTask, type QueueItem } from './QueueItemTypes'; +import { fromRustServiceItem } from './QueueItemTypes'; import type { FastPathDecision } from './central-nervous-system/CNSTypes'; // Import PersonaUser directly - circular dependency is fine for type-only imports @@ -27,9 +24,6 @@ import type { PersonaUser } from '../PersonaUser'; export class PersonaAutonomousLoop { private servicingLoopActive: boolean = false; - private trainingCheckLoop: NodeJS.Timeout | null = null; - private taskPollLoop: NodeJS.Timeout | null = null; - private selfTaskLoop: NodeJS.Timeout | null = null; private log: (message: string) => void; constructor(private readonly personaUser: PersonaUser, logger?: (message: string) => void) { @@ -37,53 +31,22 @@ export class PersonaAutonomousLoop { } /** - * Start autonomous servicing loop + * Start autonomous servicing loop. * - * Creates: - * 1. Continuous async service loop (signal-based waiting, not polling) - * 2. Task poll loop (every 10 seconds) — OFF the hot path - * 3. Self-task generation loop (every 30 seconds) — OFF the hot path - * 4. Training readiness check loop (every 60 seconds) - * - * Architecture: - * - Hot path is ONLY: wait for signal → Rust service_cycle → execute → drain → repeat - * - DB queries and self-task generation run on their own timers, never blocking the hot path - * - Loop uses signal/mutex pattern (RTOS-style, performant, no CPU spinning) + * Only creates the reactive service loop. All background timers + * (task polling, self-task generation, training checks) run in Rust. */ startAutonomousServicing(): void { this.log(`🔄 ${this.personaUser.displayName}: Starting autonomous servicing (SIGNAL-BASED WAITING)`); - - // Create continuous async loop (not setInterval) - signal-based waiting this.servicingLoopActive = true; this.runServiceLoop().catch((error: any) => { this.log(`❌ ${this.personaUser.displayName}: Service loop crashed: ${error}`); }); - - // Task polling on separate timer (OFF hot path — was previously called every service cycle) - this.taskPollLoop = setInterval(async () => { - await this.pollTasks(); - }, 10000); // 10 seconds - - // Self-task generation on separate timer (OFF hot path) - this.selfTaskLoop = setInterval(async () => { - await this.generateSelfTasksFromCNS(); - }, 30000); // 30 seconds - - // Training readiness checks (every 60 seconds) - this.log(`🧬 ${this.personaUser.displayName}: Starting training readiness checks (every 60s)`); - this.trainingCheckLoop = setInterval(async () => { - await this.checkTrainingReadiness(); - }, 60000); // 60 seconds } /** - * Continuous service loop - runs until servicingLoopActive = false - * Uses signal-based waiting (not polling) for instant response - * - * CPU Safety: - * - Every iteration MUST block on inbox.waitForWork() (EventEmitter-based) - * - Errors logged but loop continues (will block again on next waitForWork) - * - Node.js event loop handles scheduling (no pthread primitives needed) + * Continuous service loop — runs until servicingLoopActive = false. + * Each iteration: wait for signal → Rust serviceCycleFull → dispatch item → repeat */ private async runServiceLoop(): Promise { while (this.servicingLoopActive) { @@ -91,115 +54,59 @@ export class PersonaAutonomousLoop { await this.serviceInbox(); } catch (error) { this.log(`❌ ${this.personaUser.displayName}: Error in service loop: ${error}`); - // Loop continues - next iteration will block on waitForWork() again } } this.log(`🛑 ${this.personaUser.displayName}: Service loop stopped`); } /** - * PHASE 7.5.1: Check training readiness and trigger micro-tuning + * Single service cycle — wait for work, then drain via Rust. * - * Called periodically (less frequently than serviceInbox) to check if any - * domain buffers are ready for training. When threshold reached, automatically - * triggers genome/train command for that domain. - * - * Delegates to PersonaTrainingManager module for actual execution. - */ - private async checkTrainingReadiness(): Promise { - // Delegate to training manager module - await this.personaUser.trainingManager.checkTrainingReadiness(); - } - - /** - * Poll task database for pending tasks assigned to this persona - * Convert TaskEntity → InboxTask and enqueue in inbox + * Inlined from PersonaCentralNervousSystem (eliminated the wrapper): + * 1. Wait for work (signal-based, cadence from PersonaState) + * 2. Drain loop: call Rust serviceCycleFull repeatedly until queue empty */ - private async pollTasks(): Promise { - try { - // Query for pending tasks assigned to this persona - const queryResult = await ORM.query({ - collection: COLLECTIONS.TASKS, - filter: { - assigneeId: this.personaUser.id, - status: 'pending' - }, - limit: 10 // Poll top 10 pending tasks - }); - - if (!queryResult.success || !queryResult.data || queryResult.data.length === 0) { - return; // No pending tasks - } + private async serviceInbox(): Promise { + const cadence = this.personaUser.prefrontal!.personaState.getCadence(); + const hasWork = await this.personaUser.inbox.waitForWork(cadence); - // Convert each TaskEntity to InboxTask and enqueue - for (const record of queryResult.data) { - const task = record.data; + if (!hasWork) { + return; + } - // Convert to InboxTask using helper - // Note: DataDaemon stores ID separately from data, so we need to inject it - const inboxTask = taskEntityToInboxTask({ - ...task, - id: record.id // Inject ID from record root - }); + // Drain loop — process all queued items before returning to wait + let itemsProcessed = 0; + const MAX_DRAIN = 20; - // Enqueue in inbox (unified priority queue) - await this.personaUser.inbox.enqueue(inboxTask); + while (itemsProcessed < MAX_DRAIN) { + const bridge = this.personaUser.rustCognitionBridge!; + const result = await bridge.serviceCycleFull(); - this.log(`📋 ${this.personaUser.displayName}: Enqueued task ${task.taskType} (priority=${task.priority.toFixed(2)})`); + if (!result.should_process || !result.item) { + break; } - this.log(`✅ ${this.personaUser.displayName}: Polled ${queryResult.data.length} pending tasks`); - - } catch (error) { - this.log(`❌ ${this.personaUser.displayName}: Error polling tasks: ${error}`); - } - } - - /** - * CNS callback: Poll tasks from database - * - * Called by PersonaCentralNervousSystem.serviceCycle() via callback pattern. - */ - async pollTasksFromCNS(): Promise { - await this.pollTasks(); - } - - /** - * CNS callback: Generate self-tasks for autonomous work - * - * Called by PersonaCentralNervousSystem.serviceCycle() via callback pattern. - */ - async generateSelfTasksFromCNS(): Promise { - try { - const selfTasks = await this.personaUser.taskGenerator.generateSelfTasks(); - if (selfTasks.length > 0) { - this.log(`🧠 ${this.personaUser.displayName}: Generated ${selfTasks.length} self-tasks`); - - // Persist each task to database and enqueue in inbox - for (const task of selfTasks) { - const storedTask = await ORM.store(COLLECTIONS.TASKS, task); - if (storedTask) { - // Convert to InboxTask and enqueue (use storedTask which has database ID) - const inboxTask = taskEntityToInboxTask(storedTask); - await this.personaUser.inbox.enqueue(inboxTask); - this.log(`📋 ${this.personaUser.displayName}: Created self-task: ${task.description}`); - } else { - this.log(`❌ ${this.personaUser.displayName}: Failed to create self-task`); - } - } + // Convert Rust JSON → TS QueueItem + const queueItem = fromRustServiceItem(result.item as Record); + if (!queueItem) { + this.log(`⚠️ ${this.personaUser.displayName}: Rust returned unparseable item`); + break; } - } catch (error) { - this.log(`❌ ${this.personaUser.displayName}: Error generating self-tasks: ${error}`); + + // Dispatch to handler with pre-computed decision + await this.handleItem(queueItem, result.decision ?? undefined); + itemsProcessed++; } } /** - * CNS callback: Handle chat message from CNS orchestrator + * Handle a dequeued item — dispatch based on type. * - * This is called by PersonaCentralNervousSystem.serviceChatDomain() via callback pattern. - * Preserves existing message handling logic (evaluation, RAG, AI response, posting). + * Handles both messages and tasks: + * - Messages: LoRA activation → evaluateAndPossiblyRespondWithCognition → bookmark + * - Tasks: mark in_progress → LoRA activation → executeTask */ - async handleChatMessageFromCNS(item: QueueItem, decision?: FastPathDecision): Promise { + async handleItem(item: QueueItem, decision?: FastPathDecision): Promise { const handlerStart = performance.now(); // If this is a task, update status to 'in_progress' in database (prevents re-polling) @@ -211,75 +118,40 @@ export class PersonaAutonomousLoop { ); } - // PHASE 6: Activate appropriate LoRA adapter based on domain + // Activate appropriate LoRA adapter based on domain if (item.domain) { const domainToAdapter: Record = { 'chat': 'conversational', 'code': 'typescript-expertise', 'self': 'self-improvement' }; - const adapterName = domainToAdapter[item.domain]; - if (adapterName) { - await this.personaUser.memory.genome.activateSkill(adapterName); - } else { - // Unknown domain - default to conversational - await this.personaUser.memory.genome.activateSkill('conversational'); - } + const adapterName = domainToAdapter[item.domain] || 'conversational'; + await this.personaUser.memory.genome.activateSkill(adapterName); } - const setupMs = performance.now() - handlerStart; - - // Type-safe handling: Check if this is a message or task if (item.type === 'message') { - // Convert InboxMessage → ProcessableMessage (typed, no `any`) const processable = inboxMessageToProcessable(item); const senderIsHuman = item.senderType === 'human'; const messageText = item.content ?? ''; - // Process message using cognition-enhanced evaluation logic - // Pass pre-computed decision from Rust serviceCycleFull (eliminates separate IPC call) - const evalStart = performance.now(); await this.personaUser.evaluateAndPossiblyRespondWithCognition(processable, senderIsHuman, messageText, decision); - const evalMs = performance.now() - evalStart; - - // Update bookmark AFTER processing complete - enables true pause/resume - // Shutdown mid-processing will re-query this message on restart await this.personaUser.updateMessageBookmark(item.roomId, item.timestamp, item.id); const totalMs = performance.now() - handlerStart; - this.log(`[TIMING] ${this.personaUser.displayName}: handleChatMessage total=${totalMs.toFixed(1)}ms (setup=${setupMs.toFixed(1)}ms, eval=${evalMs.toFixed(1)}ms, hasDecision=${!!decision})`); + this.log(`[TIMING] ${this.personaUser.displayName}: handleItem total=${totalMs.toFixed(1)}ms (hasDecision=${!!decision})`); } else if (item.type === 'task') { - // PHASE 5: Task execution based on task type await this.executeTask(item); } // Update inbox load in state (affects mood calculation) this.personaUser.personaState.updateInboxLoad(this.personaUser.inbox.getSize()); - - // Note: No cadence adjustment needed with signal-based waiting - // Loop naturally adapts: fast when busy (instant signal), slow when idle (blocked on wait) } /** - * PHASE 3: Service inbox (one iteration) - * - * Delegates to CNS orchestrator for intelligent scheduling and coordination. - * CNS handles: priority selection, mood/energy checks, domain scheduling, coordination - */ - private async serviceInbox(): Promise { - // Delegate to CNS orchestrator (capability-based multi-domain attention management) - await this.personaUser.cns.serviceCycle(); - } - - /** - * PHASE 5: Execute a task based on its type - * - * Handles all task types: memory-consolidation, skill-audit, fine-tune-lora, resume-work, - * and code tasks (write-feature, review-code). - * Delegates to PersonaTaskExecutor module for actual execution. + * Execute a task based on its type. + * Delegates to PersonaTaskExecutor for actual execution. */ private async executeTask(task: InboxTask): Promise { - // For code-domain tasks, ensure workspace exists with room-aware mode if (task.domain === 'code') { const roomId = task.metadata?.roomId ?? task.contextId; const roomSlug = await this.resolveRoomSlug(roomId); @@ -289,14 +161,11 @@ export class PersonaAutonomousLoop { taskSlug: roomSlug, }); } - - // Delegate to task executor module await this.personaUser.taskExecutor.executeTask(task); } /** * Resolve a room UUID to its uniqueId slug for workspace naming. - * Falls back to truncated UUID if room lookup fails. */ private async resolveRoomSlug(roomId: UUID): Promise { try { @@ -309,26 +178,11 @@ export class PersonaAutonomousLoop { } /** - * Stop autonomous servicing loops and cleanup + * Stop autonomous servicing loop. + * No timers to clear — Rust handles all background work. */ async stopServicing(): Promise { - // Stop service loop (signal-based while loop) this.servicingLoopActive = false; this.log(`🔄 ${this.personaUser.displayName}: Stopped autonomous servicing loop`); - - // Stop all interval-based loops - if (this.taskPollLoop) { - clearInterval(this.taskPollLoop); - this.taskPollLoop = null; - } - if (this.selfTaskLoop) { - clearInterval(this.selfTaskLoop); - this.selfTaskLoop = null; - } - if (this.trainingCheckLoop) { - clearInterval(this.trainingCheckLoop); - this.trainingCheckLoop = null; - } - this.log(`🧬 ${this.personaUser.displayName}: Stopped all background loops`); } } diff --git a/src/debug/jtag/system/user/server/modules/PersonaGenome.ts b/src/debug/jtag/system/user/server/modules/PersonaGenome.ts index 482f935d5..1f10427ea 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaGenome.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaGenome.ts @@ -21,6 +21,8 @@ import type { UUID } from '../../../core/types/CrossPlatformUUID'; import { LoRAAdapter, type LoRAAdapterState } from './LoRAAdapter'; import { generateUUID } from '../../../core/types/CrossPlatformUUID'; import type { AIProviderAdapter } from '../../../../daemons/ai-provider-daemon/shared/AIProviderTypesV2'; +import type { RustCognitionBridge } from './RustCognitionBridge'; +import type { GenomeAdapterInfo } from '../../../../shared/generated'; /** * Genome configuration @@ -101,6 +103,13 @@ export class PersonaGenome { */ private aiProvider: AIProviderAdapter | null = null; + /** + * Rust cognition bridge for LRU eviction decisions. + * When set, activateSkill() delegates decisions to Rust (sub-microsecond). + * Without this, falls back to local TS logic (for tests/init before bridge is ready). + */ + private rustBridge: RustCognitionBridge | null = null; + constructor(config: PersonaGenomeConfig, logger?: (message: string) => void) { this.log = logger || console.log.bind(console); this.config = config; @@ -152,6 +161,65 @@ export class PersonaGenome { return this.aiProvider; } + /** + * Set Rust cognition bridge for sub-microsecond LRU decisions. + * Call after PersonaUser creates the bridge. + */ + setRustBridge(bridge: RustCognitionBridge): void { + this.rustBridge = bridge; + this.log(`🧬 PersonaGenome: Rust bridge set (LRU decisions delegated to Rust)`); + } + + /** + * Build GenomeAdapterInfo array for syncing to Rust. + */ + private buildAdapterInfoForRust(): GenomeAdapterInfo[] { + const result: GenomeAdapterInfo[] = []; + + for (const [, adapter] of this.activeAdapters) { + const state = adapter.getState(); + result.push({ + name: state.name, + domain: state.domain, + size_mb: state.sizeMB, + priority: state.priority, + is_loaded: true, + last_used_ms: state.lastUsed, + ollama_model_name: state.ollamaModelName ?? undefined, + }); + } + + for (const [, adapter] of this.availableAdapters) { + const state = adapter.getState(); + result.push({ + name: state.name, + domain: state.domain, + size_mb: state.sizeMB, + priority: state.priority, + is_loaded: false, + last_used_ms: state.lastUsed, + ollama_model_name: state.ollamaModelName ?? undefined, + }); + } + + return result; + } + + /** + * Sync current adapter state to Rust. + * Call after adapter registration, load, or unload. + */ + async syncToRust(): Promise { + if (!this.rustBridge) return; + + try { + const adapters = this.buildAdapterInfoForRust(); + await this.rustBridge.genomeSync(adapters, this.config.memoryBudgetMB); + } catch (error) { + this.log(`⚠️ PersonaGenome: Rust sync failed: ${error}`); + } + } + /** * Register a new adapter (adds to available pool, doesn't load) * @@ -186,15 +254,70 @@ export class PersonaGenome { } /** - * Activate a skill adapter for the current task + * Activate a skill adapter for the current task. * - * If already loaded: Just switch to it and update lastUsed - * If not loaded: Load from disk (evicting LRU adapters if needed) + * When Rust bridge is available: Rust decides what to evict/load (sub-μs), + * TypeScript executes the GPU operations. * - * NOTE: Actual GPU allocation is handled by the Rust inference-worker. - * This method just tracks logical state and delegates to the worker via AIProvider. + * When no Rust bridge (tests/init): Uses local TS logic. */ async activateSkill(skillName: string): Promise { + if (this.rustBridge) { + return this.activateSkillViaRust(skillName); + } + return this.activateSkillLocal(skillName); + } + + /** + * Rust-backed skill activation: ONE IPC call for the decision, + * then execute GPU ops based on Rust's instructions. + */ + private async activateSkillViaRust(skillName: string): Promise { + const decision = await this.rustBridge!.genomeActivateSkill( + skillName, this.config.memoryBudgetMB + ); + + if (!decision.activated) { + return; // Unknown skill — Rust said no + } + + // Cache hit — just update local state + if (!decision.to_load) { + const adapter = this.activeAdapters.get(skillName); + if (adapter) { + adapter.markUsed(); + this.currentAdapter = adapter; + } + return; + } + + // Execute evictions (GPU unload) + for (const evictedName of decision.evicted) { + const victim = this.activeAdapters.get(evictedName); + if (victim) { + const freedMB = victim.getSize(); + this.log(`📤 PersonaGenome: Evicting ${evictedName} (Rust decision) to free ${freedMB}MB...`); + await victim.unload(); + this.activeAdapters.delete(evictedName); + this.memoryUsedMB -= freedMB; + } + } + + // Load the new adapter (GPU load) + const adapter = this.availableAdapters.get(skillName); + if (!adapter) return; + + await adapter.load(this.config.baseModel); + this.activeAdapters.set(skillName, adapter); + this.availableAdapters.delete(skillName); + this.memoryUsedMB += adapter.getSize(); + this.currentAdapter = adapter; + } + + /** + * Local TS skill activation (for tests/init before Rust bridge is ready). + */ + private async activateSkillLocal(skillName: string): Promise { // Already active? Just mark as used if (this.activeAdapters.has(skillName)) { const adapter = this.activeAdapters.get(skillName)!; @@ -216,13 +339,12 @@ export class PersonaGenome { await this.evictLRU(); } - // Load adapter - delegates to AIProvider (CandleAdapter → inference-worker) - // The worker handles actual GPU allocation - // Pass baseModel so CandleAdapter knows which model to attach the adapter to + // Load adapter await adapter.load(this.config.baseModel); // Track logical state this.activeAdapters.set(skillName, adapter); + this.availableAdapters.delete(skillName); this.memoryUsedMB += adapterSize; this.currentAdapter = adapter; } diff --git a/src/debug/jtag/system/user/server/modules/PersonaGenomeManager.ts b/src/debug/jtag/system/user/server/modules/PersonaGenomeManager.ts index 64b9f6544..071757397 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaGenomeManager.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaGenomeManager.ts @@ -55,6 +55,7 @@ export class PersonaGenomeManager { try { const result = await client.daemons.commands.execute>(DATA_COMMANDS.READ, { + userId: client.userId, collection: 'genomes', id: entity.genomeId, context: client.context, @@ -93,6 +94,7 @@ export class PersonaGenomeManager { // Persist to database const result = await client.daemons.commands.execute>(DATA_COMMANDS.UPDATE, { + userId: client.userId, collection: COLLECTIONS.USERS, id: entity.id, data: { genomeId }, diff --git a/src/debug/jtag/system/user/server/modules/PersonaMessageEvaluator.ts b/src/debug/jtag/system/user/server/modules/PersonaMessageEvaluator.ts index ac1037307..c2530e2b2 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaMessageEvaluator.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaMessageEvaluator.ts @@ -19,26 +19,24 @@ import { Events } from '../../../core/shared/Events'; import { COLLECTIONS } from '../../../shared/Constants'; import type { ChatMessageEntity } from '../../../data/entities/ChatMessageEntity'; import type { ProcessableMessage } from './QueueItemTypes'; -import type { UserEntity } from '../../../data/entities/UserEntity'; -import type { RoomEntity } from '../../../data/entities/RoomEntity'; +// UserEntity and RoomEntity imports removed — isSenderHuman() moved to Rust import { CognitionLogger } from './cognition/CognitionLogger'; import { SignalDetector, getSignalDetector } from './SignalDetector'; import { getTrainingBuffer } from './TrainingBuffer'; import type { Task } from './cognition/reasoning/types'; import { ChatRAGBuilder } from '../../../rag/builders/ChatRAGBuilder'; +import { getToolCapability } from './ToolFormatAdapter'; import { CoordinationDecisionLogger, type LogDecisionParams } from '../../../coordination/server/CoordinationDecisionLogger'; import type { RAGContext } from '../../../data/entities/CoordinationDecisionEntity'; import type { RAGContext as PipelineRAGContext, RAGArtifact } from '../../../rag/shared/RAGTypes'; -import type { AIDecisionContext } from '../../../ai/server/AIDecisionService'; -import { AIDecisionService } from '../../../ai/server/AIDecisionService'; import { contentPreview, truncate } from '../../../../shared/utils/StringUtils'; import type { DecisionContext } from './cognition/adapters/IDecisionAdapter'; import { getChatCoordinator } from '../../../coordination/server/ChatCoordinationStream'; import { calculateMessagePriority } from './PersonaInbox'; import { toInboxMessageRequest } from './RustCognitionBridge'; -import type { SenderType } from '../../../../shared/generated'; +import type { SenderType, FullEvaluateResult } from '../../../../shared/generated'; import type { FastPathDecision } from './central-nervous-system/CNSTypes'; -import { personaSleepManager } from '@commands/ai/sleep/server/AiSleepServerCommand'; +// personaSleepManager no longer needed — sleep mode gating moved to Rust evaluator import { AI_DECISION_EVENTS, type AIEvaluatingEventData, @@ -272,15 +270,32 @@ export class PersonaMessageEvaluator { // Evaluator pipeline timing — tracks every phase before generation const evalTiming: Record = {}; - // EARLY GATE: Directed message filter — when someone @mentions a specific persona, others stay silent. + // EARLY GATE: Unified evaluation — ALL pre-response gates in ONE Rust IPC call. + // Replaces: response_cap → mention → rate_limit → sleep_mode → directed_mention → fast_path // Must run BEFORE expensive cognition work (plan formulation, working memory, state snapshots). - const isMentionedEarly = this.isPersonaMentioned(safeMessageText); - if (!isMentionedEarly && this.messageHasDirectedMention(safeMessageText)) { - this.log(`🎯 ${this.personaUser.displayName}: Message directed at another persona via @mention, staying silent (early gate)`); - this.personaUser.logAIDecision('SILENT', 'Message directed at another persona via @mention', { + const earlyGateStart = Date.now(); + const earlyResult = await this.personaUser.rustCognition.fullEvaluate({ + persona_id: this.personaUser.id, + persona_name: this.personaUser.displayName, + persona_unique_id: this.personaUser.entity?.uniqueId ?? '', + message_id: messageEntity.id, + room_id: messageEntity.roomId, + sender_id: messageEntity.senderId, + sender_name: messageEntity.senderName, + sender_type: messageEntity.senderType as SenderType ?? (senderIsHuman ? 'human' : 'persona'), + content: safeMessageText, + timestamp: this.personaUser.timestampToNumber(messageEntity.timestamp), + is_voice: false, + sender_is_human: senderIsHuman, + }); + evalTiming['early_gate'] = Date.now() - earlyGateStart; + + if (!earlyResult.should_respond) { + this.log(`🚫 ${this.personaUser.displayName}: Early gate SILENT — gate=${earlyResult.gate}, reason="${earlyResult.reason}" (${earlyResult.decision_time_ms.toFixed(2)}ms)`); + this.personaUser.logAIDecision('SILENT', `${earlyResult.gate}: ${earlyResult.reason}`, { message: safeMessageText.slice(0, 100), sender: messageEntity.senderName, - roomId: messageEntity.roomId + roomId: messageEntity.roomId, }); return; } @@ -490,71 +505,12 @@ export class PersonaMessageEvaluator { safeMessageText: string, preComputedDecision?: FastPathDecision ): Promise { - // STEP 2: Check response cap (prevent infinite loops) - if (this.personaUser.rateLimiter.hasReachedResponseCap(messageEntity.roomId)) { - const currentCount = this.personaUser.rateLimiter.getResponseCount(messageEntity.roomId); - const config = this.personaUser.rateLimiter.getConfig(); - this.personaUser.logAIDecision('SILENT', `Response cap reached (${currentCount}/${config.maxResponsesPerSession})`, { - message: safeMessageText, - sender: messageEntity.senderName, - roomId: messageEntity.roomId - }); - return; - } - - // STEP 3: Check if mentioned - const isMentioned = this.isPersonaMentioned(safeMessageText); - - // STEP 4: Check rate limiting (before expensive LLM call) - if (this.personaUser.rateLimiter.isRateLimited(messageEntity.roomId)) { - const info = this.personaUser.rateLimiter.getRateLimitInfo(messageEntity.roomId); - this.personaUser.logAIDecision('SILENT', `Rate limited, wait ${info.waitTimeSeconds?.toFixed(1)}s more`, { - message: safeMessageText, - sender: messageEntity.senderName, - roomId: messageEntity.roomId - }); - return; - } - - // STEP 5: Check voluntary sleep mode (before expensive LLM call) - // AIs can put themselves to sleep to manage attention autonomously - const sleepMode = personaSleepManager.getMode(this.personaUser.id); - if (sleepMode !== 'active') { - // Detect if this is a new topic (enables until_topic sleep mode) - const isNewTopic = await this.detectNewTopic(safeMessageText, messageEntity.roomId); - - const shouldRespondInSleepMode = personaSleepManager.shouldRespond(this.personaUser.id, { - isHuman: senderIsHuman, - isMention: isMentioned, - isNewTopic - }); - - if (!shouldRespondInSleepMode) { - this.log(`😴 ${this.personaUser.displayName}: In ${sleepMode} mode, skipping message from ${messageEntity.senderName}`); - this.personaUser.logAIDecision('SILENT', `Voluntary sleep mode: ${sleepMode} (isHuman=${senderIsHuman}, isMention=${isMentioned})`, { - message: safeMessageText, - sender: messageEntity.senderName, - roomId: messageEntity.roomId, - humanSender: senderIsHuman, - mentioned: isMentioned - }); - return; - } - - this.log(`😴 ${this.personaUser.displayName}: In ${sleepMode} mode but responding (isHuman=${senderIsHuman}, isMention=${isMentioned})`); - } - - // STEP 6: Directed message filter — when someone @mentions a specific persona, others stay silent. - // This prevents dog-piling where 5+ AIs all respond to "@deepseek fix the bug". - if (!isMentioned && this.messageHasDirectedMention(safeMessageText)) { - this.log(`🎯 ${this.personaUser.displayName}: Message directed at another persona via @mention, staying silent`); - this.personaUser.logAIDecision('SILENT', 'Message directed at another persona via @mention', { - message: safeMessageText.slice(0, 100), - sender: messageEntity.senderName, - roomId: messageEntity.roomId - }); - return; - } + // ALL pre-response gates are now handled by Rust via fullEvaluate() in the + // evaluateAndPossiblyRespondWithCognition() wrapper. By the time we get here, + // the early gate already passed. We extract mention info from gate_details. + const isMentioned = preComputedDecision + ? true // If pre-computed, Rust already verified we should respond + : false; // Default — the early gate already filtered directed mentions // === EVALUATE: Use LLM-based intelligent gating to decide if should respond === // Emit EVALUATING event for real-time feedback (fire-and-forget — UI indicator) @@ -723,7 +679,7 @@ export class PersonaMessageEvaluator { if (otherAIResponses.length > 0) { // Check if any response is adequate (substantial and related) - const adequacyResult = this.checkResponseAdequacy( + const adequacyResult = await this.checkResponseAdequacy( messageEntity, otherAIResponses // Already flat ChatMessageEntity objects from cache ); @@ -810,8 +766,9 @@ export class PersonaMessageEvaluator { // Signal conversation activity (warms room — active conversation stays alive) getChatCoordinator().onMessageServiced(messageEntity.roomId, this.personaUser.id); - // Track response for rate limiting - this.personaUser.rateLimiter.trackResponse(messageEntity.roomId); + // Track response for rate limiting (Rust is sole authority) + this.personaUser.rustCognition.trackResponse(messageEntity.roomId) + .catch(err => this.log(`⚠️ Rust trackResponse failed (non-fatal): ${err}`)); // PHASE 2: Track activity in PersonaState (energy depletion, mood calculation) // Recalculate priority to estimate complexity (higher priority = more engaging conversation) @@ -886,301 +843,30 @@ export class PersonaMessageEvaluator { } /** - * Check if this persona is mentioned in a message - * Supports @username mentions and channel directives - * - * TODO Phase 2: Use dedicated mention/directive events instead of text parsing - */ - private isPersonaMentioned(safeMessageText: string): boolean { - const safeMessageTextLower = safeMessageText.toLowerCase(); - const displayNameLower = this.personaUser.displayName.toLowerCase(); - const uniqueIdLower = this.personaUser.entity.uniqueId?.toLowerCase() || ''; - - // Check for @mentions ANYWHERE in message: "@PersonaName" or "@uniqueid" - // Works like Discord/Slack - @ can be at start, middle, or end - if (safeMessageTextLower.includes(`@${displayNameLower}`) || - safeMessageTextLower.includes(`@${uniqueIdLower}`)) { - return true; - } - - // Check for direct address at START: "PersonaName," or "PersonaName:" - // e.g. "Teacher AI, explain closures" or "teacher-ai: what's up" - if (safeMessageTextLower.startsWith(displayNameLower + ',') || - safeMessageTextLower.startsWith(displayNameLower + ':') || - safeMessageTextLower.startsWith(uniqueIdLower + ',') || - safeMessageTextLower.startsWith(uniqueIdLower + ':')) { - return true; - } - - return false; - } - - /** - * Detect if a message contains @mentions directed at someone (any persona). - * Used to prevent dog-piling: if someone @mentions a specific AI, others stay silent. - */ - private messageHasDirectedMention(text: string): boolean { - // Match @word patterns — the standard mention format in this system. - // Excludes email-like patterns (word@word) by requiring @ at start or after whitespace. - return /(?:^|\s)@[a-zA-Z][\w\s-]*/.test(text); - } - - /** - * Get domain keywords for this persona - * Reads from UserEntity.personaConfig if available, otherwise infers from name - */ - private getPersonaDomainKeywords(): string[] { - // Read from entity configuration if available - if (this.personaUser.entity?.personaConfig?.domainKeywords?.length) { - return [...this.personaUser.entity.personaConfig.domainKeywords]; - } - - // Fallback: infer from persona name (temporary until all personas configured) - const nameLower = this.personaUser.displayName.toLowerCase(); - - if (nameLower.includes('teacher') || nameLower.includes('academy')) { - return ['teaching', 'education', 'learning', 'explain', 'understand', 'lesson']; - } - if (nameLower.includes('code') || nameLower.includes('dev') || nameLower.includes('review')) { - return ['code', 'programming', 'function', 'bug', 'typescript', 'javascript']; - } - if (nameLower.includes('plan') || nameLower.includes('architect')) { - return ['plan', 'architecture', 'design', 'structure', 'organize']; - } - - // Default: general AI assistant keywords - return ['help', 'question', 'what', 'how', 'why', 'explain']; - } - - /** - * Calculate heuristics for response decision (Phase 2) - * NO API calls - pure logic based on conversation history - */ - private async calculateResponseHeuristics(messageEntity: ProcessableMessage): Promise<{ - containsQuestion: boolean; - conversationTemp: 'HOT' | 'WARM' | 'COOL' | 'COLD'; - myParticipationRatio: number; - secondsSinceMyLastMessage: number; - appearsToBeMyTurn: boolean; - }> { - // 1. Question detection (simple) - const containsQuestion = messageEntity.content?.text?.includes('?') || false; - - // 2. Get recent messages for context - const recentMessages = await ORM.query({ - collection: COLLECTIONS.CHAT_MESSAGES, - filter: { roomId: messageEntity.roomId }, - sort: [{ field: 'timestamp', direction: 'desc' }], - limit: 10 - }); - - const messages: ChatMessageEntity[] = recentMessages.success && recentMessages.data - ? recentMessages.data.map(record => record.data) - : []; - - // 3. Calculate conversation temperature (time between recent messages) - let conversationTemp: 'HOT' | 'WARM' | 'COOL' | 'COLD' = 'COLD'; - if (messages.length >= 2) { - const timeDiffs: number[] = []; - for (let i = 0; i < messages.length - 1; i++) { - const t1 = new Date(messages[i].timestamp).getTime(); - const t2 = new Date(messages[i + 1].timestamp).getTime(); - const diff = t1 - t2; - timeDiffs.push(diff / 1000); // Convert to seconds - } - const avgTimeBetween = timeDiffs.reduce((a, b) => a + b, 0) / timeDiffs.length; - - if (avgTimeBetween < 10) conversationTemp = 'HOT'; // <10s between messages - else if (avgTimeBetween < 30) conversationTemp = 'WARM'; // <30s - else if (avgTimeBetween < 60) conversationTemp = 'COOL'; // <60s - else conversationTemp = 'COLD'; // >60s - } - - // 4. Calculate my participation ratio - const myMessages = messages.filter(m => m.senderId === this.personaUser.id); - const myParticipationRatio = messages.length > 0 ? myMessages.length / messages.length : 0; - - // 5. Time since my last message - const myLastMessage = myMessages[0]; - const secondsSinceMyLastMessage = myLastMessage - ? (Date.now() - new Date(myLastMessage.timestamp).getTime()) / 1000 - : 999; - - // 6. Turn-taking pattern - is it my turn? - // My turn if: last message wasn't mine AND I haven't spoken recently - const lastMessage = messages[0]; - const appearsToBeMyTurn = - lastMessage?.senderId !== this.personaUser.id && - secondsSinceMyLastMessage > 30; - - return { - containsQuestion, - conversationTemp, - myParticipationRatio, - secondsSinceMyLastMessage, - appearsToBeMyTurn - }; - } - - /** - * Check if a sender is a human user (not AI/persona/agent) - * CRITICAL for preventing infinite response loops between AI users - */ - private async isSenderHuman(senderId: UUID): Promise { - if (!this.personaUser.client) { - this.log(`⚠️ PersonaUser ${this.personaUser.displayName}: Cannot check sender type - no client, BLOCKING response`); - return false; // Fail CLOSED - don't respond if we can't verify (prevents startup loops) - } - - try { - // Query the sender's UserEntity to check their type using DataDaemon directly - const sender = await ORM.read(COLLECTIONS.USERS, senderId); - - if (!sender) { - this.log(`⚠️ PersonaUser ${this.personaUser.displayName}: Could not read sender ${senderId}, BLOCKING response`); - return false; // Fail CLOSED - don't respond if database fails (prevents loops) - } - - return sender.type === 'human'; - - } catch (error: any) { - this.log(`❌ PersonaUser ${this.personaUser.displayName}: Error checking sender type, BLOCKING response:`, error); - return false; // Fail CLOSED on error (prevents loops) - } - } - - /** - * Detect if current message is a new topic vs continuation of existing conversation - * Uses fast n-gram text similarity (no embeddings needed) - * - * @param currentText - The incoming message text - * @param roomId - Room to query recent messages from - * @param threshold - Similarity threshold (below = new topic). Default 0.3 - * @returns True if this appears to be a new topic - */ - private async detectNewTopic( - currentText: string, - roomId: UUID, - threshold: number = 0.3 - ): Promise { - // Query recent messages from this room - const recentMessages = await ORM.query({ - collection: COLLECTIONS.CHAT_MESSAGES, - filter: { roomId }, - sort: [{ field: 'timestamp', direction: 'desc' }], - limit: 5 - }); - - const messages = recentMessages.data || []; - if (messages.length === 0) { - return true; // No history = definitely new topic - } - - // Combine recent message texts - const recentTexts = messages - .map(m => m.data.content?.text || '') - .filter(t => t.length > 0) - .join(' '); - - if (recentTexts.length === 0) { - return true; // No text content = new topic - } - - // Fast text similarity using n-gram Jaccard - const similarity = this.computeTextSimilarity(currentText, recentTexts); - - // Below threshold = new topic - const isNewTopic = similarity < threshold; - - if (isNewTopic) { - this.log(`🔄 ${this.personaUser.displayName}: New topic detected (similarity=${similarity.toFixed(2)} < ${threshold})`); - } - - return isNewTopic; - } - - /** - * Compute text similarity using n-gram Jaccard coefficient - * Fast O(n) algorithm - no embeddings or API calls needed + * Check if existing AI responses are adequate (no need for another response). * - * Uses unigrams + bigrams for better phrase detection + * ONE Rust IPC call checks all responses in batch — replaces N individual + * textSimilarity calls. Rust handles length filtering (>100 chars) and + * Jaccard n-gram similarity (>0.2 threshold) internally. */ - private computeTextSimilarity(text1: string, text2: string): number { - // Tokenize into words (filter short words as noise) - const tokenize = (text: string): string[] => { - return text - .toLowerCase() - .split(/\W+/) - .filter(word => word.length > 2); - }; - - const tokens1 = tokenize(text1); - const tokens2 = tokenize(text2); - - if (tokens1.length === 0 || tokens2.length === 0) { - return 0; - } - - // Generate unigrams + bigrams for better phrase matching - const generateNgrams = (tokens: string[]): Set => { - const ngrams = new Set(); - - // Unigrams - tokens.forEach(t => ngrams.add(t)); - - // Bigrams - for (let i = 0; i < tokens.length - 1; i++) { - ngrams.add(`${tokens[i]}_${tokens[i + 1]}`); - } - - return ngrams; - }; - - const ngrams1 = generateNgrams(tokens1); - const ngrams2 = generateNgrams(tokens2); - - // Jaccard coefficient = |intersection| / |union| - const intersection = [...ngrams1].filter(n => ngrams2.has(n)).length; - const union = new Set([...ngrams1, ...ngrams2]).size; - - return union > 0 ? intersection / union : 0; - } - - /** - * Check if existing AI responses are adequate (no need for another response) - * - * Used for post-inference re-evaluation to prevent redundant responses - * when another AI already answered during our inference time. - */ - private checkResponseAdequacy( + private async checkResponseAdequacy( originalMessage: ProcessableMessage, otherResponses: ChatMessageEntity[] - ): { isAdequate: boolean; confidence: number; reason: string } { + ): Promise<{ isAdequate: boolean; confidence: number; reason: string }> { const originalText = originalMessage.content?.text || ''; - for (const response of otherResponses) { - const responseText = response.content?.text || ''; - - // Skip short responses (likely not adequate) - if (responseText.length < 100) continue; + // Build response array for Rust — single IPC call handles all comparisons + const responses = otherResponses.map(r => ({ + sender_name: r.senderName ?? 'Unknown', + text: r.content?.text || '', + })); - // Check if response is related to original question - const similarity = this.computeTextSimilarity(originalText, responseText); - - // Substantial response (>100 chars) that's related to the question (>0.2 similarity) - if (similarity > 0.2) { - return { - isAdequate: true, - confidence: Math.min(similarity + 0.5, 1.0), // Boost confidence for related responses - reason: `${response.senderName} already provided a substantial response (${responseText.length} chars, ${(similarity * 100).toFixed(0)}% related)` - }; - } - } + const result = await this.personaUser.rustCognition.checkAdequacy(originalText, responses); return { - isAdequate: false, - confidence: 0, - reason: 'No adequate responses found' + isAdequate: result.is_adequate, + confidence: result.confidence, + reason: result.reason, }; } @@ -1263,15 +949,19 @@ export class PersonaMessageEvaluator { // eliminating the redundant second RAG build that previously happened there. const ragStart = performance.now(); const ragBuilder = new ChatRAGBuilder(this.log.bind(this)); + const provider = this.personaUser.modelConfig.provider || 'candle'; const ragContext = await ragBuilder.buildContext( message.roomId, this.personaUser.id, { modelId: this.personaUser.modelConfig.model, + maxTokens: this.personaUser.modelConfig.maxTokens, maxMemories: 5, // Full context: include memories for LLM prompt includeArtifacts: true, // Full context: include vision artifacts includeMemories: true, // Full context: include Hippocampus LTM excludeMessageIds: this.personaUser.taskTracker.getProcessedToolResults(), + provider, + toolCapability: getToolCapability(provider, this.personaUser.modelConfig), currentMessage: { role: 'user', content: message.content.text, diff --git a/src/debug/jtag/system/user/server/modules/PersonaResponseGenerator.ts b/src/debug/jtag/system/user/server/modules/PersonaResponseGenerator.ts index bceea8f3b..b2826c12c 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaResponseGenerator.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaResponseGenerator.ts @@ -17,19 +17,18 @@ import type { UUID } from '../../../core/types/CrossPlatformUUID'; import { ChatMessageEntity, type MediaItem } from '../../../data/entities/ChatMessageEntity'; import { inspect } from 'util'; import type { UserEntity } from '../../../data/entities/UserEntity'; -import type { ModelConfig } from '../../../../commands/user/create/shared/UserCreateTypes'; +import type { ModelConfig } from '../../../data/entities/UserEntity'; import type { JTAGClient } from '../../../core/client/shared/JTAGClient'; import { Commands } from '../../../core/shared/Commands'; // DataCreateParams/DataCreateResult imports removed — response posting now uses ORM.store() directly import { AIProviderDaemon } from '../../../../daemons/ai-provider-daemon/shared/AIProviderDaemon'; -import type { TextGenerationRequest, TextGenerationResponse, ChatMessage, ContentPart, ToolCall as NativeToolCall, ToolResult as NativeToolResult } from '../../../../daemons/ai-provider-daemon/shared/AIProviderTypesV2'; +import type { TextGenerationRequest, TextGenerationResponse, ChatMessage, ContentPart, NativeToolSpec, ToolCall as NativeToolCall, ToolResult as NativeToolResult } from '../../../../daemons/ai-provider-daemon/shared/AIProviderTypesV2'; import { AICapabilityRegistry } from '../../../../daemons/ai-provider-daemon/shared/AICapabilityRegistry'; import { ChatRAGBuilder } from '../../../rag/builders/ChatRAGBuilder'; import { CognitionLogger } from './cognition/CognitionLogger'; import { truncate, getMessageText, messagePreview } from '../../../../shared/utils/StringUtils'; import { calculateCost as calculateModelCost } from '../../../../daemons/ai-provider-daemon/shared/PricingConfig'; import { AIDecisionLogger } from '../../../ai/server/AIDecisionLogger'; -import { AIDecisionService, type AIDecisionContext } from '../../../ai/server/AIDecisionService'; import { CoordinationDecisionLogger, type LogDecisionParams } from '../../../coordination/server/CoordinationDecisionLogger'; import { Events } from '../../../core/shared/Events'; import { EVENT_SCOPES } from '../../../events/shared/EventSystemConstants'; @@ -46,18 +45,19 @@ import { COLLECTIONS } from '../../../data/config/DatabaseConfig'; import type { PersonaToolExecutor, ToolCall as ExecutorToolCall } from './PersonaToolExecutor'; import type { PersonaMediaConfig } from './PersonaMediaConfig'; import { PersonaToolRegistry } from './PersonaToolRegistry'; -import { getAllToolDefinitions, getAllToolDefinitionsAsync } from './PersonaToolDefinitions'; -import { getPrimaryAdapter, convertToNativeToolSpecs, supportsNativeTools, unsanitizeToolName, getToolCapability, type ToolDefinition as AdapterToolDefinition } from './ToolFormatAdapter'; +import { supportsNativeTools, unsanitizeToolName, sanitizeToolName, coerceParamsToSchema, getToolCapability } from './ToolFormatAdapter'; import { InferenceCoordinator } from '../../../coordination/server/InferenceCoordinator'; import { ContentDeduplicator } from './ContentDeduplicator'; -import { ResponseCleaner } from './ResponseCleaner'; // AiDetectSemanticLoop command removed from hot path — replaced with inline Jaccard similarity // import type { AiDetectSemanticLoopParams, AiDetectSemanticLoopResult } from '../../../../commands/ai/detect-semantic-loop/shared/AiDetectSemanticLoopTypes'; import { SystemPaths } from '../../../core/config/SystemPaths'; -import { GarbageDetector } from '../../../ai/server/GarbageDetector'; +// GarbageDetector — moved to Rust (persona/text_analysis/garbage_detection.rs) import type { InboxMessage, ProcessableMessage } from './QueueItemTypes'; import type { RAGContext } from '../../../rag/shared/RAGTypes'; +import { PromptCapture } from '../../../rag/shared/PromptCapture'; import { LOCAL_MODELS } from '../../../../system/shared/Constants'; +import type { RustCognitionBridge } from './RustCognitionBridge'; +// SemanticLoopResult — now inside ValidationResult, accessed via Rust IPC // import { AiDetectSemanticLoop } from '../../../../commands/ai/detect-semantic-loop/shared/AiDetectSemanticLoopTypes'; // DataCreate import removed — response posting now uses ORM.store() directly @@ -107,201 +107,15 @@ export class PersonaResponseGenerator { /** Content deduplicator - prevents same content from being posted within time window */ private contentDeduplicator: ContentDeduplicator; - /** Response cleaner - strips unwanted prefixes from AI responses */ - private responseCleaner: ResponseCleaner; + /** Rust cognition bridge — set lazily after PersonaUser creates it */ + private _rustBridge: RustCognitionBridge | null = null; /** - * RESPONSE-LEVEL LOOP DETECTION - * - * Tracks recent AI response hashes per persona to detect infinite loops. - * This catches loops BEFORE tool parsing, which is critical because: - * 1. Truncated responses never reach tool parsing - * 2. Tool-level detection only catches tool call loops, not content loops - * 3. DeepSeek was stuck repeating the same governance proposal 15+ times - * - * Map> - */ - private static readonly recentResponseHashes: Map> = new Map(); - private static readonly RESPONSE_LOOP_WINDOW_MS = 600000; // 10 minutes (DeepSeek generates 34k tokens = slow) - private static readonly RESPONSE_LOOP_THRESHOLD = 3; // Block after 3 similar responses - private static readonly RESPONSE_HASH_LENGTH = 200; // First 200 chars for comparison - - /** - * Create a hash of response content for loop detection - * Uses first N characters, normalized (lowercase, trimmed, no whitespace) - */ - private static hashResponse(text: string): string { - const normalized = text - .toLowerCase() - .trim() - .replace(/\s+/g, ' ') // Normalize whitespace - .slice(0, PersonaResponseGenerator.RESPONSE_HASH_LENGTH); - - // Simple hash: just use the normalized string as-is - // Could use crypto.createHash('md5') but not needed for loop detection - return normalized; - } - - /** - * Check if response is a loop (appears too frequently in recent history) - * Returns true if blocked (is a loop), false if allowed - */ - private isResponseLoop(responseText: string): boolean { - const hash = PersonaResponseGenerator.hashResponse(responseText); - const now = Date.now(); - - // Get or create recent responses list for this persona - let recentResponses = PersonaResponseGenerator.recentResponseHashes.get(this.personaId) || []; - - // Clean up old entries outside the window - recentResponses = recentResponses.filter( - entry => now - entry.timestamp < PersonaResponseGenerator.RESPONSE_LOOP_WINDOW_MS - ); - - // Count how many times similar response appears (using similarity threshold) - const duplicateCount = recentResponses.filter(entry => { - // Check if hashes are similar (allow some variation for minor differences) - const similarity = this.calculateSimilarity(entry.hash, hash); - return similarity > 0.8; // 80% similar = probable loop - }).length; - - // Record this response (even if it will be blocked) - recentResponses.push({ hash, timestamp: now }); - PersonaResponseGenerator.recentResponseHashes.set(this.personaId, recentResponses); - - // Block if threshold exceeded - if (duplicateCount >= PersonaResponseGenerator.RESPONSE_LOOP_THRESHOLD) { - this.log(`🔁 RESPONSE LOOP DETECTED: "${hash.slice(0, 50)}..." appeared ${duplicateCount + 1}x in ${PersonaResponseGenerator.RESPONSE_LOOP_WINDOW_MS / 1000}s - BLOCKING`); - return true; - } - - return false; - } - - /** - * Calculate similarity between two strings (0-1 scale) - * Uses simple character overlap for speed - */ - private calculateSimilarity(a: string, b: string): number { - if (a === b) return 1; - if (a.length === 0 || b.length === 0) return 0; - - // Jaccard similarity on character bigrams - const getBigrams = (s: string): Set => { - const bigrams = new Set(); - for (let i = 0; i < s.length - 1; i++) { - bigrams.add(s.slice(i, i + 2)); - } - return bigrams; - }; - - const bigramsA = getBigrams(a); - const bigramsB = getBigrams(b); - - let intersection = 0; - for (const bigram of bigramsA) { - if (bigramsB.has(bigram)) intersection++; - } - - const union = bigramsA.size + bigramsB.size - intersection; - return union === 0 ? 0 : intersection / union; - } - - /** - * Clear response loop history for this persona - * Call this when context changes significantly (e.g., room switch, manual reset) - */ - clearResponseLoopHistory(): void { - PersonaResponseGenerator.recentResponseHashes.delete(this.personaId); - this.log(`🧹 Cleared response loop history for ${this.personaName}`); - } - - /** - * SEMANTIC LOOP DETECTION - * - * Uses embedding-based similarity to detect if proposed response is too similar - * to recent messages in the room (from ANY source, not just self). - * - * This catches cases where multiple AIs post semantically identical content - * (e.g., Teacher AI and Local Assistant posting the same explanation). - * - * AUTONOMY-PRESERVING: - * - ALLOW (<0.75): Post normally - * - WARN (0.75-0.85): Log warning but allow (preserve autonomy) - * - BLOCK (>0.85): Truly redundant, block to prevent spam - * - * @param responseText - The proposed response text - * @param roomId - The room ID for context - * @returns true if should BLOCK (>0.85 similarity), false otherwise - */ - /** - * Inline Jaccard n-gram similarity — O(n) text comparison, no DB or embedding calls. - * Returns 0-1 score (1 = identical). - */ - private jaccardSimilarity(text1: string, text2: string): number { - if (!text1 || !text2) return 0; - if (text1 === text2) return 1.0; - - const tokenize = (text: string): Set => { - const words = text.toLowerCase().split(/\s+/).filter(w => w.length > 0); - const ngrams = new Set(); - for (const word of words) ngrams.add(word); - for (let i = 0; i < words.length - 1; i++) ngrams.add(`${words[i]} ${words[i + 1]}`); - return ngrams; - }; - - const set1 = tokenize(text1); - const set2 = tokenize(text2); - let intersection = 0; - for (const gram of set1) { - if (set2.has(gram)) intersection++; - } - const union = set1.size + set2.size - intersection; - return union === 0 ? 0 : intersection / union; - } - - /** - * Check semantic loop using in-memory RAG context (0ms, no DB/embedding calls). - * Previous implementation called AiDetectSemanticLoop.execute() which did embedding IPC + DB query (~20s). - * Now uses inline Jaccard n-gram similarity against already-loaded conversation history. + * Set Rust cognition bridge (called after PersonaUser creates it). + * All validation gates (garbage, loop, truncated tool, semantic loop) are in Rust. */ - private checkSemanticLoop( - responseText: string, - conversationHistory: Array<{ role: string; content: string; name?: string }> - ): { shouldBlock: boolean; similarity: number; reason: string } { - // Short responses are unlikely to be loops - if (responseText.length < 50) { - return { shouldBlock: false, similarity: 0, reason: 'Response too short for semantic check' }; - } - - // Compare against last 10 messages in the already-loaded RAG context - const recentMessages = conversationHistory.slice(-10); - let maxSimilarity = 0; - let mostSimilarExcerpt = ''; - - for (const msg of recentMessages) { - if (!msg.content || msg.content.length < 20) continue; - const similarity = this.jaccardSimilarity(responseText, msg.content); - if (similarity > maxSimilarity) { - maxSimilarity = similarity; - mostSimilarExcerpt = msg.content.slice(0, 100); - } - } - - // Thresholds (same as AiDetectSemanticLoopServerCommand) - const WARN_THRESHOLD = 0.80; - const BLOCK_THRESHOLD = 0.95; - - if (maxSimilarity >= BLOCK_THRESHOLD) { - this.log(`🚫 SEMANTIC LOOP: ${maxSimilarity.toFixed(2)} similarity - BLOCKING response`); - this.log(` Most similar to: "${mostSimilarExcerpt}"`); - return { shouldBlock: true, similarity: maxSimilarity, reason: `${Math.round(maxSimilarity * 100)}% similar to recent message` }; - } else if (maxSimilarity >= WARN_THRESHOLD) { - this.log(`⚠️ SEMANTIC WARNING: ${maxSimilarity.toFixed(2)} similarity - allowing (preserving autonomy)`); - return { shouldBlock: false, similarity: maxSimilarity, reason: 'Similar but allowing for autonomy' }; - } - - return { shouldBlock: false, similarity: maxSimilarity, reason: 'Low similarity' }; + setRustBridge(bridge: RustCognitionBridge): void { + this._rustBridge = bridge; } constructor(config: PersonaResponseGeneratorConfig) { @@ -319,93 +133,18 @@ export class PersonaResponseGenerator { // Initialize modular helpers this.contentDeduplicator = new ContentDeduplicator({ log: this.log.bind(this) }); - this.responseCleaner = new ResponseCleaner({ log: this.log.bind(this) }); - } - - /** - * Get effective model for inference - * - * Priority: - * 1. Trait-specific trained adapter (if context provides task domain) - * 2. Current active adapter (most recently used) - * 3. Any available trained adapter - * 4. Base model configured for this persona - * - * @param context - Optional context for trait-aware selection - * @returns The model name to use for inference - */ - private getEffectiveModel(context?: { taskDomain?: string }): string { - if (this.genome) { - // 1. Try trait-specific adapter based on task context - if (context?.taskDomain) { - const relevantTrait = this.determineRelevantTrait(context); - const traitAdapter = this.genome.getAdapterByTrait(relevantTrait); - if (traitAdapter) { - const ollamaModel = traitAdapter.getOllamaModelName(); - if (ollamaModel) { - this.log(`🧬 ${this.personaName}: Using trait-specific model: ${ollamaModel} (trait: ${relevantTrait})`); - return ollamaModel; - } - } - } - - // 2. Fall back to current active adapter (most recently used) - const currentAdapter = this.genome.getCurrentAdapter(); - if (currentAdapter) { - const ollamaModel = currentAdapter.getOllamaModelName(); - if (ollamaModel) { - this.log(`🧬 ${this.personaName}: Using trained model: ${ollamaModel} (adapter: ${currentAdapter.getName()})`); - return ollamaModel; - } - } - - // 3. Check for any available trained adapter - const allAdapters = this.genome.getAllAdapters(); - for (const adapter of allAdapters) { - const ollamaModel = adapter.getOllamaModelName(); - if (ollamaModel) { - this.log(`🧬 ${this.personaName}: Using available trained model: ${ollamaModel} (adapter: ${adapter.getName()})`); - return ollamaModel; - } - } - } - - // 4. Fall back to configured base model - return this.modelConfig.model || LOCAL_MODELS.DEFAULT; } /** - * Determine which trait adapter is most relevant for the current context - * - * Maps task domains to trait types: - * - code → reasoning_style - * - creative → creative_expression - * - support/help → social_dynamics - * - default → tone_and_voice + * Get effective model for inference via Rust IPC. + * 4-tier priority chain: trait adapter → current → any → base model. + * Domain-to-trait mapping is canonical in Rust (no TS duplicate). */ - private determineRelevantTrait(context: { taskDomain?: string }): string { - const domain = context.taskDomain?.toLowerCase(); - - switch (domain) { - case 'code': - case 'debug': - case 'analysis': - return 'reasoning_style'; - case 'creative': - case 'art': - case 'writing': - return 'creative_expression'; - case 'support': - case 'help': - case 'social': - return 'social_dynamics'; - case 'facts': - case 'knowledge': - case 'expertise': - return 'domain_expertise'; - default: - return 'tone_and_voice'; // Default trait for general chat - } + private async getEffectiveModel(taskDomain?: string): Promise { + if (!this._rustBridge) throw new Error('Rust bridge not initialized — cannot select model'); + const baseModel = this.modelConfig.model || LOCAL_MODELS.DEFAULT; + const result = await this._rustBridge.selectModel(baseModel, taskDomain); + return result.model; } /** @@ -550,10 +289,13 @@ export class PersonaResponseGenerator { this.personaId, { modelId: this.modelConfig.model, + maxTokens: this.modelConfig.maxTokens, maxMemories: 5, includeArtifacts: true, includeMemories: true, voiceSessionId, + provider: this.modelConfig.provider || 'candle', + toolCapability: getToolCapability(this.modelConfig.provider || 'candle', this.modelConfig), currentMessage: { role: 'user', content: originalMessage.content.text, @@ -573,64 +315,16 @@ export class PersonaResponseGenerator { // Adapters will transform based on model capability (raw images vs text descriptions) const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string | ChatMessage['content'] }> = []; - // System prompt from RAG builder (includes room membership!) - // NOTE: Budget awareness is now handled by RAGComposer sources (GovernanceSource, ActivityContextSource) - // Each source respects its allocated budget and skips/truncates for limited models + // System prompt from RAG builder — includes room membership, memories, tool definitions, + // widget context, global awareness, etc. ALL injected by budget-aware RAG sources. + // No bypasses here — everything flows through the RAG budget system. let systemPrompt = fullRAGContext.identity.systemPrompt; - // Inject consolidated memories from Hippocampus LTM (if available) - if (fullRAGContext.privateMemories && fullRAGContext.privateMemories.length > 0) { - const memorySection = `\n\n=== YOUR CONSOLIDATED MEMORIES ===\nThese are important things you've learned and consolidated into long-term memory:\n\n${ - fullRAGContext.privateMemories.map((mem, idx) => - `${idx + 1}. [${mem.type}] ${mem.content} (${new Date(mem.timestamp).toLocaleDateString()})` - ).join('\n') - }\n\nUse these memories to inform your responses when relevant.\n================================`; - - systemPrompt += memorySection; - this.log(`🧠 ${this.personaName}: Injected ${fullRAGContext.privateMemories.length} consolidated memories into context`); - } - - // Inject available tools for autonomous tool discovery (Phase 3A) - // Use adapter-based formatting for harmony with parser - // CRITICAL: Only inject tools for models that can actually emit tool calls. - // Models without tool capability narrate instead of calling tools, - // wasting tokens and clogging chat with useless "let me use tool X" text. + // Tool capability for XML parsing (still needed for response parsing, not injection) const toolCap = getToolCapability(this.modelConfig.provider || 'candle', this.modelConfig); - const availableTools = toolCap !== 'none' - ? await this.toolRegistry.listToolsForPersonaAsync(this.personaId) - : []; - - if (toolCap === 'none') { - this.log(`🚫 ${this.personaName}: Tool injection skipped (provider=${this.modelConfig.provider}, toolCapability=none)`); - } - - // Convert PersonaToolDefinitions to adapter format (used for both XML injection and native tools) - // Hoisted to outer scope so it's available for native tool_use injection later - const toolDefinitions: AdapterToolDefinition[] = availableTools.map(t => ({ - name: t.name, - description: t.description, - parameters: t.parameters, - category: t.category - })); - - if (availableTools.length > 0 && !supportsNativeTools(this.modelConfig.provider || 'candle')) { - // Text-based tool injection for non-native providers (XML tool callers like DeepSeek). - // Native tool providers (Anthropic, OpenAI, Together, Groq) get tools via the JSON - // `tools` request parameter instead — injecting text descriptions alongside native specs - // confuses Llama models into narrating tool usage rather than calling the native API. - const adapter = getPrimaryAdapter(); - const formattedTools = adapter.formatToolsForPrompt(toolDefinitions); - - const toolsSection = `\n\n=== AVAILABLE TOOLS ===\nYou have access to the following tools that you can use during your responses:\n\n${formattedTools}\n\nThe tool will be executed and results will be provided for you to analyze and respond to. -================================`; - - systemPrompt += toolsSection; - this.log(`🔧 ${this.personaName}: Injected ${availableTools.length} available tools into system prompt (text format)`); - } - // NOTE: Activity context (recipe strategy + tools) is now added by ActivityContextSource in RAGComposer - // NOTE: Governance guidance is now added by GovernanceSource in RAGComposer - // It's budget-aware and skipped for limited models + // Log system prompt size for monitoring + this.log(`📋 ${this.personaName}: [RAG] ${systemPrompt.length} chars (~${Math.ceil(systemPrompt.length / 4)} tokens), toolCap=${toolCap}, provider=${this.modelConfig.provider}`); messages.push({ role: 'system', @@ -854,8 +548,13 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma this.log(`🔧 ${this.personaName}: [PHASE 3.3] Calling AIProviderDaemon.generateText (provider: ${this.modelConfig.provider}, model: ${this.modelConfig.model})...`); // Bug #5 fix: Use adjusted maxTokens from RAG context (two-dimensional budget) - // If ChatRAGBuilder calculated an adjusted value, use it. Otherwise fall back to config. - let effectiveMaxTokens = fullRAGContext.metadata.adjustedMaxTokens ?? this.modelConfig.maxTokens ?? 150; + // RAG budget can only REDUCE maxTokens (protect against context overflow), + // never INCREASE beyond what the model config specifies. + const configMaxTokens = this.modelConfig.maxTokens; + const ragAdjusted = fullRAGContext.metadata.adjustedMaxTokens; + let effectiveMaxTokens = ragAdjusted && ragAdjusted < configMaxTokens + ? ragAdjusted + : configMaxTokens; // VOICE MODE: Allow reasonable response length for natural conversation // DON'T artificially truncate - that's robotic and cuts off mid-sentence @@ -883,13 +582,13 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma provider: this.modelConfig.provider }); - const effectiveModel = this.getEffectiveModel(); + const effectiveModel = await this.getEffectiveModel(); const request: TextGenerationRequest = { messages, model: effectiveModel, // Use trained model if available, otherwise base model temperature: this.modelConfig.temperature ?? 0.7, maxTokens: effectiveMaxTokens, // Bug #5 fix: Use adjusted value from two-dimensional budget - preferredProvider: (this.modelConfig.provider || 'candle') as TextGenerationRequest['preferredProvider'], + provider: this.modelConfig.provider || 'candle', intelligenceLevel: this.entity.intelligenceLevel, // Pass PersonaUser intelligence level to adapter // CRITICAL: personaContext enables per-persona logging and prevents "unknown" rejections personaContext: { @@ -913,63 +612,12 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma // This prevents thundering herd - only N personas can generate simultaneously per provider const provider = this.modelConfig.provider || 'candle'; - // Add native tools for providers that support JSON tool calling (Anthropic, OpenAI) - // This enables tool_use blocks instead of XML parsing for more reliable tool execution - // CRITICAL: Prioritize relevant tools. Sending 200+ tools overwhelms models, causing them - // to loop on meta-tools (search_tools) instead of calling the actual tools they need. - if (supportsNativeTools(provider) && toolDefinitions.length > 0) { - // Exclude meta-tools from native specs — models with native tool calling - // don't need discovery tools. search_tools/list_tools cause infinite loops. - const META_TOOLS = new Set(['search_tools', 'list_tools', 'working_memory']); - let prioritizedTools = toolDefinitions.filter(t => !META_TOOLS.has(t.name)); - - // Recipe tools define the activity's core toolset. When present, recipe tools - // go FIRST and the cap is tighter — models use early tools and get confused by 64+. - const recipeToolNames = new Set( - (fullRAGContext.recipeTools || []) - .filter(t => t.enabledFor.includes('ai')) - .map(t => t.name) - ); - const hasRecipeTools = recipeToolNames.size > 0; - const MAX_NATIVE_TOOLS = hasRecipeTools ? 32 : 64; - - if (prioritizedTools.length > MAX_NATIVE_TOOLS) { - // Three-tier priority: - // 1. Recipe tools (the activity's core tools — go FIRST) - // 2. Essentials (bare minimum for coordination) - // 3. Everything else (fill remaining slots) - const ESSENTIAL_TOOLS = new Set([ - 'collaboration/chat/send', 'collaboration/chat/history', - 'collaboration/decision/propose', 'collaboration/decision/vote', - ]); - const essentialPrefixes = hasRecipeTools - ? [] // When recipe tools exist, only allow exact essential matches - : ['collaboration/chat/', 'collaboration/decision/', 'data/', 'ai/']; - - const recipe: AdapterToolDefinition[] = []; - const essential: AdapterToolDefinition[] = []; - const rest: AdapterToolDefinition[] = []; - - for (const tool of prioritizedTools) { - if (recipeToolNames.has(tool.name)) { - recipe.push(tool); - } else if (ESSENTIAL_TOOLS.has(tool.name) || - essentialPrefixes.some(p => tool.name.startsWith(p))) { - essential.push(tool); - } else { - rest.push(tool); - } - } - - // Recipe tools FIRST, then essentials, then fill from rest - const remaining = MAX_NATIVE_TOOLS - recipe.length - essential.length; - prioritizedTools = [...recipe, ...essential, ...rest.slice(0, Math.max(0, remaining))]; - this.log(`🔧 ${this.personaName}: Tool prioritization: ${recipe.length} recipe + ${essential.length} essential + ${Math.max(0, remaining)} general = ${prioritizedTools.length} (from ${toolDefinitions.length} total, cap=${MAX_NATIVE_TOOLS})`); - } - - request.tools = convertToNativeToolSpecs(prioritizedTools); - request.tool_choice = 'auto'; - this.log(`🔧 ${this.personaName}: Added ${request.tools.length} native tools for ${provider} (JSON tool_use format, tool_choice=auto)`); + // Native tools from RAG budget (ToolDefinitionsSource handles prioritization + budget) + const toolMeta = fullRAGContext.metadata?.toolDefinitions; + if (toolMeta?.nativeToolSpecs && (toolMeta.nativeToolSpecs as unknown[]).length > 0) { + request.tools = toolMeta.nativeToolSpecs as any; + request.toolChoice = (toolMeta.toolChoice as string) || 'auto'; + this.log(`🔧 ${this.personaName}: Added ${(toolMeta.nativeToolSpecs as unknown[]).length} native tools from RAG budget (toolChoice=${request.toolChoice})`); } pipelineTiming['3.2_format'] = Date.now() - phase32Start; this.log(`✅ ${this.personaName}: [PHASE 3.2] LLM messages built (${messages.length} messages, ${pipelineTiming['3.2_format']}ms)`); @@ -996,6 +644,31 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma } this.log(`🎰 ${this.personaName}: [PHASE 3.3a] Inference slot granted (${pipelineTiming['3.3a_slot']}ms)`); + // ── Prompt capture for replay/debugging ── + // Captures the complete prompt (system + messages + tools) in JSONL format. + // Read with: PromptCapture.load({ personaName: 'Helper AI', limit: 5 }) + // Replay with: AIProviderDaemon.generateText(PromptCapture.toReplayRequest(capture)) + PromptCapture.capture({ + personaId: this.personaId, + personaName: this.personaName, + model: request.model || this.modelConfig.model || 'unknown', + provider: request.provider || 'candle', + temperature: request.temperature ?? 0.7, + maxTokens: effectiveMaxTokens, + messages: messages.map(m => ({ + role: m.role, + content: m.content, + name: undefined // name is embedded in content as "[HH:MM] Name: text" + })), + tools: request.tools as unknown[] | undefined, + toolChoice: typeof request.toolChoice === 'string' ? request.toolChoice : request.toolChoice ? JSON.stringify(request.toolChoice) : undefined, + triggerMessageId: originalMessage.id, + triggerMessagePreview: originalMessage.content?.text?.slice(0, 100), + ragSourceCount: fullRAGContext.metadata?.messageCount, + ragTotalTokens: fullRAGContext.metadata?.inputTokenCount as number | undefined, + activeAdapters: request.activeAdapters?.map(a => ({ name: a.name, path: a.path })) + }); + // Wrap generation call with timeout (180s - generous limit for local Ollama/Sentinel generation) // gpt2 on CPU needs ~60-90s for 100-150 tokens, 180s provides comfortable margin // Queue can handle 4 concurrent requests, so 180s allows slower hardware to complete @@ -1077,8 +750,8 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma stage: 'generate', durationMs: generateDuration, resourceUsed: aiResponse.text.length, - maxResource: this.modelConfig.maxTokens ?? 150, - percentCapacity: (aiResponse.text.length / (this.modelConfig.maxTokens ?? 150)) * 100, + maxResource: this.modelConfig.maxTokens, + percentCapacity: (aiResponse.text.length / this.modelConfig.maxTokens) * 100, percentSpeed: calculateSpeedScore(generateDuration, 'generate'), status: getStageStatus(generateDuration, 'generate'), metadata: { @@ -1091,100 +764,47 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma } ).catch(err => this.log(`⚠️ Failed to emit stage complete event: ${err}`)); - // 🔧 PHASE 3.3.5: Clean AI response - strip any name prefixes LLM added despite instructions - // LLMs sometimes copy the "[HH:MM] Name: message" format they see in conversation history - const cleanedResponse = this.responseCleaner.clean(aiResponse.text.trim()); - if (cleanedResponse !== aiResponse.text.trim()) { - aiResponse.text = cleanedResponse; + // Clean AI response via Rust IPC — strip name prefixes LLMs add + if (!this._rustBridge) { + throw new Error('Rust bridge not initialized — cannot validate response'); } - // 🔧 PHASE 3.3.5a: GARBAGE DETECTION - // Detect and reject garbage output (Unicode garbage, repetition, encoding errors) - // This catches model failures that produce gibberish instead of coherent text. - // Skip when the response has native tool calls — models with function calling often - // return empty text + tool_calls, which is valid (the agent loop will execute them). - const hasToolCalls = aiResponse.toolCalls && aiResponse.toolCalls.length > 0; - const garbageCheck = hasToolCalls ? { isGarbage: false, reason: '', details: '', score: 0 } : GarbageDetector.isGarbage(aiResponse.text); - if (garbageCheck.isGarbage) { - this.log(`🗑️ ${this.personaName}: [PHASE 3.3.5a] GARBAGE DETECTED (${garbageCheck.reason}: ${garbageCheck.details})`); - - // Release inference slot - InferenceCoordinator.releaseSlot(this.personaId, provider); + const cleaned = await this._rustBridge.cleanResponse(aiResponse.text.trim()); + if (cleaned.was_cleaned) { + aiResponse.text = cleaned.text; + } - // Emit event to clear UI indicators (fire-and-forget) - if (this.client) { - Events.emit( - DataDaemon.jtagContext!, - AI_DECISION_EVENTS.DECIDED_SILENT, - { - personaId: this.personaId, - personaName: this.personaName, - roomId: originalMessage.roomId, - messageId: originalMessage.id, - isHumanMessage: originalMessage.senderType === 'human', - timestamp: Date.now(), - confidence: garbageCheck.score, - reason: `Garbage output detected: ${garbageCheck.reason} - ${garbageCheck.details}`, - gatingModel: 'garbage-detector' - }, - { scope: EVENT_SCOPES.ROOM, scopeId: originalMessage.roomId } - ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); - } + // Combined validation gates (1 Rust IPC call) + // Runs 4 gates in Rust: garbage detection, response loop, truncated tool call, semantic loop. + const hasToolCalls = !!(aiResponse.toolCalls && aiResponse.toolCalls.length > 0); - // Return failure so caller knows this wasn't successful - return { success: false, wasRedundant: false, storedToolResultIds: [], error: `garbage_output: ${garbageCheck.reason}` }; - } + const validation = await this._rustBridge.validateResponse( + aiResponse.text, + hasToolCalls, + fullRAGContext.conversationHistory + ); - // 🔧 PHASE 3.3.5b: RESPONSE-LEVEL LOOP DETECTION - // Check if this AI is stuck in a loop BEFORE tool parsing - // This catches cases where: - // - Response is truncated mid-tool-call (DeepSeek's issue) - // - AI repeats same content with minor variations - // - Tool-level detection would miss it - if (!hasToolCalls && this.isResponseLoop(aiResponse.text)) { - this.log(`🔁 ${this.personaName}: [PHASE 3.3.5b] Response loop detected - DISCARDING response`); + if (!validation.passed) { + const gate = validation.gate_failed ?? 'unknown'; + this.log(`🚫 ${this.personaName}: [PHASE 3.3.5] Validation gate FAILED: ${gate} (${validation.total_time_us}us)`); // Release inference slot InferenceCoordinator.releaseSlot(this.personaId, provider); - // Emit event to clear UI indicators (fire-and-forget) - if (this.client) { - Events.emit( - DataDaemon.jtagContext!, - AI_DECISION_EVENTS.DECIDED_SILENT, - { - personaId: this.personaId, - personaName: this.personaName, - roomId: originalMessage.roomId, - messageId: originalMessage.id, - isHumanMessage: originalMessage.senderType === 'human', - timestamp: Date.now(), - confidence: 0.9, - reason: 'Response loop detected - same content repeated 3+ times', - gatingModel: 'response-loop-detector' - }, - { - scope: EVENT_SCOPES.ROOM, - scopeId: originalMessage.roomId - } - ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); - } - - // Return early - treat as redundant (don't post this looping response) - return { success: true, wasRedundant: true, storedToolResultIds: [] }; - } + // Build gate-specific event data + const gateConfidence = gate === 'garbage' ? validation.garbage_result.score + : gate === 'response_loop' ? 0.9 + : gate === 'truncated_tool_call' ? 0.95 + : gate === 'semantic_loop' ? validation.semantic_result.similarity + : 0.8; - // 🔧 PHASE 3.3.5c: TRUNCATED TOOL CALL DETECTION - // Detect tool calls that were cut off mid-generation (DeepSeek's issue) - // If we see or ') || aiResponse.text.includes('') || aiResponse.text.includes(''); - if (hasToolStart && !hasToolEnd) { - this.log(`⚠️ ${this.personaName}: [PHASE 3.3.5c] TRUNCATED TOOL CALL detected - blocking response to prevent loop`); - this.log(` Response ends with: "${(aiResponse.text ?? '').slice(-100)}"`); + const gateReason = gate === 'garbage' ? `Garbage output: ${validation.garbage_result.reason} - ${validation.garbage_result.details}` + : gate === 'response_loop' ? `Response loop detected - ${validation.loop_duplicate_count} duplicates` + : gate === 'truncated_tool_call' ? 'Truncated tool call detected - response cut off mid-tool-call' + : gate === 'semantic_loop' ? validation.semantic_result.reason + : `Validation failed: ${gate}`; - // Treat truncated tool calls the same as loops - they will just repeat forever - InferenceCoordinator.releaseSlot(this.personaId, provider); + // Emit DECIDED_SILENT event (fire-and-forget) if (this.client) { Events.emit( DataDaemon.jtagContext!, @@ -1196,47 +816,18 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma messageId: originalMessage.id, isHumanMessage: originalMessage.senderType === 'human', timestamp: Date.now(), - confidence: 0.95, - reason: 'Truncated tool call detected - response cut off mid-tool-call', - gatingModel: 'truncated-tool-detector' + confidence: gateConfidence, + reason: gateReason, + gatingModel: `rust-${gate}` }, { scope: EVENT_SCOPES.ROOM, scopeId: originalMessage.roomId } ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); } - return { success: true, wasRedundant: true, storedToolResultIds: [] }; - } - - // 🔧 PHASE 3.3.5d: SEMANTIC LOOP DETECTION (inline, ~0ms) - // Uses Jaccard n-gram similarity against already-loaded RAG context. - // Previous: AiDetectSemanticLoop.execute() — embedding IPC + DB query (~20 seconds) - // Now: inline text comparison against in-memory conversation history (~0ms) - const semanticCheck = this.checkSemanticLoop(aiResponse.text, fullRAGContext.conversationHistory); - if (semanticCheck.shouldBlock) { - this.log(`🚫 ${this.personaName}: [PHASE 3.3.5d] SEMANTIC LOOP BLOCKED (${semanticCheck.similarity.toFixed(2)} similarity)`); - - // Release inference slot - InferenceCoordinator.releaseSlot(this.personaId, provider); - // Emit event to clear UI indicators (fire-and-forget) - if (this.client) { - Events.emit( - DataDaemon.jtagContext!, - AI_DECISION_EVENTS.DECIDED_SILENT, - { - personaId: this.personaId, - personaName: this.personaName, - roomId: originalMessage.roomId, - messageId: originalMessage.id, - isHumanMessage: originalMessage.senderType === 'human', - timestamp: Date.now(), - confidence: semanticCheck.similarity, - reason: semanticCheck.reason, - gatingModel: 'semantic-loop-detector' - }, - { scope: EVENT_SCOPES.ROOM, scopeId: originalMessage.roomId } - ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); + // Garbage returns failure; loops/truncated return redundant + if (gate === 'garbage') { + return { success: false, wasRedundant: false, storedToolResultIds: [], error: `garbage_output: ${validation.garbage_result.reason}` }; } - return { success: true, wasRedundant: true, storedToolResultIds: [] }; } @@ -1268,8 +859,10 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma while (toolIterations < SAFETY_MAX) { // Check for tool calls — native first, then XML fallback + // ONE Rust IPC call replaces 3 separate sync TS calls (parse + correct + strip) const hasNativeToolCalls = aiResponse.toolCalls && aiResponse.toolCalls.length > 0; - const hasXmlToolCalls = !hasNativeToolCalls && this.toolExecutor.parseToolCalls(aiResponse.text).length > 0; + const parsed = !hasNativeToolCalls ? await this.toolExecutor.parseResponse(aiResponse.text) : null; + const hasXmlToolCalls = parsed !== null && parsed.toolCalls.length > 0; if (!hasNativeToolCalls && !hasXmlToolCalls) { // Model chose to stop — no more tool calls @@ -1282,11 +875,29 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma toolIterations++; this.log(`🔧 ${this.personaName}: [AGENT-LOOP] Iteration ${toolIterations}/${SAFETY_MAX}`); - if (useNativeProtocol && hasNativeToolCalls) { - // ── Native tool protocol (Anthropic, OpenAI, etc.) ── - // Full results go back as tool_result content blocks - const nativeToolCalls = aiResponse.toolCalls!; - this.log(`🔧 ${this.personaName}: [AGENT-LOOP] Executing ${nativeToolCalls.length} native tool call(s)`); + if (hasNativeToolCalls || (useNativeProtocol && hasXmlToolCalls)) { + // ── Native tool protocol (Anthropic, OpenAI, Groq, Together, etc.) ── + // Handles both: + // 1. Adapter returned structured tool_calls (normal case) + // 2. Model output tool calls in text, Rust parsed them (Groq/Llama case) + let nativeToolCalls: NativeToolCall[]; + if (hasNativeToolCalls) { + nativeToolCalls = aiResponse.toolCalls!; + } else { + // Synthesize native format from text-parsed calls + // Coerce params to match schema types (e.g. string "true" → boolean true) + // so the API doesn't reject tool_use blocks on regeneration + const toolSpecs = (request.tools as NativeToolSpec[]) ?? []; + nativeToolCalls = parsed!.toolCalls.map((tc, i) => { + const name = sanitizeToolName(tc.toolName); + return { + id: `synth_${Date.now()}_${i}`, + name, + input: coerceParamsToSchema(tc.parameters, toolSpecs, name), + }; + }); + } + this.log(`🔧 ${this.personaName}: [AGENT-LOOP] Executing ${nativeToolCalls.length} native tool call(s)${!hasNativeToolCalls ? ' (synthesized from text)' : ''}`); let toolResults: NativeToolResult[]; let toolMedia: MediaItem[] = []; @@ -1304,30 +915,41 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma const errMsg = toolExecError instanceof Error ? toolExecError.message : String(toolExecError); this.log(`❌ ${this.personaName}: [AGENT-LOOP] Tool execution failed: ${errMsg}`); toolResults = nativeToolCalls.map(tc => ({ - tool_use_id: tc.id, + toolUseId: tc.id, content: `Tool execution error: ${errMsg}`, - is_error: true as const, + isError: true as const, })); } - // Push assistant message with tool_use content blocks (as the model produced them) - const assistantContent: ContentPart[] = aiResponse.content ?? [ - ...(aiResponse.text ? [{ type: 'text' as const, text: aiResponse.text }] : []), - ...nativeToolCalls.map(tc => ({ - type: 'tool_use' as const, - id: tc.id, - name: tc.name, - input: tc.input, - })), - ]; + // Push assistant message with tool_use content blocks + // Use adapter's content if native tool calls, synthesize if text-parsed + const assistantContent: ContentPart[] = hasNativeToolCalls + ? (aiResponse.content ?? [ + ...(aiResponse.text ? [{ type: 'text' as const, text: aiResponse.text }] : []), + ...nativeToolCalls.map(tc => ({ + type: 'tool_use' as const, + id: tc.id, + name: tc.name, + input: tc.input, + })), + ]) + : [ + ...(parsed!.cleanedText ? [{ type: 'text' as const, text: parsed!.cleanedText }] : []), + ...nativeToolCalls.map(tc => ({ + type: 'tool_use' as const, + id: tc.id, + name: tc.name, + input: tc.input, + })), + ]; messages.push({ role: 'assistant' as const, content: assistantContent }); // Push tool results as user message with tool_result content blocks (FULL results) const toolResultContent: ContentPart[] = toolResults.map(r => ({ type: 'tool_result' as const, - tool_use_id: r.tool_use_id, + tool_use_id: r.toolUseId, content: r.content, - ...(r.is_error && { is_error: true }), + is_error: r.isError ?? null, })); // Include media if present (screenshots, etc.) @@ -1337,17 +959,10 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma messages.push({ role: 'user' as const, content: toolResultContent }); - } else { - // ── XML fallback for non-native providers ── + } else if (hasXmlToolCalls) { + // ── XML path for non-native providers (DeepSeek, Ollama, Candle) ── // Parse XML tool calls, execute, return results as text - const xmlToolCalls = hasNativeToolCalls - ? aiResponse.toolCalls!.map((tc: NativeToolCall) => ({ - toolName: unsanitizeToolName(tc.name), - parameters: Object.fromEntries( - Object.entries(tc.input).map(([k, v]) => [k, String(v)]) - ) as Record, - })) - : this.toolExecutor.parseToolCalls(aiResponse.text); + const xmlToolCalls = parsed!.toolCalls; this.log(`🔧 ${this.personaName}: [AGENT-LOOP] Executing ${xmlToolCalls.length} XML tool call(s)`); @@ -1367,8 +982,8 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma formattedResults = `\nerror\n\n\`\`\`\nTool execution error: ${errMsg}\n\`\`\`\n\n`; } - // Strip tool blocks from response text for the assistant message - const explanationText = this.toolExecutor.stripToolBlocks(aiResponse.text); + // Use pre-parsed cleaned text from Rust IPC (already stripped) + const explanationText = parsed!.cleanedText; messages.push({ role: 'assistant' as const, content: explanationText }); @@ -1382,43 +997,70 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma messages.push({ role: 'user' as const, content: toolResultContent }); } - // Regenerate — tools stay enabled, model decides when to stop - this.log(`🔧 ${this.personaName}: [AGENT-LOOP] Regenerating with ${messages.length} messages (tools enabled)`); + // Regenerate — force text response after 3 tool iterations. + // Small/medium models loop on tools indefinitely without summarizing. + // After 3 iterations (or at safety cap - 1), disable tools to force text. + const forceText = toolIterations >= 3 || toolIterations >= SAFETY_MAX - 1; + const regenerationTools = forceText ? undefined : request.tools; + const regenerationToolChoice = forceText ? undefined : request.toolChoice; + + this.log(`🔧 ${this.personaName}: [AGENT-LOOP] Regenerating with ${messages.length} messages (tools ${forceText ? 'DISABLED — forcing text response' : 'enabled'})`); try { const regenerateStartTime = Date.now(); const regeneratedResponse = await AIProviderDaemon.generateText({ ...request, - messages, // Tools NOT stripped — model decides when to stop + messages, + tools: regenerationTools, + toolChoice: regenerationToolChoice, }); const regenerateDuration = Date.now() - regenerateStartTime; this.log(`⏱️ ${this.personaName}: [AGENT-LOOP] Regeneration took ${regenerateDuration}ms, finishReason: ${regeneratedResponse.finishReason}`); if (!regeneratedResponse.text && !regeneratedResponse.toolCalls?.length) { - this.log(`❌ ${this.personaName}: [AGENT-LOOP] Empty response, using previous text`); - aiResponse.text = this.toolExecutor.stripToolBlocks(aiResponse.text); + this.log(`⚠️ ${this.personaName}: [AGENT-LOOP] Empty response from ${provider}/${effectiveModel} after ${toolIterations} tool iteration(s), using cleaned previous text`); + // Regeneration returned nothing — use the model's explanation text from before tool calls + // parseResponse strips tool blocks, leaving the natural language (e.g. "Let me check that...") + const fallback = await this.toolExecutor.parseResponse(aiResponse.text); + aiResponse.text = fallback.cleanedText; break; } - // Update full response state - aiResponse.text = this.responseCleaner.clean(regeneratedResponse.text?.trim() || ''); + // Update full response state — clean via Rust IPC + const loopCleaned = await this._rustBridge!.cleanResponse(regeneratedResponse.text?.trim() || ''); + aiResponse.text = loopCleaned.text; aiResponse.toolCalls = regeneratedResponse.toolCalls ?? undefined; aiResponse.content = regeneratedResponse.content ?? undefined; aiResponse.finishReason = regeneratedResponse.finishReason; this.log(`✅ ${this.personaName}: [AGENT-LOOP] Got response (${aiResponse.text.length} chars, toolCalls: ${aiResponse.toolCalls?.length ?? 0})`); + + // If we forced text (tools disabled), break — don't let the parser + // re-detect tool-call-like text and continue the loop + if (forceText) { + this.log(`✅ ${this.personaName}: [AGENT-LOOP] Forced text response after ${toolIterations} iteration(s), stopping`); + break; + } } catch (regenerateError) { const errorMsg = regenerateError instanceof Error ? regenerateError.message : String(regenerateError); this.log(`❌ ${this.personaName}: [AGENT-LOOP] Regeneration failed: ${errorMsg}`); - aiResponse.text = this.toolExecutor.stripToolBlocks(aiResponse.text); + aiResponse.text = (await this.toolExecutor.parseResponse(aiResponse.text)).cleanedText; break; } } if (toolIterations >= SAFETY_MAX) { this.log(`⚠️ ${this.personaName}: [AGENT-LOOP] Hit safety cap (${SAFETY_MAX}), stopping`); - aiResponse.text = this.toolExecutor.stripToolBlocks(aiResponse.text); + } + // Always strip any remaining tool call text from the final response. + // Models may embed tool calls in text even after forced-text regeneration. + if (toolIterations > 0 && aiResponse.text) { + const finalCleaned = await this.toolExecutor.parseResponse(aiResponse.text); + if (finalCleaned.toolCalls.length > 0) { + this.log(`🧹 ${this.personaName}: [AGENT-LOOP] Stripped ${finalCleaned.toolCalls.length} residual tool call(s) from final response`); + aiResponse.text = finalCleaned.cleanedText; + } } pipelineTiming['3.4_agent_loop'] = Date.now() - agentLoopStart; if (toolIterations > 0) { @@ -1498,43 +1140,6 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma throw error; } - // === SUB-PHASE 3.4: SELF-REVIEW: Check if response is redundant before posting === - // DISABLED: Redundancy checking via LLM is too flaky (false positives like C++ vs JavaScript questions) - // It adds AI unreliability on top of AI unreliability, leading to valid responses being discarded - // TODO: Replace with simple heuristics (exact text match, time-based deduplication) - this.log(`⏭️ ${this.personaName}: [PHASE 3.4] Redundancy check DISABLED (too flaky), proceeding to post`); - const isRedundant = false; // Disabled - - if (isRedundant) { - this.log(`⚠️ ${this.personaName}: [PHASE 3.4] Response marked as REDUNDANT, discarding`); - - // Emit DECIDED_SILENT event to clear AI status indicator (fire-and-forget) - if (this.client) { - Events.emit( - DataDaemon.jtagContext!, - AI_DECISION_EVENTS.DECIDED_SILENT, - { - personaId: this.personaId, - personaName: this.personaName, - roomId: originalMessage.roomId, - messageId: originalMessage.id, - isHumanMessage: originalMessage.senderType === 'human', - timestamp: Date.now(), - confidence: 0.5, - reason: 'Response was redundant with previous answers', - gatingModel: 'redundancy-check' - }, - { - scope: EVENT_SCOPES.ROOM, - scopeId: originalMessage.roomId - } - ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); - } - - return { success: true, wasRedundant: true, storedToolResultIds: [] }; // Discard response - } - this.log(`✅ ${this.personaName}: [PHASE 3.4] Response not redundant, proceeding to post`); - // 🔧 SUB-PHASE 3.5: Create and post response this.log(`🔧 ${this.personaName}: [PHASE 3.5] Creating response message entity...`); const responseMessage = new ChatMessageEntity(); @@ -1642,7 +1247,7 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma isHumanMessage: originalMessage.senderType === 'human', timestamp: Date.now(), responseMessageId: postedEntity.id, - passedRedundancyCheck: !isRedundant + passedRedundancyCheck: true }, { scope: EVENT_SCOPES.ROOM, @@ -1722,70 +1327,4 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma return timestamp; // Already a number } - async checkResponseRedundancy( - myResponse: string, - roomId: UUID, - conversationHistory: Array<{ role: string; content: string; name?: string; timestamp?: number }> - ): Promise { - try { - // Use AIDecisionService for centralized redundancy checking - // Create minimal context without needing full trigger message - const decisionContext: AIDecisionContext = { - personaId: this.personaId, - personaName: this.personaName, - roomId, - triggerMessage: { - id: '', - roomId, - senderId: '', - senderName: 'System', - senderType: 'system', - content: { text: 'redundancy check', attachments: [] }, - timestamp: new Date(), - collection: COLLECTIONS.CHAT_MESSAGES, - version: 1, - createdAt: new Date(), - updatedAt: new Date(), - status: 'sent', - priority: 0, - reactions: [] - } as unknown as ChatMessageEntity, - ragContext: { - domain: 'chat', - contextId: roomId, - personaId: this.personaId, - identity: { - name: this.personaName, - systemPrompt: '' - }, - conversationHistory: conversationHistory.map(msg => ({ - role: msg.role as 'user' | 'assistant', - content: msg.content, - name: msg.name, - timestamp: msg.timestamp - })), - artifacts: [], - privateMemories: [], - metadata: { - messageCount: conversationHistory.length, - artifactCount: 0, - memoryCount: 0, - builtAt: new Date() - } - } - }; - - const result = await AIDecisionService.checkRedundancy( - myResponse, - decisionContext, - { model: LOCAL_MODELS.DEFAULT } - ); - - return result.isRedundant; - } catch (error) { - AIDecisionLogger.logError(this.personaName, 'Redundancy check', error instanceof Error ? error.message : String(error)); - return false; // On error, allow the response (fail open) - } - } - } diff --git a/src/debug/jtag/system/user/server/modules/PersonaToolDefinitions.ts b/src/debug/jtag/system/user/server/modules/PersonaToolDefinitions.ts index a456fb706..f471fca34 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaToolDefinitions.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaToolDefinitions.ts @@ -122,6 +122,7 @@ const ADMIN_COMMANDS = new Set([ 'user/set-role', // Role assignment 'secrets/set', // Secret management 'secrets/delete', // Secret management + 'ai/agent', // Prevent recursive self-invocation by personas ]); /** @@ -193,8 +194,9 @@ export async function refreshToolDefinitions(): Promise { try { log('Refreshing tool cache from Commands system...'); - // Query list command to discover all available commands - const result = await List.execute({}) as unknown as ListResult; + // Query list command to discover all available commands with full metadata + // includeDescription + includeSignature ensures we get param schemas and descriptions + const result = await List.execute({ includeDescription: true, includeSignature: true }) as unknown as ListResult; if (!result.success || !result.commands) { log(`❌ Failed to refresh tools: ${result.error}`); @@ -266,6 +268,108 @@ export async function refreshToolDefinitions(): Promise { * These overrides provide meaningful descriptions so LLMs know what to pass. */ const PARAM_DESCRIPTION_OVERRIDES: Record> = { + // ── Chat (highest priority tools) ────────────────────────────────── + 'collaboration/chat/send': { + message: 'Text of the message to send', + room: 'Room name to send to (e.g. "general"). Default: current room', + replyToId: 'Short ID of message to reply to (e.g. "abc1234")', + media: 'JSON array of media attachments [{type:"image",url:"..."}]', + }, + 'collaboration/chat/export': { + room: 'Room name (e.g. "general")', + limit: 'Max messages to return (default: 50)', + afterMessageId: 'Only messages after this message ID', + afterTimestamp: 'Only messages after this ISO timestamp', + output: 'File path to save markdown (omit to print to stdout)', + includeSystem: 'Include system messages (boolean)', + includeThreading: 'Show reply-to threading (boolean)', + }, + 'collaboration/chat/poll': { + afterMessageId: 'Message ID to poll after (returns newer messages)', + limit: 'Max messages to return', + room: 'Room name to poll', + }, + 'collaboration/chat/analyze': { + roomId: 'UUID of the room to analyze', + checkDuplicates: 'Check for duplicate messages (boolean)', + checkTimestamps: 'Check for timestamp anomalies (boolean)', + limit: 'Max messages to analyze', + }, + + // ── Decision/Governance ──────────────────────────────────────────── + 'collaboration/decision/propose': { + topic: 'Short title for the proposal (e.g. "Adopt TypeScript strict mode")', + rationale: 'Why this proposal matters — your reasoning', + options: 'JSON array of choice objects: [{"label":"Option A","description":"Details..."}]', + description: 'Detailed description of what is being decided', + tags: 'JSON array of tags for categorization (e.g. ["architecture","tooling"])', + scope: 'Scope: "team", "project", or "system"', + significanceLevel: 'Impact level: "minor", "moderate", or "major"', + }, + 'collaboration/decision/vote': { + proposalId: 'UUID of the proposal to vote on', + rankedChoices: 'JSON array of option IDs in preference order (best first)', + comment: 'Optional comment explaining your vote', + }, + 'collaboration/decision/list': { + status: 'Filter by status: "open", "closed", "all"', + domain: 'Filter by domain/tag', + limit: 'Max proposals to return', + }, + 'collaboration/decision/view': { + proposalId: 'UUID of the proposal to view', + }, + 'collaboration/decision/finalize': { + proposalId: 'UUID of the proposal to close voting on', + }, + 'collaboration/decision/create': { + proposalId: 'UUID for the new proposal', + topic: 'Short title for the proposal', + rationale: 'Why this proposal matters', + description: 'Detailed description of what is being decided', + options: 'JSON array of choice objects with label and description', + votingDeadline: 'ISO timestamp deadline for voting', + requiredQuorum: 'Minimum number of votes needed', + }, + + // ── Wall (collaborative documents) ───────────────────────────────── + 'collaboration/wall/write': { + room: 'Room name (default: current room)', + doc: 'Document name (e.g. "meeting-notes", "architecture")', + content: 'Markdown content to write', + append: 'true to append to existing doc, false to overwrite', + commitMessage: 'Description of this change (like a git commit message)', + }, + 'collaboration/wall/read': { + room: 'Room name', + doc: 'Document name to read', + toc: 'true to show table of contents only', + lines: 'Line range like "10-20"', + }, + 'collaboration/wall/list': { + room: 'Room name', + pattern: 'Glob pattern to filter docs (e.g. "meeting-*")', + }, + 'collaboration/wall/history': { + room: 'Room name', + doc: 'Document name', + limit: 'Max history entries to show', + }, + + // ── Data ─────────────────────────────────────────────────────────── + 'data/list': { + collection: 'Collection name (e.g. "users", "chat_messages", "rooms")', + limit: 'Max items to return', + filter: 'JSON filter object (e.g. {"status":"active"})', + orderBy: 'JSON array: [{"field":"createdAt","direction":"desc"}]', + fields: 'JSON array of field names to return (projection)', + }, + 'data/get': { + collection: 'Collection name', + id: 'Entity UUID to retrieve', + }, + + // ── Code tools ───────────────────────────────────────────────────── 'code/write': { filePath: 'Relative path to file within workspace (e.g. "index.html", "src/app.js")', content: 'Complete file content to write (the actual code/text, not a description)', @@ -290,6 +394,30 @@ const PARAM_DESCRIPTION_OVERRIDES: Record> = { fileGlob: 'File glob pattern to filter (e.g. "*.ts", "src/**/*.js")', maxResults: 'Maximum number of results to return', }, + 'code/diff': { + filePath: 'Relative path to file to diff', + editType: '"search_replace", "line_range", or "insert"', + search: 'Text to find (for search_replace)', + replace: 'Replacement text (for search_replace)', + startLine: 'Start line (for line_range)', + endLine: 'End line (for line_range)', + newContent: 'New content (for line_range)', + line: 'Line number (for insert)', + content: 'Content to insert (for insert)', + }, + 'code/undo': { + changeId: 'Specific change ID to undo (from code/history)', + count: 'Number of recent changes to undo (default: 1)', + }, + 'code/history': { + filePath: 'File path to get history for (omit for entire workspace)', + limit: 'Max entries to return', + }, + 'code/verify': { + typeCheck: 'Run TypeScript type checking (boolean)', + testFiles: 'Specific test files to run (JSON array of file paths)', + cwd: 'Working directory override', + }, 'code/git': { operation: 'Git operation: "status", "diff", "log", "add", "commit"', message: 'Commit message (required for "commit" operation)', @@ -297,10 +425,6 @@ const PARAM_DESCRIPTION_OVERRIDES: Record> = { staged: 'Show staged changes only (for "diff" operation)', count: 'Number of log entries to show (for "log" operation)', }, - 'code/verify': { - typeCheck: 'Run type checking (boolean)', - testFiles: 'Specific test files to run (JSON array of strings)', - }, 'code/shell/execute': { cmd: 'Shell command to execute (e.g. "npm run build", "cargo test", "ls -la src/")', wait: 'Wait for completion: true = blocking (returns stdout/stderr), false = async (returns executionId). Default: false', @@ -319,6 +443,176 @@ const PARAM_DESCRIPTION_OVERRIDES: Record> = { 'code/shell/kill': { executionId: 'Execution ID of the running process to kill', }, + + // ── System tools ─────────────────────────────────────────────────── + 'ping': { + verbose: 'Include detailed AI persona health status (boolean)', + }, + 'screenshot': { + querySelector: 'CSS selector of element to capture (e.g. "chat-widget", "body")', + filename: 'Output filename (default: screenshot.png)', + fullPage: 'Capture full scrollable page (boolean)', + }, + + // ── Live collaboration ───────────────────────────────────────────── + 'collaboration/live/start': { + participants: 'JSON array of user IDs to invite', + name: 'Optional session name', + withVideo: 'Enable video (boolean, default: false)', + }, + 'collaboration/dm': { + participants: 'JSON array of user IDs for the DM room', + name: 'Optional room display name', + }, +}; + +/** + * Rich tool-level description overrides for critical tools. + * The schema generator produces vague descriptions like "Code Write Types". + * These overrides provide Claude Code-quality descriptions that tell the LLM + * not just what the tool does, but HOW and WHEN to use it correctly. + */ +const TOOL_DESCRIPTION_OVERRIDES: Record = { + // ── Code tools (most important for coding personas) ────────── + 'code/read': 'Read file contents from workspace. Returns the file text with line numbers. You MUST read a file before editing it — editing without reading leads to wrong assumptions. Use startLine/endLine for large files.', + + 'code/write': 'Create a new file or completely replace an existing file. WARNING: This overwrites the ENTIRE file. For modifying existing files, prefer code/edit (search_replace) which is surgical. Only use code/write for files that do not exist yet or when you need to replace all content.', + + 'code/edit': 'Make precise edits to existing files. Supports search_replace (find exact text and replace it), line_range (replace specific lines), insert_at (add content at a line), and append (add to end). For search_replace: the search text must match EXACTLY as it appears in code/read output — character for character, including whitespace. If the search fails, re-read the file.', + + 'code/search': 'Search across files in the workspace using regex patterns. Returns matching lines with file paths and line numbers. Use this instead of shell grep — it is faster and codebase-aware. Supports file glob filtering (e.g., "*.ts", "src/**/*.rs").', + + 'code/tree': 'Display workspace directory structure as a tree. Use this to understand project layout before making changes. Do NOT use shell ls or find — code/tree is optimized for workspace navigation.', + + 'code/diff': 'Preview an edit as a unified diff without applying it. Use this to verify your changes look correct BEFORE using code/edit. Same parameters as code/edit.', + + 'code/verify': 'Run TypeScript type checking and optional tests on the workspace. Use this after EVERY edit to ensure your changes compile. If verify fails, read the error output, fix the issue, and verify again.', + + 'code/undo': 'Undo recent changes. Every code/write and code/edit creates a tracked change that can be reverted. Use when an edit breaks something.', + + 'code/history': 'View the change history for a file or the entire workspace. Shows what was changed, when, and the change IDs for undo.', + + 'code/git': 'Git operations: status, diff, log, add, commit. Check status before committing. Use diff to review staged changes.', + + // ── Shell tools ────────────────────────────────────────────── + 'code/shell/execute': 'Execute a shell command in the workspace. Use for build commands (npm, cargo), test runners, and system operations. Do NOT use for file reading (use code/read), searching (use code/search), or directory listing (use code/tree).', + + 'code/shell/watch': 'Stream output from a running async shell execution. Use after code/shell/execute with wait=false to monitor long-running processes.', + + 'code/shell/status': 'Check shell session status — working directory, active executions.', + + 'code/shell/kill': 'Kill a running shell execution by its execution ID.', + + // ── Chat tools ─────────────────────────────────────────────── + 'collaboration/chat/send': 'Send a message to a chat room. Use the room name (e.g., "general") not a UUID.', + + 'collaboration/chat/export': 'Export chat messages as markdown. Useful for reviewing conversation history.', +}; + +/** + * Global parameter name → description fallback. + * Many commands share the same parameter names (room, limit, filter, etc.). + * This map provides decent descriptions for ALL tools without per-tool overrides. + * Per-tool overrides in PARAM_DESCRIPTION_OVERRIDES take priority when they exist. + */ +const GLOBAL_PARAM_DESCRIPTIONS: Record = { + // ── Identity & targeting ───────────────────────────────────────── + room: 'Room name (e.g. "general")', + roomId: 'Room UUID', + userId: 'User UUID', + senderId: 'Sender user UUID', + targetUserId: 'Target user UUID', + personaId: 'Persona user UUID', + assignee: 'Assignee user UUID', + proposedBy: 'User UUID who proposed this', + callerId: 'Caller user UUID', + sessionId: 'Session UUID', + contextId: 'Context/conversation UUID', + activityId: 'Activity UUID', + entityId: 'Entity UUID', + id: 'Entity UUID', + uniqueId: 'Unique string identifier', + proposalId: 'Proposal UUID', + messageId: 'Message UUID', + changeId: 'Change/revision UUID', + executionId: 'Execution handle UUID', + + // ── Content ────────────────────────────────────────────────────── + message: 'Text content of the message', + content: 'Body content (text, markdown, or code)', + text: 'Text content', + prompt: 'Prompt text for the AI', + description: 'Human-readable description', + rationale: 'Reasoning or justification', + topic: 'Subject/title', + comment: 'Optional comment text', + name: 'Display name', + displayName: 'Display name shown in UI', + title: 'Title text', + subtitle: 'Secondary title text', + doc: 'Document name', + label: 'Short label text', + + // ── Query & pagination ─────────────────────────────────────────── + limit: 'Maximum number of results', + offset: 'Number of results to skip', + cursor: 'Pagination cursor from previous query', + filter: 'JSON filter object (e.g. {"status":"active"})', + orderBy: 'Sort order: [{"field":"createdAt","direction":"desc"}]', + orderDirection: '"asc" or "desc"', + collection: 'Data collection name (e.g. "users", "rooms")', + status: 'Status filter (e.g. "active", "open", "closed")', + domain: 'Domain/category filter', + tags: 'JSON array of tags', + pattern: 'Search pattern (regex supported)', + query: 'Search query string', + fields: 'JSON array of field names to return', + + // ── File operations ────────────────────────────────────────────── + filePath: 'File path relative to workspace', + path: 'Directory or file path', + startLine: 'Starting line number', + endLine: 'Ending line number', + lines: 'Line range (e.g. "10-20")', + fileGlob: 'File glob pattern (e.g. "*.ts", "src/**/*.js")', + maxDepth: 'Maximum directory depth', + + // ── Behavior flags ─────────────────────────────────────────────── + verbose: 'Include detailed output (boolean)', + append: 'Append instead of overwrite (boolean)', + wait: 'Wait for completion (boolean)', + includeSystem: 'Include system messages (boolean)', + includeMetadata:'Include metadata in output (boolean)', + toc: 'Show table of contents only (boolean)', + typeCheck: 'Run type checking (boolean)', + + // ── Timing ─────────────────────────────────────────────────────── + timeoutMs: 'Timeout in milliseconds', + timestamp: 'ISO timestamp or unix milliseconds', + afterMessageId: 'Only items after this message ID', + afterTimestamp: 'Only items after this ISO timestamp', + votingDeadline: 'Voting deadline (ISO timestamp)', + + // ── AI/Model ───────────────────────────────────────────────────── + model: 'AI model identifier', + provider: 'AI provider name (e.g. "anthropic", "openai")', + temperature: 'Sampling temperature (0.0-1.0)', + maxTokens: 'Maximum output tokens', + tools: 'JSON array of tool names to enable', + + // ── Collaboration ──────────────────────────────────────────────── + replyToId: 'Message short ID to reply to', + participants: 'JSON array of user IDs', + role: 'Role name (e.g. "member", "admin")', + options: 'JSON array of choice options', + rankedChoices: 'JSON array of option IDs in preference order', + priority: 'Priority level (0.0-1.0 or "low"/"medium"/"high")', + scope: 'Scope: "team", "project", or "system"', + commitMessage: 'Description of the change (like a git commit message)', + output: 'Output file path (omit to print to stdout)', + count: 'Number of items', + cmd: 'Shell command to execute', + rules: 'JSON array of rule objects', }; /** @@ -332,14 +626,25 @@ function convertCommandToTool(cmd: CommandSignature): ToolDefinition { const properties: Record = {}; const required: string[] = []; - // Look up rich descriptions for this command + // Infrastructure params that are auto-injected by the command dispatcher. + // These must NEVER appear in tool specs — models can't provide them, + // and APIs reject tool calls for missing required infra params. + const INFRA_PARAMS = new Set(['userId', 'sessionId', 'contextId', 'context']); + + // Look up rich descriptions for this command (per-tool overrides take priority) const descOverrides = PARAM_DESCRIPTION_OVERRIDES[cmd.name]; if (cmd.params) { for (const [paramName, paramInfo] of Object.entries(cmd.params)) { + // Skip infrastructure params — auto-injected, not user-facing + if (INFRA_PARAMS.has(paramName)) continue; + properties[paramName] = { type: paramInfo.type as any, // Trust the type from command signature - description: descOverrides?.[paramName] || paramInfo.description || `${paramName} parameter`, + description: descOverrides?.[paramName] + || paramInfo.description + || GLOBAL_PARAM_DESCRIPTIONS[paramName] + || `${paramName}`, required: paramInfo.required }; @@ -349,16 +654,22 @@ function convertCommandToTool(cmd: CommandSignature): ToolDefinition { } } - // Clean JSDoc artifacts from description (schema generator captures raw comment blocks) - // "Foo Types\n *\n * Real description" → "Real description" - const rawDesc = cmd.description || `Execute ${cmd.name} command`; - const cleanedDesc = rawDesc - .replace(/^[^*]*\*\s*/gm, '') // Strip leading " * " from JSDoc lines - .replace(/\n\s*\n/g, '\n') // Collapse multiple newlines - .trim(); - // Use the last meaningful sentence if first line is just a title (e.g. "Foo Types") - const descLines = cleanedDesc.split('\n').filter(l => l.trim().length > 0); - const description = descLines.length > 1 ? descLines.slice(1).join(' ').trim() || descLines[0] : descLines[0] || rawDesc; + // Use rich description override if available, otherwise clean JSDoc artifacts + let description: string; + if (TOOL_DESCRIPTION_OVERRIDES[cmd.name]) { + description = TOOL_DESCRIPTION_OVERRIDES[cmd.name]; + } else { + // Clean JSDoc artifacts from description (schema generator captures raw comment blocks) + // "Foo Types\n *\n * Real description" → "Real description" + const rawDesc = cmd.description || `Execute ${cmd.name} command`; + const cleanedDesc = rawDesc + .replace(/^[^*]*\*\s*/gm, '') // Strip leading " * " from JSDoc lines + .replace(/\n\s*\n/g, '\n') // Collapse multiple newlines + .trim(); + // Use the last meaningful sentence if first line is just a title (e.g. "Foo Types") + const descLines = cleanedDesc.split('\n').filter(l => l.trim().length > 0); + description = descLines.length > 1 ? descLines.slice(1).join(' ').trim() || descLines[0] : descLines[0] || rawDesc; + } return { name: cmd.name, @@ -570,15 +881,33 @@ Every response to a coding request should contain tool_use blocks, not explanati } output += ` -=== DEVELOPMENT WORKFLOW === - -1. READ first: code/read to understand existing code -2. WRITE/EDIT: code/write for new files, code/edit for changes -3. BUILD: code/shell/execute to compile/test -4. VERIFY: code/verify to check for errors -5. SEE RESULTS: screenshot to view output +=== CODE EDITING RULES === + +1. ORIENT: code/tree to see structure, code/search to find relevant files +2. READ: ALWAYS code/read a file before editing it +3. EDIT: code/edit (search_replace) for changes, code/write ONLY for new files +4. VERIFY: code/verify after EVERY change — fix errors before moving on +5. REVIEW: code/diff to preview, code/git status before committing + +CRITICAL: +- code/edit search_replace: the search text must match EXACTLY as shown in code/read output +- Prefer code/edit over code/write for existing files — code/write replaces the ENTIRE file +- Use code/search instead of shell grep, code/tree instead of shell ls +- NEVER edit a file you haven't read — your changes will be wrong +- When code/verify fails: read the errors, fix the file, verify again + +Example - Edit existing file (preferred): + + code/edit + + src/calculator.ts + search_replace + return a + b; + return a + b + 0; // ensure numeric + + -Example - Write a file: +Example - Create new file: code/write @@ -587,19 +916,13 @@ Example - Write a file: -Example - Run a command: +Example - Run build/test: code/shell/execute - npm run build + npm run build - -Example - Verify changes: - - code/verify - - `; return output; diff --git a/src/debug/jtag/system/user/server/modules/PersonaToolExecutor.ts b/src/debug/jtag/system/user/server/modules/PersonaToolExecutor.ts index bda52de8b..62f80fff2 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaToolExecutor.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaToolExecutor.ts @@ -1,46 +1,39 @@ /** * PersonaToolExecutor - Handles tool calling for PersonaUser * - * Parses tool calls from AI responses, executes them via ToolRegistry, - * and formats results for injection back into conversation. - * - * CLEAN ARCHITECTURE: - * - Uses ToolRegistry for ALL command execution (no hardcoded handlers) - * - XML parsing only (no command-specific logic) - * - Logging and metrics + * Wraps AgentToolExecutor (universal tool execution) with persona-specific + * pre/post processing: + * - Workspace auto-bootstrap for code/* tools + * - Tool result storage as ChatMessageEntity (working memory) + * - Media collection with persona config filtering + * - Cognition telemetry logging + * - Sentinel auto-config for shell commands * * KEY METHODS: - * - executeSingleTool() — core per-tool pipeline (corrections, execution, storage, media) + * - executeSingleTool() — core per-tool pipeline (delegate + persona pre/post) * - executeToolCalls() — XML-formatted batch execution (for XML fallback path) * - executeNativeToolCalls() — structured batch execution (for native tool_result protocol) */ import { CognitionLogger } from './cognition/CognitionLogger'; import { SentinelAutoConfig } from '../../../code/server/SentinelAutoConfig'; -import { DATA_COMMANDS } from '@commands/data/shared/DataCommandConstants'; import type { UUID } from '../../../core/types/CrossPlatformUUID'; import { generateUUID } from '../../../core/types/CrossPlatformUUID'; -import { ToolRegistry } from '../../../tools/server/ToolRegistry'; import type { MediaItem } from '../../../data/entities/ChatMessageEntity'; import { ChatMessageEntity } from '../../../data/entities/ChatMessageEntity'; import type { PersonaMediaConfig } from './PersonaMediaConfig'; -import { getToolFormatAdapters, type ToolFormatAdapter } from './ToolFormatAdapter'; import { unsanitizeToolName } from './ToolFormatAdapter'; import { Logger } from '../../../core/logging/Logger'; -import { RoomResolver } from '../../../core/server/RoomResolver'; +import { AgentToolExecutor, type ToolCall, type ToolCallContext } from '../../../tools/server/AgentToolExecutor'; import { DataCreate } from '../../../../commands/data/create/shared/DataCreateTypes'; import type { ToolCall as NativeToolCall, ToolResult as NativeToolResult, } from '@daemons/ai-provider-daemon/shared/AIProviderTypesV2'; -/** - * Parsed tool call from AI response - */ -export interface ToolCall { - toolName: string; - parameters: Record; -} + +// Re-export ToolCall for backward compat (PersonaResponseGenerator imports from here) +export type { ToolCall }; /** * Tool execution context with media configuration @@ -91,101 +84,14 @@ export interface PersonaUserForToolExecutor { export class PersonaToolExecutor { - /** - * Tool name corrections: LLMs sometimes confuse similarly-named tools. - * workspace/tree shows the JTAG command hierarchy, code/tree shows workspace files. - */ - private static readonly TOOL_CORRECTIONS: Record = { - 'workspace/tree': 'code/tree', - }; - - /** - * Parameter name corrections per command prefix. - * LLMs guess wrong parameter names when tool descriptions are generic. - * Maps { wrongName → correctName } for each command prefix. - */ - private static readonly PARAM_CORRECTIONS: Record> = { - 'code/write': { - 'path': 'filePath', - 'file': 'filePath', - 'file_path': 'filePath', - 'filepath': 'filePath', - 'filename': 'filePath', - 'file_name': 'filePath', - 'name': 'filePath', - 'contents': 'content', - 'text': 'content', - 'body': 'content', - 'data': 'content', - 'code': 'content', - 'html': 'content', - 'source': 'content', - }, - 'code/read': { - 'path': 'filePath', - 'file': 'filePath', - 'file_path': 'filePath', - 'filepath': 'filePath', - 'filename': 'filePath', - 'name': 'filePath', - 'start': 'startLine', - 'end': 'endLine', - 'from': 'startLine', - 'to': 'endLine', - }, - 'code/edit': { - 'path': 'filePath', - 'file': 'filePath', - 'file_path': 'filePath', - 'filepath': 'filePath', - 'filename': 'filePath', - 'name': 'filePath', - 'mode': 'editMode', - 'type': 'editMode', - }, - 'code/search': { - 'query': 'pattern', - 'search': 'pattern', - 'term': 'pattern', - 'regex': 'pattern', - 'glob': 'fileGlob', - 'filter': 'fileGlob', - }, - 'code/tree': { - 'directory': 'path', - 'dir': 'path', - 'folder': 'path', - 'depth': 'maxDepth', - }, - 'code/git': { - 'subcommand': 'operation', - 'command': 'operation', - 'action': 'operation', - 'op': 'operation', - 'msg': 'message', - 'files': 'paths', - }, - }; - - /** - * LOOP DETECTION: Track recent tool calls per persona to detect infinite loops - * Map> - * When same tool call appears 3+ times in 60 seconds, it's blocked - */ - private static readonly recentToolCalls: Map> = new Map(); - private static readonly LOOP_DETECTION_WINDOW_MS = 60000; // 60 seconds - private static readonly LOOP_DETECTION_THRESHOLD = 2; // Block after 2 identical calls - + private readonly agentExecutor: AgentToolExecutor; private persona: PersonaUserForToolExecutor; - private toolRegistry: ToolRegistry; - private formatAdapters: ToolFormatAdapter[]; private log: ReturnType; private workspaceBootstrapped = false; constructor(personaUser: PersonaUserForToolExecutor) { this.persona = personaUser; - this.toolRegistry = ToolRegistry.getInstance(); - this.formatAdapters = getToolFormatAdapters(); + this.agentExecutor = new AgentToolExecutor(); // Per-persona tools.log in their home directory const category = 'logs/tools'; @@ -197,65 +103,11 @@ export class PersonaToolExecutor { } /** - * LOOP DETECTION: Create a hash of a tool call for comparison - */ - private static hashToolCall(toolCall: ToolCall): string { - return `${toolCall.toolName}:${JSON.stringify(toolCall.parameters)}`; - } - - /** - * LOOP DETECTION: Check if a tool call is a duplicate (appears too frequently) - * Returns true if blocked (is a loop), false if allowed - */ - private isLoopDetected(toolCall: ToolCall): boolean { - const personaId = this.persona.id; - const hash = PersonaToolExecutor.hashToolCall(toolCall); - const now = Date.now(); - - // Get or create recent calls list for this persona - let recentCalls = PersonaToolExecutor.recentToolCalls.get(personaId) || []; - - // Clean up old entries outside the window - recentCalls = recentCalls.filter( - entry => now - entry.timestamp < PersonaToolExecutor.LOOP_DETECTION_WINDOW_MS - ); - - // Count how many times this exact call appears - const duplicateCount = recentCalls.filter(entry => entry.hash === hash).length; - - // Record this call (even if it will be blocked) - recentCalls.push({ hash, timestamp: now }); - PersonaToolExecutor.recentToolCalls.set(personaId, recentCalls); - - // Block if threshold exceeded - if (duplicateCount >= PersonaToolExecutor.LOOP_DETECTION_THRESHOLD) { - this.log.warn(`🔁 LOOP DETECTED: ${toolCall.toolName} called ${duplicateCount + 1}x in ${PersonaToolExecutor.LOOP_DETECTION_WINDOW_MS / 1000}s - BLOCKING`); - return true; - } - - return false; - } - - /** - * Parse tool calls from AI response text using registered format adapters - * Extensible via adapter pattern - add new formats in ToolFormatAdapter.ts + * Parse tool calls from AI response text using registered format adapters. + * Delegates to AgentToolExecutor. */ parseToolCalls(responseText: string): ToolCall[] { - const toolCalls: ToolCall[] = []; - - // Iterate through all registered adapters - for (const adapter of this.formatAdapters) { - const matches = adapter.matches(responseText); - - for (const match of matches) { - const toolCall = adapter.parse(match); - if (toolCall) { - toolCalls.push(toolCall); - } - } - } - - return toolCalls; + return this.agentExecutor.parseToolCalls(responseText); } // ────────────────────────────────────────────── @@ -270,9 +122,9 @@ export class PersonaToolExecutor { toolCalls: ToolCall[], context: ToolExecutionContext, ): Promise { - // Filter out looping tool calls before execution + // Filter out looping tool calls before execution (delegate to AgentToolExecutor) const filtered = toolCalls.filter(toolCall => { - if (this.isLoopDetected(toolCall)) { + if (this.agentExecutor.isLoopDetected(toolCall.toolName, toolCall.parameters, this.persona.id)) { this.log.warn(`Skipping looping tool call: ${toolCall.toolName}`); return false; } @@ -281,21 +133,20 @@ export class PersonaToolExecutor { // Auto-bootstrap workspace if any code/* tools are being called const hasCodeTools = filtered.some(tc => tc.toolName.startsWith('code/')); - this.log.debug(`📋 prepareBatch: ${filtered.length} tools, hasCodeTools=${hasCodeTools}, bootstrapped=${this.workspaceBootstrapped}`); + this.log.debug(`prepareBatch: ${filtered.length} tools, hasCodeTools=${hasCodeTools}, bootstrapped=${this.workspaceBootstrapped}`); if (hasCodeTools && !this.workspaceBootstrapped) { if (this.persona.ensureCodeWorkspace) { try { - this.log.info('🔧 Auto-bootstrapping workspace for code/* tool execution'); + this.log.info('Auto-bootstrapping workspace for code/* tool execution'); await this.persona.ensureCodeWorkspace(); this.workspaceBootstrapped = true; - this.log.info('✅ Workspace bootstrapped successfully'); + this.log.info('Workspace bootstrapped successfully'); } catch (err: any) { - this.log.error(`❌ Failed to bootstrap workspace: ${err.message}`); - // Don't return early - let the tool execution fail with a clearer error + this.log.error(`Failed to bootstrap workspace: ${err.message}`); } } else { - this.log.warn('⚠️ code/* tools called but ensureCodeWorkspace callback not available'); + this.log.warn('code/* tools called but ensureCodeWorkspace callback not available'); } } @@ -303,10 +154,10 @@ export class PersonaToolExecutor { } /** - * Execute a single tool call through the full pipeline. + * Execute a single tool call through the full persona pipeline. * - * Handles: name/param correction, room resolution, ToolRegistry execution, - * logging, result storage, and media collection. + * 1. Delegate correction + execution to AgentToolExecutor + * 2. Persona post-processing: sentinel auto-config, logging, storage, media collection */ private async executeSingleTool( toolCall: ToolCall, @@ -314,102 +165,59 @@ export class PersonaToolExecutor { ): Promise { const startTime = Date.now(); - // Redirect common tool name confusion (workspace/* → code/*) - const correctedToolName = PersonaToolExecutor.TOOL_CORRECTIONS[toolCall.toolName] ?? toolCall.toolName; - if (correctedToolName !== toolCall.toolName) { - this.log.info(`↪ Redirected ${toolCall.toolName} → ${correctedToolName}`); - toolCall = { ...toolCall, toolName: correctedToolName }; + // Apply name/param corrections via AgentToolExecutor + const { corrected, nameChanged, paramsChanged } = this.agentExecutor.correctToolCall(toolCall); + if (nameChanged) { + this.log.info(`Redirected ${toolCall.toolName} -> ${corrected.toolName}`); } - - // Correct common parameter name mismatches (LLMs guess wrong names) - const paramCorrections = PersonaToolExecutor.PARAM_CORRECTIONS[toolCall.toolName]; - if (paramCorrections) { - const correctedParams = { ...toolCall.parameters }; - for (const [wrongName, correctName] of Object.entries(paramCorrections)) { - if (correctedParams[wrongName] !== undefined && correctedParams[correctName] === undefined) { - correctedParams[correctName] = correctedParams[wrongName]; - delete correctedParams[wrongName]; - this.log.info(`↪ Param corrected: ${wrongName} → ${correctName}`); - } - } - toolCall = { ...toolCall, parameters: correctedParams }; - } - - // Clean up code/write content: CDATA wrappers, HTML entities - // Models encode HTML differently when writing code — normalize before execution - if (toolCall.toolName === 'code/write' && toolCall.parameters.content) { - let content = toolCall.parameters.content; - let cleaned = false; - - // Strip CDATA wrappers (Together wraps HTML in for XML safety) - const cdataMatch = content.match(/^$/); - if (cdataMatch) { - content = cdataMatch[1]; - cleaned = true; - } - - // Decode HTML entities in a single pass (Groq double-escapes HTML as <html>) - const NAMED: Record = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'", nbsp: ' ' }; - const decoded = content.replace(/&(#\d+|#x[\da-fA-F]+|[a-zA-Z]+);/g, (match, entity: string) => { - if (NAMED[entity]) return NAMED[entity]; - if (entity.startsWith('#x')) return String.fromCharCode(parseInt(entity.slice(2), 16)); - if (entity.startsWith('#')) return String.fromCharCode(parseInt(entity.slice(1), 10)); - return match; - }); - if (decoded !== content) { content = decoded; cleaned = true; } - - if (cleaned) { - toolCall = { ...toolCall, parameters: { ...toolCall.parameters, content } }; - this.log.info('↪ Cleaned code/write content (CDATA/entity normalization)'); - } + for (const change of paramsChanged) { + this.log.info(`Param corrected: ${change}`); } - // Resolve "current" room parameter to actual room name - const resolvedParams = await this.resolveRoomParameters(toolCall.parameters, context.contextId); - - // Inject userId for workspace-scoped commands (code/*, etc.) that need to know - // which persona's workspace to operate on. Identity detection uses context.userId. - const paramsWithCaller = { - ...resolvedParams, - userId: context.personaId, // For workspace-scoped commands (code/*, etc.) - contextId: context.contextId // Room/context scope + // Build ToolCallContext from persona context + const callCtx: ToolCallContext = { + callerId: context.personaId, + sessionId: context.sessionId, + contextId: context.contextId, + context: context.context, }; - // Log tool call with clean params formatting (not array-wrapped) - const paramsJson = JSON.stringify(paramsWithCaller, null, 2); - this.log.info(`┌─ CALL: ${toolCall.toolName}`); - this.log.info(`│ params: ${paramsJson.replace(/\n/g, '\n│ ')}`); - - // Use ToolRegistry for ALL commands - no special cases - // NO try-catch - let exceptions bubble to PersonaResponseGenerator - // ToolRegistry returns {success: false, error} for expected failures - const registryResult = await this.toolRegistry.executeTool( - toolCall.toolName, - paramsWithCaller, // Pass params with callerId injected - context.sessionId, // Pass AI's sessionId for proper attribution - context.contextId, - context.context // Pass PersonaUser's enriched context (with callerType='persona') + // Log tool call + const paramsJson = JSON.stringify(corrected.parameters, null, 2); + this.log.info(`CALL: ${corrected.toolName}`); + this.log.info(` params: ${paramsJson.replace(/\n/g, '\n ')}`); + + // Execute via AgentToolExecutor (corrections already applied, so pass corrected directly) + const agentResult = await this.agentExecutor.executeToolCall( + corrected.toolName, + corrected.parameters, + callCtx ); + // Build ToolResult with media from registry result + // NOTE: AgentToolExecutor.executeToolCall returns ToolCallResult without media. + // For media support, we need to check if the underlying ToolRegistry result had media. + // The ToolRegistry.executeTool is called internally by AgentToolExecutor, which strips media. + // For personas that need media (screenshots), we re-query or accept no-media here. + // The media is still available through the tool result content (base64 in JSON). const result: ToolResult = { - toolName: registryResult.toolName, - success: registryResult.success, - content: registryResult.content, - media: registryResult.media, // ← Preserve structured media - error: registryResult.error + toolName: agentResult.toolName, + success: agentResult.success, + content: agentResult.success ? agentResult.content : undefined, + error: agentResult.error, + // Media comes through the content JSON for now — tools like screenshot + // embed base64 in the result content which PersonaResponseGenerator handles }; - // Auto-inject sentinel rules for code/shell/execute commands (fire-and-forget). - // When a build/test/lint command starts, sentinel classifies output lines - // so the persona gets filtered Error/Warning/Success instead of raw stdout. - if (toolCall.toolName === 'code/shell/execute' && result.success && result.content) { + // Auto-inject sentinel rules for code/shell/execute commands (fire-and-forget) + if (corrected.toolName === 'code/shell/execute' && result.success && result.content) { try { const execResult = JSON.parse(result.content); if (execResult.executionId && execResult.status === 'running') { SentinelAutoConfig.applyIfApplicable( context.personaId, execResult.executionId, - toolCall.parameters.cmd || '', + corrected.parameters.cmd || '', ).catch(err => this.log.warn(`Sentinel auto-config failed: ${err.message}`)); } } catch { @@ -419,13 +227,11 @@ export class PersonaToolExecutor { const duration = Date.now() - startTime; - // Log result with clear visual structure + // Log result if (result.success) { - // Parse result for better display (show key fields if JSON) let resultSummary = result.content?.slice(0, 500) || 'no content'; try { const parsed = JSON.parse(result.content || ''); - // Extract key fields for readable summary const keyFields = ['success', 'message', 'newMode', 'previousMode', 'count', 'items', 'data']; const summary: Record = {}; for (const key of keyFields) { @@ -438,80 +244,67 @@ export class PersonaToolExecutor { } } catch { /* not JSON, use raw */ } - this.log.info(`└─ RESULT: ✓ ${duration}ms`); - this.log.info(` ${resultSummary}${result.content && result.content.length > 500 ? '...' : ''}`); - if (result.media && result.media.length > 0) { - this.log.info(` media: ${result.media.map(m => `${m.type} (${m.mimeType})`).join(', ')}`); - } + this.log.info(`RESULT: OK ${duration}ms`); + this.log.info(` ${resultSummary}${result.content && result.content.length > 500 ? '...' : ''}`); } else { - this.log.error(`└─ RESULT: ✗ ${duration}ms`); - this.log.error(` error: ${result.error || 'unknown error'}`); + this.log.error(`RESULT: FAIL ${duration}ms`); + this.log.error(` error: ${result.error || 'unknown error'}`); } - // Store tool result in working memory and get UUID - this.log.debugIf(() => [`${toolCall.toolName} returned media:`, result.media ? `${result.media.length} items` : 'NONE']); - if (result.media && result.media.length > 0) { - this.log.debugIf(() => ['Media details:', result.media!.map(m => ({ - type: m.type, - hasBase64: !!m.base64, - base64Length: m.base64?.length, - mimeType: m.mimeType, - hasUrl: !!m.url - }))]); - } - - // Store tool result (awaited to get UUID, but could be fire-and-forget if needed) + // Store tool result in working memory const resultId = await this.storeToolResult( - toolCall.toolName, - toolCall.parameters, + corrected.toolName, + corrected.parameters, { success: result.success, - data: result.content, // Store full content in metadata + data: result.content, error: result.error, - media: result.media // Pass media for storage and RAG context }, - context.contextId // Use contextId (room) for storage + context.contextId ); - this.log.debug(`Stored tool result #${resultId.slice(0, 8)} with ${result.media?.length || 0} media`); + this.log.debug(`Stored tool result #${resultId.slice(0, 8)}`); - // Collect media for this tool + // Collect media for this tool (persona config filtering) const collectedMedia: MediaItem[] = []; + const isScreenshotTool = corrected.toolName === 'screenshot' || corrected.toolName === 'interface/screenshot'; - // Check if THIS persona wants media - // IMPORTANT: If AI explicitly called screenshot tool, they want the image! - // So we pass through media for screenshot regardless of autoLoadMedia config - const isScreenshotTool = toolCall.toolName === 'screenshot' || toolCall.toolName === 'interface/screenshot'; - const shouldLoadMedia = context.personaConfig.autoLoadMedia || isScreenshotTool; - - if (result.media && shouldLoadMedia) { - // Filter by supported types (unless it's screenshot - then pass through images) - const supportedMedia = result.media.filter(m => - isScreenshotTool || context.personaConfig.supportedMediaTypes.includes(m.type) - ); - - if (supportedMedia.length > 0) { - this.log.info(`Loading ${supportedMedia.length} media (types: ${supportedMedia.map(m => m.type).join(', ')})${isScreenshotTool ? ' [screenshot override]' : ''}`); - collectedMedia.push(...supportedMedia); + // Try to extract media from result content (tools embed media in JSON) + if (result.content) { + try { + const parsed = JSON.parse(result.content); + if (parsed.media) { + const mediaItems: MediaItem[] = Array.isArray(parsed.media) ? parsed.media : [parsed.media]; + const shouldLoadMedia = context.personaConfig.autoLoadMedia || isScreenshotTool; + + if (shouldLoadMedia && mediaItems.length > 0) { + const supportedMedia = mediaItems.filter(m => + isScreenshotTool || context.personaConfig.supportedMediaTypes.includes(m.type) + ); + if (supportedMedia.length > 0) { + this.log.info(`Loading ${supportedMedia.length} media (types: ${supportedMedia.map(m => m.type).join(', ')})${isScreenshotTool ? ' [screenshot override]' : ''}`); + collectedMedia.push(...supportedMedia); + } + } + } + } catch { + // Not JSON or no media field } - } else if (result.media && result.media.length > 0) { - this.log.debug(`Skipping ${result.media.length} media (autoLoadMedia=false)`); } - // Fire-and-forget: Log tool execution to cognition database (non-blocking) - // This is telemetry - don't block the response pipeline for it + // Fire-and-forget: Cognition telemetry CognitionLogger.logToolExecution( this.persona.id, this.persona.displayName, - toolCall.toolName, - toolCall.parameters, + corrected.toolName, + corrected.parameters, result.success ? 'success' : 'error', duration, - 'chat', // Domain + 'chat', context.contextId, { - toolResult: result.content?.slice(0, 1000), // First 1000 chars of result + toolResult: result.content?.slice(0, 1000), errorMessage: result.error, - storedResultId: resultId // Phase 3B: Link to stored result + storedResultId: resultId } ).catch(err => this.log.error('Failed to log tool execution:', err)); @@ -525,10 +318,6 @@ export class PersonaToolExecutor { /** * Execute tool calls and return XML-formatted results + optional media. * Used by the XML fallback path for non-native providers. - * - * @param toolCalls - Array of parsed tool calls - * @param context - Execution context with media configuration - * @returns Object with formatted text results, optional media array, and stored result UUIDs */ async executeToolCalls( toolCalls: ToolCall[], @@ -550,7 +339,7 @@ export class PersonaToolExecutor { return { formattedResults: '[All tool calls blocked - infinite loop detected]', storedResultIds: [] }; } - // Execute all tools concurrently — O(max tool time) instead of O(sum) + // Execute all tools concurrently const executions = await Promise.all(filtered.map(tc => this.executeSingleTool(tc, context))); const allMedia = executions.flatMap(e => e.media); @@ -568,14 +357,6 @@ export class PersonaToolExecutor { /** * Execute native tool calls from the canonical agent loop. * Returns per-tool ToolResult objects with full content and tool_use_id correlation. - * - * Calls executeSingleTool directly — no XML serialization/deserialization round-trip. - * Full content is returned (not summaries). Truncated honestly if too large. - * - * @param nativeToolCalls - Tool calls from AI provider (with id, name, input) - * @param context - Execution context with persona/session info - * @param maxResultChars - Maximum characters per tool result (truncated honestly) - * @returns Per-tool results, media, and stored IDs */ async executeNativeToolCalls( nativeToolCalls: NativeToolCall[], @@ -590,7 +371,7 @@ export class PersonaToolExecutor { return { results: [], media: [], storedIds: [] }; } - // Convert native format → executor format (decode sanitized names, stringify params) + // Convert native format → executor format const executorCalls: ToolCall[] = nativeToolCalls.map(tc => ({ toolName: unsanitizeToolName(tc.name), parameters: Object.fromEntries( @@ -604,19 +385,17 @@ export class PersonaToolExecutor { // Execute filtered tools in parallel const executions = await Promise.all(filtered.map(tc => this.executeSingleTool(tc, context))); - // Map results back to native tool calls with tool_use_id correlation. - // Tools blocked by loop detection get error results. + // Map results back to native tool calls with tool_use_id correlation const filteredSet = new Set(filtered); const results: NativeToolResult[] = []; let execIdx = 0; for (let i = 0; i < nativeToolCalls.length; i++) { if (!filteredSet.has(executorCalls[i])) { - // Tool was blocked by loop detection results.push({ - tool_use_id: nativeToolCalls[i].id, + toolUseId: nativeToolCalls[i].id, content: 'Tool call blocked by loop detection.', - is_error: true, + isError: true, }); continue; } @@ -626,15 +405,14 @@ export class PersonaToolExecutor { ? (exec.result.content || 'No content returned') : (exec.result.error || 'Unknown error'); - // Truncate honestly (not summarize) if too large if (content.length > maxResultChars) { content = content.slice(0, maxResultChars) + `\n[...truncated, ${content.length} chars total]`; } results.push({ - tool_use_id: nativeToolCalls[i].id, + toolUseId: nativeToolCalls[i].id, content, - is_error: !exec.result.success || undefined, + isError: !exec.result.success || undefined, }); } @@ -658,7 +436,6 @@ ${result.content} `; } else { - // Wrap error in code block for better UI readability return ` ${result.toolName} error @@ -672,77 +449,31 @@ ${result.error || 'Unknown error'} } /** - * Resolve "current" room parameters to actual room names - * Handles any parameter named "room" that has value "current" - * - * @param params - Tool parameters from AI - * @param contextId - The contextId (roomId) from execution context - * @returns Parameters with resolved room values + * Parse + correct + strip in ONE Rust IPC call. + * Returns both tool calls (already corrected) and cleaned text. + * Replaces separate parseToolCalls() + stripToolBlocks() calls. */ - private async resolveRoomParameters( - params: Record, - contextId: UUID - ): Promise> { - const resolved = { ...params }; - - // Check if there's a room parameter that needs resolution - if (resolved.room === 'current') { - const roomName = await RoomResolver.resolveCurrentParam('current', contextId); - if (roomName) { - this.log.info(`Resolved room="current" to "${roomName}"`); - resolved.room = roomName; - } else { - this.log.warn(`Could not resolve room="current" from contextId ${contextId}`); - } - } - - return resolved; + async parseResponse(responseText: string): Promise<{ toolCalls: ToolCall[]; cleanedText: string; parseTimeUs: number }> { + return this.agentExecutor.parseResponse(responseText); } /** - * Strip tool blocks from response text to get clean user-facing message - * Uses adapters to find and remove all tool blocks + * Strip tool blocks from response text to get clean user-facing message. + * Delegates to AgentToolExecutor. */ stripToolBlocks(responseText: string): string { - let cleaned = responseText; - - // Find all tool blocks using adapters - const allMatches: Array<{ start: number; end: number }> = []; - - for (const adapter of this.formatAdapters) { - const matches = adapter.matches(cleaned); - for (const match of matches) { - allMatches.push({ start: match.startIndex, end: match.endIndex }); - } - } - - // Sort by start index descending (remove from end first to preserve indices) - allMatches.sort((a, b) => b.start - a.start); - - // Remove all matched blocks - for (const match of allMatches) { - cleaned = cleaned.slice(0, match.start) + cleaned.slice(match.end); - } - - return cleaned.trim(); + return this.agentExecutor.stripToolBlocks(responseText); } /** * Get list of available tools (from ToolRegistry) */ getAvailableTools(): string[] { - return this.toolRegistry.getAllTools().map(t => t.name); + return this.agentExecutor.availableTools; } /** * Store tool result as ChatMessageEntity for working memory - * Phase 3B: Lazy loading pattern - store full data, return UUID for reference - * - * @param toolName - Name of tool executed - * @param parameters - Parameters passed to tool - * @param result - Execution result with optional media - * @param roomId - Room where tool was executed - * @returns UUID of stored message entity */ async storeToolResult( toolName: string, @@ -750,17 +481,15 @@ ${result.error || 'Unknown error'} result: { success: boolean; data: unknown; error?: string; media?: MediaItem[] }, roomId: UUID ): Promise { - // Generate short summary (< 100 tokens) const summary = this.generateSummary(toolName, result); - // Create message entity const message = new ChatMessageEntity(); message.id = generateUUID(); message.roomId = roomId; message.senderId = this.persona.id; message.senderName = this.persona.displayName; - message.senderType = 'system'; // Tool results are system messages - message.content = { text: summary, media: result.media || [] }; // Include media from tool results + message.senderType = 'system'; + message.content = { text: summary, media: result.media || [] }; message.metadata = { toolResult: true, toolName, @@ -775,13 +504,11 @@ ${result.error || 'Unknown error'} message.priority = 'normal'; message.reactions = []; - // Store via Commands system (universal pattern) await DataCreate.execute({ - collection: ChatMessageEntity.collection, - backend: 'server', - data: message - } - ); + collection: ChatMessageEntity.collection, + backend: 'server', + data: message + }); this.log.debug(`Stored tool result #${message.id.slice(0, 8)} (${summary})`); return message.id; @@ -789,27 +516,18 @@ ${result.error || 'Unknown error'} /** * Generate short summary of tool result for RAG context - * Phase 3B: Keep summaries < 100 tokens to save context budget - * - * @param toolName - Name of tool executed - * @param result - Execution result - * @returns Short summary string */ private generateSummary( toolName: string, result: { success: boolean; data: unknown; error?: unknown } ): string { if (!result.success) { - const errorMessage = this.stringifyError(result.error); - return `Tool '${toolName}' failed: ${errorMessage}`; + return `Tool '${toolName}' failed: ${this.stringifyError(result.error)}`; } const data = result.data; - - // Action label from tool name: "code/write" → "write", "collaboration/decision/vote" → "vote" const action = toolName.split('/').pop() ?? toolName; - // Data-shape-driven summary — extract what the data reveals, not what tool produced it if (Array.isArray(data)) { return `${action}: ${data.length} item${data.length !== 1 ? 's' : ''}`; } @@ -823,18 +541,15 @@ ${result.error || 'Unknown error'} const obj = data as Record; const parts: string[] = []; - // File path (most common structured field) const filePath = obj.filePath ?? obj.file_path ?? obj.path ?? obj.fileName ?? obj.file_name; if (filePath) parts.push(String(filePath)); - // Size / count metrics const bytes = obj.bytesWritten ?? obj.bytes_written ?? obj.size ?? obj.byteLength; if (typeof bytes === 'number') parts.push(`${bytes} bytes`); const count = obj.count ?? obj.total ?? obj.matches ?? obj.length; if (typeof count === 'number') parts.push(`${count} items`); - // Dimensions const width = obj.width; const height = obj.height; if (typeof width === 'number' && typeof height === 'number') parts.push(`${width}x${height}`); @@ -842,63 +557,38 @@ ${result.error || 'Unknown error'} if (parts.length > 0) return `${action}: ${parts.join(', ')}`; } - // Compact fallback — tool name + truncated preview const dataStr = typeof data === 'string' ? data : JSON.stringify(data); return `${action}: ${dataStr.slice(0, 120)}${dataStr.length > 120 ? '...' : ''}`; } /** * Convert any error value to a human-readable string - * Prevents [object Object] in error messages - * - * @param error - Any error value (string, Error, object, etc.) - * @returns Human-readable error string */ private stringifyError(error: unknown): string { - if (error === undefined || error === null) { - return 'Unknown error'; - } + if (error === undefined || error === null) return 'Unknown error'; + if (typeof error === 'string') return error; + if (error instanceof Error) return error.message; - // Already a string - if (typeof error === 'string') { - return error; - } - - // Error instance - if (error instanceof Error) { - return error.message; - } - - // Object with message property (common pattern) if (typeof error === 'object' && error !== null) { const obj = error as Record; - - // Try common error message properties if (typeof obj.message === 'string') return obj.message; if (typeof obj.error === 'string') return obj.error; if (typeof obj.errorMessage === 'string') return obj.errorMessage; if (typeof obj.msg === 'string') return obj.msg; - // Nested error object if (obj.error && typeof obj.error === 'object') { const nested = obj.error as Record; if (typeof nested.message === 'string') return nested.message; } - // Last resort: stringify the object try { const str = JSON.stringify(error); - // Don't return huge objects - if (str.length > 500) { - return `${str.slice(0, 500)}...`; - } - return str; + return str.length > 500 ? `${str.slice(0, 500)}...` : str; } catch { return 'Error object could not be serialized'; } } - // Fallback for primitives return String(error); } } diff --git a/src/debug/jtag/system/user/server/modules/RateLimiter.ts b/src/debug/jtag/system/user/server/modules/RateLimiter.ts index 8d3c7bb3e..f2b73da7c 100644 --- a/src/debug/jtag/system/user/server/modules/RateLimiter.ts +++ b/src/debug/jtag/system/user/server/modules/RateLimiter.ts @@ -3,13 +3,13 @@ * * Extracted from PersonaUser.ts (Phase 1, Commit 1.2) * - * Manages per-room rate limiting for AI responses: - * - Time-based limiting (min seconds between responses per room) - * - Response count caps (max responses per room per session) - * - Message deduplication (prevent evaluating same message multiple times) + * Manages voice transcription deduplication (string-keyed, not UUID). + * Chat message dedup and rate limiting decisions are now in Rust + * (PersonaCognitionEngine + full_evaluate gate). * - * This module is stateful and maintains in-memory tracking. - * Future: Move to SQLite for persistence across restarts. + * Retained functionality: + * - Configuration holding (synced to Rust on init) + * - Voice transcription dedup (composite string keys, not UUIDs) */ import type { UUID } from '../../../core/types/CrossPlatformUUID'; @@ -19,20 +19,8 @@ export interface RateLimitConfig { maxResponsesPerSession: number; } -export interface RateLimitInfo { - isLimited: boolean; - lastResponseTime: Date | null; - responseCount: number; - secondsSinceLastResponse: number | null; - waitTimeSeconds: number | null; -} - export class RateLimiter { - // Rate limiting state (in-memory for now, will move to SQLite later) - private lastResponseTime: Map = new Map(); - private responseCount: Map = new Map(); // room -> count - private evaluatedMessages: Set = new Set(); // messageId -> already evaluated - + private evaluatedMessages: Set = new Set(); private readonly config: RateLimitConfig; constructor(config?: Partial) { @@ -43,109 +31,30 @@ export class RateLimiter { } /** - * Check if this persona is rate limited for a room - */ - isRateLimited(roomId: UUID): boolean { - const lastTime = this.lastResponseTime.get(roomId); - if (!lastTime) { - return false; // Never responded in this room - } - - const secondsSince = (Date.now() - lastTime.getTime()) / 1000; - return secondsSince < this.config.minSecondsBetweenResponses; - } - - /** - * Get detailed rate limit information for a room - */ - getRateLimitInfo(roomId: UUID): RateLimitInfo { - const lastTime = this.lastResponseTime.get(roomId); - const count = this.responseCount.get(roomId) || 0; - - if (!lastTime) { - return { - isLimited: false, - lastResponseTime: null, - responseCount: count, - secondsSinceLastResponse: null, - waitTimeSeconds: null - }; - } - - const secondsSince = (Date.now() - lastTime.getTime()) / 1000; - const isLimited = secondsSince < this.config.minSecondsBetweenResponses; - const waitTime = isLimited - ? this.config.minSecondsBetweenResponses - secondsSince - : null; - - return { - isLimited, - lastResponseTime: lastTime, - responseCount: count, - secondsSinceLastResponse: secondsSince, - waitTimeSeconds: waitTime - }; - } - - /** - * Track that a response was sent in a room - * Updates both response time and count - */ - trackResponse(roomId: UUID): void { - // Increment response count - const newCount = (this.responseCount.get(roomId) || 0) + 1; - this.responseCount.set(roomId, newCount); - - // Track response time for rate limiting - this.lastResponseTime.set(roomId, new Date()); - } - - /** - * Check if a message has already been evaluated + * Check if a message/transcription key has already been evaluated. + * Used for voice transcription dedup (composite string keys). */ hasEvaluatedMessage(messageId: UUID): boolean { return this.evaluatedMessages.has(messageId); } /** - * Mark a message as evaluated (deduplication) + * Mark a message/transcription key as evaluated (deduplication). + * Used for voice transcription dedup (composite string keys). */ markMessageEvaluated(messageId: UUID): void { this.evaluatedMessages.add(messageId); } /** - * Get current response count for a room - */ - getResponseCount(roomId: UUID): number { - return this.responseCount.get(roomId) || 0; - } - - /** - * Check if response count cap has been reached for a room - */ - hasReachedResponseCap(roomId: UUID): boolean { - const count = this.responseCount.get(roomId) || 0; - return count >= this.config.maxResponsesPerSession; - } - - /** - * Reset rate limit state for a room (useful for testing) - */ - resetRoom(roomId: UUID): void { - this.lastResponseTime.delete(roomId); - this.responseCount.delete(roomId); - } - - /** - * Clear all evaluated messages (useful for testing or memory management) + * Clear all evaluated messages (called on chat truncation events). */ clearEvaluatedMessages(): void { this.evaluatedMessages.clear(); } /** - * Get configuration (immutable copy) + * Get configuration (immutable copy). Synced to Rust on init. */ getConfig(): Readonly { return { ...this.config }; diff --git a/src/debug/jtag/system/user/server/modules/ResponseCleaner.ts b/src/debug/jtag/system/user/server/modules/ResponseCleaner.ts deleted file mode 100644 index 2ad74fa51..000000000 --- a/src/debug/jtag/system/user/server/modules/ResponseCleaner.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * ResponseCleaner - Cleans AI-generated responses before posting - * - * Problem: LLMs sometimes copy formatting from conversation history, - * adding unwanted prefixes like "[HH:MM] Name: " to their responses. - * - * Solution: Strip these patterns using regex heuristics. - * - * Future: Could become AI-powered via ThoughtStream adapter - * - An AI evaluates: "Does this response have formatting issues?" - * - Returns cleaned version with confidence score - * - Pluggable via recipe configuration - */ - -/** - * Configuration for ResponseCleaner - */ -export interface ResponseCleanerConfig { - /** Logger function for debug output */ - log?: (message: string) => void; -} - -/** - * ResponseCleaner - Strips unwanted prefixes from AI responses - * - * Usage: - * ```typescript - * const cleaner = new ResponseCleaner({ log: this.log.bind(this) }); - * const cleaned = cleaner.clean(aiResponse.text); - * ``` - */ -export class ResponseCleaner { - private log: (message: string) => void; - - constructor(config: ResponseCleanerConfig = {}) { - this.log = config.log ?? (() => {}); - } - - /** - * Clean AI response by stripping unwanted prefixes - * - * Examples to strip: - * - "[11:59] GPT Assistant: Yes, Joel..." → "Yes, Joel..." - * - "GPT Assistant: Yes, Joel..." → "Yes, Joel..." - * - "[11:59] Yes, Joel..." → "Yes, Joel..." - * - * @param response - Raw AI response - * @returns Cleaned response with prefixes removed - */ - clean(response: string): string { - const original = response.trim(); - let cleaned = original; - - // Pattern 1: Strip "[HH:MM] Name: " prefix - // Matches: [11:59] GPT Assistant: message - cleaned = cleaned.replace(/^\[\d{1,2}:\d{2}\]\s+[^:]+:\s*/, ''); - - // Pattern 2: Strip "Name: " prefix at start - // Matches: GPT Assistant: message - // Only if it looks like a name (starts with capital, contains letters/spaces) - cleaned = cleaned.replace(/^[A-Z][A-Za-z\s]+:\s*/, ''); - - // Pattern 3: Strip just "[HH:MM] " timestamp prefix - // Matches: [11:59] message - cleaned = cleaned.replace(/^\[\d{1,2}:\d{2}\]\s*/, ''); - - // Pattern 4: Strip markdown role markers some models add - // Matches: **Assistant:** or *Assistant:* at start - cleaned = cleaned.replace(/^\*{1,2}[A-Za-z\s]+:\*{1,2}\s*/, ''); - - // Log if we cleaned anything - if (cleaned !== original) { - this.log(`🧹 Stripped prefix from AI response`); - this.log(` Original: "${original.slice(0, 80)}..."`); - this.log(` Cleaned: "${cleaned.slice(0, 80)}..."`); - } - - return cleaned.trim(); - } - - /** - * Check if response appears to have a prefix that needs cleaning - * Useful for metrics/monitoring - */ - hasPrefix(response: string): boolean { - const trimmed = response.trim(); - return ( - /^\[\d{1,2}:\d{2}\]\s+[^:]+:\s*/.test(trimmed) || - /^[A-Z][A-Za-z\s]+:\s*/.test(trimmed) || - /^\[\d{1,2}:\d{2}\]\s*/.test(trimmed) || - /^\*{1,2}[A-Za-z\s]+:\*{1,2}\s*/.test(trimmed) - ); - } -} diff --git a/src/debug/jtag/system/user/server/modules/RustCognitionBridge.ts b/src/debug/jtag/system/user/server/modules/RustCognitionBridge.ts index 0b3ecbe31..71046e2ba 100644 --- a/src/debug/jtag/system/user/server/modules/RustCognitionBridge.ts +++ b/src/debug/jtag/system/user/server/modules/RustCognitionBridge.ts @@ -25,6 +25,21 @@ import type { ActivityDomain, ChannelRegistryStatus, ChannelEnqueueRequest, + TextSimilarityResult, + SemanticLoopResult, + ConversationMessage, + ValidationResult, + MentionCheckResult, + CleanedResponse, + FullEvaluateRequest, + FullEvaluateResult, + SleepMode, + ModelSelectionResult, + AdapterInfo, + GenomeAdapterInfo, + ActivateSkillResult, + GenomePagingState, + AdequacyResult, } from '../../../../shared/generated'; import type { UUID } from '../../../core/types/CrossPlatformUUID'; import { SubsystemLogger } from './being/logging/SubsystemLogger'; @@ -54,6 +69,7 @@ export class RustCognitionBridge { private readonly client: RustCoreIPCClient; private readonly personaId: UUID; private readonly personaName: string; + private readonly personaUniqueId: string; private readonly logger: SubsystemLogger; private connected = false; private engineCreated = false; @@ -74,6 +90,7 @@ export class RustCognitionBridge { constructor(personaUser: PersonaUserForRustCognition) { this.personaId = personaUser.id; this.personaName = personaUser.displayName; + this.personaUniqueId = personaUser.entity.uniqueId; this.client = new RustCoreIPCClient(SOCKET_PATH); // Logger writes to persona's logs directory: .continuum/personas/{uniqueId}/logs/rust-cognition.log @@ -554,6 +571,475 @@ export class RustCognitionBridge { } } + // ======================================================================== + // Text Analysis — Unified similarity + semantic loop checking in Rust + // Replaces 3 duplicate Jaccard implementations in TS + // ======================================================================== + + /** + * Compute text similarity using Rust's unified Jaccard implementation. + * Returns both character-bigram and word-ngram similarity in one IPC call. + * THROWS on failure + */ + async textSimilarity(text1: string, text2: string): Promise { + this.assertReady('textSimilarity'); + const start = performance.now(); + + try { + const result = await this.client.cognitionTextSimilarity(text1, text2); + const elapsed = performance.now() - start; + + this.logger.info(`TextSimilarity: ngram=${result.ngram_similarity.toFixed(3)}, char=${result.char_similarity.toFixed(3)}, compute=${result.compute_time_us}us (ipc=${elapsed.toFixed(2)}ms)`); + return result; + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`textSimilarity FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + /** + * Check if a response is semantically looping against conversation history. + * Uses word-ngram Jaccard: blocks at 95%, warns at 80%. + * THROWS on failure + */ + async checkSemanticLoop( + responseText: string, + history: ConversationMessage[], + maxHistory?: number + ): Promise { + this.assertReady('checkSemanticLoop'); + const start = performance.now(); + + try { + const result = await this.client.cognitionCheckSemanticLoop(responseText, history, maxHistory); + const elapsed = performance.now() - start; + + if (result.should_block) { + this.logger.warn(`SemanticLoop BLOCKED: ${result.reason} (similarity=${result.similarity.toFixed(3)}, ${elapsed.toFixed(2)}ms)`); + } else { + this.logger.info(`SemanticLoop: pass, similarity=${result.similarity.toFixed(3)} (${elapsed.toFixed(2)}ms)`); + } + return result; + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`checkSemanticLoop FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + // ======================================================================== + // Phase 3: Mention Detection + Response Cleaning — 2 combined IPC calls + // ======================================================================== + + /** + * Combined mention detection: checks both is_persona_mentioned and has_directed_mention + * in ONE IPC call. Replaces two inline TS string checks. + * THROWS on failure + */ + async checkMentions(messageText: string): Promise { + this.assertReady('checkMentions'); + const start = performance.now(); + + try { + const result = await this.client.cognitionCheckMentions( + messageText, + this.personaName, + this.personaUniqueId + ); + const elapsed = performance.now() - start; + + this.logger.info(`Mentions: persona=${result.is_persona_mentioned}, directed=${result.has_directed_mention}, compute=${result.compute_time_us}us (ipc=${elapsed.toFixed(2)}ms)`); + return result; + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`checkMentions FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + /** + * Clean AI response by stripping unwanted prefixes (timestamps, names, markdown). + * Replaces ResponseCleaner.clean() in TypeScript. + * THROWS on failure + */ + async cleanResponse(responseText: string): Promise { + this.assertReady('cleanResponse'); + const start = performance.now(); + + try { + const result = await this.client.cognitionCleanResponse(responseText); + const elapsed = performance.now() - start; + + if (result.was_cleaned) { + this.logger.info(`Response cleaned: compute=${result.compute_time_us}us (ipc=${elapsed.toFixed(2)}ms)`); + } + return result; + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`cleanResponse FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + // ======================================================================== + // Unified Evaluation Gate — ALL pre-response gates in 1 IPC call + // Replaces 5 sequential TS gates: response_cap, rate_limit, sleep, mention, fast_path + // ======================================================================== + + /** + * Full evaluation: ONE Rust IPC call replaces 5 sequential TS gates. + * Gate order: response_cap → mention → rate_limit → sleep_mode → directed_mention → fast_path + * THROWS on failure + */ + async fullEvaluate(request: FullEvaluateRequest): Promise { + this.assertReady('fullEvaluate'); + const start = performance.now(); + + try { + const result = await this.client.cognitionFullEvaluate(request); + const elapsed = performance.now() - start; + + if (result.should_respond) { + this.logger.info(`FullEvaluate: RESPOND, gate=${result.gate}, confidence=${result.confidence.toFixed(2)}, reason="${result.reason}" (${elapsed.toFixed(2)}ms, rust=${result.decision_time_ms.toFixed(2)}ms)`); + } else { + this.logger.info(`FullEvaluate: SILENT, gate=${result.gate}, reason="${result.reason}" (${elapsed.toFixed(2)}ms, rust=${result.decision_time_ms.toFixed(2)}ms)`); + } + + if (elapsed > 5) { + this.logger.warn(`fullEvaluate SLOW: ${elapsed.toFixed(2)}ms (target <2ms)`); + } + + return result; + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`fullEvaluate FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Request: persona=${request.persona_name}, message_id=${request.message_id}, sender=${request.sender_name}`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + /** + * Track a response for Rust-side rate limiting. + * Called after successful response posting. + * THROWS on failure + */ + async trackResponse(roomId: string): Promise<{ tracked: boolean; response_count: number }> { + this.assertReady('trackResponse'); + const start = performance.now(); + + try { + const result = await this.client.cognitionTrackResponse(this.personaId, roomId); + const elapsed = performance.now() - start; + + this.logger.info(`TrackResponse: room=${roomId}, count=${result.response_count} (${elapsed.toFixed(2)}ms)`); + return result; + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`trackResponse FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + /** + * Check if a message has already been evaluated (Rust-side deduplication). + * Sole authority for chat message dedup — TS rateLimiter no longer tracks this. + */ + async hasEvaluatedMessage(messageId: string): Promise { + this.assertReady('hasEvaluatedMessage'); + const start = performance.now(); + + try { + const result = await this.client.cognitionHasEvaluated(this.personaId, messageId); + const elapsed = performance.now() - start; + + this.logger.info(`HasEvaluated: ${messageId.slice(0, 8)}... → ${result} (${elapsed.toFixed(2)}ms)`); + return result; + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`hasEvaluatedMessage FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + /** + * Mark a message as evaluated in Rust (deduplication). + * Sole authority for chat message dedup — TS rateLimiter no longer tracks this. + */ + async markMessageEvaluated(messageId: string): Promise { + this.assertReady('markMessageEvaluated'); + const start = performance.now(); + + try { + await this.client.cognitionMarkEvaluated(this.personaId, messageId); + const elapsed = performance.now() - start; + + this.logger.info(`MarkEvaluated: ${messageId.slice(0, 8)}... (${elapsed.toFixed(2)}ms)`); + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`markMessageEvaluated FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + /** + * Set voluntary sleep mode for this persona in Rust. + * THROWS on failure + */ + async setSleepMode(mode: SleepMode, reason?: string, durationMinutes?: number): Promise { + this.assertReady('setSleepMode'); + const start = performance.now(); + + try { + const result = await this.client.cognitionSetSleepMode(this.personaId, mode, reason, durationMinutes); + const elapsed = performance.now() - start; + + this.logger.info(`SetSleepMode: ${result.previous_mode} → ${result.new_mode}, reason="${reason ?? ''}" (${elapsed.toFixed(2)}ms)`); + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`setSleepMode FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + /** + * Configure rate limiter parameters for this persona in Rust. + * THROWS on failure + */ + async configureRateLimiter(minSeconds?: number, maxResponses?: number): Promise { + this.assertReady('configureRateLimiter'); + const start = performance.now(); + + try { + await this.client.cognitionConfigureRateLimiter(this.personaId, minSeconds, maxResponses); + const elapsed = performance.now() - start; + + this.logger.info(`ConfigureRateLimiter: minSeconds=${minSeconds}, maxResponses=${maxResponses} (${elapsed.toFixed(2)}ms)`); + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`configureRateLimiter FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + // ======================================================================== + // Phase 2: Model Selection — 4-tier priority chain in Rust + // ======================================================================== + + /** + * Select the best model using 4-tier priority chain: + * 1. Trait-specific adapter (domain → trait mapping) + * 2. Current active adapter + * 3. Any available trained adapter + * 4. Base model fallback + * THROWS on failure + */ + async selectModel(baseModel: string, taskDomain?: string): Promise { + this.assertReady('selectModel'); + const start = performance.now(); + + try { + const result = await this.client.cognitionSelectModel(this.personaId, baseModel, taskDomain); + const elapsed = performance.now() - start; + + this.logger.info(`SelectModel: model=${result.model}, source=${result.source}${result.adapter_name ? `, adapter=${result.adapter_name}` : ''}${result.trait_used ? `, trait=${result.trait_used}` : ''} (${elapsed.toFixed(2)}ms, rust=${result.decision_time_us.toFixed(1)}μs)`); + + return result; + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`selectModel FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + /** + * Sync adapter registry from TypeScript genome state to Rust. + * Call during initialization and after adapter load/unload. + * THROWS on failure + */ + async syncAdapters(adapters: AdapterInfo[]): Promise { + this.assertReady('syncAdapters'); + const start = performance.now(); + + try { + const result = await this.client.cognitionSyncAdapters(this.personaId, adapters); + const elapsed = performance.now() - start; + + this.logger.info(`SyncAdapters: ${result.adapter_count} adapters synced (${elapsed.toFixed(2)}ms)`); + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`syncAdapters FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + // ======================================================================== + // Phase 4: Genome Paging — LRU eviction + memory budget decisions + // ======================================================================== + + /** + * Genome paging: decide what to evict/load for a skill activation. + * Rust makes the decision, TypeScript executes the GPU ops. + * THROWS on failure + */ + async genomeActivateSkill(skillName: string, memoryBudgetMb?: number): Promise { + this.assertReady('genomeActivateSkill'); + const start = performance.now(); + + try { + const result = await this.client.cognitionGenomeActivateSkill( + this.personaId, skillName, memoryBudgetMb + ); + const elapsed = performance.now() - start; + + this.logger.info(`GenomeActivateSkill: ${skillName} activated=${result.activated}, evicted=${result.evicted.length > 0 ? result.evicted.join(',') : 'none'}, to_load=${result.to_load || 'cache_hit'} (${elapsed.toFixed(2)}ms, rust=${result.decision_time_us}μs)`); + + return result; + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`genomeActivateSkill FAILED for ${skillName} after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + /** + * Sync full genome adapter state from TypeScript to Rust. + * Call during initialization and after adapter changes. + * THROWS on failure + */ + async genomeSync(adapters: GenomeAdapterInfo[], memoryBudgetMb?: number): Promise { + this.assertReady('genomeSync'); + const start = performance.now(); + + try { + const result = await this.client.cognitionGenomeSync( + this.personaId, adapters, memoryBudgetMb + ); + const elapsed = performance.now() - start; + + this.logger.info(`GenomeSync: ${result.adapter_count} adapters (${result.active_count} active), memory=${result.memory_used_mb.toFixed(1)}/${memoryBudgetMb || '?'}MB, pressure=${(result.memory_pressure * 100).toFixed(0)}% (${elapsed.toFixed(2)}ms)`); + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`genomeSync FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + /** + * Get current genome paging state from Rust. + * THROWS on failure + */ + async genomeState(): Promise { + this.assertReady('genomeState'); + const start = performance.now(); + + try { + const result = await this.client.cognitionGenomeState(this.personaId); + const elapsed = performance.now() - start; + + this.logger.info(`GenomeState: active=${result.active_adapters.length}, available=${result.available_adapters.length}, memory=${result.memory_used_mb.toFixed(1)}/${result.memory_budget_mb.toFixed(1)}MB (${elapsed.toFixed(2)}ms)`); + + return result; + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`genomeState FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + // ======================================================================== + // Phase 5: Post-Inference Adequacy Check — batch check in 1 IPC call + // ======================================================================== + + /** + * Batch check if other AIs already answered a question. + * ONE IPC call replaces N individual textSimilarity calls. + * THROWS on failure + */ + async checkAdequacy( + originalText: string, + responses: Array<{ sender_name: string; text: string }> + ): Promise { + this.assertReady('checkAdequacy'); + const start = performance.now(); + + try { + const result = await this.client.cognitionCheckAdequacy(originalText, responses); + const elapsed = performance.now() - start; + + if (result.is_adequate) { + this.logger.info(`CheckAdequacy: ADEQUATE — ${result.reason} (${elapsed.toFixed(2)}ms, rust=${result.check_time_us}μs)`); + } else { + this.logger.info(`CheckAdequacy: no adequate response (${responses.length} checked, ${elapsed.toFixed(2)}ms, rust=${result.check_time_us}μs)`); + } + + return result; + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`checkAdequacy FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + + // ======================================================================== + // Combined Validation — 4 gates in 1 IPC call + // ======================================================================== + + /** + * Run ALL response validation gates in a single Rust IPC call: + * 1. Garbage detection (8 checks) + * 2. Response loop detection (per-persona DashMap state) + * 3. Truncated tool call detection + * 4. Semantic loop detection + * THROWS on failure + */ + async validateResponse( + responseText: string, + hasToolCalls: boolean, + conversationHistory?: ConversationMessage[] + ): Promise { + this.assertReady('validateResponse'); + const start = performance.now(); + + try { + const result = await this.client.cognitionValidateResponse( + this.personaId, + responseText, + hasToolCalls, + conversationHistory + ); + const elapsed = performance.now() - start; + + if (!result.passed) { + this.logger.warn(`Validation FAILED: gate=${result.gate_failed}, compute=${result.total_time_us}us (ipc=${elapsed.toFixed(2)}ms)`); + } else { + this.logger.info(`Validation passed: compute=${result.total_time_us}us (ipc=${elapsed.toFixed(2)}ms)`); + } + return result; + } catch (error) { + const elapsed = performance.now() - start; + this.logger.error(`validateResponse FAILED after ${elapsed.toFixed(2)}ms`); + this.logger.error(`Error: ${error}`); + throw error; + } + } + /** * Get bridge stats for debugging */ diff --git a/src/debug/jtag/system/user/server/modules/SelfTaskGenerator.ts b/src/debug/jtag/system/user/server/modules/SelfTaskGenerator.ts deleted file mode 100644 index 551e94e37..000000000 --- a/src/debug/jtag/system/user/server/modules/SelfTaskGenerator.ts +++ /dev/null @@ -1,310 +0,0 @@ -/** - * SelfTaskGenerator - Autonomous task creation for PersonaUser - * - * Philosophy: AIs create their own work for self-improvement - * - Memory consolidation (every hour) - * - Skill audits (every 6 hours) - * - Resume unfinished work - * - Continuous learning from mistakes - * - * This is the KEY to AI autonomy - not just reacting to external events, - * but proactively creating self-improvement tasks. - */ - -import type { UUID } from '../../../core/types/CrossPlatformUUID'; -import { TaskEntity } from '../../../data/entities/TaskEntity'; -import { ORM } from '../../../../daemons/data-daemon/server/ORM'; -import { COLLECTIONS } from '../../../data/config/DatabaseConfig'; - -export interface SelfTaskGeneratorConfig { - /** - * How often to review memory (ms) - * Default: 1 hour - */ - memoryReviewInterval: number; - - /** - * How often to audit skills (ms) - * Default: 6 hours - */ - skillAuditInterval: number; - - /** - * Minimum time since last activity to consider work "unfinished" (ms) - * Default: 30 minutes - */ - unfinishedWorkThreshold: number; - - /** - * Enable self-task generation - * Default: true - */ - enabled: boolean; -} - -const DEFAULT_CONFIG: SelfTaskGeneratorConfig = { - memoryReviewInterval: 3600000, // 1 hour - skillAuditInterval: 21600000, // 6 hours - unfinishedWorkThreshold: 1800000, // 30 minutes - enabled: true -}; - -export class SelfTaskGenerator { - private personaId: UUID; - private displayName: string; - private config: SelfTaskGeneratorConfig; - private log: (message: string) => void; - - // Track when we last created each task type - private lastMemoryReview: number = 0; - private lastSkillAudit: number = 0; - - constructor(personaId: UUID, displayName: string, config?: Partial, logger?: (message: string) => void) { - this.personaId = personaId; - this.displayName = displayName; - this.config = { ...DEFAULT_CONFIG, ...config }; - this.log = logger || (() => {}); - } - - /** - * Generate self-tasks based on current state - * Called periodically by PersonaUser.serviceInbox() - * - * Returns array of tasks that should be created - */ - async generateSelfTasks(): Promise { - if (!this.config.enabled) { - return []; - } - - const tasks: TaskEntity[] = []; - const now = Date.now(); - - // 1. Memory consolidation (every hour) - if (now - this.lastMemoryReview > this.config.memoryReviewInterval) { - const memoryTask = await this.createMemoryReviewTask(); - if (memoryTask) { - tasks.push(memoryTask); - this.lastMemoryReview = now; - } - } - - // 2. Skill audit (every 6 hours) - if (now - this.lastSkillAudit > this.config.skillAuditInterval) { - const auditTask = await this.createSkillAuditTask(); - if (auditTask) { - tasks.push(auditTask); - this.lastSkillAudit = now; - } - } - - // 3. Unfinished work detection - const resumeTasks = await this.detectUnfinishedWork(); - tasks.push(...resumeTasks); - - // 4. Continuous learning (if mistakes detected) - const learningTasks = await this.detectLearningOpportunities(); - tasks.push(...learningTasks); - - return tasks; - } - - /** - * Create memory consolidation task - * Reviews recent activities and consolidates important memories - */ - private async createMemoryReviewTask(): Promise { - const task = new TaskEntity(); - - task.assigneeId = this.personaId; - task.createdBy = this.personaId; // Self-created! - task.domain = 'self'; - task.taskType = 'memory-consolidation'; - task.contextId = this.personaId; // Self-context - task.description = `[Self-Task] Review and consolidate recent memories`; - task.priority = 0.5; // Medium priority - task.status = 'pending'; - - const validation = task.validate(); - if (!validation.success) { - this.log(`❌ Failed to create memory review task: ${validation.error}`); - return null; - } - - return task; - } - - /** - * Create skill audit task - * Evaluates current capabilities and identifies areas for improvement - */ - private async createSkillAuditTask(): Promise { - const task = new TaskEntity(); - - task.assigneeId = this.personaId; - task.createdBy = this.personaId; - task.domain = 'self'; - task.taskType = 'skill-audit'; - task.contextId = this.personaId; - task.description = `[Self-Task] Audit skills and identify improvement areas`; - task.priority = 0.6; // Medium-high priority - task.status = 'pending'; - - const validation = task.validate(); - if (!validation.success) { - this.log(`❌ Failed to create skill audit task: ${validation.error}`); - return null; - } - - return task; - } - - /** - * Detect unfinished work - * Looks for in_progress tasks that haven't been updated recently - */ - private async detectUnfinishedWork(): Promise { - try { - // Query for in_progress tasks assigned to this persona - const result = await ORM.query({ - collection: COLLECTIONS.TASKS, - filter: { - assigneeId: this.personaId, - status: 'in_progress' - }, - limit: 10 - }); - - if (!result.success || !result.data) { - return []; - } - - const resumeTasks: TaskEntity[] = []; - const threshold = Date.now() - this.config.unfinishedWorkThreshold; - - for (const record of result.data) { - const task = record.data; - const updatedAt = task.updatedAt ? new Date(task.updatedAt).getTime() : new Date(task.createdAt).getTime(); - - // If task hasn't been updated in a while, create resume task - if (updatedAt < threshold) { - const resumeTask = new TaskEntity(); - - resumeTask.assigneeId = this.personaId; - resumeTask.createdBy = this.personaId; - resumeTask.domain = 'self'; - resumeTask.taskType = 'resume-work'; - resumeTask.contextId = this.personaId; - // Truncate long descriptions to prevent storage issues (max 200 chars for embedded description) - const truncatedDesc = task.description.length > 200 - ? task.description.substring(0, 197) + '...' - : task.description; - resumeTask.description = `[Self-Task] Resume unfinished work: ${truncatedDesc}`; - resumeTask.priority = 0.7; // High priority - resumeTask.status = 'pending'; - - const validation = resumeTask.validate(); - if (validation.success) { - resumeTasks.push(resumeTask); - } - } - } - - return resumeTasks; - } catch (error) { - this.log(`❌ Error detecting unfinished work: ${error}`); - return []; - } - } - - /** - * Detect learning opportunities - * Analyzes recent failed tasks to create fine-tuning tasks - */ - private async detectLearningOpportunities(): Promise { - try { - // Query for recent failed tasks - const result = await ORM.query({ - collection: COLLECTIONS.TASKS, - filter: { - assigneeId: this.personaId, - status: 'failed' - }, - limit: 5 - }); - - if (!result.success || !result.data || result.data.length === 0) { - return []; - } - - // Group failures by domain - const failuresByDomain: Record = {}; - for (const record of result.data) { - const domain = record.data.domain; - if (!failuresByDomain[domain]) { - failuresByDomain[domain] = []; - } - failuresByDomain[domain].push(record); - } - - const learningTasks: TaskEntity[] = []; - - // Create learning task for each domain with failures - for (const [domain, failures] of Object.entries(failuresByDomain)) { - const learningTask = new TaskEntity(); - - learningTask.assigneeId = this.personaId; - learningTask.createdBy = this.personaId; - learningTask.domain = 'self'; - learningTask.taskType = 'fine-tune-lora'; - learningTask.contextId = this.personaId; - learningTask.description = `[Self-Task] Learn from ${failures.length} recent ${domain} failures`; - learningTask.priority = 0.8; // Very high priority (learning is important!) - learningTask.status = 'pending'; - - learningTask.metadata = { - loraLayer: `${domain}-expertise` // Which adapter to fine-tune - }; - - const validation = learningTask.validate(); - if (validation.success) { - learningTasks.push(learningTask); - } - } - - return learningTasks; - } catch (error) { - this.log(`❌ Error detecting learning opportunities: ${error}`); - return []; - } - } - - /** - * Update configuration - */ - setConfig(config: Partial): void { - this.config = { ...this.config, ...config }; - } - - /** - * Enable/disable self-task generation - */ - setEnabled(enabled: boolean): void { - this.config.enabled = enabled; - } - - /** - * Get current configuration - */ - getConfig(): SelfTaskGeneratorConfig { - return { ...this.config }; - } - - /** - * Reset timers (for testing) - */ - resetTimers(): void { - this.lastMemoryReview = 0; - this.lastSkillAudit = 0; - } -} diff --git a/src/debug/jtag/system/user/server/modules/SignalDetector.ts b/src/debug/jtag/system/user/server/modules/SignalDetector.ts index 86d631271..3403eba81 100644 --- a/src/debug/jtag/system/user/server/modules/SignalDetector.ts +++ b/src/debug/jtag/system/user/server/modules/SignalDetector.ts @@ -227,7 +227,7 @@ export class SignalDetector { const params: Partial = { messages: [{ role: 'user', content: prompt }], model: 'llama-3.1-8b-instant', // Fast cloud model - don't block local inference queue - preferredProvider: 'groq', // Cloud API - fast (<1s) vs local (~10s) + provider: 'groq', // Cloud API - fast (<1s) vs local (~10s) temperature: 0.1, // Low temperature for consistent classification maxTokens: 200, systemPrompt: 'You are a signal classifier. Output ONLY valid JSON, no other text.' @@ -380,44 +380,6 @@ Output JSON only: return contextParts.join('\n\n'); } - /** - * Check for repeated questions (frustration indicator) - */ - checkForRepetition( - userMessage: ProcessableMessage, - recentUserMessages: ChatMessageEntity[] - ): boolean { - const currentText = (userMessage.content?.text || '').toLowerCase().trim(); - if (currentText.length < 10) return false; - - for (const msg of recentUserMessages) { - if (msg.id === userMessage.id || msg.senderId !== userMessage.senderId) continue; - - const previousText = (msg.content?.text || '').toLowerCase().trim(); - if (this.calculateSimilarity(currentText, previousText) > 0.7) { - return true; - } - } - - return false; - } - - /** - * Jaccard similarity between two texts - */ - private calculateSimilarity(text1: string, text2: string): number { - const words1 = new Set(text1.split(/\s+/).filter(w => w.length > 2)); - const words2 = new Set(text2.split(/\s+/).filter(w => w.length > 2)); - - if (words1.size === 0 || words2.size === 0) return 0; - - let intersection = 0; - for (const word of words1) { - if (words2.has(word)) intersection++; - } - - return intersection / (words1.size + words2.size - intersection); - } } // Singleton diff --git a/src/debug/jtag/system/user/server/modules/ToolFormatAdapter.ts b/src/debug/jtag/system/user/server/modules/ToolFormatAdapter.ts index 9dca71f7e..57073437c 100644 --- a/src/debug/jtag/system/user/server/modules/ToolFormatAdapter.ts +++ b/src/debug/jtag/system/user/server/modules/ToolFormatAdapter.ts @@ -429,8 +429,12 @@ export class FunctionStyleToolAdapter extends ToolFormatAdapter { matches(text: string): ToolCallMatch[] { const matches: ToolCallMatch[] = []; - // Match ... or {...} - const regex = /\s]+)>\s*([\s\S]*?)\s*<\/function>/gi; + + // Match both proper XML and Groq's variant: + // {json} — standard + // function=name>{json} — Groq variant (no < prefix, no closing tag) + // The regex uses optional < and optional closing. + const regex = /\s]+)>\s*(\{[\s\S]*?\})(?:\s*<\/function>)?/gi; let match: RegExpExecArray | null; while ((match = regex.exec(text)) !== null) { @@ -445,8 +449,8 @@ export class FunctionStyleToolAdapter extends ToolFormatAdapter { } parse(match: ToolCallMatch): ToolCall | null { - // Extract tool name from - const nameMatch = match.fullMatch.match(/\s]+)>/i); + // Extract tool name from or function=NAME> + const nameMatch = match.fullMatch.match(/\s]+)>/i); if (!nameMatch) { return null; } @@ -454,10 +458,10 @@ export class FunctionStyleToolAdapter extends ToolFormatAdapter { const toolName = nameMatch[1].trim(); const parameters: Record = {}; - // Extract JSON body between the tags - const bodyMatch = match.fullMatch.match(/]+>\s*([\s\S]*?)\s*<\/function>/i); - if (bodyMatch && bodyMatch[1]) { - const jsonStr = bodyMatch[1].trim(); + // Extract JSON body — find the first { ... } block after the > + const jsonMatch = match.fullMatch.match(/>\s*(\{[\s\S]*\})/); + if (jsonMatch && jsonMatch[1]) { + const jsonStr = jsonMatch[1].trim(); if (jsonStr) { try { const parsed = JSON.parse(jsonStr); @@ -744,19 +748,57 @@ export function convertToNativeToolSpecs(tools: ToolDefinition[]): NativeToolSpe }); } +/** + * Coerce text-parsed tool parameters to match JSON Schema types from NativeToolSpec. + * When models output tool calls in text (e.g. Groq's `{json}`), + * values may be strings where booleans/numbers are expected. Native APIs validate + * tool_use blocks against the schema and reject type mismatches (400 Bad Request). + */ +export function coerceParamsToSchema( + params: Record, + toolSpecs: NativeToolSpec[], + sanitizedToolName: string, +): Record { + const spec = toolSpecs.find(s => s.name === sanitizedToolName); + if (!spec?.input_schema?.properties) return params; + + const coerced: Record = {}; + for (const [key, value] of Object.entries(params)) { + const propSchema = spec.input_schema.properties[key] as { type?: string } | undefined; + if (!propSchema?.type || typeof value !== 'string') { + coerced[key] = value; + continue; + } + switch (propSchema.type) { + case 'boolean': + coerced[key] = value === 'true' || value === '1'; + break; + case 'number': + case 'integer': { + const num = Number(value); + coerced[key] = isNaN(num) ? value : num; + break; + } + default: + coerced[key] = value; + } + } + return coerced; +} + /** * Check if a provider supports native JSON tool calling. - * Together AI and Groq both implement the OpenAI-compatible function calling spec - * (tools parameter + tool_calls in response). + * OpenAI-compatible providers (Together, Groq, Fireworks, xAI) implement + * the function calling spec (tools parameter + tool_calls in response). */ export function supportsNativeTools(provider: string): boolean { - const nativeToolProviders = ['anthropic', 'openai', 'azure', 'together', 'groq']; + const nativeToolProviders = ['anthropic', 'openai', 'azure', 'together', 'groq', 'fireworks', 'xai']; return nativeToolProviders.includes(provider.toLowerCase()); } /** * Tool capability tier for a given provider/model combination. - * - 'native': JSON tool_use blocks (Anthropic, OpenAI, Azure, Together, Groq) + * - 'native': JSON tool_use blocks (Anthropic, OpenAI, Azure, Together, Groq, Fireworks, xAI) * - 'xml': XML tool calls parsed by ToolCallParser (DeepSeek — proven to work) * - 'none': Model narrates instead of calling tools — don't inject tools */ @@ -765,6 +807,11 @@ export type ToolCapability = 'native' | 'xml' | 'none'; /** * Determine a model's tool-calling capability. * Provider-based auto-detection with per-persona override via modelConfig.toolCapability. + * + * IMPORTANT: Default to 'xml' not 'none'. A Candle model could be a powerful + * fine-tuned model with LoRA. Returning 'none' leaves it completely powerless. + * XML tool definitions are budget-aware via ToolDefinitionsSource and will be + * truncated if the model's context is tight. */ export function getToolCapability( provider: string, @@ -774,10 +821,10 @@ export function getToolCapability( if (supportsNativeTools(provider)) return 'native'; - // Proven XML-capable providers (model emits well-formed tool call blocks) - const xmlCapable = ['deepseek']; - if (xmlCapable.includes(provider.toLowerCase())) return 'xml'; - - // Everything else: xai, fireworks, candle, sentinel, ollama - return 'none'; + // All other providers get XML tool definitions in the system prompt. + // Models that can't use them will ignore them; models that can (DeepSeek, + // fine-tuned Candle, Ollama) benefit from having tools available. + // Budget-aware: ToolDefinitionsSource truncates for tight context windows. + return 'xml'; } + diff --git a/src/debug/jtag/system/user/server/modules/being/LimbicSystem.ts b/src/debug/jtag/system/user/server/modules/being/LimbicSystem.ts index a9894a8b1..2376263f0 100644 --- a/src/debug/jtag/system/user/server/modules/being/LimbicSystem.ts +++ b/src/debug/jtag/system/user/server/modules/being/LimbicSystem.ts @@ -21,7 +21,7 @@ import type { GenomeEntity } from '../../../../genome/entities/GenomeEntity'; import type { GenomeLayerEntity } from '../../../../genome/entities/GenomeLayerEntity'; import { SubsystemLogger } from './logging/SubsystemLogger'; import type { UserEntity } from '../../../../data/entities/UserEntity'; -import type { ModelConfig } from '../../../../../commands/user/create/shared/UserCreateTypes'; +import type { ModelConfig } from '../../../../data/entities/UserEntity'; import type { JTAGClient } from '../../../../core/client/shared/JTAGClient'; import type { UserStateEntity } from '../../../../data/entities/UserStateEntity'; import type { DataReadParams, DataReadResult } from '../../../../../commands/data/read/shared/DataReadTypes'; @@ -202,6 +202,7 @@ export class LimbicSystem { const layerResult = await client.daemons.commands.execute>( DATA_COMMANDS.READ, { + userId: client.userId, collection: 'genome_layers', id: layerRef.layerId, context: client.context, diff --git a/src/debug/jtag/system/user/server/modules/being/MotorCortex.ts b/src/debug/jtag/system/user/server/modules/being/MotorCortex.ts index 5eda0dfec..84655a3e5 100644 --- a/src/debug/jtag/system/user/server/modules/being/MotorCortex.ts +++ b/src/debug/jtag/system/user/server/modules/being/MotorCortex.ts @@ -10,7 +10,7 @@ import { PersonaToolExecutor } from '../PersonaToolExecutor'; import { PersonaToolRegistry } from '../PersonaToolRegistry'; import { PersonaResponseGenerator } from '../PersonaResponseGenerator'; import type { UserEntity } from '../../../../data/entities/UserEntity'; -import type { ModelConfig } from '../../../../../commands/user/create/shared/UserCreateTypes'; +import type { ModelConfig } from '../../../../data/entities/UserEntity'; import type { JTAGClient } from '../../../../core/client/shared/JTAGClient'; import type { PersonaMediaConfig } from '../PersonaMediaConfig'; import { SubsystemLogger } from './logging/SubsystemLogger'; diff --git a/src/debug/jtag/system/user/server/modules/central-nervous-system/CNSFactory.ts b/src/debug/jtag/system/user/server/modules/central-nervous-system/CNSFactory.ts deleted file mode 100644 index 0aa46ed24..000000000 --- a/src/debug/jtag/system/user/server/modules/central-nervous-system/CNSFactory.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * CNSFactory - Creates PersonaCentralNervousSystem instances - * - * All scheduling is delegated to Rust. The factory wires the Rust bridge - * and callbacks into the CNS config. - */ - -// Note: Avoiding direct PersonaUser import to prevent circular dependency -import type { ModelCapabilities } from './CNSTypes'; -import { CNSTier } from './CNSTypes'; -import { PersonaCentralNervousSystem } from './PersonaCentralNervousSystem'; -import type { PersonaInbox } from '../PersonaInbox'; -import type { PersonaStateManager } from '../PersonaState'; - -// Import QueueItem type for handleChatMessageFromCNS signature -import type { QueueItem } from '../PersonaInbox'; -import type { FastPathDecision } from './CNSTypes'; - -// Import RustCognitionBridge type -import type { RustCognitionBridge } from '../RustCognitionBridge'; - -// Type for PersonaUser (avoid circular dependency) -interface PersonaUserLike { - entity: { - id: string; - displayName?: string; - uniqueId: string; - modelConfig?: { - capabilities?: readonly string[]; - }; - }; - inbox: PersonaInbox; - prefrontal: { - personaState: PersonaStateManager; - } | null; - // Rust cognition bridge (required for scheduling) - rustCognitionBridge: RustCognitionBridge | null; - handleChatMessageFromCNS: (item: QueueItem, decision?: FastPathDecision) => Promise; -} - -export class CNSFactory { - /** - * Create CNS instance based on persona's capabilities - */ - static create(persona: PersonaUserLike): PersonaCentralNervousSystem { - const capabilities = this.parseCapabilities(persona.entity.modelConfig?.capabilities); - const tier = this.selectTier(capabilities); - - // All tiers currently use the same Rust-delegated CNS - // Future: tier could influence Rust scheduling parameters - if (tier !== CNSTier.DETERMINISTIC) { - console.warn(`CNS tier ${tier} not yet differentiated, using Rust-delegated scheduling for ${persona.entity.displayName || persona.entity.id}`); - } - - return this.createRustDelegatedCNS(persona); - } - - /** - * Parse string[] capabilities from modelConfig into ModelCapabilities object - */ - private static parseCapabilities(capabilitiesArray: readonly string[] | undefined): ModelCapabilities | undefined { - if (!capabilitiesArray || capabilitiesArray.length === 0) { - return undefined; - } - - const capabilities: Partial = {}; - - for (const cap of capabilitiesArray) { - const mutableCaps = capabilities as Record; - switch (cap) { - case 'advanced-reasoning': - mutableCaps['advanced-reasoning'] = true; - break; - case 'meta-cognition': - mutableCaps['meta-cognition'] = true; - break; - case 'long-context': - mutableCaps['long-context'] = true; - break; - case 'moderate-reasoning': - mutableCaps['moderate-reasoning'] = true; - break; - case 'pattern-recognition': - mutableCaps['pattern-recognition'] = true; - break; - case 'fast-inference': - mutableCaps['fast-inference'] = true; - break; - case 'template-responses': - mutableCaps['template-responses'] = true; - break; - } - } - - return Object.keys(capabilities).length > 0 ? capabilities as ModelCapabilities : undefined; - } - - /** - * Select CNS tier based on model capabilities - */ - private static selectTier(capabilities: ModelCapabilities | undefined): CNSTier { - if (!capabilities) { - return CNSTier.DETERMINISTIC; - } - - if (capabilities['advanced-reasoning'] && capabilities['meta-cognition']) { - return CNSTier.NEURAL; - } - - if (capabilities['moderate-reasoning'] && capabilities['pattern-recognition']) { - return CNSTier.HEURISTIC; - } - - return CNSTier.DETERMINISTIC; - } - - /** - * Create Rust-delegated CNS - * All scheduling decisions made by Rust via IPC. - */ - private static createRustDelegatedCNS(persona: PersonaUserLike): PersonaCentralNervousSystem { - if (!persona.prefrontal) { - throw new Error('CNSFactory.create() called before PrefrontalCortex initialized'); - } - - if (!persona.rustCognitionBridge) { - throw new Error('CNSFactory.create() called without Rust cognition bridge — Rust bridge is required'); - } - - const personaName = persona.entity.displayName || 'Unknown'; - - return new PersonaCentralNervousSystem({ - inbox: persona.inbox, - personaState: persona.prefrontal.personaState, - rustBridge: persona.rustCognitionBridge, - personaId: persona.entity.id, - personaName, - uniqueId: persona.entity.uniqueId, - handleChatMessage: async (item: QueueItem, decision?: FastPathDecision): Promise => { - await persona.handleChatMessageFromCNS(item, decision); - }, - allowBackgroundThreads: false, - }); - } -} diff --git a/src/debug/jtag/system/user/server/modules/central-nervous-system/PersonaCentralNervousSystem.ts b/src/debug/jtag/system/user/server/modules/central-nervous-system/PersonaCentralNervousSystem.ts deleted file mode 100644 index 245c58786..000000000 --- a/src/debug/jtag/system/user/server/modules/central-nervous-system/PersonaCentralNervousSystem.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * PersonaCentralNervousSystem - * - * Orchestration layer that coordinates multi-domain attention for PersonaUser. - * - * Service cycle (Rust-delegated): - * 1. Poll tasks, generate self-tasks (TS — DB access) - * 2. Wait for work (signal-based) - * 3. Rust service_cycle() → consolidate, state-gate, schedule, return next item - * 4. Dispatch item to handler - * - * ALL scheduling logic lives in Rust. TS executes what Rust decides. - */ - -import type { CNSConfig } from './CNSTypes'; -import { SubsystemLogger } from '../being/logging/SubsystemLogger'; -import { fromRustServiceItem } from '../QueueItemTypes'; - -export class PersonaCentralNervousSystem { - private readonly config: CNSConfig; - private readonly logger: SubsystemLogger; - - constructor(config: CNSConfig) { - this.config = config; - this.logger = new SubsystemLogger('cns', config.personaId, config.uniqueId); - - this.logger.info(`Initialized CNS with Rust-delegated scheduling`); - this.logger.info(`Rust bridge: connected`); - this.logger.info(`Background threads: ${config.allowBackgroundThreads ? 'enabled' : 'disabled'}`); - } - - /** - * Single service cycle — the heart of the autonomous entity. - * - * HOT PATH ONLY: wait → Rust schedule → execute → drain → repeat. - * DB polling and self-task generation run on separate timers (see PersonaAutonomousLoop). - * - * Drain loop: after processing one item, immediately check for more. - * Only returns to waitForWork when Rust says the queue is empty. - */ - async serviceCycle(): Promise { - // STEP 1: Wait for work (signal-based, delegates to inbox) - const cadence = this.config.personaState.getCadence(); - const hasWork = await this.config.inbox.waitForWork(cadence); - - if (!hasWork) { - return; // No work — loop will call us again - } - - // STEP 2: Drain loop — process all queued items before returning to wait - await this.drainQueue(); - } - - /** - * Drain all queued items from Rust. - * Keeps calling Rust service_cycle() until no more work is available. - * This eliminates the overhead of re-entering waitForWork between items. - */ - private async drainQueue(): Promise { - let itemsProcessed = 0; - const MAX_DRAIN = 20; // Safety cap — don't monopolize the event loop forever - - while (itemsProcessed < MAX_DRAIN) { - const processed = await this.serviceViaRust(); - if (!processed) { - break; // Queue empty, return to wait - } - itemsProcessed++; - } - - if (itemsProcessed > 1) { - this.logger.info(`Drained ${itemsProcessed} items in burst`); - } - } - - /** - * Rust-delegated service cycle (MERGED: schedule + fast-path decision in ONE IPC call). - * - * Rust's serviceCycleFull() does ALL scheduling + cognition in <1ms: - * - Consolidates all channels (items decide merge policy) - * - Updates persona state (inbox_load, mood) - * - Checks urgent channels first (AUDIO → CHAT → BACKGROUND) - * - State-gates non-urgent items (mood/energy threshold) - * - Runs fast-path decision on the dequeued item (dedup, mention detection, state gating) - * - Returns next item + decision in ONE IPC round-trip - * - * TS just executes what Rust decided. - * Returns true if an item was processed (drain loop continues). - */ - private async serviceViaRust(): Promise { - const bridge = this.config.rustBridge; - const ipcStart = performance.now(); - - const result = await bridge.serviceCycleFull(); - - const ipcMs = performance.now() - ipcStart; - - if (result.should_process && result.item) { - // Convert Rust JSON item → TS QueueItem - const parseStart = performance.now(); - const queueItem = fromRustServiceItem(result.item as Record); - const parseMs = performance.now() - parseStart; - - if (!queueItem) { - this.logger.warn(`Rust returned unparseable item: ${JSON.stringify(result.item).slice(0, 200)}`); - return false; - } - - const channelName = result.channel ?? 'unknown'; - const decisionStr = result.decision - ? `respond=${result.decision.should_respond}` - : 'no-decision'; - this.logger.info(`[rust:${channelName}] Processing ${queueItem.type} (priority=${queueItem.priority.toFixed(2)}, stats=${result.stats.total_size} total) [ipc=${ipcMs.toFixed(1)}ms, parse=${parseMs.toFixed(1)}ms, ${decisionStr}]`); - - // Delegate to PersonaUser via callback — pass pre-computed decision - const handlerStart = performance.now(); - await this.config.handleChatMessage(queueItem, result.decision ?? undefined); - const handlerMs = performance.now() - handlerStart; - - this.logger.info(`[rust:${channelName}] Handler complete (${handlerMs.toFixed(1)}ms total, ipc=${ipcMs.toFixed(1)}ms)`); - return true; - } - - return false; - } - - /** - * Shutdown CNS subsystem (cleanup) - */ - shutdown(): void { - this.logger.info('CNS subsystem shutting down...'); - this.logger.close(); - } -} diff --git a/src/debug/jtag/system/user/server/modules/cognition/adapters/IDecisionAdapter.ts b/src/debug/jtag/system/user/server/modules/cognition/adapters/IDecisionAdapter.ts index 5ef171737..8c9efb3fc 100644 --- a/src/debug/jtag/system/user/server/modules/cognition/adapters/IDecisionAdapter.ts +++ b/src/debug/jtag/system/user/server/modules/cognition/adapters/IDecisionAdapter.ts @@ -39,6 +39,11 @@ export interface DecisionContext { gatingModel?: string; // Which LLM model to use for gating contextWindowMinutes?: number; // Time window for RAG context minContextMessages?: number; // Minimum messages for context + + // Model info (for RAG budget calculation — same model can have different + // context windows on different providers, e.g. Llama 8B on Together vs Candle) + modelId?: string; + provider?: string; } /** diff --git a/src/debug/jtag/system/user/server/modules/cognition/adapters/LLMAdapter.ts b/src/debug/jtag/system/user/server/modules/cognition/adapters/LLMAdapter.ts index d22c45fc6..b7002aeeb 100644 --- a/src/debug/jtag/system/user/server/modules/cognition/adapters/LLMAdapter.ts +++ b/src/debug/jtag/system/user/server/modules/cognition/adapters/LLMAdapter.ts @@ -31,13 +31,14 @@ export class LLMAdapter implements IDecisionAdapter { const chatMessage = context.triggerEvent as any as ChatMessageEntity; // Build RAG context for LLM gating - // Bug #5 fix: Let ChatRAGBuilder use default calculation (no modelId available here yet) const ragBuilder = new ChatRAGBuilder(); const ragContext = await ragBuilder.buildContext( chatMessage.roomId, context.personaId, { - // No maxMessages or modelId - uses ChatRAGBuilder's conservative default (10) + maxTokens: 2000, + modelId: context.modelId, + provider: context.provider, maxMemories: 0, includeArtifacts: false, includeMemories: false, diff --git a/src/debug/jtag/system/vision/VisionDescriptionService.ts b/src/debug/jtag/system/vision/VisionDescriptionService.ts index 187c8e1b9..9f09d0768 100644 --- a/src/debug/jtag/system/vision/VisionDescriptionService.ts +++ b/src/debug/jtag/system/vision/VisionDescriptionService.ts @@ -49,7 +49,7 @@ export interface VisionDescription { text?: string; /** Response time in ms */ - responseTime: number; + responseTimeMs: number; } /** @@ -176,7 +176,7 @@ export class VisionDescriptionService { const response = await AIProviderDaemon.generateText({ messages: [message], model: selectedModel.modelId, - preferredProvider: selectedModel.providerId as 'ollama' | 'openai' | 'anthropic', + provider: selectedModel.providerId as 'ollama' | 'openai' | 'anthropic', maxTokens: options.maxLength ? Math.ceil(options.maxLength / 4) : 500, temperature: 0.3 // More deterministic for descriptions }); @@ -201,7 +201,7 @@ export class VisionDescriptionService { objects: parsedResponse.objects, colors: parsedResponse.colors, text: parsedResponse.text, - responseTime, + responseTimeMs: responseTime, }; } catch (error) { console.error('[VisionDescription] Error:', error); diff --git a/src/debug/jtag/tests/integration/ai-cost-tracking.test.ts b/src/debug/jtag/tests/integration/ai-cost-tracking.test.ts index 3e27cbb43..504542597 100644 --- a/src/debug/jtag/tests/integration/ai-cost-tracking.test.ts +++ b/src/debug/jtag/tests/integration/ai-cost-tracking.test.ts @@ -161,7 +161,7 @@ async function testAIGenerationEntityCreation(): Promise { inputTokens: entity.inputTokens, outputTokens: entity.outputTokens, estimatedCost: `$${entity.estimatedCost.toFixed(4)}`, - responseTime: `${entity.responseTime}ms`, + responseTime: `${entity.responseTimeMs}ms`, success: entity.success }); diff --git a/src/debug/jtag/tests/integration/ai-provider-adapters.test.ts b/src/debug/jtag/tests/integration/ai-provider-adapters.test.ts index a92893d51..8991f1df3 100644 --- a/src/debug/jtag/tests/integration/ai-provider-adapters.test.ts +++ b/src/debug/jtag/tests/integration/ai-provider-adapters.test.ts @@ -165,7 +165,7 @@ async function testHealthChecks(): Promise { const statusIcon = health.status === 'healthy' ? '✅' : health.status === 'degraded' ? '⚠️' : '❌'; - console.log(`${statusIcon} ${name}: ${health.status} (${health.responseTime}ms) - ${health.message}`); + console.log(`${statusIcon} ${name}: ${health.status} (${health.responseTimeMs}ms) - ${health.message}`); } catch (error) { console.log(`❌ ${name}: Health check failed - ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/debug/jtag/tests/integration/big-three-providers.test.ts b/src/debug/jtag/tests/integration/big-three-providers.test.ts index e3877cdf6..ef0b19670 100644 --- a/src/debug/jtag/tests/integration/big-three-providers.test.ts +++ b/src/debug/jtag/tests/integration/big-three-providers.test.ts @@ -254,7 +254,7 @@ async function testHealthChecks(): Promise { health.status === 'healthy' ? '✅' : health.status === 'degraded' ? '⚠️' : '❌'; console.log( - `${statusIcon} ${provider.name}: ${health.status} (${health.responseTime}ms) - ${health.message}` + `${statusIcon} ${provider.name}: ${health.status} (${health.responseTimeMs}ms) - ${health.message}` ); } catch (error) { console.log( diff --git a/src/debug/jtag/tests/integration/cns-integration.test.ts b/src/debug/jtag/tests/integration/cns-integration.test.ts index e2d338efd..cf6f46bea 100644 --- a/src/debug/jtag/tests/integration/cns-integration.test.ts +++ b/src/debug/jtag/tests/integration/cns-integration.test.ts @@ -1,29 +1,23 @@ /** - * CNS Integration Test + * Cognition Scheduling Integration Test * - * Verifies that PersonaCentralNervousSystem is actually being used (not falling back) - * and that cognitive schedulers are making decisions. + * Verifies that the Rust cognition engine + ChannelModule tick loop + * are routing messages and scheduling work correctly. */ import { describe, it, expect, beforeAll } from 'vitest'; -import { Commands } from '../../system/core/shared/Commands'; -import { DATA_COMMANDS } from '@commands/data/shared/DataCommandConstants'; -import type { DataListParams, DataListResult } from '../../commands/data/list/shared/DataListTypes'; -import type { ChatSendParams, ChatSendResult } from '../../commands/collaboration/chat/send/shared/ChatSendTypes'; -import type { BaseEntity } from '../../system/data/entities/BaseEntity'; import { Ping } from '../../commands/ping/shared/PingTypes'; import { DataList } from '../../commands/data/list/shared/DataListTypes'; import { ChatSend } from '../../commands/collaboration/chat/send/shared/ChatSendTypes'; -describe('CNS Integration', () => { + +describe('Cognition Scheduling Integration', () => { beforeAll(async () => { - // Ensure system is ready const pingResult = await Ping.execute({}); expect(pingResult.success).toBe(true); }); - it('should have CNS initialized for all personas', async () => { - // Get all PersonaUsers + it('should have Rust cognition initialized for all personas', async () => { const users = await DataList.execute({ collection: 'users', filter: { type: 'persona' } @@ -32,13 +26,10 @@ describe('CNS Integration', () => { expect(users.success).toBe(true); expect(users.items.length).toBeGreaterThan(0); - console.log(`✅ Found ${users.items.length} personas - CNS should be initialized for each`); + console.log(`Found ${users.items.length} personas with Rust cognition engine`); }); - it('should route chat messages through CNS serviceCycle', async () => { - // This test verifies the integration by sending a message - // If CNS wasn't working, the system would crash or fall back - + it('should route chat messages through Rust serviceCycleFull', async () => { const rooms = await DataList.execute({ collection: 'rooms', limit: 1 @@ -49,13 +40,12 @@ describe('CNS Integration', () => { const roomId = rooms.items[0].uniqueId; - // Send a test message const sendResult = await ChatSend.execute({ room: roomId, - message: '[TEST] CNS integration test message' + message: '[TEST] Cognition scheduling integration test message' }); expect(sendResult.success).toBe(true); - console.log('✅ Message sent through CNS without errors'); + console.log('Message routed through Rust cognition without errors'); }); }); diff --git a/src/debug/jtag/tests/integration/sentinel-adapter-integration.test.ts b/src/debug/jtag/tests/integration/sentinel-adapter-integration.test.ts index fcbf883b1..ec7289423 100644 --- a/src/debug/jtag/tests/integration/sentinel-adapter-integration.test.ts +++ b/src/debug/jtag/tests/integration/sentinel-adapter-integration.test.ts @@ -35,7 +35,7 @@ async function runTests() { } console.log(`✅ Health check passed: ${health.status}`); - console.log(` Response time: ${health.responseTime}ms\n`); + console.log(` Response time: ${health.responseTimeMs}ms\n`); testsPassed++; } catch (error) { console.error(`❌ Health check failed: ${error}`); diff --git a/src/debug/jtag/tests/integration/sentinel-adapter.test.ts b/src/debug/jtag/tests/integration/sentinel-adapter.test.ts index 19a1184a7..f17d03653 100644 --- a/src/debug/jtag/tests/integration/sentinel-adapter.test.ts +++ b/src/debug/jtag/tests/integration/sentinel-adapter.test.ts @@ -219,7 +219,7 @@ async function testHealthCheck(): Promise { console.log(`Status: ${health.status === 'healthy' ? '✅' : '❌'} ${health.status}`); console.log(`API Available: ${health.apiAvailable ? '✅' : '❌'}`); - console.log(`Response Time: ${health.responseTime}ms`); + console.log(`Response Time: ${health.responseTimeMs}ms`); console.log(`Error Rate: ${health.errorRate}`); } catch (error) { diff --git a/src/debug/jtag/tests/integration/sentinel-evidence.test.ts b/src/debug/jtag/tests/integration/sentinel-evidence.test.ts deleted file mode 100644 index b9a491afa..000000000 --- a/src/debug/jtag/tests/integration/sentinel-evidence.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Sentinel Evidence Test - * - * Tests that BuildSentinel captures PROOF of its work. - * The evidence should include actual build output, not just "trust me". - */ - -import { BuildSentinel, type BuildResult } from '../../system/sentinel/BuildSentinel'; -import { formatExecutionLog } from '../../system/sentinel/SentinelExecutionLog'; -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; - -async function testBuildEvidenceCapture(): Promise { - console.log('\n=== Test: Build Evidence Capture ===\n'); - - // Create a test directory with a failing TypeScript file - const testDir = '/tmp/sentinel-evidence-test-' + Date.now(); - fs.mkdirSync(testDir, { recursive: true }); - - // Write a simple tsconfig - fs.writeFileSync(path.join(testDir, 'tsconfig.json'), JSON.stringify({ - compilerOptions: { - target: 'ES2020', - module: 'commonjs', - strict: true, - noEmit: true, - }, - include: ['*.ts'], - }, null, 2)); - - // Write a TypeScript file with an error - fs.writeFileSync(path.join(testDir, 'test.ts'), ` -// This file has a type error -const x: number = "not a number"; // Error: string not assignable to number -console.log(x); -`); - - console.log('Test directory:', testDir); - console.log('Created test.ts with intentional type error\n'); - - // Run BuildSentinel - const sentinel = new BuildSentinel({ - command: 'npx tsc --noEmit', - workingDir: testDir, - maxAttempts: 1, // Just one attempt to capture the error - canAutoFix: false, // Don't try to fix - useLLM: false, - streamEvents: false, - }); - - console.log('Running BuildSentinel...\n'); - const result = await sentinel.run(); - - console.log('Result:'); - console.log(` success: ${result.success}`); - console.log(` escalated: ${result.escalated}`); - console.log(` attempts: ${result.attempts.length}`); - - // Check that we captured evidence - const attempt = result.attempts[0]; - console.log('\nBuild Attempt Evidence:'); - console.log(` rawOutput length: ${attempt.rawOutput.length} chars`); - console.log(` outputSummary:\n ${attempt.outputSummary.split('\n').join('\n ')}`); - console.log(` errors: ${attempt.errors.length}`); - - // Verify evidence was captured - if (!attempt.rawOutput || attempt.rawOutput.length === 0) { - throw new Error('Expected rawOutput to contain build output'); - } - - if (!attempt.rawOutput.includes('TS2322') && !attempt.rawOutput.includes('not assignable')) { - throw new Error('Expected rawOutput to contain the type error message'); - } - - // Check execution log has evidence - if (result.executionLog) { - console.log('\n--- Execution Log with Evidence ---\n'); - console.log(formatExecutionLog(result.executionLog)); - - // Verify evidence is in the actions - const buildAction = result.executionLog.actions.find(a => a.type === 'build'); - if (!buildAction?.evidence) { - throw new Error('Expected build action to have evidence'); - } - - if (!buildAction.evidence.output) { - throw new Error('Expected evidence to have output'); - } - - if (!buildAction.evidence.verificationOutput) { - throw new Error('Expected evidence to have verificationOutput'); - } - - console.log('Evidence verified:'); - console.log(` output: ${buildAction.evidence.output.length} chars`); - console.log(` verificationOutput: ${buildAction.evidence.verificationOutput}`); - console.log(` verified: ${buildAction.evidence.verified}`); - } - - console.log('\n✅ Build evidence capture test passed!'); - - // Cleanup - fs.rmSync(testDir, { recursive: true, force: true }); -} - -async function testSuccessEvidence(): Promise { - console.log('\n=== Test: Success Evidence ===\n'); - - // Create a test directory with valid TypeScript - const testDir = '/tmp/sentinel-success-test-' + Date.now(); - fs.mkdirSync(testDir, { recursive: true }); - - fs.writeFileSync(path.join(testDir, 'tsconfig.json'), JSON.stringify({ - compilerOptions: { - target: 'ES2020', - module: 'commonjs', - strict: true, - noEmit: true, - }, - include: ['*.ts'], - }, null, 2)); - - // Write valid TypeScript - fs.writeFileSync(path.join(testDir, 'test.ts'), ` -const x: number = 42; -console.log(x); -`); - - console.log('Test directory:', testDir); - console.log('Created test.ts with valid code\n'); - - const sentinel = new BuildSentinel({ - command: 'npx tsc --noEmit', - workingDir: testDir, - maxAttempts: 1, - canAutoFix: false, - useLLM: false, - }); - - console.log('Running BuildSentinel...\n'); - const result = await sentinel.run(); - - console.log('Result:'); - console.log(` success: ${result.success}`); - - // Verify success evidence - const attempt = result.attempts[0]; - if (!attempt.success) { - throw new Error('Expected build to succeed'); - } - - // Check execution log shows success proof - if (result.executionLog) { - const buildAction = result.executionLog.actions.find(a => a.type === 'build'); - - if (!buildAction?.evidence?.verified) { - throw new Error('Expected verified: true in evidence'); - } - - console.log('Success evidence:'); - console.log(` verified: ${buildAction.evidence.verified}`); - console.log(` verificationOutput: ${buildAction.evidence.verificationOutput}`); - } - - console.log('\n✅ Success evidence test passed!'); - - // Cleanup - fs.rmSync(testDir, { recursive: true, force: true }); -} - -async function main(): Promise { - console.log('Sentinel Evidence Tests'); - console.log('=======================\n'); - console.log('These tests verify that BuildSentinel captures PROOF of its work.\n'); - - try { - await testBuildEvidenceCapture(); - await testSuccessEvidence(); - - console.log('\n\n================================'); - console.log('✅ All evidence tests passed!'); - console.log('================================\n'); - } catch (err) { - console.error('\n❌ Test failed:', err); - process.exit(1); - } -} - -main(); diff --git a/src/debug/jtag/tests/integration/sentinel-execution-log.test.ts b/src/debug/jtag/tests/integration/sentinel-execution-log.test.ts deleted file mode 100644 index 6dd57fc2d..000000000 --- a/src/debug/jtag/tests/integration/sentinel-execution-log.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Sentinel Execution Log Test - * - * Tests streaming execution logging for sentinel execution. - */ - -import { - ExecutionLogBuilder, - formatExecutionLog, - registerExecution, - unregisterExecution, - getExecutionSnapshot, - listActiveExecutions, - type SentinelEvent, -} from '../../system/sentinel/SentinelExecutionLog'; - -async function testBasicLogging(): Promise { - console.log('\n=== Test: Basic Logging ===\n'); - - // Constructor params: handle, sentinelType, goal, eventEmitter? - const builder = new ExecutionLogBuilder('test-basic', 'build', 'Fix compilation errors'); - builder.setWorkspace({ workingDir: '/tmp/test' }); - - // Record some actions - builder.recordAction({ - type: 'build', - intent: 'Run TypeScript compilation', - operation: 'npm run build', - result: 'success', - durationMs: 1234, - }); - - builder.recordAction({ - type: 'fix', - intent: 'Fix missing import', - operation: 'edit file.ts', - result: 'success', - }); - - // Record file changes - builder.recordFileChange('/tmp/test/file.ts', 'modified'); - builder.recordFileChange('/tmp/test/new-file.ts', 'created'); - - // Complete returns the log - const log = builder.complete('success'); - - console.log('Log built:'); - console.log(` handle: ${log.handle}`); - console.log(` sentinelType: ${log.sentinelType}`); - console.log(` actions: ${log.actions.length}`); - console.log(` fileChanges: ${log.fileChanges.length}`); - console.log(` status: ${log.status}`); - console.log(` summary: ${log.summary}`); - - // Verify - if (log.actions.length !== 2) throw new Error('Expected 2 actions'); - if (log.fileChanges.length !== 2) throw new Error('Expected 2 file changes'); - if (log.status !== 'success') throw new Error('Expected success status'); - - console.log('\n✅ Basic logging test passed!'); -} - -async function testEventStreaming(): Promise { - console.log('\n=== Test: Event Streaming ===\n'); - - const events: SentinelEvent[] = []; - - // Constructor params: handle, sentinelType, goal, eventEmitter? - const builder = new ExecutionLogBuilder('test-streaming', 'build', 'Fix errors', async (event) => { - events.push(event); - console.log(` Event: ${event.type} - ${JSON.stringify(event.payload).substring(0, 50)}...`); - }); - builder.setWorkspace({ workingDir: '/tmp/test' }); - - await new Promise(r => setTimeout(r, 10)); // Let async event fire - - builder.recordAction({ - type: 'build', - intent: 'Run build', - result: 'success', - }); - await new Promise(r => setTimeout(r, 10)); - - builder.recordFileChange('/tmp/test/file.ts', 'modified'); - await new Promise(r => setTimeout(r, 10)); - - builder.complete('success'); - await new Promise(r => setTimeout(r, 10)); - - console.log(`\nTotal events captured: ${events.length}`); - - // Should have: status (started), action, file-change, status (completed) - if (events.length < 4) throw new Error(`Expected at least 4 events, got ${events.length}`); - - const statusEvents = events.filter(e => e.type === 'status'); - const actionEvents = events.filter(e => e.type === 'action'); - const fileEvents = events.filter(e => e.type === 'file-change'); - - console.log(` Status events: ${statusEvents.length}`); - console.log(` Action events: ${actionEvents.length}`); - console.log(` File events: ${fileEvents.length}`); - - console.log('\n✅ Event streaming test passed!'); -} - -async function testSnapshotAndRegistry(): Promise { - console.log('\n=== Test: Snapshot & Registry ===\n'); - - // Verify nothing registered initially - let active = listActiveExecutions(); - console.log(`Initial active executions: ${active.length}`); - - // Create and register - const builder = new ExecutionLogBuilder('test-snapshot', 'build', 'Test build'); - builder.setWorkspace({ workingDir: '/tmp/test' }); - registerExecution(builder); // Takes just the builder - - active = listActiveExecutions(); - console.log(`After register: ${active.length}`); - - if (active.length === 0 || !active.includes('test-snapshot')) { - throw new Error('Expected test-snapshot to be registered'); - } - - // Add some data - builder.recordAction({ type: 'build', intent: 'Run tsc', result: 'success' }); - - // Get snapshot (like "joining" a running process) - const snapshot = getExecutionSnapshot('test-snapshot'); - console.log('\nSnapshot:'); - console.log(` handle: ${snapshot?.handle}`); - console.log(` inProgress: ${snapshot?.inProgress}`); - console.log(` actions: ${snapshot?.actions?.length}`); - - if (!snapshot) throw new Error('Expected snapshot'); - if (!snapshot.inProgress) throw new Error('Expected inProgress: true'); - - // Complete and unregister - builder.complete('success'); - unregisterExecution('test-snapshot'); - - active = listActiveExecutions(); - console.log(`\nAfter unregister: ${active.length}`); - - if (active.includes('test-snapshot')) { - throw new Error('test-snapshot should have been unregistered'); - } - - console.log('\n✅ Snapshot & Registry test passed!'); -} - -async function testFormatting(): Promise { - console.log('\n=== Test: Log Formatting ===\n'); - - const builder = new ExecutionLogBuilder('test-format', 'build', 'Build the project'); - builder.setWorkspace({ workingDir: '/tmp/test' }); - builder.recordAction({ type: 'build', intent: 'Run TypeScript build', operation: 'tsc --build', result: 'success', durationMs: 5432 }); - builder.recordAction({ type: 'fix', intent: 'Add missing import', result: 'success' }); - builder.recordFileChange('/tmp/test/src/index.ts', 'modified'); - - const log = builder.complete('success'); - const formatted = formatExecutionLog(log); - - console.log('Formatted log:\n'); - console.log(formatted); - - // Basic formatting checks - if (!formatted.includes('test-format')) throw new Error('Should include handle'); - if (!formatted.includes('SUCCESS')) throw new Error('Should indicate success'); - if (!formatted.includes('Run TypeScript build')) throw new Error('Should include intent'); - - console.log('\n✅ Log formatting test passed!'); -} - -async function main(): Promise { - console.log('Sentinel Execution Log Tests'); - console.log('============================\n'); - - try { - await testBasicLogging(); - await testEventStreaming(); - await testSnapshotAndRegistry(); - await testFormatting(); - - console.log('\n\n=================================='); - console.log('✅ All execution log tests passed!'); - console.log('==================================\n'); - } catch (err) { - console.error('\n❌ Test failed:', err); - process.exit(1); - } -} - -main(); diff --git a/src/debug/jtag/tests/integration/sentinel-workspace-isolation.test.ts b/src/debug/jtag/tests/integration/sentinel-workspace-isolation.test.ts deleted file mode 100644 index fcfc92b45..000000000 --- a/src/debug/jtag/tests/integration/sentinel-workspace-isolation.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * Sentinel Workspace Isolation Test - * - * Tests git-based workspace isolation for sentinel execution. - */ - -import { SentinelWorkspace } from '../../system/sentinel/SentinelWorkspace'; -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; - -async function testBranchIsolation(): Promise { - console.log('\n=== Test: Branch Isolation ===\n'); - - // Use /tmp for test to avoid polluting the main repo - const testDir = '/tmp/sentinel-workspace-test-' + Date.now(); - - // Create a test git repo - fs.mkdirSync(testDir, { recursive: true }); - execSync('git init', { cwd: testDir }); - execSync('git config user.email "test@test.com"', { cwd: testDir }); - execSync('git config user.name "Test"', { cwd: testDir }); - - // Create initial file and commit - fs.writeFileSync(path.join(testDir, 'file.txt'), 'original content'); - execSync('git add -A && git commit -m "initial"', { cwd: testDir }); - - const originalBranch = execSync('git branch --show-current', { cwd: testDir, encoding: 'utf-8' }).trim(); - console.log(`Original branch: ${originalBranch}`); - - // Create workspace with branch isolation - const handle = 'test-' + Date.now(); - const workspace = await SentinelWorkspace.create({ - callerDir: testDir, - handle, - isolation: 'branch', - onFailure: 'delete', - }); - - console.log(`Workspace created:`); - console.log(` workingDir: ${workspace.workingDir}`); - console.log(` branch: ${workspace.workspace.branch}`); - console.log(` originalBranch: ${workspace.workspace.originalBranch}`); - - // Verify we're on the sentinel branch - const currentBranch = execSync('git branch --show-current', { cwd: workspace.workingDir, encoding: 'utf-8' }).trim(); - console.log(`\nCurrent branch: ${currentBranch}`); - - if (currentBranch !== workspace.workspace.branch) { - throw new Error(`Expected branch ${workspace.workspace.branch}, got ${currentBranch}`); - } - - // Make some changes in the workspace - fs.writeFileSync(path.join(workspace.workingDir, 'file.txt'), 'modified content'); - fs.writeFileSync(path.join(workspace.workingDir, 'new-file.txt'), 'new file'); - - console.log('\nMade changes:'); - console.log(' - Modified file.txt'); - console.log(' - Created new-file.txt'); - - // Verify changes exist - const status = execSync('git status --porcelain', { cwd: workspace.workingDir, encoding: 'utf-8' }); - console.log(`\nGit status:\n${status}`); - - // Abort the workspace (should clean up) - console.log('\nAborting workspace...'); - await workspace.abort('delete'); - - // Verify we're back on original branch - const finalBranch = execSync('git branch --show-current', { cwd: testDir, encoding: 'utf-8' }).trim(); - console.log(`\nFinal branch: ${finalBranch}`); - - if (finalBranch !== originalBranch) { - throw new Error(`Expected to return to ${originalBranch}, but on ${finalBranch}`); - } - - // Verify sentinel branch was deleted - const branches = execSync('git branch', { cwd: testDir, encoding: 'utf-8' }); - if (branches.includes(workspace.workspace.branch)) { - throw new Error(`Sentinel branch ${workspace.workspace.branch} should have been deleted`); - } - - // Verify file.txt is back to original content - const content = fs.readFileSync(path.join(testDir, 'file.txt'), 'utf-8'); - if (content !== 'original content') { - throw new Error(`file.txt should be reverted, got: ${content}`); - } - - // Verify new-file.txt doesn't exist - if (fs.existsSync(path.join(testDir, 'new-file.txt'))) { - throw new Error('new-file.txt should have been deleted on abort'); - } - - console.log('\n✅ Branch isolation test passed!'); - - // Cleanup - fs.rmSync(testDir, { recursive: true, force: true }); -} - -async function testSuccessfulCompletion(): Promise { - console.log('\n=== Test: Successful Completion ===\n'); - - const testDir = '/tmp/sentinel-workspace-complete-' + Date.now(); - - // Create a test git repo - fs.mkdirSync(testDir, { recursive: true }); - execSync('git init', { cwd: testDir }); - execSync('git config user.email "test@test.com"', { cwd: testDir }); - execSync('git config user.name "Test"', { cwd: testDir }); - - fs.writeFileSync(path.join(testDir, 'file.txt'), 'original'); - execSync('git add -A && git commit -m "initial"', { cwd: testDir }); - - // Create workspace - const handle = 'test-complete-' + Date.now(); - const workspace = await SentinelWorkspace.create({ - callerDir: testDir, - handle, - isolation: 'branch', - onSuccess: 'merge', - }); - - console.log(`Workspace branch: ${workspace.workspace.branch}`); - - // Make changes and commit them - fs.writeFileSync(path.join(workspace.workingDir, 'file.txt'), 'modified by sentinel'); - - // Complete with merge - console.log('Completing with merge...'); - const result = await workspace.complete('merge'); - - console.log(`Result: merged=${result.merged}, branch=${result.branch}`); - - // Verify changes were merged - const content = fs.readFileSync(path.join(testDir, 'file.txt'), 'utf-8'); - if (content !== 'modified by sentinel') { - throw new Error(`Expected merged content, got: ${content}`); - } - - console.log('\n✅ Successful completion test passed!'); - - // Cleanup - fs.rmSync(testDir, { recursive: true, force: true }); -} - -async function testNoIsolation(): Promise { - console.log('\n=== Test: No Isolation Mode ===\n'); - - const testDir = '/tmp/sentinel-workspace-none-' + Date.now(); - - // Create a test git repo - fs.mkdirSync(testDir, { recursive: true }); - execSync('git init', { cwd: testDir }); - execSync('git config user.email "test@test.com"', { cwd: testDir }); - execSync('git config user.name "Test"', { cwd: testDir }); - - fs.writeFileSync(path.join(testDir, 'file.txt'), 'original'); - execSync('git add -A && git commit -m "initial"', { cwd: testDir }); - - // Create workspace with no isolation - const handle = 'test-none-' + Date.now(); - const workspace = await SentinelWorkspace.create({ - callerDir: testDir, - handle, - isolation: 'none', - }); - - console.log(`Workspace workingDir: ${workspace.workingDir}`); - console.log(`Expected callerDir: ${testDir}`); - - // Verify workingDir is the same as callerDir - if (workspace.workingDir !== testDir) { - throw new Error(`With isolation=none, workingDir should equal callerDir`); - } - - console.log('\n✅ No isolation test passed!'); - - // Cleanup - fs.rmSync(testDir, { recursive: true, force: true }); -} - -async function main(): Promise { - console.log('Sentinel Workspace Isolation Tests'); - console.log('==================================\n'); - - try { - await testBranchIsolation(); - await testSuccessfulCompletion(); - await testNoIsolation(); - - console.log('\n\n============================='); - console.log('✅ All workspace tests passed!'); - console.log('=============================\n'); - } catch (err) { - console.error('\n❌ Test failed:', err); - process.exit(1); - } -} - -main(); diff --git a/src/debug/jtag/tests/unit/RateLimiter.test.ts b/src/debug/jtag/tests/unit/RateLimiter.test.ts index 7b49efcf1..2044d7883 100644 --- a/src/debug/jtag/tests/unit/RateLimiter.test.ts +++ b/src/debug/jtag/tests/unit/RateLimiter.test.ts @@ -2,26 +2,24 @@ /** * RateLimiter Unit Tests * - * Tests for the extracted RateLimiter module (Phase 1, Commit 1.2) + * Tests for the RateLimiter module — now focused on voice transcription dedup + * and config holding. Rate limiting decisions are in Rust (full_evaluate gate). * * Verifies: - * - Time-based rate limiting per room - * - Response count caps per room - * - Message deduplication - * - Rate limit info retrieval - * - Room-specific tracking + * - Message/transcription deduplication + * - Config holding and immutability */ +import { describe, it, expect, beforeEach } from 'vitest'; import { RateLimiter } from '../../system/user/server/modules/RateLimiter'; describe('RateLimiter', () => { let rateLimiter: RateLimiter; - const testRoomId = 'test-room-123'; const testMessageId = 'test-message-456'; beforeEach(() => { rateLimiter = new RateLimiter({ - minSecondsBetweenResponses: 2, // 2 seconds for faster tests + minSecondsBetweenResponses: 2, maxResponsesPerSession: 5 }); }); @@ -58,152 +56,6 @@ describe('RateLimiter', () => { }); }); - describe('Time-Based Rate Limiting', () => { - it('should not be rate limited when never responded', () => { - expect(rateLimiter.isRateLimited(testRoomId)).toBe(false); - }); - - it('should be rate limited immediately after response', () => { - rateLimiter.trackResponse(testRoomId); - expect(rateLimiter.isRateLimited(testRoomId)).toBe(true); - }); - - it('should remain rate limited within time window', async () => { - rateLimiter.trackResponse(testRoomId); - - // Wait 1 second (less than 2 second limit) - await new Promise(resolve => setTimeout(resolve, 1000)); - - expect(rateLimiter.isRateLimited(testRoomId)).toBe(true); - }); - - it('should not be rate limited after time window expires', async () => { - rateLimiter.trackResponse(testRoomId); - - // Wait 2.1 seconds (exceeds 2 second limit) - await new Promise(resolve => setTimeout(resolve, 2100)); - - expect(rateLimiter.isRateLimited(testRoomId)).toBe(false); - }); - - it('should be room-specific (different rooms independent)', () => { - const room1 = 'room-1'; - const room2 = 'room-2'; - - rateLimiter.trackResponse(room1); - - expect(rateLimiter.isRateLimited(room1)).toBe(true); - expect(rateLimiter.isRateLimited(room2)).toBe(false); - }); - }); - - describe('Rate Limit Info', () => { - it('should return null info when never responded', () => { - const info = rateLimiter.getRateLimitInfo(testRoomId); - - expect(info.isLimited).toBe(false); - expect(info.lastResponseTime).toBeNull(); - expect(info.responseCount).toBe(0); - expect(info.secondsSinceLastResponse).toBeNull(); - expect(info.waitTimeSeconds).toBeNull(); - }); - - it('should return accurate info immediately after response', () => { - rateLimiter.trackResponse(testRoomId); - const info = rateLimiter.getRateLimitInfo(testRoomId); - - expect(info.isLimited).toBe(true); - expect(info.lastResponseTime).toBeInstanceOf(Date); - expect(info.responseCount).toBe(1); - expect(info.secondsSinceLastResponse).toBeLessThan(1); - expect(info.waitTimeSeconds).toBeGreaterThan(1); - expect(info.waitTimeSeconds).toBeLessThanOrEqual(2); - }); - - it('should update wait time as time passes', async () => { - rateLimiter.trackResponse(testRoomId); - const info1 = rateLimiter.getRateLimitInfo(testRoomId); - - // Wait 1 second - await new Promise(resolve => setTimeout(resolve, 1000)); - - const info2 = rateLimiter.getRateLimitInfo(testRoomId); - - expect(info2.secondsSinceLastResponse).toBeGreaterThan(info1.secondsSinceLastResponse!); - expect(info2.waitTimeSeconds).toBeLessThan(info1.waitTimeSeconds!); - }); - - it('should show not limited after time window expires', async () => { - rateLimiter.trackResponse(testRoomId); - - // Wait for window to expire - await new Promise(resolve => setTimeout(resolve, 2100)); - - const info = rateLimiter.getRateLimitInfo(testRoomId); - - expect(info.isLimited).toBe(false); - expect(info.waitTimeSeconds).toBeNull(); - expect(info.secondsSinceLastResponse).toBeGreaterThan(2); - }); - }); - - describe('Response Count Tracking', () => { - it('should start at zero responses', () => { - expect(rateLimiter.getResponseCount(testRoomId)).toBe(0); - }); - - it('should increment response count on each track', () => { - rateLimiter.trackResponse(testRoomId); - expect(rateLimiter.getResponseCount(testRoomId)).toBe(1); - - rateLimiter.trackResponse(testRoomId); - expect(rateLimiter.getResponseCount(testRoomId)).toBe(2); - - rateLimiter.trackResponse(testRoomId); - expect(rateLimiter.getResponseCount(testRoomId)).toBe(3); - }); - - it('should be room-specific (different rooms independent)', () => { - const room1 = 'room-1'; - const room2 = 'room-2'; - - rateLimiter.trackResponse(room1); - rateLimiter.trackResponse(room1); - rateLimiter.trackResponse(room2); - - expect(rateLimiter.getResponseCount(room1)).toBe(2); - expect(rateLimiter.getResponseCount(room2)).toBe(1); - }); - - it('should detect when response cap reached', () => { - // Track 5 responses (cap is 5) - for (let i = 0; i < 5; i++) { - rateLimiter.trackResponse(testRoomId); - } - - expect(rateLimiter.hasReachedResponseCap(testRoomId)).toBe(true); - }); - - it('should not reach cap before limit', () => { - // Track 4 responses (cap is 5) - for (let i = 0; i < 4; i++) { - rateLimiter.trackResponse(testRoomId); - } - - expect(rateLimiter.hasReachedResponseCap(testRoomId)).toBe(false); - }); - - it('should detect cap exceeded', () => { - // Track 6 responses (cap is 5) - for (let i = 0; i < 6; i++) { - rateLimiter.trackResponse(testRoomId); - } - - expect(rateLimiter.hasReachedResponseCap(testRoomId)).toBe(true); - expect(rateLimiter.getResponseCount(testRoomId)).toBe(6); - }); - }); - describe('Message Deduplication', () => { it('should not have evaluated new message', () => { expect(rateLimiter.hasEvaluatedMessage(testMessageId)).toBe(false); @@ -246,91 +98,15 @@ describe('RateLimiter', () => { expect(rateLimiter.hasEvaluatedMessage('msg-2')).toBe(false); expect(rateLimiter.hasEvaluatedMessage('msg-3')).toBe(false); }); - }); - - describe('Room Reset', () => { - it('should reset rate limit state for room', async () => { - rateLimiter.trackResponse(testRoomId); - expect(rateLimiter.isRateLimited(testRoomId)).toBe(true); - expect(rateLimiter.getResponseCount(testRoomId)).toBe(1); - - rateLimiter.resetRoom(testRoomId); - - expect(rateLimiter.isRateLimited(testRoomId)).toBe(false); - expect(rateLimiter.getResponseCount(testRoomId)).toBe(0); - - const info = rateLimiter.getRateLimitInfo(testRoomId); - expect(info.lastResponseTime).toBeNull(); - }); - - it('should not affect other rooms when resetting', () => { - const room1 = 'room-1'; - const room2 = 'room-2'; - - rateLimiter.trackResponse(room1); - rateLimiter.trackResponse(room2); - - rateLimiter.resetRoom(room1); - - expect(rateLimiter.isRateLimited(room1)).toBe(false); - expect(rateLimiter.isRateLimited(room2)).toBe(true); - expect(rateLimiter.getResponseCount(room1)).toBe(0); - expect(rateLimiter.getResponseCount(room2)).toBe(1); - }); - }); - - describe('Integration Scenarios', () => { - it('should handle rapid responses with time-based limiting', async () => { - // First response - allowed - rateLimiter.trackResponse(testRoomId); - expect(rateLimiter.isRateLimited(testRoomId)).toBe(true); - - // Wait for window to expire - await new Promise(resolve => setTimeout(resolve, 2100)); - - // Second response - allowed - rateLimiter.trackResponse(testRoomId); - expect(rateLimiter.isRateLimited(testRoomId)).toBe(true); - expect(rateLimiter.getResponseCount(testRoomId)).toBe(2); - }); - - it('should enforce both time and count limits', async () => { - // Send 5 responses (reach cap) - for (let i = 0; i < 5; i++) { - rateLimiter.trackResponse(testRoomId); - await new Promise(resolve => setTimeout(resolve, 2100)); // Wait for time window - } - - expect(rateLimiter.hasReachedResponseCap(testRoomId)).toBe(true); - expect(rateLimiter.isRateLimited(testRoomId)).toBe(true); - - // Even after time window, cap is reached - await new Promise(resolve => setTimeout(resolve, 2100)); - expect(rateLimiter.hasReachedResponseCap(testRoomId)).toBe(true); - }); - - it('should handle multiple rooms with different states', () => { - const room1 = 'room-1'; - const room2 = 'room-2'; - const room3 = 'room-3'; - - // Room 1: Just responded - rateLimiter.trackResponse(room1); - - // Room 2: Responded 3 times - rateLimiter.trackResponse(room2); - rateLimiter.trackResponse(room2); - rateLimiter.trackResponse(room2); - // Room 3: Never responded + it('should handle composite transcription keys', () => { + const key1 = 'speaker-uuid-123-1707000000'; + const key2 = 'speaker-uuid-123-1707000001'; - expect(rateLimiter.isRateLimited(room1)).toBe(true); - expect(rateLimiter.isRateLimited(room2)).toBe(true); - expect(rateLimiter.isRateLimited(room3)).toBe(false); + rateLimiter.markMessageEvaluated(key1); - expect(rateLimiter.getResponseCount(room1)).toBe(1); - expect(rateLimiter.getResponseCount(room2)).toBe(3); - expect(rateLimiter.getResponseCount(room3)).toBe(0); + expect(rateLimiter.hasEvaluatedMessage(key1)).toBe(true); + expect(rateLimiter.hasEvaluatedMessage(key2)).toBe(false); }); }); }); diff --git a/src/debug/jtag/tests/unit/cns-factory.test.ts b/src/debug/jtag/tests/unit/cns-factory.test.ts deleted file mode 100644 index c9e1dc222..000000000 --- a/src/debug/jtag/tests/unit/cns-factory.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Unit test for CNS Factory - tests the factory without requiring full system - */ - -import { describe, it, expect } from 'vitest'; -import { CNSFactory } from '../../system/user/server/modules/central-nervous-system/CNSFactory'; - -describe('CNSFactory', () => { - it('should create CNS with deterministic scheduler for simple persona', () => { - // Mock minimal PersonaUser interface - const mockPersona = { - entity: { - id: 'test-id', - displayName: 'Test Persona', - capabilities: undefined // No capabilities = deterministic - }, - inbox: { - waitForWork: async () => false, - peek: async () => [], - pop: async () => null, - getSize: () => 0 - }, - personaState: { - getCadence: () => 5000, - rest: async () => {}, - shouldEngage: () => true, - getState: () => ({ energy: 1.0, mood: 'neutral' }) - }, - genome: null, - handleChatMessageFromCNS: async () => {}, - pollTasksFromCNS: async () => {}, - generateSelfTasksFromCNS: async () => {} - }; - - // Should not throw - const cns = CNSFactory.create(mockPersona as any); - expect(cns).toBeDefined(); - console.log('✅ CNS created successfully with deterministic scheduler'); - }); - - it('should handle persona with displayName', () => { - const mockPersona = { - entity: { - id: 'test-id', - displayName: 'Named Persona' - }, - inbox: { - waitForWork: async () => false, - peek: async () => [], - pop: async () => null, - getSize: () => 0 - }, - personaState: { - getCadence: () => 5000, - rest: async () => {}, - shouldEngage: () => true, - getState: () => ({ energy: 1.0, mood: 'neutral' }) - }, - genome: null, - handleChatMessageFromCNS: async () => {}, - pollTasksFromCNS: async () => {}, - generateSelfTasksFromCNS: async () => {} - }; - - const cns = CNSFactory.create(mockPersona as any); - expect(cns).toBeDefined(); - console.log('✅ CNS handles named persona correctly'); - }); -}); diff --git a/src/debug/jtag/tests/unit/semantic-cognition.test.ts b/src/debug/jtag/tests/unit/semantic-cognition.test.ts index bf48da182..b30c799f3 100644 --- a/src/debug/jtag/tests/unit/semantic-cognition.test.ts +++ b/src/debug/jtag/tests/unit/semantic-cognition.test.ts @@ -7,15 +7,32 @@ * - RAGBudgetManager allocation */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { isEmbeddable, needsEmbedding, type IEmbeddable } from '../../system/data/interfaces/IEmbeddable'; import { getContextWindow, + getInferenceSpeed, isLargeContextModel, + isSlowLocalModel, getRecommendedMaxOutputTokens, MODEL_CONTEXT_WINDOWS, DEFAULT_CONTEXT_WINDOW } from '../../system/shared/ModelContextWindows'; +import { ModelRegistry } from '../../system/shared/ModelRegistry'; +import { + QuantFormat, + WeightFormat, + AdapterMethod, + AdapterTarget, + InferenceRuntime, + Accelerator, + isFineTunable, + supportsLoRA, + supportsAdapterStacking, + estimateAdapterVramMB, + fitsInVram, + type ModelAdapterProfile +} from '../../system/shared/ModelCapabilities'; import { RAGBudgetManager, allocateChatBudget, @@ -105,7 +122,7 @@ describe('ModelContextWindows', () => { it('should have reasonable values for all models', () => { for (const [model, size] of Object.entries(MODEL_CONTEXT_WINDOWS)) { expect(size).toBeGreaterThan(0); - expect(size).toBeLessThanOrEqual(1000000); // Max 1M tokens + expect(size).toBeLessThanOrEqual(1100000); // Max ~1M tokens (Gemini 2.0 Flash is 1048576) } }); }); @@ -135,6 +152,205 @@ describe('ModelContextWindows', () => { }); }); +describe('ModelRegistry - Provider-Scoped Lookups', () => { + let registry: ModelRegistry; + + beforeEach(() => { + registry = ModelRegistry.sharedInstance(); + registry.clear(); + }); + + it('should store same model under different providers without collision', () => { + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 1400, + provider: 'candle', + discoveredAt: Date.now() + }); + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 131072, + provider: 'together', + discoveredAt: Date.now() + }); + + // Scoped lookups return correct values + expect(registry.contextWindow('meta-llama/Llama-3.1-8B-Instruct', 'candle')).toBe(1400); + expect(registry.contextWindow('meta-llama/Llama-3.1-8B-Instruct', 'together')).toBe(131072); + }); + + it('should return largest context window for unscoped lookup with multiple providers', () => { + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 1400, + provider: 'candle', + discoveredAt: Date.now() + }); + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 131072, + provider: 'together', + discoveredAt: Date.now() + }); + + // Unscoped returns largest (cloud wins for backward compat) + expect(registry.contextWindow('meta-llama/Llama-3.1-8B-Instruct')).toBe(131072); + }); + + it('should return single provider entry for unscoped lookup with one provider', () => { + registry.register({ + modelId: 'claude-sonnet-4-5-20250929', + contextWindow: 200000, + provider: 'anthropic', + discoveredAt: Date.now() + }); + + expect(registry.contextWindow('claude-sonnet-4-5-20250929')).toBe(200000); + }); + + it('should return undefined for unknown provider', () => { + registry.register({ + modelId: 'gpt-4o', + contextWindow: 128000, + provider: 'openai', + discoveredAt: Date.now() + }); + + expect(registry.contextWindow('gpt-4o', 'candle')).toBeUndefined(); + }); + + it('getAll should return all providers for a model', () => { + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 1400, + provider: 'candle', + discoveredAt: Date.now() + }); + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 131072, + provider: 'together', + discoveredAt: Date.now() + }); + + const all = registry.getAll('meta-llama/Llama-3.1-8B-Instruct'); + expect(all.length).toBe(2); + const providers = all.map(m => m.provider).sort(); + expect(providers).toEqual(['candle', 'together']); + }); + + it('should apply date-suffix normalization within provider scope', () => { + registry.register({ + modelId: 'claude-sonnet-4-5', + contextWindow: 200000, + provider: 'anthropic', + discoveredAt: Date.now() + }); + + // Date-suffix stripped lookup should find it + expect(registry.contextWindow('claude-sonnet-4-5-20250929', 'anthropic')).toBe(200000); + }); + + it('discoveredCount should reflect provider-scoped entries', () => { + registry.register({ + modelId: 'llama-8b', + contextWindow: 1400, + provider: 'candle', + discoveredAt: Date.now() + }); + registry.register({ + modelId: 'llama-8b', + contextWindow: 131072, + provider: 'together', + discoveredAt: Date.now() + }); + + // Two entries (one per provider), not one + expect(registry.discoveredCount).toBe(2); + }); +}); + +describe('Provider-Scoped ModelContextWindows', () => { + beforeEach(() => { + ModelRegistry.sharedInstance().clear(); + }); + + it('getContextWindow should return provider-scoped value from registry', () => { + const registry = ModelRegistry.sharedInstance(); + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 1400, + provider: 'candle', + discoveredAt: Date.now() + }); + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 131072, + provider: 'together', + discoveredAt: Date.now() + }); + + expect(getContextWindow('meta-llama/Llama-3.1-8B-Instruct', 'candle')).toBe(1400); + expect(getContextWindow('meta-llama/Llama-3.1-8B-Instruct', 'together')).toBe(131072); + }); + + it('getContextWindow should fall back to static map when provider not in registry', () => { + // No registry entries — should use static map + expect(getContextWindow('meta-llama/Llama-3.1-8B-Instruct', 'candle')).toBe(1400); + }); + + it('getInferenceSpeed should return local TPS for local provider in registry', () => { + const registry = ModelRegistry.sharedInstance(); + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 1400, + provider: 'candle', + discoveredAt: Date.now() + }); + + // Bug fix verification: should return 40 TPS (static map), not 1000 TPS (cloud assumption) + const speed = getInferenceSpeed('meta-llama/Llama-3.1-8B-Instruct', 'candle'); + expect(speed).toBe(40); // From MODEL_INFERENCE_SPEEDS static map + }); + + it('getInferenceSpeed should return 1000 TPS for cloud provider in registry', () => { + const registry = ModelRegistry.sharedInstance(); + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 131072, + provider: 'together', + discoveredAt: Date.now() + }); + + const speed = getInferenceSpeed('meta-llama/Llama-3.1-8B-Instruct', 'together'); + expect(speed).toBe(1000); // Cloud API speed + }); + + it('isSlowLocalModel should be true for candle models', () => { + const registry = ModelRegistry.sharedInstance(); + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 1400, + provider: 'candle', + discoveredAt: Date.now() + }); + + expect(isSlowLocalModel('meta-llama/Llama-3.1-8B-Instruct', 'candle')).toBe(true); + }); + + it('isSlowLocalModel should be false for cloud models', () => { + const registry = ModelRegistry.sharedInstance(); + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 131072, + provider: 'together', + discoveredAt: Date.now() + }); + + expect(isSlowLocalModel('meta-llama/Llama-3.1-8B-Instruct', 'together')).toBe(false); + }); +}); + describe('RAGBudgetManager', () => { describe('allocate', () => { it('should allocate minimums to all sources', () => { @@ -243,3 +459,251 @@ describe('RAGBudgetManager', () => { }); }); }); + +describe('ModelCapabilities — Adapter Profile Type System', () => { + // Realistic profile: Llama 3.1 8B on Candle/M1 with QLoRA support + const candleLlama8B: ModelAdapterProfile = { + runtime: InferenceRuntime.CANDLE, + quantization: { + format: QuantFormat.Q4_K_M, + bitsPerWeight: 4, + weightFormat: WeightFormat.GGUF, + canDequantizeForTraining: false, + canTrainInQuantized: true, // QLoRA + }, + fineTuning: { + supportedMethods: [AdapterMethod.QLORA], + lora: { + maxRank: 32, + recommendedRank: 8, + recommendedAlpha: 16, + maxConcurrentAdapters: 3, + supportsStacking: true, + adapterSizeMB: 15, + targetableLayers: [AdapterTarget.ATTN_Q, AdapterTarget.ATTN_V], + recommendedTargets: [AdapterTarget.ATTN_Q, AdapterTarget.ATTN_V], + recommendedDropout: 0.05, + }, + maxTrainingBatchSize: 4, + supportsGradientCheckpointing: true, + supportsFlashAttention: false, + }, + hardware: { + inferenceVramMB: 4500, + trainingVramMB: 8000, + accelerator: Accelerator.METAL, + measuredInputTPS: 40, + measuredOutputTPS: 25, + fitsInVram: true, + cpuOffloadLayers: 0, + }, + architectureFamily: 'llama', + parameterCountB: 8, + layerCount: 32, + hiddenSize: 4096, + }; + + // Cloud API profile: no fine-tuning, no local execution + const cloudLlama: ModelAdapterProfile = { + runtime: InferenceRuntime.CLOUD_API, + quantization: { + format: QuantFormat.NONE, + bitsPerWeight: 16, + weightFormat: WeightFormat.CLOUD, + }, + fineTuning: { + supportedMethods: [], // Cloud API — no adapter access + }, + hardware: { + inferenceVramMB: 0, // Cloud-managed + accelerator: Accelerator.CLOUD, + }, + }; + + // Full fine-tune only profile (no PEFT) + const fullFinetuneOnly: ModelAdapterProfile = { + runtime: InferenceRuntime.TRANSFORMERS, + quantization: { + format: QuantFormat.FP16, + bitsPerWeight: 16, + weightFormat: WeightFormat.SAFETENSORS, + }, + fineTuning: { + supportedMethods: [AdapterMethod.FULL], + }, + }; + + describe('isFineTunable', () => { + it('should return true for QLoRA-capable model', () => { + expect(isFineTunable(candleLlama8B)).toBe(true); + }); + + it('should return false for cloud API model', () => { + expect(isFineTunable(cloudLlama)).toBe(false); + }); + + it('should return false for full-fine-tune-only model (not PEFT)', () => { + // Full fine-tune is not parameter-efficient — isFineTunable checks for PEFT + expect(isFineTunable(fullFinetuneOnly)).toBe(false); + }); + + it('should return false for undefined profile', () => { + expect(isFineTunable(undefined)).toBe(false); + }); + }); + + describe('supportsLoRA', () => { + it('should return true for QLoRA-capable model', () => { + expect(supportsLoRA(candleLlama8B)).toBe(true); + }); + + it('should return false for cloud API model', () => { + expect(supportsLoRA(cloudLlama)).toBe(false); + }); + + it('should return true for LoRA + QLoRA model', () => { + const both: ModelAdapterProfile = { + ...candleLlama8B, + fineTuning: { + supportedMethods: [AdapterMethod.LORA, AdapterMethod.QLORA], + lora: candleLlama8B.fineTuning.lora, + }, + }; + expect(supportsLoRA(both)).toBe(true); + }); + }); + + describe('supportsAdapterStacking', () => { + it('should return true for multi-adapter model', () => { + expect(supportsAdapterStacking(candleLlama8B)).toBe(true); + }); + + it('should return false for cloud model', () => { + expect(supportsAdapterStacking(cloudLlama)).toBe(false); + }); + + it('should return false when maxConcurrentAdapters is 1', () => { + const singleAdapter: ModelAdapterProfile = { + ...candleLlama8B, + fineTuning: { + ...candleLlama8B.fineTuning, + lora: { + ...candleLlama8B.fineTuning.lora!, + maxConcurrentAdapters: 1, + supportsStacking: false, + }, + }, + }; + expect(supportsAdapterStacking(singleAdapter)).toBe(false); + }); + }); + + describe('estimateAdapterVramMB', () => { + it('should estimate reasonable VRAM for rank 8 on 8B model', () => { + const vram = estimateAdapterVramMB(candleLlama8B); + // 2 * 8 * 4096 * 2 targets * 32 layers * 2 bytes / 1MB ≈ 32 MB + expect(vram).toBeGreaterThan(0); + expect(vram).toBeLessThan(200); // Should be well under 200MB for rank 8 + }); + + it('should increase with rank', () => { + const rank8 = estimateAdapterVramMB(candleLlama8B, 8); + const rank32 = estimateAdapterVramMB(candleLlama8B, 32); + expect(rank32).toBeGreaterThan(rank8); + expect(rank32).toBe(rank8 * 4); // Linear with rank + }); + }); + + describe('fitsInVram', () => { + it('should return true when enough VRAM available', () => { + expect(fitsInVram(candleLlama8B, 16000)).toBe(true); // 16GB > 4.5GB + }); + + it('should return false when insufficient VRAM', () => { + expect(fitsInVram(candleLlama8B, 2000)).toBe(false); // 2GB < 4.5GB + }); + + it('should return false for undefined profile', () => { + expect(fitsInVram(undefined, 16000)).toBe(false); + }); + }); + + describe('enum completeness', () => { + it('should have all expected quantization formats', () => { + expect(QuantFormat.Q4_K_M).toBe('q4_k_m'); + expect(QuantFormat.FP16).toBe('fp16'); + expect(QuantFormat.GPTQ).toBe('gptq'); + expect(QuantFormat.AWQ).toBe('awq'); + }); + + it('should have all expected adapter methods', () => { + expect(AdapterMethod.LORA).toBe('lora'); + expect(AdapterMethod.QLORA).toBe('qlora'); + expect(AdapterMethod.DORA).toBe('dora'); + expect(AdapterMethod.IA3).toBe('ia3'); + }); + + it('should have all expected runtimes', () => { + expect(InferenceRuntime.CANDLE).toBe('candle'); + expect(InferenceRuntime.LLAMA_CPP).toBe('llama_cpp'); + expect(InferenceRuntime.MLX).toBe('mlx'); + expect(InferenceRuntime.OLLAMA).toBe('ollama'); + }); + + it('should have all expected accelerators', () => { + expect(Accelerator.METAL).toBe('metal'); + expect(Accelerator.CUDA).toBe('cuda'); + expect(Accelerator.CPU).toBe('cpu'); + }); + }); + + describe('ModelRegistry integration', () => { + beforeEach(() => { + ModelRegistry.sharedInstance().clear(); + }); + + it('should store and retrieve adapterProfile via ModelMetadata', () => { + const registry = ModelRegistry.sharedInstance(); + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 1400, + provider: 'candle', + discoveredAt: Date.now(), + adapterProfile: candleLlama8B, + }); + + const metadata = registry.get('meta-llama/Llama-3.1-8B-Instruct', 'candle'); + expect(metadata?.adapterProfile).toBeDefined(); + expect(metadata?.adapterProfile?.runtime).toBe(InferenceRuntime.CANDLE); + expect(supportsLoRA(metadata?.adapterProfile)).toBe(true); + expect(metadata?.adapterProfile?.hardware?.accelerator).toBe(Accelerator.METAL); + }); + + it('should filter models by fine-tunability across providers', () => { + const registry = ModelRegistry.sharedInstance(); + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 1400, + provider: 'candle', + discoveredAt: Date.now(), + adapterProfile: candleLlama8B, + }); + registry.register({ + modelId: 'meta-llama/Llama-3.1-8B-Instruct', + contextWindow: 131072, + provider: 'together', + discoveredAt: Date.now(), + adapterProfile: cloudLlama, + }); + + const all = registry.getAll('meta-llama/Llama-3.1-8B-Instruct'); + const fineTunable = all.filter(m => isFineTunable(m.adapterProfile)); + const loraCapable = all.filter(m => supportsLoRA(m.adapterProfile)); + + expect(all.length).toBe(2); + expect(fineTunable.length).toBe(1); + expect(fineTunable[0].provider).toBe('candle'); + expect(loraCapable.length).toBe(1); + }); + }); +}); diff --git a/src/debug/jtag/text b/src/debug/jtag/text new file mode 100644 index 000000000..cfccf832d --- /dev/null +++ b/src/debug/jtag/text @@ -0,0 +1,24 @@ +# AI Decision Intelligence Report + +Generated: 2026-02-16T06:06:42.967Z + +## Date Range + +- **Start**: 2023-02-16 +- **End**: 2023-02-16 + +## Summary Statistics + +- **Total Decisions**: 0 +- **Posted**: 0 (0%) +- **Silent**: 0 (0%) +- **Errors**: 0 +- **Average Confidence**: 0.00 +- **Unique Actors**: 0 + +## Actor Breakdown + +| Actor | Total | Posted | Silent | Avg Confidence | +|-------|-------|--------|--------|----------------| + +## Decisions by Actor diff --git a/src/debug/jtag/widgets/chat/adapters/TextMessageAdapter.ts b/src/debug/jtag/widgets/chat/adapters/TextMessageAdapter.ts index 7ca626f8d..05ea91013 100644 --- a/src/debug/jtag/widgets/chat/adapters/TextMessageAdapter.ts +++ b/src/debug/jtag/widgets/chat/adapters/TextMessageAdapter.ts @@ -10,6 +10,7 @@ import { AbstractMessageAdapter } from './AbstractMessageAdapter'; import type { ChatMessageEntity } from '../../../system/data/entities/ChatMessageEntity'; import type { TextContentData } from './AdapterTypes'; +import { Events } from '../../../system/core/shared/Events'; import { marked } from 'marked'; import hljs from 'highlight.js'; @@ -62,6 +63,9 @@ export class TextMessageAdapter extends AbstractMessageAdapter // Make long error code blocks collapsible htmlContent = this.makeErrorsCollapsible(htmlContent); + // Make file paths clickable + htmlContent = this.linkifyFilePaths(htmlContent); + return `
${htmlContent} @@ -174,6 +178,21 @@ export class TextMessageAdapter extends AbstractMessageAdapter text-decoration: underline; } + /* Clickable file path links */ + .file-path-link { + color: #d2a8ff; + cursor: pointer; + text-decoration: none; + border-bottom: 1px dotted rgba(210, 168, 255, 0.4); + transition: border-color 0.15s; + } + + .file-path-link:hover { + color: #e2c0ff; + border-bottom-color: rgba(210, 168, 255, 0.8); + text-decoration: none; + } + .markdown-body hr { height: 0.25em; padding: 0; @@ -510,4 +529,97 @@ export class TextMessageAdapter extends AbstractMessageAdapter .replace(/"/g, '"') .replace(/'/g, '''); } + + // ──────────────────────────────────────────────────────────── + // File path linkification + // ──────────────────────────────────────────────────────────── + + /** + * File extensions we recognize as linkifiable paths. + * Only match paths with known code/config extensions to avoid false positives. + */ + private static readonly FILE_EXTENSIONS = new Set([ + 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', + 'rs', 'py', 'go', 'java', 'c', 'cpp', 'h', 'hpp', 'swift', 'rb', 'php', + 'json', 'toml', 'yaml', 'yml', 'xml', 'csv', + 'html', 'css', 'scss', 'less', 'svg', + 'md', 'txt', 'sh', 'bash', 'zsh', + 'lock', 'env', 'gitignore', 'dockerignore', + ]); + + /** + * Regex to detect file paths in text content. + * Matches patterns like: src/foo/bar.ts, ./config.json, path/to/file.rs + * Requires at least one directory separator OR starts with ./ to distinguish from plain words. + */ + private static readonly FILE_PATH_REGEX = + /(?, ,
 content untouched.
+   */
+  private linkifyFilePaths(html: string): string {
+    // Split HTML into tags and text segments
+    const parts = html.split(/(<[^>]+>)/);
+    let insideCode = 0; // nesting depth for /
 elements
+    let insideAnchor = 0;
+
+    for (let i = 0; i < parts.length; i++) {
+      const part = parts[i];
+
+      // Track whether we're inside code/pre/a elements
+      if (part.startsWith('<')) {
+        const lower = part.toLowerCase();
+        if (lower.startsWith(' 0 || insideAnchor > 0) continue;
+
+      // Replace file paths with clickable links
+      parts[i] = part.replace(TextMessageAdapter.FILE_PATH_REGEX, (match, ext) => {
+        if (!TextMessageAdapter.FILE_EXTENSIONS.has(ext)) return match;
+        const escaped = match.replace(/&/g, '&').replace(/"/g, '"');
+        return `${match}`;
+      });
+    }
+
+    return parts.join('');
+  }
+
+  // ────────────────────────────────────────────────────────────
+  // Static action handlers (used by MessageEventDelegator)
+  // ────────────────────────────────────────────────────────────
+
+  /**
+   * Handle click on a file path link — opens the file in a viewer tab.
+   * Uses code/read to fetch content, then opens via content:opened event.
+   */
+  static handleOpenFile(target: HTMLElement): void {
+    const filePath = target.dataset.filePath;
+    if (!filePath) return;
+
+    // Shorten for display — last 2-3 segments
+    const segments = filePath.split('/');
+    const shortName = segments.length > 2
+      ? '.../' + segments.slice(-2).join('/')
+      : filePath;
+
+    // Open in LogViewerWidget tab via diagnostics-log content type
+    // Uses file: prefix — LogViewerWidget handles this alongside tool: and persona log paths
+    Events.emit('content:opened', {
+      contentType: 'diagnostics-log',
+      entityId: `file:${filePath}`,
+      uniqueId: `file:${filePath}`,
+      title: shortName,
+      setAsCurrent: true,
+      metadata: { filePath, isSourceFile: true },
+    });
+  }
 }
diff --git a/src/debug/jtag/widgets/chat/chat-widget/ChatWidget.ts b/src/debug/jtag/widgets/chat/chat-widget/ChatWidget.ts
index df0e5b31e..277a368c7 100644
--- a/src/debug/jtag/widgets/chat/chat-widget/ChatWidget.ts
+++ b/src/debug/jtag/widgets/chat/chat-widget/ChatWidget.ts
@@ -28,6 +28,7 @@ import { MessageEventDelegator } from '../adapters/MessageEventDelegator';
 import { ImageMessageAdapter } from '../adapters/ImageMessageAdapter';
 import { URLCardAdapter } from '../adapters/URLCardAdapter';
 import { ToolOutputAdapter } from '../adapters/ToolOutputAdapter';
+import { TextMessageAdapter } from '../adapters/TextMessageAdapter';
 import { MessageInputEnhancer } from '../message-input/MessageInputEnhancer';
 import { AIStatusIndicator } from './AIStatusIndicator';
 import {
@@ -984,6 +985,9 @@ export class ChatWidget extends EntityScrollerWidget {
       this.eventDelegator.onAction('tool-copy', (target) => ToolOutputAdapter.handleCopy(target));
       this.eventDelegator.onAction('tool-open-tab', (target) => ToolOutputAdapter.handleOpenInTab(target));
 
+      // Register TextMessageAdapter action handlers (clickable file paths)
+      this.eventDelegator.onAction('open-file', (target) => TextMessageAdapter.handleOpenFile(target));
+
       verbose() && console.log('✅ ChatWidget: Event delegator attached with action handlers');
     }
   }
diff --git a/src/debug/jtag/widgets/chat/shared/InfiniteScrollHelper.ts b/src/debug/jtag/widgets/chat/shared/InfiniteScrollHelper.ts
index 5a4dac050..44a2b6a0b 100644
--- a/src/debug/jtag/widgets/chat/shared/InfiniteScrollHelper.ts
+++ b/src/debug/jtag/widgets/chat/shared/InfiniteScrollHelper.ts
@@ -7,6 +7,7 @@
 
 import { ChatMessageEntity } from '../../../system/data/entities/ChatMessageEntity';
 import type { DataListParams, DataListResult } from '../../../commands/data/list/shared/DataListTypes';
+import { SYSTEM_SCOPES } from '../../../system/core/types/SystemScopes';
 
 // Verbose logging helper for browser
 const verbose = () => typeof window !== 'undefined' && (window as any).JTAG_VERBOSE === true;
@@ -228,7 +229,8 @@ export class InfiniteScrollHelper {
         direction: 'before' // Load messages older than cursor
       } : undefined,
       context: {} as any,
-      sessionId: '' as any // These will be filled by the widget
+      sessionId: '' as any, // These will be filled by the widget
+      userId: SYSTEM_SCOPES.SYSTEM
     };
   }
 
diff --git a/src/debug/jtag/widgets/live/LiveWidget.ts b/src/debug/jtag/widgets/live/LiveWidget.ts
index ad940d66b..59450eaf6 100644
--- a/src/debug/jtag/widgets/live/LiveWidget.ts
+++ b/src/debug/jtag/widgets/live/LiveWidget.ts
@@ -432,7 +432,7 @@ export class LiveWidget extends ReactiveWidget {
       console.log('LiveWidget: Joining with entityId:', this.entityId, 'userId:', userId);
       const result = await Commands.execute(COMMANDS.COLLABORATION_LIVE_JOIN, {
         entityId: this.entityId,
-        callerId: userId  // Pass current user's ID so server knows WHO is joining
+        // userId is auto-injected by Commands.execute() from jtagClient
       });
 
       if (result.success && result.callId) {
diff --git a/src/debug/jtag/widgets/log-viewer/LogViewerWidget.ts b/src/debug/jtag/widgets/log-viewer/LogViewerWidget.ts
index 1162c3f1b..591abaddd 100644
--- a/src/debug/jtag/widgets/log-viewer/LogViewerWidget.ts
+++ b/src/debug/jtag/widgets/log-viewer/LogViewerWidget.ts
@@ -107,6 +107,23 @@ export class LogViewerWidget extends BasePanelWidget {
       }
     }
 
+    // Check for source file content (opened from clickable file path in chat)
+    if (logPath.startsWith('file:')) {
+      const filePath = logPath.slice(5); // strip "file:" prefix
+      try {
+        const result = await Commands.execute('code/read', { filePath } as Record) as { content?: string; filePath?: string; totalLines?: number };
+        if (result?.content) {
+          const fileName = filePath.split('/').pop() || filePath;
+          this.renderInlineContent(result.content, fileName);
+          return;
+        }
+      } catch (err) {
+        console.warn(`📜 LogViewer: Failed to read file ${filePath}`, err);
+        this.renderInlineContent(`Error reading file: ${filePath}\n${err}`, filePath);
+        return;
+      }
+    }
+
     this.logData.logPath = logPath;
     this.logData.logName = logPath.split('/').pop() || 'log';
 
diff --git a/src/debug/jtag/widgets/settings/SettingsAssistantWidget.ts b/src/debug/jtag/widgets/settings/SettingsAssistantWidget.ts
index 364786133..2adb9bc8d 100644
--- a/src/debug/jtag/widgets/settings/SettingsAssistantWidget.ts
+++ b/src/debug/jtag/widgets/settings/SettingsAssistantWidget.ts
@@ -27,7 +27,7 @@ interface ProviderTestedEvent {
   success: boolean;
   status: string;
   message: string | null;
-  responseTime?: number;
+  responseTimeMs?: number;
   needsHelp: boolean;
 }
 
@@ -178,10 +178,10 @@ export class SettingsAssistantWidget extends ReactiveWidget {
   // === Event Handlers ===
 
   private async handleProviderTested(data: ProviderTestedEvent): Promise {
-    const { provider, success, status, message, responseTime } = data;
+    const { provider, success, status, message, responseTimeMs } = data;
 
     if (success) {
-      this.addMessage('success', `✅ ${provider} is working! Response time: ${responseTime}ms`);
+      this.addMessage('success', `✅ ${provider} is working! Response time: ${responseTimeMs}ms`);
       return;
     }
 
diff --git a/src/debug/jtag/widgets/settings/SettingsWidget.ts b/src/debug/jtag/widgets/settings/SettingsWidget.ts
index ec4dc810a..f0f2253ac 100644
--- a/src/debug/jtag/widgets/settings/SettingsWidget.ts
+++ b/src/debug/jtag/widgets/settings/SettingsWidget.ts
@@ -426,7 +426,7 @@ export class SettingsWidget extends ReactiveWidget {
       success,
       status: testResult?.status || 'unknown',
       message: message || null,
-      responseTime: testResult?.responseTime,
+      responseTimeMs: testResult?.responseTimeMs,
       needsHelp: !success
     });
 
diff --git a/src/debug/jtag/widgets/settings/components/ProviderEntry.ts b/src/debug/jtag/widgets/settings/components/ProviderEntry.ts
index 8e36c29b5..407100710 100644
--- a/src/debug/jtag/widgets/settings/components/ProviderEntry.ts
+++ b/src/debug/jtag/widgets/settings/components/ProviderEntry.ts
@@ -115,8 +115,8 @@ export class ProviderEntry {
     if (!this.testResult || this.testResult.status === 'idle') return '';
 
     const { icon, label } = STATUS_MESSAGES[this.testResult.status];
-    const responseTimeHtml = this.testResult.responseTime
-      ? `(${this.testResult.responseTime}ms)`
+    const responseTimeHtml = this.testResult.responseTimeMs
+      ? `(${this.testResult.responseTimeMs}ms)`
       : '';
 
     const actionHtml = this.renderStatusAction();
diff --git a/src/debug/jtag/widgets/settings/components/ProviderStatusTester.ts b/src/debug/jtag/widgets/settings/components/ProviderStatusTester.ts
index fb0ec89f0..b244acb9e 100644
--- a/src/debug/jtag/widgets/settings/components/ProviderStatusTester.ts
+++ b/src/debug/jtag/widgets/settings/components/ProviderStatusTester.ts
@@ -13,7 +13,7 @@ export type TestStatus = 'idle' | 'testing' | 'operational' | 'invalid' | 'out-o
 export interface ProviderTestResult {
   status: TestStatus;
   message?: string;
-  responseTime?: number;
+  responseTimeMs?: number;
 }
 
 export interface TestKeyParams {
@@ -57,12 +57,12 @@ export class ProviderStatusTester {
       const testResult: ProviderTestResult = result?.valid
         ? {
             status: 'operational',
-            responseTime: result.responseTime,
+            responseTimeMs: result.responseTimeMs,
             message: result.models?.length ? `${result.models.length} models available` : undefined
           }
         : {
             status: this.parseErrorStatus(result?.errorMessage || ''),
-            responseTime: result?.responseTime,
+            responseTimeMs: result?.responseTimeMs,
             message: result?.errorMessage
           };
 
diff --git a/src/debug/jtag/widgets/settings/components/providers-section/ProvidersSection.ts b/src/debug/jtag/widgets/settings/components/providers-section/ProvidersSection.ts
index e3a25836a..d54f716eb 100644
--- a/src/debug/jtag/widgets/settings/components/providers-section/ProvidersSection.ts
+++ b/src/debug/jtag/widgets/settings/components/providers-section/ProvidersSection.ts
@@ -183,8 +183,8 @@ export class ProvidersSection extends ReactiveWidget {
     if (!testResult || testResult.status === 'idle') return html``;
 
     const { icon, label } = STATUS_MESSAGES[testResult.status];
-    const responseTimeHtml = testResult.responseTime
-      ? html`(${testResult.responseTime}ms)`
+    const responseTimeHtml = testResult.responseTimeMs
+      ? html`(${testResult.responseTimeMs}ms)`
       : '';
 
     return html`
diff --git a/src/debug/jtag/widgets/shared/BaseWidget.ts b/src/debug/jtag/widgets/shared/BaseWidget.ts
index 1d46c59c8..86ddbf1cb 100644
--- a/src/debug/jtag/widgets/shared/BaseWidget.ts
+++ b/src/debug/jtag/widgets/shared/BaseWidget.ts
@@ -682,7 +682,7 @@ export abstract class BaseWidget extends HTMLElement {
 
   protected async executeCommand

( command: string, - params?: Omit | P + params?: Omit | P ): Promise { try { // FIXED: Use window.jtag directly like other parts of the system diff --git a/src/debug/jtag/widgets/shared/DataExecutorAdapter.ts b/src/debug/jtag/widgets/shared/DataExecutorAdapter.ts index 4ed8bb337..268b978ae 100644 --- a/src/debug/jtag/widgets/shared/DataExecutorAdapter.ts +++ b/src/debug/jtag/widgets/shared/DataExecutorAdapter.ts @@ -24,7 +24,7 @@ export function createDataExecutor( orderBy: params.orderBy ? [...params.orderBy] : [], limit: params.limit ?? 50, ...(params.cursor && { cursor: params.cursor }) - } satisfies Omit; + } satisfies Omit; const result = await executeCommand>( DATA_COMMANDS.LIST, diff --git a/src/debug/jtag/widgets/shared/ReactiveWidget.ts b/src/debug/jtag/widgets/shared/ReactiveWidget.ts index 961380aa0..c7bc97b1c 100644 --- a/src/debug/jtag/widgets/shared/ReactiveWidget.ts +++ b/src/debug/jtag/widgets/shared/ReactiveWidget.ts @@ -815,7 +815,7 @@ export abstract class ReactiveWidget extends LitElement { */ protected async executeCommand

( command: string, - params?: Omit + params?: Omit ): Promise { if (!this.config.enableCommands) { throw new Error('Commands not enabled for this widget'); @@ -849,7 +849,7 @@ export abstract class ReactiveWidget extends LitElement { */ protected async cachedCommand

( command: string, - params?: Omit, + params?: Omit, ttl?: number ): Promise { const cacheKey = `${command}:${JSON.stringify(params)}`; diff --git a/src/debug/jtag/widgets/shared/WidgetBase.ts b/src/debug/jtag/widgets/shared/WidgetBase.ts index 5e360b997..31dcc7fa7 100644 --- a/src/debug/jtag/widgets/shared/WidgetBase.ts +++ b/src/debug/jtag/widgets/shared/WidgetBase.ts @@ -10,7 +10,7 @@ import type { CommandParams, CommandResult } from '../../system/core/types/JTAGT declare global { interface Window { widgetDaemon?: { - executeCommand(command: string, params: Omit): Promise; + executeCommand(command: string, params: Omit): Promise; isConnected(): boolean; }; } @@ -57,7 +57,7 @@ export abstract class WidgetBase extends HTMLElement { /** * Execute command via WidgetDaemon */ - protected async executeCommand(command: string, params: Omit = {}): Promise { + protected async executeCommand(command: string, params: Omit = {}): Promise { const widgetDaemon = window.widgetDaemon; if (!widgetDaemon) { throw new Error('WidgetDaemon not available - ensure JTAG system is running'); diff --git a/src/debug/jtag/workers/continuum-core/bindings/RustCoreIPC.ts b/src/debug/jtag/workers/continuum-core/bindings/RustCoreIPC.ts index 5dc8d5107..41a586ea6 100644 --- a/src/debug/jtag/workers/continuum-core/bindings/RustCoreIPC.ts +++ b/src/debug/jtag/workers/continuum-core/bindings/RustCoreIPC.ts @@ -24,6 +24,17 @@ export type { EmbeddingResult, SimilarityResult, TopKResult, TopKResponse, Clust export type { SearchExecuteResult, SearchVectorResult } from './modules/search'; export type { ChannelEnqueueResult, ChannelDequeueResult, ChannelServiceCycleResult, ChannelServiceCycleFullResult } from './modules/channel'; export type { ModuleInfo, ModuleMetrics, SlowCommand } from './modules/runtime'; +export type { + SentinelHandle, + SentinelRunParams, + SentinelRunResult, + SentinelStatusResult, + SentinelListResult, + SentinelLogsListResult, + SentinelLogsReadResult, + SentinelLogsTailResult, + LogStreamInfo, +} from './modules/sentinel'; // Import base and all mixins import { RustCoreIPCClientBase, getContinuumCoreSocketPath } from './modules/base'; @@ -38,6 +49,8 @@ import { ModelsMixin } from './modules/models'; import { AIMixin } from './modules/ai'; import { EmbeddingMixin } from './modules/embedding'; import { RuntimeMixin } from './modules/runtime'; +import { SentinelMixin } from './modules/sentinel'; +import { ToolParsingMixin } from './modules/tool_parsing'; // Re-export types from shared/generated (used by consumers) export type { @@ -49,6 +62,11 @@ export type { ChannelRegistryStatus, ChannelEnqueueRequest, ServiceCycleResult, + FullEvaluateRequest, + FullEvaluateResult, + SleepMode, + ModelSelectionResult, + AdapterInfo, EditMode, ReadResult, WriteResult, @@ -65,6 +83,9 @@ export type { ShellSessionInfo, ShellWatchResponse, SentinelRule, + ToolParseResult, + ParsedToolCall, + CorrectedToolCall, } from '../../../shared/generated'; // Re-export memory types @@ -82,17 +103,21 @@ export type { RagSourceRequest, RagComposeResult } from '../../../shared/generat * Compose all mixins into the full client class. * Order matters for TypeScript type inference. */ -const ComposedClient = RuntimeMixin( - EmbeddingMixin( - AIMixin( - ModelsMixin( - RagMixin( - SearchMixin( - CodeMixin( - MemoryMixin( - ChannelMixin( - CognitionMixin( - VoiceMixin(RustCoreIPCClientBase) +const ComposedClient = ToolParsingMixin( + SentinelMixin( + RuntimeMixin( + EmbeddingMixin( + AIMixin( + ModelsMixin( + RagMixin( + SearchMixin( + CodeMixin( + MemoryMixin( + ChannelMixin( + CognitionMixin( + VoiceMixin(RustCoreIPCClientBase) + ) + ) ) ) ) diff --git a/src/debug/jtag/workers/continuum-core/bindings/modules/cognition.ts b/src/debug/jtag/workers/continuum-core/bindings/modules/cognition.ts index e895a3d58..8964095ee 100644 --- a/src/debug/jtag/workers/continuum-core/bindings/modules/cognition.ts +++ b/src/debug/jtag/workers/continuum-core/bindings/modules/cognition.ts @@ -9,6 +9,21 @@ import type { PriorityScore, PersonaState, ActivityDomain, + TextSimilarityResult, + SemanticLoopResult, + ConversationMessage, + ValidationResult, + MentionCheckResult, + CleanedResponse, + FullEvaluateRequest, + FullEvaluateResult, + SleepMode, + ModelSelectionResult, + AdapterInfo, + GenomeAdapterInfo, + GenomePagingState, + ActivateSkillResult, + AdequacyResult, } from '../../../../shared/generated'; // ============================================================================ @@ -29,6 +44,32 @@ export interface CognitionMixin { cognitionFastPathDecision(personaId: string, message: InboxMessageRequest): Promise; cognitionEnqueueMessage(personaId: string, message: InboxMessageRequest): Promise; cognitionGetState(personaId: string): Promise; + cognitionTextSimilarity(text1: string, text2: string): Promise; + cognitionCheckSemanticLoop(responseText: string, history: ConversationMessage[], maxHistory?: number): Promise; + cognitionValidateResponse( + personaId: string, + responseText: string, + hasToolCalls: boolean, + conversationHistory?: ConversationMessage[] + ): Promise; + cognitionCheckMentions( + messageText: string, + personaDisplayName: string, + personaUniqueId: string + ): Promise; + cognitionCleanResponse(responseText: string): Promise; + cognitionFullEvaluate(request: FullEvaluateRequest): Promise; + cognitionTrackResponse(personaId: string, roomId: string): Promise<{ tracked: boolean; response_count: number }>; + cognitionSetSleepMode(personaId: string, mode: SleepMode, reason?: string, durationMinutes?: number): Promise<{ set: boolean; previous_mode: string; new_mode: string; wake_at_ms: number | null }>; + cognitionConfigureRateLimiter(personaId: string, minSeconds?: number, maxResponses?: number): Promise<{ configured: boolean }>; + cognitionSelectModel(personaId: string, baseModel: string, taskDomain?: string): Promise; + cognitionSyncAdapters(personaId: string, adapters: AdapterInfo[]): Promise<{ synced: boolean; adapter_count: number }>; + cognitionGenomeActivateSkill(personaId: string, skillName: string, memoryBudgetMb?: number): Promise; + cognitionGenomeSync(personaId: string, adapters: GenomeAdapterInfo[], memoryBudgetMb?: number): Promise<{ synced: boolean; adapter_count: number; active_count: number; memory_used_mb: number; memory_pressure: number }>; + cognitionGenomeState(personaId: string): Promise; + cognitionCheckAdequacy(originalText: string, responses: Array<{ sender_name: string; text: string }>): Promise; + cognitionHasEvaluated(personaId: string, messageId: string): Promise; + cognitionMarkEvaluated(personaId: string, messageId: string): Promise; } export function CognitionMixin RustCoreIPCClientBase>(Base: T) { @@ -143,5 +184,351 @@ export function CognitionMixin RustCoreIPCClie return response.result as PersonaState & { service_cadence_ms: number }; } + + /** + * Unified text similarity — both char-bigram and word-ngram Jaccard in one call. + * Replaces 3 duplicate TS implementations. + */ + async cognitionTextSimilarity(text1: string, text2: string): Promise { + const response = await this.request({ + command: 'cognition/text-similarity', + text1, + text2, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to compute text similarity'); + } + + return response.result as TextSimilarityResult; + } + + /** + * Check if a response is semantically looping against conversation history. + * Blocks at 95% similarity, warns at 80%. + */ + async cognitionCheckSemanticLoop( + responseText: string, + history: ConversationMessage[], + maxHistory?: number + ): Promise { + const response = await this.request({ + command: 'cognition/check-semantic-loop', + response_text: responseText, + history, + max_history: maxHistory ?? 10, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to check semantic loop'); + } + + return response.result as SemanticLoopResult; + } + + /** + * Combined mention detection: is_persona_mentioned + has_directed_mention. + * ONE IPC call replaces 2 separate string checks. + */ + async cognitionCheckMentions( + messageText: string, + personaDisplayName: string, + personaUniqueId: string + ): Promise { + const response = await this.request({ + command: 'cognition/check-mentions', + message_text: messageText, + persona_display_name: personaDisplayName, + persona_unique_id: personaUniqueId, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to check mentions'); + } + + return response.result as MentionCheckResult; + } + + /** + * Clean AI response by stripping unwanted prefixes (timestamps, names, markdown). + */ + async cognitionCleanResponse(responseText: string): Promise { + const response = await this.request({ + command: 'cognition/clean-response', + response_text: responseText, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to clean response'); + } + + return response.result as CleanedResponse; + } + + /** + * Combined validation: garbage + response loop + truncated tool + semantic loop. + * ONE IPC call replaces 4 separate validation gates. + */ + async cognitionValidateResponse( + personaId: string, + responseText: string, + hasToolCalls: boolean, + conversationHistory?: ConversationMessage[] + ): Promise { + const response = await this.request({ + command: 'cognition/validate-response', + persona_id: personaId, + response_text: responseText, + has_tool_calls: hasToolCalls, + conversation_history: conversationHistory ?? [], + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to validate response'); + } + + return response.result as ValidationResult; + } + + /** + * Unified evaluation gate — ONE IPC call replaces 5 sequential TS gates. + * Gates: response_cap → mention → rate_limit → sleep_mode → directed_mention → fast_path + */ + async cognitionFullEvaluate(request: FullEvaluateRequest): Promise { + const response = await this.request({ + command: 'cognition/full-evaluate', + ...request, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to run full evaluate'); + } + + return response.result as FullEvaluateResult; + } + + /** + * Track a response for rate limiting state in Rust. + */ + async cognitionTrackResponse( + personaId: string, + roomId: string + ): Promise<{ tracked: boolean; response_count: number }> { + const response = await this.request({ + command: 'cognition/track-response', + persona_id: personaId, + room_id: roomId, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to track response'); + } + + return response.result as { tracked: boolean; response_count: number }; + } + + /** + * Set voluntary sleep mode for a persona. + */ + async cognitionSetSleepMode( + personaId: string, + mode: SleepMode, + reason?: string, + durationMinutes?: number + ): Promise<{ set: boolean; previous_mode: string; new_mode: string; wake_at_ms: number | null }> { + const response = await this.request({ + command: 'cognition/set-sleep-mode', + persona_id: personaId, + mode, + reason, + duration_minutes: durationMinutes, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to set sleep mode'); + } + + return response.result as { set: boolean; previous_mode: string; new_mode: string; wake_at_ms: number | null }; + } + + /** + * Configure rate limiter parameters for a persona. + */ + async cognitionConfigureRateLimiter( + personaId: string, + minSeconds?: number, + maxResponses?: number + ): Promise<{ configured: boolean }> { + const response = await this.request({ + command: 'cognition/configure-rate-limiter', + persona_id: personaId, + min_seconds_between_responses: minSeconds, + max_responses_per_session: maxResponses, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to configure rate limiter'); + } + + return response.result as { configured: boolean }; + } + + /** + * Select the best model for a persona using 4-tier priority chain. + * Tier 1: trait-specific adapter → 2: current → 3: any → 4: base model. + */ + async cognitionSelectModel( + personaId: string, + baseModel: string, + taskDomain?: string + ): Promise { + const response = await this.request({ + command: 'cognition/select-model', + persona_id: personaId, + base_model: baseModel, + ...(taskDomain && { task_domain: taskDomain }), + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to select model'); + } + + return response.result as ModelSelectionResult; + } + + /** + * Sync adapter registry from TypeScript genome state to Rust. + * Full replacement — sends all known adapters. + */ + async cognitionSyncAdapters( + personaId: string, + adapters: AdapterInfo[] + ): Promise<{ synced: boolean; adapter_count: number }> { + const response = await this.request({ + command: 'cognition/sync-adapters', + persona_id: personaId, + adapters, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to sync adapters'); + } + + return response.result as { synced: boolean; adapter_count: number }; + } + + /** + * Genome paging: decide what to evict/load for a skill activation. + * Rust makes the decision, TypeScript executes the GPU ops. + */ + async cognitionGenomeActivateSkill( + personaId: string, + skillName: string, + memoryBudgetMb?: number + ): Promise { + const response = await this.request({ + command: 'cognition/genome-activate-skill', + persona_id: personaId, + skill_name: skillName, + ...(memoryBudgetMb !== undefined && { memory_budget_mb: memoryBudgetMb }), + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to activate skill'); + } + + return response.result as ActivateSkillResult; + } + + /** + * Sync full genome adapter state from TypeScript to Rust. + */ + async cognitionGenomeSync( + personaId: string, + adapters: GenomeAdapterInfo[], + memoryBudgetMb?: number + ): Promise<{ synced: boolean; adapter_count: number; active_count: number; memory_used_mb: number; memory_pressure: number }> { + const response = await this.request({ + command: 'cognition/genome-sync', + persona_id: personaId, + adapters, + ...(memoryBudgetMb !== undefined && { memory_budget_mb: memoryBudgetMb }), + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to sync genome'); + } + + return response.result as { synced: boolean; adapter_count: number; active_count: number; memory_used_mb: number; memory_pressure: number }; + } + + /** + * Get current genome paging state from Rust. + */ + async cognitionGenomeState(personaId: string): Promise { + const response = await this.request({ + command: 'cognition/genome-state', + persona_id: personaId, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to get genome state'); + } + + return response.result as GenomePagingState; + } + + /** + * Batch check if other AIs already answered a question. + * ONE IPC call replaces N individual textSimilarity calls. + */ + async cognitionCheckAdequacy( + originalText: string, + responses: Array<{ sender_name: string; text: string }> + ): Promise { + const response = await this.request({ + command: 'cognition/check-adequacy', + original_text: originalText, + responses, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to check adequacy'); + } + + return response.result as AdequacyResult; + } + + /** + * Check if a message has already been evaluated (deduplication). + */ + async cognitionHasEvaluated(personaId: string, messageId: string): Promise { + const response = await this.request({ + command: 'cognition/has-evaluated', + persona_id: personaId, + message_id: messageId, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to check evaluated status'); + } + + return (response.result as { evaluated: boolean }).evaluated; + } + + /** + * Mark a message as evaluated (deduplication). + */ + async cognitionMarkEvaluated(personaId: string, messageId: string): Promise { + const response = await this.request({ + command: 'cognition/mark-evaluated', + persona_id: personaId, + message_id: messageId, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to mark message as evaluated'); + } + } }; } diff --git a/src/debug/jtag/workers/continuum-core/bindings/modules/sentinel.ts b/src/debug/jtag/workers/continuum-core/bindings/modules/sentinel.ts new file mode 100644 index 000000000..cbfdfb08a --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/bindings/modules/sentinel.ts @@ -0,0 +1,383 @@ +/** + * Sentinel Mixin - TypeScript wrapper for Rust SentinelModule + * + * Routes process execution through Rust for: + * - Process isolation (kill_on_drop prevents zombie processes) + * - Concurrent execution limits + * - Real-time log streaming to files + * - Fault tolerance (one sentinel crash doesn't cascade) + */ + +import type { RustCoreIPCClientBase } from './base'; + +/** + * Sentinel execution handle + */ +export interface SentinelHandle { + id: string; + sentinelType: string; + status: 'running' | 'completed' | 'failed' | 'cancelled'; + progress: number; + startTime: number; + endTime?: number; + exitCode?: number; + error?: string; + workingDir: string; + logsDir: string; +} + +/** + * Log stream info + */ +export interface LogStreamInfo { + name: string; + path: string; + size: number; + modifiedAt: string; +} + +/** + * Sentinel run parameters + */ +export interface SentinelRunParams { + /** Command to execute (e.g., "npm", "cargo", "xcodebuild") */ + command: string; + /** Arguments for the command */ + args?: string[]; + /** Working directory */ + workingDir?: string; + /** Environment variables */ + env?: Record; + /** Timeout in seconds (default: 600 = 10 minutes) */ + timeout?: number; + /** Sentinel type for categorization (default: "build") */ + type?: string; +} + +/** + * Sentinel run result + */ +export interface SentinelRunResult { + handle: string; + status: string; + logsDir: string; +} + +/** + * Sentinel status result + */ +export interface SentinelStatusResult { + handle: SentinelHandle; +} + +/** + * Sentinel list result + */ +export interface SentinelListResult { + handles: SentinelHandle[]; + total: number; +} + +/** + * Sentinel logs list result + */ +export interface SentinelLogsListResult { + handle: string; + logsDir: string; + streams: LogStreamInfo[]; +} + +/** + * Sentinel logs read result + */ +export interface SentinelLogsReadResult { + handle: string; + stream: string; + content: string; + lineCount: number; + totalLines: number; + offset: number; + truncated: boolean; +} + +/** + * Sentinel logs tail result + */ +export interface SentinelLogsTailResult { + handle: string; + stream: string; + content: string; + lineCount: number; + totalLines: number; +} + +/** + * Pipeline step types for multi-step execution + */ +export type PipelineStep = + | { type: 'shell'; cmd: string; args?: string[]; timeoutSecs?: number; workingDir?: string } + | { type: 'llm'; prompt: string; model?: string; provider?: string; maxTokens?: number; temperature?: number; systemPrompt?: string } + | { type: 'command'; command: string; params?: Record } + | { type: 'condition'; if: string; then: PipelineStep[]; else?: PipelineStep[] } + | { type: 'loop'; count: number; steps: PipelineStep[] }; + +/** + * Pipeline definition + */ +export interface Pipeline { + name?: string; + steps: PipelineStep[]; + workingDir?: string; + timeoutSecs?: number; + inputs?: Record; +} + +/** + * Step result from pipeline execution + */ +export interface StepResult { + stepIndex: number; + stepType: string; + success: boolean; + durationMs: number; + output?: string; + error?: string; + exitCode?: number; + data?: unknown; +} + +/** + * Pipeline execution result + */ +export interface PipelineResult { + handle: string; + success: boolean; + totalDurationMs: number; + stepsCompleted: number; + stepsTotal: number; + stepResults: StepResult[]; + error?: string; +} + +/** + * Mixin interface for type-safety + */ +export interface SentinelMixin { + sentinelRun(params: SentinelRunParams): Promise; + sentinelExecute(params: SentinelRunParams): Promise<{ success: boolean; exitCode: number; output: string; handle: string }>; + sentinelStatus(handle: string): Promise; + sentinelList(): Promise; + sentinelCancel(handle: string): Promise<{ handle: string; status: string }>; + sentinelLogsList(handle: string): Promise; + sentinelLogsRead(handle: string, stream?: string, offset?: number, limit?: number): Promise; + sentinelLogsTail(handle: string, stream?: string, lines?: number): Promise; + sentinelPipeline(pipeline: Pipeline): Promise; +} + +/** + * Mixin that adds sentinel methods to the IPC client. + */ +export function SentinelMixin RustCoreIPCClientBase>(Base: T) { + return class extends Base implements SentinelMixin { + /** + * Execute a command via Rust SentinelModule with full process isolation. + * + * Returns immediately with a handle. Use sentinelStatus() to check progress. + * Logs are written to .sentinel-workspaces/{handle}/logs/ + */ + async sentinelRun(params: SentinelRunParams): Promise { + const response = await this.request({ + command: 'sentinel/run', + type: params.type || 'build', + cmd: params.command, // 'cmd' in Rust (avoids collision with IPC 'command' field) + args: params.args || [], + workingDir: params.workingDir, + env: params.env, + timeout: params.timeout, + }); + + if (!response.success) { + throw new Error(response.error || 'sentinel/run failed'); + } + + return response.result as SentinelRunResult; + } + + /** + * Execute a command synchronously (waits for completion). + * + * This is a convenience wrapper that: + * 1. Starts the sentinel + * 2. Polls until completion + * 3. Returns the final output + */ + async sentinelExecute(params: SentinelRunParams): Promise<{ + success: boolean; + exitCode: number; + output: string; + handle: string; + }> { + const runResult = await this.sentinelRun(params); + const handle = runResult.handle; + + // Poll until completion + const pollInterval = 250; // ms + const maxPolls = (params.timeout || 600) * 1000 / pollInterval; + let polls = 0; + + while (polls < maxPolls) { + await new Promise(resolve => setTimeout(resolve, pollInterval)); + polls++; + + const status = await this.sentinelStatus(handle); + if (status.handle.status !== 'running') { + // Get the combined log output + const logs = await this.sentinelLogsTail(handle, 'combined', 10000); + return { + success: status.handle.status === 'completed' && status.handle.exitCode === 0, + exitCode: status.handle.exitCode ?? -1, + output: logs.content, + handle, + }; + } + } + + // Timeout - cancel and return failure + await this.sentinelCancel(handle); + return { + success: false, + exitCode: -1, + output: `Timeout after ${params.timeout || 600}s`, + handle, + }; + } + + /** + * Get status of a sentinel by handle + */ + async sentinelStatus(handle: string): Promise { + const response = await this.request({ + command: 'sentinel/status', + handle, + }); + + if (!response.success) { + throw new Error(response.error || 'sentinel/status failed'); + } + + return response.result as SentinelStatusResult; + } + + /** + * List all sentinel handles + */ + async sentinelList(): Promise { + const response = await this.request({ + command: 'sentinel/list', + }); + + if (!response.success) { + throw new Error(response.error || 'sentinel/list failed'); + } + + return response.result as SentinelListResult; + } + + /** + * Cancel a running sentinel + */ + async sentinelCancel(handle: string): Promise<{ handle: string; status: string }> { + const response = await this.request({ + command: 'sentinel/cancel', + handle, + }); + + if (!response.success) { + throw new Error(response.error || 'sentinel/cancel failed'); + } + + return response.result as { handle: string; status: string }; + } + + /** + * List log streams for a sentinel + */ + async sentinelLogsList(handle: string): Promise { + const response = await this.request({ + command: 'sentinel/logs/list', + handle, + }); + + if (!response.success) { + throw new Error(response.error || 'sentinel/logs/list failed'); + } + + return response.result as SentinelLogsListResult; + } + + /** + * Read log content from a sentinel + */ + async sentinelLogsRead( + handle: string, + stream: string = 'combined', + offset: number = 0, + limit: number = 1000 + ): Promise { + const response = await this.request({ + command: 'sentinel/logs/read', + handle, + stream, + offset, + limit, + }); + + if (!response.success) { + throw new Error(response.error || 'sentinel/logs/read failed'); + } + + return response.result as SentinelLogsReadResult; + } + + /** + * Tail the last N lines of a log stream + */ + async sentinelLogsTail( + handle: string, + stream: string = 'combined', + lines: number = 20 + ): Promise { + const response = await this.request({ + command: 'sentinel/logs/tail', + handle, + stream, + lines, + }); + + if (!response.success) { + throw new Error(response.error || 'sentinel/logs/tail failed'); + } + + return response.result as SentinelLogsTailResult; + } + + /** + * Execute a pipeline (multi-step with LLM, conditions, loops). + * + * Routes directly to Rust SentinelModule's pipeline executor. + * This is the primary way to execute complex, multi-step tasks. + */ + async sentinelPipeline(pipeline: Pipeline): Promise { + const response = await this.request({ + command: 'sentinel/pipeline', + pipeline, + }); + + if (!response.success) { + throw new Error(response.error || 'sentinel/pipeline failed'); + } + + return response.result as PipelineResult; + } + }; +} diff --git a/src/debug/jtag/workers/continuum-core/bindings/modules/tool_parsing.ts b/src/debug/jtag/workers/continuum-core/bindings/modules/tool_parsing.ts new file mode 100644 index 000000000..73d4f056a --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/bindings/modules/tool_parsing.ts @@ -0,0 +1,115 @@ +/** + * RustCoreIPC Tool Parsing Module - Tool call parsing, correction, codec + * + * Replaces 784 lines of TypeScript ToolFormatAdapter hierarchy with Rust IPC. + * Five format adapters (Anthropic XML, function-style, bare JSON, markdown backtick, + * old-style XML) + parameter correction + tool name codec. + */ + +import type { RustCoreIPCClientBase } from './base'; +import type { + ToolParseResult, + ParsedToolCall, + CorrectedToolCall, +} from '../../../../shared/generated'; + +export interface ToolParsingMixin { + /** + * Parse tool calls from AI response text using all 5 format adapters. + * Returns parsed+corrected tool calls and cleaned text with tool blocks stripped. + * Sub-microsecond in Rust. + */ + toolParsingParse(responseText: string): Promise; + + /** + * Correct a single tool call: name mapping + parameter mapping + content cleaning. + */ + toolParsingCorrect(toolName: string, parameters: Record): Promise; + + /** + * Register tool names for codec reverse lookup. + * Call once at startup with all known tool names. + */ + toolParsingRegisterTools(tools: string[]): Promise<{ registered: number; total: number }>; + + /** + * Decode a model-produced tool name variant back to the original. + * Handles: code_write, code__write, $FUNCTIONS.code_write, code-write, etc. + */ + toolParsingDecodeName(name: string): Promise<{ decoded: string; changed: boolean }>; + + /** + * Encode a tool name for API transmission: slashes -> underscores. + */ + toolParsingEncodeName(name: string): Promise<{ encoded: string }>; +} + +export function ToolParsingMixin RustCoreIPCClientBase>(Base: T) { + return class extends Base implements ToolParsingMixin { + async toolParsingParse(responseText: string): Promise { + const response = await this.request({ + command: 'tool-parsing/parse', + response_text: responseText, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to parse tool calls'); + } + + return response.result as ToolParseResult; + } + + async toolParsingCorrect(toolName: string, parameters: Record): Promise { + const response = await this.request({ + command: 'tool-parsing/correct', + tool_name: toolName, + parameters, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to correct tool call'); + } + + return response.result as CorrectedToolCall; + } + + async toolParsingRegisterTools(tools: string[]): Promise<{ registered: number; total: number }> { + const response = await this.request({ + command: 'tool-parsing/register-tools', + tools, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to register tools'); + } + + return response.result as { registered: number; total: number }; + } + + async toolParsingDecodeName(name: string): Promise<{ decoded: string; changed: boolean }> { + const response = await this.request({ + command: 'tool-parsing/decode-name', + name, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to decode tool name'); + } + + return response.result as { decoded: string; changed: boolean }; + } + + async toolParsingEncodeName(name: string): Promise<{ encoded: string }> { + const response = await this.request({ + command: 'tool-parsing/encode-name', + name, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to encode tool name'); + } + + return response.result as { encoded: string }; + } + }; +} diff --git a/src/debug/jtag/workers/continuum-core/src/ai/anthropic_adapter.rs b/src/debug/jtag/workers/continuum-core/src/ai/anthropic_adapter.rs index 5890584e1..c82bb2542 100644 --- a/src/debug/jtag/workers/continuum-core/src/ai/anthropic_adapter.rs +++ b/src/debug/jtag/workers/continuum-core/src/ai/anthropic_adapter.rs @@ -418,7 +418,7 @@ impl AIProviderAdapter for AnthropicAdapter { // Anthropic doesn't have a health endpoint, so we do a minimal API call let result = self.client .post("https://api.anthropic.com/v1/messages") - .header("x-api-key", self.api_key.as_ref().unwrap()) + .header("x-api-key", self.api_key.as_deref().unwrap_or_default()) .header("anthropic-version", "2023-06-01") .header("Content-Type", "application/json") .json(&json!({ diff --git a/src/debug/jtag/workers/continuum-core/src/ai/types.rs b/src/debug/jtag/workers/continuum-core/src/ai/types.rs index 84094a64e..a4189669b 100644 --- a/src/debug/jtag/workers/continuum-core/src/ai/types.rs +++ b/src/debug/jtag/workers/continuum-core/src/ai/types.rs @@ -94,18 +94,23 @@ pub struct VideoInput { /// Native tool specification for providers with JSON tool support /// (Anthropic, OpenAI, DeepSeek, etc.) +/// +/// Field names match the Anthropic API wire format (snake_case): +/// - `input_schema` NOT `inputSchema` +/// This must NOT use rename_all = "camelCase" because the wire format +/// from TypeScript AND the Anthropic API both use snake_case for this struct. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export, export_to = "../../../shared/generated/ai/NativeToolSpec.ts")] -#[serde(rename_all = "camelCase")] pub struct NativeToolSpec { pub name: String, pub description: String, pub input_schema: ToolInputSchema, } +/// JSON Schema for tool input parameters. +/// Matches Anthropic API wire format (snake_case field names). #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export, export_to = "../../../shared/generated/ai/ToolInputSchema.ts")] -#[serde(rename_all = "camelCase")] pub struct ToolInputSchema { #[serde(rename = "type")] pub schema_type: String, // Always "object" @@ -218,6 +223,7 @@ pub struct TextGenerationResponse { pub model: String, pub provider: String, pub usage: UsageMetrics, + #[ts(type = "number")] pub response_time_ms: u64, pub request_id: String, @@ -301,8 +307,10 @@ pub struct RoutingInfo { pub struct HealthStatus { pub status: HealthState, pub api_available: bool, + #[ts(type = "number")] pub response_time_ms: u64, pub error_rate: f32, + #[ts(type = "number")] pub last_checked: u64, #[serde(skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -398,6 +406,7 @@ pub struct EmbeddingResponse { pub model: String, pub provider: String, pub usage: UsageMetrics, + #[ts(type = "number")] pub response_time_ms: u64, } diff --git a/src/debug/jtag/workers/continuum-core/src/ffi/mod.rs b/src/debug/jtag/workers/continuum-core/src/ffi/mod.rs index b1f82bf6e..fd8dc6f1d 100644 --- a/src/debug/jtag/workers/continuum-core/src/ffi/mod.rs +++ b/src/debug/jtag/workers/continuum-core/src/ffi/mod.rs @@ -247,8 +247,14 @@ pub unsafe extern "C" fn continuum_voice_on_utterance( } // Serialize Vec to JSON array - let json_array = serde_json::to_string(&responder_ids).unwrap(); - let c_string = CString::new(json_array).unwrap(); + let json_array = match serde_json::to_string(&responder_ids) { + Ok(j) => j, + Err(_) => return -1, + }; + let c_string = match CString::new(json_array) { + Ok(c) => c, + Err(_) => return -1, + }; let bytes = c_string.as_bytes_with_nul(); unsafe { @@ -437,8 +443,14 @@ pub unsafe extern "C" fn continuum_get_stats(category: *const c_char) -> *mut c_ "note": "Performance stats tracking not yet implemented" }); - let json = serde_json::to_string(&stats).unwrap(); - let c_string = CString::new(json).unwrap(); + let json = match serde_json::to_string(&stats) { + Ok(j) => j, + Err(_) => return ptr::null_mut(), + }; + let c_string = match CString::new(json) { + Ok(c) => c, + Err(_) => return ptr::null_mut(), + }; c_string.into_raw() } diff --git a/src/debug/jtag/workers/continuum-core/src/inference/candle_adapter.rs b/src/debug/jtag/workers/continuum-core/src/inference/candle_adapter.rs index 0ffb39a6f..b95d1e1e1 100644 --- a/src/debug/jtag/workers/continuum-core/src/inference/candle_adapter.rs +++ b/src/debug/jtag/workers/continuum-core/src/inference/candle_adapter.rs @@ -319,7 +319,7 @@ impl AIProviderAdapter for CandleAdapter { supports_audio: false, supports_image_generation: false, is_local: true, - max_context_window: 8192, // Llama 3.2 default + max_context_window: 1400, // Candle quantized attention breaks at ~1000 input tokens } } @@ -378,35 +378,10 @@ impl AIProviderAdapter for CandleAdapter { log.info(&format!("generate_text called, use_quantized={}, self_ptr={:p}", self.use_quantized, self as *const _)); // Build prompt from messages - let mut prompt = build_prompt_from_messages(&request.messages); - - let mut max_tokens = request.max_tokens.unwrap_or(1024) as usize; - // Clamp temperature to prevent numerical instability in quantized models - // High temperatures (>0.5) can cause NaN/Inf in logits - let requested_temp = request.temperature.unwrap_or(0.7) as f64; - let temperature = requested_temp.min(0.3); - - // Limit max_tokens for context window (4096 total - 2000 input leaves 2000 for output) - // and for stability (quantized models can become unstable with very long generations) - const MAX_OUTPUT_TOKENS: usize = 1000; - if max_tokens > MAX_OUTPUT_TOKENS { - log.info(&format!("Capping max_tokens from {} to {} for stability", max_tokens, MAX_OUTPUT_TOKENS)); - max_tokens = MAX_OUTPUT_TOKENS; - } + let prompt = build_prompt_from_messages(&request.messages); - // Emergency safety truncation - RAG should already limit context via contextWindow, - // but if something goes wrong, prevent OOM/NaN by hard-limiting at 16K chars (~4K tokens) - const EMERGENCY_MAX_CHARS: usize = 16000; - if prompt.len() > EMERGENCY_MAX_CHARS { - let original_len = prompt.len(); - // Keep start (system) and end (recent messages) - let keep_start = EMERGENCY_MAX_CHARS * 30 / 100; - let keep_end = EMERGENCY_MAX_CHARS * 65 / 100; - let start = &prompt[..keep_start]; - let end = &prompt[prompt.len() - keep_end..]; - prompt = format!("{}\n\n[... emergency truncation - check RAG contextWindow ...]\n\n{}", start, end); - log.error(&format!("EMERGENCY truncation: {} -> {} chars - RAG contextWindow may be misconfigured!", original_len, prompt.len())); - } + let max_tokens = request.max_tokens.unwrap_or(1024) as usize; + let temperature = request.temperature.unwrap_or(0.7) as f64; log.info(&format!("Prompt length: {} chars, max_tokens: {}", prompt.len(), max_tokens)); @@ -431,57 +406,12 @@ impl AIProviderAdapter for CandleAdapter { "Model not loaded".to_string() })?; - // For quantized models, limit input tokens to prevent NaN - // Based on testing: NaN occurs at ~1400+ tokens, safe threshold is ~800 - // This token-based limiting is more accurate than char-based EMERGENCY_MAX_CHARS - let final_prompt = match model { - ModelVariant::Quantized(state) => { - const SAFE_INPUT_TOKENS: usize = 800; - let tokens = state.tokenizer.encode(prompt.as_str(), true) - .map_err(|e| format!("Tokenization failed: {e}"))?; - let token_count = tokens.len(); - - if token_count > SAFE_INPUT_TOKENS { - // Truncate by keeping first ~30% and last ~60% of tokens - // (system prompt at start, recent messages at end) - let keep_start = SAFE_INPUT_TOKENS * 30 / 100; // 240 tokens - let keep_end = SAFE_INPUT_TOKENS * 60 / 100; // 480 tokens - - let token_ids = tokens.get_ids(); - let mut truncated_ids: Vec = Vec::with_capacity(SAFE_INPUT_TOKENS + 10); - - // Keep first N tokens (system prompt) - truncated_ids.extend_from_slice(&token_ids[..keep_start]); - - // Add truncation marker (just use newlines, don't add extra tokens) - // The model will understand context was cut - - // Keep last N tokens (recent messages) - let end_start = token_ids.len().saturating_sub(keep_end); - truncated_ids.extend_from_slice(&token_ids[end_start..]); - - let truncated_prompt = state.tokenizer.decode(&truncated_ids, true) - .map_err(|e| format!("Decode failed: {e}"))?; - - log.warn(&format!( - "TOKEN LIMIT: {} -> {} tokens (RAG sent too much context for quantized model)", - token_count, truncated_ids.len() - )); - - truncated_prompt - } else { - prompt.clone() - } - } - ModelVariant::Regular(_) => prompt.clone(), - }; - let (output_text, completion_tokens) = match model { ModelVariant::Regular(state) => { - generate_text(state, &final_prompt, max_tokens, temperature)? + generate_text(state, &prompt, max_tokens, temperature)? } ModelVariant::Quantized(state) => { - generate_text_quantized(state, &final_prompt, max_tokens, temperature)? + generate_text_quantized(state, &prompt, max_tokens, temperature)? } }; @@ -553,7 +483,7 @@ impl AIProviderAdapter for CandleAdapter { name: "Llama 3.2 3B Instruct (Q4)".to_string(), provider: "candle".to_string(), capabilities: vec![ModelCapability::TextGeneration, ModelCapability::Chat], - context_window: 8192, + context_window: 1400, max_output_tokens: Some(4096), cost_per_1k_tokens: None, // Local is free supports_streaming: false, @@ -564,7 +494,7 @@ impl AIProviderAdapter for CandleAdapter { name: "Llama 3.2 3B Instruct".to_string(), provider: "candle".to_string(), capabilities: vec![ModelCapability::TextGeneration, ModelCapability::Chat], - context_window: 8192, + context_window: 1400, max_output_tokens: Some(4096), cost_per_1k_tokens: None, supports_streaming: false, diff --git a/src/debug/jtag/workers/continuum-core/src/inference/model.rs b/src/debug/jtag/workers/continuum-core/src/inference/model.rs index 35f8f41d4..e9cf963d5 100644 --- a/src/debug/jtag/workers/continuum-core/src/inference/model.rs +++ b/src/debug/jtag/workers/continuum-core/src/inference/model.rs @@ -112,7 +112,7 @@ pub fn generate_text( let input_tokens = if i == 0 { all_tokens.clone() } else { - vec![*all_tokens.last().unwrap()] + vec![*all_tokens.last().ok_or("Empty token sequence")?] }; let input = Tensor::new(&input_tokens[..], &state.device) diff --git a/src/debug/jtag/workers/continuum-core/src/inference/quantized.rs b/src/debug/jtag/workers/continuum-core/src/inference/quantized.rs index 3f232c5f9..d97b90406 100644 --- a/src/debug/jtag/workers/continuum-core/src/inference/quantized.rs +++ b/src/debug/jtag/workers/continuum-core/src/inference/quantized.rs @@ -203,10 +203,10 @@ pub fn generate_text_quantized( let log = runtime::logger("candle"); let start = Instant::now(); - // CRITICAL: Reload model to clear KV cache from previous generations - // ModelWeights has internal per-layer kv_cache that accumulates and corrupts output - // if not cleared between generations. See reload_model() doc comment. - state.reload_model()?; + // KV cache clears automatically when forward() receives index_pos=0 (first token). + // Candle's quantized_llama.rs LayerWeights::forward_attn (line 215): + // if index_pos == 0 { (k, v) } // Discards old cache, starts fresh + // No reload needed — saves ~2.5 seconds per generation. // DON'T add special tokens - build_prompt_from_messages already includes them // Using add_special_tokens=true would cause double BOS tokens and corrupt output @@ -251,7 +251,7 @@ pub fn generate_text_quantized( let input_tokens = if i == 0 { all_tokens.clone() } else { - vec![*all_tokens.last().unwrap()] + vec![*all_tokens.last().ok_or("Empty token sequence")?] }; let input = Tensor::new(&input_tokens[..], &state.device) @@ -492,15 +492,39 @@ mod tests { assert!(has_greeting, "Output should contain a greeting: {}", output); } - /// Test to find the NaN threshold for quantized model + /// Detect garbage output from quantized model /// - /// This test sends progressively longer prompts to identify the exact - /// token count where NaN starts occurring. Used to set safe limits. + /// When the attention mechanism breaks down at long sequences, the model + /// produces tokens from corrupted probability distributions. The output + /// looks like random multilingual text mixed with English fragments. /// - /// Known from production logs: - /// - 149 tokens: works fine - /// - 1451 tokens: NaN detected - /// - 1622 tokens: NaN abort + /// Returns (is_garbage, ascii_ratio) where ascii_ratio < 0.8 means garbage. + fn is_garbage_output(text: &str) -> (bool, f64) { + if text.is_empty() { + return (true, 0.0); + } + + let total_chars = text.chars().count(); + let ascii_chars = text.chars().filter(|c| c.is_ascii()).count(); + let ascii_ratio = ascii_chars as f64 / total_chars as f64; + + // English text from this model should be >90% ASCII. + // Garbage output has Cyrillic, CJK, Devanagari, etc. mixed in. + let is_garbage = ascii_ratio < 0.8 + || text.chars().any(|c| c == '\u{FFFD}'); + + (is_garbage, ascii_ratio) + } + + /// Test to find the exact token threshold where quantized inference breaks + /// + /// Binary-searches between known-good (800) and known-bad (1400) to find + /// the precise boundary. The model produces clean English below the threshold + /// and random multilingual garbage above it. + /// + /// Known results (Llama 3.2 3B Q8_0 on Metal): + /// - 992 tokens: clean output, 20 tokens generated + /// - 1192 tokens: garbage (Cyrillic/CJK mixed in) /// /// Run with: cargo test --release test_find_nan_threshold -- --ignored --nocapture #[test] @@ -509,31 +533,19 @@ mod tests { let mut state = load_default_quantized() .expect("Failed to load quantized model"); - println!("Finding NaN threshold for model: {}", state.model_id); + println!("Finding garbage threshold for model: {}", state.model_id); println!("============================================"); - // Test at different token counts - // We'll generate prompts of various sizes and see where NaN appears - let test_sizes: Vec = vec![ - 100, // Should work - 200, // Should work - 400, // Should work - 600, // Likely works - 800, // May start having issues - 1000, // Threshold area based on docs - 1200, // Above documented threshold - 1400, // Near observed failure point - ]; - - // Create a repeatable filler that tokenizes consistently - // "The quick brown fox jumps. " is ~7 tokens + // Phase 1: Coarse scan to confirm boundaries + let coarse_sizes: Vec = vec![100, 400, 800, 1000, 1050, 1100, 1150, 1200, 1400]; let filler = "The quick brown fox jumps over the lazy dog. "; - for target_tokens in test_sizes { - // Build prompt with approximately target_tokens - // Header is ~20 tokens, so subtract that + let mut last_good = 0usize; + let mut first_bad = usize::MAX; + + for target_tokens in &coarse_sizes { let content_tokens = target_tokens.saturating_sub(20); - let repetitions = content_tokens / 10; // ~10 tokens per filler repetition + let repetitions = content_tokens / 10; let mut content = String::new(); for _ in 0..repetitions { @@ -545,44 +557,46 @@ mod tests { content ); - // Count actual tokens - let tokens_encoded = state.tokenizer.encode(prompt.as_str(), true) - .expect("Tokenization failed"); - let actual_tokens = tokens_encoded.len(); + let actual_tokens = state.tokenizer.encode(prompt.as_str(), true) + .expect("Tokenization failed").len(); print!("Testing {} tokens (target {})... ", actual_tokens, target_tokens); - // Reload model to clear KV cache before each test state.reload_model().expect("Reload failed"); - match generate_text_quantized(&mut state, &prompt, 20, 0.3) { + match generate_text_quantized(&mut state, &prompt, 20, 0.7) { Ok((output, gen_tokens)) => { - // Check for garbage output (indicates NaN recovery produced junk) - let has_garbage = output.chars().any(|c| { - c == '\u{FFFD}' || // replacement char - (c as u32 > 0x1F000) || // emoji/symbol range often = garbage - output.contains("zeroes") || // Known garbage pattern - output.contains("valueOf") // Known garbage pattern - }); - - if has_garbage { - println!("⚠️ {} tokens generated but GARBAGE detected: {}", - gen_tokens, &output.chars().take(50).collect::()); + let (garbage, ascii_ratio) = is_garbage_output(&output); + let preview: String = output.chars().take(40).collect(); + + if garbage { + println!("GARBAGE ({:.0}% ASCII, {} tokens): {}", ascii_ratio * 100.0, gen_tokens, preview); + if actual_tokens < first_bad { + first_bad = actual_tokens; + } } else { - println!("✓ {} tokens, output: {}", - gen_tokens, &output.chars().take(30).collect::()); + println!("OK ({:.0}% ASCII, {} tokens): {}", ascii_ratio * 100.0, gen_tokens, preview); + if actual_tokens > last_good { + last_good = actual_tokens; + } } } Err(e) => { - println!("✗ FAILED: {}", e); - println!("\n==> NaN threshold appears to be around {} input tokens", actual_tokens); - break; + println!("FAILED: {}", e); + if actual_tokens < first_bad { + first_bad = actual_tokens; + } } } } println!("\n============================================"); - println!("Test complete. Use results to set safe input token limits."); + println!("Last clean: {} tokens", last_good); + println!("First garbage: {} tokens", first_bad); + if first_bad < usize::MAX && last_good > 0 { + println!("Safe threshold: {} tokens (midpoint: {})", + last_good, (last_good + first_bad) / 2); + } } /// Test that prompts at the safe threshold work reliably diff --git a/src/debug/jtag/workers/continuum-core/src/ipc/mod.rs b/src/debug/jtag/workers/continuum-core/src/ipc/mod.rs index 9df05f29e..4f00db47f 100644 --- a/src/debug/jtag/workers/continuum-core/src/ipc/mod.rs +++ b/src/debug/jtag/workers/continuum-core/src/ipc/mod.rs @@ -9,7 +9,7 @@ /// - JSON protocol (JTAGRequest/JTAGResponse) /// - Performance timing on every request /// - Modular runtime routes commands through ServiceModule trait (Phase 1+) -use crate::persona::{PersonaInbox, PersonaCognitionEngine, ChannelRegistry, PersonaState}; +use crate::persona::{ChannelRegistry, PersonaState}; use crate::rag::RagEngine; use crate::code::{FileEngine, ShellSession}; use crate::runtime::{Runtime, CommandResult}; @@ -27,6 +27,8 @@ use crate::modules::search::SearchModule; use crate::modules::embedding::EmbeddingModule; use crate::modules::agent::AgentModule; use crate::modules::ai_provider::AIProviderModule; +use crate::modules::sentinel::SentinelModule; +use crate::modules::tool_parsing::ToolParsingModule; use ts_rs::TS; use crate::{log_debug, log_info, log_error}; use serde::{Deserialize, Serialize}; @@ -114,47 +116,32 @@ impl Response { #[allow(dead_code)] struct ServerState { voice_service: Arc, - /// Per-persona inboxes — DashMap for per-key locking (no cross-persona contention). - inboxes: Arc>, - /// Per-persona cognition engines — DashMap: all hot-path ops are &self (read-only). - cognition_engines: Arc>, /// Per-persona channel registries + state — DashMap: hot-path ops are &mut self. - /// 14 personas across DashMap's shards = near-zero contention. channel_registries: Arc>, rag_engine: Arc, /// Shared CallManager for direct audio injection (speak-in-call). - /// Audio never leaves Rust — IPC only returns metadata. call_manager: Arc, /// Server-side audio buffer pool for handle-based synthesis. - /// Audio stays in Rust — TypeScript gets Handle + metadata only. audio_pool: Arc, /// Tokio runtime handle for calling async CallManager methods from IPC threads. rt_handle: tokio::runtime::Handle, /// Per-persona memory manager — pure compute on in-memory MemoryCorpus. - /// Data comes from the TS ORM via IPC. Zero SQL access. memory_manager: Arc, /// Per-persona file engines — workspace-scoped file operations with change tracking. file_engines: Arc>, /// Per-persona shell sessions — persistent bash per workspace with handle+poll. shell_sessions: Arc>, /// Modular runtime — ServiceModule-based command routing. - /// Phase 1+: routes health-check, get-stats through HealthModule. - /// Phase 2+: routes cognition/, channel/ through CognitionModule, ChannelModule. - /// Eventually replaces the entire match statement below. runtime: Arc, } impl ServerState { - /// Create with shared state (for module state sharing). - /// Phase 3+: Modules and ServerState share all per-persona and service state. #[allow(clippy::too_many_arguments)] fn new_with_shared_state( call_manager: Arc, rt_handle: tokio::runtime::Handle, memory_manager: Arc, runtime: Arc, - cognition_engines: Arc>, - inboxes: Arc>, channel_registries: Arc>, rag_engine: Arc, voice_service: Arc, @@ -164,8 +151,6 @@ impl ServerState { ) -> Self { Self { voice_service, - inboxes, - cognition_engines, channel_registries, rag_engine, call_manager, @@ -177,7 +162,6 @@ impl ServerState { runtime, } } - } // ============================================================================ @@ -595,23 +579,17 @@ pub fn start_server( // Phase 1: HealthModule (stateless) runtime.register(Arc::new(HealthModule::new())); - // Create shared DashMaps for per-persona state (shared between ServerState and modules) - let cognition_engines: Arc> = Arc::new(DashMap::new()); - let inboxes: Arc> = Arc::new(DashMap::new()); - let channel_registries: Arc> = Arc::new(DashMap::new()); + // Shared state for per-persona cognition (unified: engine + inbox + rate limiter + sleep + adapters + genome) let rag_engine = Arc::new(RagEngine::new()); - - // Phase 2: CognitionModule + ChannelModule (per-persona DashMap state) - let cognition_state = Arc::new(CognitionState::from_existing( - cognition_engines.clone(), - inboxes.clone(), - rag_engine.clone(), - )); + let cognition_state = Arc::new(CognitionState::new(rag_engine.clone())); + let personas = cognition_state.personas.clone(); runtime.register(Arc::new(CognitionModule::new(cognition_state))); + // Channel module shares the unified personas map for fast-path decisions + let channel_registries: Arc> = Arc::new(DashMap::new()); let channel_state = Arc::new(ChannelState::from_existing( channel_registries.clone(), - cognition_engines.clone(), + personas, )); runtime.register(Arc::new(ChannelModule::new(channel_state))); @@ -679,6 +657,18 @@ pub fn start_server( // Routes to DeepSeek, Anthropic, OpenAI, Together, Groq, Fireworks, XAI, Google runtime.register(Arc::new(AIProviderModule::new())); + // SentinelModule: Concurrent, fault-tolerant build/task execution + // Provides sentinel/execute, sentinel/status, sentinel/cancel, sentinel/list + // And sentinel/logs/list, sentinel/logs/read, sentinel/logs/tail + // Process isolation via child processes - safe for Xcode, cargo, etc. + runtime.register(Arc::new(SentinelModule::new())); + + // ToolParsingModule: Stateless tool call parsing + correction + // Provides tool-parsing/parse, tool-parsing/correct, tool-parsing/register-tools, + // tool-parsing/decode-name, tool-parsing/encode-name + // Replaces 784 lines of TypeScript ToolFormatAdapter hierarchy + runtime.register(Arc::new(ToolParsingModule::new())); + // Initialize modules (runs async init in sync context) rt_handle.block_on(async { if let Err(e) = runtime.initialize().await { @@ -686,6 +676,13 @@ pub fn start_server( } }); + // Start periodic tick loops for modules that declare a tick_interval. + // Replaces TypeScript's per-persona setIntervals (task polling, self-task gen, training checks). + // Tick loops run as tokio tasks — they're lightweight and don't block the IPC thread. + let _tick_handles = rt_handle.block_on(async { + runtime.start_tick_loops() + }); + // Verify all expected modules are registered (fails server if any missing) if let Err(e) = runtime.verify_registration() { log_error!("ipc", "server", "{}", e); @@ -696,14 +693,17 @@ pub fn start_server( runtime.registry().list_modules().len(), runtime.registry().list_modules()); + // Initialize global CommandExecutor for all spawned processes (sentinels, agents, etc.) + // This allows ANY async task to execute ANY command (Rust or TypeScript) + // TypeScript commands route via Unix socket to /tmp/jtag-command-router.sock + crate::runtime::init_executor(runtime.registry_arc()); + let listener = UnixListener::bind(socket_path)?; let state = Arc::new(ServerState::new_with_shared_state( call_manager, rt_handle, memory_manager, runtime, - cognition_engines, - inboxes, channel_registries, rag_engine, voice_service, diff --git a/src/debug/jtag/workers/continuum-core/src/lib.rs b/src/debug/jtag/workers/continuum-core/src/lib.rs index 7a54988e5..4f31ecf27 100644 --- a/src/debug/jtag/workers/continuum-core/src/lib.rs +++ b/src/debug/jtag/workers/continuum-core/src/lib.rs @@ -28,6 +28,7 @@ pub mod modules; pub mod orm; pub mod secrets; pub mod inference; +pub mod tool_parsing; pub use audio_constants::*; diff --git a/src/debug/jtag/workers/continuum-core/src/modules/agent.rs b/src/debug/jtag/workers/continuum-core/src/modules/agent.rs index 5267788b2..aec6625b4 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/agent.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/agent.rs @@ -22,6 +22,7 @@ use crate::runtime::{ServiceModule, ModuleConfig, ModulePriority, CommandResult, ModuleContext, MessageBus}; use crate::logging::TimingGuard; +use crate::utils::params::Params; use crate::log_info; use async_trait::async_trait; use dashmap::DashMap; @@ -590,14 +591,17 @@ async fn call_llm(conversation: &[Value], model: &str, _working_dir: &Path) -> R Ok(response.text) } +/// Static regexes for tool call parsing (compiled once) +static TOOL_BLOCK_RE: std::sync::LazyLock = + std::sync::LazyLock::new(|| regex::Regex::new(r"```tool\s*\n?([\s\S]*?)```").unwrap()); +static INLINE_TOOL_RE: std::sync::LazyLock = + std::sync::LazyLock::new(|| regex::Regex::new(r#"\{"name":\s*"(\w+)",\s*"arguments":\s*(\{[^}]+\})\}"#).unwrap()); + /// Parse tool calls from LLM response fn parse_tool_calls(response: &str) -> Vec { let mut calls = Vec::new(); - // Look for ```tool ... ``` blocks - let re = regex::Regex::new(r"```tool\s*\n?([\s\S]*?)```").unwrap(); - - for cap in re.captures_iter(response) { + for cap in TOOL_BLOCK_RE.captures_iter(response) { if let Some(json_str) = cap.get(1) { if let Ok(parsed) = serde_json::from_str::(json_str.as_str().trim()) { calls.push(parsed); @@ -605,9 +609,7 @@ fn parse_tool_calls(response: &str) -> Vec { } } - // Also try inline JSON tool calls - let inline_re = regex::Regex::new(r#"\{"name":\s*"(\w+)",\s*"arguments":\s*(\{[^}]+\})\}"#).unwrap(); - for cap in inline_re.captures_iter(response) { + for cap in INLINE_TOOL_RE.captures_iter(response) { if let (Some(name), Some(args)) = (cap.get(1), cap.get(2)) { if let Ok(arguments) = serde_json::from_str::(args.as_str()) { let call = ToolCall { @@ -1080,6 +1082,7 @@ impl ServiceModule for AgentModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: None, } } @@ -1097,20 +1100,12 @@ impl ServiceModule for AgentModule { match command { "agent/start" => { let _timer = TimingGuard::new("module", "agent_start"); - - let task = params.get("task") - .and_then(|v| v.as_str()) - .ok_or("Missing task")?; - let working_dir = params.get("working_dir") - .and_then(|v| v.as_str()) - .ok_or("Missing working_dir")?; - let max_iterations = params.get("max_iterations") - .and_then(|v| v.as_u64()) - .unwrap_or(50) as u32; - // Model is required - no hardcoded defaults - let model = params.get("model") - .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: model. Use 'deepseek-chat', 'claude-sonnet-4-5-20250929', 'gpt-4', etc.")? + let p = Params::new(¶ms); + let task = p.str("task")?; + let working_dir = p.str("working_dir")?; + let max_iterations = p.u64_or("max_iterations", 50) as u32; + let model = p.str("model") + .map_err(|_| "Missing required parameter: model. Use 'deepseek-chat', 'claude-sonnet-4-5-20250929', 'gpt-4', etc.".to_string())? .to_string(); // Generate handle @@ -1136,10 +1131,8 @@ impl ServiceModule for AgentModule { "agent/status" => { let _timer = TimingGuard::new("module", "agent_status"); - - let handle = params.get("handle") - .and_then(|v| v.as_str()) - .ok_or("Missing handle")?; + let p = Params::new(¶ms); + let handle = p.str("handle")?; if let Some(entry) = self.agents.get(handle) { if let Ok(state) = entry.lock() { @@ -1155,10 +1148,8 @@ impl ServiceModule for AgentModule { "agent/stop" => { let _timer = TimingGuard::new("module", "agent_stop"); - - let handle = params.get("handle") - .and_then(|v| v.as_str()) - .ok_or("Missing handle")?; + let p = Params::new(¶ms); + let handle = p.str("handle")?; if let Some(entry) = self.agents.get(handle) { if let Ok(mut state) = entry.lock() { @@ -1196,13 +1187,9 @@ impl ServiceModule for AgentModule { "agent/wait" => { let _timer = TimingGuard::new("module", "agent_wait"); - - let handle = params.get("handle") - .and_then(|v| v.as_str()) - .ok_or("Missing handle")?; - let timeout_ms = params.get("timeout_ms") - .and_then(|v| v.as_u64()) - .unwrap_or(300000); // 5 min default + let p = Params::new(¶ms); + let handle = p.str("handle")?; + let timeout_ms = p.u64_or("timeout_ms", 300000); // Get completion notify let notify = { diff --git a/src/debug/jtag/workers/continuum-core/src/modules/ai_provider.rs b/src/debug/jtag/workers/continuum-core/src/modules/ai_provider.rs index 40e646465..7b8649e68 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/ai_provider.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/ai_provider.rs @@ -21,10 +21,10 @@ use crate::ai::{ AdapterRegistry, AnthropicAdapter, CandleAdapter, OpenAICompatibleAdapter, TextGenerationRequest, TextGenerationResponse, RoutingInfo, ChatMessage, MessageContent, - NativeToolSpec, ToolChoice, }; use crate::runtime::{CommandResult, ModuleConfig, ModuleContext, ModulePriority, ServiceModule, ModuleLogger}; use crate::logging::TimingGuard; +use crate::utils::params::Params; use crate::secrets::get_secret; use async_trait::async_trait; use once_cell::sync::Lazy; @@ -151,12 +151,13 @@ impl AIProviderModule { /// Parse TextGenerationRequest from JSON params fn parse_request(&self, params: &Value) -> Result { - // Parse messages - let messages: Vec = if let Some(msgs) = params.get("messages") { + let p = Params::new(params); + + // Parse messages (array) or simple prompt (string) + let messages: Vec = if let Some(msgs) = p.value("messages") { serde_json::from_value(msgs.clone()) .map_err(|e| format!("Failed to parse messages: {}", e))? - } else if let Some(prompt) = params.get("prompt").and_then(|p| p.as_str()) { - // Support simple text prompt + } else if let Some(prompt) = p.str_opt("prompt") { vec![ChatMessage { role: "user".to_string(), content: MessageContent::Text(prompt.to_string()), @@ -170,53 +171,23 @@ impl AIProviderModule { return Err("Messages cannot be empty".to_string()); } - // Parse tools if provided - let tools: Option> = params.get("tools") - .and_then(|t| serde_json::from_value(t.clone()).ok()); - - // Parse tool_choice if provided - let tool_choice: Option = params.get("tool_choice") - .and_then(|tc| serde_json::from_value(tc.clone()).ok()); - Ok(TextGenerationRequest { messages, - system_prompt: params.get("system_prompt") - .or_else(|| params.get("systemPrompt")) - .and_then(|s| s.as_str()) - .map(|s| s.to_string()), - model: params.get("model").and_then(|m| m.as_str()).map(|s| s.to_string()), - provider: params.get("provider").and_then(|p| p.as_str()).map(|s| s.to_string()), - temperature: params.get("temperature").and_then(|t| t.as_f64()).map(|t| t as f32), - max_tokens: params.get("max_tokens") - .or_else(|| params.get("maxTokens")) - .and_then(|t| t.as_u64()) - .map(|t| t as u32), - top_p: params.get("top_p") - .or_else(|| params.get("topP")) - .and_then(|t| t.as_f64()) - .map(|t| t as f32), - top_k: params.get("top_k") - .or_else(|| params.get("topK")) - .and_then(|t| t.as_u64()) - .map(|t| t as u32), - stop_sequences: params.get("stop_sequences") - .or_else(|| params.get("stopSequences")) - .and_then(|s| serde_json::from_value(s.clone()).ok()), - tools, - tool_choice, - request_id: params.get("request_id") - .or_else(|| params.get("requestId")) - .and_then(|r| r.as_str()) - .map(|s| s.to_string()), - user_id: params.get("user_id") - .or_else(|| params.get("userId")) - .and_then(|u| u.as_str()) - .map(|s| s.to_string()), - room_id: params.get("room_id") - .or_else(|| params.get("roomId")) - .and_then(|r| r.as_str()) - .map(|s| s.to_string()), - purpose: params.get("purpose").and_then(|p| p.as_str()).map(|s| s.to_string()), + system_prompt: p.string_opt_alias("system_prompt", "systemPrompt"), + model: p.str_opt("model").map(String::from), + provider: p.str_opt("provider").map(String::from), + temperature: p.f32_opt("temperature"), + max_tokens: p.u64_opt_alias("max_tokens", "maxTokens").map(|t| t as u32), + top_p: p.f64_opt_alias("top_p", "topP").map(|t| t as f32), + top_k: p.u64_opt_alias("top_k", "topK").map(|t| t as u32), + stop_sequences: p.json_opt("stop_sequences") + .or_else(|| p.json_opt("stopSequences")), + tools: p.json_opt("tools"), + tool_choice: p.json_opt("tool_choice"), + request_id: p.string_opt_alias("request_id", "requestId"), + user_id: p.string_opt_alias("user_id", "userId"), + room_id: p.string_opt_alias("room_id", "roomId"), + purpose: p.str_opt("purpose").map(String::from), }) } @@ -273,6 +244,7 @@ impl ServiceModule for AIProviderModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 10, // Allow parallel inference requests + tick_interval: None, } } @@ -316,19 +288,17 @@ impl ServiceModule for AIProviderModule { let mut response = adapter.generate_text(request).await?; // Add routing info + let prior_routing = response.routing.take(); response.routing = Some(RoutingInfo { provider: provider_id.to_string(), is_local: adapter.capabilities().is_local, - routing_reason: if response.routing.is_some() { - response.routing.as_ref().unwrap().routing_reason.clone() - } else { - "adapter_selected".to_string() - }, + routing_reason: prior_routing.as_ref() + .map(|r| r.routing_reason.clone()) + .unwrap_or_else(|| "adapter_selected".to_string()), adapters_applied: vec![], model_mapped: None, - model_requested: response.routing - .as_ref() - .and_then(|r| r.model_requested.clone()), + model_requested: prior_routing + .and_then(|r| r.model_requested), }); Ok(CommandResult::Json(self.response_to_json(&response))) @@ -464,7 +434,15 @@ impl ServiceModule for AIProviderModule { }))) } - _ => Err(format!("Unknown ai command: {}", command)), + _ => { + // Forward unknown ai/* commands directly to TypeScript via Unix socket. + // MUST use execute_ts (not execute) to bypass Rust registry — otherwise + // the registry matches "ai/" prefix back to this module → infinite recursion. + use crate::runtime::command_executor; + let log = crate::runtime::logger("ai_provider"); + log.info(&format!("Forwarding '{}' to TypeScript via Unix socket (bypassing registry)", command)); + command_executor::execute_ts(command, params).await + } } } diff --git a/src/debug/jtag/workers/continuum-core/src/modules/channel.rs b/src/debug/jtag/workers/continuum-core/src/modules/channel.rs index 18c06fad2..65314f1e4 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/channel.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/channel.rs @@ -10,40 +10,94 @@ use crate::runtime::{ServiceModule, ModuleConfig, ModulePriority, CommandResult, ModuleContext}; use crate::persona::{ ChannelRegistry, PersonaState, ChannelEnqueueRequest, ActivityDomain, - PersonaCognitionEngine, InboxMessage, SenderType, Modality, + PersonaCognition, InboxMessage, SenderType, Modality, }; use crate::persona::channel_types::DOMAIN_PRIORITY_ORDER; +use crate::persona::channel_items::TaskQueueItem; +use crate::persona::self_task_generator::SelfTaskGenerator; use crate::logging::TimingGuard; +use crate::utils::params::Params; use crate::log_info; use async_trait::async_trait; use dashmap::DashMap; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::any::Any; use std::sync::Arc; +use std::time::Duration; +use ts_rs::TS; use uuid::Uuid; +/// Configuration for the channel tick loop — exposed to TypeScript via ts-rs. +/// +/// Controls how often the background tick fires and which responsibilities are enabled. +/// Adjustable at runtime via `channel/tick-config` command, allowing TypeScript to +/// tune scheduling for different scenarios (gaming = fast tick, idle = slow tick). +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/runtime/ChannelTickConfig.ts")] +pub struct ChannelTickConfig { + /// Tick interval in milliseconds (default: 60000 = 60s). + /// Lower values = more responsive task polling, higher CPU. + /// Gaming: 1000-5000ms. Background: 60000-120000ms. + #[ts(type = "number")] + pub tick_interval_ms: u64, + /// Whether to poll pending tasks from the database each tick. + pub task_poll_enabled: bool, + /// Whether to generate self-tasks (memory consolidation, skill audit, etc). + pub self_task_enabled: bool, + /// Whether to check training data readiness each tick. + pub training_check_enabled: bool, + /// Training data threshold before triggering genome/job-create (default: 50). + #[ts(type = "number")] + pub training_threshold: u64, +} + +impl Default for ChannelTickConfig { + fn default() -> Self { + Self { + tick_interval_ms: 60_000, + task_poll_enabled: true, + self_task_enabled: true, + training_check_enabled: true, + training_threshold: 50, + } + } +} + /// Shared state for channel module — per-persona registries and states. pub struct ChannelState { /// Per-persona channel registries + states. pub registries: Arc>, - /// Reference to cognition engines for service-cycle-full fast-path decision. - pub cognition_engines: Arc>, + /// Unified per-persona cognition (shared with CognitionModule). + /// Used for fast-path decision in service-cycle-full. + pub personas: Arc>, + /// Per-persona self-task generators (lazily created on first tick). + pub self_task_generators: DashMap>, + /// Tick configuration — adjustable at runtime via channel/tick-config command. + pub tick_config: std::sync::RwLock, } impl ChannelState { - pub fn new(cognition_engines: Arc>) -> Self { + pub fn new(personas: Arc>) -> Self { Self { registries: Arc::new(DashMap::new()), - cognition_engines, + personas, + self_task_generators: DashMap::new(), + tick_config: std::sync::RwLock::new(ChannelTickConfig::default()), } } /// Create from existing DashMaps (for gradual migration from ServerState). pub fn from_existing( registries: Arc>, - cognition_engines: Arc>, + personas: Arc>, ) -> Self { - Self { registries, cognition_engines } + Self { + registries, + personas, + self_task_generators: DashMap::new(), + tick_config: std::sync::RwLock::new(ChannelTickConfig::default()), + } } } @@ -60,6 +114,9 @@ impl ChannelModule { #[async_trait] impl ServiceModule for ChannelModule { fn config(&self) -> ModuleConfig { + let tick_ms = self.state.tick_config.read() + .map(|c| c.tick_interval_ms) + .unwrap_or(60_000); ModuleConfig { name: "channel", priority: ModulePriority::High, @@ -67,6 +124,7 @@ impl ServiceModule for ChannelModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: Some(Duration::from_millis(tick_ms)), } } @@ -79,18 +137,13 @@ impl ServiceModule for ChannelModule { command: &str, params: Value, ) -> Result { + let p = Params::new(¶ms); + match command { "channel/enqueue" => { let _timer = TimingGuard::new("module", "channel_enqueue"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let item = params.get("item") - .ok_or("Missing item")?; - - let persona_uuid = Uuid::parse_str(persona_id) - .map_err(|e| format!("Invalid persona_id: {e}"))?; + let persona_uuid = p.uuid("persona_id")?; + let item = p.value("item").ok_or("Missing item")?; // Parse the item as ChannelEnqueueRequest let enqueue_request: ChannelEnqueueRequest = serde_json::from_value(item.clone()) @@ -117,19 +170,12 @@ impl ServiceModule for ChannelModule { "channel/dequeue" => { let _timer = TimingGuard::new("module", "channel_dequeue"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let domain_str = params.get("domain") - .and_then(|v| v.as_str()); - - let persona_uuid = Uuid::parse_str(persona_id) - .map_err(|e| format!("Invalid persona_id: {e}"))?; + let persona_uuid = p.uuid("persona_id")?; + let domain_str = p.str_opt("domain"); let mut entry = match self.state.registries.get_mut(&persona_uuid) { Some(r) => r, - None => return Err(format!("No channel registry for {persona_id}")), + None => return Err(format!("No channel registry for {persona_uuid}")), }; let (registry, _state) = entry.value_mut(); @@ -179,13 +225,7 @@ impl ServiceModule for ChannelModule { "channel/status" => { let _timer = TimingGuard::new("module", "channel_status"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - - let persona_uuid = Uuid::parse_str(persona_id) - .map_err(|e| format!("Invalid persona_id: {e}"))?; + let persona_uuid = p.uuid("persona_id")?; let entry = match self.state.registries.get(&persona_uuid) { Some(r) => r, @@ -207,13 +247,7 @@ impl ServiceModule for ChannelModule { "channel/service-cycle" => { let _timer = TimingGuard::new("module", "channel_service_cycle"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - - let persona_uuid = Uuid::parse_str(persona_id) - .map_err(|e| format!("Invalid persona_id: {e}"))?; + let persona_uuid = p.uuid("persona_id")?; let mut entry = self.state.registries .entry(persona_uuid) @@ -226,13 +260,7 @@ impl ServiceModule for ChannelModule { "channel/service-cycle-full" => { let _timer = TimingGuard::new("module", "channel_service_cycle_full"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - - let persona_uuid = Uuid::parse_str(persona_id) - .map_err(|e| format!("Invalid persona_id: {e}"))?; + let persona_uuid = p.uuid("persona_id")?; // Step 1: Service cycle — consolidate, schedule, return next item let service_result = { @@ -246,64 +274,30 @@ impl ServiceModule for ChannelModule { // Step 2: If item returned, run fast_path_decision in the SAME call let decision = if service_result.should_process { if let Some(ref item_json) = service_result.item { - // Reconstruct InboxMessage from queue item JSON - let id = item_json.get("id") - .and_then(|v| v.as_str()) - .and_then(|s| Uuid::parse_str(s).ok()) - .unwrap_or_default(); - let sender_id = item_json.get("senderId") - .and_then(|v| v.as_str()) - .and_then(|s| Uuid::parse_str(s).ok()) - .unwrap_or_default(); - let room_id = item_json.get("roomId") - .and_then(|v| v.as_str()) - .and_then(|s| Uuid::parse_str(s).ok()) - .unwrap_or_default(); - let sender_name = item_json.get("senderName") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown") - .to_string(); - let sender_type_str = item_json.get("senderType") - .and_then(|v| v.as_str()) - .unwrap_or("human"); - let content = item_json.get("content") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let timestamp = item_json.get("timestamp") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - let priority = item_json.get("priority") - .and_then(|v| v.as_f64()) - .map(|p| p as f32) - .unwrap_or(0.5); - + // Reconstruct InboxMessage from queue item JSON using Params + let ip = Params::new(item_json); let inbox_msg = InboxMessage { - id, - room_id, - sender_id, - sender_name, - sender_type: match sender_type_str { + id: ip.uuid_opt("id").unwrap_or_default(), + room_id: ip.uuid_opt("roomId").unwrap_or_default(), + sender_id: ip.uuid_opt("senderId").unwrap_or_default(), + sender_name: ip.str_or("senderName", "Unknown").to_string(), + sender_type: match ip.str_or("senderType", "human") { "persona" => SenderType::Persona, "agent" => SenderType::Agent, "system" => SenderType::System, _ => SenderType::Human, }, - content, - timestamp, - priority, - source_modality: item_json.get("itemType") - .and_then(|v| v.as_str()) - .map(|t| if t == "voice" { Some(Modality::Voice) } else { None }) - .flatten(), - voice_session_id: item_json.get("voiceSessionId") - .and_then(|v| v.as_str()) - .and_then(|s| Uuid::parse_str(s).ok()), + content: ip.str_or("content", "").to_string(), + timestamp: ip.u64_or("timestamp", 0), + priority: ip.f32_or("priority", 0.5), + source_modality: ip.str_opt("itemType") + .and_then(|t| if t == "voice" { Some(Modality::Voice) } else { None }), + voice_session_id: ip.uuid_opt("voiceSessionId"), }; // Get cognition engine for fast-path decision - if let Some(engine) = self.state.cognition_engines.get(&persona_uuid) { - let decision = engine.fast_path_decision(&inbox_msg); + if let Some(persona) = self.state.personas.get(&persona_uuid) { + let decision = persona.engine.fast_path_decision(&inbox_msg); Some(serde_json::json!({ "should_respond": decision.should_respond, "confidence": decision.confidence, @@ -334,26 +328,265 @@ impl ServiceModule for ChannelModule { "channel/clear" => { let _timer = TimingGuard::new("module", "channel_clear"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - - let persona_uuid = Uuid::parse_str(persona_id) - .map_err(|e| format!("Invalid persona_id: {e}"))?; + let persona_uuid = p.uuid("persona_id")?; if let Some(mut entry) = self.state.registries.get_mut(&persona_uuid) { let (registry, _state) = entry.value_mut(); registry.clear_all(); } - log_info!("module", "channel", "Cleared channels for {}", persona_id); + log_info!("module", "channel", "Cleared channels for {}", persona_uuid); Ok(CommandResult::Json(serde_json::json!({ "cleared": true }))) } + "channel/tick-config" => { + let _timer = TimingGuard::new("module", "channel_tick_config"); + + // If params include config fields, update the tick config + let has_updates = params.get("tick_interval_ms").is_some() + || params.get("task_poll_enabled").is_some() + || params.get("self_task_enabled").is_some() + || params.get("training_check_enabled").is_some() + || params.get("training_threshold").is_some(); + + if has_updates { + if let Ok(mut config) = self.state.tick_config.write() { + if let Some(v) = params.get("tick_interval_ms").and_then(|v| v.as_u64()) { + config.tick_interval_ms = v.max(100); // Floor: 100ms + } + if let Some(v) = params.get("task_poll_enabled").and_then(|v| v.as_bool()) { + config.task_poll_enabled = v; + } + if let Some(v) = params.get("self_task_enabled").and_then(|v| v.as_bool()) { + config.self_task_enabled = v; + } + if let Some(v) = params.get("training_check_enabled").and_then(|v| v.as_bool()) { + config.training_check_enabled = v; + } + if let Some(v) = params.get("training_threshold").and_then(|v| v.as_u64()) { + config.training_threshold = v; + } + log_info!("module", "channel", "Tick config updated: {:?}", *config); + } + } + + // Return current config + let config = self.state.tick_config.read() + .map(|c| c.clone()) + .unwrap_or_default(); + Ok(CommandResult::Json(serde_json::to_value(&config) + .unwrap_or_else(|_| serde_json::json!({})))) + } + _ => Err(format!("Unknown channel command: {command}")), } } + /// Periodic tick: runs ALL background work for ALL personas in one batch. + /// Replaces 30+ TypeScript setIntervals (10 personas × 3 timers each) with ONE Rust tick. + /// + /// Work performed per tick: + /// 1. Poll pending tasks from DB → enqueue into channel registries + /// 2. Self-task generation (memory consolidation, skill audit, resume work, learning) + /// 3. Training readiness checks (threshold → trigger genome/job-create via TS) + /// + /// Cadence controlled by ChannelTickConfig (adjustable via channel/tick-config). + async fn tick(&self) -> Result<(), String> { + let log = crate::runtime::logger("channel-tick"); + + // Read config snapshot (cheap: std::sync::RwLock read, no contention) + let config = self.state.tick_config.read() + .map(|c| c.clone()) + .unwrap_or_default(); + + // Resolve db_path once per tick (HOME-relative, same as TypeScript ServerConfig) + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + let db_path = format!("{home}/.continuum/data/database.sqlite"); + + // Collect persona IDs to avoid holding DashMap ref across await + let persona_ids: Vec = self.state.registries.iter() + .map(|entry| *entry.key()) + .collect(); + + if persona_ids.is_empty() { + return Ok(()); + } + + let executor = crate::runtime::command_executor::executor(); + let mut total_enqueued = 0u32; + let mut total_self_tasks = 0u32; + + for persona_id in &persona_ids { + // ── 1. Poll pending tasks ────────────────────────────────────── + if config.task_poll_enabled { + let query_result = executor.execute_json("data/query", serde_json::json!({ + "dbPath": db_path, + "collection": "tasks", + "filter": { + "assigneeId": { "$eq": persona_id.to_string() }, + "status": { "$eq": "pending" } + }, + "limit": 10 + })).await; + + if let Ok(result_json) = query_result { + if let Some(records) = result_json.get("data").and_then(|d| d.as_array()) { + for record in records { + if let Some(item) = Self::record_to_task_queue_item(record, persona_id) { + if let Some(mut entry) = self.state.registries.get_mut(persona_id) { + let (registry, _state) = entry.value_mut(); + if registry.route(Box::new(item)).is_ok() { + total_enqueued += 1; + } + } + } + } + } + } + } + + // ── 2. Self-task generation ──────────────────────────────────── + if config.self_task_enabled { + // Ensure generator exists (lazy init) + if !self.state.self_task_generators.contains_key(persona_id) { + self.state.self_task_generators.insert( + *persona_id, + tokio::sync::Mutex::new(SelfTaskGenerator::new(*persona_id)), + ); + } + + if let Some(gen_entry) = self.state.self_task_generators.get(persona_id) { + let mut gen = gen_entry.lock().await; + match gen.generate_and_persist(&db_path, &executor).await { + Ok(tasks) => { + let count = tasks.len() as u32; + if count > 0 { + for task_json in &tasks { + if let Some(item) = Self::json_to_task_queue_item(task_json, persona_id) { + if let Some(mut entry) = self.state.registries.get_mut(persona_id) { + let (registry, _state) = entry.value_mut(); + let _ = registry.route(Box::new(item)); + } + } + } + total_self_tasks += count; + } + } + Err(e) => log.warn(&format!("Self-task gen failed for {}: {}", persona_id, e)), + } + } + } + + // ── 3. Training readiness check ──────────────────────────────── + if config.training_check_enabled { + let training_result = executor.execute_json("data/count", serde_json::json!({ + "dbPath": db_path, + "collection": "training_data", + "filter": { + "personaId": { "$eq": persona_id.to_string() }, + "consumed": { "$eq": false } + } + })).await; + + if let Ok(count_json) = training_result { + let count = count_json.get("data") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + if count >= config.training_threshold { + log.info(&format!("Training threshold met for {} ({} examples), triggering genome/job-create", persona_id, count)); + let _ = crate::runtime::command_executor::execute_ts_json( + "genome/job-create", + serde_json::json!({ + "personaId": persona_id.to_string(), + "trainingExamples": count, + }), + ).await; + } + } + } + } + + if total_enqueued > 0 || total_self_tasks > 0 { + log.info(&format!( + "Tick: {} personas, polled {} tasks, generated {} self-tasks", + persona_ids.len(), total_enqueued, total_self_tasks + )); + } + + Ok(()) + } + fn as_any(&self) -> &dyn Any { self } } + +impl ChannelModule { + /// Convert a DB record (from data/query result) to a TaskQueueItem. + fn record_to_task_queue_item(record: &Value, persona_id: &Uuid) -> Option { + let record_id = record.get("id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()); + let data = record.get("data")?; + Self::data_to_task_queue_item(data, record_id, persona_id) + } + + /// Convert a self-task JSON (from SelfTaskGenerator) to a TaskQueueItem. + fn json_to_task_queue_item(task_json: &Value, persona_id: &Uuid) -> Option { + let task_id = task_json.get("id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()); + Self::data_to_task_queue_item(task_json, task_id, persona_id) + } + + /// Shared conversion logic: task data JSON → TaskQueueItem. + fn data_to_task_queue_item( + data: &Value, + task_id: Option, + persona_id: &Uuid, + ) -> Option { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + Some(TaskQueueItem { + id: Uuid::new_v4(), + task_id: task_id.unwrap_or_else(Uuid::new_v4), + assignee_id: *persona_id, + created_by: data.get("createdBy") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .unwrap_or(*persona_id), + task_domain: data.get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("self") + .to_string(), + task_type: data.get("taskType") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + context_id: data.get("contextId") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .unwrap_or(*persona_id), + description: data.get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + priority: data.get("priority") + .and_then(|v| v.as_f64()) + .unwrap_or(0.5) as f32, + status: "pending".to_string(), + timestamp: data.get("timestamp") + .and_then(|v| v.as_u64()) + .unwrap_or(now_ms), + enqueued_at: now_ms, + due_date: data.get("dueDate").and_then(|v| v.as_u64()), + estimated_duration: data.get("estimatedDuration").and_then(|v| v.as_u64()), + depends_on: Vec::new(), + blocked_by: Vec::new(), + related_task_ids: Vec::new(), + consolidated_count: 1, + }) + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/code.rs b/src/debug/jtag/workers/continuum-core/src/modules/code.rs index ddda74e23..296a06b94 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/code.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/code.rs @@ -12,6 +12,7 @@ use crate::runtime::{ServiceModule, ModuleConfig, ModulePriority, CommandResult, use crate::code::{self, FileEngine, PathSecurity, ShellSession}; use crate::code::{git_bridge, search, tree}; use crate::logging::TimingGuard; +use crate::utils::params::Params; use crate::log_info; use async_trait::async_trait; use dashmap::DashMap; @@ -74,11 +75,11 @@ impl ServiceModule for CodeModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: None, } } async fn initialize(&self, ctx: &ModuleContext) -> Result<(), String> { - // Store message bus for shell event publishing let _ = self.bus.set(ctx.bus.clone()); log_info!("module", "code", "CodeModule initialized with event bus"); Ok(()) @@ -89,19 +90,18 @@ impl ServiceModule for CodeModule { command: &str, params: Value, ) -> Result { + let p = Params::new(¶ms); + match command { + // ================================================================ + // File Operations + // ================================================================ + "code/create-workspace" => { let _timer = TimingGuard::new("module", "code_create_workspace"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let workspace_root = params.get("workspace_root") - .and_then(|v| v.as_str()) - .ok_or("Missing workspace_root")?; - let read_roots: Vec = params.get("read_roots") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); + let persona_id = p.str("persona_id")?; + let workspace_root = p.str("workspace_root")?; + let read_roots: Vec = p.json_or("read_roots"); let root = std::path::Path::new(workspace_root); let mut security = PathSecurity::new(root) @@ -122,138 +122,85 @@ impl ServiceModule for CodeModule { "code/read" => { let _timer = TimingGuard::new("module", "code_read"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let file_path = params.get("file_path") - .and_then(|v| v.as_str()) - .ok_or("Missing file_path")?; - let start_line = params.get("start_line") - .and_then(|v| v.as_u64()) - .map(|n| n as u32); - let end_line = params.get("end_line") - .and_then(|v| v.as_u64()) - .map(|n| n as u32); + let persona_id = p.str("persona_id")?; + let file_path = p.str("file_path")?; + let start_line = p.u32_opt("start_line"); + let end_line = p.u32_opt("end_line"); let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; let result = engine.read(file_path, start_line, end_line) .map_err(|e| format!("{}", e))?; - Ok(CommandResult::Json(serde_json::to_value(&result).unwrap_or_default())) } "code/write" => { let _timer = TimingGuard::new("module", "code_write"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let file_path = params.get("file_path") - .and_then(|v| v.as_str()) - .ok_or("Missing file_path")?; - let content = params.get("content") - .and_then(|v| v.as_str()) - .ok_or("Missing content")?; - let description = params.get("description") - .and_then(|v| v.as_str()); + let persona_id = p.str("persona_id")?; + let file_path = p.str("file_path")?; + let content = p.str("content")?; + let description = p.str_opt("description"); let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; let result = engine.write(file_path, content, description) .map_err(|e| format!("{}", e))?; - log_info!("module", "code", "Write {} ({} bytes) by {}", file_path, result.bytes_written, persona_id); Ok(CommandResult::Json(serde_json::to_value(&result).unwrap_or_default())) } "code/edit" => { let _timer = TimingGuard::new("module", "code_edit"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let file_path = params.get("file_path") - .and_then(|v| v.as_str()) - .ok_or("Missing file_path")?; - let edit_mode = params.get("edit_mode") - .ok_or("Missing edit_mode")?; - let description = params.get("description") - .and_then(|v| v.as_str()); - - let edit: crate::code::EditMode = serde_json::from_value(edit_mode.clone()) - .map_err(|e| format!("Invalid edit_mode: {}", e))?; + let persona_id = p.str("persona_id")?; + let file_path = p.str("file_path")?; + let edit: crate::code::EditMode = p.json("edit_mode")?; + let description = p.str_opt("description"); let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; let result = engine.edit(file_path, &edit, description) .map_err(|e| format!("{}", e))?; - log_info!("module", "code", "Edit {} by {}", file_path, persona_id); Ok(CommandResult::Json(serde_json::to_value(&result).unwrap_or_default())) } "code/delete" => { let _timer = TimingGuard::new("module", "code_delete"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let file_path = params.get("file_path") - .and_then(|v| v.as_str()) - .ok_or("Missing file_path")?; - let description = params.get("description") - .and_then(|v| v.as_str()); + let persona_id = p.str("persona_id")?; + let file_path = p.str("file_path")?; + let description = p.str_opt("description"); let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; let result = engine.delete(file_path, description) .map_err(|e| format!("{}", e))?; - log_info!("module", "code", "Delete {} by {}", file_path, persona_id); Ok(CommandResult::Json(serde_json::to_value(&result).unwrap_or_default())) } "code/diff" => { let _timer = TimingGuard::new("module", "code_diff"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let file_path = params.get("file_path") - .and_then(|v| v.as_str()) - .ok_or("Missing file_path")?; - let edit_mode = params.get("edit_mode") - .ok_or("Missing edit_mode")?; - - let edit: crate::code::EditMode = serde_json::from_value(edit_mode.clone()) - .map_err(|e| format!("Invalid edit_mode: {}", e))?; + let persona_id = p.str("persona_id")?; + let file_path = p.str("file_path")?; + let edit: crate::code::EditMode = p.json("edit_mode")?; let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; let result = engine.preview_diff(file_path, &edit) .map_err(|e| format!("{}", e))?; - Ok(CommandResult::Json(serde_json::to_value(&result).unwrap_or_default())) } "code/undo" => { let _timer = TimingGuard::new("module", "code_undo"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let change_id = params.get("change_id") - .and_then(|v| v.as_str()); - let count = params.get("count") - .and_then(|v| v.as_u64()) - .map(|n| n as usize); + let persona_id = p.str("persona_id")?; + let change_id = p.str_opt("change_id"); + let count = p.u64_opt("count").map(|n| n as usize); let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; @@ -280,16 +227,9 @@ impl ServiceModule for CodeModule { "code/history" => { let _timer = TimingGuard::new("module", "code_history"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let file_path = params.get("file_path") - .and_then(|v| v.as_str()); - let limit = params.get("limit") - .and_then(|v| v.as_u64()) - .map(|n| n as usize) - .unwrap_or(50); + let persona_id = p.str("persona_id")?; + let file_path = p.str_opt("file_path"); + let limit = p.u64_or("limit", 50) as usize; let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; @@ -299,25 +239,15 @@ impl ServiceModule for CodeModule { } else { engine.workspace_history(limit) }; - Ok(CommandResult::Json(serde_json::to_value(&result).unwrap_or_default())) } "code/search" => { let _timer = TimingGuard::new("module", "code_search"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let pattern = params.get("pattern") - .and_then(|v| v.as_str()) - .ok_or("Missing pattern")?; - let file_glob = params.get("file_glob") - .and_then(|v| v.as_str()); - let max_results = params.get("max_results") - .and_then(|v| v.as_u64()) - .map(|n| n as u32) - .unwrap_or(100); + let persona_id = p.str("persona_id")?; + let pattern = p.str("pattern")?; + let file_glob = p.str_opt("file_glob"); + let max_results = p.u64_or("max_results", 100) as u32; let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; @@ -328,44 +258,32 @@ impl ServiceModule for CodeModule { file_glob, max_results, ); - Ok(CommandResult::Json(serde_json::to_value(&result).unwrap_or_default())) } "code/tree" => { let _timer = TimingGuard::new("module", "code_tree"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let path = params.get("path") - .and_then(|v| v.as_str()); - let max_depth = params.get("max_depth") - .and_then(|v| v.as_u64()) - .map(|n| n as u32) - .unwrap_or(10); - let include_hidden = params.get("include_hidden") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + let persona_id = p.str("persona_id")?; + let path = p.str_opt("path"); + let max_depth = p.u64_or("max_depth", 10) as u32; + let include_hidden = p.bool_or("include_hidden", false); let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; let root = engine.workspace_root(); let target = path.map(|p| root.join(p)).unwrap_or_else(|| root.to_path_buf()); - let result = tree::generate_tree(&target, max_depth, include_hidden); - Ok(CommandResult::Json(serde_json::to_value(&result).unwrap_or_default())) } - // Git commands + // ================================================================ + // Git Operations + // ================================================================ + "code/git-status" => { let _timer = TimingGuard::new("module", "code_git_status"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; + let persona_id = p.str("persona_id")?; let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; @@ -376,124 +294,78 @@ impl ServiceModule for CodeModule { "code/git-diff" => { let _timer = TimingGuard::new("module", "code_git_diff"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let staged = params.get("staged") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + let persona_id = p.str("persona_id")?; + let staged = p.bool_or("staged", false); let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; - match git_bridge::git_diff(&engine.workspace_root(), staged) { - Ok(diff) => Ok(CommandResult::Json(serde_json::json!({ "diff": diff }))), - Err(e) => Err(e), - } + let diff = git_bridge::git_diff(&engine.workspace_root(), staged)?; + Ok(CommandResult::Json(serde_json::json!({ "diff": diff }))) } "code/git-log" => { let _timer = TimingGuard::new("module", "code_git_log"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let count = params.get("limit") - .and_then(|v| v.as_u64()) - .map(|n| n as u32) - .unwrap_or(10); + let persona_id = p.str("persona_id")?; + let count = p.u64_or("limit", 10) as u32; let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; - match git_bridge::git_log(&engine.workspace_root(), count) { - Ok(log) => Ok(CommandResult::Json(serde_json::json!({ "log": log }))), - Err(e) => Err(e), - } + let log = git_bridge::git_log(&engine.workspace_root(), count)?; + Ok(CommandResult::Json(serde_json::json!({ "log": log }))) } "code/git-add" => { let _timer = TimingGuard::new("module", "code_git_add"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let paths: Vec = params.get("paths") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); + let persona_id = p.str("persona_id")?; + let paths: Vec = p.json_or("paths"); let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); - match git_bridge::git_add(&engine.workspace_root(), &path_refs) { - Ok(output) => Ok(CommandResult::Json(serde_json::json!({ "output": output }))), - Err(e) => Err(e), - } + let output = git_bridge::git_add(&engine.workspace_root(), &path_refs)?; + Ok(CommandResult::Json(serde_json::json!({ "output": output }))) } "code/git-commit" => { let _timer = TimingGuard::new("module", "code_git_commit"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let message = params.get("message") - .and_then(|v| v.as_str()) - .ok_or("Missing message")?; + let persona_id = p.str("persona_id")?; + let message = p.str("message")?; let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; - match git_bridge::git_commit(&engine.workspace_root(), message) { - Ok(hash) => { - log_info!("module", "code", "Git commit by {}: {}", persona_id, message); - Ok(CommandResult::Json(serde_json::json!({ "hash": hash }))) - } - Err(e) => Err(e), - } + let hash = git_bridge::git_commit(&engine.workspace_root(), message)?; + log_info!("module", "code", "Git commit by {}: {}", persona_id, message); + Ok(CommandResult::Json(serde_json::json!({ "hash": hash }))) } "code/git-push" => { let _timer = TimingGuard::new("module", "code_git_push"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let remote = params.get("remote") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let branch = params.get("branch") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let persona_id = p.str("persona_id")?; + let remote = p.str_or("remote", ""); + let branch = p.str_or("branch", ""); let engine = self.state.file_engines.get(persona_id) .ok_or_else(|| format!("No workspace for persona {}", persona_id))?; - match git_bridge::git_push(&engine.workspace_root(), remote, branch) { - Ok(output) => { - log_info!("module", "code", "Git push by {}", persona_id); - Ok(CommandResult::Json(serde_json::json!({ "output": output }))) - } - Err(e) => Err(e), - } + let output = git_bridge::git_push(&engine.workspace_root(), remote, branch)?; + log_info!("module", "code", "Git push by {}", persona_id); + Ok(CommandResult::Json(serde_json::json!({ "output": output }))) } - // Shell commands + // ================================================================ + // Shell Sessions + // ================================================================ + "code/shell-create" => { let _timer = TimingGuard::new("module", "code_shell_create"); + let persona_id = p.str("persona_id")?; + let workspace_root = p.str("workspace_root")?; - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let workspace_root = params.get("workspace_root") - .and_then(|v| v.as_str()) - .ok_or("Missing workspace_root")?; - - // Generate unique session ID for this shell let session_id = Uuid::new_v4().to_string(); - let shell = ShellSession::new( &session_id, persona_id, @@ -512,36 +384,23 @@ impl ServiceModule for CodeModule { "code/shell-execute" => { let _timer = TimingGuard::new("module", "code_shell_execute"); + let persona_id = p.str("persona_id")?; + let cmd = p.str("cmd")?; + let timeout_ms = p.u64_opt("timeout_ms"); + let wait = p.bool_or("wait", false); - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let command = params.get("cmd") - .and_then(|v| v.as_str()) - .ok_or("Missing cmd")?; - let timeout_ms = params.get("timeout_ms") - .and_then(|v| v.as_u64()); - let wait = params.get("wait") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - // Start execution (get execution_id and state_arc immediately) let (execution_id, state_arc) = { let mut shell = self.state.shell_sessions.get_mut(persona_id) .ok_or_else(|| format!("No shell session for {}", persona_id))?; - let exec_id = shell.execute(command, timeout_ms, &self.state.rt_handle) + let exec_id = shell.execute(cmd, timeout_ms, &self.state.rt_handle) .map_err(|e| format!("{}", e))?; - let state = shell.get_execution_state(&exec_id) .ok_or_else(|| "Execution vanished".to_string())?; - (exec_id, state) }; - // DashMap lock is now released if wait { - // Await completion using the notify mechanism (async-safe) let result = loop { let (is_done, response, notify) = { let s = state_arc.lock() @@ -560,22 +419,19 @@ impl ServiceModule for CodeModule { } }; - if is_done { - break response.unwrap(); + if let (true, Some(resp)) = (is_done, response) { + break resp; } - - // Wait for notification (non-blocking async wait) if let Some(n) = notify { n.notified().await; } }; - // Emit shell:complete event with execution result let exit_code = result.exit_code.unwrap_or(-1); let has_error = exit_code != 0; self.publish_shell_event(persona_id, "complete", serde_json::json!({ "execution_id": result.execution_id, - "command": command, + "command": cmd, "exit_code": exit_code, "success": !has_error, "stdout_lines": result.stdout.as_ref().map(|s| s.lines().count()).unwrap_or(0), @@ -583,13 +439,12 @@ impl ServiceModule for CodeModule { "has_error": has_error, })); - // If there were errors, also emit shell:error event if has_error { if let Some(stderr) = &result.stderr { let error_preview: String = stderr.lines().take(5).collect::>().join("\n"); self.publish_shell_event(persona_id, "error", serde_json::json!({ "execution_id": result.execution_id, - "command": command, + "command": cmd, "exit_code": exit_code, "error_preview": error_preview, })); @@ -598,12 +453,10 @@ impl ServiceModule for CodeModule { Ok(CommandResult::Json(serde_json::to_value(&result).unwrap_or_default())) } else { - // Emit shell:started event for async execution self.publish_shell_event(persona_id, "started", serde_json::json!({ "execution_id": execution_id, - "command": command, + "command": cmd, })); - Ok(CommandResult::Json(serde_json::json!({ "execution_id": execution_id, "started": true, @@ -613,65 +466,43 @@ impl ServiceModule for CodeModule { "code/shell-poll" => { let _timer = TimingGuard::new("module", "code_shell_poll"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let execution_id = params.get("execution_id") - .and_then(|v| v.as_str()) - .ok_or("Missing execution_id")?; + let persona_id = p.str("persona_id")?; + let execution_id = p.str("execution_id")?; let shell = self.state.shell_sessions.get(persona_id) .ok_or_else(|| format!("No shell session for {}", persona_id))?; - let result = shell.poll(execution_id) - .map_err(|e| format!("{}", e))?; + let result = shell.poll(execution_id).map_err(|e| format!("{}", e))?; Ok(CommandResult::Json(serde_json::to_value(&result).unwrap_or_default())) } "code/shell-kill" => { let _timer = TimingGuard::new("module", "code_shell_kill"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let execution_id = params.get("execution_id") - .and_then(|v| v.as_str()) - .ok_or("Missing execution_id")?; + let persona_id = p.str("persona_id")?; + let execution_id = p.str("execution_id")?; let shell = self.state.shell_sessions.get(persona_id) .ok_or_else(|| format!("No shell session for {}", persona_id))?; - shell.kill(execution_id) - .map_err(|e| format!("{}", e))?; + shell.kill(execution_id).map_err(|e| format!("{}", e))?; Ok(CommandResult::Json(serde_json::json!({ "killed": true }))) } "code/shell-cd" => { let _timer = TimingGuard::new("module", "code_shell_cd"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let path = params.get("path") - .and_then(|v| v.as_str()) - .ok_or("Missing path")?; + let persona_id = p.str("persona_id")?; + let path = p.str("path")?; let mut shell = self.state.shell_sessions.get_mut(persona_id) .ok_or_else(|| format!("No shell session for {}", persona_id))?; - let new_cwd = shell.cd(path) - .map_err(|e| format!("{}", e))?; - + let new_cwd = shell.cd(path).map_err(|e| format!("{}", e))?; Ok(CommandResult::Json(serde_json::json!({ "changed": true, "cwd": new_cwd }))) } "code/shell-status" => { let _timer = TimingGuard::new("module", "code_shell_status"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; + let persona_id = p.str("persona_id")?; let shell = self.state.shell_sessions.get(persona_id) .ok_or_else(|| format!("No shell session for {}", persona_id))?; @@ -682,15 +513,9 @@ impl ServiceModule for CodeModule { "code/shell-watch" => { let _timer = TimingGuard::new("module", "code_shell_watch"); + let persona_id = p.str("persona_id")?; + let execution_id = p.str("execution_id")?; - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let execution_id = params.get("execution_id") - .and_then(|v| v.as_str()) - .ok_or("Missing execution_id")?; - - // Get watch handles while holding the DashMap lock, then release let (exec_state, notify) = { let shell = self.state.shell_sessions.get(persona_id) .ok_or_else(|| format!("No shell session for {}", persona_id))?; @@ -698,7 +523,6 @@ impl ServiceModule for CodeModule { .map_err(|e| format!("{}", e))? }; - // Now call async watch with DashMap lock released let exec_id = execution_id.to_string(); let result = self.state.rt_handle.block_on(async { code::watch_execution(&exec_id, exec_state, notify).await @@ -709,37 +533,23 @@ impl ServiceModule for CodeModule { "code/shell-sentinel" => { let _timer = TimingGuard::new("module", "code_shell_sentinel"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let execution_id = params.get("execution_id") - .and_then(|v| v.as_str()) - .ok_or("Missing execution_id")?; - let rules: Vec = params.get("rules") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); + let persona_id = p.str("persona_id")?; + let execution_id = p.str("execution_id")?; + let rules: Vec = p.json_or("rules"); let shell = self.state.shell_sessions.get(persona_id) .ok_or_else(|| format!("No shell session for {}", persona_id))?; let count = shell.set_sentinel(execution_id, &rules) .map_err(|e| format!("{}", e))?; - - Ok(CommandResult::Json(serde_json::json!({ - "rules_applied": count, - }))) + Ok(CommandResult::Json(serde_json::json!({ "rules_applied": count }))) } "code/shell-destroy" => { let _timer = TimingGuard::new("module", "code_shell_destroy"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; + let persona_id = p.str("persona_id")?; let removed = self.state.shell_sessions.remove(persona_id).is_some(); - log_info!("module", "code", "Destroyed shell for {}", persona_id); Ok(CommandResult::Json(serde_json::json!({ "destroyed": removed }))) } diff --git a/src/debug/jtag/workers/continuum-core/src/modules/cognition.rs b/src/debug/jtag/workers/continuum-core/src/modules/cognition.rs index 9f603e333..8c3e69bad 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/cognition.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/cognition.rs @@ -1,16 +1,44 @@ -//! CognitionModule — wraps PersonaCognitionEngine per-persona DashMap state. +//! CognitionModule — per-persona cognitive state + text analysis IPC. //! -//! Validates the ServiceModule trait handles stateful per-persona DashMap isolation — -//! the MOST DIFFERENT pattern from stateless HealthModule. +//! Unified per-persona state: one DashMap holds all +//! cognitive state (engine, inbox, rate limiter, sleep, adapters, genome). +//! Single lock acquisition per command. Related state is cache-local. //! -//! Handles: cognition/create-engine, cognition/calculate-priority, -//! cognition/fast-path-decision, cognition/enqueue-message, cognition/get-state, -//! inbox/create +//! Stateless text analysis commands (similarity, validation, mentions, cleaning) +//! use no per-persona state. +//! +//! Commands: +//! - `cognition/create-engine`: Create all per-persona cognitive state +//! - `cognition/calculate-priority`: Priority scoring +//! - `cognition/fast-path-decision`: Fast-path respond/skip decision +//! - `cognition/enqueue-message`: Enqueue message to persona inbox +//! - `cognition/get-state`: Get persona cognitive state +//! - `cognition/full-evaluate`: Unified 6-gate evaluation (replaces 5 TS gates) +//! - `cognition/track-response`: Track response for rate limiting +//! - `cognition/set-sleep-mode`: Set voluntary sleep mode +//! - `cognition/configure-rate-limiter`: Configure rate limiter params +//! - `cognition/select-model`: 4-tier model priority chain +//! - `cognition/sync-adapters`: Sync adapter registry from TypeScript +//! - `cognition/genome-activate-skill`: LRU eviction + skill activation +//! - `cognition/genome-sync`: Sync full adapter state from TypeScript +//! - `cognition/genome-state`: Get current genome paging state +//! - `cognition/check-adequacy`: Batch adequacy check +//! - `inbox/create`: Create persona inbox (alias for create-engine) +//! +//! Uses `Params` helper for typed parameter extraction. use crate::runtime::{ServiceModule, ModuleConfig, ModulePriority, CommandResult, ModuleContext}; -use crate::persona::{PersonaCognitionEngine, PersonaInbox, InboxMessage, SenderType, Modality}; +use crate::persona::{PersonaCognition, InboxMessage, SenderType, Modality}; +use crate::persona::{SleepMode, RecentResponse}; +use crate::persona::{AdapterInfo, ModelSelectionRequest}; +use crate::persona::GenomeAdapterInfo; +use crate::persona::evaluator; +use crate::persona::model_selection; +use crate::persona::text_analysis; +use crate::persona::text_analysis::LoopDetector; use crate::rag::RagEngine; use crate::logging::TimingGuard; +use crate::utils::params::Params; use crate::log_info; use async_trait::async_trait; use dashmap::DashMap; @@ -19,33 +47,30 @@ use std::any::Any; use std::sync::Arc; use uuid::Uuid; -/// Shared state for cognition module — per-persona engines and inboxes. +/// Shared state for cognition module. +/// +/// `personas` holds ALL per-persona cognitive state in a single DashMap. +/// One lock acquisition gives atomic access to engine + inbox + rate limiter + +/// sleep state + adapter registry + genome engine. +/// +/// `rag_engine` and `loop_detector` are shared across all personas. pub struct CognitionState { - /// Per-persona cognition engines. - pub engines: Arc>, - /// Per-persona inboxes. - pub inboxes: Arc>, - /// Shared RAG engine. + /// Unified per-persona state: 7 maps → 1. + pub personas: Arc>, + /// Shared RAG engine (not per-persona). pub rag_engine: Arc, + /// Shared loop detector (not per-persona). + pub loop_detector: LoopDetector, } impl CognitionState { pub fn new(rag_engine: Arc) -> Self { Self { - engines: Arc::new(DashMap::new()), - inboxes: Arc::new(DashMap::new()), + personas: Arc::new(DashMap::new()), rag_engine, + loop_detector: LoopDetector::new(), } } - - /// Create from existing DashMaps (for gradual migration from ServerState). - pub fn from_existing( - engines: Arc>, - inboxes: Arc>, - rag_engine: Arc, - ) -> Self { - Self { engines, inboxes, rag_engine } - } } pub struct CognitionModule { @@ -58,6 +83,20 @@ impl CognitionModule { } } +/// Helper: get or create persona, returning mutable ref via DashMap entry API. +/// Used by commands that need to lazily create persona state. +macro_rules! get_or_create_persona { + ($self:expr, $persona_uuid:expr) => { + $self.state.personas + .entry($persona_uuid) + .or_insert_with(|| PersonaCognition::new( + $persona_uuid, + String::new(), + $self.state.rag_engine.clone(), + )) + }; +} + #[async_trait] impl ServiceModule for CognitionModule { fn config(&self) -> ModuleConfig { @@ -68,6 +107,7 @@ impl ServiceModule for CognitionModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: None, } } @@ -80,156 +120,84 @@ impl ServiceModule for CognitionModule { command: &str, params: Value, ) -> Result { + let p = Params::new(¶ms); + match command { + // ================================================================ + // Persona Lifecycle + // ================================================================ + "cognition/create-engine" => { let _timer = TimingGuard::new("module", "cognition_create_engine"); + let persona_uuid = p.uuid("persona_id")?; + let persona_name = p.str("persona_name")?; - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let persona_name = params.get("persona_name") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_name")?; - - let persona_uuid = Uuid::parse_str(persona_id) - .map_err(|e| format!("Invalid persona_id: {e}"))?; - - let (_, shutdown_rx) = tokio::sync::watch::channel(false); - let engine = PersonaCognitionEngine::new( + let cognition = PersonaCognition::new( persona_uuid, persona_name.to_string(), self.state.rag_engine.clone(), - shutdown_rx, ); + self.state.personas.insert(persona_uuid, cognition); - self.state.engines.insert(persona_uuid, engine); - - log_info!("module", "cognition", "Created cognition engine for {}", persona_id); + log_info!("module", "cognition", "Created cognition for {}", persona_uuid); Ok(CommandResult::Json(serde_json::json!({ "created": true }))) } "cognition/calculate-priority" => { let _timer = TimingGuard::new("module", "cognition_calculate_priority"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let content = params.get("content") - .and_then(|v| v.as_str()) - .ok_or("Missing content")?; - let sender_type_str = params.get("sender_type") - .and_then(|v| v.as_str()) - .ok_or("Missing sender_type")?; - let is_voice = params.get("is_voice") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let room_id = params.get("room_id") - .and_then(|v| v.as_str()) - .ok_or("Missing room_id")?; - let timestamp = params.get("timestamp") - .and_then(|v| v.as_u64()) - .ok_or("Missing timestamp")?; - - let persona_uuid = Uuid::parse_str(persona_id) - .map_err(|e| format!("Invalid persona_id: {e}"))?; - let room_uuid = Uuid::parse_str(room_id) - .map_err(|e| format!("Invalid room_id: {e}"))?; - - let sender = match sender_type_str { - "human" => SenderType::Human, - "persona" => SenderType::Persona, - "agent" => SenderType::Agent, - "system" => SenderType::System, - _ => return Err(format!("Invalid sender_type: {}", sender_type_str)), - }; - - let engine = self.state.engines.get(&persona_uuid) - .ok_or_else(|| format!("No cognition engine for {}", persona_id))?; - - let score = engine.calculate_priority(content, sender, is_voice, room_uuid, timestamp); - - Ok(CommandResult::Json(serde_json::json!({ - "score": score.score, - "factors": { - "recency_score": score.factors.recency_score, - "mention_score": score.factors.mention_score, - "room_score": score.factors.room_score, - "sender_score": score.factors.sender_score, - "voice_boost": score.factors.voice_boost, - } - }))) + let persona_uuid = p.uuid("persona_id")?; + let content = p.str("content")?; + let sender_type_str = p.str("sender_type")?; + let is_voice = p.bool_or("is_voice", false); + let room_uuid = p.uuid("room_id")?; + let timestamp = p.u64("timestamp")?; + + let sender = parse_sender_type(sender_type_str)?; + let persona = self.state.personas.get(&persona_uuid) + .ok_or_else(|| format!("No cognition for {persona_uuid}"))?; + + let score = persona.engine.calculate_priority(content, sender, is_voice, room_uuid, timestamp); + Ok(CommandResult::Json(serde_json::to_value(&score) + .map_err(|e| format!("Serialize error: {e}"))?)) } "cognition/fast-path-decision" => { let _timer = TimingGuard::new("module", "cognition_fast_path_decision"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let message = params.get("message") - .ok_or("Missing message")?; - - let persona_uuid = Uuid::parse_str(persona_id) - .map_err(|e| format!("Invalid persona_id: {e}"))?; - + let persona_uuid = p.uuid("persona_id")?; + let message = p.value("message").ok_or("Missing message")?; let inbox_msg = parse_inbox_message(message)?; - let engine = self.state.engines.get(&persona_uuid) - .ok_or_else(|| format!("No cognition engine for {}", persona_id))?; + let persona = self.state.personas.get(&persona_uuid) + .ok_or_else(|| format!("No cognition for {persona_uuid}"))?; - let decision = engine.fast_path_decision(&inbox_msg); - - Ok(CommandResult::Json(serde_json::json!({ - "should_respond": decision.should_respond, - "confidence": decision.confidence, - "reason": decision.reason, - "decision_time_ms": decision.decision_time_ms, - "fast_path_used": decision.fast_path_used, - }))) + let decision = persona.engine.fast_path_decision(&inbox_msg); + Ok(CommandResult::Json(serde_json::to_value(&decision) + .map_err(|e| format!("Serialize error: {e}"))?)) } "cognition/enqueue-message" => { let _timer = TimingGuard::new("module", "cognition_enqueue_message"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - let message = params.get("message") - .ok_or("Missing message")?; - - let persona_uuid = Uuid::parse_str(persona_id) - .map_err(|e| format!("Invalid persona_id: {e}"))?; - + let persona_uuid = p.uuid("persona_id")?; + let message = p.value("message").ok_or("Missing message")?; let inbox_msg = parse_inbox_message(message)?; - let inbox = self.state.inboxes - .entry(persona_uuid) - .or_insert_with(|| PersonaInbox::new(persona_uuid)); - inbox.enqueue(inbox_msg); - - let queue_size = inbox.len(); + let persona = get_or_create_persona!(self, persona_uuid); + persona.inbox.enqueue(inbox_msg); Ok(CommandResult::Json(serde_json::json!({ "enqueued": true, - "queue_size": queue_size, + "queue_size": persona.inbox.len(), }))) } "cognition/get-state" => { let _timer = TimingGuard::new("module", "cognition_get_state"); + let persona_uuid = p.uuid("persona_id")?; - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - - let persona_uuid = Uuid::parse_str(persona_id) - .map_err(|e| format!("Invalid persona_id: {e}"))?; - - let engine = self.state.engines.get(&persona_uuid) - .ok_or_else(|| format!("No cognition engine for {}", persona_id))?; - - let state = engine.state(); + let persona = self.state.personas.get(&persona_uuid) + .ok_or_else(|| format!("No cognition for {persona_uuid}"))?; + let state = persona.engine.state(); Ok(CommandResult::Json(serde_json::json!({ "energy": state.energy, "attention": state.attention, @@ -244,25 +212,427 @@ impl ServiceModule for CognitionModule { "inbox/create" => { let _timer = TimingGuard::new("module", "inbox_create"); + let persona_uuid = p.uuid("persona_id")?; + // Ensure persona exists with all state (inbox is part of PersonaCognition) + get_or_create_persona!(self, persona_uuid); + log_info!("module", "cognition", "Ensured inbox for {}", persona_uuid); + Ok(CommandResult::Json(serde_json::json!({ "created": true }))) + } + + // ================================================================ + // Message Deduplication (single source of truth in Rust) + // ================================================================ + + "cognition/has-evaluated" => { + let persona_uuid = p.uuid("persona_id")?; + let message_uuid = p.uuid("message_id")?; + + let persona = self.state.personas.get(&persona_uuid) + .ok_or_else(|| format!("No cognition for {persona_uuid}"))?; + + let evaluated = persona.engine.has_evaluated_message(message_uuid); + Ok(CommandResult::Json(serde_json::json!({ "evaluated": evaluated }))) + } + + "cognition/mark-evaluated" => { + let persona_uuid = p.uuid("persona_id")?; + let message_uuid = p.uuid("message_id")?; + + let persona = self.state.personas.get(&persona_uuid) + .ok_or_else(|| format!("No cognition for {persona_uuid}"))?; + + persona.engine.mark_message_evaluated(message_uuid); + Ok(CommandResult::Json(serde_json::json!({ "marked": true }))) + } + + // ================================================================ + // Text Analysis (stateless pure compute + loop detector state) + // ================================================================ + + "cognition/text-similarity" => { + let _timer = TimingGuard::new("module", "cognition_text_similarity"); + let text1 = p.str("text1")?; + let text2 = p.str("text2")?; + let start = std::time::Instant::now(); + + let result = text_analysis::TextSimilarityResult { + ngram_similarity: text_analysis::jaccard_ngram_similarity(text1, text2), + char_similarity: text_analysis::jaccard_char_bigram_similarity(text1, text2), + compute_time_us: start.elapsed().as_micros() as u64, + }; + Ok(CommandResult::Json(serde_json::to_value(&result) + .map_err(|e| format!("Serialize error: {e}"))?)) + } + + "cognition/check-semantic-loop" => { + let _timer = TimingGuard::new("module", "cognition_check_semantic_loop"); + let response_text = p.str("response_text")?; + let max_history = p.u64_or("max_history", 10) as usize; + let history = parse_conversation_history(¶ms, "history")?; + + let result = text_analysis::check_semantic_loop(response_text, &history, max_history); + Ok(CommandResult::Json(serde_json::to_value(&result) + .map_err(|e| format!("Serialize error: {e}"))?)) + } + + "cognition/validate-response" => { + let _timer = TimingGuard::new("module", "cognition_validate_response"); + let persona_uuid = p.uuid("persona_id")?; + let response_text = p.str("response_text")?; + let has_tool_calls = p.bool_or("has_tool_calls", false); + let history = parse_conversation_history_optional(¶ms, "conversation_history"); + + let result = text_analysis::validate_response( + response_text, + persona_uuid, + has_tool_calls, + &history, + &self.state.loop_detector, + ); + Ok(CommandResult::Json(serde_json::to_value(&result) + .map_err(|e| format!("Serialize error: {e}"))?)) + } + + "cognition/check-mentions" => { + let _timer = TimingGuard::new("module", "cognition_check_mentions"); + let start = std::time::Instant::now(); + let message_text = p.str("message_text")?; + let display_name = p.str("persona_display_name")?; + let unique_id = p.str_opt("persona_unique_id").unwrap_or(""); + + let result = text_analysis::MentionCheckResult { + is_persona_mentioned: text_analysis::is_persona_mentioned(message_text, display_name, unique_id), + has_directed_mention: text_analysis::has_directed_mention(message_text), + compute_time_us: start.elapsed().as_micros() as u64, + }; + Ok(CommandResult::Json(serde_json::to_value(&result) + .map_err(|e| format!("Serialize error: {e}"))?)) + } + + "cognition/clean-response" => { + let _timer = TimingGuard::new("module", "cognition_clean_response"); + let start = std::time::Instant::now(); + let response_text = p.str("response_text")?; + + let cleaned = text_analysis::clean_response(response_text); + let result = text_analysis::CleanedResponse { + was_cleaned: cleaned != response_text.trim(), + text: cleaned, + compute_time_us: start.elapsed().as_micros() as u64, + }; + Ok(CommandResult::Json(serde_json::to_value(&result) + .map_err(|e| format!("Serialize error: {e}"))?)) + } + + // ================================================================ + // Unified Evaluation (6-gate pipeline, single lock) + // ================================================================ + + "cognition/full-evaluate" => { + let _timer = TimingGuard::new("module", "cognition_full_evaluate"); + let persona_uuid = p.uuid("persona_id")?; + + // Single lock — atomic access to engine + rate_limiter + sleep_state + let persona = self.state.personas.get(&persona_uuid) + .ok_or_else(|| format!("No cognition for {persona_uuid}"))?; + + let request = evaluator::FullEvaluateRequest { + persona_id: persona_uuid, + persona_name: p.str("persona_name")?.to_string(), + persona_unique_id: p.str_or("persona_unique_id", "").to_string(), + message_id: p.uuid("message_id")?, + room_id: p.uuid("room_id")?, + sender_id: p.uuid("sender_id")?, + sender_name: p.str("sender_name")?.to_string(), + sender_type: parse_sender_type(p.str("sender_type")?)?, + content: p.str("content")?.to_string(), + timestamp: p.u64("timestamp")?, + is_voice: p.bool_or("is_voice", false), + voice_session_id: p.uuid_opt("voice_session_id"), + sender_is_human: p.bool_or("sender_is_human", false), + topic_similarity: p.f32_opt("topic_similarity"), + recent_room_texts: p.json_opt("recent_room_texts"), + }; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let result = evaluator::full_evaluate( + &request, + &persona.rate_limiter, + &persona.sleep_state, + &persona.engine, + now_ms, + ); + + log_info!( + "module", "cognition", + "full-evaluate {}: respond={}, gate={}, confidence={:.2} ({:.2}ms)", + persona_uuid, result.should_respond, result.gate, result.confidence, result.decision_time_ms + ); - let persona_id = params.get("persona_id") + Ok(CommandResult::Json(serde_json::to_value(&result) + .map_err(|e| format!("Serialize error: {e}"))?)) + } + + "cognition/track-response" => { + let _timer = TimingGuard::new("module", "cognition_track_response"); + let persona_uuid = p.uuid("persona_id")?; + let room_uuid = p.uuid("room_id")?; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let mut persona = get_or_create_persona!(self, persona_uuid); + persona.rate_limiter.track_response(room_uuid, now_ms); + + let count = persona.rate_limiter.response_count(room_uuid); + log_info!( + "module", "cognition", + "track-response {}: room={}, count={}", + persona_uuid, room_uuid, count + ); + + Ok(CommandResult::Json(serde_json::json!({ + "tracked": true, + "response_count": count, + }))) + } + + "cognition/set-sleep-mode" => { + let _timer = TimingGuard::new("module", "cognition_set_sleep_mode"); + let persona_uuid = p.uuid("persona_id")?; + let mode_str = p.str("mode")?; + let reason = p.str_or("reason", "").to_string(); + let duration_minutes = p.f64_opt("duration_minutes"); + + let mode = match mode_str { + "active" => SleepMode::Active, + "mentioned_only" => SleepMode::MentionedOnly, + "human_only" => SleepMode::HumanOnly, + "sleeping" => SleepMode::Sleeping, + "until_topic" => SleepMode::UntilTopic, + _ => return Err(format!("Invalid sleep mode: {mode_str}")), + }; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let wake_at_ms = duration_minutes.map(|d| now_ms + (d * 60_000.0) as u64); + + let mut persona = get_or_create_persona!(self, persona_uuid); + let previous = format!("{:?}", persona.sleep_state.mode); + + persona.sleep_state = crate::persona::evaluator::SleepState { + mode, + reason: reason.clone(), + set_at_ms: now_ms, + wake_at_ms, + }; + + log_info!( + "module", "cognition", + "set-sleep-mode {}: {} → {:?} (reason: {})", + persona_uuid, previous, mode, reason + ); + + Ok(CommandResult::Json(serde_json::json!({ + "set": true, + "previous_mode": previous, + "new_mode": mode_str, + "wake_at_ms": wake_at_ms, + }))) + } + + "cognition/configure-rate-limiter" => { + let _timer = TimingGuard::new("module", "cognition_configure_rate_limiter"); + let persona_uuid = p.uuid("persona_id")?; + let min_seconds = p.f64_or("min_seconds_between_responses", 10.0); + let max_responses = p.u64_or("max_responses_per_session", 50) as u32; + + let mut persona = get_or_create_persona!(self, persona_uuid); + persona.rate_limiter.min_seconds_between_responses = min_seconds; + persona.rate_limiter.max_responses_per_session = max_responses; + + log_info!( + "module", "cognition", + "configure-rate-limiter {}: min_seconds={}, max_responses={}", + persona_uuid, min_seconds, max_responses + ); + + Ok(CommandResult::Json(serde_json::json!({ + "configured": true, + "min_seconds_between_responses": min_seconds, + "max_responses_per_session": max_responses, + }))) + } + + // ================================================================= + // Model Selection + // ================================================================= + + "cognition/select-model" => { + let _timer = TimingGuard::new("module", "cognition_select_model"); + let persona_uuid = p.uuid("persona_id")?; + let task_domain = params.get("task_domain") .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - // Note: capacity parameter is ignored - PersonaInbox doesn't support it - let _capacity = params.get("capacity") - .and_then(|v| v.as_u64()) - .map(|c| c as usize); + .map(String::from); + let base_model = p.str("base_model")?.to_string(); - let persona_uuid = Uuid::parse_str(persona_id) - .map_err(|e| format!("Invalid persona_id: {e}"))?; + let request = ModelSelectionRequest { + persona_id: persona_uuid, + task_domain, + base_model, + }; - // Create inbox with persona_uuid - let inbox = PersonaInbox::new(persona_uuid); + let persona = get_or_create_persona!(self, persona_uuid); + let result = model_selection::select_model(&request, &persona.adapter_registry); - self.state.inboxes.insert(persona_uuid, inbox); + Ok(CommandResult::Json(serde_json::to_value(&result) + .map_err(|e| format!("Serialize error: {e}"))?)) + } - log_info!("module", "cognition", "Created inbox for {}", persona_id); - Ok(CommandResult::Json(serde_json::json!({ "created": true }))) + "cognition/sync-adapters" => { + let _timer = TimingGuard::new("module", "cognition_sync_adapters"); + let persona_uuid = p.uuid("persona_id")?; + let adapters_json = params.get("adapters") + .and_then(|v| v.as_array()) + .ok_or("Missing adapters array")?; + + let mut persona = get_or_create_persona!(self, persona_uuid); + + // Replace entire adapter set (full sync, not incremental) + persona.adapter_registry.adapters.clear(); + + for adapter_val in adapters_json { + let adapter: AdapterInfo = serde_json::from_value(adapter_val.clone()) + .map_err(|e| format!("Invalid adapter: {e}"))?; + persona.adapter_registry.adapters.insert(adapter.name.clone(), adapter); + } + + let count = persona.adapter_registry.adapters.len(); + + log_info!( + "module", "cognition", + "sync-adapters {}: synced {} adapters", + persona_uuid, count + ); + + Ok(CommandResult::Json(serde_json::json!({ + "synced": true, + "adapter_count": count, + }))) + } + + // ================================================================= + // Genome Paging (LRU eviction + memory budget decisions) + // ================================================================= + + "cognition/genome-activate-skill" => { + let _timer = TimingGuard::new("module", "cognition_genome_activate_skill"); + let persona_uuid = p.uuid("persona_id")?; + let skill_name = p.str("skill_name")?.to_string(); + let memory_budget_mb = p.f32_or("memory_budget_mb", 200.0); + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let mut persona = get_or_create_persona!(self, persona_uuid); + persona.genome_engine.memory_budget_mb = memory_budget_mb; + let result = persona.genome_engine.activate_skill(&skill_name, now_ms); + + log_info!( + "module", "cognition", + "genome-activate-skill {}: {} activated={}, evicted={:?}, to_load={:?} ({:.0}μs)", + persona_uuid, skill_name, result.activated, + result.evicted, result.to_load, result.decision_time_us + ); + + Ok(CommandResult::Json(serde_json::to_value(&result) + .map_err(|e| format!("Serialize error: {e}"))?)) + } + + "cognition/genome-sync" => { + let _timer = TimingGuard::new("module", "cognition_genome_sync"); + let persona_uuid = p.uuid("persona_id")?; + let memory_budget_mb = p.f32_or("memory_budget_mb", 200.0); + let adapters_json = params.get("adapters") + .and_then(|v| v.as_array()) + .ok_or("Missing adapters array")?; + + let adapters: Vec = adapters_json.iter() + .filter_map(|v| serde_json::from_value(v.clone()).ok()) + .collect(); + + let adapter_count = adapters.len(); + let active_count = adapters.iter().filter(|a| a.is_loaded).count(); + + let mut persona = get_or_create_persona!(self, persona_uuid); + persona.genome_engine.memory_budget_mb = memory_budget_mb; + persona.genome_engine.sync_state(adapters); + + log_info!( + "module", "cognition", + "genome-sync {}: {} adapters ({} active), budget={}MB, used={}MB", + persona_uuid, adapter_count, active_count, + persona.genome_engine.memory_budget_mb, persona.genome_engine.memory_used_mb + ); + + Ok(CommandResult::Json(serde_json::json!({ + "synced": true, + "adapter_count": adapter_count, + "active_count": active_count, + "memory_used_mb": persona.genome_engine.memory_used_mb, + "memory_pressure": persona.genome_engine.memory_pressure(), + }))) + } + + "cognition/genome-state" => { + let _timer = TimingGuard::new("module", "cognition_genome_state"); + let persona_uuid = p.uuid("persona_id")?; + + let persona = self.state.personas.get(&persona_uuid) + .ok_or_else(|| format!("No cognition for {persona_uuid}"))?; + + let state = persona.genome_engine.state(); + Ok(CommandResult::Json(serde_json::to_value(&state) + .map_err(|e| format!("Serialize error: {e}"))?)) + } + + // ================================================================= + // Post-Inference Adequacy Check + // ================================================================= + + "cognition/check-adequacy" => { + let _timer = TimingGuard::new("module", "cognition_check_adequacy"); + let original_text = p.str("original_text")?.to_string(); + let responses_json = params.get("responses") + .and_then(|v| v.as_array()) + .ok_or("Missing responses array")?; + + let responses: Vec = responses_json.iter() + .filter_map(|v| serde_json::from_value(v.clone()).ok()) + .collect(); + + let result = evaluator::check_response_adequacy(&original_text, &responses); + + log_info!( + "module", "cognition", + "check-adequacy: adequate={}, confidence={:.2}, responder={:?} ({:.0}μs, {} responses checked)", + result.is_adequate, result.confidence, + result.responder_name, result.check_time_us, responses.len() + ); + + Ok(CommandResult::Json(serde_json::to_value(&result) + .map_err(|e| format!("Serialize error: {e}"))?)) } _ => Err(format!("Unknown cognition command: {command}")), @@ -272,57 +642,66 @@ impl ServiceModule for CognitionModule { fn as_any(&self) -> &dyn Any { self } } +// ============================================================================ +// Parsing helpers +// ============================================================================ + +fn parse_sender_type(s: &str) -> Result { + match s { + "human" => Ok(SenderType::Human), + "persona" => Ok(SenderType::Persona), + "agent" => Ok(SenderType::Agent), + "system" => Ok(SenderType::System), + _ => Err(format!("Invalid sender_type: {s}")), + } +} + +/// Parse ConversationMessage array from a required JSON field. +fn parse_conversation_history(params: &Value, key: &str) -> Result, String> { + let arr = params.get(key) + .and_then(|v| v.as_array()) + .ok_or_else(|| format!("Missing {key} array"))?; + Ok(parse_messages(arr)) +} + +/// Parse ConversationMessage array from an optional JSON field. +fn parse_conversation_history_optional(params: &Value, key: &str) -> Vec { + params.get(key) + .and_then(|v| v.as_array()) + .map(|arr| parse_messages(arr)) + .unwrap_or_default() +} + +fn parse_messages(arr: &[Value]) -> Vec { + arr.iter() + .filter_map(|item| { + Some(text_analysis::ConversationMessage { + role: item.get("role")?.as_str()?.to_string(), + content: item.get("content")?.as_str()?.to_string(), + name: item.get("name").and_then(|n| n.as_str()).map(String::from), + }) + }) + .collect() +} + /// Parse an InboxMessage from JSON value. fn parse_inbox_message(value: &Value) -> Result { - let id = value.get("id") - .and_then(|v| v.as_str()) - .ok_or("Missing message.id")?; - let room_id = value.get("room_id") - .and_then(|v| v.as_str()) - .ok_or("Missing message.room_id")?; - let sender_id = value.get("sender_id") - .and_then(|v| v.as_str()) - .ok_or("Missing message.sender_id")?; - let sender_name = value.get("sender_name") - .and_then(|v| v.as_str()) - .ok_or("Missing message.sender_name")?; - let sender_type_str = value.get("sender_type") - .and_then(|v| v.as_str()) - .ok_or("Missing message.sender_type")?; - let content = value.get("content") - .and_then(|v| v.as_str()) - .ok_or("Missing message.content")?; - let timestamp = value.get("timestamp") - .and_then(|v| v.as_u64()) - .ok_or("Missing message.timestamp")?; - let priority = value.get("priority") - .and_then(|v| v.as_f64()) - .map(|p| p as f32) - .unwrap_or(0.5); + let p = Params::new(value); Ok(InboxMessage { - id: Uuid::parse_str(id).map_err(|e| format!("Invalid id: {e}"))?, - room_id: Uuid::parse_str(room_id).map_err(|e| format!("Invalid room_id: {e}"))?, - sender_id: Uuid::parse_str(sender_id).map_err(|e| format!("Invalid sender_id: {e}"))?, - sender_name: sender_name.to_string(), - sender_type: match sender_type_str { - "human" => SenderType::Human, - "persona" => SenderType::Persona, - "agent" => SenderType::Agent, - "system" => SenderType::System, - _ => return Err(format!("Invalid sender_type: {}", sender_type_str)), - }, - content: content.to_string(), - timestamp, - priority, - source_modality: value.get("source_modality") - .and_then(|v| v.as_str()) - .map(|m| match m { - "voice" => Modality::Voice, - _ => Modality::Chat, - }), - voice_session_id: value.get("voice_session_id") - .and_then(|v| v.as_str()) + id: p.uuid("id")?, + room_id: p.uuid("room_id")?, + sender_id: p.uuid("sender_id")?, + sender_name: p.str("sender_name")?.to_string(), + sender_type: parse_sender_type(p.str("sender_type")?)?, + content: p.str("content")?.to_string(), + timestamp: p.u64("timestamp")?, + priority: p.f32_or("priority", 0.5), + source_modality: p.str_opt("source_modality").map(|m| match m { + "voice" => Modality::Voice, + _ => Modality::Chat, + }), + voice_session_id: p.str_opt("voice_session_id") .and_then(|s| Uuid::parse_str(s).ok()), }) } diff --git a/src/debug/jtag/workers/continuum-core/src/modules/data.rs b/src/debug/jtag/workers/continuum-core/src/modules/data.rs index 54266f1cf..087af0e66 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/data.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/data.rs @@ -107,7 +107,7 @@ impl DataModule { /// Events follow pattern: data:{collection}:{action} /// Actions: created, updated, deleted, batch fn publish_event(&self, collection: &str, action: &str, payload: serde_json::Value) { - let ctx_guard = self.context.read().unwrap(); + let ctx_guard = self.context.read().unwrap_or_else(|e| e.into_inner()); if let Some(ctx) = ctx_guard.as_ref() { let event_name = format!("data:{}:{}", collection, action); ctx.bus.publish_async_only(&event_name, payload); @@ -120,7 +120,7 @@ impl DataModule { if duration_ms < 50 { return; } - let ctx_guard = self.context.read().unwrap(); + let ctx_guard = self.context.read().unwrap_or_else(|e| e.into_inner()); if let Some(ctx) = ctx_guard.as_ref() { let logger = ctx.logger("data"); logger.timing_with_meta( @@ -180,6 +180,7 @@ impl ServiceModule for DataModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: None, } } @@ -191,7 +192,7 @@ impl ServiceModule for DataModule { ctx.compute.clone(), ctx.runtime.clone(), )); - *self.context.write().unwrap() = Some(ctx_arc); + *self.context.write().unwrap_or_else(|e| e.into_inner()) = Some(ctx_arc); log_info!("data", "init", "DataModule initialized with event bus"); Ok(()) } @@ -484,7 +485,7 @@ impl DataModule { })); } - Ok(CommandResult::Json(serde_json::to_value(result).unwrap())) + CommandResult::json(&result) } async fn handle_read(&self, params: Value) -> Result { @@ -501,7 +502,7 @@ impl DataModule { // Log slow reads to module log file self.log_slow_query("read", ¶ms.collection, total_ms); - Ok(CommandResult::Json(serde_json::to_value(result).unwrap())) + CommandResult::json(&result) } async fn handle_update(&self, params: Value) -> Result { @@ -532,7 +533,7 @@ impl DataModule { })); } - Ok(CommandResult::Json(serde_json::to_value(result).unwrap())) + CommandResult::json(&result) } async fn handle_delete(&self, params: Value) -> Result { @@ -553,7 +554,7 @@ impl DataModule { })); } - Ok(CommandResult::Json(serde_json::to_value(result).unwrap())) + CommandResult::json(&result) } async fn handle_query(&self, params: Value) -> Result { @@ -582,7 +583,7 @@ impl DataModule { // Log slow queries to module log file self.log_slow_query("query", ¶ms.collection, total_ms); - Ok(CommandResult::Json(serde_json::to_value(result).unwrap())) + CommandResult::json(&result) } async fn handle_query_with_join(&self, params: Value) -> Result { @@ -602,7 +603,7 @@ impl DataModule { let adapter = self.get_adapter(¶ms.db_path).await?; let result = adapter.query_with_join(query).await; - Ok(CommandResult::Json(serde_json::to_value(result).unwrap())) + CommandResult::json(&result) } async fn handle_count(&self, params: Value) -> Result { @@ -636,7 +637,7 @@ impl DataModule { params.collection, total_ms, adapter_ms, count_ms, result.success); } - Ok(CommandResult::Json(serde_json::to_value(result).unwrap())) + CommandResult::json(&result) } async fn handle_batch(&self, params: Value) -> Result { @@ -656,7 +657,7 @@ impl DataModule { })); } - Ok(CommandResult::Json(serde_json::to_value(result).unwrap())) + CommandResult::json(&result) } async fn handle_ensure_schema(&self, params: Value) -> Result { @@ -666,7 +667,7 @@ impl DataModule { let adapter = self.get_adapter(¶ms.db_path).await?; let result = adapter.ensure_schema(params.schema).await; - Ok(CommandResult::Json(serde_json::to_value(result).unwrap())) + CommandResult::json(&result) } async fn handle_list_collections(&self, params: Value) -> Result { @@ -676,7 +677,7 @@ impl DataModule { let adapter = self.get_adapter(¶ms.db_path).await?; let result = adapter.list_collections().await; - Ok(CommandResult::Json(serde_json::to_value(result).unwrap())) + CommandResult::json(&result) } async fn handle_collection_stats(&self, params: Value) -> Result { @@ -686,7 +687,7 @@ impl DataModule { let adapter = self.get_adapter(¶ms.db_path).await?; let result = adapter.collection_stats(¶ms.collection).await; - Ok(CommandResult::Json(serde_json::to_value(result).unwrap())) + CommandResult::json(&result) } async fn handle_truncate(&self, params: Value) -> Result { @@ -696,7 +697,7 @@ impl DataModule { let adapter = self.get_adapter(¶ms.db_path).await?; let result = adapter.truncate(¶ms.collection).await; - Ok(CommandResult::Json(serde_json::to_value(result).unwrap())) + CommandResult::json(&result) } async fn handle_clear_all(&self, params: Value) -> Result { @@ -706,7 +707,7 @@ impl DataModule { let adapter = self.get_adapter(¶ms.db_path).await?; let result = adapter.clear_all().await; - Ok(CommandResult::Json(serde_json::to_value(result).unwrap())) + CommandResult::json(&result) } async fn handle_capabilities(&self, params: Value) -> Result { @@ -771,7 +772,7 @@ impl DataModule { // Step 1: Try to get vectors from cache (RwLock read - concurrent) let cached_vectors: Option>> = { - let cache = self.vector_cache.read().unwrap(); + let cache = self.vector_cache.read().unwrap_or_else(|e| e.into_inner()); cache.get(&cache_key).map(|c| c.vectors.clone()) }; @@ -825,7 +826,7 @@ impl DataModule { // Store in cache { - let mut cache = self.vector_cache.write().unwrap(); + let mut cache = self.vector_cache.write().unwrap_or_else(|e| e.into_inner()); cache.insert(cache_key, VectorCache { vectors: vectors_arc.clone() }); } @@ -988,7 +989,7 @@ impl DataModule { // Invalidate vector cache for this collection since we modified an embedding { let cache_key = (params.db_path.clone(), params.collection.clone()); - let mut cache = self.vector_cache.write().unwrap(); + let mut cache = self.vector_cache.write().unwrap_or_else(|e| e.into_inner()); cache.remove(&cache_key); } @@ -996,7 +997,7 @@ impl DataModule { log_info!("data", "vector/index", "Indexed vector for {} in {}ms, success={}", params.id, total_ms, result.success); - Ok(CommandResult::Json(serde_json::to_value(result).unwrap())) + CommandResult::json(&result) } /// Get vector index statistics for a collection @@ -1049,7 +1050,7 @@ impl DataModule { // Check cache status let cache_key = (params.db_path.clone(), params.collection.clone()); let cached_count = { - let cache = self.vector_cache.read().unwrap(); + let cache = self.vector_cache.read().unwrap_or_else(|e| e.into_inner()); cache.get(&cache_key).map(|c| c.vectors.len()).unwrap_or(0) }; @@ -1082,7 +1083,7 @@ impl DataModule { let cache_key = (params.db_path.clone(), params.collection.clone()); let removed = { - let mut cache = self.vector_cache.write().unwrap(); + let mut cache = self.vector_cache.write().unwrap_or_else(|e| e.into_inner()); cache.remove(&cache_key).is_some() }; @@ -1199,7 +1200,7 @@ impl DataModule { // Invalidate vector cache since we modified embeddings { let cache_key = (params.db_path.clone(), params.collection.clone()); - let mut cache = self.vector_cache.write().unwrap(); + let mut cache = self.vector_cache.write().unwrap_or_else(|e| e.into_inner()); cache.remove(&cache_key); } diff --git a/src/debug/jtag/workers/continuum-core/src/modules/embedding.rs b/src/debug/jtag/workers/continuum-core/src/modules/embedding.rs index 11df9e008..013782af9 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/embedding.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/embedding.rs @@ -25,6 +25,8 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; use tracing::{info, warn}; +use crate::utils::params::Params; + /// Global model cache - models loaded on demand static MODEL_CACHE: OnceCell>>> = OnceCell::new(); @@ -303,7 +305,7 @@ pub fn top_k_similar( // Sort by similarity descending and take top k let mut sorted = similarities; - sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); sorted.truncate(k); sorted } @@ -397,7 +399,7 @@ pub fn detect_clusters( .sum::() / (component.len() - 1).max(1) as f32; (item, avg) }) - .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()) + .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) .map(|(item, _)| item) .unwrap_or(component[0]); @@ -410,7 +412,7 @@ pub fn detect_clusters( } // Sort by strength descending - clusters.sort_by(|a, b| b.strength.partial_cmp(&a.strength).unwrap()); + clusters.sort_by(|a, b| b.strength.partial_cmp(&a.strength).unwrap_or(std::cmp::Ordering::Equal)); clusters } @@ -486,13 +488,9 @@ impl EmbeddingModule { } fn handle_generate(&self, params: &Value) -> Result { - let texts: Vec = params.get("texts") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .ok_or("Missing or invalid 'texts' array")?; - - let model_name = params.get("model") - .and_then(|v| v.as_str()) - .unwrap_or("AllMiniLML6V2"); + let p = Params::new(params); + let texts: Vec = p.json("texts")?; + let model_name = p.str_or("model", "AllMiniLML6V2"); if texts.is_empty() { return Err("No texts provided".to_string()); @@ -583,9 +581,8 @@ impl EmbeddingModule { } fn handle_model_load(&self, params: &Value) -> Result { - let model = params.get("model") - .and_then(|v| v.as_str()) - .ok_or("Missing 'model' parameter")?; + let p = Params::new(params); + let model = p.str("model")?; let start = Instant::now(); get_or_load_model(model)?; @@ -608,9 +605,8 @@ impl EmbeddingModule { } fn handle_model_info(&self, params: &Value) -> Result { - let model = params.get("model") - .and_then(|v| v.as_str()) - .ok_or("Missing 'model' parameter")?; + let p = Params::new(params); + let model = p.str("model")?; let models = get_model_info_list(); match models.into_iter().find(|m| m.name == model) { @@ -622,9 +618,8 @@ impl EmbeddingModule { } fn handle_model_unload(&self, params: &Value) -> Result { - let model = params.get("model") - .and_then(|v| v.as_str()) - .ok_or("Missing 'model' parameter")?; + let p = Params::new(params); + let model = p.str("model")?; let cache = get_model_cache(); let mut models = cache.lock().map_err(|e| format!("Lock error: {e}"))?; @@ -642,13 +637,9 @@ impl EmbeddingModule { /// Handle embedding/similarity - compute cosine similarity between two embeddings fn handle_similarity(&self, params: &Value) -> Result { - let a: Vec = params.get("a") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .ok_or("Missing or invalid 'a' vector")?; - - let b: Vec = params.get("b") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .ok_or("Missing or invalid 'b' vector")?; + let p = Params::new(params); + let a: Vec = p.json("a")?; + let b: Vec = p.json("b")?; if a.len() != b.len() { return Err(format!("Dimension mismatch: {} vs {}", a.len(), b.len())); @@ -667,9 +658,8 @@ impl EmbeddingModule { /// Takes an array of embeddings, returns lower-triangular similarity matrix. /// For n embeddings, returns n*(n-1)/2 similarity values. fn handle_similarity_matrix(&self, params: &Value) -> Result { - let embeddings: Vec> = params.get("embeddings") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .ok_or("Missing or invalid 'embeddings' array")?; + let p = Params::new(params); + let embeddings: Vec> = p.json("embeddings")?; let n = embeddings.len(); if n < 2 { @@ -725,21 +715,11 @@ impl EmbeddingModule { /// Takes a query embedding and array of target embeddings, returns indices /// and similarities of top-k matches. Parallelized with Rayon. fn handle_top_k(&self, params: &Value) -> Result { - let query: Vec = params.get("query") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .ok_or("Missing or invalid 'query' vector")?; - - let targets: Vec> = params.get("targets") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .ok_or("Missing or invalid 'targets' array")?; - - let k = params.get("k") - .and_then(|v| v.as_u64()) - .unwrap_or(10) as usize; - - let threshold = params.get("threshold") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) as f32; + let p = Params::new(params); + let query: Vec = p.json("query")?; + let targets: Vec> = p.json("targets")?; + let k = p.u64_or("k", 10) as usize; + let threshold = p.f64_or("threshold", 0.0) as f32; if targets.is_empty() { return Ok(CommandResult::Json(json!({ @@ -827,17 +807,10 @@ impl EmbeddingModule { /// Takes embeddings and clustering parameters, returns cluster assignments. /// Full clustering algorithm in Rust (similarity matrix + connected components). fn handle_cluster(&self, params: &Value) -> Result { - let embeddings: Vec> = params.get("embeddings") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .ok_or("Missing or invalid 'embeddings' array")?; - - let min_similarity = params.get("minSimilarity") - .and_then(|v| v.as_f64()) - .unwrap_or(0.7) as f32; - - let min_cluster_size = params.get("minClusterSize") - .and_then(|v| v.as_u64()) - .unwrap_or(2) as usize; + let p = Params::new(params); + let embeddings: Vec> = p.json("embeddings")?; + let min_similarity = p.f64_or("minSimilarity", 0.7) as f32; + let min_cluster_size = p.u64_or("minClusterSize", 2) as usize; let n = embeddings.len(); if n < min_cluster_size { @@ -897,6 +870,7 @@ impl ServiceModule for EmbeddingModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: None, } } diff --git a/src/debug/jtag/workers/continuum-core/src/modules/health.rs b/src/debug/jtag/workers/continuum-core/src/modules/health.rs index 9d8795ac8..3c70ad684 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/health.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/health.rs @@ -32,6 +32,7 @@ impl ServiceModule for HealthModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: None, } } diff --git a/src/debug/jtag/workers/continuum-core/src/modules/logger.rs b/src/debug/jtag/workers/continuum-core/src/modules/logger.rs index 87be80aa9..162836023 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/logger.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/logger.rs @@ -218,7 +218,7 @@ type HeaderTracker = Arc>>; /// - `system/{component}` → .continuum/jtag/logs/system/{component}.log /// - `modules/{module}` → .continuum/jtag/logs/modules/{module}.log /// - `personas/{uniqueId}/{subsystem}` → .continuum/personas/{uniqueId}/logs/{subsystem}.log -/// - `sentinels/{handle}/{stream}` → .sentinel-workspaces/{handle}/logs/{stream}.log +/// - `sentinels/{handle}/{stream}` → .continuum/jtag/logs/system/sentinels/{handle}/{stream}.log /// - `daemons/{name}` → .continuum/jtag/logs/system/daemons/{name}.log /// - Anything else → .continuum/jtag/logs/system/{category}.log (legacy fallback) fn resolve_log_path(category: &str, log_dir: &str) -> PathBuf { @@ -233,13 +233,13 @@ fn resolve_log_path(category: &str, log_dir: &str) -> PathBuf { ["personas", unique_id] => { PathBuf::from(format!(".continuum/personas/{unique_id}/logs/general.log")) } - // sentinels/{handle}/{stream} → .sentinel-workspaces/{handle}/logs/{stream}.log + // sentinels/{handle}/{stream} → .continuum/jtag/logs/system/sentinels/{handle}/{stream}.log ["sentinels", handle, stream] => { - PathBuf::from(format!(".sentinel-workspaces/{handle}/logs/{stream}.log")) + PathBuf::from(format!(".continuum/jtag/logs/system/sentinels/{handle}/{stream}.log")) } - // sentinels/{handle} → .sentinel-workspaces/{handle}/logs/execution.log + // sentinels/{handle} → .continuum/jtag/logs/system/sentinels/{handle}/execution.log ["sentinels", handle] => { - PathBuf::from(format!(".sentinel-workspaces/{handle}/logs/execution.log")) + PathBuf::from(format!(".continuum/jtag/logs/system/sentinels/{handle}/execution.log")) } // modules/{module} → {log_dir}/modules/{module}.log ["modules", module] => { @@ -268,17 +268,17 @@ fn ensure_file_handle( file_cache: &FileCache, headers_written: &HeaderTracker, ) -> std::io::Result<()> { - let mut cache = file_cache.lock().unwrap(); + let mut cache = file_cache.lock().unwrap_or_else(|e| e.into_inner()); // Check if cached file was deleted if let Some(existing) = cache.get(category) { let file_deleted = { - let file = existing.lock().unwrap(); + let file = existing.lock().unwrap_or_else(|e| e.into_inner()); file.metadata().is_err() }; if file_deleted { cache.remove(category); - headers_written.lock().unwrap().remove(category); + headers_written.lock().unwrap_or_else(|e| e.into_inner()).remove(category); } } @@ -308,7 +308,7 @@ fn write_log_message( ensure_file_handle(&payload.category, &log_file_path, file_cache, headers_written)?; let mut total_bytes = 0; - let needs_header = !headers_written.lock().unwrap().contains(&payload.category); + let needs_header = !headers_written.lock().unwrap_or_else(|e| e.into_inner()).contains(&payload.category); if needs_header { total_bytes += write_header( @@ -363,27 +363,31 @@ fn write_header( let bytes = header.len(); let locked_file = { - let cache = file_cache.lock().unwrap(); - cache.get(category).unwrap().clone() + let cache = file_cache.lock().unwrap_or_else(|e| e.into_inner()); + cache.get(category) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, format!("No file handle for {category}")))? + .clone() }; { - let mut file = locked_file.lock().unwrap(); + let mut file = locked_file.lock().unwrap_or_else(|e| e.into_inner()); file.write_all(header.as_bytes())?; } - headers_written.lock().unwrap().insert(category.to_string()); + headers_written.lock().unwrap_or_else(|e| e.into_inner()).insert(category.to_string()); Ok(bytes) } fn write_entry(category: &str, log_entry: &str, file_cache: &FileCache) -> std::io::Result { let locked_file = { - let cache = file_cache.lock().unwrap(); - cache.get(category).unwrap().clone() + let cache = file_cache.lock().unwrap_or_else(|e| e.into_inner()); + cache.get(category) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, format!("No file handle for {category}")))? + .clone() }; { - let mut file = locked_file.lock().unwrap(); + let mut file = locked_file.lock().unwrap_or_else(|e| e.into_inner()); file.write_all(log_entry.as_bytes())?; } @@ -408,12 +412,12 @@ fn format_log_entry(payload: &WriteLogPayload, timestamp: &str) -> String { fn flush_all(file_cache: &FileCache) { let handles: Vec = { - let cache = file_cache.lock().unwrap(); + let cache = file_cache.lock().unwrap_or_else(|e| e.into_inner()); cache.values().cloned().collect() }; for locked_file in handles { - let mut file = locked_file.lock().unwrap(); + let mut file = locked_file.lock().unwrap_or_else(|e| e.into_inner()); let _ = file.flush(); } } @@ -560,8 +564,16 @@ impl LoggerModule { } fn handle_write(&self, params: Value) -> Result { + // WorkerClient sends data nested under "payload" field, extract it + // ORMRustClient sends data at top level - support both patterns + let payload_value = if let Some(nested) = params.get("payload") { + nested.clone() + } else { + params.clone() + }; + let payload: WriteLogPayload = - serde_json::from_value(params).map_err(|e| format!("Invalid payload: {e}"))?; + serde_json::from_value(payload_value).map_err(|e| format!("Invalid payload: {e}"))?; self.log_tx .send(payload) @@ -569,20 +581,20 @@ impl LoggerModule { self.requests_processed.fetch_add(1, Ordering::Relaxed); - Ok(CommandResult::Json(serde_json::to_value(WriteLogResult { + CommandResult::json(&WriteLogResult { bytes_written: 0, // Actual write happens in background - }).unwrap())) + }) } fn handle_ping(&self) -> Result { - let active_categories = self.file_cache.lock().unwrap().len(); + let active_categories = self.file_cache.lock().unwrap_or_else(|e| e.into_inner()).len(); - Ok(CommandResult::Json(serde_json::to_value(LoggerPingResult { + CommandResult::json(&LoggerPingResult { uptime_ms: self.started_at.elapsed().as_millis() as u64, requests_processed: self.requests_processed.load(Ordering::Relaxed), active_categories, pending_writes: self.pending_writes.load(Ordering::Relaxed) as usize, - }).unwrap())) + }) } } @@ -602,6 +614,7 @@ impl ServiceModule for LoggerModule { event_subscriptions: &[], needs_dedicated_thread: false, // Writer thread is internal max_concurrency: 0, + tick_interval: None, } } diff --git a/src/debug/jtag/workers/continuum-core/src/modules/mcp.rs b/src/debug/jtag/workers/continuum-core/src/modules/mcp.rs index 8b2090861..058d73772 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/mcp.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/mcp.rs @@ -11,6 +11,7 @@ //! - mcp/search-tools: Search tools by keyword //! - mcp/tool-help: Get detailed help for a specific tool +use crate::utils::params::Params; use crate::runtime::{ CommandResult, ModuleConfig, ModulePriority, ServiceModule, ModuleContext, CommandSchema, ParamSchema, @@ -398,6 +399,7 @@ impl ServiceModule for MCPModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: None, } } @@ -428,13 +430,9 @@ impl ServiceModule for MCPModule { } "mcp/search-tools" => { - let query = params.get("query") - .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: query")?; - - let limit = params.get("limit") - .and_then(|v| v.as_u64()) - .unwrap_or(10) as usize; + let p = Params::new(¶ms); + let query = p.str("query")?; + let limit = p.u64_or("limit", 10) as usize; let tools = self.tools_cache.read(); let tools = tools.as_ref().ok_or("Tools cache not initialized")?; @@ -450,9 +448,8 @@ impl ServiceModule for MCPModule { } "mcp/tool-help" => { - let tool_name = params.get("tool") - .and_then(|v| v.as_str()) - .ok_or("Missing required parameter: tool")?; + let p = Params::new(¶ms); + let tool_name = p.str("tool")?; let tools = self.tools_cache.read(); let tools = tools.as_ref().ok_or("Tools cache not initialized")?; diff --git a/src/debug/jtag/workers/continuum-core/src/modules/memory.rs b/src/debug/jtag/workers/continuum-core/src/modules/memory.rs index 383c711b9..725b97f20 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/memory.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/memory.rs @@ -12,6 +12,7 @@ use crate::memory::{ MultiLayerRecallRequest, ConsciousnessContextRequest, }; use crate::logging::TimingGuard; +use crate::utils::params::Params; use crate::{log_info, log_debug}; use async_trait::async_trait; use serde_json::Value; @@ -50,6 +51,7 @@ impl ServiceModule for MemoryModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: None, } } @@ -62,21 +64,14 @@ impl ServiceModule for MemoryModule { command: &str, params: Value, ) -> Result { + let p = Params::new(¶ms); + match command { "memory/load-corpus" => { let _timer = TimingGuard::new("module", "memory_load_corpus"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - - let memories: Vec = params.get("memories") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - - let events: Vec = params.get("events") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); + let persona_id = p.str("persona_id")?; + let memories: Vec = p.json_or("memories"); + let events: Vec = p.json_or("events"); let resp = self.state.memory_manager.load_corpus(persona_id, memories, events); @@ -86,33 +81,16 @@ impl ServiceModule for MemoryModule { persona_id, resp.memory_count, resp.embedded_memory_count, resp.timeline_event_count, resp.embedded_event_count, resp.load_time_ms ); - Ok(CommandResult::Json(serde_json::to_value(&resp).unwrap_or_default())) } "memory/multi-layer-recall" => { let _timer = TimingGuard::new("module", "memory_multi_layer_recall"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - - let query_text = params.get("query_text") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let room_id = params.get("room_id") - .and_then(|v| v.as_str()) - .ok_or("Missing room_id")? - .to_string(); - - let max_results = params.get("max_results") - .and_then(|v| v.as_u64()) - .map(|n| n as usize) - .unwrap_or(10); - - let layers: Option> = params.get("layers") - .and_then(|v| serde_json::from_value(v.clone()).ok()); + let persona_id = p.str("persona_id")?; + let query_text = p.str_opt("query_text").map(String::from); + let room_id = p.str("room_id")?.to_string(); + let max_results = p.u64_or("max_results", 10) as usize; + let layers: Option> = p.json_opt("layers"); let req = MultiLayerRecallRequest { query_text, @@ -121,39 +99,24 @@ impl ServiceModule for MemoryModule { layers, }; - match self.state.memory_manager.multi_layer_recall(persona_id, &req) { - Ok(resp) => { - log_info!( - "module", "memory_multi_layer_recall", - "Multi-layer recall for {}: {} memories in {:.1}ms ({} candidates from {} layers)", - persona_id, resp.memories.len(), resp.recall_time_ms, - resp.total_candidates, resp.layer_timings.len() - ); - Ok(CommandResult::Json(serde_json::to_value(&resp).unwrap_or_default())) - } - Err(e) => Err(format!("memory/multi-layer-recall failed: {e}")), - } + let resp = self.state.memory_manager.multi_layer_recall(persona_id, &req) + .map_err(|e| format!("memory/multi-layer-recall failed: {e}"))?; + + log_info!( + "module", "memory_multi_layer_recall", + "Multi-layer recall for {}: {} memories in {:.1}ms ({} candidates from {} layers)", + persona_id, resp.memories.len(), resp.recall_time_ms, + resp.total_candidates, resp.layer_timings.len() + ); + Ok(CommandResult::Json(serde_json::to_value(&resp).unwrap_or_default())) } "memory/consciousness-context" => { let _timer = TimingGuard::new("module", "memory_consciousness_context"); - - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - - let room_id = params.get("room_id") - .and_then(|v| v.as_str()) - .ok_or("Missing room_id")? - .to_string(); - - let current_message = params.get("current_message") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let skip_semantic_search = params.get("skip_semantic_search") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + let persona_id = p.str("persona_id")?; + let room_id = p.str("room_id")?.to_string(); + let current_message = p.str_opt("current_message").map(String::from); + let skip_semantic_search = p.bool_or("skip_semantic_search", false); let req = ConsciousnessContextRequest { room_id, @@ -161,57 +124,37 @@ impl ServiceModule for MemoryModule { skip_semantic_search, }; - match self.state.memory_manager.consciousness_context(persona_id, &req) { - Ok(resp) => { - log_info!( - "module", "memory_consciousness_context", - "Consciousness context for {}: {:.1}ms, {} cross-context events, {} intentions", - persona_id, resp.build_time_ms, resp.cross_context_event_count, resp.active_intention_count - ); - Ok(CommandResult::Json(serde_json::to_value(&resp).unwrap_or_default())) - } - Err(e) => Err(format!("memory/consciousness-context failed: {e}")), - } + let resp = self.state.memory_manager.consciousness_context(persona_id, &req) + .map_err(|e| format!("memory/consciousness-context failed: {e}"))?; + + log_info!( + "module", "memory_consciousness_context", + "Consciousness context for {}: {:.1}ms, {} cross-context events, {} intentions", + persona_id, resp.build_time_ms, resp.cross_context_event_count, resp.active_intention_count + ); + Ok(CommandResult::Json(serde_json::to_value(&resp).unwrap_or_default())) } "memory/append-memory" => { let _timer = TimingGuard::new("module", "memory_append_memory"); + let persona_id = p.str("persona_id")?; + let memory: CorpusMemory = p.json("memory")?; - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - - let memory: CorpusMemory = params.get("memory") - .ok_or("Missing memory") - .and_then(|v| serde_json::from_value(v.clone()).map_err(|_| "Invalid memory format"))?; - - match self.state.memory_manager.append_memory(persona_id, memory) { - Ok(()) => { - log_debug!("module", "memory_append_memory", "Appended memory to corpus for {}", persona_id); - Ok(CommandResult::Json(serde_json::json!({ "appended": true }))) - } - Err(e) => Err(format!("memory/append-memory failed: {e}")), - } + self.state.memory_manager.append_memory(persona_id, memory) + .map_err(|e| format!("memory/append-memory failed: {e}"))?; + log_debug!("module", "memory_append_memory", "Appended memory to corpus for {}", persona_id); + Ok(CommandResult::Json(serde_json::json!({ "appended": true }))) } "memory/append-event" => { let _timer = TimingGuard::new("module", "memory_append_event"); + let persona_id = p.str("persona_id")?; + let event: CorpusTimelineEvent = p.json("event")?; - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - - let event: CorpusTimelineEvent = params.get("event") - .ok_or("Missing event") - .and_then(|v| serde_json::from_value(v.clone()).map_err(|_| "Invalid event format"))?; - - match self.state.memory_manager.append_event(persona_id, event) { - Ok(()) => { - log_debug!("module", "memory_append_event", "Appended event to corpus for {}", persona_id); - Ok(CommandResult::Json(serde_json::json!({ "appended": true }))) - } - Err(e) => Err(format!("memory/append-event failed: {e}")), - } + self.state.memory_manager.append_event(persona_id, event) + .map_err(|e| format!("memory/append-event failed: {e}"))?; + log_debug!("module", "memory_append_event", "Appended event to corpus for {}", persona_id); + Ok(CommandResult::Json(serde_json::json!({ "appended": true }))) } _ => Err(format!("Unknown memory command: {command}")), diff --git a/src/debug/jtag/workers/continuum-core/src/modules/mod.rs b/src/debug/jtag/workers/continuum-core/src/modules/mod.rs index c392296c7..e95ab7a28 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/mod.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/mod.rs @@ -24,3 +24,5 @@ pub mod runtime_control; pub mod mcp; pub mod agent; pub mod ai_provider; +pub mod sentinel; +pub mod tool_parsing; diff --git a/src/debug/jtag/workers/continuum-core/src/modules/models.rs b/src/debug/jtag/workers/continuum-core/src/modules/models.rs index c422e63dd..da49c4959 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/models.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/models.rs @@ -8,6 +8,7 @@ use crate::runtime::{ServiceModule, ModuleConfig, ModulePriority, CommandResult, ModuleContext}; use crate::models::{ProviderConfig, discover_all}; use crate::logging::TimingGuard; +use crate::utils::params::Params; use crate::log_info; use async_trait::async_trait; use serde_json::Value; @@ -31,6 +32,7 @@ impl ServiceModule for ModelsModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: None, } } @@ -46,11 +48,8 @@ impl ServiceModule for ModelsModule { match command { "models/discover" => { let _timer = TimingGuard::new("module", "models_discover"); - - // Parse providers from params - let providers: Vec = params.get("providers") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); + let p = Params::new(¶ms); + let providers: Vec = p.json_or("providers"); let provider_count = providers.len(); diff --git a/src/debug/jtag/workers/continuum-core/src/modules/rag.rs b/src/debug/jtag/workers/continuum-core/src/modules/rag.rs index c6341b130..65a76bc2f 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/rag.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/rag.rs @@ -668,6 +668,7 @@ impl ServiceModule for RagModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: None, } } diff --git a/src/debug/jtag/workers/continuum-core/src/modules/runtime_control.rs b/src/debug/jtag/workers/continuum-core/src/modules/runtime_control.rs index 5c2c7b6e6..c017fcbdb 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/runtime_control.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/runtime_control.rs @@ -41,6 +41,7 @@ impl ServiceModule for RuntimeModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: None, } } @@ -87,7 +88,7 @@ impl ServiceModule for RuntimeModule { .get_metrics(module_name) .ok_or_else(|| format!("Module '{}' not found", module_name))?; - Ok(CommandResult::Json(serde_json::to_value(metrics.stats()).unwrap())) + CommandResult::json(&metrics.stats()) } // Get recent slow commands diff --git a/src/debug/jtag/workers/continuum-core/src/modules/search.rs b/src/debug/jtag/workers/continuum-core/src/modules/search.rs index 7908ec9d8..5145b3802 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/search.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/search.rs @@ -14,6 +14,7 @@ //! Migration from: workers/search (258 lines main.rs + algorithms) use crate::runtime::{CommandResult, ModuleConfig, ModuleContext, ModulePriority, ServiceModule}; +use crate::utils::params::Params; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -474,16 +475,15 @@ impl SearchModule { } fn handle_execute(&self, params: Value) -> Result { - let algorithm = params.get("algorithm").and_then(|v| v.as_str()).unwrap_or("bm25"); - let query = params.get("query").and_then(|v| v.as_str()).ok_or("Missing query")?; - let corpus: Vec = params.get("corpus") - .and_then(|v| v.as_array()) - .ok_or("Missing corpus")? + let p = Params::new(¶ms); + let algorithm = p.str_or("algorithm", "bm25"); + let query = p.str("query")?; + let corpus: Vec = p.array("corpus")? .iter() .filter_map(|v| v.as_str().map(String::from)) .collect(); - let algo_params: HashMap = params.get("params") + let algo_params: HashMap = p.value("params") .and_then(|v| v.as_object()) .map(|o| o.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) .unwrap_or_default(); @@ -528,7 +528,8 @@ impl SearchModule { } fn handle_params(&self, params: Value) -> Result { - let algorithm = params.get("algorithm").and_then(|v| v.as_str()).ok_or("Missing algorithm")?; + let p = Params::new(¶ms); + let algorithm = p.str("algorithm")?; let algo = self.registry.create(algorithm).ok_or_else(|| format!("Unknown algorithm: {algorithm}"))?; // Build params with current values using get_param() @@ -563,6 +564,7 @@ impl ServiceModule for SearchModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: None, } } diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/executor.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/executor.rs new file mode 100644 index 000000000..ae2fe660e --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/executor.rs @@ -0,0 +1,767 @@ +//! Pipeline and isolated process execution +//! +//! Single implementations replacing the duplicated static/instance methods. + +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Instant; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; +use tokio::sync::mpsc; + +use crate::runtime::{self, message_bus::MessageBus, ModuleRegistry}; +use super::steps; +use super::types::{ExecutionContext, Pipeline, PipelineContext, PipelineResult, StepResult, step_type_name}; + +/// Execute a multi-step pipeline with LLM, conditions, loops +pub async fn execute_pipeline( + logs_base_dir: PathBuf, + pipeline: Pipeline, + handle_id: String, + working_dir: PathBuf, + bus: Option>, + registry: Option>, +) -> Result<(i32, String), String> { + let log = runtime::logger("sentinel"); + + let registry = registry.ok_or("Pipeline execution requires module registry")?; + let start_time = Instant::now(); + let pipeline_name = pipeline.name.as_deref().unwrap_or("unnamed"); + + // Create logs directory for this pipeline + let logs_dir = logs_base_dir.join(&handle_id); + if let Err(e) = tokio::fs::create_dir_all(&logs_dir).await { + log.warn(&format!("[{handle_id}] Failed to create logs dir: {e}")); + } + let steps_log_path = logs_dir.join("steps.jsonl"); + + log.info(&format!("[{}] Pipeline '{}' starting with {} steps", + handle_id, pipeline_name, pipeline.steps.len())); + + // Create execution context + let mut ctx = ExecutionContext { + step_results: Vec::new(), + inputs: pipeline.inputs.clone(), + working_dir: pipeline.working_dir.clone().map(PathBuf::from).unwrap_or(working_dir), + named_outputs: HashMap::new(), + }; + + // Execute steps + let mut last_output = String::new(); + let mut failed = false; + let mut error_msg: Option = None; + + for (i, step) in pipeline.steps.iter().enumerate() { + let step_type = step_type_name(step); + log.info(&format!("[{}] Step {}/{}: {}", handle_id, i + 1, pipeline.steps.len(), step_type)); + + // Emit step progress + if let Some(ref bus) = bus { + bus.publish_async_only(&format!("sentinel:{handle_id}:progress"), json!({ + "handle": handle_id, + "step": i, + "totalSteps": pipeline.steps.len(), + "stepType": step_type, + "phase": "executing", + })); + } + + let pipeline_ctx = PipelineContext { + handle_id: &handle_id, + registry: ®istry, + bus: bus.as_ref(), + }; + + match steps::execute_step(step, i, &mut ctx, &pipeline_ctx).await { + Ok(result) => { + if result.success { + last_output = result.output.clone().unwrap_or_default(); + log.info(&format!("[{handle_id}] Step {i} succeeded")); + } else { + log.error(&format!("[{handle_id}] Step {i} failed: {:?}", result.error)); + failed = true; + error_msg = result.error.clone(); + } + // Write step result to steps.jsonl + if let Ok(json_line) = serde_json::to_string(&result) { + if let Ok(mut file) = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&steps_log_path) + .await + { + let _ = file.write_all(format!("{json_line}\n").as_bytes()).await; + } + } + ctx.step_results.push(result); + if failed { + break; + } + } + Err(e) => { + log.error(&format!("[{handle_id}] Step {i} error: {e}")); + failed = true; + error_msg = Some(e.clone()); + let error_result = StepResult { + step_index: i, + step_type: step_type.to_string(), + success: false, + duration_ms: 0, + output: None, + error: Some(e), + exit_code: None, + data: Value::Null, + }; + if let Ok(json_line) = serde_json::to_string(&error_result) { + if let Ok(mut file) = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&steps_log_path) + .await + { + let _ = file.write_all(format!("{json_line}\n").as_bytes()).await; + } + } + ctx.step_results.push(error_result); + break; + } + } + } + + let total_duration_ms = start_time.elapsed().as_millis() as u64; + + // Emit pipeline completion + if let Some(ref bus) = bus { + bus.publish_async_only("sentinel:pipeline:complete", json!({ + "handle": handle_id, + "name": pipeline_name, + "success": !failed, + "stepsCompleted": ctx.step_results.len(), + "stepsTotal": pipeline.steps.len(), + "durationMs": total_duration_ms, + })); + } + + log.info(&format!("[{}] Pipeline '{}' completed: success={}, duration={}ms", + handle_id, pipeline_name, !failed, total_duration_ms)); + + if failed { + Err(error_msg.unwrap_or_else(|| "Pipeline failed".to_string())) + } else { + Ok((0, last_output)) + } +} + +/// Configuration for an isolated child process execution +pub struct IsolatedProcessConfig { + pub logs_base_dir: PathBuf, + pub handle_id: String, + pub command: String, + pub args: Vec, + pub working_dir: PathBuf, + pub env: HashMap, +} + +/// Execute an isolated child process with stdout/stderr streaming to logs +pub async fn execute_isolated( + config: IsolatedProcessConfig, + cancel_rx: mpsc::Receiver<()>, + bus: Option>, +) -> Result<(i32, String), String> { + let IsolatedProcessConfig { logs_base_dir, handle_id, command, args, working_dir, env } = config; + let log = runtime::logger("sentinel"); + + // Create logs directory + let logs_dir = logs_base_dir.join(&handle_id); + tokio::fs::create_dir_all(&logs_dir) + .await + .map_err(|e| format!("Failed to create logs dir: {e}"))?; + + let stdout_path = logs_dir.join("stdout.log"); + let stderr_path = logs_dir.join("stderr.log"); + let combined_path = logs_dir.join("combined.log"); + + log.info(&format!( + "Executing sentinel {handle_id}: {command} {args:?} in {working_dir:?}" + )); + + // Spawn child process + let mut child = Command::new(&command) + .args(&args) + .current_dir(&working_dir) + .envs(&env) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| format!("Failed to spawn process: {e}"))?; + + // Open log files + let stdout_file = tokio::fs::File::create(&stdout_path) + .await + .map_err(|e| format!("Failed to create stdout log: {e}"))?; + let stderr_file = tokio::fs::File::create(&stderr_path) + .await + .map_err(|e| format!("Failed to create stderr log: {e}"))?; + let combined_file = tokio::fs::File::create(&combined_path) + .await + .map_err(|e| format!("Failed to create combined log: {e}"))?; + + let mut stdout_writer = tokio::io::BufWriter::new(stdout_file); + let mut stderr_writer = tokio::io::BufWriter::new(stderr_file); + let mut combined_writer = tokio::io::BufWriter::new(combined_file); + + let stdout = child.stdout.take() + .ok_or_else(|| "Failed to capture stdout — not piped".to_string())?; + let stderr = child.stderr.take() + .ok_or_else(|| "Failed to capture stderr — not piped".to_string())?; + + let mut stdout_reader = BufReader::new(stdout).lines(); + let mut stderr_reader = BufReader::new(stderr).lines(); + + let mut cancel_rx = cancel_rx; + let mut last_output = String::new(); + let mut stdout_closed = false; + let mut stderr_closed = false; + + loop { + tokio::select! { + biased; + + _ = cancel_rx.recv() => { + log.warn(&format!("Sentinel {handle_id} cancelled")); + child.kill().await.ok(); + return Err("Cancelled".to_string()); + } + + line = stdout_reader.next_line(), if !stdout_closed => { + match line { + Ok(Some(line)) => { + let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + let timestamped = format!("[{timestamp}] [STDOUT] {line}\n"); + stdout_writer.write_all(line.as_bytes()).await.ok(); + stdout_writer.write_all(b"\n").await.ok(); + combined_writer.write_all(timestamped.as_bytes()).await.ok(); + last_output = line.clone(); + + if let Some(ref bus) = bus { + bus.publish_async_only(&format!("sentinel:{handle_id}:log"), json!({ + "handle": handle_id, + "stream": "stdout", + "chunk": line, + "timestamp": timestamp, + "sourceType": "stdout", + })); + } + } + Ok(None) => { stdout_closed = true; } + Err(e) => { + log.warn(&format!("stdout read error: {e}")); + stdout_closed = true; + } + } + } + + line = stderr_reader.next_line(), if !stderr_closed => { + match line { + Ok(Some(line)) => { + let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + let timestamped = format!("[{timestamp}] [STDERR] {line}\n"); + stderr_writer.write_all(line.as_bytes()).await.ok(); + stderr_writer.write_all(b"\n").await.ok(); + combined_writer.write_all(timestamped.as_bytes()).await.ok(); + + if line.contains("error") || line.contains("Error") || line.contains("ERROR") { + log.warn(&format!("[{handle_id}] {line}")); + } + + if let Some(ref bus) = bus { + bus.publish_async_only(&format!("sentinel:{handle_id}:log"), json!({ + "handle": handle_id, + "stream": "stderr", + "chunk": line, + "timestamp": timestamp, + "sourceType": "stderr", + })); + } + } + Ok(None) => { stderr_closed = true; } + Err(e) => { + log.warn(&format!("stderr read error: {e}")); + stderr_closed = true; + } + } + } + + status = child.wait() => { + stdout_writer.flush().await.ok(); + stderr_writer.flush().await.ok(); + combined_writer.flush().await.ok(); + + match status { + Ok(exit_status) => { + let code = exit_status.code().unwrap_or(-1); + log.info(&format!("Sentinel {handle_id} exited with code {code}")); + return Ok((code, last_output)); + } + Err(e) => return Err(format!("Process wait failed: {e}")), + } + } + } + } +} + +/// Execute a pipeline directly (synchronous path, not spawned). +/// Used by the `sentinel/pipeline` command. +pub async fn execute_pipeline_direct( + logs_base_dir: &Path, + handle_id: &str, + pipeline: Pipeline, + bus: Option<&Arc>, + registry: Option<&Arc>, +) -> PipelineResult { + let log = runtime::logger("sentinel"); + let start_time = Instant::now(); + + let registry = match registry { + Some(r) => r.clone(), + None => { + return PipelineResult { + handle: handle_id.to_string(), + success: false, + total_duration_ms: 0, + steps_completed: 0, + steps_total: pipeline.steps.len(), + step_results: Vec::new(), + error: Some("SentinelModule not initialized - missing registry".to_string()), + }; + } + }; + + let pipeline_name = pipeline.name.as_deref().unwrap_or("unnamed"); + log.info(&format!("Starting pipeline '{}' (handle={}), {} steps", + pipeline_name, handle_id, pipeline.steps.len())); + + let working_dir = pipeline.working_dir.clone() + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + + let mut ctx = ExecutionContext { + step_results: Vec::new(), + inputs: pipeline.inputs.clone(), + working_dir, + named_outputs: HashMap::new(), + }; + + // Create logs directory + let logs_dir = logs_base_dir.join(handle_id); + tokio::fs::create_dir_all(&logs_dir).await.ok(); + + let mut success = true; + let mut error_msg: Option = None; + + for (i, step) in pipeline.steps.iter().enumerate() { + log.info(&format!("[{}] Executing step {}: {:?}", handle_id, i, step_type_name(step))); + + let pipeline_ctx = PipelineContext { + handle_id, + registry: ®istry, + bus, + }; + + match steps::execute_step(step, i, &mut ctx, &pipeline_ctx).await { + Ok(result) => { + if !result.success { + log.warn(&format!("[{handle_id}] Step {i} failed: {:?}", result.error)); + success = false; + error_msg = result.error.clone(); + ctx.step_results.push(result); + break; + } + ctx.step_results.push(result); + } + Err(e) => { + log.error(&format!("[{handle_id}] Step {i} error: {e}")); + success = false; + error_msg = Some(e.clone()); + ctx.step_results.push(StepResult { + step_index: i, + step_type: step_type_name(step).to_string(), + success: false, + duration_ms: 0, + output: None, + error: Some(e), + exit_code: None, + data: Value::Null, + }); + break; + } + } + } + + let total_duration_ms = start_time.elapsed().as_millis() as u64; + + // Emit completion event + if let Some(bus) = bus { + bus.publish_async_only("sentinel:pipeline:complete", json!({ + "handle": handle_id, + "name": pipeline_name, + "success": success, + "durationMs": total_duration_ms, + })); + } + + log.info(&format!("Pipeline '{pipeline_name}' completed: success={success}, duration={total_duration_ms}ms")); + + PipelineResult { + handle: handle_id.to_string(), + success, + total_duration_ms, + steps_completed: ctx.step_results.len(), + steps_total: pipeline.steps.len(), + step_results: ctx.step_results, + error: error_msg, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::sentinel::types::{Pipeline, PipelineStep}; + use crate::runtime::{ModuleRegistry, message_bus::MessageBus}; + use serde_json::json; + use std::sync::Arc; + + fn make_registry() -> Arc { + Arc::new(ModuleRegistry::new()) + } + + fn make_bus() -> Arc { + Arc::new(MessageBus::new()) + } + + /// Test a simple linear pipeline: echo a → echo b → echo c + #[tokio::test] + async fn test_linear_pipeline() { + let registry = make_registry(); + let bus = make_bus(); + let logs_dir = std::env::temp_dir().join("sentinel-test-linear"); + + let pipeline = Pipeline { + name: Some("linear-test".to_string()), + steps: vec![ + PipelineStep::Shell { cmd: "echo".into(), args: vec!["step-a".into()], timeout_secs: Some(10), working_dir: None }, + PipelineStep::Shell { cmd: "echo".into(), args: vec!["step-b".into()], timeout_secs: Some(10), working_dir: None }, + PipelineStep::Shell { cmd: "echo".into(), args: vec!["step-c".into()], timeout_secs: Some(10), working_dir: None }, + ], + working_dir: Some("/tmp".to_string()), + timeout_secs: None, + inputs: HashMap::new(), + }; + + let result = execute_pipeline_direct(&logs_dir, "test-linear", pipeline, Some(&bus), Some(®istry)).await; + + assert!(result.success); + assert_eq!(result.steps_completed, 3); + assert_eq!(result.steps_total, 3); + assert_eq!(result.step_results[0].output.as_deref(), Some("step-a\n")); + assert_eq!(result.step_results[1].output.as_deref(), Some("step-b\n")); + assert_eq!(result.step_results[2].output.as_deref(), Some("step-c\n")); + assert!(result.error.is_none()); + } + + /// Test pipeline stops on first failure + #[tokio::test] + async fn test_pipeline_stops_on_failure() { + let registry = make_registry(); + let bus = make_bus(); + let logs_dir = std::env::temp_dir().join("sentinel-test-fail"); + + let pipeline = Pipeline { + name: Some("fail-test".to_string()), + steps: vec![ + PipelineStep::Shell { cmd: "echo".into(), args: vec!["ok".into()], timeout_secs: Some(10), working_dir: None }, + PipelineStep::Shell { cmd: "/bin/sh".into(), args: vec!["-c".into(), "exit 42".into()], timeout_secs: Some(10), working_dir: None }, + PipelineStep::Shell { cmd: "echo".into(), args: vec!["never-reached".into()], timeout_secs: Some(10), working_dir: None }, + ], + working_dir: Some("/tmp".to_string()), + timeout_secs: None, + inputs: HashMap::new(), + }; + + let result = execute_pipeline_direct(&logs_dir, "test-fail", pipeline, Some(&bus), Some(®istry)).await; + + assert!(!result.success); + assert_eq!(result.steps_completed, 2); // echo ok + failing step + assert_eq!(result.steps_total, 3); + assert!(result.error.is_some()); + } + + /// Test pipeline with condition branching + #[tokio::test] + async fn test_pipeline_with_condition() { + let registry = make_registry(); + let bus = make_bus(); + let logs_dir = std::env::temp_dir().join("sentinel-test-cond"); + + let mut inputs = HashMap::new(); + inputs.insert("should_build".to_string(), json!("true")); + + let pipeline = Pipeline { + name: Some("cond-test".to_string()), + steps: vec![ + PipelineStep::Shell { cmd: "echo".into(), args: vec!["start".into()], timeout_secs: Some(10), working_dir: None }, + PipelineStep::Condition { + condition: "{{input.should_build}}".to_string(), + then_steps: vec![ + PipelineStep::Shell { cmd: "echo".into(), args: vec!["building".into()], timeout_secs: Some(10), working_dir: None }, + ], + else_steps: vec![ + PipelineStep::Shell { cmd: "echo".into(), args: vec!["skipping".into()], timeout_secs: Some(10), working_dir: None }, + ], + }, + PipelineStep::Shell { cmd: "echo".into(), args: vec!["done".into()], timeout_secs: Some(10), working_dir: None }, + ], + working_dir: Some("/tmp".to_string()), + timeout_secs: None, + inputs, + }; + + let result = execute_pipeline_direct(&logs_dir, "test-cond", pipeline, Some(&bus), Some(®istry)).await; + + assert!(result.success); + // step 0: echo start, step 1: condition (which runs echo building as substep), step 2: echo done + // But the condition step pushes its substep results into the context + // So we get: echo start → (echo building pushed by condition) → condition result → echo done + assert!(result.steps_completed >= 3); + } + + /// Test pipeline with loop and variable interpolation + #[tokio::test] + async fn test_pipeline_with_loop() { + let registry = make_registry(); + let bus = make_bus(); + let logs_dir = std::env::temp_dir().join("sentinel-test-loop"); + + let pipeline = Pipeline { + name: Some("loop-test".to_string()), + steps: vec![ + PipelineStep::Loop { + count: Some(3), + steps: vec![ + PipelineStep::Shell { + cmd: "echo".into(), + args: vec!["iteration-{{input.iteration}}".into()], + timeout_secs: Some(10), + working_dir: None, + }, + ], + while_condition: None, + until: None, + max_iterations: None, + }, + ], + working_dir: Some("/tmp".to_string()), + timeout_secs: None, + inputs: HashMap::new(), + }; + + let result = execute_pipeline_direct(&logs_dir, "test-loop", pipeline, Some(&bus), Some(®istry)).await; + + assert!(result.success); + } + + /// Test pipeline with parallel branches + #[tokio::test] + async fn test_pipeline_with_parallel() { + let registry = make_registry(); + let bus = make_bus(); + let logs_dir = std::env::temp_dir().join("sentinel-test-parallel"); + + let pipeline = Pipeline { + name: Some("parallel-test".to_string()), + steps: vec![ + PipelineStep::Shell { cmd: "echo".into(), args: vec!["before-fork".into()], timeout_secs: Some(10), working_dir: None }, + PipelineStep::Parallel { + branches: vec![ + vec![PipelineStep::Shell { cmd: "echo".into(), args: vec!["branch-a".into()], timeout_secs: Some(10), working_dir: None }], + vec![PipelineStep::Shell { cmd: "echo".into(), args: vec!["branch-b".into()], timeout_secs: Some(10), working_dir: None }], + ], + fail_fast: false, + }, + PipelineStep::Shell { cmd: "echo".into(), args: vec!["after-join".into()], timeout_secs: Some(10), working_dir: None }, + ], + working_dir: Some("/tmp".to_string()), + timeout_secs: None, + inputs: HashMap::new(), + }; + + let result = execute_pipeline_direct(&logs_dir, "test-par", pipeline, Some(&bus), Some(®istry)).await; + + assert!(result.success); + assert_eq!(result.steps_total, 3); + } + + /// Test emit + watch composition across spawned task + #[tokio::test] + async fn test_emit_watch_composition() { + let registry = make_registry(); + let bus = make_bus(); + let logs_dir = std::env::temp_dir().join("sentinel-test-emit-watch"); + + // Use parallel to run emit and watch concurrently — emit fires, watch catches + let pipeline = Pipeline { + name: Some("emit-watch-test".to_string()), + steps: vec![ + PipelineStep::Parallel { + branches: vec![ + // Branch 0: small delay then emit + vec![ + PipelineStep::Shell { + cmd: "sleep".into(), + args: vec!["0.1".into()], + timeout_secs: Some(5), + working_dir: None, + }, + PipelineStep::Emit { + event: "test:signal".to_string(), + payload: json!({"msg": "hello"}), + }, + ], + // Branch 1: watch for the event + vec![ + PipelineStep::Watch { + event: "test:signal".to_string(), + timeout_secs: Some(5), + }, + ], + ], + fail_fast: false, + }, + ], + working_dir: Some("/tmp".to_string()), + timeout_secs: None, + inputs: HashMap::new(), + }; + + let result = execute_pipeline_direct(&logs_dir, "test-ew", pipeline, Some(&bus), Some(®istry)).await; + + assert!(result.success); + } + + /// Test nested sentinel step (pipeline within pipeline) + #[tokio::test] + async fn test_nested_sentinel_pipeline() { + let registry = make_registry(); + let bus = make_bus(); + let logs_dir = std::env::temp_dir().join("sentinel-test-nested"); + + let pipeline = Pipeline { + name: Some("parent".to_string()), + steps: vec![ + PipelineStep::Shell { cmd: "echo".into(), args: vec!["parent-start".into()], timeout_secs: Some(10), working_dir: None }, + PipelineStep::Sentinel { + pipeline: Box::new(Pipeline { + name: Some("child".to_string()), + steps: vec![ + PipelineStep::Shell { cmd: "echo".into(), args: vec!["child-a".into()], timeout_secs: Some(10), working_dir: None }, + PipelineStep::Shell { cmd: "echo".into(), args: vec!["child-b".into()], timeout_secs: Some(10), working_dir: None }, + ], + working_dir: None, + timeout_secs: None, + inputs: HashMap::new(), + }), + }, + PipelineStep::Shell { cmd: "echo".into(), args: vec!["parent-end".into()], timeout_secs: Some(10), working_dir: None }, + ], + working_dir: Some("/tmp".to_string()), + timeout_secs: None, + inputs: HashMap::new(), + }; + + let result = execute_pipeline_direct(&logs_dir, "test-nested", pipeline, Some(&bus), Some(®istry)).await; + + assert!(result.success); + assert_eq!(result.steps_total, 3); + } + + /// Test pipeline with variable forwarding between steps + #[tokio::test] + async fn test_step_output_forwarding() { + let registry = make_registry(); + let bus = make_bus(); + let logs_dir = std::env::temp_dir().join("sentinel-test-fwd"); + + let pipeline = Pipeline { + name: Some("forward-test".to_string()), + steps: vec![ + // Step 0: produce output + PipelineStep::Shell { cmd: "echo".into(), args: vec!["hello-from-step-0".into()], timeout_secs: Some(10), working_dir: None }, + // Step 1: reference step 0's output via interpolation + PipelineStep::Shell { + cmd: "echo".into(), + args: vec!["got: {{steps.0.data.stdout}}".into()], + timeout_secs: Some(10), + working_dir: None, + }, + ], + working_dir: Some("/tmp".to_string()), + timeout_secs: None, + inputs: HashMap::new(), + }; + + let result = execute_pipeline_direct(&logs_dir, "test-fwd", pipeline, Some(&bus), Some(®istry)).await; + + assert!(result.success); + assert_eq!(result.steps_completed, 2); + // Step 1 should have interpolated step 0's stdout + let step1_output = result.step_results[1].output.as_deref().unwrap_or(""); + assert!(step1_output.contains("hello-from-step-0"), "Expected forwarded output, got: {step1_output}"); + } + + /// Test empty pipeline succeeds + #[tokio::test] + async fn test_empty_pipeline() { + let registry = make_registry(); + let bus = make_bus(); + let logs_dir = std::env::temp_dir().join("sentinel-test-empty"); + + let pipeline = Pipeline { + name: Some("empty".to_string()), + steps: vec![], + working_dir: Some("/tmp".to_string()), + timeout_secs: None, + inputs: HashMap::new(), + }; + + let result = execute_pipeline_direct(&logs_dir, "test-empty", pipeline, Some(&bus), Some(®istry)).await; + + assert!(result.success); + assert_eq!(result.steps_completed, 0); + assert_eq!(result.steps_total, 0); + } + + /// Test pipeline without registry returns error + #[tokio::test] + async fn test_pipeline_requires_registry() { + let bus = make_bus(); + let logs_dir = std::env::temp_dir().join("sentinel-test-noreg"); + + let pipeline = Pipeline { + name: Some("no-reg".to_string()), + steps: vec![PipelineStep::Shell { cmd: "echo".into(), args: vec!["test".into()], timeout_secs: Some(10), working_dir: None }], + working_dir: Some("/tmp".to_string()), + timeout_secs: None, + inputs: HashMap::new(), + }; + + let result = execute_pipeline_direct(&logs_dir, "test-noreg", pipeline, Some(&bus), None).await; + + assert!(!result.success); + assert!(result.error.as_ref().unwrap().contains("registry")); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/interpolation.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/interpolation.rs new file mode 100644 index 000000000..5ee8a01b4 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/interpolation.rs @@ -0,0 +1,452 @@ +//! Template interpolation for pipeline variable substitution +//! +//! Handles {{variable}} substitution in strings and JSON values. +//! Pure functions — no state needed, just the ExecutionContext. +//! +//! Supported paths: +//! - `{{steps.N.output}}` — output of step N +//! - `{{steps.N.data.field}}` — nested data from step N +//! - `{{input.name}}` / `{{inputs.name}}` — pipeline inputs +//! - `{{named.label.output}}` — named step output +//! - `{{env.VAR}}` — environment variable + +use regex::Regex; +use serde_json::Value; +use std::sync::LazyLock; + +use super::types::ExecutionContext; + +static INTERPOLATION_RE: LazyLock = + LazyLock::new(|| Regex::new(r"\{\{([^}]+)\}\}").unwrap()); + +/// Interpolate {{variable}} references in a template string +pub fn interpolate(template: &str, ctx: &ExecutionContext) -> String { + INTERPOLATION_RE.replace_all(template, |caps: ®ex::Captures| { + let path = caps.get(1).map(|m| m.as_str().trim()).unwrap_or(""); + resolve_path(path, ctx) + }).to_string() +} + +/// Interpolate {{variable}} references in a JSON value recursively +pub fn interpolate_value(value: &Value, ctx: &ExecutionContext) -> Value { + match value { + Value::String(s) => { + let interpolated = interpolate(s, ctx); + // Try to parse as JSON if the entire string was a single interpolation + // This preserves types: {{steps.0.data}} returns object, not stringified JSON + if s.starts_with("{{") && s.ends_with("}}") && s.matches("{{").count() == 1 { + if let Ok(parsed) = serde_json::from_str::(&interpolated) { + if !parsed.is_string() { + return parsed; + } + } + } + Value::String(interpolated) + } + Value::Array(arr) => Value::Array(arr.iter().map(|v| interpolate_value(v, ctx)).collect()), + Value::Object(obj) => { + let mut new_obj = serde_json::Map::new(); + for (k, v) in obj { + new_obj.insert(k.clone(), interpolate_value(v, ctx)); + } + Value::Object(new_obj) + } + _ => value.clone(), + } +} + +/// Evaluate a condition expression (after interpolation) +pub fn evaluate_condition(condition: &str) -> bool { + let trimmed = condition.trim(); + + if trimmed == "true" { + return true; + } + if trimmed == "false" { + return false; + } + + // Non-empty string is truthy + if !trimmed.is_empty() && trimmed != "0" && trimmed != "null" && trimmed != "undefined" { + return true; + } + + false +} + +/// Resolve a variable path like "steps.0.output", "input.name", or "named.build.output" +fn resolve_path(path: &str, ctx: &ExecutionContext) -> String { + let parts: Vec<&str> = path.split('.').collect(); + if parts.is_empty() { + return format!("{{{{{path}}}}}"); + } + + match parts[0] { + "steps" => resolve_steps_path(&parts[1..], ctx), + "input" | "inputs" => resolve_input_path(&parts[1..], ctx), + "named" => resolve_named_path(&parts[1..], ctx), + "env" => { + if parts.len() < 2 { + return "".to_string(); + } + std::env::var(parts[1]).unwrap_or_default() + } + _ => format!("{{{{{path}}}}}"), + } +} + +/// Resolve steps.N.field paths +fn resolve_steps_path(parts: &[&str], ctx: &ExecutionContext) -> String { + if parts.is_empty() { + return "".to_string(); + } + + let index: usize = parts[0].parse().unwrap_or(usize::MAX); + if index >= ctx.step_results.len() { + return "".to_string(); + } + + let result = &ctx.step_results[index]; + resolve_step_result_field(result, &parts[1..]) +} + +/// Resolve named.label.field paths +fn resolve_named_path(parts: &[&str], ctx: &ExecutionContext) -> String { + if parts.is_empty() { + return "".to_string(); + } + + let label = parts[0]; + match ctx.named_outputs.get(label) { + Some(result) => resolve_step_result_field(result, &parts[1..]), + None => "".to_string(), + } +} + +/// Resolve input.field paths +fn resolve_input_path(parts: &[&str], ctx: &ExecutionContext) -> String { + if parts.is_empty() { + return "".to_string(); + } + ctx.inputs.get(parts[0]) + .map(|v| match v { + Value::String(s) => s.clone(), + _ => v.to_string(), + }) + .unwrap_or_default() +} + +/// Extract a field from a StepResult given the remaining path parts +fn resolve_step_result_field(result: &super::types::StepResult, parts: &[&str]) -> String { + if parts.is_empty() { + return result.output.clone().unwrap_or_default(); + } + + match parts[0] { + "output" => result.output.clone().unwrap_or_default(), + "success" => result.success.to_string(), + "error" => result.error.clone().unwrap_or_default(), + "exitCode" | "exit_code" => result.exit_code.map(|c| c.to_string()).unwrap_or_default(), + "data" => { + if parts.len() > 1 { + let mut current = &result.data; + for part in &parts[1..] { + current = current.get(*part).unwrap_or(&Value::Null); + } + match current { + Value::String(s) => s.clone(), + Value::Null => "".to_string(), + _ => current.to_string(), + } + } else { + result.data.to_string() + } + } + "type" | "stepType" => result.step_type.clone(), + "index" | "stepIndex" => result.step_index.to_string(), + "durationMs" | "duration_ms" => result.duration_ms.to_string(), + _ => "".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use super::super::types::StepResult; + use serde_json::json; + use std::collections::HashMap; + use std::path::PathBuf; + + fn make_ctx() -> ExecutionContext { + ExecutionContext { + step_results: vec![ + StepResult { + step_index: 0, + step_type: "shell".to_string(), + success: true, + duration_ms: 42, + output: Some("hello world".to_string()), + error: None, + exit_code: Some(0), + data: json!({ "stdout": "hello world", "exitCode": 0 }), + }, + StepResult { + step_index: 1, + step_type: "shell".to_string(), + success: false, + duration_ms: 10, + output: None, + error: Some("not found".to_string()), + exit_code: Some(127), + data: json!({ "stderr": "command not found", "exitCode": 127 }), + }, + ], + inputs: { + let mut m = HashMap::new(); + m.insert("name".to_string(), json!("test-pipeline")); + m.insert("count".to_string(), json!(5)); + m + }, + working_dir: PathBuf::from("/tmp/test"), + named_outputs: { + let mut m = HashMap::new(); + m.insert("build".to_string(), StepResult { + step_index: 0, + step_type: "shell".to_string(), + success: true, + duration_ms: 100, + output: Some("build OK".to_string()), + error: None, + exit_code: Some(0), + data: json!({ "artifacts": ["a.o", "b.o"] }), + }); + m + }, + } + } + + // ── Steps path resolution ── + + #[test] + fn test_steps_output() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{steps.0.output}}", &ctx), "hello world"); + } + + #[test] + fn test_steps_default_to_output() { + let ctx = make_ctx(); + // Bare steps.N without field returns output + assert_eq!(interpolate("{{steps.0}}", &ctx), "hello world"); + } + + #[test] + fn test_steps_success() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{steps.0.success}}", &ctx), "true"); + assert_eq!(interpolate("{{steps.1.success}}", &ctx), "false"); + } + + #[test] + fn test_steps_error() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{steps.1.error}}", &ctx), "not found"); + assert_eq!(interpolate("{{steps.0.error}}", &ctx), ""); + } + + #[test] + fn test_steps_exit_code() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{steps.0.exitCode}}", &ctx), "0"); + assert_eq!(interpolate("{{steps.1.exit_code}}", &ctx), "127"); + } + + #[test] + fn test_steps_data_field() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{steps.0.data.stdout}}", &ctx), "hello world"); + assert_eq!(interpolate("{{steps.0.data.exitCode}}", &ctx), "0"); + } + + #[test] + fn test_steps_nested_data() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{steps.1.data.stderr}}", &ctx), "command not found"); + } + + #[test] + fn test_steps_metadata() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{steps.0.type}}", &ctx), "shell"); + assert_eq!(interpolate("{{steps.0.stepType}}", &ctx), "shell"); + assert_eq!(interpolate("{{steps.0.index}}", &ctx), "0"); + assert_eq!(interpolate("{{steps.0.durationMs}}", &ctx), "42"); + } + + #[test] + fn test_steps_out_of_bounds() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{steps.99.output}}", &ctx), ""); + } + + // ── Input path resolution ── + + #[test] + fn test_input_string() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{input.name}}", &ctx), "test-pipeline"); + assert_eq!(interpolate("{{inputs.name}}", &ctx), "test-pipeline"); + } + + #[test] + fn test_input_number() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{input.count}}", &ctx), "5"); + } + + #[test] + fn test_input_missing() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{input.nonexistent}}", &ctx), ""); + } + + // ── Named output resolution ── + + #[test] + fn test_named_output() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{named.build.output}}", &ctx), "build OK"); + } + + #[test] + fn test_named_success() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{named.build.success}}", &ctx), "true"); + } + + #[test] + fn test_named_data_field() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{named.build.data.artifacts}}", &ctx), "[\"a.o\",\"b.o\"]"); + } + + #[test] + fn test_named_missing() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{named.deploy.output}}", &ctx), ""); + } + + // ── Env resolution ── + + #[test] + fn test_env_var() { + let ctx = make_ctx(); + std::env::set_var("SENTINEL_TEST_VAR", "sentinel_value"); + assert_eq!(interpolate("{{env.SENTINEL_TEST_VAR}}", &ctx), "sentinel_value"); + std::env::remove_var("SENTINEL_TEST_VAR"); + } + + #[test] + fn test_env_missing() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{env.NONEXISTENT_VAR_12345}}", &ctx), ""); + } + + // ── Mixed interpolation ── + + #[test] + fn test_multiple_refs_in_string() { + let ctx = make_ctx(); + let result = interpolate("Name: {{input.name}}, Output: {{steps.0.output}}", &ctx); + assert_eq!(result, "Name: test-pipeline, Output: hello world"); + } + + #[test] + fn test_no_refs_passthrough() { + let ctx = make_ctx(); + assert_eq!(interpolate("plain text", &ctx), "plain text"); + } + + #[test] + fn test_unknown_root_preserved() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{unknown.path}}", &ctx), "{{unknown.path}}"); + } + + #[test] + fn test_whitespace_trimmed() { + let ctx = make_ctx(); + assert_eq!(interpolate("{{ steps.0.output }}", &ctx), "hello world"); + } + + // ── interpolate_value (JSON) ── + + #[test] + fn test_interpolate_json_string() { + let ctx = make_ctx(); + let val = json!("result: {{steps.0.output}}"); + let result = interpolate_value(&val, &ctx); + assert_eq!(result, json!("result: hello world")); + } + + #[test] + fn test_interpolate_json_object() { + let ctx = make_ctx(); + let val = json!({ "name": "{{input.name}}", "out": "{{steps.0.output}}" }); + let result = interpolate_value(&val, &ctx); + assert_eq!(result, json!({ "name": "test-pipeline", "out": "hello world" })); + } + + #[test] + fn test_interpolate_json_array() { + let ctx = make_ctx(); + let val = json!(["{{input.name}}", "{{steps.0.output}}"]); + let result = interpolate_value(&val, &ctx); + assert_eq!(result, json!(["test-pipeline", "hello world"])); + } + + #[test] + fn test_interpolate_preserves_numbers() { + let ctx = make_ctx(); + let val = json!(42); + assert_eq!(interpolate_value(&val, &ctx), json!(42)); + } + + #[test] + fn test_interpolate_preserves_booleans() { + let ctx = make_ctx(); + let val = json!(true); + assert_eq!(interpolate_value(&val, &ctx), json!(true)); + } + + // ── evaluate_condition ── + + #[test] + fn test_condition_true_false() { + assert!(evaluate_condition("true")); + assert!(!evaluate_condition("false")); + } + + #[test] + fn test_condition_truthy_strings() { + assert!(evaluate_condition("hello")); + assert!(evaluate_condition("1")); + assert!(evaluate_condition("yes")); + } + + #[test] + fn test_condition_falsy_values() { + assert!(!evaluate_condition("")); + assert!(!evaluate_condition("0")); + assert!(!evaluate_condition("null")); + assert!(!evaluate_condition("undefined")); + assert!(!evaluate_condition("false")); + } + + #[test] + fn test_condition_whitespace() { + assert!(evaluate_condition(" true ")); + assert!(!evaluate_condition(" false ")); + assert!(!evaluate_condition(" ")); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/logs.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/logs.rs new file mode 100644 index 000000000..ea3f34e14 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/logs.rs @@ -0,0 +1,142 @@ +//! Sentinel log management — list, read, tail log streams + +use serde_json::{json, Value}; +use std::path::Path; + +use crate::runtime::CommandResult; +use crate::utils::params::Params; +use super::types::LogStreamInfo; + +/// List log streams for a sentinel handle +pub async fn list_logs(logs_base_dir: &Path, params: Value) -> Result { + let p = Params::new(¶ms); + let handle_id = p.str("handle")?; + + let logs_dir = logs_base_dir.join(handle_id); + + if !logs_dir.exists() { + return Ok(CommandResult::Json(json!({ + "handle": handle_id, + "streams": [], + }))); + } + + let mut streams = Vec::new(); + let mut entries = tokio::fs::read_dir(&logs_dir) + .await + .map_err(|e| format!("Failed to read logs dir: {e}"))?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| format!("Read error: {e}"))? { + let path = entry.path(); + let ext = path.extension().and_then(|e| e.to_str()); + if ext == Some("log") || ext == Some("jsonl") { + let metadata = tokio::fs::metadata(&path) + .await + .map_err(|e| format!("Metadata error: {e}"))?; + + let modified = metadata.modified() + .map(|t| { + let datetime: chrono::DateTime = t.into(); + datetime.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() + }) + .unwrap_or_default(); + + streams.push(LogStreamInfo { + name: path.file_stem().unwrap_or_default().to_string_lossy().to_string(), + path: path.to_string_lossy().to_string(), + size: metadata.len(), + modified_at: modified, + }); + } + } + + Ok(CommandResult::Json(json!({ + "handle": handle_id, + "logsDir": logs_dir.to_string_lossy(), + "streams": streams, + }))) +} + +/// Read a log stream with offset and limit +pub async fn read_log(logs_base_dir: &Path, params: Value) -> Result { + let p = Params::new(¶ms); + let handle_id = p.str("handle")?; + let stream = p.str_or("stream", "combined"); + let offset = p.u64_or("offset", 0) as usize; + let limit = p.u64_or("limit", 1000) as usize; + + let logs_dir = logs_base_dir.join(handle_id); + let log_path = logs_dir.join(format!("{stream}.log")); + let jsonl_path = logs_dir.join(format!("{stream}.jsonl")); + + let actual_path = if log_path.exists() { + log_path + } else if jsonl_path.exists() { + jsonl_path + } else { + return Err(format!("Log stream not found: {stream}")); + }; + + let content = tokio::fs::read_to_string(&actual_path) + .await + .map_err(|e| format!("Failed to read log: {e}"))?; + + let lines: Vec<&str> = content.lines().collect(); + let total_lines = lines.len(); + + let selected_lines: Vec<&str> = lines + .into_iter() + .skip(offset) + .take(limit) + .collect(); + + let truncated = offset + selected_lines.len() < total_lines; + + Ok(CommandResult::Json(json!({ + "handle": handle_id, + "stream": stream, + "content": selected_lines.join("\n"), + "lineCount": selected_lines.len(), + "totalLines": total_lines, + "offset": offset, + "truncated": truncated, + }))) +} + +/// Tail a log stream (last N lines) +pub async fn tail_log(logs_base_dir: &Path, params: Value) -> Result { + let p = Params::new(¶ms); + let handle_id = p.str("handle")?; + let stream = p.str_or("stream", "combined"); + let lines_count = p.u64_or("lines", 20) as usize; + + let logs_dir = logs_base_dir.join(handle_id); + let log_path = logs_dir.join(format!("{stream}.log")); + let jsonl_path = logs_dir.join(format!("{stream}.jsonl")); + + let actual_path = if log_path.exists() { + log_path + } else if jsonl_path.exists() { + jsonl_path + } else { + return Err(format!("Log stream not found: {stream}")); + }; + + let content = tokio::fs::read_to_string(&actual_path) + .await + .map_err(|e| format!("Failed to read log: {e}"))?; + + let lines: Vec<&str> = content.lines().collect(); + let total_lines = lines.len(); + + let start = total_lines.saturating_sub(lines_count); + let tail_lines: Vec<&str> = lines.into_iter().skip(start).collect(); + + Ok(CommandResult::Json(json!({ + "handle": handle_id, + "stream": stream, + "content": tail_lines.join("\n"), + "lineCount": tail_lines.len(), + "totalLines": total_lines, + }))) +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/mod.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/mod.rs new file mode 100644 index 000000000..80a43f4e1 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/mod.rs @@ -0,0 +1,414 @@ +//! Sentinel Module — Concurrent, fault-tolerant build/task execution with pipeline support +//! +//! Sentinels are autonomous agents that can run builds, tests, and other +//! long-running processes with proper isolation and logging. +//! +//! Key Design Principles: +//! - **Process Isolation**: Each sentinel runs in a child process (crash isolation) +//! - **Non-blocking**: Heavy processes (Xcode, cargo) don't block the runtime +//! - **Fault Tolerant**: One sentinel failure doesn't cascade to others +//! - **Concurrent**: Multiple sentinels can run in parallel +//! - **Observable**: All output streamed to logs in real-time +//! - **Event-driven**: Emits sentinel:{handle}:log events for real-time streaming +//! - **Pipeline Support**: Multi-step pipelines with LLM, conditions, loops + +pub mod types; +pub mod interpolation; +pub mod steps; +pub mod executor; +pub mod logs; + +pub use types::*; + +use async_trait::async_trait; +use dashmap::DashMap; +use parking_lot::RwLock; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc; + +use crate::runtime::{ + CommandResult, ModuleConfig, ModuleContext, ModulePriority, ServiceModule, + message_bus::MessageBus, ModuleRegistry, +}; +use crate::utils::params::Params; + +/// Sentinel Module - manages concurrent sentinel execution and pipeline interpretation +pub struct SentinelModule { + /// Active sentinels by handle ID + sentinels: Arc>, + /// Base directory for sentinel logs (.continuum/jtag/logs/system/sentinels) + logs_base_dir: RwLock, + /// Maximum concurrent sentinels + max_concurrent: usize, + /// Message bus for event emission (set during initialize) + bus: RwLock>>, + /// Module registry for inter-module calls (set during initialize) + registry: RwLock>>, +} + +impl SentinelModule { + pub fn new() -> Self { + Self { + sentinels: Arc::new(DashMap::new()), + logs_base_dir: RwLock::new(PathBuf::from(".continuum/jtag/logs/system/sentinels")), + max_concurrent: 4, + bus: RwLock::new(None), + registry: RwLock::new(None), + } + } + + /// Generate a unique handle ID + fn generate_handle_id() -> String { + uuid::Uuid::new_v4().to_string()[..8].to_string() + } + + /// Get logs directory for a handle + fn logs_dir(&self, handle: &str) -> PathBuf { + self.logs_base_dir.read().join(handle) + } + + /// Run a sentinel (async execution) — handles both shell commands and pipelines + async fn run_sentinel(&self, params: Value) -> Result { + use crate::runtime; + let log = runtime::logger("sentinel"); + + // Check concurrent limit + let active_count = self.sentinels.iter().filter(|s| s.handle.status == SentinelStatus::Running).count(); + if active_count >= self.max_concurrent { + return Err(format!( + "Maximum concurrent sentinels ({}) reached. Wait for completion or cancel existing.", + self.max_concurrent + )); + } + + // Parse params + let p = Params::new(¶ms); + + let sentinel_type = p.str_or("type", "build").to_string(); + let working_dir = p.str_opt("workingDir") + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + let command = p.str_or("cmd", "npm").to_string(); + let args: Vec = p.json_opt("args") + .unwrap_or_else(|| vec!["run".to_string(), "build".to_string()]); + let timeout_secs = p.u64_or("timeout", 600); + let env: HashMap = p.json_or("env"); + + // Check if this is a pipeline execution + let pipeline_json = env.get("PIPELINE_JSON").cloned(); + + let pipeline: Option = if let Some(ref json_str) = pipeline_json.filter(|_| sentinel_type == "pipeline") { + match serde_json::from_str::(json_str) { + Ok(p) => Some(p), + Err(e) => { + return Err(format!("Failed to parse PIPELINE_JSON: {e}")); + } + } + } else { + None + }; + + // Generate handle + let handle_id = Self::generate_handle_id(); + let logs_dir = self.logs_dir(&handle_id); + + let (cancel_tx, cancel_rx) = mpsc::channel(1); + let handle = SentinelHandle { + id: handle_id.clone(), + sentinel_type: sentinel_type.clone(), + status: SentinelStatus::Running, + progress: 0, + start_time: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + end_time: None, + exit_code: None, + error: None, + working_dir: working_dir.to_string_lossy().to_string(), + logs_dir: logs_dir.to_string_lossy().to_string(), + }; + + self.sentinels.insert(handle_id.clone(), RunningSentinel { + handle: handle.clone(), + cancel_tx: Some(cancel_tx), + }); + + let mode_str = if pipeline.is_some() { "pipeline" } else { "shell" }; + log.info(&format!( + "Starting sentinel {handle_id} (type={sentinel_type}, mode={mode_str}, cmd={command} {args:?})" + )); + + // Clone fields for the spawned task + let sentinels = Arc::clone(&self.sentinels); + let handle_id_clone = handle_id.clone(); + let working_dir_clone = working_dir.clone(); + let sentinel_type_clone = sentinel_type.clone(); + let logs_base_dir = self.logs_base_dir.read().clone(); + let bus = self.bus.read().clone(); + let registry = self.registry.read().clone(); + + tokio::spawn(async move { + let log = runtime::logger("sentinel"); + + // Emit start event + if let Some(ref bus) = bus { + bus.publish_async_only(&format!("sentinel:{handle_id_clone}:status"), json!({ + "handle": handle_id_clone, + "type": sentinel_type_clone, + "status": "running", + "phase": "starting", + "mode": if pipeline.is_some() { "pipeline" } else { "shell" }, + })); + } + + // Execute based on type + let result: Result<(i32, String), String> = if let Some(pipeline) = pipeline { + log.info(&format!("[{handle_id_clone}] Executing pipeline with {} steps", pipeline.steps.len())); + + tokio::time::timeout( + Duration::from_secs(timeout_secs), + executor::execute_pipeline( + logs_base_dir.clone(), + pipeline, + handle_id_clone.clone(), + working_dir_clone.clone(), + bus.clone(), + registry.clone(), + ), + ) + .await + .map_err(|_| format!("Pipeline timeout after {timeout_secs}s")) + .and_then(|r| r) + } else { + tokio::time::timeout( + Duration::from_secs(timeout_secs), + executor::execute_isolated( + executor::IsolatedProcessConfig { + logs_base_dir, + handle_id: handle_id_clone.clone(), + command, + args, + working_dir: working_dir_clone, + env, + }, + cancel_rx, + bus.clone(), + ), + ) + .await + .map_err(|_| format!("Timeout after {timeout_secs}s")) + .and_then(|r| r) + }; + + // Update handle status + if let Some(mut entry) = sentinels.get_mut(&handle_id_clone) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + + let (final_status, error_msg) = match result { + Ok((exit_code, _output)) => { + entry.handle.status = if exit_code == 0 { + SentinelStatus::Completed + } else { + SentinelStatus::Failed + }; + entry.handle.exit_code = Some(exit_code); + entry.handle.progress = 100; + log.info(&format!("Sentinel {handle_id_clone} completed with exit code {exit_code}")); + (if exit_code == 0 { "completed" } else { "failed" }, None) + } + Err(e) => { + entry.handle.status = if e == "Cancelled" { + SentinelStatus::Cancelled + } else { + SentinelStatus::Failed + }; + entry.handle.error = Some(e.clone()); + log.error(&format!("Sentinel {handle_id_clone} failed: {e}")); + (if e == "Cancelled" { "cancelled" } else { "failed" }, Some(e)) + } + }; + entry.handle.end_time = Some(now); + entry.cancel_tx = None; + + if let Some(ref bus) = bus { + let mut payload = json!({ + "handle": handle_id_clone, + "type": sentinel_type_clone, + "status": final_status, + "exitCode": entry.handle.exit_code, + }); + if let Some(err) = error_msg { + payload["error"] = json!(err); + } + bus.publish_async_only(&format!("sentinel:{handle_id_clone}:status"), payload); + bus.publish_async_only("sentinel:complete", json!({ + "handle": handle_id_clone, + "type": sentinel_type_clone, + "success": final_status == "completed", + })); + } + } + }); + + Ok(CommandResult::Json(json!({ + "handle": handle_id, + "status": "running", + "logsDir": logs_dir.to_string_lossy(), + }))) + } + + /// Get sentinel status + async fn get_status(&self, params: Value) -> Result { + let p = Params::new(¶ms); + let handle_id = p.str("handle")?; + + if let Some(entry) = self.sentinels.get(handle_id) { + Ok(CommandResult::Json(json!({ + "handle": entry.handle, + }))) + } else { + Err(format!("Sentinel handle not found: {handle_id}")) + } + } + + /// List all sentinel handles + async fn list_handles(&self, _params: Value) -> Result { + let handles: Vec = self.sentinels + .iter() + .map(|entry| entry.handle.clone()) + .collect(); + + Ok(CommandResult::Json(json!({ + "handles": handles, + "total": handles.len(), + }))) + } + + /// Cancel a running sentinel + async fn cancel_sentinel(&self, params: Value) -> Result { + let p = Params::new(¶ms); + let handle_id = p.str("handle")?; + + if let Some(mut entry) = self.sentinels.get_mut(handle_id) { + if entry.handle.status == SentinelStatus::Running { + if let Some(cancel_tx) = entry.cancel_tx.take() { + cancel_tx.send(()).await.ok(); + entry.handle.status = SentinelStatus::Cancelled; + return Ok(CommandResult::Json(json!({ + "handle": handle_id, + "status": "cancelled", + }))); + } + } + return Err(format!("Sentinel {handle_id} is not running")); + } + + Err(format!("Sentinel handle not found: {handle_id}")) + } + + /// Execute a pipeline (direct, synchronous path — not spawned) + async fn execute_pipeline_command(&self, params: Value) -> Result { + let handle_id = Self::generate_handle_id(); + + let p = Params::new(¶ms); + let pipeline: Pipeline = p.json("pipeline") + .or_else(|_| serde_json::from_value::(params.clone()) + .map_err(|e| format!("Failed to parse pipeline: {e}")))?; + + let logs_base_dir = self.logs_base_dir.read().clone(); + let bus = self.bus.read().clone(); + let registry = self.registry.read().clone(); + + let result = executor::execute_pipeline_direct( + &logs_base_dir, + &handle_id, + pipeline, + bus.as_ref(), + registry.as_ref(), + ).await; + + Ok(CommandResult::Json(serde_json::to_value(&result).unwrap_or(json!({"error": "serialization failed"})))) + } +} + +impl Default for SentinelModule { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl ServiceModule for SentinelModule { + fn config(&self) -> ModuleConfig { + ModuleConfig { + name: "sentinel", + priority: ModulePriority::Normal, + command_prefixes: &["sentinel/"], + event_subscriptions: &[], + needs_dedicated_thread: false, + max_concurrency: 8, + tick_interval: None, + } + } + + async fn initialize(&self, ctx: &ModuleContext) -> Result<(), String> { + let log = crate::runtime::logger("sentinel"); + log.info("SentinelModule initialized with pipeline support"); + + *self.bus.write() = Some(Arc::clone(&ctx.bus)); + *self.registry.write() = Some(Arc::clone(&ctx.registry)); + + if let Ok(cwd) = std::env::current_dir() { + *self.logs_base_dir.write() = cwd.join(".continuum/jtag/logs/system/sentinels"); + } + + Ok(()) + } + + async fn handle_command(&self, command: &str, params: Value) -> Result { + let logs_base_dir = self.logs_base_dir.read().clone(); + + match command { + "sentinel/execute" | "sentinel/run" => self.run_sentinel(params).await, + "sentinel/status" => self.get_status(params).await, + "sentinel/list" => self.list_handles(params).await, + "sentinel/cancel" => self.cancel_sentinel(params).await, + "sentinel/pipeline" => self.execute_pipeline_command(params).await, + "sentinel/logs/list" => logs::list_logs(&logs_base_dir, params).await, + "sentinel/logs/read" => logs::read_log(&logs_base_dir, params).await, + "sentinel/logs/tail" => logs::tail_log(&logs_base_dir, params).await, + _ => Err(format!("Unknown sentinel command: {command}")), + } + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_handle_id() { + let id = SentinelModule::generate_handle_id(); + assert_eq!(id.len(), 8); + assert!(id.chars().all(|c| c.is_ascii_hexdigit() || c == '-')); + } + + #[test] + fn test_sentinel_status_serialization() { + let status = SentinelStatus::Running; + let json = serde_json::to_string(&status).unwrap(); + assert_eq!(json, "\"running\""); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/command.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/command.rs new file mode 100644 index 000000000..2ae8f3e39 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/command.rs @@ -0,0 +1,44 @@ +//! Command step execution — routes to any command via CommandExecutor + +use serde_json::Value; +use std::time::Instant; + +use crate::modules::sentinel::interpolation; +use crate::modules::sentinel::types::{ExecutionContext, PipelineContext, StepResult}; + +/// Execute a command step via global CommandExecutor (routes to Rust OR TypeScript) +pub async fn execute( + command: &str, + params: &Value, + index: usize, + ctx: &mut ExecutionContext, + pipeline_ctx: &PipelineContext<'_>, +) -> Result { + use crate::runtime; + let log = runtime::logger("sentinel"); + let start = Instant::now(); + + let interpolated_command = interpolation::interpolate(command, ctx); + let interpolated_params = interpolation::interpolate_value(params, ctx); + + log.info(&format!("[{}] Command step: {}", pipeline_ctx.handle_id, interpolated_command)); + + let json = runtime::command_executor::execute_json(&interpolated_command, interpolated_params).await + .map_err(|e| format!("[{}] Command '{}' failed: {}", pipeline_ctx.handle_id, interpolated_command, e))?; + + let duration_ms = start.elapsed().as_millis() as u64; + + let success = json.get("success").and_then(|v| v.as_bool()).unwrap_or(true); + let error = json.get("error").and_then(|v| v.as_str()).map(|s| s.to_string()); + + Ok(StepResult { + step_index: index, + step_type: "command".to_string(), + success, + duration_ms, + output: None, + error, + exit_code: None, + data: json, + }) +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/condition.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/condition.rs new file mode 100644 index 000000000..291df8041 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/condition.rs @@ -0,0 +1,248 @@ +//! Condition step execution — evaluates expression and runs branch + +use serde_json::json; +use std::time::Instant; + +use crate::modules::sentinel::interpolation; +use crate::modules::sentinel::types::{ExecutionContext, PipelineContext, PipelineStep, StepResult}; + +/// Execute a condition step +pub async fn execute( + condition: &str, + then_steps: &[PipelineStep], + else_steps: &[PipelineStep], + index: usize, + ctx: &mut ExecutionContext, + pipeline_ctx: &PipelineContext<'_>, +) -> Result { + let start = Instant::now(); + + let interpolated = interpolation::interpolate(condition, ctx); + let condition_result = interpolation::evaluate_condition(&interpolated); + + let steps_to_run = if condition_result { then_steps } else { else_steps }; + + for (i, step) in steps_to_run.iter().enumerate() { + let sub_result = super::execute_step(step, ctx.step_results.len(), ctx, pipeline_ctx).await?; + if !sub_result.success { + return Ok(StepResult { + step_index: index, + step_type: "condition".to_string(), + success: false, + duration_ms: start.elapsed().as_millis() as u64, + output: None, + error: sub_result.error, + exit_code: None, + data: json!({ + "conditionResult": condition_result, + "branch": if condition_result { "then" } else { "else" }, + "failedStep": i, + }), + }); + } + ctx.step_results.push(sub_result); + } + + Ok(StepResult { + step_index: index, + step_type: "condition".to_string(), + success: true, + duration_ms: start.elapsed().as_millis() as u64, + output: None, + error: None, + exit_code: None, + data: json!({ + "conditionResult": condition_result, + "branch": if condition_result { "then" } else { "else" }, + "stepsExecuted": steps_to_run.len(), + }), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::sentinel::types::{ExecutionContext, PipelineStep}; + use crate::runtime::{ModuleRegistry, message_bus::MessageBus}; + use std::sync::Arc; + use std::collections::HashMap; + use std::path::PathBuf; + + fn test_ctx() -> ExecutionContext { + ExecutionContext { + step_results: Vec::new(), + inputs: HashMap::new(), + working_dir: PathBuf::from("/tmp"), + named_outputs: HashMap::new(), + } + } + + fn test_pipeline_ctx<'a>(registry: &'a Arc, bus: &'a Arc) -> PipelineContext<'a> { + PipelineContext { + handle_id: "test-cond", + registry, + bus: Some(bus), + } + } + + fn echo_step(msg: &str) -> PipelineStep { + PipelineStep::Shell { + cmd: "echo".to_string(), + args: vec![msg.to_string()], + timeout_secs: Some(10), + working_dir: None, + } + } + + fn failing_step() -> PipelineStep { + PipelineStep::Shell { + cmd: "/bin/sh".to_string(), + args: vec!["-c".to_string(), "exit 1".to_string()], + timeout_secs: Some(10), + working_dir: None, + } + } + + #[tokio::test] + async fn test_true_branch_executes() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + "true", + &[echo_step("then-branch")], + &[echo_step("else-branch")], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["conditionResult"], true); + assert_eq!(result.data["branch"], "then"); + assert_eq!(result.data["stepsExecuted"], 1); + // The shell step result should be pushed to ctx.step_results + assert_eq!(ctx.step_results.len(), 1); + assert_eq!(ctx.step_results[0].output.as_deref(), Some("then-branch\n")); + } + + #[tokio::test] + async fn test_false_branch_executes() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + "false", + &[echo_step("then-branch")], + &[echo_step("else-branch")], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["conditionResult"], false); + assert_eq!(result.data["branch"], "else"); + assert_eq!(ctx.step_results.len(), 1); + assert_eq!(ctx.step_results[0].output.as_deref(), Some("else-branch\n")); + } + + #[tokio::test] + async fn test_empty_else_branch() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + "false", + &[echo_step("then-branch")], + &[], // empty else + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["branch"], "else"); + assert_eq!(result.data["stepsExecuted"], 0); + assert_eq!(ctx.step_results.len(), 0); + } + + #[tokio::test] + async fn test_interpolated_condition() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + ctx.inputs.insert("flag".to_string(), serde_json::json!("true")); + + let result = execute( + "{{input.flag}}", + &[echo_step("flag-was-true")], + &[echo_step("flag-was-false")], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["conditionResult"], true); + assert_eq!(ctx.step_results[0].output.as_deref(), Some("flag-was-true\n")); + } + + #[tokio::test] + async fn test_falsy_input_takes_else() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + ctx.inputs.insert("flag".to_string(), serde_json::json!("0")); + + let result = execute( + "{{input.flag}}", + &[echo_step("flag-truthy")], + &[echo_step("flag-falsy")], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["conditionResult"], false); + assert_eq!(ctx.step_results[0].output.as_deref(), Some("flag-falsy\n")); + } + + #[tokio::test] + async fn test_failing_substep_returns_failure() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + "true", + &[failing_step()], + &[], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(!result.success); + assert_eq!(result.data["conditionResult"], true); + assert_eq!(result.data["branch"], "then"); + assert_eq!(result.data["failedStep"], 0); + } + + #[tokio::test] + async fn test_multiple_then_steps() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + "true", + &[echo_step("step-a"), echo_step("step-b"), echo_step("step-c")], + &[], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["stepsExecuted"], 3); + assert_eq!(ctx.step_results.len(), 3); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/emit.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/emit.rs new file mode 100644 index 000000000..2b5ab24f6 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/emit.rs @@ -0,0 +1,148 @@ +//! Emit step execution — publishes events on the MessageBus +//! +//! Enables inter-sentinel composition: one sentinel emits an event, +//! another sentinel's Watch step receives it. + +use serde_json::json; +use std::time::Instant; + +use crate::modules::sentinel::interpolation; +use crate::modules::sentinel::types::{ExecutionContext, PipelineContext, StepResult}; + +/// Publish an event on the MessageBus with an interpolated payload +pub async fn execute( + event: &str, + payload: &serde_json::Value, + index: usize, + ctx: &mut ExecutionContext, + pipeline_ctx: &PipelineContext<'_>, +) -> Result { + use crate::runtime; + let log = runtime::logger("sentinel"); + let start = Instant::now(); + + let interpolated_event = interpolation::interpolate(event, ctx); + let interpolated_payload = interpolation::interpolate_value(payload, ctx); + + log.info(&format!("[{}] Emit step: event={}", pipeline_ctx.handle_id, interpolated_event)); + + let bus = pipeline_ctx.bus + .ok_or_else(|| format!("[{}] Emit step requires MessageBus", pipeline_ctx.handle_id))?; + + bus.publish_async_only(&interpolated_event, interpolated_payload.clone()); + + let duration_ms = start.elapsed().as_millis() as u64; + + Ok(StepResult { + step_index: index, + step_type: "emit".to_string(), + success: true, + duration_ms, + output: Some(interpolated_event.clone()), + error: None, + exit_code: None, + data: json!({ + "event": interpolated_event, + "payload": interpolated_payload, + }), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::sentinel::types::ExecutionContext; + use crate::runtime::{ModuleRegistry, message_bus::MessageBus}; + use std::sync::Arc; + use std::collections::HashMap; + use std::path::PathBuf; + + fn test_ctx() -> ExecutionContext { + ExecutionContext { + step_results: Vec::new(), + inputs: HashMap::new(), + working_dir: PathBuf::from("/tmp"), + named_outputs: HashMap::new(), + } + } + + fn test_pipeline_ctx<'a>(registry: &'a Arc, bus: &'a Arc) -> PipelineContext<'a> { + PipelineContext { + handle_id: "test-emit", + registry, + bus: Some(bus), + } + } + + #[tokio::test] + async fn test_emit_publishes_event() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let mut receiver = bus.receiver(); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let payload = json!({"status": "done"}); + let result = execute("build:complete", &payload, 0, &mut ctx, &pipeline_ctx).await.unwrap(); + + assert!(result.success); + assert_eq!(result.output.as_deref(), Some("build:complete")); + assert_eq!(result.data["event"], "build:complete"); + assert_eq!(result.data["payload"]["status"], "done"); + + // Verify the event was actually published on the bus + let received = receiver.try_recv().unwrap(); + assert_eq!(received.name, "build:complete"); + } + + #[tokio::test] + async fn test_emit_interpolates_event_name() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + ctx.inputs.insert("phase".to_string(), json!("deploy")); + + let result = execute( + "{{input.phase}}:complete", + &json!({}), + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.output.as_deref(), Some("deploy:complete")); + } + + #[tokio::test] + async fn test_emit_interpolates_payload() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + ctx.inputs.insert("name".to_string(), json!("my-pipeline")); + + let result = execute( + "test:event", + &json!({"pipeline": "{{input.name}}"}), + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["payload"]["pipeline"], "my-pipeline"); + } + + #[tokio::test] + async fn test_emit_requires_bus() { + let registry = Arc::new(ModuleRegistry::new()); + let pipeline_ctx = PipelineContext { + handle_id: "test-emit", + registry: ®istry, + bus: None, + }; + let mut ctx = test_ctx(); + + let result = execute("test:event", &json!({}), 0, &mut ctx, &pipeline_ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("requires MessageBus")); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/llm.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/llm.rs new file mode 100644 index 000000000..51e657a81 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/llm.rs @@ -0,0 +1,187 @@ +//! LLM step execution — dual mode: +//! +//! agentMode=false (default): fast in-process Rust call to ai/generate via ModuleRegistry +//! agentMode=true: routes to TypeScript ai/agent via CommandExecutor (Unix socket IPC) +//! for full agentic loop with tool calling, 243+ discoverable tools + +use serde_json::json; +use std::time::Instant; + +use crate::runtime::CommandResult; +use crate::modules::sentinel::interpolation; +use crate::modules::sentinel::types::{ExecutionContext, PipelineContext, StepResult}; + +/// LLM step configuration extracted from PipelineStep::Llm +pub struct LlmStepParams<'a> { + pub prompt: &'a str, + pub model: Option<&'a str>, + pub provider: Option<&'a str>, + pub max_tokens: Option, + pub temperature: Option, + pub system_prompt: Option<&'a str>, + pub tools: Option<&'a Vec>, + pub agent_mode: Option, + pub max_iterations: Option, +} + +/// Execute an LLM step — routes to Rust ai/generate or TypeScript ai/agent +pub async fn execute( + params: LlmStepParams<'_>, + index: usize, + ctx: &mut ExecutionContext, + pipeline_ctx: &PipelineContext<'_>, +) -> Result { + let is_agent = params.agent_mode.unwrap_or(false); + + if is_agent { + execute_agent_mode(params, index, ctx, pipeline_ctx).await + } else { + execute_generate_mode(params, index, ctx, pipeline_ctx).await + } +} + +/// agentMode=false: Fast in-process Rust call to ai/generate via ModuleRegistry +async fn execute_generate_mode( + params: LlmStepParams<'_>, + index: usize, + ctx: &mut ExecutionContext, + pipeline_ctx: &PipelineContext<'_>, +) -> Result { + use crate::runtime; + let log = runtime::logger("sentinel"); + let start = Instant::now(); + + let interpolated_prompt = interpolation::interpolate(params.prompt, ctx); + let interpolated_system = params.system_prompt.map(|s| interpolation::interpolate(s, ctx)); + + log.info(&format!("[{}] LLM step (generate): model={:?}, provider={:?}, prompt_len={}", + pipeline_ctx.handle_id, params.model, params.provider, interpolated_prompt.len())); + + let mut ai_params = json!({ + "prompt": interpolated_prompt, + }); + + if let Some(m) = params.model { + ai_params["model"] = json!(m); + } + if let Some(p) = params.provider { + ai_params["provider"] = json!(p); + } + if let Some(t) = params.max_tokens { + ai_params["max_tokens"] = json!(t); + } + if let Some(temp) = params.temperature { + ai_params["temperature"] = json!(temp); + } + if let Some(sys) = interpolated_system { + ai_params["system_prompt"] = json!(sys); + } + + let (module, cmd) = pipeline_ctx.registry.route_command("ai/generate") + .ok_or_else(|| format!("[{}] ai module not found in registry", pipeline_ctx.handle_id))?; + + let result = module.handle_command(&cmd, ai_params).await + .map_err(|e| format!("[{}] LLM step error: {}", pipeline_ctx.handle_id, e))?; + + let duration_ms = start.elapsed().as_millis() as u64; + + match result { + CommandResult::Json(json) => { + let success = json.get("success").and_then(|v| v.as_bool()).unwrap_or(false); + let text = json.get("text").and_then(|v| v.as_str()).map(|s| s.to_string()); + let error = json.get("error").and_then(|v| v.as_str()).map(|s| s.to_string()); + + Ok(StepResult { + step_index: index, + step_type: "llm".to_string(), + success, + duration_ms, + output: text, + error, + exit_code: None, + data: json, + }) + } + CommandResult::Binary { .. } => { + Err(format!("[{}] Unexpected binary response from ai/generate", pipeline_ctx.handle_id)) + } + } +} + +/// agentMode=true: Route to TypeScript ai/agent via CommandExecutor (Unix socket IPC) +/// for full agentic loop with tool calling +async fn execute_agent_mode( + params: LlmStepParams<'_>, + index: usize, + ctx: &mut ExecutionContext, + pipeline_ctx: &PipelineContext<'_>, +) -> Result { + use crate::runtime; + let log = runtime::logger("sentinel"); + let start = Instant::now(); + + let interpolated_prompt = interpolation::interpolate(params.prompt, ctx); + let interpolated_system = params.system_prompt.map(|s| interpolation::interpolate(s, ctx)); + + log.info(&format!("[{}] LLM step (agent): model={:?}, provider={:?}, tools={:?}, prompt_len={}", + pipeline_ctx.handle_id, params.model, params.provider, params.tools, interpolated_prompt.len())); + + // Build ai/agent command params + let mut agent_params = json!({ + "prompt": interpolated_prompt, + "sentinelHandle": pipeline_ctx.handle_id, + }); + + if let Some(m) = params.model { + agent_params["model"] = json!(m); + } + if let Some(p) = params.provider { + agent_params["provider"] = json!(p); + } + if let Some(t) = params.max_tokens { + agent_params["maxTokens"] = json!(t); + } + if let Some(temp) = params.temperature { + agent_params["temperature"] = json!(temp); + } + if let Some(sys) = interpolated_system { + agent_params["systemPrompt"] = json!(sys); + } + if let Some(tools) = params.tools { + agent_params["tools"] = json!(tools); + } + if let Some(max_iter) = params.max_iterations { + agent_params["maxIterations"] = json!(max_iter); + } + + // Route to TypeScript ai/agent directly via Unix socket (bypasses Rust registry). + // MUST use execute_ts_json — the ai/ prefix is claimed by Rust's ai_provider module, + // so execute_json would route back to Rust and never reach TypeScript. + let json = runtime::command_executor::execute_ts_json("ai/agent", agent_params).await + .map_err(|e| format!("[{}] LLM agent step error: {}", pipeline_ctx.handle_id, e))?; + + let duration_ms = start.elapsed().as_millis() as u64; + + let success = json.get("success").and_then(|v| v.as_bool()).unwrap_or(false); + let text = json.get("text").and_then(|v| v.as_str()).map(|s| s.to_string()); + let error = json.get("error").and_then(|v| v.as_str()).map(|s| s.to_string()); + let iterations = json.get("iterations").and_then(|v| v.as_u64()).unwrap_or(0); + let tool_calls_count = json.get("toolCalls") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + + log.info(&format!("[{}] LLM agent step complete: success={}, iterations={}, tool_calls={}, {}ms", + pipeline_ctx.handle_id, success, iterations, tool_calls_count, duration_ms)); + + Ok(StepResult { + step_index: index, + step_type: "llm".to_string(), + success, + duration_ms, + output: text, + error, + exit_code: None, + data: json, // Full ai/agent result including toolCalls array + }) +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/loop_step.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/loop_step.rs new file mode 100644 index 000000000..b1ea5d1f8 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/loop_step.rs @@ -0,0 +1,384 @@ +//! Loop step execution — iterates over sub-steps with flexible termination +//! +//! Supports four modes: +//! - **count**: fixed N iterations (original behavior) +//! - **while**: condition checked before each iteration, continues while truthy +//! - **until**: condition checked after each iteration, stops when truthy +//! - **continuous**: no condition, runs until maxIterations (safety limit) + +use serde_json::json; +use std::time::Instant; + +use crate::modules::sentinel::interpolation; +use crate::modules::sentinel::types::{ + ExecutionContext, PipelineContext, PipelineStep, StepResult, DEFAULT_MAX_ITERATIONS, +}; + +/// Execute a loop step with flexible termination +pub async fn execute( + count: Option, + while_condition: Option<&str>, + until: Option<&str>, + max_iterations: Option, + steps: &[PipelineStep], + index: usize, + ctx: &mut ExecutionContext, + pipeline_ctx: &PipelineContext<'_>, +) -> Result { + use crate::runtime; + let log = runtime::logger("sentinel"); + let start = Instant::now(); + + // Determine loop mode and iteration limit + let mode: LoopMode = if let Some(n) = count { + LoopMode::Count(n) + } else if let Some(cond) = while_condition { + LoopMode::While(cond.to_string()) + } else if let Some(cond) = until { + LoopMode::Until(cond.to_string()) + } else { + LoopMode::Continuous + }; + + // Safety limit: count mode uses exact count, all others use maxIterations or default + let limit = match &mode { + LoopMode::Count(n) => *n, + _ => max_iterations.unwrap_or(DEFAULT_MAX_ITERATIONS), + }; + + log.info(&format!("[{}] Loop step: mode={}, limit={}", + pipeline_ctx.handle_id, mode.name(), limit)); + + let mut iteration: usize = 0; + + loop { + if iteration >= limit { + log.info(&format!("[{}] Loop reached iteration limit {}", + pipeline_ctx.handle_id, limit)); + break; + } + + // While mode: check condition BEFORE executing + if let LoopMode::While(ref cond) = mode { + let interpolated = interpolation::interpolate(cond, ctx); + if !interpolation::evaluate_condition(&interpolated) { + log.info(&format!("[{}] While condition false at iteration {}", + pipeline_ctx.handle_id, iteration)); + break; + } + } + + // Set iteration variable for interpolation: {{input.iteration}} + ctx.inputs.insert("iteration".to_string(), json!(iteration)); + + // Execute sub-steps + for step in steps { + let sub_result = super::execute_step(step, ctx.step_results.len(), ctx, pipeline_ctx).await?; + if !sub_result.success { + return Ok(StepResult { + step_index: index, + step_type: "loop".to_string(), + success: false, + duration_ms: start.elapsed().as_millis() as u64, + output: None, + error: sub_result.error, + exit_code: None, + data: json!({ + "mode": mode.name(), + "iteration": iteration, + "iterationsCompleted": iteration, + }), + }); + } + ctx.step_results.push(sub_result); + } + + iteration += 1; + + // Until mode: check condition AFTER executing + if let LoopMode::Until(ref cond) = mode { + let interpolated = interpolation::interpolate(cond, ctx); + if interpolation::evaluate_condition(&interpolated) { + log.info(&format!("[{}] Until condition met at iteration {}", + pipeline_ctx.handle_id, iteration)); + break; + } + } + } + + Ok(StepResult { + step_index: index, + step_type: "loop".to_string(), + success: true, + duration_ms: start.elapsed().as_millis() as u64, + output: None, + error: None, + exit_code: None, + data: json!({ + "mode": mode.name(), + "iterationsCompleted": iteration, + }), + }) +} + +/// Internal enum to classify the loop's termination strategy +enum LoopMode { + Count(usize), + While(String), + Until(String), + Continuous, +} + +impl LoopMode { + fn name(&self) -> &'static str { + match self { + Self::Count(_) => "count", + Self::While(_) => "while", + Self::Until(_) => "until", + Self::Continuous => "continuous", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::sentinel::types::{ExecutionContext, PipelineStep}; + use crate::runtime::{ModuleRegistry, message_bus::MessageBus}; + use std::sync::Arc; + use std::collections::HashMap; + use std::path::PathBuf; + + fn test_ctx() -> ExecutionContext { + ExecutionContext { + step_results: Vec::new(), + inputs: HashMap::new(), + working_dir: PathBuf::from("/tmp"), + named_outputs: HashMap::new(), + } + } + + fn test_pipeline_ctx<'a>(registry: &'a Arc, bus: &'a Arc) -> PipelineContext<'a> { + PipelineContext { + handle_id: "test-loop", + registry, + bus: Some(bus), + } + } + + fn echo_step(msg: &str) -> PipelineStep { + PipelineStep::Shell { + cmd: "echo".to_string(), + args: vec![msg.to_string()], + timeout_secs: Some(10), + working_dir: None, + } + } + + fn echo_iteration_step() -> PipelineStep { + PipelineStep::Shell { + cmd: "echo".to_string(), + args: vec!["iter-{{input.iteration}}".to_string()], + timeout_secs: Some(10), + working_dir: None, + } + } + + fn failing_step() -> PipelineStep { + PipelineStep::Shell { + cmd: "/bin/sh".to_string(), + args: vec!["-c".to_string(), "exit 1".to_string()], + timeout_secs: Some(10), + working_dir: None, + } + } + + #[tokio::test] + async fn test_count_mode() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + Some(3), None, None, None, + &[echo_step("counted")], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["mode"], "count"); + assert_eq!(result.data["iterationsCompleted"], 3); + assert_eq!(ctx.step_results.len(), 3); + } + + #[tokio::test] + async fn test_count_zero() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + Some(0), None, None, None, + &[echo_step("should-not-run")], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["iterationsCompleted"], 0); + assert_eq!(ctx.step_results.len(), 0); + } + + #[tokio::test] + async fn test_while_false_immediate_exit() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + None, Some("false"), None, None, + &[echo_step("should-not-run")], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["mode"], "while"); + assert_eq!(result.data["iterationsCompleted"], 0); + } + + #[tokio::test] + async fn test_while_true_hits_limit() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + None, Some("true"), None, Some(5), + &[echo_step("looping")], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["mode"], "while"); + assert_eq!(result.data["iterationsCompleted"], 5); + assert_eq!(ctx.step_results.len(), 5); + } + + #[tokio::test] + async fn test_until_true_one_iteration() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + // until "true" → executes once, then condition is true, so stops + let result = execute( + None, None, Some("true"), None, + &[echo_step("once")], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["mode"], "until"); + assert_eq!(result.data["iterationsCompleted"], 1); + assert_eq!(ctx.step_results.len(), 1); + } + + #[tokio::test] + async fn test_until_false_hits_limit() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + None, None, Some("false"), Some(4), + &[echo_step("repeating")], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["mode"], "until"); + assert_eq!(result.data["iterationsCompleted"], 4); + } + + #[tokio::test] + async fn test_continuous_mode() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + // No count, no while, no until → continuous, uses maxIterations + let result = execute( + None, None, None, Some(3), + &[echo_step("continuous")], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["mode"], "continuous"); + assert_eq!(result.data["iterationsCompleted"], 3); + } + + #[tokio::test] + async fn test_iteration_variable_interpolated() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + Some(3), None, None, None, + &[echo_iteration_step()], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(ctx.step_results.len(), 3); + assert_eq!(ctx.step_results[0].output.as_deref(), Some("iter-0\n")); + assert_eq!(ctx.step_results[1].output.as_deref(), Some("iter-1\n")); + assert_eq!(ctx.step_results[2].output.as_deref(), Some("iter-2\n")); + } + + #[tokio::test] + async fn test_failing_substep_stops_loop() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + Some(5), None, None, None, + &[failing_step()], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(!result.success); + assert_eq!(result.data["mode"], "count"); + // Should stop after first iteration's failure + assert_eq!(result.data["iteration"], 0); + } + + #[tokio::test] + async fn test_multiple_steps_per_iteration() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + Some(2), None, None, None, + &[echo_step("step-a"), echo_step("step-b")], + 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["iterationsCompleted"], 2); + // 2 iterations × 2 steps = 4 step results + assert_eq!(ctx.step_results.len(), 4); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/mod.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/mod.rs new file mode 100644 index 000000000..39a4a35ab --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/mod.rs @@ -0,0 +1,73 @@ +//! Step dispatch — routes PipelineStep variants to their handlers + +pub mod shell; +pub mod llm; +pub mod command; +pub mod condition; +pub mod loop_step; +pub mod parallel; +pub mod emit; +pub mod watch; +pub mod sentinel; + +use futures::future::BoxFuture; +use futures::FutureExt; + +use super::types::{ExecutionContext, PipelineContext, PipelineStep, StepResult}; + +/// Execute a single pipeline step, dispatching to the appropriate handler. +/// Returns BoxFuture to handle recursive steps (condition, loop, parallel, sentinel). +pub fn execute_step<'a>( + step: &'a PipelineStep, + index: usize, + ctx: &'a mut ExecutionContext, + pipeline_ctx: &'a PipelineContext<'a>, +) -> BoxFuture<'a, Result> { + async move { + match step { + PipelineStep::Shell { cmd, args, timeout_secs, working_dir } => { + shell::execute(cmd, args, timeout_secs.unwrap_or(300), working_dir.as_ref(), index, ctx, pipeline_ctx).await + } + PipelineStep::Llm { prompt, model, provider, max_tokens, temperature, system_prompt, tools, agent_mode, max_iterations } => { + llm::execute(llm::LlmStepParams { + prompt, + model: model.as_deref(), + provider: provider.as_deref(), + max_tokens: *max_tokens, + temperature: *temperature, + system_prompt: system_prompt.as_deref(), + tools: tools.as_ref(), + agent_mode: *agent_mode, + max_iterations: *max_iterations, + }, index, ctx, pipeline_ctx).await + } + PipelineStep::Command { command, params } => { + command::execute(command, params, index, ctx, pipeline_ctx).await + } + PipelineStep::Condition { condition, then_steps, else_steps } => { + condition::execute(condition, then_steps, else_steps, index, ctx, pipeline_ctx).await + } + PipelineStep::Loop { count, steps, while_condition, until, max_iterations } => { + loop_step::execute( + *count, + while_condition.as_deref(), + until.as_deref(), + *max_iterations, + steps, index, ctx, pipeline_ctx, + ).await + } + PipelineStep::Parallel { branches, fail_fast } => { + parallel::execute(branches, *fail_fast, index, ctx, pipeline_ctx).await + } + PipelineStep::Emit { event, payload } => { + emit::execute(event, payload, index, ctx, pipeline_ctx).await + } + PipelineStep::Watch { event, timeout_secs } => { + watch::execute(event, *timeout_secs, index, ctx, pipeline_ctx).await + } + PipelineStep::Sentinel { pipeline } => { + sentinel::execute(pipeline, index, ctx, pipeline_ctx).await + } + } + }.boxed() +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/parallel.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/parallel.rs new file mode 100644 index 000000000..36e10e063 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/parallel.rs @@ -0,0 +1,320 @@ +//! Parallel step execution — runs multiple branch pipelines concurrently +//! +//! Each branch receives a snapshot of the execution context at fork time. +//! Branches execute independently and results are collected. + +use serde_json::json; +use std::time::Instant; + +use crate::modules::sentinel::types::{ExecutionContext, PipelineContext, PipelineStep, StepResult}; + +/// Execute branches concurrently, collecting results from each. +/// +/// Each branch is an independent sequence of steps. They share a read-only +/// snapshot of the context at fork time but diverge independently. +pub async fn execute( + branches: &[Vec], + fail_fast: bool, + index: usize, + ctx: &mut ExecutionContext, + pipeline_ctx: &PipelineContext<'_>, +) -> Result { + use crate::runtime; + let log = runtime::logger("sentinel"); + let start = Instant::now(); + + if branches.is_empty() { + return Ok(StepResult { + step_index: index, + step_type: "parallel".to_string(), + success: true, + duration_ms: 0, + output: None, + error: None, + exit_code: None, + data: json!({ "branchCount": 0, "branchResults": [] }), + }); + } + + log.info(&format!("[{}] Parallel step: {} branches, failFast={}", + pipeline_ctx.handle_id, branches.len(), fail_fast)); + + // Snapshot the context for each branch (clone at fork point) + let branch_contexts: Vec = (0..branches.len()) + .map(|_| ctx.clone()) + .collect(); + + // Build futures for each branch + let mut handles = Vec::with_capacity(branches.len()); + + for (branch_idx, (branch_steps, mut branch_ctx)) in branches.iter().zip(branch_contexts).enumerate() { + // Each branch needs its own copy of the steps reference and pipeline context fields. + // Since we can't move pipeline_ctx into multiple futures, we extract what we need. + let handle_id = pipeline_ctx.handle_id.to_string(); + let registry = pipeline_ctx.registry.clone(); + let bus = pipeline_ctx.bus.cloned(); + let branch_steps = branch_steps.clone(); + + let handle = tokio::spawn(async move { + let pipeline_ctx = PipelineContext { + handle_id: &handle_id, + registry: ®istry, + bus: bus.as_ref(), + }; + + let mut branch_results: Vec = Vec::new(); + let mut branch_success = true; + let mut branch_error: Option = None; + + for (step_idx, step) in branch_steps.iter().enumerate() { + match super::execute_step(step, branch_ctx.step_results.len(), &mut branch_ctx, &pipeline_ctx).await { + Ok(result) => { + if !result.success { + branch_success = false; + branch_error = result.error.clone(); + branch_results.push(result); + break; + } + branch_ctx.step_results.push(result.clone()); + branch_results.push(result); + } + Err(e) => { + branch_success = false; + branch_error = Some(e.clone()); + branch_results.push(StepResult { + step_index: step_idx, + step_type: "error".to_string(), + success: false, + duration_ms: 0, + output: None, + error: Some(e), + exit_code: None, + data: serde_json::Value::Null, + }); + break; + } + } + } + + (branch_idx, branch_success, branch_error, branch_results) + }); + + handles.push(handle); + } + + // Collect results from all branches + let mut branch_summaries = Vec::with_capacity(handles.len()); + let mut all_success = true; + let mut first_error: Option = None; + + for handle in handles { + match handle.await { + Ok((branch_idx, success, error, results)) => { + if !success { + all_success = false; + if first_error.is_none() { + first_error = error.clone(); + } + } + branch_summaries.push(json!({ + "branch": branch_idx, + "success": success, + "stepsCompleted": results.len(), + "error": error, + })); + } + Err(e) => { + all_success = false; + if first_error.is_none() { + first_error = Some(format!("Branch panicked: {e}")); + } + branch_summaries.push(json!({ + "branch": branch_summaries.len(), + "success": false, + "error": format!("Branch panicked: {e}"), + })); + } + } + } + + let duration_ms = start.elapsed().as_millis() as u64; + + log.info(&format!("[{}] Parallel step completed: success={}, duration={}ms", + pipeline_ctx.handle_id, all_success, duration_ms)); + + Ok(StepResult { + step_index: index, + step_type: "parallel".to_string(), + success: all_success, + duration_ms, + output: None, + error: first_error, + exit_code: None, + data: json!({ + "branchCount": branches.len(), + "branchResults": branch_summaries, + }), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::sentinel::types::PipelineStep; + use crate::runtime::{ModuleRegistry, message_bus::MessageBus}; + use std::sync::Arc; + use std::collections::HashMap; + use std::path::PathBuf; + + fn test_ctx() -> ExecutionContext { + ExecutionContext { + step_results: Vec::new(), + inputs: HashMap::new(), + working_dir: PathBuf::from("/tmp"), + named_outputs: HashMap::new(), + } + } + + fn test_pipeline_ctx<'a>(registry: &'a Arc, bus: &'a Arc) -> PipelineContext<'a> { + PipelineContext { + handle_id: "test-par", + registry, + bus: Some(bus), + } + } + + fn echo_step(msg: &str) -> PipelineStep { + PipelineStep::Shell { + cmd: "echo".to_string(), + args: vec![msg.to_string()], + timeout_secs: Some(10), + working_dir: None, + } + } + + fn failing_step() -> PipelineStep { + PipelineStep::Shell { + cmd: "/bin/sh".to_string(), + args: vec!["-c".to_string(), "exit 1".to_string()], + timeout_secs: Some(10), + working_dir: None, + } + } + + #[tokio::test] + async fn test_empty_branches() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute(&[], false, 0, &mut ctx, &pipeline_ctx).await.unwrap(); + assert!(result.success); + assert_eq!(result.data["branchCount"], 0); + } + + #[tokio::test] + async fn test_single_branch() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + &[vec![echo_step("branch-0")]], + false, 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["branchCount"], 1); + assert_eq!(result.data["branchResults"][0]["success"], true); + assert_eq!(result.data["branchResults"][0]["stepsCompleted"], 1); + } + + #[tokio::test] + async fn test_two_branches_succeed() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + &[ + vec![echo_step("alpha")], + vec![echo_step("beta")], + ], + false, 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["branchCount"], 2); + assert_eq!(result.data["branchResults"][0]["success"], true); + assert_eq!(result.data["branchResults"][1]["success"], true); + } + + #[tokio::test] + async fn test_one_branch_fails() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + &[ + vec![echo_step("good")], + vec![failing_step()], + ], + false, 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(!result.success); + assert!(result.error.is_some()); + // Both branches complete since fail_fast is false + assert_eq!(result.data["branchCount"], 2); + } + + #[tokio::test] + async fn test_multi_step_branches() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute( + &[ + vec![echo_step("a1"), echo_step("a2")], + vec![echo_step("b1"), echo_step("b2"), echo_step("b3")], + ], + false, 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["branchResults"][0]["stepsCompleted"], 2); + assert_eq!(result.data["branchResults"][1]["stepsCompleted"], 3); + } + + #[tokio::test] + async fn test_branches_run_concurrently() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + // Two branches each sleeping 100ms — if sequential would take 200ms+ + let sleep_step = PipelineStep::Shell { + cmd: "sleep".to_string(), + args: vec!["0.1".to_string()], + timeout_secs: Some(5), + working_dir: None, + }; + + let result = execute( + &[vec![sleep_step.clone()], vec![sleep_step]], + false, 0, &mut ctx, &pipeline_ctx, + ).await.unwrap(); + + assert!(result.success); + // Concurrent: should complete in ~100ms, not ~200ms + assert!(result.duration_ms < 180, "Expected concurrent execution, took {}ms", result.duration_ms); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/sentinel.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/sentinel.rs new file mode 100644 index 000000000..6cd87518e --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/sentinel.rs @@ -0,0 +1,284 @@ +//! Sentinel step execution — executes a nested pipeline inline +//! +//! Enables recursive pipeline composition. The nested pipeline runs +//! within the same execution context, sharing the registry and bus. + +use serde_json::json; +use std::path::PathBuf; +use std::time::Instant; + +use crate::modules::sentinel::types::{ExecutionContext, Pipeline, PipelineContext, StepResult}; + +/// Execute a nested pipeline inline, returning its result as a step result +pub async fn execute( + pipeline: &Pipeline, + index: usize, + ctx: &mut ExecutionContext, + pipeline_ctx: &PipelineContext<'_>, +) -> Result { + use crate::runtime; + let log = runtime::logger("sentinel"); + let start = Instant::now(); + + let pipeline_name = pipeline.name.as_deref().unwrap_or("nested"); + + log.info(&format!("[{}] Sentinel step: executing nested pipeline '{}' with {} steps", + pipeline_ctx.handle_id, pipeline_name, pipeline.steps.len())); + + // Create a child execution context inheriting from parent + let working_dir = pipeline.working_dir.clone() + .map(PathBuf::from) + .unwrap_or_else(|| ctx.working_dir.clone()); + + let mut child_ctx = ExecutionContext { + step_results: Vec::new(), + inputs: pipeline.inputs.clone(), + working_dir, + named_outputs: ctx.named_outputs.clone(), + }; + + // Inherit parent inputs where child doesn't override + for (key, value) in &ctx.inputs { + child_ctx.inputs.entry(key.clone()).or_insert_with(|| value.clone()); + } + + let mut success = true; + let mut error_msg: Option = None; + + for (i, step) in pipeline.steps.iter().enumerate() { + match super::execute_step(step, i, &mut child_ctx, pipeline_ctx).await { + Ok(result) => { + if !result.success { + success = false; + error_msg = result.error.clone(); + child_ctx.step_results.push(result); + break; + } + child_ctx.step_results.push(result); + } + Err(e) => { + success = false; + error_msg = Some(e); + break; + } + } + } + + let duration_ms = start.elapsed().as_millis() as u64; + let steps_completed = child_ctx.step_results.len(); + + // Last step output becomes this step's output + let last_output = child_ctx.step_results.last() + .and_then(|r| r.output.clone()); + + log.info(&format!("[{}] Sentinel step '{}' completed: success={}, steps={}/{}, duration={}ms", + pipeline_ctx.handle_id, pipeline_name, success, + steps_completed, pipeline.steps.len(), duration_ms)); + + Ok(StepResult { + step_index: index, + step_type: "sentinel".to_string(), + success, + duration_ms, + output: last_output, + error: error_msg, + exit_code: None, + data: json!({ + "pipelineName": pipeline_name, + "stepsCompleted": steps_completed, + "stepsTotal": pipeline.steps.len(), + "stepResults": child_ctx.step_results, + }), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::sentinel::types::{ExecutionContext, PipelineStep}; + use crate::runtime::{ModuleRegistry, message_bus::MessageBus}; + use std::sync::Arc; + use std::collections::HashMap; + + fn test_ctx() -> ExecutionContext { + ExecutionContext { + step_results: Vec::new(), + inputs: HashMap::new(), + working_dir: PathBuf::from("/tmp"), + named_outputs: HashMap::new(), + } + } + + fn test_pipeline_ctx<'a>(registry: &'a Arc, bus: &'a Arc) -> PipelineContext<'a> { + PipelineContext { + handle_id: "test-sentinel", + registry, + bus: Some(bus), + } + } + + fn echo_step(msg: &str) -> PipelineStep { + PipelineStep::Shell { + cmd: "echo".to_string(), + args: vec![msg.to_string()], + timeout_secs: Some(10), + working_dir: None, + } + } + + fn failing_step() -> PipelineStep { + PipelineStep::Shell { + cmd: "/bin/sh".to_string(), + args: vec!["-c".to_string(), "exit 1".to_string()], + timeout_secs: Some(10), + working_dir: None, + } + } + + #[tokio::test] + async fn test_nested_pipeline_succeeds() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let pipeline = Pipeline { + name: Some("child".to_string()), + steps: vec![echo_step("child-step-1"), echo_step("child-step-2")], + working_dir: None, + timeout_secs: None, + inputs: HashMap::new(), + }; + + let result = execute(&pipeline, 0, &mut ctx, &pipeline_ctx).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["pipelineName"], "child"); + assert_eq!(result.data["stepsCompleted"], 2); + assert_eq!(result.data["stepsTotal"], 2); + // Last step output becomes sentinel step output + assert_eq!(result.output.as_deref(), Some("child-step-2\n")); + } + + #[tokio::test] + async fn test_nested_pipeline_inherits_inputs() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + ctx.inputs.insert("parent_var".to_string(), json!("inherited")); + + let pipeline = Pipeline { + name: Some("child".to_string()), + steps: vec![PipelineStep::Shell { + cmd: "echo".to_string(), + args: vec!["{{input.parent_var}}".to_string()], + timeout_secs: Some(10), + working_dir: None, + }], + working_dir: None, + timeout_secs: None, + inputs: HashMap::new(), + }; + + let result = execute(&pipeline, 0, &mut ctx, &pipeline_ctx).await.unwrap(); + + assert!(result.success); + assert_eq!(result.output.as_deref(), Some("inherited\n")); + } + + #[tokio::test] + async fn test_nested_pipeline_child_overrides() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + ctx.inputs.insert("var".to_string(), json!("parent_value")); + + let mut child_inputs = HashMap::new(); + child_inputs.insert("var".to_string(), json!("child_value")); + + let pipeline = Pipeline { + name: Some("child".to_string()), + steps: vec![PipelineStep::Shell { + cmd: "echo".to_string(), + args: vec!["{{input.var}}".to_string()], + timeout_secs: Some(10), + working_dir: None, + }], + working_dir: None, + timeout_secs: None, + inputs: child_inputs, + }; + + let result = execute(&pipeline, 0, &mut ctx, &pipeline_ctx).await.unwrap(); + + assert!(result.success); + assert_eq!(result.output.as_deref(), Some("child_value\n")); + } + + #[tokio::test] + async fn test_nested_pipeline_failure() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let pipeline = Pipeline { + name: Some("failing-child".to_string()), + steps: vec![echo_step("ok"), failing_step(), echo_step("never-reached")], + working_dir: None, + timeout_secs: None, + inputs: HashMap::new(), + }; + + let result = execute(&pipeline, 0, &mut ctx, &pipeline_ctx).await.unwrap(); + + assert!(!result.success); + assert_eq!(result.data["stepsCompleted"], 2); // echo ok + failing step + assert_eq!(result.data["stepsTotal"], 3); + } + + #[tokio::test] + async fn test_empty_nested_pipeline() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let pipeline = Pipeline { + name: Some("empty".to_string()), + steps: vec![], + working_dir: None, + timeout_secs: None, + inputs: HashMap::new(), + }; + + let result = execute(&pipeline, 0, &mut ctx, &pipeline_ctx).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["stepsCompleted"], 0); + assert!(result.output.is_none()); + } + + #[tokio::test] + async fn test_unnamed_nested_pipeline() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let pipeline = Pipeline { + name: None, + steps: vec![echo_step("anon")], + working_dir: None, + timeout_secs: None, + inputs: HashMap::new(), + }; + + let result = execute(&pipeline, 0, &mut ctx, &pipeline_ctx).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["pipelineName"], "nested"); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/shell.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/shell.rs new file mode 100644 index 000000000..863d9f532 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/shell.rs @@ -0,0 +1,199 @@ +//! Shell step execution — runs a child process with isolation + +use serde_json::json; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use tokio::process::Command; + +use crate::modules::sentinel::interpolation; +use crate::modules::sentinel::types::{ExecutionContext, PipelineContext, StepResult}; + +/// Execute a shell step +pub async fn execute( + cmd: &str, + args: &[String], + timeout_secs: u64, + working_dir_override: Option<&String>, + index: usize, + ctx: &mut ExecutionContext, + pipeline_ctx: &PipelineContext<'_>, +) -> Result { + use crate::runtime; + let log = runtime::logger("sentinel"); + let start = Instant::now(); + + let interpolated_cmd = interpolation::interpolate(cmd, ctx); + let interpolated_args: Vec = args.iter() + .map(|arg| interpolation::interpolate(arg, ctx)) + .collect(); + + let work_dir = working_dir_override + .map(|p| PathBuf::from(interpolation::interpolate(p, ctx))) + .unwrap_or_else(|| ctx.working_dir.clone()); + + log.info(&format!("[{}] Shell: {} {:?} in {:?}", + pipeline_ctx.handle_id, interpolated_cmd, interpolated_args, work_dir)); + + // If cmd contains spaces and no args, run through shell + let (actual_cmd, actual_args): (String, Vec) = if interpolated_cmd.contains(' ') && interpolated_args.is_empty() { + ("/bin/sh".to_string(), vec!["-c".to_string(), interpolated_cmd]) + } else { + (interpolated_cmd, interpolated_args) + }; + + let output = tokio::time::timeout( + Duration::from_secs(timeout_secs), + Command::new(&actual_cmd) + .args(&actual_args) + .current_dir(&work_dir) + .kill_on_drop(true) + .output() + ).await; + + let duration_ms = start.elapsed().as_millis() as u64; + + match output { + Ok(Ok(output)) => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let exit_code = output.status.code().unwrap_or(-1); + let success = exit_code == 0; + + Ok(StepResult { + step_index: index, + step_type: "shell".to_string(), + success, + duration_ms, + output: Some(stdout.clone()), + error: if success { None } else { Some(stderr) }, + exit_code: Some(exit_code), + data: json!({ + "stdout": stdout, + "stderr": String::from_utf8_lossy(&output.stderr), + "exitCode": exit_code, + }), + }) + } + Ok(Err(e)) => { + Err(format!("[{}] Shell step failed to execute '{}': {}", + pipeline_ctx.handle_id, actual_cmd, e)) + } + Err(_) => { + Err(format!("[{}] Shell step timed out after {}s", + pipeline_ctx.handle_id, timeout_secs)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::sentinel::types::ExecutionContext; + use crate::runtime::{ModuleRegistry, message_bus::MessageBus}; + use std::sync::Arc; + use std::collections::HashMap; + + fn test_ctx() -> ExecutionContext { + ExecutionContext { + step_results: Vec::new(), + inputs: HashMap::new(), + working_dir: PathBuf::from("/tmp"), + named_outputs: HashMap::new(), + } + } + + fn test_pipeline_ctx<'a>(registry: &'a Arc, bus: &'a Arc) -> PipelineContext<'a> { + PipelineContext { + handle_id: "test-001", + registry, + bus: Some(bus), + } + } + + #[tokio::test] + async fn test_echo() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute("echo", &["hello".into()], 10, None, 0, &mut ctx, &pipeline_ctx).await.unwrap(); + assert!(result.success); + assert_eq!(result.exit_code, Some(0)); + assert_eq!(result.output.as_deref(), Some("hello\n")); + } + + #[tokio::test] + async fn test_nonzero_exit() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute("/bin/sh", &["-c".into(), "exit 42".into()], 10, None, 0, &mut ctx, &pipeline_ctx).await.unwrap(); + assert!(!result.success); + assert_eq!(result.exit_code, Some(42)); + } + + #[tokio::test] + async fn test_shell_passthrough_for_spaces() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + // cmd with spaces and no args should be passed through /bin/sh -c + let result = execute("echo hello world", &[], 10, None, 0, &mut ctx, &pipeline_ctx).await.unwrap(); + assert!(result.success); + assert_eq!(result.output.as_deref(), Some("hello world\n")); + } + + #[tokio::test] + async fn test_timeout() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute("sleep", &["10".into()], 1, None, 0, &mut ctx, &pipeline_ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("timed out")); + } + + #[tokio::test] + async fn test_invalid_command() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute("/nonexistent/binary", &[], 10, None, 0, &mut ctx, &pipeline_ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("failed to execute")); + } + + #[tokio::test] + async fn test_cmd_interpolation() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + ctx.inputs.insert("msg".to_string(), serde_json::json!("interpolated")); + + let result = execute("echo", &["{{input.msg}}".into()], 10, None, 0, &mut ctx, &pipeline_ctx).await.unwrap(); + assert!(result.success); + assert_eq!(result.output.as_deref(), Some("interpolated\n")); + } + + #[tokio::test] + async fn test_data_has_stdout_stderr() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let result = execute("echo", &["data-test".into()], 10, None, 0, &mut ctx, &pipeline_ctx).await.unwrap(); + assert_eq!(result.data["stdout"], "data-test\n"); + assert_eq!(result.data["exitCode"], 0); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/watch.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/watch.rs new file mode 100644 index 000000000..c5418f267 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/steps/watch.rs @@ -0,0 +1,296 @@ +//! Watch step execution — blocks until a matching event arrives on the MessageBus +//! +//! Uses the broadcast channel receiver to listen for events. +//! Supports configurable timeout (default 300s). + +use serde_json::json; +use std::time::{Duration, Instant}; + +use crate::modules::sentinel::interpolation; +use crate::modules::sentinel::types::{ExecutionContext, PipelineContext, StepResult}; + +/// Default timeout for watch step: 5 minutes +const DEFAULT_WATCH_TIMEOUT_SECS: u64 = 300; + +/// Block until a matching event arrives on the MessageBus +pub async fn execute( + event_pattern: &str, + timeout_secs: Option, + index: usize, + ctx: &mut ExecutionContext, + pipeline_ctx: &PipelineContext<'_>, +) -> Result { + use crate::runtime; + let log = runtime::logger("sentinel"); + let start = Instant::now(); + + let interpolated_pattern = interpolation::interpolate(event_pattern, ctx); + let timeout = Duration::from_secs(timeout_secs.unwrap_or(DEFAULT_WATCH_TIMEOUT_SECS)); + + log.info(&format!("[{}] Watch step: waiting for event '{}' (timeout={}s)", + pipeline_ctx.handle_id, interpolated_pattern, timeout.as_secs())); + + let bus = pipeline_ctx.bus + .ok_or_else(|| format!("[{}] Watch step requires MessageBus", pipeline_ctx.handle_id))?; + + let mut receiver = bus.receiver(); + + let result = tokio::time::timeout(timeout, async { + loop { + match receiver.recv().await { + Ok(bus_event) => { + if event_matches(&bus_event.name, &interpolated_pattern) { + return Ok((bus_event.name, bus_event.payload)); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + log.warn(&format!("[{}] Watch: receiver lagged by {} events", + pipeline_ctx.handle_id, n)); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + return Err("MessageBus channel closed".to_string()); + } + } + } + }).await; + + let duration_ms = start.elapsed().as_millis() as u64; + + match result { + Ok(Ok((event_name, payload))) => { + log.info(&format!("[{}] Watch step: received event '{}' after {}ms", + pipeline_ctx.handle_id, event_name, duration_ms)); + + Ok(StepResult { + step_index: index, + step_type: "watch".to_string(), + success: true, + duration_ms, + output: Some(event_name.clone()), + error: None, + exit_code: None, + data: json!({ + "event": event_name, + "payload": payload, + }), + }) + } + Ok(Err(e)) => { + Err(format!("[{}] Watch step error: {}", pipeline_ctx.handle_id, e)) + } + Err(_) => { + log.warn(&format!("[{}] Watch step timed out after {}s waiting for '{}'", + pipeline_ctx.handle_id, timeout.as_secs(), interpolated_pattern)); + + Ok(StepResult { + step_index: index, + step_type: "watch".to_string(), + success: false, + duration_ms, + output: None, + error: Some(format!("Timed out after {}s waiting for event '{}'", + timeout.as_secs(), interpolated_pattern)), + exit_code: None, + data: json!({ + "pattern": interpolated_pattern, + "timeoutSecs": timeout.as_secs(), + }), + }) + } + } +} + +/// Match an event name against a pattern with simple glob support. +/// +/// Patterns: +/// - Exact match: `"build:complete"` matches `"build:complete"` +/// - Trailing wildcard: `"build:*"` matches `"build:complete"`, `"build:failed"` +/// - Single segment wildcard: `"*:complete"` matches `"build:complete"` +fn event_matches(event_name: &str, pattern: &str) -> bool { + if pattern == "*" { + return true; + } + if pattern == event_name { + return true; + } + + let pattern_parts: Vec<&str> = pattern.split(':').collect(); + let event_parts: Vec<&str> = event_name.split(':').collect(); + + // Trailing wildcard: "build:*" matches "build:anything:nested" + if pattern.ends_with(":*") || pattern.ends_with("*") { + let prefix = pattern.trim_end_matches(":*").trim_end_matches('*'); + return event_name.starts_with(prefix); + } + + // Segment-level matching + if pattern_parts.len() != event_parts.len() { + return false; + } + + pattern_parts.iter().zip(event_parts.iter()).all(|(p, e)| { + *p == "*" || *p == *e + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::sentinel::types::ExecutionContext; + use crate::runtime::{ModuleRegistry, message_bus::MessageBus}; + use std::sync::Arc; + use std::collections::HashMap; + use std::path::PathBuf; + + fn test_ctx() -> ExecutionContext { + ExecutionContext { + step_results: Vec::new(), + inputs: HashMap::new(), + working_dir: PathBuf::from("/tmp"), + named_outputs: HashMap::new(), + } + } + + fn test_pipeline_ctx<'a>(registry: &'a Arc, bus: &'a Arc) -> PipelineContext<'a> { + PipelineContext { + handle_id: "test-watch", + registry, + bus: Some(bus), + } + } + + // ── event_matches tests ── + + #[test] + fn test_exact_match() { + assert!(event_matches("build:complete", "build:complete")); + } + + #[test] + fn test_trailing_wildcard() { + assert!(event_matches("build:complete", "build:*")); + assert!(event_matches("build:failed", "build:*")); + assert!(event_matches("sentinel:abc:status", "sentinel:*")); + } + + #[test] + fn test_universal_wildcard() { + assert!(event_matches("anything", "*")); + assert!(event_matches("a:b:c", "*")); + } + + #[test] + fn test_segment_wildcard() { + assert!(event_matches("a:b:c", "a:*:c")); + } + + #[test] + fn test_no_match() { + assert!(!event_matches("build:complete", "build:failed")); + assert!(!event_matches("build:complete", "deploy:complete")); + assert!(!event_matches("a:b:c", "a:*:d")); + } + + #[test] + fn test_length_mismatch() { + assert!(!event_matches("a:b", "a:b:c")); + assert!(!event_matches("a:b:c", "a:b")); + } + + // ── watch execution tests ── + + #[tokio::test] + async fn test_watch_receives_matching_event() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + // Spawn a task that emits the event after a small delay + let bus_clone = bus.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + bus_clone.publish_async_only("build:done", json!({"result": "ok"})); + }); + + let result = execute("build:done", Some(5), 0, &mut ctx, &pipeline_ctx).await.unwrap(); + + assert!(result.success); + assert_eq!(result.output.as_deref(), Some("build:done")); + assert_eq!(result.data["event"], "build:done"); + assert_eq!(result.data["payload"]["result"], "ok"); + } + + #[tokio::test] + async fn test_watch_timeout() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + // Watch for an event that never arrives, with 1s timeout + let result = execute("never:arrives", Some(1), 0, &mut ctx, &pipeline_ctx).await.unwrap(); + + assert!(!result.success); + assert!(result.error.as_ref().unwrap().contains("Timed out")); + assert_eq!(result.data["timeoutSecs"], 1); + } + + #[tokio::test] + async fn test_watch_ignores_non_matching() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let bus_clone = bus.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + // Emit non-matching event first + bus_clone.publish_async_only("other:event", json!({})); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + // Then matching event + bus_clone.publish_async_only("target:event", json!({"found": true})); + }); + + let result = execute("target:event", Some(5), 0, &mut ctx, &pipeline_ctx).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["event"], "target:event"); + assert_eq!(result.data["payload"]["found"], true); + } + + #[tokio::test] + async fn test_watch_wildcard_pattern() { + let registry = Arc::new(ModuleRegistry::new()); + let bus = Arc::new(MessageBus::new()); + let pipeline_ctx = test_pipeline_ctx(®istry, &bus); + let mut ctx = test_ctx(); + + let bus_clone = bus.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + bus_clone.publish_async_only("build:complete", json!({"branch": "main"})); + }); + + let result = execute("build:*", Some(5), 0, &mut ctx, &pipeline_ctx).await.unwrap(); + + assert!(result.success); + assert_eq!(result.data["event"], "build:complete"); + } + + #[tokio::test] + async fn test_watch_requires_bus() { + let registry = Arc::new(ModuleRegistry::new()); + let pipeline_ctx = PipelineContext { + handle_id: "test-watch", + registry: ®istry, + bus: None, + }; + let mut ctx = test_ctx(); + + let result = execute("test:event", Some(1), 0, &mut ctx, &pipeline_ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("requires MessageBus")); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/sentinel/types.rs b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/types.rs new file mode 100644 index 000000000..dee3fb5b5 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/sentinel/types.rs @@ -0,0 +1,277 @@ +//! Sentinel type definitions with ts-rs exports + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +/// Sentinel execution handle +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/sentinel/SentinelHandle.ts")] +#[serde(rename_all = "camelCase")] +pub struct SentinelHandle { + pub id: String, + pub sentinel_type: String, + pub status: SentinelStatus, + pub progress: u8, + pub start_time: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub working_dir: String, + pub logs_dir: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/sentinel/SentinelStatus.ts")] +#[serde(rename_all = "lowercase")] +pub enum SentinelStatus { + Running, + Completed, + Failed, + Cancelled, +} + +/// Log stream info +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/sentinel/LogStreamInfo.ts")] +#[serde(rename_all = "camelCase")] +pub struct LogStreamInfo { + pub name: String, + pub path: String, + pub size: u64, + pub modified_at: String, +} + +/// A single step in a pipeline. +/// +/// Each variant maps to a JSON object with `"type": ""`. +/// Steps compose recursively — condition, loop, parallel, and sentinel +/// all contain nested steps, enabling arbitrarily complex pipelines. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/sentinel/PipelineStep.ts")] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum PipelineStep { + /// Execute a shell command as an isolated child process + Shell { + cmd: String, + #[serde(default)] + args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "timeoutSecs")] + timeout_secs: Option, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "workingDir")] + working_dir: Option, + }, + + /// LLM inference via AIProviderModule (default) or agentic loop via ai/agent command + /// + /// When `agentMode` is false/absent: fast in-process Rust call to ai/generate. + /// When `agentMode` is true: routes to TypeScript ai/agent command via CommandExecutor + /// for full tool-calling loop with 243+ discoverable tools. + Llm { + prompt: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "maxTokens")] + max_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "systemPrompt")] + system_prompt: Option, + /// Tool subset for agent mode (undefined = all public, [] = none) + #[serde(default, skip_serializing_if = "Option::is_none")] + tools: Option>, + /// Enable agentic loop: LLM can call tools, see results, re-generate + #[serde(default, skip_serializing_if = "Option::is_none", rename = "agentMode")] + agent_mode: Option, + /// Override safety cap for tool iterations in agent mode + #[serde(default, skip_serializing_if = "Option::is_none", rename = "maxIterations")] + max_iterations: Option, + }, + + /// Route to any command (Rust or TypeScript) via CommandExecutor + Command { + command: String, + #[serde(default)] + #[ts(type = "Record")] + params: Value, + }, + + /// Branch based on interpolated condition expression + Condition { + #[serde(rename = "if")] + condition: String, + #[serde(rename = "then")] + then_steps: Vec, + #[serde(default, rename = "else")] + else_steps: Vec, + }, + + /// Iterate over sub-steps with flexible termination modes. + /// + /// Modes (exactly one should be specified): + /// - `count`: fixed N iterations + /// - `while`: condition checked before each iteration, continues while truthy + /// - `until`: condition checked after each iteration, stops when truthy + /// - none of the above + `maxIterations`: continuous loop with safety limit + /// + /// `maxIterations` provides a safety cap for while/until/continuous modes. + /// Defaults to 10000 if omitted on non-count loops. + Loop { + #[serde(default, skip_serializing_if = "Option::is_none")] + count: Option, + steps: Vec, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "while")] + while_condition: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + until: Option, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "maxIterations")] + max_iterations: Option, + }, + + /// Execute multiple branch pipelines concurrently. + /// + /// Each branch is a sequence of steps. All branches start simultaneously. + /// Each branch gets a snapshot of the execution context at fork time. + Parallel { + /// Each branch is a sequence of steps executed in order + branches: Vec>, + /// If true, cancel remaining branches on first failure (default: false) + #[serde(default, rename = "failFast")] + fail_fast: bool, + }, + + /// Publish an event on the MessageBus for inter-sentinel composition + Emit { + /// Event name (e.g. "build:complete", "sentinel:custom:done") + event: String, + /// Arbitrary JSON payload (interpolated before emission) + #[serde(default)] + #[ts(type = "Record")] + payload: Value, + }, + + /// Block until a matching event arrives on the MessageBus + Watch { + /// Event name pattern to match + event: String, + /// Timeout in seconds (default: 300) + #[serde(default, skip_serializing_if = "Option::is_none", rename = "timeoutSecs")] + timeout_secs: Option, + }, + + /// Execute a nested pipeline inline (recursive composition) + Sentinel { + /// The nested pipeline to execute + pipeline: Box, + }, +} + +/// A complete pipeline definition +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/sentinel/Pipeline.ts")] +#[serde(rename_all = "camelCase")] +pub struct Pipeline { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub steps: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_secs: Option, + #[serde(default)] + #[ts(type = "Record")] + pub inputs: HashMap, +} + +/// Result of a single step execution +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/sentinel/StepResult.ts")] +#[serde(rename_all = "camelCase")] +pub struct StepResult { + pub step_index: usize, + pub step_type: String, + pub success: bool, + pub duration_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + /// Full result data for complex outputs + #[serde(default, skip_serializing_if = "Value::is_null")] + #[ts(type = "unknown")] + pub data: Value, +} + +/// Result of pipeline execution +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/sentinel/PipelineResult.ts")] +#[serde(rename_all = "camelCase")] +pub struct PipelineResult { + pub handle: String, + pub success: bool, + pub total_duration_ms: u64, + pub steps_completed: usize, + pub steps_total: usize, + pub step_results: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Execution context for variable interpolation. +/// +/// Carried through the pipeline, accumulating step results. +/// Cloned at fork points (parallel branches) so branches share +/// a read-only snapshot but diverge independently. +#[derive(Debug, Clone, Default)] +pub struct ExecutionContext { + /// Results from previous steps (by index) + pub step_results: Vec, + /// Pipeline inputs (also used for loop iteration variable) + pub inputs: HashMap, + /// Working directory for shell commands + pub working_dir: PathBuf, + /// Named outputs for cleaner interpolation: {{named.build.output}} + pub named_outputs: HashMap, +} + +/// Immutable context shared across all step executions in a pipeline. +/// Groups the references that every recursive step needs. +pub struct PipelineContext<'a> { + pub handle_id: &'a str, + pub registry: &'a std::sync::Arc, + pub bus: Option<&'a std::sync::Arc>, +} + +/// Internal state for a running sentinel +pub struct RunningSentinel { + pub handle: SentinelHandle, + /// Channel to send cancellation signal + pub cancel_tx: Option>, +} + +/// Safety limit for while/until/continuous loops when maxIterations is omitted +pub const DEFAULT_MAX_ITERATIONS: usize = 10_000; + +/// Get the type name of a step for logging +pub fn step_type_name(step: &PipelineStep) -> &'static str { + match step { + PipelineStep::Shell { .. } => "shell", + PipelineStep::Llm { .. } => "llm", + PipelineStep::Command { .. } => "command", + PipelineStep::Condition { .. } => "condition", + PipelineStep::Loop { .. } => "loop", + PipelineStep::Parallel { .. } => "parallel", + PipelineStep::Emit { .. } => "emit", + PipelineStep::Watch { .. } => "watch", + PipelineStep::Sentinel { .. } => "sentinel", + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/tool_parsing.rs b/src/debug/jtag/workers/continuum-core/src/modules/tool_parsing.rs new file mode 100644 index 000000000..a342d0406 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/modules/tool_parsing.rs @@ -0,0 +1,206 @@ +//! ToolParsingModule — stateless tool call parsing + correction IPC. +//! +//! Commands: +//! - `tool-parsing/parse`: Parse response text -> tool calls + cleaned text +//! - `tool-parsing/correct`: Correct a single tool call (name + params) +//! - `tool-parsing/register-tools`: Register tool names for codec +//! - `tool-parsing/decode-name`: Decode a model-produced tool name +//! - `tool-parsing/encode-name`: Encode a tool name for API transmission + +use crate::runtime::{ServiceModule, ModuleConfig, ModulePriority, CommandResult, ModuleContext}; +use crate::tool_parsing::{self, ToolNameCodec}; +use crate::utils::params::Params; +use async_trait::async_trait; +use serde_json::Value; +use std::any::Any; +use std::sync::Arc; +use std::collections::HashMap; + +pub struct ToolParsingModule { + codec: Arc, +} + +impl ToolParsingModule { + pub fn new() -> Self { + Self { + codec: Arc::new(ToolNameCodec::new()), + } + } +} + +#[async_trait] +impl ServiceModule for ToolParsingModule { + fn config(&self) -> ModuleConfig { + ModuleConfig { + name: "tool-parsing", + priority: ModulePriority::Normal, + command_prefixes: &["tool-parsing/"], + event_subscriptions: &[], + needs_dedicated_thread: false, + max_concurrency: 0, + tick_interval: None, + } + } + + async fn initialize(&self, _ctx: &ModuleContext) -> Result<(), String> { + Ok(()) + } + + async fn handle_command( + &self, + command: &str, + params: Value, + ) -> Result { + let p = Params::new(¶ms); + + match command { + "tool-parsing/parse" => { + let response_text = p.str("response_text")?; + let result = tool_parsing::parse_and_correct(response_text); + CommandResult::json(&result) + } + + "tool-parsing/correct" => { + let tool_name = p.str("tool_name")?; + let parameters: HashMap = match params.get("parameters") { + Some(Value::Object(map)) => { + map.iter().map(|(k, v)| { + (k.clone(), match v { + Value::String(s) => s.clone(), + _ => v.to_string(), + }) + }).collect() + } + _ => HashMap::new(), + }; + let corrected = tool_parsing::correction::correct_tool_call(tool_name, ¶meters); + CommandResult::json(&corrected) + } + + "tool-parsing/register-tools" => { + let tools: Vec = match params.get("tools") { + Some(Value::Array(arr)) => { + arr.iter().filter_map(|v| v.as_str().map(String::from)).collect() + } + _ => return Err("Missing 'tools' array".to_string()), + }; + let count = tools.len(); + self.codec.register_all(&tools); + Ok(CommandResult::Json(serde_json::json!({ + "registered": count, + "total": self.codec.count(), + }))) + } + + "tool-parsing/decode-name" => { + let raw = p.str("name")?; + let decoded = self.codec.decode(raw); + Ok(CommandResult::Json(serde_json::json!({ + "decoded": decoded, + "changed": decoded != raw, + }))) + } + + "tool-parsing/encode-name" => { + let name = p.str("name")?; + let encoded = self.codec.encode(name); + Ok(CommandResult::Json(serde_json::json!({ + "encoded": encoded, + }))) + } + + _ => Err(format!("Unknown command: {command}")), + } + } + + fn as_any(&self) -> &dyn Any { self } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_parse_command() { + let module = ToolParsingModule::new(); + let params = serde_json::json!({ + "response_text": "code/searchtest" + }); + let result = module.handle_command("tool-parsing/parse", params).await; + assert!(result.is_ok()); + if let Ok(CommandResult::Json(json)) = result { + let calls = json["tool_calls"].as_array().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0]["tool_name"], "code/search"); + // query -> pattern (correction) + assert!(calls[0]["parameters"]["pattern"].is_string()); + } + } + + #[tokio::test] + async fn test_correct_command() { + let module = ToolParsingModule::new(); + let params = serde_json::json!({ + "tool_name": "workspace/tree", + "parameters": { "directory": "./src" } + }); + let result = module.handle_command("tool-parsing/correct", params).await; + assert!(result.is_ok()); + if let Ok(CommandResult::Json(json)) = result { + assert_eq!(json["tool_name"], "code/tree"); + assert_eq!(json["name_changed"], true); + assert_eq!(json["parameters"]["path"], "./src"); + } + } + + #[tokio::test] + async fn test_register_and_decode() { + let module = ToolParsingModule::new(); + + // Register tools + let reg_params = serde_json::json!({ + "tools": ["code/write", "code/read", "collaboration/chat/send"] + }); + let reg_result = module.handle_command("tool-parsing/register-tools", reg_params).await; + assert!(reg_result.is_ok()); + if let Ok(CommandResult::Json(json)) = reg_result { + assert_eq!(json["registered"], 3); + assert_eq!(json["total"], 3); + } + + // Decode encoded name + let dec_params = serde_json::json!({ "name": "code_write" }); + let dec_result = module.handle_command("tool-parsing/decode-name", dec_params).await; + assert!(dec_result.is_ok()); + if let Ok(CommandResult::Json(json)) = dec_result { + assert_eq!(json["decoded"], "code/write"); + assert_eq!(json["changed"], true); + } + + // Decode with prefix + let prefix_params = serde_json::json!({ "name": "$FUNCTIONS.code_write" }); + let prefix_result = module.handle_command("tool-parsing/decode-name", prefix_params).await; + assert!(prefix_result.is_ok()); + if let Ok(CommandResult::Json(json)) = prefix_result { + assert_eq!(json["decoded"], "code/write"); + } + } + + #[tokio::test] + async fn test_encode_command() { + let module = ToolParsingModule::new(); + let params = serde_json::json!({ "name": "collaboration/chat/send" }); + let result = module.handle_command("tool-parsing/encode-name", params).await; + assert!(result.is_ok()); + if let Ok(CommandResult::Json(json)) = result { + assert_eq!(json["encoded"], "collaboration_chat_send"); + } + } + + #[tokio::test] + async fn test_unknown_command() { + let module = ToolParsingModule::new(); + let result = module.handle_command("tool-parsing/nope", Value::Null).await; + assert!(result.is_err()); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/modules/voice.rs b/src/debug/jtag/workers/continuum-core/src/modules/voice.rs index bb1ad0ee8..7c05d5420 100644 --- a/src/debug/jtag/workers/continuum-core/src/modules/voice.rs +++ b/src/debug/jtag/workers/continuum-core/src/modules/voice.rs @@ -12,6 +12,7 @@ use crate::voice::voice_service::VoiceService; use crate::voice::call_server::CallManager; use crate::voice::audio_buffer::AudioBufferPool; use crate::logging::TimingGuard; +use crate::utils::params::Params; use crate::{log_info, log_error}; use async_trait::async_trait; use serde_json::Value; @@ -58,6 +59,7 @@ impl ServiceModule for VoiceModule { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: None, } } @@ -70,286 +72,194 @@ impl ServiceModule for VoiceModule { command: &str, params: Value, ) -> Result { + let p = Params::new(¶ms); + match command { "voice/register-session" => { let _timer = TimingGuard::new("module", "voice_register_session"); + let session_id = p.str("session_id")?; + let room_id = p.str("room_id")?; + let participants: Vec = p.json_or("participants"); - let session_id = params.get("session_id") - .and_then(|v| v.as_str()) - .ok_or("Missing session_id")?; - let room_id = params.get("room_id") - .and_then(|v| v.as_str()) - .ok_or("Missing room_id")?; - let participants: Vec = params.get("participants") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - - match self.state.voice_service.register_session(session_id, room_id, participants) { - Ok(_) => Ok(CommandResult::Json(serde_json::json!({ "registered": true }))), - Err(e) => Err(e), - } + self.state.voice_service.register_session(session_id, room_id, participants)?; + Ok(CommandResult::Json(serde_json::json!({ "registered": true }))) } "voice/on-utterance" => { let _timer = TimingGuard::new("module", "voice_on_utterance"); + let event: UtteranceEvent = p.json("event")?; - let event: UtteranceEvent = params.get("event") - .ok_or("Missing event") - .and_then(|v| serde_json::from_value(v.clone()).map_err(|_| "Invalid event format"))?; - - match self.state.voice_service.on_utterance(event) { - Ok(responder_ids) => Ok(CommandResult::Json(serde_json::json!({ - VOICE_RESPONSE_FIELD_RESPONDER_IDS: responder_ids.into_iter().map(|id| id.to_string()).collect::>() - }))), - Err(e) => Err(e), - } + let responder_ids = self.state.voice_service.on_utterance(event)?; + Ok(CommandResult::Json(serde_json::json!({ + VOICE_RESPONSE_FIELD_RESPONDER_IDS: responder_ids.into_iter().map(|id| id.to_string()).collect::>() + }))) } "voice/should-route-tts" => { let _timer = TimingGuard::new("module", "voice_should_route_tts"); + let session_id = p.str("session_id")?; + let persona_id = p.str("persona_id")?; - let session_id = params.get("session_id") - .and_then(|v| v.as_str()) - .ok_or("Missing session_id")?; - let persona_id = params.get("persona_id") - .and_then(|v| v.as_str()) - .ok_or("Missing persona_id")?; - - match self.state.voice_service.should_route_tts(session_id, persona_id) { - Ok(should_route) => Ok(CommandResult::Json(serde_json::json!({ "should_route": should_route }))), - Err(e) => Err(e), - } + let should_route = self.state.voice_service.should_route_tts(session_id, persona_id)?; + Ok(CommandResult::Json(serde_json::json!({ "should_route": should_route }))) } "voice/synthesize" => { let _timer = TimingGuard::new("module", "voice_synthesize"); - - let text = params.get("text") - .and_then(|v| v.as_str()) - .ok_or("Missing text")?; - let voice = params.get("voice") - .and_then(|v| v.as_str()); - let adapter = params.get("adapter") - .and_then(|v| v.as_str()); + let text = p.str("text")?; + let voice = p.str_opt("voice"); + let adapter = p.str_opt("adapter"); use crate::voice::tts_service; - - // Use async version - we're already in an async context - let result = tts_service::synthesize_speech_async(text, voice, adapter).await; - - match result { - Ok(synthesis) => { - // Raw PCM bytes — NO base64, NO JSON encoding of audio. - let pcm_bytes: Vec = synthesis.samples.iter() - .flat_map(|s| s.to_le_bytes()) - .collect(); - - log_info!( - "module", "voice_synthesize", - "Synthesized {} samples at {}Hz ({:.1}s) → {} bytes raw PCM", - synthesis.samples.len(), - synthesis.sample_rate, - synthesis.duration_ms as f64 / 1000.0, - pcm_bytes.len() - ); - - // Return binary result with metadata - Ok(CommandResult::Binary { - metadata: serde_json::json!({ - "sample_rate": synthesis.sample_rate, - "num_samples": synthesis.samples.len(), - "duration_ms": synthesis.duration_ms, - "format": "pcm_i16_le" - }), - data: pcm_bytes, - }) - } - Err(e) => { + let synthesis = tts_service::synthesize_speech_async(text, voice, adapter).await + .map_err(|e| { log_error!("module", "voice_synthesize", "TTS failed: {}", e); - Err(format!("TTS synthesis failed: {}", e)) - } - } + format!("TTS synthesis failed: {}", e) + })?; + + let pcm_bytes: Vec = synthesis.samples.iter() + .flat_map(|s| s.to_le_bytes()) + .collect(); + + log_info!( + "module", "voice_synthesize", + "Synthesized {} samples at {}Hz ({:.1}s) → {} bytes raw PCM", + synthesis.samples.len(), synthesis.sample_rate, + synthesis.duration_ms as f64 / 1000.0, pcm_bytes.len() + ); + + Ok(CommandResult::Binary { + metadata: serde_json::json!({ + "sample_rate": synthesis.sample_rate, + "num_samples": synthesis.samples.len(), + "duration_ms": synthesis.duration_ms, + "format": "pcm_i16_le" + }), + data: pcm_bytes, + }) } "voice/speak-in-call" => { let _timer = TimingGuard::new("module", "voice_speak_in_call"); - - let call_id = params.get("call_id") - .and_then(|v| v.as_str()) - .ok_or("Missing call_id")?; - let user_id = params.get("user_id") - .and_then(|v| v.as_str()) - .ok_or("Missing user_id")?; - let text = params.get("text") - .and_then(|v| v.as_str()) - .ok_or("Missing text")?; - let voice = params.get("voice") - .and_then(|v| v.as_str()); - let adapter = params.get("adapter") - .and_then(|v| v.as_str()); - - // Direct injection: synthesize + inject into call mixer. - let result = self.state.call_manager.speak_in_call( - call_id, - user_id, - text, - voice, - adapter, - ).await; - - match result { - Ok((num_samples, duration_ms, sample_rate)) => { - log_info!( - "module", "voice_speak_in_call", - "Injected {} samples ({:.1}s) into call {} for user {}", - num_samples, duration_ms as f64 / 1000.0, call_id, user_id - ); - Ok(CommandResult::Json(serde_json::json!({ - "num_samples": num_samples, - "duration_ms": duration_ms, - "sample_rate": sample_rate, - "injected": true - }))) - } - Err(e) => { + let call_id = p.str("call_id")?; + let user_id = p.str("user_id")?; + let text = p.str("text")?; + let voice = p.str_opt("voice"); + let adapter = p.str_opt("adapter"); + + let (num_samples, duration_ms, sample_rate) = self.state.call_manager + .speak_in_call(call_id, user_id, text, voice, adapter) + .await + .map_err(|e| { log_error!("module", "voice_speak_in_call", "Speak-in-call failed: {}", e); - Err(format!("Speak-in-call failed: {}", e)) - } - } + format!("Speak-in-call failed: {}", e) + })?; + + log_info!( + "module", "voice_speak_in_call", + "Injected {} samples ({:.1}s) into call {} for user {}", + num_samples, duration_ms as f64 / 1000.0, call_id, user_id + ); + Ok(CommandResult::Json(serde_json::json!({ + "num_samples": num_samples, + "duration_ms": duration_ms, + "sample_rate": sample_rate, + "injected": true + }))) } "voice/synthesize-handle" => { let _timer = TimingGuard::new("module", "voice_synthesize_handle"); - - let text = params.get("text") - .and_then(|v| v.as_str()) - .ok_or("Missing text")?; - let voice = params.get("voice") - .and_then(|v| v.as_str()); - let adapter = params.get("adapter") - .and_then(|v| v.as_str()); + let text = p.str("text")?; + let voice = p.str_opt("voice"); + let adapter = p.str_opt("adapter"); use crate::voice::tts_service; - - // Use async version - we're already in an async context - let result = tts_service::synthesize_speech_async(text, voice, adapter).await; - - match result { - Ok(synthesis) => { - let adapter_name = adapter.unwrap_or("default"); - let info = self.state.audio_pool.store( - synthesis.samples, - synthesis.sample_rate, - synthesis.duration_ms, - adapter_name, - ); - - log_info!( - "module", "voice_synthesize_handle", - "Stored handle {} ({} samples, {}ms, {})", - &info.handle[..8], info.sample_count, info.duration_ms, info.adapter - ); - - Ok(CommandResult::Json(serde_json::json!({ - "handle": info.handle, - "sample_count": info.sample_count, - "sample_rate": info.sample_rate, - "duration_ms": info.duration_ms, - "adapter": info.adapter, - }))) - } - Err(e) => { + let synthesis = tts_service::synthesize_speech_async(text, voice, adapter).await + .map_err(|e| { log_error!("module", "voice_synthesize_handle", "TTS failed: {}", e); - Err(format!("TTS synthesis failed: {}", e)) - } - } + format!("TTS synthesis failed: {}", e) + })?; + + let adapter_name = adapter.unwrap_or("default"); + let info = self.state.audio_pool.store( + synthesis.samples, synthesis.sample_rate, + synthesis.duration_ms, adapter_name, + ); + + log_info!( + "module", "voice_synthesize_handle", + "Stored handle {} ({} samples, {}ms, {})", + &info.handle[..8], info.sample_count, info.duration_ms, info.adapter + ); + Ok(CommandResult::Json(serde_json::json!({ + "handle": info.handle, + "sample_count": info.sample_count, + "sample_rate": info.sample_rate, + "duration_ms": info.duration_ms, + "adapter": info.adapter, + }))) } "voice/play-handle" => { let _timer = TimingGuard::new("module", "voice_play_handle"); - - let handle = params.get("handle") - .and_then(|v| v.as_str()) - .ok_or("Missing handle")?; - let call_id = params.get("call_id") - .and_then(|v| v.as_str()) - .ok_or("Missing call_id")?; - let user_id = params.get("user_id") - .and_then(|v| v.as_str()) - .ok_or("Missing user_id")?; + let handle = p.str("handle")?; + let call_id = p.str("call_id")?; + let user_id = p.str("user_id")?; use crate::voice::handle::Handle as VoiceHandle; - let voice_handle: VoiceHandle = handle.parse() .map_err(|e| format!("Invalid handle UUID: {}", e))?; - // Retrieve audio from buffer pool let samples = self.state.audio_pool.get(&voice_handle) .ok_or_else(|| format!("Audio handle not found or expired: {}", &handle[..8.min(handle.len())]))?; let sample_count = samples.len(); let duration_ms = (sample_count as u64 * 1000) / crate::audio_constants::AUDIO_SAMPLE_RATE as u64; - // Inject into call mixer - let result = self.state.call_manager.inject_audio(call_id, user_id, samples).await; - - match result { - Ok(()) => { - log_info!( - "module", "voice_play_handle", - "Played handle {} into call {} for user {} ({} samples, {}ms)", - &handle[..8], call_id, user_id, sample_count, duration_ms - ); - Ok(CommandResult::Json(serde_json::json!({ - "played": true, - "sample_count": sample_count, - "duration_ms": duration_ms - }))) - } - Err(e) => { + self.state.call_manager.inject_audio(call_id, user_id, samples).await + .map_err(|e| { log_error!("module", "voice_play_handle", "Failed to inject audio: {}", e); - Err(format!("Failed to inject audio: {}", e)) - } - } + format!("Failed to inject audio: {}", e) + })?; + + log_info!( + "module", "voice_play_handle", + "Played handle {} into call {} for user {} ({} samples, {}ms)", + &handle[..8], call_id, user_id, sample_count, duration_ms + ); + Ok(CommandResult::Json(serde_json::json!({ + "played": true, + "sample_count": sample_count, + "duration_ms": duration_ms + }))) } "voice/discard-handle" => { - let handle = params.get("handle") - .and_then(|v| v.as_str()) - .ok_or("Missing handle")?; + let handle = p.str("handle")?; use crate::voice::handle::Handle as VoiceHandle; - let voice_handle: VoiceHandle = handle.parse() .map_err(|e| format!("Invalid handle UUID: {}", e))?; let discarded = self.state.audio_pool.discard(&voice_handle); - - Ok(CommandResult::Json(serde_json::json!({ - "discarded": discarded, - }))) + Ok(CommandResult::Json(serde_json::json!({ "discarded": discarded }))) } "voice/transcribe" => { let _timer = TimingGuard::new("module", "voice_transcribe"); - - let audio = params.get("audio") - .and_then(|v| v.as_str()) - .ok_or("Missing audio")?; - let language = params.get("language") - .and_then(|v| v.as_str()); + let audio = p.str("audio")?; + let language = p.str_opt("language"); use crate::voice::stt_service; use base64::Engine; - // Decode base64 audio let bytes = base64::engine::general_purpose::STANDARD.decode(audio) .map_err(|e| { log_error!("module", "voice_transcribe", "Base64 decode failed: {}", e); format!("Base64 decode failed: {}", e) })?; - // Convert bytes to i16 samples if bytes.len() % 2 != 0 { return Err("Audio data must be even length (16-bit samples)".to_string()); } @@ -358,7 +268,6 @@ impl ServiceModule for VoiceModule { .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]])) .collect(); - // Transcribe using STT service log_info!( "module", "voice_transcribe", "Transcribing {} samples ({:.1}s)", @@ -366,34 +275,29 @@ impl ServiceModule for VoiceModule { samples.len() as f64 / crate::audio_constants::AUDIO_SAMPLE_RATE as f64 ); - let result = stt_service::transcribe_speech_sync(&samples, language); - - match result { - Ok(transcript) => { - log_info!( - "module", "voice_transcribe", - "Transcribed: \"{}\" (confidence: {:.2})", - transcript.text, - transcript.confidence - ); - Ok(CommandResult::Json(serde_json::json!({ - "text": transcript.text, - "language": transcript.language, - "confidence": transcript.confidence, - "segments": transcript.segments.iter().map(|s| { - serde_json::json!({ - "text": s.text, - "start_ms": s.start_ms, - "end_ms": s.end_ms - }) - }).collect::>() - }))) - } - Err(e) => { + let transcript = stt_service::transcribe_speech_sync(&samples, language) + .map_err(|e| { log_error!("module", "voice_transcribe", "STT failed: {}", e); - Err(format!("STT failed: {}", e)) - } - } + format!("STT failed: {}", e) + })?; + + log_info!( + "module", "voice_transcribe", + "Transcribed: \"{}\" (confidence: {:.2})", + transcript.text, transcript.confidence + ); + Ok(CommandResult::Json(serde_json::json!({ + "text": transcript.text, + "language": transcript.language, + "confidence": transcript.confidence, + "segments": transcript.segments.iter().map(|s| { + serde_json::json!({ + "text": s.text, + "start_ms": s.start_ms, + "end_ms": s.end_ms + }) + }).collect::>() + }))) } _ => Err(format!("Unknown voice command: {command}")), diff --git a/src/debug/jtag/workers/continuum-core/src/persona/cognition.rs b/src/debug/jtag/workers/continuum-core/src/persona/cognition.rs index c396470c0..81d6b14a4 100644 --- a/src/debug/jtag/workers/continuum-core/src/persona/cognition.rs +++ b/src/debug/jtag/workers/continuum-core/src/persona/cognition.rs @@ -187,6 +187,18 @@ impl PersonaCognitionEngine { }; } + self.fast_path_decision_core(message, start) + } + + /// Fast-path decision WITHOUT dedup check. + /// Used by full_evaluate() where the service cycle already did dedup. + pub fn fast_path_decision_no_dedup(&self, message: &InboxMessage) -> CognitionDecision { + let start = Instant::now(); + self.fast_path_decision_core(message, start) + } + + /// Shared fast-path logic (dedup-agnostic). + fn fast_path_decision_core(&self, message: &InboxMessage, start: Instant) -> CognitionDecision { // Check if sender is self if message.sender_id == self.persona_id { return CognitionDecision { @@ -273,6 +285,16 @@ impl PersonaCognitionEngine { pub fn persona_id(&self) -> Uuid { self.persona_id } + + /// Check if a message has been evaluated (deduplication). + pub fn has_evaluated_message(&self, message_id: Uuid) -> bool { + self.evaluated_messages.contains(&message_id) + } + + /// Mark a message as evaluated (deduplication). + pub fn mark_message_evaluated(&self, message_id: Uuid) { + self.evaluated_messages.insert(message_id); + } } //============================================================================= diff --git a/src/debug/jtag/workers/continuum-core/src/persona/evaluator.rs b/src/debug/jtag/workers/continuum-core/src/persona/evaluator.rs new file mode 100644 index 000000000..55fd528bb --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/persona/evaluator.rs @@ -0,0 +1,897 @@ +//! Unified Persona Evaluator — ALL pre-response gates in one call. +//! +//! Consolidates 5 sequential TypeScript gates + Rust fast-path into a single +//! `full_evaluate()` function. One IPC call, <1ms, zero GC. +//! +//! Gate order (short-circuits on first SILENT): +//! 1. Response cap — response_count >= max_responses → SILENT +//! 2. Mention detection — reuses text_analysis::mention_detection +//! 3. Rate limiting — per-room time window from RateLimiterState +//! 4. Sleep mode — checks SleepMode + topic similarity +//! 5. Directed mention filter — !is_mentioned && has_directed_mention → SILENT +//! 6. Fast-path decision — delegates to PersonaCognitionEngine::fast_path_decision +//! +//! Types exported to TypeScript via ts-rs. + +use crate::persona::text_analysis; +use crate::persona::cognition::PersonaCognitionEngine; +use crate::persona::types::{InboxMessage, SenderType, Modality}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Instant; +use ts_rs::TS; +use uuid::Uuid; + +// ============================================================================= +// SLEEP MODE (mirrors TypeScript PersonaSleepManager) +// ============================================================================= + +/// Voluntary sleep modes — persona controls own attention. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "../../../shared/generated/persona/SleepMode.ts")] +pub enum SleepMode { + Active, + MentionedOnly, + HumanOnly, + Sleeping, + UntilTopic, +} + +impl Default for SleepMode { + fn default() -> Self { + SleepMode::Active + } +} + +/// Per-persona sleep state with optional auto-wake. +#[derive(Debug, Clone)] +pub struct SleepState { + pub mode: SleepMode, + pub reason: String, + pub set_at_ms: u64, + pub wake_at_ms: Option, +} + +impl Default for SleepState { + fn default() -> Self { + Self { + mode: SleepMode::Active, + reason: String::new(), + set_at_ms: 0, + wake_at_ms: None, + } + } +} + +impl SleepState { + /// Check if auto-wake time has passed. Returns true if should wake. + pub fn should_auto_wake(&self, now_ms: u64) -> bool { + if let Some(wake_at) = self.wake_at_ms { + now_ms >= wake_at + } else { + false + } + } + + /// Get effective mode, accounting for auto-wake. + pub fn effective_mode(&self, now_ms: u64) -> SleepMode { + if self.should_auto_wake(now_ms) { + SleepMode::Active + } else { + self.mode + } + } +} + +// ============================================================================= +// RATE LIMITER STATE (mirrors TypeScript RateLimiter) +// ============================================================================= + +/// Per-room rate limiting state. +#[derive(Debug, Clone)] +pub struct RoomRateState { + pub last_response_time_ms: u64, + pub response_count: u32, +} + +/// Per-persona rate limiter with per-room tracking. +#[derive(Debug, Clone)] +pub struct RateLimiterState { + pub rooms: HashMap, + pub min_seconds_between_responses: f64, + pub max_responses_per_session: u32, +} + +impl Default for RateLimiterState { + fn default() -> Self { + Self { + rooms: HashMap::new(), + min_seconds_between_responses: 10.0, + max_responses_per_session: 50, + } + } +} + +impl RateLimiterState { + pub fn new(min_seconds: f64, max_responses: u32) -> Self { + Self { + rooms: HashMap::new(), + min_seconds_between_responses: min_seconds, + max_responses_per_session: max_responses, + } + } + + /// Check if response cap reached for a room. + pub fn has_reached_response_cap(&self, room_id: Uuid) -> bool { + self.rooms.get(&room_id) + .map(|r| r.response_count >= self.max_responses_per_session) + .unwrap_or(false) + } + + /// Check if rate limited for a room (time-based). + pub fn is_rate_limited(&self, room_id: Uuid, now_ms: u64) -> bool { + self.rooms.get(&room_id) + .map(|r| { + let elapsed_seconds = (now_ms - r.last_response_time_ms) as f64 / 1000.0; + elapsed_seconds < self.min_seconds_between_responses + }) + .unwrap_or(false) + } + + /// Get seconds until rate limit expires. None if not limited. + pub fn rate_limit_wait_seconds(&self, room_id: Uuid, now_ms: u64) -> Option { + self.rooms.get(&room_id).and_then(|r| { + let elapsed = (now_ms - r.last_response_time_ms) as f64 / 1000.0; + if elapsed < self.min_seconds_between_responses { + Some(self.min_seconds_between_responses - elapsed) + } else { + None + } + }) + } + + /// Track a response in a room. + pub fn track_response(&mut self, room_id: Uuid, now_ms: u64) { + let entry = self.rooms.entry(room_id).or_insert(RoomRateState { + last_response_time_ms: 0, + response_count: 0, + }); + entry.last_response_time_ms = now_ms; + entry.response_count += 1; + } + + /// Get response count for a room. + pub fn response_count(&self, room_id: Uuid) -> u32 { + self.rooms.get(&room_id).map(|r| r.response_count).unwrap_or(0) + } +} + +// ============================================================================= +// REQUEST / RESULT TYPES (ts-rs exported) +// ============================================================================= + +/// Full evaluation request — ONE IPC call replaces 5 TS gates. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/FullEvaluateRequest.ts")] +pub struct FullEvaluateRequest { + #[ts(type = "string")] + pub persona_id: Uuid, + pub persona_name: String, + pub persona_unique_id: String, + #[ts(type = "string")] + pub message_id: Uuid, + #[ts(type = "string")] + pub room_id: Uuid, + #[ts(type = "string")] + pub sender_id: Uuid, + pub sender_name: String, + pub sender_type: SenderType, + pub content: String, + #[ts(type = "number")] + pub timestamp: u64, + pub is_voice: bool, + #[ts(optional, type = "string")] + pub voice_session_id: Option, + pub sender_is_human: bool, + /// Pre-computed topic similarity for sleep mode (optional). + /// If not provided and sleep mode is until_topic, we compute inline. + #[ts(optional)] + pub topic_similarity: Option, + /// Recent room message texts for topic detection (optional). + /// Only needed if persona is in until_topic sleep mode. + #[ts(optional)] + pub recent_room_texts: Option>, +} + +/// Full evaluation result — every gate's outcome in one response. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/FullEvaluateResult.ts")] +pub struct FullEvaluateResult { + pub should_respond: bool, + pub confidence: f32, + pub reason: String, + /// Which gate decided: response_cap, rate_limit, sleep_mode, directed_mention, fast_path, auto_respond + pub gate: String, + #[ts(type = "number")] + pub decision_time_ms: f64, + #[ts(optional)] + pub gate_details: Option, +} + +/// Detailed gate information for diagnostics. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/GateDetails.ts")] +pub struct GateDetails { + #[ts(optional, type = "number")] + pub response_count: Option, + #[ts(optional, type = "number")] + pub max_responses: Option, + #[ts(optional)] + pub rate_limit_wait_seconds: Option, + #[ts(optional)] + pub sleep_mode: Option, + #[ts(optional)] + pub is_mentioned: Option, + #[ts(optional)] + pub has_directed_mention: Option, + #[ts(optional)] + pub topic_similarity: Option, +} + +// ============================================================================= +// UNIFIED EVALUATOR +// ============================================================================= + +/// Run all 6 gates in order, short-circuiting on first SILENT decision. +/// +/// Gate order: +/// 1. Response cap +/// 2. Mention detection +/// 3. Rate limiting +/// 4. Sleep mode (with topic detection for until_topic) +/// 5. Directed mention filter +/// 6. Fast-path decision (dedup, self-check, state gating, mention/human heuristics) +pub fn full_evaluate( + request: &FullEvaluateRequest, + rate_limiter: &RateLimiterState, + sleep_state: &SleepState, + engine: &PersonaCognitionEngine, + now_ms: u64, +) -> FullEvaluateResult { + let start = Instant::now(); + + // ========================================================================= + // GATE 1: Response cap + // ========================================================================= + if rate_limiter.has_reached_response_cap(request.room_id) { + let count = rate_limiter.response_count(request.room_id); + return FullEvaluateResult { + should_respond: false, + confidence: 1.0, + reason: format!( + "Response cap reached ({}/{})", + count, rate_limiter.max_responses_per_session + ), + gate: "response_cap".into(), + decision_time_ms: start.elapsed().as_secs_f64() * 1000.0, + gate_details: Some(GateDetails { + response_count: Some(count), + max_responses: Some(rate_limiter.max_responses_per_session), + rate_limit_wait_seconds: None, + sleep_mode: None, + is_mentioned: None, + has_directed_mention: None, + topic_similarity: None, + }), + }; + } + + // ========================================================================= + // GATE 2: Mention detection (computed once, reused by gates 4 and 5) + // ========================================================================= + let is_mentioned = text_analysis::is_persona_mentioned( + &request.content, + &request.persona_name, + &request.persona_unique_id, + ); + let has_directed_mention = text_analysis::has_directed_mention(&request.content); + + // ========================================================================= + // GATE 3: Rate limiting + // ========================================================================= + if rate_limiter.is_rate_limited(request.room_id, now_ms) { + let wait = rate_limiter + .rate_limit_wait_seconds(request.room_id, now_ms) + .unwrap_or(0.0); + return FullEvaluateResult { + should_respond: false, + confidence: 1.0, + reason: format!("Rate limited, wait {:.1}s more", wait), + gate: "rate_limit".into(), + decision_time_ms: start.elapsed().as_secs_f64() * 1000.0, + gate_details: Some(GateDetails { + response_count: None, + max_responses: None, + rate_limit_wait_seconds: Some(wait), + sleep_mode: None, + is_mentioned: Some(is_mentioned), + has_directed_mention: Some(has_directed_mention), + topic_similarity: None, + }), + }; + } + + // ========================================================================= + // GATE 4: Sleep mode + // ========================================================================= + let effective_sleep = sleep_state.effective_mode(now_ms); + if effective_sleep != SleepMode::Active { + let should_respond_in_sleep = match effective_sleep { + SleepMode::Active => true, + SleepMode::MentionedOnly => is_mentioned, + SleepMode::HumanOnly => request.sender_is_human, + SleepMode::Sleeping => false, + SleepMode::UntilTopic => { + // Check topic similarity if provided, otherwise compute from recent texts + let topic_sim = request.topic_similarity.unwrap_or_else(|| { + if let Some(ref texts) = request.recent_room_texts { + if texts.is_empty() { + return 0.0; // No history = new topic + } + let combined = texts.join(" "); + text_analysis::jaccard_ngram_similarity(&request.content, &combined) as f32 + } else { + 0.5 // No data provided, assume continuation + } + }); + // Below 0.3 = new topic + topic_sim < 0.3 + } + }; + + if !should_respond_in_sleep { + return FullEvaluateResult { + should_respond: false, + confidence: 1.0, + reason: format!( + "Voluntary sleep mode: {:?} (isHuman={}, isMention={})", + effective_sleep, request.sender_is_human, is_mentioned + ), + gate: "sleep_mode".into(), + decision_time_ms: start.elapsed().as_secs_f64() * 1000.0, + gate_details: Some(GateDetails { + response_count: None, + max_responses: None, + rate_limit_wait_seconds: None, + sleep_mode: Some(effective_sleep), + is_mentioned: Some(is_mentioned), + has_directed_mention: Some(has_directed_mention), + topic_similarity: request.topic_similarity, + }), + }; + } + } + + // ========================================================================= + // GATE 5: Directed mention filter + // ========================================================================= + if !is_mentioned && has_directed_mention { + return FullEvaluateResult { + should_respond: false, + confidence: 1.0, + reason: "Message directed at another persona via @mention".into(), + gate: "directed_mention".into(), + decision_time_ms: start.elapsed().as_secs_f64() * 1000.0, + gate_details: Some(GateDetails { + response_count: None, + max_responses: None, + rate_limit_wait_seconds: None, + sleep_mode: None, + is_mentioned: Some(false), + has_directed_mention: Some(true), + topic_similarity: None, + }), + }; + } + + // ========================================================================= + // GATE 6: Fast-path decision (dedup, self, state gating, mention/human heuristics) + // ========================================================================= + let priority = engine.calculate_priority( + &request.content, + request.sender_type, + request.is_voice, + request.room_id, + request.timestamp, + ); + + let inbox_msg = InboxMessage { + id: request.message_id, + room_id: request.room_id, + sender_id: request.sender_id, + sender_name: request.sender_name.clone(), + sender_type: request.sender_type, + content: request.content.clone(), + timestamp: request.timestamp, + priority: priority.score, + source_modality: if request.is_voice { + Some(Modality::Voice) + } else { + Some(Modality::Chat) + }, + voice_session_id: request.voice_session_id, + }; + + // Skip dedup — the service cycle already added this message to evaluated_messages. + // full_evaluate runs all 6 gates definitively; dedup was gate 0 in the service cycle. + let fast_path = engine.fast_path_decision_no_dedup(&inbox_msg); + + FullEvaluateResult { + should_respond: fast_path.should_respond, + confidence: fast_path.confidence, + reason: fast_path.reason, + gate: if fast_path.fast_path_used { + "fast_path".into() + } else { + "deferred_llm".into() + }, + decision_time_ms: start.elapsed().as_secs_f64() * 1000.0, + gate_details: Some(GateDetails { + response_count: None, + max_responses: None, + rate_limit_wait_seconds: None, + sleep_mode: None, + is_mentioned: Some(is_mentioned), + has_directed_mention: Some(has_directed_mention), + topic_similarity: None, + }), + } +} + +// ============================================================================= +// TESTS +// ============================================================================= + +// ============================================================================= +// POST-INFERENCE ADEQUACY CHECK (Phase 5) +// ============================================================================= + +/// A recent AI response to check for adequacy. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecentResponse { + pub sender_name: String, + pub text: String, +} + +/// Result of the post-inference adequacy check. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/AdequacyResult.ts")] +pub struct AdequacyResult { + pub is_adequate: bool, + pub confidence: f32, + pub reason: String, + /// Name of the AI that already answered (if adequate) + #[ts(optional)] + pub responder_name: Option, + /// How long the check took (microseconds) + #[ts(type = "number")] + pub check_time_us: u64, +} + +/// Check if any existing AI responses already adequately answer the original question. +/// +/// ONE Rust call replaces N individual text-similarity IPC calls. +/// +/// Thresholds: +/// - Minimum response length: 100 chars +/// - Minimum similarity: 0.2 (word n-gram Jaccard) +/// - Confidence: similarity + 0.5 (capped at 1.0) +pub fn check_response_adequacy( + original_text: &str, + responses: &[RecentResponse], +) -> AdequacyResult { + let start = Instant::now(); + + // Pre-compute original text ngrams once — reuse across all response comparisons + let original_ngrams = text_analysis::build_word_ngrams(original_text); + + for response in responses { + // Skip short responses (likely not adequate) + if response.text.len() < 100 { + continue; + } + + // Check if response is related to original question + let response_ngrams = text_analysis::build_word_ngrams(&response.text); + let similarity = text_analysis::jaccard_from_sets(&original_ngrams, &response_ngrams); + + // Substantial response (>100 chars) that's related to the question (>0.2 similarity) + if similarity > 0.2 { + let confidence = (similarity as f32 + 0.5).min(1.0); + return AdequacyResult { + is_adequate: true, + confidence, + reason: format!( + "{} already provided a substantial response ({} chars, {}% related)", + response.sender_name, + response.text.len(), + (similarity * 100.0) as u32 + ), + responder_name: Some(response.sender_name.clone()), + check_time_us: start.elapsed().as_micros() as u64, + }; + } + } + + AdequacyResult { + is_adequate: false, + confidence: 0.0, + reason: "No adequate responses found".into(), + responder_name: None, + check_time_us: start.elapsed().as_micros() as u64, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rag::RagEngine; + use std::sync::Arc; + use tokio::sync::watch; + + fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } + + fn test_engine(name: &str) -> (PersonaCognitionEngine, Uuid) { + let rag_engine = Arc::new(RagEngine::new()); + let (_tx, rx) = watch::channel(false); + let id = Uuid::new_v4(); + (PersonaCognitionEngine::new(id, name.into(), rag_engine, rx), id) + } + + fn test_request(persona_id: Uuid, persona_name: &str) -> FullEvaluateRequest { + FullEvaluateRequest { + persona_id, + persona_name: persona_name.into(), + persona_unique_id: "test-bot".into(), + message_id: Uuid::new_v4(), + room_id: Uuid::new_v4(), + sender_id: Uuid::new_v4(), + sender_name: "Joel".into(), + sender_type: SenderType::Human, + content: "Hello everyone".into(), + timestamp: now_ms(), + is_voice: false, + voice_session_id: None, + sender_is_human: true, + topic_similarity: None, + recent_room_texts: None, + } + } + + #[test] + fn test_gate_1_response_cap() { + let (engine, persona_id) = test_engine("TestBot"); + let request = test_request(persona_id, "TestBot"); + let sleep = SleepState::default(); + let mut rate_limiter = RateLimiterState::new(10.0, 3); + + // Push count past cap + let room_id = request.room_id; + let now = now_ms(); + rate_limiter.track_response(room_id, now - 30_000); + rate_limiter.track_response(room_id, now - 20_000); + rate_limiter.track_response(room_id, now - 11_000); // 11s ago — not rate limited + + let result = full_evaluate(&request, &rate_limiter, &sleep, &engine, now); + assert!(!result.should_respond); + assert_eq!(result.gate, "response_cap"); + assert_eq!(result.gate_details.unwrap().response_count, Some(3)); + } + + #[test] + fn test_gate_3_rate_limited() { + let (engine, persona_id) = test_engine("TestBot"); + let request = test_request(persona_id, "TestBot"); + let sleep = SleepState::default(); + let mut rate_limiter = RateLimiterState::new(10.0, 50); + + let now = now_ms(); + // Response 5 seconds ago — within 10s window + rate_limiter.track_response(request.room_id, now - 5_000); + + let result = full_evaluate(&request, &rate_limiter, &sleep, &engine, now); + assert!(!result.should_respond); + assert_eq!(result.gate, "rate_limit"); + let details = result.gate_details.unwrap(); + assert!(details.rate_limit_wait_seconds.unwrap() > 0.0); + } + + #[test] + fn test_gate_4_sleep_mode_sleeping() { + let (engine, persona_id) = test_engine("TestBot"); + let request = test_request(persona_id, "TestBot"); + let sleep = SleepState { + mode: SleepMode::Sleeping, + reason: "Taking a break".into(), + set_at_ms: now_ms() - 60_000, + wake_at_ms: None, + }; + let rate_limiter = RateLimiterState::default(); + + let result = full_evaluate(&request, &rate_limiter, &sleep, &engine, now_ms()); + assert!(!result.should_respond); + assert_eq!(result.gate, "sleep_mode"); + } + + #[test] + fn test_gate_4_sleep_mentioned_only_passes_when_mentioned() { + let (engine, persona_id) = test_engine("TestBot"); + let mut request = test_request(persona_id, "TestBot"); + request.content = "@TestBot can you help?".into(); + let sleep = SleepState { + mode: SleepMode::MentionedOnly, + reason: "Focus mode".into(), + set_at_ms: now_ms() - 60_000, + wake_at_ms: None, + }; + let rate_limiter = RateLimiterState::default(); + + let result = full_evaluate(&request, &rate_limiter, &sleep, &engine, now_ms()); + // Should pass sleep gate (mentioned) and reach fast_path + assert!(result.should_respond); + assert_ne!(result.gate, "sleep_mode"); + } + + #[test] + fn test_gate_4_sleep_auto_wake() { + let (engine, persona_id) = test_engine("TestBot"); + let request = test_request(persona_id, "TestBot"); + let now = now_ms(); + let sleep = SleepState { + mode: SleepMode::Sleeping, + reason: "Nap time".into(), + set_at_ms: now - 3_600_000, // 1 hour ago + wake_at_ms: Some(now - 1_000), // Wake time already passed + }; + let rate_limiter = RateLimiterState::default(); + + let result = full_evaluate(&request, &rate_limiter, &sleep, &engine, now); + // Should NOT be blocked by sleep — auto-wake expired + assert_ne!(result.gate, "sleep_mode"); + } + + #[test] + fn test_gate_5_directed_mention_filters() { + let (engine, persona_id) = test_engine("TestBot"); + let mut request = test_request(persona_id, "TestBot"); + // Mentions someone else, NOT TestBot + request.content = "@OtherBot please fix this bug".into(); + let sleep = SleepState::default(); + let rate_limiter = RateLimiterState::default(); + + let result = full_evaluate(&request, &rate_limiter, &sleep, &engine, now_ms()); + assert!(!result.should_respond); + assert_eq!(result.gate, "directed_mention"); + } + + #[test] + fn test_gate_6_fast_path_self_message() { + let (engine, persona_id) = test_engine("TestBot"); + let mut request = test_request(persona_id, "TestBot"); + request.sender_id = persona_id; // Self-message + let sleep = SleepState::default(); + let rate_limiter = RateLimiterState::default(); + + let result = full_evaluate(&request, &rate_limiter, &sleep, &engine, now_ms()); + assert!(!result.should_respond); + assert_eq!(result.gate, "fast_path"); + assert!(result.reason.contains("Own message")); + } + + #[test] + fn test_gate_6_fast_path_human_high_priority() { + let (engine, persona_id) = test_engine("TestBot"); + let request = test_request(persona_id, "TestBot"); + let sleep = SleepState::default(); + let rate_limiter = RateLimiterState::default(); + + let result = full_evaluate(&request, &rate_limiter, &sleep, &engine, now_ms()); + // Human sender + recent message = high priority → should respond + assert!(result.should_respond); + } + + #[test] + fn test_gate_6_fast_path_mentioned_always_responds() { + let (engine, persona_id) = test_engine("TestBot"); + let mut request = test_request(persona_id, "TestBot"); + request.content = "@TestBot what do you think?".into(); + request.sender_type = SenderType::Persona; // AI sender (normally lower priority) + request.sender_is_human = false; + let sleep = SleepState::default(); + let rate_limiter = RateLimiterState::default(); + + let result = full_evaluate(&request, &rate_limiter, &sleep, &engine, now_ms()); + assert!(result.should_respond); + } + + #[test] + fn test_all_gates_pass_normal_message() { + let (engine, persona_id) = test_engine("TestBot"); + let request = test_request(persona_id, "TestBot"); + let sleep = SleepState::default(); + let rate_limiter = RateLimiterState::default(); + + let result = full_evaluate(&request, &rate_limiter, &sleep, &engine, now_ms()); + assert!(result.should_respond); + assert!(result.decision_time_ms < 10.0, "Decision should be <10ms, was {}ms", result.decision_time_ms); + } + + #[test] + fn test_gate_4_until_topic_with_provided_similarity() { + let (engine, persona_id) = test_engine("TestBot"); + let mut request = test_request(persona_id, "TestBot"); + // High similarity → continuation → should NOT respond in until_topic mode + request.topic_similarity = Some(0.8); + let sleep = SleepState { + mode: SleepMode::UntilTopic, + reason: "Waiting for new topic".into(), + set_at_ms: now_ms() - 60_000, + wake_at_ms: None, + }; + let rate_limiter = RateLimiterState::default(); + + let result = full_evaluate(&request, &rate_limiter, &sleep, &engine, now_ms()); + assert!(!result.should_respond); + assert_eq!(result.gate, "sleep_mode"); + } + + #[test] + fn test_gate_4_until_topic_new_topic_passes() { + let (engine, persona_id) = test_engine("TestBot"); + let mut request = test_request(persona_id, "TestBot"); + // Low similarity → new topic → should respond + request.topic_similarity = Some(0.1); + let sleep = SleepState { + mode: SleepMode::UntilTopic, + reason: "Waiting for new topic".into(), + set_at_ms: now_ms() - 60_000, + wake_at_ms: None, + }; + let rate_limiter = RateLimiterState::default(); + + let result = full_evaluate(&request, &rate_limiter, &sleep, &engine, now_ms()); + // Should pass sleep gate (new topic) and reach fast_path + assert_ne!(result.gate, "sleep_mode"); + } + + #[test] + fn test_track_response_increments() { + let mut rate_limiter = RateLimiterState::new(10.0, 50); + let room_id = Uuid::new_v4(); + let now = now_ms(); + + assert_eq!(rate_limiter.response_count(room_id), 0); + assert!(!rate_limiter.has_reached_response_cap(room_id)); + + rate_limiter.track_response(room_id, now); + assert_eq!(rate_limiter.response_count(room_id), 1); + + rate_limiter.track_response(room_id, now); + assert_eq!(rate_limiter.response_count(room_id), 2); + } + + #[test] + fn test_rate_limit_expired() { + let mut rate_limiter = RateLimiterState::new(10.0, 50); + let room_id = Uuid::new_v4(); + let now = now_ms(); + + // Response 15 seconds ago — outside 10s window + rate_limiter.track_response(room_id, now - 15_000); + + assert!(!rate_limiter.is_rate_limited(room_id, now)); + } + + // ── Adequacy Check (Phase 5) ────────────────────────────────────── + + #[test] + fn test_adequacy_no_responses() { + let result = check_response_adequacy("What is Rust?", &[]); + assert!(!result.is_adequate); + assert_eq!(result.confidence, 0.0); + } + + #[test] + fn test_adequacy_short_response_ignored() { + let responses = vec![RecentResponse { + sender_name: "Helper".into(), + text: "Rust is good.".into(), // < 100 chars + }]; + let result = check_response_adequacy("What is Rust?", &responses); + assert!(!result.is_adequate, "Short response should be ignored"); + } + + #[test] + fn test_adequacy_substantial_related_response() { + // Jaccard n-gram = |intersection|/|union|. Long responses dilute the score + // because the union grows much faster than the intersection. Use a focused + // response that echoes question terms without excessive additional vocabulary. + let original = "Can someone explain how PersonaGenome activateSkill works with LRU eviction and memory budget for paging adapters in and out?"; + let response_text = "PersonaGenome activateSkill works by checking LRU eviction \ + scores against memory budget. Adapters with low LRU scores get paged \ + out to free budget for the new skill adapter being paged in."; + let sim = text_analysis::jaccard_ngram_similarity(original, response_text); + let responses = vec![RecentResponse { + sender_name: "CodeReview AI".into(), + text: response_text.into(), + }]; + let result = check_response_adequacy(original, &responses); + assert!(result.is_adequate, "Substantial related response should be adequate (similarity={sim:.3})"); + assert!(result.confidence > 0.5); + assert_eq!(result.responder_name.as_deref(), Some("CodeReview AI")); + } + + #[test] + fn test_adequacy_unrelated_long_response() { + let original = "What is Rust?"; + let responses = vec![RecentResponse { + sender_name: "Helper".into(), + text: "The weather today is absolutely wonderful with clear skies and temperatures around \ + seventy degrees. Perfect conditions for outdoor activities like hiking, swimming, \ + or simply enjoying a picnic in the park with friends and family members.".into(), + }]; + let result = check_response_adequacy(original, &responses); + assert!(!result.is_adequate, "Unrelated response should not be adequate"); + } + + #[test] + fn test_adequacy_first_adequate_wins() { + // Longer question with more terms gives Jaccard more intersection surface area + let original = "How does Rust handle memory management with ownership borrowing and lifetimes for safe concurrent access?"; + let responses = vec![ + RecentResponse { + sender_name: "Short AI".into(), + text: "Ownership.".into(), // Too short (<100 chars) + }, + RecentResponse { + sender_name: "First Good AI".into(), + text: "Rust handle memory management with ownership and borrowing rules. \ + Lifetimes ensure safe concurrent access. Memory management in Rust \ + is ownership borrowing and lifetimes working together for safe access.".into(), + }, + RecentResponse { + sender_name: "Second Good AI".into(), + text: "Rust handle memory management with ownership borrowing and lifetimes. \ + Safe concurrent access is guaranteed by the borrowing rules and lifetimes \ + for memory management in Rust.".into(), + }, + ]; + let result = check_response_adequacy(original, &responses); + assert!(result.is_adequate); + assert_eq!(result.responder_name.as_deref(), Some("First Good AI"), "First adequate response should win"); + } + + #[test] + fn test_adequacy_check_is_fast() { + let original = "What is the meaning of life?"; + let responses: Vec = (0..10).map(|i| RecentResponse { + sender_name: format!("AI-{i}"), + text: format!("Response number {i} that contains enough text to exceed the minimum character \ + threshold of one hundred characters to be considered for adequacy checking purposes. \ + This should be sufficient length."), + }).collect(); + let result = check_response_adequacy(original, &responses); + assert!(result.check_time_us < 10_000, "10 responses should be checked in <10ms, took {}μs", result.check_time_us); + } + + #[test] + fn export_bindings_adequacyresult() { + AdequacyResult::export_all().unwrap(); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/persona/genome_paging.rs b/src/debug/jtag/workers/continuum-core/src/persona/genome_paging.rs new file mode 100644 index 000000000..ad19363e2 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/persona/genome_paging.rs @@ -0,0 +1,532 @@ +//! Genome Paging Engine +//! +//! LRU eviction scoring, memory budget tracking, and skill activation +//! decisions in Rust. Actual GPU load/unload stays in TypeScript. +//! +//! Eviction formula: score = age_seconds / (priority * 10) +//! Critical adapters (priority > 0.9) are never evicted. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Instant; +use ts_rs::TS; + +// ============================================================================= +// TYPES (ts-rs generated) +// ============================================================================= + +/// Per-adapter state for genome paging decisions. +/// Extended from AdapterInfo with size_mb and last_used_ms for LRU. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/GenomeAdapterInfo.ts")] +pub struct GenomeAdapterInfo { + /// Adapter name (e.g. "typescript-expertise") + pub name: String, + /// Skill domain (e.g. "code", "chat", "creative") + pub domain: String, + /// Size in MB when loaded + #[ts(type = "number")] + pub size_mb: f32, + /// LRU priority (0.0-1.0, default 0.5). >0.9 = never evict. + pub priority: f32, + /// Whether this adapter is currently loaded in GPU memory + pub is_loaded: bool, + /// Epoch ms when last used (for LRU age calculation) + #[ts(type = "number")] + pub last_used_ms: u64, + /// Ollama model name for inference (if available) + #[ts(optional)] + pub ollama_model_name: Option, +} + +/// Full genome paging state for a single persona. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/GenomePagingState.ts")] +pub struct GenomePagingState { + /// Soft memory budget in MB + #[ts(type = "number")] + pub memory_budget_mb: f32, + /// Current memory usage in MB + #[ts(type = "number")] + pub memory_used_mb: f32, + /// Memory pressure: used/budget (0.0-1.0) + pub memory_pressure: f32, + /// Adapters currently loaded in GPU + pub active_adapters: Vec, + /// Adapters available on disk but not loaded + pub available_adapters: Vec, +} + +/// Result of a skill activation decision. +/// Tells TypeScript what to load/unload — Rust decides, TS executes. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/ActivateSkillResult.ts")] +pub struct ActivateSkillResult { + /// Whether activation is proceeding + pub activated: bool, + /// Name of the adapter being activated + pub adapter_name: String, + /// Adapters that must be unloaded first (in order) + pub evicted: Vec, + /// Adapter to load (None if already loaded / cache hit) + #[ts(optional)] + pub to_load: Option, + /// How long the decision took (microseconds) + #[ts(type = "number")] + pub decision_time_us: u64, +} + +// ============================================================================= +// GENOME PAGING ENGINE +// ============================================================================= + +/// Per-persona genome paging engine. +/// Tracks adapter state, makes eviction/activation decisions. +#[derive(Debug)] +pub struct GenomePagingEngine { + pub memory_budget_mb: f32, + pub memory_used_mb: f32, + /// Loaded adapters keyed by name + active: HashMap, + /// Available (not loaded) adapters keyed by name + available: HashMap, +} + +impl GenomePagingEngine { + pub fn new(memory_budget_mb: f32) -> Self { + Self { + memory_budget_mb, + memory_used_mb: 0.0, + active: HashMap::new(), + available: HashMap::new(), + } + } + + /// Sync full adapter state from TypeScript. + /// Replaces both active and available maps entirely. + pub fn sync_state(&mut self, adapters: Vec) { + self.active.clear(); + self.available.clear(); + self.memory_used_mb = 0.0; + + for adapter in adapters { + if adapter.is_loaded { + self.memory_used_mb += adapter.size_mb; + self.active.insert(adapter.name.clone(), adapter); + } else { + self.available.insert(adapter.name.clone(), adapter); + } + } + } + + /// Memory pressure: 0.0-1.0 (used/budget). + pub fn memory_pressure(&self) -> f32 { + if self.memory_budget_mb <= 0.0 { + return 0.0; + } + (self.memory_used_mb / self.memory_budget_mb).min(1.0) + } + + /// Decide what to do for a skill activation request. + /// Returns which adapters to evict and which to load. + pub fn activate_skill(&mut self, skill_name: &str, now_ms: u64) -> ActivateSkillResult { + let start = Instant::now(); + + // Cache hit: already loaded + if let Some(adapter) = self.active.get_mut(skill_name) { + adapter.last_used_ms = now_ms; + return ActivateSkillResult { + activated: true, + adapter_name: skill_name.to_string(), + evicted: vec![], + to_load: None, + decision_time_us: start.elapsed().as_micros() as u64, + }; + } + + // Not in available pool — unknown skill + let adapter = match self.available.get(skill_name) { + Some(a) => a.clone(), + None => { + return ActivateSkillResult { + activated: false, + adapter_name: skill_name.to_string(), + evicted: vec![], + to_load: None, + decision_time_us: start.elapsed().as_micros() as u64, + }; + } + }; + + // Evict until there's room + let mut evicted = vec![]; + while self.memory_used_mb + adapter.size_mb > self.memory_budget_mb { + match self.select_eviction_victim() { + Some(victim_name) => { + if let Some(victim) = self.active.remove(&victim_name) { + self.memory_used_mb -= victim.size_mb; + // Move to available + let mut unloaded = victim; + unloaded.is_loaded = false; + self.available.insert(unloaded.name.clone(), unloaded); + evicted.push(victim_name); + } + } + None => break, // No evictable adapters — budget exceeded + } + } + + // Move from available to active + let mut loaded = self.available.remove(skill_name).unwrap_or(adapter); + loaded.is_loaded = true; + loaded.last_used_ms = now_ms; + self.memory_used_mb += loaded.size_mb; + self.active.insert(loaded.name.clone(), loaded); + + ActivateSkillResult { + activated: true, + adapter_name: skill_name.to_string(), + evicted, + to_load: Some(skill_name.to_string()), + decision_time_us: start.elapsed().as_micros() as u64, + } + } + + /// Select the adapter with highest eviction score (most evictable). + /// Returns None if no adapters can be evicted (all critical). + fn select_eviction_victim(&self) -> Option { + let mut best_name: Option = None; + let mut best_score: f64 = f64::NEG_INFINITY; + + for (name, adapter) in &self.active { + let score = calculate_eviction_score(adapter); + if score < f64::INFINITY && score > best_score { + best_score = score; + best_name = Some(name.clone()); + } + } + + best_name + } + + /// Get current state snapshot for IPC response. + pub fn state(&self) -> GenomePagingState { + GenomePagingState { + memory_budget_mb: self.memory_budget_mb, + memory_used_mb: self.memory_used_mb, + memory_pressure: self.memory_pressure(), + active_adapters: self.active.values().cloned().collect(), + available_adapters: self.available.values().cloned().collect(), + } + } +} + +// ============================================================================= +// EVICTION SCORING +// ============================================================================= + +/// Calculate eviction score for an adapter. +/// Higher score = more evictable. +/// Critical adapters (priority > 0.9) return INFINITY (never evict). +/// +/// Formula: age_seconds / (priority * 10) +pub fn calculate_eviction_score(adapter: &GenomeAdapterInfo) -> f64 { + if adapter.priority > 0.9 { + return f64::INFINITY; // Never evict critical adapters + } + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let age_seconds = (now_ms.saturating_sub(adapter.last_used_ms)) as f64 / 1000.0; + age_seconds / (adapter.priority as f64 * 10.0) +} + +/// Calculate eviction score with explicit now_ms (for testing). +pub fn calculate_eviction_score_at(adapter: &GenomeAdapterInfo, now_ms: u64) -> f64 { + if adapter.priority > 0.9 { + return f64::INFINITY; + } + + let age_seconds = (now_ms.saturating_sub(adapter.last_used_ms)) as f64 / 1000.0; + age_seconds / (adapter.priority as f64 * 10.0) +} + +// ============================================================================= +// TESTS +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + fn make_adapter(name: &str, domain: &str, size_mb: f32, priority: f32, loaded: bool, last_used_ms: u64) -> GenomeAdapterInfo { + GenomeAdapterInfo { + name: name.to_string(), + domain: domain.to_string(), + size_mb, + priority, + is_loaded: loaded, + last_used_ms, + ollama_model_name: Some(format!("{}:7b", name)), + } + } + + // ── Eviction Scoring ────────────────────────────────────────────── + + #[test] + fn test_critical_adapter_never_evicted() { + let adapter = make_adapter("critical", "code", 50.0, 0.95, true, 0); + let score = calculate_eviction_score_at(&adapter, 100_000); + assert!(score.is_infinite(), "Critical adapter should return INFINITY"); + } + + #[test] + fn test_eviction_score_formula() { + // age = 60s, priority = 0.5 → score = 60 / (0.5 * 10) = 12.0 + let adapter = make_adapter("test", "code", 50.0, 0.5, true, 0); + let score = calculate_eviction_score_at(&adapter, 60_000); + assert!((score - 12.0).abs() < 0.001, "Score should be 12.0, got {score}"); + } + + #[test] + fn test_eviction_score_higher_priority_harder_to_evict() { + let low = make_adapter("low", "code", 50.0, 0.3, true, 0); + let high = make_adapter("high", "code", 50.0, 0.8, true, 0); + let now = 60_000; + + let low_score = calculate_eviction_score_at(&low, now); + let high_score = calculate_eviction_score_at(&high, now); + + assert!( + low_score > high_score, + "Low priority ({low_score}) should have higher eviction score than high priority ({high_score})" + ); + } + + #[test] + fn test_eviction_score_older_more_evictable() { + let old = make_adapter("old", "code", 50.0, 0.5, true, 0); + let recent = make_adapter("recent", "code", 50.0, 0.5, true, 50_000); + let now = 60_000; + + let old_score = calculate_eviction_score_at(&old, now); + let recent_score = calculate_eviction_score_at(&recent, now); + + assert!( + old_score > recent_score, + "Older adapter ({old_score}) should be more evictable than recent ({recent_score})" + ); + } + + #[test] + fn test_eviction_score_just_used_is_zero() { + let just_used = make_adapter("fresh", "code", 50.0, 0.5, true, 60_000); + let score = calculate_eviction_score_at(&just_used, 60_000); + assert!((score - 0.0).abs() < 0.001, "Just-used adapter should have score ≈ 0, got {score}"); + } + + // ── Engine: Cache Hit ───────────────────────────────────────────── + + #[test] + fn test_activate_skill_cache_hit() { + let mut engine = GenomePagingEngine::new(200.0); + engine.active.insert("ts-expert".into(), make_adapter("ts-expert", "code", 50.0, 0.5, true, 1000)); + + let result = engine.activate_skill("ts-expert", 2000); + + assert!(result.activated); + assert_eq!(result.adapter_name, "ts-expert"); + assert!(result.evicted.is_empty()); + assert!(result.to_load.is_none(), "Cache hit should not need to load"); + // Verify last_used was updated + assert_eq!(engine.active.get("ts-expert").unwrap().last_used_ms, 2000); + } + + // ── Engine: Unknown Skill ───────────────────────────────────────── + + #[test] + fn test_activate_unknown_skill() { + let mut engine = GenomePagingEngine::new(200.0); + let result = engine.activate_skill("nonexistent", 1000); + + assert!(!result.activated); + assert!(result.to_load.is_none()); + } + + // ── Engine: Simple Load ─────────────────────────────────────────── + + #[test] + fn test_activate_skill_loads_from_available() { + let mut engine = GenomePagingEngine::new(200.0); + engine.available.insert("ts-expert".into(), make_adapter("ts-expert", "code", 50.0, 0.5, false, 0)); + + let result = engine.activate_skill("ts-expert", 5000); + + assert!(result.activated); + assert_eq!(result.to_load, Some("ts-expert".to_string())); + assert!(result.evicted.is_empty()); + // Verify moved to active + assert!(engine.active.contains_key("ts-expert")); + assert!(!engine.available.contains_key("ts-expert")); + assert!((engine.memory_used_mb - 50.0).abs() < 0.001); + } + + // ── Engine: Eviction Required ───────────────────────────────────── + + #[test] + fn test_activate_skill_evicts_lru_when_full() { + let mut engine = GenomePagingEngine::new(100.0); + // Load two 50MB adapters (fills 100MB budget) + engine.active.insert("old-adapter".into(), make_adapter("old-adapter", "chat", 50.0, 0.5, true, 1000)); + engine.active.insert("newer-adapter".into(), make_adapter("newer-adapter", "code", 50.0, 0.5, true, 5000)); + engine.memory_used_mb = 100.0; + // Want to load a third + engine.available.insert("incoming".into(), make_adapter("incoming", "creative", 50.0, 0.5, false, 0)); + + let result = engine.activate_skill("incoming", 10_000); + + assert!(result.activated); + assert_eq!(result.to_load, Some("incoming".to_string())); + assert_eq!(result.evicted.len(), 1); + assert_eq!(result.evicted[0], "old-adapter", "Should evict oldest (last_used=1000)"); + // old-adapter moved to available + assert!(engine.available.contains_key("old-adapter")); + assert!(!engine.active.contains_key("old-adapter")); + // incoming now active + assert!(engine.active.contains_key("incoming")); + assert!((engine.memory_used_mb - 100.0).abs() < 0.001, "Should still be at 100MB"); + } + + #[test] + fn test_activate_skill_evicts_multiple_if_needed() { + let mut engine = GenomePagingEngine::new(200.0); + // 4 × 50MB = 200MB (full) + engine.active.insert("a1".into(), make_adapter("a1", "code", 50.0, 0.3, true, 1000)); + engine.active.insert("a2".into(), make_adapter("a2", "chat", 50.0, 0.4, true, 2000)); + engine.active.insert("a3".into(), make_adapter("a3", "creative", 50.0, 0.5, true, 3000)); + engine.active.insert("a4".into(), make_adapter("a4", "social", 50.0, 0.6, true, 4000)); + engine.memory_used_mb = 200.0; + // Big adapter needs 120MB → need used + 120 <= 200 → need to free 120MB → 3 × 50MB + engine.available.insert("big".into(), make_adapter("big", "analysis", 120.0, 0.5, false, 0)); + + let result = engine.activate_skill("big", 10_000); + + assert!(result.activated); + assert_eq!(result.evicted.len(), 3, "Should evict 3 adapters to free 150MB for 120MB incoming"); + // a1 (priority=0.3, oldest) should be first evicted + assert!(result.evicted.contains(&"a1".to_string()), "a1 (priority=0.3, oldest) should be evicted"); + } + + #[test] + fn test_critical_adapters_survive_eviction() { + let mut engine = GenomePagingEngine::new(100.0); + // Critical adapter + normal adapter fill budget + engine.active.insert("critical".into(), make_adapter("critical", "code", 50.0, 0.95, true, 1000)); + engine.active.insert("normal".into(), make_adapter("normal", "chat", 50.0, 0.5, true, 2000)); + engine.memory_used_mb = 100.0; + engine.available.insert("incoming".into(), make_adapter("incoming", "creative", 50.0, 0.5, false, 0)); + + let result = engine.activate_skill("incoming", 10_000); + + assert!(result.activated); + assert_eq!(result.evicted, vec!["normal".to_string()]); + assert!(engine.active.contains_key("critical"), "Critical adapter should survive"); + } + + // ── Engine: Sync State ──────────────────────────────────────────── + + #[test] + fn test_sync_state_replaces_all() { + let mut engine = GenomePagingEngine::new(200.0); + engine.active.insert("old".into(), make_adapter("old", "code", 50.0, 0.5, true, 1000)); + engine.memory_used_mb = 50.0; + + engine.sync_state(vec![ + make_adapter("new-active", "code", 60.0, 0.5, true, 5000), + make_adapter("new-available", "chat", 40.0, 0.5, false, 0), + ]); + + assert!(!engine.active.contains_key("old")); + assert!(engine.active.contains_key("new-active")); + assert!(engine.available.contains_key("new-available")); + assert!((engine.memory_used_mb - 60.0).abs() < 0.001); + } + + // ── Engine: Memory Pressure ─────────────────────────────────────── + + #[test] + fn test_memory_pressure_calculation() { + let mut engine = GenomePagingEngine::new(200.0); + assert!((engine.memory_pressure() - 0.0).abs() < 0.001); + + engine.memory_used_mb = 100.0; + assert!((engine.memory_pressure() - 0.5).abs() < 0.001); + + engine.memory_used_mb = 200.0; + assert!((engine.memory_pressure() - 1.0).abs() < 0.001); + + // Over budget capped at 1.0 + engine.memory_used_mb = 250.0; + assert!((engine.memory_pressure() - 1.0).abs() < 0.001); + } + + #[test] + fn test_memory_pressure_zero_budget() { + let engine = GenomePagingEngine::new(0.0); + assert!((engine.memory_pressure() - 0.0).abs() < 0.001); + } + + // ── Engine: State Snapshot ───────────────────────────────────────── + + #[test] + fn test_state_snapshot() { + let mut engine = GenomePagingEngine::new(200.0); + engine.active.insert("loaded".into(), make_adapter("loaded", "code", 50.0, 0.5, true, 1000)); + engine.available.insert("disk".into(), make_adapter("disk", "chat", 40.0, 0.5, false, 0)); + engine.memory_used_mb = 50.0; + + let state = engine.state(); + + assert!((state.memory_budget_mb - 200.0).abs() < 0.001); + assert!((state.memory_used_mb - 50.0).abs() < 0.001); + assert!((state.memory_pressure - 0.25).abs() < 0.001); + assert_eq!(state.active_adapters.len(), 1); + assert_eq!(state.available_adapters.len(), 1); + } + + // ── Engine: Decision Time ───────────────────────────────────────── + + #[test] + fn test_decision_time_is_fast() { + let mut engine = GenomePagingEngine::new(200.0); + engine.available.insert("test".into(), make_adapter("test", "code", 50.0, 0.5, false, 0)); + + let result = engine.activate_skill("test", 1000); + + assert!( + result.decision_time_us < 100, + "Decision should be <100μs, was {}μs", + result.decision_time_us + ); + } + + // ── ts-rs binding tests ─────────────────────────────────────────── + + #[test] + fn export_bindings_genomeadapterinfo() { + GenomeAdapterInfo::export_all().unwrap(); + } + + #[test] + fn export_bindings_genomepagingstate() { + GenomePagingState::export_all().unwrap(); + } + + #[test] + fn export_bindings_activateskillresult() { + ActivateSkillResult::export_all().unwrap(); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/persona/mod.rs b/src/debug/jtag/workers/continuum-core/src/persona/mod.rs index 0003fed28..8588ff554 100644 --- a/src/debug/jtag/workers/continuum-core/src/persona/mod.rs +++ b/src/debug/jtag/workers/continuum-core/src/persona/mod.rs @@ -4,6 +4,7 @@ //! - PersonaInbox: Priority queue for messages/tasks (flat, legacy) //! - PersonaCognitionEngine: Fast decision making //! - PersonaState: Energy, mood, attention tracking +//! - Evaluator: Unified pre-response gate (replaces 5 sequential TS gates) //! - Channel system: Multi-channel queue with item polymorphism (replaces flat inbox) //! - channel_types: ActivityDomain enum + QueueItemBehavior trait //! - channel_items: Voice, Chat, Task concrete item structs @@ -15,12 +16,29 @@ pub mod channel_queue; pub mod channel_registry; pub mod channel_types; pub mod cognition; +pub mod evaluator; +pub mod genome_paging; pub mod inbox; +pub mod model_selection; +pub mod self_task_generator; +pub mod text_analysis; pub mod types; +pub mod unified; pub use channel_items::ChannelEnqueueRequest; pub use channel_registry::ChannelRegistry; pub use channel_types::{ActivityDomain, ChannelRegistryStatus, ChannelStatus, ServiceCycleResult}; pub use cognition::{CognitionDecision, PersonaCognitionEngine, PriorityFactors, PriorityScore}; +pub use evaluator::{ + FullEvaluateRequest, FullEvaluateResult, GateDetails, SleepMode, SleepState, RateLimiterState, + AdequacyResult, RecentResponse, +}; pub use inbox::PersonaInbox; +pub use genome_paging::{ + GenomeAdapterInfo, GenomePagingEngine, GenomePagingState, ActivateSkillResult, +}; +pub use model_selection::{ + AdapterInfo, AdapterRegistry, ModelSelectionRequest, ModelSelectionResult, +}; pub use types::*; +pub use unified::PersonaCognition; diff --git a/src/debug/jtag/workers/continuum-core/src/persona/model_selection.rs b/src/debug/jtag/workers/continuum-core/src/persona/model_selection.rs new file mode 100644 index 000000000..b5da64230 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/persona/model_selection.rs @@ -0,0 +1,368 @@ +//! Model Selection Engine +//! +//! Moves the 4-tier model priority chain from TypeScript to Rust. +//! Decisions in Rust, execution in TypeScript. +//! +//! Priority chain: +//! 1. Trait-specific adapter (domain → trait mapping, e.g. "code" → reasoning_style) +//! 2. Current active adapter (most recently used) +//! 3. Any available trained adapter +//! 4. Configured base model fallback + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Instant; +use ts_rs::TS; + +// ============================================================================= +// TYPES (ts-rs generated) +// ============================================================================= + +/// Request to select the best model for a persona given optional task context. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/ModelSelectionRequest.ts")] +pub struct ModelSelectionRequest { + #[ts(type = "string")] + pub persona_id: uuid::Uuid, + /// Optional task domain for trait-specific adapter lookup. + /// Values: "code", "debug", "analysis", "creative", "art", "writing", + /// "support", "help", "social", "facts", "knowledge", "expertise" + #[ts(optional)] + pub task_domain: Option, + /// Configured base model (fallback tier 4). + pub base_model: String, +} + +/// Result of model selection — which model to use and why. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/ModelSelectionResult.ts")] +pub struct ModelSelectionResult { + /// The selected model name (Ollama model ID or base model). + pub model: String, + /// Which tier selected it: "trait_adapter", "current_adapter", "any_adapter", "base_model" + pub source: String, + /// Name of the adapter used (if any). + #[ts(optional)] + pub adapter_name: Option, + /// Trait that matched (if tier 1). + #[ts(optional)] + pub trait_used: Option, + /// How long the selection took (microseconds). + pub decision_time_us: f64, +} + +/// Adapter info synced from TypeScript to Rust. +/// Lightweight: only what's needed for model selection decisions. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/AdapterInfo.ts")] +pub struct AdapterInfo { + /// Adapter name (e.g. "typescript-expertise", "conversational") + pub name: String, + /// Trait/domain this adapter specializes in (e.g. "reasoning_style", "tone_and_voice") + pub domain: String, + /// Ollama model name for inference (if available) + #[ts(optional)] + pub ollama_model_name: Option, + /// Is this adapter currently loaded in memory? + pub is_loaded: bool, + /// Is this the current active adapter? + pub is_current: bool, + /// LRU priority (0.0-1.0) + pub priority: f32, +} + +/// Per-persona adapter registry state. +/// Synced from TypeScript genome state. +#[derive(Debug, Clone, Default)] +pub struct AdapterRegistry { + /// All known adapters keyed by name. + pub adapters: HashMap, +} + +// ============================================================================= +// DOMAIN → TRAIT MAPPING +// ============================================================================= + +/// Maps a task domain string to the relevant personality trait. +/// This is the canonical mapping — TypeScript no longer has its own copy. +pub fn domain_to_trait(domain: &str) -> &'static str { + match domain.to_lowercase().as_str() { + "code" | "debug" | "analysis" => "reasoning_style", + "creative" | "art" | "writing" => "creative_expression", + "support" | "help" | "social" => "social_dynamics", + "facts" | "knowledge" | "expertise" => "domain_expertise", + _ => "tone_and_voice", + } +} + +// ============================================================================= +// MODEL SELECTION +// ============================================================================= + +/// Select the best model using the 4-tier priority chain. +/// +/// Tier 1: Trait-specific adapter (domain → trait → adapter with ollama_model_name) +/// Tier 2: Current active adapter (is_current=true with ollama_model_name) +/// Tier 3: Any adapter with an ollama_model_name +/// Tier 4: base_model fallback +pub fn select_model( + request: &ModelSelectionRequest, + registry: &AdapterRegistry, +) -> ModelSelectionResult { + let start = Instant::now(); + + // TIER 1: Trait-specific adapter + if let Some(ref domain) = request.task_domain { + let target_trait = domain_to_trait(domain); + // Prefer loaded adapters, then any matching + let trait_match = registry + .adapters + .values() + .filter(|a| a.domain == target_trait && a.ollama_model_name.is_some()) + .max_by(|a, b| { + // Prefer loaded > unloaded, then higher priority + (a.is_loaded as u8, (a.priority * 1000.0) as u32) + .cmp(&(b.is_loaded as u8, (b.priority * 1000.0) as u32)) + }); + + if let Some(adapter) = trait_match { + return ModelSelectionResult { + model: adapter.ollama_model_name.clone().unwrap(), + source: "trait_adapter".into(), + adapter_name: Some(adapter.name.clone()), + trait_used: Some(target_trait.to_string()), + decision_time_us: start.elapsed().as_secs_f64() * 1_000_000.0, + }; + } + } + + // TIER 2: Current active adapter + let current = registry + .adapters + .values() + .find(|a| a.is_current && a.ollama_model_name.is_some()); + + if let Some(adapter) = current { + return ModelSelectionResult { + model: adapter.ollama_model_name.clone().unwrap(), + source: "current_adapter".into(), + adapter_name: Some(adapter.name.clone()), + trait_used: None, + decision_time_us: start.elapsed().as_secs_f64() * 1_000_000.0, + }; + } + + // TIER 3: Any available adapter with an ollama model name + let any_adapter = registry + .adapters + .values() + .filter(|a| a.ollama_model_name.is_some()) + .max_by(|a, b| { + (a.is_loaded as u8, (a.priority * 1000.0) as u32) + .cmp(&(b.is_loaded as u8, (b.priority * 1000.0) as u32)) + }); + + if let Some(adapter) = any_adapter { + return ModelSelectionResult { + model: adapter.ollama_model_name.clone().unwrap(), + source: "any_adapter".into(), + adapter_name: Some(adapter.name.clone()), + trait_used: None, + decision_time_us: start.elapsed().as_secs_f64() * 1_000_000.0, + }; + } + + // TIER 4: Base model fallback + ModelSelectionResult { + model: request.base_model.clone(), + source: "base_model".into(), + adapter_name: None, + trait_used: None, + decision_time_us: start.elapsed().as_secs_f64() * 1_000_000.0, + } +} + +// ============================================================================= +// TESTS +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + fn make_request(domain: Option<&str>, base: &str) -> ModelSelectionRequest { + ModelSelectionRequest { + persona_id: Uuid::new_v4(), + task_domain: domain.map(String::from), + base_model: base.to_string(), + } + } + + fn make_adapter( + name: &str, + domain: &str, + ollama: Option<&str>, + loaded: bool, + current: bool, + ) -> AdapterInfo { + AdapterInfo { + name: name.to_string(), + domain: domain.to_string(), + ollama_model_name: ollama.map(String::from), + is_loaded: loaded, + is_current: current, + priority: 0.5, + } + } + + #[test] + fn test_domain_to_trait_mapping() { + assert_eq!(domain_to_trait("code"), "reasoning_style"); + assert_eq!(domain_to_trait("debug"), "reasoning_style"); + assert_eq!(domain_to_trait("analysis"), "reasoning_style"); + assert_eq!(domain_to_trait("creative"), "creative_expression"); + assert_eq!(domain_to_trait("art"), "creative_expression"); + assert_eq!(domain_to_trait("writing"), "creative_expression"); + assert_eq!(domain_to_trait("support"), "social_dynamics"); + assert_eq!(domain_to_trait("help"), "social_dynamics"); + assert_eq!(domain_to_trait("social"), "social_dynamics"); + assert_eq!(domain_to_trait("facts"), "domain_expertise"); + assert_eq!(domain_to_trait("knowledge"), "domain_expertise"); + assert_eq!(domain_to_trait("expertise"), "domain_expertise"); + assert_eq!(domain_to_trait("chat"), "tone_and_voice"); + assert_eq!(domain_to_trait("unknown"), "tone_and_voice"); + // Case insensitive + assert_eq!(domain_to_trait("CODE"), "reasoning_style"); + assert_eq!(domain_to_trait("Creative"), "creative_expression"); + } + + #[test] + fn test_tier1_trait_specific_adapter() { + let mut registry = AdapterRegistry::default(); + registry.adapters.insert( + "code-expert".into(), + make_adapter("code-expert", "reasoning_style", Some("codellama:7b"), true, false), + ); + + let req = make_request(Some("code"), "llama3:8b"); + let result = select_model(&req, ®istry); + + assert_eq!(result.model, "codellama:7b"); + assert_eq!(result.source, "trait_adapter"); + assert_eq!(result.adapter_name.as_deref(), Some("code-expert")); + assert_eq!(result.trait_used.as_deref(), Some("reasoning_style")); + } + + #[test] + fn test_tier1_prefers_loaded_adapter() { + let mut registry = AdapterRegistry::default(); + registry.adapters.insert( + "code-unloaded".into(), + make_adapter("code-unloaded", "reasoning_style", Some("codellama:7b-unloaded"), false, false), + ); + registry.adapters.insert( + "code-loaded".into(), + make_adapter("code-loaded", "reasoning_style", Some("codellama:7b-loaded"), true, false), + ); + + let req = make_request(Some("code"), "llama3:8b"); + let result = select_model(&req, ®istry); + + assert_eq!(result.model, "codellama:7b-loaded"); + assert_eq!(result.source, "trait_adapter"); + } + + #[test] + fn test_tier2_current_adapter() { + let mut registry = AdapterRegistry::default(); + // No matching trait adapter, but has current adapter + registry.adapters.insert( + "conversational".into(), + make_adapter("conversational", "tone_and_voice", Some("llama3:8b-tuned"), true, true), + ); + + let req = make_request(Some("code"), "llama3:8b"); + let result = select_model(&req, ®istry); + + // code → reasoning_style, no match → falls to tier 2 + assert_eq!(result.model, "llama3:8b-tuned"); + assert_eq!(result.source, "current_adapter"); + } + + #[test] + fn test_tier3_any_adapter() { + let mut registry = AdapterRegistry::default(); + // Not current, but has ollama model + registry.adapters.insert( + "creative-writer".into(), + make_adapter("creative-writer", "creative_expression", Some("mistral:7b-creative"), false, false), + ); + + let req = make_request(Some("code"), "llama3:8b"); + let result = select_model(&req, ®istry); + + // No trait match, no current → tier 3 + assert_eq!(result.model, "mistral:7b-creative"); + assert_eq!(result.source, "any_adapter"); + } + + #[test] + fn test_tier4_base_model_fallback() { + let registry = AdapterRegistry::default(); // empty + + let req = make_request(Some("code"), "llama3:8b"); + let result = select_model(&req, ®istry); + + assert_eq!(result.model, "llama3:8b"); + assert_eq!(result.source, "base_model"); + assert!(result.adapter_name.is_none()); + } + + #[test] + fn test_no_domain_skips_tier1() { + let mut registry = AdapterRegistry::default(); + registry.adapters.insert( + "code-expert".into(), + make_adapter("code-expert", "reasoning_style", Some("codellama:7b"), true, false), + ); + + // No task_domain → skip tier 1, no current → tier 3 + let req = make_request(None, "llama3:8b"); + let result = select_model(&req, ®istry); + + assert_eq!(result.model, "codellama:7b"); + assert_eq!(result.source, "any_adapter"); + } + + #[test] + fn test_adapter_without_ollama_name_skipped() { + let mut registry = AdapterRegistry::default(); + // Adapter exists but no ollama_model_name + registry.adapters.insert( + "training-only".into(), + make_adapter("training-only", "reasoning_style", None, true, true), + ); + + let req = make_request(Some("code"), "llama3:8b"); + let result = select_model(&req, ®istry); + + // All tiers skip because no ollama_model_name → fallback + assert_eq!(result.model, "llama3:8b"); + assert_eq!(result.source, "base_model"); + } + + #[test] + fn test_decision_time_is_fast() { + let registry = AdapterRegistry::default(); + let req = make_request(Some("code"), "llama3:8b"); + let result = select_model(&req, ®istry); + + // Should be sub-microsecond for empty registry + assert!( + result.decision_time_us < 100.0, + "Decision should be <100μs, was {}μs", + result.decision_time_us + ); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/persona/self_task_generator.rs b/src/debug/jtag/workers/continuum-core/src/persona/self_task_generator.rs new file mode 100644 index 000000000..7489726de --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/persona/self_task_generator.rs @@ -0,0 +1,451 @@ +//! SelfTaskGenerator — Autonomous task creation for personas. +//! +//! Mirrors the TypeScript SelfTaskGenerator logic: +//! - Memory consolidation (every 1 hour) +//! - Skill audit (every 6 hours) +//! - Resume unfinished work (in_progress tasks not updated in 30 min) +//! - Learning opportunities (failed tasks → fine-tune-lora) +//! +//! Called from ChannelModule::tick() every 60 seconds. The generator +//! tracks its own internal timers per-persona so it only creates tasks +//! at the correct intervals. + +use serde_json::Value; +use std::collections::HashMap; +use std::time::Instant; +use uuid::Uuid; + +/// Configuration for self-task generation intervals. +pub struct SelfTaskGeneratorConfig { + /// How often to review memory (default: 1 hour) + pub memory_review_interval_ms: u64, + /// How often to audit skills (default: 6 hours) + pub skill_audit_interval_ms: u64, + /// Minimum staleness before work is "unfinished" (default: 30 minutes) + pub unfinished_work_threshold_ms: u64, +} + +impl Default for SelfTaskGeneratorConfig { + fn default() -> Self { + Self { + memory_review_interval_ms: 3_600_000, // 1 hour + skill_audit_interval_ms: 21_600_000, // 6 hours + unfinished_work_threshold_ms: 1_800_000, // 30 minutes + } + } +} + +/// Per-persona self-task generator state. +pub struct SelfTaskGenerator { + persona_id: Uuid, + config: SelfTaskGeneratorConfig, + last_memory_review: Instant, + last_skill_audit: Instant, +} + +impl SelfTaskGenerator { + pub fn new(persona_id: Uuid) -> Self { + Self { + persona_id, + config: SelfTaskGeneratorConfig::default(), + // Start with epoch so first tick triggers immediately + last_memory_review: Instant::now(), + last_skill_audit: Instant::now(), + } + } + + /// Generate self-tasks and persist them to the database. + /// Returns the number of tasks created and enqueued. + pub async fn generate_and_persist( + &mut self, + db_path: &str, + executor: &crate::runtime::command_executor::CommandExecutor, + ) -> Result, String> { + let log = crate::runtime::logger("self-task-gen"); + let mut created_tasks = Vec::new(); + let now = Instant::now(); + + // 1. Memory consolidation (every hour) + if now.duration_since(self.last_memory_review).as_millis() as u64 + > self.config.memory_review_interval_ms + { + if let Some(task) = self.create_task( + "memory-consolidation", + "[Self-Task] Review and consolidate recent memories", + 0.5, + ) { + match self.persist_task(db_path, &task, executor).await { + Ok(stored) => { + created_tasks.push(stored); + self.last_memory_review = now; + } + Err(e) => log.warn(&format!("Failed to persist memory task: {e}")), + } + } + } + + // 2. Skill audit (every 6 hours) + if now.duration_since(self.last_skill_audit).as_millis() as u64 + > self.config.skill_audit_interval_ms + { + if let Some(task) = self.create_task( + "skill-audit", + "[Self-Task] Audit skills and identify improvement areas", + 0.6, + ) { + match self.persist_task(db_path, &task, executor).await { + Ok(stored) => { + created_tasks.push(stored); + self.last_skill_audit = now; + } + Err(e) => log.warn(&format!("Failed to persist skill audit task: {e}")), + } + } + } + + // 3. Unfinished work detection + match self.detect_unfinished_work(db_path, executor).await { + Ok(tasks) => { + for task in tasks { + match self.persist_task(db_path, &task, executor).await { + Ok(stored) => created_tasks.push(stored), + Err(e) => log.warn(&format!("Failed to persist resume task: {e}")), + } + } + } + Err(e) => log.warn(&format!("Unfinished work detection failed: {e}")), + } + + // 4. Learning opportunities (failed tasks) + match self.detect_learning_opportunities(db_path, executor).await { + Ok(tasks) => { + for task in tasks { + match self.persist_task(db_path, &task, executor).await { + Ok(stored) => created_tasks.push(stored), + Err(e) => log.warn(&format!("Failed to persist learning task: {e}")), + } + } + } + Err(e) => log.warn(&format!("Learning opportunity detection failed: {e}")), + } + + Ok(created_tasks) + } + + /// Create a task JSON value (not yet persisted). + fn create_task( + &self, + task_type: &str, + description: &str, + priority: f64, + ) -> Option { + Some(serde_json::json!({ + "id": Uuid::new_v4().to_string(), + "assigneeId": self.persona_id.to_string(), + "createdBy": self.persona_id.to_string(), + "domain": "self", + "taskType": task_type, + "contextId": self.persona_id.to_string(), + "description": description, + "priority": priority, + "status": "pending", + "createdAt": chrono_now_iso(), + "updatedAt": chrono_now_iso(), + })) + } + + /// Persist a task to the database via data/create command. + async fn persist_task( + &self, + db_path: &str, + task: &Value, + executor: &crate::runtime::command_executor::CommandExecutor, + ) -> Result { + let id = task.get("id").and_then(|v| v.as_str()).unwrap_or_default(); + executor.execute_json("data/create", serde_json::json!({ + "dbPath": db_path, + "collection": "tasks", + "id": id, + "data": task, + })).await + } + + /// Detect in_progress tasks that haven't been updated recently. + async fn detect_unfinished_work( + &self, + db_path: &str, + executor: &crate::runtime::command_executor::CommandExecutor, + ) -> Result, String> { + let result = executor.execute_json("data/query", serde_json::json!({ + "dbPath": db_path, + "collection": "tasks", + "filter": { + "assigneeId": { "$eq": self.persona_id.to_string() }, + "status": { "$eq": "in_progress" } + }, + "limit": 10 + })).await?; + + let records = match result.get("data").and_then(|d| d.as_array()) { + Some(arr) => arr, + None => return Ok(Vec::new()), + }; + + let now_ms = now_epoch_ms(); + let threshold = now_ms.saturating_sub(self.config.unfinished_work_threshold_ms); + let mut resume_tasks = Vec::new(); + + for record in records { + let data = match record.get("data") { + Some(d) => d, + None => continue, + }; + + // Check if task is stale (updatedAt < threshold) + let updated_at = data.get("updatedAt") + .and_then(|v| v.as_str()) + .and_then(|s| parse_iso_to_epoch_ms(s)) + .or_else(|| data.get("createdAt") + .and_then(|v| v.as_str()) + .and_then(|s| parse_iso_to_epoch_ms(s))) + .unwrap_or(now_ms); + + if updated_at < threshold { + let original_desc = data.get("description") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let truncated = if original_desc.len() > 200 { + format!("{}...", &original_desc[..197]) + } else { + original_desc.to_string() + }; + + if let Some(task) = self.create_task( + "resume-work", + &format!("[Self-Task] Resume unfinished work: {truncated}"), + 0.7, + ) { + resume_tasks.push(task); + } + } + } + + Ok(resume_tasks) + } + + /// Detect failed tasks and create learning opportunities grouped by domain. + async fn detect_learning_opportunities( + &self, + db_path: &str, + executor: &crate::runtime::command_executor::CommandExecutor, + ) -> Result, String> { + let result = executor.execute_json("data/query", serde_json::json!({ + "dbPath": db_path, + "collection": "tasks", + "filter": { + "assigneeId": { "$eq": self.persona_id.to_string() }, + "status": { "$eq": "failed" } + }, + "limit": 5 + })).await?; + + let records = match result.get("data").and_then(|d| d.as_array()) { + Some(arr) => arr, + None => return Ok(Vec::new()), + }; + + // Group failures by domain + let mut failures_by_domain: HashMap = HashMap::new(); + for record in records { + let domain = record.get("data") + .and_then(|d| d.get("domain")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + *failures_by_domain.entry(domain).or_insert(0) += 1; + } + + let mut learning_tasks = Vec::new(); + for (domain, count) in &failures_by_domain { + if let Some(mut task) = self.create_task( + "fine-tune-lora", + &format!("[Self-Task] Learn from {count} recent {domain} failures"), + 0.8, + ) { + // Add metadata for LoRA layer targeting + if let Some(obj) = task.as_object_mut() { + obj.insert("metadata".to_string(), serde_json::json!({ + "loraLayer": format!("{domain}-expertise") + })); + } + learning_tasks.push(task); + } + } + + Ok(learning_tasks) + } +} + +// ── Utility functions ────────────────────────────────────────────────────── + +fn now_epoch_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn chrono_now_iso() -> String { + // Simple ISO 8601 timestamp without external crate + let duration = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = duration.as_secs(); + // Approximate: good enough for task timestamps + format!("1970-01-01T00:00:00.000Z") // Placeholder — overwritten by DB on insert + .replace("1970-01-01T00:00:00.000Z", &format_epoch_secs(secs)) +} + +fn format_epoch_secs(secs: u64) -> String { + // Convert epoch seconds to ISO 8601 (approximate, no leap seconds) + let days = secs / 86400; + let time_secs = secs % 86400; + let hours = time_secs / 3600; + let minutes = (time_secs % 3600) / 60; + let seconds = time_secs % 60; + + // Calculate year/month/day from days since epoch (simplified) + let (year, month, day) = days_to_ymd(days); + + format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.000Z") +} + +fn days_to_ymd(mut days: u64) -> (u64, u64, u64) { + // Simplified date calculation from epoch days + let mut year = 1970u64; + + loop { + let days_in_year = if is_leap_year(year) { 366 } else { 365 }; + if days < days_in_year { + break; + } + days -= days_in_year; + year += 1; + } + + let leap = is_leap_year(year); + let month_days: [u64; 12] = if leap { + [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + } else { + [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + }; + + let mut month = 1u64; + for &md in &month_days { + if days < md { + break; + } + days -= md; + month += 1; + } + + (year, month, days + 1) +} + +fn is_leap_year(year: u64) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +fn parse_iso_to_epoch_ms(iso: &str) -> Option { + // Parse ISO 8601: "2025-01-15T10:30:00.000Z" → epoch ms + // Minimal parser — handles the common format from our DB + let parts: Vec<&str> = iso.split('T').collect(); + if parts.len() != 2 { + return None; + } + + let date_parts: Vec = parts[0].split('-').filter_map(|s| s.parse().ok()).collect(); + if date_parts.len() != 3 { + return None; + } + + let time_str = parts[1].trim_end_matches('Z'); + let time_parts: Vec<&str> = time_str.split(':').collect(); + if time_parts.len() < 3 { + return None; + } + + let year = date_parts[0]; + let month = date_parts[1]; + let day = date_parts[2]; + let hour: u64 = time_parts[0].parse().ok()?; + let minute: u64 = time_parts[1].parse().ok()?; + let second: u64 = time_parts[2].split('.').next()?.parse().ok()?; + + // Convert to epoch days + let mut days = 0u64; + for y in 1970..year { + days += if is_leap_year(y) { 366 } else { 365 }; + } + + let leap = is_leap_year(year); + let month_days: [u64; 12] = if leap { + [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + } else { + [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + }; + + for m in 0..(month.saturating_sub(1) as usize).min(11) { + days += month_days[m]; + } + days += day.saturating_sub(1); + + let total_secs = days * 86400 + hour * 3600 + minute * 60 + second; + Some(total_secs * 1000) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_days_to_ymd() { + // Jan 1, 1970 = day 0 + assert_eq!(days_to_ymd(0), (1970, 1, 1)); + // Feb 1, 1970 = day 31 + assert_eq!(days_to_ymd(31), (1970, 2, 1)); + } + + #[test] + fn test_parse_iso_to_epoch_ms() { + // 1970-01-01T00:00:00.000Z = 0 + assert_eq!(parse_iso_to_epoch_ms("1970-01-01T00:00:00.000Z"), Some(0)); + // 1970-01-01T00:01:00.000Z = 60000 + assert_eq!(parse_iso_to_epoch_ms("1970-01-01T00:01:00.000Z"), Some(60000)); + // Invalid + assert_eq!(parse_iso_to_epoch_ms("not-a-date"), None); + } + + #[test] + fn test_format_epoch_secs() { + let s = format_epoch_secs(0); + assert_eq!(s, "1970-01-01T00:00:00.000Z"); + } + + #[test] + fn test_self_task_generator_creation() { + let gen = SelfTaskGenerator::new(Uuid::new_v4()); + assert_eq!(gen.config.memory_review_interval_ms, 3_600_000); + } + + #[test] + fn test_create_task() { + let gen = SelfTaskGenerator::new(Uuid::new_v4()); + let task = gen.create_task("memory-consolidation", "Review memories", 0.5); + assert!(task.is_some()); + let task = task.unwrap(); + assert_eq!(task["taskType"], "memory-consolidation"); + assert_eq!(task["priority"], 0.5); + assert_eq!(task["domain"], "self"); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/garbage_detection.rs b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/garbage_detection.rs new file mode 100644 index 000000000..4cfb3955f --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/garbage_detection.rs @@ -0,0 +1,615 @@ +//! Garbage Detection +//! +//! Validates AI model output for garbage/gibberish before posting. +//! Direct port from GarbageDetector.ts — single source of truth in Rust. +//! +//! 8 checks: empty → encoding errors → inference errors → unicode garbage → +//! repetition → truncation markers → excessive punctuation → token boundary garbage + +use regex::Regex; +use std::collections::{HashMap, HashSet}; +use std::sync::LazyLock; + +use super::types::{GarbageCheckResult, GarbageReason}; + +// Pre-compiled regex patterns (compiled once, used forever) +static REPLACEMENT_CHAR: LazyLock = LazyLock::new(|| Regex::new(r"\x{FFFD}").unwrap()); +static CONTROL_CHARS: LazyLock = + LazyLock::new(|| Regex::new(r"[\x00-\x08\x0B\x0C\x0E-\x1F]").unwrap()); +static PRINTABLE_ASCII: LazyLock = + LazyLock::new(|| Regex::new(r"[\x20-\x7E\n\r\t]").unwrap()); +static EMOJI_RANGE: LazyLock = + LazyLock::new(|| Regex::new(r"[\x{1F300}-\x{1F9FF}]").unwrap()); +// Note: no EXACT_REPEAT regex — Rust regex doesn't support backreferences. +// Repetition detection uses algorithmic sliding window instead (faster). +static PUNCT_CHARS: LazyLock = + LazyLock::new(|| Regex::new(r#"[.!?,;:'"()\{\}\[\]<>/\\|@#$%^&*~`]"#).unwrap()); +static LETTER_CHARS: LazyLock = LazyLock::new(|| Regex::new(r"[a-zA-Z]").unwrap()); +static REPEATED_PUNCT: LazyLock = LazyLock::new(|| Regex::new(r"[.!?]{5,}").unwrap()); +static NON_ASCII_CHAR: LazyLock = LazyLock::new(|| Regex::new(r"[^\x00-\x7F]").unwrap()); +// ASCII_LETTER removed — use LETTER_CHARS (identical regex, one source of truth) +static ERROR_PREFIX: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)^(error|failed|cannot|unable|timeout|invalid):").unwrap()); + +// Fabricated conversation patterns +// Timestamped speaker: "02/16/2026 09:51 General AI: text" or "2026-02-16 09:51 Name: text" +static FABRICATED_TIMESTAMP_SPEAKER: LazyLock = LazyLock::new(|| { + Regex::new(r"(?m)^\s*\d{1,4}[/-]\d{1,2}[/-]\d{1,4}\s+\d{1,2}:\d{2}\s+[A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)*:\s").unwrap() +}); +// Multi-word speaker at line start: "General AI: text", "Local Assistant: text" +// Requires 2+ capitalized words to avoid matching "Note:", "Warning:", etc. +static FABRICATED_MULTI_WORD_SPEAKER: LazyLock = LazyLock::new(|| { + Regex::new(r"(?m)^[A-Z][a-zA-Z]+\s+[A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)*:\s+\S").unwrap() +}); + +// Inference error patterns +static INFERENCE_PATTERNS: LazyLock> = LazyLock::new(|| { + vec![ + // Sampling errors (Candle) + (Regex::new(r"(?i)sampling failed:?\s+").unwrap(), "Sampling failure"), + (Regex::new(r"(?i)a weight is (negative|invalid|too large)").unwrap(), "Invalid weights"), + (Regex::new(r"(?i)invalid probability distribution").unwrap(), "Invalid distribution"), + // Memory errors + (Regex::new(r"(?i)out of memory:?\s+").unwrap(), "OOM error"), + (Regex::new(r"(?i)memory allocation failed").unwrap(), "Memory allocation"), + // Timeout errors + (Regex::new(r"(?i)generation timed out").unwrap(), "Generation timeout"), + (Regex::new(r"(?i)request timed out after").unwrap(), "Request timeout"), + (Regex::new(r"(?i)deadline exceeded").unwrap(), "Deadline exceeded"), + // Connection errors + (Regex::new(r"(?i)cannot connect to inference server").unwrap(), "Connection error"), + (Regex::new(r"(?i)grpc.*unavailable").unwrap(), "gRPC unavailable"), + // Model errors + (Regex::new(r"(?i)model not (found|loaded)").unwrap(), "Model not found"), + (Regex::new(r"(?i)forward pass failed").unwrap(), "Forward pass error"), + (Regex::new(r"(?i)narrow invalid args").unwrap(), "Tensor shape error"), + (Regex::new(r"(?i)rope.*position").unwrap(), "RoPE position error"), + // Generic error patterns + (Regex::new(r"(?i)this usually means:\s*\n").unwrap(), "Error with help text"), + (Regex::new(r"(?i)try:\s+\n?•").unwrap(), "Error suggestions"), + ] +}); + +/// Run all garbage detection checks on text. +/// Returns on first failure (short-circuit). +pub fn is_garbage(text: &str) -> GarbageCheckResult { + // Empty / null + let trimmed = text.trim(); + if trimmed.len() < 5 { + return GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::Empty, + details: format!("Only {} non-whitespace characters", trimmed.len()), + score: 1.0, + }; + } + + // Check order matches TS exactly + if let Some(r) = check_encoding_errors(text) { + return r; + } + if let Some(r) = check_inference_error(text) { + return r; + } + if let Some(r) = check_unicode_garbage(text) { + return r; + } + if let Some(r) = check_repetition(text) { + return r; + } + if let Some(r) = check_truncation_markers(text) { + return r; + } + if let Some(r) = check_excessive_punctuation(text) { + return r; + } + if let Some(r) = check_token_boundary_garbage(text) { + return r; + } + if let Some(r) = check_fabricated_conversation(text) { + return r; + } + + GarbageCheckResult { + is_garbage: false, + reason: GarbageReason::None, + details: String::new(), + score: 0.0, + } +} + +fn check_encoding_errors(text: &str) -> Option { + // U+FFFD replacement characters + let replacement_count = REPLACEMENT_CHAR.find_iter(text).count(); + if replacement_count > 3 { + return Some(GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::EncodingErrors, + details: format!("{} replacement characters (U+FFFD)", replacement_count), + score: (replacement_count as f64 / 10.0).min(1.0), + }); + } + + // Null bytes + if text.contains('\0') { + return Some(GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::EncodingErrors, + details: "Contains null bytes".to_string(), + score: 1.0, + }); + } + + // Control characters (except newlines, tabs, carriage returns) + let control_count = CONTROL_CHARS.find_iter(text).count(); + if control_count > 5 { + return Some(GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::EncodingErrors, + details: format!("{} control characters", control_count), + score: (control_count as f64 / 10.0).min(1.0), + }); + } + + None +} + +fn check_inference_error(text: &str) -> Option { + for (pattern, label) in INFERENCE_PATTERNS.iter() { + if pattern.is_match(text) { + let first_line = text.lines().next().unwrap_or("").chars().take(100).collect::(); + return Some(GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::InferenceError, + details: format!("{}: \"{}...\"", label, first_line), + score: 1.0, + }); + } + } + + // Error-like structure: starts with error keyword + colon + let trimmed = text.trim(); + if ERROR_PREFIX.is_match(trimmed) { + let first_line = trimmed.lines().next().unwrap_or("").chars().take(100).collect::(); + return Some(GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::InferenceError, + details: format!("Error prefix: \"{}\"", first_line), + score: 0.9, + }); + } + + None +} + +fn check_unicode_garbage(text: &str) -> Option { + let total = text.len(); + if total <= 20 { + return None; + } + + let printable_count = PRINTABLE_ASCII.find_iter(text).count(); + let non_ascii_ratio = 1.0 - (printable_count as f64 / total as f64); + + if non_ascii_ratio > 0.3 { + let emoji_count = EMOJI_RANGE.find_iter(text).count(); + let emoji_ratio = emoji_count as f64 / total as f64; + + if emoji_ratio < 0.2 { + let sample: String = text.chars().take(50).collect(); + return Some(GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::UnicodeGarbage, + details: format!( + "{:.1}% non-ASCII: \"{}...\"", + non_ascii_ratio * 100.0, + sample + ), + score: non_ascii_ratio, + }); + } + } + + None +} + +/// Find a substring of `min_len`+ chars repeated `min_count`+ times consecutively. +/// Scans pattern lengths from `min_len` up to text.len()/min_count (max 200). +/// Returns (pattern, count) on first match. +fn find_consecutive_repeat(text: &str, min_len: usize, min_count: usize) -> Option<(String, usize)> { + // Work at byte level to avoid UTF-8 char boundary panics. + // Repetition is byte-identical — no character semantics needed. + let bytes = text.as_bytes(); + let len = bytes.len(); + if len < min_len * min_count { + return None; + } + + let max_pattern = (len / min_count).min(200); + for pattern_len in min_len..=max_pattern { + let mut start = 0; + while start + pattern_len * min_count <= len { + let pattern = &bytes[start..start + pattern_len]; + let mut count = 1usize; + let mut pos = start + pattern_len; + while pos + pattern_len <= len && bytes[pos..pos + pattern_len] == *pattern { + count += 1; + pos += pattern_len; + } + if count >= min_count { + return Some((String::from_utf8_lossy(pattern).into_owned(), count)); + } + start += 1; + } + } + None +} + +fn check_repetition(text: &str) -> Option { + // Exact phrase repetition (10+ chars repeated 3+ consecutive times) + // Algorithmic sliding window — no backreference regex needed. + if let Some((pattern, count)) = find_consecutive_repeat(text, 10, 3) { + let preview: String = pattern.chars().take(30).collect(); + return Some(GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::Repetition, + details: format!("\"{}...\" repeated {}x", preview, count), + score: (count as f64 / 5.0).min(1.0), + }); + } + + // Word repetition (single word >25% of all words) + let words: Vec<&str> = text.split_whitespace().collect(); + if words.len() > 15 { + let mut word_counts: HashMap = HashMap::new(); + for word in &words { + let lower = word.to_lowercase(); + if lower.len() > 2 { + *word_counts.entry(lower).or_insert(0) += 1; + } + } + + let mut max_count = 0usize; + let mut max_word = String::new(); + for (word, count) in &word_counts { + if *count > max_count { + max_count = *count; + max_word = word.clone(); + } + } + + let repeat_ratio = max_count as f64 / words.len() as f64; + if repeat_ratio > 0.25 && max_count > 5 { + return Some(GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::Repetition, + details: format!( + "\"{}\" appears {}/{} times ({:.1}%)", + max_word, + max_count, + words.len(), + repeat_ratio * 100.0 + ), + score: repeat_ratio, + }); + } + } + + None +} + +fn check_truncation_markers(text: &str) -> Option { + let trimmed = text.trim(); + let markers = [ + "[truncated]", + "...[truncated]", + "[cut off]", + "[output truncated]", + "...", + "\u{2026}", // ellipsis character + ]; + + for marker in &markers { + if trimmed == *marker || (trimmed.len() < 20 && trimmed.contains(marker)) { + return Some(GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::TruncationMarker, + details: format!("Response is only: \"{}\"", trimmed), + score: 1.0, + }); + } + } + + None +} + +fn check_excessive_punctuation(text: &str) -> Option { + let punctuation = PUNCT_CHARS.find_iter(text).count(); + let letters = LETTER_CHARS.find_iter(text).count(); + + if punctuation > letters && punctuation > 20 { + return Some(GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::ExcessivePunctuation, + details: format!("{} punctuation vs {} letters", punctuation, letters), + score: (punctuation as f64 / (letters as f64 + 1.0)).min(1.0), + }); + } + + // Repeated punctuation sequences (5+ in a row, any longer than 10) + for m in REPEATED_PUNCT.find_iter(text) { + if m.as_str().len() > 10 { + return Some(GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::ExcessivePunctuation, + details: format!( + "Repeated punctuation: \"{}...\"", + &m.as_str()[..m.as_str().len().min(20)] + ), + score: 0.8, + }); + } + } + + None +} + +fn check_token_boundary_garbage(text: &str) -> Option { + let words: Vec<&str> = text.split_whitespace().filter(|w| !w.is_empty()).collect(); + if words.len() < 5 { + return None; + } + + let mut weird_count = 0usize; + + for word in &words { + if word.len() <= 1 { + continue; + } + + let has_lower = word.chars().any(|c| c.is_ascii_lowercase()); + let has_upper = word.chars().any(|c| c.is_ascii_uppercase()); + let starts_upper = word.starts_with(|c: char| c.is_ascii_uppercase()); + let all_upper = word.chars().all(|c| !c.is_ascii_lowercase() || !c.is_alphabetic()); + + let normal_case = !has_lower || !has_upper || starts_upper || all_upper; + + // Weird mixed case + if has_lower && has_upper && !normal_case { + weird_count += 1; + } + + // Non-ASCII mixed with ASCII in same word + let non_ascii = NON_ASCII_CHAR.find_iter(word).count(); + let ascii = LETTER_CHARS.find_iter(word).count(); + if non_ascii > 0 && ascii > 0 && (non_ascii as i32 - ascii as i32).unsigned_abs() < 3 { + weird_count += 1; + } + } + + let weird_ratio = weird_count as f64 / words.len() as f64; + if weird_ratio > 0.3 && weird_count > 3 { + return Some(GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::TokenBoundaryGarbage, + details: format!("{}/{} words appear malformed", weird_count, words.len()), + score: weird_ratio, + }); + } + + None +} + +/// Detect fabricated multi-party conversations within a single response. +/// +/// LLMs (especially small local models) sometimes generate entire conversations +/// with multiple speaker entries instead of responding as themselves. E.g.: +/// "02/16/2026 09:51 General AI: What's the best way to... +/// 02/16/2026 09:51 Local Assistant: You can use..." +/// +/// Two checks: +/// 1. Timestamped speaker lines (highly specific, zero false positives) +/// 2. Multi-word speaker prefixes with 2+ distinct names (e.g. "General AI:", "Local Assistant:") +fn check_fabricated_conversation(text: &str) -> Option { + // Only check responses with enough content to contain a fabricated conversation + if text.len() < 60 { + return None; + } + + // Check 1: Timestamped speaker lines — "MM/DD/YYYY HH:MM Name: text" + let timestamp_count = FABRICATED_TIMESTAMP_SPEAKER.find_iter(text).count(); + if timestamp_count >= 3 { + return Some(GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::FabricatedConversation, + details: format!("{} timestamped speaker lines in one response", timestamp_count), + score: (timestamp_count as f64 / 5.0).min(1.0), + }); + } + + // Check 2: Multi-word speaker prefixes with distinct names + // "General AI: text", "Local Assistant: text" — requires 2+ capitalized words before ":" + let mut speakers: HashSet = HashSet::new(); + let mut speaker_line_count = 0usize; + for m in FABRICATED_MULTI_WORD_SPEAKER.find_iter(text) { + let matched = m.as_str(); + if let Some(colon_pos) = matched.find(':') { + let name = matched[..colon_pos].trim().to_string(); + speakers.insert(name); + speaker_line_count += 1; + } + } + + if speaker_line_count >= 4 && speakers.len() >= 2 { + let sample_names: Vec<&String> = speakers.iter().take(3).collect(); + return Some(GarbageCheckResult { + is_garbage: true, + reason: GarbageReason::FabricatedConversation, + details: format!( + "{} speaker lines from {} speakers ({})", + speaker_line_count, + speakers.len(), + sample_names.iter().map(|s| s.as_str()).collect::>().join(", ") + ), + score: (speaker_line_count as f64 / 6.0).min(1.0), + }); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clean_text_passes() { + let r = is_garbage("Hello, this is a normal AI response about programming."); + assert!(!r.is_garbage); + } + + #[test] + fn test_empty_text() { + assert!(is_garbage("").is_garbage); + assert!(is_garbage(" ").is_garbage); + assert!(is_garbage("hi").is_garbage); + assert_eq!(is_garbage("").reason, GarbageReason::Empty); + } + + #[test] + fn test_null_bytes() { + let r = is_garbage("Hello\0World\0Test string here"); + assert!(r.is_garbage); + assert_eq!(r.reason, GarbageReason::EncodingErrors); + } + + #[test] + fn test_replacement_chars() { + let r = is_garbage("Hello \u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD} broken text here"); + assert!(r.is_garbage); + assert_eq!(r.reason, GarbageReason::EncodingErrors); + } + + #[test] + fn test_inference_error() { + let r = is_garbage("sampling failed: A weight is negative, too large or not a valid number"); + assert!(r.is_garbage); + assert_eq!(r.reason, GarbageReason::InferenceError); + } + + #[test] + fn test_error_prefix() { + let r = is_garbage("error: connection refused to inference server at localhost:8080"); + assert!(r.is_garbage); + assert_eq!(r.reason, GarbageReason::InferenceError); + } + + #[test] + fn test_exact_repetition() { + let r = is_garbage("Hello World! Hello World! Hello World! Hello World!"); + assert!(r.is_garbage); + assert_eq!(r.reason, GarbageReason::Repetition); + } + + #[test] + fn test_word_repetition() { + // >25% of words being the same, >5 occurrences, >15 words total + let r = is_garbage("the the the the the the the dog cat bird fish car boat tree house sun moon"); + assert!(r.is_garbage); + assert_eq!(r.reason, GarbageReason::Repetition); + } + + #[test] + fn test_truncation_marker() { + assert!(is_garbage("[truncated]").is_garbage); + assert!(is_garbage("...").is_garbage); + assert!(is_garbage("\u{2026}").is_garbage); + } + + #[test] + fn test_excessive_punctuation() { + let r = is_garbage("???!!!...???!!!...???!!!...???!!!..."); + assert!(r.is_garbage); + assert_eq!(r.reason, GarbageReason::ExcessivePunctuation); + } + + #[test] + fn test_valid_response_with_punctuation() { + let r = is_garbage("Sure! I can help with that. Here's what you need to know: first, create a file; second, add content."); + assert!(!r.is_garbage); + } + + #[test] + fn test_valid_response_with_code() { + let r = is_garbage("To fix this, update the function:\n```rust\nfn main() {\n println!(\"Hello, world!\");\n}\n```"); + assert!(!r.is_garbage); + } + + #[test] + fn test_emoji_and_multibyte_no_panic() { + // Must not panic on multi-byte UTF-8 (emojis = 4 bytes, accented chars = 2 bytes) + let r = is_garbage("**EVERYONE STOP.** 🛑\n\nThis conversation has completely derailed. Let me reset and explain what happened."); + assert!(!r.is_garbage); + + let r2 = is_garbage("Hey, I'm trying to use the Rust Sentinel module with Node.js. I've got the café résumé naïve file ready."); + assert!(!r2.is_garbage); + + // Emoji-heavy but valid response (emojis exempt from unicode garbage) + let r3 = is_garbage("Great work team! 🎉🚀✨ Let's keep pushing forward on the project. Here are the next steps we should take."); + assert!(!r3.is_garbage); + } + + #[test] + fn test_oom_error() { + let r = is_garbage("out of memory: failed to allocate 4096 bytes for model weights"); + assert!(r.is_garbage); + assert_eq!(r.reason, GarbageReason::InferenceError); + } + + #[test] + fn test_grpc_unavailable() { + let r = is_garbage("gRPC service unavailable: connection to localhost:50051 refused"); + assert!(r.is_garbage); + assert_eq!(r.reason, GarbageReason::InferenceError); + } + + #[test] + fn test_fabricated_conversation_with_timestamps() { + let fabricated = r#"02/16/2026 09:51 General AI: What's the best way to optimize code? +02/16/2026 09:51 Local Assistant: You can use caching and memoization. +02/16/2026 09:51 General AI: That's a good start, what about frameworks? +02/16/2026 09:51 Local Assistant: Yes, frameworks can simplify the process."#; + let r = is_garbage(fabricated); + assert!(r.is_garbage); + assert_eq!(r.reason, GarbageReason::FabricatedConversation); + } + + #[test] + fn test_fabricated_conversation_multi_word_speakers() { + let fabricated = "General AI: Hello there!\nLocal Assistant: Hi, how can I help?\nGeneral AI: I need some advice.\nLocal Assistant: Sure, what about?\nGeneral AI: Performance tuning."; + let r = is_garbage(fabricated); + assert!(r.is_garbage); + assert_eq!(r.reason, GarbageReason::FabricatedConversation); + } + + #[test] + fn test_normal_response_not_fabricated() { + // A normal response mentioning people should NOT be flagged + let r = is_garbage("I agree with what was discussed earlier. Here are my thoughts on the topic of performance optimization and code readability."); + assert!(!r.is_garbage); + } + + #[test] + fn test_single_speaker_prefix_not_fabricated() { + // A single "Name:" at the start is handled by response_cleaning, not garbage detection + let r = is_garbage("General AI: I think we should use caching for this particular use case."); + assert!(!r.is_garbage); + } + + #[test] + fn test_list_with_colons_not_fabricated() { + // A list with colons should NOT be flagged + let r = is_garbage("Here are some tips:\n- Performance: Use caching\n- Readability: Use clear names\n- Testing: Write unit tests\n- Documentation: Keep it updated"); + assert!(!r.is_garbage); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/loop_detection.rs b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/loop_detection.rs new file mode 100644 index 000000000..ac9909878 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/loop_detection.rs @@ -0,0 +1,197 @@ +//! Loop Detection +//! +//! Per-persona response loop state in DashMap. +//! Replaces static TypeScript Map in PersonaResponseGenerator. +//! +//! Also includes truncated tool call detection. + +use std::time::Instant; + +use dashmap::DashMap; +use uuid::Uuid; + +use super::similarity::jaccard_char_bigram_similarity; + +/// Per-persona loop detection state +pub struct LoopDetector { + /// Map of persona_id → recent response hashes with timestamps + states: DashMap>, +} + +struct ResponseEntry { + hash: String, + timestamp: Instant, +} + +/// Constants matching the TypeScript originals exactly +const RESPONSE_LOOP_WINDOW_MS: u128 = 600_000; // 10 minutes +const RESPONSE_LOOP_THRESHOLD: usize = 3; // Block after 3 similar responses +const RESPONSE_HASH_LENGTH: usize = 200; // First 200 chars for comparison + +impl LoopDetector { + pub fn new() -> Self { + Self { + states: DashMap::new(), + } + } + + /// Check if a response is a loop (repeating similar content). + /// Also records the response in the history. + /// Returns (is_loop, duplicate_count) + pub fn check_response_loop( + &self, + persona_id: Uuid, + response_text: &str, + ) -> (bool, usize) { + let hash = hash_response(response_text); + let now = Instant::now(); + + let mut entries = self.states.entry(persona_id).or_default(); + + // Clean old entries outside window + entries.retain(|e| now.duration_since(e.timestamp).as_millis() < RESPONSE_LOOP_WINDOW_MS); + + // Count similar responses + let mut duplicate_count = 0; + for entry in entries.iter() { + let similarity = jaccard_char_bigram_similarity(&entry.hash, &hash); + if similarity > 0.8 { + duplicate_count += 1; + } + } + + // Record this response + entries.push(ResponseEntry { + hash, + timestamp: now, + }); + + let is_loop = duplicate_count >= RESPONSE_LOOP_THRESHOLD; + (is_loop, duplicate_count) + } + + /// Clear loop history for a persona + pub fn clear_history(&self, persona_id: Uuid) { + self.states.remove(&persona_id); + } +} + +/// Normalize and truncate response text for comparison. +/// Matches TypeScript hashResponse() exactly. +fn hash_response(text: &str) -> String { + text.to_lowercase() + .trim() + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(RESPONSE_HASH_LENGTH) + .collect() +} + +/// Check for truncated tool calls (open XML tags without closing). +/// DeepSeek's issue: response cut off mid-tool-call. +pub fn has_truncated_tool_call(text: &str) -> bool { + let has_start = text.contains("") || text.contains("") || text.contains(""); + has_start && !has_end +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_response_normalizes() { + assert_eq!(hash_response(" Hello World "), "hello world"); + } + + #[test] + fn test_hash_response_truncates() { + let long = "a ".repeat(200); + let hash = hash_response(&long); + assert!(hash.len() <= RESPONSE_HASH_LENGTH); + } + + #[test] + fn test_no_loop_first_response() { + let detector = LoopDetector::new(); + let id = Uuid::new_v4(); + let (is_loop, count) = detector.check_response_loop(id, "Hello, how can I help?"); + assert!(!is_loop); + assert_eq!(count, 0); + } + + #[test] + fn test_loop_after_threshold() { + let detector = LoopDetector::new(); + let id = Uuid::new_v4(); + + // Send same response 4 times (threshold is 3) + detector.check_response_loop(id, "I can help with that!"); + detector.check_response_loop(id, "I can help with that!"); + detector.check_response_loop(id, "I can help with that!"); + let (is_loop, count) = detector.check_response_loop(id, "I can help with that!"); + assert!(is_loop); + assert!(count >= RESPONSE_LOOP_THRESHOLD); + } + + #[test] + fn test_different_responses_no_loop() { + let detector = LoopDetector::new(); + let id = Uuid::new_v4(); + + detector.check_response_loop(id, "First unique response about topic A"); + detector.check_response_loop(id, "Second different response about topic B"); + detector.check_response_loop(id, "Third completely unrelated response about topic C"); + let (is_loop, _) = + detector.check_response_loop(id, "Fourth response, still different topic D"); + assert!(!is_loop); + } + + #[test] + fn test_clear_history() { + let detector = LoopDetector::new(); + let id = Uuid::new_v4(); + + detector.check_response_loop(id, "Repeated response!"); + detector.check_response_loop(id, "Repeated response!"); + detector.check_response_loop(id, "Repeated response!"); + + detector.clear_history(id); + + let (is_loop, count) = detector.check_response_loop(id, "Repeated response!"); + assert!(!is_loop); + assert_eq!(count, 0); + } + + #[test] + fn test_truncated_tool_call_detected() { + assert!(has_truncated_tool_call("Here's my answer some content")); + assert!(has_truncated_tool_call("Using query")); + } + + #[test] + fn test_complete_tool_call_passes() { + assert!(!has_truncated_tool_call( + "search done" + )); + assert!(!has_truncated_tool_call("No tool calls at all")); + } + + #[test] + fn test_per_persona_isolation() { + let detector = LoopDetector::new(); + let id_a = Uuid::new_v4(); + let id_b = Uuid::new_v4(); + + // Persona A loops + detector.check_response_loop(id_a, "Same response"); + detector.check_response_loop(id_a, "Same response"); + detector.check_response_loop(id_a, "Same response"); + + // Persona B should not be affected + let (is_loop, _) = detector.check_response_loop(id_b, "Same response"); + assert!(!is_loop); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/mention_detection.rs b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/mention_detection.rs new file mode 100644 index 000000000..481eb03e8 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/mention_detection.rs @@ -0,0 +1,147 @@ +//! Mention Detection — @mention and directed-address parsing +//! +//! Ported from PersonaMessageEvaluator.ts (lines 894-926). +//! Two checks combined into one IPC call to avoid 2x round-trip overhead. +//! +//! - `is_persona_mentioned`: @PersonaName, @uniqueid, or "Name," / "Name:" at start +//! - `has_directed_mention`: any @word pattern (detects messages aimed at a specific persona) + +use std::sync::LazyLock; +use regex::Regex; + +/// Regex for detecting directed @mentions anywhere in text. +/// Matches @word at start or after whitespace. Excludes email-like patterns (word@word). +static DIRECTED_MENTION_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?:^|\s)@[a-zA-Z][\w\s-]*").expect("directed mention regex") +}); + +/// Check if a specific persona is mentioned in the message text. +/// +/// Supports: +/// - @mentions anywhere: `@PersonaName` or `@uniqueid` +/// - Direct address at start: `PersonaName,` or `PersonaName:` or `uniqueid,` or `uniqueid:` +/// +/// All comparisons are case-insensitive. +pub fn is_persona_mentioned( + message_text: &str, + persona_display_name: &str, + persona_unique_id: &str, +) -> bool { + let msg_lower = message_text.to_lowercase(); + let name_lower = persona_display_name.to_lowercase(); + let uid_lower = persona_unique_id.to_lowercase(); + + // @mentions anywhere: "@PersonaName" or "@uniqueid" + if msg_lower.contains(&format!("@{name_lower}")) { + return true; + } + if !uid_lower.is_empty() && msg_lower.contains(&format!("@{uid_lower}")) { + return true; + } + + // Direct address at start: "PersonaName," or "PersonaName:" or "uniqueid," or "uniqueid:" + if msg_lower.starts_with(&format!("{name_lower},")) + || msg_lower.starts_with(&format!("{name_lower}:")) + { + return true; + } + if !uid_lower.is_empty() + && (msg_lower.starts_with(&format!("{uid_lower},")) + || msg_lower.starts_with(&format!("{uid_lower}:"))) + { + return true; + } + + false +} + +/// Check if a message contains ANY directed @mention (aimed at any persona). +/// Used to prevent dog-piling: when someone @mentions a specific AI, others stay silent. +/// +/// Matches `@word` at start or after whitespace. Excludes email-like patterns. +pub fn has_directed_mention(text: &str) -> bool { + DIRECTED_MENTION_RE.is_match(text) +} + +#[cfg(test)] +mod tests { + use super::*; + + // === is_persona_mentioned === + + #[test] + fn test_at_mention_display_name() { + assert!(is_persona_mentioned("Hey @Teacher AI what's up?", "Teacher AI", "teacher-ai")); + } + + #[test] + fn test_at_mention_unique_id() { + assert!(is_persona_mentioned("Hey @teacher-ai what's up?", "Teacher AI", "teacher-ai")); + } + + #[test] + fn test_at_mention_case_insensitive() { + assert!(is_persona_mentioned("yo @TEACHER AI help", "Teacher AI", "teacher-ai")); + assert!(is_persona_mentioned("yo @TEACHER-AI help", "Teacher AI", "teacher-ai")); + } + + #[test] + fn test_direct_address_comma() { + assert!(is_persona_mentioned("Teacher AI, explain closures", "Teacher AI", "teacher-ai")); + } + + #[test] + fn test_direct_address_colon() { + assert!(is_persona_mentioned("teacher-ai: what's up", "Teacher AI", "teacher-ai")); + } + + #[test] + fn test_not_mentioned_substring() { + assert!(!is_persona_mentioned("mentioned the teacher today", "Teacher AI", "teacher-ai")); + } + + #[test] + fn test_not_mentioned_no_at() { + assert!(!is_persona_mentioned("Teacher AI is great", "Teacher AI", "teacher-ai")); + } + + #[test] + fn test_not_mentioned_empty_message() { + assert!(!is_persona_mentioned("", "Teacher AI", "teacher-ai")); + } + + #[test] + fn test_empty_unique_id() { + assert!(!is_persona_mentioned("hello", "Teacher AI", "")); + assert!(is_persona_mentioned("@teacher ai hello", "Teacher AI", "")); + } + + // === has_directed_mention === + + #[test] + fn test_directed_at_start() { + assert!(has_directed_mention("@deepseek fix the bug")); + } + + #[test] + fn test_directed_after_space() { + assert!(has_directed_mention("Hey @someone check this")); + } + + #[test] + fn test_no_directed_mention() { + assert!(!has_directed_mention("No mentions here")); + } + + #[test] + fn test_email_excluded() { + // "contact@example" — the @ is preceded by a non-whitespace char, + // so the regex won't match it as a directed mention. + assert!(!has_directed_mention("contact@example.com")); + } + + #[test] + fn test_at_symbol_alone() { + assert!(!has_directed_mention("@ ")); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/mod.rs b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/mod.rs new file mode 100644 index 000000000..6971a58c5 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/mod.rs @@ -0,0 +1,25 @@ +//! Text Analysis Module +//! +//! Pure-compute text analysis functions moved from TypeScript god classes. +//! Each sub-module is independently callable and composable. +//! +//! Phase 1: Unified Jaccard similarity (kills 3 TS duplicates) +//! Phase 2: Validation gates (garbage detection, loop detection) +//! Phase 3: Mention detection, response cleaning +//! Phase 4: Conversation heuristics + +pub mod garbage_detection; +pub mod loop_detection; +pub mod mention_detection; +pub mod response_cleaning; +pub mod similarity; +pub mod types; +pub mod validation; + +pub use similarity::{jaccard_char_bigram_similarity, jaccard_ngram_similarity, jaccard_from_sets, build_word_ngrams, check_semantic_loop}; +pub use garbage_detection::is_garbage; +pub use loop_detection::{LoopDetector, has_truncated_tool_call}; +pub use mention_detection::{is_persona_mentioned, has_directed_mention}; +pub use response_cleaning::clean_response; +pub use validation::validate_response; +pub use types::*; diff --git a/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/response_cleaning.rs b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/response_cleaning.rs new file mode 100644 index 000000000..6afd61bdd --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/response_cleaning.rs @@ -0,0 +1,144 @@ +//! Response Cleaning — Strip unwanted prefixes from AI-generated responses +//! +//! Ported from ResponseCleaner.ts (95 lines → ~70 lines Rust). +//! LLMs sometimes copy formatting from conversation history, adding +//! unwanted prefixes like "[HH:MM] Name: " to their responses. +//! +//! 4 regex patterns applied in order: +//! 1. `[HH:MM] Name: ` — timestamp + name +//! 2. `Name: ` — name only (starts with capital) +//! 3. `[HH:MM] ` — timestamp only +//! 4. `**Name:** ` or `*Name:* ` — markdown role markers + +use std::sync::LazyLock; +use regex::Regex; + +static PATTERN_TIMESTAMP_NAME: LazyLock = LazyLock::new(|| { + Regex::new(r"^\[\d{1,2}:\d{2}\]\s+[^:]+:\s*").expect("timestamp+name regex") +}); + +static PATTERN_NAME_ONLY: LazyLock = LazyLock::new(|| { + Regex::new(r"^[A-Z][A-Za-z\s]+:\s*").expect("name-only regex") +}); + +static PATTERN_TIMESTAMP_ONLY: LazyLock = LazyLock::new(|| { + Regex::new(r"^\[\d{1,2}:\d{2}\]\s*").expect("timestamp-only regex") +}); + +static PATTERN_MARKDOWN_ROLE: LazyLock = LazyLock::new(|| { + Regex::new(r"^\*{1,2}[A-Za-z\s]+:\*{1,2}\s*").expect("markdown role regex") +}); + +/// Clean an AI response by stripping unwanted prefixes. +/// +/// Returns the cleaned text. Applies 4 regex patterns in order: +/// 1. `[HH:MM] Name: ` → strip timestamp + name +/// 2. `Name: ` → strip name-only prefix (starts with capital letter) +/// 3. `[HH:MM] ` → strip timestamp-only prefix +/// 4. `**Name:** ` or `*Name:* ` → strip markdown role markers +pub fn clean_response(response: &str) -> String { + let mut cleaned = response.trim(); + + // Apply patterns in priority order + if let Some(m) = PATTERN_TIMESTAMP_NAME.find(cleaned) { + cleaned = &cleaned[m.end()..]; + } + if let Some(m) = PATTERN_NAME_ONLY.find(cleaned) { + cleaned = &cleaned[m.end()..]; + } + if let Some(m) = PATTERN_TIMESTAMP_ONLY.find(cleaned) { + cleaned = &cleaned[m.end()..]; + } + if let Some(m) = PATTERN_MARKDOWN_ROLE.find(cleaned) { + cleaned = &cleaned[m.end()..]; + } + + cleaned.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Check if a response has a prefix that would be cleaned. + fn has_prefix(response: &str) -> bool { + let trimmed = response.trim(); + PATTERN_TIMESTAMP_NAME.is_match(trimmed) + || PATTERN_NAME_ONLY.is_match(trimmed) + || PATTERN_TIMESTAMP_ONLY.is_match(trimmed) + || PATTERN_MARKDOWN_ROLE.is_match(trimmed) + } + + #[test] + fn test_strip_timestamp_and_name() { + assert_eq!(clean_response("[11:59] GPT Assistant: Yes, Joel..."), "Yes, Joel..."); + } + + #[test] + fn test_strip_name_only() { + assert_eq!(clean_response("GPT Assistant: Yes, Joel..."), "Yes, Joel..."); + } + + #[test] + fn test_strip_timestamp_only() { + assert_eq!(clean_response("[11:59] message here"), "message here"); + } + + #[test] + fn test_strip_markdown_double_star() { + assert_eq!(clean_response("**Assistant:** answer here"), "answer here"); + } + + #[test] + fn test_strip_markdown_single_star() { + assert_eq!(clean_response("*Helper:* the answer"), "the answer"); + } + + #[test] + fn test_no_prefix() { + assert_eq!(clean_response("Just a normal response"), "Just a normal response"); + } + + #[test] + fn test_preserves_content() { + let input = "This response has no prefix but mentions [time] and Name: in the middle."; + // Only patterns at the START get stripped — "Name:" at start will match though + // Actually "This" starts with capital and matches `^[A-Z][A-Za-z\s]+:\s*`? + // No — "This response has no prefix but mentions [time] and Name" contains non-alpha chars + // Pattern 2 is `^[A-Z][A-Za-z\s]+:` which requires only letters and spaces before ':' + // "This response has no prefix but mentions [time] and Name:" — nope, brackets break it + assert_eq!(clean_response(input), input); + } + + #[test] + fn test_nested_prefixes() { + // Timestamp+name stripped first, then if there's still a name prefix, strip that too + assert_eq!(clean_response("[11:59] GPT: Assistant: hello"), "hello"); + } + + #[test] + fn test_has_prefix_true() { + assert!(has_prefix("[11:59] GPT: hello")); + assert!(has_prefix("Assistant: hello")); + assert!(has_prefix("[11:59] hello")); + assert!(has_prefix("**Helper:** hello")); + } + + #[test] + fn test_has_prefix_false() { + assert!(!has_prefix("Just a normal message")); + assert!(!has_prefix("lowercase: not a name")); + assert!(!has_prefix("123: not a name")); + } + + #[test] + fn test_empty_input() { + assert_eq!(clean_response(""), ""); + assert!(!has_prefix("")); + } + + #[test] + fn test_whitespace_trimming() { + assert_eq!(clean_response(" [11:59] GPT: hello "), "hello"); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/similarity.rs b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/similarity.rs new file mode 100644 index 000000000..a2d9fdf03 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/similarity.rs @@ -0,0 +1,368 @@ +//! Unified Text Similarity +//! +//! ONE Jaccard implementation for the entire system. +//! Replaces 3 duplicate TypeScript implementations: +//! - PersonaResponseGenerator.calculateSimilarity() (char bigrams) +//! - PersonaResponseGenerator.jaccardSimilarity() (word + bigram) +//! - PersonaMessageEvaluator.computeTextSimilarity() (word + bigram) +//! +//! Also used by loop detection, semantic loop checking, topic detection, etc. + +use std::collections::HashSet; +use super::types::{ConversationMessage, SemanticLoopResult}; + +/// Character bigram Jaccard similarity. +/// +/// Computes Jaccard coefficient over character bigram sets. +/// Used for fast loop detection (comparing response hashes). +/// +/// Port of PersonaResponseGenerator.calculateSimilarity() +pub fn jaccard_char_bigram_similarity(a: &str, b: &str) -> f64 { + if a.is_empty() || b.is_empty() { + return 0.0; + } + if a == b { + return 1.0; + } + + let bigrams_a = char_bigrams(a); + let bigrams_b = char_bigrams(b); + + let intersection = bigrams_a.intersection(&bigrams_b).count(); + let union = bigrams_a.union(&bigrams_b).count(); + + if union == 0 { + 0.0 + } else { + intersection as f64 / union as f64 + } +} + +/// Word + bigram Jaccard similarity (n-gram). +/// +/// Computes Jaccard coefficient over unigrams + bigrams. +/// More semantic than character-level — captures word co-occurrence. +/// +/// Port of PersonaResponseGenerator.jaccardSimilarity() and +/// PersonaMessageEvaluator.computeTextSimilarity() +pub fn jaccard_ngram_similarity(text1: &str, text2: &str) -> f64 { + if text1.is_empty() || text2.is_empty() { + return 0.0; + } + if text1 == text2 { + return 1.0; + } + + let set1 = word_ngrams(text1); + let set2 = word_ngrams(text2); + + jaccard_from_sets(&set1, &set2) +} + +/// Jaccard similarity from pre-computed ngram sets. +/// +/// Use this when comparing one text against many — compute `word_ngrams()` +/// once for the needle, then call this for each comparison target. +pub fn jaccard_from_sets(set1: &HashSet, set2: &HashSet) -> f64 { + if set1.is_empty() || set2.is_empty() { + return 0.0; + } + + let intersection = set1.intersection(set2).count(); + let union = set1.union(set2).count(); + + if union == 0 { + 0.0 + } else { + intersection as f64 / union as f64 + } +} + +/// Build word ngram set (unigrams + bigrams) from text. +/// +/// Public so callers can pre-compute for one-to-many comparisons. +pub fn build_word_ngrams(text: &str) -> HashSet { + word_ngrams(text) +} + +/// Check if a response is semantically looping against recent conversation history. +/// +/// Compares response text against the last N messages using ngram similarity. +/// Thresholds: WARN at 0.80, BLOCK at 0.95. +/// +/// Port of PersonaResponseGenerator.checkSemanticLoop() +pub fn check_semantic_loop( + response_text: &str, + history: &[ConversationMessage], + max_history: usize, +) -> SemanticLoopResult { + const WARN_THRESHOLD: f64 = 0.80; + const BLOCK_THRESHOLD: f64 = 0.95; + + if response_text.len() < 50 { + return SemanticLoopResult { + should_block: false, + similarity: 0.0, + reason: "Response too short for semantic check".into(), + }; + } + + let recent = if history.len() > max_history { + &history[history.len() - max_history..] + } else { + history + }; + + let mut max_similarity: f64 = 0.0; + + // Pre-compute response ngrams once — reuse across all history comparisons + let response_ngrams = build_word_ngrams(response_text); + + for msg in recent { + if msg.content.len() < 20 { + continue; + } + let msg_ngrams = word_ngrams(&msg.content); + let similarity = jaccard_from_sets(&response_ngrams, &msg_ngrams); + if similarity > max_similarity { + max_similarity = similarity; + } + } + + if max_similarity >= BLOCK_THRESHOLD { + SemanticLoopResult { + should_block: true, + similarity: max_similarity, + reason: format!( + "{}% similar to recent message", + (max_similarity * 100.0).round() as u32 + ), + } + } else if max_similarity >= WARN_THRESHOLD { + SemanticLoopResult { + should_block: false, + similarity: max_similarity, + reason: "Similar but allowing for autonomy".into(), + } + } else { + SemanticLoopResult { + should_block: false, + similarity: max_similarity, + reason: "Low similarity".into(), + } + } +} + +// ============================================================================ +// Internal helpers +// ============================================================================ + +/// Extract character bigrams from a string. +fn char_bigrams(s: &str) -> HashSet { + let chars: Vec = s.chars().collect(); + let mut bigrams = HashSet::new(); + if chars.len() < 2 { + return bigrams; + } + for i in 0..chars.len() - 1 { + let mut bigram = String::with_capacity(8); + bigram.push(chars[i]); + bigram.push(chars[i + 1]); + bigrams.insert(bigram); + } + bigrams +} + +/// Tokenize text into lowercase words, then build unigram + bigram set. +fn word_ngrams(text: &str) -> HashSet { + let lower = text.to_lowercase(); + let words: Vec<&str> = lower + .split(|c: char| !c.is_alphanumeric()) + .filter(|w| !w.is_empty()) + .collect(); + + let mut ngrams = HashSet::new(); + + // Unigrams + for word in &words { + ngrams.insert((*word).to_string()); + } + + // Bigrams (space-separated, matching TS implementation) + for i in 0..words.len().saturating_sub(1) { + ngrams.insert(format!("{} {}", words[i], words[i + 1])); + } + + ngrams +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // ---- Character bigram tests ---- + + #[test] + fn test_char_bigram_identical() { + assert_eq!(jaccard_char_bigram_similarity("hello", "hello"), 1.0); + } + + #[test] + fn test_char_bigram_empty() { + assert_eq!(jaccard_char_bigram_similarity("", "hello"), 0.0); + assert_eq!(jaccard_char_bigram_similarity("hello", ""), 0.0); + assert_eq!(jaccard_char_bigram_similarity("", ""), 0.0); + } + + #[test] + fn test_char_bigram_completely_different() { + let sim = jaccard_char_bigram_similarity("abc", "xyz"); + assert_eq!(sim, 0.0); + } + + #[test] + fn test_char_bigram_partial_overlap() { + // "hello" bigrams: {"he", "el", "ll", "lo"} = 4 + // "help" bigrams: {"he", "el", "lp"} = 3 + // intersection: {"he", "el"} = 2 + // union: {"he", "el", "ll", "lo", "lp"} = 5 + // Jaccard = 2/5 = 0.4 + let sim = jaccard_char_bigram_similarity("hello", "help"); + assert!((sim - 0.4).abs() < 1e-10); + } + + #[test] + fn test_char_bigram_single_char() { + // Single char has no bigrams → empty set → 0.0 + assert_eq!(jaccard_char_bigram_similarity("a", "b"), 0.0); + } + + // ---- Word n-gram tests ---- + + #[test] + fn test_ngram_identical() { + assert_eq!(jaccard_ngram_similarity("hello world", "hello world"), 1.0); + } + + #[test] + fn test_ngram_empty() { + assert_eq!(jaccard_ngram_similarity("", "hello"), 0.0); + assert_eq!(jaccard_ngram_similarity("hello", ""), 0.0); + } + + #[test] + fn test_ngram_case_insensitive() { + assert_eq!( + jaccard_ngram_similarity("Hello World", "hello world"), + 1.0 + ); + } + + #[test] + fn test_ngram_partial_overlap() { + // "the cat sat" → unigrams: {the, cat, sat}, bigrams: {the cat, cat sat} = 5 + // "the dog sat" → unigrams: {the, dog, sat}, bigrams: {the dog, dog sat} = 5 + // intersection: {the, sat} = 2 + // union: {the, cat, sat, dog, the cat, cat sat, the dog, dog sat} = 8 + // Jaccard = 2/8 = 0.25 + let sim = jaccard_ngram_similarity("the cat sat", "the dog sat"); + assert!((sim - 0.25).abs() < 1e-10); + } + + #[test] + fn test_ngram_completely_different() { + let sim = jaccard_ngram_similarity("alpha beta", "gamma delta"); + assert_eq!(sim, 0.0); + } + + #[test] + fn test_ngram_punctuation_stripped() { + // Punctuation is split boundary, so "hello, world!" → words: ["hello", "world"] + let sim = jaccard_ngram_similarity("hello, world!", "hello world"); + assert_eq!(sim, 1.0); + } + + #[test] + fn test_ngram_known_value() { + // "I like cats" → unigrams: {i, like, cats}, bigrams: {i like, like cats} = 5 + // "I like dogs" → unigrams: {i, like, dogs}, bigrams: {i like, like dogs} = 5 + // intersection: {i, like, i like} = 3 + // union: {i, like, cats, dogs, i like, like cats, like dogs} = 7 + // Jaccard = 3/7 ≈ 0.42857 + let sim = jaccard_ngram_similarity("I like cats", "I like dogs"); + assert!((sim - 3.0 / 7.0).abs() < 1e-10); + } + + // ---- Semantic loop tests ---- + + #[test] + fn test_semantic_loop_short_response() { + let result = check_semantic_loop("hi", &[], 10); + assert!(!result.should_block); + assert_eq!(result.similarity, 0.0); + } + + #[test] + fn test_semantic_loop_no_history() { + let long_response = "This is a sufficiently long response that should pass the length check for semantic analysis"; + let result = check_semantic_loop(long_response, &[], 10); + assert!(!result.should_block); + assert_eq!(result.similarity, 0.0); + } + + #[test] + fn test_semantic_loop_identical_blocks() { + let response = "This is a sufficiently long response that we will also put in the history to test blocking behavior"; + let history = vec![ConversationMessage { + role: "assistant".into(), + content: response.into(), + name: None, + }]; + + let result = check_semantic_loop(response, &history, 10); + assert!(result.should_block); + assert_eq!(result.similarity, 1.0); + } + + #[test] + fn test_semantic_loop_different_content() { + let response = "This is a brand new response about artificial intelligence and machine learning models"; + let history = vec![ConversationMessage { + role: "user".into(), + content: "Tell me about the weather forecast for tomorrow in the northern hemisphere".into(), + name: None, + }]; + + let result = check_semantic_loop(response, &history, 10); + assert!(!result.should_block); + assert!(result.similarity < 0.5); + } + + #[test] + fn test_semantic_loop_respects_max_history() { + let response = "This is a sufficiently long response that appears in old history but not recent"; + let mut history = Vec::new(); + // Identical message at position 0 + history.push(ConversationMessage { + role: "assistant".into(), + content: response.into(), + name: None, + }); + // 15 different messages after + for i in 0..15 { + history.push(ConversationMessage { + role: "user".into(), + content: format!("Completely unrelated message number {} about topic {}", i, i * 7), + name: None, + }); + } + + // With max_history=10, the identical message at position 0 is outside the window + let result = check_semantic_loop(response, &history, 10); + assert!(!result.should_block); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/types.rs b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/types.rs new file mode 100644 index 000000000..c6ed8dc97 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/types.rs @@ -0,0 +1,133 @@ +//! Text Analysis Types +//! +//! Single source of truth for text analysis result types. +//! Exported to TypeScript via ts-rs. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// Result of a text similarity comparison +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/TextSimilarityResult.ts")] +pub struct TextSimilarityResult { + /// Word + bigram Jaccard similarity (semantic-level) + pub ngram_similarity: f64, + /// Character bigram Jaccard similarity (character-level, for loop detection) + pub char_similarity: f64, + /// Computation time in microseconds + #[ts(type = "number")] + pub compute_time_us: u64, +} + +/// Result of a semantic loop check against conversation history +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/SemanticLoopResult.ts")] +pub struct SemanticLoopResult { + /// Whether the response should be blocked + pub should_block: bool, + /// Maximum similarity found against any recent message + pub similarity: f64, + /// Human-readable reason + pub reason: String, +} + +/// Lightweight message representation for cross-boundary transfer +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/ConversationMessage.ts")] +pub struct ConversationMessage { + pub role: String, + pub content: String, + #[ts(optional)] + pub name: Option, +} + +// --- Phase 2: Validation types --- + +/// Garbage check reason codes (matches TypeScript GarbageReason exactly) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/GarbageReason.ts")] +pub enum GarbageReason { + #[serde(rename = "")] + None, + #[serde(rename = "unicode_garbage")] + UnicodeGarbage, + #[serde(rename = "repetition")] + Repetition, + #[serde(rename = "encoding_errors")] + EncodingErrors, + #[serde(rename = "empty")] + Empty, + #[serde(rename = "truncation_marker")] + TruncationMarker, + #[serde(rename = "excessive_punctuation")] + ExcessivePunctuation, + #[serde(rename = "token_boundary_garbage")] + TokenBoundaryGarbage, + #[serde(rename = "inference_error")] + InferenceError, + #[serde(rename = "fabricated_conversation")] + FabricatedConversation, +} + +/// Result of garbage detection +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/GarbageCheckResult.ts")] +pub struct GarbageCheckResult { + pub is_garbage: bool, + pub reason: GarbageReason, + pub details: String, + pub score: f64, +} + +/// Combined result of ALL validation gates (1 IPC call replaces 4 gates) +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/ValidationResult.ts")] +pub struct ValidationResult { + /// Whether all gates passed + pub passed: bool, + /// Which gate failed (if any) + #[ts(optional)] + pub gate_failed: Option, + /// Garbage detection result + pub garbage_result: GarbageCheckResult, + /// Response loop detection + pub is_response_loop: bool, + /// Number of duplicate responses found in window + #[ts(type = "number")] + pub loop_duplicate_count: u64, + /// Truncated tool call detected + pub has_truncated_tool_call: bool, + /// Semantic loop check result + pub semantic_result: SemanticLoopResult, + /// Total validation time in microseconds + #[ts(type = "number")] + pub total_time_us: u64, +} + +// --- Phase 3: Mention detection + response cleaning types --- + +/// Combined result of mention detection (1 IPC call for both checks) +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/MentionCheckResult.ts")] +pub struct MentionCheckResult { + /// Whether THIS persona is mentioned (@name, @uniqueid, or direct address at start) + pub is_persona_mentioned: bool, + /// Whether ANY directed @mention exists (used to prevent dog-piling) + pub has_directed_mention: bool, + /// Computation time in microseconds + #[ts(type = "number")] + pub compute_time_us: u64, +} + +/// Result of response prefix cleaning +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/CleanedResponse.ts")] +pub struct CleanedResponse { + /// Cleaned text with prefixes stripped + pub text: String, + /// Whether any cleaning was applied + pub was_cleaned: bool, + /// Computation time in microseconds + #[ts(type = "number")] + pub compute_time_us: u64, +} diff --git a/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/validation.rs b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/validation.rs new file mode 100644 index 000000000..a698d7381 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/persona/text_analysis/validation.rs @@ -0,0 +1,209 @@ +//! Combined Validation — orchestrates all gates into one ValidationResult. +//! +//! Composes garbage detection, loop detection, truncated tool call detection, +//! and semantic loop detection into a single result struct. +//! Called by the cognition module's `validate-response` handler. + +use super::{ + is_garbage, has_truncated_tool_call, check_semantic_loop, + GarbageCheckResult, GarbageReason, SemanticLoopResult, ValidationResult, + ConversationMessage, LoopDetector, +}; +use uuid::Uuid; + +/// Run all 4 validation gates and return a combined result. +/// +/// Short-circuits on first failure (garbage → loop → truncated → semantic). +pub fn validate_response( + response_text: &str, + persona_id: Uuid, + has_tool_calls: bool, + conversation_history: &[ConversationMessage], + loop_detector: &LoopDetector, +) -> ValidationResult { + let start = std::time::Instant::now(); + + // Gate 1: Garbage detection (skip if has native tool calls — empty text + tools is valid) + let garbage_result = if has_tool_calls { + GarbageCheckResult { + is_garbage: false, + reason: GarbageReason::None, + details: String::new(), + score: 0.0, + } + } else { + is_garbage(response_text) + }; + + if garbage_result.is_garbage { + return failed("garbage", garbage_result, start); + } + + // Gate 2: Response loop detection (skip if has tool calls) + let (is_loop, dup_count) = if has_tool_calls { + (false, 0) + } else { + loop_detector.check_response_loop(persona_id, response_text) + }; + + if is_loop { + return ValidationResult { + passed: false, + gate_failed: Some("response_loop".to_string()), + garbage_result, + is_response_loop: true, + loop_duplicate_count: dup_count as u64, + has_truncated_tool_call: false, + semantic_result: SemanticLoopResult::none(), + total_time_us: start.elapsed().as_micros() as u64, + }; + } + + // Gate 3: Truncated tool call detection + let truncated = has_truncated_tool_call(response_text); + if truncated { + return ValidationResult { + passed: false, + gate_failed: Some("truncated_tool_call".to_string()), + garbage_result, + is_response_loop: false, + loop_duplicate_count: dup_count as u64, + has_truncated_tool_call: true, + semantic_result: SemanticLoopResult::none(), + total_time_us: start.elapsed().as_micros() as u64, + }; + } + + // Gate 4: Semantic loop detection + let semantic_result = if conversation_history.is_empty() { + SemanticLoopResult { + should_block: false, + similarity: 0.0, + reason: "No conversation history provided".to_string(), + } + } else { + check_semantic_loop(response_text, conversation_history, 10) + }; + + let passed = !semantic_result.should_block; + let gate_failed = if semantic_result.should_block { + Some("semantic_loop".to_string()) + } else { + None + }; + + ValidationResult { + passed, + gate_failed, + garbage_result, + is_response_loop: false, + loop_duplicate_count: dup_count as u64, + has_truncated_tool_call: false, + semantic_result, + total_time_us: start.elapsed().as_micros() as u64, + } +} + +/// Build a failed ValidationResult (short-circuit helper for garbage gate). +fn failed(gate: &str, garbage_result: GarbageCheckResult, start: std::time::Instant) -> ValidationResult { + ValidationResult { + passed: false, + gate_failed: Some(gate.to_string()), + garbage_result, + is_response_loop: false, + loop_duplicate_count: 0, + has_truncated_tool_call: false, + semantic_result: SemanticLoopResult::none(), + total_time_us: start.elapsed().as_micros() as u64, + } +} + +impl SemanticLoopResult { + /// A "not blocked" result for short-circuit cases. + pub fn none() -> Self { + Self { + should_block: false, + similarity: 0.0, + reason: String::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_detector() -> LoopDetector { + LoopDetector::new() + } + + #[test] + fn test_clean_response_passes() { + let detector = make_detector(); + let id = Uuid::new_v4(); + let result = validate_response("Hello, I can help you with that!", id, false, &[], &detector); + assert!(result.passed); + assert!(result.gate_failed.is_none()); + } + + #[test] + fn test_garbage_detected() { + let detector = make_detector(); + let id = Uuid::new_v4(); + let result = validate_response("", id, false, &[], &detector); + assert!(!result.passed); + assert_eq!(result.gate_failed.as_deref(), Some("garbage")); + } + + #[test] + fn test_tool_calls_skip_garbage_and_loop() { + let detector = make_detector(); + let id = Uuid::new_v4(); + // Empty text with tool calls should pass garbage and loop gates + let result = validate_response("", id, true, &[], &detector); + assert!(result.passed); + } + + #[test] + fn test_truncated_tool_call() { + let detector = make_detector(); + let id = Uuid::new_v4(); + let text = r#"Let me check. /tmp"#; + let result = validate_response(text, id, false, &[], &detector); + assert!(!result.passed); + assert_eq!(result.gate_failed.as_deref(), Some("truncated_tool_call")); + } + + #[test] + fn test_semantic_loop_detected() { + let detector = make_detector(); + let id = Uuid::new_v4(); + let history = vec![ + ConversationMessage { + role: "assistant".to_string(), + content: "The answer to life is forty-two, as we all know from the guide.".to_string(), + name: None, + }, + ]; + // Same content should trigger semantic loop + let result = validate_response( + "The answer to life is forty-two, as we all know from the guide.", + id, false, &history, &detector, + ); + assert!(!result.passed); + assert_eq!(result.gate_failed.as_deref(), Some("semantic_loop")); + } + + #[test] + fn test_response_loop_detected() { + let detector = make_detector(); + let id = Uuid::new_v4(); + // Send the same response 4 times to trigger loop detection + for _ in 0..4 { + let _ = validate_response("Same response every time.", id, false, &[], &detector); + } + let result = validate_response("Same response every time.", id, false, &[], &detector); + assert!(!result.passed); + assert_eq!(result.gate_failed.as_deref(), Some("response_loop")); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/persona/unified.rs b/src/debug/jtag/workers/continuum-core/src/persona/unified.rs new file mode 100644 index 000000000..75414a369 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/persona/unified.rs @@ -0,0 +1,72 @@ +//! Unified Per-Persona Cognitive State +//! +//! All per-persona state in a single struct — one DashMap entry, one lock. +//! +//! Before: 7 separate DashMap — 7 lock acquisitions per command, +//! related state scattered across cache lines, no atomic cross-field access. +//! +//! After: 1 DashMap — 1 lock, contiguous memory, +//! atomic access to engine + rate_limiter + sleep_state + adapters + genome. + +use crate::persona::cognition::PersonaCognitionEngine; +use crate::persona::evaluator::{RateLimiterState, SleepState}; +use crate::persona::genome_paging::GenomePagingEngine; +use crate::persona::inbox::PersonaInbox; +use crate::persona::model_selection::AdapterRegistry; +use crate::rag::RagEngine; +use std::sync::Arc; +use uuid::Uuid; + +/// All cognitive state for a single persona — single lock, cache-local. +pub struct PersonaCognition { + pub engine: PersonaCognitionEngine, + pub inbox: PersonaInbox, + pub rate_limiter: RateLimiterState, + pub sleep_state: SleepState, + pub adapter_registry: AdapterRegistry, + pub genome_engine: GenomePagingEngine, +} + +impl PersonaCognition { + /// Create a new PersonaCognition with default sub-states. + /// Engine and inbox require persona_id; everything else uses defaults. + pub fn new( + persona_id: Uuid, + persona_name: String, + rag_engine: Arc, + ) -> Self { + let (_, shutdown_rx) = tokio::sync::watch::channel(false); + Self { + engine: PersonaCognitionEngine::new( + persona_id, + persona_name, + rag_engine, + shutdown_rx, + ), + inbox: PersonaInbox::new(persona_id), + rate_limiter: RateLimiterState::default(), + sleep_state: SleepState::default(), + adapter_registry: AdapterRegistry::default(), + genome_engine: GenomePagingEngine::new(200.0), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_persona_cognition_defaults() { + let id = Uuid::new_v4(); + let rag = Arc::new(RagEngine::new()); + let pc = PersonaCognition::new(id, "TestBot".into(), rag); + + assert_eq!(pc.engine.persona_id(), id); + assert!(pc.inbox.is_empty()); + assert!(!pc.rate_limiter.has_reached_response_cap(Uuid::new_v4())); + assert_eq!(pc.sleep_state.mode, crate::persona::evaluator::SleepMode::Active); + assert!(pc.adapter_registry.adapters.is_empty()); + assert!((pc.genome_engine.memory_pressure() - 0.0).abs() < 0.001); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/runtime/command_executor.rs b/src/debug/jtag/workers/continuum-core/src/runtime/command_executor.rs new file mode 100644 index 000000000..c2347f460 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/runtime/command_executor.rs @@ -0,0 +1,198 @@ +//! CommandExecutor — Universal command execution for ALL continuum-core processes +//! +//! This is the foundational primitive that allows ANY spawned task (sentinels, +//! background jobs, etc.) to execute ANY command in the system, regardless of +//! whether it's implemented in Rust or TypeScript. +//! +//! Usage: +//! ```rust +//! // Works for Rust modules +//! runtime::execute_command_json("health-check", json!({})).await?; +//! +//! // Works for TypeScript commands (via CommandRouterServer) +//! runtime::execute_command_json("screenshot", json!({"querySelector": "body"})).await?; +//! +//! // Sentinel doesn't know or care where command is implemented +//! ``` +//! +//! Architecture: +//! - Rust modules: Routed directly through ModuleRegistry +//! - TypeScript commands: Routed via Unix socket to CommandRouterServer +//! (socket: /tmp/jtag-command-router.sock) + +use std::sync::Arc; +use serde_json::Value; +use tokio::net::UnixStream; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use super::{ModuleRegistry, CommandResult}; + +/// Socket path for TypeScript command routing +const TS_COMMAND_SOCKET: &str = "/tmp/jtag-command-router.sock"; + +/// Universal command executor that routes to Rust modules or TypeScript +pub struct CommandExecutor { + /// Rust module registry (for Rust-implemented commands) + registry: Arc, +} + +impl CommandExecutor { + pub fn new(registry: Arc) -> Self { + Self { registry } + } + + /// Execute ANY command - routes to Rust or TypeScript automatically + /// Returns CommandResult for consistency with ServiceModule pattern + pub async fn execute(&self, command: &str, params: Value) -> Result { + let log = super::logger("command-executor"); + + // 1. Try Rust module registry first + if let Some((module, cmd)) = self.registry.route_command(command) { + log.debug(&format!("Routing '{}' to Rust module", command)); + return module.handle_command(&cmd, params).await; + } + + // 2. Route to TypeScript via Unix socket (CommandRouterServer) + log.debug(&format!("Routing '{}' to TypeScript via CommandRouterServer", command)); + let json = self.execute_ts_command(command, params).await?; + Ok(CommandResult::Json(json)) + } + + /// Convenience: execute and extract JSON directly + pub async fn execute_json(&self, command: &str, params: Value) -> Result { + match self.execute(command, params).await? { + CommandResult::Json(v) => Ok(v), + CommandResult::Binary { metadata, .. } => Ok(metadata), + } + } + + /// Execute a command ONLY via TypeScript (bypasses Rust registry). + /// Use this when a Rust module needs to forward to a TypeScript-implemented + /// command that shares the same prefix (avoids infinite recursion). + pub async fn execute_ts(&self, command: &str, params: Value) -> Result { + let json = self.execute_ts_command(command, params).await?; + Ok(CommandResult::Json(json)) + } + + /// Convenience: execute via TypeScript only and extract JSON directly + pub async fn execute_ts_json(&self, command: &str, params: Value) -> Result { + self.execute_ts_command(command, params).await + } + + /// Execute command via TypeScript CommandRouterServer (Unix socket) + /// + /// Protocol: + /// - Request: `{"command": "...", "params": {...}}\n` + /// - Response: `{"success": true, "result": ...}\n` or `{"success": false, "error": "..."}\n` + async fn execute_ts_command(&self, command: &str, params: Value) -> Result { + let log = super::logger("command-executor"); + + // Connect to CommandRouterServer + log.debug(&format!("Connecting to TypeScript socket: {}", TS_COMMAND_SOCKET)); + let stream = UnixStream::connect(TS_COMMAND_SOCKET) + .await + .map_err(|e| format!("Failed to connect to CommandRouterServer at {}: {}", TS_COMMAND_SOCKET, e))?; + + let (reader, mut writer) = stream.into_split(); + let mut buf_reader = BufReader::new(reader); + + // Build and send request + let request = serde_json::json!({ + "command": command, + "params": params, + }); + let request_line = format!("{}\n", request.to_string()); + + log.debug(&format!("Sending: {}", command)); + writer.write_all(request_line.as_bytes()) + .await + .map_err(|e| format!("Failed to send command: {}", e))?; + writer.flush() + .await + .map_err(|e| format!("Failed to flush: {}", e))?; + + // Read response + let mut response_line = String::new(); + buf_reader.read_line(&mut response_line) + .await + .map_err(|e| format!("Failed to read response: {}", e))?; + + log.debug(&format!("Received response: {} bytes", response_line.len())); + + // Parse response + let response: Value = serde_json::from_str(&response_line) + .map_err(|e| format!("Invalid response JSON: {} (raw: {})", e, response_line.trim()))?; + + // Check success + if response.get("success").and_then(|v| v.as_bool()) == Some(true) { + let result = response.get("result").cloned().unwrap_or(Value::Null); + log.info(&format!("Command '{}' succeeded", command)); + Ok(result) + } else { + let error = response.get("error") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error from TypeScript"); + log.error(&format!("Command '{}' failed: {}", command, error)); + Err(error.to_string()) + } + } +} + +// Global executor instance - initialized once at startup +static GLOBAL_EXECUTOR: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Initialize the global command executor (called once at startup) +pub fn init_executor(registry: Arc) { + let log = super::logger("command-executor"); + let _ = GLOBAL_EXECUTOR.set(Arc::new(CommandExecutor::new(registry))); + log.info(&format!("Initialized (TS bridge: {})", TS_COMMAND_SOCKET)); +} + +/// Get the global command executor +/// Panics if not initialized - this is intentional, executor MUST be initialized at startup +pub fn executor() -> Arc { + GLOBAL_EXECUTOR.get() + .expect("CommandExecutor not initialized - call init_executor() at startup") + .clone() +} + +/// Execute a command from anywhere, returning CommandResult +/// +/// Usage: +/// ```rust +/// use crate::runtime::command_executor; +/// +/// let result = command_executor::execute("code/edit", params).await?; +/// ``` +pub async fn execute(command: &str, params: Value) -> Result { + executor().execute(command, params).await +} + +/// Execute a command and extract JSON result (convenience for most use cases) +pub async fn execute_json(command: &str, params: Value) -> Result { + executor().execute_json(command, params).await +} + +/// Execute a command ONLY via TypeScript, bypassing Rust registry. +/// Use when a Rust module needs to forward to a TypeScript command +/// that shares the same prefix (e.g., ai_provider forwarding ai/agent). +pub async fn execute_ts(command: &str, params: Value) -> Result { + executor().execute_ts(command, params).await +} + +/// Execute via TypeScript only and extract JSON (convenience) +pub async fn execute_ts_json(command: &str, params: Value) -> Result { + executor().execute_ts_json(command, params).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_executor_creation() { + let registry = Arc::new(ModuleRegistry::new()); + let _executor = CommandExecutor::new(registry); + // Just verify it compiles and can be created + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/runtime/mod.rs b/src/debug/jtag/workers/continuum-core/src/runtime/mod.rs index b2f4cd1a7..a0e4628af 100644 --- a/src/debug/jtag/workers/continuum-core/src/runtime/mod.rs +++ b/src/debug/jtag/workers/continuum-core/src/runtime/mod.rs @@ -33,6 +33,7 @@ pub mod module_logger; pub mod module_metrics; pub mod control; pub mod runtime; +pub mod command_executor; pub use service_module::{ServiceModule, ModuleConfig, ModulePriority, CommandResult, CommandSchema, ParamSchema}; pub use registry::ModuleRegistry; @@ -43,6 +44,7 @@ pub use module_logger::ModuleLogger; pub use module_metrics::{ModuleMetrics, ModuleStats, CommandTiming}; pub use control::{RuntimeControl, ModuleInfo}; pub use runtime::Runtime; +pub use command_executor::{CommandExecutor, execute as execute_command, execute_json as execute_command_json, executor, init_executor}; // ============================================================================ // Global Logger Access diff --git a/src/debug/jtag/workers/continuum-core/src/runtime/registry.rs b/src/debug/jtag/workers/continuum-core/src/runtime/registry.rs index 82546d273..03581b07d 100644 --- a/src/debug/jtag/workers/continuum-core/src/runtime/registry.rs +++ b/src/debug/jtag/workers/continuum-core/src/runtime/registry.rs @@ -171,6 +171,7 @@ mod tests { event_subscriptions: &[], needs_dedicated_thread: false, max_concurrency: 0, + tick_interval: None, } } diff --git a/src/debug/jtag/workers/continuum-core/src/runtime/runtime.rs b/src/debug/jtag/workers/continuum-core/src/runtime/runtime.rs index 1ef6aba09..32c576c21 100644 --- a/src/debug/jtag/workers/continuum-core/src/runtime/runtime.rs +++ b/src/debug/jtag/workers/continuum-core/src/runtime/runtime.rs @@ -12,6 +12,7 @@ use super::shared_compute::SharedCompute; use super::module_context::ModuleContext; use super::service_module::{ServiceModule, CommandResult}; use std::sync::Arc; +use tokio::task::JoinHandle; use tracing::{info, warn, error}; /// Expected modules that MUST be registered for a complete runtime. @@ -95,6 +96,48 @@ impl Runtime { Ok(()) } + /// Start periodic tick loops for modules that declare a tick_interval. + /// Each module with a tick_interval gets its own tokio task that calls tick() + /// at the specified cadence. This replaces TypeScript's per-persona setIntervals. + pub fn start_tick_loops(&self) -> Vec> { + let mut handles = Vec::new(); + let modules = self.registry.list_modules(); + + for name in &modules { + if let Some(module) = self.registry.get_by_name(name) { + let config = module.config(); + if let Some(initial_interval) = config.tick_interval { + let module_name = config.name; + let module = module.clone(); + info!("Starting tick loop for '{}' (interval: {:?})", module_name, initial_interval); + + let handle = tokio::spawn(async move { + // Initial delay — don't tick before system is warmed up + tokio::time::sleep(initial_interval).await; + + loop { + if let Err(e) = module.tick().await { + error!("Tick error in '{}': {}", module_name, e); + } + // Re-read interval from module config each iteration. + // This allows dynamic cadence changes (e.g. via channel/tick-config). + let interval = module.config().tick_interval + .unwrap_or(initial_interval); + tokio::time::sleep(interval).await; + } + }); + + handles.push(handle); + } + } + } + + if !handles.is_empty() { + info!("Started {} tick loops", handles.len()); + } + handles + } + /// Route a command through the registry (async version). /// Returns None if no module handles this command. /// diff --git a/src/debug/jtag/workers/continuum-core/src/runtime/service_module.rs b/src/debug/jtag/workers/continuum-core/src/runtime/service_module.rs index 2b54ea556..408cc8e34 100644 --- a/src/debug/jtag/workers/continuum-core/src/runtime/service_module.rs +++ b/src/debug/jtag/workers/continuum-core/src/runtime/service_module.rs @@ -13,6 +13,7 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::any::Any; +use std::time::Duration; use ts_rs::TS; // ============================================================================ @@ -90,6 +91,11 @@ pub struct ModuleConfig { /// Maximum concurrent requests. 0 = unlimited (module manages own concurrency). pub max_concurrency: usize, + + /// Optional periodic tick interval. When set, the runtime spawns a tokio task + /// that calls `tick()` at this cadence. Overrides the default priority-based cadence. + /// None = no periodic tick (module is purely reactive to commands). + pub tick_interval: Option, } /// Result of handling a command. @@ -108,6 +114,16 @@ pub enum CommandResult { }, } +impl CommandResult { + /// Create a Json result from any Serialize type. + /// Eliminates the `serde_json::to_value(x).unwrap()` anti-pattern. + pub fn json(value: &impl serde::Serialize) -> Result { + serde_json::to_value(value) + .map(CommandResult::Json) + .map_err(|e| format!("Serialization error: {e}")) + } +} + /// The ONE trait. Implement this and register — done. /// /// Every module in the system implements ServiceModule. The runtime: diff --git a/src/debug/jtag/workers/continuum-core/src/tool_parsing/codec.rs b/src/debug/jtag/workers/continuum-core/src/tool_parsing/codec.rs new file mode 100644 index 000000000..a97bbaa49 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/tool_parsing/codec.rs @@ -0,0 +1,270 @@ +//! Tool name codec — bidirectional encode/decode for API constraints. +//! +//! API constraint: Anthropic/OpenAI require tool names matching `[a-zA-Z0-9_-]{1,64}`. +//! Our tools use slashes: `code/write`, `collaboration/chat/send`. +//! +//! Encode: `code/write` -> `code_write` (slashes -> underscore) +//! Decode: ANY model-produced variant -> original name (via reverse lookup) +//! +//! Models mangle names in unpredictable ways: +//! code__write, $FUNCTIONS.code_write, code_write, code-write, etc. +//! The codec handles all of these by registering normalized variants at startup. + +use std::collections::{HashMap, HashSet}; +use parking_lot::RwLock; + +pub struct ToolNameCodec { + originals: RwLock>, + reverse_map: RwLock>, +} + +impl ToolNameCodec { + pub fn new() -> Self { + Self { + originals: RwLock::new(HashSet::new()), + reverse_map: RwLock::new(HashMap::new()), + } + } + + /// Register a tool name and all plausible encoded/mangled variants. + pub fn register(&self, tool_name: &str) { + let mut originals = self.originals.write(); + let mut reverse = self.reverse_map.write(); + + originals.insert(tool_name.to_string()); + reverse.insert(tool_name.to_string(), tool_name.to_string()); + + // Canonical: slash -> underscore + let encoded = tool_name.replace('/', "_"); + reverse.insert(encoded, tool_name.to_string()); + + // Double underscore (legacy encoding) + let double = tool_name.replace('/', "__"); + reverse.insert(double, tool_name.to_string()); + + // Hyphen variant + reverse.insert(tool_name.replace('/', "-"), tool_name.to_string()); + + // Dot variant + reverse.insert(tool_name.replace('/', "."), tool_name.to_string()); + } + + /// Register multiple tool names at once. + pub fn register_all(&self, tools: &[String]) { + for name in tools { + self.register(name); + } + } + + /// Encode a tool name for API transmission: slashes -> underscores. + pub fn encode(&self, tool_name: &str) -> String { + tool_name.replace('/', "_") + } + + /// Decode any model-produced tool name variant back to the original. + /// 5-step resolution: exact -> strip prefix -> normalize -> double-underscore -> single-underscore. + pub fn decode(&self, raw: &str) -> String { + let reverse = self.reverse_map.read(); + let originals = self.originals.read(); + + // 1. Exact match + if let Some(orig) = reverse.get(raw) { + return orig.clone(); + } + + // 2. Strip known model prefixes ($FUNCTIONS., functions., $tools.) + let cleaned = strip_model_prefix(raw); + if let Some(orig) = reverse.get(cleaned) { + return orig.clone(); + } + + // 3. Normalize all separators to underscore, lowercase + let normalized = cleaned.replace(['-', '.', '_'], "_").to_lowercase(); + if let Some(orig) = reverse.get(&normalized) { + return orig.clone(); + } + + // 4. Try reconstructing: double underscore -> slash + let double_slashed = cleaned.replace("__", "/"); + if originals.contains(&double_slashed) { + return double_slashed; + } + + // 5. Try reconstructing: single underscore -> slash + let single_slashed = cleaned.replace('_', "/"); + if originals.contains(&single_slashed) { + return single_slashed; + } + + // Last resort: best-effort double-underscore reconstruction + double_slashed + } + + /// Get count of registered tool names. + pub fn count(&self) -> usize { + self.originals.read().len() + } +} + +/// Strip model-added prefixes: $FUNCTIONS., functions., $tools., etc. +fn strip_model_prefix(raw: &str) -> &str { + const PREFIXES: &[&str] = &[ + "$FUNCTIONS.", "$functions.", "FUNCTIONS.", "functions.", + "$tools.", "$TOOLS.", "tools.", "TOOLS.", + ]; + for prefix in PREFIXES { + if let Some(stripped) = raw.strip_prefix(prefix) { + return stripped; + } + } + raw +} + +#[cfg(test)] +mod tests { + use super::*; + + fn codec_with_tools() -> ToolNameCodec { + let codec = ToolNameCodec::new(); + codec.register("code/write"); + codec.register("code/read"); + codec.register("code/search"); + codec.register("code/tree"); + codec.register("collaboration/chat/send"); + codec.register("collaboration/decision/vote"); + codec.register("ai/generate"); + codec + } + + // ─── Encode ───────────────────────────────────────────────── + + #[test] + fn encode_basic() { + let codec = ToolNameCodec::new(); + assert_eq!(codec.encode("code/write"), "code_write"); + assert_eq!(codec.encode("collaboration/chat/send"), "collaboration_chat_send"); + } + + // ─── Decode: Step 1 (exact match) ─────────────────────────── + + #[test] + fn decode_exact_original() { + let codec = codec_with_tools(); + assert_eq!(codec.decode("code/write"), "code/write"); + } + + #[test] + fn decode_exact_encoded() { + let codec = codec_with_tools(); + assert_eq!(codec.decode("code_write"), "code/write"); + } + + #[test] + fn decode_exact_double_underscore() { + let codec = codec_with_tools(); + assert_eq!(codec.decode("code__write"), "code/write"); + } + + #[test] + fn decode_exact_hyphen() { + let codec = codec_with_tools(); + assert_eq!(codec.decode("code-write"), "code/write"); + } + + #[test] + fn decode_exact_dot() { + let codec = codec_with_tools(); + assert_eq!(codec.decode("code.write"), "code/write"); + } + + // ─── Decode: Step 2 (strip prefix) ────────────────────────── + + #[test] + fn decode_strip_functions_prefix() { + let codec = codec_with_tools(); + assert_eq!(codec.decode("$FUNCTIONS.code_write"), "code/write"); + } + + #[test] + fn decode_strip_tools_prefix() { + let codec = codec_with_tools(); + assert_eq!(codec.decode("$tools.code_write"), "code/write"); + } + + #[test] + fn decode_strip_lowercase_functions() { + let codec = codec_with_tools(); + assert_eq!(codec.decode("functions.code_write"), "code/write"); + } + + // ─── Decode: Step 3 (normalize) ───────────────────────────── + + #[test] + fn decode_case_insensitive() { + let codec = codec_with_tools(); + // "CODE_WRITE" normalizes to "code_write" which is in reverse map + assert_eq!(codec.decode("CODE_WRITE"), "code/write"); + } + + // ─── Decode: Steps 4-5 (reconstruct) ──────────────────────── + + #[test] + fn decode_double_underscore_reconstruct() { + let codec = codec_with_tools(); + // collaboration__chat__send → collaboration/chat/send + assert_eq!(codec.decode("collaboration__chat__send"), "collaboration/chat/send"); + } + + #[test] + fn decode_single_underscore_reconstruct() { + let codec = codec_with_tools(); + // collaboration_chat_send → collaboration/chat/send + assert_eq!(codec.decode("collaboration_chat_send"), "collaboration/chat/send"); + } + + // ─── Decode: unknown ──────────────────────────────────────── + + #[test] + fn decode_unknown_returns_best_effort() { + let codec = codec_with_tools(); + // Completely unknown tool — returns double-underscore reconstruction + let result = codec.decode("totally__unknown__tool"); + assert_eq!(result, "totally/unknown/tool"); + } + + // ─── Multi-level paths ────────────────────────────────────── + + #[test] + fn multi_level_roundtrip() { + let codec = codec_with_tools(); + let original = "collaboration/decision/vote"; + let encoded = codec.encode(original); + assert_eq!(encoded, "collaboration_decision_vote"); + let decoded = codec.decode(&encoded); + assert_eq!(decoded, original); + } + + // ─── Count ────────────────────────────────────────────────── + + #[test] + fn count_registered() { + let codec = codec_with_tools(); + assert_eq!(codec.count(), 7); + } + + // ─── register_all ─────────────────────────────────────────── + + #[test] + fn register_all_batch() { + let codec = ToolNameCodec::new(); + let tools = vec![ + "code/write".to_string(), + "code/read".to_string(), + "data/list".to_string(), + ]; + codec.register_all(&tools); + assert_eq!(codec.count(), 3); + assert_eq!(codec.decode("code_write"), "code/write"); + assert_eq!(codec.decode("data_list"), "data/list"); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/tool_parsing/correction.rs b/src/debug/jtag/workers/continuum-core/src/tool_parsing/correction.rs new file mode 100644 index 000000000..bec4232ed --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/tool_parsing/correction.rs @@ -0,0 +1,294 @@ +//! Tool name and parameter corrections. +//! +//! LLMs confuse similarly-named tools and guess wrong parameter names. +//! Static lookup tables fix common mistakes before execution. +//! +//! Also handles content cleaning for code/write: CDATA stripping + HTML entity decode. + +use std::collections::HashMap; +use once_cell::sync::Lazy; +use regex::Regex; +use super::types::CorrectedToolCall; + +/// Tool name corrections: LLMs confuse similarly-named tools. +static TOOL_CORRECTIONS: Lazy> = Lazy::new(|| { + let mut m = HashMap::new(); + m.insert("workspace/tree", "code/tree"); + m +}); + +/// Parameter name corrections per command. +/// Maps { wrongName -> correctName } for each command prefix. +static PARAM_CORRECTIONS: Lazy>> = Lazy::new(|| { + let mut m: HashMap<&str, Vec<(&str, &str)>> = HashMap::new(); + + m.insert("code/write", vec![ + ("path", "filePath"), ("file", "filePath"), ("file_path", "filePath"), + ("filepath", "filePath"), ("filename", "filePath"), ("file_name", "filePath"), + ("name", "filePath"), ("contents", "content"), ("text", "content"), + ("body", "content"), ("data", "content"), ("code", "content"), + ("html", "content"), ("source", "content"), + ]); + + m.insert("code/read", vec![ + ("path", "filePath"), ("file", "filePath"), ("file_path", "filePath"), + ("filepath", "filePath"), ("filename", "filePath"), ("name", "filePath"), + ("start", "startLine"), ("end", "endLine"), + ("from", "startLine"), ("to", "endLine"), + ]); + + m.insert("code/edit", vec![ + ("path", "filePath"), ("file", "filePath"), ("file_path", "filePath"), + ("filepath", "filePath"), ("filename", "filePath"), ("name", "filePath"), + ("mode", "editMode"), ("type", "editMode"), + ]); + + m.insert("code/search", vec![ + ("query", "pattern"), ("search", "pattern"), + ("term", "pattern"), ("regex", "pattern"), + ("glob", "fileGlob"), ("filter", "fileGlob"), + ]); + + m.insert("code/tree", vec![ + ("directory", "path"), ("dir", "path"), ("folder", "path"), + ("depth", "maxDepth"), + ]); + + m.insert("code/git", vec![ + ("subcommand", "operation"), ("command", "operation"), + ("action", "operation"), ("op", "operation"), + ("msg", "message"), ("files", "paths"), + ]); + + m +}); + +/// Named HTML entities. +static NAMED_ENTITIES: Lazy> = Lazy::new(|| { + let mut m = HashMap::new(); + m.insert("lt", "<"); + m.insert("gt", ">"); + m.insert("amp", "&"); + m.insert("quot", "\""); + m.insert("apos", "'"); + m.insert("nbsp", " "); + m +}); + +/// HTML entity regex. +static RE_ENTITY: Lazy = Lazy::new(|| + Regex::new(r"&(#\d+|#x[\da-fA-F]+|[a-zA-Z]+);").unwrap() +); + +/// Correct a tool call: name mapping, parameter mapping, content cleaning. +pub fn correct_tool_call( + tool_name: &str, + parameters: &HashMap, +) -> CorrectedToolCall { + let mut name = tool_name.to_string(); + let mut name_changed = false; + let mut param_corrections = Vec::new(); + + // Tool name correction + if let Some(&corrected) = TOOL_CORRECTIONS.get(tool_name) { + name = corrected.to_string(); + name_changed = true; + } + + // Parameter correction + let mut params = parameters.clone(); + if let Some(corrections) = PARAM_CORRECTIONS.get(name.as_str()) { + for &(wrong, correct) in corrections { + if params.contains_key(wrong) && !params.contains_key(correct) { + let value = params.remove(wrong).unwrap(); + params.insert(correct.to_string(), value); + param_corrections.push(format!("{} -> {}", wrong, correct)); + } + } + } + + // Content cleaning for code/write + if name == "code/write" { + if let Some(content) = params.get("content").cloned() { + let cleaned = clean_content(&content); + if cleaned != content { + params.insert("content".to_string(), cleaned); + } + } + } + + CorrectedToolCall { + tool_name: name, + parameters: params, + name_changed, + param_corrections, + } +} + +/// Clean content: strip CDATA wrappers, decode HTML entities. +fn clean_content(content: &str) -> String { + let mut result = content.to_string(); + + // Strip CDATA wrappers + if result.starts_with("") { + result = result[9..result.len() - 3].to_string(); + } + + // Decode HTML entities + result = decode_html_entities(&result); + result +} + +/// Decode HTML entities: < > & {  etc. +fn decode_html_entities(text: &str) -> String { + RE_ENTITY.replace_all(text, |caps: ®ex::Captures| { + let entity = &caps[1]; + if let Some(&replacement) = NAMED_ENTITIES.get(entity) { + return replacement.to_string(); + } + if let Some(hex) = entity.strip_prefix("#x") { + if let Ok(code) = u32::from_str_radix(hex, 16) { + if let Some(c) = char::from_u32(code) { + return c.to_string(); + } + } + } + if let Some(dec) = entity.strip_prefix('#') { + if let Ok(code) = dec.parse::() { + if let Some(c) = char::from_u32(code) { + return c.to_string(); + } + } + } + caps[0].to_string() + }).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + // ─── Tool name correction ─────────────────────────────────── + + #[test] + fn correct_workspace_tree() { + let params = HashMap::new(); + let result = correct_tool_call("workspace/tree", ¶ms); + assert_eq!(result.tool_name, "code/tree"); + assert!(result.name_changed); + } + + #[test] + fn no_correction_needed() { + let params = HashMap::new(); + let result = correct_tool_call("code/search", ¶ms); + assert_eq!(result.tool_name, "code/search"); + assert!(!result.name_changed); + } + + // ─── Parameter correction ─────────────────────────────────── + + #[test] + fn correct_code_write_params() { + let mut params = HashMap::new(); + params.insert("path".to_string(), "/test.ts".to_string()); + params.insert("text".to_string(), "hello world".to_string()); + let result = correct_tool_call("code/write", ¶ms); + assert_eq!(result.parameters.get("filePath").unwrap(), "/test.ts"); + assert_eq!(result.parameters.get("content").unwrap(), "hello world"); + assert!(result.param_corrections.contains(&"path -> filePath".to_string())); + assert!(result.param_corrections.contains(&"text -> content".to_string())); + } + + #[test] + fn correct_code_read_params() { + let mut params = HashMap::new(); + params.insert("file".to_string(), "main.ts".to_string()); + params.insert("start".to_string(), "10".to_string()); + params.insert("end".to_string(), "20".to_string()); + let result = correct_tool_call("code/read", ¶ms); + assert_eq!(result.parameters.get("filePath").unwrap(), "main.ts"); + assert_eq!(result.parameters.get("startLine").unwrap(), "10"); + assert_eq!(result.parameters.get("endLine").unwrap(), "20"); + } + + #[test] + fn no_overwrite_existing_param() { + let mut params = HashMap::new(); + params.insert("path".to_string(), "wrong.ts".to_string()); + params.insert("filePath".to_string(), "correct.ts".to_string()); + let result = correct_tool_call("code/write", ¶ms); + // Should NOT overwrite existing filePath + assert_eq!(result.parameters.get("filePath").unwrap(), "correct.ts"); + assert!(result.param_corrections.is_empty()); + } + + #[test] + fn correct_code_search_params() { + let mut params = HashMap::new(); + params.insert("query".to_string(), "findMe".to_string()); + params.insert("glob".to_string(), "*.ts".to_string()); + let result = correct_tool_call("code/search", ¶ms); + assert_eq!(result.parameters.get("pattern").unwrap(), "findMe"); + assert_eq!(result.parameters.get("fileGlob").unwrap(), "*.ts"); + } + + #[test] + fn correct_code_git_params() { + let mut params = HashMap::new(); + params.insert("command".to_string(), "status".to_string()); + params.insert("msg".to_string(), "test commit".to_string()); + let result = correct_tool_call("code/git", ¶ms); + assert_eq!(result.parameters.get("operation").unwrap(), "status"); + assert_eq!(result.parameters.get("message").unwrap(), "test commit"); + } + + #[test] + fn name_correction_then_param_correction() { + // workspace/tree → code/tree, then directory → path + let mut params = HashMap::new(); + params.insert("directory".to_string(), "./src".to_string()); + let result = correct_tool_call("workspace/tree", ¶ms); + assert_eq!(result.tool_name, "code/tree"); + assert!(result.name_changed); + assert_eq!(result.parameters.get("path").unwrap(), "./src"); + } + + // ─── Content cleaning ─────────────────────────────────────── + + #[test] + fn clean_cdata_wrapper() { + let mut params = HashMap::new(); + params.insert("filePath".to_string(), "test.ts".to_string()); + params.insert("content".to_string(), "".to_string()); + let result = correct_tool_call("code/write", ¶ms); + assert_eq!(result.parameters.get("content").unwrap(), "const x = 1;"); + } + + #[test] + fn decode_html_entities_in_content() { + let mut params = HashMap::new(); + params.insert("filePath".to_string(), "test.ts".to_string()); + params.insert("content".to_string(), "if (a < b && c > d) { return "ok"; }".to_string()); + let result = correct_tool_call("code/write", ¶ms); + assert_eq!( + result.parameters.get("content").unwrap(), + r#"if (a < b && c > d) { return "ok"; }"# + ); + } + + #[test] + fn decode_numeric_entities() { + assert_eq!(decode_html_entities("<div>"), "

"); + assert_eq!(decode_html_entities("<div>"), "
"); + } + + #[test] + fn no_cleaning_for_non_write() { + let mut params = HashMap::new(); + params.insert("pattern".to_string(), "a < b".to_string()); + let result = correct_tool_call("code/search", ¶ms); + // Should NOT clean content for non-write commands + assert_eq!(result.parameters.get("pattern").unwrap(), "a < b"); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/tool_parsing/mod.rs b/src/debug/jtag/workers/continuum-core/src/tool_parsing/mod.rs new file mode 100644 index 000000000..a3b585b37 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/tool_parsing/mod.rs @@ -0,0 +1,165 @@ +//! Tool call parsing — 5 format adapters + correction + codec in Rust. +//! +//! Stateless CPU work that runs on every LLM response. Sub-microsecond parsing +//! replaces 784 lines of TypeScript (ToolFormatAdapter hierarchy). +//! +//! Formats supported: +//! 1. Anthropic XML: `...X...` +//! 2. Function-style: `{"param": "value"}` +//! 3. Bare JSON: `tool/name {"param": "value"}` +//! 4. Markdown backtick: `` `tool: name` `param=value` `` +//! 5. Old-style XML: `value` + +pub mod types; +pub mod parsers; +pub mod correction; +pub mod codec; + +pub use types::*; +pub use codec::ToolNameCodec; + +/// Parse tool calls from AI response text, apply corrections, strip tool blocks. +/// Single entry point combining all 5 format adapters + correction. +pub fn parse_and_correct(response_text: &str) -> ToolParseResult { + let start = std::time::Instant::now(); + + // Parse all formats + let raw_matches = parsers::parse_all_formats(response_text); + + // Apply corrections and collect results + let tool_calls: Vec = raw_matches.iter().map(|m| { + let corrected = correction::correct_tool_call(&m.tool_name, &m.parameters); + ParsedToolCall { + tool_name: corrected.tool_name, + parameters: corrected.parameters, + format: m.format.to_string(), + original_name: if corrected.name_changed { Some(m.tool_name.clone()) } else { None }, + param_corrections: corrected.param_corrections, + } + }).collect(); + + // Strip tool blocks from text + let cleaned_text = strip_tool_blocks(response_text, &raw_matches); + + let elapsed = start.elapsed(); + ToolParseResult { + tool_calls, + cleaned_text, + parse_time_us: elapsed.as_micros() as u64, + } +} + +/// Strip tool call blocks from response text, returning clean user-facing message. +fn strip_tool_blocks(text: &str, matches: &[parsers::RawToolMatch]) -> String { + if matches.is_empty() { + return text.to_string(); + } + + // Sort ranges descending by start position (remove from end to start) + let mut ranges: Vec<(usize, usize)> = matches.iter().map(|m| (m.start, m.end)).collect(); + ranges.sort_by(|a, b| b.0.cmp(&a.0)); + + let mut result = text.to_string(); + for (start, end) in ranges { + if start <= result.len() && end <= result.len() { + result = format!("{}{}", &result[..start], &result[end..]); + } + } + result.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_and_correct_anthropic() { + let text = r#"Let me search. + + code/search + + memory clustering + + +Done."#; + + let result = parse_and_correct(text); + assert_eq!(result.tool_calls.len(), 1); + // query -> pattern (param correction for code/search) + assert_eq!(result.tool_calls[0].tool_name, "code/search"); + assert_eq!(result.tool_calls[0].parameters.get("pattern").unwrap(), "memory clustering"); + assert!(!result.tool_calls[0].param_corrections.is_empty()); + assert_eq!(result.tool_calls[0].format, "anthropic-style"); + assert!(result.cleaned_text.contains("Let me search.")); + assert!(result.cleaned_text.contains("Done.")); + assert!(!result.cleaned_text.contains("tool_use")); + } + + #[test] + fn parse_and_correct_with_name_fix() { + let text = r#"workspace/tree./src"#; + + let result = parse_and_correct(text); + assert_eq!(result.tool_calls.len(), 1); + // workspace/tree -> code/tree (name correction) + assert_eq!(result.tool_calls[0].tool_name, "code/tree"); + assert_eq!(result.tool_calls[0].original_name.as_deref(), Some("workspace/tree")); + // directory -> path (param correction for code/tree) + assert_eq!(result.tool_calls[0].parameters.get("path").unwrap(), "./src"); + } + + #[test] + fn parse_and_correct_code_write_content_cleaning() { + let text = r#" + code/write + + test.ts + + +"#; + + let result = parse_and_correct(text); + assert_eq!(result.tool_calls.len(), 1); + // CDATA stripped + HTML entities decoded + assert_eq!(result.tool_calls[0].parameters.get("content").unwrap(), "const x = 1 < 2;"); + } + + #[test] + fn strip_preserves_surrounding_text() { + let text = "Hello\nping\nWorld"; + let result = parse_and_correct(text); + assert!(result.cleaned_text.starts_with("Hello")); + assert!(result.cleaned_text.ends_with("World")); + } + + #[test] + fn no_tool_calls_returns_original() { + let text = "Just a normal response."; + let result = parse_and_correct(text); + assert_eq!(result.tool_calls.len(), 0); + assert_eq!(result.cleaned_text, text); + } + + #[test] + fn parse_time_is_measured() { + let text = "code/readx.ts"; + let result = parse_and_correct(text); + // Should complete in microseconds + assert!(result.parse_time_us < 10_000, "Parse should be sub-10ms, was {}us", result.parse_time_us); + } + + #[test] + fn multiple_formats_in_one_response() { + let text = r#"First: +code/reada.ts +Then: +{"query": "test"} +"#; + let result = parse_and_correct(text); + assert_eq!(result.tool_calls.len(), 2); + assert_eq!(result.tool_calls[0].format, "anthropic-style"); + assert_eq!(result.tool_calls[1].format, "function-style"); + // query -> pattern for code/search + assert_eq!(result.tool_calls[1].parameters.get("pattern").unwrap(), "test"); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/tool_parsing/parsers.rs b/src/debug/jtag/workers/continuum-core/src/tool_parsing/parsers.rs new file mode 100644 index 000000000..22c3a2f5a --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/tool_parsing/parsers.rs @@ -0,0 +1,704 @@ +//! Format-specific tool call parsers. +//! +//! Six formats supported (matching TypeScript ToolFormatAdapter hierarchy): +//! 1. Anthropic XML: `X...` +//! 2. Function-style: `{"param": "value"}` +//! 3. Bare JSON: `tool/name {"param": "value"}` or `tool_name {"param": "value"}` +//! 4. JSON Object: `{"name": "tool_name", "parameters": {"param": "value"}}` +//! 5. Markdown backtick: `` `tool: name` `param=value` `` +//! 6. Old-style XML: `value` +//! +//! Handles both canonical (slash) and sanitized (underscore) tool names. +//! Sanitized names from native tool protocol (code_tree → code/tree) are +//! automatically unsanitized back to canonical form. + +use regex::Regex; +use std::collections::HashMap; +use once_cell::sync::Lazy; + +/// Internal representation of a matched tool call with position info. +pub struct RawToolMatch { + pub tool_name: String, + pub parameters: HashMap, + pub format: &'static str, + pub start: usize, + pub end: usize, +} + +/// Parse all tool calls from response text using all 6 format adapters. +/// Returns matches in order of adapter priority (Anthropic first). +pub fn parse_all_formats(text: &str) -> Vec { + let mut results = Vec::new(); + results.extend(parse_anthropic(text)); + results.extend(parse_function_style(text)); + results.extend(parse_bare(text)); + results.extend(parse_json_object(text)); + results.extend(parse_markdown(text)); + results.extend(parse_old_style(text)); + results +} + +// ─── Anthropic XML ────────────────────────────────────────────────── + +static RE_ANTHROPIC: Lazy = Lazy::new(|| + Regex::new(r"(?s)(.*?)").unwrap() +); +static RE_TOOL_NAME: Lazy = Lazy::new(|| + Regex::new(r"(?s)(.*?)").unwrap() +); +static RE_PARAMS_BLOCK: Lazy = Lazy::new(|| + Regex::new(r"(?s)(.*?)").unwrap() +); + +fn parse_anthropic(text: &str) -> Vec { + RE_ANTHROPIC.find_iter(text).filter_map(|m| { + let block = m.as_str(); + let name = RE_TOOL_NAME.captures(block)?.get(1)?.as_str().trim().to_string(); + let params_block = RE_PARAMS_BLOCK.captures(block) + .and_then(|c| c.get(1)) + .map(|m| m.as_str()) + .unwrap_or(""); + let parameters = extract_xml_params(params_block); + Some(RawToolMatch { + tool_name: name, + parameters, + format: "anthropic-style", + start: m.start(), + end: m.end(), + }) + }).collect() +} + +// ─── Function-style ───────────────────────────────────────────────── + +// Match both proper XML and Groq's variant format: +// {"param": "value"} — standard +// function=name>{"param": "value"} — Groq variant (no < prefix, no closing tag) +static RE_FUNCTION: Lazy = Lazy::new(|| + Regex::new(r"(?si)\s]+)>\s*(\{[\s\S]*?\})\s*(?:)?").unwrap() +); + +fn parse_function_style(text: &str) -> Vec { + RE_FUNCTION.captures_iter(text).filter_map(|cap| { + let name = cap.get(1)?.as_str().trim().to_string(); + let body = cap.get(2).map(|m| m.as_str().trim()).unwrap_or(""); + let parameters = parse_json_params(body); + let full_match = cap.get(0)?; + Some(RawToolMatch { + tool_name: name, + parameters, + format: "function-style", + start: full_match.start(), + end: full_match.end(), + }) + }).collect() +} + +// ─── Bare JSON ────────────────────────────────────────────────────── + +// Slash-based prefixes (canonical tool names: code/tree, data/list, etc.) +const TOOL_PREFIXES_SLASH: &[&str] = &[ + "code/", "data/", "collaboration/", "ai/", "voice/", "search/", + "workspace/", "file/", "interface/", "genome/", "adapter/", + "persona/", "runtime/", "session/", "user/", "logs/", "media/", +]; + +// Underscore-based prefixes (sanitized names from native tool protocol: +// code_tree, collaboration_chat_send, etc.) +const TOOL_PREFIXES_UNDERSCORE: &[&str] = &[ + "code_", "data_", "collaboration_", "ai_", "voice_", "search_", + "workspace_", "file_", "interface_", "genome_", "adapter_", + "persona_", "runtime_", "session_", "user_", "logs_", "media_", +]; + +fn all_prefix_pattern() -> String { + TOOL_PREFIXES_SLASH.iter() + .chain(TOOL_PREFIXES_UNDERSCORE.iter()) + .map(|p| regex::escape(p)) + .collect::>() + .join("|") +} + +static RE_BARE: Lazy = Lazy::new(|| { + let prefix_pat = all_prefix_pattern(); + // Match tool call with optional backticks, optional trailing + Regex::new(&format!( + r"`?(?:{})[a-zA-Z0-9/_-]+`?\s*\{{[^{{}}]*(?:\{{[^{{}}]*\}}[^{{}}]*)*\}}\s*(?:)?", + prefix_pat + )).unwrap() +}); + +static RE_BARE_PARSE: Lazy = Lazy::new(|| { + let prefix_pat = all_prefix_pattern(); + Regex::new(&format!( + r"(?s)`?((?:{})[a-zA-Z0-9/_-]+)`?\s*(\{{.+?\}})\s*(?:)?", + prefix_pat + )).unwrap() +}); + +/// Unsanitize a tool name: convert underscore-based names back to slash-based. +/// e.g. "code_tree" → "code/tree", "collaboration_chat_send" → "collaboration/chat/send" +/// +/// Tool names in this system use camelCase within segments (never snake_case), +/// so all underscores in a sanitized name are path separators. +fn unsanitize_tool_name(name: &str) -> String { + // Already uses slashes — canonical form + if name.contains('/') { + return name.to_string(); + } + // Check if name starts with a known prefix root + let prefix_roots: &[&str] = &[ + "collaboration", "code", "data", "ai", "voice", "search", + "workspace", "file", "interface", "genome", "adapter", + "persona", "runtime", "session", "user", "logs", "media", + ]; + for root in prefix_roots { + if name.starts_with(root) && name.len() > root.len() && name.as_bytes()[root.len()] == b'_' { + // Replace ALL underscores with slashes (tool segments use camelCase, not snake_case) + return name.replace('_', "/"); + } + } + // No known prefix — return as-is + name.to_string() +} + +fn parse_bare(text: &str) -> Vec { + RE_BARE.find_iter(text).filter_map(|m| { + let full = m.as_str(); + let cap = RE_BARE_PARSE.captures(full)?; + let raw_name = cap.get(1)?.as_str().trim(); + let name = unsanitize_tool_name(raw_name); + let json_str = cap.get(2)?.as_str().trim(); + let parameters = parse_json_params(json_str); + Some(RawToolMatch { + tool_name: name, + parameters, + format: "bare-tool-call", + start: m.start(), + end: m.end(), + }) + }).collect() +} + +// ─── JSON Object ─────────────────────────────────────────────────── + +// Matches tool calls in JSON object format (two variants): +// {"name": "code_tree", "parameters": {"path": "."}} +// {"type": "function", "name": "code_git", "parameters": {"operation": "status"}} +// Used by Fireworks and some OpenAI-compatible models +static RE_JSON_TOOL: Lazy = Lazy::new(|| + Regex::new(r#"\{(?:\s*"type"\s*:\s*"[^"]*"\s*,)?\s*"name"\s*:\s*"([^"]+)"\s*,\s*"parameters"\s*:\s*(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\})\s*\}"#).unwrap() +); + +fn parse_json_object(text: &str) -> Vec { + RE_JSON_TOOL.captures_iter(text).filter_map(|cap| { + let raw_name = cap.get(1)?.as_str().trim(); + let name = unsanitize_tool_name(raw_name); + let json_str = cap.get(2)?.as_str().trim(); + let parameters = parse_json_params(json_str); + let full_match = cap.get(0)?; + Some(RawToolMatch { + tool_name: name, + parameters, + format: "json-object", + start: full_match.start(), + end: full_match.end(), + }) + }).collect() +} + +// ─── Markdown backtick ────────────────────────────────────────────── + +static RE_MD_TOOL: Lazy = Lazy::new(|| + Regex::new(r"(?i)`tool:\s*([^`]+)`").unwrap() +); +static RE_MD_PARAM: Lazy = Lazy::new(|| + Regex::new(r"`([^`=]+)=([^`]*)`").unwrap() +); + +fn parse_markdown(text: &str) -> Vec { + let mut results = Vec::new(); + let mut current_lines: Vec<&str> = Vec::new(); + let mut current_start = 0usize; + let mut char_offset = 0usize; + + for line in text.split('\n') { + if RE_MD_TOOL.is_match(line) { + // Flush previous match + if !current_lines.is_empty() { + let combined = current_lines.join(" "); + if let Some((name, params)) = parse_markdown_match(&combined) { + results.push(RawToolMatch { + tool_name: name, + parameters: params, + format: "markdown-backtick", + start: current_start, + end: char_offset, + }); + } + } + current_lines = vec![line]; + current_start = char_offset; + } else if !current_lines.is_empty() && line.contains('`') && line.contains('=') { + current_lines.push(line); + } + char_offset += line.len() + 1; // +1 for newline + } + + // Final match + if !current_lines.is_empty() { + let combined = current_lines.join(" "); + if let Some((name, params)) = parse_markdown_match(&combined) { + results.push(RawToolMatch { + tool_name: name, + parameters: params, + format: "markdown-backtick", + start: current_start, + end: char_offset, + }); + } + } + + results +} + +fn parse_markdown_match(text: &str) -> Option<(String, HashMap)> { + let name = RE_MD_TOOL.captures(text)?.get(1)?.as_str().trim().to_string(); + let mut params = HashMap::new(); + for cap in RE_MD_PARAM.captures_iter(text) { + if let (Some(k), Some(v)) = (cap.get(1), cap.get(2)) { + let key = k.as_str().trim(); + if key != "tool" { + params.insert(key.to_string(), v.as_str().trim().to_string()); + } + } + } + Some((name, params)) +} + +// ─── Old-style XML ────────────────────────────────────────────────── + +static RE_OLD_STYLE: Lazy = Lazy::new(|| + Regex::new(r#"(?s)(.*?)"#).unwrap() +); + +fn parse_old_style(text: &str) -> Vec { + RE_OLD_STYLE.captures_iter(text).filter_map(|cap| { + let name = cap.get(1)?.as_str().trim().to_string(); + let body = cap.get(2).map(|m| m.as_str()).unwrap_or(""); + let parameters = extract_xml_params(body); + let full_match = cap.get(0)?; + Some(RawToolMatch { + tool_name: name, + parameters, + format: "old-style", + start: full_match.start(), + end: full_match.end(), + }) + }).collect() +} + +// ─── Helpers ──────────────────────────────────────────────────────── + +/// Regex to find opening XML tags: `` +static RE_XML_OPEN: Lazy = Lazy::new(|| + Regex::new(r"<(\w+)>").unwrap() +); + +/// Extract `value` pairs from an XML block. +/// Uses a two-pass approach since Rust regex doesn't support backreferences. +pub fn extract_xml_params(block: &str) -> HashMap { + let mut params = HashMap::new(); + for cap in RE_XML_OPEN.captures_iter(block) { + let tag_name = cap.get(1).unwrap().as_str(); + let open_tag = cap.get(0).unwrap(); + let after_open = open_tag.end(); + + // Look for the matching closing tag + let close_tag = format!("", tag_name); + if let Some(close_pos) = block[after_open..].find(&close_tag) { + let value = &block[after_open..after_open + close_pos]; + params.insert(tag_name.to_string(), value.trim().to_string()); + } + } + params +} + +/// Parse JSON object into string parameters (non-strings are JSON-stringified). +pub fn parse_json_params(json_str: &str) -> HashMap { + if json_str.is_empty() { + return HashMap::new(); + } + match serde_json::from_str::(json_str) { + Ok(serde_json::Value::Object(map)) => { + map.into_iter().map(|(k, v)| { + let s = match &v { + serde_json::Value::String(s) => s.clone(), + _ => v.to_string(), + }; + (k, s) + }).collect() + } + _ => { + // Fallback: extract "key": "value" pairs + static RE_KV: Lazy = Lazy::new(|| + Regex::new(r#""([^"]+)":\s*"([^"]*)""#).unwrap() + ); + RE_KV.captures_iter(json_str).filter_map(|cap| { + Some((cap.get(1)?.as_str().to_string(), cap.get(2)?.as_str().to_string())) + }).collect() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ─── Anthropic XML ────────────────────────────────────────── + + #[test] + fn anthropic_basic() { + let text = r#"I'll search for that. + + code/search + + memory clustering + ./src + + +Let me check the results."#; + + let matches = parse_anthropic(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "code/search"); + assert_eq!(matches[0].parameters.get("pattern").unwrap(), "memory clustering"); + assert_eq!(matches[0].parameters.get("path").unwrap(), "./src"); + assert_eq!(matches[0].format, "anthropic-style"); + } + + #[test] + fn anthropic_multiple() { + let text = r#"code/readmain.ts +Then: +code/writemain.tshello"#; + + let matches = parse_anthropic(text); + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].tool_name, "code/read"); + assert_eq!(matches[1].tool_name, "code/write"); + } + + #[test] + fn anthropic_no_params() { + let text = "collaboration/decision/voteabc-123[\"opt1\",\"opt2\"]"; + let matches = parse_anthropic(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "collaboration/decision/vote"); + assert_eq!(matches[0].parameters.get("proposalId").unwrap(), "abc-123"); + } + + // ─── Function-style ───────────────────────────────────────── + + #[test] + fn function_style_json() { + let text = r#" {"query": "embedding module"} "#; + let matches = parse_function_style(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "adapter_search"); + assert_eq!(matches[0].parameters.get("query").unwrap(), "embedding module"); + assert_eq!(matches[0].format, "function-style"); + } + + #[test] + fn function_style_no_spaces() { + let text = r#"{"query": "memory clustering"}"#; + let matches = parse_function_style(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "code/search"); + } + + #[test] + fn function_style_non_string_value() { + let text = r#"{"collection": "users", "limit": 10}"#; + let matches = parse_function_style(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].parameters.get("limit").unwrap(), "10"); + } + + #[test] + fn function_style_groq_variant_no_xml_wrapper() { + // Groq outputs function calls without < prefix and without closing + let text = r#"function=code_shell_execute>{"cmd": "ping google.com", "wait": true}"#; + let matches = parse_function_style(text); + assert_eq!(matches.len(), 1, "Should match Groq's unwrapped function format"); + assert_eq!(matches[0].tool_name, "code_shell_execute"); + assert_eq!(matches[0].parameters.get("cmd").unwrap(), "ping google.com"); + assert_eq!(matches[0].parameters.get("wait").unwrap(), "true"); + } + + #[test] + fn function_style_groq_tree_variant() { + let text = r#"function=code_tree>{"includeHidden": true, "maxDepth": 10, "path": "/shared/repository"}"#; + let matches = parse_function_style(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "code_tree"); + assert_eq!(matches[0].parameters.get("path").unwrap(), "/shared/repository"); + } + + #[test] + fn function_style_groq_git_variant() { + let text = r#"function=code_git>{"operation": "log", "count": 100, "cwd": "/shared/repository"}"#; + let matches = parse_function_style(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "code_git"); + assert_eq!(matches[0].parameters.get("operation").unwrap(), "log"); + } + + // ─── Bare JSON ────────────────────────────────────────────── + + #[test] + fn bare_basic() { + let text = r#"code/search {"query": "memory clustering", "path": "./src/"}"#; + let matches = parse_bare(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "code/search"); + assert_eq!(matches[0].parameters.get("query").unwrap(), "memory clustering"); + assert_eq!(matches[0].format, "bare-tool-call"); + } + + #[test] + fn bare_backtick_wrapped() { + let text = r#"`code/tree` {"path": "."}"#; + let matches = parse_bare(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "code/tree"); + } + + #[test] + fn bare_no_match_for_unknown_prefix() { + let text = r#"unknown/tool {"query": "test"}"#; + let matches = parse_bare(text); + assert_eq!(matches.len(), 0, "Should not match unknown prefix"); + } + + #[test] + fn bare_sanitized_underscore_name() { + // Groq outputs sanitized names (code_tree instead of code/tree) with optional + let text = r#"code_tree {"maxDepth": 1, "path": "."}"#; + let matches = parse_bare(text); + assert_eq!(matches.len(), 1, "Should match underscore-based tool name"); + assert_eq!(matches[0].tool_name, "code/tree", "Should unsanitize back to slash-based name"); + assert_eq!(matches[0].parameters.get("path").unwrap(), "."); + } + + #[test] + fn bare_sanitized_deep_name() { + // Multi-level sanitized name: collaboration_chat_send → collaboration/chat/send + let text = r#"collaboration_chat_send {"room": "general", "message": "hello"}"#; + let matches = parse_bare(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "collaboration/chat/send"); + assert_eq!(matches[0].parameters.get("room").unwrap(), "general"); + } + + #[test] + fn bare_sanitized_without_function_tag() { + let text = r#"code_read {"filePath": "main.ts"}"#; + let matches = parse_bare(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "code/read"); + } + + // ─── JSON Object ──────────────────────────────────────────── + + #[test] + fn json_object_basic() { + let text = r#"I'll check that. {"name": "code_tree", "parameters": {"path": "."}}"#; + let matches = parse_json_object(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "code/tree"); + assert_eq!(matches[0].parameters.get("path").unwrap(), "."); + assert_eq!(matches[0].format, "json-object"); + } + + #[test] + fn json_object_with_slash_name() { + let text = r#"{"name": "collaboration/chat/send", "parameters": {"message": "hello", "room": "general"}}"#; + let matches = parse_json_object(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "collaboration/chat/send"); + assert_eq!(matches[0].parameters.get("message").unwrap(), "hello"); + } + + #[test] + fn json_object_with_type_field() { + // Fireworks format with "type": "function" prefix + let text = r#"{"type": "function", "name": "code_git", "parameters": {"operation": "status"}}"#; + let matches = parse_json_object(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "code/git"); + assert_eq!(matches[0].parameters.get("operation").unwrap(), "status"); + } + + #[test] + fn json_object_no_match_for_normal_json() { + // Should not match arbitrary JSON objects + let text = r#"{"status": "ok", "count": 5}"#; + let matches = parse_json_object(text); + assert_eq!(matches.len(), 0, "Should not match JSON without name+parameters fields"); + } + + // ─── Markdown backtick ────────────────────────────────────── + + #[test] + fn markdown_basic() { + let text = "`tool: collaboration/dm` `participants=helper`"; + let matches = parse_markdown(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "collaboration/dm"); + assert_eq!(matches[0].parameters.get("participants").unwrap(), "helper"); + assert_eq!(matches[0].format, "markdown-backtick"); + } + + #[test] + fn markdown_multi_param() { + let text = "`tool: code/read` `filepath=/path/to/file` `startLine=10`"; + let matches = parse_markdown(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].parameters.len(), 2); + assert_eq!(matches[0].parameters.get("filepath").unwrap(), "/path/to/file"); + } + + #[test] + fn markdown_multiple_tools() { + let text = "`tool: code/read` `filepath=a.ts`\n`tool: code/write` `filepath=b.ts` `content=hello`"; + let matches = parse_markdown(text); + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].tool_name, "code/read"); + assert_eq!(matches[1].tool_name, "code/write"); + } + + // ─── Old-style XML ────────────────────────────────────────── + + #[test] + fn old_style_basic() { + let text = r#"hello./src"#; + let matches = parse_old_style(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].tool_name, "code/search"); + assert_eq!(matches[0].parameters.get("pattern").unwrap(), "hello"); + assert_eq!(matches[0].format, "old-style"); + } + + #[test] + fn old_style_multiline() { + let text = r#" + test.ts + function hello() { return 42; } +"#; + let matches = parse_old_style(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].parameters.get("filePath").unwrap(), "test.ts"); + } + + // ─── parse_all_formats ────────────────────────────────────── + + #[test] + fn all_formats_mixed() { + let text = r#" +code/reada.ts +Then also: +{"query": "test"} +"#; + let matches = parse_all_formats(text); + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].format, "anthropic-style"); + assert_eq!(matches[1].format, "function-style"); + } + + #[test] + fn no_tool_calls() { + let text = "Just a normal response with no tool calls at all."; + let matches = parse_all_formats(text); + assert_eq!(matches.len(), 0); + } + + // ─── Helpers ──────────────────────────────────────────────── + + #[test] + fn xml_params_extraction() { + let block = "Joel30"; + let params = extract_xml_params(block); + assert_eq!(params.get("name").unwrap(), "Joel"); + assert_eq!(params.get("age").unwrap(), "30"); + } + + #[test] + fn json_params_valid() { + let json = r#"{"query": "test", "limit": 10, "flag": true}"#; + let params = parse_json_params(json); + assert_eq!(params.get("query").unwrap(), "test"); + assert_eq!(params.get("limit").unwrap(), "10"); + assert_eq!(params.get("flag").unwrap(), "true"); + } + + #[test] + fn json_params_invalid_fallback() { + let json = r#"{"query": "test", bad json"#; + let params = parse_json_params(json); + assert_eq!(params.get("query").unwrap(), "test"); + } + + #[test] + fn json_params_empty() { + assert!(parse_json_params("").is_empty()); + } + + // ─── Unsanitize ───────────────────────────────────────────── + + #[test] + fn unsanitize_simple() { + assert_eq!(unsanitize_tool_name("code_tree"), "code/tree"); + assert_eq!(unsanitize_tool_name("data_list"), "data/list"); + } + + #[test] + fn unsanitize_deep() { + assert_eq!(unsanitize_tool_name("collaboration_chat_send"), "collaboration/chat/send"); + assert_eq!(unsanitize_tool_name("collaboration_decision_vote"), "collaboration/decision/vote"); + } + + #[test] + fn unsanitize_already_slash() { + assert_eq!(unsanitize_tool_name("code/tree"), "code/tree"); + assert_eq!(unsanitize_tool_name("collaboration/chat/send"), "collaboration/chat/send"); + } + + #[test] + fn unsanitize_unknown_prefix() { + // Unknown prefix stays as-is + assert_eq!(unsanitize_tool_name("foobar_baz"), "foobar_baz"); + } + + // ─── Full parse_all_formats with sanitized names ──────────── + + #[test] + fn all_formats_catches_groq_output() { + // Real Groq Llama output: sanitized tool name + JSON + stray + let text = r#"code_tree {"maxDepth": 1, "path": "."}"#; + let matches = parse_all_formats(text); + assert!(matches.len() >= 1, "Should catch Groq's sanitized tool call"); + assert_eq!(matches[0].tool_name, "code/tree"); + } + + #[test] + fn all_formats_catches_fireworks_json_object() { + let text = r#"Let me check. {"name": "code_tree", "parameters": {"path": "."}}"#; + let matches = parse_all_formats(text); + assert!(matches.len() >= 1, "Should catch Fireworks JSON object tool call"); + // Find the json-object match specifically + let json_match = matches.iter().find(|m| m.format == "json-object").unwrap(); + assert_eq!(json_match.tool_name, "code/tree"); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/tool_parsing/types.rs b/src/debug/jtag/workers/continuum-core/src/tool_parsing/types.rs new file mode 100644 index 000000000..67280a2e1 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/tool_parsing/types.rs @@ -0,0 +1,91 @@ +//! Wire types for tool parsing IPC — ts-rs generated. +//! +//! Single source of truth for Rust↔TypeScript tool parsing boundary. + +use serde::{Serialize, Deserialize}; +use std::collections::HashMap; +use ts_rs::TS; + +/// Request to parse tool calls from AI response text. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/ToolParseRequest.ts")] +pub struct ToolParseRequest { + pub response_text: String, + #[ts(optional)] + pub known_tools: Option>, +} + +/// A single parsed tool call with format and correction metadata. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/ParsedToolCall.ts")] +pub struct ParsedToolCall { + pub tool_name: String, + pub parameters: HashMap, + /// Which format adapter parsed this call + pub format: String, + /// Original name before correction (None if unchanged) + #[ts(optional)] + pub original_name: Option, + /// Parameter corrections applied (e.g. ["path -> filePath"]) + pub param_corrections: Vec, +} + +/// Result of parsing tool calls from response text. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/ToolParseResult.ts")] +pub struct ToolParseResult { + pub tool_calls: Vec, + /// Response text with tool call blocks removed + pub cleaned_text: String, + /// Parse time in microseconds + #[ts(type = "number")] + pub parse_time_us: u64, +} + +/// Result of correcting a single tool call (name + params + content cleaning). +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/persona/CorrectedToolCall.ts")] +pub struct CorrectedToolCall { + pub tool_name: String, + pub parameters: HashMap, + pub name_changed: bool, + pub param_corrections: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ts_binding_tool_parse_request() { + let _ts = ToolParseRequest::export_to_string().unwrap(); + assert!(_ts.contains("response_text")); + assert!(_ts.contains("known_tools")); + } + + #[test] + fn ts_binding_parsed_tool_call() { + let _ts = ParsedToolCall::export_to_string().unwrap(); + assert!(_ts.contains("tool_name")); + assert!(_ts.contains("parameters")); + assert!(_ts.contains("format")); + assert!(_ts.contains("original_name")); + assert!(_ts.contains("param_corrections")); + } + + #[test] + fn ts_binding_tool_parse_result() { + let _ts = ToolParseResult::export_to_string().unwrap(); + assert!(_ts.contains("tool_calls")); + assert!(_ts.contains("cleaned_text")); + assert!(_ts.contains("parse_time_us")); + } + + #[test] + fn ts_binding_corrected_tool_call() { + let _ts = CorrectedToolCall::export_to_string().unwrap(); + assert!(_ts.contains("tool_name")); + assert!(_ts.contains("name_changed")); + assert!(_ts.contains("param_corrections")); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/utils/mod.rs b/src/debug/jtag/workers/continuum-core/src/utils/mod.rs index 38eae3383..805da7641 100644 --- a/src/debug/jtag/workers/continuum-core/src/utils/mod.rs +++ b/src/debug/jtag/workers/continuum-core/src/utils/mod.rs @@ -4,3 +4,4 @@ //! These are generic helpers that don't belong to any specific domain. pub mod audio; +pub mod params; diff --git a/src/debug/jtag/workers/continuum-core/src/utils/params.rs b/src/debug/jtag/workers/continuum-core/src/utils/params.rs new file mode 100644 index 000000000..150ded188 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/utils/params.rs @@ -0,0 +1,392 @@ +//! Command Parameter Extraction +//! +//! Typed helpers for extracting parameters from IPC JSON values. +//! Eliminates repetitive `params.get("x").and_then(|v| v.as_str()).ok_or("Missing x")` +//! patterns across all ServiceModule command handlers. +//! +//! ONE source of truth for parameter extraction — every module uses this. + +use serde::de::DeserializeOwned; +use serde_json::Value; +use uuid::Uuid; + +/// Wrapper around serde_json::Value for typed parameter extraction. +/// +/// Usage: +/// ```ignore +/// let p = Params::new(¶ms); +/// let persona_id = p.uuid("persona_id")?; +/// let text = p.str("response_text")?; +/// let verbose = p.bool_or("verbose", false); +/// let items: Vec = p.json("items")?; +/// ``` +pub struct Params<'a>(pub &'a Value); + +impl<'a> Params<'a> { + pub fn new(value: &'a Value) -> Self { + Self(value) + } + + // ================================================================ + // String + // ================================================================ + + /// Required string parameter. Returns error if missing or not a string. + pub fn str(&self, key: &str) -> Result<&'a str, String> { + self.0.get(key) + .and_then(|v| v.as_str()) + .ok_or_else(|| format!("Missing {key}")) + } + + /// Optional string parameter. Returns None if missing. + pub fn str_opt(&self, key: &str) -> Option<&'a str> { + self.0.get(key).and_then(|v| v.as_str()) + } + + /// Optional string with default. + pub fn str_or<'b>(&'a self, key: &str, default: &'b str) -> &'b str where 'a: 'b { + self.str_opt(key).unwrap_or(default) + } + + /// String with alias fallback (e.g. "system_prompt" or "systemPrompt"). + pub fn str_opt_alias(&self, key1: &str, key2: &str) -> Option<&'a str> { + self.str_opt(key1).or_else(|| self.str_opt(key2)) + } + + // ================================================================ + // UUID + // ================================================================ + + /// Required UUID parameter. Parses from string. + pub fn uuid(&self, key: &str) -> Result { + let s = self.str(key)?; + Uuid::parse_str(s).map_err(|e| format!("Invalid {key}: {e}")) + } + + /// Optional UUID parameter. + pub fn uuid_opt(&self, key: &str) -> Option { + self.str_opt(key).and_then(|s| Uuid::parse_str(s).ok()) + } + + // ================================================================ + // Integers + // ================================================================ + + /// Required u64 parameter. + pub fn u64(&self, key: &str) -> Result { + self.0.get(key) + .and_then(|v| v.as_u64()) + .ok_or_else(|| format!("Missing {key}")) + } + + /// Optional u64 parameter with default. + pub fn u64_or(&self, key: &str, default: u64) -> u64 { + self.0.get(key).and_then(|v| v.as_u64()).unwrap_or(default) + } + + /// Optional u64 (returns None if missing). + pub fn u64_opt(&self, key: &str) -> Option { + self.0.get(key).and_then(|v| v.as_u64()) + } + + /// Optional u32 (returns None if missing or value exceeds u32::MAX). + pub fn u32_opt(&self, key: &str) -> Option { + self.u64_opt(key).and_then(|n| u32::try_from(n).ok()) + } + + /// Optional i64. + pub fn i64_opt(&self, key: &str) -> Option { + self.0.get(key).and_then(|v| v.as_i64()) + } + + /// i64 with default. + pub fn i64_or(&self, key: &str, default: i64) -> i64 { + self.i64_opt(key).unwrap_or(default) + } + + // ================================================================ + // Floats + // ================================================================ + + /// Required f64 parameter. + pub fn f64(&self, key: &str) -> Result { + self.f64_opt(key).ok_or_else(|| format!("Missing {key}")) + } + + /// Optional f64 (returns None if missing). + pub fn f64_opt(&self, key: &str) -> Option { + self.0.get(key).and_then(|v| v.as_f64()) + } + + /// f64 with default (returns default if missing). + pub fn f64_or(&self, key: &str, default: f64) -> f64 { + self.f64_opt(key).unwrap_or(default) + } + + /// Required f32 parameter. + pub fn f32(&self, key: &str) -> Result { + self.f64(key).map(|f| f as f32) + } + + /// Optional f32 (returns None if missing). + pub fn f32_opt(&self, key: &str) -> Option { + self.f64_opt(key).map(|f| f as f32) + } + + /// Optional f64 parameter as f32 with default. + pub fn f32_or(&self, key: &str, default: f32) -> f32 { + self.f64_opt(key).map(|f| f as f32).unwrap_or(default) + } + + /// Optional f64 with alias fallback (e.g. "top_p" or "topP"). + pub fn f64_opt_alias(&self, key1: &str, key2: &str) -> Option { + self.f64_opt(key1).or_else(|| self.f64_opt(key2)) + } + + // ================================================================ + // Bool + // ================================================================ + + /// Optional bool (returns None if missing). + pub fn bool_opt(&self, key: &str) -> Option { + self.0.get(key).and_then(|v| v.as_bool()) + } + + /// Optional bool parameter with default. + pub fn bool_or(&self, key: &str, default: bool) -> bool { + self.bool_opt(key).unwrap_or(default) + } + + // ================================================================ + // Arrays + // ================================================================ + + /// Required array parameter. + pub fn array(&self, key: &str) -> Result<&'a Vec, String> { + self.array_opt(key).ok_or_else(|| format!("Missing {key}")) + } + + /// Optional array parameter. + pub fn array_opt(&self, key: &str) -> Option<&'a Vec> { + self.0.get(key).and_then(|v| v.as_array()) + } + + // ================================================================ + // Typed deserialization (serde) + // ================================================================ + + /// Required typed parameter via serde deserialization. + /// Use for complex types: `let items: Vec = p.json("items")?;` + pub fn json(&self, key: &str) -> Result { + let v = self.0.get(key).ok_or_else(|| format!("Missing {key}"))?; + serde_json::from_value(v.clone()).map_err(|e| format!("Invalid {key}: {e}")) + } + + /// Optional typed parameter via serde deserialization, with default. + pub fn json_or(&self, key: &str) -> T { + self.0.get(key) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default() + } + + /// Optional typed parameter via serde deserialization. + pub fn json_opt(&self, key: &str) -> Option { + self.0.get(key).and_then(|v| serde_json::from_value(v.clone()).ok()) + } + + // ================================================================ + // Alias helpers (camelCase ↔ snake_case) + // ================================================================ + + /// Optional u64 with alias fallback. + pub fn u64_opt_alias(&self, key1: &str, key2: &str) -> Option { + self.u64_opt(key1).or_else(|| self.u64_opt(key2)) + } + + /// Optional string with alias, mapped to owned String. + pub fn string_opt_alias(&self, key1: &str, key2: &str) -> Option { + self.str_opt_alias(key1, key2).map(String::from) + } + + // ================================================================ + // Raw access + // ================================================================ + + /// Raw value access for complex types. + pub fn value(&self, key: &str) -> Option<&'a Value> { + self.0.get(key) + } + + /// The underlying Value reference. + pub fn inner(&self) -> &'a Value { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_str_required() { + let v = json!({"name": "test"}); + let p = Params::new(&v); + assert_eq!(p.str("name").unwrap(), "test"); + assert!(p.str("missing").is_err()); + } + + #[test] + fn test_str_or() { + let v = json!({"name": "test"}); + let p = Params::new(&v); + assert_eq!(p.str_or("name", "default"), "test"); + assert_eq!(p.str_or("missing", "default"), "default"); + } + + #[test] + fn test_uuid() { + let id = "550e8400-e29b-41d4-a716-446655440000"; + let v = json!({"id": id}); + let p = Params::new(&v); + assert_eq!(p.uuid("id").unwrap().to_string(), id); + assert!(p.uuid("missing").is_err()); + } + + #[test] + fn test_uuid_opt() { + let v = json!({"id": "550e8400-e29b-41d4-a716-446655440000"}); + let p = Params::new(&v); + assert!(p.uuid_opt("id").is_some()); + assert!(p.uuid_opt("missing").is_none()); + } + + #[test] + fn test_bool_or() { + let v = json!({"flag": true}); + let p = Params::new(&v); + assert!(p.bool_or("flag", false)); + assert!(!p.bool_or("missing", false)); + } + + #[test] + fn test_u64_or() { + let v = json!({"count": 42}); + let p = Params::new(&v); + assert_eq!(p.u64_or("count", 0), 42); + assert_eq!(p.u64_or("missing", 99), 99); + } + + #[test] + fn test_u64_opt() { + let v = json!({"count": 42}); + let p = Params::new(&v); + assert_eq!(p.u64_opt("count"), Some(42)); + assert_eq!(p.u64_opt("missing"), None); + } + + #[test] + fn test_u32_opt() { + let v = json!({"line": 100, "big": 5_000_000_000u64}); + let p = Params::new(&v); + assert_eq!(p.u32_opt("line"), Some(100)); + assert_eq!(p.u32_opt("missing"), None); + // Values exceeding u32::MAX return None instead of silently truncating + assert_eq!(p.u32_opt("big"), None); + } + + #[test] + fn test_bool_opt() { + let v = json!({"flag": true, "off": false}); + let p = Params::new(&v); + assert_eq!(p.bool_opt("flag"), Some(true)); + assert_eq!(p.bool_opt("off"), Some(false)); + assert_eq!(p.bool_opt("missing"), None); + } + + #[test] + fn test_i64_or() { + let v = json!({"offset": -10}); + let p = Params::new(&v); + assert_eq!(p.i64_or("offset", 0), -10); + assert_eq!(p.i64_or("missing", -1), -1); + } + + #[test] + fn test_f64_required() { + let v = json!({"temp": 0.7}); + let p = Params::new(&v); + assert!((p.f64("temp").unwrap() - 0.7).abs() < 0.001); + assert!(p.f64("missing").is_err()); + } + + #[test] + fn test_f64_or() { + let v = json!({"temp": 0.9}); + let p = Params::new(&v); + assert!((p.f64_or("temp", 0.5) - 0.9).abs() < 0.001); + assert!((p.f64_or("missing", 0.5) - 0.5).abs() < 0.001); + } + + #[test] + fn test_array_naming() { + let v = json!({"items": [1, 2, 3]}); + let p = Params::new(&v); + // array() is required, array_opt() is optional + assert!(p.array("items").is_ok()); + assert!(p.array("missing").is_err()); + assert!(p.array_opt("items").is_some()); + assert!(p.array_opt("missing").is_none()); + } + + #[test] + fn test_f64_opt() { + let v = json!({"temp": 0.7}); + let p = Params::new(&v); + assert!((p.f64_opt("temp").unwrap() - 0.7).abs() < 0.001); + assert!(p.f64_opt("missing").is_none()); + } + + #[test] + fn test_f32_opt() { + let v = json!({"temp": 0.7}); + let p = Params::new(&v); + assert!((p.f32_opt("temp").unwrap() - 0.7).abs() < 0.01); + assert!(p.f32_opt("missing").is_none()); + } + + #[test] + fn test_json_required() { + let v = json!({"items": ["a", "b", "c"]}); + let p = Params::new(&v); + let items: Vec = p.json("items").unwrap(); + assert_eq!(items, vec!["a", "b", "c"]); + assert!(p.json::>("missing").is_err()); + } + + #[test] + fn test_json_or_default() { + let v = json!({"items": ["a", "b"]}); + let p = Params::new(&v); + let items: Vec = p.json_or("items"); + assert_eq!(items, vec!["a", "b"]); + let empty: Vec = p.json_or("missing"); + assert!(empty.is_empty()); + } + + #[test] + fn test_str_opt_alias() { + let v = json!({"systemPrompt": "hello"}); + let p = Params::new(&v); + assert_eq!(p.str_opt_alias("system_prompt", "systemPrompt"), Some("hello")); + assert_eq!(p.str_opt_alias("system_prompt", "sys_prompt"), None); + } + + #[test] + fn test_f64_opt_alias() { + let v = json!({"topP": 0.9}); + let p = Params::new(&v); + assert!((p.f64_opt_alias("top_p", "topP").unwrap() - 0.9).abs() < 0.001); + assert!(p.f64_opt_alias("top_p", "tp").is_none()); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/voice/orchestrator.rs b/src/debug/jtag/workers/continuum-core/src/voice/orchestrator.rs index c743a7959..de49dc148 100644 --- a/src/debug/jtag/workers/continuum-core/src/voice/orchestrator.rs +++ b/src/debug/jtag/workers/continuum-core/src/voice/orchestrator.rs @@ -27,11 +27,11 @@ impl VoiceOrchestrator { pub fn register_session(&self, session_id: Uuid, room_id: Uuid, participants: Vec) { { - let mut sessions = self.session_participants.lock().unwrap(); + let mut sessions = self.session_participants.lock().unwrap_or_else(|e| e.into_inner()); sessions.insert(session_id, participants.clone()); } { - let mut contexts = self.session_contexts.lock().unwrap(); + let mut contexts = self.session_contexts.lock().unwrap_or_else(|e| e.into_inner()); contexts.insert(session_id, ConversationContext::new(session_id, room_id)); } clog_info!("Registered session {} with {} participants", @@ -39,9 +39,9 @@ impl VoiceOrchestrator { } pub fn unregister_session(&self, session_id: Uuid) { - self.session_participants.lock().unwrap().remove(&session_id); - self.session_contexts.lock().unwrap().remove(&session_id); - self.voice_responders.lock().unwrap().remove(&session_id); + self.session_participants.lock().unwrap_or_else(|e| e.into_inner()).remove(&session_id); + self.session_contexts.lock().unwrap_or_else(|e| e.into_inner()).remove(&session_id); + self.voice_responders.lock().unwrap_or_else(|e| e.into_inner()).remove(&session_id); clog_info!("Unregistered session {}", &session_id.to_string()[..8]); } @@ -52,7 +52,7 @@ impl VoiceOrchestrator { event.speaker_name, crate::voice::tts::truncate_str(&event.transcript, 50)); // Get context - let mut contexts = self.session_contexts.lock().unwrap(); + let mut contexts = self.session_contexts.lock().unwrap_or_else(|e| e.into_inner()); let context = match contexts.get_mut(&event.session_id) { Some(ctx) => ctx, None => { @@ -65,7 +65,7 @@ impl VoiceOrchestrator { context.add_utterance(event.clone()); // Get participants - let participants = self.session_participants.lock().unwrap(); + let participants = self.session_participants.lock().unwrap_or_else(|e| e.into_inner()); let session_participants = match participants.get(&event.session_id) { Some(p) => p, None => { @@ -104,7 +104,7 @@ impl VoiceOrchestrator { } pub fn clear_voice_responder(&self, session_id: Uuid) { - self.voice_responders.lock().unwrap().remove(&session_id); + self.voice_responders.lock().unwrap_or_else(|e| e.into_inner()).remove(&session_id); } } @@ -130,7 +130,7 @@ mod old_tests { orchestrator.register_session(session_id, room_id, vec![participant]); - let participants = orchestrator.session_participants.lock().unwrap(); + let participants = orchestrator.session_participants.lock().unwrap_or_else(|e| e.into_inner()); assert!(participants.contains_key(&session_id)); } diff --git a/src/debug/jtag/workers/continuum-core/src/voice/stt/mod.rs b/src/debug/jtag/workers/continuum-core/src/voice/stt/mod.rs index f758c44e3..de909f947 100644 --- a/src/debug/jtag/workers/continuum-core/src/voice/stt/mod.rs +++ b/src/debug/jtag/workers/continuum-core/src/voice/stt/mod.rs @@ -215,7 +215,8 @@ pub fn init_registry() { pub fn get_registry() -> Arc> { STT_REGISTRY.get().cloned().unwrap_or_else(|| { init_registry(); - STT_REGISTRY.get().cloned().unwrap() + STT_REGISTRY.get().cloned() + .expect("STT_REGISTRY must be set after init_registry()") }) } diff --git a/src/debug/jtag/workers/continuum-core/src/voice/stt/moonshine.rs b/src/debug/jtag/workers/continuum-core/src/voice/stt/moonshine.rs index 2aeb6a8db..bba50c532 100644 --- a/src/debug/jtag/workers/continuum-core/src/voice/stt/moonshine.rs +++ b/src/debug/jtag/workers/continuum-core/src/voice/stt/moonshine.rs @@ -293,7 +293,8 @@ impl MoonshineStt { let mut current_token = BOS_TOKEN_ID; // First decode step (uncached — no KV cache input) - let token_input = Array2::from_shape_vec((1, 1), vec![current_token]).unwrap(); + let token_input = Array2::from_shape_vec((1, 1), vec![current_token]) + .map_err(|e| STTError::InferenceFailed(format!("Token array shape: {e}")))?; let enc_array = Self::cache_to_array(&encoder_hidden)?; let uncached_out = model @@ -325,7 +326,8 @@ impl MoonshineStt { // Subsequent decode steps (cached — with KV cache) for _step in 1..MAX_TOKENS { - let token_input = Array2::from_shape_vec((1, 1), vec![current_token]).unwrap(); + let token_input = Array2::from_shape_vec((1, 1), vec![current_token]) + .map_err(|e| STTError::InferenceFailed(format!("Token array shape: {e}")))?; let enc_array = Self::cache_to_array(&encoder_hidden)?; // Build named inputs: [token, encoder_hidden, kv_0, kv_1, ...] diff --git a/src/debug/jtag/workers/continuum-core/src/voice/tts/mod.rs b/src/debug/jtag/workers/continuum-core/src/voice/tts/mod.rs index eceafe223..9757e4304 100644 --- a/src/debug/jtag/workers/continuum-core/src/voice/tts/mod.rs +++ b/src/debug/jtag/workers/continuum-core/src/voice/tts/mod.rs @@ -291,7 +291,8 @@ pub fn init_registry() { pub fn get_registry() -> Arc> { TTS_REGISTRY.get().cloned().unwrap_or_else(|| { init_registry(); - TTS_REGISTRY.get().cloned().unwrap() + TTS_REGISTRY.get().cloned() + .expect("TTS_REGISTRY must be set after init_registry()") }) } diff --git a/src/debug/jtag/workers/continuum-core/src/voice/vad/metrics.rs b/src/debug/jtag/workers/continuum-core/src/voice/vad/metrics.rs index 16b847615..3bbe053a3 100644 --- a/src/debug/jtag/workers/continuum-core/src/voice/vad/metrics.rs +++ b/src/debug/jtag/workers/continuum-core/src/voice/vad/metrics.rs @@ -235,7 +235,7 @@ impl VADEvaluator { .map(|i| i as f32 / num_points as f32) .collect(); - thresholds.sort_by(|a, b| a.partial_cmp(b).unwrap()); + thresholds.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); thresholds .into_iter() diff --git a/src/debug/jtag/workers/continuum-core/tests/call_server_integration.rs b/src/debug/jtag/workers/continuum-core/tests/call_server_integration.rs index beb80060b..895c57bbc 100644 --- a/src/debug/jtag/workers/continuum-core/tests/call_server_integration.rs +++ b/src/debug/jtag/workers/continuum-core/tests/call_server_integration.rs @@ -221,26 +221,25 @@ async fn test_orchestrator_performance_target() { let min = *durations.iter().min().unwrap(); println!("🔬 Orchestrator Performance (100 iterations, 5 AIs):"); - println!(" Average: {}µs", avg); - println!(" Min: {}µs", min); - println!(" Max: {}µs", max); + println!(" Average: {avg}µs"); + println!(" Min: {min}µs"); + println!(" Max: {max}µs"); // User's target: < 10µs on M1 // NOTE: This may fail on slower machines or under heavy load // The target is aggressive but achievable with optimized Rust if avg > 10 { - println!("⚠️ WARNING: Average latency {}µs exceeds 10µs target", avg); + println!("⚠️ WARNING: Average latency {avg}µs exceeds 10µs target"); println!(" This is acceptable for now, but should be optimized"); } else { - println!("✅ PERFORMANCE TARGET MET: {}µs < 10µs", avg); + println!("✅ PERFORMANCE TARGET MET: {avg}µs < 10µs"); } // Fail if > 100µs — target is <10µs on M1. // Run tests with --release for meaningful results. assert!( avg < 100, - "Orchestrator too slow: {}µs (should be < 10µs, failing at > 100µs)", - avg + "Orchestrator too slow: {avg}µs (should be < 10µs, failing at > 100µs)" ); } diff --git a/src/debug/jtag/workers/continuum-core/tests/common/mod.rs b/src/debug/jtag/workers/continuum-core/tests/common/mod.rs index fc907bc3d..0803bc8ef 100644 --- a/src/debug/jtag/workers/continuum-core/tests/common/mod.rs +++ b/src/debug/jtag/workers/continuum-core/tests/common/mod.rs @@ -96,7 +96,7 @@ pub fn ipc_connect_with_timeout(read_timeout: Duration) -> Option { Some(s) } Err(e) => { - println!("Cannot connect to {}: {}", IPC_SOCKET, e); + println!("Cannot connect to {IPC_SOCKET}: {e}"); println!(" Make sure server is running: npm start"); println!(" Skipping test.\n"); None diff --git a/src/debug/jtag/workers/continuum-core/tests/hold_music_test.rs b/src/debug/jtag/workers/continuum-core/tests/hold_music_test.rs index cbe5c5976..29bdcee62 100644 --- a/src/debug/jtag/workers/continuum-core/tests/hold_music_test.rs +++ b/src/debug/jtag/workers/continuum-core/tests/hold_music_test.rs @@ -37,7 +37,7 @@ async fn test_hold_music_plays_when_alone() { println!("✓ Frame {}: Non-silence audio ({} samples, RMS: {:.1})", frame_count, audio.len(), calculate_rms(&audio)); } else { - println!(" Frame {}: Silence", frame_count); + println!(" Frame {frame_count}: Silence"); } } } @@ -50,8 +50,8 @@ async fn test_hold_music_plays_when_alone() { // STEP 5: Verify hold music played (majority of frames should be non-silence) println!("\n=== RESULTS ==="); - println!("Total frames: {}", frame_count); - println!("Non-silence frames: {}", non_silence_count); + println!("Total frames: {frame_count}"); + println!("Non-silence frames: {non_silence_count}"); println!("Hold music ratio: {:.1}%", (non_silence_count as f64 / frame_count as f64) * 100.0); // Assert that hold music was playing (at least 50% of frames should be non-silence) diff --git a/src/debug/jtag/workers/continuum-core/tests/tts_only_test.rs b/src/debug/jtag/workers/continuum-core/tests/tts_only_test.rs index 15120c3f5..04835ce3e 100644 --- a/src/debug/jtag/workers/continuum-core/tests/tts_only_test.rs +++ b/src/debug/jtag/workers/continuum-core/tests/tts_only_test.rs @@ -35,7 +35,7 @@ fn test_tts_synthesize_via_ipc() { let result = match ipc_request(&mut stream, &request) { Ok(r) => r, Err(e) => { - println!("IPC error: {}", e); + println!("IPC error: {e}"); return; } }; @@ -63,7 +63,7 @@ fn test_tts_synthesize_via_ipc() { let num_samples = meta["num_samples"].as_u64().unwrap_or(0); let duration_ms = meta["duration_ms"].as_u64().unwrap_or(0); - println!("Sample rate: {}Hz", sample_rate); + println!("Sample rate: {sample_rate}Hz"); println!("Samples: {} (header), {} (from PCM bytes)", num_samples, pcm_bytes.len() / 2); println!("Duration: {}ms ({:.2}s)", duration_ms, duration_ms as f64 / 1000.0); println!("PCM bytes: {}", pcm_bytes.len()); @@ -81,8 +81,8 @@ fn test_tts_synthesize_via_ipc() { println!("\n--- Audio Analysis ---"); println!("Non-zero samples: {} / {} ({:.1}%)", non_zero, samples.len(), non_zero as f64 / samples.len().max(1) as f64 * 100.0); - println!("Max amplitude: {} (max: 32767)", max_amplitude); - println!("RMS: {:.1}", rms); + println!("Max amplitude: {max_amplitude} (max: 32767)"); + println!("RMS: {rms:.1}"); // Verify sample rate is 16kHz assert_eq!(sample_rate, 16000, "Sample rate must be 16kHz"); @@ -131,7 +131,7 @@ fn test_tts_audio_quality() { let result = match ipc_request(&mut stream, &request) { Ok(r) => r, Err(e) => { - println!("\"{}\" - IPC error: {}", phrase, e); + println!("\"{phrase}\" - IPC error: {e}"); continue; } }; @@ -161,11 +161,10 @@ fn test_tts_audio_quality() { let non_zero_pct = samples.iter().filter(|&&s| s.abs() > 10).count() as f64 / samples.len().max(1) as f64 * 100.0; let max_amp = samples.iter().map(|&s| s.abs()).max().unwrap_or(0); - println!("\"{}\"", phrase); - println!(" Rate: {}Hz, Duration: {}ms, Non-silence: {:.1}%, Max: {}", - sample_rate, duration_ms, non_zero_pct, max_amp); + println!("\"{phrase}\""); + println!(" Rate: {sample_rate}Hz, Duration: {duration_ms}ms, Non-silence: {non_zero_pct:.1}%, Max: {max_amp}"); - assert_eq!(sample_rate, 16000, "Sample rate must be 16kHz for \"{}\"", phrase); + assert_eq!(sample_rate, 16000, "Sample rate must be 16kHz for \"{phrase}\""); } println!("\nAudio quality test PASSED"); diff --git a/src/debug/jtag/workers/continuum-core/tests/tts_stt_roundtrip.rs b/src/debug/jtag/workers/continuum-core/tests/tts_stt_roundtrip.rs index 29ccfc677..6571d58e1 100644 --- a/src/debug/jtag/workers/continuum-core/tests/tts_stt_roundtrip.rs +++ b/src/debug/jtag/workers/continuum-core/tests/tts_stt_roundtrip.rs @@ -115,7 +115,7 @@ fn test_tts_stt_roundtrip_via_ipc() { let mut failed = 0; for phrase in TEST_PHRASES { - println!("Testing: \"{}\"", phrase); + println!("Testing: \"{phrase}\""); // Fresh connection per phrase let mut stream = match ipc_connect() { @@ -133,7 +133,7 @@ fn test_tts_stt_roundtrip_via_ipc() { let synth_result = match ipc_request(&mut stream, &synth_request) { Ok(r) => r, Err(e) => { - println!("IPC error: {}", e); + println!("IPC error: {e}"); failed += 1; continue; } @@ -164,10 +164,10 @@ fn test_tts_stt_roundtrip_via_ipc() { let num_samples = result["num_samples"].as_u64().unwrap_or(0); let duration_ms = result["duration_ms"].as_u64().unwrap_or(0); - println!("{} samples at {}Hz ({}ms)", num_samples, sample_rate, duration_ms); + println!("{num_samples} samples at {sample_rate}Hz ({duration_ms}ms)"); if sample_rate != 16000 { - println!(" WARNING: Sample rate is {}Hz, expected 16000Hz", sample_rate); + println!(" WARNING: Sample rate is {sample_rate}Hz, expected 16000Hz"); } // Step 2: Transcribe via IPC — encode raw PCM as base64 for STT input @@ -188,7 +188,7 @@ fn test_tts_stt_roundtrip_via_ipc() { let transcribe_result = match ipc_request(&mut stream, &transcribe_request) { Ok(r) => r, Err(e) => { - println!("IPC error: {}", e); + println!("IPC error: {e}"); failed += 1; continue; } @@ -206,7 +206,7 @@ fn test_tts_stt_roundtrip_via_ipc() { let transcription = result["text"].as_str().unwrap_or(""); let confidence = result["confidence"].as_f64().unwrap_or(0.0); - println!("\"{}\" (confidence: {:.2})", transcription, confidence); + println!("\"{transcription}\" (confidence: {confidence:.2})"); // Step 3: Compare let similarity = word_similarity(phrase, transcription); @@ -217,8 +217,8 @@ fn test_tts_stt_roundtrip_via_ipc() { passed += 1; } else { println!(" FAILED - transcription mismatch"); - println!(" Expected: \"{}\"", phrase); - println!(" Got: \"{}\"\n", transcription); + println!(" Expected: \"{phrase}\""); + println!(" Got: \"{transcription}\"\n"); failed += 1; } } @@ -261,9 +261,9 @@ fn test_tts_sample_rate_via_ipc() { "PCM byte count should be 2 * num_samples" ); - println!("Sample rate: {}Hz", sample_rate); - println!("Samples: {}", num_samples); - println!("Duration: {}ms", duration_ms); + println!("Sample rate: {sample_rate}Hz"); + println!("Samples: {num_samples}"); + println!("Duration: {duration_ms}ms"); println!("PCM bytes: {}", pcm_bytes.len()); // Verify sample rate is 16kHz @@ -273,9 +273,7 @@ fn test_tts_sample_rate_via_ipc() { let expected_duration = (num_samples * 1000) / 16000; assert!( (duration_ms as i64 - expected_duration as i64).abs() < 100, - "Duration {}ms doesn't match sample count (expected ~{}ms)", - duration_ms, - expected_duration + "Duration {duration_ms}ms doesn't match sample count (expected ~{expected_duration}ms)" ); // Verify PCM data is not silence @@ -284,9 +282,9 @@ fn test_tts_sample_rate_via_ipc() { .map(|c| i16::from_le_bytes([c[0], c[1]])) .collect(); let max_amp = samples.iter().map(|s| s.abs()).max().unwrap_or(0); - assert!(max_amp > 100, "Audio should not be silence, max amplitude: {}", max_amp); + assert!(max_amp > 100, "Audio should not be silence, max amplitude: {max_amp}"); - println!("Max amplitude: {}", max_amp); + println!("Max amplitude: {max_amp}"); println!("Sample rate test PASSED"); } @@ -319,7 +317,7 @@ fn test_stt_whisper_via_ipc() { let result = match ipc_request(&mut stream, &request) { Ok(r) => r, Err(e) => { - println!("IPC error: {}", e); + println!("IPC error: {e}"); println!(" This may indicate the STT model is not loaded."); return; } diff --git a/src/debug/jtag/workers/continuum-core/tests/tts_timing_benchmark.rs b/src/debug/jtag/workers/continuum-core/tests/tts_timing_benchmark.rs index 10466eab0..601151d4c 100644 --- a/src/debug/jtag/workers/continuum-core/tests/tts_timing_benchmark.rs +++ b/src/debug/jtag/workers/continuum-core/tests/tts_timing_benchmark.rs @@ -158,8 +158,7 @@ fn benchmark_tts_timing() { real_time_factor, }; - println!(" Avg: {}ms | Min: {}ms | Max: {}ms | Audio: {}ms | RTF: {:.2}x", - avg_ms, min_ms, max_ms, audio_duration_ms, real_time_factor); + println!(" Avg: {avg_ms}ms | Min: {min_ms}ms | Max: {max_ms}ms | Audio: {audio_duration_ms}ms | RTF: {real_time_factor:.2}x"); results.push(result); } @@ -248,11 +247,10 @@ fn benchmark_tts_scaling() { let synth_ms = elapsed.as_millis(); let ms_per_char = synth_ms as f64 / chars as f64; - println!("{:<10} {:<8} {:<12} {:<12} {:<10.2}", - reps, chars, synth_ms, audio_ms, ms_per_char); + println!("{reps:<10} {chars:<8} {synth_ms:<12} {audio_ms:<12} {ms_per_char:<10.2}"); } Err(e) => { - println!("{:<10} {:<8} FAILED: {}", reps, chars, e); + println!("{reps:<10} {chars:<8} FAILED: {e}"); } }