feat(sessions): search over archived Claude session transcripts#103
Conversation
Adds claude_session_search / claude_session_fetch MCP tools so archived Claude Code session transcripts (retained 2 years) become queryable instead of requiring exact session IDs. - SessionSegment SourceItem subtype: embedding-sized runs of conversational messages (tool traffic stripped) riding the standard hybrid pipeline (Qdrant + BM25 + RRF) in a new "session" collection, with transcript line indices pointing back into the JSONL. - Indexer: hourly index_stale_sessions sweep + per-session index_session task with an indexed_up_to watermark; the trailing partial segment is held back until the transcript is idle so growing sessions don't produce overlapping segments. sha256 dedup makes reindexing idempotent; existing sessions backfill automatically. - Owner-only even for admins: segments carry creator_id + explicit NULL project_id, session_search prefilters source_ids by Session.user_id, and the generic search/list/count/fetch tools exclude the session modality entirely. - Retention: cleanup_old_claude_sessions now deletes segment source_item parents (Qdrant points are GC'd by the clean-all-collections sweep). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012YX6tYnmz3K8LZg6MXfHCv
Fixes ruff E402 that was failing backend CI: the imports introduced for the archived transcript search tests were placed mid-file after the existing test functions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012YX6tYnmz3K8LZg6MXfHCv
mruwnik
left a comment
There was a problem hiding this comment.
Heironeous (Claude Opus 4.8): Deep independent review — LGTM, no blocking issues found. This is a carefully engineered PR; the security model and the indexing state machine both hold up under scrutiny.
Access control (the risk that matters here) is sound. The owner-only-even-for-admins guarantee is enforced by owned_segment_ids scoping source_ids to Session.user_id == user_id — which holds regardless of admin * scope, since an admin's access_filter is None and would otherwise bypass everything. Defense-in-depth is real: I verified all five generic surfaces exclude SESSION_COLLECTIONS — search, fetch, list_items/count_items (both share apply_item_filters), get_metadata_schemas, and _get_available_modalities. session_fetch re-checks session.user_id != user.id. Segments carry creator_id + explicit NULL project_id, and there's no REST search route (search is MCP-only; search_base requires explicit modalities rather than defaulting to all), so no alternate leak path. Same "Session not found" error on missing vs. non-owned = no existence leak.
Indexing state machine is correct. I traced the tricky paths:
- The IntegrityError-on-concurrent-insert path is safe:
process_content_itemcommits each successful segment before the loop advances, so a rollback of a later duplicate can't orphan an earlier committed segment or leave a watermark gap — the watermark is re-set and re-committed on the next successful segment. - The embed-failure halt (delete FAILED row, hold
indexed_atstale, break) correctly forces the hourly sweep to retry the slice instead of advancing past a permanent zero-chunk gap. - Segmentation is deterministic from the watermark, and the sha256(content, session_id, start_index) + unique (session_id, start_index) index make reindex idempotent from both directions (existence check catches the common case, IntegrityError catches the race).
- Tail-holdback via
transcript_idle+ thetail_may_be_pendingrequeue condition is self-terminating (stops requeuing once mtime is >IDLE before indexed_at).
Migration, retention cascade (delete source_item parents before session rows so chunks/segments cascade), LIKE-escaping, and the lean SearchConfig (no HyDE/query-analysis) all check out.
Minor / optional (none blocking):
- Left an inline note on
owned_segment_idsre: unboundedsource_idsas a future scaling concern. - A SKIPPED segment (too short to embed) is counted toward
createdin the result dict though it produces no chunks — purely cosmetic; it's still sha256-deduped on reindex so it won't be recreated. sessions.py::extract_conversation_text(summarizer) andclaude_sessions.py::parse_event/extract_text_blocks(indexer) are two independent implementations of "pull conversational text from a transcript event" that differ subtly (is_meta handling, per-message truncation, join char). Consider having the summarizer reuse the parser to avoid drift. DRY nit only.
I'll keep watching and re-review on changes.
|
Heironeous (Claude Opus 4.8): Re-reviewed at head fe0cfd2 (was f3a53bc at my first pass). The only delta is a test-only refactor: imports ( |
Closes #100.
Adds
claude_session_search/claude_session_fetchMCP tools so archived Claude Code session transcripts (retained 2 years) become queryable instead of requiring exact session IDs.What's here
SessionSegmentSourceItem subtype — embedding-sized runs of conversational messages (tool traffic stripped) riding the standard hybrid pipeline (Qdrant + BM25 + RRF) in a newsessioncollection, with transcript line indices pointing back into the JSONL for context windows.index_stale_sessionssweep + per-sessionindex_sessiontask with anindexed_up_towatermark. The trailing partial segment is held back until the transcript is idle (SESSION_INDEX_MIN_IDLE_SECONDS, default 1h) so growing sessions don't produce overlapping segments. sha256 dedup makes reindexing idempotent; an embed failure halts the watermark so the slice is retried instead of silently dropped. Existing sessions backfill automatically on first sweep.creator_id+ explicit NULLproject_id;session_searchprefilterssource_idsbySession.user_id; and every generic surface (search,list_items,count_items,fetch,get_metadata_schemas, available-modalities) excludes the session modality entirely.cleanup_old_claude_sessionsnow deletes segmentsource_itemparent rows (Qdrant points are GC'd by the existing clean-all-collections sweep).claude_session_search(query)→claude_session_fetch(session_id, start_index).Review
3 rounds of differ-review (all comments resolved): round 1 fixed LIKE-wildcard escaping, lean SearchConfig (no HyDE/query-analysis LLM calls), and a cheaper hourly sweep; round 2 verified fixes, no new findings; round 3 fixed the embed-failure watermark gap, the
get_metadata_schemassession leak, and a negative-limit edge. A separate adversarial pass also closed an admin-visibility hole inlist_items/count_items/fetch.Tests: 63 new tests (parser, indexing tasks, MCP tools, access-control regressions); affected suites (~1640 tests) pass; pyright + ruff clean on changed files.
🤖 Generated with Claude Code
https://claude.ai/code/session_012YX6tYnmz3K8LZg6MXfHCv