Skip to content

cc-explorer corpus-layer redesign: bounded cache + rg prefilter + mtime pruning (Phases 0-3)#54

Open
coryking wants to merge 3 commits into
mainfrom
worktree-cc-explorer-bounded-cache
Open

cc-explorer corpus-layer redesign: bounded cache + rg prefilter + mtime pruning (Phases 0-3)#54
coryking wants to merge 3 commits into
mainfrom
worktree-cc-explorer-bounded-cache

Conversation

@coryking

@coryking coryking commented Jul 7, 2026

Copy link
Copy Markdown
Owner

The incident

A live cc-explorer MCP server instance was found at 5.5 GB RSS: one search_projects with no projects scope parsed the entire ~/.claude/projects corpus into pydantic object graphs and retained them forever in an unbounded module-global cache. Control instances: 80 MB. This PR is the full approved redesign — "to find a needle, first you must drink the ocean" is dead.

No database, no ingest pipeline, no hooks, no watcher. JSONL stays the only truth (Syncthing replicates it across boxes); instead the cache is bounded, searches are prefiltered with ripgrep so cost scales with answer size, and the timeline prunes by mtime.

Phase 0 — bound the cache

  • TranscriptCache: cachetools.LRUCache keyed by resolved path, byte-capped via getsizeof; entries charged file_size × 1.8 (measured heap factor). CC_EXPLORER_CACHE_MB, default 200. Oversized single files served uncached.
  • Tolerant parsing stops being silent: malformed lines counted to stderr once per file; structural headers (mode, permission-mode, custom-title, x-converter-provenance) skip silently. The except (json.JSONDecodeError, ValueError, Exception) nonsense-tuple is gone.

Phase 1 — corpus identity layer + rg prefilter

  • corpus.py (new): SessionRef = a session known by filename alone (deletes the _ArtifactSession shadow type — the symptom of SessionInfo conflating cheap identity with expensive derived state). Corpus = the refs in scope for one call: discover / narrow_to_ids / narrow_to_artifact_ids / candidate_refs. Project resolution + discovery moved here from search.py.
  • Prefilter: candidate_refs runs rg over raw JSONL bytes (argv-batched — rg has no --files-from), Python streaming fallback if rg is missing. Raw bytes are a candidate superset of extracted text except where JSON string escaping rewrites characters — rg_safe gates quote/backslash/\s/anchor/lookaround patterns to the scan-all path (memory-safe post Phase 0). _entry_matches remains matcher of record; the prefilter can over-select, never drop a true hit.
  • resolve.py (new): id→artifact resolution out of the tool layer, operating on refs — no parse ever.
  • SessionInfo.load(ref) is the only promotion path; agents_present is now a lazy cached property (with_agents_present flag killed); every session-keyed tool resolves refs by filename and promotes only its target. get_agent_detail without a session narrows by agent-filename glob instead of parsing every project.

Phase 2 — timeline mtime pruning

Append-only transcripts older than the window start can't contain in-window entries → skipped without parsing. Compaction rewrites bump mtime, so pruning errs safe. A session survives when any subagent body is fresh even if the parent is cold (background agents outlive their parent's last write).

Phase 3 — orjson + measurements

orjson for line parsing in the parser and the discovery head-scans.

Measured on the real corpus (1,472 sessions / ~3 GB), in-process:

flow after before (incident forensics)
cross-corpus search_projects 0.5 s prefilter + 1.1 s triage, peak RSS 195 MB minutes, 5.5 GB RSS
get_activity_timeline 7d 1.0 s full-corpus parse
read_turn, bare turn uuid 0.4 s to locate the file full-corpus parse

Live in-process smoke of every read tool against the real corpus: list_projects, cross-corpus search_projects, grep_session, read_turn (no session), browse_session, list_project_sessions, get_activity_timeline — all correct.

Tests

412 passed (56 new across the PR): cache eviction/cap/oversize; rg_safe gate; scanner contracts incl. argv batching and rust-regex rejection → ScannerError → scan-all; candidate-superset incl. the stripped-XML/newline boundary case; prefilter/full-scan triage equivalence; narrow_to_artifact_ids (incl. workflow-nested agents); mtime-pruning payload parity + stale-parent/fresh-subagent survival. The existing triage-oracle equivalence test carries over and now proves the prefilter changes nothing.

🤖 Generated with Claude Code

coryking and others added 2 commits July 7, 2026 09:30
The module-global parse cache was an unbounded dict retaining the fully
parsed pydantic object graph of every transcript ever loaded. One
cross-project search_projects call parses the whole ~3 GB corpus, which
left a live MCP server instance at 5.5 GB RSS (raw bytes x ~1.8 heap
factor, measured). Control instances that never ran a cross-project op
sit at 80 MB.

- TranscriptCache: cachetools.LRUCache keyed by resolved path, byte-capped
  via getsizeof; entries charged file_size x 1.8 (the measured heap
  factor). Cap: CC_EXPLORER_CACHE_MB env, default 200 MB. A single file
  larger than the cap is served uncached rather than erroring.
- load_transcript keeps its exact signature; the cache instance is
  module-level and injectable for tests.
- Tolerant parsing stops being silent: malformed/unknown lines are counted
  and reported once per file to stderr (never stdout — that's the stdio
  MCP channel). Known structural header lines (mode, permission-mode,
  custom-title, x-converter-provenance) skip silently — they're wire
  format, not data. Replaces the nonsense except-tuple
  (json.JSONDecodeError, ValueError, Exception).

Phase 0 of the corpus-layer redesign (bounded memory + rg prefilter +
mtime pruning); the prefilter and identity/derived type split land next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uning + orjson

Phases 1-3 of the corpus-layer redesign, on top of the bounded cache
(previous commit). Search cost now scales with the answer, not the corpus.

corpus.py (NEW) — the identity layer. SessionRef is a session known by
filename alone (replaces the _ArtifactSession shadow type); Corpus is the
refs in scope for one tool call, built from dir listings only (discover /
narrow_to_ids / narrow_to_artifact_ids / candidate_refs). Project
resolution + discovery (resolve_project(s), discover_projects,
ProjectInfo) move here from search.py — they were always identity, not
matching.

Prefilter: candidate_refs runs ripgrep over raw JSONL bytes (argv-batched;
rg has no --files-from) with a streaming Python fallback when rg is
missing. Raw bytes are a candidate SUPERSET of extracted text except where
JSON escaping rewrites characters — rg_safe gates quote/backslash/\s/
anchor/lookaround patterns to the scan-all path, so the prefilter can
over-select but never drop a true hit. The typed matcher (_entry_matches)
stays the matcher of record.

resolve.py (NEW) — id→artifact resolution out of mcp_server, operating on
SessionRefs (no parse). _ArtifactSession, _sessions_by_filename,
_projects_for_sessions, _narrow_projects_for_artifacts all deleted.

search.py — SessionInfo.load(ref) is the ONLY promotion path (parses
exactly one session); agents_present becomes a lazy cached property
(with_agents_present flag killed); load_sessions is a thin
discover+promote wrapper.

mcp_server.py rewired: search_projects and read_turn(no session) go
prefilter-first; every session-keyed tool resolves refs by filename and
promotes only its target; get_agent_detail narrows by agent-filename glob
instead of parsing every project; convert/rewind/delete resolve on refs.

activity.py — mtime-window pruning: append-only transcripts older than the
window start are skipped without parsing (compaction rewrites bump mtime,
so pruning errs safe); a session survives if any of its subagent bodies is
fresh even when the parent is cold.

parser.py — orjson for line parsing (2-6x on many-small-lines workloads).

Measured on the real corpus (1,472 sessions / ~3 GB):
  cross-corpus search  0.5 s prefilter + 1.1 s triage, peak RSS 195 MB
                       (incident baseline: minutes, 5.5 GB RSS)
  timeline 7d          1.0 s
  read_turn (no sess)  0.4 s to locate the holding file

412 tests pass (43 new: rg_safe gate, scanner contracts incl. argv
batching, candidate superset incl. the stripped-XML/newline boundary case,
prefilter/full-scan triage equivalence, narrow_to_artifact_ids,
mtime-pruning parity + fresh-subagent survival).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coryking coryking changed the title fix(cc-explorer): bound the transcript parse cache (Phase 0 of corpus redesign) cc-explorer corpus-layer redesign: bounded cache + rg prefilter + mtime pruning (Phases 0-3) Jul 7, 2026
…edesign

Four parallel cleanup reviews (reuse / simplification / efficiency /
altitude) over the branch diff, deduped and applied:

- search.promote_refs(): the bulk form of SessionInfo.load — replaces the
  same walrus-filter idiom copy-pasted across load_sessions,
  _load_all_sessions, search_projects, read_turn, and get_agent_detail.
- Corpus.narrow_to_artifact_ids: one recursive glob per encoded dir
  (was one glob per (dir, id) plus a second per-session glob), and
  narrow_to_ids computed once instead of twice.
- corpus owns MIN_ID_LEN; resolve.py and get_agent_detail import it
  instead of re-declaring the literal 6.
- SessionRef.transcript_files reuses subagents.resolve_subagents_dir
  instead of inlining the path formula.
- browse_session: one Corpus.discover per call (the agent-id fallback
  reused it instead of re-walking ~/.claude/projects), and the whole flow
  is ref-only — browse_session_turns now takes a transcript Path, so the
  fabricated minimal SessionInfo (a bypass of the load()-only promotion
  invariant) is gone.
- activity timeline: staleness prune is stat-only (raw agent-file walk)
  before collect_agent_files reads meta.json/provenance — only surviving
  sessions pay for provenance. 7d window on the real corpus: 0.98s → 0.44s.
- parser._STRUCTURAL_LINE_TYPES covers the other known header types
  (ai-title, last-prompt, attachment, agent-name, relocated,
  worktree-state, pr-link) — they were already skipped, but were being
  reported as "unparseable" noise on stderr (394 lines in one session).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant