@tamng0905/builder-essential-skills 0.1.0

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.
Files changed (66) hide show
  1. package/.claude/agents/lead-builder.md +55 -0
  2. package/.claude/agents/lead-reviewer.md +39 -0
  3. package/README.md +276 -0
  4. package/assets/readme-hero.png +0 -0
  5. package/assets/skill-cards/async-learning-teacher.svg +7 -0
  6. package/assets/skill-cards/code-standards.svg +7 -0
  7. package/assets/skill-cards/lead-research.svg +7 -0
  8. package/assets/skill-cards/lead.svg +7 -0
  9. package/assets/skill-cards/orwell-writing.svg +7 -0
  10. package/assets/skill-cards/session-profiler.svg +7 -0
  11. package/assets/skill-cards/validate-market.svg +7 -0
  12. package/assets/skill-cards/write-blog.svg +7 -0
  13. package/bin/builder-essential-skills.js +134 -0
  14. package/install.ps1 +58 -0
  15. package/install.sh +63 -0
  16. package/package.json +24 -0
  17. package/skills/async-learning-teacher/README.md +50 -0
  18. package/skills/async-learning-teacher/SKILL.md +192 -0
  19. package/skills/async-learning-teacher/agents/openai.yaml +4 -0
  20. package/skills/code-standards/README.md +27 -0
  21. package/skills/code-standards/SKILL.md +51 -0
  22. package/skills/lead/README.md +29 -0
  23. package/skills/lead/SKILL.md +222 -0
  24. package/skills/lead/config.py +359 -0
  25. package/skills/lead/dispatch.md +342 -0
  26. package/skills/lead/loop.md +103 -0
  27. package/skills/lead/models.json +34 -0
  28. package/skills/lead/research.md +76 -0
  29. package/skills/lead/status.ps1 +114 -0
  30. package/skills/lead/status.sh +110 -0
  31. package/skills/lead/watchdog.ps1 +90 -0
  32. package/skills/lead/watchdog.sh +88 -0
  33. package/skills/lead-research/README.md +27 -0
  34. package/skills/lead-research/SKILL.md +157 -0
  35. package/skills/lead-research/tactics.md +150 -0
  36. package/skills/orwell-writing/README.md +27 -0
  37. package/skills/orwell-writing/SKILL.md +44 -0
  38. package/skills/orwell-writing/agents/openai.yaml +4 -0
  39. package/skills/session-profiler/README.md +25 -0
  40. package/skills/session-profiler/SKILL.md +103 -0
  41. package/skills/session-profiler/agents/openai.yaml +4 -0
  42. package/skills/session-profiler/scripts/requirements.txt +2 -0
  43. package/skills/session-profiler/scripts/session_profiler/__init__.py +5 -0
  44. package/skills/session-profiler/scripts/session_profiler/analyses.py +246 -0
  45. package/skills/session-profiler/scripts/session_profiler/cli.py +77 -0
  46. package/skills/session-profiler/scripts/session_profiler/dataset.py +369 -0
  47. package/skills/session-profiler/scripts/session_profiler/discover.py +191 -0
  48. package/skills/session-profiler/scripts/session_profiler/toc.py +74 -0
  49. package/skills/session-profiler/scripts/session_profiler/trace.py +146 -0
  50. package/skills/session-profiler/scripts/sp +20 -0
  51. package/skills/session-profiler/scripts/tests/codex_home/sessions/2026/01/01/rollout-child.jsonl +7 -0
  52. package/skills/session-profiler/scripts/tests/codex_home/sessions/2026/01/01/rollout-root.jsonl +10 -0
  53. package/skills/session-profiler/scripts/tests/fixture/main/subagents/agent-abc.jsonl +2 -0
  54. package/skills/session-profiler/scripts/tests/fixture/main/subagents/agent-abc.meta.json +1 -0
  55. package/skills/session-profiler/scripts/tests/fixture/main.jsonl +7 -0
  56. package/skills/session-profiler/scripts/tests/test_fixture.py +58 -0
  57. package/skills/validate-market/README.md +27 -0
  58. package/skills/validate-market/SKILL.md +202 -0
  59. package/skills/write-blog/README.md +27 -0
  60. package/skills/write-blog/SKILL.md +142 -0
  61. package/skills/write-blog/check.sh +52 -0
  62. package/skills/write-blog/reference/angles.md +51 -0
  63. package/skills/write-blog/reference/craft.md +94 -0
  64. package/skills/write-blog/reference/seo.md +116 -0
  65. package/skills/write-blog/reference/voice.md +64 -0
  66. package/skills/write-blog/reference/wiring.md +113 -0
@@ -0,0 +1,369 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from collections import defaultdict
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import pandas as pd
11
+
12
+ from .discover import codex_family, detect_provider, resolve_session, session_id
13
+
14
+ # Public Claude Opus-tier rates from the source specification, USD per million tokens.
15
+ CLAUDE_PRICE = {"input": 15.0, "cache_read": 1.5, "cache_write": 18.75, "output": 75.0}
16
+ # Public standard API rates per million tokens, verified 2026-07-11 against
17
+ # developers.openai.com/api/docs/models/. Codex subscription billing can differ.
18
+ OPENAI_PRICE = {
19
+ "gpt-5.6-sol": {"input": 5.0, "cache_read": 0.5, "cache_write": 6.25, "output": 30.0},
20
+ "gpt-5.6-terra": {"input": 2.5, "cache_read": 0.25, "cache_write": 3.125, "output": 15.0},
21
+ "gpt-5.6-luna": {"input": 1.0, "cache_read": 0.1, "cache_write": 1.25, "output": 6.0},
22
+ "gpt-5.6": {"input": 5.0, "cache_read": 0.5, "cache_write": 6.25, "output": 30.0},
23
+ "gpt-5.4": {"input": 2.5, "cache_read": 0.25, "cache_write": 3.125, "output": 15.0},
24
+ "gpt-5": {"input": 1.25, "cache_read": 0.125, "cache_write": 1.5625, "output": 10.0},
25
+ }
26
+ OPAQUE = re.compile(r"(?<![A-Za-z0-9])[A-Za-z0-9_+\-/=]{40,}")
27
+ AUTH = re.compile(r"(?i)(authorization|cookie|x-api-key)([\"' ]*[:=][\"' ]*)([^\s,;}]+)")
28
+
29
+
30
+ def sanitize(value: Any, limit: int = 2000) -> str:
31
+ if value is None:
32
+ return ""
33
+ text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
34
+ text = AUTH.sub(r"\1\2[REDACTED]", text)
35
+ text = OPAQUE.sub("[REDACTED_TOKEN]", text)
36
+ return text[:limit]
37
+
38
+
39
+ def _ts(value: Any) -> pd.Timestamp:
40
+ return pd.to_datetime(value, utc=True, errors="coerce")
41
+
42
+
43
+ def _price(provider: str, model: str) -> dict | None:
44
+ if provider == "claude":
45
+ return CLAUDE_PRICE
46
+ for prefix, price in OPENAI_PRICE.items():
47
+ if model == prefix or re.fullmatch(re.escape(prefix) + r"-\d{4}-\d{2}-\d{2}", model):
48
+ return price
49
+ return None
50
+
51
+
52
+ def _cost(usage: dict, provider: str = "claude", model: str = "") -> float:
53
+ price = _price(provider, model)
54
+ if not price:
55
+ return 0.0
56
+ long_context = provider == "codex" and model.startswith(("gpt-5.4", "gpt-5.6")) and usage.get("input_tokens", 0) + usage.get("cache_read_input_tokens", 0) > 272_000
57
+ input_multiplier = 2.0 if long_context else 1.0
58
+ output_multiplier = 1.5 if long_context else 1.0
59
+ return (
60
+ usage.get("input_tokens", 0) * price["input"] * input_multiplier
61
+ + usage.get("cache_read_input_tokens", 0) * price["cache_read"] * input_multiplier
62
+ + usage.get("cache_creation_input_tokens", 0) * price["cache_write"]
63
+ + usage.get("output_tokens", 0) * price["output"] * output_multiplier
64
+ ) / 1_000_000
65
+
66
+
67
+ def _records(path: Path) -> list[dict]:
68
+ rows = []
69
+ with path.open(errors="replace") as fh:
70
+ for line_no, line in enumerate(fh, 1):
71
+ try:
72
+ row = json.loads(line)
73
+ except json.JSONDecodeError:
74
+ continue
75
+ row["_line_no"] = line_no
76
+ rows.append(row)
77
+ return rows
78
+
79
+
80
+ def _agent_files(main: Path) -> list[tuple[str, Path, dict]]:
81
+ if detect_provider(main) == "codex":
82
+ family = codex_family(main)
83
+ root_id = family[0][0]
84
+ found = []
85
+ for index, (thread_id, path, session_meta) in enumerate(family):
86
+ spawn = session_meta.get("source", {}).get("subagent", {}).get("thread_spawn", {}) if isinstance(session_meta.get("source"), dict) else {}
87
+ found.append(("main" if index == 0 else thread_id, path, {
88
+ "name": "main" if index == 0 else spawn.get("agent_nickname", thread_id),
89
+ "agentType": "main" if index == 0 else spawn.get("agent_role", "subagent"),
90
+ "description": "",
91
+ "spawnDepth": 0 if index == 0 else spawn.get("depth", 1),
92
+ "parentThreadId": "" if index == 0 else spawn.get("parent_thread_id", root_id),
93
+ "threadId": thread_id,
94
+ "rootThreadId": root_id,
95
+ }))
96
+ return found
97
+ found: list[tuple[str, Path, dict]] = [("main", main, {"name": "main", "agentType": "main", "spawnDepth": 0})]
98
+ side = main.parent / main.stem / "subagents"
99
+ if not side.exists():
100
+ return found
101
+ for path in sorted(side.glob("agent-*.jsonl")):
102
+ meta_path = path.with_suffix(".meta.json")
103
+ try:
104
+ meta = json.loads(meta_path.read_text()) if meta_path.exists() else {}
105
+ except (OSError, json.JSONDecodeError):
106
+ meta = {}
107
+ found.append((path.stem, path, meta))
108
+ return found
109
+
110
+
111
+ def _content_blocks(row: dict) -> list[dict]:
112
+ content = row.get("message", {}).get("content", [])
113
+ if isinstance(content, str):
114
+ return [{"type": "text", "text": content}]
115
+ return content if isinstance(content, list) else []
116
+
117
+
118
+ def parse(session: str | Path, out_dir: str | Path | None = None) -> Path:
119
+ main = resolve_session(session)
120
+ provider = detect_provider(main)
121
+ parsed_session_id = session_id(main)
122
+ out = Path(out_dir).expanduser() if out_dir else Path(os.environ.get("SESSION_DATA_DIR", Path.home() / ".cache" / "session-profiler" / parsed_session_id))
123
+ out.mkdir(parents=True, exist_ok=True)
124
+
125
+ files = _agent_files(main)
126
+ raw_by_agent = {agent_id: _records(path) for agent_id, path, _ in files}
127
+ spawn_owner: dict[str, str] = {}
128
+ for agent_id, rows in raw_by_agent.items():
129
+ for row in rows:
130
+ if provider == "claude":
131
+ for block in _content_blocks(row):
132
+ if block.get("type") == "tool_use" and block.get("name") in {"Agent", "Task"}:
133
+ spawn_owner[block.get("id", "")] = agent_id
134
+ id_map = {meta.get("threadId"): agent_id for agent_id, _, meta in files}
135
+
136
+ agents = []
137
+ events: list[dict] = []
138
+ for agent_id, path, meta in files:
139
+ rows = raw_by_agent[agent_id]
140
+ timestamps = [_ts(r.get("timestamp")) for r in rows]
141
+ timestamps = [t for t in timestamps if not pd.isna(t)]
142
+ parent_id = spawn_owner.get(meta.get("toolUseId", ""), "" if agent_id == "main" else "main")
143
+ spawn_tool_id = meta.get("toolUseId", "")
144
+ if provider == "codex" and agent_id != "main":
145
+ parent_id = id_map.get(meta.get("parentThreadId"), "main")
146
+ child_thread = meta.get("threadId", "")
147
+ for row in raw_by_agent.get(parent_id, []):
148
+ payload = row.get("payload", {})
149
+ if payload.get("type") in {"custom_tool_call_output", "function_call_output"} and child_thread in json.dumps(payload.get("output", ""), default=str):
150
+ spawn_tool_id = payload.get("call_id", "")
151
+ break
152
+ description = meta.get("description", "")
153
+ if provider == "codex" and not description:
154
+ description = next((sanitize(row.get("payload", {}).get("message", ""), 200) for row in rows if row.get("type") == "event_msg" and row.get("payload", {}).get("type") == "user_message"), "")
155
+ agent = {
156
+ "id": agent_id,
157
+ "name": meta.get("name") or agent_id,
158
+ "agent_type": meta.get("agentType") or ("main" if agent_id == "main" else "subagent"),
159
+ "description": description,
160
+ "spawn_depth": int(meta.get("spawnDepth", 0 if agent_id == "main" else 1)),
161
+ "spawn_tool_use_id": spawn_tool_id,
162
+ "parent_agent_id": parent_id,
163
+ "provider": provider,
164
+ "source": str(path),
165
+ "start_ts": timestamps[0].isoformat() if timestamps else None,
166
+ "end_ts": timestamps[-1].isoformat() if timestamps else None,
167
+ }
168
+ agents.append(agent)
169
+ if provider == "codex":
170
+ _parse_codex_agent(rows, agent, events)
171
+ else:
172
+ _parse_claude_agent(rows, agent, events)
173
+
174
+ df = pd.DataFrame(events)
175
+ if not df.empty:
176
+ df["ts"] = pd.to_datetime(df["ts"], utc=True)
177
+ df["end_ts"] = pd.to_datetime(df["end_ts"], utc=True)
178
+ df = df.sort_values(["ts", "agent_id", "line_no"], na_position="last").reset_index(drop=True)
179
+ df.to_parquet(out / "events.parquet", index=False)
180
+ df.to_csv(out / "events.csv", index=False)
181
+ (out / "agents.json").write_text(json.dumps({"provider": provider, "session_id": parsed_session_id, "main": str(main), "agents": agents}, indent=2))
182
+ (out / ".session-data-dir").write_text(str(out.resolve()))
183
+ state = Path.home() / ".cache" / "session-profiler" / "last-work-dir"
184
+ state.parent.mkdir(parents=True, exist_ok=True)
185
+ state.write_text(str(out.resolve()))
186
+ return out
187
+
188
+
189
+ def _parse_claude_agent(rows: list[dict], agent: dict, events: list[dict]) -> None:
190
+ aid = agent["id"]
191
+ base = {"provider": "claude", "agent_id": aid, "agent_name": agent["name"], "agent_type": agent["agent_type"], "spawn_depth": agent["spawn_depth"]}
192
+ tool_starts: dict[str, dict] = {}
193
+ tool_uses_by_message: defaultdict[str, list[dict]] = defaultdict(list)
194
+ messages: defaultdict[str, list[tuple[pd.Timestamp, dict]]] = defaultdict(list)
195
+
196
+ for row in rows:
197
+ ts = _ts(row.get("timestamp"))
198
+ if pd.isna(ts):
199
+ continue
200
+ typ = row.get("type")
201
+ msg = row.get("message", {})
202
+ mid = msg.get("id", "")
203
+ if typ == "assistant" and mid:
204
+ messages[mid].append((ts, row))
205
+ if typ == "user" and not row.get("toolUseResult"):
206
+ texts = [b.get("text", "") for b in _content_blocks(row) if b.get("type") == "text"]
207
+ if not texts and isinstance(msg.get("content"), str):
208
+ texts = [msg["content"]]
209
+ text = "\n".join(texts).strip()
210
+ if text:
211
+ events.append({**base, "ts": ts, "end_ts": ts, "duration_ms": 0.0, "event_type": "user_prompt", "message_id": "", "tool_use_id": "", "tool_name": "", "model": "", "text": sanitize(text), "input_preview": "", "output_preview": "", "is_error": False, "concurrent": False, "input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "output_tokens": 0, "estimated_cost_usd": 0.0, "source": agent["source"], "line_no": row["_line_no"]})
212
+ for block in _content_blocks(row):
213
+ btype = block.get("type", "")
214
+ common = {**base, "ts": ts, "end_ts": ts, "duration_ms": 0.0, "message_id": mid, "model": msg.get("model", ""), "source": agent["source"], "line_no": row["_line_no"], "input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "output_tokens": 0, "estimated_cost_usd": 0.0, "is_error": False, "concurrent": False}
215
+ if typ == "assistant" and btype in {"text", "thinking"}:
216
+ events.append({**common, "event_type": "assistant_text" if btype == "text" else "thinking", "tool_use_id": "", "tool_name": "", "text": sanitize(block.get("text") or block.get("thinking")), "input_preview": "", "output_preview": ""})
217
+ elif typ == "assistant" and btype == "tool_use":
218
+ event = {**common, "event_type": "tool", "tool_use_id": block.get("id", ""), "tool_name": block.get("name", ""), "text": "", "input_preview": sanitize(block.get("input")), "output_preview": ""}
219
+ events.append(event)
220
+ tool_starts[event["tool_use_id"]] = event
221
+ tool_uses_by_message[mid].append(event)
222
+ elif typ == "user" and btype == "tool_result":
223
+ tool_id = block.get("tool_use_id", "")
224
+ error = bool(block.get("is_error") or row.get("toolUseResult", {}).get("isError"))
225
+ output = block.get("content", row.get("toolUseResult", ""))
226
+ events.append({**common, "event_type": "tool_result", "tool_use_id": tool_id, "tool_name": "", "text": "", "input_preview": "", "output_preview": sanitize(output), "is_error": error})
227
+ if tool_id in tool_starts:
228
+ start = tool_starts[tool_id]
229
+ start["end_ts"] = ts
230
+ start["duration_ms"] = max(0.0, (ts - start["ts"]).total_seconds() * 1000)
231
+ start["output_preview"] = sanitize(output)
232
+ start["is_error"] = error
233
+
234
+ for group in tool_uses_by_message.values():
235
+ if len(group) > 1:
236
+ for event in group:
237
+ event["concurrent"] = True
238
+
239
+ for mid, snapshots in messages.items():
240
+ snapshots.sort(key=lambda pair: (pair[0], pair[1]["_line_no"]))
241
+ first_ts, first_row = snapshots[0]
242
+ last_ts, last_row = snapshots[-1]
243
+ usage = last_row.get("message", {}).get("usage", {}) or {}
244
+ events.append({**base, "ts": first_ts, "end_ts": last_ts, "duration_ms": max(0.0, (last_ts - first_ts).total_seconds() * 1000), "event_type": "inference", "message_id": mid, "tool_use_id": "", "tool_name": "", "model": last_row.get("message", {}).get("model", ""), "text": "", "input_preview": "", "output_preview": "", "is_error": False, "concurrent": False, "input_tokens": int(usage.get("input_tokens", 0) or 0), "cache_read_input_tokens": int(usage.get("cache_read_input_tokens", 0) or 0), "cache_creation_input_tokens": int(usage.get("cache_creation_input_tokens", 0) or 0), "output_tokens": int(usage.get("output_tokens", 0) or 0), "estimated_cost_usd": _cost(usage), "source": agent["source"], "line_no": first_row["_line_no"]})
245
+
246
+
247
+ def _codex_is_error(value: Any) -> bool:
248
+ if isinstance(value, str):
249
+ try:
250
+ return _codex_is_error(json.loads(value))
251
+ except json.JSONDecodeError:
252
+ return bool(re.search(r"(?i)(?:process exited with code|exit code\s*[:=]?)\s*[1-9]\d*", value))
253
+ if isinstance(value, list):
254
+ return any(_codex_is_error(item) for item in value)
255
+ if not isinstance(value, dict):
256
+ return False
257
+ if value.get("is_error") or value.get("isError") or value.get("success") is False:
258
+ return True
259
+ if value.get("exit_code") not in (None, 0) or value.get("exitCode") not in (None, 0):
260
+ return True
261
+ if str(value.get("status", "")).lower() in {"error", "failed", "failure"}:
262
+ return True
263
+ return any(_codex_is_error(item) for item in value.values() if isinstance(item, (dict, list)))
264
+
265
+
266
+ def _parse_codex_agent(rows: list[dict], agent: dict, events: list[dict]) -> None:
267
+ base = {"provider": "codex", "agent_id": agent["id"], "agent_name": agent["name"], "agent_type": agent["agent_type"], "spawn_depth": agent["spawn_depth"]}
268
+ models: dict[str, str] = {}
269
+ for row in rows:
270
+ if row.get("type") == "turn_context":
271
+ payload = row.get("payload", {})
272
+ models[payload.get("turn_id", "")] = payload.get("model", "")
273
+ current_turn = ""
274
+ inference_start: dict[str, pd.Timestamp] = {}
275
+ tool_starts: dict[str, dict] = {}
276
+ pending_tools: list[dict] = []
277
+ response_index = 0
278
+
279
+ def common(ts: pd.Timestamp, row: dict, turn_id: str) -> dict:
280
+ return {**base, "ts": ts, "end_ts": ts, "duration_ms": 0.0, "message_id": turn_id, "model": models.get(turn_id, ""), "source": agent["source"], "line_no": row["_line_no"], "input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "output_tokens": 0, "estimated_cost_usd": 0.0, "is_error": False, "concurrent": False}
281
+
282
+ for row in rows:
283
+ ts = _ts(row.get("timestamp"))
284
+ if pd.isna(ts):
285
+ continue
286
+ payload = row.get("payload", {})
287
+ row_type = row.get("type")
288
+ payload_type = payload.get("type", "")
289
+ metadata = payload.get("internal_chat_message_metadata_passthrough", {}) or {}
290
+ turn_id = metadata.get("turn_id") or payload.get("turn_id") or current_turn
291
+ if row_type == "event_msg" and payload_type == "task_started":
292
+ current_turn = turn_id
293
+ inference_start[turn_id] = ts
294
+ continue
295
+ if row_type == "turn_context":
296
+ current_turn = payload.get("turn_id", current_turn)
297
+ continue
298
+ if row_type == "event_msg" and payload_type == "user_message":
299
+ text = payload.get("message", "")
300
+ if text:
301
+ events.append({**common(ts, row, turn_id), "event_type": "user_prompt", "tool_use_id": "", "tool_name": "", "text": sanitize(text), "input_preview": "", "output_preview": ""})
302
+ continue
303
+ if row_type == "response_item" and payload_type == "message":
304
+ if payload.get("role") != "assistant":
305
+ continue
306
+ text = "\n".join(block.get("text", "") for block in payload.get("content", []) if block.get("type") in {"output_text", "text"})
307
+ if text:
308
+ events.append({**common(ts, row, turn_id), "event_type": "assistant_text", "tool_use_id": "", "tool_name": "", "text": sanitize(text), "input_preview": "", "output_preview": ""})
309
+ continue
310
+ if row_type == "response_item" and payload_type == "reasoning":
311
+ summary = payload.get("summary", [])
312
+ text = "\n".join(item.get("text", "") if isinstance(item, dict) else str(item) for item in summary)
313
+ events.append({**common(ts, row, turn_id), "event_type": "thinking", "tool_use_id": "", "tool_name": "", "text": sanitize(text), "input_preview": "", "output_preview": ""})
314
+ continue
315
+ if row_type == "response_item" and payload_type in {"custom_tool_call", "function_call", "local_shell_call", "web_search_call"}:
316
+ tool_id = payload.get("call_id") or payload.get("id", "")
317
+ tool_input = payload.get("input", payload.get("arguments", payload.get("action", "")))
318
+ event = {**common(ts, row, turn_id), "event_type": "tool", "tool_use_id": tool_id, "tool_name": payload.get("name") or payload_type.removesuffix("_call"), "text": "", "input_preview": sanitize(tool_input), "output_preview": ""}
319
+ events.append(event)
320
+ tool_starts[tool_id] = event
321
+ pending_tools.append(event)
322
+ continue
323
+ if row_type == "response_item" and payload_type in {"custom_tool_call_output", "function_call_output", "local_shell_call_output"}:
324
+ tool_id = payload.get("call_id", "")
325
+ output = payload.get("output", "")
326
+ error = _codex_is_error(output)
327
+ events.append({**common(ts, row, turn_id), "event_type": "tool_result", "tool_use_id": tool_id, "tool_name": "", "text": "", "input_preview": "", "output_preview": sanitize(output), "is_error": error})
328
+ if tool_id in tool_starts:
329
+ start = tool_starts[tool_id]
330
+ start["end_ts"] = ts
331
+ start["duration_ms"] = max(0.0, (ts - start["ts"]).total_seconds() * 1000)
332
+ start["output_preview"] = sanitize(output)
333
+ start["is_error"] = error
334
+ inference_start[turn_id] = ts
335
+ continue
336
+ if row_type == "event_msg" and payload_type == "token_count":
337
+ if len(pending_tools) > 1:
338
+ for event in pending_tools:
339
+ event["concurrent"] = True
340
+ pending_tools = []
341
+ usage = payload.get("info", {}).get("last_token_usage", {}) or {}
342
+ if not usage or not turn_id or not any(int(usage.get(key, 0) or 0) for key in ("input_tokens", "cached_input_tokens", "output_tokens")):
343
+ continue
344
+ response_index += 1
345
+ cached = int(usage.get("cached_input_tokens", 0) or 0)
346
+ normalized = {"input_tokens": max(0, int(usage.get("input_tokens", 0) or 0) - cached), "cache_read_input_tokens": cached, "cache_creation_input_tokens": 0, "output_tokens": int(usage.get("output_tokens", 0) or 0)}
347
+ start = inference_start.get(turn_id, ts)
348
+ model = models.get(turn_id, "")
349
+ events.append({**common(start, row, turn_id), "end_ts": ts, "duration_ms": max(0.0, (ts - start).total_seconds() * 1000), "event_type": "inference", "message_id": f"{turn_id}:{response_index}", "tool_use_id": "", "tool_name": "", "model": model, "text": "", "input_preview": "", "output_preview": "", **normalized, "estimated_cost_usd": _cost(normalized, "codex", model)})
350
+ inference_start[turn_id] = ts
351
+
352
+
353
+ def work_dir(value: str | Path | None = None) -> Path:
354
+ if value:
355
+ return Path(value).expanduser()
356
+ if os.environ.get("SESSION_DATA_DIR"):
357
+ return Path(os.environ["SESSION_DATA_DIR"]).expanduser()
358
+ state = Path.home() / ".cache" / "session-profiler" / "last-work-dir"
359
+ if state.exists():
360
+ return Path(state.read_text().strip())
361
+ raise FileNotFoundError("No parsed session. Run `sp parse <session>` first or set SESSION_DATA_DIR.")
362
+
363
+
364
+ def load(value: str | Path | None = None) -> pd.DataFrame:
365
+ return pd.read_parquet(work_dir(value) / "events.parquet")
366
+
367
+
368
+ def load_agents(value: str | Path | None = None) -> dict:
369
+ return json.loads((work_dir(value) / "agents.json").read_text())
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+
9
+ def claude_projects_root() -> Path:
10
+ return Path.home() / ".claude" / "projects"
11
+
12
+
13
+ def codex_roots() -> list[Path]:
14
+ root = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")).expanduser()
15
+ return [root / "sessions", root / "archived_sessions"]
16
+
17
+
18
+ def project_dirs() -> list[Path]:
19
+ root = claude_projects_root()
20
+ paths = [p for p in root.iterdir() if p.is_dir()] if root.exists() else []
21
+ paths.extend(p for p in codex_roots() if p.exists())
22
+ return sorted(paths, key=lambda p: p.stat().st_mtime, reverse=True)
23
+
24
+
25
+ def detect_provider(path: Path) -> str:
26
+ try:
27
+ first = json.loads(path.open(errors="replace").readline())
28
+ return "codex" if first.get("type") == "session_meta" else "claude"
29
+ except (OSError, json.JSONDecodeError):
30
+ return "claude"
31
+
32
+
33
+ def _session_meta(path: Path) -> dict:
34
+ try:
35
+ with path.open(errors="replace") as fh:
36
+ first = json.loads(fh.readline())
37
+ return first.get("payload", {}) if first.get("type") == "session_meta" else {}
38
+ except (OSError, json.JSONDecodeError):
39
+ return {}
40
+
41
+
42
+ def session_id(path: Path) -> str:
43
+ return _session_meta(path).get("id") or path.stem
44
+
45
+
46
+ def codex_transcripts() -> list[Path]:
47
+ paths = []
48
+ for root in codex_roots():
49
+ if root.exists():
50
+ paths.extend(root.rglob("rollout-*.jsonl"))
51
+ return sorted(paths, key=lambda p: p.stat().st_mtime, reverse=True)
52
+
53
+
54
+ def codex_index() -> dict[str, tuple[Path, dict]]:
55
+ result = {}
56
+ for path in codex_transcripts():
57
+ meta = _session_meta(path)
58
+ if meta.get("id"):
59
+ result[meta["id"]] = (path, meta)
60
+ return result
61
+
62
+
63
+ def codex_family(path: Path) -> list[tuple[str, Path, dict]]:
64
+ """Return a Codex rollout and all recursively spawned subagent rollouts."""
65
+ index = codex_index()
66
+ root_id = session_id(path)
67
+ children: dict[str, list[tuple[str, Path, dict]]] = {}
68
+ for thread_id, (candidate, meta) in index.items():
69
+ spawn = meta.get("source", {}).get("subagent", {}).get("thread_spawn", {}) if isinstance(meta.get("source"), dict) else {}
70
+ parent = spawn.get("parent_thread_id")
71
+ if parent:
72
+ children.setdefault(parent, []).append((thread_id, candidate, meta))
73
+ found: list[tuple[str, Path, dict]] = [(root_id, path, _session_meta(path))]
74
+ queue = [root_id]
75
+ while queue:
76
+ parent = queue.pop(0)
77
+ for item in children.get(parent, []):
78
+ found.append(item)
79
+ queue.append(item[0])
80
+ return found
81
+
82
+
83
+ def main_transcripts(project_dir: Path) -> list[Path]:
84
+ if not project_dir.exists():
85
+ return []
86
+ if project_dir in codex_roots() or ".codex/sessions" in str(project_dir) or ".codex/archived_sessions" in str(project_dir):
87
+ paths = project_dir.rglob("rollout-*.jsonl")
88
+ return sorted((p for p in paths if not _codex_parent_id(p)), key=lambda p: p.stat().st_mtime, reverse=True)
89
+ return sorted((p for p in project_dir.glob("*.jsonl") if p.is_file()), key=lambda p: p.stat().st_mtime, reverse=True)
90
+
91
+
92
+ def _codex_parent_id(path: Path) -> str:
93
+ source = _session_meta(path).get("source", {})
94
+ if not isinstance(source, dict):
95
+ return ""
96
+ return source.get("subagent", {}).get("thread_spawn", {}).get("parent_thread_id", "")
97
+
98
+
99
+ def is_codex_subagent(path: Path) -> bool:
100
+ return bool(_codex_parent_id(path))
101
+
102
+
103
+ def resolve_session(value: str | Path) -> Path:
104
+ candidate = Path(value).expanduser()
105
+ if candidate.is_file():
106
+ return candidate.resolve()
107
+ matches = find_sessions(str(value))
108
+ if not matches:
109
+ raise FileNotFoundError(f"No Claude Code or Codex session matches {value!r}")
110
+ if len(matches) > 1:
111
+ shown = "\n".join(f" {session_id(p)} {p}" for p in matches[:12])
112
+ raise ValueError(f"Session prefix is ambiguous:\n{shown}")
113
+ return matches[0]
114
+
115
+
116
+ def find_sessions(prefix: str) -> list[Path]:
117
+ claude = [p for project in project_dirs() if ".claude/projects" in str(project) for p in main_transcripts(project) if p.stem.startswith(prefix)]
118
+ codex = [p for p in codex_transcripts() if session_id(p).startswith(prefix) or p.stem.startswith(prefix)]
119
+ return claude + codex
120
+
121
+
122
+ def _first_prompt(path: Path, limit: int = 100) -> str:
123
+ provider = detect_provider(path)
124
+ try:
125
+ with path.open(errors="replace") as fh:
126
+ for line in fh:
127
+ row = json.loads(line)
128
+ if provider == "codex":
129
+ payload = row.get("payload", {})
130
+ if row.get("type") != "event_msg" or payload.get("type") != "user_message":
131
+ continue
132
+ text = payload.get("message", "")
133
+ else:
134
+ if row.get("type") != "user" or row.get("toolUseResult"):
135
+ continue
136
+ content = row.get("message", {}).get("content", "")
137
+ text = content if isinstance(content, str) else " ".join(x.get("text", "") for x in content if x.get("type") == "text")
138
+ if text.strip():
139
+ return " ".join(text.split())[:limit]
140
+ except (OSError, json.JSONDecodeError):
141
+ pass
142
+ return ""
143
+
144
+
145
+ def session_info(path: Path) -> dict:
146
+ provider = detect_provider(path)
147
+ agents = []
148
+ if provider == "codex":
149
+ root_id = session_id(path)
150
+ for thread_id, transcript, meta in codex_family(path)[1:]:
151
+ spawn = meta.get("source", {}).get("subagent", {}).get("thread_spawn", {})
152
+ model, start, end = _transcript_shape(transcript)
153
+ agents.append({"id": thread_id, "name": spawn.get("agent_nickname") or thread_id, "agentType": spawn.get("agent_role", "subagent"), "description": _first_prompt(transcript, 200), "spawnDepth": spawn.get("depth", 1), "parentThreadId": spawn.get("parent_thread_id", root_id), "model": model, "start_ts": start, "end_ts": end})
154
+ else:
155
+ side = path.parent / path.stem / "subagents"
156
+ for meta_path in sorted(side.glob("agent-*.meta.json")) if side.exists() else []:
157
+ try:
158
+ meta = json.loads(meta_path.read_text())
159
+ except (OSError, json.JSONDecodeError):
160
+ meta = {}
161
+ meta["id"] = meta_path.name.removesuffix(".meta.json")
162
+ model, start, end = _transcript_shape(meta_path.with_suffix("").with_suffix(".jsonl"))
163
+ meta.update({"model": model, "start_ts": start, "end_ts": end})
164
+ agents.append(meta)
165
+ model, start, end = _transcript_shape(path)
166
+ span_seconds = None
167
+ if start and end:
168
+ span_seconds = (datetime.fromisoformat(end.replace("Z", "+00:00")) - datetime.fromisoformat(start.replace("Z", "+00:00"))).total_seconds()
169
+ return {"provider": provider, "session_id": session_id(path), "path": str(path), "bytes": path.stat().st_size, "modified": path.stat().st_mtime, "model": model, "start_ts": start, "end_ts": end, "span_seconds": span_seconds, "first_prompt": _first_prompt(path), "subagents": agents}
170
+
171
+
172
+ def _transcript_shape(path: Path) -> tuple[str, str | None, str | None]:
173
+ model = ""
174
+ start = end = None
175
+ if not path.exists():
176
+ return model, start, end
177
+ try:
178
+ with path.open(errors="replace") as fh:
179
+ for line in fh:
180
+ row = json.loads(line)
181
+ stamp = row.get("timestamp")
182
+ if stamp:
183
+ start = start or stamp
184
+ end = stamp
185
+ if not model:
186
+ model = row.get("message", {}).get("model", "")
187
+ if row.get("type") == "turn_context":
188
+ model = row.get("payload", {}).get("model", "")
189
+ except (OSError, json.JSONDecodeError):
190
+ pass
191
+ return model, start, end
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from .dataset import load, load_agents, work_dir
9
+
10
+
11
+ def digest(data_dir=None) -> str:
12
+ df = load(data_dir)
13
+ part = df[(df.agent_id == "main") & df.event_type.isin(["user_prompt", "assistant_text", "tool"])]
14
+ lines = []
15
+ for _, row in part.sort_values("ts").iterrows():
16
+ if row.event_type == "tool" and row.tool_name not in {"Agent", "Task"} and "spawn_agent" not in str(row.tool_name):
17
+ continue
18
+ body = row.text if row.event_type != "tool" else f"spawn {row.tool_name}: {row.input_preview}"
19
+ body = " ".join(str(body).split())[:1000]
20
+ if body:
21
+ lines.append(f"[{row.ts.isoformat()}] {row.event_type}: {body}")
22
+ return "\n".join(lines)
23
+
24
+
25
+ def _markdown(data: dict) -> str:
26
+ lines = ["# Session table of contents", ""]
27
+ for phase in data.get("phases", []):
28
+ lines.append(f"## {phase.get('title', 'Phase')}")
29
+ if phase.get("summary"):
30
+ lines.append(phase["summary"])
31
+ lines.append("")
32
+ for step in phase.get("steps", []):
33
+ lines.append(f"- {step.get('title', 'Step')}")
34
+ for sub in step.get("substeps", []):
35
+ lines.append(f" - {sub}")
36
+ lines.append("")
37
+ return "\n".join(lines)
38
+
39
+
40
+ def create(data_dir=None, dry_run=False, model=None, engine="auto") -> str:
41
+ out = work_dir(data_dir)
42
+ source = digest(out)
43
+ prompt = """Write a chronological, hierarchical table of contents for this agent coding session.
44
+ Choose the number of phases from the work itself. Each phase needs title, summary, start_ts,
45
+ end_ts, and steps. Each step needs title, start_ts, end_ts, and optional substeps (strings).
46
+ Use concrete actions, not generic labels. Return JSON only: {\"phases\":[...]}.
47
+
48
+ SESSION DIGEST
49
+ """ + source
50
+ (out / "toc-prompt.txt").write_text(prompt)
51
+ if dry_run:
52
+ return prompt
53
+ provider = load_agents(out).get("provider", "claude")
54
+ selected = provider if engine == "auto" else engine
55
+ if selected == "codex":
56
+ result_file = out / "toc-response.txt"
57
+ command = ["codex", "exec", "--ephemeral", "--sandbox", "read-only", "--skip-git-repo-check", "--color", "never", "-o", str(result_file)]
58
+ if model:
59
+ command.extend(["--model", model])
60
+ command.append(prompt)
61
+ proc = subprocess.run(command, text=True, capture_output=True)
62
+ raw = result_file.read_text().strip() if result_file.exists() else proc.stdout.strip()
63
+ else:
64
+ command = ["claude", "-p", "--model", model or "sonnet", prompt]
65
+ proc = subprocess.run(command, text=True, capture_output=True)
66
+ raw = proc.stdout.strip()
67
+ if proc.returncode:
68
+ raise RuntimeError(proc.stderr.strip() or f"{selected} TOC generation failed")
69
+ raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw)
70
+ data = json.loads(raw)
71
+ (out / "toc.json").write_text(json.dumps(data, indent=2))
72
+ md = _markdown(data)
73
+ (out / "toc.md").write_text(md)
74
+ return md