Compare commits
No commits in common. "96403effd6394e0b18fb05757a2b05ce8d726bbf" and "b1c37161ddb09ac138a07a4e3812833af58ec017" have entirely different histories.
96403effd6
...
b1c37161dd
7 changed files with 8 additions and 1327 deletions
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
|
@ -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.
|
|
||||||
|
|
@ -855,16 +855,6 @@ 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-
|
||||||
|
|
@ -958,9 +948,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,10 @@ 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:
|
||||||
|
|
@ -60,32 +58,11 @@ rather keep IDs monotonic across resets.)
|
||||||
|
|
||||||
## Known quirks
|
## Known quirks
|
||||||
|
|
||||||
- **Editing a `!journal` message updates its entry** (since v0.3.0).
|
- **Edited messages don't re-trigger the bot.** Matrix sends edits as
|
||||||
Fix a typo, add a line, and the stored entry changes in place — the
|
a separate `m.replace` event that bots don't react to. If you typed
|
||||||
bot confirms with a 📝 reaction on your message rather than posting
|
`!journal` then edited the message to add content, the bot saw only
|
||||||
a reply. The original send time is kept, since `ts` records when the
|
the empty `!journal` and won't record. Send a fresh message instead
|
||||||
work was logged, not when the wording was corrected.
|
of editing.
|
||||||
|
|
||||||
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`.
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,6 @@ 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
|
||||||
|
|
@ -66,18 +60,6 @@ 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
|
||||||
|
|
@ -119,39 +101,10 @@ 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, event_id)"
|
"INSERT INTO entries (user, room, ts, text) VALUES ($1, $2, $3, $4)",
|
||||||
" 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)
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
maubot: 0.1.0
|
maubot: 0.1.0
|
||||||
id: dev.aiolabs.journal
|
id: dev.aiolabs.journal
|
||||||
version: 0.3.0
|
version: 0.2.0
|
||||||
license: AGPL-3.0-or-later
|
license: AGPL-3.0-or-later
|
||||||
modules:
|
modules:
|
||||||
- journal
|
- journal
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue