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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user