sourcecode 1.35.36__py3-none-any.whl → 1.36.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.35.36"
3
+ __version__ = "1.36.1"
sourcecode/cache.py CHANGED
@@ -58,6 +58,8 @@ import json
58
58
  import os
59
59
  import re
60
60
  import subprocess
61
+ import time
62
+ import uuid
61
63
  from datetime import datetime, timezone
62
64
  from pathlib import Path
63
65
  from typing import Any, Optional
@@ -101,6 +103,12 @@ _DEFAULT_KEEP_COMMITS: int = 5
101
103
  _DEFAULT_MAX_CORES: int = 20
102
104
  _DEFAULT_MAX_SIZE_MB: int = 50
103
105
 
106
+ #: Windows hardening for _atomic_write: os.replace can raise PermissionError
107
+ #: (WinError 5/32) when an antivirus scanner, search indexer, or concurrent
108
+ #: reader transiently holds the destination open. Retry briefly to ride it out.
109
+ _REPLACE_RETRIES: int = 5
110
+ _REPLACE_BACKOFF_S: float = 0.05
111
+
104
112
  # Matches "snapshot-<hex_commit>-<hex_flags>.json.gz"
105
113
  _SNAPSHOT_RE = re.compile(r"^snapshot-([0-9a-f]+)-[0-9a-f]+\.json\.gz$")
106
114
 
@@ -808,20 +816,38 @@ def _gc_cas(cache_d: Path, surviving_snapshots: list[Path]) -> None:
808
816
  # ---------------------------------------------------------------------------
809
817
 
810
818
  def _atomic_write(dest: Path, data: bytes) -> None:
811
- """Write *data* to *dest* atomically via a sibling .tmp file + rename.
812
-
813
- On POSIX, ``Path.replace()`` is a single ``rename(2)`` syscall the
814
- destination either has the old content or the new content, never a partial
815
- write. The .tmp suffix keeps the partial file out of glob patterns used
816
- by the cache reader and GC.
819
+ """Write *data* to *dest* atomically via a unique temp file + rename.
820
+
821
+ ``os.replace`` is an atomic overwrite on both POSIX and Windows: the
822
+ destination ends up with either the old or the new content, never a partial
823
+ write. The trailing ``.tmp`` suffix keeps partial files out of the
824
+ ``*.json.gz`` / ``*.gz`` glob patterns used by the cache reader and GC.
825
+
826
+ Windows hardening (no-ops on POSIX):
827
+ * The temp name is made *unique* (pid + random token) so concurrent
828
+ writers of the same destination never collide on a shared temp file —
829
+ on Windows that collision raises ``PermissionError`` (WinError 32,
830
+ sharing violation), whereas POSIX tolerates it.
831
+ * ``os.replace`` is retried with a short backoff: on Windows it raises
832
+ ``PermissionError`` (WinError 5/32) when an antivirus scanner, the
833
+ search indexer, or a concurrent reader holds a transient handle on the
834
+ destination. POSIX ``rename(2)`` has no such restriction.
817
835
  """
818
- tmp = dest.with_suffix(".tmp")
836
+ tmp = dest.parent / f"{dest.name}.{os.getpid()}.{uuid.uuid4().hex[:8]}.tmp"
819
837
  try:
820
838
  tmp.write_bytes(data)
821
- tmp.replace(dest)
822
- except Exception:
823
- _safe_unlink(tmp)
824
- raise
839
+ last_exc: Optional[BaseException] = None
840
+ for attempt in range(_REPLACE_RETRIES):
841
+ try:
842
+ os.replace(tmp, dest)
843
+ return
844
+ except PermissionError as exc: # transient lock on Windows — retry
845
+ last_exc = exc
846
+ time.sleep(_REPLACE_BACKOFF_S * (attempt + 1))
847
+ if last_exc is not None:
848
+ raise last_exc
849
+ finally:
850
+ _safe_unlink(tmp) # remove temp on failure; no-op after a successful replace
825
851
 
826
852
 
827
853
  def _safe_unlink(path: Path) -> None:
sourcecode/cli.py CHANGED
@@ -168,7 +168,6 @@ Cold scan: 2–10s depending on repo size. Warm cache: 0.3–0.6s.
168
168
  sourcecode --agent full structured JSON for AI agents
169
169
 
170
170
  [bold]Auth commands:[/bold]
171
- auth login [dim]# authenticate via browser (device code)[/dim]
172
171
  auth status [dim]# show current plan and auth state[/dim]
173
172
  auth logout [dim]# remove local credentials[/dim]
174
173
 
@@ -570,7 +569,7 @@ app.add_typer(mcp_app, name="mcp")
570
569
  cache_app = typer.Typer(help="Cache inspection and management.", rich_markup_mode="rich")
571
570
  app.add_typer(cache_app, name="cache")
572
571
 
573
- auth_app = typer.Typer(help="Authentication: login, status, logout.", rich_markup_mode="rich")
572
+ auth_app = typer.Typer(help="Authentication: status, logout.", rich_markup_mode="rich")
574
573
  app.add_typer(auth_app, name="auth")
575
574
 
576
575
 
@@ -653,6 +652,109 @@ def version_callback(value: bool) -> None:
653
652
  raise typer.Exit()
654
653
 
655
654
 
655
+ # ANSI Shadow block wordmark, stacked "source" / "code" so it fits an 80-col
656
+ # terminal (the single-line "sourcecode" is 83 wide and would wrap).
657
+ _WELCOME_SOURCE = (
658
+ "███████╗ ██████╗ ██╗ ██╗██████╗ ██████╗███████╗",
659
+ "██╔════╝██╔═══██╗██║ ██║██╔══██╗██╔════╝██╔════╝",
660
+ "███████╗██║ ██║██║ ██║██████╔╝██║ █████╗ ",
661
+ "╚════██║██║ ██║██║ ██║██╔══██╗██║ ██╔══╝ ",
662
+ "███████║╚██████╔╝╚██████╔╝██║ ██║╚██████╗███████╗",
663
+ "╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝",
664
+ )
665
+ _WELCOME_CODE = (
666
+ " ██████╗ ██████╗ ██████╗ ███████╗",
667
+ "██╔════╝██╔═══██╗██╔══██╗██╔════╝",
668
+ "██║ ██║ ██║██║ ██║█████╗ ",
669
+ "██║ ██║ ██║██║ ██║██╔══╝ ",
670
+ "╚██████╗╚██████╔╝██████╔╝███████╗",
671
+ " ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
672
+ )
673
+
674
+ _WELCOME_CMDS = (
675
+ ("sourcecode --compact", "repo summary"),
676
+ ("sourcecode prepare-context onboard", "onboarding"),
677
+ ("sourcecode mcp init", "connect IDE"),
678
+ )
679
+
680
+
681
+ def _print_welcome_plain(tier: str) -> None:
682
+ """Plain-text welcome — fallback when rich is unavailable."""
683
+ lines = ["", f" sourcecode {__version__} · {tier}", "",
684
+ " AI coding-agent context, instant.", "", " Get started:"]
685
+ for cmd, desc in _WELCOME_CMDS:
686
+ lines.append(f" {cmd.ljust(34)}{desc}")
687
+ lines.append("")
688
+ lines.append(" sourcecode --help all commands")
689
+ if tier != "Pro":
690
+ lines.append(" sourcecode activate <key> unlock Pro")
691
+ lines.append("")
692
+ typer.echo("\n".join(lines))
693
+
694
+
695
+ def _print_welcome() -> None:
696
+ """Branded quickstart shown only on a bare invocation at a human terminal.
697
+
698
+ Agents and pipes never reach here: they either pass args/flags or stdout is
699
+ not a TTY, so the JSON machine contract is completely unchanged.
700
+ """
701
+ try:
702
+ from sourcecode import license as _lic
703
+ tier = "Pro" if _lic.is_pro else "Free"
704
+ except Exception:
705
+ tier = "Free"
706
+
707
+ try:
708
+ from rich import box
709
+ from rich.console import Console
710
+ from rich.panel import Panel
711
+ from rich.text import Text
712
+ except Exception:
713
+ _print_welcome_plain(tier)
714
+ return
715
+
716
+ pad = max(len(c) for c, _ in _WELCOME_CMDS) + 2
717
+ tier_style = "green" if tier != "Pro" else "magenta"
718
+ code_w = max(len(s) for s in _WELCOME_CODE)
719
+
720
+ t = Text()
721
+ for ln in _WELCOME_SOURCE:
722
+ t.append(ln + "\n", style="bold cyan")
723
+ # Append version + tier to the right of the "code" block's middle rows.
724
+ for i, ln in enumerate(_WELCOME_CODE):
725
+ t.append(ln.ljust(code_w), style="bold cyan")
726
+ if i == 2:
727
+ t.append(f" {__version__}", style="dim")
728
+ elif i == 3:
729
+ t.append(" ")
730
+ t.append(tier, style=tier_style)
731
+ t.append("\n")
732
+
733
+ t.append("\nAI coding-agent context, instant.\n\n", style="white")
734
+
735
+ for cmd, desc in _WELCOME_CMDS:
736
+ t.append("▸ ", style="cyan")
737
+ t.append(cmd.ljust(pad), style="bold")
738
+ t.append(desc + "\n", style="dim")
739
+
740
+ t.append("\n--help", style="bold")
741
+ t.append(" · ", style="dim")
742
+ if tier != "Pro":
743
+ t.append("activate <key>", style="bold")
744
+ t.append(" for Pro", style="dim")
745
+ else:
746
+ t.append("you're on Pro ✓", style="green")
747
+
748
+ panel = Panel(
749
+ t,
750
+ box=box.ROUNDED,
751
+ border_style="cyan",
752
+ padding=(1, 3),
753
+ expand=False,
754
+ )
755
+ Console().print(panel)
756
+
757
+
656
758
  @app.callback(invoke_without_command=True)
657
759
  def main(
658
760
  ctx: typer.Context,
@@ -902,6 +1004,13 @@ def main(
902
1004
  if ctx.invoked_subcommand is not None:
903
1005
  return
904
1006
 
1007
+ # Bare invocation at a human terminal → branded quickstart instead of
1008
+ # dumping a large JSON blob. Agents/pipes are untouched: any arg/flag, or a
1009
+ # non-TTY stdout (piped), falls through to the normal analysis + JSON.
1010
+ if len(sys.argv) <= 1 and sys.stdout.isatty():
1011
+ _print_welcome()
1012
+ raise typer.Exit()
1013
+
905
1014
  _t0 = time.monotonic()
906
1015
  no_tree: bool = False # set True by --agent; --no-tree flag removed
907
1016
 
@@ -2797,6 +2906,17 @@ def prepare_context_cmd(
2797
2906
  _cached_pctx = _pctx_cache.read(target, _pctx_cache_key)
2798
2907
  if _cached_pctx is not None:
2799
2908
  _emit_command_output(_cached_pctx, output_path, copy)
2909
+ try:
2910
+ from sourcecode import telemetry as _tel
2911
+ _tel.record(
2912
+ "execution_completed",
2913
+ cmd="prepare-context",
2914
+ feature=task,
2915
+ output_fmt=format,
2916
+ duration_s=0.0,
2917
+ )
2918
+ except Exception:
2919
+ pass
2800
2920
  return
2801
2921
 
2802
2922
  builder = TaskContextBuilder(target)
@@ -3192,6 +3312,18 @@ def prepare_context_cmd(
3192
3312
 
3193
3313
  _emit_command_output(_pc_content, output_path, copy)
3194
3314
 
3315
+ try:
3316
+ from sourcecode import telemetry as _tel
3317
+ _tel.record(
3318
+ "execution_completed",
3319
+ cmd="prepare-context",
3320
+ feature=task,
3321
+ output_fmt=format,
3322
+ duration_s=_time.perf_counter() - _t0,
3323
+ )
3324
+ except Exception:
3325
+ pass
3326
+
3195
3327
  from sourcecode.mcp_nudge import nudge_mcp_if_needed as _nudge
3196
3328
  _nudge()
3197
3329
 
@@ -5282,26 +5414,9 @@ def activate_cmd(
5282
5414
 
5283
5415
 
5284
5416
  # ---------------------------------------------------------------------------
5285
- # Auth commands (device-flow login / status / logout)
5417
+ # Auth commands (status / logout)
5286
5418
  # ---------------------------------------------------------------------------
5287
5419
 
5288
- @auth_app.command("login")
5289
- def auth_login_cmd() -> None:
5290
- """Authenticate via browser (device code flow).
5291
-
5292
- \b
5293
- The CLI shows a URL. Open it in your browser, log in with your account,
5294
- and the CLI completes authentication automatically.
5295
- Credentials are stored in ~/.sourcecode/license.json (30-min cache; Supabase is source of truth).
5296
-
5297
- \b
5298
- Examples:
5299
- sourcecode auth login
5300
- """
5301
- from sourcecode.license import auth_login as _auth_login
5302
- _auth_login()
5303
-
5304
-
5305
5420
  @auth_app.command("status")
5306
5421
  def auth_status_cmd() -> None:
5307
5422
  """Show current authentication and plan status."""
sourcecode/license.py CHANGED
@@ -71,9 +71,6 @@ _DELTA_FREE_LIMIT: int = 30
71
71
  # Hybrid model size limit: repos at/under this many Java source files are fully
72
72
  # free (every command, no caps). Above it = enterprise-scale monolith = Pro.
73
73
  _FREE_REPO_JAVA_FILE_LIMIT: int = 500
74
- _DEVICE_POLL_INTERVAL_S: float = 2.5
75
- _DEVICE_POLL_TIMEOUT_S: float = 300.0 # 5-minute window for user to complete browser auth
76
- _AUTH_BASE_URL: str = "https://sourcecode.dev"
77
74
  _LICENSE_KEY_RE = re.compile(r"^[A-Za-z0-9_\-]{1,200}$")
78
75
 
79
76
  # ---------------------------------------------------------------------------
@@ -243,78 +240,6 @@ def _call_get_license(license_key: str) -> Optional[dict]:
243
240
  return None # Network error — caller decides what to do
244
241
 
245
242
 
246
- def _generate_device_code() -> str:
247
- """Generate a human-readable device code: XXXX-XXXX-XXXX."""
248
- import uuid
249
- raw = uuid.uuid4().hex.upper()
250
- return f"{raw[:4]}-{raw[4:8]}-{raw[8:12]}"
251
-
252
-
253
- def _call_device_check(device_code: str) -> Optional[dict]:
254
- """Poll /device-check edge function. Returns dict or None on network error.
255
-
256
- Expected responses:
257
- {"status": "pending"}
258
- {"status": "complete", "device_token": "...", "email": "...", "plan": "pro", ...}
259
- {"status": "error", "message": "..."}
260
- """
261
- import urllib.error
262
- import urllib.request
263
-
264
- if not _SUPABASE_ANON_KEY:
265
- return None
266
-
267
- url = f"{_SUPABASE_URL}/functions/v1/device-check"
268
- body = json.dumps({"device_code": device_code}).encode("utf-8")
269
- req = urllib.request.Request(url, data=body, method="POST")
270
- req.add_header("apikey", _SUPABASE_ANON_KEY)
271
- req.add_header("Authorization", f"Bearer {_SUPABASE_ANON_KEY}")
272
- req.add_header("Content-Type", "application/json")
273
- req.add_header("Accept", "application/json")
274
- try:
275
- with urllib.request.urlopen(req, timeout=8) as resp:
276
- return json.loads(resp.read().decode("utf-8"))
277
- except urllib.error.HTTPError as exc:
278
- try:
279
- return json.loads(exc.read().decode("utf-8", errors="replace"))
280
- except Exception:
281
- return {"status": "error", "message": f"HTTP {exc.code}"}
282
- except Exception:
283
- return None
284
-
285
-
286
- def _call_get_user_plan(device_token: str) -> Optional[dict]:
287
- """Fetch current plan/status for an authenticated device token.
288
-
289
- Expected response:
290
- {"valid": true, "plan": "pro", "status": "active", "features": [...], "email": "..."}
291
- {"valid": false, "error": "token_revoked"}
292
- """
293
- import urllib.error
294
- import urllib.request
295
-
296
- if not _SUPABASE_ANON_KEY:
297
- return None
298
-
299
- url = f"{_SUPABASE_URL}/functions/v1/get-user-plan"
300
- body = json.dumps({"device_token": device_token}).encode("utf-8")
301
- req = urllib.request.Request(url, data=body, method="POST")
302
- req.add_header("apikey", _SUPABASE_ANON_KEY)
303
- req.add_header("Authorization", f"Bearer {_SUPABASE_ANON_KEY}")
304
- req.add_header("Content-Type", "application/json")
305
- req.add_header("Accept", "application/json")
306
- try:
307
- with urllib.request.urlopen(req, timeout=8) as resp:
308
- return json.loads(resp.read().decode("utf-8"))
309
- except urllib.error.HTTPError as exc:
310
- try:
311
- return json.loads(exc.read().decode("utf-8", errors="replace"))
312
- except Exception:
313
- return {"valid": False, "error": f"HTTP {exc.code}"}
314
- except Exception:
315
- return None
316
-
317
-
318
243
  def _maybe_revalidate() -> None:
319
244
  """Re-validate cached license if stale. Mutates globals; never raises."""
320
245
  global _license_data, is_pro
@@ -338,39 +263,7 @@ def _maybe_revalidate() -> None:
338
263
  except Exception:
339
264
  pass
340
265
 
341
- auth_method = _license_data.get("auth_method")
342
-
343
- if auth_method == "device_flow":
344
- device_token = _license_data.get("device_token")
345
- if not device_token:
346
- return
347
- result = _call_get_user_plan(device_token)
348
- if result is None:
349
- return # Network error — keep cached (offline-first)
350
- if not result.get("valid", True):
351
- _license_data = None
352
- is_pro = False
353
- try:
354
- if _LICENSE_FILE.exists():
355
- _LICENSE_FILE.unlink()
356
- except Exception:
357
- pass
358
- return
359
- _license_data["plan"] = result.get("plan", "free")
360
- _license_data["status"] = result.get("status", "active")
361
- _license_data["features"] = result.get("features", [])
362
- _license_data["validated_at"] = datetime.now(timezone.utc).isoformat()
363
- is_pro = (
364
- _license_data.get("plan") == "pro"
365
- and _license_data.get("status", "active") != "inactive"
366
- )
367
- try:
368
- _write_license_file(_license_data)
369
- except Exception:
370
- pass
371
- return
372
-
373
- # Key-based auth (existing flow / legacy)
266
+ # Key-based auth
374
267
  key = _license_data.get("license_key")
375
268
  if not key:
376
269
  return
@@ -416,6 +309,15 @@ _init()
416
309
  # Entitlement helpers
417
310
  # ---------------------------------------------------------------------------
418
311
 
312
+ def _emit_telemetry(event: str, **kw: object) -> None:
313
+ """Best-effort telemetry emit. Respects opt-in; never raises or blocks."""
314
+ try:
315
+ from sourcecode import telemetry as _tel
316
+ _tel.record(event, **kw) # type: ignore[arg-type]
317
+ except Exception:
318
+ pass
319
+
320
+
419
321
  def can_use(feature_name: str) -> bool:
420
322
  """Return True if the current plan has access to feature_name.
421
323
 
@@ -510,6 +412,7 @@ def require_feature(
510
412
  }
511
413
  if extra_fields:
512
414
  payload.update(extra_fields)
415
+ _emit_telemetry("gate_blocked", feature=feature_name, success=False)
513
416
  _emit_upgrade_and_exit(
514
417
  f"'{display}' is a Pro feature.",
515
418
  [info.get("description", ""), info.get("value", "")],
@@ -560,6 +463,7 @@ def require_repo_or_pro(
560
463
  }
561
464
  if extra_fields:
562
465
  payload.update(extra_fields)
466
+ _emit_telemetry("gate_blocked", feature=feature_name, repo_size="large", success=False)
563
467
  _emit_upgrade_and_exit(headline, body, payload)
564
468
 
565
469
 
@@ -574,105 +478,7 @@ def require_pro(feature_name: str) -> None:
574
478
 
575
479
 
576
480
  # ---------------------------------------------------------------------------
577
- # Device-flow authentication
578
- # ---------------------------------------------------------------------------
579
-
580
- def _finish_device_auth(result: dict) -> None:
581
- """Persist device-flow credentials and emit success JSON. Exits on error."""
582
- global _license_data, is_pro
583
-
584
- device_token = result.get("device_token") or result.get("access_token") or ""
585
- email = result.get("email", "")
586
- plan = result.get("plan", "free")
587
- plan_status = (
588
- result.get("status_detail")
589
- or result.get("user_status")
590
- or result.get("status", "active")
591
- )
592
- features = result.get("features") or []
593
-
594
- if not device_token:
595
- sys.stderr.write("\n")
596
- _fail("auth_error", "Authentication completed but no session token received. Contact support.")
597
-
598
- _LICENSE_DIR.mkdir(parents=True, exist_ok=True)
599
- now = datetime.now(timezone.utc).isoformat()
600
- data: dict = {
601
- "auth_method": "device_flow",
602
- "device_token": device_token,
603
- "email": email,
604
- "plan": plan,
605
- "status": plan_status,
606
- "features": features,
607
- "authenticated_at": now,
608
- "validated_at": now,
609
- }
610
- _write_license_file(data)
611
- _license_data = data
612
- is_pro = plan == "pro" and plan_status != "inactive"
613
-
614
- sys.stderr.write(f"\n Authenticated as {email}. Plan: {plan}\n\n")
615
- sys.stderr.flush()
616
-
617
- output: dict = {"status": "authenticated", "email": email, "plan": plan, "pro": is_pro}
618
- if not is_pro:
619
- output["upgrade_hint"] = "https://sourcecode.dev/pricing"
620
- else:
621
- output["features"] = features
622
- sys.stdout.write(json.dumps(output, ensure_ascii=False) + "\n")
623
- sys.stdout.flush()
624
-
625
-
626
- def auth_login() -> None:
627
- """Device code authentication flow.
628
-
629
- Shows a browser URL; polls the backend every 2.5 s until the user
630
- completes authentication or the 5-minute window expires.
631
- Writes credentials to ~/.sourcecode/license.json on success.
632
- Exits 0 on success, 1 on any failure.
633
- """
634
- import time
635
-
636
- device_code = _generate_device_code()
637
- activate_url = f"{_AUTH_BASE_URL}/activate?code={device_code}"
638
-
639
- sys.stderr.write(f"\n Open this URL to authenticate:\n {activate_url}\n\n Waiting")
640
- sys.stderr.flush()
641
-
642
- deadline = time.monotonic() + _DEVICE_POLL_TIMEOUT_S
643
- _tick = 0
644
-
645
- while time.monotonic() < deadline:
646
- time.sleep(_DEVICE_POLL_INTERVAL_S)
647
- _tick += 1
648
- if _tick % 4 == 0:
649
- sys.stderr.write(".")
650
- sys.stderr.flush()
651
-
652
- result = _call_device_check(device_code)
653
- if result is None:
654
- continue # network blip — keep polling
655
-
656
- status = result.get("status")
657
- if status == "pending":
658
- continue
659
-
660
- if status == "complete":
661
- _finish_device_auth(result)
662
- return
663
-
664
- if status == "error" or result.get("error"):
665
- sys.stderr.write("\n")
666
- _fail("auth_error", result.get("message") or result.get("error") or "Authentication failed.")
667
-
668
- # Unknown status — keep polling
669
-
670
- sys.stderr.write("\n")
671
- _fail("auth_timeout", "Authentication timed out after 5 minutes. Please try again.")
672
-
673
-
674
- # ---------------------------------------------------------------------------
675
- # Activation (key-based — legacy / direct key entry)
481
+ # Activation (key-based — direct key entry)
676
482
  # ---------------------------------------------------------------------------
677
483
 
678
484
  def activate_license(license_key: str) -> None:
@@ -693,9 +499,11 @@ def activate_license(license_key: str) -> None:
693
499
  _fail("network_error", "Could not reach license server. Check your internet connection.")
694
500
 
695
501
  if not result.get("valid"):
502
+ _emit_telemetry("activation", feature="key", success=False, error_kind="InvalidLicense")
696
503
  _fail("invalid_license", result.get("error", "License key is not valid or subscription is inactive."))
697
504
 
698
505
  if result.get("plan") != "pro":
506
+ _emit_telemetry("activation", feature="key", success=False, error_kind="NotPro")
699
507
  _fail("not_pro", "This license is not a Pro license.")
700
508
 
701
509
  _LICENSE_DIR.mkdir(parents=True, exist_ok=True)
@@ -709,6 +517,7 @@ def activate_license(license_key: str) -> None:
709
517
  "validated_at": now,
710
518
  }
711
519
  _write_license_file(data)
520
+ _emit_telemetry("activation", feature="key", success=True)
712
521
 
713
522
  output = {"status": "activated", "plan": "pro", "features": data["features"]}
714
523
  sys.stdout.write(json.dumps(output, ensure_ascii=False) + "\n")
@@ -19,7 +19,7 @@ import sys
19
19
  import uuid
20
20
  from typing import Any, Optional
21
21
 
22
- from sourcecode.telemetry.config import is_enabled
22
+ from sourcecode.telemetry.config import get_install_id, is_enabled
23
23
  from sourcecode.telemetry.events import (
24
24
  TelemetryEvent,
25
25
  duration_bucket,
@@ -77,6 +77,8 @@ def record(
77
77
  duration_s: Optional[float] = None,
78
78
  success: bool = True,
79
79
  error_kind: Optional[str] = None,
80
+ feature: Optional[str] = None,
81
+ repo_size: Optional[str] = None,
80
82
  ) -> None:
81
83
  """Record a telemetry event. Fire-and-forget — never blocks or raises.
82
84
 
@@ -96,10 +98,16 @@ def record(
96
98
  cmd=cmd,
97
99
  flags=flags or [],
98
100
  output_fmt=output_fmt,
99
- repo_size=file_count_bucket(file_count) if file_count is not None else "unknown",
101
+ repo_size=(
102
+ repo_size if repo_size is not None
103
+ else file_count_bucket(file_count) if file_count is not None
104
+ else "unknown"
105
+ ),
100
106
  duration=duration_bucket(duration_s) if duration_s is not None else "unknown",
101
107
  success=success,
102
108
  error_kind=error_kind,
109
+ feature=feature,
110
+ install=get_install_id(),
103
111
  session=_SESSION,
104
112
  )
105
113
  payload = sanitize(ev)
@@ -63,5 +63,28 @@ def mark_asked() -> None:
63
63
  _save(data)
64
64
 
65
65
 
66
+ def get_install_id() -> str:
67
+ """Stable anonymous install id — a random UUID v4.
68
+
69
+ Created lazily on first opted-in event. NOT derived from hardware, email,
70
+ hostname or any identifier — it only says "the same install across runs",
71
+ which is what enables unique-user, conversion and retention metrics.
72
+ Returns "" if it cannot be persisted (telemetry then degrades to events
73
+ without a stable id, never an error).
74
+ """
75
+ data = _load()
76
+ tel = data.setdefault("telemetry", {})
77
+ iid = tel.get("install_id")
78
+ if not iid:
79
+ import uuid
80
+ iid = str(uuid.uuid4())
81
+ tel["install_id"] = iid
82
+ _save(data)
83
+ # If the write failed, re-read to avoid handing out a non-persisted id
84
+ if not _load().get("telemetry", {}).get("install_id"):
85
+ return ""
86
+ return str(iid)
87
+
88
+
66
89
  def config_file_path() -> Path:
67
90
  return _CONFIG_FILE
@@ -59,6 +59,9 @@ class TelemetryEvent:
59
59
  duration — <1s | <5s | <15s | <60s | 60s+
60
60
  success — True/False
61
61
  error_kind — exception class name only (no message, no traceback)
62
+ feature — gated feature / task name (closed categorical set) or None
63
+ install — stable anonymous install UUID (random, no PII); enables
64
+ unique-user / conversion / retention metrics
62
65
  session — 8-char random hex, ephemeral, NOT persisted
63
66
  """
64
67
 
@@ -75,4 +78,6 @@ class TelemetryEvent:
75
78
  duration: str = "unknown"
76
79
  success: bool = True
77
80
  error_kind: Optional[str] = None
81
+ feature: Optional[str] = None
82
+ install: str = ""
78
83
  session: str = ""
@@ -63,6 +63,19 @@ _SAFE_EVENTS: frozenset[str] = frozenset({
63
63
  "execution_failed",
64
64
  "telemetry_enabled",
65
65
  "telemetry_disabled",
66
+ "gate_blocked",
67
+ "activation",
68
+ })
69
+ # Closed set of gated features / task names. Used to learn which capability
70
+ # drives Pro demand. All values are fixed product identifiers — no user data.
71
+ _SAFE_FEATURES: frozenset[str] = frozenset({
72
+ # gated features (license._FEATURE_INFO keys)
73
+ "impact", "modernize", "fix-bug", "review-pr", "delta", "generate-tests",
74
+ "--full", "git-history", "multi-repo", "export-rich", "team-snapshots",
75
+ # prepare-context task names not already above
76
+ "explain", "onboard", "refactor",
77
+ # activation outcomes
78
+ "key",
66
79
  })
67
80
  _SAFE_SIZES: frozenset[str] = frozenset({"tiny", "small", "medium", "large", "huge", "unknown"})
68
81
  _SAFE_DURATIONS: frozenset[str] = frozenset({"<1s", "<5s", "<15s", "<60s", "60s+", "unknown"})
@@ -103,6 +116,14 @@ def _safe_session(value: str) -> str:
103
116
  return ""
104
117
 
105
118
 
119
+ _UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
120
+
121
+
122
+ def _safe_install(value: str) -> str:
123
+ """Install id must be a canonical UUID — nothing else can pass."""
124
+ return value if value and _UUID_RE.match(value) else ""
125
+
126
+
106
127
  def sanitize(event: TelemetryEvent) -> dict[str, Any]:
107
128
  """Apply privacy filter to event and return a safe dict for transmission.
108
129
 
@@ -127,6 +148,13 @@ def sanitize(event: TelemetryEvent) -> dict[str, Any]:
127
148
  if event.error_kind:
128
149
  safe["error_kind"] = _safe_error_kind(event.error_kind)
129
150
 
151
+ if event.feature:
152
+ safe["feature"] = _safe_str(event.feature, _SAFE_FEATURES, "other")
153
+
154
+ install = _safe_install(event.install)
155
+ if install:
156
+ safe["install"] = install
157
+
130
158
  session = _safe_session(event.session)
131
159
  if session:
132
160
  safe["session"] = session
@@ -15,7 +15,7 @@ import os
15
15
  import threading
16
16
  from typing import Any
17
17
 
18
- _DEFAULT_ENDPOINT = "https://t.sourcecode.dev/v1/event"
18
+ _DEFAULT_ENDPOINT = "https://qkndlmyekvujjdgthtmz.supabase.co/functions/v1/telemetry"
19
19
  _TIMEOUT_S = 3
20
20
 
21
21
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.35.36
3
+ Version: 1.36.1
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
40
40
 
41
41
  **Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
42
42
 
43
- ![Version](https://img.shields.io/badge/version-1.35.36-blue)
43
+ ![Version](https://img.shields.io/badge/version-1.36.1-blue)
44
44
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
45
45
 
46
46
  ---
@@ -114,7 +114,7 @@ pipx install sourcecode
114
114
 
115
115
  ```bash
116
116
  sourcecode version
117
- # sourcecode 1.35.36
117
+ # sourcecode 1.36.1
118
118
  ```
119
119
 
120
120
  ---
@@ -311,8 +311,7 @@ when the work gets bigger or automated.
311
311
  files only, by design. sourcecode monetises enterprise Java monoliths.
312
312
 
313
313
  ```bash
314
- sourcecode auth login # browser device-code auth
315
- sourcecode activate <key> # or activate a license key directly
314
+ sourcecode activate <key> # activate a license key
316
315
  ```
317
316
 
318
317
  Full breakdown: [docs/PRODUCT_TIERS.md](docs/PRODUCT_TIERS.md).
@@ -1,13 +1,13 @@
1
- sourcecode/__init__.py,sha256=ZxOECabDmAtGWQ7baAF8kAmS-2Kzj1Q5E1ciWq3lDi8,104
1
+ sourcecode/__init__.py,sha256=mpBzwyDeOFDWE6zNN6TkjBgPX2taBQzVcufgFBu_TN8,103
2
2
  sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
3
3
  sourcecode/architecture_analyzer.py,sha256=liCwQmLgb5vplohy8arjYxs_HOIv5C9MjLh_gY6bc5Q,44115
4
4
  sourcecode/architecture_summary.py,sha256=z34_6v7cSwy98cof2UVciGho7SCrZ93tiqMmq5WNzRQ,20405
5
5
  sourcecode/ast_extractor.py,sha256=sa6CmLpn-k5G3_Hzxn8hAlZ5-TS-EVzXDD0Gvxd2jzs,50613
6
- sourcecode/cache.py,sha256=wAyPrXN5DqiGivnMpeEuun2xHDKfBer2_oBsh6kj_vc,30447
6
+ sourcecode/cache.py,sha256=1V3vsaODAa2UBJAC0xpvxpmRdriCezQx5Q8JCcfgziE,31892
7
7
  sourcecode/canonical_ir.py,sha256=c_lYTVoegg-1W2dZ34_2s3tN8L0GVx7eiDRh9ghdSD8,24178
8
8
  sourcecode/cir_graphs.py,sha256=rZi8JV4ZrAa2WSCeyNa4JIEKQ_yZzDZTsrvVz2KfuKA,8919
9
9
  sourcecode/classifier.py,sha256=hKzg-nQ47htqqIUzSGvYxv15cXrA3KgICTwJmdqal0o,8095
10
- sourcecode/cli.py,sha256=5LrX0Fwp1Wqw7yE3hrXc26TOPf_A2vod7X6SG1s_0ag,247606
10
+ sourcecode/cli.py,sha256=76_rdkUKpm9fGP-97rx5Q25q2KXzW030fDpDZiwCnIA,252320
11
11
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
12
12
  sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
13
13
  sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
@@ -27,7 +27,7 @@ sourcecode/flow_analyzer.py,sha256=dSiuY4w49k29jW_EPXUOND9B5uVbuCA7kjnuHi-pIWA,2
27
27
  sourcecode/fqn_utils.py,sha256=XLU7zDkNBXz_RZkIUNfpPmp1nekWtqP-fxV92tDV1vg,2158
28
28
  sourcecode/git_analyzer.py,sha256=JStxTQXNjBWi_wLdwhsZs9mT-v50cSJIz4Agzn6Kh9I,13362
29
29
  sourcecode/graph_analyzer.py,sha256=DHR8fY69oU_Pi4SYaWboX6EoEFrctQKB9dsjpqwGMzw,62403
30
- sourcecode/license.py,sha256=iYtczUa4xh4-Y-2pMlfa6T7t1g-4nuld6-_PXT-Cq5Q,27299
30
+ sourcecode/license.py,sha256=i_X1bYdobL_z9OVuLiycnWEFSaaNhcKKuTd6G55U3_k,20747
31
31
  sourcecode/mcp_nudge.py,sha256=5ELU_ixzh6uA83NXLOZT8h00OhL53okfQdji3jyKOjg,2917
32
32
  sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
33
33
  sourcecode/migrate_check.py,sha256=vowVIAxVaHU8vhZUEt-HrWrWM38m6a5INHJQGjEg5E0,55390
@@ -90,14 +90,14 @@ sourcecode/mcp/onboarding/applier.py,sha256=B9CneieWTpaDSDIyW3S5nrlRlBpvfqUcgi93
90
90
  sourcecode/mcp/onboarding/backup.py,sha256=ihqGOR8QTX8HASRSEDyfFyXr5bkXrygPHamv4p9KTmk,1452
91
91
  sourcecode/mcp/onboarding/detector.py,sha256=kDc0U6kXMuq_GivqwKrgJzIVLVeoLr3RQl63ksW10I8,3327
92
92
  sourcecode/mcp/onboarding/planner.py,sha256=Fopg5f72FDiPfldF7NOxYjcBA_w8hi_jBJpSz39lPb8,1332
93
- sourcecode/telemetry/__init__.py,sha256=M0eQZFNkmJiLbI_oNP4QEXwVju1dQ2d4P-E1-Bw8PxE,3116
94
- sourcecode/telemetry/config.py,sha256=Pir0WHp4z-9Qclnn2NDZ3vwitqsMkOAJckmwjUSxrk4,1795
93
+ sourcecode/telemetry/__init__.py,sha256=rth1GuU9Tqt6BvbOe6q6sro1yCygiDW4dN3r1OvmvQM,3375
94
+ sourcecode/telemetry/config.py,sha256=_MfMevIic1NTc8IRmCzQs96D8KPBLOWZ5cdhWrnHuwI,2639
95
95
  sourcecode/telemetry/consent.py,sha256=wLMvGNJeSSyZoNkQXpoUioY6mMv4Qdvuw7S9jAEWnII,2237
96
- sourcecode/telemetry/events.py,sha256=oEvvulfsv5GIDWG2174gSS6tNB95w38AIYiYeifGKlE,2294
97
- sourcecode/telemetry/filters.py,sha256=Asa71oRl7q3Wt_FMwuufIZJFzSYdgRNKS8LHCIyFeYE,4805
98
- sourcecode/telemetry/transport.py,sha256=QSslxIwij8YkRWcVvxykODDrkiN_GAAEu3dUP7KIWeE,1651
99
- sourcecode-1.35.36.dist-info/METADATA,sha256=I2uv7cJb0-J0u-7RNSvPYjiExMkJIwjHqlLnOCcN618,30386
100
- sourcecode-1.35.36.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
101
- sourcecode-1.35.36.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
102
- sourcecode-1.35.36.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
103
- sourcecode-1.35.36.dist-info/RECORD,,
96
+ sourcecode/telemetry/events.py,sha256=LtzYfaX9Ilckj5PTvAcTpDa9mLqDsYPDUiDkRa58piY,2580
97
+ sourcecode/telemetry/filters.py,sha256=NHa5T-6DaZduQPFuC34jOqHWQgSizM-Ygq8aZ4j19ng,5834
98
+ sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
99
+ sourcecode-1.36.1.dist-info/METADATA,sha256=3B_cemxNmCw3HpB0xSWmblfvftNCGh45Wi5mQvvrdeg,30313
100
+ sourcecode-1.36.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
101
+ sourcecode-1.36.1.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
102
+ sourcecode-1.36.1.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
103
+ sourcecode-1.36.1.dist-info/RECORD,,