Skip to content

Conversation History

wody edited this page May 24, 2026 · 6 revisions

Conversation History

Permanent persistence of every Claude console turn — introduced in v0.16.0 on top of the v0.14.0 PostgreSQL transition. View at /projects/{id}/history (per-project) or /chat/history (General Chat).

What gets persisted

Role Source Notes
user ClaudeSessionManager.sendPrompt(...) The text the user sent.
assistant ClaudeStreamParserAssistantMessage(isPartial=false) Streaming partials are skipped — only the finalized turn text is saved.
tool_use ClaudeStreamParserToolUse content = stream-json input (JSON). toolName + toolUseId recorded for matching.
tool_result ClaudeStreamParserToolResult (ok) content = stream-json output.
tool_result_error ClaudeStreamParserToolResult(isError=true) Same shape, separate role for filtering.
system ClaudeEvent.SessionStarted / Done / emitSystem(...) content = small JSON envelope {kind,...}.
error ClaudeStreamParserErrorEvent content = {code,message} JSON.
unknown ClaudeStreamParserUnknown Raw stream-json line preserved in content + raw.

Persistence is fire-and-forget — a DB write failure logs a warning but never breaks the live console stream.

Schema

CREATE TABLE conversation_turns (
    id             VARCHAR(64)  PRIMARY KEY,
    project_id     VARCHAR(64)  NOT NULL,
    session_id     VARCHAR(64),           -- nullable (first user prompt arrives before system/init)
    turn_idx       INTEGER      NOT NULL, -- monotonic within (project, session)
    ts             VARCHAR(64)  NOT NULL, -- ISO-8601 UTC
    role           VARCHAR(16)  NOT NULL,
    content        TEXT         NOT NULL, -- text or JSON-encoded payload
    tool_name      VARCHAR(64),
    tool_use_id    VARCHAR(128),          -- match tool_use ↔ tool_result
    tokens_in      INTEGER,
    tokens_out     INTEGER,
    raw            TEXT,
    agent_name     VARCHAR(64),           -- v0.49.0+ — null = main console; non-null = sub-agent
    INDEX (project_id, ts),
    INDEX (project_id, session_id, turn_idx),
    INDEX (tool_use_id),
    INDEX (project_id, agent_name, ts)   -- v0.49.0+
);

PG TEXT is unlimited (up to 1 GB per cell). Even verbose Read outputs of hundreds of KB fit without trouble.

Sub-agent column (v0.49.0+)

When Phase 23 (v0.44.0) introduced the sub-agent process pool, each (projectId, agentName) got its own Claude child process — but the turns lived only in the in-memory LogHub ring. v0.49.0 adds the agent_name column so SubAgentSessionManager persists turns alongside the main console:

  • agent_name IS NULL — main project console (default for every row inserted before v0.49.0).
  • agent_name = '<name>' — sub-agent turn. The (projectId, agentName) pair is the natural conversation scope. The same session_id per agent (separate from the main console's) keeps turn_idx monotonic per agent.

History pages currently show both main + sub-agent turns mixed together — a agentName= filter UI is on the roadmap.

Filters (UI)

/projects/{id}/history and /chat/history expose 6 filter knobs as GET query parameters. Combine freely:

Param Example Notes
session f1d4e0c2-… dropdown populated from distinct sessionIds for the project
role tool_use one of: user / assistant / tool_use / tool_result / tool_result_error / system / error / unknown
tool Bash exact match on tool_name
from 2026-05-24T00:00:00Z ISO ts ≥
to 2026-05-25T00:00:00Z ISO ts ≤
q assembleDebug content LIKE %q% — see "Search" below

Pagination: 100 rows per page, ?p=<index> (0-based), oldest-first within the page.

URL recipes

/projects/my-app/history?role=tool_use&tool=Bash
                                              # what shell commands did Claude run?

/projects/my-app/history?role=assistant
                                              # all of Claude's textual replies

/projects/my-app/history?q=NullPointerException
                                              # turns mentioning a specific crash

/chat/history?role=user
                                              # everything I've asked General Chat

/projects/my-app/history?from=2026-05-24T00:00:00Z&to=2026-05-24T23:59:59Z
                                              # one calendar day

Search (v0.16.0 limitation)

Content search uses LIKE '%query%' on the content column — fine for small projects but linear scan as the table grows. Patterns are escaped (%, _, \ are quoted), so users can't inject wildcards or break the query.

Planned for the next minor: tsvector GIN index on content (PostgreSQL full-text search) for sub-second matches over millions of rows.

Direct SQL access

docker exec -it vibe-coder-postgres psql -U vibecoder -d vibecoder

vibecoder=> \d conversation_turns
vibecoder=> SELECT role, count(*) FROM conversation_turns
            WHERE project_id = 'my-app' GROUP BY 1 ORDER BY 2 DESC;

vibecoder=> SELECT ts, role, left(content, 80)
            FROM conversation_turns
            WHERE project_id = 'my-app' AND tool_name = 'Edit'
            ORDER BY ts DESC LIMIT 20;

Rotation / retention

Not automatic in v0.16.0. The table grows by (turns per session) × (sessions). Estimate: a heavy 8-hour day of one project might log ~500 turns → ~5 MB. A whole year ~2 GB — well within the comfortable range for the bind-mounted vibe-coder-data/postgres/ directory.

When you do want to prune:

DELETE FROM conversation_turns WHERE ts < (now() - interval '180 days')::text;
VACUUM ANALYZE conversation_turns;

Or via a per-project cleanup:

DELETE FROM conversation_turns
WHERE project_id = 'old-experiment-app';

ProjectService.delete cascades to this table automatically when a project is removed via UI.

Privacy

  • No request bodies are recorded — only the turn payloads (which the user already sees in the console).
  • Secrets (PATs, passwords) that a user pastes into a prompt will be saved as-is — be mindful when chatting about credentials. Same caveat as Slack/Discord/etc. The whole content is the user's own data.
  • No PII obfuscation — the database belongs to the operator; they can PG DELETE or UPDATE rows directly.

Why this needed the PG transition

SQLite is single-writer. With both the live console stream and full-text search over years of history hitting the same DB, lock contention would make the console feel laggy. PG's row-level locking + multi-connection pool makes both ops trivial.

JSONB columns and tsvector + GIN (future) also need PG — SQLite's JSON1 and FTS5 are usable but the future path is more straightforward on PG.

See the Upgrade Guide v0.13 → v0.14 section for the migration story.

Search, export, archive

The original v0.16.0 page provides per-project filtering. Three follow-ups extend this:

  • v0.30.0 — Cross-project search. /history (no project id) walks every project's conversation_turns and highlights matches. See Conversation Search & Archive.
  • v0.31.0 — Export / import. GET /projects/{id}/history/export + POST .../history/import for hand-offs between hosts. JSON envelope (schemaVersion: 1), sessionId-level idempotency.
  • v0.33.0 — Auto-archive. ConversationArchiver dumps sessions older than 30 days to <workspace>/.vibecoder/<projectId>/archive/session-<sid>.json and deletes the rows. Idempotent re-run safe.

The archive JSON uses the same envelope as manual export, so restoring an old session is just 📤 가져오기 on the same page.

Clone this wiki locally