Add transcript backup tracking system and pre-compact hook improvements
- pre-compact-backup.sh: derive transcript path from session_id+cwd (no longer relies on transcript_path field that Claude Code stopped providing); register each backup in tracking.json after saving - extract-transcripts.py: new script managing tracking.json — register, list, extract conversation text, mark-processed modes - list-transcripts-here.sh: thin wrapper for extract-transcripts --list using $(pwd); needed because SKILL.md bang commands reject $() substitution - install-hooks.sh: now also symlinks skill-helper scripts into ~/.claude/scripts/ via a curated SKILL_HELPERS list - Memory docs: new script-extract-transcripts.md, script-list-transcripts-here.md; updated skill-log.md, script-install-hooks.md, MEMORY.md index Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,9 +13,12 @@
|
|||||||
- [git-status-report](memory/script-git-status-report.md) — Scans directories for git repos, reports uncommitted changes and remote sync (lives in small-scripts)
|
- [git-status-report](memory/script-git-status-report.md) — Scans directories for git repos, reports uncommitted changes and remote sync (lives in small-scripts)
|
||||||
- [statusline](memory/script-statusline.md) — Status bar scripts: statusline.sh (renderer) and set-topic.sh (topic setter) for per-session topic display
|
- [statusline](memory/script-statusline.md) — Status bar scripts: statusline.sh (renderer) and set-topic.sh (topic setter) for per-session topic display
|
||||||
- [require-plan-file](memory/script-require-plan-file.md) — PreToolUse hook: blocks ExitPlanMode unless a *-PLAN.md file exists in the project root
|
- [require-plan-file](memory/script-require-plan-file.md) — PreToolUse hook: blocks ExitPlanMode unless a *-PLAN.md file exists in the project root
|
||||||
|
- [extract-transcripts](memory/script-extract-transcripts.md) — Manages transcript backup tracking: register, list, extract, mark-processed. Used by /log and pre-compact hook
|
||||||
|
- [list-transcripts-here](memory/script-list-transcripts-here.md) — Wrapper calling extract-transcripts.py --list $(pwd); exists to avoid $() in SKILL.md bang commands
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
|
- [/end-session](memory/skill-end-session.md) — End-of-session wrap-up: session log + CONTEXT.md update for seamless resumption
|
||||||
- [/log](memory/skill-log.md) — End-of-session logging to memory/log/ for later reflection
|
- [/log](memory/skill-log.md) — End-of-session logging to memory/log/ for later reflection
|
||||||
- [/reflect-logs](memory/skill-reflect-logs.md) — Processes session logs into topic memory files (gotchas, decisions, process lessons)
|
- [/reflect-logs](memory/skill-reflect-logs.md) — Processes session logs into topic memory files (gotchas, decisions, process lessons)
|
||||||
- [/reflect](memory/skill-reflect.md) — Milestone reflection: reviews conversation + git log, produces structured artifacts
|
- [/reflect](memory/skill-reflect.md) — Milestone reflection: reviews conversation + git log, produces structured artifacts
|
||||||
|
|||||||
@@ -1,17 +1,36 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Pre-compact hook: save a copy of the session transcript before compaction.
|
# Pre-compact hook: save a copy of the session transcript before compaction.
|
||||||
# Claude Code pipes JSON to stdin with session_id, transcript_path, cwd, etc.
|
# Claude Code pipes JSON with session_id, cwd, hook_event_name (no transcript_path).
|
||||||
|
# Derives the transcript location from session_id + cwd.
|
||||||
|
# After saving, registers the backup in ~/.claude/transcript-backups/tracking.json.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Parse stdin JSON for transcript path and session ID
|
# Parse stdin JSON
|
||||||
INPUT="$(cat)"
|
INPUT="$(cat)"
|
||||||
TRANSCRIPT_PATH="$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('transcript_path',''))" 2>/dev/null)"
|
|
||||||
SESSION_ID="$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null)"
|
SESSION_ID="$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null)"
|
||||||
TRIGGER="$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('trigger','unknown'))" 2>/dev/null)"
|
CWD="$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null)"
|
||||||
|
PROVIDED_PATH="$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('transcript_path',''))" 2>/dev/null)"
|
||||||
|
|
||||||
if [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
|
if [ -z "$SESSION_ID" ]; then
|
||||||
echo "pre-compact-backup: no transcript found, skipping" >&2
|
echo "pre-compact-backup: missing session_id in hook input, skipping" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try provided path first (older Claude Code versions), then derive from session_id + cwd
|
||||||
|
if [ -n "$PROVIDED_PATH" ] && [ -f "$PROVIDED_PATH" ]; then
|
||||||
|
TRANSCRIPT_PATH="$PROVIDED_PATH"
|
||||||
|
elif [ -n "$CWD" ]; then
|
||||||
|
# Derive transcript path: /home/paul/dev/claude -> -home-paul-dev-claude
|
||||||
|
PROJECT_DIR="$(echo "$CWD" | tr '/' '-')"
|
||||||
|
TRANSCRIPT_PATH="$HOME/.claude/projects/${PROJECT_DIR}/${SESSION_ID}.jsonl"
|
||||||
|
else
|
||||||
|
echo "pre-compact-backup: cannot locate transcript (no cwd or transcript_path), skipping" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$TRANSCRIPT_PATH" ]; then
|
||||||
|
echo "pre-compact-backup: transcript not found at ${TRANSCRIPT_PATH}, skipping" >&2
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -20,11 +39,21 @@ BACKUP_DIR="$HOME/.claude/transcript-backups"
|
|||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
BACKUP_FILE="${BACKUP_DIR}/${TIMESTAMP}-${SESSION_ID:0:8}-${TRIGGER}.jsonl"
|
BACKUP_NAME="${TIMESTAMP}-${SESSION_ID:0:8}.jsonl"
|
||||||
|
BACKUP_FILE="${BACKUP_DIR}/${BACKUP_NAME}"
|
||||||
|
|
||||||
cp "$TRANSCRIPT_PATH" "$BACKUP_FILE"
|
cp "$TRANSCRIPT_PATH" "$BACKUP_FILE"
|
||||||
|
|
||||||
# Prune backups older than 30 days
|
# Prune backups older than 30 days
|
||||||
find "$BACKUP_DIR" -name "*.jsonl" -mtime +30 -delete 2>/dev/null || true
|
find "$BACKUP_DIR" -name "*.jsonl" -mtime +30 -delete 2>/dev/null || true
|
||||||
|
|
||||||
echo "pre-compact-backup: saved $(wc -l < "$BACKUP_FILE") lines to ${BACKUP_FILE##*/}" >&2
|
# Register backup in tracking.json using the register helper
|
||||||
|
python3 "$HOME/.claude/scripts/extract-transcripts.py" \
|
||||||
|
--register "$BACKUP_NAME" \
|
||||||
|
--session-id "$SESSION_ID" \
|
||||||
|
--cwd "$CWD" \
|
||||||
|
2>/dev/null \
|
||||||
|
&& TRACKED=" [tracked]" \
|
||||||
|
|| TRACKED=" [tracking failed]"
|
||||||
|
|
||||||
|
echo "pre-compact-backup: saved $(wc -l < "$BACKUP_FILE") lines to ${BACKUP_NAME} [project: ${CWD}]${TRACKED}" >&2
|
||||||
|
|||||||
41
memory/script-extract-transcripts.md
Normal file
41
memory/script-extract-transcripts.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# script: extract-transcripts
|
||||||
|
|
||||||
|
**Location:** `claude-foundations/scripts/extract-transcripts.py`
|
||||||
|
**Symlinked to:** `~/.claude/scripts/extract-transcripts.py`
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Manages the transcript backup tracking system. Registers pre-compaction JSONL backups, lists unprocessed ones per project, extracts readable conversation text for analysis, and marks entries as processed after a log is written.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Register a new backup (called by pre-compact-backup.sh hook)
|
||||||
|
python3 ~/.claude/scripts/extract-transcripts.py \
|
||||||
|
--register BACKUP_NAME --session-id SESSION_ID --cwd PROJECT_CWD
|
||||||
|
|
||||||
|
# List unprocessed transcripts for a project (used by /log skill)
|
||||||
|
python3 ~/.claude/scripts/extract-transcripts.py --list /home/paul/dev/claude/projects/foo
|
||||||
|
# → compact JSON array of {backup, session_id, saved_at, path, exists}
|
||||||
|
|
||||||
|
# Extract readable conversation from one backup (used by /log Sonnet subagent)
|
||||||
|
python3 ~/.claude/scripts/extract-transcripts.py --extract BACKUP_NAME
|
||||||
|
|
||||||
|
# Mark all unprocessed backups for a project as done
|
||||||
|
python3 ~/.claude/scripts/extract-transcripts.py \
|
||||||
|
--mark-all-processed /home/paul/dev/claude/projects/foo --log-file memory/log/YYYY-MM-DD.HHMMSS-transcripts.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- Tracking state lives in `~/.claude/transcript-backups/tracking.json`, keyed by backup filename
|
||||||
|
- Each entry: `{session_id, cwd, saved_at, processed, log_file, processed_at}`
|
||||||
|
- `--list` filters by `cwd == project_cwd` and `processed == false` — output is metadata only (no content), safe for skill pre-gathering
|
||||||
|
- `--extract` reads the JSONL, skips sidechain entries, extracts `user`/`assistant` message text, truncates per-message at 3000 chars
|
||||||
|
- Tool use blocks are summarised as `[tool: ToolName(key=...)]` rather than shown in full
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- `sys.exit(0)` inside a bare `except: pass` block is caught as SystemExit — always use specific exception types or `break` when early exit is needed inside exception handlers
|
||||||
|
- The JSONL entries for `type: "user"` without a `message` field (e.g., file-history snapshots) are skipped silently
|
||||||
|
- Old backups with `-auto` suffix (from before this system) are not in tracking.json and will never appear in `--list`
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Symlinks all hook scripts from `claude-foundations/hooks/` into `~/.claude/hooks/` and prints the `settings.json` configuration to add.
|
Symlinks all hook scripts from `claude-foundations/hooks/` into `~/.claude/hooks/`, and symlinks skill-helper scripts from `claude-foundations/scripts/` into `~/.claude/scripts/`. Also prints the `settings.json` configuration to add.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -13,15 +13,27 @@ cd ~/dev/claude/projects/claude-foundations
|
|||||||
scripts/install-hooks.sh
|
scripts/install-hooks.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
One-time setup. Re-run after adding new hooks.
|
One-time setup. Re-run after adding new hooks or skill-helper scripts.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1. Iterates over `hooks/*.sh`
|
1. Iterates over `hooks/*.sh`, creates symlinks in `~/.claude/hooks/` (force-overwrites)
|
||||||
2. Creates symlinks in `~/.claude/hooks/` (force-overwrites existing)
|
2. Iterates over a curated list of skill-helper scripts, creates symlinks in `~/.claude/scripts/`
|
||||||
3. Prints the JSON config for `~/.claude/settings.json` covering PreCompact, PostToolUse, and PreToolUse matchers
|
3. Prints the JSON config for `~/.claude/settings.json` covering PreCompact, PostToolUse, and PreToolUse matchers
|
||||||
|
|
||||||
|
## Skill-helper scripts vs regular scripts
|
||||||
|
|
||||||
|
Not all `claude-foundations/scripts/` go into `~/.claude/scripts/`. Only scripts that skills reference via `~/.claude/scripts/` are installed there. The `SKILL_HELPERS` array in `install-hooks.sh` is the authoritative list. Currently: `extract-transcripts.py`, `list-transcripts-here.sh`.
|
||||||
|
|
||||||
|
Regular scripts (`statusline.sh`, `set-topic.sh`, etc.) are accessed via their full path in `claude-foundations/scripts/`.
|
||||||
|
|
||||||
|
## Profile notes
|
||||||
|
|
||||||
|
- Hooks: `~/.claude/hooks/` is referenced in all profile `settings.json` files — install once, works everywhere
|
||||||
|
- Scripts: `~/.claude/scripts/` is the only location skills reference — no per-profile script dirs needed
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- The printed JSON must be manually added to `settings.json` — the script doesn't edit it automatically.
|
- The printed JSON must be manually added to `settings.json` — the script doesn't edit it automatically.
|
||||||
- Symlinks mean the hook code stays in the repo; updates take effect immediately without re-running the script.
|
- Symlinks mean the hook/script code stays in the repo; updates take effect immediately without re-running the script.
|
||||||
|
- Adding a new skill-helper script requires updating the `SKILL_HELPERS` array in `install-hooks.sh` and re-running it.
|
||||||
|
|||||||
26
memory/script-list-transcripts-here.md
Normal file
26
memory/script-list-transcripts-here.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# script: list-transcripts-here
|
||||||
|
|
||||||
|
**Location:** `claude-foundations/scripts/list-transcripts-here.sh`
|
||||||
|
**Symlinked to:** `~/.claude/scripts/list-transcripts-here.sh`
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Thin wrapper around `extract-transcripts.py --list "$(pwd)"`. Exists because SKILL.md bang commands (`!`command``) cannot use `$()` substitution — the Claude Code permission checker rejects it. The wrapper runs the substitution internally (in bash, where it's fine) and outputs the result.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From within a project directory
|
||||||
|
bash ~/.claude/scripts/list-transcripts-here.sh
|
||||||
|
# → JSON array of unprocessed transcript backups for the current cwd
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Single line: `python3 "$HOME/.claude/scripts/extract-transcripts.py" --list "$(pwd)"`
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Must be called with `bash list-transcripts-here.sh` (not `python3`) — it's a shell wrapper, not a Python script
|
||||||
|
- Output is the same as `extract-transcripts.py --list`, so check that script's docs for the JSON format
|
||||||
|
- The `$()` substitution is the entire reason this wrapper exists; if that restriction is ever lifted from SKILL.md bang commands, this wrapper can be removed
|
||||||
@@ -12,22 +12,31 @@ End-of-session logging. Captures key decisions, gotchas, open questions, and pro
|
|||||||
/log
|
/log
|
||||||
```
|
```
|
||||||
|
|
||||||
No arguments. Reviews the full conversation history automatically.
|
No arguments. Reviews the full conversation history and any pre-compaction transcript backups automatically.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1. Creates `memory/log/` if needed
|
1. Creates `memory/log/` if needed
|
||||||
2. Reviews the conversation and extracts: Summary, Decisions, Gotchas (tagged with `[topic]`), Open Questions, Key Context, Process Notes
|
2. Reviews the conversation and extracts: Summary, Decisions, Gotchas (tagged with `[topic]`), Open Questions, Key Context, Process Notes
|
||||||
3. Writes a structured log file — empty sections are omitted
|
3. Writes a structured in-context log — empty sections are omitted
|
||||||
4. Prunes old logs: deletes reflected logs older than `retention_days` (default 7), warns about unreflected logs older than `warn_unreflected_days` (default 14)
|
4. **Transcript analysis (when backups exist):** Checks `~/.claude/transcript-backups/tracking.json` for unprocessed pre-compaction snapshots for the current project. If found, spawns a **Sonnet** subagent to read the JSONL backups (via `extract-transcripts.py --extract`) and write a companion log `HHMMSS-transcripts.md`. The subagent then marks backups as processed in `tracking.json`. Sonnet is used (not Haiku) because gotcha detection requires judgment about backtracking and failed attempts.
|
||||||
|
5. Prunes old logs: deletes reflected logs older than `retention_days` (default 7), warns about unreflected logs older than `warn_unreflected_days` (default 14)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `~/.claude/scripts/list-transcripts-here.sh` — pre-gathers unprocessed transcript metadata (small JSON, no context overflow)
|
||||||
|
- `~/.claude/scripts/extract-transcripts.py` — reads JSONL backups and manages tracking.json (used by the Sonnet subagent)
|
||||||
|
- Both scripts live in `claude-foundations/scripts/`, symlinked into `~/.claude/scripts/`
|
||||||
|
|
||||||
## Part of the knowledge pipeline
|
## Part of the knowledge pipeline
|
||||||
|
|
||||||
`/log` → `/reflect-logs` → `/distill-best-practices`
|
`/log` → `/reflect-logs` → `/distill-best-practices`
|
||||||
|
|
||||||
Raw session logs are input for `/reflect-logs`, which routes entries into topic memory files.
|
Raw session logs (and transcript companion logs) are input for `/reflect-logs`, which routes entries into topic memory files.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Trivial sessions can be skipped — the skill says so if nothing worth logging happened
|
- Trivial sessions can be skipped — the skill says so if nothing worth logging happened
|
||||||
- `[topic]` tags on gotchas should match existing memory file topics for routing by `/reflect-logs`
|
- `[topic]` tags on gotchas should match existing memory file topics for routing by `/reflect-logs`
|
||||||
|
- The Sonnet subagent writes to the project's absolute `memory/log/` path; if it uses a relative path from the wrong cwd, the file ends up in the wrong place — verify on first real run
|
||||||
|
- Pre-compaction backups from before the tracking system existed (files with `-auto` in the name) are not in `tracking.json` and won't be processed; they'll be pruned by the 30-day cleanup in the hook
|
||||||
|
|||||||
265
scripts/extract-transcripts.py
Executable file
265
scripts/extract-transcripts.py
Executable file
@@ -0,0 +1,265 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Manage and extract session transcript backups for the /log skill.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
--register BACKUP_NAME --session-id SID --cwd CWD
|
||||||
|
Register a new backup file in tracking.json (called by pre-compact hook).
|
||||||
|
|
||||||
|
--list CWD
|
||||||
|
List unprocessed transcripts for a project as JSON (small output, for /log skill).
|
||||||
|
|
||||||
|
--extract BACKUP_NAME
|
||||||
|
Extract readable conversation text from one backup (for subagent use).
|
||||||
|
|
||||||
|
--mark-processed BACKUP_NAME LOG_FILE
|
||||||
|
Mark a backup as processed, recording which log file captured it.
|
||||||
|
|
||||||
|
--mark-all-processed CWD LOG_FILE
|
||||||
|
Mark ALL unprocessed backups for a cwd as processed.
|
||||||
|
|
||||||
|
Tracking file: ~/.claude/transcript-backups/tracking.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BACKUP_DIR = Path.home() / ".claude" / "transcript-backups"
|
||||||
|
TRACKING_FILE = BACKUP_DIR / "tracking.json"
|
||||||
|
MAX_TEXT_LEN = 3000 # max chars per message before truncating
|
||||||
|
|
||||||
|
|
||||||
|
def load_tracking():
|
||||||
|
if TRACKING_FILE.exists():
|
||||||
|
with open(TRACKING_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_tracking(tracking):
|
||||||
|
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(TRACKING_FILE, "w") as f:
|
||||||
|
json.dump(tracking, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def now_utc():
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text_from_content(content):
|
||||||
|
"""Extract plain text from a message content field (string or list of blocks)."""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content.strip()
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts = []
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
btype = block.get("type")
|
||||||
|
if btype == "text":
|
||||||
|
text = block.get("text", "").strip()
|
||||||
|
if text:
|
||||||
|
parts.append(text)
|
||||||
|
elif btype == "tool_use":
|
||||||
|
name = block.get("name", "?")
|
||||||
|
inp = block.get("input", {})
|
||||||
|
keys = list(inp.keys())[:3] if isinstance(inp, dict) else []
|
||||||
|
summary = ", ".join(f"{k}=..." for k in keys) if keys else "..."
|
||||||
|
parts.append(f"[tool: {name}({summary})]")
|
||||||
|
return " ".join(parts)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_conversation(jsonl_path):
|
||||||
|
"""Read a JSONL transcript; return list of (role, text) tuples."""
|
||||||
|
turns = []
|
||||||
|
try:
|
||||||
|
with open(jsonl_path) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entry = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if entry.get("type") not in ("user", "assistant"):
|
||||||
|
continue
|
||||||
|
if entry.get("isSidechain"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg = entry.get("message", {})
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
role = msg.get("role", entry.get("type", "?"))
|
||||||
|
content = msg.get("content", "")
|
||||||
|
text = extract_text_from_content(content)
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(text) > MAX_TEXT_LEN:
|
||||||
|
text = text[:MAX_TEXT_LEN] + f"... [truncated, {len(text)} chars total]"
|
||||||
|
|
||||||
|
turns.append((role, text))
|
||||||
|
except Exception as e:
|
||||||
|
turns.append(("error", f"Failed to read transcript: {e}"))
|
||||||
|
return turns
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Modes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_register(args):
|
||||||
|
"""Register a new backup in tracking.json."""
|
||||||
|
try:
|
||||||
|
idx = args.index("--register")
|
||||||
|
backup_name = args[idx + 1]
|
||||||
|
session_id = args[args.index("--session-id") + 1]
|
||||||
|
cwd = args[args.index("--cwd") + 1]
|
||||||
|
except (ValueError, IndexError) as e:
|
||||||
|
print(f"Usage: --register BACKUP_NAME --session-id SID --cwd CWD", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
tracking = load_tracking()
|
||||||
|
tracking[backup_name] = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"cwd": cwd,
|
||||||
|
"saved_at": now_utc(),
|
||||||
|
"processed": False,
|
||||||
|
"log_file": None,
|
||||||
|
}
|
||||||
|
save_tracking(tracking)
|
||||||
|
print(f"Registered {backup_name} for {cwd}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(cwd):
|
||||||
|
"""List unprocessed transcripts for a cwd as compact JSON."""
|
||||||
|
tracking = load_tracking()
|
||||||
|
unprocessed = [
|
||||||
|
{
|
||||||
|
"backup": fname,
|
||||||
|
"session_id": entry["session_id"][:8],
|
||||||
|
"saved_at": entry.get("saved_at", "?"),
|
||||||
|
"path": str(BACKUP_DIR / fname),
|
||||||
|
"exists": (BACKUP_DIR / fname).exists(),
|
||||||
|
}
|
||||||
|
for fname, entry in sorted(tracking.items(), key=lambda x: x[1].get("saved_at", ""))
|
||||||
|
if entry.get("cwd") == cwd and not entry.get("processed", False)
|
||||||
|
]
|
||||||
|
print(json.dumps(unprocessed, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_extract(backup_name):
|
||||||
|
"""Extract readable conversation from one backup file."""
|
||||||
|
backup_path = BACKUP_DIR / backup_name
|
||||||
|
if not backup_path.exists():
|
||||||
|
print(f"Backup not found: {backup_path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
tracking = load_tracking()
|
||||||
|
entry = tracking.get(backup_name, {})
|
||||||
|
|
||||||
|
print(f"{'='*72}")
|
||||||
|
print(f"Transcript: {backup_name} (pre-compaction snapshot)")
|
||||||
|
print(f"Project: {entry.get('cwd', 'unknown')}")
|
||||||
|
print(f"Session: {entry.get('session_id', '?')[:8]} | Saved: {entry.get('saved_at', '?')}")
|
||||||
|
print(f"{'='*72}")
|
||||||
|
|
||||||
|
turns = extract_conversation(backup_path)
|
||||||
|
if not turns:
|
||||||
|
print("[no readable conversation found]")
|
||||||
|
return
|
||||||
|
|
||||||
|
for role, text in turns:
|
||||||
|
label = "USER" if role == "user" else "ASST"
|
||||||
|
print(f"\n[{label}] {text}")
|
||||||
|
|
||||||
|
print(f"\n[{len(turns)} turns extracted]")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_mark_processed(backup_name, log_file):
|
||||||
|
"""Mark one backup as processed."""
|
||||||
|
tracking = load_tracking()
|
||||||
|
if backup_name not in tracking:
|
||||||
|
print(f"Warning: {backup_name} not found in tracking, adding entry", file=sys.stderr)
|
||||||
|
tracking[backup_name] = {}
|
||||||
|
tracking[backup_name]["processed"] = True
|
||||||
|
tracking[backup_name]["processed_at"] = now_utc()
|
||||||
|
tracking[backup_name]["log_file"] = log_file
|
||||||
|
save_tracking(tracking)
|
||||||
|
print(f"Marked {backup_name} as processed → {log_file}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_mark_all_processed(cwd, log_file):
|
||||||
|
"""Mark all unprocessed backups for a cwd as processed."""
|
||||||
|
tracking = load_tracking()
|
||||||
|
count = 0
|
||||||
|
for fname, entry in tracking.items():
|
||||||
|
if entry.get("cwd") == cwd and not entry.get("processed", False):
|
||||||
|
tracking[fname]["processed"] = True
|
||||||
|
tracking[fname]["processed_at"] = now_utc()
|
||||||
|
tracking[fname]["log_file"] = log_file
|
||||||
|
count += 1
|
||||||
|
save_tracking(tracking)
|
||||||
|
print(f"Marked {count} transcript(s) as processed → {log_file}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = sys.argv[1:]
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if "--register" in args:
|
||||||
|
cmd_register(args)
|
||||||
|
elif "--list" in args:
|
||||||
|
idx = args.index("--list")
|
||||||
|
cwd = args[idx + 1] if idx + 1 < len(args) else ""
|
||||||
|
if not cwd:
|
||||||
|
print("Usage: --list CWD", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
cmd_list(cwd)
|
||||||
|
elif "--extract" in args:
|
||||||
|
idx = args.index("--extract")
|
||||||
|
backup_name = args[idx + 1] if idx + 1 < len(args) else ""
|
||||||
|
if not backup_name:
|
||||||
|
print("Usage: --extract BACKUP_NAME", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
cmd_extract(backup_name)
|
||||||
|
elif "--mark-all-processed" in args:
|
||||||
|
idx = args.index("--mark-all-processed")
|
||||||
|
cwd = args[idx + 1] if idx + 1 < len(args) else ""
|
||||||
|
log_idx = args.index("--log-file") if "--log-file" in args else -1
|
||||||
|
log_file = args[log_idx + 1] if log_idx >= 0 and log_idx + 1 < len(args) else ""
|
||||||
|
if not cwd or not log_file:
|
||||||
|
print("Usage: --mark-all-processed CWD --log-file LOG_FILE", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
cmd_mark_all_processed(cwd, log_file)
|
||||||
|
elif "--mark-processed" in args:
|
||||||
|
idx = args.index("--mark-processed")
|
||||||
|
backup_name = args[idx + 1] if idx + 1 < len(args) else ""
|
||||||
|
log_idx = args.index("--log-file") if "--log-file" in args else -1
|
||||||
|
log_file = args[log_idx + 1] if log_idx >= 0 and log_idx + 1 < len(args) else ""
|
||||||
|
if not backup_name or not log_file:
|
||||||
|
print("Usage: --mark-processed BACKUP_NAME --log-file LOG_FILE", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
cmd_mark_processed(backup_name, log_file)
|
||||||
|
else:
|
||||||
|
print(f"Unknown arguments: {args}", file=sys.stderr)
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,26 +1,49 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Symlink all hooks from claude-foundations/hooks/ into ~/.claude/hooks/
|
# Symlink all hooks from claude-foundations/hooks/ into ~/.claude/hooks/,
|
||||||
# and print the settings.json configuration to add.
|
# and symlink skill-helper scripts from claude-foundations/scripts/ into ~/.claude/scripts/.
|
||||||
|
# Prints the settings.json configuration to add for hooks.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
HOOKS_SRC="$(cd "$SCRIPT_DIR/../hooks" && pwd)"
|
HOOKS_SRC="$(cd "$SCRIPT_DIR/../hooks" && pwd)"
|
||||||
HOOKS_DST="$HOME/.claude/hooks"
|
HOOKS_DST="$HOME/.claude/hooks"
|
||||||
|
SCRIPTS_DST="$HOME/.claude/scripts"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hooks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
mkdir -p "$HOOKS_DST"
|
mkdir -p "$HOOKS_DST"
|
||||||
|
echo "Installing hooks: $HOOKS_SRC → $HOOKS_DST"
|
||||||
echo "Installing hooks from $HOOKS_SRC → $HOOKS_DST"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
for HOOK in "$HOOKS_SRC"/*.sh; do
|
for HOOK in "$HOOKS_SRC"/*.sh; do
|
||||||
NAME="$(basename "$HOOK")"
|
NAME="$(basename "$HOOK")"
|
||||||
ln -sf "$HOOK" "$HOOKS_DST/$NAME"
|
ln -sf "$HOOK" "$HOOKS_DST/$NAME"
|
||||||
echo " ✓ $NAME"
|
echo " ✓ $NAME"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Skill-helper scripts (scripts that skills reference via ~/.claude/scripts/)
|
||||||
|
# Not all claude-foundations scripts go here — only the ones used by skills.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mkdir -p "$SCRIPTS_DST"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Hooks symlinked. Ensure your ~/.claude/settings.json includes:"
|
echo "Installing skill-helper scripts: $SCRIPT_DIR → $SCRIPTS_DST"
|
||||||
|
SKILL_HELPERS=(
|
||||||
|
"extract-transcripts.py"
|
||||||
|
"list-transcripts-here.sh"
|
||||||
|
)
|
||||||
|
for SCRIPT in "${SKILL_HELPERS[@]}"; do
|
||||||
|
SRC="$SCRIPT_DIR/$SCRIPT"
|
||||||
|
if [ -f "$SRC" ]; then
|
||||||
|
ln -sf "$SRC" "$SCRIPTS_DST/$SCRIPT"
|
||||||
|
echo " ✓ $SCRIPT"
|
||||||
|
else
|
||||||
|
echo " ✗ $SCRIPT (not found at $SRC)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done. Ensure your ~/.claude/settings.json (and profile settings.json files) include:"
|
||||||
echo ""
|
echo ""
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
{
|
{
|
||||||
@@ -50,3 +73,6 @@ cat <<'EOF'
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
echo ""
|
||||||
|
echo "Note: hooks are referenced as ~/.claude/hooks/ (default profile path) in all profile settings.json files."
|
||||||
|
echo "Note: skill-helper scripts are installed to ~/.claude/scripts/ only — profiles reference this shared path."
|
||||||
|
|||||||
4
scripts/list-transcripts-here.sh
Executable file
4
scripts/list-transcripts-here.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Wrapper for extract-transcripts.py --list, passing the current working directory.
|
||||||
|
# Used by the /log skill's pre-gathered context (bang commands can't use $() substitution).
|
||||||
|
python3 "$HOME/.claude/scripts/extract-transcripts.py" --list "$(pwd)"
|
||||||
Reference in New Issue
Block a user