Compare commits

...

4 commits

Author SHA1 Message Date
96403effd6 docs(spec): record the Alfred vault trial as alternate storage (ADR 0001)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 17:31:30 +02:00
59a143671d docs: add chateau pilot review (2026-06-02) — findings from the Signal dry run
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 17:30:51 +02:00
35b967b507 feat(journal): edits update the entry in place instead of duplicating
Editing a `!journal` message now rewrites the entry it created rather
than recording another row. Entries are keyed on their source event id
(new `event_id` column, v2 migration); get_edit() yields that original
id, so the edit is matched back to its row.

`ts` keeps the original send time — it records when the work was logged,
not when the wording was fixed. Confirmation is a 📝 reaction on the
edited message, because replying to an edit event renders as "This event
could not be displayed" in Element and a reply per save is noise.

Supersedes 3dc7eab, which ignored edits outright; updating is what's
actually wanted. Entries predating this have event_id IS NULL and can't
be matched, so editing one is a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wNB1sgkcBGijWjKTjtwG8
2026-09-14 17:01:33 +02:00
3dc7eab2ab fix(journal): ignore message edits so they stop recording duplicates
An edited `!journal ...` message was recorded as a brand-new entry, so
one message edited five times produced five near-identical rows.

Matrix delivers an edit as a fresh m.room.message, and mautrix swaps its
content for `m.new_content` during deserialization
(mautrix/types/event/message.py:393-396), stripping the "* " fallback
prefix. The handler therefore saw something byte-identical to a new
command. Neither @command.passive nor @command.new filters edits, so
guard on evt.content.get_edit() in the plugin.

Also corrects the README quirk that claimed edits could never reach the
bot — it asserted the opposite of the actual behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wNB1sgkcBGijWjKTjtwG8
2026-09-14 16:56:20 +02:00
7 changed files with 1327 additions and 8 deletions

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -0,0 +1,75 @@
# ADR 0001 — Alfred: agent-runtime trial writing to a Markdown vault
**Status:** trial, 2026-09-20
**Owner:** padreug
**Code:** `~/Work/tries/2026-09-20-alfred-vm/` (bohm), not yet in any aiolabs repo
## Context
The community-organizer spec (this repo, `docs/community-organizer-spec.md`)
defines capture as NIP-52 events scoped by NIP-72 communities, produced by
the `tracker` maubot plugin. Phase 1 of `tracker` shipped rules-only; the
LLM tier (§6.1 level 2), Nostr publishing (§4) and per-user signing (§7.2)
never landed. The 2026-06-02 pilot review recorded that the community had
already named the bot "Alfred" and asked for digests, an LLM fallback and
grammar tolerance.
On 2026-09-19 a separate "2nd brain" landed for the operator: plain-Markdown
zk vaults, one Forgejo repo per vault, `brain todos` scanning `- [ ]` lines
in `journal/` and `projects/`, `brain sync` for git. The chateau vault
(`padreug/brain-chateaudufaune`) is one of them.
The May 2026 planning session had suggested an OpenClaw/ZeroClaw-style
agent "running on its own machine" as an alternate runtime (spec §12).
## Decision
Run a **trial** of that alternate runtime, sized to one community and one
vault:
- **Runtime:** ZeroClaw (0.8.3 from nixpkgs, rebuilt with `channel-matrix`;
upstream NixOS module) in a QEMU VM on bohm. Explicitly **not** on cfaun.
- **Store:** the chateau vault — Markdown + git, `main`, same repo the
operator's laptop syncs. Alfred commits and pushes every write with a
repo-scoped write deploy key; force-push is blocked on Forgejo.
- **Inference:** the optimus box (`http://192.168.0.33:8080/v1`, llama-swap:
GLM-4.7-Flash for chat, GPT-OSS-120B for the nightly digest).
- **Behaviour:** mention-gated replies in one Matrix room; a matrix-nio
sidecar logs the whole room per day; a 22:00 Europe/Paris cron job
digests the log into `journal/YYYY-MM-DD.md` (`## Chat digest (Alfred)`)
and proposes tasks; "what needs doing" runs a deterministic port of
`brain todos` so answers match the laptop.
- **Bounds:** `workspace_only`, shell allowlist `git` + `vault-todos`, no
web/delegation tools, autonomy `full` (switch to `supervised` if it
misbehaves). Prompt injection from the room is bounded to that repo;
git history is the undo.
## Consequences
- **Not spec-conformant.** Alfred emits no §4 events and knows nothing of
§5 communities or §7 signing. Nothing downstream (eink renderer, relays,
third-party Nostr clients) sees its output. It shares §3.1 vocabulary
(task / journal / done / list) and the §6 rule that capture never blocks
on classification (unsure → `inbox/`).
- **Two writers, one repo.** Laptop and bot both `pull --rebase` before
push and stage explicit paths only; the bot never touches `hubs/`,
`notes/`, `README.md`, `.zk/`. Conflicts abort and ask a human.
- **Local-model quality is the unknown.** Multi-step tool loops (edit
file + git) on GLM-Flash are unproven; the deterministic todos path is
immune.
- **Dependency churn.** ZeroClaw moves fast; config keys were read from the
v0.8.3 source. The upstream v0.8.5 flake did not evaluate (Cargo hash
mismatch), hence the nixpkgs rebuild.
## Revisit when
- The trial holds up for a few weeks → promote to cfaun as a
`services.zeroclaw` instance in `server-deploy` (sops for env + deploy
key, `networking.hosts` for optimus) and decide whether `tracker` is
retired or bridged (a small publisher turning vault commits into §4
events would restore conformance).
- The agent is unreliable → fall back to a deterministic maubot plugin
(`dev.aiolabs.alfred`): Python vault writers, `brain todos` port,
in-process git under an asyncio lock, model used only to parse JSON.
Sketched in the 2026-09-20 planning session; not built.
- Either way, update spec §12 "Alternate storage (trial)" and this ADR.

View file

@ -855,6 +855,16 @@ scoping in §5. A ZeroClaw-based implementation would carry the
`["client", "maubot-tracker", "..."]`; renderers ignore the difference `["client", "maubot-tracker", "..."]`; renderers ignore the difference
since they filter by community `a`-tag. since they filter by community `a`-tag.
### Alternate storage (trial)
Since 2026-09-20 a ZeroClaw-based "Alfred" trial targets a plain-Markdown
zk vault synced over git (`padreug/brain-chateaudufaune`) as its store
instead of the §4 events. It is **not** spec-conformant — no NIP-52
events, no §5 community scoping, no §7 signing — but keeps the §3.1
vocabulary and the §6 rule that capture never blocks on classification
(unsure items go to `inbox/`). Rationale, bounds and revisit criteria in
[`adr-0001-alfred-vault-trial.md`](adr-0001-alfred-vault-trial.md).
### Reference identity provider — operator-IdP pattern ### Reference identity provider — operator-IdP pattern
The aiolabs reference implementation runs the **operator-IdP-with- The aiolabs reference implementation runs the **operator-IdP-with-
@ -948,6 +958,9 @@ the bot signing as itself and human attribution carried in the
## Changelog ## Changelog
- **0.3** (2026-09-20) — §12 gains "Alternate storage (trial)": the
Alfred / ZeroClaw trial writes a Markdown git vault, not §4 events;
see ADR 0001.
- **0.2** (2026-06-28) — reflect shipped state of the reference IdP + - **0.2** (2026-06-28) — reflect shipped state of the reference IdP +
sidecar bunker. `aiolabs/lnbits#9` and `#18` are no longer sidecar bunker. `aiolabs/lnbits#9` and `#18` are no longer
in-flight; `aiolabs/nsecbunkerd` (deploys from `dev`) is live with in-flight; `aiolabs/nsecbunkerd` (deploys from `dev`) is live with

View file

@ -40,10 +40,12 @@ CREATE TABLE entries (
user TEXT NOT NULL, -- @sender:domain user TEXT NOT NULL, -- @sender:domain
room TEXT NOT NULL, -- !roomid:domain room TEXT NOT NULL, -- !roomid:domain
ts BIGINT NOT NULL, -- ms since epoch (from evt.timestamp) ts BIGINT NOT NULL, -- ms since epoch (from evt.timestamp)
text TEXT NOT NULL -- raw entry body text TEXT NOT NULL, -- raw entry body
event_id TEXT -- source event, so edits update in place (v2)
); );
CREATE INDEX entries_user_ts ON entries (user, ts DESC); CREATE INDEX entries_user_ts ON entries (user, ts DESC);
CREATE INDEX entries_ts ON entries (ts DESC); CREATE INDEX entries_ts ON entries (ts DESC);
CREATE INDEX entries_event_id ON entries (event_id);
``` ```
Wipe data via the maubot UI's per-instance **Database** tab: Wipe data via the maubot UI's per-instance **Database** tab:
@ -58,11 +60,32 @@ rather keep IDs monotonic across resets.)
## Known quirks ## Known quirks
- **Edited messages don't re-trigger the bot.** Matrix sends edits as - **Editing a `!journal` message updates its entry** (since v0.3.0).
a separate `m.replace` event that bots don't react to. If you typed Fix a typo, add a line, and the stored entry changes in place — the
`!journal` then edited the message to add content, the bot saw only bot confirms with a 📝 reaction on your message rather than posting
the empty `!journal` and won't record. Send a fresh message instead a reply. The original send time is kept, since `ts` records when the
of editing. work was logged, not when the wording was corrected.
This works because entries are keyed on the source event id, added in
the v2 migration. Entries recorded before v0.3.0 have `event_id IS
NULL` and so can't be matched — editing one of those is a no-op.
Worth knowing if you write other maubot plugins: mautrix swaps an
edit's content for `m.new_content` before the handler sees it
(`mautrix/types/event/message.py:393-396`), stripping the `* `
fallback prefix, so an edit is **indistinguishable from a new
command**. Neither `@command.passive` nor `@command.new` filters
them. A plugin that doesn't call `evt.content.get_edit()` will
silently record a duplicate on every edit — which is exactly what
this plugin did before v0.3.0.
- **Reactions never trigger the bot.** `@command.passive` registers on
`EventType.ROOM_MESSAGE` and filters `msgtype` to `(m.text,)`, so an
`m.reaction` fails both checks. If a "Logged for" appears right after
someone reacts, the trigger was an edit landing at the same moment —
a reply to an edit event renders as "This event could not be
displayed" in Element, which makes it look unrelated to any message.
- **`!journal show <random text>` runs the show query with that text - **`!journal show <random text>` runs the show query with that text
as the user filter.** If it doesn't match any MXID, you get as the user filter.** If it doesn't match any MXID, you get
"No entries." Use a fully-qualified MXID like `@pat:ariege.io`. "No entries." Use a fully-qualified MXID like `@pat:ariege.io`.

View file

@ -25,6 +25,12 @@ async def upgrade_v1(conn: Connection) -> None:
await conn.execute("CREATE INDEX entries_ts ON entries (ts DESC)") await conn.execute("CREATE INDEX entries_ts ON entries (ts DESC)")
@upgrade_table.register(description="Track source event id so edits can update entries")
async def upgrade_v2(conn: Connection) -> None:
await conn.execute("ALTER TABLE entries ADD COLUMN event_id TEXT")
await conn.execute("CREATE INDEX entries_event_id ON entries (event_id)")
# Match `!journal` followed by any whitespace (space, tab, OR newline) # Match `!journal` followed by any whitespace (space, tab, OR newline)
# and capture everything after. Maubot's @command.new parser only treats # and capture everything after. Maubot's @command.new parser only treats
# *space* as the command/args delimiter, so `!journal\n<content>` gets # *space* as the command/args delimiter, so `!journal\n<content>` gets
@ -60,6 +66,18 @@ class JournalBot(Plugin):
async def journal(self, evt: MessageEvent, match) -> None: async def journal(self, evt: MessageEvent, match) -> None:
rest = (match[1] or "").strip() rest = (match[1] or "").strip()
# An edit arrives as a *fresh* m.room.message whose content mautrix
# swaps for `m.new_content` before we see it
# (mautrix/types/event/message.py:393-396), stripping the "* "
# fallback prefix — so it is indistinguishable from a new command
# and used to record a duplicate row per edit. get_edit() returns
# the *original* event id, which is what entries are keyed on, so
# the edit updates that row in place instead.
edit_of = evt.content.get_edit()
if edit_of:
await self._apply_edit(evt, edit_of, rest)
return
if not rest: if not rest:
await evt.reply(_USAGE) await evt.reply(_USAGE)
return return
@ -101,10 +119,39 @@ class JournalBot(Plugin):
# Default: record the full rest (multi-line preserved) # Default: record the full rest (multi-line preserved)
await self.database.execute( await self.database.execute(
"INSERT INTO entries (user, room, ts, text) VALUES ($1, $2, $3, $4)", "INSERT INTO entries (user, room, ts, text, event_id)"
" VALUES ($1, $2, $3, $4, $5)",
evt.sender, evt.sender,
evt.room_id, evt.room_id,
evt.timestamp, evt.timestamp,
rest, rest,
evt.event_id,
) )
await evt.reply(f"📓 Logged for {evt.sender}.") await evt.reply(f"📓 Logged for {evt.sender}.")
async def _apply_edit(self, evt: MessageEvent, original_id, rest: str) -> None:
"""Apply an edit of a previously recorded `!journal` message."""
row = await self.database.fetchrow(
"SELECT id FROM entries WHERE event_id = $1", original_id
)
if row is None:
# Edit of a message that never became an entry: a `show`/`today`
# query, or an entry recorded before v0.3.0 started tracking
# event_id. Nothing to update, and re-recording would duplicate.
return
if not rest:
# Editing the body away would blank the entry; leave it alone.
return
# `ts` deliberately keeps the original send time — it records when
# the work was logged, not when a typo was fixed.
await self.database.execute(
"UPDATE entries SET text = $1 WHERE event_id = $2", rest, original_id
)
try:
# Confirm on the original message rather than replying: a reply
# to an edit event renders as "This event could not be displayed"
# in Element, and a message per keystroke-save is noise.
await self.client.react(evt.room_id, original_id, "📝")
except Exception:
self.log.debug("Could not react to edited entry", exc_info=True)

View file

@ -1,6 +1,6 @@
maubot: 0.1.0 maubot: 0.1.0
id: dev.aiolabs.journal id: dev.aiolabs.journal
version: 0.2.0 version: 0.3.0
license: AGPL-3.0-or-later license: AGPL-3.0-or-later
modules: modules:
- journal - journal