chore: import zh skill sessions
This commit is contained in:
@@ -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 清单整理
|
||||
- 原作者、版权和许可证信息以上游仓库为准
|
||||
@@ -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`,在调度提示中注明部分提取情况,然后继续处理;合成器会在发现中标记不完整的情况。
|
||||
Executable
+81
@@ -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
|
||||
Executable
+135
@@ -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}))
|
||||
Executable
+304
@@ -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}))
|
||||
Executable
+365
@@ -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}))
|
||||
Reference in New Issue
Block a user