Skip to content

Instantly share code, notes, and snippets.

@weaming
Created August 14, 2026 11:32
Show Gist options
  • Select an option

  • Save weaming/77fe43a44c774cc395e235fd47fb9685 to your computer and use it in GitHub Desktop.

Select an option

Save weaming/77fe43a44c774cc395e235fd47fb9685 to your computer and use it in GitHub Desktop.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""解析 Codex 或 Claude 会话历史。"""
import argparse
import json
import os
import shutil
import sqlite3
import sys
import unicodedata
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from pathlib import Path
from typing import Any, Iterable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
CODEX_DATABASE = Path.home() / '.codex' / 'thread_history_1.sqlite'
CLAUDE_DIRECTORY = Path.home() / '.claude'
DEFAULT_TIMEZONE = 'Asia/Shanghai'
CODEX_TOOL_TYPES = {
'commandExecution': 'shell',
'fileChange': 'fileChange',
'imageView': 'imageView',
'mcpToolCall': 'mcpToolCall',
'webSearch': 'webSearch',
}
BLUE = '\033[34m'
FAINT = '\033[2m'
RESET = '\033[0m'
class HistoryError(RuntimeError):
"""表示会话历史无法解析。"""
@dataclass
class ConversationTurn:
"""表示一轮用户输入、工具调用和助手回答。"""
question: str | None = None
tools: list[str] = field(default_factory=list)
answer: str | None = None
def add_tool(self, name: str | None) -> None:
if name and name not in self.tools:
self.tools.append(name)
@dataclass
class SessionData:
source: str
session_id: str
path: Path
inputs: list[str] = field(default_factory=list)
tools: list[str] = field(default_factory=list)
final_output: str | None = None
activity_dates: set[date] = field(default_factory=set)
turns: list[ConversationTurn] = field(default_factory=list)
started_at: datetime | None = None
ended_at: datetime | None = None
def refresh_summary(self) -> None:
"""根据对话轮次刷新会话级摘要字段。"""
self.turns = [
turn for turn in self.turns if turn.question or turn.tools or (turn.answer and turn.answer.strip())
]
self.inputs = [turn.question for turn in self.turns if turn.question]
self.tools = []
for turn in self.turns:
for tool_name in turn.tools:
if tool_name not in self.tools:
self.tools.append(tool_name)
self.final_output = next(
(turn.answer for turn in reversed(self.turns) if turn.answer and turn.answer.strip()),
None,
)
def matches_query(self, query: str | None) -> bool:
"""判断查询词是否出现在用户输入或最终输出中。"""
if not query:
return True
normalized_query = query.casefold()
answer_texts = [turn.answer or '' for turn in self.turns]
searchable_texts = [*self.inputs, *answer_texts]
return any(normalized_query in text.casefold() for text in searchable_texts)
def add_activity_timestamp(self, timestamp: Any, display_timezone: ZoneInfo) -> None:
"""记录时间戳在显示时区对应的日期。"""
activity_time = parse_timestamp(timestamp, display_timezone)
if activity_time:
self.activity_dates.add(activity_time.date())
if self.started_at is None or activity_time < self.started_at:
self.started_at = activity_time
if self.ended_at is None or activity_time > self.ended_at:
self.ended_at = activity_time
def get_display_timezone() -> ZoneInfo:
"""读取 TZ 环境变量,未设置时使用 Asia/Shanghai。"""
timezone_name = os.environ.get('TZ') or DEFAULT_TIMEZONE
try:
return ZoneInfo(timezone_name)
except ZoneInfoNotFoundError as error:
raise HistoryError(f'无效的 TZ 时区:{timezone_name}') from error
def parse_timestamp(timestamp: Any, display_timezone: ZoneInfo) -> datetime | None:
"""将 Unix 毫秒或 ISO 时间戳转换为显示时区时间。"""
if isinstance(timestamp, (int, float)):
return datetime.fromtimestamp(timestamp / 1000, display_timezone)
if not isinstance(timestamp, str) or not timestamp:
return None
try:
parsed_timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
except ValueError:
return None
if parsed_timestamp.tzinfo is None:
parsed_timestamp = parsed_timestamp.replace(tzinfo=display_timezone)
return parsed_timestamp.astimezone(display_timezone)
def get_today(display_timezone: ZoneInfo) -> date:
"""获取显示时区中的今天。"""
return datetime.now(display_timezone).date()
def get_date_timestamp_bounds(target_date: date, display_timezone: ZoneInfo) -> tuple[int, int]:
"""获取显示时区指定日期的 Unix 毫秒边界。"""
next_date = target_date + timedelta(days=1)
start_timestamp = datetime.combine(target_date, time.min, tzinfo=display_timezone)
end_timestamp = datetime.combine(next_date, time.min, tzinfo=display_timezone)
return int(start_timestamp.timestamp() * 1000), int(end_timestamp.timestamp() * 1000)
def get_text(value: Any) -> str | None:
"""从字符串或消息内容块中提取可展示文本。"""
if isinstance(value, str):
return value
if not isinstance(value, list):
return None
text_parts: list[str] = []
for block in value:
if isinstance(block, str):
text_parts.append(block)
elif isinstance(block, dict) and block.get('type') == 'text':
text = block.get('text')
if isinstance(text, str):
text_parts.append(text)
return '\n'.join(text_parts) or None
def get_tool_names(value: Any) -> Iterable[str]:
"""递归提取消息内容中的工具名称。"""
if isinstance(value, dict):
if value.get('type') == 'tool_use':
name = value.get('name') or value.get('tool_name')
if isinstance(name, str):
yield name
for nested_value in value.values():
yield from get_tool_names(nested_value)
elif isinstance(value, list):
for nested_value in value:
yield from get_tool_names(nested_value)
def connect_readonly(database_path: Path) -> sqlite3.Connection:
"""以只读方式打开 SQLite 历史数据库。"""
if not database_path.is_file():
raise HistoryError(f'找不到 Codex 历史数据库:{database_path}')
try:
return sqlite3.connect(f'file:{database_path}?mode=ro', uri=True)
except sqlite3.Error as error:
raise HistoryError(f'打开 Codex 历史数据库失败:{error}') from error
def resolve_codex_session(session_id: str, database_path: Path) -> tuple[str, Path]:
"""解析 Codex 会话 ID,并允许使用唯一前缀。"""
with connect_readonly(database_path) as connection:
rows = connection.execute(
"""
SELECT DISTINCT thread_id
FROM thread_items
WHERE thread_id = ? OR thread_id LIKE ?
ORDER BY thread_id
""",
(session_id, f'{session_id}%'),
).fetchall()
if not rows:
raise HistoryError(f'找不到 Codex 会话:{session_id}')
if len(rows) > 1:
matches = ', '.join(row[0] for row in rows)
raise HistoryError(f'Codex 会话前缀不唯一:{matches}')
return rows[0][0], database_path
def codex_session_exists(session_id: str, database_path: Path) -> bool:
"""判断 Codex 会话或唯一前缀是否存在。"""
try:
resolve_codex_session(session_id, database_path)
except HistoryError:
return False
return True
def parse_codex(
session_id: str,
database_path: Path,
display_timezone: ZoneInfo,
) -> SessionData:
"""解析 Codex SQLite 会话。"""
resolved_session_id, resolved_path = resolve_codex_session(session_id, database_path)
session = SessionData('codex', resolved_session_id, resolved_path)
turns_by_id: dict[str, ConversationTurn] = {}
with connect_readonly(database_path) as connection:
rows = connection.execute(
"""
SELECT item_type, item_json, created_at_ms, turn_id
FROM thread_items
WHERE thread_id = ?
ORDER BY rollout_ordinal
""",
(resolved_session_id,),
).fetchall()
for item_type, item_json, created_at_ms, turn_id in rows:
session.add_activity_timestamp(created_at_ms, display_timezone)
turn_key = str(turn_id)
turn = turns_by_id.setdefault(turn_key, ConversationTurn())
try:
item = json.loads(item_json)
except json.JSONDecodeError:
continue
if not isinstance(item, dict):
continue
if item_type == 'userMessage':
content = get_text(item.get('content'))
if content:
turn.question = f'{turn.question}\n{content.strip()}' if turn.question else content.strip()
continue
tool_name = CODEX_TOOL_TYPES.get(item_type)
if tool_name:
if item_type == 'mcpToolCall' and isinstance(item.get('tool'), str):
tool_name = item['tool']
turn.add_tool(tool_name)
if item_type != 'agentMessage':
continue
output = item.get('text')
if not isinstance(output, str) or not output.strip():
continue
output = output.strip()
if item.get('phase') == 'final_answer' or turn.answer is None:
turn.answer = output
session.turns = list(turns_by_id.values())
session.refresh_summary()
return session
def list_codex_session_ids(
database_path: Path,
display_timezone: ZoneInfo,
target_date: date | None,
) -> list[str]:
"""列出 Codex 历史中的会话 ID。"""
query = """
SELECT thread_id
FROM thread_items
"""
query_parameters: tuple[int, ...] = ()
if target_date:
start_timestamp, end_timestamp = get_date_timestamp_bounds(target_date, display_timezone)
query += 'WHERE created_at_ms >= ? AND created_at_ms < ?\n'
query_parameters = (start_timestamp, end_timestamp)
query += 'GROUP BY thread_id ORDER BY MAX(created_at_ms) DESC'
with connect_readonly(database_path) as connection:
rows = connection.execute(query, query_parameters).fetchall()
return [row[0] for row in rows]
def claude_session_candidates(session_id: str, claude_directory: Path) -> list[Path]:
"""查找 Claude 会话文件。"""
session_path = Path(session_id).expanduser()
if session_path.is_file():
return [session_path]
candidates: list[Path] = []
for search_directory in (
claude_directory / 'projects',
claude_directory / 'transcripts',
):
if not search_directory.is_dir():
continue
candidates.extend(
path
for path in search_directory.rglob(f'{session_id}.jsonl')
if path.is_file() and not path.name.endswith('.wakatime')
)
return candidates
def resolve_claude_session(session_id: str, claude_directory: Path) -> Path:
"""解析 Claude 会话文件路径。"""
candidates = claude_session_candidates(session_id, claude_directory)
if not candidates:
raise HistoryError(f'找不到 Claude 会话:{session_id}')
if len(candidates) > 1:
matches = ', '.join(str(path) for path in candidates)
raise HistoryError(f'Claude 会话对应多个文件:{matches}')
return candidates[0]
def list_claude_session_paths(claude_directory: Path) -> list[Path]:
"""列出 Claude 项目和 transcript 中的会话文件。"""
session_paths: set[Path] = set()
for search_directory in (
claude_directory / 'projects',
claude_directory / 'transcripts',
):
if not search_directory.is_dir():
continue
session_paths.update(
path for path in search_directory.rglob('*.jsonl') if path.is_file() and not path.name.endswith('.wakatime')
)
return sorted(session_paths, key=lambda path: path.stat().st_mtime, reverse=True)
def is_real_user_input(item: dict[str, Any], content: str | None) -> bool:
"""过滤 Claude 的内部命令和工具返回消息。"""
if not content or item.get('isMeta') is True:
return False
if isinstance(item.get('toolUseResult'), (dict, list, str)):
return False
return '<local-command-caveat>' not in content and '<command-name>' not in content
def parse_claude(
session_id: str,
claude_directory: Path,
display_timezone: ZoneInfo,
allow_empty: bool = False,
) -> SessionData:
"""解析 Claude JSONL 会话。"""
session_path = resolve_claude_session(session_id, claude_directory)
resolved_session_id = session_path.stem
session = SessionData('claude', resolved_session_id, session_path)
turns: list[ConversationTurn] = []
current_turn: ConversationTurn | None = None
try:
lines = session_path.open(encoding='utf-8')
except OSError as error:
raise HistoryError(f'读取 Claude 会话失败:{session_path}:{error}') from error
line_number = 0
with lines:
for line_number, line in enumerate(lines, start=1):
try:
item = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(item, dict):
continue
session.add_activity_timestamp(item.get('timestamp'), display_timezone)
item_type = item.get('type')
message = item.get('message')
message_role = message.get('role') if isinstance(message, dict) else None
content_value = message.get('content') if isinstance(message, dict) else item.get('content')
content = get_text(content_value)
if item_type == 'user' and message_role != 'assistant':
if is_real_user_input(item, content):
if current_turn:
turns.append(current_turn)
current_turn = ConversationTurn(question=content.strip())
continue
if current_turn is None:
current_turn = ConversationTurn()
for tool_name in get_tool_names(item):
current_turn.add_tool(tool_name)
if item_type == 'tool_use':
current_turn.add_tool(item.get('tool_name'))
if item_type == 'assistant' or message_role == 'assistant':
if content and content.strip():
current_turn.answer = content.strip()
if current_turn:
turns.append(current_turn)
session.turns = turns
session.refresh_summary()
if not allow_empty and not session.inputs and not session.tools and not session.final_output:
raise HistoryError(f'Claude 会话没有可解析内容:{session_path}(最后检查行:{line_number})')
return session
def detect_source(session_id: str, claude_directory: Path) -> str:
"""根据会话 ID 和本地历史文件判断来源。"""
if session_id.startswith('ses_') or session_id.endswith('.jsonl'):
return 'claude'
if Path(session_id).expanduser().is_file() or claude_session_candidates(session_id, claude_directory):
return 'claude'
return 'codex'
def print_labeled_text(
label: str,
content: str | None,
indent_continuation: bool = True,
separator: str = '>',
) -> None:
"""以紧凑标签格式输出多行文本。"""
if not content or not content.strip():
print(f'{label}{separator} (无)')
return
lines = content.strip().splitlines()
print(f'{label}{separator} {lines[0]}')
for line in lines[1:]:
print(f' {line}' if indent_continuation else line)
def use_terminal_color() -> bool:
"""判断当前输出是否适合使用 ANSI 颜色。"""
return sys.stdout.isatty() and 'NO_COLOR' not in os.environ
def colorize(text: str, color: str, enabled: bool) -> str:
"""为文本添加 ANSI 颜色。"""
if not enabled:
return text
return f'{color}{text}{RESET}'
def print_session_separator(use_color: bool, with_blank_lines: bool = True) -> None:
"""输出终端宽度的淡色会话分隔线。"""
width = max(shutil.get_terminal_size((80, 20)).columns, 1)
separator = '─' * width
if with_blank_lines:
print()
print(colorize(separator, FAINT, use_color))
if with_blank_lines:
print()
def print_session_time(session: SessionData, display_timezone: ZoneInfo) -> None:
"""输出会话起止时间和显示时区。"""
if session.started_at and session.ended_at:
start_time = session.started_at.strftime('%Y-%m-%d %H:%M')
end_time = session.ended_at.strftime('%Y-%m-%d %H:%M')
print(f'Time: {start_time} ~ {end_time} (TZ={display_timezone.key})')
else:
print(f'Time: (无) (TZ={display_timezone.key})')
def print_session(
session: SessionData,
display_timezone: ZoneInfo,
use_color: bool | None = None,
) -> None:
"""以紧凑格式输出会话正文。"""
if use_color is None:
use_color = use_terminal_color()
colored_session_id = colorize(session.session_id, BLUE, use_color)
print(f'[{session.source}] UUID {colored_session_id}')
print_session_time(session, display_timezone)
print()
if not session.turns:
print(' Q: (无)')
print_labeled_text(' A:', session.final_output, indent_continuation=False, separator='')
return
label_width = len(f'Q{len(session.turns)}:') + 1
for index, turn in enumerate(session.turns, start=1):
question_label = f'Q{index}:'.rjust(label_width)
print_labeled_text(question_label, turn.question, separator='')
if turn.tools:
tool_text = ', '.join(turn.tools)
tool_label = f'T{index}:'.rjust(label_width)
print(f'{tool_label} {tool_text}')
answer_index = get_last_answer_index(session)
answer_label = f'A{answer_index}:'.rjust(label_width)
answer_prefix = f'{answer_label} '
answer_text = shorten_to_terminal_width(session.final_output, answer_prefix)
print_labeled_text(
answer_label,
answer_text,
indent_continuation=False,
separator='',
)
def load_all_sessions(
source: str,
codex_database: Path,
claude_directory: Path,
display_timezone: ZoneInfo,
target_date: date | None,
) -> list[SessionData]:
"""加载指定来源的全部可解析会话。"""
sessions: list[SessionData] = []
if source in ('auto', 'codex'):
session_ids = list_codex_session_ids(codex_database, display_timezone, target_date)
for session_id in session_ids:
sessions.append(parse_codex(session_id, codex_database, display_timezone))
if source in ('auto', 'claude'):
for session_path in list_claude_session_paths(claude_directory):
try:
session = parse_claude(
str(session_path),
claude_directory,
display_timezone,
allow_empty=True,
)
if (target_date is None or target_date in session.activity_dates) and (
session.inputs or session.tools or session.final_output
):
sessions.append(session)
except HistoryError as error:
print(f'警告:跳过 Claude 会话:{error}', file=sys.stderr)
if not sessions:
raise HistoryError('没有找到可解析的会话')
return sessions
def get_display_width(text: str) -> int:
"""计算文本在终端中的显示列数。"""
display_width = 0
for character in text:
if unicodedata.combining(character):
continue
display_width += 2 if unicodedata.east_asian_width(character) in ('W', 'F') else 1
return display_width
def get_last_answer_index(session: SessionData) -> int:
"""获取最后一个助手回答所在的轮次编号。"""
return next(
(index for index, turn in reversed(list(enumerate(session.turns, start=1))) if turn.answer),
max(len(session.turns), 1),
)
def shorten_to_terminal_width(content: str | None, prefix: str) -> str:
"""按终端宽度将文本压成不会换行的单行。"""
single_line = ' '.join(content.split()) if content else '(无)'
terminal_width = max(shutil.get_terminal_size((80, 20)).columns, 1)
available_width = max(terminal_width - get_display_width(prefix) - 1, 1)
if get_display_width(single_line) <= available_width:
return single_line
truncated_characters: list[str] = []
current_width = 0
target_width = max(available_width - 1, 0)
for character in single_line:
character_width = get_display_width(character)
if current_width + character_width > target_width:
break
truncated_characters.append(character)
current_width += character_width
return ''.join(truncated_characters) + '…'
def print_session_index(
sessions: list[SessionData],
display_timezone: ZoneInfo,
) -> None:
"""输出会话索引,不展开全部正文。"""
use_color = use_terminal_color()
sorted_sessions = sorted(
sessions,
key=lambda session: session.ended_at.timestamp() if session.ended_at else float('-inf'),
)
for index, session in enumerate(sorted_sessions):
if index:
print_session_separator(use_color, with_blank_lines=False)
first_input = session.inputs[0] if session.inputs else None
colored_session_id = colorize(session.session_id, BLUE, use_color)
question_index = next(
(turn_index for turn_index, turn in enumerate(session.turns, start=1) if turn.question),
1,
)
answer_index = get_last_answer_index(session)
label_width = len(f'Q{max(len(session.turns), 1)}:') + 1
question_label = f'Q{question_index}:'.rjust(label_width)
answer_label = f'A{answer_index}:'.rjust(label_width)
question_prefix = f'{question_label} '
answer_prefix = f'{answer_label} '
print(f'[{session.source}] {colored_session_id}')
print_session_time(session, display_timezone)
print(f'{question_prefix}{shorten_to_terminal_width(first_input, question_prefix)}')
print(f'{answer_prefix}{shorten_to_terminal_width(session.final_output, answer_prefix)}')
def print_matching_sessions(
sessions: list[SessionData],
query: str,
display_timezone: ZoneInfo,
target_date: date | None,
) -> None:
"""输出匹配查询词的完整会话。"""
matching_sessions = [session for session in sessions if session.matches_query(query)]
if not matching_sessions:
raise HistoryError(f'没有找到请求或最终响应包含“{query}”的会话')
matching_sessions.sort(key=lambda session: session.ended_at.timestamp() if session.ended_at else float('-inf'))
use_color = use_terminal_color()
scope = str(target_date) if target_date else '全部日期'
print(f'范围={scope} TZ={display_timezone.key} | 匹配={len(matching_sessions)}')
for index, session in enumerate(matching_sessions):
if index:
print_session_separator(use_color)
print_session(session, display_timezone, use_color)
def build_parser() -> argparse.ArgumentParser:
"""创建命令行参数解析器。"""
parser = argparse.ArgumentParser(
description='解析 Codex 或 Claude 会话历史,输出输入、工具调用和最终输出。',
epilog='示例:ai-sessions tantivy;ai-sessions 019... tantivy;ai-sessions --source claude',
)
parser.add_argument(
'session_or_query',
nargs='?',
metavar='SESSION|QUERY',
help='会话 UUID 或查询词;普通文本会自动当作查询词',
)
parser.add_argument(
'query_text',
nargs='?',
metavar='QUERY',
help='指定会话后的查询词',
)
parser.add_argument(
'-q',
'--query',
dest='query',
help='按请求或最终响应文本过滤会话,不区分大小写',
)
parser.add_argument(
'--source',
choices=('auto', 'codex', 'claude'),
default='auto',
help='会话来源,默认按 ID 自动判断',
)
parser.add_argument(
'-a',
'--all',
action='store_true',
help='扫描全部日期的会话,默认只扫描 TZ 对应的今天',
)
parser.add_argument(
'-d',
'--date',
dest='date_text',
metavar='YYYY-MM-DD',
help='按 TZ 时区的指定日期过滤,默认今天',
)
parser.add_argument(
'--codex-db',
type=Path,
default=CODEX_DATABASE,
help=f'Codex 历史数据库(默认:{CODEX_DATABASE})',
)
parser.add_argument(
'--claude-dir',
type=Path,
default=CLAUDE_DIRECTORY,
help=f'Claude 数据目录(默认:{CLAUDE_DIRECTORY})',
)
return parser
def get_target_date(arguments: argparse.Namespace, display_timezone: ZoneInfo) -> date | None:
"""解析日期参数,返回按 TZ 过滤的目标日期。"""
if arguments.all and arguments.date_text:
raise HistoryError('不能同时使用 --all 和 --date')
if arguments.all:
return None
if not arguments.date_text:
return get_today(display_timezone)
try:
return date.fromisoformat(arguments.date_text)
except ValueError as error:
raise HistoryError(f'日期格式必须是 YYYY-MM-DD:{arguments.date_text}') from error
def normalize_arguments(arguments: argparse.Namespace) -> tuple[str | None, str | None]:
"""将简短的位置参数形式转换为会话 ID 和查询词。"""
first_value = arguments.session_or_query
second_value = arguments.query_text
option_query = arguments.query
if second_value and option_query:
raise HistoryError('不能同时使用位置查询词和 --query')
if not first_value:
return None, option_query
if second_value:
return first_value, second_value
source = arguments.source
is_claude_session = bool(claude_session_candidates(first_value, arguments.claude_dir))
if source == 'claude' or first_value.startswith('ses_') or first_value.endswith('.jsonl'):
is_session = is_claude_session or first_value.startswith('ses_') or first_value.endswith('.jsonl')
elif source == 'codex':
is_session = codex_session_exists(first_value, arguments.codex_db)
else:
is_session = is_claude_session or codex_session_exists(first_value, arguments.codex_db)
if is_session:
return first_value, option_query
if option_query:
raise HistoryError('第一个位置参数不是会话 ID,不能同时作为查询词使用 --query')
return None, first_value
def main() -> int:
"""解析参数并输出会话。"""
arguments = build_parser().parse_args()
try:
display_timezone = get_display_timezone()
target_date = get_target_date(arguments, display_timezone)
session_id, query = normalize_arguments(arguments)
if session_id:
source = arguments.source if arguments.source != 'auto' else detect_source(session_id, arguments.claude_dir)
if source == 'codex':
session = parse_codex(session_id, arguments.codex_db, display_timezone)
else:
session = parse_claude(session_id, arguments.claude_dir, display_timezone)
if not session.matches_query(query):
raise HistoryError(f'会话不匹配查询:{query}')
print_session(session, display_timezone)
return 0
sessions = load_all_sessions(
arguments.source,
arguments.codex_db,
arguments.claude_dir,
display_timezone,
target_date,
)
if query:
print_matching_sessions(
sessions,
query,
display_timezone,
target_date,
)
else:
print_session_index(
sessions,
display_timezone,
)
except HistoryError as error:
print(f'错误:{error}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())
@weaming

weaming commented Aug 14, 2026

Copy link
Copy Markdown
Author
ai-sessions -h
usage: ai-sessions [-h] [-q QUERY] [--source {auto,codex,claude}] [-a] [-d YYYY-MM-DD] [--codex-db CODEX_DB]
                   [--claude-dir CLAUDE_DIR]
                   [SESSION|QUERY] [QUERY]

解析 Codex 或 Claude 会话历史,输出输入、工具调用和最终输出。

positional arguments:
  SESSION|QUERY         会话 UUID 或查询词;普通文本会自动当作查询词
  QUERY                 指定会话后的查询词

options:
  -h, --help            show this help message and exit
  -q QUERY, --query QUERY
                        按请求或最终响应文本过滤会话,不区分大小写
  --source {auto,codex,claude}
                        会话来源,默认按 ID 自动判断
  -a, --all             扫描全部日期的会话,默认只扫描 TZ 对应的今天
  -d YYYY-MM-DD, --date YYYY-MM-DD
                        按 TZ 时区的指定日期过滤,默认今天
  --codex-db CODEX_DB   Codex 历史数据库(默认:/Users/garden/.codex/thread_history_1.sqlite)
  --claude-dir CLAUDE_DIR
                        Claude 数据目录(默认:/Users/garden/.claude)

示例:ai-sessions tantivy;ai-sessions 019... tantivy;ai-sessions --source claude
image image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment