Clawern deep-dive
The autonomous pipeline — watch a folder, read what arrives, remember across documents — and the seven-report eval arc that hardened it.
src/claw/precedent-board.ts478 lines · PrecedentBoard L95–458
Outline 26 symbols
- logger const
- PrecedentBoardState interface export
- PrecedentQuery interface export
- PrecedentMatch interface export
- PrecedentSummary interface export
- emptyState function
- dedupKey function
- patternNameFromType function
- daysBetween function
- clamp function
- PrecedentBoard class export
- constructor method
- buildDedupIndex method
- save method
- getState method
- indexFindings method
- search method
- markConfirmed method
- statusCounts method
- reinforce method
- decay method
- compact method
- summary method
- _instances const
- getPrecedentBoard function export
- resetPrecedentBoard function export
1/**
2 * Precedent Board — Institutional Memory for Claw Mode.
3 *
4 * After each document is processed, significant findings are indexed
5 * into the board. Before processing future documents, the board is
6 * queried and matching precedents are injected as context.
7 *
8 * All logic is local (no LLM calls). Per-client isolated. Evidence-linked.
9 * Confidence-decaying. Follows the DocumentRegistry persistence pattern.
10 *
11 * Persistence: `~/.lavern/precedents.json` (atomic writes).
12 *
13 * Hardened: dedup index, clamped scores, evidence guards, NaN-safe dates,
14 * bounded outcomes, sanitized inputs.
15 */
16
17import * as crypto from 'node:crypto';
18import * as path from 'node:path';
19import { readJsonFile, writeJsonFileAtomic, ensureDir } from '../utils/fs-helpers.js';
20import { config } from '../config.js';
21import { createLogger } from '../utils/logger.js';
22import type { PrecedentEntry, MemoryTags } from '../mcp/tools/memory-system.js';
23import type { Finding } from '../types/debate.js';
24
25const logger = createLogger('PRECEDENT-BOARD');
26
27// ── Types ────────────────────────────────────────────────────────────────
28
29export interface PrecedentBoardState {
30 entries: Record<string, PrecedentEntry>;
31 version: number;
32 lastDecay: string;
33}
34
35export interface PrecedentQuery {
36 findingType?: string;
37 severity?: 'RED' | 'YELLOW' | 'GREEN';
38 jurisdiction?: string;
39 documentType?: string;
40 textQuery?: string;
41 limit?: number;
42}
43
44export interface PrecedentMatch {
45 entry: PrecedentEntry;
46 relevanceScore: number;
47}
48
49export interface PrecedentSummary {
50 total: number;
51 active: number;
52 deprecated: number;
53 topPatterns: string[];
54}
55
56// ── Helpers ──────────────────────────────────────────────────────────────
57
58function emptyState(): PrecedentBoardState {
59 return {
60 entries: {},
61 version: 1,
62 lastDecay: new Date().toISOString(),
63 };
64}
65
66/** Dedup key: full SHA-256 of normalized findingType + first evidence string. */
67function dedupKey(findingType: string, evidence: string[]): string {
68 const raw = `${findingType}:${(evidence[0] ?? '').toLowerCase().trim().slice(0, 200)}`;
69 return crypto.createHash('sha256').update(raw).digest('hex');
70}
71
72/** Human-readable pattern name from finding type. */
73function patternNameFromType(findingType: string): string {
74 return findingType
75 .split('-')
76 .map(w => w.charAt(0).toUpperCase() + w.slice(1))
77 .join(' ') + ' Pattern';
78}
79
80/** Days between two ISO timestamps. Returns 0 on invalid dates. */
81function daysBetween(a: string, b: string): number {
82 const ta = new Date(a).getTime();
83 const tb = new Date(b).getTime();
84 if (isNaN(ta) || isNaN(tb)) return 0;
85 return Math.abs(ta - tb) / (1000 * 60 * 60 * 24);
86}
87
88/** Clamp a number to [min, max]. */
89function clamp(value: number, min: number, max: number): number {
90 return Math.max(min, Math.min(max, value));
91}
92
93// ── Board ────────────────────────────────────────────────────────────────
94
95export class PrecedentBoard {
96 private state: PrecedentBoardState;
97 private statePath: string;
98 private archivePath: string;
99 /** O(1) dedup index: SHA-256 key → precedent ID */
100 private dedupIndex: Map<string, string>;
101
102 constructor(dir: string) {
103 this.statePath = path.join(dir, 'precedents.json');
104 this.archivePath = path.join(dir, 'precedents-archive.json');
105 ensureDir(dir);
106 this.state = readJsonFile<PrecedentBoardState>(this.statePath, emptyState());
107 this.dedupIndex = this.buildDedupIndex();
108 }
109
110 /** Build dedup index from current state. */
111 private buildDedupIndex(): Map<string, string> {
112 const index = new Map<string, string>();
113 for (const [id, entry] of Object.entries(this.state.entries)) {
114 const key = dedupKey(
115 entry.tags?.custom?.[0] ?? entry.patternName,
116 [entry.beforeSnippet],
117 );
118 index.set(key, id);
119 }
120 return index;
121 }
122
123 // ── Persistence ────────────────────────────────────────────────────────
124
125 save(): void {
126 writeJsonFileAtomic(this.statePath, this.state);
127 }
128
129 getState(): PrecedentBoardState {
130 return this.state;
131 }
132
133 // ── Indexing ───────────────────────────────────────────────────────────
134
135 /**
136 * Index significant findings from a completed session.
137 * Only RED/YELLOW findings with confidence >= 0.7 and non-empty evidence are indexed.
138 * Deduplicates by finding type + first evidence text via O(1) index lookup.
139 */
140 indexFindings(
141 documentHash: string,
142 documentType: string,
143 jurisdiction: string,
144 findings: Finding[],
145 ): number {
146 if (!Array.isArray(findings) || findings.length === 0) return 0;
147
148 const significant = findings.filter(
149 f => (f.severity === 'RED' || f.severity === 'YELLOW')
150 && f.confidence >= 0.7
151 && Array.isArray(f.evidence) && f.evidence.length > 0
152 && f.evidence[0].length > 0
153 && typeof f.findingType === 'string' && f.findingType.length > 0
154 && typeof f.content === 'string' && f.content.length > 0,
155 );
156
157 if (significant.length === 0) return 0;
158
159 let indexed = 0;
160 const now = new Date().toISOString();
161
162 for (const finding of significant) {
163 const key = dedupKey(finding.findingType, finding.evidence);
164
165 // O(1) dedup check
166 const existingId = this.dedupIndex.get(key);
167 if (existingId && this.state.entries[existingId]) {
168 this.reinforce(existingId, documentHash, 0.1);
169 } else {
170 const id = `PREC-${crypto.randomBytes(4).toString('hex')}`;
171 const tags: MemoryTags = {
172 documentType,
173 jurisdiction,
174 engagementType: 'review',
175 custom: [finding.findingType],
176 };
177
178 const entry: PrecedentEntry = {
179 id,
180 documentType,
181 jurisdiction,
182 patternName: patternNameFromType(finding.findingType),
183 description: finding.content.slice(0, 300),
184 beforeSnippet: finding.evidence[0],
185 afterSnippet: '',
186 qualityScore: finding.confidence,
187 addedAt: now,
188 timesUsed: 1,
189 timesQueried: 0,
190 effectivenessScore: clamp(finding.confidence / 4, 0, 1),
191 outcomes: [{
192 sessionId: documentHash,
193 timestamp: now,
194 applied: true,
195 scoreDelta: 0,
196 verificationPassed: true,
197 }],
198 deprecated: false,
199 tags,
200 };
201
202 this.state.entries[id] = entry;
203 this.dedupIndex.set(key, id);
204 indexed++;
205 logger.info('Precedent indexed', { id, findingType: finding.findingType, severity: finding.severity });
206 }
207 }
208
209 if (indexed > 0 || significant.length > 0) {
210 this.save();
211 }
212
213 return indexed;
214 }
215
216 // ── Search ────────────────────────────────────────────────────────────
217
218 search(query: PrecedentQuery): PrecedentMatch[] {
219 const limit = query.limit ?? 10;
220 const now = new Date().toISOString();
221 const entries = Object.values(this.state.entries).filter(e => !e.deprecated);
222
223 if (entries.length === 0) return [];
224
225 // Single-pass filtering
226 const ftLower = query.findingType?.toLowerCase();
227 const jLower = query.jurisdiction?.toLowerCase();
228 const dtLower = query.documentType?.toLowerCase();
229 const qLower = query.textQuery?.toLowerCase();
230
231 const filtered = entries.filter(e => {
232 if (ftLower && !(
233 e.patternName.toLowerCase().includes(ftLower) ||
234 (e.tags?.custom ?? []).some(c => c.toLowerCase().includes(ftLower))
235 )) return false;
236 if (jLower && !(e.tags?.jurisdiction ?? e.jurisdiction).toLowerCase().includes(jLower)) return false;
237 if (dtLower && !(e.tags?.documentType ?? e.documentType).toLowerCase().includes(dtLower)) return false;
238 if (qLower && !(
239 e.description.toLowerCase().includes(qLower) ||
240 e.beforeSnippet.toLowerCase().includes(qLower) ||
241 e.patternName.toLowerCase().includes(qLower)
242 )) return false;
243 return true;
244 });
245
246 if (filtered.length === 0) return [];
247
248 // Score and sort
249 const maxUsed = Math.max(1, ...filtered.map(e => e.timesUsed));
250
251 const scored: PrecedentMatch[] = filtered.map(e => {
252 const usageScore = e.timesUsed / maxUsed;
253 const lastActivity = e.outcomes.length > 0
254 ? e.outcomes[e.outcomes.length - 1].timestamp
255 : e.addedAt;
256 const recencyScore = 1 / (1 + daysBetween(lastActivity, now) / 90);
257 const relevanceScore = clamp(usageScore * 0.3 + e.effectivenessScore * 0.4 + recencyScore * 0.3, 0, 1);
258
259 return { entry: e, relevanceScore };
260 });
261
262 scored.sort((a, b) => b.relevanceScore - a.relevanceScore);
263
264 const results = scored.slice(0, limit);
265
266 // Increment query counts
267 if (results.length > 0) {
268 for (const match of results) {
269 match.entry.timesQueried++;
270 }
271 this.save();
272 }
273
274 return results;
275 }
276
277 // ── Status (Phase 5: confirmed-precedent reinforcement) ──────────────
278
279 /**
280 * Promote a precedent from 'tentative' to 'confirmed'. Called by the
281 * Curator's consolidation pass when the precedent has been seen
282 * ≥ CONFIRM_THRESHOLD times with consistent verdicts.
283 *
284 * Confirmed precedents are weighted higher in Reader prompts — the
285 * model is told "the firm has confirmed this position across N matters"
286 * rather than "the firm has tentatively flagged this pattern."
287 *
288 * Returns true when the status actually changed.
289 */
290 markConfirmed(precedentId: string): boolean {
291 const entry = this.state.entries[precedentId];
292 if (!entry) return false;
293 if (entry.deprecated) return false;
294 if (entry.status === 'confirmed') return false;
295 entry.status = 'confirmed';
296 entry.statusUpdatedAt = new Date().toISOString();
297 this.save();
298 logger.info('Precedent confirmed', { id: precedentId, timesUsed: entry.timesUsed });
299 return true;
300 }
301
302 /** Count of precedents per lifecycle status. Useful for ops + heartbeat. */
303 statusCounts(): { tentative: number; confirmed: number; deprecated: number } {
304 let tentative = 0, confirmed = 0, deprecated = 0;
305 for (const entry of Object.values(this.state.entries)) {
306 const s = entry.status ?? (entry.deprecated ? 'deprecated' : 'tentative');
307 if (s === 'confirmed') confirmed++;
308 else if (s === 'deprecated' || entry.deprecated) deprecated++;
309 else tentative++;
310 }
311 return { tentative, confirmed, deprecated };
312 }
313
314 // ── Reinforcement ─────────────────────────────────────────────────────
315
316 reinforce(precedentId: string, sessionId: string, scoreDelta: number): void {
317 const entry = this.state.entries[precedentId];
318 if (!entry) return;
319
320 entry.timesUsed++;
321
322 // Incremental update: adds 20% of delta, clamped to [0, 1]
323 entry.effectivenessScore = clamp(
324 entry.effectivenessScore + scoreDelta * 0.2,
325 0,
326 1,
327 );
328
329 // Cap outcomes BEFORE push to prevent memory spikes
330 const maxOutcomes = config.claw.precedentMaxOutcomes;
331 if (entry.outcomes.length >= maxOutcomes) {
332 entry.outcomes.shift();
333 }
334
335 entry.outcomes.push({
336 sessionId,
337 timestamp: new Date().toISOString(),
338 applied: true,
339 scoreDelta,
340 verificationPassed: true,
341 });
342
343 this.save();
344 }
345
346 // ── Decay ─────────────────────────────────────────────────────────────
347
348 /**
349 * Apply time-based decay to precedent effectiveness.
350 * Runs at most once per day. Called from heartbeat.
351 */
352 decay(): void {
353 const now = new Date().toISOString();
354 if (daysBetween(this.state.lastDecay, now) < 1) return;
355
356 const decayDays = config.claw.precedentDecayDays;
357 let changed = false;
358
359 for (const entry of Object.values(this.state.entries)) {
360 if (entry.deprecated) continue;
361
362 const lastActivity = entry.outcomes.length > 0
363 ? entry.outcomes[entry.outcomes.length - 1].timestamp
364 : entry.addedAt;
365
366 const daysInactive = daysBetween(lastActivity, now);
367
368 // Deprecate if unreinforced for 6x the decay window
369 if (daysInactive > decayDays * 6) {
370 entry.deprecated = true;
371 entry.deprecationReason = `Unreinforced for ${Math.floor(daysInactive)} days`;
372 changed = true;
373 logger.info('Precedent deprecated', { id: entry.id, daysInactive: Math.floor(daysInactive) });
374 continue;
375 }
376
377 // Gradual decay after the configured threshold
378 if (daysInactive > decayDays) {
379 entry.effectivenessScore = clamp(entry.effectivenessScore * 0.95, 0, 1);
380 changed = true;
381 }
382 }
383
384 this.state.lastDecay = now;
385 this.save();
386 }
387
388 // ── Compaction ────────────────────────────────────────────────────────
389
390 /**
391 * Archive deprecated and old entries to keep active state lean.
392 */
393 compact(maxAgeDays?: number): void {
394 const threshold = maxAgeDays ?? config.claw.precedentArchiveDays;
395 const now = new Date();
396 const toArchive: PrecedentEntry[] = [];
397 const idsToRemove: string[] = [];
398
399 for (const [id, entry] of Object.entries(this.state.entries)) {
400 const addedTime = new Date(entry.addedAt).getTime();
401 const ageDays = isNaN(addedTime) ? 0 : (now.getTime() - addedTime) / (1000 * 60 * 60 * 24);
402
403 if (entry.deprecated || ageDays > threshold) {
404 toArchive.push(entry);
405 idsToRemove.push(id);
406 }
407 }
408
409 if (toArchive.length === 0) return;
410
411 // Merge with existing archive
412 const archive = readJsonFile<PrecedentEntry[]>(this.archivePath, []);
413 archive.push(...toArchive);
414 writeJsonFileAtomic(this.archivePath, archive);
415
416 // Remove from active state and dedup index
417 for (const id of idsToRemove) {
418 const entry = this.state.entries[id];
419 if (entry) {
420 const key = dedupKey(
421 entry.tags?.custom?.[0] ?? entry.patternName,
422 [entry.beforeSnippet],
423 );
424 this.dedupIndex.delete(key);
425 }
426 delete this.state.entries[id];
427 }
428
429 this.save();
430 logger.info('Precedent board compacted', { archived: toArchive.length, remaining: Object.keys(this.state.entries).length });
431 }
432
433 // ── Summary ───────────────────────────────────────────────────────────
434
435 get summary(): PrecedentSummary {
436 const entries = Object.values(this.state.entries);
437 const active = entries.filter(e => !e.deprecated);
438 const deprecated = entries.filter(e => e.deprecated);
439
440 // Top patterns by usage
441 const patternCounts = new Map<string, number>();
442 for (const e of active) {
443 patternCounts.set(e.patternName, (patternCounts.get(e.patternName) ?? 0) + e.timesUsed);
444 }
445
446 const topPatterns = [...patternCounts.entries()]
447 .sort((a, b) => b[1] - a[1])
448 .slice(0, 5)
449 .map(([name]) => name);
450
451 return {
452 total: entries.length,
453 active: active.length,
454 deprecated: deprecated.length,
455 topPatterns,
456 };
457 }
458}
459
460// ── Singleton ───────────────────────────────────────────────────────────
461
462const _instances = new Map<string, PrecedentBoard>();
463
464export function getPrecedentBoard(dir?: string): PrecedentBoard {
465 const resolvedDir = dir ?? config.claw.dir;
466 let instance = _instances.get(resolvedDir);
467 if (!instance) {
468 instance = new PrecedentBoard(resolvedDir);
469 _instances.set(resolvedDir, instance);
470 }
471 return instance;
472}
473
474/** Reset all instances (for testing). */
475export function resetPrecedentBoard(): void {
476 _instances.clear();
477}
478