Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 94 additions & 1 deletion docs/design/brain/who-answers.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ make that true, and any new bot has to state which gate it uses.
|---|---|---|
| **Reaction** | This message, this action, chosen per message | archivist `_on_reaction` (🔖 📌 bookmark, 📎 📄 archive, 🔁 🔄 retry) |
| **@-mention** | Deliberate address, overrides everything ambient | every bot, `MicroBot._is_bot_mentioned`; the agent adds nanobot's own pill check |
| **Name in the vocative** | "Merlin, what is missing?" - how people actually talk | agent only, `name_trigger.addressed_by_name` |
| **Name in the vocative** | "Merlin, what is missing?" - how people actually talk | every bot on the main timeline (`MicroBot._is_bot_mentioned`), and the agent, through the same `stack.name_trigger` |
| **Thread ownership** | Inside a bounded conversation, the thread is the address | agent `AgentThreads`, archivist `MicroBot.thread_owner` |
| **Room** | The room's default job, and its `!config process` mode | archivist (`documents` room means search; `react` mode means reactions only) |
| **Message shape** | A URL, a long paste, a file | archivist only, and only on the main timeline |
Expand Down Expand Up @@ -224,6 +224,99 @@ and the archivist when it posts a filing card). Their output carries
`dev.famstack.source` / `dev.famstack.event`, and other bots read the
envelope rather than the prose.

## The archivist, as a decision tree

What the archivist does with one event, read from the code
(`stacklets/docs/bot/archivist.py`, gates in `MicroBot`). The first
matching line wins. "Addressed" means: picked from the `@` list
(`m.mentions`), or its Matrix ID in the text, or, on the main timeline
only, its name at the start or end of the message ("Archivist, ...",
"..., Archivist?", `stack.name_trigger`).

### A text message (`_on_text`)

```
1. sent by the archivist itself ............................ ignore
2. in the Memories room .................................... diary_room.on_text:
in a thread under one of its diary cards, with words ... correct that card
anything else ......................................... silence
3. carries a dev.famstack.source envelope (mail bot) ....... file the source
4. sent by another bot (localpart ends in -bot) ............ ignore
5. empty ................................................... ignore
6. a `!config` command ..................................... change the room's settings
(first message in a room: post the welcome first)
7. not addressed, and the room's mode is `react` ........... ignore (reactions only)
8. not addressed, in a thread the archivist does not own ... ignore
(except a handoff: mail attachments posted under a mail card)
9. not addressed, and it lands on one of its filings
(a reply to it, or in its thread) ....................... correction: re-file
10. the command ladder:
help / hilfe / ? ....................................... the room's welcome text
`(` ... `)` ............................................ start / finish a scan session
`show 42` .............................................. post document 42
only a URL ............................................. Documents room: archive in Paperless
elsewhere: bookmark
not addressed, text with a URL ......................... same, the words are the hint
addressed, or in the Documents room .................... search and answer
(topic room: that topic only)
a voice transcript, or a long paste with one
person in the room (bots and the tech admin not
counted) ............................................... note
anything else .......................................... ignore: people talking
```

### A voice message (`MicroBot._dispatch`)

```
Every m.audio with a plain mxc:// payload is transcribed before any
handler sees it (voice.is_voice, _decode_voice):
no speech, or no transcriber ................................ nothing at all
otherwise ................................................... the transcript goes through
"A text message" above, same
event id and thread, marked
as transcribed
In the Memories room only a voice reply in a thread is decoded
(a correction to a card); memos are left for the diary.
```

Speech and typing are routed identically, with one difference: a
transcript that reaches the end of the ladder is filed as a note, where
typed text would need to be a long paste in a one-person room.

### A file: photo or PDF (`_on_file`)

```
1. sent by the archivist, unsupported type, or no mxc:// .... ignore
2. in the Memories room ..................................... ignore; the diary reads it
on its own schedule
3. not addressed, room mode `react` ......................... ignore
4. not addressed, in a thread it does not own ............... ignore (handoffs excepted)
5. the sender has an open scan session ...................... add as a page
6. in the Documents room .................................... file in Paperless
7. elsewhere ................................................ capture: text extracted,
summarised, filed
```

### A reaction (`_on_reaction`)

```
1. by a bot or the tech admin, or in the Memories room ...... ignore
2. 🔖 📌 ...................................................... keep the message: bookmark or note
📎 📄 ...................................................... archive the source in Paperless
🔁 🔄 ...................................................... retry the filing
any other emoji ........................................... ignore
```

### What the tree says about priorities

- An address beats every guess: steps 7 and 8 only apply when the
archivist was not addressed, and step 9 (correction) only then too.
- Inside a thread the name alone is not an address, so "Archivist, this
is Marge's car" in a filing's thread stays a correction (step 9). To
search from a thread, pick the archivist from the `@` list.
- The Memories room is decided first and keeps the archivist silent
except for corrections to its own cards.

## The gaps

1. **The vocative is not shared.** "Merlin, save this" on the main
Expand Down
6 changes: 3 additions & 3 deletions docs/design/brain/write-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ that started the session.
agent exists.
10. **The signal is free.** `AGENT_NAME=Stacky` and `AGENT_BOT_ID=stacky-bot` are
already in the bot-runner's environment.
11. **But the matcher is not shared.** `name_trigger.py` lives in the agent
stacklet, and the agent container mounts no `lib/stack`. If the two ever
disagree about "was the agent addressed", either both act or neither does.
11. **The matcher is shared.** `lib/stack/name_trigger.py` is imported by the
bot-runner and mounted as one file into the agent container, so the bots
agree on "was I addressed by name".
12. **The two answerers are not redundant.** The archivist's search is dual
(Paperless plus vault, with synthesis and deep-dive); the agent's
`memory_search` is vault-only, and `stack docs search` does not exist.
Expand Down
4 changes: 2 additions & 2 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Name a room `Topic: Powerplant Picnic` (German: `Thema: ...`), disable encryptio

The room becomes the filing system. Plan the picnic in chat, drop the gear list, the location ideas, the photo of the flyer. When it's over you have a complete, searchable record of the project without ever having organized anything.

Questions asked inside a topic room automatically search just that topic. Ask "@archivist what did Lenny suggest?" in the picnic room and the answer comes from the picnic material, not from your tax documents.
Questions asked inside a topic room automatically search just that topic. Ask "Archivist, what did Lenny suggest?" in the picnic room and the answer comes from the picnic material, not from your tax documents.

---

Expand Down Expand Up @@ -177,7 +177,7 @@ Memories work the same way, in the thread under each memory: see [Put a memory r
Two ways to ask:

- In **#Documents**, just type. Search is the default there.
- In **any other room**, @-mention the bot: `@archivist when does the car insurance renew?`
- In **any other room**, start with its name: `Archivist, when does the car insurance renew?` Picking it from the `@` list works too. Inside a thread the name alone is not enough, because a message there corrects the filing: pick the archivist from the `@` list to ask something instead.

Plain words work as search ("Duff Insurance", "vaccination Bart"). Questions get real answers: the archivist searches the document archive and the family knowledge vault, reads what it finds, and answers with numbered sources you can check:

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
"""Does this message address the agent by name?
"""Does this message address a bot by name?

nanobot's group policy answers "is the bot mentioned?" by reading the
`m.mentions` payload, which only exists when the sender picked the bot
out of an autocomplete list. That is a fine rule for Slack and a poor
one for a family room, where people type what they would say out loud:
Matrix answers "is the bot mentioned?" with the `m.mentions` payload,
which only exists when the sender picked the bot out of an autocomplete
list. That is a fine rule for Slack and a poor one for a family room,
where people type what they would say out loud:

Stacky, what's on our list?

No pill, no `m.mentions`, no reply. This module supplies the missing
half of the question, and nothing else -- it is pure text in, bool out,
so the rule can be argued with in tests rather than in a running
container.
container. It is stdlib-only and imports nothing from the framework:
the bot-runner uses it as `stack.name_trigger`, and the agent container
mounts this one file as `name_trigger`, so both agree on what counts.

WHAT COUNTS AS BEING ADDRESSED

Expand Down Expand Up @@ -111,3 +113,19 @@ def addressed_by_name(body: str, name: str) -> bool:
if not text:
return False
return bool(_pattern(name).search(text))


def strip_address(body: str, name: str) -> str:
"""The message without the words that address `name`.

"hey Archivist, when does the insurance renew?" becomes "when does
the insurance renew?", so the bot hears the question, not its own
name. Unchanged when the message does not address `name`.
"""
text = strip_reply_fallback(body).strip()
if not addressed_by_name(text, name):
return body or ""
n = re.escape(name.strip())
text = re.sub(rf"^\W*(?:{_GREETINGS}\W+)?{n}(?!\w)[\s,:;.!-]*", "", text, count=1, flags=re.IGNORECASE)
text = re.sub(rf"[,;]\s*{n}(?!\w)(\W*)$", r"\1", text, count=1, flags=re.IGNORECASE)
return text.strip()
6 changes: 5 additions & 1 deletion stacklets/agent/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ COPY workspace/ /app/workspace-seed/
# Runtime-context shim: per-turn family briefing injected into nanobot's prompt.
# See runtime/README.md for what it patches and why.
COPY runtime/ /app/runtime/
# name_trigger.py is not baked: compose mounts it from lib/stack, where the
# bot-runner reads the same file. Without the mount the shim logs and the
# agent answers pill mentions only.
# Container-side `stack` shim: forwards allowlisted commands to the host's
# famstack API (host.docker.internal) and prints the CLI's plaintext output.
# See client/stack and stacklets/core/famstack-api.py.
Expand All @@ -44,7 +47,8 @@ USER nanobot
ENV HOME=/home/nanobot
# Put the runtime shim on the path so its sitecustomize.py auto-loads at
# interpreter startup and patches nanobot's per-turn context assembly.
ENV PYTHONPATH=/app/runtime
# /app/shared holds framework files compose mounts one by one (name_trigger).
ENV PYTHONPATH=/app/runtime:/app/shared
# Where the container-side `stack` shim reaches the host's famstack API (core).
ENV STACK_API_ADDR=host.docker.internal:42001

Expand Down
6 changes: 6 additions & 0 deletions stacklets/agent/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ services:
# image runs standalone, but a bind mount means an edit + restart takes
# effect without a rebuild. Keep in sync with the Dockerfile COPY.
- ./runtime:/app/runtime:ro
# The name trigger is shared with the bot-runner, so every bot agrees
# on "was I addressed by name". It lives in lib/stack; only this one
# stdlib file is mounted, under the name the shim imports. Not into
# /app/runtime: that is a read-only mount itself, and Docker cannot
# create a mountpoint inside it. /app/shared is on PYTHONPATH.
- ../../lib/stack/name_trigger.py:/app/shared/name_trigger.py:ro
# The `stack` shim, live for the same reason. It is the agent's only
# route to the CLI, so a bug in it takes out every vault tool at once —
# and it stayed baked-only long enough for one to go unnoticed.
Expand Down
24 changes: 21 additions & 3 deletions stacklets/core/bot-runner/microbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
from room_context import RoomContext, context_for
from stack.ai.client import LLM, LLMError, LLMUnavailableError, Transcriber
from stack.users import TECH_ADMIN_USERNAME
from stack.name_trigger import addressed_by_name, strip_address

# The framework's "I picked this up and I'm working on it" signal. A
# bot reacts with 👀 on the source message the moment it starts a
Expand Down Expand Up @@ -1319,7 +1320,7 @@ def _room_context(self, room) -> RoomContext:
def _is_bot_mentioned(self, event) -> bool:
"""True if the event explicitly addresses this bot.

Two paths, OR-ed:
Three paths, OR-ed:

1. ``m.mentions.user_ids`` (MSC3952) — modern Matrix clients
(Element X, Element-web) populate this list when the user
Expand All @@ -1330,6 +1331,12 @@ def _is_bot_mentioned(self, event) -> bool:
``@bot:server`` form. The check is on the full mxid, not
the localpart, so a casual mention of the bot's name in
chat doesn't trip the gate.
3. Addressed by name, the way people talk: "Archivist, when does
the insurance renew?" (`stack.name_trigger`, the same rule the
agent uses, from the bot's `name` in bot.toml). Only on the
main timeline: in a thread or a reply the conversation already
has an address, and there a message without a pick from the
list keeps its meaning (for the archivist, a correction).
"""
content = (
event.source.get("content", {}) if hasattr(event, "source") else {}
Expand All @@ -1338,7 +1345,12 @@ def _is_bot_mentioned(self, event) -> bool:
if self.user_id in (mentions.get("user_ids") or []):
return True
body = getattr(event, "body", "") or ""
return self.user_id in body
if self.user_id in body:
return True
relates = content.get("m.relates_to") or {}
if relates.get("rel_type") == "m.thread" or relates.get("m.in_reply_to"):
return False
return addressed_by_name(body, self.display_name or "")

async def _should_react(self, ctx: RoomContext, *, mentioned: bool) -> bool:
"""Decide whether the bot acts on the current event at all.
Expand Down Expand Up @@ -1520,6 +1532,7 @@ def normalize_emoji(key: str) -> str:
@staticmethod
def strip_mention(
body: str, bot_user_id: str, *, formatted_body: str | None = None,
display_name: str | None = None,
) -> str:
"""Remove the bot mention (and any clinging punctuation/whitespace)
from a message body.
Expand All @@ -1538,7 +1551,9 @@ def strip_mention(
only inside the HTML ``formatted_body``. We pull the
display name from the formatted_body's anchor element and
strip it from the start of ``body``.
3. Neither: nothing to strip.
3. Addressed by name ("Archivist, find MLX", "..., Archivist?"):
the address is dropped, see `stack.name_trigger`.
4. None of these: nothing to strip.

Conservative on punctuation: trims a single trailing ``:`` or
``,`` after the mention (common in tab-complete output) and
Expand Down Expand Up @@ -1576,6 +1591,9 @@ def strip_mention(
rest = rest[1:].lstrip()
return rest.strip()

# Path 3: addressed by name, no pill.
if display_name:
return strip_address(body, display_name)
return body

async def emit_event(self, room_id: str, event_type: str, body: dict) -> bool:
Expand Down
1 change: 1 addition & 0 deletions stacklets/docs/bot/archivist.py
Original file line number Diff line number Diff line change
Expand Up @@ -1729,6 +1729,7 @@ async def _on_text(self, room, event: RoomMessageText) -> None:
formatted_body = event.source.get("content", {}).get("formatted_body")
query = self.strip_mention(
query, self.user_id, formatted_body=formatted_body,
display_name=self.display_name,
)
if not query:
query = "help"
Expand Down
Loading
Loading