elliot-stack 1.0.36 → 1.0.37
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/LICENSE +21 -21
- package/bin/install.cjs +981 -981
- package/hooks/repo-search-nudge.js +32 -32
- package/package.json +1 -1
- package/skills/estack-active-learning-tutor/SKILL.md +339 -339
- package/skills/estack-better-title/SKILL.md +64 -64
- package/skills/estack-better-title/scripts/rename.sh +55 -55
- package/skills/estack-chris-voss/SKILL.md +80 -80
- package/skills/estack-chris-voss/references/elliot-notes.md +120 -120
- package/skills/estack-chris-voss/references/voss-principles.md +210 -210
- package/skills/estack-customer-discovery/SKILL.md +60 -60
- package/skills/estack-flight-planner/SKILL.md +332 -332
- package/skills/estack-flight-planner/references/config_schema.md +156 -156
- package/skills/estack-flight-planner/references/flight_history_schema.md +97 -97
- package/skills/estack-flight-planner/references/shuttle_schedules.md +98 -98
- package/skills/estack-flight-planner/scripts/check_setup.sh +89 -89
- package/skills/estack-flight-planner/scripts/fetch_flights.py +99 -99
- package/skills/estack-flight-planner/scripts/filter_flights.py +265 -265
- package/skills/estack-flight-planner/scripts/pair_shuttles.py +173 -173
- package/skills/estack-github-issue-tracker/SKILL.md +322 -322
- package/skills/estack-github-issue-tracker/bin/tracker-tools.cjs +1358 -1358
- package/skills/estack-github-issue-tracker/references/gh-cli-patterns.md +124 -124
- package/skills/estack-github-issue-tracker/references/result-file-schema.md +156 -156
- package/skills/estack-github-issue-tracker/references/tracker-schema.md +96 -96
- package/skills/estack-github-issue-tracker/tracker-template.md +58 -58
- package/skills/estack-leadership-coach/SKILL.md +1 -1
- package/skills/estack-leadership-coach/adding-references.md +1 -1
- package/skills/estack-migrate-claude-session-history/SKILL.md +15 -2
- package/skills/estack-pdf-to-md/SKILL.md +1 -2
- package/skills/estack-prompt-builder-coach/SKILL.md +81 -81
- package/skills/estack-prompt-builder-coach/definition-of-done-generator.md +42 -42
- package/skills/estack-prompt-builder-coach/prompt-builder.md +37 -37
- package/skills/estack-prompt-builder-coach/task-shaper.md +36 -36
- package/skills/estack-prompt-builder-coach/vague-ask-auditor.md +37 -37
- package/skills/estack-read-claude-session-history/SKILL.md +224 -204
- package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -126
- package/skills/estack-read-claude-session-history/references/modes.md +423 -423
- package/skills/estack-read-claude-session-history/references/recipes.md +271 -271
- package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -1
- package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -460
- package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -234
- package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -179
- package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -88
- package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -144
- package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1776 -1776
- package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -40
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -20
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +9 -9
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +7 -7
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -8
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -1
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -6
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -10
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -56
- package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +239 -239
- package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -201
- package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -199
- package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -195
- package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -133
- package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -78
- package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -43
- package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +179 -179
- package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -212
- package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +80 -80
|
@@ -1,460 +1,460 @@
|
|
|
1
|
-
"""JSONL parsing primitives, message classification, and session summaries."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import re
|
|
7
|
-
import sys
|
|
8
|
-
from datetime import datetime, timedelta, timezone
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from typing import Iterator, Literal
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
NOISE_TYPES: set[str] = {
|
|
14
|
-
"permission-mode", "ai-title", "custom-title", "attachment",
|
|
15
|
-
"last-prompt", "queue-operation", "file-history-snapshot",
|
|
16
|
-
"system", "agent-name", "pr-link",
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
COMPACT_MARKER = "This session is being continued from a previous conversation"
|
|
20
|
-
|
|
21
|
-
# 5 MB — beyond this, dump mode auto-degrades unless --force-dump.
|
|
22
|
-
LARGE_FILE_THRESHOLD = 5 * 1024 * 1024
|
|
23
|
-
|
|
24
|
-
EntryType = Literal["user", "assistant", "title", "noise", "compact"]
|
|
25
|
-
|
|
26
|
-
_PARSE_CACHE: dict[Path, tuple[float, list[dict]]] = {}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def iter_lines(path: Path) -> Iterator[dict]:
|
|
30
|
-
"""Yield parsed JSON objects from a .jsonl file, streaming.
|
|
31
|
-
|
|
32
|
-
A truncated (un-newline-terminated) trailing line is dropped silently with
|
|
33
|
-
a stderr note. Malformed JSON lines are also dropped silently.
|
|
34
|
-
"""
|
|
35
|
-
truncated = False
|
|
36
|
-
try:
|
|
37
|
-
with open(path, encoding="utf-8") as f:
|
|
38
|
-
for line in f:
|
|
39
|
-
stripped = line.strip()
|
|
40
|
-
if not stripped:
|
|
41
|
-
continue
|
|
42
|
-
if not line.endswith("\n"):
|
|
43
|
-
# Last line, no terminator — could be partial. Try to parse,
|
|
44
|
-
# but if it fails, treat as truncation.
|
|
45
|
-
try:
|
|
46
|
-
yield json.loads(stripped)
|
|
47
|
-
except json.JSONDecodeError:
|
|
48
|
-
truncated = True
|
|
49
|
-
continue
|
|
50
|
-
try:
|
|
51
|
-
yield json.loads(stripped)
|
|
52
|
-
except json.JSONDecodeError:
|
|
53
|
-
continue
|
|
54
|
-
finally:
|
|
55
|
-
if truncated:
|
|
56
|
-
print(
|
|
57
|
-
f"[note: dropped truncated trailing line in {path.name}]",
|
|
58
|
-
file=sys.stderr,
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def parse_lines(path: Path) -> list[dict]:
|
|
63
|
-
"""Read all JSONL records from a file, with mtime-based caching."""
|
|
64
|
-
try:
|
|
65
|
-
mtime = path.stat().st_mtime
|
|
66
|
-
except OSError:
|
|
67
|
-
return list(iter_lines(path))
|
|
68
|
-
cached = _PARSE_CACHE.get(path)
|
|
69
|
-
if cached is not None and cached[0] == mtime:
|
|
70
|
-
return cached[1]
|
|
71
|
-
records = list(iter_lines(path))
|
|
72
|
-
_PARSE_CACHE[path] = (mtime, records)
|
|
73
|
-
return records
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def extract_text_blocks(
|
|
77
|
-
content,
|
|
78
|
-
include_thinking: bool = False,
|
|
79
|
-
include_tool_use: bool = False,
|
|
80
|
-
) -> list[str]:
|
|
81
|
-
"""Pull human-readable text from a content field (string or block list)."""
|
|
82
|
-
if isinstance(content, str):
|
|
83
|
-
return [content] if content.strip() else []
|
|
84
|
-
if not isinstance(content, list):
|
|
85
|
-
return []
|
|
86
|
-
texts: list[str] = []
|
|
87
|
-
for block in content:
|
|
88
|
-
if not isinstance(block, dict):
|
|
89
|
-
continue
|
|
90
|
-
t = block.get("type")
|
|
91
|
-
if t == "text" and block.get("text", "").strip():
|
|
92
|
-
texts.append(block["text"])
|
|
93
|
-
elif t == "advisor_tool_result":
|
|
94
|
-
inner = block.get("content", {})
|
|
95
|
-
if isinstance(inner, dict) and inner.get("text"):
|
|
96
|
-
texts.append(f"[ADVISOR]\n{inner['text']}")
|
|
97
|
-
elif t == "thinking" and include_thinking:
|
|
98
|
-
think = block.get("thinking", "") or block.get("text", "")
|
|
99
|
-
if think.strip():
|
|
100
|
-
texts.append(f"[THINKING]\n{think}")
|
|
101
|
-
elif t == "tool_use" and include_tool_use:
|
|
102
|
-
name = block.get("name", "?")
|
|
103
|
-
tool_input = block.get("input", {})
|
|
104
|
-
try:
|
|
105
|
-
preview = json.dumps(tool_input)[:200]
|
|
106
|
-
except (TypeError, ValueError):
|
|
107
|
-
preview = str(tool_input)[:200]
|
|
108
|
-
texts.append(f"[TOOL_USE {name}] {preview}")
|
|
109
|
-
return texts
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def is_compact_marker(text: str) -> bool:
|
|
113
|
-
return bool(text) and COMPACT_MARKER in text
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def classify_entry(obj: dict) -> EntryType:
|
|
117
|
-
"""Single source of truth for entry-type classification."""
|
|
118
|
-
t = obj.get("type", "")
|
|
119
|
-
if t == "ai-title" or t == "custom-title":
|
|
120
|
-
return "title"
|
|
121
|
-
if t in NOISE_TYPES:
|
|
122
|
-
return "noise"
|
|
123
|
-
msg = obj.get("message", {})
|
|
124
|
-
if not msg:
|
|
125
|
-
return "noise"
|
|
126
|
-
role = msg.get("role")
|
|
127
|
-
if role == "user":
|
|
128
|
-
content = msg.get("content", "")
|
|
129
|
-
text = (
|
|
130
|
-
content if isinstance(content, str)
|
|
131
|
-
else " ".join(
|
|
132
|
-
b.get("text", "") for b in content
|
|
133
|
-
if isinstance(b, dict) and b.get("type") == "text"
|
|
134
|
-
)
|
|
135
|
-
)
|
|
136
|
-
if is_compact_marker(text):
|
|
137
|
-
return "compact"
|
|
138
|
-
return "user"
|
|
139
|
-
if role == "assistant":
|
|
140
|
-
return "assistant"
|
|
141
|
-
return "noise"
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def get_messages(lines: list[dict]) -> list[dict]:
|
|
145
|
-
"""Filter to signal messages, returning {role, texts, line_index, is_compact, timestamp}."""
|
|
146
|
-
messages: list[dict] = []
|
|
147
|
-
for i, obj in enumerate(lines):
|
|
148
|
-
cls = classify_entry(obj)
|
|
149
|
-
if cls in ("noise", "title"):
|
|
150
|
-
continue
|
|
151
|
-
msg = obj.get("message", {})
|
|
152
|
-
if not msg:
|
|
153
|
-
continue
|
|
154
|
-
content = msg.get("content", "")
|
|
155
|
-
texts = extract_text_blocks(content)
|
|
156
|
-
timestamp = obj.get("timestamp")
|
|
157
|
-
messages.append({
|
|
158
|
-
"role": "user" if cls in ("user", "compact") else "assistant",
|
|
159
|
-
"texts": texts,
|
|
160
|
-
"line_index": i,
|
|
161
|
-
"is_compact": cls == "compact",
|
|
162
|
-
"timestamp": timestamp,
|
|
163
|
-
})
|
|
164
|
-
return messages
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def filter_by_role(
|
|
168
|
-
messages: list[dict], role: Literal["user", "assistant", "both"]
|
|
169
|
-
) -> list[dict]:
|
|
170
|
-
if role == "both":
|
|
171
|
-
return messages
|
|
172
|
-
return [m for m in messages if m["role"] == role]
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
# Display timezone. None → system local time. Set via set_timezone() (--tz flag).
|
|
176
|
-
# JSONL timestamps are UTC; every parsed timestamp is converted to this zone so
|
|
177
|
-
# all displayed times match the user's wall clock and compare cleanly against
|
|
178
|
-
# parse_timespec() values (which are local).
|
|
179
|
-
_TARGET_TZ: timezone | None = None
|
|
180
|
-
|
|
181
|
-
_TZ_OFFSET_RE = re.compile(r"^([+-])(\d{1,2})(?::?(\d{2}))?$")
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def set_timezone(spec: str | None) -> None:
|
|
185
|
-
"""Set the display timezone from a --tz spec.
|
|
186
|
-
|
|
187
|
-
Accepts:
|
|
188
|
-
- None / "local" → system local time (default)
|
|
189
|
-
- "UTC" → UTC
|
|
190
|
-
- fixed offsets → "+5", "-4", "+05:30", "UTC-4"
|
|
191
|
-
- IANA names → "America/New_York" (via zoneinfo)
|
|
192
|
-
"""
|
|
193
|
-
global _TARGET_TZ
|
|
194
|
-
if not spec or spec.strip().lower() == "local":
|
|
195
|
-
_TARGET_TZ = None
|
|
196
|
-
return
|
|
197
|
-
s = spec.strip()
|
|
198
|
-
if s.upper().startswith("UTC"):
|
|
199
|
-
rest = s[3:].strip()
|
|
200
|
-
if not rest:
|
|
201
|
-
_TARGET_TZ = timezone.utc
|
|
202
|
-
return
|
|
203
|
-
s = rest # "UTC-4" → "-4"
|
|
204
|
-
m = _TZ_OFFSET_RE.match(s)
|
|
205
|
-
if m:
|
|
206
|
-
sign = 1 if m.group(1) == "+" else -1
|
|
207
|
-
hours = int(m.group(2))
|
|
208
|
-
mins = int(m.group(3) or 0)
|
|
209
|
-
_TARGET_TZ = timezone(sign * timedelta(hours=hours, minutes=mins))
|
|
210
|
-
return
|
|
211
|
-
try:
|
|
212
|
-
from zoneinfo import ZoneInfo
|
|
213
|
-
_TARGET_TZ = ZoneInfo(spec.strip())
|
|
214
|
-
except Exception as e:
|
|
215
|
-
raise ValueError(
|
|
216
|
-
f"Unrecognized timezone: {spec!r}. "
|
|
217
|
-
"Use an IANA name (America/New_York), 'UTC', or an offset (+5, -4, +05:30)."
|
|
218
|
-
) from e
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def to_display(dt: datetime) -> datetime:
|
|
222
|
-
"""Convert an aware datetime to the display timezone, returned naive."""
|
|
223
|
-
return dt.astimezone(_TARGET_TZ).replace(tzinfo=None)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def epoch_to_display(epoch: float) -> datetime:
|
|
227
|
-
"""Convert an epoch (e.g. st_mtime) to the display timezone, returned naive."""
|
|
228
|
-
return to_display(datetime.fromtimestamp(epoch, tz=timezone.utc))
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def display_to_epoch(dt: datetime) -> float:
|
|
232
|
-
"""Interpret a naive display-timezone datetime as an epoch.
|
|
233
|
-
|
|
234
|
-
Inverse of epoch_to_display. Needed because naive_dt.timestamp() assumes
|
|
235
|
-
*local* time, which is wrong under a --tz override.
|
|
236
|
-
"""
|
|
237
|
-
if dt.tzinfo is None and _TARGET_TZ is not None:
|
|
238
|
-
dt = dt.replace(tzinfo=_TARGET_TZ)
|
|
239
|
-
return dt.timestamp()
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def now_display() -> datetime:
|
|
243
|
-
"""Current time as a naive datetime in the display timezone."""
|
|
244
|
-
import time as _time
|
|
245
|
-
return epoch_to_display(_time.time())
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
def _parse_timestamp(ts) -> datetime | None:
|
|
249
|
-
"""Parse a JSONL timestamp → naive datetime in the display timezone."""
|
|
250
|
-
if not ts:
|
|
251
|
-
return None
|
|
252
|
-
if isinstance(ts, (int, float)):
|
|
253
|
-
try:
|
|
254
|
-
return epoch_to_display(float(ts))
|
|
255
|
-
except (ValueError, OSError, OverflowError):
|
|
256
|
-
return None
|
|
257
|
-
if isinstance(ts, str):
|
|
258
|
-
# ISO 8601 with possible Z
|
|
259
|
-
s = ts.replace("Z", "+00:00")
|
|
260
|
-
try:
|
|
261
|
-
dt = datetime.fromisoformat(s)
|
|
262
|
-
except ValueError:
|
|
263
|
-
return None
|
|
264
|
-
if dt.tzinfo is not None:
|
|
265
|
-
return to_display(dt)
|
|
266
|
-
return dt # naive — assume already local
|
|
267
|
-
return None
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def filter_by_time(
|
|
271
|
-
messages: list[dict],
|
|
272
|
-
since: datetime | None,
|
|
273
|
-
until: datetime | None,
|
|
274
|
-
) -> list[dict]:
|
|
275
|
-
if since is None and until is None:
|
|
276
|
-
return messages
|
|
277
|
-
out = []
|
|
278
|
-
for m in messages:
|
|
279
|
-
ts = _parse_timestamp(m.get("timestamp"))
|
|
280
|
-
if ts is None:
|
|
281
|
-
continue
|
|
282
|
-
# Strip tzinfo for naive comparison
|
|
283
|
-
if ts.tzinfo is not None:
|
|
284
|
-
ts = ts.replace(tzinfo=None)
|
|
285
|
-
if since is not None and ts < since:
|
|
286
|
-
continue
|
|
287
|
-
if until is not None and ts > until:
|
|
288
|
-
continue
|
|
289
|
-
out.append(m)
|
|
290
|
-
return out
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def _truncate(s: str, n: int) -> str:
|
|
294
|
-
if not s:
|
|
295
|
-
return ""
|
|
296
|
-
s = s.replace("\n", " ").strip()
|
|
297
|
-
return s if len(s) <= n else s[: n - 1] + "…"
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
def infer_status(
|
|
301
|
-
lines: list[dict],
|
|
302
|
-
mtime: float,
|
|
303
|
-
current_session_id: str | None,
|
|
304
|
-
session_uuid: str | None,
|
|
305
|
-
) -> Literal["clean", "interrupted", "pending-user", "active"]:
|
|
306
|
-
"""Heuristic session status from the shape of the final entry."""
|
|
307
|
-
now = datetime.now().timestamp()
|
|
308
|
-
if (
|
|
309
|
-
current_session_id
|
|
310
|
-
and session_uuid
|
|
311
|
-
and current_session_id == session_uuid
|
|
312
|
-
and now - mtime < 300
|
|
313
|
-
):
|
|
314
|
-
return "active"
|
|
315
|
-
|
|
316
|
-
if not lines:
|
|
317
|
-
return "clean"
|
|
318
|
-
|
|
319
|
-
# Walk backwards through non-noise entries
|
|
320
|
-
last_assistant = None
|
|
321
|
-
has_dangling_tool_use = False
|
|
322
|
-
pending_tool_use_ids: set[str] = set()
|
|
323
|
-
tool_result_ids: set[str] = set()
|
|
324
|
-
for obj in lines:
|
|
325
|
-
msg = obj.get("message", {})
|
|
326
|
-
if not isinstance(msg, dict):
|
|
327
|
-
continue
|
|
328
|
-
content = msg.get("content")
|
|
329
|
-
if not isinstance(content, list):
|
|
330
|
-
continue
|
|
331
|
-
for block in content:
|
|
332
|
-
if not isinstance(block, dict):
|
|
333
|
-
continue
|
|
334
|
-
bt = block.get("type")
|
|
335
|
-
if bt == "tool_use":
|
|
336
|
-
tid = block.get("id")
|
|
337
|
-
if tid:
|
|
338
|
-
pending_tool_use_ids.add(tid)
|
|
339
|
-
elif bt == "tool_result":
|
|
340
|
-
tid = block.get("tool_use_id")
|
|
341
|
-
if tid:
|
|
342
|
-
tool_result_ids.add(tid)
|
|
343
|
-
|
|
344
|
-
dangling = pending_tool_use_ids - tool_result_ids
|
|
345
|
-
if dangling:
|
|
346
|
-
has_dangling_tool_use = True
|
|
347
|
-
|
|
348
|
-
# Find the last assistant message
|
|
349
|
-
for obj in reversed(lines):
|
|
350
|
-
msg = obj.get("message", {})
|
|
351
|
-
if msg.get("role") == "assistant":
|
|
352
|
-
last_assistant = msg
|
|
353
|
-
break
|
|
354
|
-
|
|
355
|
-
if has_dangling_tool_use:
|
|
356
|
-
return "interrupted"
|
|
357
|
-
|
|
358
|
-
if last_assistant is not None:
|
|
359
|
-
content = last_assistant.get("content", "")
|
|
360
|
-
text = (
|
|
361
|
-
content if isinstance(content, str)
|
|
362
|
-
else " ".join(
|
|
363
|
-
b.get("text", "") for b in content
|
|
364
|
-
if isinstance(b, dict) and b.get("type") == "text"
|
|
365
|
-
)
|
|
366
|
-
)
|
|
367
|
-
if text.strip().endswith("?"):
|
|
368
|
-
return "pending-user"
|
|
369
|
-
|
|
370
|
-
return "clean"
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
def session_summary(path: Path, current_session_id: str | None = None) -> dict:
|
|
374
|
-
"""Compact per-session metrics for brief / list / journal / count modes."""
|
|
375
|
-
from .tools import extract_tool_calls, files_touched # local import to avoid cycle
|
|
376
|
-
from .paths import decode_project_name, list_subagents
|
|
377
|
-
from .subagents import load_meta
|
|
378
|
-
|
|
379
|
-
try:
|
|
380
|
-
stat = path.stat()
|
|
381
|
-
except OSError:
|
|
382
|
-
return {
|
|
383
|
-
"path": path,
|
|
384
|
-
"uuid": path.stem,
|
|
385
|
-
"mtime": 0,
|
|
386
|
-
"size": 0,
|
|
387
|
-
"exists": False,
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
lines = parse_lines(path)
|
|
391
|
-
messages = get_messages(lines)
|
|
392
|
-
user_msgs = [m for m in messages if m["role"] == "user" and not m["is_compact"]]
|
|
393
|
-
assistant_msgs = [m for m in messages if m["role"] == "assistant"]
|
|
394
|
-
|
|
395
|
-
# Title
|
|
396
|
-
title = ""
|
|
397
|
-
for obj in lines:
|
|
398
|
-
if obj.get("type") in ("ai-title", "custom-title"):
|
|
399
|
-
title = obj.get("aiTitle") or obj.get("customTitle") or ""
|
|
400
|
-
if title:
|
|
401
|
-
break
|
|
402
|
-
|
|
403
|
-
first_prompt = ""
|
|
404
|
-
if user_msgs and user_msgs[0]["texts"]:
|
|
405
|
-
first_prompt = _truncate(user_msgs[0]["texts"][0], 200)
|
|
406
|
-
|
|
407
|
-
last_assistant = ""
|
|
408
|
-
if assistant_msgs and assistant_msgs[-1]["texts"]:
|
|
409
|
-
last_assistant = _truncate(assistant_msgs[-1]["texts"][-1], 200)
|
|
410
|
-
|
|
411
|
-
last_activity = epoch_to_display(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
412
|
-
|
|
413
|
-
tool_calls = extract_tool_calls(lines)
|
|
414
|
-
tool_counts: dict[str, int] = {}
|
|
415
|
-
for tc in tool_calls:
|
|
416
|
-
tool_counts[tc["name"]] = tool_counts.get(tc["name"], 0) + 1
|
|
417
|
-
|
|
418
|
-
files = files_touched(lines)
|
|
419
|
-
edit_count = len(files)
|
|
420
|
-
|
|
421
|
-
subagents = list_subagents(path)
|
|
422
|
-
subagent_types: dict[str, int] = {}
|
|
423
|
-
for sa in subagents:
|
|
424
|
-
meta = load_meta(sa)
|
|
425
|
-
atype = meta.get("agentType", "unknown")
|
|
426
|
-
subagent_types[atype] = subagent_types.get(atype, 0) + 1
|
|
427
|
-
|
|
428
|
-
has_compact = any(m["is_compact"] for m in messages)
|
|
429
|
-
parent_dir_name = path.parent.name
|
|
430
|
-
decoded = decode_project_name(parent_dir_name)
|
|
431
|
-
|
|
432
|
-
status = infer_status(
|
|
433
|
-
lines, stat.st_mtime, current_session_id, path.stem
|
|
434
|
-
)
|
|
435
|
-
|
|
436
|
-
return {
|
|
437
|
-
"path": path,
|
|
438
|
-
"uuid": path.stem,
|
|
439
|
-
"mtime": stat.st_mtime,
|
|
440
|
-
"size": stat.st_size,
|
|
441
|
-
"exists": True,
|
|
442
|
-
"title": title,
|
|
443
|
-
"first_prompt": first_prompt,
|
|
444
|
-
"last_assistant": last_assistant,
|
|
445
|
-
"last_activity": last_activity,
|
|
446
|
-
"msg_count": len(messages),
|
|
447
|
-
"edit_count": edit_count,
|
|
448
|
-
"tool_counts": tool_counts,
|
|
449
|
-
"files_touched": list(files.keys()),
|
|
450
|
-
"subagent_count": len(subagents),
|
|
451
|
-
"subagent_types": subagent_types,
|
|
452
|
-
"has_compact": has_compact,
|
|
453
|
-
"has_subagents": bool(subagents),
|
|
454
|
-
"cwd": parent_dir_name,
|
|
455
|
-
"decoded_project": decoded,
|
|
456
|
-
"status": status,
|
|
457
|
-
"is_current": bool(
|
|
458
|
-
current_session_id and current_session_id == path.stem
|
|
459
|
-
),
|
|
460
|
-
}
|
|
1
|
+
"""JSONL parsing primitives, message classification, and session summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterator, Literal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
NOISE_TYPES: set[str] = {
|
|
14
|
+
"permission-mode", "ai-title", "custom-title", "attachment",
|
|
15
|
+
"last-prompt", "queue-operation", "file-history-snapshot",
|
|
16
|
+
"system", "agent-name", "pr-link",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
COMPACT_MARKER = "This session is being continued from a previous conversation"
|
|
20
|
+
|
|
21
|
+
# 5 MB — beyond this, dump mode auto-degrades unless --force-dump.
|
|
22
|
+
LARGE_FILE_THRESHOLD = 5 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
EntryType = Literal["user", "assistant", "title", "noise", "compact"]
|
|
25
|
+
|
|
26
|
+
_PARSE_CACHE: dict[Path, tuple[float, list[dict]]] = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def iter_lines(path: Path) -> Iterator[dict]:
|
|
30
|
+
"""Yield parsed JSON objects from a .jsonl file, streaming.
|
|
31
|
+
|
|
32
|
+
A truncated (un-newline-terminated) trailing line is dropped silently with
|
|
33
|
+
a stderr note. Malformed JSON lines are also dropped silently.
|
|
34
|
+
"""
|
|
35
|
+
truncated = False
|
|
36
|
+
try:
|
|
37
|
+
with open(path, encoding="utf-8") as f:
|
|
38
|
+
for line in f:
|
|
39
|
+
stripped = line.strip()
|
|
40
|
+
if not stripped:
|
|
41
|
+
continue
|
|
42
|
+
if not line.endswith("\n"):
|
|
43
|
+
# Last line, no terminator — could be partial. Try to parse,
|
|
44
|
+
# but if it fails, treat as truncation.
|
|
45
|
+
try:
|
|
46
|
+
yield json.loads(stripped)
|
|
47
|
+
except json.JSONDecodeError:
|
|
48
|
+
truncated = True
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
yield json.loads(stripped)
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
continue
|
|
54
|
+
finally:
|
|
55
|
+
if truncated:
|
|
56
|
+
print(
|
|
57
|
+
f"[note: dropped truncated trailing line in {path.name}]",
|
|
58
|
+
file=sys.stderr,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_lines(path: Path) -> list[dict]:
|
|
63
|
+
"""Read all JSONL records from a file, with mtime-based caching."""
|
|
64
|
+
try:
|
|
65
|
+
mtime = path.stat().st_mtime
|
|
66
|
+
except OSError:
|
|
67
|
+
return list(iter_lines(path))
|
|
68
|
+
cached = _PARSE_CACHE.get(path)
|
|
69
|
+
if cached is not None and cached[0] == mtime:
|
|
70
|
+
return cached[1]
|
|
71
|
+
records = list(iter_lines(path))
|
|
72
|
+
_PARSE_CACHE[path] = (mtime, records)
|
|
73
|
+
return records
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def extract_text_blocks(
|
|
77
|
+
content,
|
|
78
|
+
include_thinking: bool = False,
|
|
79
|
+
include_tool_use: bool = False,
|
|
80
|
+
) -> list[str]:
|
|
81
|
+
"""Pull human-readable text from a content field (string or block list)."""
|
|
82
|
+
if isinstance(content, str):
|
|
83
|
+
return [content] if content.strip() else []
|
|
84
|
+
if not isinstance(content, list):
|
|
85
|
+
return []
|
|
86
|
+
texts: list[str] = []
|
|
87
|
+
for block in content:
|
|
88
|
+
if not isinstance(block, dict):
|
|
89
|
+
continue
|
|
90
|
+
t = block.get("type")
|
|
91
|
+
if t == "text" and block.get("text", "").strip():
|
|
92
|
+
texts.append(block["text"])
|
|
93
|
+
elif t == "advisor_tool_result":
|
|
94
|
+
inner = block.get("content", {})
|
|
95
|
+
if isinstance(inner, dict) and inner.get("text"):
|
|
96
|
+
texts.append(f"[ADVISOR]\n{inner['text']}")
|
|
97
|
+
elif t == "thinking" and include_thinking:
|
|
98
|
+
think = block.get("thinking", "") or block.get("text", "")
|
|
99
|
+
if think.strip():
|
|
100
|
+
texts.append(f"[THINKING]\n{think}")
|
|
101
|
+
elif t == "tool_use" and include_tool_use:
|
|
102
|
+
name = block.get("name", "?")
|
|
103
|
+
tool_input = block.get("input", {})
|
|
104
|
+
try:
|
|
105
|
+
preview = json.dumps(tool_input)[:200]
|
|
106
|
+
except (TypeError, ValueError):
|
|
107
|
+
preview = str(tool_input)[:200]
|
|
108
|
+
texts.append(f"[TOOL_USE {name}] {preview}")
|
|
109
|
+
return texts
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_compact_marker(text: str) -> bool:
|
|
113
|
+
return bool(text) and COMPACT_MARKER in text
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def classify_entry(obj: dict) -> EntryType:
|
|
117
|
+
"""Single source of truth for entry-type classification."""
|
|
118
|
+
t = obj.get("type", "")
|
|
119
|
+
if t == "ai-title" or t == "custom-title":
|
|
120
|
+
return "title"
|
|
121
|
+
if t in NOISE_TYPES:
|
|
122
|
+
return "noise"
|
|
123
|
+
msg = obj.get("message", {})
|
|
124
|
+
if not msg:
|
|
125
|
+
return "noise"
|
|
126
|
+
role = msg.get("role")
|
|
127
|
+
if role == "user":
|
|
128
|
+
content = msg.get("content", "")
|
|
129
|
+
text = (
|
|
130
|
+
content if isinstance(content, str)
|
|
131
|
+
else " ".join(
|
|
132
|
+
b.get("text", "") for b in content
|
|
133
|
+
if isinstance(b, dict) and b.get("type") == "text"
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
if is_compact_marker(text):
|
|
137
|
+
return "compact"
|
|
138
|
+
return "user"
|
|
139
|
+
if role == "assistant":
|
|
140
|
+
return "assistant"
|
|
141
|
+
return "noise"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_messages(lines: list[dict]) -> list[dict]:
|
|
145
|
+
"""Filter to signal messages, returning {role, texts, line_index, is_compact, timestamp}."""
|
|
146
|
+
messages: list[dict] = []
|
|
147
|
+
for i, obj in enumerate(lines):
|
|
148
|
+
cls = classify_entry(obj)
|
|
149
|
+
if cls in ("noise", "title"):
|
|
150
|
+
continue
|
|
151
|
+
msg = obj.get("message", {})
|
|
152
|
+
if not msg:
|
|
153
|
+
continue
|
|
154
|
+
content = msg.get("content", "")
|
|
155
|
+
texts = extract_text_blocks(content)
|
|
156
|
+
timestamp = obj.get("timestamp")
|
|
157
|
+
messages.append({
|
|
158
|
+
"role": "user" if cls in ("user", "compact") else "assistant",
|
|
159
|
+
"texts": texts,
|
|
160
|
+
"line_index": i,
|
|
161
|
+
"is_compact": cls == "compact",
|
|
162
|
+
"timestamp": timestamp,
|
|
163
|
+
})
|
|
164
|
+
return messages
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def filter_by_role(
|
|
168
|
+
messages: list[dict], role: Literal["user", "assistant", "both"]
|
|
169
|
+
) -> list[dict]:
|
|
170
|
+
if role == "both":
|
|
171
|
+
return messages
|
|
172
|
+
return [m for m in messages if m["role"] == role]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Display timezone. None → system local time. Set via set_timezone() (--tz flag).
|
|
176
|
+
# JSONL timestamps are UTC; every parsed timestamp is converted to this zone so
|
|
177
|
+
# all displayed times match the user's wall clock and compare cleanly against
|
|
178
|
+
# parse_timespec() values (which are local).
|
|
179
|
+
_TARGET_TZ: timezone | None = None
|
|
180
|
+
|
|
181
|
+
_TZ_OFFSET_RE = re.compile(r"^([+-])(\d{1,2})(?::?(\d{2}))?$")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def set_timezone(spec: str | None) -> None:
|
|
185
|
+
"""Set the display timezone from a --tz spec.
|
|
186
|
+
|
|
187
|
+
Accepts:
|
|
188
|
+
- None / "local" → system local time (default)
|
|
189
|
+
- "UTC" → UTC
|
|
190
|
+
- fixed offsets → "+5", "-4", "+05:30", "UTC-4"
|
|
191
|
+
- IANA names → "America/New_York" (via zoneinfo)
|
|
192
|
+
"""
|
|
193
|
+
global _TARGET_TZ
|
|
194
|
+
if not spec or spec.strip().lower() == "local":
|
|
195
|
+
_TARGET_TZ = None
|
|
196
|
+
return
|
|
197
|
+
s = spec.strip()
|
|
198
|
+
if s.upper().startswith("UTC"):
|
|
199
|
+
rest = s[3:].strip()
|
|
200
|
+
if not rest:
|
|
201
|
+
_TARGET_TZ = timezone.utc
|
|
202
|
+
return
|
|
203
|
+
s = rest # "UTC-4" → "-4"
|
|
204
|
+
m = _TZ_OFFSET_RE.match(s)
|
|
205
|
+
if m:
|
|
206
|
+
sign = 1 if m.group(1) == "+" else -1
|
|
207
|
+
hours = int(m.group(2))
|
|
208
|
+
mins = int(m.group(3) or 0)
|
|
209
|
+
_TARGET_TZ = timezone(sign * timedelta(hours=hours, minutes=mins))
|
|
210
|
+
return
|
|
211
|
+
try:
|
|
212
|
+
from zoneinfo import ZoneInfo
|
|
213
|
+
_TARGET_TZ = ZoneInfo(spec.strip())
|
|
214
|
+
except Exception as e:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
f"Unrecognized timezone: {spec!r}. "
|
|
217
|
+
"Use an IANA name (America/New_York), 'UTC', or an offset (+5, -4, +05:30)."
|
|
218
|
+
) from e
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def to_display(dt: datetime) -> datetime:
|
|
222
|
+
"""Convert an aware datetime to the display timezone, returned naive."""
|
|
223
|
+
return dt.astimezone(_TARGET_TZ).replace(tzinfo=None)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def epoch_to_display(epoch: float) -> datetime:
|
|
227
|
+
"""Convert an epoch (e.g. st_mtime) to the display timezone, returned naive."""
|
|
228
|
+
return to_display(datetime.fromtimestamp(epoch, tz=timezone.utc))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def display_to_epoch(dt: datetime) -> float:
|
|
232
|
+
"""Interpret a naive display-timezone datetime as an epoch.
|
|
233
|
+
|
|
234
|
+
Inverse of epoch_to_display. Needed because naive_dt.timestamp() assumes
|
|
235
|
+
*local* time, which is wrong under a --tz override.
|
|
236
|
+
"""
|
|
237
|
+
if dt.tzinfo is None and _TARGET_TZ is not None:
|
|
238
|
+
dt = dt.replace(tzinfo=_TARGET_TZ)
|
|
239
|
+
return dt.timestamp()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def now_display() -> datetime:
|
|
243
|
+
"""Current time as a naive datetime in the display timezone."""
|
|
244
|
+
import time as _time
|
|
245
|
+
return epoch_to_display(_time.time())
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _parse_timestamp(ts) -> datetime | None:
|
|
249
|
+
"""Parse a JSONL timestamp → naive datetime in the display timezone."""
|
|
250
|
+
if not ts:
|
|
251
|
+
return None
|
|
252
|
+
if isinstance(ts, (int, float)):
|
|
253
|
+
try:
|
|
254
|
+
return epoch_to_display(float(ts))
|
|
255
|
+
except (ValueError, OSError, OverflowError):
|
|
256
|
+
return None
|
|
257
|
+
if isinstance(ts, str):
|
|
258
|
+
# ISO 8601 with possible Z
|
|
259
|
+
s = ts.replace("Z", "+00:00")
|
|
260
|
+
try:
|
|
261
|
+
dt = datetime.fromisoformat(s)
|
|
262
|
+
except ValueError:
|
|
263
|
+
return None
|
|
264
|
+
if dt.tzinfo is not None:
|
|
265
|
+
return to_display(dt)
|
|
266
|
+
return dt # naive — assume already local
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def filter_by_time(
|
|
271
|
+
messages: list[dict],
|
|
272
|
+
since: datetime | None,
|
|
273
|
+
until: datetime | None,
|
|
274
|
+
) -> list[dict]:
|
|
275
|
+
if since is None and until is None:
|
|
276
|
+
return messages
|
|
277
|
+
out = []
|
|
278
|
+
for m in messages:
|
|
279
|
+
ts = _parse_timestamp(m.get("timestamp"))
|
|
280
|
+
if ts is None:
|
|
281
|
+
continue
|
|
282
|
+
# Strip tzinfo for naive comparison
|
|
283
|
+
if ts.tzinfo is not None:
|
|
284
|
+
ts = ts.replace(tzinfo=None)
|
|
285
|
+
if since is not None and ts < since:
|
|
286
|
+
continue
|
|
287
|
+
if until is not None and ts > until:
|
|
288
|
+
continue
|
|
289
|
+
out.append(m)
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _truncate(s: str, n: int) -> str:
|
|
294
|
+
if not s:
|
|
295
|
+
return ""
|
|
296
|
+
s = s.replace("\n", " ").strip()
|
|
297
|
+
return s if len(s) <= n else s[: n - 1] + "…"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def infer_status(
|
|
301
|
+
lines: list[dict],
|
|
302
|
+
mtime: float,
|
|
303
|
+
current_session_id: str | None,
|
|
304
|
+
session_uuid: str | None,
|
|
305
|
+
) -> Literal["clean", "interrupted", "pending-user", "active"]:
|
|
306
|
+
"""Heuristic session status from the shape of the final entry."""
|
|
307
|
+
now = datetime.now().timestamp()
|
|
308
|
+
if (
|
|
309
|
+
current_session_id
|
|
310
|
+
and session_uuid
|
|
311
|
+
and current_session_id == session_uuid
|
|
312
|
+
and now - mtime < 300
|
|
313
|
+
):
|
|
314
|
+
return "active"
|
|
315
|
+
|
|
316
|
+
if not lines:
|
|
317
|
+
return "clean"
|
|
318
|
+
|
|
319
|
+
# Walk backwards through non-noise entries
|
|
320
|
+
last_assistant = None
|
|
321
|
+
has_dangling_tool_use = False
|
|
322
|
+
pending_tool_use_ids: set[str] = set()
|
|
323
|
+
tool_result_ids: set[str] = set()
|
|
324
|
+
for obj in lines:
|
|
325
|
+
msg = obj.get("message", {})
|
|
326
|
+
if not isinstance(msg, dict):
|
|
327
|
+
continue
|
|
328
|
+
content = msg.get("content")
|
|
329
|
+
if not isinstance(content, list):
|
|
330
|
+
continue
|
|
331
|
+
for block in content:
|
|
332
|
+
if not isinstance(block, dict):
|
|
333
|
+
continue
|
|
334
|
+
bt = block.get("type")
|
|
335
|
+
if bt == "tool_use":
|
|
336
|
+
tid = block.get("id")
|
|
337
|
+
if tid:
|
|
338
|
+
pending_tool_use_ids.add(tid)
|
|
339
|
+
elif bt == "tool_result":
|
|
340
|
+
tid = block.get("tool_use_id")
|
|
341
|
+
if tid:
|
|
342
|
+
tool_result_ids.add(tid)
|
|
343
|
+
|
|
344
|
+
dangling = pending_tool_use_ids - tool_result_ids
|
|
345
|
+
if dangling:
|
|
346
|
+
has_dangling_tool_use = True
|
|
347
|
+
|
|
348
|
+
# Find the last assistant message
|
|
349
|
+
for obj in reversed(lines):
|
|
350
|
+
msg = obj.get("message", {})
|
|
351
|
+
if msg.get("role") == "assistant":
|
|
352
|
+
last_assistant = msg
|
|
353
|
+
break
|
|
354
|
+
|
|
355
|
+
if has_dangling_tool_use:
|
|
356
|
+
return "interrupted"
|
|
357
|
+
|
|
358
|
+
if last_assistant is not None:
|
|
359
|
+
content = last_assistant.get("content", "")
|
|
360
|
+
text = (
|
|
361
|
+
content if isinstance(content, str)
|
|
362
|
+
else " ".join(
|
|
363
|
+
b.get("text", "") for b in content
|
|
364
|
+
if isinstance(b, dict) and b.get("type") == "text"
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
if text.strip().endswith("?"):
|
|
368
|
+
return "pending-user"
|
|
369
|
+
|
|
370
|
+
return "clean"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def session_summary(path: Path, current_session_id: str | None = None) -> dict:
|
|
374
|
+
"""Compact per-session metrics for brief / list / journal / count modes."""
|
|
375
|
+
from .tools import extract_tool_calls, files_touched # local import to avoid cycle
|
|
376
|
+
from .paths import decode_project_name, list_subagents
|
|
377
|
+
from .subagents import load_meta
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
stat = path.stat()
|
|
381
|
+
except OSError:
|
|
382
|
+
return {
|
|
383
|
+
"path": path,
|
|
384
|
+
"uuid": path.stem,
|
|
385
|
+
"mtime": 0,
|
|
386
|
+
"size": 0,
|
|
387
|
+
"exists": False,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
lines = parse_lines(path)
|
|
391
|
+
messages = get_messages(lines)
|
|
392
|
+
user_msgs = [m for m in messages if m["role"] == "user" and not m["is_compact"]]
|
|
393
|
+
assistant_msgs = [m for m in messages if m["role"] == "assistant"]
|
|
394
|
+
|
|
395
|
+
# Title
|
|
396
|
+
title = ""
|
|
397
|
+
for obj in lines:
|
|
398
|
+
if obj.get("type") in ("ai-title", "custom-title"):
|
|
399
|
+
title = obj.get("aiTitle") or obj.get("customTitle") or ""
|
|
400
|
+
if title:
|
|
401
|
+
break
|
|
402
|
+
|
|
403
|
+
first_prompt = ""
|
|
404
|
+
if user_msgs and user_msgs[0]["texts"]:
|
|
405
|
+
first_prompt = _truncate(user_msgs[0]["texts"][0], 200)
|
|
406
|
+
|
|
407
|
+
last_assistant = ""
|
|
408
|
+
if assistant_msgs and assistant_msgs[-1]["texts"]:
|
|
409
|
+
last_assistant = _truncate(assistant_msgs[-1]["texts"][-1], 200)
|
|
410
|
+
|
|
411
|
+
last_activity = epoch_to_display(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
412
|
+
|
|
413
|
+
tool_calls = extract_tool_calls(lines)
|
|
414
|
+
tool_counts: dict[str, int] = {}
|
|
415
|
+
for tc in tool_calls:
|
|
416
|
+
tool_counts[tc["name"]] = tool_counts.get(tc["name"], 0) + 1
|
|
417
|
+
|
|
418
|
+
files = files_touched(lines)
|
|
419
|
+
edit_count = len(files)
|
|
420
|
+
|
|
421
|
+
subagents = list_subagents(path)
|
|
422
|
+
subagent_types: dict[str, int] = {}
|
|
423
|
+
for sa in subagents:
|
|
424
|
+
meta = load_meta(sa)
|
|
425
|
+
atype = meta.get("agentType", "unknown")
|
|
426
|
+
subagent_types[atype] = subagent_types.get(atype, 0) + 1
|
|
427
|
+
|
|
428
|
+
has_compact = any(m["is_compact"] for m in messages)
|
|
429
|
+
parent_dir_name = path.parent.name
|
|
430
|
+
decoded = decode_project_name(parent_dir_name)
|
|
431
|
+
|
|
432
|
+
status = infer_status(
|
|
433
|
+
lines, stat.st_mtime, current_session_id, path.stem
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
"path": path,
|
|
438
|
+
"uuid": path.stem,
|
|
439
|
+
"mtime": stat.st_mtime,
|
|
440
|
+
"size": stat.st_size,
|
|
441
|
+
"exists": True,
|
|
442
|
+
"title": title,
|
|
443
|
+
"first_prompt": first_prompt,
|
|
444
|
+
"last_assistant": last_assistant,
|
|
445
|
+
"last_activity": last_activity,
|
|
446
|
+
"msg_count": len(messages),
|
|
447
|
+
"edit_count": edit_count,
|
|
448
|
+
"tool_counts": tool_counts,
|
|
449
|
+
"files_touched": list(files.keys()),
|
|
450
|
+
"subagent_count": len(subagents),
|
|
451
|
+
"subagent_types": subagent_types,
|
|
452
|
+
"has_compact": has_compact,
|
|
453
|
+
"has_subagents": bool(subagents),
|
|
454
|
+
"cwd": parent_dir_name,
|
|
455
|
+
"decoded_project": decoded,
|
|
456
|
+
"status": status,
|
|
457
|
+
"is_current": bool(
|
|
458
|
+
current_session_id and current_session_id == path.stem
|
|
459
|
+
),
|
|
460
|
+
}
|