elliot-stack 1.0.17 → 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.
- package/README.md +17 -0
- package/bin/install.cjs +322 -43
- package/hooks/repo-search-nudge.js +31 -0
- package/package.json +3 -2
- package/skills/estack-read-claude-session-history/SKILL.md +196 -0
- package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -0
- package/skills/estack-read-claude-session-history/references/modes.md +366 -0
- package/skills/estack-read-claude-session-history/references/recipes.md +237 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -0
- package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -0
- package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -0
- package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -0
- package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -0
- package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -0
- package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1448 -0
- package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +3 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +175 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +80 -0
- package/skills/estack-repo-search/SKILL.md +0 -65
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Path resolution, project discovery, and time-spec parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
CLAUDE_DIR = Path.home() / ".claude"
|
|
12
|
+
DEFAULT_LIVE_PROJECTS = CLAUDE_DIR / "projects"
|
|
13
|
+
DEFAULT_BACKUPS_DIR = Path.home() / ".claude-backups"
|
|
14
|
+
|
|
15
|
+
KNOWN_ROOTS = {"live", "mirror", "snapshot-24h", "snapshot-1w", "snapshot-1mo"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def encode_cwd(cwd: str) -> str:
|
|
19
|
+
"""Convert an absolute path to the Claude project directory name.
|
|
20
|
+
|
|
21
|
+
Replaces colons, backslashes, forward slashes, and whitespace with hyphens.
|
|
22
|
+
Verified against the 34 real project dirs on this machine — no other chars
|
|
23
|
+
appear in encoded names.
|
|
24
|
+
"""
|
|
25
|
+
return re.sub(r"[:\\/\s]", "-", cwd)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def decode_project_name(encoded: str) -> str:
|
|
29
|
+
"""Best-effort reverse for display.
|
|
30
|
+
|
|
31
|
+
Strips the `C--Users-<user>-` drive/home prefix when present, replaces
|
|
32
|
+
remaining hyphens with spaces, and joins path-like segments with " > ".
|
|
33
|
+
|
|
34
|
+
Falls back to the raw encoded name if the heuristic fails. Display only —
|
|
35
|
+
never use this to look up a real directory.
|
|
36
|
+
"""
|
|
37
|
+
if not encoded:
|
|
38
|
+
return encoded
|
|
39
|
+
|
|
40
|
+
# Strip leading drive prefix `C--Users-<name>-`
|
|
41
|
+
m = re.match(r"^([A-Z])--Users-([^-]+)-(.+)$", encoded)
|
|
42
|
+
if m:
|
|
43
|
+
remainder = m.group(3)
|
|
44
|
+
else:
|
|
45
|
+
remainder = encoded
|
|
46
|
+
|
|
47
|
+
# Heuristic: every run of single hyphens is a path separator. The encoder
|
|
48
|
+
# mapped one `-` per separator char, so a single `-` in the original path
|
|
49
|
+
# is impossible to recover. We split on single `-` between word characters
|
|
50
|
+
# and treat the result as path segments. Multiple consecutive hyphens
|
|
51
|
+
# indicate the original had spaces+hyphens fused together — collapse to one.
|
|
52
|
+
# In practice this gives readable output like "Other Claude Code > Personal Brand Project".
|
|
53
|
+
cleaned = re.sub(r"-{2,}", "-", remainder)
|
|
54
|
+
# Words are likely separated by hyphens; segments by capitalized starts.
|
|
55
|
+
# Simple approach: just replace hyphens with spaces.
|
|
56
|
+
return cleaned.replace("-", " ").strip() or encoded
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def current_session_id() -> str | None:
|
|
60
|
+
"""Return the current Claude Code session UUID from CLAUDE_SESSION_ID env var.
|
|
61
|
+
|
|
62
|
+
Returns None when called outside a Claude Code session.
|
|
63
|
+
"""
|
|
64
|
+
val = os.environ.get("CLAUDE_SESSION_ID", "").strip()
|
|
65
|
+
return val or None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_root(name: str | None) -> Path:
|
|
69
|
+
"""Resolve a root name to its absolute projects directory.
|
|
70
|
+
|
|
71
|
+
- "live" (default, None) -> ~/.claude/projects
|
|
72
|
+
- "mirror" -> ~/.claude-backups/mirror/projects
|
|
73
|
+
- "snapshot-24h" -> ~/.claude-backups/snapshot-24h/projects
|
|
74
|
+
- "snapshot-1w" / "snapshot-1mo" -> analogous
|
|
75
|
+
- <absolute path> -> passes through unchanged
|
|
76
|
+
"""
|
|
77
|
+
if not name or name == "live":
|
|
78
|
+
return DEFAULT_LIVE_PROJECTS
|
|
79
|
+
if name in KNOWN_ROOTS:
|
|
80
|
+
return DEFAULT_BACKUPS_DIR / name / "projects"
|
|
81
|
+
p = Path(name)
|
|
82
|
+
if p.is_absolute():
|
|
83
|
+
return p
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Unknown root: {name!r}. Expected one of {sorted(KNOWN_ROOTS)} or an absolute path."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def find_project_dir(cwd: str, root: Path | None = None) -> Path:
|
|
90
|
+
"""Resolve a project directory under the given root.
|
|
91
|
+
|
|
92
|
+
Tries exact encoded match first, falls back to case-insensitive substring.
|
|
93
|
+
"""
|
|
94
|
+
if root is None:
|
|
95
|
+
root = DEFAULT_LIVE_PROJECTS
|
|
96
|
+
encoded = encode_cwd(cwd)
|
|
97
|
+
candidate = root / encoded
|
|
98
|
+
if candidate.exists():
|
|
99
|
+
return candidate
|
|
100
|
+
if root.exists():
|
|
101
|
+
matches = [
|
|
102
|
+
d for d in root.iterdir()
|
|
103
|
+
if d.is_dir() and encoded.lower() in d.name.lower()
|
|
104
|
+
]
|
|
105
|
+
if matches:
|
|
106
|
+
return matches[0]
|
|
107
|
+
raise FileNotFoundError(
|
|
108
|
+
f"No project directory found for cwd: {cwd}\nExpected: {candidate}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def list_projects(root: Path | None = None) -> list[Path]:
|
|
113
|
+
"""All encoded-cwd dirs under the given root."""
|
|
114
|
+
if root is None:
|
|
115
|
+
root = DEFAULT_LIVE_PROJECTS
|
|
116
|
+
if not root.exists():
|
|
117
|
+
return []
|
|
118
|
+
return sorted([d for d in root.iterdir() if d.is_dir()], key=lambda d: d.name)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def filter_projects(root: Path | None, name: str) -> list[Path]:
|
|
122
|
+
"""Project dirs whose encoded or decoded name contains `name` (case-insensitive).
|
|
123
|
+
|
|
124
|
+
Matches against both forms so `--project "Keel Project"`, `--project
|
|
125
|
+
Keel-Project`, and `--project keel` all hit the same directory.
|
|
126
|
+
"""
|
|
127
|
+
q = name.strip().lower()
|
|
128
|
+
q_encoded = q.replace(" ", "-")
|
|
129
|
+
out = []
|
|
130
|
+
for d in list_projects(root):
|
|
131
|
+
dname = d.name.lower()
|
|
132
|
+
decoded = decode_project_name(d.name).lower()
|
|
133
|
+
if q in dname or q_encoded in dname or q in decoded:
|
|
134
|
+
out.append(d)
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def list_transcripts(
|
|
139
|
+
project_dir: Path,
|
|
140
|
+
since: datetime | None = None,
|
|
141
|
+
until: datetime | None = None,
|
|
142
|
+
) -> list[Path]:
|
|
143
|
+
"""Return .jsonl files in the project dir, newest first.
|
|
144
|
+
|
|
145
|
+
Excludes subagent transcripts (files starting with `agent-`).
|
|
146
|
+
"""
|
|
147
|
+
if not project_dir.exists():
|
|
148
|
+
return []
|
|
149
|
+
files = [f for f in project_dir.glob("*.jsonl") if not f.name.startswith("agent-")]
|
|
150
|
+
# display_to_epoch (not .timestamp()) — naive bounds are in the display
|
|
151
|
+
# timezone, which differs from local under a --tz override.
|
|
152
|
+
from . import parser as _parser
|
|
153
|
+
if since is not None:
|
|
154
|
+
since_ts = _parser.display_to_epoch(since)
|
|
155
|
+
files = [f for f in files if f.stat().st_mtime >= since_ts]
|
|
156
|
+
if until is not None:
|
|
157
|
+
until_ts = _parser.display_to_epoch(until)
|
|
158
|
+
files = [f for f in files if f.stat().st_mtime <= until_ts]
|
|
159
|
+
files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
|
|
160
|
+
return files
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def list_subagents(session_file: Path) -> list[Path]:
|
|
164
|
+
"""Return subagent transcript files for a given parent session."""
|
|
165
|
+
uuid = session_file.stem
|
|
166
|
+
subagent_dir = session_file.parent / uuid / "subagents"
|
|
167
|
+
if not subagent_dir.exists():
|
|
168
|
+
return []
|
|
169
|
+
return sorted(
|
|
170
|
+
subagent_dir.glob("agent-*.jsonl"),
|
|
171
|
+
key=lambda f: f.stat().st_mtime,
|
|
172
|
+
reverse=True,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
_RELATIVE_RE = re.compile(r"^(\d+)\s*(m|h|d|w|mo)$", re.IGNORECASE)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def parse_timespec(s: str) -> datetime:
|
|
180
|
+
"""Parse a time spec into a naive datetime in the display timezone
|
|
181
|
+
(system local time unless --tz overrides it).
|
|
182
|
+
|
|
183
|
+
Accepts:
|
|
184
|
+
- ISO date: "2026-05-01"
|
|
185
|
+
- ISO datetime: "2026-05-01T14:30" or "2026-05-01 14:30"
|
|
186
|
+
- Relative: "30m", "24h", "7d", "1w", "1mo"
|
|
187
|
+
- Named: "today", "yesterday", "now"
|
|
188
|
+
"""
|
|
189
|
+
if not s:
|
|
190
|
+
raise ValueError("Empty time spec")
|
|
191
|
+
s = s.strip()
|
|
192
|
+
lower = s.lower()
|
|
193
|
+
# "now" in the display timezone (== datetime.now() unless --tz is set),
|
|
194
|
+
# so that named/relative specs stay consistent with displayed times.
|
|
195
|
+
from . import parser as _parser
|
|
196
|
+
now = _parser.now_display()
|
|
197
|
+
if lower == "now":
|
|
198
|
+
return now
|
|
199
|
+
if lower == "today":
|
|
200
|
+
return now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
201
|
+
if lower == "yesterday":
|
|
202
|
+
return (now - timedelta(days=1)).replace(
|
|
203
|
+
hour=0, minute=0, second=0, microsecond=0
|
|
204
|
+
)
|
|
205
|
+
m = _RELATIVE_RE.match(s)
|
|
206
|
+
if m:
|
|
207
|
+
n = int(m.group(1))
|
|
208
|
+
unit = m.group(2).lower()
|
|
209
|
+
if unit == "m":
|
|
210
|
+
return now - timedelta(minutes=n)
|
|
211
|
+
if unit == "h":
|
|
212
|
+
return now - timedelta(hours=n)
|
|
213
|
+
if unit == "d":
|
|
214
|
+
return now - timedelta(days=n)
|
|
215
|
+
if unit == "w":
|
|
216
|
+
return now - timedelta(weeks=n)
|
|
217
|
+
if unit == "mo":
|
|
218
|
+
return now - timedelta(days=30 * n)
|
|
219
|
+
# ISO formats
|
|
220
|
+
for fmt in (
|
|
221
|
+
"%Y-%m-%dT%H:%M:%S",
|
|
222
|
+
"%Y-%m-%dT%H:%M",
|
|
223
|
+
"%Y-%m-%d %H:%M:%S",
|
|
224
|
+
"%Y-%m-%d %H:%M",
|
|
225
|
+
"%Y-%m-%d",
|
|
226
|
+
):
|
|
227
|
+
try:
|
|
228
|
+
return datetime.strptime(s, fmt)
|
|
229
|
+
except ValueError:
|
|
230
|
+
continue
|
|
231
|
+
try:
|
|
232
|
+
return datetime.fromisoformat(s)
|
|
233
|
+
except ValueError as e:
|
|
234
|
+
raise ValueError(f"Unrecognized time spec: {s!r}") from e
|
|
@@ -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()}
|