Skip to content

[Feature] JSONL-backed session persistence with Store interface #711

@is-Xiaoen

Description

@is-Xiaoen

🎯 The Goal / Use Case

The current session system (pkg/session/manager.go) stores everything in an in-memory map and serializes full JSON files to disk on every Save(). This works for simple cases, but breaks down in a few real scenarios:

  1. Race condition with summarizationmaybeSummarize() launches a background goroutine that calls TruncateHistory() + Save() while new messages are still being appended. This can corrupt persisted history and cause 400 errors on the next LLM call (see Race condition in session history causes "tool_call_ids did not have response messages" (HTTP 400) #704).
  2. Full serialization is expensive — every Save() marshals the entire session to JSON and does a temp-file-rename dance. On embedded devices with limited I/O bandwidth, this adds latency that users can feel.
  3. No session lifecycle management — there's no way to expire old sessions, no size limits, no cleanup. On a long-running gateway, sessions/ just grows forever.
  4. No shared memory across agents — the multi-agent work (Feature: Base Multi-agent Collaboration Framework & Shared Context #294, WIP: feat: base multi-agent collaboration framework & shared context #423) will need some form of shared context pool. The current per-session JSON files can't support that.

I ran into the race condition issue (#704) myself while testing on a Pi, and when I dug into the session code I realized the problems go deeper than just adding a mutex.

💡 Proposed Solution

Replace the JSON file backend with SQLite, using a pure-Go driver (modernc.org/sqlite or crawshaw.io/sqlite) so we keep zero-CGo cross-compilation.

The idea is to keep SessionManager's public API mostly the same so nothing upstream breaks, but swap the storage layer underneath.

Rough schema I'm thinking:

CREATE TABLE sessions (
    key TEXT PRIMARY KEY,
    summary TEXT DEFAULT '',
    created_at DATETIME,
    updated_at DATETIME
);

CREATE TABLE messages (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    session_key TEXT NOT NULL REFERENCES sessions(key),
    role TEXT NOT NULL,
    content TEXT,
    tool_calls TEXT,      -- JSON, nullable
    tool_call_id TEXT,    -- nullable
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_messages_session ON messages(session_key, id);

Key benefits:

  • AddMessage becomes an INSERT instead of full-file rewrite
  • TruncateHistory + Save can run in a transaction — no more race condition
  • Session expiry is just DELETE FROM sessions WHERE updated_at < ?
  • Later, a memory table can hold long-term facts for multi-agent shared context

🛠 Potential Implementation

I'd break this into a few PRs:

PR 1: New pkg/memory/ package with a Store interface + SQLite implementation + tests. Doesn't touch anything existing yet.

PR 2: Wire it into AgentLoop — replace SessionManager.Save() calls with the new store. Keep the JSON path as fallback (auto-detect: if sessions/*.json exists, migrate on first load).

PR 3: Add lifecycle management — TTL-based session expiry, configurable max message count per session, /session stats command or similar.

I want to keep the interface clean enough that someone could later swap in a different backend (Postgres for a server deployment, etc.) without touching agent code.

🚦 Impact & Roadmap Alignment

  • This is a Core Feature
  • This is a Nice-to-Have / Enhancement
  • This aligns with the current Roadmap

This was specifically mentioned in the 26M2W3 community meeting notes under the Agent board: "记忆系统:引入 SQLite". It also directly addresses #704 (race condition) and provides the foundation for #294 (multi-agent shared context).

🔄 Alternatives Considered

  • Just fix the race condition with a mutex: Solves Race condition in session history causes "tool_call_ids did not have response messages" (HTTP 400) #704 but doesn't address the full-serialization cost or lifecycle management. Band-aid.
  • bbolt / badger (embedded KV stores): More overhead than SQLite for structured data. We need relational queries (messages by session, ordered by time) and SQLite is purpose-built for this.
  • Keep JSON but write incrementally (append-only log): Considered this. It handles the write performance, but makes truncation/expiry complicated and doesn't help with the shared memory use case.

💬 Additional Context

I've already had two PRs merged (#173, #186) and I'm familiar with the session/agent code paths. Happy to take this on if the direction looks right.

Would appreciate feedback from the agent board maintainers on:

  1. Is modernc.org/sqlite acceptable, or is there a preference for another driver? (It adds ~3-4MB to the binary — need to check if that fits the 20M target from the meeting notes)
  2. Should the migration from JSON be automatic, or a separate picoclaw migrate subcommand?
  3. Any concerns about SQLite on the target embedded platforms?

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions