Skip to content

State & storage

Two kinds of state, kept apart on purpose.

Markdown is the source of truth for identity. Personas are meant to be edited by hand and diffed in git.

SQLite is the source of truth for everything with a lifecycle. Sessions, approvals, jobs, the audit trail — none of which anybody wants to read as a file.

The directory

.nouride/ one install, one mounted volume
├── config.toml daemon config
├── data/ the daemon's own state
│ ├── nouride.db SQLite: sessions, turns, cron, approvals, audit, usage
│ ├── nouride.db-wal WAL mode — concurrent read and write
│ ├── secrets.json 0600, write-only from outside
│ ├── .control-token 0600 — what the CLI reads to authenticate
│ ├── .session-secret generated if SESSION_SECRET is unset
│ ├── credentials/ 0700 — file-shaped credentials, unreachable by file tools
│ ├── attachments/<agent>/<date>/
│ ├── whatsapp/ Baileys session, AES-256-GCM at rest
│ ├── jobs/ background command logs
│ ├── tool-output/<session>/ tool results too large for the context
│ └── backups/agents/ dated copies of the persona packs
├── agents/<id>/ persona packs — git-trackable identity
│ ├── IDENTITY.md SOUL.md USER.md AGENTS.md MEMORY.md
│ ├── config.toml skills.toml
│ └── skills/<name>/SKILL.md
├── memories/ captured transcripts, if session memory is on
└── workspace/
├── shared/ every agent — the working directory
└── private/<id>/ one agent, unreadable by the others

Three keys point at three of these — data_dir, agents_dir, workspace_dir — rather than one root, because they differ in backup policy, sensitivity and size. workspace_dir on another disk is a supported arrangement, and the persona pack is deliberately not a workspace.

SQLite, and nothing else

No Postgres, no Redis, no vector database, no external daemon. One file, WAL mode, opened through Bun’s native driver.

The schema lives in numbered, forward-only migrations compiled into the binary; PRAGMA user_version tracks what has been applied. There is no migration step to run — a new binary migrates at boot.

What is in there: sessions and their turns, tool executions, approvals, standing grants, sender access, background jobs, cron jobs and their runs, skill drafts, usage and cost, the daemon’s own log, and dashboard accounts.

Every query lives in a *.store.ts file and everything else takes a store, enforced from both directions by the lint rules. find apps packages -name '*.store.ts' is therefore the complete inventory of what a move to another database would have to rewrite.

Secrets

Config files hold secret names. Values live somewhere else, and are resolved at the moment they are used:

api_key = "nougate" → $NOUGATE
→ $NOURIDE_SECRET_NOUGATE
→ data/secrets.json

Environment wins over the store, so a container platform can always pin a value. The store is what the dashboard writes to, which is why adding a bot token does not need a redeploy.

This is what makes a config.toml safe to commit, print, and render in a browser.

Terminal window
nouride secret # NAMES only — values are never printed
printf '%s' "$TOKEN" | nouride secret set telegram-nouva --value-stdin
nouride secret rm telegram-nouva

--value <v> exists and the CLI says out loud what it costs: the value ends up in your shell history. Pipe it instead.

What ages out, and when

[retention]
session_days = 30 # conversations, and the attachments belonging to them
audit_days = 30 # the audit trail
job_log_days = 14 # background command output
tool_output_days = 2 # tool results spilled to disk
scratchpad_days = 3 # each agent's disposable directory

Two of these are worth understanding.

job_log_days cannot be “delete when the job ends”, because the agent may still be holding the path the tool handed it.

scratchpad_days covers workspace/private/<agent>/.scratchpad/ — the disposable directory an agent is told to put throwaway artefacts in: a screenshot on its way to being sent, a download on its way to being converted. Deleting inside it is the one delete that is not a hardline class, so an agent can clear up after itself. It is swept on a timer rather than at the end of the turn, because the next message is usually about the file that was just sent.

audit_days has a second effect worth knowing: “which skills has nobody read” is derived from audit rows, so the honest window for that question is however much audit history the daemon still has.

Conversation history

A conversation is stored per agent, per chat, per gateway instance. max_conversation_turns (200) caps how much is kept.

When the context gets long the daemon compacts rather than truncating: the older part is summarised and the recent turns are kept verbatim. max_context_ratio (0.8) decides how much of the model’s window gets filled before that happens.

/new starts a fresh conversation. What survives it: MEMORY.md, USER.md, and the People entries — the three places a permanent fact belongs.

Session memory

Optional, and off unless active_dir is set. When a conversation ends, the transcript is written to disk as YYYY-MM-DD-HHMM.md.

It exists because of a real gap: the daemon keeps a conversation in one database column and /new overwrites it, so on an install with an external memory engine the busiest days were exactly the ones missing from the vault — the engine indexes dated files, and a conversation that never became a file was never in one.

The daemon writes the file and stops there. Summarising, indexing and archiving stay the memory engine’s job.

[session_memory]
active_dir = "./.nouride/memories"
retention_days = 3
[session_memory.reset]
mode = "none" # none | daily | idle

daily closes a conversation that began before today’s boundary; idle closes one nobody has touched for a while. That is the whole difference between them: a chat kept up all day is still stale at the next boundary, and one started an hour ago is not idle however old the chat is.

A capture is dated by when the conversation was last alive, not when the rollover closed it — a chat that ran until 23:00 and idles overnight is closed by the first message next morning, and dating it “now” would file last night’s transcript under today.

Health

[health]
heartbeat_interval_ms = 60000
disk_warn_threshold_mb = 1024
wal_warn_threshold_mb = 100

The daemon checks its own free disk on that heartbeat and alerts rather than only logging; /health reports degraded once the WAL passes its threshold. This is the daemon watching itself — nothing here wakes an agent. Cron does that, and it is the only thing that does.