45 KiB
Agent Development Guidelines
Temporary File Storage
All temporary files and build artifacts must use <project-root>/tmp/ instead of system /tmp.
Rationale
- System
/tmpis shared and can conflict with other processes - Project-local
tmp/provides isolation and easy cleanup tmp/can be.gitignored and safely deleted at any time- Predictable paths for debugging and logging
Convention
# CORRECT - project-local temp directory
TMP_DIR="$(pwd)/tmp"
mkdir -p "$TMP_DIR"
WORK_FILE="$TMP_DIR/build-$(date +%s).log"
# WRONG - system temp directory
TMP_FILE="/tmp/work-file"
Applies To
- Build logs and intermediate files
- Server PID files and sockets
- Package caches
- Downloaded artifacts
- Any ephemeral file that doesn't need to persist after build/run
Do Not Mount Datasets There
<project-root>/tmp/ is for ordinary files and directories only. Do not
mount ZFS datasets, clones, nullfs mounts, tmpfs, or recovery filesystems
anywhere under repo tmp/.
Why:
tmp/must stay safely deletable with normalrm -rf- mounted filesystems turn cleanup failures into ambiguous "permission denied" or "directory not empty" errors
- dataset lifecycle is different from temp-file lifecycle; mounted recovery state must be destroyed or unmounted intentionally, not cleaned like scratch files
Use instead:
- a dedicated dataset mountpoint outside the repo, or
- a clearly named recovery path outside
tmp/such as/mnt/<name>or/var/db/<name>-recovery
Gitignore Entry
/tmp/
Tmux Window Targeting (Session:Index)
Always target tmux windows by session:index syntax, never by name alone.
Why
tmux window names can conflict with session names (e.g., -t testing could refer to session "testing" or window "testing"). The SESSION:WINDOW pattern eliminates ambiguity.
Convention
# CORRECT - explicit session:window targeting
tmux send-keys -t 0:1 "command" Enter
# WRONG - ambiguous; causes "can't find pane" errors
tmux send-keys -t testing "command" Enter
Pattern: SESSION:WINDOW
SESSION= session index or name (e.g.,0,main,aider-smoke)WINDOW= window index (e.g.,0,1,2)
Check Before Targeting
Always run tmux list-windows first to see current state:
tmux list-windows
# 0: codex (1 panes) [171x39]
# 1: testing- (1 panes) [171x39]
# 2: node* (1 panes) [171x39] (active)
Then send to the correct window: tmux send-keys -t 0:1 "command" Enter
Long-Running Tasks
Run builds, installs, CMS rebuilds, ISO builds in the default session's window 1 (or a dedicated window):
# Check state first
tmux list-windows
# Then target session:window explicitly
tmux send-keys -t 0:1 "just build" Enter
tmux capture-pane -t 0:1 -p -S -100 # Read output after
Creating Windows (if needed)
# Create new window in current/default session
tmux new-window -t 0 -n build-work
# Send command to it
tmux send-keys -t 0:3 "long-running-command" Enter
Do not rely on window names for targeting; always use the index returned by list-windows.
One Command Per Shell Call
Run one command at a time. Never chain with &&, ||, or ;, and don't
wrap in tee … ; echo EXIT=$?. Send each command separately and capture its
output before the next.
Why
Chained commands hide which step failed and what its output was. Worse, if a
mid-chain command starts a long-running operation (e.g. git add over a huge
untracked tree), the parent shell stays attached and holds locks (e.g.
.git/index.lock) for minutes. We lost time to a stuck git add that walked
html/clawdie/iso/ because it was buried inside a five-step && chain.
Applies To
- All
Bashtool calls - All
tmux send-keysinvocations - Any provisioning or test loop driven by an agent
If you need conditional behavior, check the previous command's exit status in the next call instead of chaining.
bastille destroy Always Prompts
sudo bastille destroy <jail> asks Are you sure you want to continue? [y|n]
even with -a and -f. There is no non-interactive flag.
Convention
yes y | sudo bastille destroy -a <jail>
This is the only reliable way to destroy a jail from a script or an agent session.
Agent CLI Prerequisite
setup onboard fails fast if none of pi, aider, claude, codex, or
gemini resolve on PATH. The controlplane harness uses Aider+Pi as the
primary driver. The gate lives in setup/agent-cli-check.ts and is mirrored
in setup/environment.ts. Validated install paths are documented in
doc/AGENT-CLI-VALIDATION.md.
Date Formatting Convention
All user-facing dates must use the configured onboarding locale and timezone, rendered through the shared date helpers.
With time, use:
DD.mmm.YYYY HH:MM
DD.mmm.YYYY HH:MM:SS
Rationale
- Numeric month-only forms are ambiguous for humans and LLMs
- Onboarding now detects FreeBSD locale/timezone and lets the operator confirm or override them
- User-facing dates must follow the chosen installation profile, not a hardcoded repo locale
- Internal storage uses ISO 8601 (
YYYY-MM-DDTHH:mm:ss.sssZ) for machine consumption - ISO 8601 is sortable, universally parsable, and timezone-aware
Implementation
Shell Scripts (bash)
# CORRECT - repo helper uses .env/system locale and timezone
. ./scripts/date-format.sh
format_display_timestamp_now
# 10.mar.2026 14:30:00
# Also supported: setup/profile writes DISPLAY_LOCALE, SYSTEM_LOCALE, and TZ
# WRONG - numeric month is ambiguous
date '+%d.%m.%Y %H:%M:%S'
TypeScript (user-facing display)
import { formatDisplayDate } from './src/display-date';
formatDisplayDate(new Date());
// Uses DISPLAY_LOCALE + TZ from config/.env
// WRONG - bypasses shared locale/timezone policy
d.toLocaleString(...)
TypeScript (internal storage/database)
// CORRECT - ISO 8601 for storage, IPC, database timestamps
new Date().toISOString(); // "2024-03-06T14:30:00.000Z"
// Keep these as-is - they are for machine consumption
Files Affected
| File | Change |
|---|---|
setup/profile.ts |
detects FreeBSD locale/timezone and writes onboarding profile |
setup.sh:13 |
bootstrap logs use format_display_timestamp_now |
src/display-date.ts |
user-facing display uses configured locale/timezone helpers |
scripts/memory/*.sh |
shell-side display uses shared locale/timezone helper |
Files NOT Affected
The following use toISOString() and should remain unchanged:
src/db.ts- Database timestampssrc/ipc.ts- IPC message timestampssrc/task-scheduler.ts- Task scheduling timestampssrc/container-runner.ts- Container logs and file namingskills-engine/*.ts- State trackingsetup/register.ts,setup/groups.ts- Registration timestampsjail/agent-runner/src/ipc-mcp-stdio.ts- IPC timestamps- All test files using
toISOString() - All skill modification files in
.agent/skills/*/modify/
Before Editing Date-Related Code
- Determine if date is user-facing (logs, transcripts, UI) or internal (storage, IPC)
- User-facing → shared helpers driven by onboarding locale/timezone
- Internal → ISO 8601 (
toISOString()) - Never mix the two
Ansible File Naming
Use .yaml consistently for Ansible files in this repository.
Rationale
- Ansible supports both
.ymland.yaml - This repo standardizes on
.yamlfor clarity and consistency - Do not mix both extensions inside
infra/ansible/
Applies To
- inventories
- playbooks
- vars files
- role task files
Examples
infra/ansible/inventories/production/hosts.yaml
infra/ansible/playbooks/host-preflight.yaml
infra/ansible/playbooks/jail-cms-create.yaml
Bridge Naming
Use warden0 as the canonical host bridge name for Bastille/Warden networking.
- Do not introduce new
clawdie0references in code, docs, skills, or examples. - Runtime source of truth lives in
src/jail-config.ts. - If a historical reference must remain, mark it explicitly as archived.
Internal DNS Naming
Use home.arpa as the canonical internal namespace for host and jail-local
resolution.
AGENT_INTERNAL_DOMAINshould default to<agent>.home.arpa.- Do not introduce new
.localdefaults for internal service names. .localis reserved for mDNS and can create resolver ambiguity or name leakage on the local link.AGENT_DOMAINis operator-facing and should default tohome.arpafor local DNS zones. The host FQDN is<agent>.home.arpa. If a real public domain exists, use it and configure DNS.
ZFS Snapshot Naming
User-facing manual snapshots must use DD.mmm.YYYY rendered through the
configured locale/timezone helpers.
Format
# CORRECT - repo helper, onboarding-profile aware
SNAP_NAME="pre-update-$(./scripts/date-format.sh snapshot-stamp)"
# Output: pre-update-10.mar.2026-1430
# WRONG - numeric month is ambiguous
date '+%d.%m.%Y-%H%M'
Naming Convention
<dataset>@<description>-<DD.mmm.YYYY>[-<HHMM>]
Examples:
zroot/clawdie-runtime/jails/clawdie-worker@setup-complete-10.mar.2026zroot/clawdie-runtime/jails/clawdie-worker@pre-update-10.mar.2026-1430zroot/clawdie-runtime/jails/clawdie-browser-vm@pre-upgrade-08.mar.2026
Exceptions
Automated snapshots (Sanoid) use ISO format and should NOT be changed:
autosnap_2026-03-10_04:00:00_hourly- machine-generated, machine-consumed
Rationale
- Consistency with the operator-selected install profile
- ZFS allows dots in snapshot names
- Month names avoid ambiguous numeric-only dates
Session Log Naming
Session logs in docs/internal/sessions/ use a hybrid format:
Filename Format
docs/internal/sessions/YYYY-MM-DD-<topic>.md
ISO date for sortability in ls output.
Example: docs/internal/sessions/2026-03-10-jail-resource-limits.md
Content Format
Inside the file, use European format for user-facing dates:
# Session: Jail Resource Limits
**Date:** 10.mar.2026 12:30 UTC
**Topic:** Applying ZFS quotas and RCTL memory limits
Rationale
| Aspect | Format | Reason |
|---|---|---|
| Filename | ISO (YYYY-MM-DD) |
Sortable in directory listings |
| Content | DD.mmm.YYYY |
User-facing, matches AGENTS.md convention |
Do Not Mix
Never use ISO dates in user-facing content, and never use European dates in filenames.
Service Terminology
Use different language depending on audience. The rule: operator-facing content uses humanized service names; code, config, and developer docs use exact technical terms.
| Context | Use |
|---|---|
| HTML docs, README, setup messages, user-facing logs | data service, web publishing service, code service, worker environment |
| TypeScript, setup scripts, config, developer docs | jail, db jail, cms jail, git jail, pf, zfs, bastille, hostd |
Mapping:
| Operator name | Technical term |
|---|---|
| Data Service | db jail |
| Web Service | cms jail |
| Code Service | git jail |
| Worker | worker jail |
In prose, lead with the capitalized name and follow with the technical term in parentheses on first use:
The Data Service (db jail) runs PostgreSQL at 10.0.0.3.
In subsequent references within the same page or section, either form is fine.
Rationale: jail, pf, zfs, bastille are what the runtime actually uses in code and logs. Changing them in code creates confusion. But operator-facing docs should describe what a service does, not its implementation container type.
When in doubt: if the word appears in a TypeScript import, a Bastille command, or a config key, keep it technical. If it appears in a sentence a human reads before they know what Bastille is, use the humanized form.
Multitenant Rules
Design source of truth: docs/internal/MULTITENANT.md. The rules
below are the day-to-day "do/don't" extract.
- No
PLATFORM_*env vars.PLATFORM_ID,PLATFORM_SERVICE_NAME, andPLATFORM_RUNTIME_USERare removed. Platform identity is two constants baked into code: service nameclawdie, platform namespacesystem. - Service name (
clawdie) ≠ platform namespace (system). The service user, rc.d service, and brand areclawdie(one of them). Shared platform DBs and resources use thesystem_*prefix (system_ops,system_brain,system_skills,system_git,system_web) so a tenant id that happens to equalclawdiecannot collide with platform resources. - Never derive infra names from
ASSISTANT_NAME. Renaming the assistant must require zero infra change. If a code path turnsASSISTANT_NAMEinto a DB name, dataset path, jail name, service name, or UNIX user, that is a bug. TENANT_IDis for additive tenants only. The root install is not a tenant; it is the platform. Do not seedTENANT_IDfrom the assistant name at onboarding.- Shared services stay shared. Git Service, Web Service, and Local AI Models are platform-owned shared services. Tenants consume them; they do not get separate root-service instances or fake root-tenant DBs by default.
- Service account ≠ operator account. Service account default is
clawdie(override allowed via bootstrap config, never via onboarding). The operator account already exists on the host; Clawdie checks for it on existing-host installs and never renames or recreates it. systemis a reserved host label and reserved tenant id. Validator rejects any tenant id that normalizes tosystem.- Ownership rule. Every resource must answer
shared-platformortenant:<id>. Ambiguous ownership is a bug.
Skills Artifact
bootstrap/skills-memory/artifact.sql — PostgreSQL snapshot for the skills DB — is now a committed, refreshable artifact.
Regenerate it when knowledge-source files change and OpenRouter budget allows it:
just refresh-skills-artifact
- The wrapper detects changes in docs, identity files, and
.agent/skills/*/SKILL.mdbefore spending embedding tokens. - It checks OpenRouter key budget first and refuses to run below
SKILLS_ARTIFACT_MIN_OPENROUTER_REMAINING_USD(default$0.25). - Use
just refresh-skills-artifact --check-onlyto verify whether regeneration is needed without calling embeddings. - Architecture: PostgreSQL + pgvector in the db jail for skills (static, committed to git). No SQLite or sqlite-vec path.
- Plan:
docs/internal/SKILLS-ARTIFACT-V1-PLAN.md
Install Orchestrator
just install (setup/install.ts) is the canonical single-command install path.
- Runs 20 steps in order with ZFS checkpoints at pf, jails, db, git, cms, verify
- Never exits on a missing LLM key —
pi-configemits a warning and continues --from <step>resumes from any step after a failure;--dry-runprints the plan- Optional steps (skills-memory, browser-vm, telegram-auth) are skipped cleanly when their conditions aren't met
- Reference:
docs/public/install/install.md
When checking install state after a run, prefer just doctor over manually querying individual step outputs.
Public Docs Source Of Truth
Keep public-facing product descriptions aligned with the current repository state.
Source Files
README.md- repo landing and operator quick starthtml/docs-clawdie-si/- canonical public documentation sitehtml/clawdie/index.html- marketing/landing page
Canonical pages
html/docs-clawdie-si/docs/install.html— the only installation guide. FreeBSD host setup and Clawdie install steps live here. There is no separatefreebsd-setup.html— it was merged in.
Legacy Pages
The following are legacy entry points and should prefer redirects over duplicate long-form content:
html/clawdie/docs/*.htmlhtml/clawdie/guides/*.htmlhtml/clawdie/license.htmlhtml/clawdie/changelog.html
Update Rule
When setup flow, runtime architecture, supported channels, or public URLs change:
- update
README.md - update the matching page in
html/docs-clawdie-si/ - update any claims in
html/clawdie/index.html - redirect legacy duplicated pages instead of maintaining two divergent copies
Git Remotes
- Primary:
code.smilepowered.org(self-hosted Forgejo, port 2222 SSH). Source of truth. - Mirror:
codeberg.org/Clawdie— public read-only mirror. Do not push to it directly. - The pi monorepo lives at
https://codeberg.org/Clawdie/pi.git; do not push to the upstream GitHub remote. - For git jail mirrors, keep
REMOTE_GIT_URLfor the primary repo and add extras viaGIT_MIRROR_URLS(comma-separated).
Verify Before Claiming Remote State
Don't report what's on a remote without fetching first. Local
origin/<branch> refs are snapshots of the last git fetch, not the
live remote tip — a claim like "no new remote work landed" without a
recent fetch is a claim about your local cache, not the remote.
Convention
- Before any "what's on origin?" report, run
git fetch <remote>(orgit fetch --allwhen remote scope is unclear). - If you must speak about state before fetching, phrase it explicitly: "no new work in my local refs (fetching to confirm)" — never "no new remote work landed".
- When two agents disagree about a remote tip, both fetch and compare before debugging further. Stale refs are the most common cause of "git looks broken" reports that turn out to be false alarms.
Rationale
This bit us on 26.apr.2026: one agent reported origin/multitenant = 1e87f34 while another had just pushed 3d33482 to the same remote.
Both were "right" relative to their own caches — neither had fetched.
The diagnostic round-trip took longer than the fetch would have.
Inspect Before Mutating Auth
For authentication, SSH, sudoers, group membership, and similar security-sensitive system state:
- Read the current state first
- Confirm the actual path, include model, and active configuration
- Only then write the minimal change needed
Applies To
sudoersandsudoers.dsshd_configauthorized_keys- user/group membership
- key files and SSH trust setup
Rationale
- FreeBSD and packaged tools may use different canonical paths than assumed
- include/drop-in behavior must be verified before writing
- auth mistakes can lock out operator access
Example
For sudoers:
1. read /usr/local/etc/sudoers
2. confirm @includedir /usr/local/etc/sudoers.d
3. add the drop-in
4. validate with visudo -c
Git Hooks
Tracked hooks live in hooks/. Activate once after cloning:
just install-hooks
# equivalent: git config core.hooksPath hooks
Already wired into setup.sh on fresh installs.
hooks/pre-commit
Reads package.json version and patches the backtick version line in
README.md automatically on every commit. Keeps README in sync without
manual edits — bumping version in package.json is sufficient.
hooks/prepare-commit-msg
Automatically appends build and test status to every commit message (skipped for merge and squash commits). This gives agents and reviewers a clear record of what was passing at commit time.
Release Tagging
Run just release only when a minor or major version bump has been
committed and pushed — never on patch bumps or regular commits.
| Version change | Tag? |
|---|---|
0.8.0 → 0.9.0 (minor) |
✓ run just release |
0.8.0 → 1.0.0 (major) |
✓ run just release |
0.8.0 → 0.8.1 (patch) |
✗ skip |
The script reads the version from package.json, creates an annotated git
tag, and pushes it to Codeberg. This is what makes the version appear in the
Tags list on Codeberg.
Always confirm with the user before running — a tag is a public checkpoint and should be intentional. When in doubt, ask: "Ready to tag v0.X.0 on Codeberg?"
Commit Message Convention
Every commit message must include a build and test status footer. The
hooks/prepare-commit-msg hook adds this automatically when the hooks path
is configured. When committing without the hook active (e.g. in CI or a bare
context), append it manually:
Short summary of what changed.
Optional body paragraph.
---
Build: pass | Tests: pass — 7 passed (1 file)
Build: pass—just build(tsc) exited 0Build: FAIL— tsc had errors at commit timeTests: pass — N passed (M files)— vitest summary lineTests: FAIL — N failed— some tests were failing at commit time
Rationale
Failures are sometimes expected (work in progress, known upstream issue, mid-refactor). The footer is not a gate — it is a record. Other agents picking up this repo mid-session can read the git log and immediately know the state of the codebase at each commit without re-running the full suite to establish a baseline.
UTF-8 compliance
Rule: always write UTF-8 to ~/.login_conf. Never write a legacy charset.
~/.login_conf format
me:\
:charset=UTF-8:\
:lang=sl_SI.UTF-8:
The lang tag must end in .UTF-8. The charset field must be UTF-8.
Never write ISO8859-2, ISO-8859-2, latin2, or any other non-UTF-8
encoding — doing so silently breaks Slovenian character rendering (č/š/ž
appear as _) in every terminal session until manually corrected.
Normalisation rule for applyHostLocale()
Strip any existing charset suffix from the detected locale tag, then
append .UTF-8. For example:
| Detected | Written |
|---|---|
sl_SI |
sl_SI.UTF-8 |
sl_SI.ISO8859-2 |
sl_SI.UTF-8 |
sl_SI.UTF-8 |
sl_SI.UTF-8 |
en_US.UTF-8 |
en_US.UTF-8 |
After writing ~/.login_conf
Always run cap_mkdb ~/.login_conf to rebuild the binary database.
The new locale takes effect on the next login — a new tmux window,
new SSH session, or exec $SHELL -l. Never suggest tmux kill-server
to activate locale changes; that destroys the user's entire working
environment.
Agent Identity
Agents working on this repo must identify themselves for attribution and handoff tracking.
| Identity | Platform | Capabilities | Restrictions |
|---|---|---|---|
| Claude / Hermes (debby — Debian 13) | debby, 16 cores / 15 GB RAM | Rust builds, cargo test, code editing, git push | Cannot run tests expecting FreeBSD, cannot build ISO, no FreeBSD cmds |
| Claude / Hermes (Linux) | Generic Linux dev machine | Code search, file editing, git push | Cannot run tests (Linux), cannot build ISO, cannot run FreeBSD commands |
| Codex app (FreeBSD) | FreeBSD 15 host | Runs tests, edits code, validates runtime seams | Receives handoff docs, cannot push without operator review |
| Aider / FreeBSD agent | FreeBSD 15 host | Runs tests, reviews patches, validates on host | Receives handoff docs, cannot push without operator review |
| Operator (Sam) | FreeBSD 15 host + Linux | Final review, merge decisions, ISO builds, deploys | Human — all commits require approval |
Debian Build Agent (debby)
Hostname debby — Debian 13 (trixie), 16 cores, 15 GB RAM. Primary role: Rust app builder.
Repos checked out on this machine:
| Path | Repo | Notes |
|---|---|---|
~/ai/clawdie |
Clawdie/Clawdie-AI |
main development repo |
~/ai/clawdie-iso |
Clawdie/Clawdie-ISO |
ISO builder, firstboot, installer |
~/ai/colibri |
Clawdie/Colibri |
Cross-platform Rust control plane |
~/ai/herdr |
ogulcancelik/herdr |
Rust CLI for LLM agents (exploration) |
Rust Toolchain — installed ✅
rustup + rustc/cargo 1.95.0 (stable) installed via rustup on 26.maj.2026.
colibri workspace builds and all 16 tests pass on this machine.
Node.js v24.16.0 installed via nvm and set as default.
Source cargo env in each new shell before invoking cargo:
source "$HOME/.cargo/env"
System build deps (build-essential, pkg-config, libssl-dev) already present.
⚠️ Agents MUST NOT install additional packages automatically. Notify the operator and wait for confirmation before adding any tooling.
Debian Build Agent MUST NOT
- Install packages via
apt,cargo install,rustup, or any package manager - Run FreeBSD 15-specific commands (
bastille,service,pkg,jls,zpool) - Build the ISO (
./build.sh) - Assume runtime behaviour matches FreeBSD 15 production
Debian Build Agent SHOULD
- Run
cargo build,cargo test,cargo clippyonce Rust is confirmed installed - Build Colibri from
~/ai/colibriand Clawdie-AI TypeScript from~/ai/clawdie - Push to Codeberg for the FreeBSD 15 agent to validate runtime seams
- Create handoff docs for FreeBSD-specific tasks
- Guide the operator with exact commands when a system-level change is needed
Linux Agent Constraints (Claude / Hermes)
The Linux agent (Claude or Hermes on Linux) MUST NOT:
- Attempt to build the ISO (
./build.sh) - Run
npm testornpx vitest(unreliable on Linux) - Run any FreeBSD 15-specific commands (
bastille,service,pkg,jls) - Assume packages or runtime behavior matches FreeBSD 15
The Linux agent SHOULD:
- Write code, tests, and documentation
- Push to Codeberg for the FreeBSD 15 agent to validate
- Create handoff docs for FreeBSD-specific tasks
- Guide the operator with exact commands to run
FreeBSD Agent Constraints
The FreeBSD agent MUST:
- Read and follow any active
doc/*-HANDOFF.mdfiles on startup - Check off tasks as they complete them
- Report test results in the handoff doc or commit message
- Not delete handoff docs until ALL deletion criteria are met
Session Compaction
When a pi session JSONL file exceeds size or token limits, the agent runner rotates to a fresh session and compacts the old one in the background.
How it works
- Trigger:
isSessionOversize()inagent-runner.tschecks both byte size and estimated token count - Rotate: A fresh session file is created (
session-{timestamp}-{runId}.jsonl). The agent resumes on the new file immediately — no blocking. - Compact (background):
scheduleCompaction()runscompactSession()asynchronously on the old file:- Old entries (first ~70%) are separated from recent entries (last N turns)
- A
pi --print --no-sessioncall generates a narrative summary (can use a separate model viaAGENT_COMPACTION_PROVIDER/AGENT_COMPACTION_MODEL) - Fallback: if LLM summarization fails or times out, old entries are concatenated
- The summary is stored in the configured memory database via
storeMemory()withtopics: ['session-compaction'],importance: 4 - The compacted file is written atomically (temp file → rename)
- Handoff:
getImportantMemories(5)is injected into the system prompt as<session-reset-context>so the new session has context carryover
Token-aware triggers
Compaction triggers on either byte size OR token count, whichever is hit first:
AGENT_SESSION_MAX_BYTES— byte limit (default 2MB)AGENT_SESSION_MAX_TOKENS— token estimate limit (default 200K)- If OpenRouter is the provider, auto-detects model context length and uses 60% of it
- Heuristic: 4 chars ≈ 1 token
Config
| Var | Default | Purpose |
|---|---|---|
AGENT_SESSION_MAX_BYTES |
2,000,000 | Byte limit that triggers compaction |
AGENT_SESSION_MAX_TOKENS |
200,000 | Token estimate limit that triggers compaction |
AGENT_SESSION_COMPACT_ENABLED |
YES |
Feature flag |
AGENT_SESSION_COMPACT_KEEP_TURNS |
20 |
Recent turns preserved at full fidelity |
AGENT_SESSION_COMPACT_MIN_ENTRIES |
30 |
Don't compact sessions shorter than this |
AGENT_SESSION_COMPACT_TIMEOUT_MS |
120,000 |
Timeout for LLM summarization call |
AGENT_COMPACTION_PROVIDER |
(inherit) | Optional separate LLM provider for compaction |
AGENT_COMPACTION_MODEL |
(inherit) | Optional separate LLM model for compaction |
Files
| File | Role |
|---|---|
src/session-compaction.ts |
Core logic: parse, split, summarize, write, store |
src/session-compaction.test.ts |
Unit tests (pure functions + mocked DB) |
src/agent-runner.ts |
Non-blocking rotation + scheduleCompaction() background task |
src/config.ts |
Config exports |
src/agent-session.ts |
SessionEntry type, pruneOldEntries() (called after each heartbeat) |
src/telegram-commands.ts |
/compact Telegram command (admin-gated, confirmation keyboard) |
src/controlplane-heartbeat.ts |
pruneOldEntries(session, 50) called after each session entry write |
Security
- Summarization prompt only includes
task,skill,result, and truncatedoutputfields — never raw full output - Atomic write prevents corruption on crash (old file untouched until rename succeeds)
- Compaction summary goes through the same
storeMemory()pipeline as all other memories storeMemoryfailure is non-fatal — compaction succeeds even if memory storage fails- API keys passed to compaction subprocess from config exports, not raw
process.env
Cost Model
Budget enforcement has been removed from the design (26.maj.2026, Sam & Hermes). DeepSeek prefix caching delivers ~98% cache-hit rate, making per-chat token budgets unnecessary. The harness is optimized for cost efficiency at the provider level rather than through runtime enforcement.
Removed modules: chat-policy.ts, controlplane-budget.ts, reports/budget-report.ts,
chat_spend table, /resume command, budget state machine.
Text-to-Speech (TTS)
Uses Microsoft Edge TTS (free, no API key). Voice en-US-EmmaMultilingualNeural. Output format: audio-16khz-32kbitrate-mono-mp3 (native MP3 — Telegram reads duration natively).
Auto-modes
| Mode | Behavior |
|---|---|
always |
Every agent reply gets a voice message |
inbound |
Reply with voice only when user sent a voice note |
tagged |
Only when agent output contains [[tts]] marker |
off |
No voice messages |
Per-chat override via /tts command with inline keyboard (on/off/status/default).
Files
| File | Role |
|---|---|
src/tts.ts |
synthesize(), shouldApplyTts(), stripTtsMarker(), stripMarkdown() |
src/channels/telegram.ts |
sendVoice() with path traversal guard |
src/config.ts |
TTS_AUTO_MODE, TTS_PROVIDER, TTS_VOICE, TTS_OUTPUT_FORMAT |
bin/edge-tts |
Repo wrapper for edge-tts CLI (venv → system fallback) |
Inbound Sanitization
All inbound Telegram text and captions are sanitized before processing:
- Zero-width / invisible characters stripped —
sanitizeInboundText()removes U+200B-200F, 202A-202E, 2066-2069, FEFF, C0 controls, DEL - Byte-level truncation —
truncateUtf8ByBytes()enforcesAGENT_MAX_INBOUND_BYTES(default 64KB) at UTF-8 character boundaries (never splits multi-byte sequences) - Char-level truncation — existing
AGENT_MAX_INBOUND_CHARS(default 12,000) applied after byte truncation
Extracted to src/sanitize.ts with src/sanitize.test.ts. Imported by src/channels/telegram.ts.
HTML Message Splitting
Telegram HTML messages have a 4096-character limit. splitHtmlChunks() in src/split-html.ts splits long HTML:
- Splits on
\n\nparagraph boundaries first - Falls back to newline boundaries within paragraphs
- Avoids splitting inside an HTML tag (best-effort; tags longer than
maxLenare unavoidable) - Merges tiny trailing chunks (<200 chars) with the previous chunk
Used by sendHtml() in src/channels/telegram.ts. Tests in src/split-html.test.ts.
Startup Report
On service start, a system status report is sent to the ops chat (TELEGRAM_OPS_CHAT_ID).
Extracted from src/index.ts to src/startup-report.ts. Key exported functions for testability:
| Function | Purpose |
|---|---|
buildStartupReport() |
Full HTML report (system, AI stack, ZFS, jails, packages) |
formatTimestamp() |
Locale/timezone-aware timestamp with Slovenian months |
parseUptime() |
Parses FreeBSD kern.boottime format |
parseJails() |
Parses bastille list all output |
parseZpool() |
Parses zpool list output |
Tests in src/startup-report.test.ts. Only runs on FreeBSD (uses sysctl, zpool, bastille, pkg).
OpenRouter Key Status
On startup and in /policy, the agent queries OpenRouter's /api/v1/auth/key endpoint to report remaining budget, limit, and free-tier status. Cached for 10 minutes.
| File | Role |
|---|---|
src/openrouter-status.ts |
getOpenRouterKeyStatus(), formatOpenRouterStatusLine() |
src/startup-report.ts |
buildStartupReportWithDiagnostics() — async variant that includes OR status |
src/telegram-commands.ts |
/policy shows OpenRouter key status |
Embeddings
pgvector embeddings for memory semantic search. Default behavior:
- If
OPENROUTER_API_KEYis set: OpenRouter embeddings endpoint (https://openrouter.ai/api/v1) - Otherwise: local llama-server at
http://localhost:8080/v1 - Set
EMBED_BASE_URL=(empty) to disable embeddings entirely (FTS-only mode)
Config
| Var | Default | Purpose |
|---|---|---|
EMBED_BASE_URL |
OpenRouter or local | Embeddings API base URL |
EMBED_API_KEY |
Inherits OPENROUTER_API_KEY |
API key for embeddings |
EMBED_MODEL |
BAAI/bge-m3 |
Embedding model |
EMBED_DIMENSIONS |
1024 |
Vector dimensions (must match DB) |
Maintenance
scripts/backfill-embeddings.ts — repairs missing vector embeddings after API outages or provider switches. Usage: env $(grep -v '^#' .env | xargs) npx tsx scripts/backfill-embeddings.ts [--dry-run]
Model Catalog
LLM model listings from configured providers (OpenRouter, zai) are fetched daily and cached in the memory database for the /model command.
How it works
- Daily sync: On startup and once per 24h via the controlplane heartbeat,
syncModelCatalog()fetches model lists from all configured providers - Diff detection: Compares against the previous catalog and reports new/removed/price-changed/free-tier-changed models to the ops chat
/modelcommand: Reads from the localmodel_catalogtable — no API calls at command time. Cascading keyboard: provider → sub-provider (OpenRouter) → model- Per-group override: Selected model is stored in
jail_configJSON on theregistered_groupstable.runJailAgent()usesinput.provider/modelwith fallback to globalPI_TUI_PROVIDER/PI_TUI_MODEL
Tables
| Table | Database | Purpose |
|---|---|---|
model_catalog |
memory DB | Current model catalog from all providers |
model_catalog_previous |
memory DB | Previous catalog for diff computation |
Config
No new env vars. Uses existing OPENROUTER_API_KEY and ZAI_API_KEY to determine which providers to query.
Files
| File | Role |
|---|---|
src/model-catalog.ts |
syncModelCatalog(), formatModelDiff(), catalog query helpers |
src/controlplane-db.ts |
Schema migration for model_catalog tables |
src/controlplane-heartbeat.ts |
Daily sync wired into heartbeat loop |
src/telegram-commands.ts |
/model command handler with cascading keyboards |
src/agent-runner.ts |
AgentInput.provider/model fields, fallback to globals |
src/types.ts |
JailConfig.provider/model fields |
Agentic Harness (FreeBSD Ops)
The control plane now pivots to a terminal-first harness (extensions + safety).
- The controlplane UI is agent-generated dashboards, not a standalone UI app.
- Operator access stays via the control plane API at
http://127.0.0.1:3100(or nginx TLS for remote access). - PF should allow
tailscale0ingress to3100(direct API) and443(nginx proxy) when testing from the tailnet.
Forgejo (Primary Git Remote)
Primary remote: code.smilepowered.org (self-hosted Forgejo, port 2222 SSH).
Codeberg is the public mirror; Forgejo is the source of truth for all agents.
| Repo | Purpose | Forgejo Remote |
|---|---|---|
Clawdie-AI |
Agent runtime, control plane, channels | git@code.smilepowered.org:clawdie/clawdie-ai.git |
clawdie-iso |
ISO builder, firstboot wizard, installer | git@code.smilepowered.org:clawdie/clawdie-iso.git |
Colibri |
Cross-platform Rust control plane core | git@code.smilepowered.org:clawdie/colibri.git |
Machine-User Permissions
Each agent host has its own Forgejo user + SSH key. No shared credentials.
| User | Host | Agent | Permissions |
|---|---|---|---|
hermes-debby |
debby | Hermes | write on clawdie-ai, clawdie-iso, colibri |
claude-domedog |
domedog | Claude | write on clawdie-ai, clawdie-iso, colibri |
codex-osa |
osa | Codex | write on clawdie-ai, clawdie-iso, colibri |
opus-smilepowered |
smilepowered | Opus | write on clawdie-ai, clawdie-iso, colibri |
- SSH keys: one per machine user, registered on Forgejo. Never copy private keys.
- Tokens: day-to-day git auth is SSH-key-only. No admin/user/org tokens for normal agent work.
- Bootstrap/admin token: retained briefly for stabilization after migration; delete within 1–2 days.
- Email: hermes@clawdie.si (hermes-debby), claude@clawdie.si (claude-domedog), codex@clawdie.si (codex-osa), opus@clawdie.si (opus-smilepowered).
- Secrets: operator-managed secrets live in Vaultwarden at
vault.smilepowered.org, collectionagent-secrets. - Branch protection: direct pushes to
mainare rejected on all three repos;clawdie-iso/xfce-operator-usbis also protected while live. Use PR branches. - Webhooks (future): push events → FreeBSD validation on osa.
When Changes Span Repos
- Make changes in the repo that owns the primary logic
- Create a handoff doc in THAT repo listing what needs updating in the others
- Push all affected repos
- Operator or other agent applies the cross-repo changes
Example Flow
Linux Claude/Hermes adds auth fields to clawdie-iso firstboot.sh
→ Pushes clawdie-iso
→ Creates handoff in Clawdie-AI: "doc/INSTALLER-AUTH-HANDOFF.md"
→ Lists: "shell-env.sh needs CLAUDE_CODE_OAUTH_TOKEN added"
→ FreeBSD 15 agent reads handoff, applies change, deletes doc
Install-Time Boundary
The ISO ↔ AI install-time boundary is currently documented in:
docs/internal/ISO-SETUP-IMPORT.mddocs/internal/ISO-FIRST-BOOT-IMPLEMENTATION.md
Use ISO-SETUP-IMPORT.md as the primary reference for the shell/import
bridge between ISO firstboot (POSIX sh, pre-Node) and the AI runtime
(TypeScript, post-deploy). Use ISO-FIRST-BOOT-IMPLEMENTATION.md for the
typed first-boot schema and installer-side behavior that the TypeScript
runtime actually enforces.
Do not introduce a separate parallel contract doc unless those sources are
first consolidated. Changes that affect setup.txt, system.env,
INSTALL_MODE, ZFS install defaults, or firstboot environment derivation
must be checked against both repos in the same session.
Agent Handoff Documents
Use ephemeral handoff files in doc/ to transfer context between agents
working on the same feature across sessions.
Convention
- Name:
doc/<FEATURE>-HANDOFF.md - Contents: Session summary, task checklist, open questions, test commands
- Lifecycle: Create when handing off work, delete when work is complete
- Deletion:
git rm doc/<FEATURE>-HANDOFF.mdafter all tasks confirmed
Required Structure
Every handoff doc MUST have:
# <Feature> Handoff
**From:** <agent identity> (<platform>)
**Date:** DD.mmm.YYYY
**Status:** IN-PROGRESS | COMPLETE
## Deletion Criteria
- [ ] <specific test command> passes
- [ ] <specific verification step>
## Tasks
- [ ] Task 1 — <description> — <file:line>
- [ ] Task 2 — <description> — <file:line>
- [x] Task 3 — <description> (done by <agent>)
## Results (fill in when done)
- Build: pass/fail
- Tests: N passed, M failed
- Bugs found: <list or "none">
## Open Questions
- Q1?
- Q2?
## Delete After
<git command to delete this file>
Completing a Handoff
The receiving agent:
- Reads this file top to bottom
- Checks off tasks with
- [x]as they complete them - Fills in the "Results" section
- If tasks remain incomplete, commits updated handoff doc with progress
- When ALL deletion criteria are met, deletes the file in the final commit
Rationale
- Git history preserves deleted files forever — no information is lost
- Ephemeral docs prevent stale context from accumulating
- Clear signal: if the file exists, work is incomplete; if deleted, it's done
- Checklist format shows progress at a glance
- Results section lets operator see what happened without reading git log
Applies To
- Multi-session features where one agent picks up where another left off
- Cross-repo work (e.g.,
clawdie-isochanges that requireClawdie-AIintegration) - Any task too large for a single session
Do Not Use For
- Permanent design decisions → use
doc/<TOPIC>.md(no HANDOFF suffix) - Session logs → use
docs/internal/sessions/DD.mmm.YYYY-<topic>.md - Temporary build artifacts → use
tmp/
Attribution in Commit History
Use attribution in commit messages, not in code comments.
Labels:
Sam & Codex— changes made by Sam and CodexSam & Claude— changes made by Sam and ClaudeC&C— joint change with equal credit for Claude and Codex
Add the label to the commit subject or body. Example:
Fix bhyve preflight checks (C&C)