Implements audit-harness-inspired session lifecycle: audit session creation/end/recover/report-daily with JSONL buffer and graded context recovery (L0-L4). Guideline service for behavioral compliance rules (condition/action model with criticality). Correction service for persistent user correction tracking across agent sessions. 8 supporting skills: audit-start/end/report-daily/recover + command variants for slash-command integration.
190 lines
5.6 KiB
TypeScript
190 lines
5.6 KiB
TypeScript
import type {
|
|
Journey,
|
|
JourneyNode,
|
|
JourneyEdge,
|
|
JourneyNodeId,
|
|
JourneyEdgeId,
|
|
} from './journey-store.js';
|
|
import type { Guideline, GuidelineId, Criticality } from './guideline-store.js';
|
|
|
|
export interface ProjectedGuideline {
|
|
id: GuidelineId;
|
|
content: {
|
|
condition: string;
|
|
action: string | null;
|
|
description: string | null;
|
|
};
|
|
criticality: Criticality;
|
|
creationUtc: string;
|
|
enabled: boolean;
|
|
tags: string[];
|
|
labels: string[];
|
|
metadata: Record<string, unknown>;
|
|
}
|
|
|
|
function formatNodeGuidelineId(nodeId: JourneyNodeId, edgeId?: JourneyEdgeId | null): GuidelineId {
|
|
return `journey_node:${nodeId}${edgeId ? `:${edgeId}` : ''}` as GuidelineId;
|
|
}
|
|
|
|
export function projectJourneyToGuidelines(
|
|
journey: Journey,
|
|
nodes: JourneyNode[],
|
|
edges: JourneyEdge[],
|
|
): ProjectedGuideline[] {
|
|
const nodeMap = new Map<JourneyNodeId, JourneyNode>();
|
|
for (const n of nodes) nodeMap.set(n.id, n);
|
|
|
|
const edgeMap = new Map<JourneyEdgeId, JourneyEdge>();
|
|
for (const e of edges) edgeMap.set(e.id, e);
|
|
|
|
const nodeEdges = new Map<JourneyNodeId, JourneyEdge[]>();
|
|
for (const e of edges) {
|
|
const list = nodeEdges.get(e.source) || [];
|
|
list.push(e);
|
|
nodeEdges.set(e.source, list);
|
|
}
|
|
|
|
const guidelines: Map<GuidelineId, ProjectedGuideline> = new Map();
|
|
const nodeIndexes = new Map<JourneyNodeId, number>();
|
|
let index = 0;
|
|
|
|
const queue: Array<{ edgeId: JourneyEdgeId | null; nodeId: JourneyNodeId }> = [];
|
|
const visited = new Set<string>();
|
|
|
|
queue.push({ edgeId: null, nodeId: journey.rootId });
|
|
|
|
while (queue.length > 0) {
|
|
const { edgeId, nodeId } = queue.shift()!;
|
|
const visitKey = `${edgeId || ''}:${nodeId}`;
|
|
if (visited.has(visitKey)) continue;
|
|
visited.add(visitKey);
|
|
|
|
const node = nodeMap.get(nodeId);
|
|
if (!node) continue;
|
|
|
|
if (!nodeIndexes.has(nodeId)) {
|
|
index++;
|
|
nodeIndexes.set(nodeId, index);
|
|
}
|
|
|
|
const edge = edgeId ? edgeMap.get(edgeId) : undefined;
|
|
|
|
const baseJourneyNode: Record<string, unknown> = {
|
|
follow_ups: [],
|
|
index: String(nodeIndexes.get(nodeId)),
|
|
journey_id: journey.id,
|
|
labels: node.labels,
|
|
tool_ids: node.tools,
|
|
};
|
|
|
|
const edgeJourneyNode = (edge?.metadata?.['journey_node'] as Record<string, unknown>) || {};
|
|
const nodeJourneyNode = (node.metadata?.['journey_node'] as Record<string, unknown>) || {};
|
|
|
|
const mergedJourneyNode = { ...baseJourneyNode, ...nodeJourneyNode, ...edgeJourneyNode };
|
|
|
|
const metadata: Record<string, unknown> = {
|
|
journey_node: mergedJourneyNode,
|
|
};
|
|
for (const [k, v] of Object.entries(node.metadata)) {
|
|
if (k !== 'journey_node') metadata[k] = v;
|
|
}
|
|
if (edge) {
|
|
for (const [k, v] of Object.entries(edge.metadata)) {
|
|
if (k !== 'journey_node') metadata[k] = v;
|
|
}
|
|
}
|
|
|
|
const gid = formatNodeGuidelineId(nodeId, edgeId);
|
|
const guideline: ProjectedGuideline = {
|
|
id: gid,
|
|
content: {
|
|
condition: (edge?.condition) || '',
|
|
action: node.action,
|
|
description: node.description,
|
|
},
|
|
criticality: 'high' as Criticality,
|
|
creationUtc: new Date().toISOString(),
|
|
enabled: true,
|
|
tags: journey.tags,
|
|
labels: [...(node.labels || [])],
|
|
metadata,
|
|
};
|
|
|
|
guidelines.set(gid, guideline);
|
|
|
|
const childEdges = nodeEdges.get(nodeId) || [];
|
|
for (const childEdge of childEdges) {
|
|
if (visited.has(`${childEdge.id}:${childEdge.target}`)) continue;
|
|
queue.push({ edgeId: childEdge.id, nodeId: childEdge.target });
|
|
|
|
const childGid = formatNodeGuidelineId(childEdge.target, childEdge.id);
|
|
const followUps = (guideline.metadata['journey_node'] as Record<string, unknown>)['follow_ups'] as string[];
|
|
if (!followUps.includes(childGid)) {
|
|
followUps.push(childGid);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...guidelines.values()];
|
|
}
|
|
|
|
export interface BacktrackCheck {
|
|
journeyId: string;
|
|
currentNodeId: JourneyNodeId;
|
|
previousNodeId: JourneyNodeId;
|
|
isBacktrack: boolean;
|
|
recommendation: string | null;
|
|
}
|
|
|
|
export function detectJourneyBacktrack(
|
|
journey: Journey,
|
|
nodes: JourneyNode[],
|
|
edges: JourneyEdge[],
|
|
currentNodeId: JourneyNodeId,
|
|
previousNodeId: JourneyNodeId,
|
|
): BacktrackCheck {
|
|
const nodeMap = new Map<JourneyNodeId, JourneyNode>();
|
|
for (const n of nodes) nodeMap.set(n.id, n);
|
|
|
|
const adjacency = new Map<JourneyNodeId, JourneyNodeId[]>();
|
|
for (const e of edges) {
|
|
const list = adjacency.get(e.source) || [];
|
|
list.push(e.target);
|
|
adjacency.set(e.source, list);
|
|
}
|
|
|
|
const isInForwardPath = (from: JourneyNodeId, target: JourneyNodeId): boolean => {
|
|
const visitedInner = new Set<JourneyNodeId>();
|
|
const queueInner: JourneyNodeId[] = [from];
|
|
while (queueInner.length > 0) {
|
|
const current = queueInner.shift()!;
|
|
if (current === target) return true;
|
|
if (visitedInner.has(current)) continue;
|
|
visitedInner.add(current);
|
|
for (const next of adjacency.get(current) || []) {
|
|
if (!visitedInner.has(next)) queueInner.push(next);
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const fromCurToPrev = isInForwardPath(currentNodeId, previousNodeId);
|
|
const fromPrevToCur = isInForwardPath(previousNodeId, currentNodeId);
|
|
|
|
const isBacktrack = !fromPrevToCur && !fromCurToPrev;
|
|
|
|
let recommendation: string | null = null;
|
|
if (isBacktrack && nodeMap.has(previousNodeId)) {
|
|
const prevNode = nodeMap.get(previousNodeId)!;
|
|
recommendation = `Detected potential backtrack from '${currentNodeId}' to '${previousNodeId}' (${prevNode.action || 'no action'}). Consider whether this regression is intentional.`;
|
|
}
|
|
|
|
return {
|
|
journeyId: journey.id,
|
|
currentNodeId,
|
|
previousNodeId,
|
|
isBacktrack,
|
|
recommendation,
|
|
};
|
|
}
|