-
Notifications
You must be signed in to change notification settings - Fork 1
Conversation History
Permanent persistence of every Claude console turn, stored in PostgreSQL. View at
/projects/{id}/history (per-project) or /chat/history (General Chat).
| Role | Source | Notes |
|---|---|---|
user |
ClaudeSessionManager.sendPrompt(...) |
The text the user sent. |
assistant |
ClaudeStreamParser → AssistantMessage(isPartial=false)
|
Streaming partials are skipped — only the finalized turn text is saved. |
tool_use |
ClaudeStreamParser → ToolUse
|
content = stream-json input (JSON). toolName + toolUseId recorded for matching. |
tool_result |
ClaudeStreamParser → ToolResult (ok) |
content = stream-json output. |
tool_result_error |
ClaudeStreamParser → ToolResult(isError=true)
|
Same shape, separate role for filtering. |
system |
ClaudeEvent.SessionStarted / Done / emitSystem(...)
|
content = small JSON envelope {kind,...}. |
error |
ClaudeStreamParser → ErrorEvent
|
content = {code,message} JSON. |
unknown |
ClaudeStreamParser → Unknown
|
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.
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.
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 samesession_idper agent (separate from the main console's) keepsturn_idxmonotonic per agent.
History pages show both main + sub-agent turns, with an agent=
filter (see "Agent filter" below).
/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.
/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
Each turn gets two extra columns:
-
user_memo(text, nullable) — free-form note, 8000-char cap. -
starred(bool, defaultfalse) — bookmark toggle.
UI (/history):
- ☆/★ button below the timestamp toggles
starred. Click triggersPOST /api/projects/{id}/history/{turnId}/star?starred=true|false(CSRF via?_csrf=). - "+ 메모" placeholder under the content opens a
prompt()editor; saving firesPOST /api/projects/{id}/history/{turnId}/memowith{"memo":"…"}. Empty body removes the memo. - Filter form gains a "★ starred 만" checkbox; URL persists as
?starred=1.
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 semantics — simple 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 viato_tsquery(a future option). - No Korean morpheme awareness.
"개발자"≠"개발자가". For rich Korean search installmecab-koand 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).
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.
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;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.
- 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
DELETEorUPDATErows directly.
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.
On top of per-project filtering:
-
Cross-project search.
/history(no project id) walks every project'sconversation_turnsand highlights matches. See Conversation Search & Archive. -
Export / import.
GET /projects/{id}/history/export+POST .../history/importfor hand-offs between hosts. JSON envelope (schemaVersion: 1), sessionId-level idempotency. -
Auto-archive.
ConversationArchiverdumps sessions older than 30 days to<workspace>/.vibecoder/<projectId>/archive/session-<sid>.jsonand 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.