Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions Releases/v5.0.0/.claude/hooks/lib/learning-readback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,41 @@
import { readFileSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';

/** Default freshness window for the learning digest. Entries older than this are
* excluded so a quiet category can't surface weeks-old signals as if current. */
const DEFAULT_MAX_AGE_DAYS = 21;

/**
* Parse the timestamp encoded in a learning filename
* (YYYY-MM-DD-HHMMSS_LEARNING_*.md). Returns null if the name doesn't match.
*/
export function parseLearningDate(filename: string): Date | null {
const m = filename.match(/^(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})_/);
if (!m) return null;
const [, y, mo, d, h, mi, s] = m;
const dt = new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
return Number.isNaN(dt.getTime()) ? null : dt;
}

/**
* Whether a learning file is recent enough to surface in the digest. Unparseable
* names are treated as not fresh (excluded) — the digest must never present a
* signal it cannot date as though it were current.
*/
export function isFresh(filename: string, maxAgeDays: number, now: Date = new Date()): boolean {
const dt = parseLearningDate(filename);
if (!dt) return false;
const ageMs = now.getTime() - dt.getTime();
return ageMs >= 0 && ageMs <= maxAgeDays * 86_400_000;
}

/**
* Read the N most recent learning files from a LEARNING subdirectory.
* Files are named YYYY-MM-DD-HHMMSS_LEARNING_*.md with YAML frontmatter.
* Extracts the **Feedback:** line and rating for compact display.
* Read the N most recent learning files from a LEARNING subdirectory, limited to
* a freshness window. Files are named YYYY-MM-DD-HHMMSS_LEARNING_*.md with YAML
* frontmatter. Extracts the **Feedback:** line and rating, and stamps each entry
* with its date so staleness is always visible.
*/
function getRecentLearnings(baseDir: string, subdir: string, count: number): string[] {
function getRecentLearnings(baseDir: string, subdir: string, count: number, maxAgeDays: number = DEFAULT_MAX_AGE_DAYS): string[] {
const insights: string[] = [];
const learningDir = join(baseDir, 'MEMORY', 'LEARNING', subdir);
if (!existsSync(learningDir)) return insights;
Expand All @@ -56,14 +85,18 @@ function getRecentLearnings(baseDir: string, subdir: string, count: number): str

for (const file of files) {
if (insights.length >= count) break;
// Skip stale entries so a quiet category can't surface old signals.
if (!isFresh(file, maxAgeDays)) continue;
try {
const content = readFileSync(join(monthPath, file), 'utf-8');
const feedbackMatch = content.match(/\*\*Feedback:\*\*\s*(.+)/);
const ratingMatch = content.match(/rating:\s*(\d+)/);
if (feedbackMatch) {
const rating = ratingMatch ? ratingMatch[1] : '?';
const feedback = feedbackMatch[1].substring(0, 80);
insights.push(`[${rating}/10] ${feedback}`);
const dt = parseLearningDate(file);
const dateStr = dt ? dt.toISOString().slice(0, 10) : '';
insights.push(`[${rating}/10] (${dateStr}) ${feedback}`);
}
} catch { /* skip unreadable files */ }
}
Expand Down
51 changes: 51 additions & 0 deletions Releases/v5.0.0/.claude/hooks/tests/learning-readback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Tests for the learning-digest staleness guard.
//
// loadLearningDigest() previously showed the N most-recent entries per category
// with no date and no age cutoff, so a quiet category surfaced weeks-old signals
// as if current. These tests cover the date helpers and the end-to-end behavior
// (fresh shown, stale hidden) against throwaway temp fixtures.
//
// Run: bun test learning-readback.test.ts
import { test, expect } from "bun:test";
import { mkdirSync, writeFileSync, mkdtempSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { parseLearningDate, isFresh, loadLearningDigest } from "../lib/learning-readback.ts";

test("parseLearningDate extracts the timestamp and rejects bad names", () => {
const d = parseLearningDate("2026-06-20-091530_LEARNING_sentiment-rating-7.md");
expect(d).not.toBeNull();
expect(d!.getFullYear()).toBe(2026);
expect(d!.getMonth()).toBe(5); // June (0-indexed)
expect(parseLearningDate("not-a-learning-file.md")).toBeNull();
});

test("isFresh includes recent, excludes old and unparseable", () => {
const now = new Date(2026, 5, 20, 12, 0, 0);
expect(isFresh("2026-06-19-100000_LEARNING_x.md", 21, now)).toBe(true);
expect(isFresh("2026-04-01-100000_LEARNING_x.md", 21, now)).toBe(false);
expect(isFresh("garbled.md", 21, now)).toBe(false);
});

function pad(n: number): string { return String(n).padStart(2, "0"); }

function writeLearning(base: string, subdir: string, d: Date, feedback: string, rating: number) {
const month = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
const stamp = `${month}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
const dir = join(base, "MEMORY", "LEARNING", subdir, month);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${stamp}_LEARNING_test.md`), `---\nrating: ${rating}\n---\n**Feedback:** ${feedback}\n`);
}

test("loadLearningDigest shows fresh entries and hides stale ones", () => {
const base = mkdtempSync(join(tmpdir(), "lrb-"));
const now = new Date();
const stale = new Date(now.getTime() - 60 * 86_400_000); // 60 days ago

writeLearning(base, "ALGORITHM", now, "fresh signal here", 7);
writeLearning(base, "ALGORITHM", stale, "stale signal here", 3);

const out = loadLearningDigest(base) || "";
expect(out).toContain("fresh signal here");
expect(out).not.toContain("stale signal here");
});