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.
Files changed (81) hide show
  1. package/LICENSE +21 -21
  2. package/bin/install.cjs +981 -981
  3. package/hooks/repo-search-nudge.js +32 -32
  4. package/package.json +1 -1
  5. package/skills/estack-active-learning-tutor/SKILL.md +339 -339
  6. package/skills/estack-better-title/SKILL.md +64 -64
  7. package/skills/estack-better-title/scripts/rename.sh +55 -55
  8. package/skills/estack-chris-voss/SKILL.md +80 -80
  9. package/skills/estack-chris-voss/references/elliot-notes.md +120 -120
  10. package/skills/estack-chris-voss/references/voss-principles.md +210 -210
  11. package/skills/estack-customer-discovery/SKILL.md +60 -60
  12. package/skills/estack-flight-planner/SKILL.md +332 -332
  13. package/skills/estack-flight-planner/references/config_schema.md +156 -156
  14. package/skills/estack-flight-planner/references/flight_history_schema.md +97 -97
  15. package/skills/estack-flight-planner/references/shuttle_schedules.md +98 -98
  16. package/skills/estack-flight-planner/scripts/check_setup.sh +89 -89
  17. package/skills/estack-flight-planner/scripts/fetch_flights.py +99 -99
  18. package/skills/estack-flight-planner/scripts/filter_flights.py +265 -265
  19. package/skills/estack-flight-planner/scripts/pair_shuttles.py +173 -173
  20. package/skills/estack-github-issue-tracker/SKILL.md +322 -322
  21. package/skills/estack-github-issue-tracker/bin/tracker-tools.cjs +1358 -1358
  22. package/skills/estack-github-issue-tracker/references/gh-cli-patterns.md +124 -124
  23. package/skills/estack-github-issue-tracker/references/result-file-schema.md +156 -156
  24. package/skills/estack-github-issue-tracker/references/tracker-schema.md +96 -96
  25. package/skills/estack-github-issue-tracker/tracker-template.md +58 -58
  26. package/skills/estack-leadership-coach/SKILL.md +1 -1
  27. package/skills/estack-leadership-coach/adding-references.md +1 -1
  28. package/skills/estack-migrate-claude-session-history/SKILL.md +15 -2
  29. package/skills/estack-pdf-to-md/SKILL.md +1 -2
  30. package/skills/estack-prompt-builder-coach/SKILL.md +81 -81
  31. package/skills/estack-prompt-builder-coach/definition-of-done-generator.md +42 -42
  32. package/skills/estack-prompt-builder-coach/prompt-builder.md +37 -37
  33. package/skills/estack-prompt-builder-coach/task-shaper.md +36 -36
  34. package/skills/estack-prompt-builder-coach/vague-ask-auditor.md +37 -37
  35. package/skills/estack-read-claude-session-history/SKILL.md +224 -204
  36. package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -126
  37. package/skills/estack-read-claude-session-history/references/modes.md +423 -423
  38. package/skills/estack-read-claude-session-history/references/recipes.md +271 -271
  39. package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -1
  40. package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -460
  41. package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -234
  42. package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -179
  43. package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -88
  44. package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -144
  45. package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1776 -1776
  46. package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -40
  47. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -20
  48. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -4
  49. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -2
  50. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +9 -9
  51. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +7 -7
  52. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +3 -3
  53. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +3 -3
  54. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +5 -5
  55. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -2
  56. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -8
  57. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -2
  58. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -2
  59. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -2
  60. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -2
  61. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -1
  62. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -4
  63. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -6
  64. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -5
  65. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -10
  66. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +2 -2
  67. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -2
  68. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -3
  69. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -5
  70. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -2
  71. package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -56
  72. package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +239 -239
  73. package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -201
  74. package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -199
  75. package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -195
  76. package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -133
  77. package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -78
  78. package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -43
  79. package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +179 -179
  80. package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -212
  81. 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
+ }