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.
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/PKG-INFO +1 -1
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/git_operations.py +151 -0
- entelligence_cli-0.1.76/entelligence_cli/upload/conductor.py +37 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/discovery.py +186 -3
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/manifest.py +12 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/models.py +16 -1
- entelligence_cli-0.1.76/entelligence_cli/upload/pii.py +152 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/runner.py +53 -10
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/PKG-INFO +1 -1
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/SOURCES.txt +7 -1
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/pyproject.toml +2 -2
- entelligence_cli-0.1.76/tests/test_conductor.py +38 -0
- entelligence_cli-0.1.76/tests/test_discovery_opencode.py +160 -0
- entelligence_cli-0.1.76/tests/test_pii.py +117 -0
- entelligence_cli-0.1.76/tests/test_upload_repo_filter.py +274 -0
- entelligence_cli-0.1.76/tests/test_upload_repo_roots.py +161 -0
- entelligence_cli-0.1.74/tests/test_upload_repo_filter.py +0 -93
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/README.md +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/__init__.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/api_client.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/ascii-art.txt +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/browser_login.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/cli.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/__init__.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/auth.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/incident.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/insights.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/review.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/router.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/security.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/commands/update.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/config.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/exceptions.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/__init__.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/bedrock.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/catalog.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/claude_settings.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/compress.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/daemon.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/engine.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/gateway.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/gemini_translate.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/openai_translate.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/proxy.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/router/state.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/scheduling.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/textual_ui.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/ui/__init__.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/ui/interactive.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/ui/loaders.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/ui/output.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/ui/styles.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/update_checker.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/__init__.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/config.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/secrets.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/upload/stores.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli/wrapped_game.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/dependency_links.txt +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/entry_points.txt +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/requires.txt +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/entelligence_cli.egg-info/top_level.txt +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/setup.cfg +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/tests/test_attach_fix_smoke.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/tests/test_attach_ticket_smoke.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/tests/test_incident_smoke.py +0 -0
- {entelligence_cli-0.1.74 → entelligence_cli-0.1.76}/tests/test_security_commands.py +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
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)
|