elliot-stack 1.0.18 → 1.0.19

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 (44) hide show
  1. package/README.md +11 -0
  2. package/bin/install.cjs +134 -49
  3. package/package.json +1 -1
  4. package/skills/estack-read-claude-session-history/SKILL.md +196 -0
  5. package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -0
  6. package/skills/estack-read-claude-session-history/references/modes.md +366 -0
  7. package/skills/estack-read-claude-session-history/references/recipes.md +237 -0
  8. package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -0
  9. package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -0
  10. package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -0
  11. package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -0
  12. package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -0
  13. package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -0
  14. package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1448 -0
  15. package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -0
  16. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -0
  17. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -0
  18. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -0
  19. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -0
  20. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -0
  21. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -0
  22. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -0
  23. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -0
  24. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -0
  25. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -0
  26. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -0
  27. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -0
  28. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -0
  29. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -0
  30. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +3 -0
  31. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -0
  32. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -0
  33. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -0
  34. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -0
  35. package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -0
  36. package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -0
  37. package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -0
  38. package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -0
  39. package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -0
  40. package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -0
  41. package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -0
  42. package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +175 -0
  43. package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -0
  44. package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +80 -0
@@ -0,0 +1,179 @@
1
+ """Unified search engine across sessions, projects, and roots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from collections import namedtuple
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Iterator, Literal
11
+
12
+ from . import parser as _parser
13
+ from . import paths as _paths
14
+
15
+
16
+ Match = namedtuple(
17
+ "Match",
18
+ ["session_path", "mtime", "role", "where", "timestamp", "window_text"],
19
+ )
20
+
21
+
22
+ def _block_text(block: dict, where: str) -> str:
23
+ """Extract searchable text for a given 'where' channel from one block."""
24
+ bt = block.get("type")
25
+ if where == "text" and bt == "text":
26
+ return block.get("text", "")
27
+ if where == "thinking" and bt == "thinking":
28
+ return block.get("thinking", "") or block.get("text", "")
29
+ if where == "tool_use" and bt == "tool_use":
30
+ name = block.get("name", "")
31
+ try:
32
+ inp = json.dumps(block.get("input", {}))
33
+ except (TypeError, ValueError):
34
+ inp = str(block.get("input", ""))
35
+ return f"[tool:{name}] {inp}"
36
+ if where == "tool_result" and bt == "tool_result":
37
+ inner = block.get("content", "")
38
+ if isinstance(inner, str):
39
+ return inner
40
+ if isinstance(inner, list):
41
+ parts = []
42
+ for item in inner:
43
+ if isinstance(item, dict) and item.get("type") == "text":
44
+ parts.append(item.get("text", ""))
45
+ return "\n".join(parts)
46
+ return ""
47
+
48
+
49
+ def _entry_search_text(obj: dict, in_channel: str) -> list[tuple[str, str]]:
50
+ """Return list of (where, text) for an entry filtered to in_channel."""
51
+ msg = obj.get("message", {})
52
+ if not isinstance(msg, dict):
53
+ return []
54
+ content = msg.get("content")
55
+ out: list[tuple[str, str]] = []
56
+ if isinstance(content, str):
57
+ if in_channel in ("text", "all"):
58
+ out.append(("text", content))
59
+ return out
60
+ if not isinstance(content, list):
61
+ return out
62
+ channels = ["text", "tool_use", "thinking", "tool_result"] if in_channel == "all" else [in_channel]
63
+ for block in content:
64
+ if not isinstance(block, dict):
65
+ continue
66
+ for ch in channels:
67
+ t = _block_text(block, ch)
68
+ if t:
69
+ out.append((ch, t))
70
+ return out
71
+
72
+
73
+ def _window(text: str, q: str, n: int = 200) -> str:
74
+ """Return up to n chars of context around the first match of q."""
75
+ if not text or not q:
76
+ return ""
77
+ lower = text.lower()
78
+ idx = lower.find(q.lower())
79
+ if idx == -1:
80
+ return text[:n]
81
+ start = max(0, idx - n // 2)
82
+ end = min(len(text), idx + len(q) + n // 2)
83
+ window = text[start:end]
84
+ prefix = "…" if start > 0 else ""
85
+ suffix = "…" if end < len(text) else ""
86
+ return prefix + window + suffix
87
+
88
+
89
+ def search_session(
90
+ path: Path,
91
+ query: str,
92
+ role: Literal["user", "assistant", "both"] = "both",
93
+ in_channel: Literal["text", "tool_use", "thinking", "all"] = "text",
94
+ since: datetime | None = None,
95
+ until: datetime | None = None,
96
+ ) -> list[Match]:
97
+ """Search one transcript file. Returns ordered matches."""
98
+ lines = _parser.parse_lines(path)
99
+ try:
100
+ mtime = path.stat().st_mtime
101
+ except OSError:
102
+ mtime = 0
103
+ matches: list[Match] = []
104
+ q = query.lower()
105
+ for obj in lines:
106
+ cls = _parser.classify_entry(obj)
107
+ if cls == "noise" or cls == "title":
108
+ continue
109
+ msg_role = "user" if cls in ("user", "compact") else "assistant"
110
+ if role != "both" and msg_role != role:
111
+ continue
112
+ ts = obj.get("timestamp")
113
+ ts_dt = None
114
+ if since is not None or until is not None:
115
+ ts_dt = _parser._parse_timestamp(ts)
116
+ if ts_dt is None:
117
+ continue
118
+ if ts_dt.tzinfo is not None:
119
+ ts_dt = ts_dt.replace(tzinfo=None)
120
+ if since is not None and ts_dt < since:
121
+ continue
122
+ if until is not None and ts_dt > until:
123
+ continue
124
+ for where, text in _entry_search_text(obj, in_channel):
125
+ if q in text.lower():
126
+ matches.append(Match(
127
+ session_path=path,
128
+ mtime=mtime,
129
+ role=msg_role,
130
+ where=where,
131
+ timestamp=ts,
132
+ window_text=_window(text, query),
133
+ ))
134
+ return matches
135
+
136
+
137
+ def search_project(
138
+ project_dir: Path,
139
+ query: str,
140
+ role: Literal["user", "assistant", "both"] = "both",
141
+ in_channel: Literal["text", "tool_use", "thinking", "all"] = "text",
142
+ since: datetime | None = None,
143
+ until: datetime | None = None,
144
+ progress: bool = True,
145
+ ) -> Iterator[Match]:
146
+ """Search every transcript in a project directory, newest first."""
147
+ files = _paths.list_transcripts(project_dir, since=since, until=until)
148
+ for i, f in enumerate(files, 1):
149
+ if progress:
150
+ print(
151
+ f"Searching {i}/{len(files)}: {f.name}...",
152
+ file=sys.stderr,
153
+ end="\r",
154
+ )
155
+ try:
156
+ for m in search_session(f, query, role, in_channel, since, until):
157
+ yield m
158
+ except Exception as e:
159
+ print(f"\nError reading {f.name}: {e}", file=sys.stderr)
160
+ if progress:
161
+ print(file=sys.stderr)
162
+
163
+
164
+ def search_all_projects(
165
+ root: Path,
166
+ query: str,
167
+ role: Literal["user", "assistant", "both"] = "both",
168
+ in_channel: Literal["text", "tool_use", "thinking", "all"] = "text",
169
+ since: datetime | None = None,
170
+ until: datetime | None = None,
171
+ progress: bool = True,
172
+ ) -> Iterator[Match]:
173
+ """Walk every project directory under root."""
174
+ for project_dir in _paths.list_projects(root):
175
+ if progress:
176
+ print(f"--- {project_dir.name} ---", file=sys.stderr)
177
+ yield from search_project(
178
+ project_dir, query, role, in_channel, since, until, progress
179
+ )
@@ -0,0 +1,88 @@
1
+ """Subagent transcript discovery and metadata loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from . import parser as _parser
10
+ from . import paths as _paths
11
+ from . import tools as _tools
12
+
13
+
14
+ def load_meta(agent_path: Path) -> dict:
15
+ """Read the sibling agent-<id>.meta.json sidecar.
16
+
17
+ Returns {"agentType": "unknown", "description": ""} on miss.
18
+ """
19
+ meta_path = agent_path.with_suffix(".meta.json")
20
+ if not meta_path.exists():
21
+ # Some sidecars use .meta.json appended to full name
22
+ alt = agent_path.parent / f"{agent_path.stem}.meta.json"
23
+ if alt.exists():
24
+ meta_path = alt
25
+ else:
26
+ return {"agentType": "unknown", "description": ""}
27
+ try:
28
+ with open(meta_path, encoding="utf-8") as f:
29
+ data = json.load(f)
30
+ return {
31
+ "agentType": data.get("agentType", data.get("subagent_type", "unknown")),
32
+ "description": data.get("description", ""),
33
+ }
34
+ except (OSError, json.JSONDecodeError):
35
+ return {"agentType": "unknown", "description": ""}
36
+
37
+
38
+ def _last_assistant_text(path: Path) -> str:
39
+ """Pull the last assistant text from a subagent transcript."""
40
+ lines = _parser.parse_lines(path)
41
+ messages = _parser.get_messages(lines)
42
+ for m in reversed(messages):
43
+ if m["role"] == "assistant" and m["texts"]:
44
+ return "\n".join(m["texts"])
45
+ return ""
46
+
47
+
48
+ def agent_finals(parent_session: Path) -> list[tuple[str, dict, str]]:
49
+ """For each subagent of a session: (agent_id, meta, last_assistant_text)."""
50
+ out: list[tuple[str, dict, str]] = []
51
+ for sa in _paths.list_subagents(parent_session):
52
+ agent_id = sa.stem # e.g., "agent-xxxx"
53
+ meta = load_meta(sa)
54
+ text = _last_assistant_text(sa)
55
+ out.append((agent_id, meta, text))
56
+ return out
57
+
58
+
59
+ def agent_tools(agent_path: Path, tool_filter: Optional[set[str]] = None) -> list[dict]:
60
+ return _tools.extract_tool_calls(_parser.parse_lines(agent_path), tool_filter)
61
+
62
+
63
+ def agent_files(agent_path: Path) -> dict[Path, list[str]]:
64
+ return _tools.files_touched(_parser.parse_lines(agent_path))
65
+
66
+
67
+ def group_by_parent(
68
+ root: Path, agent_type_filter: Optional[str] = None
69
+ ) -> dict[Path, list[tuple[Path, dict]]]:
70
+ """For every parent session under root, list its subagents + meta.
71
+
72
+ Optionally filter by agentType.
73
+ """
74
+ out: dict[Path, list[tuple[Path, dict]]] = {}
75
+ for project_dir in _paths.list_projects(root):
76
+ for parent in _paths.list_transcripts(project_dir):
77
+ subs = _paths.list_subagents(parent)
78
+ if not subs:
79
+ continue
80
+ entries = []
81
+ for sa in subs:
82
+ meta = load_meta(sa)
83
+ if agent_type_filter and meta["agentType"] != agent_type_filter:
84
+ continue
85
+ entries.append((sa, meta))
86
+ if entries:
87
+ out[parent] = entries
88
+ return out
@@ -0,0 +1,144 @@
1
+ """Tool-call extraction, per-tool formatting, and tool-result lookup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+
10
+ def extract_tool_calls(
11
+ lines: list[dict], tool_filter: Optional[set[str]] = None
12
+ ) -> list[dict]:
13
+ """Every tool_use block with timestamp and parent line index.
14
+
15
+ Returns list of dicts: {name, input, id, timestamp, line_index}.
16
+ """
17
+ out = []
18
+ for i, obj in enumerate(lines):
19
+ msg = obj.get("message", {})
20
+ if not isinstance(msg, dict):
21
+ continue
22
+ content = msg.get("content")
23
+ if not isinstance(content, list):
24
+ continue
25
+ ts = obj.get("timestamp")
26
+ for block in content:
27
+ if not isinstance(block, dict):
28
+ continue
29
+ if block.get("type") != "tool_use":
30
+ continue
31
+ name = block.get("name", "")
32
+ if tool_filter and name not in tool_filter:
33
+ continue
34
+ out.append({
35
+ "name": name,
36
+ "input": block.get("input", {}) or {},
37
+ "id": block.get("id", ""),
38
+ "timestamp": ts,
39
+ "line_index": i,
40
+ })
41
+ return out
42
+
43
+
44
+ def _first_line(s: str, n: int = 200) -> str:
45
+ if not s:
46
+ return ""
47
+ line = s.splitlines()[0] if s.splitlines() else s
48
+ return line if len(line) <= n else line[: n - 1] + "…"
49
+
50
+
51
+ def format_tool_call(call: dict) -> str:
52
+ """Per-tool human-readable one-liner."""
53
+ name = call.get("name", "?")
54
+ inp = call.get("input", {}) or {}
55
+
56
+ if name in ("Bash", "PowerShell"):
57
+ cmd = inp.get("command", "")
58
+ return f"{name}: {_first_line(cmd, 200)}"
59
+ if name == "Read":
60
+ fp = inp.get("file_path", "")
61
+ offset = inp.get("offset")
62
+ limit = inp.get("limit")
63
+ if offset is not None or limit is not None:
64
+ o = offset or 1
65
+ l = limit or 0
66
+ return f"Read {fp} (lines {o}-{o + l if l else '?'})"
67
+ return f"Read {fp}"
68
+ if name == "Edit":
69
+ fp = inp.get("file_path", "")
70
+ edits = inp.get("edits")
71
+ if isinstance(edits, list):
72
+ return f"Edit {fp} ({len(edits)} edits)"
73
+ return f"Edit {fp}"
74
+ if name == "Write":
75
+ fp = inp.get("file_path", "")
76
+ content = inp.get("content", "")
77
+ return f"Write {fp} ({len(content)} chars)"
78
+ if name in ("Agent", "Task"):
79
+ sub = inp.get("subagent_type") or inp.get("agentType") or "?"
80
+ desc = inp.get("description", "")
81
+ return f"{name}[{sub}]: {_first_line(desc, 200)}"
82
+ if name == "Skill":
83
+ sk = inp.get("skill", "")
84
+ args = inp.get("args", "")
85
+ if args:
86
+ return f"Skill: {sk} {_first_line(str(args), 100)}"
87
+ return f"Skill: {sk}"
88
+ if name == "Glob":
89
+ pat = inp.get("pattern", "")
90
+ return f"Glob: {pat}"
91
+ if name == "Grep":
92
+ pat = inp.get("pattern", "")
93
+ return f"Grep: {pat}"
94
+ try:
95
+ preview = json.dumps(inp)[:200]
96
+ except (TypeError, ValueError):
97
+ preview = str(inp)[:200]
98
+ return f"{name}: {preview}"
99
+
100
+
101
+ def extract_tool_results(
102
+ lines: list[dict], tool_use_id: str
103
+ ) -> Optional[str]:
104
+ """Find the tool_result block whose tool_use_id matches the given id."""
105
+ if not tool_use_id:
106
+ return None
107
+ for obj in lines:
108
+ msg = obj.get("message", {})
109
+ if not isinstance(msg, dict):
110
+ continue
111
+ content = msg.get("content")
112
+ if not isinstance(content, list):
113
+ continue
114
+ for block in content:
115
+ if not isinstance(block, dict):
116
+ continue
117
+ if block.get("type") != "tool_result":
118
+ continue
119
+ if block.get("tool_use_id") != tool_use_id:
120
+ continue
121
+ inner = block.get("content", "")
122
+ if isinstance(inner, str):
123
+ return inner
124
+ if isinstance(inner, list):
125
+ parts = []
126
+ for sub in inner:
127
+ if isinstance(sub, dict) and sub.get("type") == "text":
128
+ parts.append(sub.get("text", ""))
129
+ return "\n".join(parts)
130
+ return None
131
+
132
+
133
+ def files_touched(lines: list[dict]) -> dict[Path, list[str]]:
134
+ """Map of file paths to the list of operations performed on them."""
135
+ out: dict[str, list[str]] = {}
136
+ for call in extract_tool_calls(lines):
137
+ name = call["name"]
138
+ inp = call.get("input", {}) or {}
139
+ if name in ("Edit", "Write", "Read", "NotebookEdit"):
140
+ fp = inp.get("file_path") or inp.get("notebook_path", "")
141
+ if fp:
142
+ out.setdefault(fp, []).append(name)
143
+ # Convert to Path keys
144
+ return {Path(k): v for k, v in out.items()}