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}` : '';
|
||||
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,
|
||||
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 });
|
||||
},
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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<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/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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user