Skip to content

Conversation History

Sia edited this page May 31, 2026 · 6 revisions

Conversation History

Permanent persistence of every Claude console turn, stored in PostgreSQL. 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),           -- 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)
);

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

Sub-agent column

Each (projectId, agentName) in the sub-agent process pool gets its own Claude child process. The agent_name column lets SubAgentSessionManager persist turns alongside the main console:

  • agent_name IS NULL — main project console.
  • 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 show both main + sub-agent turns, with an agent= filter (see "Agent filter" below).

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 tsvector match — see "Search" below
agent * / reviewer / omit sub-agent filter — see "Agent filter" 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

Memo + star

Each turn gets two extra columns:

  • user_memo (text, nullable) — free-form note, 8000-char cap.
  • starred (bool, default false) — bookmark toggle.

UI (/history):

  • ☆/★ button below the timestamp toggles starred. Click triggers POST /api/projects/{id}/history/{turnId}/star?starred=true|false (CSRF via ?_csrf=).
  • "+ 메모" placeholder under the content opens a prompt() editor; saving fires POST /api/projects/{id}/history/{turnId}/memo with {"memo":"…"}. Empty body removes the memo.
  • Filter form gains a "★ starred 만" checkbox; URL persists as ?starred=1.

Search

content_tsv is a PG-12 GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED column, indexed with GIN. Filter.q matches via content_tsv @@ plainto_tsquery('simple', ?) (parameter binding, no SQL injection risk). Sub-millisecond on hundreds of thousands of rows per EXPLAIN ANALYZE.

Non-ASCII queries (Korean / Japanese / Chinese / emoji) auto-route through a pg_trgm GIN trigram index on content. The limiter is Filter.q.isAsciiOnly() — ASCII queries stay on the tsvector path (whole-word matching, fastest); non-ASCII queries go through indexed content ILIKE %q% with %/_/\ escaped. Same parameter binding.

role = "usage" rows record ClaudeEvent.UsageReport content as {"input":…,"output":…,"cacheRead":…,"cacheCreate":…}. ConversationTurnRepository.usageSummary(projectId) aggregates across turns and powers the /usage cache stats card.

-- What the engine ends up running for ?q=assembleDebug
SELECT id, role, ts, content
FROM conversation_turns
WHERE project_id = ?
  AND content_tsv @@ plainto_tsquery('simple', 'assembleDebug')
ORDER BY ts ASC LIMIT 100;

Match semanticssimple tokenizer is language-agnostic: no stemming, no lemmatization, no morphological analysis. Tokens split on whitespace + punctuation. Pros:

  • Korean / Japanese / Chinese text still tokenizes reasonably.
  • Predictable matching (no surprise stem reductions).

Cons:

  • No substring match. "develop" won't match "developer". If you need it, add a :* suffix via to_tsquery (a future option).
  • No Korean morpheme awareness. "개발자""개발자가". For rich Korean search install mecab-ko and re-index with a custom configuration.

Migration — the column + index are added via IF NOT EXISTS raw SQL in Database.init() on every boot. Existing rows are backfilled automatically (PG fills the STORED value when the column appears).

Agent filter

Sub-agent turns get agent_name set; main-console turns have agent_name IS NULL. Filter.agentName is 3-mode:

Filter.agentName URL agent= Behaviour
null (default) omitted main console only (IS NULL)
"" * no filter — main + every sub-agent
"reviewer" reviewer only @reviewer turns

The /history page exposes the same as a dropdown ("(main only)" / "(all)" / "@<name>") populated by ConversationTurnRepository.distinctAgents(projectId). Rows show a small @<agent> badge so the source is visible at a glance, and pagination preserves the choice.

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

Retention is not automatic. 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 PostgreSQL

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 also build on PG.

Search, export, archive

On top of per-project filtering:

  • Cross-project search. /history (no project id) walks every project's conversation_turns and highlights matches. See Conversation Search & Archive.
  • Export / import. GET /projects/{id}/history/export + POST .../history/import for hand-offs between hosts. JSON envelope (schemaVersion: 1), sessionId-level idempotency.
  • 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