A DuckDB extension written in Rust for querying, analysing and inspecting AI coding agents history. Read conversations, plans, todos, history, and usage stats directly from your local agent data directories — or drop to read_events() for the raw, lossless JSONL lines behind them.
Supported agents: Claude Code (~/.claude), Claude Desktop ("Cowork", ~/Library/Application Support/Claude), GitHub Copilot CLI (~/.copilot), Cursor (~/Library/Application Support/Cursor/User/globalStorage/state.vscdb, source='cursor'), OpenAI Codex CLI (~/.codex, source='codex'), Gemini CLI (~/.gemini) and xAI Grok CLI (~/.grok, source='grok').
Written in 🦀 Rust.
|
Or try the Streamlit Example
Or the Terminal TUI
|
All functions read from the default agent directory (~/.claude for Claude Code, ~/.copilot for Copilot) when no path is provided.
The provider is auto-detected from the directory structure.
-- How many conversations have I had with Claude?
SELECT COUNT(DISTINCT session_id) AS sessions,
COUNT(*) AS total_messages
FROM read_conversations();
-- What did I work on this week?
SELECT date, message_count, tool_call_count
FROM read_stats()
ORDER BY date DESC
LIMIT 7;
-- Which tools does github copilot use most?
SELECT tool_name, COUNT(*) AS uses
FROM read_conversations('~/.copilot')
WHERE tool_name IS NOT NULL
GROUP BY tool_name
ORDER BY uses DESC
LIMIT 10;
-- What are my active todos in my custom claude path?
SELECT content, status
FROM read_todos('~/work_folder/.claude')
WHERE status != 'completed'
ORDER BY item_index;
-- Which tools does Gemini CLI use most?
SELECT tool_name, COUNT(*) AS uses
FROM read_conversations('~/.gemini')
WHERE message_type = 'tool_call'
GROUP BY tool_name
ORDER BY uses DESC
LIMIT 10;
-- Compare activity across Claude, Copilot, and Gemini
SELECT source, COUNT(DISTINCT session_id) AS sessions, COUNT(*) AS messages
FROM (
SELECT * FROM read_conversations(path='~/.claude')
UNION ALL
SELECT * FROM read_conversations(path='~/.copilot')
UNION ALL
SELECT * FROM read_conversations(path='~/.gemini')
)
GROUP BY source;When called without arguments, each function reads from its provider's default path:
| Function | Default path | Detected as |
|---|---|---|
read_conversations() |
~/.claude |
Claude Code |
read_events() |
~/.claude |
Claude Code |
read_plans() |
~/.claude |
Claude Code |
read_todos() |
~/.claude |
Claude Code |
read_history() |
~/.claude |
Claude Code |
read_stats() |
~/.claude |
Claude Code |
To read Claude Desktop, Copilot, or Gemini data, pass the path explicitly:
FROM read_conversations(path='~/Library/Application Support/Claude'); -- detected as Claude Desktop
FROM read_conversations(path='~/.copilot'); -- detected as Copilot
FROM read_conversations(path='~/.gemini'); -- detected as Gemini CLIAll functions accept two optional parameters:
path— data directory path (default:~/.claudefor legacy no-argument calls;source='codex'usesCODEX_HOMEor~/.codex). Auto-detected from folder structure (local-agent-mode-sessions/→ Claude Desktop,projects/→ Claude,session-state/→ Copilot, astate.vscdbfile → Cursor,sessions/<YYYY>/→ Codex,tmp/+installation_id→ Gemini CLI,sessions/<%encoded-cwd>/→ Grok). An explicit Codex path can name its home, sessions directory, or one rollout file. Grok lives at~/.grok, so passpath='~/.grok'(orsource='grok').source— explicit provider override:'claude','claude-desktop','copilot','cursor','codex','gemini', or'grok'. Use when auto-detection fails or for non-standard directory layouts.include_archived— for Codex directory discovery, includearchived_sessions/(defaultfalse).
Every table includes a source column ('claude', 'claude-desktop', 'copilot', 'cursor', 'codex', 'gemini', or 'grok') as the first column.
read_events()is the exception to the "empty result" convention. It is the raw, lossless relation (Claude and Codex only) and it errors on an unusable path or an unsupported provider instead of returning zero rows — seeread_eventsbelow.
Cursor support is gated behind the default-on
cursorcargo feature. It readsstate.vscdbwith a self-contained, pure-Rust, read-only SQLite reader (src/vscdb.rs) — no external dependency and no bundled C SQLite, so every target arch (includingwindows_amd64_mingw) builds with negligible size overhead. Build with--no-default-featuresto drop it. Onlyread_conversations()is implemented for Cursor; the other tables return no rows forsource='cursor'.
Codex conversation data is read from active rollouts under
CODEX_HOME(default~/.codex) and, withinclude_archived := true, archived rollouts.session_index.jsonland the versioned state SQLite database enrich titles, thread relationships, and other metadata; transcript reading still works if either index is unavailable. See the Codex column mapping.
Grok has no extra build dependencies. Transcripts live at
~/.grok/sessions/<%encoded-cwd>/<session-uuid>/chat_history.jsonlwith session metadata in a siblingsummary.json(and optionalsignals.jsonforread_stats). Grok fills shared columns plus Grok-onlyreasoning_effortandreasoning_tokens(see field map).encrypted_contentis never read. Token usage is not onchat_historylines — it comes from the siblingupdates.jsonl(sessionUpdate == "turn_completed"→usage).
Reads conversation/event data.
- Claude: JSONL files from
projects/<project>/<session>.jsonl(including nested sub-agent transcripts atprojects/<project>/<session>/subagents/agent-*.jsonl) - Claude Desktop: JSONL files from
local-agent-mode-sessions/**/.claude/projects/<project>/<session>.jsonl(same schema as Claude Code) - Copilot: JSONL events from
session-state/<uuid>/events.jsonl - Cursor:
composerData:*/bubbleId:*rows fromstate.vscdb(read with the pure-Rustsrc/vscdb.rsreader; one composer = one session) - Codex: JSONL rollout streams from
sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl - Gemini: JSON chat checkpoints from
tmp/<project-hash>/chats/session-<ts>-<id>.json(one file = one session; each tool call is also emitted as atool_callrow) - Grok: JSONL transcripts from
sessions/<%encoded-cwd>/<session-uuid>/chat_history.jsonl, with session metadata from siblingsummary.json. Timestamps and token columns come from siblingupdates.jsonl(wire event stream — chat_history itself has no time/usage fields). Subagent children linked via…/<parent>/subagents/<child>/meta.jsonsetis_agent=true(and session-levelparent_uuid).
| Column | Type | Description |
|---|---|---|
source |
VARCHAR | 'claude', 'claude-desktop', 'copilot', 'cursor', 'codex', 'gemini', or 'grok' |
session_id |
VARCHAR | Session UUID |
project_path |
VARCHAR | Project/working directory path |
project_dir |
VARCHAR | Raw encoded directory name (Claude / Grok cwd dir) |
file_name |
VARCHAR | Source filename |
is_agent |
BOOLEAN | Child session launched in-process by another agent: Claude subagents/agent-*.jsonl; Codex threads with any native parent pointer (source.subagent.*, parent_thread_id, thread_source = subagent); Grok via subagent meta linkage |
line_number |
BIGINT | Line number within file (1-based) |
message_type |
VARCHAR | See message type mappings below |
uuid |
VARCHAR | Message/event UUID |
parent_uuid |
VARCHAR | Parent message/event UUID (Grok: parent session id on subagent rows) |
timestamp |
VARCHAR | ISO 8601 timestamp (Claude/Copilot per-message; Grok: from updates.jsonl event clock, else summary session stamp) |
message_role |
VARCHAR | user, assistant, tool, or NULL |
message_content |
VARCHAR | Text content |
model |
VARCHAR | AI model used |
tool_name |
VARCHAR | Tool called |
tool_use_id |
VARCHAR | Tool use/call identifier |
tool_input |
VARCHAR | Tool input as JSON string |
input_tokens |
BIGINT | Input token count (Claude/Gemini per-message, Copilot truncation; Grok: last updates.jsonl turn_completed inputTokens, session aggregate duplicated on every row) |
output_tokens |
BIGINT | Output token count (Grok: last turn_completed outputTokens) |
cache_creation_tokens |
BIGINT | Cache creation tokens (Claude only; Grok always NULL) |
cache_read_tokens |
BIGINT | Cache read tokens (Claude; Gemini cached; Grok: last turn_completed cachedReadTokens) |
reasoning_tokens |
BIGINT | Reasoning token count (Grok only: last turn_completed reasoningTokens; other providers NULL) |
slug |
VARCHAR | Session slug (Claude; Grok summary.generated_title) |
git_branch |
VARCHAR | Git branch |
cwd |
VARCHAR | Working directory |
version |
VARCHAR | Agent CLI version (Grok: summary.chat_format_version as string) |
stop_reason |
VARCHAR | Claude API stop reason (NULL for Grok) |
reasoning_effort |
VARCHAR | Grok-only: per-message reasoning_effort (low/medium/high/…), else session-level summary.reasoning_effort backfill; NULL for other sources |
repository |
VARCHAR | GitHub repository (Copilot; Grok from summary.git_remotes[0]) |
record_id |
VARCHAR | Provider record identifier when available |
file_path |
VARCHAR | Transcript file path for the physical source line (Claude, Codex) |
byte_offset |
BIGINT | Byte offset of the physical source line (Claude, Codex) |
ordinal |
BIGINT | Zero-based physical line ordinal (Claude, Codex) |
parent_session_id |
VARCHAR | Session that launched this one, when the source records it (Claude nested subagents; Codex parent_thread_id / thread_spawn) |
client |
VARCHAR | Front end that wrote the session: Claude entrypoint (cli, claude-desktop, sdk-cli); Codex cli, desktop, exec, editor, browser |
agent_path |
VARCHAR | Structured child-agent path; Claude uses the nested transcript path or flat agent filename |
parse_error |
VARCHAR | Parser diagnostic for a retained unsupported or malformed source record |
raw_event |
VARCHAR | Exact valid UTF-8 JSONL line text without its terminator (Claude, Codex) |
author |
VARCHAR | Who wrote the row: user, caller, agent, tool or system (see below); never NULL |
Claude and Claude Desktop retain file_path, byte_offset, and ordinal on
every non-blank JSONL row, including unsupported and malformed records. For
valid UTF-8 lines, raw_event is the exact line text without its terminator;
invalid UTF-8 fidelity is available from read_events() via raw_bytes,
addressed by file_path and byte_offset. Such rows use
message_type = '_parse_error' and carry the parser diagnostic in
parse_error. Full reads and projections that select raw_event populate
that field; projections that omit it avoid retaining the line text while
preserving the other evidence fields. Nested Claude subagents derive
parent_session_id from projects/<project>/<parent>/subagents/; their
agent_path is <parent>/subagents/agent-*.jsonl. Flat legacy agent-*.jsonl
files use an explicit sessionId as parent_session_id when present and leave
it NULL otherwise. session_id keeps its existing file/native behavior.
Who wrote the row (author): message_role is the provider's own role, and
a subagent's brief arrives there as user. author is the derived answer:
author |
Meaning |
|---|---|
user |
A person typed it (Claude promptSource typed/queued, Desktop turnOrigin = human, legacy records without flags; Codex interactive threads) |
caller |
Written into the user slot by the program that launched the session: Claude nested subagents and sdk-cli (claude -p, turnOrigin = sdk) turns; Codex child threads (is_agent) and codex exec runs (client = exec), replayed parent prompts included |
agent |
Assistant text, thinking/reasoning, plans and tool calls |
tool |
Tool results (Claude tool_result; Codex *_output and Desktop tool items whose content is the output) |
system |
Harness and lifecycle records: Claude system, summary, snapshots, attachments and user-slot records flagged isMeta, isCompactSummary or promptSource = system; Codex developer messages, usage/lifecycle events, and user-slot injections wrapped in Codex's fixed envelopes (<environment_context>, # AGENTS.md instructions, <turn_aborted>, …) |
Copilot, Cursor, Gemini and Grok carry no launch evidence, so their author
follows message_role. A codex exec or claude -p run started by a person
from a shell is still caller: the transcript only shows that a program
supplied the prompt. Those shell-launched sessions have no native parent
pointer; docs/lineage.sql recovers the caller from the launching session's
tool call when the brief text or cwd and start time match.
Message type mappings:
| Claude | Copilot | Gemini | Grok | Description |
|---|---|---|---|---|
user |
user |
user |
user |
User message |
assistant |
assistant |
assistant (from gemini) |
assistant |
Assistant response |
system |
— | — | system |
System prompt |
summary |
— | — | — | Conversation summary |
| — | reasoning |
— | reasoning |
Assistant reasoning (summary text only) |
| — | turn_start / turn_end |
— | — | Assistant turn boundaries |
| — | tool_start / tool_result |
tool_call |
tool_call / tool_result |
Tool execution events |
| — | session_start / session_resume |
— | — | Session lifecycle |
| — | session_info / session_error |
info / error |
— | Session info/errors |
| — | truncation / model_change |
— | — | Context management |
| — | compaction_start / compaction_complete |
— | — | Context compaction |
| — | abort |
— | — | User cancellation |
Gemini tool calls: each assistant (
gemini) turn may embed multipletoolCalls. The first is surfaced inline on the assistant row (tool_name/tool_use_id/tool_input), and every call additionally gets its owntool_callrow whoseparent_uuidlinks back to the assistant message and whosemessage_contentholds the call status (success/error/cancelled).
Grok field map:
Grok source Column Notes typemessage_typesystem/user/reasoning/assistant/tool_result; eachtool_calls[]→tool_callcontent/summary[].textmessage_contentUser may be string or {type,text}blocks; reasoning uses summary text only (neverencrypted_content)model_id(elsesummary.current_model_id)modeltool_calls[].name/id/argumentstool_name/tool_use_id/tool_inputOne row per call; string or object args → stable string reasoning_effort(message, else summary)reasoning_effortGrok-only nullable varchar; stop_reasonstays NULLsummary.generated_titleslugSession title summary.chat_format_versionversionStringified updates.jsonltimestamp(+ kind)timestampUnix sec → ISO; cursor-aligned to chat types ( user_message_chunk→user,agent_thought_chunk→reasoning, …). Fallback: summary activity stampsummary.head_branch/git_remotes[0]/git_root_dirgit_branch/repository/project_pathreasoning.id, else syntheticuuidPrefer real id; else {session_id}:{line_number}so uuid is never NULLsubagent meta.jsonis_agent/parent_uuidChild session → true; parent session id updates.jsonlturn_completed.usage.inputTokensinput_tokensLast usable snapshot; session/prompt aggregate duplicated on every row (not per-line) …outputTokensoutput_tokenssame …cachedReadTokenscache_read_tokenssame …reasoningTokensreasoning_tokensGrok-only column; other providers NULL Token source note: Usage is cumulative within one user-prompt agent loop (
numTurns1→N then resets). v1 stamps the lastturn_completedusage inupdates.jsonlonto all conversation rows for that session. Do not sum token columns across rows for a session — pick any row (orMAX/ANY_VALUE). Sessions withoutupdates.jsonl(or withoutusage) keep token columns NULL.cache_creation_tokensis never set for Grok.Synthetic uuid form:
{session_id}:{line_number}(1-based chat_history line). Multi-row fan-out from one assistant line (text + tool_call rows) shares that line's uuid. Prefer realreasoning.idwhen present.Timestamps:
chat_historyhas no time fields. The CLI clocks live inupdates.jsonl. The parser walks chat lines and assigns the next matching wire event's ISO time (sametimestampcolumn as Claude). Noupdates.jsonl→ summary session stamp only.
The lossless raw JSONL relation: one row per physical line of every
transcript file, exactly as it sits on disk. Where read_conversations()
normalizes seven providers into one shared schema (and therefore drops whatever
it has no column for), read_events() drops nothing — unknown event types,
malformed JSON, blank lines and a half-written final line all come back as rows,
addressed by file_path + line_number + byte_offset.
- Claude (
source='claude'):projects/<project>/<session>.jsonl, including nested sub-agent transcripts atprojects/<project>/<session>/subagents/agent-*.jsonl(same discovery walk asread_conversations()) - Codex (
source='codex'):sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl
Other providers are not supported by read_events() and raise an error
(use read_conversations() for those).
| Column | Type | Description |
|---|---|---|
source |
VARCHAR | 'claude' or 'codex' |
session_id |
VARCHAR | File-derived session id (Claude: file stem or parent session directory for subagents; Codex: rollout filename UUID). Event-level ids remain in raw; use file/line provenance when they differ. |
file_path |
VARCHAR | Absolute path of the transcript file the line came from |
file_name |
VARCHAR | File name only |
line_number |
BIGINT | 1-based physical line number in that file (counts blank and malformed lines) |
byte_offset |
BIGINT | Byte offset of the line's first byte from the start of the file |
byte_length |
BIGINT | Length of the line in bytes, excluding its terminator |
line_ending |
VARCHAR | 'LF', 'CRLF', or NULL when the line has no terminator (final, possibly partial, line) |
raw |
VARCHAR | The line verbatim, terminator excluded — nothing parsed, reordered or re-serialized |
raw_is_exact |
BOOLEAN | false if invalid UTF-8 needed U+FFFD in the text representation; raw_bytes always retains the original bytes |
is_valid_json |
BOOLEAN | Whether the line parses as JSON |
parse_error |
VARCHAR | The JSON parser's own message when it does not, else NULL |
event_type |
VARCHAR | Top-level "type" verbatim — no mapping table, so future/unknown types pass through (NULL if absent or non-string) |
timestamp |
VARCHAR | Top-level "timestamp" verbatim (NULL if absent or non-string) |
raw_bytes |
BLOB | Exact line bytes, excluding the separately recorded terminator; authoritative even for invalid UTF-8 or embedded NUL |
event_type and timestamp are conveniences, not a schema: everything else
stays in raw, so nested payloads are queried from there (e.g. with the json
extension).
Newline handling. \n (reported as LF) and \r\n (CRLF) terminate a
line; the terminator is excluded from raw and from byte_length and recorded
in line_ending. A bare \r is not a terminator and stays inside raw. An
empty line is a row with raw = '' and byte_length = 0. A file whose last
line has no terminator — an agent still writing, or a truncated log — yields a
final row with line_ending IS NULL, kept whether or not it parses. Because
nothing is normalized in raw_bytes, it and line_ending reconstruct the original bytes.
For UTF-8 logs (raw_is_exact is true on every row), the text form is:
SELECT string_agg(
raw || CASE line_ending WHEN 'CRLF' THEN chr(13) || chr(10)
WHEN 'LF' THEN chr(10)
ELSE '' END,
'' ORDER BY line_number)
FROM read_events(path='~/.claude')
WHERE file_path = '...'; -- byte-identical to the fileErrors. A raw reader that silently returns nothing is indistinguishable from
a lossless read of an empty directory, so read_events() fails loudly:
| Condition | Behavior |
|---|---|
path does not exist / is not a directory |
error (read_events: path '…' does not exist) |
provider is not Claude or Codex (explicit source or auto-detected) |
error (read_events: unsupported provider …) |
| a transcript or discovery directory cannot be read | error naming the path and the OS error |
| supported provider, no transcripts found | 0 rows (an empty tree is a legitimate answer) |
-- Which raw lines does the normalized view not account for?
SELECT file_name, line_number, event_type, parse_error, raw
FROM read_events(path='~/.codex', source='codex')
WHERE NOT is_valid_json OR event_type NOT IN ('session_meta', 'turn_context', 'response_item', 'event_msg');
-- Re-read one exact line from disk by its address
SELECT file_path, byte_offset, byte_length, raw
FROM read_events(path='~/.claude')
WHERE session_id = '…' AND line_number = 42;Reads plan files.
- Claude:
plans/*.md - Copilot:
session-state/<uuid>/plan.md
| Column | Type | Description |
|---|---|---|
source |
VARCHAR | 'claude', 'claude-desktop', or 'copilot' |
session_id |
VARCHAR | Parent session UUID (Copilot only, NULL for Claude) |
plan_name |
VARCHAR | Plan name (filename stem or workspace summary) |
file_name |
VARCHAR | Full filename |
file_path |
VARCHAR | Absolute file path |
content |
VARCHAR | Full markdown content |
file_size |
BIGINT | File size in bytes |
Reads todo/checklist items.
- Claude:
todos/<session>-agent-<agent>.json - Copilot: Checkpoint markdown checklists from
session-state/<uuid>/checkpoints/*.md
| Column | Type | Description |
|---|---|---|
source |
VARCHAR | 'claude', 'claude-desktop', or 'copilot' |
session_id |
VARCHAR | Parent session UUID |
agent_id |
VARCHAR | Agent UUID (Claude only, NULL for Copilot) |
file_name |
VARCHAR | Source filename |
item_index |
BIGINT | 0-based index (-1 for parse errors) |
content |
VARCHAR | Todo item text |
status |
VARCHAR | pending, in_progress, completed, or _parse_error |
active_form |
VARCHAR | Active form description (Claude only) |
Reads command history.
- Claude:
history.jsonl(structured JSONL) - Copilot:
command-history-state.json(simple string array)
| Column | Type | Description |
|---|---|---|
source |
VARCHAR | 'claude', 'claude-desktop', or 'copilot' |
line_number |
BIGINT | Line/entry number (1-based) |
timestamp_ms |
BIGINT | Unix timestamp in ms (Claude only) |
project |
VARCHAR | Project path (Claude only) |
session_id |
VARCHAR | Session UUID (Claude only) |
display |
VARCHAR | Command/prompt text |
pasted_contents |
VARCHAR | Pasted content as JSON (Claude only) |
Reads daily activity stats.
- Claude:
stats-cache.jsondaily activity - Grok: rolls up per-session
signals.json(+summaryfallbacks) bysummary.created_atdate into the same columns (no new table function).message_count=userMessageCount + assistantMessageCount(elsesummary.num_messages);tool_call_count=signals.toolCallCount;session_count= sessions on that date. Other providers return empty (derive fromread_conversations()in SQL instead).
| Column | Type | Description |
|---|---|---|
source |
VARCHAR | 'claude' or 'grok' |
date |
VARCHAR | Date (YYYY-MM-DD) |
message_count |
BIGINT | Messages sent that day |
session_count |
BIGINT | Sessions started that day |
tool_call_count |
BIGINT | Tool calls made that day |
The extension auto-detects the data source by examining the directory structure:
- Claude Desktop: contains
local-agent-mode-sessions/directory - Claude: contains
projects/directory - Copilot: contains
session-state/directory - Gemini CLI: contains a
tmp/directory plus aninstallation_idfile - Unknown: returns empty results (or use
sourceparameter to force)
-- Auto-detect
FROM read_conversations(path='~/.claude'); -- detected as Claude
FROM read_conversations(path='~/Library/Application Support/Claude'); -- detected as Claude Desktop
FROM read_conversations(path='~/.copilot'); -- detected as Copilot
FROM read_conversations(path='~/.gemini'); -- detected as Gemini CLI
-- Override detection
FROM read_conversations(path='custom/dir', source='gemini');Tables can be joined within the same source:
-- Conversations ↔ History (via session_id)
SELECT c.*, h.display
FROM read_conversations(path='~/.claude') c
JOIN read_history(path='~/.claude') h ON c.session_id = h.session_id;
-- Cross-source: always filter by source
SELECT * FROM (
SELECT * FROM read_conversations(path='~/.claude')
UNION ALL
SELECT * FROM read_conversations(path='~/.copilot')
) WHERE source = 'copilot';| Join | Left Key | Right Key | Notes |
|---|---|---|---|
| conversations ↔ history | session_id |
session_id |
Same source only |
| conversations ↔ todos | session_id |
session_id |
Same source only |
| conversations ↔ plans | slug |
plan_name |
Claude only |
| conversations ↔ history | project_path |
project |
Claude only |
When a JSONL line or JSON file cannot be parsed, the extension emits a row with:
message_type = '_parse_error'(conversations)status = '_parse_error'(todos)display = 'Parse error: ...'(history)
Filter them with WHERE message_type != '_parse_error'.
read_events() does not summarize parse failures this way: the offending line
is returned verbatim in raw with is_valid_json = false and the parser's own
message in parse_error, so the bytes that failed are still inspectable.
Keyboard-driven terminal explorer with session browser, overview dashboard, and SQL editor:
cd examples/tui
uv sync
uv run python -m agent_chronicleSee examples/tui/README.md for details.
Interactive notebook for exploring agent data:
cd examples/marimo
uv sync
marimo edit explore.pyMulti-page web application with session browser and SQL query interface:
cd examples/explorer
uv sync
streamlit run app.pySee examples/explorer/README.md for details.
# Build and run all SQLLogicTest assertions
make test437 pinned assertions across 21 test files covering row counts, column validation, cross-source queries, join invariants, edge cases, Grok stats, and parse error handling.
# First time: configure build environment
make configure
# Build debug extension
make debug
# Run tests
make testThe compiled extension is at build/debug/agent_data.duckdb_extension (or build/release/ for make release).
# Load directly from a local build
duckdb -unsigned -c "LOAD 'build/debug/agent_data.duckdb_extension'; FROM read_conversations();"When DuckDB publishes a new stable release, keep the community package aligned with the latest stable target:
-
Check whether the repo is still aligned with DuckDB latest stable:
python3 scripts/update_duckdb_release.py --check
-
If a new stable release exists, update all local release surfaces:
python3 scripts/update_duckdb_release.py --apply
This updates
duckdb-release.toml,Cargo.toml,Cargo.lock,Makefile,.github/workflows/MainDistributionPipeline.yml, and example DuckDB Python constraints. -
Run the full local validation entrypoint:
scripts/validate_duckdb_release.sh
This runs Rust/build/test checks and an exact-version DuckDB Python smoke test that loads
build/debug/agent_data.duckdb_extension. -
Update
duckdb/community-extensionsafter the source commit is validated:scripts/prepare_community_extension_pr.py --open-pr
Keep upstream
repo.refimmutable. A commit SHA is preferred; an immutable tag is also acceptable when maintainers intentionally release by tag. Do not usemainas the stable ref because community builds should be reproducible. -
After the upstream community-extension workflow deploys from a trusted context, verify publication:
uv run --with duckdb==$(python3 - <<'PY'
import tomllib print(tomllib.load(open("duckdb-release.toml", "rb"))["duckdb"]["python_version"]) PY ) python scripts/verify_community_publication.py
Pull-request deploys may build artifacts without publishing public binaries
when upstream secrets are unavailable.
The scheduled `DuckDB Release Monitor` workflow checks for stable-release drift
and opens a PR when the updater and validation pass. The `DuckDB Next
Compatibility` workflow can be run on schedule or manually to test DuckDB
`main`/next without publishing binaries. For DuckDB feature-freeze branches,
use upstream `repo.ref_next` for pre-release validation, then update `repo.ref`
after the stable source commit is merged.
## License
MIT — see [LICENSE](LICENSE).