@ericrisco/rsc 0.1.33 → 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/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.33",
2
+ "version": "0.1.34",
3
3
  "counts": {
4
4
  "skills": 232
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,10 +17,22 @@ An adapter MUST:
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import re
20
21
  from abc import ABC, abstractmethod
21
22
  from pathlib import Path
22
23
  from typing import Any
23
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
+
24
36
 
25
37
  class BaseAdapter(ABC):
26
38
  """Contract every runtime adapter must satisfy."""
@@ -1,36 +1,71 @@
1
1
  """Codex CLI adapter (@openai/codex).
2
2
 
3
3
  Session files: ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>.jsonl
4
- Format: JSON Lines. Each line is an event object.
5
-
6
- CONFIRMED (2026-06, via official CLI reference and community tooling):
7
- - Base path: ~/.codex/sessions/
8
- - Subdirectory layout: year/month/day
9
- - File naming: rollout-<ISO-timestamp>.jsonl
10
- - Format: JSONL, one event per line
11
-
12
- STUBBED (format details):
13
- The internal schema of each JSONL line has not been verified against a live
14
- Codex installation. Based on community-authored session viewers, events appear
15
- to carry {"role": "user"|"assistant", "content": "...", ...}. If the role
16
- field is absent or the schema differs, parse() returns [] gracefully.
17
-
18
- Degradation: if ~/.codex/ does not exist, discover() returns [].
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.
19
23
  """
20
24
 
21
25
  from __future__ import annotations
22
26
 
27
+ import datetime
23
28
  import json
29
+ import re
24
30
  from pathlib import Path
25
31
  from typing import Any
26
32
 
27
- from .base import BaseAdapter
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
28
64
 
29
65
 
30
66
  class CodexAdapter(BaseAdapter):
31
67
  RUNTIME_ID = "codex"
32
68
 
33
- # CONFIRMED: path layout from official CLI docs and community tools.
34
69
  SESSIONS_DIR = Path.home() / ".codex" / "sessions"
35
70
 
36
71
  def discover(self) -> list[Path]:
@@ -50,17 +85,17 @@ class CodexAdapter(BaseAdapter):
50
85
  return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
51
86
 
52
87
  def parse(self, path: Path) -> list[dict[str, Any]]:
53
- """Parse one Codex session JSONL file.
54
-
55
- STUBBED schema: assumes {"role": "user", "content": "..."} event lines.
56
- Falls back silently if the schema differs.
57
- """
58
- records: list[dict[str, Any]] = []
88
+ """Parse one Codex session JSONL file into user-prompt records with
89
+ session-aware error/correction context."""
59
90
  try:
60
91
  lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
61
92
  except OSError:
62
93
  return []
63
94
 
95
+ # 1) Flatten the file into an ordered timeline of turns.
96
+ timeline: list[dict[str, Any]] = []
97
+ model = ""
98
+ pending_tool = ""
64
99
  for line in lines:
65
100
  line = line.strip()
66
101
  if not line:
@@ -69,45 +104,103 @@ class CodexAdapter(BaseAdapter):
69
104
  obj = json.loads(line)
70
105
  except json.JSONDecodeError:
71
106
  continue
107
+ if not isinstance(obj, dict):
108
+ continue
72
109
 
73
- # STUBBED: expected schema based on community session viewers.
74
- role = obj.get("role") or obj.get("type", "")
75
- if role != "user":
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
76
121
  continue
77
122
 
78
- content = obj.get("content", "")
79
- if isinstance(content, list):
80
- # Some formats use a content array like OpenAI chat API.
81
- parts = [
82
- c.get("text", "") if isinstance(c, dict) else str(c)
83
- for c in content
84
- if isinstance(c, dict) and c.get("type") == "text"
85
- ]
86
- text = "\n".join(parts)
87
- elif isinstance(content, str):
88
- text = content
89
- else:
90
- text = ""
91
-
92
- if not text.strip():
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})
93
133
  continue
94
134
 
95
- ts = obj.get("timestamp") or obj.get("created_at")
96
- if isinstance(ts, str):
97
- try:
98
- import datetime
99
- dt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
100
- ts = dt.timestamp()
101
- except (ValueError, AttributeError):
102
- ts = None
103
- elif not isinstance(ts, (int, float)):
104
- ts = None
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
105
190
 
106
191
  records.append({
107
192
  "runtime": self.RUNTIME_ID,
108
193
  "session_file": str(path),
109
- "prompt_text": text,
110
- "timestamp": ts,
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,
111
204
  })
112
205
 
113
206
  return records
@@ -1,50 +1,58 @@
1
1
  """Gemini CLI adapter (@google/gemini-cli).
2
2
 
3
- Session files: ~/.gemini/tmp/<project_hash>/chats/*.jsonl
4
- Format: JSON Lines. Each line is a MessageRecord.
5
-
6
- CONFIRMED (2026-06, via official Gemini CLI docs at google-gemini.github.io
7
- and github.com/google-gemini/gemini-cli session-management.md):
8
- - Base path: ~/.gemini/tmp/
9
- - Subdirectory: <project_hash>/chats/
10
- - File format: JSONL
11
- - MessageRecord fields include: sessionId, projectHash, model, role,
12
- content (array of {text: "..."}), and token usage fields.
13
-
14
- STUBBED (exact MessageRecord schema):
15
- The exact JSONL schema per line has not been verified against a live Gemini
16
- CLI installation. The role/content structure is inferred from the official
17
- session-management docs. If the schema differs, parse() returns [] gracefully.
18
-
19
- Degradation: if ~/.gemini/ does not exist, discover() returns [].
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).
20
18
  """
21
19
 
22
20
  from __future__ import annotations
23
21
 
22
+ import datetime
24
23
  import json
25
24
  from pathlib import Path
26
25
  from typing import Any
27
26
 
28
- from .base import BaseAdapter
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
29
39
 
30
40
 
31
41
  class GeminiAdapter(BaseAdapter):
32
42
  RUNTIME_ID = "gemini"
33
43
 
34
- # CONFIRMED: path from official gemini-cli session-management docs.
35
44
  GEMINI_TMP_DIR = Path.home() / ".gemini" / "tmp"
36
45
 
37
46
  def discover(self) -> list[Path]:
38
- """Find all *.jsonl chat files under ~/.gemini/tmp/<hash>/chats/."""
47
+ """Find all session *.json chat files under ~/.gemini/tmp/<hash>/chats/."""
39
48
  if not self.GEMINI_TMP_DIR.exists():
40
49
  return []
41
50
  files: list[Path] = []
42
51
  try:
43
- # Pattern: ~/.gemini/tmp/<project_hash>/chats/*.jsonl
44
- for jsonl in self.GEMINI_TMP_DIR.rglob("chats/*.jsonl"):
52
+ for jf in self.GEMINI_TMP_DIR.rglob("chats/*.json"):
45
53
  try:
46
- if jsonl.is_file():
47
- files.append(jsonl)
54
+ if jf.is_file():
55
+ files.append(jf)
48
56
  except OSError:
49
57
  continue
50
58
  except OSError:
@@ -52,70 +60,83 @@ class GeminiAdapter(BaseAdapter):
52
60
  return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
53
61
 
54
62
  def parse(self, path: Path) -> list[dict[str, Any]]:
55
- """Parse one Gemini CLI chat JSONL file.
56
-
57
- STUBBED schema: expected MessageRecord with {role, content: [{text}], ...}.
58
- Falls back silently if the schema differs.
59
- """
60
- records: 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."""
61
65
  try:
62
- lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
63
- except OSError:
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):
64
70
  return []
65
71
 
66
- for line in lines:
67
- line = line.strip()
68
- if not line:
69
- continue
70
- try:
71
- obj = json.loads(line)
72
- except json.JSONDecodeError:
73
- continue
72
+ messages = obj.get("messages")
73
+ if not isinstance(messages, list):
74
+ return []
74
75
 
75
- # STUBBED: inferred from gemini-cli session-management docs.
76
- role = obj.get("role", "")
77
- if role not in ("user", "USER"):
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):
78
80
  continue
79
-
80
- content = obj.get("content", [])
81
- if isinstance(content, list):
82
- parts = []
83
- for block in content:
84
- if isinstance(block, dict):
85
- text = block.get("text", "")
86
- if text:
87
- parts.append(text)
88
- text = "\n".join(parts)
89
- elif isinstance(content, str):
90
- text = content
91
- else:
92
- text = ""
93
-
94
- if not text.strip():
81
+ mtype = m.get("type")
82
+ content = m.get("content", "")
83
+ if not isinstance(content, str) or not content.strip():
95
84
  continue
96
-
97
- # Gemini MessageRecord may carry token counts and model name.
98
- model = obj.get("model") or obj.get("modelVersion")
99
- ts = obj.get("timestamp") or obj.get("createTime")
100
- if isinstance(ts, str):
101
- try:
102
- import datetime
103
- dt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
104
- ts = dt.timestamp()
105
- except (ValueError, AttributeError):
106
- ts = None
107
- elif not isinstance(ts, (int, float)):
108
- ts = None
109
-
110
- record: dict[str, Any] = {
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({
111
127
  "runtime": self.RUNTIME_ID,
112
128
  "session_file": str(path),
113
- "prompt_text": text,
114
- "timestamp": ts,
115
- }
116
- if model:
117
- record["model"] = str(model)
118
-
119
- records.append(record)
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
+ })
120
141
 
121
142
  return records
@@ -33,6 +33,7 @@ from typing import Any
33
33
  # ---------------------------------------------------------------------------
34
34
  _TOOLS_DIR = Path(__file__).parent
35
35
  sys.path.insert(0, str(_TOOLS_DIR))
36
+ from adapters.base import CORRECTION_RE
36
37
  from adapters.registry import get_adapters, list_runtime_ids
37
38
 
38
39
  # ---------------------------------------------------------------------------
@@ -42,15 +43,11 @@ from adapters.registry import get_adapters, list_runtime_ids
42
43
  MAX_PROMPTS = 300
43
44
  PROMPT_TEXT_LIMIT = 1500
44
45
  CORRECTION_TEXT_LIMIT = 500
46
+ ERROR_TEXT_LIMIT = 500
45
47
  CONTEXT_BEFORE_LIMIT = 500
46
48
 
47
- CORRECTION_PATTERNS = re.compile(
48
- r"\b(no[,.]?\s|wrong|instead|actually|don'?t|shouldn'?t|stop|not that|"
49
- r"I said|I meant|I asked|that'?s not|please don'?t|why did you|"
50
- r"you should have|that was wrong|incorrect|try again|redo|"
51
- r"that broke|you broke|revert|undo)\b",
52
- re.IGNORECASE,
53
- )
49
+ # Single source of truth for the correction heuristic — shared with every adapter.
50
+ CORRECTION_PATTERNS = CORRECTION_RE
54
51
 
55
52
  # Model tier classification (provider-neutral labels).
56
53
  # Maps substrings found in model IDs to tier names.
@@ -144,13 +141,15 @@ def normalise_record(raw: dict[str, Any], position: int, total: int) -> dict[str
144
141
  "has_xml_tags": has_xml_tags,
145
142
  "has_file_paths": has_file_paths,
146
143
  "has_code_blocks": has_code_blocks,
147
- # These will be populated by post-processing when session context is available.
148
- "followed_by_error": False,
149
- "error_was_recovered": False,
150
- "followed_by_correction": False,
151
- "correction_text": "",
152
- "error_tool": "",
153
- "error_text": "",
144
+ # Error/correction context: honored from the adapter when it does its own
145
+ # session-aware detection (codex, gemini); defaults to False for adapters
146
+ # that only surface raw prompts. Claude uses process_claude_sessions instead.
147
+ "followed_by_error": bool(raw.get("followed_by_error", False)),
148
+ "error_was_recovered": bool(raw.get("error_was_recovered", False)),
149
+ "followed_by_correction": bool(raw.get("followed_by_correction", False)),
150
+ "correction_text": truncate(raw.get("correction_text", ""), CORRECTION_TEXT_LIMIT),
151
+ "error_tool": raw.get("error_tool", "") or "",
152
+ "error_text": truncate(raw.get("error_text", ""), ERROR_TEXT_LIMIT),
154
153
  "context_before": truncate(raw.get("context_before", ""), CONTEXT_BEFORE_LIMIT),
155
154
  # Compute fields (best-effort from adapter)
156
155
  "model": model_id,