chore: import zh skill sessions

This commit is contained in:
wehub-skill-sync
2026-07-13 21:36:56 +08:00
commit 08cde863c6
6 changed files with 1110 additions and 0 deletions
+9
View File
@@ -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 清单整理
- 原作者、版权和许可证信息以上游仓库为准
+216
View File
@@ -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 <repo> <days> | tr '\n' '\0' | xargs -0 python3 scripts/extract-metadata.py --cwd-filter <repo>
```
每行输出是一个描述会话的 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/<session-id>.skeleton.txt" < <session-file>
```
标准输出仅接收一行 JSON 状态(`{"_meta": true, "wrote": "...", "bytes": N, ...}`)。从每个状态行捕获 `bytes``parse_errors`
**条件性尾部提取**——如果骨架在调查中途结束(最后一个可见轮次是一个没有解决方案的工具调用,或代理在没有结论的情况下进行调试),则使用 `tail` 格式重新提取:
```bash
python3 scripts/extract-skeleton.py --output "$SCRATCH/<session-id>.skeleton.tail.txt" < <session-file>
```
(骨架脚本本身不直接接受 `tail:N` 上限;如果需要仅尾部视图,可在提取后通过 `tail -n 50` 在 shell 中后处理临时文件。仅在头部输出表明会话在调查中途被截断时使用此方法。)
**条件性错误模式**——对于调查死胡同可能有价值的会话:
```bash
python3 scripts/extract-errors.py --output "$SCRATCH/<session-id>.errors.txt" < <session-file>
```
有选择地使用——仅当了解出了什么问题能增加价值时才使用。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`,在调度提示中注明部分提取情况,然后继续处理;合成器会在发现中标记不完整的情况。
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Discover session files across Claude Code, Codex, and Cursor.
#
# Usage: discover-sessions.sh <repo-name> <days> [--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 <repo-name> $(bash discover-sessions.sh <repo-name> 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 <repo-name> <days> [--platform claude|codex|cursor]}"
DAYS="${2:?Usage: discover-sessions.sh <repo-name> <days> [--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
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Extract error signals from a Claude Code, Codex, or Cursor JSONL session file.
Usage:
cat <session.jsonl> | python3 extract-errors.py
cat <session.jsonl> | 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}))
+304
View File
@@ -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 <session.jsonl> | 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 `<system_instruction>...</system_instruction>`
# 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("</system_instruction>")
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}))
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env python3
"""Extract the conversation skeleton from a Claude Code, Codex, or Cursor JSONL session file.
Usage:
cat <session.jsonl> | python3 extract-skeleton.py
cat <session.jsonl> | 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)[^>]*>.*?</(?:task-notification|local-command-caveat|local-command-stdout|local-command-stderr|system-reminder)>",
re.DOTALL,
)
_STRIP_TAG = re.compile(
r"</?(?:command-message|command-name|command-args|user_query)[^>]*>"
)
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("</system_instruction>")
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}))