clawdie-ai/AGENTS.md
Sam & Hermes 4e78408cec
Some checks failed
CI / ci (pull_request) Has been cancelled
docs: add opus-smilepowered to agent matrix (Sam & Hermes)
2026-05-29 14:43:06 +02:00

45 KiB
Raw Permalink Blame History

Agent Development Guidelines

Temporary File Storage

All temporary files and build artifacts must use <project-root>/tmp/ instead of system /tmp.

Rationale

  • System /tmp is 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 normal rm -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 Bash tool calls
  • All tmux send-keys invocations
  • 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 timestamps
  • src/ipc.ts - IPC message timestamps
  • src/task-scheduler.ts - Task scheduling timestamps
  • src/container-runner.ts - Container logs and file naming
  • skills-engine/*.ts - State tracking
  • setup/register.ts, setup/groups.ts - Registration timestamps
  • jail/agent-runner/src/ipc-mcp-stdio.ts - IPC timestamps
  • All test files using toISOString()
  • All skill modification files in .agent/skills/*/modify/
  1. Determine if date is user-facing (logs, transcripts, UI) or internal (storage, IPC)
  2. User-facing → shared helpers driven by onboarding locale/timezone
  3. Internal → ISO 8601 (toISOString())
  4. Never mix the two

Ansible File Naming

Use .yaml consistently for Ansible files in this repository.

Rationale

  • Ansible supports both .yml and .yaml
  • This repo standardizes on .yaml for 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 clawdie0 references 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_DOMAIN should default to <agent>.home.arpa.
  • Do not introduce new .local defaults for internal service names.
  • .local is reserved for mDNS and can create resolver ambiguity or name leakage on the local link.
  • AGENT_DOMAIN is operator-facing and should default to home.arpa for 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.2026
  • zroot/clawdie-runtime/jails/clawdie-worker@pre-update-10.mar.2026-1430
  • zroot/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, and PLATFORM_RUNTIME_USER are removed. Platform identity is two constants baked into code: service name clawdie, platform namespace system.
  • Service name (clawdie) ≠ platform namespace (system). The service user, rc.d service, and brand are clawdie (one of them). Shared platform DBs and resources use the system_* prefix (system_ops, system_brain, system_skills, system_git, system_web) so a tenant id that happens to equal clawdie cannot collide with platform resources.
  • Never derive infra names from ASSISTANT_NAME. Renaming the assistant must require zero infra change. If a code path turns ASSISTANT_NAME into a DB name, dataset path, jail name, service name, or UNIX user, that is a bug.
  • TENANT_ID is for additive tenants only. The root install is not a tenant; it is the platform. Do not seed TENANT_ID from 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.
  • system is a reserved host label and reserved tenant id. Validator rejects any tenant id that normalizes to system.
  • Ownership rule. Every resource must answer shared-platform or tenant:<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.md before 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-only to 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-config emits a warning and continues
  • --from <step> resumes from any step after a failure; --dry-run prints 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 start
  • html/docs-clawdie-si/ - canonical public documentation site
  • html/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 separate freebsd-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/*.html
  • html/clawdie/guides/*.html
  • html/clawdie/license.html
  • html/clawdie/changelog.html

Update Rule

When setup flow, runtime architecture, supported channels, or public URLs change:

  1. update README.md
  2. update the matching page in html/docs-clawdie-si/
  3. update any claims in html/clawdie/index.html
  4. 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_URL for the primary repo and add extras via GIT_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> (or git fetch --all when 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:

  1. Read the current state first
  2. Confirm the actual path, include model, and active configuration
  3. Only then write the minimal change needed

Applies To

  • sudoers and sudoers.d
  • sshd_config
  • authorized_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.00.9.0 (minor) ✓ run just release
0.8.01.0.0 (major) ✓ run just release
0.8.00.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: passjust build (tsc) exited 0
  • Build: FAIL — tsc had errors at commit time
  • Tests: pass — N passed (M files) — vitest summary line
  • Tests: 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 clippy once Rust is confirmed installed
  • Build Colibri from ~/ai/colibri and 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 test or npx 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.md files 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

  1. Trigger: isSessionOversize() in agent-runner.ts checks both byte size and estimated token count
  2. Rotate: A fresh session file is created (session-{timestamp}-{runId}.jsonl). The agent resumes on the new file immediately — no blocking.
  3. Compact (background): scheduleCompaction() runs compactSession() asynchronously on the old file:
    • Old entries (first ~70%) are separated from recent entries (last N turns)
    • A pi --print --no-session call generates a narrative summary (can use a separate model via AGENT_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() with topics: ['session-compaction'], importance: 4
    • The compacted file is written atomically (temp file → rename)
  4. 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 truncated output fields — 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
  • storeMemory failure 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:

  1. Zero-width / invisible characters stripped — sanitizeInboundText() removes U+200B-200F, 202A-202E, 2066-2069, FEFF, C0 controls, DEL
  2. Byte-level truncationtruncateUtf8ByBytes() enforces AGENT_MAX_INBOUND_BYTES (default 64KB) at UTF-8 character boundaries (never splits multi-byte sequences)
  3. 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\n paragraph boundaries first
  • Falls back to newline boundaries within paragraphs
  • Avoids splitting inside an HTML tag (best-effort; tags longer than maxLen are 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_KEY is 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

  1. Daily sync: On startup and once per 24h via the controlplane heartbeat, syncModelCatalog() fetches model lists from all configured providers
  2. Diff detection: Compares against the previous catalog and reports new/removed/price-changed/free-tier-changed models to the ops chat
  3. /model command: Reads from the local model_catalog table — no API calls at command time. Cascading keyboard: provider → sub-provider (OpenRouter) → model
  4. Per-group override: Selected model is stored in jail_config JSON on the registered_groups table. runJailAgent() uses input.provider/model with fallback to global PI_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 tailscale0 ingress to 3100 (direct API) and 443 (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 12 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, collection agent-secrets.
  • Branch protection: direct pushes to main are rejected on all three repos; clawdie-iso/xfce-operator-usb is also protected while live. Use PR branches.
  • Webhooks (future): push events → FreeBSD validation on osa.

When Changes Span Repos

  1. Make changes in the repo that owns the primary logic
  2. Create a handoff doc in THAT repo listing what needs updating in the others
  3. Push all affected repos
  4. 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.md
  • docs/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.md after 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:

  1. Reads this file top to bottom
  2. Checks off tasks with - [x] as they complete them
  3. Fills in the "Results" section
  4. If tasks remain incomplete, commits updated handoff doc with progress
  5. 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-iso changes that require Clawdie-AI integration)
  • 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 Codex
  • Sam & Claude — changes made by Sam and Claude
  • C&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)