Skip to content

Commit d9ffa76

Browse files
authored
feat(agent): let Stacky edit the family's lists, and answer what changed (#61)
* feat(memory): say out loud what an edit did to a family's list A family's Road-Trip list grew from thirteen items to twenty-seven and never had a single one ticked off. Nothing was malformed; things went quiet. Each re-post of the list was re-extracted with fresh wording ("Alternative Dachbox" came back as suchen, recherchieren, pruefen, besorgen), and because dedup matches on exact text, every variant landed as a new item. `stack.list_doc` reads a list page and reports what one edit actually did: ticked off, reopened, added, moved between headings, reworded, and -- the only category that means something was destroyed -- removed, always named in full. A count is not checkable by a family member; "8 items became 7" tells nobody which one went. Pure: no git, no Matrix, no I/O. It is what the write path will call to turn an opaque rewrite into a reviewable one, what a CLI verb would call, and what the curator needs so the classifier stops replacing the family's words with its own. Also records the findings and plan in docs/design/brain/write-layer.md so the reasoning behind all of this outlives the session that produced it. * feat(memory): stop a re-posted list from growing every time it is posted A family kept one list for a camping trip and re-posted the whole thing whenever something changed, because that was the only way to say "this is the current state". Thirteen items became twenty-seven, and not one was ever ticked off. Nothing was broken in the merge. The classifier simply never saw the list it was adding to. Reading each post blind, it re-worded the items every pass -- "Alternative Dachbox" came back as suchen, then recherchieren, then pruefen, then besorgen -- and because the merge matches on exact text, every variant landed as a new entry. So show it the list. A note captured into a topic room now carries that topic's current todos into the classify prompt, framed as already recorded, with the point spelled out: an item is not new because it is phrased differently, and marking something finished is not this field's job. Verified on the rig against the real list: posting it, posting it again, and posting it a third time with "-> CHECK" markers all leave the list at fourteen items. Before this, the third one added "Fenstertasche pruefen" -- the model understood the marker and had nowhere to put it. * feat(memory): let a whole page be rewritten, and report what that changed Reading the vault is fs-shaped: read_file on a path, and it works because every model is trained on it. Writing had no counterpart, so it grew domain verbs instead, and a model that can describe the right list perfectly still cannot perform twenty string-matched calls in a row to produce it. `stack memory write <path> --by <person>` is the counterpart. One page in, one page out, attributed. What comes back is not "ok": for a list page it names what the edit did, and always names in full anything it removed. $ stack memory write family/camping/todos.md --by marge Wrote family/camping/todos.md (by marge) ticked off 2: Heringe mitbringen, Kochloeffel mitbringen $ stack memory write family/camping/todos.md --by bart Wrote family/camping/todos.md (by bart) REMOVED 1: Campingstuehle mitbringen That report is also the reason a primitive write is safe to hand to a model: a rewrite that quietly loses six items stops being quiet. Content arrives via the agent's own data directory rather than argv, because a markdown document does not survive shlex. * feat(agent): let Stacky change a list by rewriting the page Stacky could read the family's vault and change nothing in it. Changing a list meant one CLI call per item, matched by substring. Asked to tidy one, it produced the correct final document in chat, grouped and split exactly as asked, then failed to perform the twenty calls that would have made it real, and said it had. So `write_file` on a vault page now routes to `stack memory write`. Stacky reads the page, rewrites it whole, and reads back what actually changed ("ticked off 2: ...", or "REMOVED 1: ..."). Ticking off is `- [x]`, splitting a list in two is a heading. Ordinary markdown, which is what these models are good at. `edit_file` on a vault page is refused with instructions rather than translated: a family list is fifteen lines, whole-document reasoning is the point, and patching a read-only mount would be the same write with extra ways to be wrong. SKILL.md changes with it, or it would keep teaching the verbs this replaces. It now carries the rules that matter: write the page back in full, keep the family's words, tick off rather than delete, relay what the write reports, and never claim a change without having read that report. * fix(agent): make Stacky's edits to a list actually reach it Rewriting a page died in the tool loop instead of saving. Every edit the agent made to a family list was lost, and it had no way to tell. Editing a page through apply_patch now points at the way that works rather than failing against a read-only mount. * fix(agent): stop Stacky ticking off items nobody asked about Asked to reorganise a list, it would strike two unrelated items and describe a change it never made. It now edits the page instead, and says so when it cannot. * feat(agent): let Stacky tick one item off without rewriting the list Changing a list meant replacing the whole page, so an edit made from a copy that was a few seconds old quietly undid whatever someone else had just added. Narrow changes now touch only the lines they name, and if the list moved on in the meantime Stacky is told which line no longer fits instead of overwriting the change. * feat(memory): say in the history what each change actually did Every edit was recorded as "updated todos.md", whoever made it and whatever it changed, so scrolling the history answered nothing. A change now names itself: "marge ticked off 1: Wetter checken in camping". Pages that are not lists say how much text moved, and an edit that lost items still names every one of them. * feat(memory): ask what changed in the family's memory, and when The vault has always recorded every version and who wrote it, and nothing read it back. Now "what's new this week", "who changed Homer's page" and "when did this land on the list" are one question each, for the family and for Stacky. * feat(agent): let Stacky answer "what has Homer been up to lately" Asked that, Stacky searched four times and guessed from whatever his page says now. Questions about change have their own answer: what was added, by whom, when. Housekeeping by the bots stays out of it, so the answer is what the family did.
1 parent e9f7f7e commit d9ffa76

23 files changed

Lines changed: 2946 additions & 34 deletions

docs/design/brain/write-layer.md

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# Writing to memory — one primitive, validated per page type
2+
3+
> Status: Design. Nothing here is built except the findings, which are verified.
4+
> Applies to: how humans, the archivist and the agent share the vault's writable pages
5+
> Sibling docs:
6+
> - [memory-mutations.md](memory-mutations.md) — the write seam as built today
7+
> - [interaction-patterns.md](interaction-patterns.md) — the bot interaction layers
8+
> - [vault-format.md](vault-format.md) — what a page is
9+
> Evidence: a real family Road-Trip room transcript (June to August 2026) plus a
10+
> reproduction on the demo rig, 2026-08-03.
11+
12+
## Why this doc exists
13+
14+
A family used a topic room for two months to plan a trip. It mostly worked. The
15+
part that failed, failed completely: a thirteen-item list became twenty-seven
16+
entries, none of them ever ticked off, and the agent claimed twice to have ticked
17+
them. This doc records what we found, what we decided, and what is still open, so
18+
the plan survives the session that produced it.
19+
20+
The headline is not the duplicates. It is that **the archivist invites a
21+
conversation it cannot have**. It answered a pasted list with a title, a summary,
22+
five extracted facts and six tags, which reads like a participant. Then:
23+
24+
07:31:39 Marge: Fenstertasche ist bestellt, Thema 1 kann abgehakt werden
25+
07:32:27 Marge: Welche Themen sind noch auf der Liste?
26+
07:34:50 Marge: * ?Welche Themen sind noch auf der Liste?
27+
(nothing, three times)
28+
07:39:40 Marge: * Liste Bus Erweiterungen: 1. ... 10.
29+
30+
She tried plain language, then the archivist's own documented `?` syntax, then
31+
gave up and re-posted the whole list. Every duplicate downstream is blast radius
32+
from that workaround. Re-posting is not a habit to design around; it is what
33+
someone does when nothing answers.
34+
35+
## Findings
36+
37+
Each of these was verified in code or reproduced, not inferred from the report
38+
that started the session.
39+
40+
### The list path
41+
42+
1. **The agent hallucinates completed actions.** Reproduced on the rig: loop
43+
iteration 0, zero tool calls, and a reply claiming the list was updated.
44+
2. **The hallucination was masked by a concurrent true write.** The list really
45+
did change, via the archivist. Commit `chore(todos): homer added action items
46+
to camping` is the curator's message, not the agent's. From the family's side
47+
it looked like the agent worked.
48+
3. **Extraction inverts done-markers.** The capture LLM understood `-> CHECK`
49+
perfectly ("eine Mischung aus bereits geprüften Gegenständen") and then emitted
50+
`- [ ] Fenstertasche prüfen`. It had the understanding and no way to express
51+
it: `action_items` has no done state, so it bent the meaning to fit the shape.
52+
4. **Extraction rewrites the family's words.** "Kühlbox" became "Kühlbox
53+
mitbringen". `add_items` dedups on exact task text, so each re-extraction of
54+
the same list added a fresh variant: "Alternative Dachbox"
55+
recherchieren/prüfen/suchen/besorgen, four entries for one item. This is the
56+
mechanical cause of 13 items becoming 27. **Verbatim is not politeness, it is
57+
what makes idempotency possible.**
58+
5. **Message edits re-capture.** There is no `m.replace` handling anywhere in the
59+
archivist or microbot. An edit arrives as a new event with a `* ` body and is
60+
filed as a fresh paste. Six re-posts became six notes and six extractions.
61+
6. **One list per topic.** Marge asked for two, in words: "Es sollen zwei Listen
62+
sein. Eine Liste mit Verbesserungen und eine Liste mit Dingen die zusätzlich
63+
auf unsere Packliste sollen."
64+
7. **The agent can describe the right answer and not perform it.** Asked to split
65+
and dedupe, it produced the correct final document in chat, grouped and
66+
categorised, then failed to execute it as twenty-odd string-matched CLI calls.
67+
That gap is the argument for a different primitive, not a better prompt.
68+
69+
### Ownership
70+
71+
8. **`?` search is dead.** The room welcome promises `?<frage>`; `archivist.py`
72+
only searches when `mentioned or is_documents`. A documented feature that
73+
silently does nothing.
74+
9. **Address-beats-ambient already exists in the code.** `_handle_correction` is
75+
gated on `not mentioned`, with a comment saying deliberate address beats
76+
ambient context. The archivist applies the rule to itself and has no idea the
77+
agent exists.
78+
10. **The signal is free.** `AGENT_NAME=Stacky` and `AGENT_BOT_ID=stacky-bot` are
79+
already in the bot-runner's environment.
80+
11. **But the matcher is not shared.** `name_trigger.py` lives in the agent
81+
stacklet, and the agent container mounts no `lib/stack`. If the two ever
82+
disagree about "was the agent addressed", either both act or neither does.
83+
12. **The two answerers are not redundant.** The archivist's search is dual
84+
(Paperless plus vault, with synthesis and deep-dive); the agent's
85+
`memory_search` is vault-only, and `stack docs search` does not exist.
86+
13. **`_on_text` has 17 decision points**, 7+ in the `elif` chain.
87+
88+
### Structure
89+
90+
14. **Capture sits in the wrong stacklet.** `capture_pipeline.py` writes no
91+
Paperless document; `_publish` is classify plus mirror. Its one docs
92+
dependency is `paperless.get_tags()` for the person roster, which is itself a
93+
memory concern sourced from docs. And `git_mirror.py:57` does a hard-coded
94+
`sys.path` traversal into `stacklets/memory/bot/cli/`.
95+
15. **The agent reads the projection and writes the source.** `MEMORY_VAULT_DIR`
96+
is `{data_dir}/memory/brain`, mounted `:ro`; writes land in
97+
`{data_dir}/memory/vault` via `update_memory`. **A naive `write_file` bolted
98+
onto the path the agent already reads would write into a generated
99+
projection and lose it on the next sync.**
100+
16. **No lock on the working copy.** The host CLI and the curator share one, with
101+
no `status --porcelain` guard on the rebase/reset paths.
102+
103+
### Smaller
104+
105+
17. **Fetched interstitials get filed as knowledge.** A Google Maps link became
106+
"Google Cookie- und Datenschutzhinweise", tagged `privacy-first`, `consent`.
107+
18. **The agent reached for `stack up memory`** twice. `DOMAIN_ALLOW` refused it,
108+
so the refusal is doing real work.
109+
19. **Capture latency is 10 to 16 seconds**, not the 30 estimated from log
110+
timestamps. Synchronous is fine; the async design was unnecessary.
111+
112+
## Decisions taken
113+
114+
**Capture is a memory capability.** `stack memory capture` ships in memory's
115+
namespace (PR #60), handling pasted text, links and images through the
116+
archivist's own pipeline. The handler still sits under `docs/bot` because the
117+
pipeline does; it travels with the pipeline when that moves.
118+
119+
**Reject unvalidated free-form rewrite; accept validated primitive writes.** The
120+
first proposal was to let the agent rewrite `todos.md` freely. Rejected: it hands
121+
whole-file overwrite to the model that just hallucinated, turning a visible
122+
harmless failure ("nothing happened") into an invisible destructive one ("six of
123+
twenty-five items quietly vanished"). What changes the calculus is **feedback**:
124+
a validator that reports what the edit did, so loss is never silent.
125+
126+
**Address decides who acts.** Exactly one component responds to a message.
127+
Addressed to the agent, the agent owns it. Nobody addressed, the archivist's
128+
ambient rules apply and its shape heuristics are fine precisely because nobody
129+
asked for anything.
130+
131+
**The archivist becomes ambient-only, eventually.** It watches, files, corrects,
132+
and never answers. Not yet: its search reaches Paperless and the agent's does
133+
not. Sequenced, not big-bang.
134+
135+
## The direction: a markdown-native write layer
136+
137+
Reads are already fs-native. The agent does `read_file("vault/homer/about.md")`
138+
and it works, because models are trained on it and nobody had to design a
139+
retrieval verb. Writes have no counterpart, and that asymmetry is what forced
140+
domain verbs like `todo strike "<item>" --by <person>`. We did not choose verbs
141+
because writes are special. We chose them because there was no write primitive.
142+
143+
The proposal is the write analogue of what `/go` did for links: a stable logical
144+
surface whose backing store is implementation detail. `update_memory` already
145+
says this out loud, and the layer is the generalisation of it.
146+
147+
**Shape.**
148+
149+
- fs-like primitives (`write_file`, `apply_patch`) over one logical namespace.
150+
- Routing under the hood: source versus projection, which bucket, which store.
151+
- Registration per well-known page type: which schema applies, and what is
152+
writable at all. It is a capability boundary as much as a validator.
153+
- Validation is **semantic**, not syntactic. "Valid markdown with `- [ ]` lines"
154+
is easy and worthless. The check that matters compares before and after:
155+
items removed outright, items reworded, items added, structure violated.
156+
- The tool result **is** the review: "Struck 7. Removed 6 you were not asked to
157+
remove: Fenstertasche, Kochlöffel, ... Reworded 3. Confirm or revise."
158+
- The semantic diff also writes the commit message, so intent falls out of
159+
validation instead of being a string the caller invents.
160+
- Both writers bind to the same schema. One place states "task text is the
161+
family's words, verbatim", instead of it being a habit we hope survives.
162+
163+
**What it subsumes.** Variadic `strike`, `--list`, `--done`, and sections-as-CLI
164+
-grammar were all attempts to make a verb expressive enough to describe a
165+
document edit. If the primitive is a document edit, none get built. That is the
166+
reason to decide this before shipping them, not after.
167+
168+
**What it does not fix.** An agent that claimed a strike without calling anything
169+
can still claim an edit without calling anything. Validation only runs on writes
170+
that happen. What changes is the odds: the transcript shows it *did* perform the
171+
batched add and *did not* perform eight strikes, so collapsing the operation to
172+
one call is the lever. It also does not remove the need for verbatim extraction
173+
on the ambient path, where no agent is involved at all.
174+
175+
## Open decisions
176+
177+
**The write namespace.** Mirror the disk (`vault/family/camping/todos.md`) or
178+
mirror `/go` (`topic/camping/todos`)? Mirroring `/go` is the more honest version
179+
of the analogy, and bucket derivation is exactly the routing the layer should
180+
own. But the agent's reads use disk-shaped paths today, so either the read side
181+
moves too or two namespaces coexist for a while.
182+
183+
**Concurrency semantics.** `update_memory` takes a transform (`doc -> doc`)
184+
applied to a fresh read, which is read-modify-write. `write_file` implies
185+
last-writer-wins: the agent reads, thinks for thirty seconds, the curator writes,
186+
the agent writes, and the curator's change is gone silently. The fs world's
187+
answer fits the model: hand out a revision on read, carry it on write, fail a
188+
stale write with "this changed under you, here it is again". Same diagnostic
189+
channel as schema validation. Worth doing regardless, given finding 16.
190+
191+
**Whether the capture pipeline migration precedes or follows the write layer.**
192+
193+
## Order of work
194+
195+
1. **The list schema and validator, as a pure module.** No I/O. It is what the
196+
write layer calls, what a CLI verb would call, and what the curator needs. It
197+
is testable today against the real list from the transcript. First step
198+
regardless of which way the open decisions go.
199+
2. **Verbatim extraction.** The ambient path stops rewriting the family's words.
200+
Independent of everything else and the single highest-value mechanical fix.
201+
3. **The agent can actually change a list.** Behind the validator, whichever
202+
primitive wins.
203+
4. **The ownership rule.** Share `addressed_by_name`, mount `lib/stack` into the
204+
agent, archivist skips its capture branches when the agent was addressed.
205+
5. **The archivist stops promising what it cannot do.** Either `?` works or the
206+
welcome stops offering it.
207+
6. Then: `m.replace`, the interstitial capture, `stack docs search`, and the
208+
capture-pipeline migration.

lib/stack/forgejo.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ def put_file(self, owner: str, repo: str, path: str, *,
327327

328328
def edit_file(self, owner: str, repo: str, path: str,
329329
transform: Callable[[str], str], *,
330-
message: str, branch: str = "main",
330+
message: str | Callable[[str, str], str], branch: str = "main",
331331
author_name: str | None = None,
332332
author_email: str | None = None) -> dict | None:
333333
"""Read a file, run `transform` over its text, and commit the result.
@@ -342,16 +342,25 @@ def edit_file(self, owner: str, repo: str, path: str,
342342
churn the repo with empty commits. `author_name`/`author_email` set the
343343
commit author, so the person who triggered the change owns it in the
344344
history, not the token's identity.
345+
346+
`message` may be a callable taking the text before and after. Only the
347+
caller of a read-modify-write knows what its own transform did, and it
348+
cannot know before the transform has run against the current file --
349+
which is after the point a plain string would have had to be decided.
350+
Passing the function instead is what lets a commit subject say "ticked
351+
off Kühlbox" rather than "updated todos.md", and a history worth
352+
reading is the difference.
345353
"""
346354
existing = self.get_file(owner, repo, path, ref=branch)
347355
prior = existing.get("content", "") if existing else ""
348356
sha = existing.get("sha") if existing else None
349357
merged = transform(prior)
350358
if merged == prior:
351359
return None
360+
subject = message(prior, merged) if callable(message) else message
352361
return self.put_file(
353362
owner, repo, path,
354-
content=merged, message=message, branch=branch, sha=sha,
363+
content=merged, message=subject, branch=branch, sha=sha,
355364
author_name=author_name, author_email=author_email,
356365
)
357366

0 commit comments

Comments
 (0)