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
2 changes: 1 addition & 1 deletion docs/design/agent/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ The famstack-product ships with self-contained defaults. Individual instances cu
- **Grocery backend.** Default backend stores the list in `family/memory/lists/groceries.md`; the "phone access" path is "ask the bot in Matrix." Optional Trello backend (Homer's personal stack) bridges to an existing Trello board via REST API; family workflow unchanged, agent gains read/write through the same `GroceryStore` interface.
- **Pattern.** Same shape as `taxonomy.toml` (default seed, instance overrides), `ontology.toml` (seed, evolved per instance), and the brain invariant "memory is instance data, not product policy." The agent defines the protocol; instances pick the backend.

The v1 build ships the vault backend. The Trello backend ships in the same repo but disabled by default. Other instances (deskstack, future families) get to pick.
The v1 build ships the vault backend. The Trello backend ships in the same repo but disabled by default. Other instances (future families, non-family deployments) get to pick.

### Long-term (not in v1, captured for direction)

Expand Down
6 changes: 1 addition & 5 deletions docs/design/brain/family-ontology.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,16 +222,12 @@ Write-up: whitepaper round 6 + likely the strongest standalone post
Decision 2026-06-12: no authored per-topic ontologies (insurance terms,
medical specialties, ...) for famstack. Maintenance treadmill, prompt
token bloat on the small models we optimize for, serves completeness the
corpus doesn't need. Two variants stay alive:
corpus doesn't need. One variant stays alive:

- **Emergent topic depth** — the living loop accumulates confirmed
correspondents, aliases, and doctype frequencies UNDER topics from the
household's actual mail. A query over the entity pages + usage, zero
authoring. Revisit after the extraction-proposal phase.
- **Authored vertical packs for deskstack** — law firm / tax advisor
domain ontologies (court names, Mandant/Akte structures, deadline
doctypes) are authored once, shared by every firm, and a paid
differentiator. Product idea, parked under deskstack.

Round 6 error analysis can reopen this: if 9B failures cluster in one
domain, that's the data-driven case for depth in that topic.
Expand Down
2 changes: 1 addition & 1 deletion docs/design/brain/knowledge-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ class FamstackEvent:
summary: str # one-line human-readable summary
data: dict # structured payload (varies by type)
timestamp: datetime # UTC
actor: str | None # who/what caused it: "@homer:merles.eu", "archivist-bot"
actor: str | None # who/what caused it: "@homer:home.local", "archivist-bot"

class EventSink(ABC):
"""Where events go. First implementation: Matrix."""
Expand Down
4 changes: 2 additions & 2 deletions docs/design/brain/knowledge-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ facts:
- "Coverage period: 2026-01-01 to 2026-12-31"
action_items:
- { action: "Compare prices before renewal", due: "2026-11-30" }
source_link: https://paperless.merles.eu/documents/247/details
source_link: https://paperless.home.local/documents/247/details
processing: ai_formatted
model: qwen3-32b
---
Expand Down Expand Up @@ -251,7 +251,7 @@ topics = ["medical"]
**Machine-appended example** (`facts.jsonl`):

```json
{"id":"f_2026-04-20T14:22Z_a7b3","kind":"fact","text":"Car insurance premium is EUR 340/year","persons":["Homer"],"topics":["insurance","vehicle"],"source":{"kind":"paperless","doc_id":247},"actor":"@homer:merles.eu","extracted_at":"2026-04-20T14:22:00Z","expires_at":"2026-07-19T00:00Z","confidence":0.85,"superseded_by":null}
{"id":"f_2026-04-20T14:22Z_a7b3","kind":"fact","text":"Car insurance premium is EUR 340/year","persons":["Homer"],"topics":["insurance","vehicle"],"source":{"kind":"paperless","doc_id":247},"actor":"@homer:home.local","extracted_at":"2026-04-20T14:22:00Z","expires_at":"2026-07-19T00:00Z","confidence":0.85,"superseded_by":null}
```

**Invariants:**
Expand Down
12 changes: 6 additions & 6 deletions docs/design/brain/ontology-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,19 @@ persons:
homer:
aliases: [Homer, Papa]
services:
matrix: "@homer:merles.eu"
matrix: "@homer:home.local"
paperless: "Person: Homer"
immich: face-abc123
forgejo: homer
calendar: homer@merles.eu
calendar: homer@home.local

marge:
aliases: [Marge, Mama]
services:
matrix: "@marge:merles.eu"
matrix: "@marge:home.local"
paperless: "Person: Marge"
immich: face-def456
calendar: marge@merles.eu
calendar: marge@home.local

organizations:
duff-insurance:
Expand Down Expand Up @@ -275,10 +275,10 @@ The person entity in the ontology is the join key:

```markdown
## Homer
- matrix: @homer:merles.eu
- matrix: @homer:home.local
- paperless: "Person: Homer"
- immich: face-id-abc123
- calendar: homer@merles.eu
- calendar: homer@home.local
```

When Kit Bot serves Homer, it can query "everything associated with Homer" across all services by resolving through the ontology. When Marge asks Kit something, the person filter scopes results to what's relevant to her.
Expand Down
8 changes: 4 additions & 4 deletions docs/design/brain/ontology-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,14 +224,14 @@ persons:
aliases: [Papa]
services:
paperless_tag: "Person: Homer"
matrix: "@homer:merles.eu"
matrix: "@homer:home.local"

marge:
label: Marge
aliases: [Mama]
services:
paperless_tag: "Person: Marge"
matrix: "@marge:merles.eu"
matrix: "@marge:home.local"

# ── Organizations ──────────────────────────────────────────────
# Open set. Bootstrap with a small list of universally-useful orgs
Expand Down Expand Up @@ -586,7 +586,7 @@ Same handler, parsed by the LLM, written directly to ontology.yaml as a commit:
```
update: organisation Duff Insurance categories +finance

Confirmed-By: @homer:merles.eu
Confirmed-By: @homer:home.local
```

A correction always sets `confirmed: true` on the affected entries — that locks them against future auto-overrides from low-confidence learning.
Expand Down Expand Up @@ -624,7 +624,7 @@ Net: the system errs on the side of writing, but it doesn't err on the side of *

- `ontology-seed-self-employed.yaml` — adds `Mandant`, `Rechnungsnummer`, `Honorar`, `USt-Voranmeldung`
- `ontology-seed-landlord.yaml` — adds `Mieter`, `Nebenkostenabrechnung`, `Mietvertrag`
- `ontology-seed-deskstack.yaml` — office-oriented vocabulary for the deskstack product (clients, matters, billable hours)
- `ontology-seed-office.yaml` — office-oriented vocabulary (clients, matters, billable hours)

Starter packs are how we monetize tier specialization without bloating the default install. Each pack ships a curated baseline; auto-extension takes over from there.

Expand Down
2 changes: 1 addition & 1 deletion docs/design/brain/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Non-goals for 0.3.0:
- Active decay / supersede / promotion logic (wait for dream cycle in 0.4.0+).
- Matrix conversation extraction beyond what Archivist already emits (Deriver bot — 0.4.0+).
- Vector / semantic retrieval. Keyword + ontology expansion is enough at family scale.
- Cross-product ontology sharing (famstack vs deskstack via published artifact). Same code path, different seeds.
- Cross-product ontology sharing (famstack vs another deployment via published artifact). Same code path, different seeds.

## Invariants

Expand Down
4 changes: 2 additions & 2 deletions docs/design/brain/topic-rooms.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ A Matrix room whose name starts with `Thema:` or `Topic:` is a topic room. The a

Topic rooms compose with the existing capture pipeline. The classifier, mirror, search, deriver, ontology-canonicalizer — none of them special-case topic folders. They see a bucket like any other.

**Topics always nest inside the bucket that owns them.** Shared topics live under the household's configured shared bucket (`family/<slug>/`, or `office/<slug>/` for deskstack); personal topics live under the originating person's bucket (`homer/<slug>/`). The top level of the vault stays pure access-scope: one folder per privacy boundary, never a topic folder. This makes a default sender-scoped search (`["family/", "<localpart>/"]`) automatically include shared-topic content — a family member asking "what did we note about camping?" in #documents finds it without knowing the topic room exists.
**Topics always nest inside the bucket that owns them.** Shared topics live under the household's configured shared bucket (`family/<slug>/`, or `office/<slug>/` in a non-family deployment); personal topics live under the originating person's bucket (`homer/<slug>/`). The top level of the vault stays pure access-scope: one folder per privacy boundary, never a topic folder. This makes a default sender-scoped search (`["family/", "<localpart>/"]`) automatically include shared-topic content — a family member asking "what did we note about camping?" in #documents finds it without knowing the topic room exists.

## Naming convention

Expand All @@ -37,7 +37,7 @@ Topic rooms compose with the existing capture pipeline. The classifier, mirror,
| `Topic: 3D printing` | `3d-printing` | `family/3d-printing/` | `homer/3d-printing/` | `3d-printing` |
| `Thema: Café Hopping` | `cafe-hopping` | `family/cafe-hopping/` | `homer/cafe-hopping/` | `cafe-hopping` |

(Shared-bucket paths show `family/` because that is the default `[core] shared_bucket`. A deskstack household with `shared_bucket = "office"` gets `office/<slug>/`.)
(Shared-bucket paths show `family/` because that is the default `[core] shared_bucket`. A deployment with `shared_bucket = "office"` gets `office/<slug>/`.)

### Parser rules

Expand Down
4 changes: 2 additions & 2 deletions docs/stack-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -991,8 +991,8 @@ shared_bucket = "family" # slug for the shared/institutional
# bucket inside the memory vault.
# Personal entities (homer, marge, …)
# live at <vault>/<localpart>/. Default
# "family" fits famstack; deskstack or
# non-family deployments override.
# "family" fits famstack; non-family
# deployments override.

[updates]
schedule = "0 0 3 * * *" # Watchtower cron (3am nightly)
Expand Down
2 changes: 1 addition & 1 deletion lib/stack/bot_runner.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""The bot-runner container as the stack's tools runtime.

The host-side `./stack` is stdlib-only by design: it starts fast and
needs no pip install before a family can use it. But some commands are
needs no pip install before it can be run. But some commands are
thin wrappers over pipelines that want aiohttp, loguru, yaml and a
rendered service env. Rather than break the stdlib invariant on the host
or clone those pipelines in urllib, those commands `docker exec` into
Expand Down
4 changes: 2 additions & 2 deletions lib/stack/installer_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def detect_timezone():


def detect_language(timezone: str) -> str:
"""Guess the household language from timezone. Defaults to English."""
"""Guess the instance language from timezone. Defaults to English."""
return _TZ_LANGUAGE.get(timezone, "en")


Expand Down Expand Up @@ -268,7 +268,7 @@ def write_stack_toml(family_name, server_name, timezone, language="en"):
# Slug for the bucket inside the memory vault that holds institutional
# artifacts (documents, correspondents). Personal entities live at
# <vault>/<localpart>/; the shared bucket lives at <vault>/<slug>/.
# Defaults to "family"; rename for deskstack ("office"), surname-based
# Defaults to "family"; rename for a non-family deployment ("office"), surname-based
# households, etc.
shared_bucket = "family"

Expand Down
8 changes: 4 additions & 4 deletions lib/stack/links.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Logical link construction — the emitter half of the `/go` namespace.

A link a bot posts into a Matrix room is there forever: the timeline is
append-only, so whatever URL was in the message is the URL a family
member clicks two years later. Point it straight at a service and it
append-only, so whatever URL was in the message is the URL someone
clicks two years later. Point it straight at a service and it
dies the day the domain changes, the stack flips between port mode and
domain mode, or Paperless moves. Point it at `home.<domain>/go/docs/247`
and it re-resolves at click time.
Expand Down Expand Up @@ -50,7 +50,7 @@
#
# A link posted into chat is permanent, so the cost of putting a kind in
# the wrong family is paid forever. When in doubt, ask what changes when
# a family renames a topic or corrects a title.
# someone renames a topic or corrects a title.

def go_docs(doc_id: int | str) -> str:
"""`/docs/<id>` — a document, wherever it is filed right now.
Expand Down Expand Up @@ -98,7 +98,7 @@ def go_capture(capture_id: str) -> str:


def go_person(slug: str, leaf: str | None = None) -> str:
"""`/person/<slug>` — a household member's page.
"""`/person/<slug>` — a person's page.

>>> go_person("homer")
'/person/homer'
Expand Down
6 changes: 3 additions & 3 deletions lib/stack/list_doc.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""A list page, and what changed between two versions of one.

A family's list lives in `todos.md`, and more than one thing writes it: the
A list lives in `todos.md`, and more than one thing writes it: the
curator merging extracted action items, a person editing it in Forgejo's
editor, and an agent asked to tidy it up. The interesting failure is not a
malformed document. It is a quiet one: six of twenty-five items gone and a
Expand All @@ -17,7 +17,7 @@
so intent comes out of what changed rather than a sentence the caller invents.

WHY REWORDING IS ITS OWN CATEGORY
A real family list went from thirteen items to twenty-seven because each
A real list went from thirteen items to twenty-seven because each
pass through the classifier renamed things: "Alternative Dachbox" came back
as "suchen", then "recherchieren", then "prüfen", then "besorgen". Nothing
was lost and nothing was really added, but a report that called that four
Expand Down Expand Up @@ -95,7 +95,7 @@ def destructive(self) -> bool:
def summary(self) -> str:
"""One line naming what happened, losses first and named in full.

Counts are not checkable by a family member -- "8 items became 7" tells
Counts are not checkable by a reader -- "8 items became 7" tells
nobody which one went. So a removal always names every item, while the
ordinary categories stay short.
"""
Expand Down
10 changes: 5 additions & 5 deletions lib/stack/ontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
and the loader; the *content* — the actual list of topics and types —
lives outside the framework, in a seed file shipped by whichever
stacklet owns the vocabulary (in famstack: `stacklets/memory/`).
A different product (deskstack, studio, freelance) supplies different
A different deployment (studio, freelance, an office) supplies different
seeds against the same machinery.

Two readers care about an ontology:
Expand Down Expand Up @@ -189,7 +189,7 @@ def resolve_doctype(self, text: str, lang: str = "en") -> Optional[DocType]:
def languages(self) -> List[str]:
"""All language codes the ontology carries names in.

Derived dynamically from the entries so a household that adds a
Derived dynamically from the entries so an instance that adds a
third language ('fr', 'es', …) on a topic doesn't need any code
change to be cross-language-canonicalized.
"""
Expand All @@ -206,7 +206,7 @@ def languages(self) -> List[str]:
# may still emit a name in a different language (training-data
# bias) or drop a doctype-shaped name into the topic field.
# `canonicalize_topic` and `canonicalize_doctype` normalize the
# LLM's output back to a single canonical in the household language
# LLM's output back to a single canonical in the configured language
# — by trying every language the ontology knows — and flag
# cross-field hallucinations so the matcher can reject them
# instead of silently growing the tag set with garbage.
Expand All @@ -215,9 +215,9 @@ def canonicalize_topic(self, text: str, lang: str) -> "Resolution":
"""Resolve an LLM topic string to a canonical topic name.

Tries `lang` first, then every other language the ontology
knows — a German household with `topic.travel.names.de = "Reise"`
knows — a German instance with `topic.travel.names.de = "Reise"`
still recognizes the LLM's "Travel" as the same concept and
returns "Reise" (the household-language canonical).
returns "Reise" (the canonical in the configured language).

When `text` resolves to a doctype instead of a topic, returns
`Resolution(canonical=None, cross_field=True)` so the caller
Expand Down
6 changes: 3 additions & 3 deletions lib/stack/page_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
`apply_patch` is the tool nanobot advertises to the model as the default way
to change a file, and the model reaches for it accordingly. Its edits are
ordinary text substitutions -- find this exact string, put that one there --
which it normally performs against a file on disk. A family memory page is
not on disk: the agent sees a read-only projection, and the real document
lives in the family's git store behind `stack memory write`.
which it normally performs against a file on disk. A memory page is not on
disk: the agent sees a read-only projection, and the real document lives
in the git-backed store behind `stack memory write`.

So this is the same operation with the filesystem taken out: text in, edits
in, text out. Pure, so the write path can run it wherever the *current*
Expand Down
13 changes: 6 additions & 7 deletions lib/stack/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,10 @@ def _build_template_vars(self) -> dict:
"domain": self._cfg("core", "domain"),
"language": self._cfg("core", "language", self._cfg("ai", "language", "en")),
"timezone": self._cfg("core", "timezone", "UTC"),
# Name of the shared/family-level bucket inside the memory
# vault. Personal entities (homer, marge, …) live at
# <vault>/<slug>/, the shared bucket at <vault>/<shared_bucket>/.
# Default "family" fits famstack; deskstack or non-family
# deployments override to "office", "household", a surname, etc.
# Name of the shared bucket inside the memory vault. Personal
# entities live at <vault>/<slug>/, the shared bucket at
# <vault>/<shared_bucket>/. Defaults to "family"; deployments
# override to "office", "household", a surname, etc.
"shared_bucket": self._cfg("core", "shared_bucket", "family"),
# Wiki freshness (the memory stacklet's curator sidecar).
# Bool/int from stack.toml render as env-friendly strings.
Expand Down Expand Up @@ -285,9 +284,9 @@ def _build_template_vars(self) -> dict:
)
template_vars["mail_poll_interval"] = str(mail_cfg.get("poll_interval", 120))

# The family agent's identity, one knob: `[agent] name` (default
# The agent's identity, one knob: `[agent] name` (default
# "Stacky") drives its persona, Matrix display name, handle, and home
# room. A family renames it (e.g. "Merlin" -> @merlin-bot, #merlin)
# room. An instance renames it (e.g. "Merlin" -> @merlin-bot, #merlin)
# with one stack.toml line and no code edits.
agent_name = self._cfg("agent", "name", "Stacky")
agent_slug = agent_name.strip().lower().replace(" ", "-")
Expand Down
2 changes: 1 addition & 1 deletion lib/stack/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def slug(text: str) -> str:
def slugify_person(name: str) -> str:
"""Map a person name to its vault bucket slug.

Buckets are the Matrix localpart lowercased; for the default family
Buckets are the Matrix localpart lowercased; with stock user seeding
that is the first name lowercased ("Homer Simpson" -> "homer"). We
take the first whitespace token so a full name still resolves to the
bucket the captures landed in. The person's container entity page
Expand Down
2 changes: 1 addition & 1 deletion stacklets/core/stacklet.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ BRAIN_REPO_DIR = "/data/memory/brain"
# Shared-bucket slug — the entity that owns institutional artifacts
# (documents, correspondents) inside the memory vault. Personal
# entities (homer, marge, …) live at <vault>/<slug>/. Defaults to
# "family"; deskstack or non-family deployments override via
# "family"; non-family deployments override via
# stack.toml [core] shared_bucket.
SHARED_BUCKET = "{shared_bucket}"

Expand Down
Loading
Loading