Memory (brain) in the sandbox
pi’s long-term memory lives in git repos under ~/pi/brain/ (spec FR-003, FR-004):
pi-memory-core (all domains) and one pi-memory-<domain> per configured domain — by default
pi-memory-private and pi-memory-work; more with pi-sandbox domain add <name> (pi-config
spec 005, commands.md). This page covers how
sessions see them, how the context is injected, and how the owner reviews and pushes changes.
Store layout#
Each repo has README.md (for humans), briefing.md (always loaded, ≤ 60 lines), index.md
(one line per note), log.md (append-only) and notes/<slug>.md. Below a domain store, memory
mirrors the workspace tree (pi-config spec 004): every level of a project’s path — host, group,
…, project — can have its own folder with the same layout, e.g.
pi-memory-private/git.example.org/group/repo/. A note’s scope is core, domain or the
level’s path relative to the domain store. The core store has no levels. Full format:
pi-config/specs/001-pi-workspace-setup/contracts/memory-format.md.
Scope per session — enforced by mounts#
| Store | Default session | --with-domain <other> |
|---|---|---|
| core | /brain/core, read-write | read-write |
| the project’s domain | /brain/<domain>, read-write | read-write |
the named domain <other> | not mounted | /brain/<other>, read-only (kernel-enforced) |
| every other domain | not mounted | not mounted |
At most one extra domain per session. With exactly two domains configured,
--with-other-domain is the same as --with-domain <the other one>.
The domain comes from the project path (ws/<domain>/<path>), never from a flag. The opt-in
lasts for that one run. A missing repo is replaced by the empty, read-only empty/ placeholder
and a warning; nothing is created on the host.
Not covered by this separation: session history, which is shared across domains (state.md).
Session context#
Before pi starts, container/render-context.sh writes /run/pi-session/context.md (tmpfs, per
session) and pi gets it via --append-system-prompt. It contains the scope table (store, path,
access) for core, the domain, every level on the project’s path and the project (levels
without memory are marked), and their briefings in that order (each capped at 60 lines / 4000
bytes; the opt-in store’s briefing is not inlined). The whole file stays within 16 KiB: the
budget goes to the nearest levels first (project, then its parent, …, domain, core); a briefing
that no longer fits is shortened to whole lines with a pointer to its file, or marked as not
inlined. The agent writes each fact to the narrowest level on the path where it holds.
To fill memory deliberately, run /memory-seed in a session: without an argument it reads the
project’s documentation and writes the project level; /memory-seed core, domain or a level
path interviews you for that level (pi-config agent/prompts/memory-seed.md). The memory rules — what to keep, what never to store,
where to write — are in pi-config/agent/AGENTS.md.
The agent writes memory on its own and announces each write
(memory: added notes/<slug>.md (<scope>)). These rules are behavioural: they rely on the
instructions and on your review at sync time, not on a technical filter.
Sync: review and push#
pi-sandbox memory-sync private # core + private
pi-sandbox memory-sync work # core + workThe wrapper reads the credentials from the Keychain and starts a separate sync container
(compose.sync.yaml): only /brain/core and /brain/<domain> are mounted, read-write, with no
project, no config and no agent state. The agent’s sessions never receive these credentials.
For each store, container/memory-sync.sh:
- refuses the store if its
.git/confighas keys outside an allowlist (core basics,remote.*url/fetch,branch.*,user.*) — other keys could run commands or redirect the push while the credential is present; hooks are always disabled; - fetches the remote state from
BRAIN_<STORE>_URLand stops if the remote has commits the local repo lacks (pull or rebase on the host, then sync again); - runs the memory check: prints the store’s findings; a blocking finding
(secret, project folder in core) stops the store with
blocked by the memory check (fix the listed lines); every possible personal-data finding in a line that is not yet on the remote is asked asconfirm <path>:<line> <category>? [y/N]— onlyyconfirms, anything else (also no input) skips the store withskipped (personal data not confirmed); - shows the changed files, the full diff, and every unpushed commit with its patch — commits
not made by the sync are flagged
warning: commit <sha> was not made by memory-sync; - prints the review list and asks
push <store>? [y/N]; - on
y: commitschore(memory): sync <domain> <YYYY-MM-DD>and pushes; on anything else:skipped, changes stay local.
Review list (check before answering y)#
- no secrets, tokens or credentials;
- none of the never-store categories: personal data from customer projects, end-user data, database extracts / exports / logs / screenshots, Art. 9 data, applicant data, assessments of people, business contact details;
- every entry is in the right store and scope (project / domain / core).
Remove or fix anything wrong directly in ~/pi/brain/… on the host, then sync again.
Exit code 30 means a store failed (missing store, URL or credential, unreachable remote, remote ahead, refused config, blocked by the memory check, rejected push); local changes are always kept.
Memory check#
container/memory-check.sh checks one store’s working tree (tracked and untracked files,
.gitignore respected, .git excluded). It runs inside memory-sync (above) and on demand:
pi-sandbox memory-check private # core + private, read-only, no network, no credentialsReport: one == <store>: clean|warnings|blocked|missing line per store, then one line per
finding — <path>[:<line>] <severity> <category>: <message>. Matched values are never
printed: secrets are redacted by the scanner, personal data is reported by line number only.
Exit codes: 0 clean, 31 warnings / possible personal data, 30 a store is missing, 32 blocking
(priority 32 > 30 > 31).
| Category | Severity | Rule |
|---|---|---|
secret | block | gitleaks (built into the image, pinned) finds a token, key or password; or a URL carries credentials (scheme://user:password@…; placeholders like <TOKEN> excluded) |
scope-violation | block | a folder other than notes/ in the core store — project memory belongs in its domain store |
email, phone, iban, ip | confirm | pattern match (German and international formats; IPv4 and IPv6 — loopback, 0.0.0.0 and :: ignored) |
assessment | confirm | a term from container/assessment-words.txt (judgements about people, disciplinary or health terms) |
briefing-size | warn | briefing.md over 60 lines or 4000 bytes — the session context would truncate it |
index-missing-note, index-unlisted-note | warn | index.md links a missing note, or a note is not listed |
note-metadata, note-scope | warn | frontmatter lacks title/scope/updated (YYYY-MM-DD)/tags, or scope ≠ location |
log-format | warn | a log.md line is not <YYYY-MM-DD> | add|update|delete | <path> | <reason> (comments, headers, blank lines are fine) |
binary-file | warn | memory is plain text; binary files are not content-scanned |
large-file | warn | a text file over 1 MiB (MEMORY_MAX_FILE_BYTES) — likely an export or log; not content-scanned |
README.md of a store is scanned for secrets and personal data but not for format. Personal
data is heuristic: confirm false positives (e.g. a noreply address) during the sync; a
confirmation is not stored — once the line is pushed it is no longer asked about. There is no
allow-list.
Credentials#
One Keychain entry per store, service pi-sandbox, value username:token, plus the push URL
in .env. Names follow one rule for every domain: <ID> is the domain name in uppercase with
- → _, so domain my-club uses BRAIN_MY_CLUB_URL, BRAIN_MY_CLUB_TOKEN_CMD,
BRAIN_MY_CLUB_USER and the Keychain account brain-my-club-token. The wrapper passes core’s
values and the synced domain’s values to the sync container only, as BRAIN_CORE_URL /
BRAIN_CORE_TOKEN and BRAIN_DOMAIN_URL / BRAIN_DOMAIN_TOKEN. The default stores:
| Store | Host | Credential | Keychain account | .env |
|---|---|---|---|---|
| work | GitLab | Deploy token with write_repository — scoped to this one repo | brain-work-token | BRAIN_WORK_URL=https://gitlab.com/<user>/pi-memory-work.git |
| core | Codeberg | Your own access token with write:repository scope | brain-core-token | BRAIN_CORE_URL=https://codeberg.org/<owner>/pi-memory-core.git |
| private | Codeberg | Same Codeberg token | brain-private-token | BRAIN_PRIVATE_URL=https://codeberg.org/<owner>/pi-memory-private.git |
Where the wrapper reads them from — per store, in this order, with no silent fallback between the two:
- A command of yours, if
BRAIN_<ID>_TOKEN_CMDis set in.env(BRAIN_CORE_TOKEN_CMD,BRAIN_PRIVATE_TOKEN_CMD,BRAIN_WORK_TOKEN_CMD, …). The wrapper runs it on the Mac and uses its output — any password-manager CLI works. If the command fails or prints nothing,memory-syncstops with exit 30. - Otherwise the Keychain item
pi-sandbox/brain-<name>-token(brain-core-tokenfor core).
The value may be username:token, or just the token: then the username comes from
BRAIN_<ID>_USER in .env (BRAIN_CORE_USER, BRAIN_PRIVATE_USER, BRAIN_WORK_USER, …).
# .env — 1Password
BRAIN_CORE_TOKEN_CMD="op read op://Private/codeberg-pi-memory/credential"
BRAIN_PRIVATE_TOKEN_CMD="op read op://Private/codeberg-pi-memory/credential"
BRAIN_CORE_USER=<codeberg-user>
BRAIN_PRIVATE_USER=<codeberg-user>
# .env — Bitwarden (run `export BW_SESSION=$(bw unlock --raw)` in your shell first)
BRAIN_WORK_TOKEN_CMD="bw get password gitlab-pi-memory-work-deploy-token"
BRAIN_WORK_USER=<deploy-token-username>The *_CMD values are references (item names), not secrets; .env stays git-ignored anyway.
The commands may prompt you (Touch ID for op, a locked vault for bw); they run with your
terminal. Their output is never printed or written to a file.
Or store them in the Keychain (you are prompted for the value):
security add-generic-password -s pi-sandbox -a brain-work-token -U -w # "<deploy-user>:<token>"
security add-generic-password -s pi-sandbox -a brain-core-token -U -w # "<codeberg-user>:<token>"
security add-generic-password -s pi-sandbox -a brain-private-token -U -w # same Codeberg tokenCreating them
- GitLab (work): project
pi-memory-work→ Settings → Repository → Deploy tokens → namepi-sandbox-sync, scopewrite_repository. The username is shown with the token. - Codeberg (core, private): Settings → Applications → Generate new token → repository permission read and write, nothing else. Username: your Codeberg user.
Accepted risk (owner’s decision). Codeberg has no per-repo deploy tokens, so the Codeberg
token can write to every repo of your account, not just the two memory repos. Mitigations:
it is read from the Keychain only for memory-sync, exists only in the short-lived sync
container (never in an agent session), and the credential helper
(container/git-credential-brain) answers only for the exact BRAIN_<STORE>_URL. A dedicated
bot account with access to only the two repos would remove this risk.
Revoke and replace
| Credential | Revoke | Replace |
|---|---|---|
| GitLab deploy token | project → Settings → Repository → Deploy tokens → Revoke | create a new one, then security add-generic-password -s pi-sandbox -a brain-work-token -U -w |
| Codeberg token | Settings → Applications → delete the token | generate a new one, update brain-core-token and brain-private-token |
| Remove from the Mac | security delete-generic-password -s pi-sandbox -a brain-<store>-token | — |
Remaining risk (FR-019a): the sync container runs the same image as the agent’s sessions.