feat: add memory b cell pattern cache

This commit is contained in:
artale 2026-06-21 16:02:20 +02:00
parent 084197d109
commit 522ec63025
10 changed files with 287 additions and 3 deletions

View File

@ -276,6 +276,11 @@ fable-agent plinius godmode "improve explanation quality"
- `fable5 flue`
- `fable5 channels`
- `--run <id>`
- `fable5 memory-b-cell <file>`
- `--cache <path>`
- `fable5 receipt-health`
- `--root <path>`
- `--window-minutes <n>`
- `fable5 heartbeat`
- `--id <id>`
- `--name <name>`

View File

@ -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 |
| Pattern recognition receptors | Structural threat detection | AST capability scanner, glyph/covert-channel scanner |
| 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 |
| 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.
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 |

View File

@ -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.

View File

@ -1,8 +1,9 @@
import * as fs from "node:fs";
import * as path from "node:path";
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 { assessMemoryBCell, type MemoryBCellResult } from "./memory-b-cell.js";
export type VerificationStatus = "verified" | "partial" | "failed" | "unverified";
export type RiskLevel = "low" | "medium" | "high";
@ -29,6 +30,7 @@ export interface VerificationAttestation {
observed_changes: string[];
checks: VerificationCheck[];
risk_findings: RiskFinding[];
memory_b_cells: MemoryBCellResult[];
human_review: { required: boolean; reasons: string[] };
deploy_route: "8099/deploy via git-proxy";
verification_status: VerificationStatus;
@ -41,6 +43,7 @@ export interface VerifyTargetOptions {
out?: string;
now?: Date;
requestedBy?: "human" | "agent" | "ci";
memoryCachePath?: 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 checks: VerificationCheck[] = [];
const risk_findings: RiskFinding[] = [];
const memory_b_cells: MemoryBCellResult[] = [];
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 legibility = assessLegibilityRisk(content);
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: "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);
if (pkg) {
@ -90,6 +100,7 @@ export function verifyTarget(opts: VerifyTargetOptions): VerificationAttestation
observed_changes: changed.files,
checks,
risk_findings,
memory_b_cells,
human_review: { required: reviewReasons.length > 0, reasons: reviewReasons },
deploy_route: "8099/deploy via git-proxy",
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 });
}
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[] {
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");

View File

@ -62,6 +62,12 @@ export type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEven
export { sendMissionHeartbeat, writeMissionHeartbeatReceipt } 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 type { DuelEvalReceipt, DuelTally, EvalTraceCommand, EvalTraceReceipt, SetGradeReceipt } from "./eval-trace.js";

View File

@ -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 });
});
});

View File

@ -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`);
}

View File

@ -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 });
});
});

View File

@ -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];
});
}

View File

@ -1832,6 +1832,30 @@ fable
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
.command("heartbeat")
.description("Write a mission-control heartbeat locally and optionally POST it remotely")