feat(coder,server): audit engine — session audit, guideline compliance, user correction tracking
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.
This commit is contained in:
52
apps/server/src/services/audit/corrections.ts
Normal file
52
apps/server/src/services/audit/corrections.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export interface UserCorrectionRecord {
|
||||
record_type: 'conversation';
|
||||
action_type: 'user_correction';
|
||||
priority: 'critical_for_recovery';
|
||||
timestamp: string;
|
||||
original_claim: string;
|
||||
correction: string;
|
||||
principle_extracted: string;
|
||||
persisted_to: string[];
|
||||
}
|
||||
|
||||
export function createCorrection(params: {
|
||||
originalClaim: string;
|
||||
correction: string;
|
||||
principleExtracted?: string;
|
||||
persistedTo?: string[];
|
||||
}): UserCorrectionRecord {
|
||||
return {
|
||||
record_type: 'conversation',
|
||||
action_type: 'user_correction',
|
||||
priority: 'critical_for_recovery',
|
||||
timestamp: new Date().toISOString(),
|
||||
original_claim: params.originalClaim,
|
||||
correction: params.correction,
|
||||
principle_extracted: params.principleExtracted || '',
|
||||
persisted_to: params.persistedTo || [],
|
||||
};
|
||||
}
|
||||
|
||||
export function findCorrections(
|
||||
records: Record<string, unknown>[],
|
||||
): UserCorrectionRecord[] {
|
||||
return records.filter(
|
||||
r => r['action_type'] === 'user_correction',
|
||||
) as unknown as UserCorrectionRecord[];
|
||||
}
|
||||
|
||||
export function checkCorrectionConflict(
|
||||
proposedAction: string,
|
||||
corrections: UserCorrectionRecord[],
|
||||
): UserCorrectionRecord | null {
|
||||
for (const c of corrections) {
|
||||
if (!c.original_claim) continue;
|
||||
const claimKeywords = c.original_claim.toLowerCase().split(/\s+/).filter(w => w.length > 3);
|
||||
const actionLower = proposedAction.toLowerCase();
|
||||
const matchCount = claimKeywords.filter(k => actionLower.includes(k)).length;
|
||||
if (matchCount >= 2 && matchCount / claimKeywords.length >= 0.5) {
|
||||
if (c.persisted_to.length > 0) return c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
251
apps/server/src/services/audit/guideline-store.ts
Normal file
251
apps/server/src/services/audit/guideline-store.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { ensureRunsDir } from './runs-dir.js';
|
||||
|
||||
export type GuidelineId = string;
|
||||
export type TagId = string;
|
||||
export type Criticality = 'low' | 'medium' | 'high';
|
||||
export type GuidelineDocumentVersion = string;
|
||||
|
||||
export interface GuidelineContent {
|
||||
condition: string;
|
||||
action: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface Guideline {
|
||||
id: GuidelineId;
|
||||
creationUtc: string;
|
||||
content: GuidelineContent;
|
||||
enabled: boolean;
|
||||
tags: TagId[];
|
||||
labels: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
criticality: Criticality;
|
||||
title: string | null;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface GuidelineDocument {
|
||||
id: string;
|
||||
version: GuidelineDocumentVersion;
|
||||
creation_utc: string;
|
||||
condition: string;
|
||||
action: string | null;
|
||||
description: string | null;
|
||||
title: string | null;
|
||||
criticality: string;
|
||||
enabled: boolean;
|
||||
metadata: Record<string, unknown>;
|
||||
labels: string[];
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface GuidelineUpdateParams {
|
||||
condition?: string;
|
||||
action?: string | null;
|
||||
description?: string | null;
|
||||
title?: string | null;
|
||||
criticality?: Criticality;
|
||||
enabled?: boolean;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
let result = '';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
result += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function dbPath(projectRoot?: string): string {
|
||||
const dir = join(ensureRunsDir(projectRoot), '..', 'guidelines');
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
return join(dir, 'guidelines.json');
|
||||
}
|
||||
|
||||
function readDb(projectRoot?: string): GuidelineDocument[] {
|
||||
const path = dbPath(projectRoot);
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as GuidelineDocument[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeDb(docs: GuidelineDocument[], projectRoot?: string): void {
|
||||
writeFileSync(dbPath(projectRoot), JSON.stringify(docs, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function toDocument(g: Guideline): GuidelineDocument {
|
||||
return {
|
||||
id: g.id,
|
||||
version: '0.11.0',
|
||||
creation_utc: g.creationUtc,
|
||||
condition: g.content.condition,
|
||||
action: g.content.action,
|
||||
description: g.content.description,
|
||||
title: g.title,
|
||||
criticality: g.criticality,
|
||||
enabled: g.enabled,
|
||||
metadata: g.metadata,
|
||||
labels: g.labels,
|
||||
priority: g.priority,
|
||||
};
|
||||
}
|
||||
|
||||
function fromDocument(d: GuidelineDocument): Guideline {
|
||||
return {
|
||||
id: d.id,
|
||||
creationUtc: d.creation_utc,
|
||||
content: {
|
||||
condition: d.condition,
|
||||
action: d.action ?? null,
|
||||
description: d.description ?? null,
|
||||
},
|
||||
title: d.title ?? null,
|
||||
criticality: (d.criticality || 'medium') as Criticality,
|
||||
enabled: d.enabled ?? true,
|
||||
tags: [],
|
||||
labels: d.labels ?? [],
|
||||
metadata: d.metadata ?? {},
|
||||
priority: d.priority ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export class GuidelineDocumentStore {
|
||||
createGuideline(params: {
|
||||
condition: string;
|
||||
action?: string | null;
|
||||
description?: string | null;
|
||||
title?: string | null;
|
||||
criticality?: Criticality;
|
||||
enabled?: boolean;
|
||||
labels?: string[];
|
||||
priority?: number;
|
||||
id?: GuidelineId;
|
||||
}, projectRoot?: string): Guideline {
|
||||
const docs = readDb(projectRoot);
|
||||
const id = params.id || `gl_${generateId()}`;
|
||||
|
||||
if (docs.find(d => d.id === id)) {
|
||||
throw new Error(`Guideline with id '${id}' already exists`);
|
||||
}
|
||||
|
||||
const guideline: Guideline = {
|
||||
id,
|
||||
creationUtc: new Date().toISOString(),
|
||||
content: {
|
||||
condition: params.condition,
|
||||
action: params.action ?? null,
|
||||
description: params.description ?? null,
|
||||
},
|
||||
title: params.title ?? null,
|
||||
criticality: params.criticality ?? 'medium',
|
||||
enabled: params.enabled ?? true,
|
||||
tags: [],
|
||||
labels: params.labels ?? [],
|
||||
metadata: {},
|
||||
priority: params.priority ?? 0,
|
||||
};
|
||||
|
||||
docs.push(toDocument(guideline));
|
||||
writeDb(docs, projectRoot);
|
||||
return guideline;
|
||||
}
|
||||
|
||||
listGuidelines(params?: {
|
||||
tags?: TagId[];
|
||||
labels?: string[];
|
||||
}, projectRoot?: string): Guideline[] {
|
||||
let docs = readDb(projectRoot);
|
||||
|
||||
if (params?.tags && params.tags.length > 0) {
|
||||
const tagSet = new Set(params.tags);
|
||||
docs = docs.filter(d => d.metadata['tags'] &&
|
||||
Array.isArray(d.metadata['tags']) &&
|
||||
(d.metadata['tags'] as string[]).some(t => tagSet.has(t)));
|
||||
}
|
||||
|
||||
if (params?.labels && params.labels.length > 0) {
|
||||
const labelSet = new Set(params.labels);
|
||||
docs = docs.filter(d => {
|
||||
const gl = fromDocument(d);
|
||||
return params.labels!.every(l => gl.labels.includes(l));
|
||||
});
|
||||
}
|
||||
|
||||
return docs.map(fromDocument);
|
||||
}
|
||||
|
||||
readGuideline(id: GuidelineId, projectRoot?: string): Guideline {
|
||||
const docs = readDb(projectRoot);
|
||||
const doc = docs.find(d => d.id === id);
|
||||
if (!doc) throw new Error(`Guideline '${id}' not found`);
|
||||
return fromDocument(doc);
|
||||
}
|
||||
|
||||
updateGuideline(id: GuidelineId, params: GuidelineUpdateParams, projectRoot?: string): Guideline {
|
||||
const docs = readDb(projectRoot);
|
||||
const idx = docs.findIndex(d => d.id === id);
|
||||
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
|
||||
|
||||
const doc = docs[idx]!;
|
||||
if (params.condition !== undefined) doc.condition = params.condition;
|
||||
if (params.action !== undefined) doc.action = params.action;
|
||||
if (params.description !== undefined) doc.description = params.description;
|
||||
if (params.title !== undefined) doc.title = params.title;
|
||||
if (params.criticality !== undefined) doc.criticality = params.criticality;
|
||||
if (params.enabled !== undefined) doc.enabled = params.enabled;
|
||||
if (params.priority !== undefined) doc.priority = params.priority;
|
||||
|
||||
docs[idx] = doc;
|
||||
writeDb(docs, projectRoot);
|
||||
return fromDocument(doc);
|
||||
}
|
||||
|
||||
deleteGuideline(id: GuidelineId, projectRoot?: string): void {
|
||||
const docs = readDb(projectRoot);
|
||||
const idx = docs.findIndex(d => d.id === id);
|
||||
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
|
||||
docs.splice(idx, 1);
|
||||
writeDb(docs, projectRoot);
|
||||
}
|
||||
|
||||
findGuideline(content: GuidelineContent, projectRoot?: string): Guideline {
|
||||
const docs = readDb(projectRoot);
|
||||
const doc = docs.find(d =>
|
||||
d.condition === content.condition &&
|
||||
(content.action === undefined || d.action === content.action),
|
||||
);
|
||||
if (!doc) throw new Error(`Guideline not found for condition='${content.condition}'`);
|
||||
return fromDocument(doc);
|
||||
}
|
||||
|
||||
upsertLabels(id: GuidelineId, labels: string[], projectRoot?: string): Guideline {
|
||||
const docs = readDb(projectRoot);
|
||||
const idx = docs.findIndex(d => d.id === id);
|
||||
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
|
||||
|
||||
const doc = docs[idx]!;
|
||||
const current = new Set(doc.labels || []);
|
||||
for (const l of labels) current.add(l);
|
||||
doc.labels = [...current];
|
||||
writeDb(docs, projectRoot);
|
||||
return fromDocument(doc);
|
||||
}
|
||||
|
||||
removeLabels(id: GuidelineId, labels: string[], projectRoot?: string): Guideline {
|
||||
const docs = readDb(projectRoot);
|
||||
const idx = docs.findIndex(d => d.id === id);
|
||||
if (idx === -1) throw new Error(`Guideline '${id}' not found`);
|
||||
|
||||
const doc = docs[idx]!;
|
||||
const removeSet = new Set(labels);
|
||||
doc.labels = (doc.labels || []).filter(l => !removeSet.has(l));
|
||||
writeDb(docs, projectRoot);
|
||||
return fromDocument(doc);
|
||||
}
|
||||
}
|
||||
68
apps/server/src/services/audit/index.ts
Normal file
68
apps/server/src/services/audit/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export {
|
||||
findRunsDir,
|
||||
ensureRunsDir,
|
||||
readCurrentSession,
|
||||
writeCurrentSession,
|
||||
clearCurrentSession,
|
||||
readIndex,
|
||||
writeIndex,
|
||||
updateIndexEntry,
|
||||
findInProgressSessions,
|
||||
INDEX_SCHEMA_VERSION,
|
||||
GITIGNORE_CONTENT,
|
||||
} from './runs-dir.js';
|
||||
export type { IndexEntry, IndexFile } from './runs-dir.js';
|
||||
|
||||
export {
|
||||
generateSessionId,
|
||||
isoNow,
|
||||
createSession,
|
||||
getSessionDir,
|
||||
getActiveSession,
|
||||
readSession,
|
||||
updateSession,
|
||||
endSession,
|
||||
appendToTrail,
|
||||
readTrail,
|
||||
recoverContext,
|
||||
checkUnfinishedSessions,
|
||||
generateSessionSummary,
|
||||
} from './session-manager.js';
|
||||
export type { SessionJson, RecoverySummary } from './session-manager.js';
|
||||
|
||||
export {
|
||||
createCorrection,
|
||||
findCorrections,
|
||||
checkCorrectionConflict,
|
||||
} from './corrections.js';
|
||||
export type { UserCorrectionRecord } from './corrections.js';
|
||||
|
||||
export {
|
||||
GuidelineDocumentStore,
|
||||
} from './guideline-store.js';
|
||||
export type {
|
||||
GuidelineId,
|
||||
GuidelineContent,
|
||||
Guideline,
|
||||
Criticality,
|
||||
GuidelineUpdateParams,
|
||||
GuidelineDocument,
|
||||
} from './guideline-store.js';
|
||||
|
||||
export {
|
||||
JourneyStore,
|
||||
} from './journey-store.js';
|
||||
export type {
|
||||
JourneyId,
|
||||
JourneyNodeId,
|
||||
JourneyEdgeId,
|
||||
Journey,
|
||||
JourneyNode,
|
||||
JourneyEdge,
|
||||
} from './journey-store.js';
|
||||
|
||||
export {
|
||||
projectJourneyToGuidelines,
|
||||
detectJourneyBacktrack,
|
||||
} from './journey-projection.js';
|
||||
export type { ProjectedGuideline, BacktrackCheck } from './journey-projection.js';
|
||||
189
apps/server/src/services/audit/journey-projection.ts
Normal file
189
apps/server/src/services/audit/journey-projection.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
360
apps/server/src/services/audit/journey-store.ts
Normal file
360
apps/server/src/services/audit/journey-store.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { ensureRunsDir } from './runs-dir.js';
|
||||
import type { GuidelineId } from './guideline-store.js';
|
||||
|
||||
export type JourneyId = string;
|
||||
export type JourneyNodeId = string;
|
||||
export type JourneyEdgeId = string;
|
||||
|
||||
export interface JourneyNode {
|
||||
id: JourneyNodeId;
|
||||
creationUtc: string;
|
||||
action: string | null;
|
||||
tools: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
description: string | null;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export interface JourneyEdge {
|
||||
id: JourneyEdgeId;
|
||||
creationUtc: string;
|
||||
source: JourneyNodeId;
|
||||
target: JourneyNodeId;
|
||||
condition: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Journey {
|
||||
id: JourneyId;
|
||||
creationUtc: string;
|
||||
description: string;
|
||||
triggers: GuidelineId[];
|
||||
title: string;
|
||||
rootId: JourneyNodeId;
|
||||
tags: string[];
|
||||
labels: string[];
|
||||
priority: number;
|
||||
}
|
||||
|
||||
interface JourneyDocument {
|
||||
id: string;
|
||||
version: string;
|
||||
creation_utc: string;
|
||||
title: string;
|
||||
description: string;
|
||||
root_id: JourneyNodeId;
|
||||
labels: string[];
|
||||
priority: number;
|
||||
}
|
||||
|
||||
interface NodeDocument {
|
||||
id: string;
|
||||
node_id: JourneyNodeId;
|
||||
journey_id: JourneyId;
|
||||
creation_utc: string;
|
||||
action: string | null;
|
||||
tools: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
description: string | null;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
interface EdgeDocument {
|
||||
id: string;
|
||||
journey_id: JourneyId;
|
||||
creation_utc: string;
|
||||
source: JourneyNodeId;
|
||||
target: JourneyNodeId;
|
||||
condition: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface TriggerDocument {
|
||||
id: string;
|
||||
journey_id: JourneyId;
|
||||
trigger: GuidelineId;
|
||||
creation_utc: string;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
let result = '';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
result += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function dbPath(name: string, projectRoot?: string): string {
|
||||
const dir = join(ensureRunsDir(projectRoot), '..', 'journeys');
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
return join(dir, `${name}.json`);
|
||||
}
|
||||
|
||||
function readCollection<T>(name: string, projectRoot?: string): T[] {
|
||||
try {
|
||||
return JSON.parse(readFileSync(dbPath(name, projectRoot), 'utf-8')) as T[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeCollection<T>(name: string, data: T[], projectRoot?: string): void {
|
||||
writeFileSync(dbPath(name, projectRoot), JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export class JourneyStore {
|
||||
createJourney(params: {
|
||||
title: string;
|
||||
description: string;
|
||||
triggers?: GuidelineId[];
|
||||
labels?: string[];
|
||||
priority?: number;
|
||||
}, projectRoot?: string): Journey {
|
||||
const id = `jny_${generateId()}`;
|
||||
const rootId = `node_${generateId()}`;
|
||||
const creationUtc = new Date().toISOString();
|
||||
|
||||
const journey: Journey = {
|
||||
id,
|
||||
creationUtc,
|
||||
description: params.description,
|
||||
triggers: params.triggers || [],
|
||||
title: params.title,
|
||||
rootId,
|
||||
tags: [],
|
||||
labels: params.labels || [],
|
||||
priority: params.priority || 0,
|
||||
};
|
||||
|
||||
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
|
||||
journeys.push({
|
||||
id,
|
||||
version: '0.7.0',
|
||||
creation_utc: creationUtc,
|
||||
title: params.title,
|
||||
description: params.description,
|
||||
root_id: rootId,
|
||||
labels: params.labels || [],
|
||||
priority: params.priority || 0,
|
||||
});
|
||||
writeCollection('journeys', journeys, projectRoot);
|
||||
|
||||
const root: JourneyNode = {
|
||||
id: rootId,
|
||||
creationUtc,
|
||||
action: null,
|
||||
tools: [],
|
||||
metadata: {},
|
||||
description: null,
|
||||
labels: [],
|
||||
};
|
||||
const nodes = readCollection<NodeDocument>('nodes', projectRoot);
|
||||
nodes.push({
|
||||
id: `nd_${generateId()}`,
|
||||
node_id: rootId,
|
||||
journey_id: id,
|
||||
creation_utc: creationUtc,
|
||||
action: null,
|
||||
tools: [],
|
||||
metadata: {},
|
||||
description: null,
|
||||
labels: [],
|
||||
});
|
||||
writeCollection('nodes', nodes, projectRoot);
|
||||
|
||||
return journey;
|
||||
}
|
||||
|
||||
readJourney(id: JourneyId, projectRoot?: string): Journey {
|
||||
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
|
||||
const doc = journeys.find(j => j.id === id);
|
||||
if (!doc) throw new Error(`Journey '${id}' not found`);
|
||||
|
||||
const triggers = readCollection<TriggerDocument>('triggers', projectRoot)
|
||||
.filter(t => t.journey_id === id)
|
||||
.map(t => t.trigger);
|
||||
|
||||
return {
|
||||
id: doc.id,
|
||||
creationUtc: doc.creation_utc,
|
||||
description: doc.description,
|
||||
triggers,
|
||||
title: doc.title,
|
||||
rootId: doc.root_id,
|
||||
tags: [],
|
||||
labels: doc.labels || [],
|
||||
priority: doc.priority || 0,
|
||||
};
|
||||
}
|
||||
|
||||
deleteJourney(id: JourneyId, projectRoot?: string): void {
|
||||
let journeys = readCollection<JourneyDocument>('journeys', projectRoot);
|
||||
const idx = journeys.findIndex(j => j.id === id);
|
||||
if (idx === -1) throw new Error(`Journey '${id}' not found`);
|
||||
journeys.splice(idx, 1);
|
||||
writeCollection('journeys', journeys, projectRoot);
|
||||
|
||||
let nodes = readCollection<NodeDocument>('nodes', projectRoot);
|
||||
nodes = nodes.filter(n => n.journey_id !== id);
|
||||
writeCollection('nodes', nodes, projectRoot);
|
||||
|
||||
let edges = readCollection<EdgeDocument>('edges', projectRoot);
|
||||
edges = edges.filter(e => e.journey_id !== id);
|
||||
writeCollection('edges', edges, projectRoot);
|
||||
|
||||
let triggers = readCollection<TriggerDocument>('triggers', projectRoot);
|
||||
triggers = triggers.filter(t => t.journey_id !== id);
|
||||
writeCollection('triggers', triggers, projectRoot);
|
||||
}
|
||||
|
||||
listJourneys(projectRoot?: string): Journey[] {
|
||||
const journeys = readCollection<JourneyDocument>('journeys', projectRoot);
|
||||
return journeys.map(j => this.readJourney(j.id, projectRoot));
|
||||
}
|
||||
|
||||
createNode(journeyId: JourneyId, params: {
|
||||
action?: string | null;
|
||||
tools?: string[];
|
||||
description?: string | null;
|
||||
labels?: string[];
|
||||
id?: JourneyNodeId;
|
||||
}, projectRoot?: string): JourneyNode {
|
||||
const nodeId = params.id || `node_${generateId()}`;
|
||||
const creationUtc = new Date().toISOString();
|
||||
|
||||
const node: JourneyNode = {
|
||||
id: nodeId,
|
||||
creationUtc,
|
||||
action: params.action ?? null,
|
||||
tools: params.tools || [],
|
||||
metadata: {},
|
||||
description: params.description ?? null,
|
||||
labels: params.labels || [],
|
||||
};
|
||||
|
||||
const nodes = readCollection<NodeDocument>('nodes', projectRoot);
|
||||
nodes.push({
|
||||
id: `nd_${generateId()}`,
|
||||
node_id: nodeId,
|
||||
journey_id: journeyId,
|
||||
creation_utc: creationUtc,
|
||||
action: node.action,
|
||||
tools: node.tools,
|
||||
metadata: node.metadata,
|
||||
description: node.description,
|
||||
labels: node.labels,
|
||||
});
|
||||
writeCollection('nodes', nodes, projectRoot);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
listNodes(journeyId: JourneyId, projectRoot?: string): JourneyNode[] {
|
||||
const docs = readCollection<NodeDocument>('nodes', projectRoot)
|
||||
.filter(n => n.journey_id === journeyId);
|
||||
|
||||
const nodes = docs.map(d => ({
|
||||
id: d.node_id,
|
||||
creationUtc: d.creation_utc,
|
||||
action: d.action,
|
||||
tools: d.tools,
|
||||
metadata: d.metadata,
|
||||
description: d.description,
|
||||
labels: d.labels || [],
|
||||
}));
|
||||
|
||||
nodes.push({
|
||||
id: 'end' as JourneyNodeId,
|
||||
creationUtc: new Date().toISOString(),
|
||||
action: null,
|
||||
tools: [],
|
||||
metadata: {},
|
||||
description: null,
|
||||
labels: [],
|
||||
});
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
createEdge(journeyId: JourneyId, params: {
|
||||
source: JourneyNodeId;
|
||||
target: JourneyNodeId;
|
||||
condition?: string | null;
|
||||
}, projectRoot?: string): JourneyEdge {
|
||||
const creationUtc = new Date().toISOString();
|
||||
const edge: JourneyEdge = {
|
||||
id: `edge_${generateId()}`,
|
||||
creationUtc,
|
||||
source: params.source,
|
||||
target: params.target,
|
||||
condition: params.condition ?? null,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const edges = readCollection<EdgeDocument>('edges', projectRoot);
|
||||
edges.push({
|
||||
id: edge.id,
|
||||
journey_id: journeyId,
|
||||
creation_utc: creationUtc,
|
||||
source: params.source,
|
||||
target: params.target,
|
||||
condition: params.condition ?? null,
|
||||
metadata: {},
|
||||
});
|
||||
writeCollection('edges', edges, projectRoot);
|
||||
|
||||
return edge;
|
||||
}
|
||||
|
||||
listEdges(journeyId: JourneyId, nodeId?: JourneyNodeId, projectRoot?: string): JourneyEdge[] {
|
||||
let docs = readCollection<EdgeDocument>('edges', projectRoot)
|
||||
.filter(e => e.journey_id === journeyId);
|
||||
|
||||
if (nodeId) {
|
||||
docs = docs.filter(e => e.source === nodeId || e.target === nodeId);
|
||||
}
|
||||
|
||||
return docs.map(d => ({
|
||||
id: d.id,
|
||||
creationUtc: d.creation_utc,
|
||||
source: d.source,
|
||||
target: d.target,
|
||||
condition: d.condition,
|
||||
metadata: d.metadata,
|
||||
}));
|
||||
}
|
||||
|
||||
deleteEdge(edgeId: JourneyEdgeId, projectRoot?: string): void {
|
||||
let edges = readCollection<EdgeDocument>('edges', projectRoot);
|
||||
const idx = edges.findIndex(e => e.id === edgeId);
|
||||
if (idx === -1) throw new Error(`Edge '${edgeId}' not found`);
|
||||
edges.splice(idx, 1);
|
||||
writeCollection('edges', edges, projectRoot);
|
||||
}
|
||||
|
||||
addTrigger(journeyId: JourneyId, trigger: GuidelineId, projectRoot?: string): boolean {
|
||||
const triggers = readCollection<TriggerDocument>('triggers', projectRoot);
|
||||
if (triggers.find(t => t.journey_id === journeyId && t.trigger === trigger)) {
|
||||
return false;
|
||||
}
|
||||
triggers.push({
|
||||
id: `trg_${generateId()}`,
|
||||
journey_id: journeyId,
|
||||
trigger,
|
||||
creation_utc: new Date().toISOString(),
|
||||
});
|
||||
writeCollection('triggers', triggers, projectRoot);
|
||||
return true;
|
||||
}
|
||||
|
||||
removeTrigger(journeyId: JourneyId, trigger: GuidelineId, projectRoot?: string): boolean {
|
||||
let triggers = readCollection<TriggerDocument>('triggers', projectRoot);
|
||||
const len = triggers.length;
|
||||
triggers = triggers.filter(t => !(t.journey_id === journeyId && t.trigger === trigger));
|
||||
writeCollection('triggers', triggers, projectRoot);
|
||||
return triggers.length < len;
|
||||
}
|
||||
}
|
||||
111
apps/server/src/services/audit/runs-dir.ts
Normal file
111
apps/server/src/services/audit/runs-dir.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
export const INDEX_SCHEMA_VERSION = '1.1';
|
||||
export const GITIGNORE_CONTENT = `# boocode audit runs
|
||||
/*
|
||||
!index.json
|
||||
`;
|
||||
|
||||
export interface IndexEntry {
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
task?: string;
|
||||
skill?: string;
|
||||
created?: string;
|
||||
last_updated?: string;
|
||||
record_count?: number;
|
||||
anomaly_count?: number;
|
||||
max_anomaly_level?: string;
|
||||
}
|
||||
|
||||
export interface IndexFile {
|
||||
schema_version: string;
|
||||
entries: IndexEntry[];
|
||||
}
|
||||
|
||||
function findRunsDirFrom(start: string): string {
|
||||
const explicit = process.env['AUDIT_DOT_DIR']?.trim();
|
||||
const candidates = explicit ? [explicit] : ['.boo'];
|
||||
let cur = resolve(start);
|
||||
while (true) {
|
||||
for (const basename of candidates) {
|
||||
const candidate = join(cur, basename, 'runs');
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
const parent = resolve(cur, '..');
|
||||
if (parent === cur) break;
|
||||
cur = parent;
|
||||
}
|
||||
const defaultBasename = explicit || '.boo';
|
||||
return join(resolve(start), defaultBasename, 'runs');
|
||||
}
|
||||
|
||||
export function findRunsDir(projectRoot?: string): string {
|
||||
return findRunsDirFrom(projectRoot || process.cwd());
|
||||
}
|
||||
|
||||
export function ensureRunsDir(projectRoot?: string): string {
|
||||
const dir = findRunsDir(projectRoot);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const gitignorePath = join(dir, '.gitignore');
|
||||
if (!existsSync(gitignorePath)) {
|
||||
writeFileSync(gitignorePath, GITIGNORE_CONTENT, 'utf-8');
|
||||
}
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function readCurrentSession(projectRoot?: string): string | null {
|
||||
const path = join(ensureRunsDir(projectRoot), '.current_session');
|
||||
try {
|
||||
return readFileSync(path, 'utf-8').trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCurrentSession(sessionId: string, projectRoot?: string): void {
|
||||
writeFileSync(join(ensureRunsDir(projectRoot), '.current_session'), sessionId, 'utf-8');
|
||||
}
|
||||
|
||||
export function clearCurrentSession(projectRoot?: string): void {
|
||||
const path = join(ensureRunsDir(projectRoot), '.current_session');
|
||||
try {
|
||||
writeFileSync(path, '', 'utf-8');
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
export function readIndex(projectRoot?: string): IndexFile {
|
||||
const path = join(ensureRunsDir(projectRoot), 'index.json');
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as IndexFile;
|
||||
} catch {
|
||||
return { schema_version: INDEX_SCHEMA_VERSION, entries: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeIndex(index: IndexFile, projectRoot?: string): void {
|
||||
const runsDir = ensureRunsDir(projectRoot);
|
||||
writeFileSync(join(runsDir, 'index.json'), JSON.stringify(index, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export function updateIndexEntry(entry: IndexEntry, projectRoot?: string): void {
|
||||
const idx = readIndex(projectRoot);
|
||||
const existing = idx.entries.find(e => e.id === entry.id);
|
||||
if (existing) {
|
||||
Object.assign(existing, entry);
|
||||
} else {
|
||||
idx.entries.push({ ...entry });
|
||||
}
|
||||
writeIndex(idx, projectRoot);
|
||||
}
|
||||
|
||||
export function findInProgressSessions(projectRoot?: string): IndexEntry[] {
|
||||
const idx = readIndex(projectRoot);
|
||||
return idx.entries.filter(e => e.status === 'in_progress');
|
||||
}
|
||||
236
apps/server/src/services/audit/session-manager.ts
Normal file
236
apps/server/src/services/audit/session-manager.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
ensureRunsDir,
|
||||
readCurrentSession,
|
||||
writeCurrentSession,
|
||||
clearCurrentSession,
|
||||
updateIndexEntry,
|
||||
findInProgressSessions,
|
||||
readIndex,
|
||||
type IndexEntry,
|
||||
} from './runs-dir.js';
|
||||
|
||||
export interface SessionJson {
|
||||
session_id: string;
|
||||
task: string;
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
status: 'in_progress' | 'completed';
|
||||
expected_record_types?: string[];
|
||||
total_records?: number;
|
||||
}
|
||||
|
||||
export function generateSessionId(): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const h = String(now.getHours()).padStart(2, '0');
|
||||
const min = String(now.getMinutes()).padStart(2, '0');
|
||||
return `adhoc_${y}${m}${d}_${h}${min}`;
|
||||
}
|
||||
|
||||
export function isoNow(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function createSession(
|
||||
task: string,
|
||||
sessionId?: string,
|
||||
projectRoot?: string,
|
||||
): string {
|
||||
const sid = sessionId || generateSessionId();
|
||||
const runsDir = ensureRunsDir(projectRoot);
|
||||
const sessionDir = join(runsDir, sid);
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
|
||||
const session: SessionJson = {
|
||||
session_id: sid,
|
||||
task,
|
||||
start_time: isoNow(),
|
||||
status: 'in_progress',
|
||||
expected_record_types: ['data', 'change', 'conversation'],
|
||||
};
|
||||
|
||||
writeFileSync(join(sessionDir, 'session.json'), JSON.stringify(session, null, 2), 'utf-8');
|
||||
writeCurrentSession(sid, projectRoot);
|
||||
|
||||
updateIndexEntry({
|
||||
id: sid,
|
||||
type: 'adhoc',
|
||||
status: 'in_progress',
|
||||
task,
|
||||
created: session.start_time,
|
||||
last_updated: session.start_time,
|
||||
}, projectRoot);
|
||||
|
||||
return sid;
|
||||
}
|
||||
|
||||
export function getSessionDir(sessionId: string, projectRoot?: string): string {
|
||||
return join(ensureRunsDir(projectRoot), sessionId);
|
||||
}
|
||||
|
||||
export function getActiveSession(projectRoot?: string): SessionJson | null {
|
||||
const sid = readCurrentSession(projectRoot);
|
||||
if (!sid) return null;
|
||||
return readSession(sid, projectRoot);
|
||||
}
|
||||
|
||||
export function readSession(sessionId: string, projectRoot?: string): SessionJson | null {
|
||||
const path = join(getSessionDir(sessionId, projectRoot), 'session.json');
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as SessionJson;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSession(
|
||||
sessionId: string,
|
||||
updates: Partial<SessionJson>,
|
||||
projectRoot?: string,
|
||||
): void {
|
||||
const session = readSession(sessionId, projectRoot) || { session_id: sessionId, task: '', start_time: isoNow(), status: 'in_progress' as const };
|
||||
Object.assign(session, updates);
|
||||
writeFileSync(
|
||||
join(getSessionDir(sessionId, projectRoot), 'session.json'),
|
||||
JSON.stringify(session, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
export function endSession(sessionId: string, projectRoot?: string): void {
|
||||
updateSession(sessionId, { status: 'completed', end_time: isoNow() }, projectRoot);
|
||||
updateIndexEntry({ id: sessionId, type: 'adhoc', status: 'completed', last_updated: isoNow() }, projectRoot);
|
||||
clearCurrentSession(projectRoot);
|
||||
}
|
||||
|
||||
export function appendToTrail(sessionId: string, records: Record<string, unknown>[], projectRoot?: string): void {
|
||||
const trailPath = join(getSessionDir(sessionId, projectRoot), 'audit_trail.jsonl');
|
||||
const lines = records.map(r => JSON.stringify(r)).join('\n') + '\n';
|
||||
appendFileSync(trailPath, lines, 'utf-8');
|
||||
}
|
||||
|
||||
export function readTrail(sessionId: string, projectRoot?: string): Record<string, unknown>[] {
|
||||
const trailPath = join(getSessionDir(sessionId, projectRoot), 'audit_trail.jsonl');
|
||||
try {
|
||||
const content = readFileSync(trailPath, 'utf-8').trim();
|
||||
if (!content) return [];
|
||||
return content.split('\n').filter(Boolean).map(line => JSON.parse(line) as Record<string, unknown>);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface RecoverySummary {
|
||||
sessionId: string;
|
||||
task: string;
|
||||
recentActivity: IndexEntry[];
|
||||
userCorrections: Record<string, unknown>[];
|
||||
unresolvedIssues: string[];
|
||||
recommendedPriorities: string[];
|
||||
level: number;
|
||||
}
|
||||
|
||||
export function recoverContext(
|
||||
sessionId: string,
|
||||
level: number,
|
||||
projectRoot?: string,
|
||||
): RecoverySummary {
|
||||
const session = readSession(sessionId, projectRoot);
|
||||
const idx = readIndex(projectRoot);
|
||||
const recentActivity = idx.entries.slice(-5);
|
||||
const trail = readTrail(sessionId, projectRoot);
|
||||
const userCorrections = trail.filter(r => r['action_type'] === 'user_correction');
|
||||
|
||||
const summary: RecoverySummary = {
|
||||
sessionId,
|
||||
task: session?.task || '(unknown)',
|
||||
recentActivity,
|
||||
userCorrections,
|
||||
unresolvedIssues: [],
|
||||
recommendedPriorities: [],
|
||||
level,
|
||||
};
|
||||
|
||||
if (level >= 1) {
|
||||
const last = trail.slice(-3);
|
||||
if (last.length > 0) {
|
||||
summary.recommendedPriorities.push(`Last action: ${JSON.stringify(last[last.length - 1]?.['action'] || 'none')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (level >= 3) {
|
||||
summary.recommendedPriorities.push(`Full trail: ${trail.length} records`);
|
||||
}
|
||||
|
||||
let checkCount = 0;
|
||||
for (const entry of recentActivity) {
|
||||
if (entry.status === 'in_progress' && entry.id !== sessionId) {
|
||||
summary.unresolvedIssues.push(`Unfinished session: ${entry.id} (${entry.task || 'no task'})`);
|
||||
checkCount++;
|
||||
if (checkCount >= 3) break;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function checkUnfinishedSessions(projectRoot?: string): IndexEntry[] {
|
||||
return findInProgressSessions(projectRoot);
|
||||
}
|
||||
|
||||
export function generateSessionSummary(sessionId: string, projectRoot?: string): string {
|
||||
const session = readSession(sessionId, projectRoot);
|
||||
const trail = readTrail(sessionId, projectRoot);
|
||||
const corrections = trail.filter(r => r['action_type'] === 'user_correction');
|
||||
const changes = trail.filter(r => r['action'] === 'edit_file' || r['action'] === 'create_file' || r['action'] === 'delete_file');
|
||||
|
||||
const lines: string[] = [
|
||||
`# Session Summary | ${sessionId}`,
|
||||
'',
|
||||
`## Task: ${session?.task || '(unknown)'}`,
|
||||
`## Time: ${session?.start_time || '?'} → ${session?.end_time || 'in_progress'}`,
|
||||
`## Status: ${session?.status || 'unknown'}`,
|
||||
'',
|
||||
'## Completed Work',
|
||||
];
|
||||
|
||||
for (const r of trail) {
|
||||
if (r['action']) {
|
||||
lines.push(`- ${r['action']}: ${r['detail'] || r['reason'] || '(no detail)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (corrections.length > 0) {
|
||||
lines.push('', '## User Corrections');
|
||||
for (const c of corrections) {
|
||||
lines.push(`- Original: ${c['original_claim']}`);
|
||||
lines.push(` Correction: ${c['correction']}`);
|
||||
if (c['principle_extracted']) {
|
||||
lines.push(` Principle: ${c['principle_extracted']}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length > 0) {
|
||||
lines.push('', '## Files Changed');
|
||||
const fileSet = new Set<string>();
|
||||
for (const c of changes) {
|
||||
const files = c['files'];
|
||||
if (Array.isArray(files)) {
|
||||
for (const f of files) fileSet.add(String(f));
|
||||
}
|
||||
}
|
||||
for (const f of fileSet) lines.push(`- ${f}`);
|
||||
}
|
||||
|
||||
lines.push('', '## Stats');
|
||||
lines.push(`- Total records: ${trail.length}`);
|
||||
lines.push(`- Corrections: ${corrections.length}`);
|
||||
lines.push(`- File changes: ${changes.length}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user