feat(mcp-server): 4 MCP tool gaps + 3 new A2A pipelines (Priority 3/6/7)
MCP tool gaps filled (DOMAIN_PRODUCTS.md alignment): - jarvis.memory.create — POST /jarvis/agents/:agentId/memory (sessionId, type, content, importance, tags, expiresAt) - jarvis.teams.listMembers — GET /jarvis/teams/:teamId/members (role, status, joinedAt) - nomgap.fasting.getSession — GET /fasting/sessions/:id (client func already existed, MCP tool was missing) - peakpulse.weather.getSnapshot — extracts weather field from peakpulseSessionGet response New A2A pipelines (all registered in server.ts): - transcript-extraction-pipeline.ts: lysnrai.transcripts.runExtractionPipeline - TranscriptCollectorAgent -> ExtractionBatchAgent -> ExtractionReportAgent - Queries transcripts missing extractedAt, runs extraction, returns batch report + dryRun support - sync-conflict-pipeline.ts: chronomind.sync.diagnoseConflicts - ConflictDetectorAgent -> SyncStateInspectorAgent -> DiagnosticsSessionAgent -> ConflictReportAgent - Queries telemetry for sync_conflict events, classifies pattern, creates diagnostics session on conflict - route-safety-pipeline.ts: peakpulse.sessions.assessSafety - SessionDataAgent -> RouteProfileAgent -> SafetyAnalysisAgent -> SafetyReportAgent - Fetches GPS + weather, evaluates UV/wind/altitude/speed risk factors, enriches with extraction entities Client additions (jarvis-client.ts): jarvisMemoryCreate, jarvisTeamsListMembers + JarvisTeamMemberDoc interface MCP server total: 93 tools across 17 namespaces
This commit is contained in:
parent
7e47151918
commit
d7aa90b021
@ -248,3 +248,40 @@ export function jarvisMemoryGetContext(
|
|||||||
const qs = limit !== undefined ? `?limit=${limit}` : '';
|
const qs = limit !== undefined ? `?limit=${limit}` : '';
|
||||||
return jarvisFetch(`/jarvis/agents/${agentId}/memory/context${qs}`, { method: 'GET' }, opts);
|
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<JarvisMemoryDoc> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
406
services/mcp-server/src/modules/a2a/route-safety-pipeline.ts
Normal file
406
services/mcp-server/src/modules/a2a/route-safety-pipeline.ts
Normal file
@ -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<SessionSnapshot> {
|
||||||
|
const session = await peakpulseSessionGet(sessionId, opts);
|
||||||
|
const s = session as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
const metrics = (s['metrics'] as Record<string, unknown>) ?? {};
|
||||||
|
const weather = s['weather'] as Record<string, unknown> | 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<RouteProfile> {
|
||||||
|
try {
|
||||||
|
const route = await peakpulseRouteGet(sessionId, opts);
|
||||||
|
const r = route as unknown as Record<string, unknown>;
|
||||||
|
const trackPoints = (r['trackPoints'] as Array<Record<string, unknown>>) ?? [];
|
||||||
|
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<SafetyAnalysis> {
|
||||||
|
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<RouteSafetyReport> {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
307
services/mcp-server/src/modules/a2a/sync-conflict-pipeline.ts
Normal file
307
services/mcp-server/src/modules/a2a/sync-conflict-pipeline.ts
Normal file
@ -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<ConflictDetection> {
|
||||||
|
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<Record<string, unknown>>;
|
||||||
|
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<SyncStateResult> {
|
||||||
|
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<DiagnosticsCapture> {
|
||||||
|
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<SyncConflictReport> {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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<CollectionResult> {
|
||||||
|
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<BatchResult> {
|
||||||
|
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<TranscriptExtractionReport> {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -21,6 +21,8 @@ import {
|
|||||||
jarvisMarketplaceCertify,
|
jarvisMarketplaceCertify,
|
||||||
jarvisMarketplaceSuspend,
|
jarvisMarketplaceSuspend,
|
||||||
jarvisMarketplaceFeature,
|
jarvisMarketplaceFeature,
|
||||||
|
jarvisMemoryCreate,
|
||||||
|
jarvisTeamsListMembers,
|
||||||
} from '../../lib/jarvis-client.js';
|
} from '../../lib/jarvis-client.js';
|
||||||
import type { McpToolRequest } from '../tools/types.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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { registerTool } from '../tools/registry.js';
|
|||||||
import { config } from '../../lib/config.js';
|
import { config } from '../../lib/config.js';
|
||||||
import {
|
import {
|
||||||
nomgapFastingSessionsList,
|
nomgapFastingSessionsList,
|
||||||
|
nomgapFastingSessionGet,
|
||||||
nomgapFastingGetStats,
|
nomgapFastingGetStats,
|
||||||
nomgapFastingGetWeeklyStats,
|
nomgapFastingGetWeeklyStats,
|
||||||
nomgapProtocolsList,
|
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 ───────────────────────────────────────────────────
|
// ── nomgap.push.pending ───────────────────────────────────────────────────
|
||||||
|
|
||||||
registerTool({
|
registerTool({
|
||||||
|
|||||||
@ -100,3 +100,25 @@ registerTool({
|
|||||||
return peakpulseRouteGet(args.sessionId, { token: tokenOf(req), requestId: req.id });
|
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<string, unknown>).weather ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@ -39,6 +39,9 @@ import './modules/a2a/daily-brief-pipeline.js';
|
|||||||
import './modules/a2a/marketplace-cert-pipeline.js';
|
import './modules/a2a/marketplace-cert-pipeline.js';
|
||||||
import './modules/a2a/safety-monitor-pipeline.js';
|
import './modules/a2a/safety-monitor-pipeline.js';
|
||||||
import './modules/a2a/sync-diagnostics-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/mindlyst/mindlyst-tools.js';
|
||||||
import './modules/lysnrai/lysnrai-tools.js';
|
import './modules/lysnrai/lysnrai-tools.js';
|
||||||
import './modules/jarvis/jarvis-tools.js';
|
import './modules/jarvis/jarvis-tools.js';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user