From 779b8d3eca292ede03ab59baad8f4862e8103435 Mon Sep 17 00:00:00 2001 From: artale Date: Thu, 18 Jun 2026 03:46:09 +0200 Subject: [PATCH] feat: add feedback memory primitive --- COMMANDS.md | 4 ++++ src/fable5/feedback-memory.test.ts | 36 +++++++++++++++++++++++++++++ src/fable5/feedback-memory.ts | 37 ++++++++++++++++++++++++++++++ src/fable5/index.ts | 3 +++ src/index.ts | 12 +++++++++- 5 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/fable5/feedback-memory.test.ts create mode 100644 src/fable5/feedback-memory.ts diff --git a/COMMANDS.md b/COMMANDS.md index 4334129..3e4736f 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -260,6 +260,10 @@ fable-agent plinius godmode "improve explanation quality" - `--add-rule ` - `--add-failure ` - `--add-lesson ` + - `--approved ` + - `--rejected ` + - `--performance ` + - `--repo ` - `--import` - `fable5 workflow ` - `-s, --subtasks ` diff --git a/src/fable5/feedback-memory.test.ts b/src/fable5/feedback-memory.test.ts new file mode 100644 index 0000000..796db1b --- /dev/null +++ b/src/fable5/feedback-memory.test.ts @@ -0,0 +1,36 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { appendFeedbackMemory, feedbackMemoryPath, feedbackMemoryStats } from "./feedback-memory.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function tmpRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "fable-feedback-memory-")); + roots.push(root); + return root; +} + +describe("feedback memory", () => { + it("appends feedback to the right markdown file", () => { + const root = tmpRoot(); + const entry = appendFeedbackMemory(root, "approved", "ship this\nnow", new Date("2026-06-18T00:00:00.000Z")); + + expect(entry.type).toBe("approved"); + expect(fs.readFileSync(feedbackMemoryPath(root, "approved"), "utf-8")).toContain("ship this now"); + }); + + it("counts entries per memory file", () => { + const root = tmpRoot(); + appendFeedbackMemory(root, "approved", "ok"); + appendFeedbackMemory(root, "rejected", "bad"); + appendFeedbackMemory(root, "rejected", "worse"); + + expect(feedbackMemoryStats(root)).toEqual({ approved: 1, rejected: 2, performance: 0 }); + }); +}); diff --git a/src/fable5/feedback-memory.ts b/src/fable5/feedback-memory.ts new file mode 100644 index 0000000..7da90a2 --- /dev/null +++ b/src/fable5/feedback-memory.ts @@ -0,0 +1,37 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +export type FeedbackMemoryType = "approved" | "rejected" | "performance"; + +export interface FeedbackMemoryEntry { + type: FeedbackMemoryType; + text: string; + createdAt: string; +} + +const FILES: Record = { + approved: "approved.md", + rejected: "rejected.md", + performance: "performance.md", +}; + +export function feedbackMemoryPath(repo: string, type: FeedbackMemoryType): string { + return path.join(repo, ".fable", "memory", FILES[type]); +} + +export function appendFeedbackMemory(repo: string, type: FeedbackMemoryType, text: string, now = new Date()): FeedbackMemoryEntry { + const file = feedbackMemoryPath(repo, type); + const entry = { type, text, createdAt: now.toISOString() }; + fs.mkdirSync(path.dirname(file), { recursive: true }); + if (!fs.existsSync(file)) fs.writeFileSync(file, `# ${type}\n\n`); + fs.appendFileSync(file, `- ${entry.createdAt}: ${text.replace(/\r?\n/g, " ")}\n`); + return entry; +} + +export function feedbackMemoryStats(repo: string): Record { + return Object.fromEntries((Object.keys(FILES) as FeedbackMemoryType[]).map((type) => { + const file = feedbackMemoryPath(repo, type); + const count = fs.existsSync(file) ? fs.readFileSync(file, "utf-8").split(/\r?\n/).filter((line) => line.startsWith("- ")).length : 0; + return [type, count]; + })) as Record; +} diff --git a/src/fable5/index.ts b/src/fable5/index.ts index 8b065c9..ddd2b6d 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -65,6 +65,9 @@ export type { MissionHeartbeatOptions, MissionHeartbeatReceipt, MissionHeartbeat export { createEvalTraceReceipt, writeEvalTraceReceipt } from "./eval-trace.js"; export type { EvalTraceCommand, EvalTraceReceipt } from "./eval-trace.js"; +export { appendFeedbackMemory, feedbackMemoryPath, feedbackMemoryStats } from "./feedback-memory.js"; +export type { FeedbackMemoryEntry, FeedbackMemoryType } from "./feedback-memory.js"; + export { runCyberPreflight, writeCyberPreflightReceipt } from "./cyber-preflight.js"; export type { CyberPreflightCheck, CyberPreflightOptions, CyberPreflightReceipt } from "./cyber-preflight.js"; diff --git a/src/index.ts b/src/index.ts index a0ddfa2..17e4d82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1940,12 +1940,22 @@ fable .option("--add-rule ", "Add a general rule") .option("--add-failure ", "Add a failure entry") .option("--add-lesson ", "Add a lesson learned") + .option("--approved ", "Append approved feedback memory") + .option("--rejected ", "Append rejected feedback memory") + .option("--performance ", "Append performance feedback memory") + .option("--repo ", "Repo path for .fable/memory", ".") .option("--import", "Import from knowledge base") .action(async (project: string, opts: Record) => { const { FiveStageStateFile } = await import("./fable5/state-file-5stage.js"); const state = new FiveStageStateFile(); - if (opts.addFact) { + if (opts.approved || opts.rejected || opts.performance) { + const { appendFeedbackMemory } = await import("./fable5/feedback-memory.js"); + const type = opts.approved ? "approved" : opts.rejected ? "rejected" : "performance"; + const text = (opts.approved ?? opts.rejected ?? opts.performance) as string; + appendFeedbackMemory(path.resolve(opts.repo as string), type, `[${project}] ${text}`); + console.log(` ✓ ${type} feedback memory appended`); + } else if (opts.addFact) { state.addVerifiedFact(project, opts.addFact as string, "cli"); console.log(` ✓ Verified fact added to "${project}"`); } else if (opts.addRule) {