Compare commits

..

No commits in common. "96403effd6394e0b18fb05757a2b05ce8d726bbf" and "b1c37161ddb09ac138a07a4e3812833af58ec017" have entirely different histories.

7 changed files with 8 additions and 1327 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,75 +0,0 @@
# 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,16 +855,6 @@ scoping in §5. A ZeroClaw-based implementation would carry the
`["client", "maubot-tracker", "..."]`; renderers ignore the difference
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
The aiolabs reference implementation runs the **operator-IdP-with-
@ -958,9 +948,6 @@ the bot signing as itself and human attribution carried in the
## 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 +
sidecar bunker. `aiolabs/lnbits#9` and `#18` are no longer
in-flight; `aiolabs/nsecbunkerd` (deploys from `dev`) is live with

View file

@ -40,12 +40,10 @@ CREATE TABLE entries (
user TEXT NOT NULL, -- @sender:domain
room TEXT NOT NULL, -- !roomid:domain
ts BIGINT NOT NULL, -- ms since epoch (from evt.timestamp)
text TEXT NOT NULL, -- raw entry body
event_id TEXT -- source event, so edits update in place (v2)
text TEXT NOT NULL -- raw entry body
);
CREATE INDEX entries_user_ts ON entries (user, 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:
@ -60,32 +58,11 @@ rather keep IDs monotonic across resets.)
## Known quirks
- **Editing a `!journal` message updates its entry** (since v0.3.0).
Fix a typo, add a line, and the stored entry changes in place — the
bot confirms with a 📝 reaction on your message rather than posting
a reply. The original send time is kept, since `ts` records when the
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.
- **Edited messages don't re-trigger the bot.** Matrix sends edits as
a separate `m.replace` event that bots don't react to. If you typed
`!journal` then edited the message to add content, the bot saw only
the empty `!journal` and won't record. Send a fresh message instead
of editing.
- **`!journal show <random text>` runs the show query with that text
as the user filter.** If it doesn't match any MXID, you get
"No entries." Use a fully-qualified MXID like `@pat:ariege.io`.

View file

@ -25,12 +25,6 @@ async def upgrade_v1(conn: Connection) -> None:
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)
# and capture everything after. Maubot's @command.new parser only treats
# *space* as the command/args delimiter, so `!journal\n<content>` gets
@ -66,18 +60,6 @@ class JournalBot(Plugin):
async def journal(self, evt: MessageEvent, match) -> None:
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:
await evt.reply(_USAGE)
return
@ -119,39 +101,10 @@ class JournalBot(Plugin):
# Default: record the full rest (multi-line preserved)
await self.database.execute(
"INSERT INTO entries (user, room, ts, text, event_id)"
" VALUES ($1, $2, $3, $4, $5)",
"INSERT INTO entries (user, room, ts, text) VALUES ($1, $2, $3, $4)",
evt.sender,
evt.room_id,
evt.timestamp,
rest,
evt.event_id,
)
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
id: dev.aiolabs.journal
version: 0.3.0
version: 0.2.0
license: AGPL-3.0-or-later
modules:
- journal