@iseoane/agent-depot 0.5.0 → 0.5.2

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.
@@ -2,10 +2,11 @@
2
2
  """Mine the raw session logs for hard evidence that the user had to correct
3
3
  the agent -- the whole window, not a sample.
4
4
 
5
- Sources: Claude Code JSONL (`<claude-home>/projects/*/*.jsonl`, required)
6
- and, best-effort, Codex rollout JSONL (`<codex-home>/{sessions,
7
- archived_sessions}/**/rollout-*.jsonl`) when that layout is present. When
8
- Codex is not found, `sources` in the output is `["claude"]` only.
5
+ Sources: Claude Code JSONL (`<claude-home>/projects/*/*.jsonl`), Codex rollout
6
+ JSONL (`<codex-home>/{sessions,archived_sessions}/**/rollout-*.jsonl`), and Pi
7
+ session JSONL (`<pi-home>/sessions/*/*.jsonl`). The report records each
8
+ source's availability and scanned-file count. Grok Build and ZCode are not
9
+ supported by this correction scanner.
9
10
 
10
11
  Detection is a case-insensitive regex over the user's own words, seeded with
11
12
  both Spanish and English phrasings. The patterns intentionally over-match --
@@ -468,6 +469,104 @@ def find_codex_files(codex_home, cutoff):
468
469
  return sorted(files)
469
470
 
470
471
 
472
+ # ---------------------------------------------------------------------------
473
+ # Pi
474
+ # ---------------------------------------------------------------------------
475
+
476
+
477
+ def pi_active_entry_ids(records):
478
+ """Return Pi's active session-entry ancestry when parent links are complete."""
479
+ entries = [record for record in records if record.get("type") != "session"]
480
+ if not entries or any(
481
+ not entry.get("id") or "parentId" not in entry for entry in entries
482
+ ):
483
+ return None
484
+ by_id = {entry["id"]: entry for entry in entries}
485
+ active = set()
486
+ current = entries[-1]["id"]
487
+ while current is not None:
488
+ if current in active or current not in by_id:
489
+ return None
490
+ active.add(current)
491
+ current = by_id[current].get("parentId")
492
+ return active
493
+
494
+
495
+ def pi_text(content):
496
+ if isinstance(content, str):
497
+ return content
498
+ if not isinstance(content, list):
499
+ return None
500
+ parts = [
501
+ block["text"] for block in content
502
+ if isinstance(block, dict) and block.get("type") == "text"
503
+ and isinstance(block.get("text"), str) and block["text"]
504
+ ]
505
+ return "\n".join(parts) if parts else None
506
+
507
+
508
+ def iter_pi_turns(path, max_context):
509
+ try:
510
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
511
+ records = []
512
+ for line in handle:
513
+ try:
514
+ records.append(json.loads(line))
515
+ except json.JSONDecodeError:
516
+ continue
517
+ except OSError:
518
+ return
519
+ records = [record for record in records if isinstance(record, dict)]
520
+ header = next((record for record in records if record.get("type") == "session"), {})
521
+ session_id = header.get("id") or path.stem
522
+ cwd = header.get("cwd")
523
+ messages = [record for record in records if record.get("type") == "message"]
524
+ active_ids = pi_active_entry_ids(records)
525
+ for index, record in enumerate(messages):
526
+ if active_ids is not None and record.get("id") not in active_ids:
527
+ continue
528
+ message = record.get("message")
529
+ if not isinstance(message, dict):
530
+ continue
531
+ role = message.get("role")
532
+ if role not in ("user", "assistant"):
533
+ continue
534
+ raw_text = pi_text(message.get("content"))
535
+ if role == "user":
536
+ text = clean_user_text(raw_text)
537
+ if text is not None:
538
+ yield {
539
+ "session_id": session_id, "harness": "pi",
540
+ "uuid": record.get("id") or f"{path}:{index}",
541
+ "timestamp": record.get("timestamp"), "cwd": cwd,
542
+ "git_branch": None, "role": "user", "text": text,
543
+ "condensed": truncate(text, max_context),
544
+ }
545
+ else:
546
+ yield {
547
+ "session_id": session_id, "harness": "pi",
548
+ "uuid": record.get("id") or f"{path}:{index}",
549
+ "timestamp": record.get("timestamp"), "cwd": cwd,
550
+ "git_branch": None, "role": "assistant", "text": None,
551
+ "condensed": truncate(raw_text, max_context) if raw_text else "",
552
+ }
553
+
554
+
555
+ def find_pi_files(pi_home, cutoff):
556
+ root = pi_home / "sessions"
557
+ if not root.is_dir():
558
+ return []
559
+ files = []
560
+ for path in root.glob("*/*.jsonl"):
561
+ try:
562
+ mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
563
+ except OSError:
564
+ continue
565
+ if mtime >= cutoff:
566
+ files.append(path)
567
+ return sorted(files)
568
+
569
+
471
570
  # ---------------------------------------------------------------------------
472
571
  # Timestamp / scope helpers
473
572
  # ---------------------------------------------------------------------------
@@ -563,6 +662,7 @@ def parse_args(argv=None):
563
662
  p.add_argument("--all-conversations", action="store_true")
564
663
  p.add_argument("--claude-home", default=os.environ.get("CLAUDE_CONFIG_DIR", "~/.claude"))
565
664
  p.add_argument("--codex-home", default="~/.codex")
665
+ p.add_argument("--pi-home", default=os.environ.get("PI_CODING_AGENT_DIR", "~/.pi/agent"))
566
666
  p.add_argument("--patterns", default=None, help="extra newline-separated regex patterns")
567
667
  p.add_argument("--max-context", type=int, default=400,
568
668
  help="chars of preceding-assistant excerpt to keep (default: 400)")
@@ -589,23 +689,41 @@ def main(argv=None):
589
689
 
590
690
  claude_home = Path(args.claude_home).expanduser()
591
691
  codex_home = Path(args.codex_home).expanduser()
692
+ pi_home = Path(args.pi_home).expanduser()
592
693
  cutoff = datetime.now(timezone.utc) - timedelta(days=args.days)
593
694
  repo_norms = [normalize_repo(r) for r in args.repo]
594
695
 
595
696
  patterns = compile_patterns(args.patterns)
596
697
 
597
698
  claude_files = find_claude_files(claude_home, cutoff)
598
- if not claude_files and not (claude_home / "projects").is_dir():
599
- print(f"error: Claude Code project history not found at {claude_home / 'projects'}",
699
+ codex_files = find_codex_files(codex_home, cutoff)
700
+ pi_files = find_pi_files(pi_home, cutoff)
701
+ roots = {
702
+ "claude": (claude_home / "projects",),
703
+ "codex": (codex_home / "sessions", codex_home / "archived_sessions"),
704
+ "pi": (pi_home / "sessions",),
705
+ }
706
+ if not any(any(root.is_dir() for root in paths) for paths in roots.values()):
707
+ print("error: no supported session source found (Claude Code, Codex, or Pi)",
600
708
  file=sys.stderr)
601
709
  sys.exit(1)
602
710
 
603
- codex_files = find_codex_files(codex_home, cutoff)
604
- sources = ["claude"]
605
- if codex_files:
606
- sources.append("codex")
607
-
608
- session_files_scanned = len(claude_files) + len(codex_files)
711
+ files_by_source = {"claude": claude_files, "codex": codex_files, "pi": pi_files}
712
+ sources = []
713
+ if roots["claude"][0].is_dir():
714
+ sources.append("claude")
715
+ sources.extend(source for source in ("codex", "pi") if files_by_source[source])
716
+ source_status = {
717
+ source: {
718
+ "status": "scanned" if files else "no_recent_sessions" if any(
719
+ root.is_dir() for root in roots[source]
720
+ ) else "not_found",
721
+ "session_files": len(files),
722
+ "paths": [str(root) for root in roots[source]],
723
+ }
724
+ for source, files in files_by_source.items()
725
+ }
726
+ session_files_scanned = sum(len(files) for files in files_by_source.values())
609
727
 
610
728
  # session_id -> {"turns": {turn_key: turn_dict}}
611
729
  sessions = {}
@@ -619,6 +737,11 @@ def main(argv=None):
619
737
  bucket = sessions.setdefault(turn["session_id"], {})
620
738
  key = f"codex:{turn['uuid']}"
621
739
  bucket[key] = turn
740
+ for path in pi_files:
741
+ for turn in iter_pi_turns(path, args.max_context):
742
+ bucket = sessions.setdefault(turn["session_id"], {})
743
+ key = f"pi:{turn['uuid']}"
744
+ bucket[key] = turn
622
745
 
623
746
  # Pass 1: walk every in-scope session and collect pattern-matched
624
747
  # candidates. Nothing here is decided to be a real correction yet --
@@ -744,6 +867,8 @@ def main(argv=None):
744
867
  "generated_at": datetime.now(timezone.utc).isoformat(),
745
868
  "window_days": args.days,
746
869
  "sources": sources,
870
+ "source_status": source_status,
871
+ "unsupported_sources": ["grok", "zcode"],
747
872
  "stats": {
748
873
  "session_files_scanned": session_files_scanned,
749
874
  "sessions_scanned": sessions_scanned,
@@ -763,7 +888,10 @@ def main(argv=None):
763
888
  (out_dir / "corrections.json").write_text(json.dumps(report, indent=2))
764
889
 
765
890
  print(f"corrections.json: {out_dir / 'corrections.json'}")
766
- print(f"sources: {', '.join(sources)}")
891
+ print(f"sources: {', '.join(sources) if sources else 'none in window'}")
892
+ for source, status in source_status.items():
893
+ print(f" {source}: {status['status']} ({status['session_files']} files)")
894
+ print("not supported: Grok Build, ZCode")
767
895
  print(f"session files: {session_files_scanned} scanned")
768
896
  print(f"sessions: {sessions_scanned} scanned, {sessions_in_scope} in scope")
769
897
  print(f"user turns: {user_turns_total}")
@@ -14,6 +14,8 @@ This file is the single source of truth for harness support in `doctor-md-skill`
14
14
 
15
15
  At startup, identify the harness executing the skill from the runtime context. Do not infer it from conversation files found on disk.
16
16
 
17
+ Pi session parsing uses the session header's `cwd` for project scope and follows the active entry's `parentId` ancestry when the entry links form a valid tree. Non-message entries establish the branch path but do not become transcript turns; tool-result messages are not user turns. Older or incomplete logs without valid parent links are read in recorded message order, so their inactive branches cannot be distinguished.
18
+
17
19
  If the executing harness is not listed above, or cannot be identified confidently, stop before creating a report directory or reading conversation history. Tell the user:
18
20
 
19
21
  > doctor-md-skill currently supports Claude Code, Codex, Pi, Grok Build, and ZCode. This run appears to be using an unsupported harness, so no conversations were read.
@@ -437,6 +437,24 @@ class CodexReader(Reader):
437
437
  return self._meta or {"id": self.path.stem, "cwd": None, "started_at": first_ts}
438
438
 
439
439
 
440
+ def pi_active_entry_ids(records):
441
+ """Return Pi's active session-entry ancestry when parent links are complete."""
442
+ entries = [record for record in records if record.get("type") != "session"]
443
+ if not entries or any(
444
+ not entry.get("id") or "parentId" not in entry for entry in entries
445
+ ):
446
+ return None
447
+ by_id = {entry["id"]: entry for entry in entries}
448
+ active = set()
449
+ current = entries[-1]["id"]
450
+ while current is not None:
451
+ if current in active or current not in by_id:
452
+ return None
453
+ active.add(current)
454
+ current = by_id[current].get("parentId")
455
+ return active
456
+
457
+
440
458
  class PiReader(Reader):
441
459
  key = "pi"
442
460
 
@@ -472,10 +490,16 @@ class PiReader(Reader):
472
490
  self._meta["started_at"] = self._meta.get("started_at") or ts
473
491
 
474
492
  def events(self):
475
- for obj in read_jsonl(self.path):
493
+ records = list(read_jsonl(self.path))
494
+ active_ids = pi_active_entry_ids(records)
495
+ for obj in records:
496
+ kind = obj.get("type")
497
+ if kind not in ("session", "message"):
498
+ continue
499
+ if kind == "message" and active_ids is not None and obj.get("id") not in active_ids:
500
+ continue
476
501
  if obj.get("timestamp"):
477
502
  yield Event(STAMP, obj["timestamp"])
478
- kind = obj.get("type")
479
503
  if kind == "session":
480
504
  self._read_header(obj)
481
505
  elif kind == "message" and isinstance(obj.get("message"), dict):