From 170cf985bbd8bf80302dab8a7e778280215af962 Mon Sep 17 00:00:00 2001 From: Jack Lau Date: Thu, 15 Oct 2026 01:01:59 -0400 Subject: [PATCH] =?UTF-8?q?fix(dashboard):=20label=20API=20preference=20wr?= =?UTF-8?q?ites=20as=20`=C2=B7=20agent`,=20not=20`=C2=B7=20you`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/preferences recorded every write in preferences/edits.md as `· you`. /distill treats `· you` lines as the owner's: never reworded, never removed, deletions never re-added. But agents write through this endpoint too. On 2026-09-20 an agent rewrote the whole global preferences page via the API; the ledger called it `· you`, so the agent's wording became permanently protected and later refreshes stayed strictly additive. The POST now takes an optional `source` — "you" or "agent", anything else is a 400 and the page is untouched — defaulting to "agent". The dashboard editor sends source:"you"; it is the only hand-edit path. The distill skill defines `· agent` entries as revisable like `· distill` lines, never the owner's. Co-Authored-By: Claude Fable 5.1 --- assets/dashboard.js | 2 +- assets/skills/distill/SKILL.md | 8 +++-- internal/dashboard/prefs_source_test.go | 41 +++++++++++++++++++++++++ internal/dashboard/server.go | 15 ++++++++- internal/dashboard/server_test.go | 3 +- 5 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 internal/dashboard/prefs_source_test.go diff --git a/assets/dashboard.js b/assets/dashboard.js index 3d20a44..32b6e85 100644 --- a/assets/dashboard.js +++ b/assets/dashboard.js @@ -749,7 +749,7 @@ async function initPrefs(){ tog.disabled=true; st.textContent='saving…'; try{ const r=await apiFetch('/api/preferences',{method:'POST',headers:{'Content-Type':'application/json'}, - body:JSON.stringify({brain:prefsBrain,content:ta.value})}); + body:JSON.stringify({brain:prefsBrain,content:ta.value,source:'you'})}); const j=await r.json(); if(r.ok){ st.textContent='saved · '+j.bytes+' bytes'; if(j.cap) PCAP=j.cap; showMeter(j.bytes); setMode(false); } // close only on success else st.textContent='error: '+(j.error||r.status); // stay open; don't lose the edit diff --git a/assets/skills/distill/SKILL.md b/assets/skills/distill/SKILL.md index e94936e..e347c21 100644 --- a/assets/skills/distill/SKILL.md +++ b/assets/skills/distill/SKILL.md @@ -376,7 +376,9 @@ may reword only bullets *you* added, never the user's. One readable log carries everything you need: `$DATA/preferences/edits.md`, the **diff history** of the page. Each entry is a timestamped, sourced diff — `· you` is a hand-edit (dashboard or by -hand), `· distill` is a refresh you wrote. The diff is **context-free** (zero unchanged lines): +hand in the dashboard editor), `· agent` is a write by an agent or script through the dashboard API +(the API's default label — treat those lines as revisable, like your own, never as the user's), and +`· distill` is a refresh you wrote. The diff is **context-free** (zero unchanged lines): **every** line is a real change — a line starting `-` was removed, `+` was added, and the rest of the line is the content verbatim. A removed bullet `- Foo` therefore appears as `-- Foo` (the first `-` is the diff marker, `- Foo` is the line). Do not read it as two dashes of content: @@ -398,8 +400,8 @@ Then: parallel key ledger to keep in sync: - **What the user removed** — any `-` line in a `· you` entry. NEVER re-add a steer the user deleted; the removal is deliberate. (You just *see* it now, instead of matching keys.) - - **Which current bullets are yours** — ones you introduced in a `· distill` entry that the user - hasn't edited or removed since. Only these may you reword or collapse; treat every other + - **Which current bullets are yours** — ones you introduced in a `· distill` or `· agent` entry + that the user hasn't edited or removed since. Only these may you reword or collapse; treat every other bullet as the user's and leave it untouched. - **Whether the user edited since your last write** — if the newest `· you` entry is newer than your newest `· distill` entry, the page is the user's this run: stay **strictly additive**, diff --git a/internal/dashboard/prefs_source_test.go b/internal/dashboard/prefs_source_test.go new file mode 100644 index 0000000..9d0f214 --- /dev/null +++ b/internal/dashboard/prefs_source_test.go @@ -0,0 +1,41 @@ +package dashboard + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The edit ledger must say who wrote the page: only the dashboard editor is the +// owner's hand ("· you"); any other API caller is an agent ("· agent"), so +// /distill never mistakes agent wording for the owner's protected lines. +func TestPreferencesEditSourceLabel(t *testing.T) { + srv, ts := newTestServer(t) + ledger := filepath.Join(srv.Q.Data, "preferences", "edits.md") + page := filepath.Join(srv.Q.Data, "preferences", "global.md") + + if code, resp := postJSON(t, ts.URL+"/api/preferences", map[string]any{"content": "# P\n\n- one\n"}, nil); code != 200 { + t.Fatalf("unlabelled POST = %d %v", code, resp) + } + b, _ := os.ReadFile(ledger) + if !strings.Contains(string(b), " · agent\n") || strings.Contains(string(b), " · you\n") { + t.Errorf("a write without a source must be recorded as · agent, got:\n%s", b) + } + + if code, resp := postJSON(t, ts.URL+"/api/preferences", map[string]any{"content": "# P\n\n- two\n", "source": "you"}, nil); code != 200 { + t.Fatalf("source=you POST = %d %v", code, resp) + } + b, _ = os.ReadFile(ledger) + if !strings.Contains(string(b), " · you\n") { + t.Errorf("the editor's write (source=you) must be recorded as · you, got:\n%s", b) + } + + code, resp := postJSON(t, ts.URL+"/api/preferences", map[string]any{"content": "# P\n\n- three\n", "source": "distill"}, nil) + if code != 400 || !strings.Contains(resp["error"].(string), "source") { + t.Errorf("unknown source must be rejected with 400 naming source, got %d %v", code, resp) + } + if onDisk, _ := os.ReadFile(page); string(onDisk) != "# P\n\n- two\n" { + t.Errorf("rejected write must not touch the page, got %q", onDisk) + } +} diff --git a/internal/dashboard/server.go b/internal/dashboard/server.go index f7bab9f..c275466 100644 --- a/internal/dashboard/server.go +++ b/internal/dashboard/server.go @@ -400,6 +400,19 @@ func (s *Server) doPOST(w http.ResponseWriter, r *http.Request) { s.sendJSON(w, 400, map[string]any{"error": "content must be a string"}) return } + // Who is writing decides the ledger label /distill reads: "you" is the + // owner typing in the dashboard editor (those lines are protected); + // anything else calling this endpoint — an agent, a script — is "agent", + // so its wording never gets mistaken for the owner's. + source := "agent" + if v, has := d["source"]; has { + src, ok := v.(string) + if !ok || (src != "you" && src != "agent") { + s.sendJSON(w, 400, map[string]any{"error": `source must be "you" (the dashboard editor) or "agent"`}) + return + } + source = src + } pdir := filepath.Join(s.Q.Data, "preferences") if err := os.MkdirAll(pdir, 0o755); err != nil { s.postErr(w, err) @@ -419,7 +432,7 @@ func (s *Server) doPOST(w http.ResponseWriter, r *http.Request) { // entry is a real change; nothing unchanged to mistake for an edit. if diffBody := editLedgerDiff(splitPyLines(old), splitPyLines(content)); len(diffBody) > 0 { ts := s.Q.Now().Format("2006-01-02T15:04:05") // local time, seconds - entry := "## " + ts + " · you\n\n```diff\n" + strings.Join(diffBody, "\n") + "\n```\n\n" + entry := "## " + ts + " · " + source + "\n\n```diff\n" + strings.Join(diffBody, "\n") + "\n```\n\n" f, err := os.OpenFile(filepath.Join(pdir, "edits.md"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { s.postErr(w, err) diff --git a/internal/dashboard/server_test.go b/internal/dashboard/server_test.go index 212e0cf..7250742 100644 --- a/internal/dashboard/server_test.go +++ b/internal/dashboard/server_test.go @@ -317,7 +317,8 @@ func TestPreferencesEditLedger(t *testing.T) { t.Parallel() srv, ts := newTestServer(t) post := func(content string) { - code, _ := postJSON(t, ts.URL+"/api/preferences", map[string]any{"content": content}, nil) + // the dashboard editor identifies itself; see TestPreferencesEditSourceLabel for the API default + code, _ := postJSON(t, ts.URL+"/api/preferences", map[string]any{"content": content, "source": "you"}, nil) if code != 200 { t.Fatalf("preferences POST = %d", code) }