From 08cde863c652f5073f2986147bcfe0a8d1915908 Mon Sep 17 00:00:00 2001 From: wehub-skill-sync Date: Mon, 13 Jul 2026 21:36:56 +0800 Subject: [PATCH] chore: import zh skill sessions --- README.wehub.md | 9 + SKILL.md | 216 +++++++++++++++++++++ scripts/discover-sessions.sh | 81 ++++++++ scripts/extract-errors.py | 135 +++++++++++++ scripts/extract-metadata.py | 304 +++++++++++++++++++++++++++++ scripts/extract-skeleton.py | 365 +++++++++++++++++++++++++++++++++++ 6 files changed, 1110 insertions(+) create mode 100644 README.wehub.md create mode 100644 SKILL.md create mode 100755 scripts/discover-sessions.sh create mode 100755 scripts/extract-errors.py create mode 100755 scripts/extract-metadata.py create mode 100755 scripts/extract-skeleton.py diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..2ab37b6 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,9 @@ +# WeHub 来源说明 + +- Skill 名称:`sessions` +- 中文类目:跨 session 状态交接与恢复 +- 上游仓库:`slopus__happy` +- 上游路径:`.agents/skills/sessions/SKILL.md` +- 上游链接:https://github.com/slopus/happy/blob/HEAD/.agents/skills/sessions/SKILL.md +- 本仓库为 WeHub 中文 Skill 汉化包,基于 skill 市场筛选 Top200 清单整理 +- 原作者、版权和许可证信息以上游仓库为准 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..e63c8e5 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,216 @@ +--- +name: sessions +description: "搜索并询问关于 Claude Code、Codex 和 Cursor 中编码代理会话历史的问题。当需要了解之前做过什么、之前尝试过什么、跨会话如何调查一个问题、最近发生了什么,或任何关于过去代理会话的问题时使用。当用户提及先前的会话、之前的尝试或过去的调查时也可使用——即使没有明确提到'sessions'一词。" +--- + +# /sessions(从 EveryInc/compound-engineering-plugin ce-sessions 安装) + +跨 Claude Code、Codex 和 Cursor 搜索会话历史,综合总结之前处理过、尝试过、决定过或学到过的内容。 + +## 用法 + +``` +/ce-sessions [问题或主题] +/ce-sessions +``` + +## 预解析上下文 + +**Git 分支(预解析):** !`git rev-parse --abbrev-ref HEAD 2>/dev/null || true` + +如果上述行解析为一个简单的分支名(如 `feat/my-branch`),则将其用于分支过滤,并传递给综合子代理。如果它仍然包含反引号命令字符串或为空,则在运行时推导分支名。 + +**仓库名称(预解析):** !`basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || true` + +如果上述行解析为一个简单的仓库文件夹名,则将其用于会话发现。否则在运行时推导。 + +## 注意:2026 年 + +当前年份是 2026 年。在解释会话时间戳时使用此信息。 + +## 防护规则 + +以下规则在编排和综合过程中始终适用。 + +- **绝不要将会话文件整体读入上下文。** 会话文件可能达到 1-7MB。始终先使用提取脚本进行过滤,然后在过滤后的输出上进行分析。 +- **绝不要逐字提取或复现工具调用的输入/输出。** 只需总结尝试了什么以及发生了什么。 +- **绝不要包含思考或推理块的内容。** Claude Code 思考块是内部推理;Codex 推理块已加密。两者均不可操作。 +- **绝不要分析当前会话。** 其对话历史已对调用者可用。 +- **呈现技术内容,而非个人内容。** 会话包含所有内容——凭据、情绪、不成熟的意见。请自行判断哪些内容属于技术总结,哪些不属于。 +- **访问出错时快速失败。** 如果由于权限问题导致会话发现失败,立即报告问题。不要使用不同的工具或方法重试同一操作——重复重试只会浪费 token 而不会改变结果。 + +## 执行 + +如果未提供问题参数,则询问用户想了解会话历史中的什么内容。使用平台的阻塞式提问工具:Claude Code 中的 `AskUserQuestion`(如果其 schema 尚未加载,先调用 `ToolSearch` 并指定 `select:AskUserQuestion`),Codex 中的 `request_user_input`,Gemini 中的 `ask_user`,Pi 中的 `ask_user`(需要 `pi-ask-user` 扩展)。仅当阻塞式工具在 harness 中不存在或调用出错时(如 Codex 编辑模式),才回退到纯文本提问。绝不要因需要加载 schema 而静默跳过提问。 + +### 第 1 步——确定扫描窗口 + +从用户的问题推断时间范围。从窄窗口开始;仅在窄窗口未找到相关内容时才扩大窗口。 + +| 信号 | 初始扫描窗口 | +|------|---------------------| +| "今天"、"今天上午" | 1 天 | +| "最近"、"最近几天"、"这周"、或未给出时间信号 | 7 天 | +| "最近几周"、"这个月" | 30 天 | +| "最近几个月"、广泛的功能历史 | 90 天 | + +Claude Code 默认保留约 30 天的会话历史。除非用户延长了保留期限,否则更宽的窗口可能在 Claude Code 上找不到任何内容。 + +### 第 2 步——发现会话并提取元数据 + +运行发现 + 元数据管道(保留空分隔符 xargs 加固机制,使 `extract-metadata.py` 能以批处理模式运行): + +```bash +bash scripts/discover-sessions.sh | tr '\n' '\0' | xargs -0 python3 scripts/extract-metadata.py --cwd-filter +``` + +每行输出是一个描述会话的 JSON 对象(平台、文件、大小、时间戳、会话 ID,以及平台特定字段)。最后的 `_meta` 行携带 `files_processed` 和 `parse_errors`。 + +如果清单的 `_meta` 行显示 `files_processed: 0`,则返回"未找到相关先前的会话"并停止。 + +如果 `parse_errors > 0`,则注明部分会话无法解析,并继续处理已返回的内容。 + +要缩小平台范围,可在 `discover-sessions.sh` 调用中添加 `--platform claude`、`--platform codex` 或 `--platform cursor`。默认包含所有三个平台。 + +### 第 3 步——过滤和排序 + +按顺序应用以下过滤器,筛选出值得深入分析的会话: + +1. **分支过滤器(仅 Claude Code)。** 保留 `branch == dispatch_branch` 精确匹配的会话,或分支名包含问题主题关键词的会话(例如,关于"auth 中间件"的问题匹配 `feat/auth-fix`、`chore/auth-refactor` 等分支)。Codex 会话不携带 `gitBranch`——跳过此过滤器。 + +2. **如果分支过滤器返回零个会话,或者你在处理 Codex 会话:** + - 从问题主题中推导出 2-4 个关键词。例如,对于"auth 中间件中 session-validation 拒绝有效 token 导致最近崩溃"的问题,推导出 `auth,middleware,session,token`(或类似关键词)。 + - 重新运行发现管道,在 `extract-metadata.py` 调用后追加 `--keyword K1,K2,...`。脚本返回 `match_count` 非零的会话以及每个关键词的计数。 + - **如果 `files_matched: 0`,则返回"未找到相关先前的会话"并停止。** 不要提取任何内容。 + - 如果 `files_matched > 0`,则将这些会话视为候选。按 `match_count` 排序,平局时按每个关键词的计数排序。 + +3. **删除扫描窗口之外的会话。** 优先使用 `last_ts`,回退到 `ts`。丢弃两个时间戳都在窗口开始之前的会话。 + +4. **排除当前会话**——其对话历史已对调用者可用。 + +5. **应用深度分析上限。** 在所有平台中最多取 **5 个会话**。按分支匹配 → `match_count` → 文件大小 > 30KB → 最近时间排序。 + +6. **过滤后至少保留一个会话时才继续。** 否则返回"未找到相关先前的会话"并停止。 + +**注意:`gitBranch` 仅在第一条用户消息时捕获。** 一个从 `main` 开始、通过会话中途的 `git checkout` 在功能分支上完成实质性工作的会话,记录的是 `branch: "main"`。分支匹配返回空并不是结论性证据——这就是第 2 步中关键词过滤回退机制存在的原因。 + +### 第 4 步——设置临时工作空间 + +为每次运行创建一个一次性临时工作目录: + +```bash +SCRATCH=$(mktemp -d -t ce-sessions-XXXXXX) +``` + +捕获绝对路径;将其传入第 5 步和第 6 步。操作系统会在会话结束时处理清理工作;在第 7 步末尾显式执行 `rm -rf "$SCRATCH"` 也无害,且能使意图更明确。 + +### 第 5 步——提取每个会话的内容(文件中介) + +对每个选中的会话,使用 `--output` 运行骨架提取器,使内容直接写入临时文件——提取的字节不会通过编排器的工具结果往返传输: + +```bash +python3 scripts/extract-skeleton.py --output "$SCRATCH/.skeleton.txt" < +``` + +标准输出仅接收一行 JSON 状态(`{"_meta": true, "wrote": "...", "bytes": N, ...}`)。从每个状态行捕获 `bytes` 和 `parse_errors`。 + +**条件性尾部提取**——如果骨架在调查中途结束(最后一个可见轮次是一个没有解决方案的工具调用,或代理在没有结论的情况下进行调试),则使用 `tail` 格式重新提取: + +```bash +python3 scripts/extract-skeleton.py --output "$SCRATCH/.skeleton.tail.txt" < +``` + +(骨架脚本本身不直接接受 `tail:N` 上限;如果需要仅尾部视图,可在提取后通过 `tail -n 50` 在 shell 中后处理临时文件。仅在头部输出表明会话在调查中途被截断时使用此方法。) + +**条件性错误模式**——对于调查死胡同可能有价值的会话: + +```bash +python3 scripts/extract-errors.py --output "$SCRATCH/.errors.txt" < +``` + +有选择地使用——仅当了解出了什么问题能增加价值时才使用。Cursor 代理记录不记录工具结果,因此错误模式对 Cursor 会话不会产生任何内容。 + +### 第 6 步——调度综合子代理 + +通过平台的子代理原语(Claude Code 中的 `Agent`,Codex 中的 `spawn_agent`,Pi 中的 `subagent`,需要 `pi-subagents` 扩展)调度 `ce-session-historian` 子代理。省略 `mode` 参数,以便应用用户配置的权限设置。在中档模型上运行(例如 Claude Code 中的 `model: "sonnet"`)——合成器不需要前沿推理能力。 + +调度提示是代理的输入合约。传递以下字段: + +- `problem_topic`——用一句话描述具体问题。从用户参数中提取,如果未提供,则从无参数提示的答案中提取。 +- `scratch_dir`——`$SCRATCH` 的绝对路径。 +- `sessions`——对象数组,每个提取的会话对应一个,包含: + - `path`——骨架文件的绝对路径(以及提取了错误文件时的 `errors_path`) + - `platform`——`claude`、`codex` 或 `cursor` + - `branch`——存在时的 git 分支(仅 Claude Code) + - `cwd`——存在时的工作目录(仅 Codex) + - `ts` 和 `last_ts`——会话时间戳 + - `match_count` 和 `keyword_matches`——使用关键词过滤时 +- `output_schema`——代理响应的结构。默认 schema: + ``` + 将你的响应组织为以下章节(如无发现则省略对应章节): + - 之前尝试过的内容 + - 哪些方法无效 + - 关键决策 + - 相关上下文 + ``` + 当调用者(如 `ce-compound`)在技能参数中提供了 schema,则逐字传递。 + +调度示例格式: + +``` +综合来自这些先前会话的发现: + +问题主题:<一行主题> + +要读取的会话($SCRATCH 中的路径): +1. /tmp/ce-sessions-XXXX/abc123.skeleton.txt + platform=claude branch=feat/auth-fix ts=2026-05-01 +2. /tmp/ce-sessions-XXXX/def456.skeleton.txt errors=/tmp/ce-sessions-XXXX/def456.errors.txt + platform=codex cwd=/Users/.../my-project ts=2026-05-03 +... + +输出 schema: +- 之前尝试过的内容 +- 哪些方法无效 +- 关键决策 +- 相关上下文 + +过滤规则:仅呈现与此特定问题直接相关的发现。 +忽略来自同一会话或分支的无关工作。 +``` + +代理通过平台的原生文件读取工具读取每个路径,并返回散文格式的发现。批量提取内容仅存在于代理的子代理上下文中——编排器的工作状态仅保留文件路径和小型清单元数据。 + +### 第 7 步——返回发现 + +将合成器的输出文本逐字返回给调用者。如果发现或关键词过滤返回了零个会话(第 2 步或第 3 步),则改为返回字面字符串 `no relevant prior sessions`。 + +可选地清理临时空间: + +```bash +rm -rf "$SCRATCH" +``` + +操作系统最终无论如何都会处理清理工作;显式清理是为了期望看到它的读者。 + +## 输出 + +当调用者(通常是输入 `/ce-sessions` 的用户,或通过平台技能调用原语调用 ce-sessions 的其他技能)未指定输出格式时,应包含一个简要的头部说明搜索范围: + +``` +**搜索的会话**: [数量]([N] 个 Claude Code,[N] 个 Codex,[N] 个 Cursor)| [日期范围] +``` + +然后是合成器的散文式发现。当调用者提供了 schema 时,逐字遵循该 schema,并省略默认头部。 + +## 时间预算 + +一旦获得完整答案,立即停止。如果在几秒钟内就能自信地得出"未找到相关先前的会话",这本身就是一个完整的答案;不要为了填满时间而延长搜索。第 3 步中的结构性上限(最多深度分析 5 个会话)和第 5 步中的条件性尾部/错误提取机制从结构上限制了运行时间。 + +## 错误处理 + +如果发现管道失败(例如,家目录不可读、权限失败),将错误呈现给调用者。不要用 git 日志、文件列表或其他来源替代——此技能的合约是会话元数据和综合。 + +如果提取的 `--output` 写入失败(磁盘满、权限问题),呈现清晰的错误信息,且不要用不完整的路径调度合成器。 + +如果任何脚本的 `_meta` 报告 `parse_errors > 0`,在调度提示中注明部分提取情况,然后继续处理;合成器会在发现中标记不完整的情况。 diff --git a/scripts/discover-sessions.sh b/scripts/discover-sessions.sh new file mode 100755 index 0000000..ed4f349 --- /dev/null +++ b/scripts/discover-sessions.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Discover session files across Claude Code, Codex, and Cursor. +# +# Usage: discover-sessions.sh [--platform claude|codex|cursor] +# +# Outputs one file path per line. Safe in both bash and zsh (all globs guarded). +# Pass output to extract-metadata.py: +# python3 extract-metadata.py --cwd-filter $(bash discover-sessions.sh 7) +# +# Arguments: +# repo-name Folder name of the repo (e.g., "my-repo"). Used for directory matching. +# days Scan window in days (e.g., 7). Files older than this are skipped. +# --platform Restrict to a single platform. Omit to search all. + +set -euo pipefail + +REPO_NAME="${1:?Usage: discover-sessions.sh [--platform claude|codex|cursor]}" +DAYS="${2:?Usage: discover-sessions.sh [--platform claude|codex|cursor]}" +PLATFORM="${4:-all}" + +# Parse optional --platform flag +shift 2 +while [ $# -gt 0 ]; do + case "$1" in + --platform) PLATFORM="$2"; shift 2 ;; + *) shift ;; + esac +done + +# --- Claude Code --- +discover_claude() { + local base="$HOME/.claude/projects" + [ -d "$base" ] || return 0 + + # Find all project dirs matching repo name + for dir in "$base"/*"$REPO_NAME"*/; do + [ -d "$dir" ] || continue + find "$dir" -maxdepth 1 -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null + done +} + +# --- Codex --- +discover_codex() { + for base in "$HOME/.codex/sessions" "$HOME/.agents/sessions"; do + [ -d "$base" ] || continue + + # Use mtime-based discovery (consistent with Claude/Cursor) so that + # sessions started before the scan window but still active within it + # are not missed. + find "$base" -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null + done +} + +# --- Cursor --- +discover_cursor() { + local base="$HOME/.cursor/projects" + [ -d "$base" ] || return 0 + + for dir in "$base"/*"$REPO_NAME"*/; do + [ -d "$dir" ] || continue + local transcripts="$dir/agent-transcripts" + [ -d "$transcripts" ] || continue + find "$transcripts" -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null + done +} + +# --- Dispatch --- +case "$PLATFORM" in + claude) discover_claude ;; + codex) discover_codex ;; + cursor) discover_cursor ;; + all) + discover_claude + discover_codex + discover_cursor + ;; + *) + echo "Unknown platform: $PLATFORM" >&2 + exit 1 + ;; +esac diff --git a/scripts/extract-errors.py b/scripts/extract-errors.py new file mode 100755 index 0000000..9d48c82 --- /dev/null +++ b/scripts/extract-errors.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Extract error signals from a Claude Code, Codex, or Cursor JSONL session file. + +Usage: + cat | python3 extract-errors.py + cat | python3 extract-errors.py --output PATH + +Auto-detects platform from the JSONL structure. +Note: Cursor agent transcripts do not log tool results, so no errors can be extracted. +Finds failed tool calls / commands and outputs them with timestamps. + +When --output PATH is given, the extracted error log is written to PATH and +stdout receives only a one-line JSON status (_meta with wrote/bytes/stats). +This lets callers route bulk content to a scratch file without round-tripping +extraction bytes through orchestrator tool results. + +Without --output, extracted content goes to stdout and ends with a _meta line. +""" +import argparse +import io +import os +import sys +import json + +parser = argparse.ArgumentParser(add_help=True) +parser.add_argument( + "--output", + metavar="PATH", + help="Write extracted errors to PATH instead of stdout. Stdout receives a one-line _meta status.", +) +args = parser.parse_args() + +_original_stdout = sys.stdout +if args.output: + sys.stdout = io.StringIO() + +stats = {"lines": 0, "parse_errors": 0, "errors_found": 0} + + +def summarize_error(raw): + """Extract a short error summary instead of dumping the full payload.""" + text = str(raw).strip() + # Take the first non-empty line as the error message + for line in text.split("\n"): + line = line.strip() + if line: + return line[:200] + return text[:200] + + +def handle_claude(obj): + if obj.get("type") == "user": + content = obj.get("message", {}).get("content", []) + if isinstance(content, list): + for block in content: + if block.get("type") == "tool_result" and block.get("is_error"): + ts = obj.get("timestamp", "")[:19] + summary = summarize_error(block.get("content", "")) + print(f"[{ts}] [error] {summary}") + print("---") + stats["errors_found"] += 1 + + +def handle_codex(obj): + if obj.get("type") == "event_msg": + p = obj.get("payload", {}) + if p.get("type") == "exec_command_end": + output = p.get("aggregated_output", "") + stderr = p.get("stderr", "") + command = p.get("command", []) + cmd_str = command[-1] if command else "" + + exit_match = None + if "Process exited with code " in output: + try: + code_str = output.split("Process exited with code ")[1].split("\n")[0] + exit_code = int(code_str) + if exit_code != 0: + exit_match = exit_code + except (IndexError, ValueError): + pass + + if exit_match is not None or stderr: + ts = obj.get("timestamp", "")[:19] + error_summary = summarize_error(stderr if stderr else output) + print(f"[{ts}] [error] exit={exit_match} cmd={cmd_str[:120]}: {error_summary}") + print("---") + stats["errors_found"] += 1 + + +# Auto-detect platform from first few lines, then process all +detected = None +buffer = [] + +for line in sys.stdin: + line = line.strip() + if not line: + continue + buffer.append(line) + stats["lines"] += 1 + + if not detected and len(buffer) <= 10: + try: + obj = json.loads(line) + if obj.get("type") in ("user", "assistant"): + detected = "claude" + elif obj.get("type") in ("session_meta", "turn_context", "response_item", "event_msg"): + detected = "codex" + elif obj.get("role") in ("user", "assistant") and "type" not in obj: + detected = "cursor" + except (json.JSONDecodeError, KeyError): + pass + +# Cursor transcripts don't log tool results — no errors to extract +def handle_noop(obj): + pass + +handlers = {"claude": handle_claude, "codex": handle_codex, "cursor": handle_noop} +handler = handlers.get(detected, handle_noop) + +for line in buffer: + try: + handler(json.loads(line)) + except (json.JSONDecodeError, KeyError): + stats["parse_errors"] += 1 + +print(json.dumps({"_meta": True, **stats})) + +if args.output: + body = sys.stdout.getvalue() + sys.stdout = _original_stdout + with open(args.output, "w") as f: + f.write(body) + bytes_written = os.path.getsize(args.output) + print(json.dumps({"_meta": True, "wrote": args.output, "bytes": bytes_written, **stats})) diff --git a/scripts/extract-metadata.py b/scripts/extract-metadata.py new file mode 100755 index 0000000..e7c6431 --- /dev/null +++ b/scripts/extract-metadata.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Extract session metadata from Claude Code, Codex, and Cursor JSONL files. + +Batch mode (preferred — one invocation for all files): + python3 extract-metadata.py /path/to/dir/*.jsonl + python3 extract-metadata.py file1.jsonl file2.jsonl file3.jsonl + +Single-file mode (stdin): + head -20 | python3 extract-metadata.py + +Auto-detects platform from the JSONL structure. +Outputs one JSON object per file, one per line. +Includes a final _meta line with processing stats. +""" +import sys +import json +import os + +MAX_LINES = 25 # Only need first ~25 lines for metadata + + +def try_claude(lines): + for line in lines: + try: + obj = json.loads(line.strip()) + if obj.get("type") == "user" and "gitBranch" in obj: + return { + "platform": "claude", + "branch": obj["gitBranch"], + "ts": obj.get("timestamp", ""), + "session": obj.get("sessionId", ""), + } + except (json.JSONDecodeError, KeyError): + pass + return None + + +def try_codex(lines): + meta = {} + for line in lines: + try: + obj = json.loads(line.strip()) + if obj.get("type") == "session_meta": + p = obj.get("payload", {}) + meta["platform"] = "codex" + meta["cwd"] = p.get("cwd", "") + meta["session"] = p.get("id", "") + meta["ts"] = p.get("timestamp", obj.get("timestamp", "")) + meta["source"] = p.get("source", "") + meta["cli_version"] = p.get("cli_version", "") + elif obj.get("type") == "turn_context": + p = obj.get("payload", {}) + meta["model"] = p.get("model", "") + meta["cwd"] = meta.get("cwd") or p.get("cwd", "") + except (json.JSONDecodeError, KeyError): + pass + return meta if meta else None + + +def try_cursor(lines): + """Cursor agent transcripts: role-based entries, no timestamps or metadata fields.""" + for line in lines: + try: + obj = json.loads(line.strip()) + # Cursor entries have 'role' at top level but no 'type' + if obj.get("role") in ("user", "assistant") and "type" not in obj: + return {"platform": "cursor"} + except (json.JSONDecodeError, KeyError): + pass + return None + + +def extract_from_lines(lines): + return try_claude(lines) or try_codex(lines) or try_cursor(lines) + + +TAIL_BYTES = 16384 # Read last 16KB to find final timestamp past trailing metadata + + +def get_last_timestamp(filepath, size): + """Read the tail of a file to find the last message with a timestamp.""" + try: + with open(filepath, "rb") as f: + f.seek(max(0, size - TAIL_BYTES)) + tail = f.read().decode("utf-8", errors="ignore") + lines = tail.strip().split("\n") + for line in reversed(lines): + try: + obj = json.loads(line.strip()) + if "timestamp" in obj: + return obj["timestamp"] + except (json.JSONDecodeError, KeyError): + pass + except (OSError, IOError): + pass + return None + + +def _extract_user_assistant_text(filepath): + """Return concatenated user + assistant text content from a session JSONL. + + Skips JSONL metadata field names and values (sessionId, gitBranch, uuid, + timestamps, type tags), tool_use blocks (tool names + tool inputs), + tool_result blocks (tool outputs), and thinking/reasoning blocks. Only + content the user or assistant actually said is included. + + Without this filtering, common topic words like "session" would match every + JSONL file via the sessionId field, drowning out real content matches. + """ + chunks = [] + try: + with open(filepath, "r", errors="replace") as f: + for line in f: + try: + obj = json.loads(line.strip()) + except (json.JSONDecodeError, ValueError): + continue + + # Claude Code: type-tagged top-level + t = obj.get("type") + if t == "user": + msg = obj.get("message", {}) + content = msg.get("content") + if isinstance(content, str): + chunks.append(content) + elif isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + chunks.append(block.get("text", "")) + # Skip tool_result blocks — tool outputs are not user content. + continue + if t == "assistant": + msg = obj.get("message", {}) + content = msg.get("content", []) + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + chunks.append(block.get("text", "")) + # Skip tool_use and thinking blocks. + continue + + # Codex: payload-typed events + if t == "event_msg": + p = obj.get("payload", {}) + if p.get("type") == "user_message": + # Strip Codex/Conductor `...` + # wrapper before counting. Without this, generic wrapper terms + # (e.g., "Conductor", environment labels) false-match against + # boilerplate the user did not author. Mirrors the same split + # used in ce-session-extract/scripts/extract-skeleton.py. + msg = p.get("message", "") + if isinstance(msg, str): + parts = msg.split("") + chunks.append(parts[-1] if parts else msg) + continue + if t == "response_item": + p = obj.get("payload", {}) + if p.get("type") == "message" and p.get("role") == "assistant": + for block in p.get("content", []): + if isinstance(block, dict) and block.get("type") == "output_text": + chunks.append(block.get("text", "")) + continue + + # Cursor: role-tagged with no top-level type + if obj.get("role") in ("user", "assistant") and "type" not in obj: + msg = obj.get("message", {}) + for block in msg.get("content", []) if isinstance(msg.get("content"), list) else []: + if isinstance(block, dict) and block.get("type") == "text": + chunks.append(block.get("text", "")) + continue + except (OSError, IOError): + pass + return "\n".join(chunks) + + +def count_keyword_matches(filepath, keywords): + """Case-insensitive substring count for each keyword in user/assistant text. + + Returns a dict {original_keyword: count}. Scans only content the user or + assistant said — not JSONL metadata, tool calls, tool outputs, or thinking + blocks — so common topic words like "session" do not false-match against + the sessionId field. + """ + text_lower = _extract_user_assistant_text(filepath).lower() + return {kw: text_lower.count(kw.lower()) for kw in keywords} + + +def process_file(filepath): + """Extract metadata only. Keyword scanning is done separately so callers + can apply cheap filters (e.g. --cwd-filter) before paying the full-file + content scan cost.""" + try: + size = os.path.getsize(filepath) + with open(filepath, "r") as f: + lines = [] + for i, line in enumerate(f): + if i >= MAX_LINES: + break + lines.append(line) + result = extract_from_lines(lines) + if result: + result["file"] = filepath + result["size"] = size + if result["platform"] == "cursor": + # Cursor transcripts have no timestamps in JSONL. + # Use file modification time as the best available signal. + # Derive session ID from the parent directory name (UUID). + mtime = os.path.getmtime(filepath) + from datetime import datetime, timezone + + result["ts"] = datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat() + result["session"] = os.path.basename(os.path.dirname(filepath)) + else: + last_ts = get_last_timestamp(filepath, size) + if last_ts: + result["last_ts"] = last_ts + return result, None + else: + return None, filepath + except (OSError, IOError) as e: + return None, filepath + + +# Parse arguments: files and optional --cwd-filter / --keyword +files = [] +cwd_filter = None +keywords = None +args = sys.argv[1:] +i = 0 +while i < len(args): + if args[i] == "--cwd-filter" and i + 1 < len(args): + cwd_filter = args[i + 1] + i += 2 + elif args[i] == "--keyword" and i + 1 < len(args): + keywords = [k for k in args[i + 1].split(",") if k] + i += 2 + elif not args[i].startswith("-"): + files.append(args[i]) + i += 1 + else: + i += 1 + +if files: + # Batch mode: process all files + processed = 0 + parse_errors = 0 + filtered = 0 + matched = 0 + for filepath in files: + if not filepath.endswith(".jsonl"): + continue + result, error = process_file(filepath) + processed += 1 + if result: + # Apply CWD filter first: cheap metadata-only check. Skip Codex + # sessions from other repos before paying the full-file keyword + # scan cost — Codex discovery returns sessions across all repos, + # so without this ordering --keyword would scan files that are + # immediately discarded. + if cwd_filter and result.get("cwd") and cwd_filter not in result["cwd"]: + filtered += 1 + continue + # Apply keyword scan only after cheap filters pass. + if keywords: + matches = count_keyword_matches(filepath, keywords) + result["keyword_matches"] = matches + result["match_count"] = sum(matches.values()) + if result["match_count"] == 0: + continue + matched += 1 + print(json.dumps(result)) + elif error: + parse_errors += 1 + + meta = {"_meta": True, "files_processed": processed, "parse_errors": parse_errors} + if filtered: + meta["filtered_by_cwd"] = filtered + if keywords: + meta["files_matched"] = matched + print(json.dumps(meta)) +else: + # No file arguments: either single-file stdin mode or empty xargs invocation. + # When xargs runs us with no input (e.g., discover found no files), stdin is + # empty or a TTY — emit a clean zero-file result instead of a false parse error. + if sys.stdin.isatty(): + lines = [] + else: + lines = list(sys.stdin) + + if not lines: + # No input at all — zero-file result (clean exit for empty pipelines). + # When --keyword was supplied, emit files_matched: 0 so callers relying + # on its presence to terminate quickly in zero-match scans see a + # consistent shape with the batch-mode no-match case. + meta = {"_meta": True, "files_processed": 0, "parse_errors": 0} + if keywords: + meta["files_matched"] = 0 + print(json.dumps(meta)) + else: + # Genuine single-file stdin mode (backward compatible) + result = extract_from_lines(lines) + if result: + print(json.dumps(result)) + print(json.dumps({"_meta": True, "files_processed": 1, "parse_errors": 0 if result else 1})) diff --git a/scripts/extract-skeleton.py b/scripts/extract-skeleton.py new file mode 100755 index 0000000..e5e9267 --- /dev/null +++ b/scripts/extract-skeleton.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""Extract the conversation skeleton from a Claude Code, Codex, or Cursor JSONL session file. + +Usage: + cat | python3 extract-skeleton.py + cat | python3 extract-skeleton.py --output PATH + +Auto-detects platform (Claude Code, Codex, or Cursor) from the JSONL structure. +Extracts: + - User messages (text only, no tool results) + - Assistant text (no thinking/reasoning blocks) + - Collapsed tool call summaries (consecutive same-tool calls grouped) + +Consecutive tool calls of the same type are collapsed: + 3+ Read calls -> "[tools] 3x Read (file1, file2, +1 more) -> all ok" +Codex call/result pairs are deduplicated (only the result with status is kept). + +When --output PATH is given, the extracted skeleton is written to PATH and +stdout receives only a one-line JSON status (_meta with wrote/bytes/stats). +This lets callers route bulk content to a scratch file without round-tripping +extraction bytes through orchestrator tool results. + +Without --output, extracted content goes to stdout and ends with a _meta line. +""" +import argparse +import io +import os +import sys +import json +import re + +parser = argparse.ArgumentParser(add_help=True) +parser.add_argument( + "--output", + metavar="PATH", + help="Write extracted skeleton to PATH instead of stdout. Stdout receives a one-line _meta status.", +) +args = parser.parse_args() + +# Capture-and-redirect when --output is set: prints in the rest of the script +# go to the buffer; at the end the buffer is written to PATH and a status +# line is emitted to the real stdout. +_original_stdout = sys.stdout +if args.output: + sys.stdout = io.StringIO() + +stats = {"lines": 0, "parse_errors": 0, "user": 0, "assistant": 0, "tool": 0} + +# Claude Code wrapper tags to strip from user message content. +# Strip entirely (tag + content): framework noise and raw command output. +# Strip tags only (keep content): command-message, command-name, command-args, user_query. +_STRIP_BLOCK = re.compile( + r"<(?:task-notification|local-command-caveat|local-command-stdout|local-command-stderr|system-reminder)[^>]*>.*?", + re.DOTALL, +) +_STRIP_TAG = re.compile( + r"]*>" +) + + +def clean_text(text): + """Strip framework wrapper tags from message text (Claude and Cursor).""" + text = _STRIP_BLOCK.sub("", text) + text = _STRIP_TAG.sub("", text) + text = re.sub(r"\n{3,}", "\n\n", text).strip() + return text + +# Buffer for pending tool entries: [{"ts", "name", "target", "status"}] +pending_tools = [] + + +def flush_tools(): + """Print buffered tool entries, collapsing consecutive same-name groups.""" + if not pending_tools: + return + + # Group consecutive entries by tool name + groups = [] + for entry in pending_tools: + if groups and groups[-1][0]["name"] == entry["name"]: + groups[-1].append(entry) + else: + groups.append([entry]) + + for group in groups: + name = group[0]["name"] + if len(group) <= 2: + # Print individually + for e in group: + status = f" -> {e['status']}" if e.get("status") else "" + ts_prefix = f"[{e['ts']}] " if e.get("ts") else "" + print(f"{ts_prefix}[tool] {name} {e['target']}{status}") + stats["tool"] += 1 + else: + # Collapse + ts = group[0].get("ts", "") + targets = [e["target"] for e in group if e.get("target")] + ok = sum(1 for e in group if e.get("status") == "ok") + err = sum(1 for e in group if e.get("status") and e["status"] != "ok") + no_status = len(group) - ok - err + + # Show first 2 targets, then "+N more" + if len(targets) > 2: + target_str = ", ".join(targets[:2]) + f", +{len(targets) - 2} more" + elif targets: + target_str = ", ".join(targets) + else: + target_str = "" + + if no_status == len(group): + status_str = "" + elif err == 0: + status_str = " -> all ok" + else: + status_str = f" -> {ok} ok, {err} error" + + ts_prefix = f"[{ts}] " if ts else "" + print(f"{ts_prefix}[tools] {len(group)}x {name} ({target_str}){status_str}") + stats["tool"] += len(group) + + pending_tools.clear() + + +def _safe_slice(value, n): + """Slice value if it is a string; otherwise return ''. + + Some Claude Code / MCP tool inputs put structured data (dicts, lists) in + fields like `query` or `prompt`. `dict[:N]` raises TypeError, so guard + every slice with an isinstance check. + """ + return value[:n] if isinstance(value, str) else "" + + +def summarize_claude_tool(block): + """Extract name and target from a Claude Code tool_use block.""" + name = block.get("name", "unknown") + inp = block.get("input", {}) + fp = inp.get("file_path") + p = inp.get("path") + target = ( + (fp if isinstance(fp, str) else None) + or (p if isinstance(p, str) else None) + or _safe_slice(inp.get("command"), 120) + or _safe_slice(inp.get("pattern"), 200) + or _safe_slice(inp.get("query"), 80) + or _safe_slice(inp.get("prompt"), 80) + or "" + ) + if isinstance(target, str) and len(target) > 120: + target = target[:120] + return name, target + + +def handle_claude(obj): + msg_type = obj.get("type") + ts = obj.get("timestamp", "")[:19] + + if msg_type == "user": + msg = obj.get("message", {}) + content = msg.get("content", "") + + if isinstance(content, list): + for block in content: + if block.get("type") == "tool_result": + is_error = block.get("is_error", False) + status = "error" if is_error else "ok" + tool_use_id = block.get("tool_use_id") + matched = False + if tool_use_id: + for entry in pending_tools: + if entry.get("id") == tool_use_id: + entry["status"] = status + matched = True + break + if not matched: + # Fallback: assign to earliest pending entry without a status + for entry in pending_tools: + if not entry.get("status"): + entry["status"] = status + break + + texts = [ + c.get("text", "") + for c in content + if c.get("type") == "text" and len(c.get("text", "")) > 10 + ] + content = " ".join(texts) + + if isinstance(content, str): + content = clean_text(content) + if len(content) > 15: + flush_tools() + print(f"[{ts}] [user] {content[:800]}") + print("---") + stats["user"] += 1 + + elif msg_type == "assistant": + msg = obj.get("message", {}) + content = msg.get("content", []) + if isinstance(content, list): + has_text = False + for block in content: + if block.get("type") == "text": + text = clean_text(block.get("text", "")) + if len(text) > 20: + if not has_text: + flush_tools() + has_text = True + print(f"[{ts}] [assistant] {text[:800]}") + print("---") + stats["assistant"] += 1 + elif block.get("type") == "tool_use": + name, target = summarize_claude_tool(block) + entry = {"ts": ts, "name": name, "target": target} + tool_id = block.get("id") + if tool_id: + entry["id"] = tool_id + pending_tools.append(entry) + + +def handle_codex(obj): + msg_type = obj.get("type") + ts = obj.get("timestamp", "")[:19] + + if msg_type == "event_msg": + p = obj.get("payload", {}) + if p.get("type") == "user_message": + text = p.get("message", "") + if isinstance(text, str) and len(text) > 15: + parts = text.split("") + user_text = parts[-1].strip() if parts else text + if len(user_text) > 15: + flush_tools() + print(f"[{ts}] [user] {user_text[:800]}") + print("---") + stats["user"] += 1 + + elif p.get("type") == "exec_command_end": + # This is the deduplicated result — has status info + command = p.get("command", []) + cmd_str = command[-1] if command else "" + output = p.get("aggregated_output", "") + + status = "ok" + if "Process exited with code " in output: + try: + code = int(output.split("Process exited with code ")[1].split("\n")[0]) + if code != 0: + status = f"error(exit {code})" + except (IndexError, ValueError): + pass + + if cmd_str: + # Shorten common patterns for readability + short_cmd = cmd_str[:120] + pending_tools.append({"ts": ts, "name": "exec", "target": short_cmd, "status": status}) + + elif msg_type == "response_item": + p = obj.get("payload", {}) + if p.get("type") == "message" and p.get("role") == "assistant": + for block in p.get("content", []): + if block.get("type") == "output_text" and len(block.get("text", "")) > 20: + flush_tools() + print(f"[{ts}] [assistant] {block['text'][:800]}") + print("---") + stats["assistant"] += 1 + + # Skip function_call — exec_command_end is the deduplicated version with status + + +def handle_cursor(obj): + """Cursor agent transcripts: role-based, no timestamps, same content structure as Claude.""" + role = obj.get("role") + content = obj.get("message", {}).get("content", []) + + if role == "user": + texts = [] + for block in (content if isinstance(content, list) else []): + if block.get("type") == "text": + texts.append(block.get("text", "")) + text = clean_text(" ".join(texts)) + if len(text) > 15: + flush_tools() + # No timestamps available in Cursor transcripts + print(f"[user] {text[:800]}") + print("---") + stats["user"] += 1 + + elif role == "assistant": + has_text = False + for block in (content if isinstance(content, list) else []): + if block.get("type") == "text": + text = block.get("text", "") + # Skip [REDACTED] placeholder blocks + if len(text) > 20 and text.strip() != "[REDACTED]": + if not has_text: + flush_tools() + has_text = True + print(f"[assistant] {text[:800]}") + print("---") + stats["assistant"] += 1 + elif block.get("type") == "tool_use": + name = block.get("name", "unknown") + inp = block.get("input", {}) + p = inp.get("path") + fp = inp.get("file_path") + target = ( + (p if isinstance(p, str) else None) + or (fp if isinstance(fp, str) else None) + or _safe_slice(inp.get("command"), 120) + or _safe_slice(inp.get("pattern"), 200) + or _safe_slice(inp.get("glob_pattern"), 200) + or _safe_slice(inp.get("target_directory"), 200) + or "" + ) + if isinstance(target, str) and len(target) > 120: + target = target[:120] + # No status info available — Cursor doesn't log tool results + pending_tools.append({"ts": "", "name": name, "target": target}) + + +# Auto-detect platform from first few lines, then process all +detected = None +buffer = [] + +for line in sys.stdin: + line = line.strip() + if not line: + continue + buffer.append(line) + stats["lines"] += 1 + + if not detected and len(buffer) <= 10: + try: + obj = json.loads(line) + if obj.get("type") in ("user", "assistant"): + detected = "claude" + elif obj.get("type") in ("session_meta", "turn_context", "response_item", "event_msg"): + detected = "codex" + elif obj.get("role") in ("user", "assistant") and "type" not in obj: + detected = "cursor" + except (json.JSONDecodeError, KeyError): + pass + +handlers = {"claude": handle_claude, "codex": handle_codex, "cursor": handle_cursor} +handler = handlers.get(detected, handle_codex) + +for line in buffer: + try: + handler(json.loads(line)) + except (json.JSONDecodeError, KeyError): + stats["parse_errors"] += 1 + +# Flush any remaining buffered tools +flush_tools() + +print(json.dumps({"_meta": True, **stats})) + +if args.output: + body = sys.stdout.getvalue() + sys.stdout = _original_stdout + with open(args.output, "w") as f: + f.write(body) + bytes_written = os.path.getsize(args.output) + print(json.dumps({"_meta": True, "wrote": args.output, "bytes": bytes_written, **stats}))