@ericrisco/rsc 0.1.32 → 0.1.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/manifest.json +24 -5
- package/package.json +1 -1
- package/scripts/lib/domains.js +1 -1
- package/skills/analyze/SKILL.md +1 -0
- package/skills/author-skill/SKILL.md +20 -0
- package/skills/author-skill/references/description-recipe.md +2 -0
- package/skills/debug/SKILL.md +1 -1
- package/skills/implement/SKILL.md +72 -2
- package/skills/implement/references/per-task-review.md +46 -0
- package/skills/implement/scripts/review-package +59 -0
- package/skills/implement/scripts/sdd-workspace +47 -0
- package/skills/implement/scripts/task-brief +77 -0
- package/skills/parallel/SKILL.md +29 -0
- package/skills/plan/references/plan-template.md +18 -0
- package/skills/roast-me/SKILL.md +124 -0
- package/skills/roast-me/evals/README.md +76 -0
- package/skills/roast-me/evals/cases.yaml +75 -0
- package/skills/roast-me/prompts/analyze.md +90 -0
- package/skills/roast-me/prompts/compute.md +100 -0
- package/skills/roast-me/prompts/roast.md +181 -0
- package/skills/roast-me/tools/adapters/__init__.py +1 -0
- package/skills/roast-me/tools/adapters/__pycache__/__init__.cpython-312.pyc +0 -0
- package/skills/roast-me/tools/adapters/__pycache__/base.cpython-312.pyc +0 -0
- package/skills/roast-me/tools/adapters/__pycache__/claude.cpython-312.pyc +0 -0
- package/skills/roast-me/tools/adapters/__pycache__/codex.cpython-312.pyc +0 -0
- package/skills/roast-me/tools/adapters/__pycache__/gemini.cpython-312.pyc +0 -0
- package/skills/roast-me/tools/adapters/__pycache__/registry.cpython-312.pyc +0 -0
- package/skills/roast-me/tools/adapters/base.py +65 -0
- package/skills/roast-me/tools/adapters/claude.py +140 -0
- package/skills/roast-me/tools/adapters/codex.py +206 -0
- package/skills/roast-me/tools/adapters/gemini.py +142 -0
- package/skills/roast-me/tools/adapters/registry.py +68 -0
- package/skills/roast-me/tools/extract_prompts.py +519 -0
- package/skills/ship/SKILL.md +9 -1
- package/skills/specify/SKILL.md +21 -1
- package/skills/tasks/SKILL.md +25 -0
- package/skills/worktrees/SKILL.md +25 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Base adapter contract for roast-me transcript discovery.
|
|
2
|
+
|
|
3
|
+
Every runtime adapter implements two methods:
|
|
4
|
+
discover() -> list[Path] — find local session files; return [] if none found
|
|
5
|
+
parse(path) -> list[dict] — yield raw prompt-like records from one file
|
|
6
|
+
|
|
7
|
+
The adapter contract is intentionally minimal. Higher-level normalisation
|
|
8
|
+
(truncation, field enrichment, error/correction detection) happens in
|
|
9
|
+
extract_prompts.py, not here.
|
|
10
|
+
|
|
11
|
+
An adapter MUST:
|
|
12
|
+
- Never raise on missing files or unexpected formats — return [] instead.
|
|
13
|
+
- Tag every record with {"runtime": self.RUNTIME_ID}.
|
|
14
|
+
- Return only records that look like user-initiated prompts (not tool results,
|
|
15
|
+
not system messages).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
#: Shared correction detector — a user message matching this right after a prompt
|
|
26
|
+
#: signals the previous turn went wrong. Single source of truth for every adapter
|
|
27
|
+
#: (and re-used by extract_prompts.py) so the heuristic never drifts between runtimes.
|
|
28
|
+
CORRECTION_RE = re.compile(
|
|
29
|
+
r"\b(no[,.]?\s|wrong|instead|actually|don'?t|shouldn'?t|stop|not that|"
|
|
30
|
+
r"I said|I meant|I asked|that'?s not|please don'?t|why did you|"
|
|
31
|
+
r"you should have|that was wrong|incorrect|try again|redo|"
|
|
32
|
+
r"that broke|you broke|revert|undo)\b",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BaseAdapter(ABC):
|
|
38
|
+
"""Contract every runtime adapter must satisfy."""
|
|
39
|
+
|
|
40
|
+
#: Short identifier used to tag records and in CLI --runtime flag.
|
|
41
|
+
RUNTIME_ID: str = "unknown"
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def discover(self) -> list[Path]:
|
|
45
|
+
"""Return a list of session file paths found for this runtime.
|
|
46
|
+
|
|
47
|
+
Must return [] (not raise) when the runtime is not installed or no
|
|
48
|
+
data directory exists.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def parse(self, path: Path) -> list[dict[str, Any]]:
|
|
53
|
+
"""Parse one session file and return a list of raw prompt records.
|
|
54
|
+
|
|
55
|
+
Each record must include at minimum:
|
|
56
|
+
- "runtime": str — the RUNTIME_ID of this adapter
|
|
57
|
+
- "prompt_text": str — the user's message text
|
|
58
|
+
- "timestamp": float | None — unix timestamp of the message (or None)
|
|
59
|
+
- "session_file": str — stringified path of the source file
|
|
60
|
+
|
|
61
|
+
Additional fields are welcomed and used by extract_prompts.py if present
|
|
62
|
+
(e.g. "model", "cost_usd").
|
|
63
|
+
|
|
64
|
+
Must return [] (not raise) on parse failures.
|
|
65
|
+
"""
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Claude Code adapter — first-class reference implementation.
|
|
2
|
+
|
|
3
|
+
Session files: ~/.claude/projects/*/*.jsonl
|
|
4
|
+
Format: JSON Lines, one object per line.
|
|
5
|
+
|
|
6
|
+
Each line is a conversation event with a "type" field:
|
|
7
|
+
"user" — user message (prompt or tool result)
|
|
8
|
+
"assistant" — model response
|
|
9
|
+
|
|
10
|
+
User prompt records are lines where:
|
|
11
|
+
- type == "user"
|
|
12
|
+
- message.isMeta is falsy
|
|
13
|
+
- message.content contains at least one text block (not only tool_result blocks)
|
|
14
|
+
|
|
15
|
+
This is the canonical adapter; its parsing logic is the reference for all others.
|
|
16
|
+
|
|
17
|
+
Confirmed: path and format verified against live ~/.claude/projects structure on
|
|
18
|
+
macOS (2026-06). The JSONL schema is stable across Claude Code versions.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .base import BaseAdapter
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ClaudeAdapter(BaseAdapter):
|
|
32
|
+
RUNTIME_ID = "claude"
|
|
33
|
+
|
|
34
|
+
# Location of Claude Code project session files.
|
|
35
|
+
PROJECTS_DIR = Path.home() / ".claude" / "projects"
|
|
36
|
+
|
|
37
|
+
def discover(self) -> list[Path]:
|
|
38
|
+
"""Find all *.jsonl session files under ~/.claude/projects/."""
|
|
39
|
+
if not self.PROJECTS_DIR.exists():
|
|
40
|
+
return []
|
|
41
|
+
files: list[Path] = []
|
|
42
|
+
try:
|
|
43
|
+
for jsonl in self.PROJECTS_DIR.rglob("*.jsonl"):
|
|
44
|
+
try:
|
|
45
|
+
if jsonl.is_file():
|
|
46
|
+
files.append(jsonl)
|
|
47
|
+
except OSError:
|
|
48
|
+
continue
|
|
49
|
+
except OSError:
|
|
50
|
+
return []
|
|
51
|
+
return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
|
|
52
|
+
|
|
53
|
+
def parse(self, path: Path) -> list[dict[str, Any]]:
|
|
54
|
+
"""Parse one Claude Code .jsonl session file."""
|
|
55
|
+
records: list[dict[str, Any]] = []
|
|
56
|
+
try:
|
|
57
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
58
|
+
except OSError:
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
for line in lines:
|
|
62
|
+
line = line.strip()
|
|
63
|
+
if not line:
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
obj = json.loads(line)
|
|
67
|
+
except json.JSONDecodeError:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
if obj.get("type") != "user":
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
msg = obj.get("message", {})
|
|
74
|
+
if msg.get("isMeta"):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
content = msg.get("content", [])
|
|
78
|
+
text = _extract_text(content)
|
|
79
|
+
if not text.strip():
|
|
80
|
+
continue
|
|
81
|
+
# Skip messages that are only tool_result blocks
|
|
82
|
+
if _is_only_tool_results(content):
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
ts = _parse_timestamp(obj)
|
|
86
|
+
records.append({
|
|
87
|
+
"runtime": self.RUNTIME_ID,
|
|
88
|
+
"session_file": str(path),
|
|
89
|
+
"prompt_text": text,
|
|
90
|
+
"timestamp": ts,
|
|
91
|
+
# Forward the raw object so the main extractor can inspect
|
|
92
|
+
# adjacent lines for error/correction context.
|
|
93
|
+
"_raw": obj,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return records
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Helpers
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _extract_text(content: Any) -> str:
|
|
104
|
+
if isinstance(content, str):
|
|
105
|
+
return content
|
|
106
|
+
if isinstance(content, list):
|
|
107
|
+
parts = []
|
|
108
|
+
for block in content:
|
|
109
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
110
|
+
parts.append(block.get("text", ""))
|
|
111
|
+
return "\n".join(parts)
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_only_tool_results(content: Any) -> bool:
|
|
116
|
+
if not isinstance(content, list):
|
|
117
|
+
return False
|
|
118
|
+
has_text = any(
|
|
119
|
+
isinstance(b, dict) and b.get("type") == "text" for b in content
|
|
120
|
+
)
|
|
121
|
+
has_tool_result = any(
|
|
122
|
+
isinstance(b, dict) and b.get("type") == "tool_result" for b in content
|
|
123
|
+
)
|
|
124
|
+
return has_tool_result and not has_text
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_timestamp(obj: dict) -> float | None:
|
|
128
|
+
ts = obj.get("timestamp")
|
|
129
|
+
if ts is None:
|
|
130
|
+
return None
|
|
131
|
+
if isinstance(ts, (int, float)):
|
|
132
|
+
return float(ts)
|
|
133
|
+
if isinstance(ts, str):
|
|
134
|
+
try:
|
|
135
|
+
import datetime
|
|
136
|
+
dt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
137
|
+
return dt.timestamp()
|
|
138
|
+
except (ValueError, AttributeError):
|
|
139
|
+
pass
|
|
140
|
+
return None
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Codex CLI adapter (@openai/codex).
|
|
2
|
+
|
|
3
|
+
Session files: ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>.jsonl
|
|
4
|
+
Format: JSON Lines. Each line is {"timestamp", "type", "payload"}.
|
|
5
|
+
|
|
6
|
+
CONFIRMED (2026-06, against live ~/.codex/sessions on macOS):
|
|
7
|
+
- Base path / layout / naming: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
8
|
+
- Line envelope: {"timestamp": ISO8601, "type": str, "payload": {...}}
|
|
9
|
+
- The genuine user prompt is an `event_msg` line whose payload.type is
|
|
10
|
+
"user_message", carrying the typed text in payload.message. (Injected
|
|
11
|
+
AGENTS.md / developer preambles appear only as response_item/message lines,
|
|
12
|
+
so keying on user_message excludes them.)
|
|
13
|
+
- Assistant turns: event_msg/agent_message (payload.message) and
|
|
14
|
+
response_item/message role=assistant.
|
|
15
|
+
- Tool calls: response_item/function_call (payload.name).
|
|
16
|
+
- Tool results: response_item/function_call_output (payload.output) — shell
|
|
17
|
+
execs include a "Process exited with code N" line; N != 0 (or common error
|
|
18
|
+
markers) means the command failed.
|
|
19
|
+
- Model: turn_context.model / session_meta payload.
|
|
20
|
+
|
|
21
|
+
Degradation: if ~/.codex/ does not exist, discover() returns []; any unexpected
|
|
22
|
+
line shape is skipped, so parse() never raises.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import datetime
|
|
28
|
+
import json
|
|
29
|
+
import re
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from .base import CORRECTION_RE, BaseAdapter
|
|
34
|
+
|
|
35
|
+
# A shell tool result failed when it reports a non-zero exit code, or carries a
|
|
36
|
+
# common error marker with no explicit success code. Conservative on purpose —
|
|
37
|
+
# better to miss a soft error than to invent one.
|
|
38
|
+
_EXIT_CODE_RE = re.compile(r"exited with code\s+(\d+)", re.IGNORECASE)
|
|
39
|
+
_ERROR_MARKER_RE = re.compile(
|
|
40
|
+
r"\b(command not found|no such file|permission denied|traceback \(most recent|"
|
|
41
|
+
r"fatal:|panic:|segmentation fault|unhandled exception)\b",
|
|
42
|
+
re.IGNORECASE,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _looks_like_error(output: str) -> bool:
|
|
47
|
+
if not output:
|
|
48
|
+
return False
|
|
49
|
+
m = _EXIT_CODE_RE.search(output)
|
|
50
|
+
if m:
|
|
51
|
+
return m.group(1) != "0"
|
|
52
|
+
return bool(_ERROR_MARKER_RE.search(output))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _ts(value: Any) -> float | None:
|
|
56
|
+
if isinstance(value, (int, float)):
|
|
57
|
+
return float(value)
|
|
58
|
+
if isinstance(value, str):
|
|
59
|
+
try:
|
|
60
|
+
return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
|
61
|
+
except (ValueError, AttributeError):
|
|
62
|
+
return None
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CodexAdapter(BaseAdapter):
|
|
67
|
+
RUNTIME_ID = "codex"
|
|
68
|
+
|
|
69
|
+
SESSIONS_DIR = Path.home() / ".codex" / "sessions"
|
|
70
|
+
|
|
71
|
+
def discover(self) -> list[Path]:
|
|
72
|
+
"""Find all rollout-*.jsonl files under ~/.codex/sessions/."""
|
|
73
|
+
if not self.SESSIONS_DIR.exists():
|
|
74
|
+
return []
|
|
75
|
+
files: list[Path] = []
|
|
76
|
+
try:
|
|
77
|
+
for jsonl in self.SESSIONS_DIR.rglob("rollout-*.jsonl"):
|
|
78
|
+
try:
|
|
79
|
+
if jsonl.is_file():
|
|
80
|
+
files.append(jsonl)
|
|
81
|
+
except OSError:
|
|
82
|
+
continue
|
|
83
|
+
except OSError:
|
|
84
|
+
return []
|
|
85
|
+
return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
|
|
86
|
+
|
|
87
|
+
def parse(self, path: Path) -> list[dict[str, Any]]:
|
|
88
|
+
"""Parse one Codex session JSONL file into user-prompt records with
|
|
89
|
+
session-aware error/correction context."""
|
|
90
|
+
try:
|
|
91
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
92
|
+
except OSError:
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
# 1) Flatten the file into an ordered timeline of turns.
|
|
96
|
+
timeline: list[dict[str, Any]] = []
|
|
97
|
+
model = ""
|
|
98
|
+
pending_tool = ""
|
|
99
|
+
for line in lines:
|
|
100
|
+
line = line.strip()
|
|
101
|
+
if not line:
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
obj = json.loads(line)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
continue
|
|
107
|
+
if not isinstance(obj, dict):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
top_type = obj.get("type")
|
|
111
|
+
payload = obj.get("payload")
|
|
112
|
+
if not isinstance(payload, dict):
|
|
113
|
+
payload = {}
|
|
114
|
+
ts = _ts(obj.get("timestamp"))
|
|
115
|
+
|
|
116
|
+
# Track the active model from session metadata / turn context.
|
|
117
|
+
if top_type in ("session_meta", "turn_context"):
|
|
118
|
+
m = payload.get("model")
|
|
119
|
+
if isinstance(m, str) and m:
|
|
120
|
+
model = m
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
if top_type == "event_msg":
|
|
124
|
+
p_type = payload.get("type")
|
|
125
|
+
if p_type == "user_message":
|
|
126
|
+
text = payload.get("message", "")
|
|
127
|
+
if isinstance(text, str) and text.strip():
|
|
128
|
+
timeline.append({"kind": "user", "text": text, "ts": ts})
|
|
129
|
+
elif p_type == "agent_message":
|
|
130
|
+
text = payload.get("message", "")
|
|
131
|
+
if isinstance(text, str) and text.strip():
|
|
132
|
+
timeline.append({"kind": "assistant", "text": text, "ts": ts})
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
if top_type == "response_item":
|
|
136
|
+
p_type = payload.get("type")
|
|
137
|
+
if p_type == "function_call":
|
|
138
|
+
name = payload.get("name", "")
|
|
139
|
+
pending_tool = name if isinstance(name, str) else ""
|
|
140
|
+
elif p_type == "function_call_output":
|
|
141
|
+
output = payload.get("output", "")
|
|
142
|
+
output = output if isinstance(output, str) else json.dumps(output)
|
|
143
|
+
timeline.append({
|
|
144
|
+
"kind": "tool_output",
|
|
145
|
+
"tool": pending_tool,
|
|
146
|
+
"is_error": _looks_like_error(output),
|
|
147
|
+
"text": output,
|
|
148
|
+
"ts": ts,
|
|
149
|
+
})
|
|
150
|
+
pending_tool = ""
|
|
151
|
+
# response_item/message (assistant/user/developer) is intentionally
|
|
152
|
+
# ignored: genuine prompts come from event_msg/user_message, and the
|
|
153
|
+
# assistant context comes from event_msg/agent_message.
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
# 2) Walk the timeline; enrich each user prompt with what follows it.
|
|
157
|
+
user_indices = [i for i, t in enumerate(timeline) if t["kind"] == "user"]
|
|
158
|
+
records: list[dict[str, Any]] = []
|
|
159
|
+
for n, idx in enumerate(user_indices):
|
|
160
|
+
turn = timeline[idx]
|
|
161
|
+
next_user = user_indices[n + 1] if n + 1 < len(user_indices) else len(timeline)
|
|
162
|
+
|
|
163
|
+
followed_by_error = False
|
|
164
|
+
error_tool = ""
|
|
165
|
+
error_text = ""
|
|
166
|
+
for j in range(idx + 1, next_user):
|
|
167
|
+
t = timeline[j]
|
|
168
|
+
if t["kind"] == "tool_output" and t.get("is_error"):
|
|
169
|
+
followed_by_error = True
|
|
170
|
+
error_tool = t.get("tool", "")
|
|
171
|
+
error_text = t.get("text", "")
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
followed_by_correction = False
|
|
175
|
+
correction_text = ""
|
|
176
|
+
if next_user < len(timeline):
|
|
177
|
+
nxt = timeline[next_user]["text"]
|
|
178
|
+
if CORRECTION_RE.search(nxt):
|
|
179
|
+
followed_by_correction = True
|
|
180
|
+
correction_text = nxt
|
|
181
|
+
|
|
182
|
+
# context_before: last assistant text strictly before this prompt.
|
|
183
|
+
context_before = ""
|
|
184
|
+
for j in range(idx - 1, -1, -1):
|
|
185
|
+
if timeline[j]["kind"] == "assistant":
|
|
186
|
+
context_before = timeline[j]["text"]
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
error_was_recovered = followed_by_error and not followed_by_correction
|
|
190
|
+
|
|
191
|
+
records.append({
|
|
192
|
+
"runtime": self.RUNTIME_ID,
|
|
193
|
+
"session_file": str(path),
|
|
194
|
+
"prompt_text": turn["text"],
|
|
195
|
+
"timestamp": turn.get("ts"),
|
|
196
|
+
"model": model,
|
|
197
|
+
"context_before": context_before,
|
|
198
|
+
"followed_by_error": followed_by_error,
|
|
199
|
+
"error_was_recovered": error_was_recovered,
|
|
200
|
+
"followed_by_correction": followed_by_correction,
|
|
201
|
+
"correction_text": correction_text,
|
|
202
|
+
"error_tool": error_tool,
|
|
203
|
+
"error_text": error_text,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
return records
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Gemini CLI adapter (@google/gemini-cli).
|
|
2
|
+
|
|
3
|
+
Session files: ~/.gemini/tmp/<project_hash>/chats/session-*.json
|
|
4
|
+
Format: a SINGLE JSON object per file (not JSONL).
|
|
5
|
+
|
|
6
|
+
CONFIRMED (2026-06, against live ~/.gemini/tmp on macOS):
|
|
7
|
+
- Base path / layout: ~/.gemini/tmp/<project_hash>/chats/*.json
|
|
8
|
+
- File shape: {"sessionId", "projectHash", "startTime", "lastUpdated",
|
|
9
|
+
"messages": [ {"id", "timestamp", "type", "content", ...} ]}
|
|
10
|
+
- messages[].type is "user" or "gemini"; content is a plain string.
|
|
11
|
+
- gemini (assistant) messages also carry "model", "thoughts", "tokens".
|
|
12
|
+
- The format does NOT persist tool calls/errors as messages, so tool-error
|
|
13
|
+
detection is not possible here — followed_by_error stays False (honest);
|
|
14
|
+
correction detection (next user message) still works.
|
|
15
|
+
|
|
16
|
+
Degradation: if ~/.gemini/ does not exist, discover() returns []; a file that
|
|
17
|
+
is not the expected object shape yields no records (parse never raises).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime
|
|
23
|
+
import json
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .base import CORRECTION_RE, BaseAdapter
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ts(value: Any) -> float | None:
|
|
31
|
+
if isinstance(value, (int, float)):
|
|
32
|
+
return float(value)
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
try:
|
|
35
|
+
return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
|
36
|
+
except (ValueError, AttributeError):
|
|
37
|
+
return None
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GeminiAdapter(BaseAdapter):
|
|
42
|
+
RUNTIME_ID = "gemini"
|
|
43
|
+
|
|
44
|
+
GEMINI_TMP_DIR = Path.home() / ".gemini" / "tmp"
|
|
45
|
+
|
|
46
|
+
def discover(self) -> list[Path]:
|
|
47
|
+
"""Find all session *.json chat files under ~/.gemini/tmp/<hash>/chats/."""
|
|
48
|
+
if not self.GEMINI_TMP_DIR.exists():
|
|
49
|
+
return []
|
|
50
|
+
files: list[Path] = []
|
|
51
|
+
try:
|
|
52
|
+
for jf in self.GEMINI_TMP_DIR.rglob("chats/*.json"):
|
|
53
|
+
try:
|
|
54
|
+
if jf.is_file():
|
|
55
|
+
files.append(jf)
|
|
56
|
+
except OSError:
|
|
57
|
+
continue
|
|
58
|
+
except OSError:
|
|
59
|
+
return []
|
|
60
|
+
return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
|
|
61
|
+
|
|
62
|
+
def parse(self, path: Path) -> list[dict[str, Any]]:
|
|
63
|
+
"""Parse one Gemini chat JSON file into user-prompt records with
|
|
64
|
+
correction context. Tool errors are not represented in this format."""
|
|
65
|
+
try:
|
|
66
|
+
obj = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
|
67
|
+
except (OSError, json.JSONDecodeError):
|
|
68
|
+
return []
|
|
69
|
+
if not isinstance(obj, dict):
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
messages = obj.get("messages")
|
|
73
|
+
if not isinstance(messages, list):
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
# Normalise into an ordered timeline of {kind, text, model, ts}.
|
|
77
|
+
timeline: list[dict[str, Any]] = []
|
|
78
|
+
for m in messages:
|
|
79
|
+
if not isinstance(m, dict):
|
|
80
|
+
continue
|
|
81
|
+
mtype = m.get("type")
|
|
82
|
+
content = m.get("content", "")
|
|
83
|
+
if not isinstance(content, str) or not content.strip():
|
|
84
|
+
continue
|
|
85
|
+
ts = _ts(m.get("timestamp"))
|
|
86
|
+
if mtype == "user":
|
|
87
|
+
timeline.append({"kind": "user", "text": content, "ts": ts})
|
|
88
|
+
elif mtype == "gemini":
|
|
89
|
+
model = m.get("model")
|
|
90
|
+
timeline.append({
|
|
91
|
+
"kind": "assistant",
|
|
92
|
+
"text": content,
|
|
93
|
+
"ts": ts,
|
|
94
|
+
"model": str(model) if model else "",
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
user_indices = [i for i, t in enumerate(timeline) if t["kind"] == "user"]
|
|
98
|
+
records: list[dict[str, Any]] = []
|
|
99
|
+
for n, idx in enumerate(user_indices):
|
|
100
|
+
turn = timeline[idx]
|
|
101
|
+
next_user = user_indices[n + 1] if n + 1 < len(user_indices) else len(timeline)
|
|
102
|
+
|
|
103
|
+
# Model that answered this prompt: the first assistant reply after it.
|
|
104
|
+
model = ""
|
|
105
|
+
context_after_assistant = ""
|
|
106
|
+
for j in range(idx + 1, next_user):
|
|
107
|
+
if timeline[j]["kind"] == "assistant":
|
|
108
|
+
model = timeline[j].get("model", "") or model
|
|
109
|
+
if not context_after_assistant:
|
|
110
|
+
context_after_assistant = timeline[j]["text"]
|
|
111
|
+
# context_before: last assistant text strictly before this prompt.
|
|
112
|
+
context_before = ""
|
|
113
|
+
for j in range(idx - 1, -1, -1):
|
|
114
|
+
if timeline[j]["kind"] == "assistant":
|
|
115
|
+
context_before = timeline[j]["text"]
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
followed_by_correction = False
|
|
119
|
+
correction_text = ""
|
|
120
|
+
if next_user < len(timeline):
|
|
121
|
+
nxt = timeline[next_user]["text"]
|
|
122
|
+
if CORRECTION_RE.search(nxt):
|
|
123
|
+
followed_by_correction = True
|
|
124
|
+
correction_text = nxt
|
|
125
|
+
|
|
126
|
+
records.append({
|
|
127
|
+
"runtime": self.RUNTIME_ID,
|
|
128
|
+
"session_file": str(path),
|
|
129
|
+
"prompt_text": turn["text"],
|
|
130
|
+
"timestamp": turn.get("ts"),
|
|
131
|
+
"model": model,
|
|
132
|
+
"context_before": context_before,
|
|
133
|
+
# This format does not record tool errors — be honest, not invent.
|
|
134
|
+
"followed_by_error": False,
|
|
135
|
+
"error_was_recovered": False,
|
|
136
|
+
"followed_by_correction": followed_by_correction,
|
|
137
|
+
"correction_text": correction_text,
|
|
138
|
+
"error_tool": "",
|
|
139
|
+
"error_text": "",
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
return records
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Registry mapping runtime IDs to adapter classes.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
|
|
5
|
+
from adapters.registry import get_adapters
|
|
6
|
+
|
|
7
|
+
# Get adapters to run for a given --runtime flag value.
|
|
8
|
+
for adapter in get_adapters("auto"):
|
|
9
|
+
files = adapter.discover()
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
auto mode: runs EVERY registered adapter and merges results. If an adapter
|
|
13
|
+
finds no files (discover() returns []), it is silently skipped. Records from
|
|
14
|
+
each adapter are tagged with their RUNTIME_ID.
|
|
15
|
+
|
|
16
|
+
Unknown runtime: raises ValueError with a clear message so the caller can
|
|
17
|
+
print it and exit 0 with an empty result (never crash).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .base import BaseAdapter
|
|
23
|
+
from .claude import ClaudeAdapter
|
|
24
|
+
from .codex import CodexAdapter
|
|
25
|
+
from .gemini import GeminiAdapter
|
|
26
|
+
|
|
27
|
+
# All registered adapters. Add new runtimes here.
|
|
28
|
+
_ALL_ADAPTERS: list[type[BaseAdapter]] = [
|
|
29
|
+
ClaudeAdapter,
|
|
30
|
+
CodexAdapter,
|
|
31
|
+
GeminiAdapter,
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# Map runtime ID -> adapter class
|
|
35
|
+
_REGISTRY: dict[str, type[BaseAdapter]] = {
|
|
36
|
+
cls.RUNTIME_ID: cls for cls in _ALL_ADAPTERS
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_adapters(runtime: str) -> list[BaseAdapter]:
|
|
41
|
+
"""Return instantiated adapters for the given runtime identifier.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
runtime: One of "auto", or a specific runtime ID such as "claude",
|
|
45
|
+
"codex", "gemini".
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A list of adapter instances. In "auto" mode, all registered adapters
|
|
49
|
+
are returned so the caller can try each one and keep those with data.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If the runtime ID is not recognised.
|
|
53
|
+
"""
|
|
54
|
+
runtime = runtime.strip().lower()
|
|
55
|
+
if runtime == "auto":
|
|
56
|
+
return [cls() for cls in _ALL_ADAPTERS]
|
|
57
|
+
if runtime not in _REGISTRY:
|
|
58
|
+
known = ", ".join(sorted(_REGISTRY.keys()))
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Unknown runtime '{runtime}'. Known runtimes: {known}. "
|
|
61
|
+
f"Use 'auto' to try all of them."
|
|
62
|
+
)
|
|
63
|
+
return [_REGISTRY[runtime]()]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def list_runtime_ids() -> list[str]:
|
|
67
|
+
"""Return all registered runtime IDs (excluding 'auto')."""
|
|
68
|
+
return sorted(_REGISTRY.keys())
|