pi setup

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#

StoreDefault session--with-domain <other>
core/brain/core, read-writeread-write
the project’s domain/brain/<domain>, read-writeread-write
the named domain <other>not mounted/brain/<other>, read-only (kernel-enforced)
every other domainnot mountednot 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#

bash
pi-sandbox memory-sync private    # core + private
pi-sandbox memory-sync work        # core + work

The 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:

  1. refuses the store if its .git/config has 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;
  2. fetches the remote state from BRAIN_<STORE>_URL and stops if the remote has commits the local repo lacks (pull or rebase on the host, then sync again);
  3. 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 as confirm <path>:<line> <category>? [y/N] — only y confirms, anything else (also no input) skips the store with skipped (personal data not confirmed);
  4. 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;
  5. prints the review list and asks push <store>? [y/N];
  6. on y: commits chore(memory): sync <domain> <YYYY-MM-DD> and pushes; on anything else: skipped, changes stay local.
Memory sync of one store. The sync container checks the store's git config, fetches the remote, runs the memory check, shows the diff and pushes only after you confirm. Every stop keeps the local changes.

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:

bash
pi-sandbox memory-check private   # core + private, read-only, no network, no credentials

Report: 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).

CategorySeverityRule
secretblockgitleaks (built into the image, pinned) finds a token, key or password; or a URL carries credentials (scheme://user:password@…; placeholders like <TOKEN> excluded)
scope-violationblocka folder other than notes/ in the core store — project memory belongs in its domain store
email, phone, iban, ipconfirmpattern match (German and international formats; IPv4 and IPv6 — loopback, 0.0.0.0 and :: ignored)
assessmentconfirma term from container/assessment-words.txt (judgements about people, disciplinary or health terms)
briefing-sizewarnbriefing.md over 60 lines or 4000 bytes — the session context would truncate it
index-missing-note, index-unlisted-notewarnindex.md links a missing note, or a note is not listed
note-metadata, note-scopewarnfrontmatter lacks title/scope/updated (YYYY-MM-DD)/tags, or scope ≠ location
log-formatwarna log.md line is not <YYYY-MM-DD> | add|update|delete | <path> | <reason> (comments, headers, blank lines are fine)
binary-filewarnmemory is plain text; binary files are not content-scanned
large-filewarna 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:

StoreHostCredentialKeychain account.env
workGitLabDeploy token with write_repository — scoped to this one repobrain-work-tokenBRAIN_WORK_URL=https://gitlab.com/<user>/pi-memory-work.git
coreCodebergYour own access token with write:repository scopebrain-core-tokenBRAIN_CORE_URL=https://codeberg.org/<owner>/pi-memory-core.git
privateCodebergSame Codeberg tokenbrain-private-tokenBRAIN_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:

  1. A command of yours, if BRAIN_<ID>_TOKEN_CMD is 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-sync stops with exit 30.
  2. Otherwise the Keychain item pi-sandbox / brain-<name>-token (brain-core-token for 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, …).

bash
# .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):

bash
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 token

Creating them

  • GitLab (work): project pi-memory-work → Settings → Repository → Deploy tokens → name pi-sandbox-sync, scope write_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

CredentialRevokeReplace
GitLab deploy tokenproject → Settings → Repository → Deploy tokens → Revokecreate a new one, then security add-generic-password -s pi-sandbox -a brain-work-token -U -w
Codeberg tokenSettings → Applications → delete the tokengenerate a new one, update brain-core-token and brain-private-token
Remove from the Macsecurity 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.