1
0
Fork 0
mirror of https://github.com/SeriousBug/dotfiles synced 2026-06-16 20:35:08 -05:00

Add cc-compact skill and claude-token-count CLI

- cc-compact: skill to reload a past Claude Code session's context
  (resolves by id or ai-title, extracts a bounded XML summary via
  compact_session.py) so work can be resumed without ingesting the log
- migrate the existing new-work skill into the repo
- deploy ~/.claude/skills via ensure_dir_symlink in setup.sh
- claude-token-count: count tokens via Anthropic's count_tokens endpoint,
  pulling the API key from 1Password at runtime
This commit is contained in:
Kaan Barmore-Genc 2026-05-31 02:28:42 -05:00
parent c82e17654e
commit b0070920e3
6 changed files with 451 additions and 0 deletions

View file

@ -73,6 +73,7 @@ depends = []
[bin.files] [bin.files]
"bin/discord-send" = "~/.local/bin/discord-send" "bin/discord-send" = "~/.local/bin/discord-send"
"bin/claude-token-count" = "~/.local/bin/claude-token-count"
[bin.variables] [bin.variables]

95
bin/claude-token-count Executable file
View file

@ -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'

View file

@ -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 <id>` or
`cc-compact <id>`, 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 <session-uuid>
# 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.

View file

@ -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 <UUID>.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/<encoded-cwd>/<session-id>.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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("<session-summary>\n")
out(f" <file>{esc(path)}</file>\n")
out(f" <project>{esc(cwd)}</project>\n")
out(f" <git-branch>{esc(branch)}</git-branch>\n")
out(f' <time-span start="{esc(first_ts)}" end="{esc(last_ts)}" />\n')
out(f" <records>{esc(', '.join(f'{k}={v}' for k, v in sorted(counts.items())))}</records>\n")
out(f" <user-prompts count=\"{len(turns)}\" />\n")
out("</session-summary>\n")
def emit_exchanges(label, indices):
if not indices:
return
out(f'\n<exchanges section="{label}">\n')
for i in indices:
t = turns[i]
reply = "\n".join(t["reply"]).strip()
out(f' <exchange n="{i + 1}">\n')
out(f" <user>\n{clip(t['prompt'], args.maxlen)}\n </user>\n")
if reply:
out(f" <agent>\n{clip(reply, args.maxlen)}\n </agent>\n")
else:
out(" <agent note=\"no text reply — tool calls only\" />\n")
out(" </exchange>\n")
out("</exchanges>\n")
emit_exchanges("first", first_idx)
emit_exchanges("sampled-middle", mid_idx)
emit_exchanges("last", last_idx)
out(f'\n<most-edited-files top="{args.top_files}">\n')
for fp, c in sorted(edit_counts.items(), key=lambda kv: -kv[1])[: args.top_files]:
out(f' <file edits="{c}">{esc(fp)}</file>\n')
out("</most-edited-files>\n")
out("\n<last-tool-calls>\n")
for name, arg in recent_tools[-8:]:
out(f' <call tool="{esc(name)}">{esc(arg)}</call>\n')
out("</last-tool-calls>\n")
out("\n<final-agent-message>\n")
out(clip("\n".join(last_assistant_text), 2000) + "\n")
out("</final-agent-message>\n")
if __name__ == "__main__":
main()

View file

@ -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.

View file

@ -259,6 +259,7 @@ echo ""
echo "Setting up directory symlinks..." echo "Setting up directory symlinks..."
DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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/fish/functions" "~/.config/fish/functions" "$FORCE_DEPLOY"
ensure_dir_symlink "$DOTFILES_DIR/claude/skills" "~/.claude/skills" "$FORCE_DEPLOY"
echo "" echo ""
echo "✓ Setup complete!" echo "✓ Setup complete!"