feat: add memory b cell pattern cache
This commit is contained in:
parent
084197d109
commit
522ec63025
|
|
@ -276,6 +276,11 @@ fable-agent plinius godmode "improve explanation quality"
|
||||||
- `fable5 flue`
|
- `fable5 flue`
|
||||||
- `fable5 channels`
|
- `fable5 channels`
|
||||||
- `--run <id>`
|
- `--run <id>`
|
||||||
|
- `fable5 memory-b-cell <file>`
|
||||||
|
- `--cache <path>`
|
||||||
|
- `fable5 receipt-health`
|
||||||
|
- `--root <path>`
|
||||||
|
- `--window-minutes <n>`
|
||||||
- `fable5 heartbeat`
|
- `fable5 heartbeat`
|
||||||
- `--id <id>`
|
- `--id <id>`
|
||||||
- `--name <name>`
|
- `--name <name>`
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ The product claim stays narrow: fable-agent is not a new organism or orchestrato
|
||||||
| Innate immune response | Fast input triage | `plinius safety`, content safety gate, adaptive reframing detection |
|
| Innate immune response | Fast input triage | `plinius safety`, content safety gate, adaptive reframing detection |
|
||||||
| Pattern recognition receptors | Structural threat detection | AST capability scanner, glyph/covert-channel scanner |
|
| Pattern recognition receptors | Structural threat detection | AST capability scanner, glyph/covert-channel scanner |
|
||||||
| Adaptive immunity | Learned risk memory | receipts, hallucination detections, RSI verification receipts |
|
| Adaptive immunity | Learned risk memory | receipts, hallucination detections, RSI verification receipts |
|
||||||
|
| Memory B cell | Pattern cache for repeat encounters | `.fable/memory-b-cells.json`, `fable.memory_b_cell.v1` |
|
||||||
| Nervous system | Runtime telemetry | channel events, typed receipts, attestations |
|
| Nervous system | Runtime telemetry | channel events, typed receipts, attestations |
|
||||||
| Motor reflex | Controlled action | guarded `8099/deploy via git-proxy` decision path |
|
| Motor reflex | Controlled action | guarded `8099/deploy via git-proxy` decision path |
|
||||||
|
|
||||||
|
|
@ -43,3 +44,13 @@ The product claim stays narrow: fable-agent is not a new organism or orchestrato
|
||||||
Biological systems survive by sensing, verifying, remembering, and acting through constrained channels.
|
Biological systems survive by sensing, verifying, remembering, and acting through constrained channels.
|
||||||
fable-agent applies that pattern to agent work: scan, verify, attest, and only then deploy.
|
fable-agent applies that pattern to agent work: scan, verify, attest, and only then deploy.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Live implementation checklist
|
||||||
|
|
||||||
|
| Biological primitive | Implementation | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| Immune system | 5-layer verification pipeline | implemented |
|
||||||
|
| Memory B cell | Pattern cache: first encounter scans, repeat encounter cache hits | implemented |
|
||||||
|
| Apoptosis | Deploy gate / kill switch remains fail-closed | implemented via guarded deploy checks |
|
||||||
|
| Homeostasis | Receipt consumer freshness check | implemented via `fable5 receipt-health` |
|
||||||
|
| Nervous system | Receipt/channel/attestation event surface | implemented via `.runs/<id>/channel.jsonl` and attestations |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Memory B Cell Pattern Cache Receipt — 2026-06-21
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
```text
|
||||||
|
PASS memory B cell cache proof
|
||||||
|
PASS receipt consumer health proof
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proof commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node dist/index.js fable5 memory-b-cell TEMP/memory-b-cell-proof/safe.ts --cache .fable/memory-b-cell-proof.json
|
||||||
|
node dist/index.js fable5 memory-b-cell TEMP/memory-b-cell-proof/safe.ts --cache .fable/memory-b-cell-proof.json
|
||||||
|
node dist/index.js fable5 memory-b-cell TEMP/memory-b-cell-proof/unsafe.ts --cache .fable/memory-b-cell-proof.json
|
||||||
|
node dist/index.js fable5 memory-b-cell TEMP/memory-b-cell-proof/unsafe.ts --cache .fable/memory-b-cell-proof.json
|
||||||
|
node dist/index.js fable5 receipt-health --root .fable --window-minutes 10080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Observed behavior
|
||||||
|
|
||||||
|
| Encounter | Pattern | Verdict | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| First encounter | clean TypeScript export | `known_safe` | full AST scan, `decision=allow` |
|
||||||
|
| Repeat encounter | same clean code | `known_safe` | `cache_hit` |
|
||||||
|
| First encounter | `node:child_process` / `execSync` | `known_unsafe` | full AST scan, `shell_exec=true`, `decision=review` |
|
||||||
|
| Repeat encounter | same subprocess pattern | `known_unsafe` | `cache_hit` |
|
||||||
|
| Homeostasis | `.fable` receipt freshness | `healthy` | 50 receipts, newest inside window |
|
||||||
|
|
||||||
|
## Boundary
|
||||||
|
|
||||||
|
Memory B cell cache accelerates repeat pattern recognition. It does **not** replace deploy proof. Guarded deploy decisions still require receipts and use `8099/deploy via git-proxy`; `8098 deploy-webhook` remains legacy/stale for fable-agent deploy decisions.
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { scanAstFile, type AstSafetyReport } from "../core/ast-safety.js";
|
import type { AstSafetyReport } from "../core/ast-safety.js";
|
||||||
import { assessLegibilityRisk } from "../core/unicode-safety.js";
|
import { assessLegibilityRisk } from "../core/unicode-safety.js";
|
||||||
|
import { assessMemoryBCell, type MemoryBCellResult } from "./memory-b-cell.js";
|
||||||
|
|
||||||
export type VerificationStatus = "verified" | "partial" | "failed" | "unverified";
|
export type VerificationStatus = "verified" | "partial" | "failed" | "unverified";
|
||||||
export type RiskLevel = "low" | "medium" | "high";
|
export type RiskLevel = "low" | "medium" | "high";
|
||||||
|
|
@ -29,6 +30,7 @@ export interface VerificationAttestation {
|
||||||
observed_changes: string[];
|
observed_changes: string[];
|
||||||
checks: VerificationCheck[];
|
checks: VerificationCheck[];
|
||||||
risk_findings: RiskFinding[];
|
risk_findings: RiskFinding[];
|
||||||
|
memory_b_cells: MemoryBCellResult[];
|
||||||
human_review: { required: boolean; reasons: string[] };
|
human_review: { required: boolean; reasons: string[] };
|
||||||
deploy_route: "8099/deploy via git-proxy";
|
deploy_route: "8099/deploy via git-proxy";
|
||||||
verification_status: VerificationStatus;
|
verification_status: VerificationStatus;
|
||||||
|
|
@ -41,6 +43,7 @@ export interface VerifyTargetOptions {
|
||||||
out?: string;
|
out?: string;
|
||||||
now?: Date;
|
now?: Date;
|
||||||
requestedBy?: "human" | "agent" | "ci";
|
requestedBy?: "human" | "agent" | "ci";
|
||||||
|
memoryCachePath?: string;
|
||||||
runner?: (command: string, args: string[], cwd?: string) => { status: number | null; stdout: string; stderr: string };
|
runner?: (command: string, args: string[], cwd?: string) => { status: number | null; stdout: string; stderr: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,6 +52,7 @@ export function verifyTarget(opts: VerifyTargetOptions): VerificationAttestation
|
||||||
const runner = opts.runner ?? runCommand;
|
const runner = opts.runner ?? runCommand;
|
||||||
const checks: VerificationCheck[] = [];
|
const checks: VerificationCheck[] = [];
|
||||||
const risk_findings: RiskFinding[] = [];
|
const risk_findings: RiskFinding[] = [];
|
||||||
|
const memory_b_cells: MemoryBCellResult[] = [];
|
||||||
|
|
||||||
if (!fs.existsSync(target)) throw new Error(`target does not exist: ${opts.target}`);
|
if (!fs.existsSync(target)) throw new Error(`target does not exist: ${opts.target}`);
|
||||||
|
|
||||||
|
|
@ -62,10 +66,16 @@ export function verifyTarget(opts: VerifyTargetOptions): VerificationAttestation
|
||||||
const content = fs.readFileSync(file, "utf-8");
|
const content = fs.readFileSync(file, "utf-8");
|
||||||
const legibility = assessLegibilityRisk(content);
|
const legibility = assessLegibilityRisk(content);
|
||||||
if (legibility.risky) risk_findings.push({ kind: "human_legibility", severity: "high", file, detail: legibility.reason });
|
if (legibility.risky) risk_findings.push({ kind: "human_legibility", severity: "high", file, detail: legibility.reason });
|
||||||
if (/\.(ts|tsx|js|jsx)$/i.test(file)) addAstFindings(scanAstFile(file), risk_findings);
|
if (/\.(ts|tsx|js|jsx)$/i.test(file)) {
|
||||||
|
const cell = assessMemoryBCell(file, content, opts.memoryCachePath, opts.now);
|
||||||
|
memory_b_cells.push(cell);
|
||||||
|
if (cell.report) addAstFindings(cell.report, risk_findings);
|
||||||
|
if (!cell.report && cell.verdict === "known_unsafe") addMemoryFindings(cell, risk_findings);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
checks.push({ name: "glyph_legibility", status: risk_findings.some((f) => f.kind === "human_legibility") ? "failed" : "passed" });
|
checks.push({ name: "glyph_legibility", status: risk_findings.some((f) => f.kind === "human_legibility") ? "failed" : "passed" });
|
||||||
checks.push({ name: "ast_capabilities", status: "passed", detail: `${risk_findings.filter((f) => ["shell_exec", "network", "fs_write", "eval_like"].includes(f.kind)).length} capability finding(s)` });
|
checks.push({ name: "memory_b_cell_cache", status: "passed", detail: `${memory_b_cells.filter((c) => c.encounter === "cache_hit").length} hit(s), ${memory_b_cells.filter((c) => c.encounter === "first_encounter").length} first encounter(s)` });
|
||||||
|
checks.push({ name: "ast_capabilities", status: "passed", detail: `${risk_findings.filter((f) => ["shell_exec", "network", "fs_write", "eval_like", "memory_b_cell"].includes(f.kind)).length} capability finding(s)` });
|
||||||
|
|
||||||
const pkg = findPackageJson(target);
|
const pkg = findPackageJson(target);
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
|
|
@ -90,6 +100,7 @@ export function verifyTarget(opts: VerifyTargetOptions): VerificationAttestation
|
||||||
observed_changes: changed.files,
|
observed_changes: changed.files,
|
||||||
checks,
|
checks,
|
||||||
risk_findings,
|
risk_findings,
|
||||||
|
memory_b_cells,
|
||||||
human_review: { required: reviewReasons.length > 0, reasons: reviewReasons },
|
human_review: { required: reviewReasons.length > 0, reasons: reviewReasons },
|
||||||
deploy_route: "8099/deploy via git-proxy",
|
deploy_route: "8099/deploy via git-proxy",
|
||||||
verification_status: failed ? "failed" : skipped ? "partial" : "verified",
|
verification_status: failed ? "failed" : skipped ? "partial" : "verified",
|
||||||
|
|
@ -125,6 +136,11 @@ function addAstFindings(report: AstSafetyReport, findings: RiskFinding[]): void
|
||||||
if (report.private_glyphs) findings.push({ kind: "private_glyphs", severity: "high", file: report.file, detail: report.legibility_reason });
|
if (report.private_glyphs) findings.push({ kind: "private_glyphs", severity: "high", file: report.file, detail: report.legibility_reason });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addMemoryFindings(cell: MemoryBCellResult, findings: RiskFinding[]): void {
|
||||||
|
const kinds = ["shell_exec", "network", "fs_write", "eval_like", "private_glyphs"].filter((k) => cell.reason.includes(k));
|
||||||
|
for (const kind of kinds.length > 0 ? kinds : ["memory_b_cell"]) findings.push({ kind, severity: kind === "network" || kind === "fs_write" ? "medium" : "high", file: cell.file, detail: `memory B cell cache hit: ${cell.reason}` });
|
||||||
|
}
|
||||||
|
|
||||||
function reviewReasonsFor(findings: RiskFinding[], changed: string[]): string[] {
|
function reviewReasonsFor(findings: RiskFinding[], changed: string[]): string[] {
|
||||||
const reasons = findings.filter((f) => f.severity === "high").map((f) => `${f.kind}${f.file ? ` in ${path.relative(process.cwd(), f.file)}` : ""}`);
|
const reasons = findings.filter((f) => f.severity === "high").map((f) => `${f.kind}${f.file ? ` in ${path.relative(process.cwd(), f.file)}` : ""}`);
|
||||||
if (changed.some((f) => /(^|\/)(deploy|security|src\/core|src\/fable5)\//.test(f.trim().replace(/^[A-Z? ]+\s+/, "").replace(/\\/g, "/")))) reasons.push("sensitive path changed");
|
if (changed.some((f) => /(^|\/)(deploy|security|src\/core|src\/fable5)\//.test(f.trim().replace(/^[A-Z? ]+\s+/, "").replace(/\\/g, "/")))) reasons.push("sensitive path changed");
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,12 @@ export type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEven
|
||||||
export { sendMissionHeartbeat, writeMissionHeartbeatReceipt } from "./mission-heartbeat.js";
|
export { sendMissionHeartbeat, writeMissionHeartbeatReceipt } from "./mission-heartbeat.js";
|
||||||
export type { MissionHeartbeatOptions, MissionHeartbeatReceipt, MissionHeartbeatStatus } from "./mission-heartbeat.js";
|
export type { MissionHeartbeatOptions, MissionHeartbeatReceipt, MissionHeartbeatStatus } from "./mission-heartbeat.js";
|
||||||
|
|
||||||
|
export { assessMemoryBCell, memoryBCellStats } from "./memory-b-cell.js";
|
||||||
|
export type { MemoryBCellEntry, MemoryBCellResult, MemoryBCellStats } from "./memory-b-cell.js";
|
||||||
|
|
||||||
|
export { checkReceiptHealth } from "./receipt-consumer.js";
|
||||||
|
export type { ReceiptHealth } from "./receipt-consumer.js";
|
||||||
|
|
||||||
export { createDuelEvalReceipt, createEvalTraceReceipt, gradeSetRecovery, tallyDuelReceipts, writeDuelEvalReceipt, writeEvalTraceReceipt } from "./eval-trace.js";
|
export { createDuelEvalReceipt, createEvalTraceReceipt, gradeSetRecovery, tallyDuelReceipts, writeDuelEvalReceipt, writeEvalTraceReceipt } from "./eval-trace.js";
|
||||||
export type { DuelEvalReceipt, DuelTally, EvalTraceCommand, EvalTraceReceipt, SetGradeReceipt } from "./eval-trace.js";
|
export type { DuelEvalReceipt, DuelTally, EvalTraceCommand, EvalTraceReceipt, SetGradeReceipt } from "./eval-trace.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { assessMemoryBCell, memoryBCellStats } from "./memory-b-cell.js";
|
||||||
|
|
||||||
|
function tmpRoot(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), "memory-b-cell-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("memory B cell pattern cache", () => {
|
||||||
|
it("learns safe patterns and returns instant cache hits", () => {
|
||||||
|
const cache = path.join(tmpRoot(), "cache.json");
|
||||||
|
const source = "export const add = (a: number, b: number) => a + b;\n";
|
||||||
|
|
||||||
|
const first = assessMemoryBCell("safe.ts", source, cache, new Date("2026-06-21T00:00:00.000Z"));
|
||||||
|
const second = assessMemoryBCell("safe.ts", source, cache, new Date("2026-06-21T00:01:00.000Z"));
|
||||||
|
|
||||||
|
expect(first.encounter).toBe("first_encounter");
|
||||||
|
expect(first.verdict).toBe("known_safe");
|
||||||
|
expect(second.encounter).toBe("cache_hit");
|
||||||
|
expect(second.verdict).toBe("known_safe");
|
||||||
|
expect(memoryBCellStats(cache)).toMatchObject({ total: 1, known_safe: 1, known_unsafe: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("learns unsafe subprocess patterns", () => {
|
||||||
|
const cache = path.join(tmpRoot(), "cache.json");
|
||||||
|
const source = "import { execSync } from 'node:child_process'; execSync('echo hi');\n";
|
||||||
|
|
||||||
|
const first = assessMemoryBCell("unsafe.ts", source, cache);
|
||||||
|
const second = assessMemoryBCell("unsafe.ts", source, cache);
|
||||||
|
|
||||||
|
expect(first.verdict).toBe("known_unsafe");
|
||||||
|
expect(first.reason).toContain("shell_exec");
|
||||||
|
expect(second.encounter).toBe("cache_hit");
|
||||||
|
expect(memoryBCellStats(cache)).toMatchObject({ total: 1, known_safe: 0, known_unsafe: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import * as crypto from "node:crypto";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { scanAstSource, type AstSafetyReport } from "../core/ast-safety.js";
|
||||||
|
|
||||||
|
export type MemoryBCellEncounter = "first_encounter" | "cache_hit";
|
||||||
|
export type MemoryBCellVerdict = "known_safe" | "known_unsafe";
|
||||||
|
|
||||||
|
export interface MemoryBCellEntry {
|
||||||
|
key: string;
|
||||||
|
verdict: MemoryBCellVerdict;
|
||||||
|
reason: string;
|
||||||
|
seen: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryBCellResult {
|
||||||
|
schema: "fable.memory_b_cell.v1";
|
||||||
|
file: string;
|
||||||
|
key: string;
|
||||||
|
encounter: MemoryBCellEncounter;
|
||||||
|
verdict: MemoryBCellVerdict;
|
||||||
|
reason: string;
|
||||||
|
report?: AstSafetyReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryBCellStats {
|
||||||
|
schema: "fable.memory_b_cell.stats.v1";
|
||||||
|
total: number;
|
||||||
|
known_safe: number;
|
||||||
|
known_unsafe: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheFile { entries: Record<string, MemoryBCellEntry> }
|
||||||
|
|
||||||
|
export const DEFAULT_MEMORY_B_CELL_CACHE = path.join(".fable", "memory-b-cells.json");
|
||||||
|
|
||||||
|
export function assessMemoryBCell(file: string, source: string, cachePath = DEFAULT_MEMORY_B_CELL_CACHE, now = new Date()): MemoryBCellResult {
|
||||||
|
const key = patternKey(source);
|
||||||
|
const cache = readCache(cachePath);
|
||||||
|
const hit = cache.entries[key];
|
||||||
|
if (hit) {
|
||||||
|
hit.seen += 1;
|
||||||
|
hit.updatedAt = now.toISOString();
|
||||||
|
writeCache(cachePath, cache);
|
||||||
|
return { schema: "fable.memory_b_cell.v1", file, key, encounter: "cache_hit", verdict: hit.verdict, reason: hit.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = scanAstSource(file, source);
|
||||||
|
const verdict: MemoryBCellVerdict = report.decision === "allow" ? "known_safe" : "known_unsafe";
|
||||||
|
const reason = report.decision === "allow" ? "clean structural scan" : riskyReason(report);
|
||||||
|
cache.entries[key] = { key, verdict, reason, seen: 1, updatedAt: now.toISOString() };
|
||||||
|
writeCache(cachePath, cache);
|
||||||
|
return { schema: "fable.memory_b_cell.v1", file, key, encounter: "first_encounter", verdict, reason, report };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memoryBCellStats(cachePath = DEFAULT_MEMORY_B_CELL_CACHE): MemoryBCellStats {
|
||||||
|
const entries = Object.values(readCache(cachePath).entries);
|
||||||
|
return {
|
||||||
|
schema: "fable.memory_b_cell.stats.v1",
|
||||||
|
total: entries.length,
|
||||||
|
known_safe: entries.filter((e) => e.verdict === "known_safe").length,
|
||||||
|
known_unsafe: entries.filter((e) => e.verdict === "known_unsafe").length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function patternKey(source: string): string {
|
||||||
|
// ponytail: content hash cache; upgrade to normalized AST fingerprints after real false misses.
|
||||||
|
return crypto.createHash("sha256").update(source).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskyReason(report: AstSafetyReport): string {
|
||||||
|
const risks = [
|
||||||
|
report.shell_exec && "shell_exec",
|
||||||
|
report.network && "network",
|
||||||
|
report.fs_write && "fs_write",
|
||||||
|
report.eval_like && "eval_like",
|
||||||
|
report.private_glyphs && "private_glyphs",
|
||||||
|
].filter(Boolean);
|
||||||
|
return risks.length > 0 ? `risky capabilities: ${risks.join(", ")}` : "structural review required";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCache(cachePath: string): CacheFile {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(cachePath, "utf-8")) as CacheFile;
|
||||||
|
} catch {
|
||||||
|
return { entries: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCache(cachePath: string, cache: CacheFile): void {
|
||||||
|
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||||
|
fs.writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { checkReceiptHealth } from "./receipt-consumer.js";
|
||||||
|
|
||||||
|
describe("receipt consumer health", () => {
|
||||||
|
it("is healthy when a receipt landed inside the window", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "receipt-health-"));
|
||||||
|
const file = path.join(dir, "latest.json");
|
||||||
|
fs.writeFileSync(file, "{}\n");
|
||||||
|
const now = new Date();
|
||||||
|
fs.utimesSync(file, now, now);
|
||||||
|
|
||||||
|
expect(checkReceiptHealth(dir, 15, now).status).toBe("healthy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades when there are no recent receipts", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "receipt-health-"));
|
||||||
|
|
||||||
|
expect(checkReceiptHealth(dir, 15, new Date("2026-06-21T00:00:00.000Z"))).toMatchObject({ status: "degraded", receipts: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
export interface ReceiptHealth {
|
||||||
|
schema: "fable.receipt_health.v1";
|
||||||
|
status: "healthy" | "degraded";
|
||||||
|
checkedAt: string;
|
||||||
|
windowMinutes: number;
|
||||||
|
receipts: number;
|
||||||
|
newest?: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkReceiptHealth(root = ".fable", windowMinutes = 15, now = new Date()): ReceiptHealth {
|
||||||
|
const files = fs.existsSync(root) ? walk(root).filter((f) => f.endsWith(".json") || f.endsWith(".jsonl")) : [];
|
||||||
|
const newestMs = files.map((f) => fs.statSync(f).mtimeMs).sort((a, b) => b - a)[0];
|
||||||
|
const newest = newestMs ? new Date(newestMs).toISOString() : undefined;
|
||||||
|
const healthy = newestMs !== undefined && now.getTime() - newestMs <= windowMinutes * 60_000;
|
||||||
|
return {
|
||||||
|
schema: "fable.receipt_health.v1",
|
||||||
|
status: healthy ? "healthy" : "degraded",
|
||||||
|
checkedAt: now.toISOString(),
|
||||||
|
windowMinutes,
|
||||||
|
receipts: files.length,
|
||||||
|
newest,
|
||||||
|
reason: healthy ? undefined : `no receipt newer than ${windowMinutes} minute(s)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(dir: string): string[] {
|
||||||
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
return entry.isDirectory() ? walk(full) : [full];
|
||||||
|
});
|
||||||
|
}
|
||||||
24
src/index.ts
24
src/index.ts
|
|
@ -1832,6 +1832,30 @@ fable
|
||||||
console.log(``);
|
console.log(``);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fable
|
||||||
|
.command("memory-b-cell <file>")
|
||||||
|
.description("Assess a file through the memory B cell pattern cache")
|
||||||
|
.option("--cache <path>", "Cache path", path.join(".fable", "memory-b-cells.json"))
|
||||||
|
.action(async (file: string, opts: { cache?: string }) => {
|
||||||
|
const { assessMemoryBCell } = await import("./fable5/memory-b-cell.js");
|
||||||
|
const full = path.resolve(file);
|
||||||
|
const result = assessMemoryBCell(full, fs.readFileSync(full, "utf-8"), opts.cache);
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
if (result.verdict === "known_unsafe") process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
fable
|
||||||
|
.command("receipt-health")
|
||||||
|
.description("Check receipt consumer health over a recent time window")
|
||||||
|
.option("--root <path>", "Receipt root", ".fable")
|
||||||
|
.option("--window-minutes <n>", "Freshness window", (v) => Number(v), 15)
|
||||||
|
.action(async (opts: { root?: string; windowMinutes?: number }) => {
|
||||||
|
const { checkReceiptHealth } = await import("./fable5/receipt-consumer.js");
|
||||||
|
const health = checkReceiptHealth(opts.root ?? ".fable", opts.windowMinutes ?? 15);
|
||||||
|
console.log(JSON.stringify(health, null, 2));
|
||||||
|
if (health.status !== "healthy") process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
fable
|
fable
|
||||||
.command("heartbeat")
|
.command("heartbeat")
|
||||||
.description("Write a mission-control heartbeat locally and optionally POST it remotely")
|
.description("Write a mission-control heartbeat locally and optionally POST it remotely")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue