9.2 KiB
Session System
Back to README
This document describes the runtime session system used by PicoClaw to:
- map inbound messages onto stable conversation scopes
- persist message history and summaries
- preserve compatibility with legacy
agent:...session keys while the runtime uses opaque canonical keys
This 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.
Responsibilities
The session system has four jobs:
- Decide which messages should share the same conversation context.
- Persist that context durably across turns and restarts.
- Expose a small
SessionStoreinterface to the agent loop. - Keep older session-key formats working during storage and routing migrations.
Main Components
| 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/loop.go, pkg/agent/loop_message.go |
Initializes the store, allocates session scope, and persists metadata before turns run. |
Session Data Model
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:
spacechattopicsender
The default config uses:
{
"session": {
"dimensions": ["chat"]
}
}
That means one shared conversation per chat unless a dispatch rule overrides it.
Canonical Keys And Legacy Aliases
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:
- opaque canonical key
- legacy
agent:...key
That behavior lives in pkg/agent/loop_utils.go:resolveScopeKey.
Allocation Flow
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/loop_message.goresolves the agent route from normalized inbound context.session.AllocateRouteSessionconverts the route'sSessionPolicyplus inbound context into a structuredSessionScope.- The allocator builds:
SessionKey: canonical routed session keySessionAliases: compatibility aliases for that routed scopeMainSessionKey: agent-level main session keyMainAliases: legacy alias for the main session
runAgentLooppersists scope metadata and aliases throughensureSessionMetadata.- During later reads or writes,
JSONLBackend.ResolveSessionKeymaps 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.
Scope Construction Rules
pkg/session/allocator.go builds scope values from normalized inbound context.
Important rules:
spacebecomes<space_type>:<space_id>chatbecomes<chat_type>:<chat_id>topicbecomestopic:<topic_id>senderis canonicalized throughsession.identity_linksbefore being stored
There are two special cases worth calling out.
Telegram forum isolation
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.
Identity links
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.
Storage Format
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: oneproviders.Messageper line, append-only.meta.json: summary, timestamps, line counts, logical truncation offset, scope, aliases
SessionMeta currently includes:
KeySummarySkipCountCreatedAtUpdatedAtScopeAliases
Write And Crash Semantics
The JSONL store is designed around append-first durability and stale-over-loss recovery:
AddMessageandAddFullMessageappend one JSON line,fsync, then update metadata.TruncateHistoryis logical first: it only advancesmeta.Skip.Compactphysically rewrites the JSONL file to remove skipped lines.SetHistoryandCompactwrite metadata before rewriting JSONL so a crash may temporarily expose old data, but should not lose data.- Corrupt JSONL lines are skipped during reads instead of failing the entire session.
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".
Concurrency Model
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.
Compatibility And Migration
pkg/agent/instance.go:initSessionStore prefers the JSONL backend.
Startup sequence:
- Create
memory.NewJSONLStore(dir). - Run
memory.MigrateFromJSON(...)to import legacy.jsonsessions. - Wrap the store with
session.NewJSONLBackend(store). - If JSONL initialization or migration fails, fall back to
session.NewSessionManager(dir).
This fallback is intentional: a partial migration would be worse than staying on the legacy store for one run.
Alias promotion
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:
- legacy direct-message keys
- older Pico direct-session keys
while moving the runtime onto opaque canonical keys.
Other SessionStore Implementations
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.
Operational Consumers
The session system is consumed by more than the agent loop:
web/backend/api/session.goreads JSONL metadata and legacy JSON sessions to expose session history in the launcher UI.pkg/agent/steering.gocan recover scope metadata for active steering flows.- tooling and tests can still refer to legacy aliases because alias resolution is handled below the agent loop.
Related Files
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/loop.gopkg/agent/loop_message.go