docs/architecture/session-system.md
Back to README
This document describes the runtime session system used by PicoClaw to:
agent:... session keys while the runtime uses opaque canonical keysThis document covers the core runtime path in pkg/session, pkg/memory, and pkg/agent.
It does not describe launcher login cookies or dashboard authentication sessions in web/backend/middleware.
The session system has four jobs:
SessionStore interface to the agent loop.| Layer | Files | Responsibility |
|---|---|---|
| Session contract | pkg/session/session_store.go | Defines the SessionStore interface used by the agent loop. |
| Legacy backend | pkg/session/manager.go | Stores one JSON file per session. Still used as a fallback. |
| Session adapter | pkg/session/jsonl_backend.go | Adapts pkg/memory.Store to SessionStore, including alias and scope metadata support. |
| Durable storage | pkg/memory/jsonl.go | Append-only JSONL storage plus .meta.json sidecar metadata. |
| Scope and key building | pkg/session/scope.go, pkg/session/key.go, pkg/session/allocator.go | Builds structured scopes, opaque canonical keys, and legacy aliases from routing results. |
| Runtime integration | pkg/agent/instance.go, pkg/agent/agent.go, pkg/agent/agent_message.go | Initializes the store, allocates session scope, and persists metadata before turns run. |
The structured session identity is represented by session.SessionScope:
| Field | Meaning |
|---|---|
Version | Schema version. Current value is ScopeVersionV1. |
AgentID | Routed agent handling the turn. |
Channel | Normalized inbound channel name. |
Account | Normalized account or bot identifier. |
Dimensions | Ordered list of active partition dimensions such as chat or sender. |
Values | Concrete normalized values for each selected dimension. |
Only four dimensions are currently recognized by the allocator:
spacechattopicsenderThe default config uses:
{
"session": {
"dimensions": ["chat"]
}
}
That means one shared conversation per chat unless a dispatch rule overrides it.
The runtime now prefers opaque canonical keys:
sk_v1_<sha256>
These keys are built from a canonical scope signature in pkg/session/key.go.
The goal is to make storage keys stable while decoupling them from any specific legacy text format.
For compatibility, the allocator also emits legacy aliases such as:
agent:main:direct:user123
agent:main:slack:channel:c001
agent:main:pico:direct:pico:session-123
These aliases matter because older sessions, tests, and some tools still refer to the legacy shape. The JSONL backend resolves aliases back to the canonical key before reads and writes.
The agent loop also preserves explicit incoming session keys when the caller already supplied one of the recognized explicit formats:
agent:... keyThat behavior lives in pkg/agent/agent_utils.go:resolveScopeKey.
The end-to-end flow for a normal inbound message is:
InboundMessage
-> RouteResolver.ResolveRoute(...)
-> session.AllocateRouteSession(...)
-> resolveScopeKey(...)
-> ensureSessionMetadata(...)
-> AgentLoop turn execution
-> SessionStore read/write operations
More concretely:
pkg/agent/agent_message.go resolves the agent route from normalized inbound context.session.AllocateRouteSession converts the route's SessionPolicy plus inbound context into a structured SessionScope.SessionKey: canonical routed session keySessionAliases: compatibility aliases for that routed scopeMainSessionKey: agent-level main session keyMainAliases: legacy alias for the main sessionrunAgentLoop persists scope metadata and aliases through ensureSessionMetadata.JSONLBackend.ResolveSessionKey maps aliases back onto the canonical key.The main session key is separate from routed chat sessions.
It is mainly used for agent-level or system-style flows that need one stable per-agent conversation, for example processSystemMessage.
pkg/session/allocator.go builds scope values from normalized inbound context.
Important rules:
space becomes <space_type>:<space_id>chat becomes <chat_type>:<chat_id>topic becomes topic:<topic_id>sender is canonicalized through session.identity_links before being storedThere are two special cases worth calling out.
Telegram forum topics must stay isolated even when the configured dimensions only mention chat.
To preserve that behavior, the allocator appends /<topic_id> to the chat value for Telegram forum messages unless topic is already an explicit dimension.
Example:
group:-1001234567890/42
group:-1001234567890/99
Those produce different session keys.
session.identity_links lets multiple sender identifiers collapse into one canonical identity.
Both dispatch matching and session allocation use that mapping so that the same person can keep one conversation even if their raw sender IDs differ across channels or accounts.
The default runtime backend is pkg/memory.JSONLStore, wrapped by session.JSONLBackend.
Each session uses two files:
{sanitized_key}.jsonl
{sanitized_key}.meta.json
The files store:
.jsonl: one providers.Message per line, append-only.meta.json: summary, timestamps, line counts, logical truncation offset, scope, aliasesSessionMeta currently includes:
KeySummarySkipCountCreatedAtUpdatedAtScopeAliasesThe JSONL store is designed around append-first durability and stale-over-loss recovery:
AddMessage and AddFullMessage append one JSON line, fsync, then update metadata.TruncateHistory is logical first: it only advances meta.Skip.Compact physically rewrites the JSONL file to remove skipped lines.SetHistory and Compact write metadata before rewriting JSONL so a crash may temporarily expose old data, but should not lose data.JSONLBackend.Save maps onto store.Compact(...).
In other words, Save is no longer "flush dirty memory to disk"; it is now "reclaim dead lines after logical truncation".
pkg/memory.JSONLStore uses a fixed 64-shard mutex array keyed by session hash.
That gives per-session serialization without keeping an unbounded mutex map in memory.
The legacy SessionManager uses a single in-memory map guarded by an RW mutex.
Both backends satisfy the same SessionStore interface, which is why the agent loop does not need storage-specific code.
pkg/agent/instance.go:initSessionStore prefers the JSONL backend.
Startup sequence:
memory.NewJSONLStore(dir).memory.MigrateFromJSON(...) to import legacy .json sessions.session.NewJSONLBackend(store).session.NewSessionManager(dir).This fallback is intentional: a partial migration would be worse than staying on the legacy store for one run.
When canonical metadata is first created, EnsureSessionMetadata may promote history from a non-empty legacy alias into the canonical session.
That promotion only happens when the canonical session is still empty, so active canonical history is not overwritten.
This is how the system preserves old histories such as:
while moving the runtime onto opaque canonical keys.
pkg/agent/subturn.go defines an ephemeralSessionStore.
It satisfies the same SessionStore interface, but keeps data in memory only and is destroyed when the sub-turn ends.
That lets SubTurn reuse the same session-facing APIs without writing child-session history into the parent's durable storage.
The session system is consumed by more than the agent loop:
web/backend/api/session.go reads JSONL metadata and legacy JSON sessions to expose session history in the launcher UI.pkg/agent/steering.go can recover scope metadata for active steering flows.pkg/session/session_store.gopkg/session/manager.gopkg/session/jsonl_backend.gopkg/session/scope.gopkg/session/key.gopkg/session/allocator.gopkg/memory/jsonl.gopkg/agent/instance.gopkg/agent/agent.gopkg/agent/agent_message.go