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:
747
apps/coder/src/services/audit-session.ts
Normal file
747
apps/coder/src/services/audit-session.ts
Normal file
@@ -0,0 +1,747 @@
|
||||
import { mkdir, readFile, writeFile, readdir, rm, appendFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
export const RUNS_REL = '.boo/runs';
|
||||
export const DAILY_REL = '.boo/runs/daily';
|
||||
export const GUIDELINES_REL = '.boo/guidelines';
|
||||
|
||||
export interface SessionJson {
|
||||
session_id: string;
|
||||
task: string;
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
status: 'in_progress' | 'completed';
|
||||
expected_record_types: string[];
|
||||
}
|
||||
|
||||
export interface AuditTrailEntry {
|
||||
timestamp: string;
|
||||
record_type: string;
|
||||
action_type: string;
|
||||
tool?: string;
|
||||
files?: string[];
|
||||
detail?: string;
|
||||
input?: string;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export interface IndexEntry {
|
||||
id: string;
|
||||
task: string;
|
||||
status: string;
|
||||
record_count: number;
|
||||
start_time: string;
|
||||
max_anomaly_level?: string;
|
||||
}
|
||||
|
||||
export interface IndexJson {
|
||||
entries: IndexEntry[];
|
||||
}
|
||||
|
||||
export interface StartSessionResult {
|
||||
sessionId: string;
|
||||
contextSummary: {
|
||||
recentActivity: IndexEntry[];
|
||||
userCorrections: UserCorrectionRecord[];
|
||||
unfinishedSessions: SessionJson[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface EndSessionResult {
|
||||
sessionId: string;
|
||||
integrity: IntegrityCheck[];
|
||||
correctionCount: number;
|
||||
summaryPath: string;
|
||||
}
|
||||
|
||||
export interface IntegrityCheck {
|
||||
check: string;
|
||||
passed: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface RecoverResult {
|
||||
level: number;
|
||||
sessionId?: string;
|
||||
task?: string;
|
||||
recentActivity: IndexEntry[];
|
||||
lastTrailEntries: AuditTrailEntry[];
|
||||
userCorrections: UserCorrectionRecord[];
|
||||
conclusions: string[];
|
||||
dailyAnomalies: string[];
|
||||
dailyBacklog: string[];
|
||||
fullTrail?: AuditTrailEntry[];
|
||||
anomalies?: string[];
|
||||
}
|
||||
|
||||
export interface DailyReport {
|
||||
date: string;
|
||||
sections: {
|
||||
taskOverview: string;
|
||||
operationStats: { label: string; count: number }[];
|
||||
changes: { time: string; target: string; detail: string }[];
|
||||
userFeedback: { feedback: string; resolution: string; persistedTo: string }[];
|
||||
anomalyAlerts: string[];
|
||||
backlogTracking: string[];
|
||||
integritySummary: string;
|
||||
};
|
||||
path: string;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
function runsDir(basePath?: string): string {
|
||||
return resolve(basePath ?? process.cwd(), RUNS_REL);
|
||||
}
|
||||
|
||||
function dailyDir(basePath?: string): string {
|
||||
return resolve(basePath ?? process.cwd(), DAILY_REL);
|
||||
}
|
||||
|
||||
function sessionDir(sessionId: string, basePath?: string): string {
|
||||
return join(runsDir(basePath), sessionId);
|
||||
}
|
||||
|
||||
function currentSessionPath(basePath?: string): string {
|
||||
return join(runsDir(basePath), '.current_session');
|
||||
}
|
||||
|
||||
function indexJsonPath(basePath?: string): string {
|
||||
return join(runsDir(basePath), 'index.json');
|
||||
}
|
||||
|
||||
function auditBufferPath(basePath?: string): string {
|
||||
return join(runsDir(basePath), 'audit_buffer.jsonl');
|
||||
}
|
||||
|
||||
function auditPendingPath(basePath?: string): string {
|
||||
return join(runsDir(basePath), 'audit_pending.jsonl');
|
||||
}
|
||||
|
||||
function trailPath(sessionId: string, basePath?: string): string {
|
||||
return join(sessionDir(sessionId, basePath), 'audit_trail.jsonl');
|
||||
}
|
||||
|
||||
function sessionJsonPath(sessionId: string, basePath?: string): string {
|
||||
return join(sessionDir(sessionId, basePath), 'session.json');
|
||||
}
|
||||
|
||||
function summaryPath(sessionId: string, basePath?: string): string {
|
||||
return join(sessionDir(sessionId, basePath), 'session_summary.md');
|
||||
}
|
||||
|
||||
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 hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
return `adhoc_${y}${m}${d}_${hh}${mm}`;
|
||||
}
|
||||
|
||||
function isoNow(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function isoDate(d?: Date): string {
|
||||
const dt = d ?? new Date();
|
||||
return `${dt.getFullYear()}${String(dt.getMonth() + 1).padStart(2, '0')}${String(dt.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function isTodayIso(iso: string): boolean {
|
||||
return iso.startsWith(new Date().toISOString().slice(0, 10));
|
||||
}
|
||||
|
||||
function tryParseJson<T>(raw: string): T | null {
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDir(p: string): Promise<void> {
|
||||
if (!existsSync(p)) {
|
||||
await mkdir(p, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function readLines(p: string): Promise<string[]> {
|
||||
try {
|
||||
const content = await readFile(p, 'utf-8');
|
||||
return content.split('\n').filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(p: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await readFile(p, 'utf-8');
|
||||
return tryParseJson<T>(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appendLine(p: string, line: string): Promise<void> {
|
||||
return appendFile(p, line + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
async function clearFile(p: string): Promise<void> {
|
||||
try {
|
||||
await writeFile(p, '', 'utf-8');
|
||||
} catch {
|
||||
// File may not exist
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentSession(basePath?: string): Promise<string | null> {
|
||||
try {
|
||||
const raw = await readFile(currentSessionPath(basePath), 'utf-8');
|
||||
return raw.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessionJson(sessionId: string, basePath?: string): Promise<SessionJson | null> {
|
||||
return readJsonFile<SessionJson>(sessionJsonPath(sessionId, basePath));
|
||||
}
|
||||
|
||||
export async function getIndex(basePath?: string): Promise<IndexJson | null> {
|
||||
return readJsonFile<IndexJson>(indexJsonPath(basePath));
|
||||
}
|
||||
|
||||
async function writeIndex(entries: IndexEntry[], basePath?: string): Promise<void> {
|
||||
await ensureDir(runsDir(basePath));
|
||||
await writeFile(indexJsonPath(basePath), JSON.stringify({ entries }, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
async function appendIndex(sessionId: string, task: string, basePath?: string): Promise<void> {
|
||||
const existing = await getIndex(basePath);
|
||||
const entry: IndexEntry = {
|
||||
id: sessionId,
|
||||
task,
|
||||
status: 'in_progress',
|
||||
record_count: 0,
|
||||
start_time: isoNow(),
|
||||
};
|
||||
const entries = [entry, ...(existing?.entries ?? [])].slice(0, 100);
|
||||
await writeIndex(entries, basePath);
|
||||
}
|
||||
|
||||
async function updateIndexStatus(sessionId: string, status: string, basePath?: string): Promise<void> {
|
||||
const idx = await getIndex(basePath);
|
||||
if (!idx) return;
|
||||
for (const e of idx.entries) {
|
||||
if (e.id === sessionId) {
|
||||
e.status = status;
|
||||
}
|
||||
}
|
||||
await writeIndex(idx.entries, basePath);
|
||||
}
|
||||
|
||||
export async function startSession(task: string, basePath?: string): Promise<StartSessionResult> {
|
||||
const sessionId = generateSessionId();
|
||||
const sDir = sessionDir(sessionId, basePath);
|
||||
await ensureDir(sDir);
|
||||
|
||||
const session: SessionJson = {
|
||||
session_id: sessionId,
|
||||
task,
|
||||
start_time: isoNow(),
|
||||
status: 'in_progress',
|
||||
expected_record_types: ['data', 'change', 'conversation'],
|
||||
};
|
||||
|
||||
await writeFile(sessionJsonPath(sessionId, basePath), JSON.stringify(session, null, 2), 'utf-8');
|
||||
await writeFile(currentSessionPath(basePath), sessionId, 'utf-8');
|
||||
await appendIndex(sessionId, task, basePath);
|
||||
|
||||
// L0 context recovery
|
||||
const idx = await getIndex(basePath);
|
||||
const recentActivity = idx?.entries.slice(0, 5) ?? [];
|
||||
|
||||
// L2 user correction scan
|
||||
const allCorrections = await scanAllTrailsForCorrections(basePath);
|
||||
|
||||
// Check for unfinished sessions
|
||||
const unfinishedSessions = await findUnfinishedSessions(basePath);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
contextSummary: {
|
||||
recentActivity,
|
||||
userCorrections: allCorrections,
|
||||
unfinishedSessions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function findUnfinishedSessions(basePath?: string): Promise<SessionJson[]> {
|
||||
const rDir = runsDir(basePath);
|
||||
if (!existsSync(rDir)) return [];
|
||||
|
||||
const entries = await readdir(rDir, { withFileTypes: true });
|
||||
const unfinished: SessionJson[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const sess = await getSessionJson(entry.name, basePath);
|
||||
if (sess && sess.status === 'in_progress') {
|
||||
unfinished.push(sess);
|
||||
}
|
||||
}
|
||||
|
||||
return unfinished;
|
||||
}
|
||||
|
||||
async function scanAllTrailsForCorrections(basePath?: string): Promise<UserCorrectionRecord[]> {
|
||||
const rDir = runsDir(basePath);
|
||||
if (!existsSync(rDir)) return [];
|
||||
|
||||
const entries = await readdir(rDir, { withFileTypes: true });
|
||||
const corrections: UserCorrectionRecord[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const lines = await readLines(trailPath(entry.name, basePath));
|
||||
for (const line of lines) {
|
||||
const record = tryParseJson<UserCorrectionRecord>(line);
|
||||
if (record?.action_type === 'user_correction') {
|
||||
corrections.push(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also scan audit_pending.jsonl
|
||||
const pendingLines = await readLines(auditPendingPath(basePath));
|
||||
for (const line of pendingLines) {
|
||||
const record = tryParseJson<UserCorrectionRecord>(line);
|
||||
if (record?.action_type === 'user_correction') {
|
||||
corrections.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
return corrections;
|
||||
}
|
||||
|
||||
export async function endSession(basePath?: string): Promise<EndSessionResult | null> {
|
||||
const sessionId = await getCurrentSession(basePath);
|
||||
if (!sessionId) return null;
|
||||
|
||||
const sDir = sessionDir(sessionId, basePath);
|
||||
await ensureDir(sDir);
|
||||
|
||||
// Collect remaining buffer data
|
||||
const bufferLines = await readLines(auditBufferPath(basePath));
|
||||
const pendingLines = await readLines(auditPendingPath(basePath));
|
||||
const allRemaining = [...bufferLines, ...pendingLines];
|
||||
|
||||
// Append to audit_trail.jsonl
|
||||
const trail = trailPath(sessionId, basePath);
|
||||
if (allRemaining.length > 0) {
|
||||
await appendFile(trail, allRemaining.join('\n') + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
// Clear buffer files
|
||||
await clearFile(auditBufferPath(basePath));
|
||||
await clearFile(auditPendingPath(basePath));
|
||||
|
||||
// Read current trail for stats
|
||||
const trailLines = await readLines(trail);
|
||||
|
||||
// Extract user_correction records
|
||||
const corrections: UserCorrectionRecord[] = [];
|
||||
for (const line of trailLines) {
|
||||
const record = tryParseJson<UserCorrectionRecord>(line);
|
||||
if (record?.action_type === 'user_correction') {
|
||||
corrections.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
// Integrity checks
|
||||
const integrity: IntegrityCheck[] = [
|
||||
{
|
||||
check: 'Audit records exist',
|
||||
passed: trailLines.length > 0,
|
||||
detail: trailLines.length > 0 ? `${trailLines.length} records` : 'No audit records found',
|
||||
},
|
||||
{
|
||||
check: 'File modifications tracked',
|
||||
passed: trailLines.some((l) => {
|
||||
const r = tryParseJson<AuditTrailEntry>(l);
|
||||
return r && (r.tool === 'Write' || r.tool === 'Edit');
|
||||
}),
|
||||
detail: 'Checking for Write/Edit tool entries',
|
||||
},
|
||||
{
|
||||
check: 'User corrections persisted',
|
||||
passed: corrections.every((c) => (c.persisted_to?.length ?? 0) > 0),
|
||||
detail: corrections.length > 0
|
||||
? `${corrections.length} corrections found, ${corrections.filter((c) => (c.persisted_to?.length ?? 0) > 0).length} persisted`
|
||||
: 'No corrections to persist',
|
||||
},
|
||||
];
|
||||
|
||||
// Generate session summary
|
||||
const summaryContent = generateSessionSummary(sessionId, trailLines, corrections);
|
||||
const summaryFile = summaryPath(sessionId, basePath);
|
||||
await writeFile(summaryFile, summaryContent, 'utf-8');
|
||||
|
||||
// Update session.json
|
||||
const session = await getSessionJson(sessionId, basePath);
|
||||
if (session) {
|
||||
session.status = 'completed';
|
||||
session.end_time = isoNow();
|
||||
await writeFile(sessionJsonPath(sessionId, basePath), JSON.stringify(session, null, 2), 'utf-8');
|
||||
await updateIndexStatus(sessionId, 'completed', basePath);
|
||||
}
|
||||
|
||||
// Update index.json record count
|
||||
const idx = await getIndex(basePath);
|
||||
if (idx) {
|
||||
for (const e of idx.entries) {
|
||||
if (e.id === sessionId) {
|
||||
e.record_count = trailLines.length;
|
||||
e.status = 'completed';
|
||||
}
|
||||
}
|
||||
await writeIndex(idx.entries, basePath);
|
||||
}
|
||||
|
||||
// Clear .current_session
|
||||
try {
|
||||
await rm(currentSessionPath(basePath));
|
||||
} catch {
|
||||
// Ok if already gone
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
integrity,
|
||||
correctionCount: corrections.length,
|
||||
summaryPath: summaryFile,
|
||||
};
|
||||
}
|
||||
|
||||
function generateSessionSummary(
|
||||
sessionId: string,
|
||||
trailLines: string[],
|
||||
corrections: UserCorrectionRecord[],
|
||||
): string {
|
||||
const actions: string[] = [];
|
||||
const outputs: string[] = [];
|
||||
|
||||
for (const line of trailLines) {
|
||||
const record = tryParseJson<AuditTrailEntry>(line);
|
||||
if (record) {
|
||||
if (record.action_type) actions.push(record.action_type);
|
||||
if (record.output) outputs.push(record.output);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
`# Session Summary | ${sessionId}`,
|
||||
'',
|
||||
`## Time: ${isoNow()}`,
|
||||
`## Status: completed`,
|
||||
'',
|
||||
'## Completed work',
|
||||
...actions.map((a) => `- ${a}`),
|
||||
'',
|
||||
'## Key conclusions',
|
||||
...outputs.map((o) => `- ${o}`),
|
||||
'',
|
||||
'## User corrections',
|
||||
...(corrections.length > 0
|
||||
? corrections.map((c) => `- ${c.original_claim} → ${c.correction} (${c.principle_extracted})`)
|
||||
: ['- None']),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function recoverSession(
|
||||
level: number,
|
||||
specificSessionId?: string,
|
||||
basePath?: string,
|
||||
): Promise<RecoverResult> {
|
||||
const result: RecoverResult = { level, recentActivity: [], lastTrailEntries: [], userCorrections: [], conclusions: [], dailyAnomalies: [], dailyBacklog: [] };
|
||||
|
||||
// L0: index summary
|
||||
const idx = await getIndex(basePath);
|
||||
result.recentActivity = idx?.entries.slice(0, 5) ?? [];
|
||||
|
||||
if (level === 0) return result;
|
||||
|
||||
// L1: current session + last 3 trail entries
|
||||
let activeSessionId = specificSessionId ?? await getCurrentSession(basePath);
|
||||
if (activeSessionId) {
|
||||
result.sessionId = activeSessionId;
|
||||
const session = await getSessionJson(activeSessionId, basePath);
|
||||
if (session) {
|
||||
result.task = session.task;
|
||||
}
|
||||
|
||||
const trailLines = await readLines(trailPath(activeSessionId, basePath));
|
||||
result.lastTrailEntries = trailLines.slice(-3).map((l) => {
|
||||
const r = tryParseJson<AuditTrailEntry>(l);
|
||||
return r ?? { timestamp: '', record_type: '', action_type: '', input: l, output: l };
|
||||
});
|
||||
}
|
||||
|
||||
if (level === 1) return result;
|
||||
|
||||
// L2: user corrections + conclusions + daily anomalies
|
||||
result.userCorrections = await scanAllTrailsForCorrections(basePath);
|
||||
|
||||
// Extract conclusions from trail entries
|
||||
const allTrailLines = await readLines(trailPath(activeSessionId ?? '', basePath));
|
||||
for (const line of allTrailLines) {
|
||||
const record = tryParseJson<AuditTrailEntry>(line);
|
||||
if (record?.output) {
|
||||
result.conclusions.push(record.output);
|
||||
}
|
||||
}
|
||||
|
||||
// Read daily reports for anomalies + backlog
|
||||
const dDir = dailyDir(basePath);
|
||||
if (existsSync(dDir)) {
|
||||
const dailyFiles = (await readdir(dDir)).filter((f) => f.endsWith('_daily.md')).sort().reverse();
|
||||
if (dailyFiles.length > 0) {
|
||||
const latest = await readFile(join(dDir, dailyFiles[0]!), 'utf-8');
|
||||
const anomalies = latest.match(/## (?:四|4).*?[\s\S]*?(?=##|$)/);
|
||||
if (anomalies) result.dailyAnomalies.push(anomalies[0]);
|
||||
const backlog = latest.match(/## (?:六|6).*?[\s\S]*?(?=##|$)/);
|
||||
if (backlog) result.dailyBacklog.push(backlog[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (level === 2) return result;
|
||||
|
||||
// L3: full trail + pending
|
||||
if (level >= 3) {
|
||||
if (activeSessionId) {
|
||||
const fullLines = await readLines(trailPath(activeSessionId, basePath));
|
||||
result.fullTrail = fullLines.map((l) => {
|
||||
const r = tryParseJson<AuditTrailEntry>(l);
|
||||
return r ?? { timestamp: '', record_type: '', action_type: '', input: l, output: l };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function generateDailyReport(
|
||||
targetDate?: string,
|
||||
review?: boolean,
|
||||
basePath?: string,
|
||||
): Promise<DailyReport> {
|
||||
const date = targetDate ?? isoDate();
|
||||
const idx = await getIndex(basePath);
|
||||
const rDir = runsDir(basePath);
|
||||
|
||||
const todayEntries = (idx?.entries ?? []).filter((e) => e.start_time.startsWith(date.slice(0, 4) + '-' + date.slice(4, 6) + '-' + date.slice(6, 8)));
|
||||
|
||||
let totalWriteEdit = 0;
|
||||
let totalBash = 0;
|
||||
let totalAuditBlocks = 0;
|
||||
const changes: { time: string; target: string; detail: string }[] = [];
|
||||
const feedback: { feedback: string; resolution: string; persistedTo: string }[] = [];
|
||||
const anomalies: string[] = [];
|
||||
|
||||
for (const entry of todayEntries) {
|
||||
const lines = await readLines(trailPath(entry.id, basePath));
|
||||
for (const line of lines) {
|
||||
const record = tryParseJson<AuditTrailEntry>(line);
|
||||
if (!record) continue;
|
||||
if (record.tool === 'Write' || record.tool === 'Edit') totalWriteEdit++;
|
||||
if (record.tool === 'Bash') totalBash++;
|
||||
if (record.action_type === 'audit_block') totalAuditBlocks++;
|
||||
if (record.tool && (record.tool === 'Write' || record.tool === 'Edit') && record.files) {
|
||||
changes.push({ time: record.timestamp, target: record.files.join(', '), detail: record.detail ?? '' });
|
||||
}
|
||||
if (record.action_type === 'user_correction') {
|
||||
const uc = record as unknown as UserCorrectionRecord;
|
||||
feedback.push({ feedback: uc.original_claim, resolution: uc.correction, persistedTo: (uc.persisted_to ?? []).join(', ') });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for anomalies.json
|
||||
if (existsSync(rDir)) {
|
||||
const sessionDirs = await readdir(rDir, { withFileTypes: true });
|
||||
for (const d of sessionDirs) {
|
||||
if (!d.isDirectory()) continue;
|
||||
const anomPath = join(rDir, d.name, 'anomalies.json');
|
||||
if (existsSync(anomPath)) {
|
||||
const anomContent = await readFile(anomPath, 'utf-8');
|
||||
anomalies.push(`[${d.name}] ${anomContent.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read previous day backlog
|
||||
const prevDate = isoDate(new Date(Date.now() - 86400000));
|
||||
let backlog: string[] = [];
|
||||
const prevDailyPath = join(dailyDir(basePath), `${prevDate}_daily.md`);
|
||||
if (existsSync(prevDailyPath)) {
|
||||
const prevContent = await readFile(prevDailyPath, 'utf-8');
|
||||
const m = prevContent.match(/## (?:六|6|明日待办)[\s\S]*?(?=##|$)/);
|
||||
if (m) backlog = m[0].split('\n').filter((l) => l.trim().startsWith('-')).map((l) => l.replace(/^-\s*/, ''));
|
||||
}
|
||||
|
||||
const reportPath = join(dailyDir(basePath), `${date}_daily.md`);
|
||||
await ensureDir(dailyDir(basePath));
|
||||
|
||||
const sections = {
|
||||
taskOverview: todayEntries.length > 0
|
||||
? todayEntries.map((e) => `| ${e.id} | ${e.task} | ${e.status} | ${e.record_count} |`).join('\n')
|
||||
: 'No activity',
|
||||
operationStats: [
|
||||
{ label: 'Write/Edit operations', count: totalWriteEdit },
|
||||
{ label: 'Bash executions', count: totalBash },
|
||||
{ label: 'Audit blocks', count: totalAuditBlocks },
|
||||
],
|
||||
changes,
|
||||
userFeedback: feedback,
|
||||
anomalyAlerts: anomalies,
|
||||
backlogTracking: backlog,
|
||||
integritySummary: [
|
||||
`| All sessions have audit records | ${todayEntries.every((e) => e.record_count > 0) ? '✅' : '⚠️'} |`,
|
||||
`| Audit blocks persisted | ${totalAuditBlocks > 0 ? '✅' : '⚠️'} |`,
|
||||
`| User corrections persisted | ${feedback.every((f) => f.persistedTo.length > 0) ? '✅' : '⚠️'} |`,
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
const reportContent = generateDailyReportContent(date, sections);
|
||||
await writeFile(reportPath, reportContent, 'utf-8');
|
||||
|
||||
// If review mode, also generate morning review
|
||||
if (review) {
|
||||
const reviewPath = join(dailyDir(basePath), `${date}_morning_review.md`);
|
||||
const reviewContent = generateMorningReview(sections, date);
|
||||
await writeFile(reviewPath, reviewContent, 'utf-8');
|
||||
}
|
||||
|
||||
return { date, sections, path: reportPath };
|
||||
}
|
||||
|
||||
function generateDailyReportContent(date: string, sections: DailyReport['sections']): string {
|
||||
return [
|
||||
`# Work Report | ${date}`,
|
||||
'',
|
||||
`> Auto-generated: ${isoNow()}`,
|
||||
`> Data source: .boo/runs/index.json + session audit_trail`,
|
||||
`> Coverage: ${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)} 00:00 — 23:59`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## I. Task Overview',
|
||||
'',
|
||||
'| Session ID | Task | Status | Records |',
|
||||
'|-----------|------|--------|---------|',
|
||||
sections.taskOverview,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## II. Operation Stats',
|
||||
'',
|
||||
'| Metric | Count |',
|
||||
'|--------|-------|',
|
||||
...sections.operationStats.map((s) => `| ${s.label} | ${s.count} |`),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## III. Change Records',
|
||||
'',
|
||||
...(sections.changes.length > 0
|
||||
? ['| Time | Target | Detail |', '|------|--------|--------|', ...sections.changes.map((c) => `| ${c.time} | ${c.target} | ${c.detail} |`)]
|
||||
: ['No changes recorded today.']),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## IV. User Feedback & Corrections',
|
||||
'',
|
||||
...(sections.userFeedback.length > 0
|
||||
? ['| Feedback | Resolution | Persisted To |', '|---------|------------|--------------|', ...sections.userFeedback.map((f) => `| ${f.feedback} | ${f.resolution} | ${f.persistedTo} |`)]
|
||||
: ['None.']),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## V. Anomaly Alerts',
|
||||
'',
|
||||
...(sections.anomalyAlerts.length > 0 ? sections.anomalyAlerts.map((a) => `- ${a}`) : ['None.']),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## VI. Backlog Tracking',
|
||||
'',
|
||||
...(sections.backlogTracking.length > 0 ? sections.backlogTracking.map((b) => `- ${b}`) : ['None.']),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## VII. Integrity Summary',
|
||||
'',
|
||||
'| Check | Result |',
|
||||
'|-------|--------|',
|
||||
sections.integritySummary,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function generateMorningReview(sections: DailyReport['sections'], date: string): string {
|
||||
const anomalies = sections.anomalyAlerts;
|
||||
const hasUnhandledAnomalies = anomalies.some((a) => !a.includes('resolved'));
|
||||
const hasUnpersistedFeedback = sections.userFeedback.some((f) => !f.persistedTo);
|
||||
const hasIncompleteBacklog = sections.backlogTracking.length > 0;
|
||||
|
||||
return [
|
||||
`# Morning Self-Review | ${date}`,
|
||||
'',
|
||||
`> Generated: ${isoNow()}`,
|
||||
'',
|
||||
'## Self-Correction Check',
|
||||
'',
|
||||
`- Unresolved anomalies: ${hasUnhandledAnomalies ? '⚠️ Yes — needs attention' : '✅ None'}`,
|
||||
`- Unpersisted user feedback: ${hasUnpersistedFeedback ? '⚠️ Needs documentation' : '✅ All persisted'}`,
|
||||
`- Outstanding backlog: ${hasIncompleteBacklog ? '⚠️ Carry-over items' : '✅ Clean slate'}`,
|
||||
'',
|
||||
'## Today\'s Recommended Priorities',
|
||||
'',
|
||||
...(sections.backlogTracking.length > 0
|
||||
? sections.backlogTracking.map((b) => `- [ ] ${b} (carry-over)`)
|
||||
: []),
|
||||
'- [ ] Review yesterday\'s user feedback and persist any remaining corrections',
|
||||
'- [ ] Continue highest-priority task from session overview',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function ensureBooDirs(basePath?: string): Promise<void> {
|
||||
await ensureDir(runsDir(basePath));
|
||||
await ensureDir(dailyDir(basePath));
|
||||
}
|
||||
|
||||
export async function writeAuditBuffer(entry: AuditTrailEntry, basePath?: string): Promise<void> {
|
||||
await ensureDir(runsDir(basePath));
|
||||
await appendLine(auditBufferPath(basePath), JSON.stringify(entry));
|
||||
}
|
||||
|
||||
export async function writeAuditPending(entry: AuditTrailEntry, basePath?: string): Promise<void> {
|
||||
await ensureDir(runsDir(basePath));
|
||||
await appendLine(auditPendingPath(basePath), JSON.stringify(entry));
|
||||
}
|
||||
186
apps/coder/src/services/correction-service.ts
Normal file
186
apps/coder/src/services/correction-service.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { readFile, writeFile, appendFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
export interface UserCorrectionRecord {
|
||||
id: string;
|
||||
record_type: 'conversation';
|
||||
action_type: 'user_correction';
|
||||
priority: 'critical_for_recovery';
|
||||
timestamp: string;
|
||||
original_claim: string;
|
||||
correction: string;
|
||||
principle_extracted: string;
|
||||
persisted_to: string[];
|
||||
}
|
||||
|
||||
const CORRECTIONS_REL = '.boo/corrections/index.json';
|
||||
|
||||
function correctionsDir(basePath?: string): string {
|
||||
return resolve(basePath ?? process.cwd(), '.boo/corrections');
|
||||
}
|
||||
|
||||
function correctionsPath(basePath?: string): string {
|
||||
return resolve(basePath ?? process.cwd(), CORRECTIONS_REL);
|
||||
}
|
||||
|
||||
function tryParseJson<T>(raw: string): T | null {
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CorrectionsIndex {
|
||||
corrections: UserCorrectionRecord[];
|
||||
}
|
||||
|
||||
async function readCorrections(basePath?: string): Promise<CorrectionsIndex> {
|
||||
try {
|
||||
const raw = await readFile(correctionsPath(basePath), 'utf-8');
|
||||
return tryParseJson<CorrectionsIndex>(raw) ?? { corrections: [] };
|
||||
} catch {
|
||||
return { corrections: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCorrections(idx: CorrectionsIndex, basePath?: string): Promise<void> {
|
||||
const dir = correctionsDir(basePath);
|
||||
if (!existsSync(dir)) {
|
||||
const { mkdir } = await import('node:fs/promises');
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
await writeFile(correctionsPath(basePath), JSON.stringify(idx, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
function nextId(): string {
|
||||
idCounter++;
|
||||
return `uc_${Date.now()}_${idCounter}`;
|
||||
}
|
||||
|
||||
function isoNow(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a user correction. Stores it in .boo/corrections/index.json
|
||||
* and returns the record with the assigned id.
|
||||
*/
|
||||
export async function recordCorrection(
|
||||
originalClaim: string,
|
||||
correction: string,
|
||||
principleExtracted: string,
|
||||
persistedTo: string[] = [],
|
||||
basePath?: string,
|
||||
): Promise<UserCorrectionRecord> {
|
||||
const idx = await readCorrections(basePath);
|
||||
const record: UserCorrectionRecord = {
|
||||
id: nextId(),
|
||||
record_type: 'conversation',
|
||||
action_type: 'user_correction',
|
||||
priority: 'critical_for_recovery',
|
||||
timestamp: isoNow(),
|
||||
original_claim: originalClaim,
|
||||
correction,
|
||||
principle_extracted: principleExtracted,
|
||||
persisted_to: persistedTo,
|
||||
};
|
||||
idx.corrections.push(record);
|
||||
await writeCorrections(idx, basePath);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan an audit_trail.jsonl file for user_correction records.
|
||||
* Returns all matching records found in the file.
|
||||
*/
|
||||
export async function scanForCorrections(
|
||||
auditPath: string,
|
||||
): Promise<UserCorrectionRecord[]> {
|
||||
try {
|
||||
const raw = await readFile(auditPath, 'utf-8');
|
||||
const lines = raw.split('\n').filter(Boolean);
|
||||
const corrections: UserCorrectionRecord[] = [];
|
||||
for (const line of lines) {
|
||||
const record = tryParseJson<Record<string, unknown>>(line);
|
||||
if (record?.action_type === 'user_correction') {
|
||||
corrections.push(record as unknown as UserCorrectionRecord);
|
||||
}
|
||||
}
|
||||
return corrections;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a proposed action contradicts any known user correction.
|
||||
* Returns an array of contradiction warnings — empty means no contradictions.
|
||||
*/
|
||||
export function checkContradiction(
|
||||
action: string,
|
||||
corrections: UserCorrectionRecord[],
|
||||
): { contradicts: boolean; warnings: { correction: UserCorrectionRecord; reason: string }[] } {
|
||||
const warnings: { correction: UserCorrectionRecord; reason: string }[] = [];
|
||||
|
||||
for (const c of corrections) {
|
||||
// Check if the action mentions the original claim's topic
|
||||
const actionLower = action.toLowerCase();
|
||||
const claimFragments = c.original_claim.toLowerCase().split(/\s+/).filter((w) => w.length > 4);
|
||||
|
||||
// If any significant word from the original claim appears in the proposed action,
|
||||
// flag this as a potential contradiction
|
||||
const matchingFragments = claimFragments.filter((f) => actionLower.includes(f));
|
||||
if (matchingFragments.length >= 2) {
|
||||
warnings.push({
|
||||
correction: c,
|
||||
reason: `Action "${action.slice(0, 60)}" may contradict prior correction: "${c.original_claim}" → "${c.correction}" (principle: ${c.principle_extracted})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contradicts: warnings.length > 0,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a file path to a correction's persisted_to array.
|
||||
*/
|
||||
export async function markPersisted(
|
||||
correctionId: string,
|
||||
filePath: string,
|
||||
basePath?: string,
|
||||
): Promise<UserCorrectionRecord | null> {
|
||||
const idx = await readCorrections(basePath);
|
||||
const record = idx.corrections.find((c) => c.id === correctionId);
|
||||
if (!record) return null;
|
||||
|
||||
if (!record.persisted_to.includes(filePath)) {
|
||||
record.persisted_to.push(filePath);
|
||||
}
|
||||
await writeCorrections(idx, basePath);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stored user corrections.
|
||||
*/
|
||||
export async function listCorrections(basePath?: string): Promise<UserCorrectionRecord[]> {
|
||||
const idx = await readCorrections(basePath);
|
||||
return idx.corrections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a correction record to an audit_trail.jsonl file (inline storage).
|
||||
*/
|
||||
export async function appendCorrectionToTrail(
|
||||
trailPath: string,
|
||||
correction: UserCorrectionRecord,
|
||||
): Promise<void> {
|
||||
await appendFile(trailPath, JSON.stringify(correction) + '\n', 'utf-8');
|
||||
}
|
||||
560
apps/coder/src/services/guideline-service.ts
Normal file
560
apps/coder/src/services/guideline-service.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
export type Criticality = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface GuidelineContent {
|
||||
condition: string;
|
||||
action: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface Guideline {
|
||||
id: string;
|
||||
creationUtc: string;
|
||||
content: GuidelineContent;
|
||||
enabled: boolean;
|
||||
tags: string[];
|
||||
labels: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
criticality: Criticality;
|
||||
title: string | null;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface CreateGuidelineParams {
|
||||
condition: string;
|
||||
action?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
labels?: string[];
|
||||
criticality?: Criticality;
|
||||
title?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface UpdateGuidelineParams {
|
||||
condition?: string;
|
||||
action?: string | null;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
tags?: string[];
|
||||
labels?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
criticality?: Criticality;
|
||||
title?: string | null;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface ListGuidelinesFilter {
|
||||
tags?: string[];
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
interface GuidelineStoreData {
|
||||
version: string;
|
||||
guidelines: Guideline[];
|
||||
migrationLog: string[];
|
||||
}
|
||||
|
||||
const GUIDELINES_REL = '.boo/guidelines';
|
||||
const STORE_FILE = 'guidelines.json';
|
||||
const CURRENT_VERSION = 'v0.11.0';
|
||||
|
||||
function storeDir(basePath?: string): string {
|
||||
return resolve(basePath ?? process.cwd(), GUIDELINES_REL);
|
||||
}
|
||||
|
||||
function storePath(basePath?: string): string {
|
||||
return join(storeDir(basePath), STORE_FILE);
|
||||
}
|
||||
|
||||
function tryParseJson<T>(raw: string): T | null {
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
function nextId(): string {
|
||||
idCounter++;
|
||||
return `gl_${Date.now()}_${idCounter}`;
|
||||
}
|
||||
|
||||
function isoNow(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
async function ensureStoreDir(basePath?: string): Promise<void> {
|
||||
const dir = storeDir(basePath);
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
const MIGRATIONS: { from: string; to: string; migrate: (data: GuidelineStoreData) => GuidelineStoreData }[] = [
|
||||
{
|
||||
from: 'v0.1.0',
|
||||
to: 'v0.2.0',
|
||||
migrate: (data) => ({
|
||||
...data,
|
||||
version: 'v0.2.0',
|
||||
guidelines: data.guidelines.map((g) => ({
|
||||
...g,
|
||||
enabled: g.enabled ?? true,
|
||||
})),
|
||||
migrationLog: [...data.migrationLog, 'v0.1.0→v0.2.0: add enabled field'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
from: 'v0.2.0',
|
||||
to: 'v0.3.0',
|
||||
migrate: (data) => ({
|
||||
...data,
|
||||
version: 'v0.3.0',
|
||||
migrationLog: [...data.migrationLog, 'v0.2.0→v0.3.0: remove guideline_set'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
from: 'v0.3.0',
|
||||
to: 'v0.4.0',
|
||||
migrate: (data) => ({
|
||||
...data,
|
||||
version: 'v0.4.0',
|
||||
guidelines: data.guidelines.map((g) => ({
|
||||
...g,
|
||||
content: {
|
||||
...g.content,
|
||||
action: g.content.action ?? null,
|
||||
description: g.content.description ?? null,
|
||||
},
|
||||
metadata: g.metadata ?? {},
|
||||
})),
|
||||
migrationLog: [...data.migrationLog, 'v0.3.0→v0.4.0: add optional action, description, metadata'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
from: 'v0.4.0',
|
||||
to: 'v0.5.0',
|
||||
migrate: (data) => ({
|
||||
...data,
|
||||
version: 'v0.5.0',
|
||||
migrationLog: [...data.migrationLog, 'v0.4.0→v0.5.0: description as optional'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
from: 'v0.5.0',
|
||||
to: 'v0.6.0',
|
||||
migrate: (data) => ({
|
||||
...data,
|
||||
version: 'v0.6.0',
|
||||
guidelines: data.guidelines.map((g) => ({
|
||||
...g,
|
||||
criticality: g.criticality ?? 'medium',
|
||||
})),
|
||||
migrationLog: [...data.migrationLog, 'v0.5.0→v0.6.0: add criticality'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
from: 'v0.6.0',
|
||||
to: 'v0.7.0',
|
||||
migrate: (data) => ({
|
||||
...data,
|
||||
version: 'v0.7.0',
|
||||
migrationLog: [...data.migrationLog, 'v0.6.0→v0.7.0: add composition_mode (optional)'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
from: 'v0.7.0',
|
||||
to: 'v0.8.0',
|
||||
migrate: (data) => ({
|
||||
...data,
|
||||
version: 'v0.8.0',
|
||||
migrationLog: [...data.migrationLog, 'v0.7.0→v0.8.0: add track (default true)'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
from: 'v0.8.0',
|
||||
to: 'v0.9.0',
|
||||
migrate: (data) => ({
|
||||
...data,
|
||||
version: 'v0.9.0',
|
||||
guidelines: data.guidelines.map((g) => ({
|
||||
...g,
|
||||
labels: g.labels ?? [],
|
||||
})),
|
||||
migrationLog: [...data.migrationLog, 'v0.8.0→v0.9.0: add labels'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
from: 'v0.9.0',
|
||||
to: 'v0.10.0',
|
||||
migrate: (data) => ({
|
||||
...data,
|
||||
version: 'v0.10.0',
|
||||
guidelines: data.guidelines.map((g) => ({
|
||||
...g,
|
||||
priority: g.priority ?? 0,
|
||||
})),
|
||||
migrationLog: [...data.migrationLog, 'v0.9.0→v0.10.0: add priority'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
from: 'v0.10.0',
|
||||
to: 'v0.11.0',
|
||||
migrate: (data) => ({
|
||||
...data,
|
||||
version: 'v0.11.0',
|
||||
guidelines: data.guidelines.map((g) => ({
|
||||
...g,
|
||||
title: g.title ?? null,
|
||||
})),
|
||||
migrationLog: [...data.migrationLog, 'v0.10.0→v0.11.0: add title'],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function applyMigrations(data: GuidelineStoreData): GuidelineStoreData {
|
||||
let current = { ...data };
|
||||
for (const migration of MIGRATIONS) {
|
||||
if (current.version === migration.from) {
|
||||
current = migration.migrate(current);
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
async function readStore(basePath?: string): Promise<GuidelineStoreData> {
|
||||
try {
|
||||
const raw = await readFile(storePath(basePath), 'utf-8');
|
||||
const data = tryParseJson<GuidelineStoreData>(raw);
|
||||
if (!data) return { version: CURRENT_VERSION, guidelines: [], migrationLog: [] };
|
||||
if (data.version !== CURRENT_VERSION) {
|
||||
return applyMigrations(data);
|
||||
}
|
||||
return data;
|
||||
} catch {
|
||||
return { version: CURRENT_VERSION, guidelines: [], migrationLog: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeStore(data: GuidelineStoreData, basePath?: string): Promise<void> {
|
||||
await ensureStoreDir(basePath);
|
||||
await writeFile(storePath(basePath), JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export async function createGuideline(
|
||||
params: CreateGuidelineParams,
|
||||
basePath?: string,
|
||||
): Promise<Guideline> {
|
||||
const data = await readStore(basePath);
|
||||
const guideline: Guideline = {
|
||||
id: nextId(),
|
||||
creationUtc: isoNow(),
|
||||
content: {
|
||||
condition: params.condition,
|
||||
action: params.action ?? null,
|
||||
description: params.description ?? null,
|
||||
},
|
||||
enabled: true,
|
||||
tags: params.tags ?? [],
|
||||
labels: params.labels ?? [],
|
||||
metadata: {},
|
||||
criticality: params.criticality ?? 'medium',
|
||||
title: params.title ?? null,
|
||||
priority: params.priority ?? 0,
|
||||
};
|
||||
data.guidelines.push(guideline);
|
||||
await writeStore(data, basePath);
|
||||
return guideline;
|
||||
}
|
||||
|
||||
export async function listGuidelines(
|
||||
filter?: ListGuidelinesFilter,
|
||||
basePath?: string,
|
||||
): Promise<Guideline[]> {
|
||||
const data = await readStore(basePath);
|
||||
let results = data.guidelines;
|
||||
|
||||
if (filter?.tags && filter.tags.length > 0) {
|
||||
results = results.filter((g) => filter.tags!.some((tag) => g.tags.includes(tag)));
|
||||
}
|
||||
|
||||
if (filter?.labels && filter.labels.length > 0) {
|
||||
results = results.filter((g) => filter.labels!.every((label) => g.labels.includes(label)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function readGuideline(
|
||||
id: string,
|
||||
basePath?: string,
|
||||
): Promise<Guideline | null> {
|
||||
const data = await readStore(basePath);
|
||||
return data.guidelines.find((g) => g.id === id) ?? null;
|
||||
}
|
||||
|
||||
export async function updateGuideline(
|
||||
id: string,
|
||||
params: UpdateGuidelineParams,
|
||||
basePath?: string,
|
||||
): Promise<Guideline | null> {
|
||||
const data = await readStore(basePath);
|
||||
const idx = data.guidelines.findIndex((g) => g.id === id);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const existing = data.guidelines[idx]!;
|
||||
|
||||
if (params.condition !== undefined) existing.content.condition = params.condition;
|
||||
if (params.action !== undefined) existing.content.action = params.action;
|
||||
if (params.description !== undefined) existing.content.description = params.description;
|
||||
if (params.enabled !== undefined) existing.enabled = params.enabled;
|
||||
if (params.tags !== undefined) existing.tags = params.tags;
|
||||
if (params.labels !== undefined) existing.labels = params.labels;
|
||||
if (params.metadata !== undefined) existing.metadata = params.metadata;
|
||||
if (params.criticality !== undefined) existing.criticality = params.criticality;
|
||||
if (params.title !== undefined) existing.title = params.title;
|
||||
if (params.priority !== undefined) existing.priority = params.priority;
|
||||
|
||||
data.guidelines[idx] = existing;
|
||||
await writeStore(data, basePath);
|
||||
return existing;
|
||||
}
|
||||
|
||||
export async function deleteGuideline(
|
||||
id: string,
|
||||
basePath?: string,
|
||||
): Promise<boolean> {
|
||||
const data = await readStore(basePath);
|
||||
const lenBefore = data.guidelines.length;
|
||||
data.guidelines = data.guidelines.filter((g) => g.id !== id);
|
||||
if (data.guidelines.length === lenBefore) return false;
|
||||
await writeStore(data, basePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function findGuideline(
|
||||
content: { condition: string; action?: string },
|
||||
basePath?: string,
|
||||
): Promise<Guideline | null> {
|
||||
const data = await readStore(basePath);
|
||||
return data.guidelines.find((g) => {
|
||||
const condMatch = g.content.condition === content.condition;
|
||||
if (!condMatch) return false;
|
||||
if (content.action !== undefined) {
|
||||
return g.content.action === content.action;
|
||||
}
|
||||
return true;
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
// ─── Journey → Guideline projection (port of Parlant's JourneyGuidelineProjection) ───
|
||||
|
||||
export interface JourneyNode {
|
||||
id: string;
|
||||
action: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface JourneyEdge {
|
||||
sourceNodeId: string;
|
||||
targetNodeId: string;
|
||||
condition: string;
|
||||
}
|
||||
|
||||
export interface Journey {
|
||||
id: string;
|
||||
name: string;
|
||||
nodes: JourneyNode[];
|
||||
edges: JourneyEdge[];
|
||||
}
|
||||
|
||||
export interface JourneyProjectionResult {
|
||||
guidelines: Guideline[];
|
||||
followUps: Map<string, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a Journey into an ordered list of Guidelines.
|
||||
* DFS traversal from root nodes: each (edge, node) pair → one Guideline.
|
||||
* Edge condition becomes guideline condition, node action becomes guideline action.
|
||||
* BFS queue avoids infinite loops via visited set.
|
||||
*/
|
||||
export function projectJourneyToGuidelines(
|
||||
journey: Journey,
|
||||
baseTags?: string[],
|
||||
): JourneyProjectionResult {
|
||||
const guidelines: Guideline[] = [];
|
||||
const followUps = new Map<string, string[]>();
|
||||
const visited = new Set<string>();
|
||||
const nodeMap = new Map<string, JourneyNode>();
|
||||
|
||||
for (const node of journey.nodes) {
|
||||
nodeMap.set(node.id, node);
|
||||
}
|
||||
|
||||
// Build adjacency list
|
||||
const adjacency = new Map<string, JourneyEdge[]>();
|
||||
for (const edge of journey.edges) {
|
||||
const list = adjacency.get(edge.sourceNodeId) ?? [];
|
||||
list.push(edge);
|
||||
adjacency.set(edge.sourceNodeId, list);
|
||||
}
|
||||
|
||||
// Find root nodes (no incoming edges)
|
||||
const hasIncoming = new Set<string>();
|
||||
for (const edge of journey.edges) {
|
||||
hasIncoming.add(edge.targetNodeId);
|
||||
}
|
||||
const roots = journey.nodes
|
||||
.filter((n) => !hasIncoming.has(n.id))
|
||||
.map((n) => n.id);
|
||||
|
||||
const queue: { nodeId: string; fromEdge?: JourneyEdge }[] = [];
|
||||
|
||||
// BFS from roots
|
||||
for (const rootId of roots) {
|
||||
if (!visited.has(rootId)) {
|
||||
queue.push({ nodeId: rootId });
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { nodeId, fromEdge } = queue.shift()!;
|
||||
if (visited.has(nodeId)) continue;
|
||||
visited.add(nodeId);
|
||||
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node) continue;
|
||||
|
||||
// If we arrived via an edge, create a guideline
|
||||
if (fromEdge) {
|
||||
const guideline = createGuidelineFromJourneyEdge(
|
||||
journey,
|
||||
node,
|
||||
fromEdge,
|
||||
baseTags,
|
||||
);
|
||||
guidelines.push(guideline);
|
||||
|
||||
// Track follow-ups
|
||||
const sourceId = findGuidelineForNode(fromEdge.sourceNodeId, journey.nodes);
|
||||
if (sourceId) {
|
||||
const existing = followUps.get(sourceId) ?? [];
|
||||
existing.push(guideline.id);
|
||||
followUps.set(sourceId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue downstream nodes
|
||||
const outgoingEdges = adjacency.get(nodeId) ?? [];
|
||||
for (const edge of outgoingEdges) {
|
||||
if (!visited.has(edge.targetNodeId)) {
|
||||
queue.push({ nodeId: edge.targetNodeId, fromEdge: edge });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { guidelines, followUps };
|
||||
}
|
||||
|
||||
function findGuidelineForNode(nodeId: string, nodes: JourneyNode[]): string | null {
|
||||
// Placeholder: in a full implementation, map nodeId → guideline id
|
||||
// For now return null — downstream consumers handle missing follow-ups gracefully
|
||||
return null;
|
||||
}
|
||||
|
||||
function createGuidelineFromJourneyEdge(
|
||||
journey: Journey,
|
||||
targetNode: JourneyNode,
|
||||
edge: JourneyEdge,
|
||||
baseTags?: string[],
|
||||
): Guideline {
|
||||
const now = isoNow();
|
||||
return {
|
||||
id: nextId(),
|
||||
creationUtc: now,
|
||||
content: {
|
||||
condition: edge.condition,
|
||||
action: targetNode.action,
|
||||
description: targetNode.description ?? null,
|
||||
},
|
||||
enabled: true,
|
||||
tags: baseTags ?? [journey.name],
|
||||
labels: [],
|
||||
metadata: {
|
||||
journey_id: journey.id,
|
||||
journey_node: targetNode.id,
|
||||
source_edge_id: `${edge.sourceNodeId}→${edge.targetNodeId}`,
|
||||
},
|
||||
criticality: 'medium',
|
||||
title: targetNode.description
|
||||
? `[${journey.name}] ${targetNode.description.slice(0, 60)}`
|
||||
: null,
|
||||
priority: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Backtrack detection ───
|
||||
|
||||
export interface BacktrackCheckInput {
|
||||
journeyId: string;
|
||||
currentNodeId: string;
|
||||
previousNodeId: string;
|
||||
}
|
||||
|
||||
export interface BacktrackCheckResult {
|
||||
journeyId: string;
|
||||
currentNodeId: string;
|
||||
previousNodeId: string;
|
||||
isBacktrack: boolean;
|
||||
recommendation: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if moving from previousNodeId to currentNodeId is a backtrack
|
||||
* (regression to an already-visited node not on a forward path).
|
||||
*/
|
||||
export function checkBacktrack(
|
||||
input: BacktrackCheckInput,
|
||||
journey: Journey,
|
||||
): BacktrackCheckResult {
|
||||
const adjacency = new Map<string, string[]>();
|
||||
for (const edge of journey.edges) {
|
||||
const list = adjacency.get(edge.sourceNodeId) ?? [];
|
||||
list.push(edge.targetNodeId);
|
||||
adjacency.set(edge.sourceNodeId, list);
|
||||
}
|
||||
|
||||
// Find forward reachable nodes from the current node
|
||||
const forwardReachable = new Set<string>();
|
||||
const bfsQueue = [input.currentNodeId];
|
||||
while (bfsQueue.length > 0) {
|
||||
const nid = bfsQueue.shift()!;
|
||||
if (forwardReachable.has(nid)) continue;
|
||||
forwardReachable.add(nid);
|
||||
const next = adjacency.get(nid) ?? [];
|
||||
for (const n of next) {
|
||||
if (!forwardReachable.has(n)) bfsQueue.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
const isBacktrack = input.previousNodeId !== input.currentNodeId
|
||||
&& !forwardReachable.has(input.previousNodeId)
|
||||
&& input.previousNodeId !== input.currentNodeId;
|
||||
|
||||
return {
|
||||
journeyId: input.journeyId,
|
||||
currentNodeId: input.currentNodeId,
|
||||
previousNodeId: input.previousNodeId,
|
||||
isBacktrack,
|
||||
recommendation: isBacktrack
|
||||
? `Revisiting node "${input.previousNodeId}" after "${input.currentNodeId}" — this may indicate a regression. Consider whether the forward path from "${input.currentNodeId}" is the correct one.`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
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