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.
- inscript_pkg/__init__.py +633 -0
- inscript_pkg/hook.py +489 -0
- inscript_pkg/reflect.py +377 -0
- refocus-0.7.0.dist-info/METADATA +115 -0
- refocus-0.7.0.dist-info/RECORD +9 -0
- refocus-0.7.0.dist-info/WHEEL +5 -0
- refocus-0.7.0.dist-info/entry_points.txt +3 -0
- refocus-0.7.0.dist-info/licenses/LICENSE +21 -0
- refocus-0.7.0.dist-info/top_level.txt +1 -0
inscript_pkg/__init__.py
ADDED
|
@@ -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")
|