entelligence-cli 0.1.74__tar.gz → 0.1.76__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.
Files changed (68) hide show
  1. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/PKG-INFO +1 -1
  2. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/git_operations.py +151 -0
  3. entelligence_cli-0.1.76/entelligence_cli/upload/conductor.py +37 -0
  4. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/discovery.py +186 -3
  5. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/manifest.py +12 -0
  6. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/models.py +16 -1
  7. entelligence_cli-0.1.76/entelligence_cli/upload/pii.py +152 -0
  8. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/runner.py +53 -10
  9. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/PKG-INFO +1 -1
  10. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/SOURCES.txt +7 -1
  11. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/pyproject.toml +2 -2
  12. entelligence_cli-0.1.76/tests/test_conductor.py +38 -0
  13. entelligence_cli-0.1.76/tests/test_discovery_opencode.py +160 -0
  14. entelligence_cli-0.1.76/tests/test_pii.py +117 -0
  15. entelligence_cli-0.1.76/tests/test_upload_repo_filter.py +274 -0
  16. entelligence_cli-0.1.76/tests/test_upload_repo_roots.py +161 -0
  17. entelligence_cli-0.1.74/tests/test_upload_repo_filter.py +0 -93
  18. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/README.md +0 -0
  19. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/__init__.py +0 -0
  20. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/api_client.py +0 -0
  21. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/ascii-art.txt +0 -0
  22. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/browser_login.py +0 -0
  23. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/cli.py +0 -0
  24. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/__init__.py +0 -0
  25. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/auth.py +0 -0
  26. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/incident.py +0 -0
  27. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/insights.py +0 -0
  28. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/review.py +0 -0
  29. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/router.py +0 -0
  30. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/security.py +0 -0
  31. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/update.py +0 -0
  32. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/config.py +0 -0
  33. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/exceptions.py +0 -0
  34. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/__init__.py +0 -0
  35. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/bedrock.py +0 -0
  36. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/catalog.py +0 -0
  37. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/claude_settings.py +0 -0
  38. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/compress.py +0 -0
  39. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/daemon.py +0 -0
  40. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/engine.py +0 -0
  41. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/gateway.py +0 -0
  42. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/gemini_translate.py +0 -0
  43. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/openai_translate.py +0 -0
  44. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/proxy.py +0 -0
  45. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/state.py +0 -0
  46. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/scheduling.py +0 -0
  47. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/textual_ui.py +0 -0
  48. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/ui/__init__.py +0 -0
  49. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/ui/interactive.py +0 -0
  50. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/ui/loaders.py +0 -0
  51. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/ui/output.py +0 -0
  52. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/ui/styles.py +0 -0
  53. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/update_checker.py +0 -0
  54. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/__init__.py +0 -0
  55. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/config.py +0 -0
  56. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/secrets.py +0 -0
  57. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/stores.py +0 -0
  58. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/wrapped_game.py +0 -0
  59. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/dependency_links.txt +0 -0
  60. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/entry_points.txt +0 -0
  61. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/requires.txt +0 -0
  62. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/top_level.txt +0 -0
  63. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/setup.cfg +0 -0
  64. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/tests/test_attach_fix_smoke.py +0 -0
  65. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/tests/test_attach_ticket_smoke.py +0 -0
  66. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/tests/test_incident_smoke.py +0 -0
  67. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/tests/test_security_commands.py +0 -0
  68. {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/tests/test_update_command.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: entelligence-cli
3
- Version: 0.1.74
3
+ Version: 0.1.76
4
4
  Summary: AI-powered code review assistant for your terminal - Get instant, intelligent feedback on your code changes
5
5
  Author-email: EntelligenceAI <info@entelligence.ai>
6
6
  Project-URL: Homepage, https://entelligence.ai
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import os
4
5
  import re
5
6
  import subprocess
7
+ from collections import Counter
6
8
  from collections.abc import Iterable
7
9
  from pathlib import Path
8
10
 
@@ -524,3 +526,152 @@ def build_cwd_repos(cwds: Iterable[str | Path | None]) -> dict[str, dict[str, st
524
526
  entry["base"] = base
525
527
  out[key] = entry
526
528
  return out
529
+
530
+
531
+ # ── file-touch repo resolution (the parent-folder fallback) ──────────────────
532
+ #
533
+ # When a session's cwd is a *parent* folder (dev started the agent above the
534
+ # repo, or in a container of repos), `git -C <cwd>` finds no repo and the upload
535
+ # gate drops the whole session — even though its edited files live in real,
536
+ # possibly-connected sub-repos. These helpers recover the repo from the files the
537
+ # session actually touched, so those sessions upload instead of vanishing.
538
+
539
+ _WRITE_TOOLS = frozenset({"Edit", "Write", "MultiEdit", "NotebookEdit"})
540
+ _READ_TOOLS = frozenset({"Read", "NotebookRead"})
541
+ _dir_repo_cache: dict[str, str | None] = {}
542
+
543
+
544
+ def repo_for_path(file_path: str | Path) -> str | None:
545
+ """``owner/repo`` of the git repo containing ``file_path``, or None.
546
+
547
+ Walks up from the file's directory via ``git -C <dir> remote get-url origin``
548
+ (git resolves the enclosing repo). Cached per directory — one session touches
549
+ many files across a handful of dirs.
550
+ """
551
+ directory = str(Path(file_path).expanduser().parent)
552
+ if directory not in _dir_repo_cache:
553
+ _dir_repo_cache[directory] = parse_owner_repo(get_remote_url(directory))
554
+ return _dir_repo_cache[directory]
555
+
556
+
557
+ def _majority_repo(paths: Iterable[str | Path]) -> str | None:
558
+ counts: Counter[str] = Counter()
559
+ for p in paths:
560
+ repo = repo_for_path(p)
561
+ if repo:
562
+ counts[repo] += 1
563
+ return counts.most_common(1)[0][0] if counts else None
564
+
565
+
566
+ def session_repo_from_transcript(transcript_path: str | Path) -> str | None:
567
+ """Resolve a session's repo from the files its tool calls touched.
568
+
569
+ Write-precedence majority: the repo most of the session's WRITTEN files live
570
+ in (that's where the work landed). Reads are only a fallback for sessions
571
+ that never wrote (planning/research) — a read often hits a reference or
572
+ dependency repo, not the one being worked on. Returns None when nothing
573
+ resolves (no file ops, or none under a git repo).
574
+
575
+ The fallback for sessions started from a parent folder whose cwd doesn't
576
+ resolve — see the module note above.
577
+
578
+ Claude Code transcripts only: Codex wraps records in a ``payload`` object
579
+ with a different tool-call encoding, so nothing matches here and Codex
580
+ sessions return None (they stay gated on cwd resolution, as before).
581
+ """
582
+ written: list[str] = []
583
+ read: list[str] = []
584
+ try:
585
+ with open(transcript_path, encoding="utf-8") as fh:
586
+ for line in fh:
587
+ try:
588
+ obj = json.loads(line)
589
+ except (json.JSONDecodeError, ValueError):
590
+ continue
591
+ message = obj.get("message")
592
+ if not isinstance(message, dict):
593
+ continue
594
+ for block in message.get("content") or []:
595
+ if not isinstance(block, dict) or block.get("type") != "tool_use":
596
+ continue
597
+ inp = block.get("input")
598
+ if not isinstance(inp, dict):
599
+ # A malformed line can carry a non-dict input (list /
600
+ # string); `.get` on it would raise AttributeError and,
601
+ # since it's outside the OSError guard, crash the whole
602
+ # upload loop. Skip it — one bad line isn't fatal.
603
+ continue
604
+ fp = inp.get("file_path") or inp.get("notebook_path")
605
+ if not isinstance(fp, str) or not fp:
606
+ continue
607
+ if block.get("name") in _WRITE_TOOLS:
608
+ written.append(fp)
609
+ elif block.get("name") in _READ_TOOLS:
610
+ read.append(fp)
611
+ except OSError:
612
+ return None
613
+ return _majority_repo(written) or _majority_repo(read)
614
+
615
+
616
+ _dir_root_cache: dict[str, tuple[str, str] | None] = {}
617
+
618
+
619
+ def repo_root_for_path(file_path: str | Path) -> tuple[str, str] | None:
620
+ """``(git_toplevel, owner/repo)`` of the repo containing ``file_path``.
621
+
622
+ Git-truth resolution (``rev-parse --show-toplevel`` + origin URL),
623
+ cached per directory like ``repo_for_path``. None when the path isn't
624
+ inside a repo, or the repo has no parseable origin.
625
+ """
626
+ directory = str(Path(file_path).expanduser().parent)
627
+ if directory not in _dir_root_cache:
628
+ root = get_toplevel(directory)
629
+ repo = parse_owner_repo(get_remote_url(directory)) if root else None
630
+ _dir_root_cache[directory] = (root, repo) if root and repo else None
631
+ return _dir_root_cache[directory]
632
+
633
+
634
+ def touched_repo_roots(transcript_path: str | Path) -> dict[str, str]:
635
+ """``{git_toplevel: owner/repo}`` for every repo this session's tool calls touched.
636
+
637
+ The manifest stamp that makes per-TASK multi-repo attribution possible
638
+ at ingest: the backend has no git on disk, so it prefix-matches each
639
+ task's touched-file paths against these roots. One entry per repo
640
+ (roots, not files) resolved with real git — exact where name-matching
641
+ would guess (nested repos, worktrees, coincidental folder names).
642
+
643
+ Cheap on big transcripts: lines are pre-filtered on a ``tool_use``
644
+ substring before JSON parsing, and git runs once per distinct
645
+ directory (cached).
646
+
647
+ Claude Code transcripts only (same shape caveat as
648
+ ``session_repo_from_transcript``); Codex yields an empty map.
649
+ """
650
+ roots: dict[str, str] = {}
651
+ try:
652
+ with open(transcript_path, encoding="utf-8") as fh:
653
+ for line in fh:
654
+ if '"tool_use"' not in line:
655
+ continue
656
+ try:
657
+ obj = json.loads(line)
658
+ except (json.JSONDecodeError, ValueError):
659
+ continue
660
+ message = obj.get("message")
661
+ if not isinstance(message, dict):
662
+ continue
663
+ for block in message.get("content") or []:
664
+ if not isinstance(block, dict) or block.get("type") != "tool_use":
665
+ continue
666
+ inp = block.get("input")
667
+ if not isinstance(inp, dict):
668
+ continue
669
+ fp = inp.get("file_path") or inp.get("notebook_path")
670
+ if not isinstance(fp, str) or not fp:
671
+ continue
672
+ resolved = repo_root_for_path(fp)
673
+ if resolved:
674
+ roots[resolved[0]] = resolved[1]
675
+ except OSError:
676
+ return roots
677
+ return roots
@@ -0,0 +1,37 @@
1
+ """Conductor attribution — recognize sessions that ran inside a Conductor workspace.
2
+
3
+ Conductor (conductor.build) runs Claude Code / Codex against the DEFAULT ``~/.claude`` /
4
+ ``~/.codex`` homes, so its sessions are already discovered like any other — keyed by the worktree
5
+ cwd, which lives under ``~/conductor/workspaces/<project>/<workspace>``. Recognizing that cwd lets
6
+ the manifest mark a session's orchestrator as "conductor" (a dimension orthogonal to ``agent``),
7
+ so downstream tooling / the frontend can distinguish parallel-agent runs from plain sessions.
8
+
9
+ Path-based only — no dependency on Conductor's macOS app database.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from pathlib import Path
16
+
17
+
18
+ CONDUCTOR_WORKSPACES = Path.home() / "conductor" / "workspaces"
19
+
20
+
21
+ def conductor_workspace(cwd: Path | str | None) -> str | None:
22
+ """Return a ``<project>/<workspace>`` label when ``cwd`` is inside a Conductor workspace.
23
+
24
+ e.g. ``~/conductor/workspaces/Starter project/quebec/src`` -> ``Starter project/quebec``.
25
+ Returns None when ``cwd`` is missing or not under the Conductor workspaces root.
26
+ """
27
+ if cwd is None:
28
+ return None
29
+ # normpath collapses `.`/`..` lexically (no filesystem/symlink access) so a cwd like
30
+ # `.../workspaces/proj/ws/../other` resolves to `proj/other`, not the original `proj/ws`.
31
+ path = Path(os.path.normpath(cwd))
32
+ if CONDUCTOR_WORKSPACES not in path.parents:
33
+ return None
34
+ parts = path.relative_to(CONDUCTOR_WORKSPACES).parts
35
+ # Require BOTH a project and a workspace segment — a bare
36
+ # ~/conductor/workspaces/<project> (no worktree) is not a real workspace.
37
+ return "/".join(parts[:2]) if len(parts) >= 2 else None
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import logging
7
+ import os
8
+ import sqlite3
7
9
  from collections.abc import Callable, Iterable, Iterator
8
10
  from concurrent.futures import ProcessPoolExecutor, as_completed
9
11
  from datetime import UTC, datetime
@@ -193,6 +195,141 @@ def find_codex_candidates(home: HomeSpec) -> Iterator[Session]:
193
195
  )
194
196
 
195
197
 
198
+ # ----- Discovery: opencode --------------------------------------------------
199
+ #
200
+ # opencode (sst/opencode, v1.17+) stores everything in a single SQLite db at
201
+ # ``<data-dir>/opencode.db`` — no per-session file. A session is one ``session`` row (which
202
+ # carries ``directory`` = cwd) plus rows in ``message`` / ``part``. To ride the same file-based
203
+ # pipeline as Claude Code / Codex, we synthesize one NDJSON transcript per session (one line per
204
+ # message, its parts merged in) and materialize it under this cache root; the rest of the pipeline
205
+ # (secret + PII scrub, upload, manifest, dedup) then treats it like any other transcript file.
206
+
207
+ OPENCODE_CACHE_ROOT = Path.home() / ".entelligence" / "cache" / "opencode"
208
+
209
+
210
+ def _opencode_db(home: Path) -> Path | None:
211
+ """The opencode SQLite db under ``home`` (``opencode.db``, or an ``opencode-<channel>.db``)."""
212
+ db = home / "opencode.db"
213
+ if db.is_file():
214
+ return db
215
+ others = sorted(home.glob("opencode*.db"))
216
+ return others[0] if others else None
217
+
218
+
219
+ def _sqlite_ro_uri(db: Path) -> str:
220
+ """Read-only, immutable SQLite URI. Percent-encode the path so a home dir containing
221
+ ``?`` or ``#`` (URI delimiters) doesn't make SQLite open the wrong file."""
222
+ from urllib.parse import quote
223
+
224
+ return f"file:{quote(str(db))}?mode=ro&immutable=1"
225
+
226
+
227
+ def _safe_component(value: object, fallback: str) -> str:
228
+ """A single, filesystem-safe path component: no separators or ``.``/``..`` traversal, so an
229
+ absolute or dotted opencode id can't escape the cache root when joined with ``/``."""
230
+ text = str(value or fallback)
231
+ for sep in ("/", "\\", "\x00"):
232
+ text = text.replace(sep, "_")
233
+ return text.strip(". ") or fallback
234
+
235
+
236
+ def _opencode_json(raw: str) -> dict:
237
+ # TypeError guards a SQL NULL data column (json.loads(None) raises TypeError, not ValueError).
238
+ try:
239
+ obj = json.loads(raw)
240
+ return obj if isinstance(obj, dict) else {"value": obj}
241
+ except (json.JSONDecodeError, TypeError, ValueError):
242
+ return {}
243
+
244
+
245
+ def _opencode_synthesize(con: sqlite3.Connection, session_id: str) -> bytes:
246
+ """One NDJSON line per message (parts merged), ordered by creation time. b'' if empty."""
247
+ lines: list[str] = []
248
+ messages = con.execute(
249
+ "SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created, id",
250
+ (session_id,),
251
+ ).fetchall()
252
+ for msg_id, msg_data in messages:
253
+ parts = con.execute(
254
+ "SELECT data FROM part WHERE message_id = ? ORDER BY time_created, id",
255
+ (msg_id,),
256
+ ).fetchall()
257
+ record = {
258
+ "id": msg_id,
259
+ "sessionID": session_id,
260
+ "message": _opencode_json(msg_data),
261
+ "parts": [_opencode_json(p[0]) for p in parts],
262
+ }
263
+ lines.append(json.dumps(record, separators=(",", ":")))
264
+ return ("\n".join(lines) + "\n").encode() if lines else b""
265
+
266
+
267
+ def find_opencode_candidates(
268
+ home: HomeSpec, *, cache_root: Path | None = None
269
+ ) -> Iterator[Session]:
270
+ """Yield a Session per opencode session, materializing each into an NDJSON transcript.
271
+
272
+ ``home.path`` is the opencode data dir (holds ``opencode.db``). Each session is synthesized
273
+ to ``<cache_root>/<home_label>/<project_id>/<session_id>.jsonl`` and that file becomes the
274
+ Session's ``abs_path`` — so scanning, redaction, upload, and dedup are all unchanged.
275
+ """
276
+ db = _opencode_db(home.path)
277
+ if db is None:
278
+ logger.debug("Skipping %s (no opencode db)", home.path)
279
+ return
280
+ root = (cache_root or OPENCODE_CACHE_ROOT) / home.label
281
+ try:
282
+ con = sqlite3.connect(_sqlite_ro_uri(db), uri=True)
283
+ except sqlite3.Error as exc:
284
+ logger.debug("opencode db open failed %s: %s", db, exc)
285
+ return
286
+ try:
287
+ con.row_factory = sqlite3.Row
288
+ try:
289
+ rows = con.execute(
290
+ "SELECT id, project_id, directory, time_updated FROM session"
291
+ ).fetchall()
292
+ except sqlite3.Error as exc:
293
+ logger.debug("opencode session query failed on %s: %s", db, exc)
294
+ return
295
+ for s in rows:
296
+ # A schema mismatch (missing message/part table) raises sqlite3.Error mid-generator;
297
+ # contain it per-session so one bad db doesn't abort the whole upload.
298
+ try:
299
+ data = _opencode_synthesize(con, s["id"])
300
+ except sqlite3.Error as exc:
301
+ logger.debug("opencode synthesize failed for session %s: %s", s["id"], exc)
302
+ continue
303
+ if not data:
304
+ continue
305
+ # Sanitize both components: an absolute or ``..``-laden project_id/session id would
306
+ # otherwise let ``root / rel`` write outside the cache (Path drops the left side on an
307
+ # absolute right side).
308
+ rel = Path(_safe_component(s["project_id"], "unknown")) / (
309
+ f"{_safe_component(s['id'], 'session')}.jsonl"
310
+ )
311
+ abs_path = root / rel
312
+ try:
313
+ abs_path.parent.mkdir(parents=True, exist_ok=True)
314
+ abs_path.write_bytes(data)
315
+ except OSError as exc:
316
+ logger.debug("opencode materialize failed %s: %s", abs_path, exc)
317
+ continue
318
+ directory = s["directory"]
319
+ yield Session(
320
+ agent=AgentKind.OPENCODE,
321
+ home_label=home.label,
322
+ abs_path=abs_path,
323
+ rel_path=rel,
324
+ size_bytes=len(data),
325
+ mtime=datetime.fromtimestamp((s["time_updated"] or 0) / 1000, tz=UTC),
326
+ cwd=Path(directory) if directory else None,
327
+ secret_findings=[],
328
+ )
329
+ finally:
330
+ con.close()
331
+
332
+
196
333
  # ----- Scan + orchestration -------------------------------------------------
197
334
 
198
335
 
@@ -243,6 +380,8 @@ def find_all_candidates(homes: Iterable[HomeSpec]) -> list[Session]:
243
380
  found.extend(find_claude_code_candidates(home))
244
381
  elif home.kind is AgentKind.CODEX:
245
382
  found.extend(find_codex_candidates(home))
383
+ elif home.kind is AgentKind.OPENCODE:
384
+ found.extend(find_opencode_candidates(home))
246
385
  found.sort(key=lambda s: s.mtime, reverse=True)
247
386
  return found
248
387
 
@@ -361,6 +500,43 @@ def _count_codex_sessions(home: Path) -> int:
361
500
  return 0
362
501
 
363
502
 
503
+ def _count_opencode_sessions(home: Path) -> int:
504
+ db = _opencode_db(home)
505
+ if db is None:
506
+ return 0
507
+ try:
508
+ con = sqlite3.connect(_sqlite_ro_uri(db), uri=True)
509
+ try:
510
+ return con.execute("SELECT count(*) FROM session").fetchone()[0]
511
+ finally:
512
+ con.close()
513
+ except sqlite3.Error:
514
+ return 0
515
+
516
+
517
+ def _detect_opencode_homes() -> dict[Path, int]:
518
+ """opencode lives at $XDG_DATA_HOME/opencode (default ~/.local/share/opencode), not ~/.<x>."""
519
+ out: dict[Path, int] = {}
520
+ roots: list[Path] = []
521
+ xdg = os.environ.get("XDG_DATA_HOME")
522
+ if xdg:
523
+ roots.append(Path(xdg) / "opencode")
524
+ roots.append(Path.home() / ".local" / "share" / "opencode")
525
+ seen: set[Path] = set()
526
+ for root in roots:
527
+ try:
528
+ resolved = root.resolve()
529
+ except OSError:
530
+ continue
531
+ if resolved in seen or not resolved.is_dir():
532
+ continue
533
+ seen.add(resolved)
534
+ n = _count_opencode_sessions(resolved)
535
+ if n > 0:
536
+ out[resolved] = n
537
+ return out
538
+
539
+
364
540
  def _detect_candidate_homes(prefix: str, counter: Callable[[Path], int]) -> dict[Path, int]:
365
541
  """Find ~/<prefix>* dirs that actually contain transcripts. Returns {resolved_path: count}."""
366
542
  out: dict[Path, int] = {}
@@ -379,11 +555,12 @@ def _detect_candidate_homes(prefix: str, counter: Callable[[Path], int]) -> dict
379
555
  return out
380
556
 
381
557
 
382
- def auto_detect_homes() -> tuple[list[Path], list[Path]]:
383
- """Return (claude_homes, codex_homes) auto-detected under ~/ that hold transcripts."""
558
+ def auto_detect_homes() -> tuple[list[Path], list[Path], list[Path]]:
559
+ """Return (claude_homes, codex_homes, opencode_homes) auto-detected that hold transcripts."""
384
560
  claude = sorted(_detect_candidate_homes(".claude", _count_cc_sessions).keys())
385
561
  codex = sorted(_detect_candidate_homes(".codex", _count_codex_sessions).keys())
386
- return claude, codex
562
+ opencode = sorted(_detect_opencode_homes().keys())
563
+ return claude, codex, opencode
387
564
 
388
565
 
389
566
  def homes_from_config(cfg: UploadConfig) -> list[HomeSpec]:
@@ -401,4 +578,10 @@ def homes_from_config(cfg: UploadConfig) -> list[HomeSpec]:
401
578
  console.print(f"[yellow]skip:[/yellow] codex-home {p} no longer exists")
402
579
  continue
403
580
  homes.append(HomeSpec(kind=AgentKind.CODEX, path=p.resolve()))
581
+ for raw in cfg.opencode_homes:
582
+ p = Path(raw).expanduser()
583
+ if not p.is_dir():
584
+ console.print(f"[yellow]skip:[/yellow] opencode-home {p} no longer exists")
585
+ continue
586
+ homes.append(HomeSpec(kind=AgentKind.OPENCODE, path=p.resolve()))
404
587
  return homes
@@ -14,6 +14,7 @@ from datetime import datetime
14
14
  from typing import TYPE_CHECKING
15
15
 
16
16
  from ..ui.output import console
17
+ from .conductor import conductor_workspace
17
18
  from .models import EntelligenceIdentity, UploadConfig, UploadPlan
18
19
 
19
20
 
@@ -105,6 +106,13 @@ def build_manifest(
105
106
  "mtime": s.mtime.isoformat(),
106
107
  "cwd": str(s.cwd) if s.cwd is not None else None,
107
108
  "secret_match_counts": s.secret_counts_by_type or None,
109
+ "pii_match_counts": s.pii_counts or None,
110
+ # Distinguishes "PII found and removed" from "found but left in" (secrets_mode=allow):
111
+ # true only when PII was detected AND this upload was redacted.
112
+ "pii_redacted": bool(s.pii_counts) and redacted,
113
+ # Orchestrator tag (orthogonal to `agent`): set when the session ran inside a
114
+ # Conductor workspace, so the frontend can distinguish parallel-agent runs.
115
+ "conductor": conductor_workspace(s.cwd),
108
116
  }
109
117
  for s in home_sessions
110
118
  ]
@@ -121,6 +129,10 @@ def build_manifest(
121
129
  "total_bytes": sum(e["size_bytes"] for e in entries),
122
130
  "entelligence": plan.entelligence.model_dump() if plan.entelligence else None,
123
131
  "cwd_repos": cwd_repos,
132
+ # `{git_toplevel: owner/repo}` for every repo this home's sessions'
133
+ # tool calls touched — the ingest side prefix-matches task files
134
+ # against these roots for per-task multi-repo attribution.
135
+ "repo_roots": plan.repo_roots.get(home_label, {}),
124
136
  "sessions": entries,
125
137
  }
126
138
  return json.dumps(body, indent=2).encode()
@@ -20,6 +20,7 @@ MANIFEST_DIR = "_manifest"
20
20
  class AgentKind(StrEnum):
21
21
  CLAUDE_CODE = "claude-code"
22
22
  CODEX = "codex"
23
+ OPENCODE = "opencode"
23
24
 
24
25
 
25
26
  class HomeSpec(BaseModel):
@@ -45,6 +46,12 @@ class Session(BaseModel):
45
46
  exclude=True,
46
47
  repr=False,
47
48
  )
49
+ # Populated at upload time by pii.redact_pii (counts by type, never values).
50
+ pii_counts: dict[str, int] = Field(
51
+ default_factory=dict,
52
+ exclude=True,
53
+ repr=False,
54
+ )
48
55
 
49
56
  @property
50
57
  def cwd_str(self) -> str:
@@ -81,6 +88,11 @@ class UploadPlan(BaseModel):
81
88
  sessions: list[Session]
82
89
  entelligence: EntelligenceIdentity | None = None
83
90
  cwd_repos: dict[str, dict[str, str]] = Field(default_factory=dict)
91
+ # Per-home `{git_toplevel: owner/repo}` for every repo the home's
92
+ # sessions' TOOL CALLS touched (not just their cwds) — keyed by
93
+ # home_label. The ingest side prefix-matches each task's touched files
94
+ # against these roots for multi-repo attribution.
95
+ repo_roots: dict[str, dict[str, str]] = Field(default_factory=dict)
84
96
 
85
97
  @property
86
98
  def total_bytes(self) -> int:
@@ -107,9 +119,12 @@ class UploadConfig(BaseModel):
107
119
 
108
120
  claude_homes: list[str] = Field(default_factory=list)
109
121
  codex_homes: list[str] = Field(default_factory=list)
122
+ # opencode data dirs (e.g. ~/.local/share/opencode). Sessions live in a SQLite db there,
123
+ # not per-session files, so discovery materializes them into transcripts (see discovery.py).
124
+ opencode_homes: list[str] = Field(default_factory=list)
110
125
  secrets_mode: Literal["redact", "allow", "skip"] = Field(default="redact")
111
126
  skip_prefixes: list[str] = Field(default_factory=list)
112
127
  entelligence: EntelligenceIdentity | None = None
113
128
 
114
129
  def needs_setup(self) -> bool:
115
- return not (self.claude_homes or self.codex_homes)
130
+ return not (self.claude_homes or self.codex_homes or self.opencode_homes)
@@ -0,0 +1,152 @@
1
+ """Client-side PII scrub — the deterministic complement to ``secrets.py``.
2
+
3
+ ``secrets.py`` removes credentials (detect-secrets + a pattern table). This module removes the
4
+ *personal identifiers* that dominate coding-agent transcripts and that no secret scanner or
5
+ chat-trained PII model handles well: the OS username baked into nearly every file path
6
+ (``/Users/<name>/...``), the machine owner's identity (login / git name), emails, and
7
+ URL-embedded session/installation tokens.
8
+
9
+ Design decisions (see also the evaluation that rejected Rampart/StarPII for this data):
10
+ - Boundary-aware regex, **not** routed through ``secrets.redact_bytes``. That path assumes
11
+ high-entropy secrets: it substring-replaces and whole-line-wipes any value shorter than
12
+ ``MIN_BYTE_REPLACE_LEN`` (12) — both wrong for a 7-char username.
13
+ - Runs **after** ``secrets.redact_bytes`` in the pipeline: secret findings reference the
14
+ original line bytes, so a PII edit must not shift them first.
15
+ - Placeholders reuse the ``<REDACTED:Type>`` convention so a single ``grep '<REDACTED:'`` covers
16
+ both passes.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import contextlib
22
+ import getpass
23
+ import logging
24
+ import re
25
+ import subprocess
26
+ from collections import Counter
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+
30
+
31
+ logger = logging.getLogger("entelligence_cli.upload")
32
+
33
+ # Path segments after /Users/ or /home/ that are not people — never redact these.
34
+ NON_USER_SEGMENTS: frozenset[str] = frozenset(
35
+ {
36
+ "shared",
37
+ "guest",
38
+ "public",
39
+ "default",
40
+ "default user",
41
+ "all users",
42
+ "administrator",
43
+ "root",
44
+ "runner",
45
+ "library",
46
+ "applications",
47
+ "home",
48
+ "user",
49
+ }
50
+ )
51
+ _NON_USER_BYTES: frozenset[bytes] = frozenset(s.encode() for s in NON_USER_SEGMENTS)
52
+
53
+ # Below this length an identity token is too generic to match as a bare word safely.
54
+ _MIN_IDENTITY_LEN = 3
55
+
56
+ # /Users/<seg>, /home/<seg>, C:\Users\<seg> — the segment is the username.
57
+ _PATH_USER_RE = re.compile(rb"(?P<pre>/Users/|/home/|[A-Za-z]:\\Users\\)(?P<seg>[^/\\\s\"']+)")
58
+ # (?<!\\) stops a JSON escape's trailing letter from starting a bogus local-part: transcript
59
+ # bytes carry literal `\n`/`\t` inside JSON strings, so raw `\n@click.option` is a decorator,
60
+ # not an email. Real emails (preceded by a quote/space) still match.
61
+ _EMAIL_RE = re.compile(rb"(?<!\\)\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
62
+ # (count-label, pattern, replacement) — scrub the token, keep the public URL shape.
63
+ _URL_TOKEN_RES: list[tuple[str, re.Pattern[bytes], bytes]] = [
64
+ ("Token", re.compile(rb"(claude\.ai/code/session_)[A-Za-z0-9]+"), rb"\1<REDACTED:Token>"),
65
+ ("Id", re.compile(rb"(/(?:app/)?installations/)\d+"), rb"\1<REDACTED:Id>"),
66
+ (
67
+ "Token",
68
+ re.compile(rb"([?&](?:access_token|token|api_key|key|code)=)[^\s&\"']+"),
69
+ rb"\1<REDACTED:Token>",
70
+ ),
71
+ ]
72
+
73
+ _USERNAME_PLACEHOLDER = b"<REDACTED:Username>"
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class Identity:
78
+ """The machine owner's identifiers, resolved once per run."""
79
+
80
+ tokens: frozenset[str] = field(default_factory=frozenset)
81
+
82
+ @classmethod
83
+ def resolve(cls) -> Identity:
84
+ candidates: set[str] = set()
85
+ # Identity is best-effort; the path regex is the main signal, so swallow failures.
86
+ with contextlib.suppress(Exception):
87
+ candidates.add(getpass.getuser())
88
+ with contextlib.suppress(Exception):
89
+ candidates.add(Path.home().name)
90
+ candidates.add(_git_global_name())
91
+ tokens = {
92
+ t
93
+ for t in candidates
94
+ if t and len(t) >= _MIN_IDENTITY_LEN and t.lower() not in NON_USER_SEGMENTS
95
+ }
96
+ return cls(tokens=frozenset(tokens))
97
+
98
+
99
+ def _git_global_name() -> str:
100
+ try:
101
+ result = subprocess.run(
102
+ ["git", "config", "--global", "user.name"],
103
+ capture_output=True,
104
+ text=True,
105
+ timeout=5,
106
+ )
107
+ return result.stdout.strip()
108
+ except Exception: # noqa: BLE001 — git may be absent; not fatal
109
+ return ""
110
+
111
+
112
+ def redact_pii(
113
+ data: bytes, identity: Identity, *, redact: bool = True
114
+ ) -> tuple[bytes, dict[str, int]]:
115
+ """Return (new_bytes, counts_by_type). With ``redact=False`` (secrets_mode "allow"), count
116
+ only and leave ``data`` byte-for-byte unchanged.
117
+
118
+ Order matters: emails run *before* the identity pass so an email containing the username
119
+ (``rishabh@…``) is redacted whole rather than double-counted. We transform a ``work`` copy
120
+ through every pass so counts are accurate even in count-only mode; the original ``data`` is
121
+ what's returned unless ``redact`` is set.
122
+ """
123
+ counts: Counter[str] = Counter()
124
+
125
+ def _path_sub(match: re.Match[bytes]) -> bytes:
126
+ seg = match.group("seg")
127
+ if seg == _USERNAME_PLACEHOLDER or seg.lower() in _NON_USER_BYTES or len(seg) < 2:
128
+ return match.group(0)
129
+ counts["Username"] += 1
130
+ return match.group("pre") + _USERNAME_PLACEHOLDER
131
+
132
+ def _email_sub(_match: re.Match[bytes]) -> bytes:
133
+ counts["Email"] += 1
134
+ return b"<REDACTED:Email>"
135
+
136
+ # 1. usernames in file paths (catches teammates' usernames too, not just this machine's)
137
+ work = _PATH_USER_RE.sub(_path_sub, data)
138
+ # 2. emails — before identity, so a name embedded in an email isn't recounted below
139
+ work = _EMAIL_RE.sub(_email_sub, work)
140
+ # 3. this machine's identity as bare whole words (no substring clobber, case-insensitive)
141
+ for token in sorted(identity.tokens, key=len, reverse=True):
142
+ pattern = re.compile(rb"\b" + re.escape(token.encode()) + rb"\b", re.IGNORECASE)
143
+ work, n = pattern.subn(_USERNAME_PLACEHOLDER, work)
144
+ if n:
145
+ counts["Username"] += n
146
+ # 4. token-bearing URLs (leave public URLs intact)
147
+ for label, pattern, replacement in _URL_TOKEN_RES:
148
+ work, n = pattern.subn(replacement, work)
149
+ if n:
150
+ counts[label] += n
151
+
152
+ return (work if redact else data), dict(counts)