refocus 0.7.0__py3-none-any.whl

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.
@@ -0,0 +1,633 @@
1
+ """Inscript — universal agent activity ledger.
2
+
3
+ Records what AI agents do: which project they're in, what files they
4
+ touch, what they change. Any tool reads ~/.inscript/ for context.
5
+
6
+ Usage:
7
+ from inscript_pkg import active_project, active_session
8
+
9
+ project = active_project() # Path or None
10
+ session = active_session() # session ID or None
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import time
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ __version__ = "0.7.0"
21
+
22
+ INSCRIPT_DIR = Path.home() / ".inscript"
23
+ ACTIVE_PROJECT_FILE = INSCRIPT_DIR / "active_project"
24
+ ACTIVE_SESSION_FILE = INSCRIPT_DIR / "active_session"
25
+ SESSIONS_DIR = INSCRIPT_DIR / "sessions"
26
+ OVERLAP_DIR = INSCRIPT_DIR / "overlap"
27
+ CONFIG_FILE = INSCRIPT_DIR / "config.toml"
28
+
29
+ DEFAULT_CONFIG = {
30
+ "retention": {"policy": "30d", "max_storage": "1GB", "store_diffs": True},
31
+ "overlap": {"enabled": True},
32
+ }
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Config
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def _load_config() -> dict:
40
+ if not CONFIG_FILE.exists():
41
+ return DEFAULT_CONFIG
42
+ try:
43
+ import tomllib
44
+ return tomllib.loads(CONFIG_FILE.read_text())
45
+ except Exception:
46
+ return DEFAULT_CONFIG
47
+
48
+
49
+ def store_diffs() -> bool:
50
+ return _load_config().get("retention", {}).get("store_diffs", True)
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Active project
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def active_project() -> Path | None:
58
+ try:
59
+ text = ACTIVE_PROJECT_FILE.read_text().strip()
60
+ if text:
61
+ p = Path(text)
62
+ if p.is_dir():
63
+ return p
64
+ except (OSError, ValueError):
65
+ pass
66
+ return None
67
+
68
+
69
+ def set_active_project(path: str | Path) -> None:
70
+ INSCRIPT_DIR.mkdir(parents=True, exist_ok=True)
71
+ ACTIVE_PROJECT_FILE.write_text(str(Path(path).resolve()) + "\n")
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Sessions
76
+ # ---------------------------------------------------------------------------
77
+
78
+ def active_session() -> str | None:
79
+ try:
80
+ return ACTIVE_SESSION_FILE.read_text().strip() or None
81
+ except OSError:
82
+ return None
83
+
84
+
85
+ def session_dir(session_id: str) -> Path:
86
+ return SESSIONS_DIR / session_id
87
+
88
+
89
+ def _format_tokens(n: int) -> str:
90
+ """Format token count: 1234 -> 1.2k, 1234567 -> 1.2M."""
91
+ if n >= 1_000_000:
92
+ return f"{n / 1_000_000:.1f}M"
93
+ elif n >= 1_000:
94
+ return f"{n / 1_000:.1f}k"
95
+ return str(n)
96
+
97
+
98
+ def list_sessions() -> list[dict]:
99
+ sessions = []
100
+ if not SESSIONS_DIR.exists():
101
+ return sessions
102
+ for d in sorted(SESSIONS_DIR.iterdir(), reverse=True):
103
+ meta_file = d / "meta.json"
104
+ if meta_file.exists():
105
+ try:
106
+ meta = json.loads(meta_file.read_text())
107
+ meta["session_id"] = d.name
108
+ sessions.append(meta)
109
+ except (json.JSONDecodeError, OSError):
110
+ pass
111
+ return sessions
112
+
113
+
114
+ def active_sessions() -> list[dict]:
115
+ return [s for s in list_sessions() if s.get("status") == "active"]
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Overlap
120
+ # ---------------------------------------------------------------------------
121
+
122
+ def project_hash(project_path: str) -> str:
123
+ return hashlib.sha256(project_path.encode()).hexdigest()[:12]
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # CLI
128
+ # ---------------------------------------------------------------------------
129
+
130
+ def cli() -> None:
131
+ import sys
132
+ args = sys.argv[1:]
133
+ if not args:
134
+ _cmd_status()
135
+ return
136
+
137
+ commands = {
138
+ "init": _cmd_init,
139
+ "status": lambda: _cmd_status(),
140
+ "log": lambda: _cmd_log(args[1] if len(args) > 1 else None),
141
+ "overlap": _cmd_overlap,
142
+ "cleanup": _cmd_cleanup,
143
+ "export": lambda: _cmd_export(args[1]) if len(args) > 1 else print("Usage: inscript export <session-id>", file=sys.stderr),
144
+ "set": lambda: (set_active_project(args[1]), print(f"Active project: {Path(args[1]).resolve()}")) if len(args) > 1 else print("Usage: inscript set <path>", file=sys.stderr),
145
+ "tag": lambda: _cmd_tag(args[1] if len(args) > 1 else None),
146
+ "untag": lambda: _cmd_tag(None),
147
+ "time": lambda: _cmd_time(args[1] if len(args) > 1 else None),
148
+ "help": _cmd_help, "--help": _cmd_help, "-h": _cmd_help,
149
+ }
150
+
151
+ handler = commands.get(args[0])
152
+ if handler:
153
+ handler()
154
+ else:
155
+ print(f"Unknown command: {args[0]}", file=sys.stderr)
156
+ _cmd_help()
157
+ sys.exit(1)
158
+
159
+
160
+ def _cmd_help():
161
+ print("""inscript — universal agent activity ledger
162
+
163
+ Commands:
164
+ inscript Show status
165
+ inscript init Set up retention and storage options
166
+ inscript log [id] Activity log for a session (latest if omitted)
167
+ inscript overlap File collisions across concurrent sessions
168
+ inscript cleanup Enforce retention policy
169
+ inscript export <id> Export session as markdown
170
+ inscript set <path> Manually set active project
171
+ inscript tag <name> Tag current work with a feature/task name
172
+ inscript untag Clear the current tag
173
+ inscript time [tag] Show time spent, optionally filtered by tag""")
174
+
175
+
176
+ def _cmd_init():
177
+ INSCRIPT_DIR.mkdir(parents=True, exist_ok=True)
178
+ print("inscript setup\n")
179
+ sd = input("Store raw diffs? [Y/n]: ").strip().lower()
180
+ policy = input("Retention [forever/30d/7d] (default 30d): ").strip() or "30d"
181
+ max_storage = input("Max storage [unlimited/1GB/500MB] (default 1GB): ").strip() or "1GB"
182
+
183
+ CONFIG_FILE.write_text(f"""[retention]
184
+ policy = "{policy}"
185
+ max_storage = "{max_storage}"
186
+ store_diffs = {'true' if sd != 'n' else 'false'}
187
+
188
+ [overlap]
189
+ enabled = true
190
+ """)
191
+ print(f"\nConfig written to {CONFIG_FILE}")
192
+
193
+
194
+ def _cmd_status():
195
+ proj = active_project()
196
+ sess = active_session()
197
+
198
+ print(f"Project: {proj or 'none'}")
199
+
200
+ if sess:
201
+ sdir = session_dir(sess)
202
+ meta_file = sdir / "meta.json"
203
+ if meta_file.exists():
204
+ meta = json.loads(meta_file.read_text())
205
+ touches_file = sdir / "touches.jsonl"
206
+ touch_count = sum(1 for _ in touches_file.open()) if touches_file.exists() else 0
207
+ print(f"Session: {sess} (started {meta.get('start_time', '?')}, {touch_count} touches)")
208
+ else:
209
+ print(f"Session: {sess}")
210
+ else:
211
+ print("Session: none")
212
+
213
+ others = [s for s in active_sessions() if s.get("session_id") != sess]
214
+ if others:
215
+ print(f"\nOther active sessions: {len(others)}")
216
+ for s in others[:5]:
217
+ print(f" {s['session_id']} — {s.get('project', '?')}")
218
+
219
+
220
+ def _load_prompts(sdir: Path) -> list[dict]:
221
+ """Load prompts for a session."""
222
+ prompts_file = sdir / "prompts.jsonl"
223
+ if not prompts_file.exists():
224
+ return []
225
+ prompts = []
226
+ for line in prompts_file.open():
227
+ try:
228
+ prompts.append(json.loads(line))
229
+ except json.JSONDecodeError:
230
+ pass
231
+ return prompts
232
+
233
+
234
+ def _cmd_log(session_id: str | None):
235
+ if session_id is None:
236
+ session_id = active_session()
237
+ if session_id is None:
238
+ sessions = list_sessions()
239
+ if not sessions:
240
+ print("No sessions found", file=__import__("sys").stderr)
241
+ return
242
+ session_id = sessions[0]["session_id"]
243
+
244
+ sdir = session_dir(session_id)
245
+ touches_file = sdir / "touches.jsonl"
246
+ if not touches_file.exists():
247
+ print(f"No activity log for {session_id}", file=__import__("sys").stderr)
248
+ return
249
+
250
+ prompts = _load_prompts(sdir)
251
+
252
+ # Group touches by prompt_idx
253
+ touches_by_prompt: dict[int | None, list[dict]] = {}
254
+ files_seen = set()
255
+ edits = 0
256
+ for line in touches_file.open():
257
+ try:
258
+ e = json.loads(line)
259
+ pidx = e.get("prompt_idx")
260
+ touches_by_prompt.setdefault(pidx, []).append(e)
261
+ files_seen.add(e.get("file"))
262
+ if e.get("action") in ("edit", "write"):
263
+ edits += 1
264
+ except json.JSONDecodeError:
265
+ pass
266
+
267
+ print(f"Session: {session_id}\n")
268
+
269
+ if prompts:
270
+ for p in prompts:
271
+ idx = p.get("idx", 0)
272
+ prompt_text = p.get("prompt", "")
273
+ # Truncate long prompts
274
+ if len(prompt_text) > 80:
275
+ prompt_text = prompt_text[:77] + "..."
276
+ print(f" [{p.get('ts', '?')}] \"{prompt_text}\"")
277
+ for t in touches_by_prompt.get(idx, []):
278
+ extra = f" ({t['lines_changed']} lines)" if t.get("lines_changed") else ""
279
+ print(f" {t.get('action', '?'):6s} {t.get('file', '?')}{extra}")
280
+ print()
281
+ else:
282
+ # No prompts recorded — flat list
283
+ for line in touches_file.open():
284
+ try:
285
+ e = json.loads(line)
286
+ extra = f" ({e['lines_changed']} lines)" if e.get("lines_changed") else ""
287
+ print(f" {e.get('ts', '?')} {e.get('action', '?'):6s} {e.get('file', '?')}{extra}")
288
+ except json.JSONDecodeError:
289
+ pass
290
+
291
+ # Show token usage if summary exists
292
+ summary_file = sdir / "summary.json"
293
+ token_str = ""
294
+ if summary_file.exists():
295
+ try:
296
+ s = json.loads(summary_file.read_text())
297
+ tokens = s.get("tokens")
298
+ if tokens:
299
+ total = tokens.get("total_tokens", 0)
300
+ token_str = f", {_format_tokens(total)} tokens"
301
+ except (json.JSONDecodeError, OSError):
302
+ pass
303
+
304
+ print(f" {len(files_seen)} files, {edits} edits, {len(prompts)} prompts{token_str}")
305
+
306
+
307
+ def _cmd_tag(tag_name: str | None):
308
+ """Set or clear the active tag for the current session."""
309
+ sess = active_session()
310
+ if not sess:
311
+ print("No active session", file=__import__("sys").stderr)
312
+ __import__("sys").exit(1)
313
+
314
+ sdir = session_dir(sess)
315
+ tag_file = sdir / "active_tag"
316
+
317
+ if tag_name is None:
318
+ # Clear tag
319
+ try:
320
+ tag_file.unlink()
321
+ except OSError:
322
+ pass
323
+ print("Tag cleared")
324
+ else:
325
+ tag_file.write_text(tag_name + "\n")
326
+ print(f"Tagged: {tag_name}")
327
+
328
+
329
+ def _format_duration(seconds: int) -> str:
330
+ """Format seconds into human-readable duration."""
331
+ if seconds < 60:
332
+ return f"{seconds}s"
333
+ elif seconds < 3600:
334
+ m, s = divmod(seconds, 60)
335
+ return f"{m}m {s}s"
336
+ else:
337
+ h, remainder = divmod(seconds, 3600)
338
+ m, s = divmod(remainder, 60)
339
+ return f"{h}h {m}m"
340
+
341
+
342
+ def _cmd_time(tag_filter: str | None):
343
+ """Show time spent, optionally filtered by tag."""
344
+ all_sessions = list_sessions()
345
+ if not all_sessions:
346
+ print("No sessions found")
347
+ return
348
+
349
+ # Collect timing data from prompts across all sessions
350
+ tag_data: dict[str | None, dict] = {} # tag -> {sessions, prompts, first_ts, last_ts, active_seconds, files, edits}
351
+
352
+ for s in all_sessions:
353
+ sid = s["session_id"]
354
+ sdir = session_dir(sid)
355
+ prompts = _load_prompts(sdir)
356
+ touches_file = sdir / "touches.jsonl"
357
+
358
+ # Load touches
359
+ touches: list[dict] = []
360
+ if touches_file.exists():
361
+ for line in touches_file.open():
362
+ try:
363
+ touches.append(json.loads(line))
364
+ except json.JSONDecodeError:
365
+ pass
366
+
367
+ if not prompts:
368
+ continue
369
+
370
+ # Compute time per prompt block
371
+ for i, p in enumerate(prompts):
372
+ tag = p.get("tag")
373
+ if tag_filter and tag != tag_filter:
374
+ continue
375
+
376
+ # Parse this prompt's timestamp
377
+ prompt_ts = p.get("ts", "")
378
+
379
+ # Find next prompt's timestamp (or session end) for duration
380
+ if i + 1 < len(prompts):
381
+ next_ts = prompts[i + 1].get("ts", "")
382
+ else:
383
+ # Use last touch timestamp or summary end_time
384
+ summary_file = sdir / "summary.json"
385
+ if summary_file.exists():
386
+ try:
387
+ summary = json.loads(summary_file.read_text())
388
+ end = summary.get("end_time", "")
389
+ next_ts = end.split("T")[-1] if "T" in end else ""
390
+ except (json.JSONDecodeError, OSError):
391
+ next_ts = ""
392
+ else:
393
+ # Use last touch
394
+ block_touches = [t for t in touches if t.get("prompt_idx") == p.get("idx")]
395
+ next_ts = block_touches[-1].get("ts", "") if block_touches else ""
396
+
397
+ # Compute duration for this prompt block
398
+ block_seconds = _ts_diff(prompt_ts, next_ts)
399
+
400
+ # Count files and edits for this block
401
+ block_touches = [t for t in touches if t.get("prompt_idx") == p.get("idx")]
402
+ block_files = {t.get("file") for t in block_touches}
403
+ block_edits = sum(1 for t in block_touches if t.get("action") in ("edit", "write"))
404
+
405
+ # Aggregate by tag
406
+ if tag not in tag_data:
407
+ tag_data[tag] = {
408
+ "sessions": set(),
409
+ "prompts": 0,
410
+ "active_seconds": 0,
411
+ "first_ts": s.get("start_time", ""),
412
+ "last_ts": s.get("start_time", ""),
413
+ "files": set(),
414
+ "edits": 0,
415
+ "prompt_texts": [],
416
+ }
417
+
418
+ td = tag_data[tag]
419
+ td["sessions"].add(sid)
420
+ td["prompts"] += 1
421
+ td["active_seconds"] += block_seconds
422
+ td["files"].update(block_files)
423
+ td["edits"] += block_edits
424
+ td["prompt_texts"].append(p.get("prompt", ""))
425
+ # Track first/last
426
+ session_time = s.get("start_time", "")
427
+ if session_time < td["first_ts"] or not td["first_ts"]:
428
+ td["first_ts"] = session_time
429
+ if session_time > td["last_ts"]:
430
+ td["last_ts"] = session_time
431
+
432
+ if not tag_data:
433
+ if tag_filter:
434
+ print(f"No data for tag: {tag_filter}")
435
+ else:
436
+ print("No prompt data found")
437
+ return
438
+
439
+ # Display
440
+ if tag_filter:
441
+ # Single tag detail view
442
+ td = tag_data.get(tag_filter)
443
+ if not td:
444
+ print(f"No data for tag: {tag_filter}")
445
+ return
446
+ print(f"Feature: {tag_filter}\n")
447
+ print(f" Sessions: {len(td['sessions'])}")
448
+ print(f" Prompts: {td['prompts']}")
449
+ print(f" Active time: {_format_duration(td['active_seconds'])}")
450
+ print(f" Files: {len(td['files'])}")
451
+ print(f" Edits: {td['edits']}")
452
+ print(f" First: {td['first_ts']}")
453
+ print(f" Last: {td['last_ts']}")
454
+ print(f"\n Prompts:")
455
+ for pt in td["prompt_texts"]:
456
+ display = pt[:70] + "..." if len(pt) > 70 else pt
457
+ print(f" - \"{display}\"")
458
+ else:
459
+ # Overview of all tags
460
+ print("Time by tag:\n")
461
+ # Sort: tagged first (alphabetical), then untagged
462
+ sorted_tags = sorted(
463
+ tag_data.keys(),
464
+ key=lambda t: (t is None, t or "")
465
+ )
466
+ for tag in sorted_tags:
467
+ td = tag_data[tag]
468
+ label = tag or "(untagged)"
469
+ print(f" {label}")
470
+ print(f" {_format_duration(td['active_seconds'])} active, {td['prompts']} prompts, {td['edits']} edits, {len(td['sessions'])} sessions")
471
+ print()
472
+
473
+
474
+ def _ts_diff(ts1: str, ts2: str) -> int:
475
+ """Compute seconds between two HH:MM:SS timestamps. Returns 0 on failure."""
476
+ try:
477
+ parts1 = [int(x) for x in ts1.split(":")]
478
+ parts2 = [int(x) for x in ts2.split(":")]
479
+ s1 = parts1[0] * 3600 + parts1[1] * 60 + parts1[2]
480
+ s2 = parts2[0] * 3600 + parts2[1] * 60 + parts2[2]
481
+ diff = s2 - s1
482
+ return max(0, diff) # Clamp negative (midnight crossing edge case)
483
+ except (ValueError, IndexError):
484
+ return 0
485
+
486
+
487
+ def _cmd_overlap():
488
+ if not OVERLAP_DIR.exists():
489
+ print("No overlap data")
490
+ return
491
+ for f in sorted(OVERLAP_DIR.iterdir()):
492
+ if f.suffix != ".jsonl":
493
+ continue
494
+ entries = []
495
+ for line in f.open():
496
+ try:
497
+ entries.append(json.loads(line))
498
+ except json.JSONDecodeError:
499
+ pass
500
+ if entries:
501
+ print(f"Project: {entries[0].get('project', f.stem)}")
502
+ for e in entries[-10:]:
503
+ print(f" {e.get('ts', '?')} {e.get('file', '?')} sessions: {e.get('sessions', [])}")
504
+ print()
505
+
506
+
507
+ def _cmd_cleanup():
508
+ config = _load_config()
509
+ policy = config.get("retention", {}).get("policy", "30d")
510
+ if policy == "forever":
511
+ print("Retention: forever. Nothing to clean.")
512
+ return
513
+
514
+ days = 30
515
+ if policy.endswith("d"):
516
+ try:
517
+ days = int(policy[:-1])
518
+ except ValueError:
519
+ pass
520
+
521
+ cutoff = time.time() - (days * 86400)
522
+ removed = 0
523
+ if SESSIONS_DIR.exists():
524
+ import shutil
525
+ for d in list(SESSIONS_DIR.iterdir()):
526
+ try:
527
+ if d.stat().st_mtime < cutoff:
528
+ shutil.rmtree(d)
529
+ removed += 1
530
+ except OSError:
531
+ pass
532
+ print(f"Removed {removed} sessions older than {days} days")
533
+
534
+
535
+ def _cmd_export(session_id: str):
536
+ sdir = session_dir(session_id)
537
+ meta_file = sdir / "meta.json"
538
+ if not meta_file.exists():
539
+ print(f"Session {session_id} not found", file=__import__("sys").stderr)
540
+ __import__("sys").exit(1)
541
+
542
+ meta = json.loads(meta_file.read_text())
543
+ summary_file = sdir / "summary.json"
544
+ touches_file = sdir / "touches.jsonl"
545
+ diffs_file = sdir / "diffs.jsonl"
546
+
547
+ print(f"# Session {session_id}\n")
548
+ print(f"- **Project**: {meta.get('project', '?')}")
549
+ print(f"- **Started**: {meta.get('start_time', '?')}")
550
+
551
+ if summary_file.exists():
552
+ s = json.loads(summary_file.read_text())
553
+ print(f"- **Ended**: {s.get('end_time', '?')}")
554
+ if s.get("duration_seconds"):
555
+ print(f"- **Duration**: {_format_duration(s['duration_seconds'])}")
556
+ print(f"- **Prompts**: {s.get('prompts', '?')}")
557
+ print(f"- **Files read**: {s.get('files_read', '?')}")
558
+ print(f"- **Files written**: {s.get('files_written', '?')}")
559
+ print(f"- **Total edits**: {s.get('total_edits', '?')}")
560
+ tokens = s.get("tokens")
561
+ if tokens:
562
+ print(f"- **Model**: {tokens.get('model', '?')}")
563
+ print(f"- **Tokens**: {_format_tokens(tokens.get('total_tokens', 0))} total ({_format_tokens(tokens.get('input_tokens', 0))} in, {_format_tokens(tokens.get('output_tokens', 0))} out)")
564
+ if tokens.get("cache_read_tokens"):
565
+ print(f"- **Cache**: {_format_tokens(tokens['cache_read_tokens'])} read, {_format_tokens(tokens.get('cache_write_tokens', 0))} written")
566
+
567
+ # Load prompts, touches, and diffs
568
+ prompts = _load_prompts(sdir)
569
+
570
+ touches_by_prompt: dict[int | None, list[dict]] = {}
571
+ if touches_file.exists():
572
+ for line in touches_file.open():
573
+ try:
574
+ e = json.loads(line)
575
+ touches_by_prompt.setdefault(e.get("prompt_idx"), []).append(e)
576
+ except json.JSONDecodeError:
577
+ pass
578
+
579
+ diffs_by_prompt: dict[int | None, list[dict]] = {}
580
+ if diffs_file.exists():
581
+ for line in diffs_file.open():
582
+ try:
583
+ d = json.loads(line)
584
+ diffs_by_prompt.setdefault(d.get("prompt_idx"), []).append(d)
585
+ except json.JSONDecodeError:
586
+ pass
587
+
588
+ if prompts:
589
+ # Group output by prompt
590
+ for p in prompts:
591
+ idx = p.get("idx", 0)
592
+ print(f"\n## \"{p.get('prompt', '?')}\"\n")
593
+ print(f"*{p.get('ts', '')}*\n")
594
+
595
+ touches = touches_by_prompt.get(idx, [])
596
+ if touches:
597
+ print("| Action | File | Details |")
598
+ print("|--------|------|---------|")
599
+ for t in touches:
600
+ details = ""
601
+ if t.get("lines_changed"):
602
+ details = f"{t['lines_changed']} lines"
603
+ elif t.get("lines"):
604
+ details = f"{t['lines']} lines"
605
+ print(f"| {t.get('action', '')} | `{t.get('file', '')}` | {details} |")
606
+ print()
607
+
608
+ diffs = diffs_by_prompt.get(idx, [])
609
+ for d in diffs:
610
+ if d.get("old_string") is not None:
611
+ print(f"**`{d.get('file', '?')}`**")
612
+ print(f"```diff\n- {d['old_string']}\n+ {d.get('new_string', '')}\n```\n")
613
+ elif d.get("is_new"):
614
+ print(f"**`{d.get('file', '?')}`** — new file ({d.get('lines', '?')} lines)\n")
615
+ else:
616
+ # No prompts — flat output
617
+ if touches_by_prompt:
618
+ print(f"\n## Activity\n")
619
+ print("| Time | Action | File |")
620
+ print("|------|--------|------|")
621
+ for touches in touches_by_prompt.values():
622
+ for e in touches:
623
+ print(f"| {e.get('ts', '')} | {e.get('action', '')} | `{e.get('file', '')}` |")
624
+
625
+ if diffs_by_prompt:
626
+ print(f"\n## Changes\n")
627
+ for diffs in diffs_by_prompt.values():
628
+ for d in diffs:
629
+ if d.get("old_string") is not None:
630
+ print(f"### `{d.get('file', '?')}` ({d.get('ts', '')})\n")
631
+ print(f"```diff\n- {d['old_string']}\n+ {d.get('new_string', '')}\n```\n")
632
+ elif d.get("is_new"):
633
+ print(f"New file `{d.get('file', '?')}` ({d.get('lines', '?')} lines)\n")