inscript 0.3.0__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inscript
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Universal agent activity ledger. Records what AI agents do.
5
5
  Author: Andrew Park
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inscript
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Universal agent activity ledger. Records what AI agents do.
5
5
  Author: Andrew Park
6
6
  License-Expression: MIT
@@ -17,7 +17,7 @@ import time
17
17
  from datetime import datetime, timezone
18
18
  from pathlib import Path
19
19
 
20
- __version__ = "0.3.0"
20
+ __version__ = "0.4.0"
21
21
 
22
22
  INSCRIPT_DIR = Path.home() / ".inscript"
23
23
  ACTIVE_PROJECT_FILE = INSCRIPT_DIR / "active_project"
@@ -133,6 +133,9 @@ def cli() -> None:
133
133
  "cleanup": _cmd_cleanup,
134
134
  "export": lambda: _cmd_export(args[1]) if len(args) > 1 else print("Usage: inscript export <session-id>", file=sys.stderr),
135
135
  "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),
136
+ "tag": lambda: _cmd_tag(args[1] if len(args) > 1 else None),
137
+ "untag": lambda: _cmd_tag(None),
138
+ "time": lambda: _cmd_time(args[1] if len(args) > 1 else None),
136
139
  "help": _cmd_help, "--help": _cmd_help, "-h": _cmd_help,
137
140
  }
138
141
 
@@ -155,7 +158,10 @@ Commands:
155
158
  inscript overlap File collisions across concurrent sessions
156
159
  inscript cleanup Enforce retention policy
157
160
  inscript export <id> Export session as markdown
158
- inscript set <path> Manually set active project""")
161
+ inscript set <path> Manually set active project
162
+ inscript tag <name> Tag current work with a feature/task name
163
+ inscript untag Clear the current tag
164
+ inscript time [tag] Show time spent, optionally filtered by tag""")
159
165
 
160
166
 
161
167
  def _cmd_init():
@@ -276,6 +282,186 @@ def _cmd_log(session_id: str | None):
276
282
  print(f" {len(files_seen)} files, {edits} edits, {len(prompts)} prompts")
277
283
 
278
284
 
285
+ def _cmd_tag(tag_name: str | None):
286
+ """Set or clear the active tag for the current session."""
287
+ sess = active_session()
288
+ if not sess:
289
+ print("No active session", file=__import__("sys").stderr)
290
+ __import__("sys").exit(1)
291
+
292
+ sdir = session_dir(sess)
293
+ tag_file = sdir / "active_tag"
294
+
295
+ if tag_name is None:
296
+ # Clear tag
297
+ try:
298
+ tag_file.unlink()
299
+ except OSError:
300
+ pass
301
+ print("Tag cleared")
302
+ else:
303
+ tag_file.write_text(tag_name + "\n")
304
+ print(f"Tagged: {tag_name}")
305
+
306
+
307
+ def _format_duration(seconds: int) -> str:
308
+ """Format seconds into human-readable duration."""
309
+ if seconds < 60:
310
+ return f"{seconds}s"
311
+ elif seconds < 3600:
312
+ m, s = divmod(seconds, 60)
313
+ return f"{m}m {s}s"
314
+ else:
315
+ h, remainder = divmod(seconds, 3600)
316
+ m, s = divmod(remainder, 60)
317
+ return f"{h}h {m}m"
318
+
319
+
320
+ def _cmd_time(tag_filter: str | None):
321
+ """Show time spent, optionally filtered by tag."""
322
+ all_sessions = list_sessions()
323
+ if not all_sessions:
324
+ print("No sessions found")
325
+ return
326
+
327
+ # Collect timing data from prompts across all sessions
328
+ tag_data: dict[str | None, dict] = {} # tag -> {sessions, prompts, first_ts, last_ts, active_seconds, files, edits}
329
+
330
+ for s in all_sessions:
331
+ sid = s["session_id"]
332
+ sdir = session_dir(sid)
333
+ prompts = _load_prompts(sdir)
334
+ touches_file = sdir / "touches.jsonl"
335
+
336
+ # Load touches
337
+ touches: list[dict] = []
338
+ if touches_file.exists():
339
+ for line in touches_file.open():
340
+ try:
341
+ touches.append(json.loads(line))
342
+ except json.JSONDecodeError:
343
+ pass
344
+
345
+ if not prompts:
346
+ continue
347
+
348
+ # Compute time per prompt block
349
+ for i, p in enumerate(prompts):
350
+ tag = p.get("tag")
351
+ if tag_filter and tag != tag_filter:
352
+ continue
353
+
354
+ # Parse this prompt's timestamp
355
+ prompt_ts = p.get("ts", "")
356
+
357
+ # Find next prompt's timestamp (or session end) for duration
358
+ if i + 1 < len(prompts):
359
+ next_ts = prompts[i + 1].get("ts", "")
360
+ else:
361
+ # Use last touch timestamp or summary end_time
362
+ summary_file = sdir / "summary.json"
363
+ if summary_file.exists():
364
+ try:
365
+ summary = json.loads(summary_file.read_text())
366
+ end = summary.get("end_time", "")
367
+ next_ts = end.split("T")[-1] if "T" in end else ""
368
+ except (json.JSONDecodeError, OSError):
369
+ next_ts = ""
370
+ else:
371
+ # Use last touch
372
+ block_touches = [t for t in touches if t.get("prompt_idx") == p.get("idx")]
373
+ next_ts = block_touches[-1].get("ts", "") if block_touches else ""
374
+
375
+ # Compute duration for this prompt block
376
+ block_seconds = _ts_diff(prompt_ts, next_ts)
377
+
378
+ # Count files and edits for this block
379
+ block_touches = [t for t in touches if t.get("prompt_idx") == p.get("idx")]
380
+ block_files = {t.get("file") for t in block_touches}
381
+ block_edits = sum(1 for t in block_touches if t.get("action") in ("edit", "write"))
382
+
383
+ # Aggregate by tag
384
+ if tag not in tag_data:
385
+ tag_data[tag] = {
386
+ "sessions": set(),
387
+ "prompts": 0,
388
+ "active_seconds": 0,
389
+ "first_ts": s.get("start_time", ""),
390
+ "last_ts": s.get("start_time", ""),
391
+ "files": set(),
392
+ "edits": 0,
393
+ "prompt_texts": [],
394
+ }
395
+
396
+ td = tag_data[tag]
397
+ td["sessions"].add(sid)
398
+ td["prompts"] += 1
399
+ td["active_seconds"] += block_seconds
400
+ td["files"].update(block_files)
401
+ td["edits"] += block_edits
402
+ td["prompt_texts"].append(p.get("prompt", ""))
403
+ # Track first/last
404
+ session_time = s.get("start_time", "")
405
+ if session_time < td["first_ts"] or not td["first_ts"]:
406
+ td["first_ts"] = session_time
407
+ if session_time > td["last_ts"]:
408
+ td["last_ts"] = session_time
409
+
410
+ if not tag_data:
411
+ if tag_filter:
412
+ print(f"No data for tag: {tag_filter}")
413
+ else:
414
+ print("No prompt data found")
415
+ return
416
+
417
+ # Display
418
+ if tag_filter:
419
+ # Single tag detail view
420
+ td = tag_data.get(tag_filter)
421
+ if not td:
422
+ print(f"No data for tag: {tag_filter}")
423
+ return
424
+ print(f"Feature: {tag_filter}\n")
425
+ print(f" Sessions: {len(td['sessions'])}")
426
+ print(f" Prompts: {td['prompts']}")
427
+ print(f" Active time: {_format_duration(td['active_seconds'])}")
428
+ print(f" Files: {len(td['files'])}")
429
+ print(f" Edits: {td['edits']}")
430
+ print(f" First: {td['first_ts']}")
431
+ print(f" Last: {td['last_ts']}")
432
+ print(f"\n Prompts:")
433
+ for pt in td["prompt_texts"]:
434
+ display = pt[:70] + "..." if len(pt) > 70 else pt
435
+ print(f" - \"{display}\"")
436
+ else:
437
+ # Overview of all tags
438
+ print("Time by tag:\n")
439
+ # Sort: tagged first (alphabetical), then untagged
440
+ sorted_tags = sorted(
441
+ tag_data.keys(),
442
+ key=lambda t: (t is None, t or "")
443
+ )
444
+ for tag in sorted_tags:
445
+ td = tag_data[tag]
446
+ label = tag or "(untagged)"
447
+ print(f" {label}")
448
+ print(f" {_format_duration(td['active_seconds'])} active, {td['prompts']} prompts, {td['edits']} edits, {len(td['sessions'])} sessions")
449
+ print()
450
+
451
+
452
+ def _ts_diff(ts1: str, ts2: str) -> int:
453
+ """Compute seconds between two HH:MM:SS timestamps. Returns 0 on failure."""
454
+ try:
455
+ parts1 = [int(x) for x in ts1.split(":")]
456
+ parts2 = [int(x) for x in ts2.split(":")]
457
+ s1 = parts1[0] * 3600 + parts1[1] * 60 + parts1[2]
458
+ s2 = parts2[0] * 3600 + parts2[1] * 60 + parts2[2]
459
+ diff = s2 - s1
460
+ return max(0, diff) # Clamp negative (midnight crossing edge case)
461
+ except (ValueError, IndexError):
462
+ return 0
463
+
464
+
279
465
  def _cmd_overlap():
280
466
  if not OVERLAP_DIR.exists():
281
467
  print("No overlap data")
@@ -105,6 +105,15 @@ def _current_prompt_idx(sdir: Path) -> int | None:
105
105
  return n - 1 if n > 0 else None
106
106
 
107
107
 
108
+ def _current_tag(sdir: Path) -> str | None:
109
+ """Read the active tag for this session."""
110
+ tag_file = sdir / "active_tag"
111
+ try:
112
+ return tag_file.read_text().strip() or None
113
+ except OSError:
114
+ return None
115
+
116
+
108
117
  # ---------------------------------------------------------------------------
109
118
  # SessionStart handler
110
119
  # ---------------------------------------------------------------------------
@@ -158,12 +167,17 @@ def handle_prompt_submit(data: dict) -> None:
158
167
  return
159
168
 
160
169
  prompt_idx = _count_lines(sdir / "prompts.jsonl")
170
+ tag = _current_tag(sdir)
161
171
 
162
- _append_jsonl(sdir / "prompts.jsonl", {
172
+ entry = {
163
173
  "idx": prompt_idx,
164
174
  "ts": _now_time(),
165
175
  "prompt": prompt,
166
- })
176
+ }
177
+ if tag:
178
+ entry["tag"] = tag
179
+
180
+ _append_jsonl(sdir / "prompts.jsonl", entry)
167
181
 
168
182
 
169
183
  # ---------------------------------------------------------------------------
@@ -222,6 +236,7 @@ def handle_post_tool_use(data: dict) -> None:
222
236
  # Append to touches.jsonl
223
237
  action = _tool_action(tool_name)
224
238
  prompt_idx = _current_prompt_idx(sdir)
239
+ tag = _current_tag(sdir)
225
240
  touch = {
226
241
  "ts": _now_time(),
227
242
  "file": display_path,
@@ -230,6 +245,8 @@ def handle_post_tool_use(data: dict) -> None:
230
245
  }
231
246
  if prompt_idx is not None:
232
247
  touch["prompt_idx"] = prompt_idx
248
+ if tag:
249
+ touch["tag"] = tag
233
250
 
234
251
  # Add lines_changed for Edit
235
252
  if tool_name == "Edit" and tool_input.get("new_string") is not None:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "inscript"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Universal agent activity ledger. Records what AI agents do."
9
9
  readme = "README.md"
10
10
  license = "MIT"
File without changes
File without changes
File without changes