release: v1.38.0 with continue-claude-work and skill-creator enhancements
## New Skill: continue-claude-work (v1.1.0) - Recover actionable context from local `.claude` session artifacts - Compact-boundary-aware extraction (reads Claude's own compaction summaries) - Subagent workflow recovery (reports completed vs interrupted subagents) - Session end reason detection (clean exit, interrupted, error cascade, abandoned) - Size-adaptive strategy for small/large sessions - Noise filtering (skips 37-53% of session lines) - Self-session exclusion, stale index fallback, MEMORY.md integration - Bundled Python script (no external dependencies) - Security scan passed, argument-hint added ## Skill Updates - **skill-creator** (v1.5.0): Complete rewrite with evaluation framework - Added agents/ (analyzer, comparator, grader) - Added eval-viewer/ (generate_review.py, viewer.html) - Added scripts/ (run_eval, aggregate_benchmark, improve_description, run_loop) - Added references/schemas.md (eval/benchmark schemas) - Expanded SKILL.md with inline vs fork guidance, progressive disclosure patterns - Enhanced package_skill.py and quick_validate.py - **transcript-fixer** (v1.2.0): CLI improvements and test coverage - Enhanced argument_parser.py and commands.py - Added correction_service.py improvements - Added test_correction_service.py - **tunnel-doctor** (v1.4.0): Quick diagnostic script - Added scripts/quick_diagnose.py - Enhanced SKILL.md with 5-layer conflict model - **pdf-creator** (v1.1.0): Auto DYLD_LIBRARY_PATH + rendering fixes - Auto-detect and set DYLD_LIBRARY_PATH for weasyprint - Fixed list rendering and CSS improvements - **github-contributor** (v1.0.3): Enhanced project evaluation - Added evidence-loop, redaction, and merge-ready PR guidance ## Documentation - Updated marketplace.json (v1.38.0, 42 skills) - Updated CHANGELOG.md with v1.38.0 entry - Updated CLAUDE.md (skill count, marketplace version, #42 description) - Updated README.md (badges, skill section #42, use case, requirements) - Updated README.zh-CN.md (badges, skill section #42, use case, requirements) - Fixed absolute paths in continue-claude-work/references/file_structure.md ## Validation - All skills passed quick_validate.py - continue-claude-work passed security_scan.py - marketplace.json validated (valid JSON) - Cross-checked version consistency across all docs
This commit is contained in:
4
continue-claude-work/.security-scan-passed
Normal file
4
continue-claude-work/.security-scan-passed
Normal file
@@ -0,0 +1,4 @@
|
||||
Security scan passed
|
||||
Scanned at: 2026-03-07T14:27:12.638956
|
||||
Tool: gitleaks + pattern-based validation
|
||||
Content hash: c464aa735e8b7832c2c77e4cea22fff9c7e6117ecee4f6769f5eb62cced8a11a
|
||||
141
continue-claude-work/SKILL.md
Normal file
141
continue-claude-work/SKILL.md
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: continue-claude-work
|
||||
description: Recover actionable context from local `.claude` session artifacts and continue interrupted work without running `claude --resume`. This skill should be used when the user provides a Claude session ID, asks to continue prior work from local history, or wants to inspect `.claude` files before resuming implementation.
|
||||
argument-hint: [session-id]
|
||||
---
|
||||
|
||||
# Continue Claude Work
|
||||
|
||||
## Overview
|
||||
|
||||
Recover actionable context from a prior Claude Code session and continue execution in the current conversation. Use local session files as the source of truth, then continue with concrete edits and checks — not just summarizing.
|
||||
|
||||
**Why this exists instead of `claude --resume`**: `claude --resume` replays the full session transcript into the context window. For long sessions this wastes tokens on resolved issues and stale state. This skill **selectively reconstructs** only actionable context — the latest compact summary, pending work, known errors, and current workspace state — giving a fresh start with prior knowledge.
|
||||
|
||||
## File Structure Reference
|
||||
|
||||
For directory layout, JSONL schemas, and compaction block format, see `references/file_structure.md`.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Extract Context (single script call)
|
||||
|
||||
Run the bundled extraction script. It handles session discovery, compact-boundary parsing, noise filtering, and workspace state in one call:
|
||||
|
||||
```bash
|
||||
# Latest session for current project
|
||||
python3 scripts/extract_resume_context.py
|
||||
|
||||
# Specific session by ID
|
||||
python3 scripts/extract_resume_context.py --session <SESSION_ID>
|
||||
|
||||
# Search by topic
|
||||
python3 scripts/extract_resume_context.py --query "auth feature"
|
||||
|
||||
# List recent sessions
|
||||
python3 scripts/extract_resume_context.py --list
|
||||
```
|
||||
|
||||
The script outputs a structured Markdown **briefing** containing:
|
||||
- **Session metadata** from `sessions-index.json`
|
||||
- **Compact summary** — Claude's own distilled summary from the last compaction boundary (highest-signal context)
|
||||
- **Last user requests** — the most recent explicit asks
|
||||
- **Last assistant responses** — what was claimed done
|
||||
- **Errors encountered** — tool failures and error outputs
|
||||
- **Unresolved tool calls** — indicates interrupted session
|
||||
- **Subagent workflow state** — which subagents completed, which were interrupted, their last outputs
|
||||
- **Session end reason** — clean exit, interrupted (ctrl-c), error cascade, or abandoned
|
||||
- **Files touched** — files created/edited/read during the session
|
||||
- **MEMORY.md** — persistent cross-session notes
|
||||
- **Git state** — current status, branch, recent log
|
||||
|
||||
The script automatically skips the currently active session (modified < 60s ago) to avoid self-extraction.
|
||||
|
||||
### Step 2: Branch by Session End Reason
|
||||
|
||||
The briefing includes a **Session end reason**. Use it to choose the right continuation strategy:
|
||||
|
||||
| End Reason | Strategy |
|
||||
|-----------|----------|
|
||||
| **Clean exit** | Session completed normally. Read the last user request that was addressed. Continue from pending work if any. |
|
||||
| **Interrupted** | Tool calls were dispatched but never got results (likely ctrl-c or timeout). Retry the interrupted tool calls or assess whether they are still needed. |
|
||||
| **Error cascade** | Multiple API errors caused the session to fail. Do not retry blindly — diagnose the root cause first. |
|
||||
| **Abandoned** | User sent a message but got no response. Treat the last user message as the current request. |
|
||||
|
||||
If the briefing has a **Subagent Workflow** section with interrupted subagents, check what each was doing and whether to retry or skip.
|
||||
|
||||
### Step 3: Reconcile and Continue
|
||||
|
||||
Before making changes:
|
||||
1. Confirm the current directory matches the session's project.
|
||||
2. If the git branch has changed from the session's branch, note this and decide whether to switch.
|
||||
3. Inspect files related to pending work — verify old claims still hold.
|
||||
4. Do not assume old claims are valid without checking.
|
||||
|
||||
Then:
|
||||
- Implement the next concrete step aligned with the latest user request.
|
||||
- Run deterministic verification (tests, type-checks, build).
|
||||
- If blocked, state the exact blocker and propose one next action.
|
||||
|
||||
### Step 4: Report
|
||||
|
||||
Respond concisely:
|
||||
- **Context recovered**: which session, key findings from the briefing
|
||||
- **Work executed**: files changed, commands run, test results
|
||||
- **Remaining**: pending tasks, if any
|
||||
|
||||
## How the Script Works
|
||||
|
||||
### Compact-Boundary-Aware Extraction
|
||||
|
||||
The script finds the **last** compact boundary in the session JSONL and extracts its summary. This is the single highest-signal piece of context in any long session -- Claude's own distilled understanding of the entire conversation up to that point. For details on compaction format and JSONL schemas, see `references/file_structure.md`.
|
||||
|
||||
### Size-Adaptive Strategy
|
||||
|
||||
| Session size | Strategy |
|
||||
|-------------|----------|
|
||||
| Has compactions | Read last compact summary + all post-compact messages |
|
||||
| < 500 KB, no compactions | Read last 60% of messages |
|
||||
| 500 KB - 5 MB | Read last 30% of messages |
|
||||
| > 5 MB | Read last 15% of messages |
|
||||
|
||||
### Subagent Context Extraction
|
||||
|
||||
When a session has subagent directories (`<session-id>/subagents/`), the script parses each subagent's JSONL to extract agent type, completion status, and last text output. This enables recovery of multi-agent workflows -- e.g., if a 32-subagent evaluation pipeline was interrupted, the briefing shows which agents completed and which need retry.
|
||||
|
||||
### Session End Reason Detection
|
||||
|
||||
The script classifies how the session ended:
|
||||
- **completed** -- assistant had the last word (clean exit)
|
||||
- **interrupted** -- unresolved tool calls (ctrl-c or timeout)
|
||||
- **error_cascade** -- 3+ API errors
|
||||
- **abandoned** -- user sent a message with no response
|
||||
|
||||
### Noise Filtering
|
||||
|
||||
These message types are skipped (37-53% of lines in real sessions):
|
||||
- `progress`, `queue-operation`, `file-history-snapshot` -- operational noise
|
||||
- `api_error`, `turn_duration`, `stop_hook_summary` -- system subtypes
|
||||
- `<task-notification>`, `<system-reminder>` -- filtered from user text extraction
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not run `claude --resume` or `claude --continue` — this skill provides context recovery within the current session.
|
||||
- Do not treat compact summaries as complete truth — they are lossy. Always verify claims against current workspace.
|
||||
- Do not overwrite unrelated working-tree changes.
|
||||
- Do not load the full session file into context — always use the script.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Cannot recover sessions whose `.jsonl` files have been deleted from `~/.claude/projects/`.
|
||||
- Cannot access sessions from other machines (files are local only).
|
||||
- Edit tool operations show deltas, not full file content — use `claude-code-history-files-finder` for full file recovery.
|
||||
- Compact summaries are lossy — early conversation details may be missing.
|
||||
- `sessions-index.json` can be stale (entries pointing to deleted files). The script falls back to filesystem-based discovery.
|
||||
|
||||
## Example Trigger Phrases
|
||||
|
||||
- "continue work from session `abc123-...`"
|
||||
- "don't resume, just read the .claude files and continue"
|
||||
- "check what I was working on in the last session and keep going"
|
||||
- "search my sessions for the PR review work"
|
||||
249
continue-claude-work/references/file_structure.md
Normal file
249
continue-claude-work/references/file_structure.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Claude Code Local File Structure
|
||||
|
||||
Ground-truth reference for `~/.claude/` directory layout and JSONL session format.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
~/.claude/
|
||||
projects/ # Per-project session storage (primary)
|
||||
<normalized-path>/
|
||||
sessions-index.json # Master index of all sessions
|
||||
<session-id>.jsonl # Session transcript
|
||||
<session-id>/ # Session subdirectory (optional)
|
||||
subagents/
|
||||
agent-<agent-id>.meta.json # Agent metadata
|
||||
agent-<agent-id>.jsonl # Agent transcript
|
||||
tool-results/
|
||||
toolu_<tool-id>.txt # Large tool outputs
|
||||
memory/ # Persistent memory files (MEMORY.md, etc.)
|
||||
history.jsonl # Global prompt history (no session IDs)
|
||||
tasks/ # Task tracking (per-session lock/highwatermark)
|
||||
plans/ # Plan documents (random-name.md)
|
||||
debug/ # Per-session debug logs (<session-id>.txt)
|
||||
transcripts/ # Global tool operation logs (ses_<id>.jsonl)
|
||||
file-history/ # File modification backups
|
||||
todos/ # Todo items
|
||||
```
|
||||
|
||||
## Path Normalization
|
||||
|
||||
Project paths are encoded by replacing `/` with `-`:
|
||||
|
||||
| Original | Normalized |
|
||||
|----------|-----------|
|
||||
| `/path/to/project` | `-path-to-project` |
|
||||
| `/another/workspace/app` | `-another-workspace-app` |
|
||||
|
||||
## sessions-index.json Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"entries": [
|
||||
{
|
||||
"sessionId": "20089b2a-e3dd-48b8-809c-0647128bf3b8",
|
||||
"fullPath": "~/.claude/projects/-path-to-project/20089b2a-....jsonl",
|
||||
"fileMtime": 1741327503477,
|
||||
"firstPrompt": "fix the login bug",
|
||||
"summary": "Fixed authentication redirect...",
|
||||
"messageCount": 42,
|
||||
"created": "2026-03-07T03:25:03.477Z",
|
||||
"modified": "2026-03-07T12:21:43.806Z",
|
||||
"gitBranch": "main",
|
||||
"projectPath": "/path/to/project",
|
||||
"isSidechain": false
|
||||
}
|
||||
],
|
||||
"originalPath": "/path/to/project"
|
||||
}
|
||||
```
|
||||
|
||||
Key fields for session identification:
|
||||
- `sessionId` — UUID v4 format
|
||||
- `firstPrompt` — first user message (best for topic matching)
|
||||
- `summary` — auto-generated summary of the session
|
||||
- `modified` — last activity timestamp (ISO 8601)
|
||||
- `gitBranch` — git branch at session time
|
||||
- `isSidechain` — `false` for main conversations
|
||||
|
||||
## Compaction in Session Files
|
||||
|
||||
Claude Code uses [server-side compaction](https://platform.claude.com/docs/en/build-with-claude/compaction). When context fills up, two consecutive lines appear:
|
||||
|
||||
### Line 1: compact_boundary marker
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "system",
|
||||
"subtype": "compact_boundary",
|
||||
"parentUuid": null,
|
||||
"logicalParentUuid": "prev-uuid",
|
||||
"compactMetadata": {
|
||||
"trigger": "input_tokens",
|
||||
"preTokens": 180000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Line 2: Compact summary (special user message)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "user",
|
||||
"isCompactSummary": true,
|
||||
"isVisibleInTranscriptOnly": true,
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\nAnalysis:\n1. **Initial Request**: User asked to...\n2. **Progress**: Completed X, Y, Z...\n3. **Current state**: Working on..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key properties:
|
||||
- **`isCompactSummary: true`** — most reliable way to identify compact summaries
|
||||
- **`isVisibleInTranscriptOnly: true`** — not sent to the API, only stored in the transcript
|
||||
- Summary is always a plain string in `.message.content` (not an array)
|
||||
- Typically 12K-31K characters (high-density information)
|
||||
- A long session may have multiple compact boundaries (4+ is common for 10MB+ sessions)
|
||||
- The **last** compact boundary's summary reflects the most recent state
|
||||
- Messages after the last boundary are the "hot zone" — they were in Claude's live context
|
||||
|
||||
### Default compaction prompt (from API docs)
|
||||
|
||||
> "You have written a partial transcript for the initial task above. Please write a summary of the transcript. The purpose of this summary is to provide continuity so you can continue to make progress towards solving the task in a future context, where the raw history above may not be accessible and will be replaced with this summary."
|
||||
|
||||
## Session JSONL Message Types
|
||||
|
||||
Each `.jsonl` file has one JSON object per line. Common types:
|
||||
|
||||
### file-history-snapshot (always first line)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "file-history-snapshot",
|
||||
"messageId": "uuid",
|
||||
"snapshot": { "trackedFileBackups": {}, "timestamp": "..." },
|
||||
"isSnapshotUpdate": false
|
||||
}
|
||||
```
|
||||
|
||||
### User message
|
||||
|
||||
```json
|
||||
{
|
||||
"parentUuid": "prev-uuid or null",
|
||||
"isSidechain": false,
|
||||
"cwd": "/path/to/project",
|
||||
"sessionId": "session-uuid",
|
||||
"version": "2.1.71",
|
||||
"gitBranch": "main",
|
||||
"type": "user",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": "fix the login bug"
|
||||
},
|
||||
"uuid": "msg-uuid",
|
||||
"timestamp": "2026-03-07T03:25:03.477Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: `.message.content` can be:
|
||||
- A **string** for plain text user messages
|
||||
- An **array** of content blocks for tool results and multi-part messages:
|
||||
```json
|
||||
"content": [
|
||||
{ "type": "tool_result", "tool_use_id": "toolu_...", "content": "..." },
|
||||
{ "type": "text", "text": "now do X" }
|
||||
]
|
||||
```
|
||||
|
||||
### Assistant message
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"model": "claude-opus-4-6",
|
||||
"content": [
|
||||
{ "type": "thinking", "thinking": "internal reasoning..." },
|
||||
{ "type": "text", "text": "visible response text" },
|
||||
{ "type": "tool_use", "id": "toolu_...", "name": "Bash", "input": { "command": "..." } }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Content block types in assistant messages:
|
||||
- `thinking` — internal reasoning (skip when extracting actionable context)
|
||||
- `text` — visible response to user (extract this)
|
||||
- `tool_use` — tool invocations (useful for understanding what was done)
|
||||
|
||||
### Tool result (user message with tool output)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "tool_result", "tool_use_id": "toolu_...", "content": "command output..." }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## history.jsonl Schema
|
||||
|
||||
Global prompt log. Does NOT contain session IDs — only useful for finding when a prompt was issued and in which project:
|
||||
|
||||
```json
|
||||
{
|
||||
"display": "/init ",
|
||||
"pastedContents": {},
|
||||
"timestamp": 1758996122446,
|
||||
"project": "/path/to/project"
|
||||
}
|
||||
```
|
||||
|
||||
## Extraction Patterns
|
||||
|
||||
### List recent sessions for current project
|
||||
|
||||
```bash
|
||||
cat ~/.claude/projects/<normalized-path>/sessions-index.json \
|
||||
| jq '.entries | sort_by(.modified) | reverse | .[:5] | .[] | {sessionId, firstPrompt, summary, messageCount, modified, gitBranch}'
|
||||
```
|
||||
|
||||
### Extract user text from session tail
|
||||
|
||||
Handles both string and array content formats:
|
||||
|
||||
```bash
|
||||
tail -n 200 <session-file>.jsonl \
|
||||
| jq -r 'select(.type == "user" and .message.role == "user")
|
||||
| .message.content
|
||||
| if type == "string" then .
|
||||
elif type == "array" then map(select(.type == "text") | .text) | join("\n")
|
||||
else empty end' \
|
||||
| tail -n 80
|
||||
```
|
||||
|
||||
### Extract assistant text (excluding thinking/tool_use)
|
||||
|
||||
```bash
|
||||
tail -n 200 <session-file>.jsonl \
|
||||
| jq -r 'select(.message.role == "assistant")
|
||||
| .message.content
|
||||
| if type == "array" then map(select(.type == "text") | .text) | join("\n")
|
||||
else empty end' \
|
||||
| tail -n 120
|
||||
```
|
||||
|
||||
### Find sessions by keyword in firstPrompt
|
||||
|
||||
```bash
|
||||
cat ~/.claude/projects/<normalized-path>/sessions-index.json \
|
||||
| jq -r '.entries[] | select(.firstPrompt | test("keyword"; "i")) | "\(.modified) \(.sessionId) \(.firstPrompt)"'
|
||||
```
|
||||
837
continue-claude-work/scripts/extract_resume_context.py
Executable file
837
continue-claude-work/scripts/extract_resume_context.py
Executable file
@@ -0,0 +1,837 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract actionable resume context from Claude Code session files.
|
||||
|
||||
Produces a structured Markdown briefing by fusing:
|
||||
- Session index metadata (sessions-index.json)
|
||||
- Compact boundary summaries (highest-signal context)
|
||||
- Post-compact user/assistant messages (the "hot zone")
|
||||
- Subagent workflow state (multi-agent recovery)
|
||||
- Session end reason detection
|
||||
- Git workspace state
|
||||
- MEMORY.md persistent context
|
||||
- Interrupted tool-call detection
|
||||
|
||||
Usage:
|
||||
# Extract context from latest session for current project
|
||||
python3 extract_resume_context.py
|
||||
|
||||
# Extract context from a specific session
|
||||
python3 extract_resume_context.py --session <SESSION_ID>
|
||||
|
||||
# Search sessions by topic
|
||||
python3 extract_resume_context.py --query "auth feature"
|
||||
|
||||
# List recent sessions
|
||||
python3 extract_resume_context.py --list
|
||||
|
||||
# Specify project path explicitly
|
||||
python3 extract_resume_context.py --project /path/to/project
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
CLAUDE_DIR = Path.home() / ".claude"
|
||||
PROJECTS_DIR = CLAUDE_DIR / "projects"
|
||||
|
||||
# Message types that are noise — skip when extracting context
|
||||
NOISE_TYPES = {"progress", "queue-operation", "file-history-snapshot", "last-prompt"}
|
||||
# System message subtypes that are noise
|
||||
NOISE_SUBTYPES = {"api_error", "turn_duration", "stop_hook_summary"}
|
||||
|
||||
# Patterns that indicate system/internal content, not real user requests
|
||||
NOISE_USER_PATTERNS = [
|
||||
"This session is being continued",
|
||||
"<task-notification>",
|
||||
"<system-reminder>",
|
||||
]
|
||||
|
||||
|
||||
def normalize_path(project_path: str) -> str:
|
||||
"""Convert absolute path to Claude's normalized directory name."""
|
||||
return project_path.replace("/", "-")
|
||||
|
||||
|
||||
def find_project_dir(project_path: str) -> Path | None:
|
||||
"""Find the Claude projects directory for a given project path."""
|
||||
abs_path = os.path.abspath(project_path)
|
||||
|
||||
# If the path is already inside ~/.claude/projects/, use it directly
|
||||
projects_str = str(PROJECTS_DIR) + "/"
|
||||
if abs_path.startswith(projects_str):
|
||||
candidate = Path(abs_path)
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
rel = abs_path[len(projects_str):]
|
||||
top_dir = PROJECTS_DIR / rel.split("/")[0]
|
||||
if top_dir.is_dir():
|
||||
return top_dir
|
||||
|
||||
normalized = normalize_path(abs_path)
|
||||
candidate = PROJECTS_DIR / normalized
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
# Fallback: search for partial match
|
||||
for d in PROJECTS_DIR.iterdir():
|
||||
if d.is_dir() and normalized in d.name:
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def load_sessions_index(project_dir: Path) -> list[dict]:
|
||||
"""Load and parse sessions-index.json, sorted by modified desc."""
|
||||
index_file = project_dir / "sessions-index.json"
|
||||
if not index_file.exists():
|
||||
return []
|
||||
with open(index_file, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
entries = data.get("entries", [])
|
||||
entries.sort(key=lambda e: e.get("modified", ""), reverse=True)
|
||||
return entries
|
||||
|
||||
|
||||
def search_sessions(entries: list[dict], query: str) -> list[dict]:
|
||||
"""Search sessions by keyword in firstPrompt and summary."""
|
||||
query_lower = query.lower()
|
||||
results = []
|
||||
for entry in entries:
|
||||
first_prompt = (entry.get("firstPrompt") or "").lower()
|
||||
summary = (entry.get("summary") or "").lower()
|
||||
if query_lower in first_prompt or query_lower in summary:
|
||||
results.append(entry)
|
||||
return results
|
||||
|
||||
|
||||
def format_session_entry(entry: dict, file_exists: bool = True) -> str:
|
||||
"""Format a session index entry for display."""
|
||||
sid = entry.get("sessionId", "?")
|
||||
modified = entry.get("modified", "?")
|
||||
msgs = entry.get("messageCount", "?")
|
||||
branch = entry.get("gitBranch", "?")
|
||||
prompt = (entry.get("firstPrompt") or "")[:80]
|
||||
ghost = "" if file_exists else " [file missing]"
|
||||
return f" {sid} [{branch}] {msgs} msgs {modified}{ghost}\n {prompt}"
|
||||
|
||||
|
||||
# ── Session file parsing ────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_session_structure(session_file: Path) -> dict:
|
||||
"""Parse a session JSONL file and return structured data."""
|
||||
file_size = session_file.stat().st_size
|
||||
total_lines = 0
|
||||
|
||||
# First pass: find compact boundaries and count lines
|
||||
compact_boundaries = [] # (line_num, summary_text)
|
||||
with open(session_file, encoding="utf-8") as f:
|
||||
prev_boundary_line = None
|
||||
for i, raw_line in enumerate(f):
|
||||
total_lines += 1
|
||||
|
||||
# Detect compact summary via isCompactSummary flag (most reliable)
|
||||
if '"isCompactSummary"' in raw_line:
|
||||
try:
|
||||
obj = json.loads(raw_line)
|
||||
if obj.get("isCompactSummary"):
|
||||
content = obj.get("message", {}).get("content", "")
|
||||
if isinstance(content, str):
|
||||
boundary_line = prev_boundary_line if prev_boundary_line is not None else max(0, i - 1)
|
||||
compact_boundaries.append((boundary_line, content))
|
||||
prev_boundary_line = None
|
||||
continue
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Detect compact_boundary marker
|
||||
if '"compact_boundary"' in raw_line and '"subtype"' in raw_line:
|
||||
try:
|
||||
obj = json.loads(raw_line)
|
||||
if obj.get("subtype") == "compact_boundary":
|
||||
prev_boundary_line = i
|
||||
continue
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Fallback: if prev line was boundary and this is a user message with long string content
|
||||
if prev_boundary_line is not None:
|
||||
try:
|
||||
obj = json.loads(raw_line)
|
||||
content = obj.get("message", {}).get("content", "")
|
||||
if isinstance(content, str) and len(content) > 100:
|
||||
compact_boundaries.append((prev_boundary_line, content))
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
compact_boundaries.append((prev_boundary_line, ""))
|
||||
prev_boundary_line = None
|
||||
|
||||
# Determine hot zone: everything after last compact boundary + its summary
|
||||
if compact_boundaries:
|
||||
last_boundary_line = compact_boundaries[-1][0]
|
||||
hot_zone_start = last_boundary_line + 2 # skip boundary + summary
|
||||
else:
|
||||
# No compact boundaries: use size-adaptive strategy
|
||||
if file_size < 500_000: # <500KB: read last 60%
|
||||
hot_zone_start = max(0, int(total_lines * 0.4))
|
||||
elif file_size < 5_000_000: # <5MB: read last 30%
|
||||
hot_zone_start = max(0, int(total_lines * 0.7))
|
||||
else: # >5MB: read last 15%
|
||||
hot_zone_start = max(0, int(total_lines * 0.85))
|
||||
|
||||
# Second pass: extract hot zone messages
|
||||
messages = []
|
||||
unresolved_tool_calls = {} # tool_use_id -> tool_use_info
|
||||
errors = []
|
||||
files_touched = set()
|
||||
last_message_role = None
|
||||
error_count = 0
|
||||
|
||||
with open(session_file, encoding="utf-8") as f:
|
||||
for i, raw_line in enumerate(f):
|
||||
if i < hot_zone_start:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(raw_line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
msg_type = obj.get("type", "")
|
||||
if msg_type in NOISE_TYPES:
|
||||
continue
|
||||
if msg_type == "system":
|
||||
subtype = obj.get("subtype", "")
|
||||
if subtype in NOISE_SUBTYPES:
|
||||
if subtype == "api_error":
|
||||
error_count += 1
|
||||
continue
|
||||
if subtype == "compact_boundary":
|
||||
continue
|
||||
|
||||
# Track tool calls and results
|
||||
msg = obj.get("message", {})
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
|
||||
# Extract tool_use from assistant messages
|
||||
if role == "assistant" and isinstance(content, list):
|
||||
for block in content:
|
||||
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
||||
continue
|
||||
tool_id = block.get("id", "")
|
||||
tool_name = block.get("name", "?")
|
||||
inp = block.get("input", {})
|
||||
unresolved_tool_calls[tool_id] = {
|
||||
"name": tool_name,
|
||||
"input_preview": str(inp)[:200],
|
||||
}
|
||||
# Track file operations
|
||||
if tool_name in ("Write", "Edit", "Read"):
|
||||
fp = inp.get("file_path", "")
|
||||
if fp:
|
||||
files_touched.add(fp)
|
||||
elif tool_name == "Bash":
|
||||
cmd = inp.get("command", "")
|
||||
for match in re.findall(r'(?<!\w)(/[a-zA-Z][\w./\-]+)', cmd):
|
||||
if not match.startswith("/dev/"):
|
||||
files_touched.add(match)
|
||||
|
||||
# Resolve tool results
|
||||
if role == "user" and isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
tool_id = block.get("tool_use_id", "")
|
||||
unresolved_tool_calls.pop(tool_id, None)
|
||||
is_error = block.get("is_error", False)
|
||||
result_content = block.get("content", "")
|
||||
if is_error and isinstance(result_content, str):
|
||||
errors.append(result_content[:500])
|
||||
|
||||
# Track last message for end-reason detection
|
||||
if role in ("user", "assistant"):
|
||||
last_message_role = role
|
||||
messages.append(obj)
|
||||
|
||||
# Detect session end reason
|
||||
end_reason = _detect_end_reason(
|
||||
last_message_role, unresolved_tool_calls, error_count,
|
||||
)
|
||||
|
||||
return {
|
||||
"total_lines": total_lines,
|
||||
"file_size": file_size,
|
||||
"compact_boundaries": compact_boundaries,
|
||||
"hot_zone_start": hot_zone_start,
|
||||
"messages": messages,
|
||||
"unresolved_tool_calls": dict(unresolved_tool_calls),
|
||||
"errors": errors,
|
||||
"error_count": error_count,
|
||||
"files_touched": files_touched,
|
||||
"end_reason": end_reason,
|
||||
}
|
||||
|
||||
|
||||
def _detect_end_reason(
|
||||
last_role: str | None,
|
||||
unresolved: dict,
|
||||
error_count: int,
|
||||
) -> str:
|
||||
"""Detect why the session ended."""
|
||||
if unresolved:
|
||||
return "interrupted" # Tool calls dispatched but no results — likely ctrl-c
|
||||
if error_count >= 3:
|
||||
return "error_cascade" # Multiple API errors suggest systemic failure
|
||||
if last_role == "assistant":
|
||||
return "completed" # Assistant had the last word — clean end
|
||||
if last_role == "user":
|
||||
return "abandoned" # User sent a message but got no response
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _is_noise_user_text(text: str) -> bool:
|
||||
"""Check if user text is system noise rather than a real request."""
|
||||
for pattern in NOISE_USER_PATTERNS:
|
||||
if text.startswith(pattern) or pattern in text[:200]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_user_text(messages: list[dict], limit: int = 5) -> list[str]:
|
||||
"""Extract the last N user text messages (not tool results or system noise)."""
|
||||
user_texts = []
|
||||
for msg_obj in reversed(messages):
|
||||
if msg_obj.get("isCompactSummary"):
|
||||
continue
|
||||
msg = msg_obj.get("message", {})
|
||||
if msg.get("role") != "user":
|
||||
continue
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and content.strip():
|
||||
if _is_noise_user_text(content):
|
||||
continue
|
||||
user_texts.append(content.strip())
|
||||
elif isinstance(content, list):
|
||||
texts = [
|
||||
b.get("text", "")
|
||||
for b in content
|
||||
if isinstance(b, dict) and b.get("type") == "text"
|
||||
]
|
||||
combined = "\n".join(t for t in texts if t.strip())
|
||||
if combined and not _is_noise_user_text(combined):
|
||||
user_texts.append(combined)
|
||||
if len(user_texts) >= limit:
|
||||
break
|
||||
user_texts.reverse()
|
||||
return user_texts
|
||||
|
||||
|
||||
def extract_assistant_text(messages: list[dict], limit: int = 3) -> list[str]:
|
||||
"""Extract the last N assistant text responses (no thinking/tool_use)."""
|
||||
assistant_texts = []
|
||||
for msg_obj in reversed(messages):
|
||||
msg = msg_obj.get("message", {})
|
||||
if msg.get("role") != "assistant":
|
||||
continue
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
texts = [
|
||||
b.get("text", "")
|
||||
for b in content
|
||||
if isinstance(b, dict) and b.get("type") == "text"
|
||||
]
|
||||
combined = "\n".join(t for t in texts if t.strip())
|
||||
if combined:
|
||||
assistant_texts.append(combined[:2000])
|
||||
if len(assistant_texts) >= limit:
|
||||
break
|
||||
assistant_texts.reverse()
|
||||
return assistant_texts
|
||||
|
||||
|
||||
# ── Subagent extraction ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def extract_subagent_context(session_file: Path) -> list[dict]:
|
||||
"""Extract subagent summaries from session subdirectories.
|
||||
|
||||
Returns list of {name, type, status, last_text, is_interrupted}.
|
||||
"""
|
||||
session_dir = session_file.parent / session_file.stem
|
||||
subagents_dir = session_dir / "subagents"
|
||||
if not subagents_dir.is_dir():
|
||||
return []
|
||||
|
||||
# Group by agent ID: find meta.json and .jsonl pairs
|
||||
agent_ids = set()
|
||||
for f in subagents_dir.iterdir():
|
||||
if f.suffix == ".jsonl":
|
||||
agent_ids.add(f.stem)
|
||||
|
||||
results = []
|
||||
for agent_id in sorted(agent_ids):
|
||||
jsonl_file = subagents_dir / f"{agent_id}.jsonl"
|
||||
meta_file = subagents_dir / f"{agent_id}.meta.json"
|
||||
|
||||
# Parse agent type from ID (format: agent-a<type>-<hash> or agent-a<hash>)
|
||||
agent_type = "unknown"
|
||||
if meta_file.exists():
|
||||
try:
|
||||
with open(meta_file, encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
agent_type = meta.get("type", meta.get("subagent_type", "unknown"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
if agent_type == "unknown":
|
||||
# Infer from ID pattern: agent-a<type>-<hash>
|
||||
match = re.match(r'agent-a(compact|prompt_suggestion|[a-z_]+)-', agent_id)
|
||||
if match:
|
||||
agent_type = match.group(1)
|
||||
|
||||
# Skip compact and prompt_suggestion agents (internal, not user work)
|
||||
if agent_type in ("compact", "prompt_suggestion"):
|
||||
continue
|
||||
|
||||
# Read last few lines for final output
|
||||
last_text = ""
|
||||
is_interrupted = False
|
||||
line_count = 0
|
||||
|
||||
if jsonl_file.exists():
|
||||
try:
|
||||
lines = jsonl_file.read_text(encoding="utf-8").strip().split("\n")
|
||||
line_count = len(lines)
|
||||
has_tool_use_pending = False
|
||||
# Check last 10 lines for final assistant text
|
||||
for raw_line in reversed(lines[-10:]):
|
||||
try:
|
||||
obj = json.loads(raw_line)
|
||||
msg = obj.get("message", {})
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
|
||||
if role == "assistant" and isinstance(content, list):
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
block_type = block.get("type")
|
||||
if block_type == "tool_use":
|
||||
has_tool_use_pending = True
|
||||
elif block_type == "text":
|
||||
text = block.get("text", "")
|
||||
if text.strip() and not last_text:
|
||||
last_text = text.strip()[:500]
|
||||
|
||||
if role == "user" and isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
has_tool_use_pending = False
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
is_interrupted = has_tool_use_pending
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
results.append({
|
||||
"id": agent_id,
|
||||
"type": agent_type,
|
||||
"last_text": last_text,
|
||||
"is_interrupted": is_interrupted,
|
||||
"lines": line_count,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ── Context sources ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_git_state(project_path: str) -> str:
|
||||
"""Get current git status and recent log."""
|
||||
parts = []
|
||||
try:
|
||||
branch = subprocess.run(
|
||||
["git", "branch", "--show-current"],
|
||||
capture_output=True, text=True, cwd=project_path, timeout=5,
|
||||
)
|
||||
if branch.stdout.strip():
|
||||
parts.append(f"**Current branch**: `{branch.stdout.strip()}`")
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
try:
|
||||
status = subprocess.run(
|
||||
["git", "status", "--short"],
|
||||
capture_output=True, text=True, cwd=project_path, timeout=10,
|
||||
)
|
||||
if status.stdout.strip():
|
||||
parts.append(f"### git status\n```\n{status.stdout.strip()}\n```")
|
||||
else:
|
||||
parts.append("### git status\nClean working tree.")
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
parts.append("### git status\n(unavailable)")
|
||||
|
||||
try:
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-5"],
|
||||
capture_output=True, text=True, cwd=project_path, timeout=10,
|
||||
)
|
||||
if log.stdout.strip():
|
||||
parts.append(f"### git log (last 5)\n```\n{log.stdout.strip()}\n```")
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def get_memory_md(project_dir: Path) -> str | None:
|
||||
"""Read MEMORY.md if it exists in the project's memory directory."""
|
||||
memory_dir = project_dir / "memory"
|
||||
memory_file = memory_dir / "MEMORY.md"
|
||||
if memory_file.exists():
|
||||
content = memory_file.read_text(encoding="utf-8").strip()
|
||||
if content:
|
||||
return content[:3000]
|
||||
return None
|
||||
|
||||
|
||||
def get_session_memory(session_file: Path) -> str | None:
|
||||
"""Read session-memory/summary.md if it exists (newer CC versions)."""
|
||||
session_dir = session_file.parent / session_file.stem
|
||||
summary = session_dir / "session-memory" / "summary.md"
|
||||
if summary.exists():
|
||||
content = summary.read_text(encoding="utf-8").strip()
|
||||
if content:
|
||||
return content[:3000]
|
||||
return None
|
||||
|
||||
|
||||
# ── Output formatting ────────────────────────────────────────────────
|
||||
|
||||
|
||||
END_REASON_LABELS = {
|
||||
"completed": "Clean exit (assistant completed response)",
|
||||
"interrupted": "Interrupted (unresolved tool calls — likely ctrl-c or timeout)",
|
||||
"error_cascade": "Error cascade (multiple API errors)",
|
||||
"abandoned": "Abandoned (user message with no response)",
|
||||
"unknown": "Unknown",
|
||||
}
|
||||
|
||||
|
||||
def build_briefing(
|
||||
session_entry: dict | None,
|
||||
parsed: dict,
|
||||
project_path: str,
|
||||
project_dir: Path,
|
||||
session_file: Path,
|
||||
) -> str:
|
||||
"""Build the structured Markdown briefing."""
|
||||
sections = []
|
||||
|
||||
# Header
|
||||
sections.append("# Resume Context Briefing\n")
|
||||
|
||||
# Session metadata
|
||||
if session_entry:
|
||||
sid = session_entry.get("sessionId", "?")
|
||||
modified = session_entry.get("modified", "?")
|
||||
branch = session_entry.get("gitBranch", "?")
|
||||
msg_count = session_entry.get("messageCount", "?")
|
||||
first_prompt = session_entry.get("firstPrompt", "")
|
||||
summary = session_entry.get("summary", "")
|
||||
|
||||
sections.append("## Session Info\n")
|
||||
sections.append(f"- **ID**: `{sid}`")
|
||||
sections.append(f"- **Last active**: {modified}")
|
||||
sections.append(f"- **Branch**: `{branch}`")
|
||||
sections.append(f"- **Messages**: {msg_count}")
|
||||
sections.append(f"- **First prompt**: {first_prompt}")
|
||||
if summary:
|
||||
sections.append(f"- **Summary**: {summary[:300]}")
|
||||
|
||||
# File stats + end reason
|
||||
file_mb = parsed["file_size"] / 1_000_000
|
||||
end_label = END_REASON_LABELS.get(parsed["end_reason"], parsed["end_reason"])
|
||||
sections.append(f"\n**Session file**: {file_mb:.1f} MB, {parsed['total_lines']} lines, "
|
||||
f"{len(parsed['compact_boundaries'])} compaction(s)")
|
||||
sections.append(f"**Session end reason**: {end_label}")
|
||||
if parsed["error_count"] > 0:
|
||||
sections.append(f"**API errors**: {parsed['error_count']}")
|
||||
|
||||
# Session memory (newer CC versions generate this automatically)
|
||||
session_mem = get_session_memory(session_file)
|
||||
if session_mem:
|
||||
sections.append("\n## Session Memory (auto-generated by Claude Code)\n")
|
||||
sections.append(session_mem)
|
||||
|
||||
# Compact summary (highest-signal context)
|
||||
if parsed["compact_boundaries"]:
|
||||
last_summary = parsed["compact_boundaries"][-1][1]
|
||||
if last_summary:
|
||||
# Allow up to 8K chars — this is the highest-signal content
|
||||
display = last_summary[:8000]
|
||||
if len(last_summary) > 8000:
|
||||
display += f"\n\n... (truncated, full summary: {len(last_summary)} chars)"
|
||||
sections.append("\n## Compact Summary (auto-generated by previous session)\n")
|
||||
sections.append(display)
|
||||
|
||||
# Last user requests
|
||||
user_texts = extract_user_text(parsed["messages"])
|
||||
if user_texts:
|
||||
sections.append("\n## Last User Requests\n")
|
||||
for i, text in enumerate(user_texts, 1):
|
||||
display = text[:500]
|
||||
if len(text) > 500:
|
||||
display += "..."
|
||||
sections.append(f"### Request {i}\n{display}\n")
|
||||
|
||||
# Last assistant responses
|
||||
assistant_texts = extract_assistant_text(parsed["messages"])
|
||||
if assistant_texts:
|
||||
sections.append("\n## Last Assistant Responses\n")
|
||||
for i, text in enumerate(assistant_texts, 1):
|
||||
display = text[:1000]
|
||||
if len(text) > 1000:
|
||||
display += "..."
|
||||
sections.append(f"### Response {i}\n{display}\n")
|
||||
|
||||
# Errors encountered
|
||||
if parsed["errors"]:
|
||||
sections.append("\n## Errors Encountered\n")
|
||||
seen = set()
|
||||
for err in parsed["errors"]:
|
||||
short = err[:200]
|
||||
if short not in seen:
|
||||
seen.add(short)
|
||||
sections.append(f"```\n{err}\n```\n")
|
||||
|
||||
# Unresolved tool calls (interrupted session)
|
||||
if parsed["unresolved_tool_calls"]:
|
||||
sections.append("\n## Unresolved Tool Calls (session was interrupted)\n")
|
||||
for tool_id, info in parsed["unresolved_tool_calls"].items():
|
||||
sections.append(f"- **{info['name']}**: `{tool_id}`")
|
||||
sections.append(f" Input: {info['input_preview']}")
|
||||
|
||||
# Subagent context (the "nobody has done this" feature)
|
||||
subagents = extract_subagent_context(session_file)
|
||||
if subagents:
|
||||
interrupted = [s for s in subagents if s["is_interrupted"]]
|
||||
completed = [s for s in subagents if not s["is_interrupted"]]
|
||||
sections.append(f"\n## Subagent Workflow ({len(completed)} completed, {len(interrupted)} interrupted)\n")
|
||||
if interrupted:
|
||||
sections.append("### Interrupted Subagents\n")
|
||||
for sa in interrupted:
|
||||
sections.append(f"- **{sa['type']}** (`{sa['id']}`, {sa['lines']} lines)")
|
||||
if sa["last_text"]:
|
||||
sections.append(f" Last output: {sa['last_text'][:300]}")
|
||||
if completed:
|
||||
sections.append("\n### Completed Subagents\n")
|
||||
for sa in completed:
|
||||
sections.append(f"- **{sa['type']}** (`{sa['id']}`, {sa['lines']} lines)")
|
||||
if sa["last_text"]:
|
||||
sections.append(f" Last output: {sa['last_text'][:200]}")
|
||||
|
||||
# Files touched in session
|
||||
if parsed["files_touched"]:
|
||||
sections.append("\n## Files Touched in Session\n")
|
||||
for fp in sorted(parsed["files_touched"])[:30]:
|
||||
sections.append(f"- `{fp}`")
|
||||
|
||||
# MEMORY.md
|
||||
memory = get_memory_md(project_dir)
|
||||
if memory:
|
||||
sections.append("\n## Persistent Memory (MEMORY.md)\n")
|
||||
sections.append(memory)
|
||||
|
||||
# Git state
|
||||
sections.append("\n## Current Workspace State\n")
|
||||
sections.append(get_git_state(project_path))
|
||||
|
||||
return "\n".join(sections)
|
||||
|
||||
|
||||
# ── CLI ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_session_files(entries: list[dict], project_dir: Path) -> dict[str, bool]:
|
||||
"""Check which index entries have actual files on disk."""
|
||||
status = {}
|
||||
for entry in entries:
|
||||
sid = entry.get("sessionId", "")
|
||||
session_file = project_dir / f"{sid}.jsonl"
|
||||
if session_file.exists():
|
||||
status[sid] = True
|
||||
else:
|
||||
full_path = entry.get("fullPath", "")
|
||||
status[sid] = bool(full_path and Path(full_path).exists())
|
||||
return status
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract actionable resume context from Claude Code sessions.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project", "-p",
|
||||
default=os.getcwd(),
|
||||
help="Project path (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--session", "-s",
|
||||
default=None,
|
||||
help="Session ID to extract context from",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--query", "-q",
|
||||
default=None,
|
||||
help="Search sessions by keyword in firstPrompt/summary",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list", "-l",
|
||||
action="store_true",
|
||||
help="List recent sessions",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit", "-n",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Number of sessions to list (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude-current",
|
||||
default=None,
|
||||
help="Session ID to exclude (typically the currently active session)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
project_path = os.path.abspath(args.project)
|
||||
project_dir = find_project_dir(project_path)
|
||||
|
||||
if not project_dir:
|
||||
print(f"Error: no Claude session data found for {project_path}", file=sys.stderr)
|
||||
print(f"Looked in: {PROJECTS_DIR / normalize_path(project_path)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
entries = load_sessions_index(project_dir)
|
||||
|
||||
# ── List mode ──
|
||||
if args.list:
|
||||
# Show both index entries and actual files, with file-exists status
|
||||
file_status = _check_session_files(entries, project_dir)
|
||||
existing = sum(1 for v in file_status.values() if v)
|
||||
missing = sum(1 for v in file_status.values() if not v)
|
||||
|
||||
if not entries and not list(project_dir.glob("*.jsonl")):
|
||||
print("No sessions found.")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Sessions for {project_path}:\n")
|
||||
if missing > 0:
|
||||
print(f" (index has {len(entries)} entries, {existing} with files, {missing} with missing files)\n")
|
||||
|
||||
for entry in entries[:args.limit]:
|
||||
sid = entry.get("sessionId", "")
|
||||
exists = file_status.get(sid, False)
|
||||
print(format_session_entry(entry, file_exists=exists))
|
||||
print()
|
||||
|
||||
# Also show files NOT in index
|
||||
indexed_ids = {e.get("sessionId") for e in entries}
|
||||
orphan_files = [
|
||||
f for f in sorted(project_dir.glob("*.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||||
if f.stem not in indexed_ids
|
||||
]
|
||||
if orphan_files:
|
||||
print(f" Files not in index ({len(orphan_files)}):")
|
||||
for f in orphan_files[:5]:
|
||||
size_kb = f.stat().st_size / 1000
|
||||
print(f" {f.stem} ({size_kb:.0f} KB)")
|
||||
print()
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
# ── Query mode ──
|
||||
if args.query:
|
||||
results = search_sessions(entries, args.query)
|
||||
if not results:
|
||||
print(f"No sessions matching '{args.query}'.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"Sessions matching '{args.query}' ({len(results)} found):\n")
|
||||
for entry in results[: args.limit]:
|
||||
print(format_session_entry(entry))
|
||||
print()
|
||||
if len(results) == 1:
|
||||
args.session = results[0]["sessionId"]
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
# ── Extract mode ──
|
||||
session_id = args.session
|
||||
session_entry = None
|
||||
|
||||
if session_id:
|
||||
for entry in entries:
|
||||
if entry.get("sessionId") == session_id:
|
||||
session_entry = entry
|
||||
break
|
||||
session_file = project_dir / f"{session_id}.jsonl"
|
||||
if not session_file.exists():
|
||||
if session_entry:
|
||||
full_path = session_entry.get("fullPath", "")
|
||||
if full_path and Path(full_path).exists():
|
||||
session_file = Path(full_path)
|
||||
if not session_file.exists():
|
||||
for jsonl in project_dir.glob("*.jsonl"):
|
||||
if session_id in jsonl.name:
|
||||
session_file = jsonl
|
||||
break
|
||||
else:
|
||||
print(f"Error: session file not found for {session_id}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Use latest session — prefer actual files, skip current session
|
||||
jsonl_files = sorted(
|
||||
project_dir.glob("*.jsonl"),
|
||||
key=lambda f: f.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if args.exclude_current:
|
||||
jsonl_files = [f for f in jsonl_files if f.stem != args.exclude_current]
|
||||
if not jsonl_files:
|
||||
print("Error: no session files found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Skip the most recent file if it's likely the current active session
|
||||
# (modified within the last 60 seconds and no explicit session requested)
|
||||
if len(jsonl_files) > 1:
|
||||
newest = jsonl_files[0]
|
||||
age_seconds = time.time() - newest.stat().st_mtime
|
||||
if age_seconds < 60:
|
||||
print(f"Skipping active session {newest.stem} (modified {age_seconds:.0f}s ago)",
|
||||
file=sys.stderr)
|
||||
jsonl_files = jsonl_files[1:]
|
||||
|
||||
session_file = jsonl_files[0]
|
||||
session_id = session_file.stem
|
||||
for entry in entries:
|
||||
if entry.get("sessionId") == session_id:
|
||||
session_entry = entry
|
||||
break
|
||||
|
||||
# Parse and build briefing
|
||||
print(f"Parsing session {session_id} ({session_file.stat().st_size / 1_000_000:.1f} MB)...",
|
||||
file=sys.stderr)
|
||||
|
||||
parsed = parse_session_structure(session_file)
|
||||
briefing = build_briefing(session_entry, parsed, project_path, project_dir, session_file)
|
||||
|
||||
print(briefing)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user