* refactor(sessions): migrate runtime storage to sqlite * test(sessions): fix sqlite CI regressions * test(sessions): align remaining sqlite fixtures * fix(codex): require sqlite trajectory recorder * test(sessions): align orphan recovery sqlite fixture * test(sessions): align sqlite rebase fixtures * fix(sessions): finish current-main integration of the sqlite flip Resolve the whole-store SDK removal across its owner boundary: drop the loadSessionStore re-export and the registry whole-store wrappers, wire hasTrackedActiveSessionRun into gateway chat, complete the preserveLockedHarnessIds cleanup contract, flip the codex thread-history import to storePath targets, and port remaining main-side tests from file-store helpers to session accessor reads. * chore: drop committed pebbles log, revert plugin-inspector bump, refresh generated docs Remove the 1.8k-line .pebbles/events.jsonl work log from the branch, restore the plugin-inspector advisory lane to main's pinned 0.3.10 so the supply-chain bump gets its own review, and regenerate docs_map, the plugin SDK API baseline, and the export-surface ratchet for the merged tree. * feat(sessions): keep archived transcripts by default with zstd cold storage Codex-style retention: deleting or resetting a session archives its transcript as a zstd-compressed JSONL artifact (plain when the runtime lacks node:zlib zstd) and keeps it until the disk budget evicts oldest first. resetArchiveRetention now governs both deleted and reset archives and defaults to keep; maxDiskBytes defaults to 2gb so retention stays bounded, with archives evicted before live sessions. The cron reaper follows the same knob instead of deleting archives on its own timer. * fix(state): converge agent DB migration lineages and bound database growth Merge coherence: run both structure-gated legacy memory-schema repairs (flip-lineage drop, main-lineage identity rebuild) before the flip migration so pre-flip v1/v2 and pre-merge flip v1/v4 databases all converge, and hoist foreign_keys=OFF outside the schema transaction where the pragma was silently ignored and the v1 sessions rebuild cascade-deleted session_entries. Growth guards: fresh agent DBs enable auto_vacuum=INCREMENTAL, WAL maintenance releases freed pages in bounded passes (never a blocking full VACUUM), and doctor reports state/agent DB bloat from freelist stats. * fix(codex): resolve the store path for thread-history import via the SDK The supervision catalog passed the legacy sessionFile locator to the storePath-targeted transcript mirror; resolve the agent store path with the session-store SDK helper instead of a runtime-object seam so test fakes and headless callers need no extra surface. Drop the obsolete missing-session-id preprocessing case: sessions rows are NOT NULL on session_id and upsert repairs id-less patches at write time. * fix(sessions): fail safe on malformed disk-budget config and doctor stat errors A malformed explicit maxDiskBytes disables the budget instead of falling back to the destructive 2gb default the user never chose, and the doctor bloat check skips databases whose paths stat-fail instead of aborting doctor. * fix(sessions): complete sqlite conflict translations * test(sqlite): align hardening checks with maintenance * test(sessions): inspect compressed transcript archives * fix(tests): await session seeds and drop unused helpers flagged by CI lint The five unawaited writeSessionStoreSeed calls raced their SQLite seeds against the assertions, failing compact shards; the bloat probe drops a useless initializer and the merged tests drop now-unused helpers. * test(sessions): type legacy proof events directly * test(sessions): align hardening contracts * perf(sessions): read usage transcript sizes from SQL aggregates Usage/cost scans walked every session and materialized every transcript event just to re-stringify it for a byte estimate — the #86718 stall class reborn on the DB. readTranscriptStatsSync sums stored JSON bytes in SQLite without loading a single row. * fix(sessions): re-root foreign-root transcript paths onto the current sessions dir Restored backups, moved OPENCLAW_STATE_DIR, and rehearsal copies carry absolute sessionFile paths from the old root; the containment fallback kept those foreign paths, so migration read (and would archive) files in the original root and reported local copies missing. Re-root the canonical agents/<id>/sessions suffix onto the current dir when the file exists there; genuine cross-root layouts still fall through unchanged. * test(agents): seed harness admission through sqlite * fix(sqlite): close agent db on pragma setup failure * fix(doctor): compact and retrofit incremental auto-vacuum after session import The migration is the sanctioned offline window: post-import compact reclaims import churn and applies auto_vacuum=INCREMENTAL to databases created before the fresh-DB pragma existed, so runtime maintenance can release pages in bounded passes on every install. --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
8.1 KiB
summary, read_when, title
| summary | read_when | title | |||
|---|---|---|---|---|---|
| How OpenClaw manages conversation sessions |
|
Session management |
OpenClaw routes every inbound message to a session based on where it came from: DMs, group chats, cron jobs, etc. All session state is owned by the gateway; UI clients query the gateway for session data.
How messages are routed
| Source | Behavior |
|---|---|
| Direct messages | Shared session by default |
| Group chats | Isolated per group |
| Rooms/channels | Isolated per room |
| Cron jobs | Fresh session per run |
| Webhooks | Isolated per hook |
DM isolation
By default, all DMs share one session for continuity, which is fine for single-user setups.
If multiple people can message your agent, enable DM isolation. Without it, all users share the same conversation context, so Alice's private messages would be visible to Bob.{
session: {
dmScope: "per-channel-peer", // isolate by channel + sender
},
}
session.dmScope options:
| Value | Behavior |
|---|---|
main (default) |
All DMs share one session |
per-peer |
Isolate by sender, across channels |
per-channel-peer |
Isolate by channel + sender (recommended) |
per-account-channel-peer |
Isolate by account + channel + sender |
Dock linked channels
Dock commands move the current direct-chat session's reply route to another linked channel without starting a new session. See Channel docking for examples, config, and troubleshooting.
Verify your setup with openclaw security audit.
Session lifecycle
Sessions are reused until they expire under session.reset:
- Daily reset (default
mode: "daily") - new session at a configured local hour (session.reset.atHour, default4, 0-23) on the gateway host. Daily freshness is based on when the currentsessionIdstarted, not on later metadata writes. - Idle reset (
mode: "idle") - new session aftersession.reset.idleMinutesof inactivity. Idle freshness is based on the last real user/channel interaction, so heartbeat, cron, and exec system events do not keep the session alive. - Manual reset - type
/newor/resetin chat./new <model>also switches the model.
When both daily and idle resets are configured, whichever expires first wins. Heartbeat, cron, exec, and other system-event turns may write session metadata, but those writes do not extend daily or idle reset freshness. When a reset rolls the session, queued system-event notices for the old session are discarded so stale background updates are not prepended to the first prompt in the new session.
Sessions with an active provider-owned CLI session are not cut by the implicit
daily default. Use /reset or configure session.reset explicitly when those
sessions should expire on a timer.
Override the default per chat type or per channel:
{
session: {
reset: { mode: "daily", atHour: 4 },
resetByType: {
group: { mode: "idle", idleMinutes: 120 },
thread: { mode: "daily", atHour: 6 },
},
resetByChannel: {
discord: { mode: "idle", idleMinutes: 10080 },
},
},
}
resetByType supports direct (legacy alias dm), group, and thread.
Legacy top-level session.idleMinutes still works as a compatibility alias for
an idle-mode default when no session.reset/resetByType block is set.
Where state lives
- Runtime session rows:
~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite - Archived transcript files:
~/.openclaw/agents/<agentId>/sessions/ - Legacy row migration source:
~/.openclaw/agents/<agentId>/sessions/sessions.json
The session rows in the per-agent SQLite database keep separate lifecycle timestamps:
sessionStartedAt: when the currentsessionIdbegan; daily reset uses this.lastInteractionAt: last user/channel interaction that extends idle lifetime.updatedAt: last store-row mutation; useful for listing and pruning, but not authoritative for daily/idle reset freshness.
During migration from older installs, gateway startup and openclaw doctor --fix import legacy sessions.json rows and hot transcript JSONL history into
SQLite automatically. Rows without sessionStartedAt are resolved from the
legacy transcript JSONL session header when available. If an older row also
lacks lastInteractionAt, idle freshness falls back to that session start time,
not to later bookkeeping writes. Use openclaw doctor --session-sqlite inspect --session-sqlite-all-agents and the Doctor migration
sequence when you want explicit
inspection or validation evidence.
Session maintenance
OpenClaw bounds session storage over time via session.maintenance, defaults
shown:
{
session: {
maintenance: {
mode: "enforce", // "enforce" applies cleanup; "warn" only reports
pruneAfter: "30d",
maxEntries: 500,
},
},
}
For production-sized maxEntries limits, Gateway runtime writes use a small
high-water buffer and clean back down to the configured cap in batches.
Session store reads do not prune or cap entries during Gateway startup, so
startup and isolated cron sessions do not pay for a full store cleanup.
openclaw sessions cleanup --enforce applies the cap immediately.
Gateway model-run probe sessions are short-lived by default. Rows matching
agent:*:explicit:model-run-<uuid> use fixed 24h retention, but cleanup is
pressure-gated: it only removes stale probe rows when session-entry
maintenance/cap pressure is reached, and runs before the broader stale-entry
age cutoff and entry cap. Normal direct, group, thread, cron, hook, heartbeat,
ACP, and sub-agent sessions do not inherit this 24h retention.
Maintenance preserves durable external conversation pointers, including group sessions and thread-scoped chat sessions, while still allowing synthetic cron, hook, heartbeat, ACP, and sub-agent entries to age out.
If you previously used DM isolation and later returned session.dmScope to
main, preview stale peer-keyed DM rows with
openclaw sessions cleanup --dry-run --fix-dm-scope. Applying the same flag
retires those old direct-DM rows and keeps their transcripts as deleted
archives.
Preview any maintenance run with openclaw sessions cleanup --dry-run.
Inspecting sessions
| Command | Shows |
|---|---|
openclaw status |
Session store path and recent activity |
openclaw sessions --json |
All sessions (filter with --active <minutes>) |
/status in chat |
Context usage, model, and toggles |
/context list |
What is in the system prompt |
Further reading
- Session Pruning - trimming tool results
- Compaction - summarizing long conversations
- Session Tools - agent tools for cross-session work
- Session Management Deep Dive - store schema, transcripts, send policy, origin metadata, and advanced config
- Multi-Agent - routing and session isolation across agents
- Background Tasks - how detached work creates task records with session references
- Channel Routing - how inbound messages are routed to sessions