diff --git a/services/mcp-server/src/lib/jarvis-client.ts b/services/mcp-server/src/lib/jarvis-client.ts index 159d890f..9e15b526 100644 --- a/services/mcp-server/src/lib/jarvis-client.ts +++ b/services/mcp-server/src/lib/jarvis-client.ts @@ -248,3 +248,40 @@ export function jarvisMemoryGetContext( const qs = limit !== undefined ? `?limit=${limit}` : ''; return jarvisFetch(`/jarvis/agents/${agentId}/memory/context${qs}`, { method: 'GET' }, opts); } + +export function jarvisMemoryCreate( + agentId: string, + input: { + sessionId: string; + type: 'skill_note' | 'preference' | 'goal' | 'context' | 'exercise'; + content: string; + importance?: number; + tags?: string[]; + expiresAt?: string | null; + }, + opts: JarvisClientOptions +): Promise { + return jarvisFetch( + `/jarvis/agents/${agentId}/memory`, + { method: 'POST', body: JSON.stringify({ ...input, agentId }) }, + opts + ); +} + +// ── Teams ───────────────────────────────────────────────────────────────── + +export interface JarvisTeamMemberDoc { + userId: string; + teamId: string; + role: 'owner' | 'manager' | 'member'; + status: 'active' | 'invited' | 'removed'; + joinedAt: string; + invitedBy?: string; +} + +export function jarvisTeamsListMembers( + teamId: string, + opts: JarvisClientOptions +): Promise<{ members: JarvisTeamMemberDoc[]; total: number }> { + return jarvisFetch(`/jarvis/teams/${teamId}/members`, { method: 'GET' }, opts); +} diff --git a/services/mcp-server/src/modules/a2a/route-safety-pipeline.ts b/services/mcp-server/src/modules/a2a/route-safety-pipeline.ts new file mode 100644 index 00000000..61c659f9 --- /dev/null +++ b/services/mcp-server/src/modules/a2a/route-safety-pipeline.ts @@ -0,0 +1,406 @@ +/** + * RouteSafetyAssessmentAgent — A2A pipeline for PeakPulse session safety assessment. + * + * Agent roster (4 steps): + * 1. SessionDataAgent — fetch session document (GPS track, weather, metrics) + * 2. RouteProfileAgent — fetch GPS track points + haptic events, build route summary + * 3. SafetyAnalysisAgent — call extraction service with route + weather context + * 4. SafetyReportAgent — assemble structured safety brief with risk level + recommendations + * + * MCP tools: + * peakpulse.sessions.assessSafety(sessionId) — run pipeline + */ + +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import type { McpToolRequest } from '../tools/types.js'; +import { peakpulseSessionGet, peakpulseRouteGet } from '../../lib/peakpulse-client.js'; +import { extractionRun, type ExtractionItem } from '../../lib/extraction-client.js'; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type RiskLevel = 'low' | 'moderate' | 'high' | 'critical'; +type ActivityType = 'hiking' | 'skiing' | 'unknown'; + +interface SessionSnapshot { + sessionId: string; + activityType: ActivityType; + locationName: string | null; + durationMs: number | null; + distanceM: number | null; + elevationGainM: number | null; + maxSpeedKmh: number | null; + weather: { + tempC: number | null; + windSpeedKmh: number | null; + uvIndex: number | null; + condition: string | null; + } | null; +} + +interface RouteProfile { + trackPointCount: number; + elevationProfileSummary: string; + maxElevationM: number | null; + minElevationM: number | null; + totalElevationGainM: number; + steepestGradePercent: number | null; + hapticEventCount: number; +} + +interface SafetyAnalysis { + riskLevel: RiskLevel; + riskFactors: string[]; + extractedEntities: ExtractionItem[]; + extractionSkipped: boolean; +} + +export interface RouteSafetyReport { + runId: string; + productId: 'peakpulse'; + sessionId: string; + activityType: ActivityType; + locationName: string | null; + riskLevel: RiskLevel; + riskFactors: string[]; + weatherSummary: string; + routeSummary: string; + recommendations: string[]; + extractedEntities: ExtractionItem[]; + generatedAt: string; +} + +// ── Step 1: SessionDataAgent ─────────────────────────────────────────────────── + +async function fetchSessionData( + sessionId: string, + opts: { token?: string; requestId?: string } +): Promise { + const session = await peakpulseSessionGet(sessionId, opts); + const s = session as unknown as Record; + + const metrics = (s['metrics'] as Record) ?? {}; + const weather = s['weather'] as Record | null | undefined; + + return { + sessionId, + activityType: (s['activityType'] as ActivityType) ?? 'unknown', + locationName: (s['locationName'] as string) ?? null, + durationMs: (metrics['durationMs'] as number) ?? null, + distanceM: (metrics['distanceM'] as number) ?? null, + elevationGainM: (metrics['elevationGainM'] as number) ?? null, + maxSpeedKmh: (metrics['maxSpeedKmh'] as number) ?? null, + weather: weather + ? { + tempC: (weather['tempC'] as number) ?? null, + windSpeedKmh: (weather['windSpeedKmh'] as number) ?? null, + uvIndex: (weather['uvIndex'] as number) ?? null, + condition: (weather['condition'] as string) ?? null, + } + : null, + }; +} + +// ── Step 2: RouteProfileAgent ───────────────────────────────────────────────── + +async function buildRouteProfile( + sessionId: string, + opts: { token?: string; requestId?: string } +): Promise { + try { + const route = await peakpulseRouteGet(sessionId, opts); + const r = route as unknown as Record; + const trackPoints = (r['trackPoints'] as Array>) ?? []; + const hapticEvents = (r['hapticEvents'] as unknown[]) ?? []; + + const altitudes = trackPoints + .map(p => p['altitude'] as number) + .filter((a): a is number => typeof a === 'number'); + + const maxElevationM = altitudes.length > 0 ? Math.max(...altitudes) : null; + const minElevationM = altitudes.length > 0 ? Math.min(...altitudes) : null; + + let totalGain = 0; + for (let i = 1; i < altitudes.length; i++) { + const diff = altitudes[i]! - altitudes[i - 1]!; + if (diff > 0) totalGain += diff; + } + + let steepestGradePercent: number | null = null; + for (let i = 1; i < trackPoints.length; i++) { + const altDiff = Math.abs( + ((trackPoints[i]!['altitude'] as number) ?? 0) - + ((trackPoints[i - 1]!['altitude'] as number) ?? 0) + ); + const distM = (trackPoints[i]!['distanceFromPrevM'] as number) ?? 10; + if (distM > 0) { + const grade = (altDiff / distM) * 100; + if (steepestGradePercent === null || grade > steepestGradePercent) { + steepestGradePercent = Math.round(grade * 10) / 10; + } + } + } + + const elevationRange = + maxElevationM !== null && minElevationM !== null + ? `${minElevationM.toFixed(0)}m–${maxElevationM.toFixed(0)}m` + : 'unknown'; + + return { + trackPointCount: trackPoints.length, + elevationProfileSummary: `Elevation range: ${elevationRange}, total gain: ${totalGain.toFixed(0)}m`, + maxElevationM, + minElevationM, + totalElevationGainM: Math.round(totalGain), + steepestGradePercent, + hapticEventCount: hapticEvents.length, + }; + } catch { + return { + trackPointCount: 0, + elevationProfileSummary: 'Route data unavailable', + maxElevationM: null, + minElevationM: null, + totalElevationGainM: 0, + steepestGradePercent: null, + hapticEventCount: 0, + }; + } +} + +// ── Step 3: SafetyAnalysisAgent ─────────────────────────────────────────────── + +async function analyseSafety( + snapshot: SessionSnapshot, + profile: RouteProfile, + opts: { token?: string; requestId?: string } +): Promise { + const riskFactors: string[] = []; + + // Rule-based risk scoring + if (snapshot.weather?.uvIndex !== null && (snapshot.weather?.uvIndex ?? 0) >= 8) { + riskFactors.push(`Very high UV index (${snapshot.weather!.uvIndex}) — sun protection critical`); + } + if (snapshot.weather?.windSpeedKmh !== null && (snapshot.weather?.windSpeedKmh ?? 0) >= 50) { + riskFactors.push( + `High wind speed (${snapshot.weather!.windSpeedKmh} km/h) — avalanche and fall risk` + ); + } + if (snapshot.weather?.tempC !== null && (snapshot.weather?.tempC ?? 20) <= -10) { + riskFactors.push(`Extreme cold (${snapshot.weather!.tempC}°C) — hypothermia risk`); + } + if (profile.maxElevationM !== null && profile.maxElevationM >= 3000) { + riskFactors.push( + `High altitude (${profile.maxElevationM.toFixed(0)}m) — altitude sickness risk above 3000m` + ); + } + if (profile.totalElevationGainM >= 1200) { + riskFactors.push( + `Significant elevation gain (${profile.totalElevationGainM}m) — fatigue and overexertion risk` + ); + } + if (profile.steepestGradePercent !== null && profile.steepestGradePercent >= 40) { + riskFactors.push( + `Steep terrain detected (${profile.steepestGradePercent}% grade) — slip/fall risk` + ); + } + if (snapshot.activityType === 'skiing' && (snapshot.maxSpeedKmh ?? 0) >= 80) { + riskFactors.push( + `Very high ski speed recorded (${snapshot.maxSpeedKmh} km/h) — collision risk` + ); + } + + const riskLevel: RiskLevel = (() => { + if (riskFactors.length === 0) return 'low'; + if (riskFactors.length === 1) return 'moderate'; + if (riskFactors.length <= 3) return 'high'; + return 'critical'; + })(); + + // Best-effort extraction for entity enrichment + let extractedEntities: ExtractionItem[] = []; + let extractionSkipped = false; + + if (riskLevel !== 'low' && profile.elevationProfileSummary !== 'Route data unavailable') { + const context = [ + `Activity: ${snapshot.activityType}`, + `Location: ${snapshot.locationName ?? 'unknown'}`, + profile.elevationProfileSummary, + snapshot.weather + ? `Weather: ${snapshot.weather.condition ?? 'unknown'}, ${snapshot.weather.tempC ?? 'N/A'}°C, wind ${snapshot.weather.windSpeedKmh ?? 'N/A'} km/h, UV ${snapshot.weather.uvIndex ?? 'N/A'}` + : 'Weather data unavailable', + `Risk factors: ${riskFactors.join('; ')}`, + ].join('. '); + + try { + const result = await extractionRun({ text: context, taskId: 'triage' }, opts); + extractedEntities = result.extractions ?? []; + } catch { + extractionSkipped = true; + } + } else { + extractionSkipped = true; + } + + return { riskLevel, riskFactors, extractedEntities, extractionSkipped }; +} + +// ── Step 4: SafetyReportAgent ───────────────────────────────────────────────── + +function buildSafetyReport( + runId: string, + snapshot: SessionSnapshot, + profile: RouteProfile, + analysis: SafetyAnalysis +): RouteSafetyReport { + const now = new Date().toISOString(); + + const weatherSummary = snapshot.weather + ? [ + snapshot.weather.condition ?? 'unknown conditions', + snapshot.weather.tempC !== null ? `${snapshot.weather.tempC}°C` : null, + snapshot.weather.windSpeedKmh !== null + ? `wind ${snapshot.weather.windSpeedKmh} km/h` + : null, + snapshot.weather.uvIndex !== null ? `UV ${snapshot.weather.uvIndex}` : null, + ] + .filter(Boolean) + .join(', ') + : 'No weather data captured'; + + const routeSummary = [ + profile.trackPointCount > 0 ? `${profile.trackPointCount} GPS points` : null, + profile.maxElevationM !== null ? `max elevation ${profile.maxElevationM.toFixed(0)}m` : null, + `${profile.totalElevationGainM}m gain`, + profile.steepestGradePercent !== null + ? `steepest grade ${profile.steepestGradePercent}%` + : null, + ] + .filter(Boolean) + .join(', '); + + const recommendations: string[] = []; + if (analysis.riskLevel === 'low') { + recommendations.push('Session appears safe. No action required.'); + } else { + if (analysis.riskFactors.some(f => f.includes('UV'))) { + recommendations.push('Apply SPF 50+ sunscreen; wear UV-blocking eyewear on future sessions.'); + } + if (analysis.riskFactors.some(f => f.includes('wind') || f.includes('avalanche'))) { + recommendations.push( + 'Check avalanche forecast before high-wind sessions. Carry beacon + probe + shovel.' + ); + } + if (analysis.riskFactors.some(f => f.includes('altitude'))) { + recommendations.push( + 'Allow 24–48h acclimatisation above 3000m. Descend if headache or nausea develops.' + ); + } + if (analysis.riskFactors.some(f => f.includes('elevation gain'))) { + recommendations.push( + 'Consider splitting high-gain routes across multiple days. Monitor heart rate.' + ); + } + if (analysis.riskFactors.some(f => f.includes('steep terrain'))) { + recommendations.push( + 'Use trekking poles on steep descents. Consider crampons on icy gradients.' + ); + } + if (analysis.riskFactors.some(f => f.includes('ski speed'))) { + recommendations.push( + 'Review speed zones and terrain for skill level. Consider guided assessment.' + ); + } + if (analysis.riskFactors.some(f => f.includes('cold') || f.includes('hypothermia'))) { + recommendations.push( + 'Layer appropriately; carry emergency bivouac gear. Do not underestimate wind chill.' + ); + } + } + + return { + runId, + productId: 'peakpulse', + sessionId: snapshot.sessionId, + activityType: snapshot.activityType, + locationName: snapshot.locationName, + riskLevel: analysis.riskLevel, + riskFactors: analysis.riskFactors, + weatherSummary, + routeSummary, + recommendations, + extractedEntities: analysis.extractedEntities, + generatedAt: now, + }; +} + +// ── Pipeline runner ──────────────────────────────────────────────────────────── + +async function runRouteSafetyPipeline( + sessionId: string, + req: McpToolRequest +): Promise { + const runId = randomUUID(); + const opts = { + token: req.headers.authorization?.slice(7), + requestId: req.id, + }; + + req.log.info({ runId, stepId: 'session', sessionId }, 'SessionDataAgent start'); + const snapshot = await fetchSessionData(sessionId, opts); + req.log.info( + { + runId, + stepId: 'session', + activityType: snapshot.activityType, + hasWeather: !!snapshot.weather, + }, + 'SessionDataAgent done' + ); + + req.log.info({ runId, stepId: 'route', sessionId }, 'RouteProfileAgent start'); + const profile = await buildRouteProfile(sessionId, opts); + req.log.info( + { + runId, + stepId: 'route', + trackPoints: profile.trackPointCount, + elevGain: profile.totalElevationGainM, + }, + 'RouteProfileAgent done' + ); + + req.log.info({ runId, stepId: 'safety' }, 'SafetyAnalysisAgent start'); + const analysis = await analyseSafety(snapshot, profile, opts); + req.log.info( + { + runId, + stepId: 'safety', + riskLevel: analysis.riskLevel, + factors: analysis.riskFactors.length, + }, + 'SafetyAnalysisAgent done' + ); + + req.log.info({ runId, stepId: 'report' }, 'SafetyReportAgent start'); + const report = buildSafetyReport(runId, snapshot, profile, analysis); + req.log.info({ runId, stepId: 'report', riskLevel: report.riskLevel }, 'SafetyReportAgent done'); + + return report; +} + +// ── MCP tool registration ───────────────────────────────────────────────────── + +registerTool({ + name: 'peakpulse.sessions.assessSafety', + description: + 'A2A pipeline: fetches GPS track + weather snapshot for a completed PeakPulse session, evaluates elevation, temperature, UV, wind, and speed risk factors, optionally enriches with extraction entities, and returns a structured safety brief with risk level and recommendations. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + sessionId: z.string().min(1).describe('Completed session ID to assess'), + }), + async execute(args, req) { + return runRouteSafetyPipeline(args.sessionId, req); + }, +}); diff --git a/services/mcp-server/src/modules/a2a/sync-conflict-pipeline.ts b/services/mcp-server/src/modules/a2a/sync-conflict-pipeline.ts new file mode 100644 index 00000000..3a0e7e1c --- /dev/null +++ b/services/mcp-server/src/modules/a2a/sync-conflict-pipeline.ts @@ -0,0 +1,307 @@ +/** + * SyncConflictDiagnosticsAgent — A2A pipeline for ChronoMind sync conflict diagnostics. + * + * Agent roster (4 steps): + * 1. ConflictDetectorAgent — query telemetry for sync_conflict events for a user + * 2. SyncStateInspectorAgent — pull current sync status (queue depth, unsynced count) + * 3. DiagnosticsSessionAgent — create platform diagnostics session when conflicts found + * 4. ConflictReportAgent — assemble report with root cause analysis + remediation + * + * MCP tools: + * chronomind.sync.diagnoseConflicts(userId, deviceId?, from?, to?) — run pipeline + */ + +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import type { McpToolRequest } from '../tools/types.js'; +import { + telemetryQuery, + diagnosticsCreateSession, + diagnosticsGetLogs, + diagnosticsUpdateSession, + type DebugSession, +} from '../../lib/platform-client.js'; +import { chronomindSyncStatus } from '../../lib/chronomind-client.js'; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type ConflictPattern = 'version_clash' | 'concurrent_edit' | 'stale_device' | 'unknown'; + +interface ConflictDetection { + userId: string; + deviceId: string | null; + conflictCount: number; + recentConflicts: unknown[]; + conflictPattern: ConflictPattern; + fromTime: string; + toTime: string; +} + +interface SyncStateResult { + unsyncedCount: number; + pendingCount: number; + lastSyncAt: string | null; + activeTimers: number; +} + +interface DiagnosticsCapture { + skipped: boolean; + skipReason?: string; + session?: DebugSession; + logEntries: unknown[]; +} + +export interface SyncConflictReport { + runId: string; + productId: 'chronomind'; + userId: string; + deviceId: string | null; + conflictCount: number; + conflictPattern: ConflictPattern; + unsyncedCount: number; + pendingCount: number; + diagnosticsSessionId: string | null; + logEntries: unknown[]; + rootCauseSummary: string; + recommendedAction: string; + generatedAt: string; +} + +// ── Step 1: ConflictDetectorAgent ───────────────────────────────────────────── + +async function detectConflicts( + userId: string, + deviceId: string | null, + fromTime: string, + toTime: string, + opts: { token?: string; requestId?: string } +): Promise { + let conflictCount = 0; + let recentConflicts: unknown[] = []; + + try { + const result = await telemetryQuery( + { + productId: 'chronomind', + eventType: 'sync_conflict', + from: fromTime, + to: toTime, + limit: 20, + }, + { ...opts, productId: 'chronomind' } + ); + conflictCount = result.total; + recentConflicts = result.events; + } catch { + // best-effort + } + + let conflictPattern: ConflictPattern = 'unknown'; + if (conflictCount > 0) { + const events = recentConflicts as Array>; + const hasVersionClash = events.some(e => String(e['errorCode'] ?? '').includes('version')); + const hasConcurrentEdit = events.some(e => String(e['errorCode'] ?? '').includes('concurrent')); + const hasStaleDevice = events.some(e => String(e['errorCode'] ?? '').includes('stale')); + + if (hasVersionClash) conflictPattern = 'version_clash'; + else if (hasConcurrentEdit) conflictPattern = 'concurrent_edit'; + else if (hasStaleDevice) conflictPattern = 'stale_device'; + } + + return { + userId, + deviceId, + conflictCount, + recentConflicts, + conflictPattern, + fromTime, + toTime, + }; +} + +// ── Step 2: SyncStateInspectorAgent ─────────────────────────────────────────── + +async function inspectSyncState(opts: { + token?: string; + requestId?: string; +}): Promise { + try { + const status = await chronomindSyncStatus(opts); + return { + unsyncedCount: status.unsyncedCount ?? 0, + pendingCount: status.pending ?? 0, + lastSyncAt: status.lastSyncedAt ?? null, + activeTimers: status.active ?? 0, + }; + } catch { + return { unsyncedCount: 0, pendingCount: 0, lastSyncAt: null, activeTimers: 0 }; + } +} + +// ── Step 3: DiagnosticsSessionAgent ─────────────────────────────────────────── + +async function captureDiagnostics( + runId: string, + userId: string, + conflictDetection: ConflictDetection, + opts: { token?: string; requestId?: string } +): Promise { + if (conflictDetection.conflictCount === 0) { + return { skipped: true, skipReason: 'no_conflicts_detected', logEntries: [] }; + } + + try { + const session = await diagnosticsCreateSession( + { + productId: 'chronomind', + targetUserId: userId, + maxDurationMinutes: 30, + }, + opts + ); + + const logs = await diagnosticsGetLogs(session.id, { limit: 50 }, opts).catch(() => ({ + logs: [], + })); + + await diagnosticsUpdateSession(session.id, { status: 'completed' }, opts).catch(() => null); + + return { skipped: false, session, logEntries: (logs as { logs: unknown[] }).logs }; + } catch { + return { skipped: true, skipReason: 'diagnostics_session_failed', logEntries: [] }; + } +} + +// ── Step 4: ConflictReportAgent ─────────────────────────────────────────────── + +function buildConflictReport( + runId: string, + detection: ConflictDetection, + syncState: SyncStateResult, + diagnostics: DiagnosticsCapture +): SyncConflictReport { + const now = new Date().toISOString(); + + const rootCauseSummary = (() => { + if (detection.conflictCount === 0) + return 'No sync conflicts detected in the specified time window.'; + switch (detection.conflictPattern) { + case 'version_clash': + return `${detection.conflictCount} version clash conflict(s) detected. Timer sync versions diverged — likely caused by the same timer being edited on two devices while offline.`; + case 'concurrent_edit': + return `${detection.conflictCount} concurrent edit conflict(s) detected. Multiple devices modified the same timer simultaneously.`; + case 'stale_device': + return `${detection.conflictCount} stale device conflict(s) detected. A device is syncing with an outdated timer version — device likely offline for an extended period.`; + default: + return `${detection.conflictCount} sync conflict(s) detected with unclassified pattern. Check diagnostics logs for detailed error codes.`; + } + })(); + + const recommendedAction = (() => { + if (detection.conflictCount === 0) return 'No action required.'; + if (syncState.unsyncedCount > 10) + return 'Force full re-sync from the primary device. Clear the offline queue on secondary devices before next sync.'; + if (detection.conflictPattern === 'version_clash') + return 'Trigger a full timer re-sync (POST /timers/sync with force=true) from the device with the latest edits.'; + if (detection.conflictPattern === 'stale_device') + return "Clear the stale device's local cache and trigger a fresh pull from the server."; + return 'Review diagnostics logs for detailed conflict context, then force re-sync.'; + })(); + + return { + runId, + productId: 'chronomind', + userId: detection.userId, + deviceId: detection.deviceId, + conflictCount: detection.conflictCount, + conflictPattern: detection.conflictPattern, + unsyncedCount: syncState.unsyncedCount, + pendingCount: syncState.pendingCount, + diagnosticsSessionId: diagnostics.session?.id ?? null, + logEntries: diagnostics.logEntries, + rootCauseSummary, + recommendedAction, + generatedAt: now, + }; +} + +// ── Pipeline runner ──────────────────────────────────────────────────────────── + +async function runSyncConflictPipeline( + userId: string, + deviceId: string | null, + fromTime: string, + toTime: string, + req: McpToolRequest +): Promise { + const runId = randomUUID(); + const opts = { + token: req.headers.authorization?.slice(7), + requestId: req.id, + }; + + req.log.info({ runId, stepId: 'detect', userId, deviceId }, 'ConflictDetectorAgent start'); + const detection = await detectConflicts(userId, deviceId, fromTime, toTime, opts); + req.log.info( + { + runId, + stepId: 'detect', + conflictCount: detection.conflictCount, + pattern: detection.conflictPattern, + }, + 'ConflictDetectorAgent done' + ); + + req.log.info({ runId, stepId: 'syncState' }, 'SyncStateInspectorAgent start'); + const syncState = await inspectSyncState(opts); + req.log.info( + { runId, stepId: 'syncState', unsyncedCount: syncState.unsyncedCount }, + 'SyncStateInspectorAgent done' + ); + + req.log.info( + { runId, stepId: 'diagnostics', conflictCount: detection.conflictCount }, + 'DiagnosticsSessionAgent start' + ); + const diagnostics = await captureDiagnostics(runId, userId, detection, opts); + req.log.info( + { + runId, + stepId: 'diagnostics', + skipped: diagnostics.skipped, + sessionId: diagnostics.session?.id, + }, + 'DiagnosticsSessionAgent done' + ); + + req.log.info({ runId, stepId: 'report' }, 'ConflictReportAgent start'); + const report = buildConflictReport(runId, detection, syncState, diagnostics); + req.log.info( + { runId, stepId: 'report', conflictCount: report.conflictCount }, + 'ConflictReportAgent done' + ); + + return report; +} + +// ── MCP tool registration ───────────────────────────────────────────────────── + +registerTool({ + name: 'chronomind.sync.diagnoseConflicts', + description: + 'A2A pipeline: queries ChronoMind telemetry for sync_conflict events, inspects current sync queue state, creates a diagnostics session for affected users, and returns a root-cause conflict report with remediation steps. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + userId: z.string().min(1).describe('User to run conflict diagnostics for'), + deviceId: z.string().optional().describe('Optional: narrow to a specific device ID'), + from: z.string().datetime().optional().describe('Start of conflict window (default: 24h ago)'), + to: z.string().datetime().optional().describe('End of conflict window (default: now)'), + }), + async execute(args, req) { + const now = new Date(); + const toTime = args.to ?? now.toISOString(); + const fromTime = args.from ?? new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + return runSyncConflictPipeline(args.userId, args.deviceId ?? null, fromTime, toTime, req); + }, +}); diff --git a/services/mcp-server/src/modules/a2a/transcript-extraction-pipeline.ts b/services/mcp-server/src/modules/a2a/transcript-extraction-pipeline.ts new file mode 100644 index 00000000..ffb74432 --- /dev/null +++ b/services/mcp-server/src/modules/a2a/transcript-extraction-pipeline.ts @@ -0,0 +1,204 @@ +/** + * TranscriptExtractionPipelineAgent — A2A pipeline for LysnrAI transcript enrichment. + * + * Agent roster (3 steps): + * 1. TranscriptCollectorAgent — list transcripts, filter where extractedAt is null + * 2. ExtractionBatchAgent — run extraction on each unprocessed transcript (serial, best-effort) + * 3. ExtractionReportAgent — assemble report with counts, errors, sample entities + * + * MCP tools: + * lysnrai.transcripts.runExtractionPipeline(limit?, dryRun?) — run pipeline + */ + +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import type { McpToolRequest } from '../tools/types.js'; +import { + lysnraiTranscriptsList, + lysnraiTranscriptRunExtraction, + type TranscriptDoc, +} from '../../lib/lysnrai-client.js'; +import { config } from '../../lib/config.js'; + +// ── Types ────────────────────────────────────────────────────────────────────── + +interface CollectionResult { + totalFetched: number; + unextractedIds: string[]; + sampleTranscripts: TranscriptDoc[]; +} + +interface BatchResult { + processed: number; + succeeded: string[]; + failed: Array<{ id: string; error: string }>; + skipped: boolean; +} + +export interface TranscriptExtractionReport { + runId: string; + productId: 'lysnrai'; + dryRun: boolean; + totalFetched: number; + unextractedCount: number; + processed: number; + succeeded: number; + failed: number; + failedIds: string[]; + sampleExtractedIds: string[]; + summary: string; + generatedAt: string; +} + +// ── Step 1: TranscriptCollectorAgent ────────────────────────────────────────── + +async function collectUnextracted( + limit: number, + opts: { token?: string; requestId?: string } +): Promise { + const result = await lysnraiTranscriptsList({ limit }, opts); + const transcripts = result.transcripts; + + const unextracted = transcripts.filter(t => !t.extractedAt); + + return { + totalFetched: transcripts.length, + unextractedIds: unextracted.map(t => t.id), + sampleTranscripts: unextracted.slice(0, 5), + }; +} + +// ── Step 2: ExtractionBatchAgent ────────────────────────────────────────────── + +async function runExtractionBatch( + transcriptIds: string[], + dryRun: boolean, + opts: { token?: string; requestId?: string } +): Promise { + if (dryRun || transcriptIds.length === 0) { + return { + processed: 0, + succeeded: [], + failed: [], + skipped: dryRun, + }; + } + + const succeeded: string[] = []; + const failed: Array<{ id: string; error: string }> = []; + + for (const id of transcriptIds) { + try { + await lysnraiTranscriptRunExtraction(id, opts); + succeeded.push(id); + } catch (err) { + failed.push({ id, error: err instanceof Error ? err.message : String(err) }); + } + } + + return { processed: transcriptIds.length, succeeded, failed, skipped: false }; +} + +// ── Step 3: ExtractionReportAgent ───────────────────────────────────────────── + +function buildReport( + runId: string, + dryRun: boolean, + collection: CollectionResult, + batch: BatchResult +): TranscriptExtractionReport { + const now = new Date().toISOString(); + const unextractedCount = collection.unextractedIds.length; + + let summary: string; + if (dryRun) { + summary = `DRY RUN: Found ${unextractedCount} unextracted transcripts out of ${collection.totalFetched} fetched. No extraction was run.`; + } else if (unextractedCount === 0) { + summary = `All ${collection.totalFetched} transcripts are already extracted. Nothing to do.`; + } else { + const failNote = batch.failed.length > 0 ? ` ${batch.failed.length} failed.` : ''; + summary = `Extracted ${batch.succeeded.length}/${unextractedCount} transcripts.${failNote}`; + } + + return { + runId, + productId: 'lysnrai', + dryRun, + totalFetched: collection.totalFetched, + unextractedCount, + processed: batch.processed, + succeeded: batch.succeeded.length, + failed: batch.failed.length, + failedIds: batch.failed.map(f => f.id), + sampleExtractedIds: batch.succeeded.slice(0, 5), + summary, + generatedAt: now, + }; +} + +// ── Pipeline runner ──────────────────────────────────────────────────────────── + +async function runTranscriptExtractionPipeline( + limit: number, + dryRun: boolean, + req: McpToolRequest +): Promise { + const runId = randomUUID(); + const opts = { + token: req.headers.authorization?.slice(7), + requestId: req.id, + }; + + req.log.info({ runId, stepId: 'collect', limit, dryRun }, 'TranscriptCollectorAgent start'); + const collection = await collectUnextracted(limit, opts); + req.log.info( + { + runId, + stepId: 'collect', + totalFetched: collection.totalFetched, + unextractedCount: collection.unextractedIds.length, + }, + 'TranscriptCollectorAgent done' + ); + + req.log.info( + { runId, stepId: 'batch', count: collection.unextractedIds.length, dryRun }, + 'ExtractionBatchAgent start' + ); + const batch = await runExtractionBatch(collection.unextractedIds, dryRun, opts); + req.log.info( + { runId, stepId: 'batch', succeeded: batch.succeeded.length, failed: batch.failed.length }, + 'ExtractionBatchAgent done' + ); + + req.log.info({ runId, stepId: 'report' }, 'ExtractionReportAgent start'); + const report = buildReport(runId, dryRun, collection, batch); + req.log.info({ runId, stepId: 'report', summary: report.summary }, 'ExtractionReportAgent done'); + + return report; +} + +// ── MCP tool registration ───────────────────────────────────────────────────── + +registerTool({ + name: 'lysnrai.transcripts.runExtractionPipeline', + description: + 'A2A pipeline: fetches LysnrAI transcripts missing extraction data, runs the extraction service on each, and returns a report with counts and failures. Use dryRun=true to preview without running extraction. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + limit: z.coerce + .number() + .min(1) + .max(config.QUERY_MAX_LIMIT) + .default(config.QUERY_DEFAULT_LIMIT) + .describe('Max transcripts to fetch and process per run'), + dryRun: z + .boolean() + .default(false) + .describe('If true, only collect and count — do not run extraction'), + }), + async execute(args, req) { + return runTranscriptExtractionPipeline(args.limit, args.dryRun, req); + }, +}); diff --git a/services/mcp-server/src/modules/jarvis/jarvis-tools.ts b/services/mcp-server/src/modules/jarvis/jarvis-tools.ts index 7d1d59bd..d016164e 100644 --- a/services/mcp-server/src/modules/jarvis/jarvis-tools.ts +++ b/services/mcp-server/src/modules/jarvis/jarvis-tools.ts @@ -21,6 +21,8 @@ import { jarvisMarketplaceCertify, jarvisMarketplaceSuspend, jarvisMarketplaceFeature, + jarvisMemoryCreate, + jarvisTeamsListMembers, } from '../../lib/jarvis-client.js'; import type { McpToolRequest } from '../tools/types.js'; @@ -250,3 +252,62 @@ registerTool({ }); }, }); + +// ── jarvis.memory.create ────────────────────────────────────────────────── + +registerTool({ + name: 'jarvis.memory.create', + description: + 'Create a persistent memory entry for a coaching agent (skill note, preference, goal, context, or exercise). Used to seed agent context from external data. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + agentId: z.string().min(1).describe('Agent to store memory for'), + sessionId: z.string().min(1).describe('Session ID that produced this memory'), + type: z + .enum(['skill_note', 'preference', 'goal', 'context', 'exercise']) + .describe('Memory type'), + content: z.string().min(1).max(5000).describe('Memory content (max 5000 chars)'), + importance: z.coerce + .number() + .min(0) + .max(1) + .default(0.5) + .describe('Importance score 0–1 (default 0.5)'), + tags: z.array(z.string()).default([]).describe('Optional tags for retrieval'), + expiresAt: z + .string() + .datetime() + .nullable() + .optional() + .describe('ISO 8601 expiry (null = never)'), + }), + async execute(args, req) { + return jarvisMemoryCreate( + args.agentId, + { + sessionId: args.sessionId, + type: args.type, + content: args.content, + importance: args.importance, + tags: args.tags, + expiresAt: args.expiresAt ?? null, + }, + { token: tokenOf(req), requestId: req.id } + ); + }, +}); + +// ── jarvis.teams.listMembers ────────────────────────────────────────────── + +registerTool({ + name: 'jarvis.teams.listMembers', + description: + 'List members of a JarvisJr enterprise team (role, status, joinedAt). Useful for provisioning audits and team management workflows. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + teamId: z.string().min(1).describe('Team ID'), + }), + async execute(args, req) { + return jarvisTeamsListMembers(args.teamId, { token: tokenOf(req), requestId: req.id }); + }, +}); diff --git a/services/mcp-server/src/modules/nomgap/nomgap-tools.ts b/services/mcp-server/src/modules/nomgap/nomgap-tools.ts index a158aeca..3f2eee38 100644 --- a/services/mcp-server/src/modules/nomgap/nomgap-tools.ts +++ b/services/mcp-server/src/modules/nomgap/nomgap-tools.ts @@ -10,6 +10,7 @@ import { registerTool } from '../tools/registry.js'; import { config } from '../../lib/config.js'; import { nomgapFastingSessionsList, + nomgapFastingSessionGet, nomgapFastingGetStats, nomgapFastingGetWeeklyStats, nomgapProtocolsList, @@ -206,6 +207,21 @@ registerTool({ }, }); +// ── nomgap.fasting.getSession ──────────────────────────────────────────── + +registerTool({ + name: 'nomgap.fasting.getSession', + description: + 'Get a single fasting session by ID. Returns full session document including stage transitions, mood check-ins, water intake, and computed metrics. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + sessionId: z.string().min(1).describe('Fasting session ID'), + }), + async execute(args, req) { + return nomgapFastingSessionGet(args.sessionId, { token: tokenOf(req), requestId: req.id }); + }, +}); + // ── nomgap.push.pending ─────────────────────────────────────────────────── registerTool({ diff --git a/services/mcp-server/src/modules/peakpulse/peakpulse-tools.ts b/services/mcp-server/src/modules/peakpulse/peakpulse-tools.ts index e60ee586..eb12ec76 100644 --- a/services/mcp-server/src/modules/peakpulse/peakpulse-tools.ts +++ b/services/mcp-server/src/modules/peakpulse/peakpulse-tools.ts @@ -100,3 +100,25 @@ registerTool({ return peakpulseRouteGet(args.sessionId, { token: tokenOf(req), requestId: req.id }); }, }); + +// ── peakpulse.weather.getSnapshot ─────────────────────────────────────────── + +registerTool({ + name: 'peakpulse.weather.getSnapshot', + description: + 'Get the weather snapshot captured at the start of a session (temperature, wind speed, UV index, condition). Returns null if weather data was not captured. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + sessionId: z.string().min(1).describe('Session ID to retrieve weather snapshot for'), + }), + async execute(args, req) { + const session = await peakpulseSessionGet(args.sessionId, { + token: tokenOf(req), + requestId: req.id, + }); + return { + sessionId: args.sessionId, + weather: (session as unknown as Record).weather ?? null, + }; + }, +}); diff --git a/services/mcp-server/src/server.ts b/services/mcp-server/src/server.ts index ef55af92..92f83266 100644 --- a/services/mcp-server/src/server.ts +++ b/services/mcp-server/src/server.ts @@ -39,6 +39,9 @@ import './modules/a2a/daily-brief-pipeline.js'; import './modules/a2a/marketplace-cert-pipeline.js'; import './modules/a2a/safety-monitor-pipeline.js'; import './modules/a2a/sync-diagnostics-pipeline.js'; +import './modules/a2a/transcript-extraction-pipeline.js'; +import './modules/a2a/sync-conflict-pipeline.js'; +import './modules/a2a/route-safety-pipeline.js'; import './modules/mindlyst/mindlyst-tools.js'; import './modules/lysnrai/lysnrai-tools.js'; import './modules/jarvis/jarvis-tools.js';