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:
saravanakumardb1 2026-03-05 15:18:21 -08:00
parent 7e47151918
commit d7aa90b021
8 changed files with 1056 additions and 0 deletions

View File

@ -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);
}

View 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 2448h 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);
},
});

View 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);
},
});

View File

@ -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);
},
});

View File

@ -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 01 (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 });
},
});

View File

@ -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({

View File

@ -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,
};
},
});

View File

@ -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';