diff --git a/.dotter/global.toml b/.dotter/global.toml index 7e16296..d7be65d 100644 --- a/.dotter/global.toml +++ b/.dotter/global.toml @@ -73,6 +73,7 @@ depends = [] [bin.files] "bin/discord-send" = "~/.local/bin/discord-send" +"bin/claude-token-count" = "~/.local/bin/claude-token-count" [bin.variables] diff --git a/bin/claude-token-count b/bin/claude-token-count new file mode 100755 index 0000000..151b76e --- /dev/null +++ b/bin/claude-token-count @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# claude-token-count — count tokens for text using Anthropic's +# /v1/messages/count_tokens endpoint (the exact Claude tokenizer). +# +# The API key is pulled from 1Password at runtime via the `op` CLI; it is +# never written to disk and is passed to curl via a config file on /dev/fd +# so it stays out of the process argument list. +# +# Usage: +# claude-token-count "some text" # count the argument(s) +# claude-token-count --file notes.md # count a file's contents +# some-command | claude-token-count # count piped stdin +# +# Options: +# --file PATH read the text to count from PATH +# --model NAME model whose tokenizer to use (default: $CLAUDE_TOKEN_COUNT_MODEL +# or claude-sonnet-4-6) +# --raw print the raw JSON response instead of just the number +# -h, --help show this help +# +# Requires: op (1Password CLI, signed in), curl, jq. + +set -euo pipefail + +# 1Password reference for the Anthropic API key (from the item's share link). +OP_ACCOUNT="EADC56ZW7NARRJTNZKSRBYRQNU" +OP_REF="op://m6vn2ie66x7ysrjq73q45oiyeu/te6tuanr426ohu33t2qhqzjrb4/credential" + +MODEL="${CLAUDE_TOKEN_COUNT_MODEL:-claude-sonnet-4-6}" +RAW=false +FILE="" +declare -a TEXT_PARTS=() + +usage() { sed -n '2,/^set -euo/{/^set -euo/!p}' "$0" | sed 's/^# \{0,1\}//'; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --file) FILE="${2:?--file requires a path}"; shift 2 ;; + --file=*) FILE="${1#--file=}"; shift ;; + --model) MODEL="${2:?--model requires a name}"; shift 2 ;; + --model=*) MODEL="${1#--model=}"; shift ;; + --raw) RAW=true; shift ;; + -h|--help) usage; exit 0 ;; + *) TEXT_PARTS+=("$1"); shift ;; + esac +done + +for tool in op curl jq; do + command -v "$tool" >/dev/null 2>&1 || { echo "Error: '$tool' is required but not installed." >&2; exit 1; } +done + +# Resolve the text to count: --file wins, then positional args, then stdin. +if [[ -n "$FILE" ]]; then + [[ -r "$FILE" ]] || { echo "Error: cannot read file: $FILE" >&2; exit 1; } + TEXT="$(cat -- "$FILE")" +elif [[ ${#TEXT_PARTS[@]} -gt 0 ]]; then + TEXT="${TEXT_PARTS[*]}" +elif [[ ! -t 0 ]]; then + TEXT="$(cat)" +else + echo "Error: no input. Provide text as an argument, --file, or via stdin." >&2 + exit 2 +fi + +[[ -n "$TEXT" ]] || { echo "Error: input is empty." >&2; exit 2; } + +# Build the request body, encoding the text safely as a JSON string. +BODY="$(jq -n --arg model "$MODEL" --arg text "$TEXT" \ + '{model: $model, messages: [{role: "user", content: $text}]}')" + +# Fetch the key into a variable (local to this process) and hand it to curl +# through a --config file via process substitution, keeping it out of argv. +API_KEY="$(op read --account "$OP_ACCOUNT" "$OP_REF")" + +RESPONSE="$(curl -sS https://api.anthropic.com/v1/messages/count_tokens \ + --config <(printf 'header = "x-api-key: %s"\n' "$API_KEY") \ + -H "anthropic-version: 2023-06-01" \ + -H "content-type: application/json" \ + -d "$BODY")" +unset API_KEY + +if $RAW; then + echo "$RESPONSE" + exit 0 +fi + +# Surface API errors instead of printing "null". +if echo "$RESPONSE" | jq -e 'has("error")' >/dev/null 2>&1; then + echo "Error from Anthropic API:" >&2 + echo "$RESPONSE" | jq -r '.error.type + ": " + .error.message' >&2 + exit 1 +fi + +echo "$RESPONSE" | jq -r '.input_tokens' diff --git a/claude/skills/cc-compact/SKILL.md b/claude/skills/cc-compact/SKILL.md new file mode 100644 index 0000000..9cd2872 --- /dev/null +++ b/claude/skills/cc-compact/SKILL.md @@ -0,0 +1,64 @@ +--- +name: cc-compact +description: Summarize ("compact") a past Claude Code session without ingesting its entire history. Resolves the session log, then extracts only the key signals — opening intent, recent prompts, most-edited files, and what the agent was doing last. Use, when asked, to resume long sessions without ingesting entire log. +--- + +You are reloading the context of a previous Claude Code session so you can +**continue that work in this session** — like `/resume` followed by `/compact`, but pre-compacted. The +bundled helper script extracts the key signals and prints a compact report. + +**Hard rule: you may NOT read the session `.jsonl` file by any means** — no +`cat`, `head`, `tail`, `jq`, `grep`, `Read`, nothing. Run the helper script +**once** and work solely from its output. That is the only thing allowed to +touch the file. + +The helper script lives at `~/.claude/skills/cc-compact/compact_session.py`. + +## Step 1: Resolve the session + +The argument is one of two forms: + +- **A session id** — when invoked like `/resume claude --resume ` or + `cc-compact `, the user already gave you the UUID. Pass it as `--id`. +- **A session name / title** — free text. Pass it as `--title`; the script + matches it against the `ai-title` records inside the logs (case-insensitive + substring, newest match wins). + +Run the script exactly once with the matching form: + +```sh +# By id: +python3 ~/.claude/skills/cc-compact/compact_session.py --id + +# By title: +python3 ~/.claude/skills/cc-compact/compact_session.py --title "the session name" +``` + +## Step 2: Read the output + +It prints a bounded, XML-tagged report: + +- header: project, git branch, time span, record/prompt counts +- the first few exchanges (user prompt + the agent's reply) — the original intent +- a few exchanges sampled from the middle (non-overlapping) — the journey +- the last few exchanges (user prompt + the agent's reply) — where things were heading +- the most-edited files, ranked by edit count +- the last several tool calls — what the agent was physically doing last +- the final assistant message — what it was saying / waiting on last + +## Step 3: Pick up the work + +The output is context for *you* — treat it like a resumed session, not something +to report on. From it, reconstruct what the work was, which files are in play, +and what the agent was in the middle of. + +Compaction is lossy — the output may not make the next step unambiguous. Before +diving in, decide whether you actually know how to proceed: + +- **If the next step is very clear, last few messages from user confirms what to do**, open with one line confirming what you're + resuming, then continue from where it left off. +- **If it's ambiguous** (unclear what the user wants next, multiple plausible + directions, or the session ended mid-decision), **ask the user how to proceed** + before acting. Surface the few plausible next steps you inferred and let them + pick or correct you. If there is any doubt, opt to ask the user first. + diff --git a/claude/skills/cc-compact/compact_session.py b/claude/skills/cc-compact/compact_session.py new file mode 100755 index 0000000..f5d6482 --- /dev/null +++ b/claude/skills/cc-compact/compact_session.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Compact a Claude Code session log into a small, bounded summary. + +This reads a session JSONL file *carefully* — it never dumps the whole +history. It extracts only the few signals needed to understand what the +session was about and what the agent was doing at the end: + + - header metadata (project, branch, time span, message counts) + - the first few exchanges (user prompt + the agent's reply, truncated) + - a few exchanges randomly sampled from the middle (non-overlapping) + - the last few exchanges (user prompt + the agent's reply, truncated) + - the most-edited files (top N by edit count) + - the final assistant text (what it was saying last) + - the last few tool calls (what it was doing last) + +Session resolution (pick one): + --file PATH use this JSONL file directly + --id UUID find .jsonl under the projects dir + --title TEXT find the session whose ai-title contains TEXT + (case-insensitive substring; newest match wins) + +Sessions live at: ~/.claude/projects//.jsonl +""" + +import argparse +import glob +import json +import os +import random +import sys + +PROJECTS_DIR = os.path.expanduser("~/.claude/projects") + +# Tools that change files on disk, and which input field holds the path. +EDIT_TOOLS = { + "Edit": "file_path", + "Write": "file_path", + "MultiEdit": "file_path", + "NotebookEdit": "notebook_path", +} + + +def truncate(text, n): + """Collapse whitespace to a single line, then clip to n chars. + Use for compact one-liners like tool arguments.""" + text = " ".join(text.split()) + return text if len(text) <= n else text[: n - 1] + "…" + + +def clip(text, n): + """Clip to n chars while preserving newlines/indentation. + Use for quoted messages where structure matters.""" + text = text.strip("\n") + return text if len(text) <= n else text[:n].rstrip() + " …[truncated]" + + +def esc(text): + """Escape XML metacharacters for tag values and attributes.""" + return str(text).replace("&", "&").replace("<", "<").replace(">", ">") + + +def content_to_text(content): + """A message's .content is either a string or a list of typed blocks.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"] + return "\n".join(p for p in parts if p) + return "" + + +def is_genuine_prompt(rec): + """A real human-typed prompt: a user message that isn't a tool result, + meta record, slash-command wrapper, or interrupt marker.""" + if rec.get("type") != "user" or rec.get("isMeta"): + return False + content = rec.get("message", {}).get("content") + if not isinstance(content, str): + return False + s = content.lstrip() + if not s: + return False + skip_prefixes = ("<", "[Request interrupted", "Caveat:") + return not s.startswith(skip_prefixes) + + +def resolve_file(args): + if args.file: + return os.path.expanduser(args.file) + + if args.id: + matches = glob.glob(os.path.join(PROJECTS_DIR, "**", f"{args.id}.jsonl"), recursive=True) + if not matches: + sys.exit(f"No session file found for id {args.id} under {PROJECTS_DIR}") + return matches[0] + + if args.title: + needle = args.title.lower() + candidates = [] # (mtime, path, title) + for path in glob.glob(os.path.join(PROJECTS_DIR, "**", "*.jsonl"), recursive=True): + title = None + try: + with open(path, encoding="utf-8") as fh: + for line in fh: + if '"ai-title"' not in line: + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + continue + if rec.get("type") == "ai-title": + title = rec.get("aiTitle", "") + if needle in title.lower(): + break + title = None + except OSError: + continue + if title is not None: + candidates.append((os.path.getmtime(path), path, title)) + if not candidates: + sys.exit(f"No session whose ai-title contains {args.title!r}") + candidates.sort(reverse=True) + if len(candidates) > 1: + sys.stderr.write("Multiple matches (using newest):\n") + for _, path, title in candidates: + sys.stderr.write(f" {path} — {title}\n") + return candidates[0][1] + + sys.exit("Provide one of --file, --id, or --title") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--file", help="path to a session .jsonl") + g.add_argument("--id", help="session UUID") + g.add_argument("--title", help="substring of the session's ai-title") + ap.add_argument("--first", type=int, default=5, help="how many opening exchanges") + ap.add_argument("--last", type=int, default=10, help="how many closing exchanges") + ap.add_argument("--middle", type=int, default=5, help="how many exchanges randomly sampled from the middle") + ap.add_argument("--top-files", type=int, default=10, help="how many most-edited files") + ap.add_argument("--maxlen", type=int, default=800, help="max chars per quoted message") + ap.add_argument("--seed", type=int, default=1, help="seed for the middle random sample") + args = ap.parse_args() + + path = resolve_file(args) + + turns = [] # conversational turns: {"prompt": str, "reply": [text,...]} + edit_counts = {} # file path -> edit count + last_assistant_text = [] # final text blocks + recent_tools = [] # (name, short arg) + counts = {} # record type -> count + first_ts = last_ts = None + cwd = branch = None + + with open(path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + continue + + rtype = rec.get("type") + counts[rtype] = counts.get(rtype, 0) + 1 + + ts = rec.get("timestamp") + if ts: + first_ts = first_ts or ts + last_ts = ts + cwd = cwd or rec.get("cwd") + branch = branch or rec.get("gitBranch") + + if is_genuine_prompt(rec): + turns.append({"prompt": rec["message"]["content"], "reply": []}) + + if rtype == "assistant": + blocks = rec.get("message", {}).get("content", []) + if isinstance(blocks, list): + text_here = [] + for b in blocks: + if not isinstance(b, dict): + continue + if b.get("type") == "text" and b.get("text", "").strip(): + text_here.append(b["text"]) + elif b.get("type") == "tool_use": + name = b.get("name", "?") + inp = b.get("input", {}) or {} + if name in EDIT_TOOLS: + fp = inp.get(EDIT_TOOLS[name]) + if fp: + edit_counts[fp] = edit_counts.get(fp, 0) + 1 + arg = inp.get("file_path") or inp.get("command") or inp.get("description") or inp.get("path") or "" + recent_tools.append((name, truncate(str(arg), 300))) + if text_here: + last_assistant_text = text_here # keep only the latest turn's text + if turns: # attach the agent's reply to the current turn + turns[-1]["reply"].extend(text_here) + + # Non-overlapping index sets: first wins, then last, then the middle is + # sampled only from what's left between them. If the regions collide + # (short session), the overlap is simply dropped. + n = len(turns) + first_idx = list(range(min(args.first, n))) + last_start = max(len(first_idx), n - args.last) + last_idx = list(range(last_start, n)) + mid_pool = list(range(len(first_idx), last_start)) + if args.seed is not None: + random.seed(args.seed) + mid_idx = sorted(random.sample(mid_pool, min(args.middle, len(mid_pool)))) + + # ---- emit a bounded, XML-tagged report (newlines preserved) ---- + out = sys.stdout.write + + out("\n") + out(f" {esc(path)}\n") + out(f" {esc(cwd)}\n") + out(f" {esc(branch)}\n") + out(f' \n') + out(f" {esc(', '.join(f'{k}={v}' for k, v in sorted(counts.items())))}\n") + out(f" \n") + out("\n") + + def emit_exchanges(label, indices): + if not indices: + return + out(f'\n\n') + for i in indices: + t = turns[i] + reply = "\n".join(t["reply"]).strip() + out(f' \n') + out(f" \n{clip(t['prompt'], args.maxlen)}\n \n") + if reply: + out(f" \n{clip(reply, args.maxlen)}\n \n") + else: + out(" \n") + out(" \n") + out("\n") + + emit_exchanges("first", first_idx) + emit_exchanges("sampled-middle", mid_idx) + emit_exchanges("last", last_idx) + + out(f'\n\n') + for fp, c in sorted(edit_counts.items(), key=lambda kv: -kv[1])[: args.top_files]: + out(f' {esc(fp)}\n') + out("\n") + + out("\n\n") + for name, arg in recent_tools[-8:]: + out(f' {esc(arg)}\n') + out("\n") + + out("\n\n") + out(clip("\n".join(last_assistant_text), 2000) + "\n") + out("\n") + + +if __name__ == "__main__": + main() diff --git a/claude/skills/new-work/SKILL.md b/claude/skills/new-work/SKILL.md new file mode 100644 index 0000000..4af3b96 --- /dev/null +++ b/claude/skills/new-work/SKILL.md @@ -0,0 +1,28 @@ +--- +name: new-work +description: Prepare the repo to start a new piece of work. Use this skill when the user asks to start a new task, begin new work, create a new branch, or start working on something new. +--- + +You are helping the user start a new piece of work in a git repository. Follow these steps in order: + +## Step 1: Check for uncommitted changes + +Run `git status --short` to check for uncommitted changes. + +If there are uncommitted changes: +- Evaluate the changes: + - If there are build artifacts, etc. then ignore them. If they are tracked by git, note for user that they might want to gitingore these. + - If changes look important, such as code changes, new code files that are not empty, changes to build workflows etc., then ask user if they want to stash + +## Step 2: Switch to main and pull latest + +- Attempt to check out `main`. + - If that fails because the branch does not exist, check out `master` instead. +- Then pull the latest changes: + +## Step 3: Create a new branch + +Based on the description of the work the user has provided, create a short, descriptive, kebab-case branch name (e.g. `add-user-auth`, `fix-checkout-crash`, `refactor-api-client`). +Create and check out the new branch. +Once branch is created, proceed with work as usual. + diff --git a/setup.sh b/setup.sh index 20e08ab..d23d7e0 100755 --- a/setup.sh +++ b/setup.sh @@ -259,6 +259,7 @@ echo "" echo "Setting up directory symlinks..." DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ensure_dir_symlink "$DOTFILES_DIR/fish/functions" "~/.config/fish/functions" "$FORCE_DEPLOY" +ensure_dir_symlink "$DOTFILES_DIR/claude/skills" "~/.claude/skills" "$FORCE_DEPLOY" echo "" echo "✓ Setup complete!"