@0dai-dev/cli 4.3.6 → 4.3.8

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 (77) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +133 -33
  3. package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
  4. package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
  5. package/lib/ai/registry/mcp-catalog.json +98 -0
  6. package/lib/commands/auth.js +2 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/doctor.js +707 -12
  9. package/lib/commands/experience.js +40 -5
  10. package/lib/commands/feedback.js +157 -15
  11. package/lib/commands/gh.js +26 -0
  12. package/lib/commands/graph.js +9 -4
  13. package/lib/commands/heatmap.js +1 -1
  14. package/lib/commands/init.js +298 -27
  15. package/lib/commands/mcp.js +111 -33
  16. package/lib/commands/models.js +138 -41
  17. package/lib/commands/play.js +20 -4
  18. package/lib/commands/provider.js +30 -59
  19. package/lib/commands/quota.js +1 -1
  20. package/lib/commands/receipt.js +1 -1
  21. package/lib/commands/run.js +14 -6
  22. package/lib/commands/runner.js +31 -1
  23. package/lib/commands/status.js +176 -11
  24. package/lib/commands/swarm.js +130 -12
  25. package/lib/commands/trust.js +1 -1
  26. package/lib/commands/update.js +184 -38
  27. package/lib/commands/usage.js +1 -1
  28. package/lib/commands/validate.js +32 -3
  29. package/lib/commands/vault.js +43 -8
  30. package/lib/python/__init__.py +0 -0
  31. package/lib/python/agent_quotas.py +525 -0
  32. package/lib/python/anomaly_alert.py +397 -0
  33. package/lib/python/anti_pattern_detector.py +799 -0
  34. package/lib/python/auth.py +443 -0
  35. package/lib/python/capi_profile_guard.py +477 -0
  36. package/lib/python/compliance_report.py +581 -0
  37. package/lib/python/drift_detector.py +388 -0
  38. package/lib/python/experience_pipeline.py +1130 -0
  39. package/lib/python/graph.py +19 -0
  40. package/lib/python/graph_core.py +293 -0
  41. package/lib/python/graph_io.py +179 -0
  42. package/lib/python/graph_legacy.py +2052 -0
  43. package/lib/python/graph_legacy_helpers.py +221 -0
  44. package/lib/python/graph_outcomes_core.py +85 -0
  45. package/lib/python/graph_queries.py +171 -0
  46. package/lib/python/graph_slice.py +198 -0
  47. package/lib/python/graph_slicer.py +576 -0
  48. package/lib/python/graph_slicer_cli.py +60 -0
  49. package/lib/python/graph_validation.py +64 -0
  50. package/lib/python/heatmap.py +943 -0
  51. package/lib/python/json_utils.py +193 -0
  52. package/lib/python/mcp_exposure_check.py +247 -0
  53. package/lib/python/model_router.py +1434 -0
  54. package/lib/python/project_manager.py +621 -0
  55. package/lib/python/provider_profiles.py +1618 -0
  56. package/lib/python/provider_registry.py +1211 -0
  57. package/lib/python/provider_registry_cli.py +125 -0
  58. package/lib/python/receipt_png.py +727 -0
  59. package/lib/python/structural_memory.py +325 -0
  60. package/lib/python/swarm_cost.py +177 -0
  61. package/lib/python/usage_ledger.py +569 -0
  62. package/lib/scripts/mcp_tier_config.py +240 -0
  63. package/lib/shared.js +96 -12
  64. package/lib/tui/index.mjs +35174 -0
  65. package/lib/utils/activation_telemetry.js +1 -4
  66. package/lib/utils/constants.js +7 -1
  67. package/lib/utils/identity.js +184 -0
  68. package/lib/utils/mcp-auth.js +81 -15
  69. package/lib/utils/plan.js +1 -1
  70. package/lib/vault/index.js +19 -3
  71. package/lib/vault/storage.js +21 -2
  72. package/lib/wizard.js +5 -2
  73. package/package.json +9 -3
  74. package/scripts/build-python-bundle.js +106 -0
  75. package/scripts/build-tui.js +14 -1
  76. package/scripts/harvest_experience.py +523 -0
  77. package/scripts/postinstall.js +15 -9
@@ -0,0 +1,943 @@
1
+ #!/usr/bin/env python3
2
+ """Repo heatmap: LOC x agent-edit intensity.
3
+
4
+ The heatmap is intentionally split into two layers:
5
+ - repository structure + LOC provide the treemap area
6
+ - swarm task history provides the intensity signal
7
+
8
+ Modes:
9
+ edits Completed task touches per file
10
+ failures Failed task touches per file
11
+ authorship Distinct agents touching a file
12
+
13
+ The script can emit JSON for the dashboard, print a concise summary for the
14
+ CLI, and export a 1200x630 PNG for shareable snapshots.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import math
21
+ import pathlib
22
+ import re
23
+ import subprocess
24
+ import time
25
+ from collections import defaultdict
26
+ from dataclasses import dataclass, field
27
+ from typing import Any, Iterable
28
+
29
+ try: # Pillow is needed ONLY for `--png`; the text + `--json` paths never touch it.
30
+ from PIL import Image, ImageColor, ImageDraw, ImageFont
31
+ except ModuleNotFoundError: # npm-installed users may lack Pillow — degrade, don't crash at import.
32
+ Image = ImageColor = ImageDraw = ImageFont = None # type: ignore[assignment]
33
+
34
+
35
+ SUPPORTED_MODES = ("edits", "failures", "authorship")
36
+
37
+ RENDER_SKIP_PREFIXES = {
38
+ ".git",
39
+ ".next",
40
+ ".tox",
41
+ ".venv",
42
+ "build",
43
+ "coverage",
44
+ "dist",
45
+ "node_modules",
46
+ "venv",
47
+ }
48
+
49
+ RENDER_SKIP_DIR_PREFIXES = (
50
+ "ai/swarm/",
51
+ "ai/outcomes/tasks/",
52
+ "ai/experience/events/",
53
+ )
54
+
55
+ RENDER_SKIP_SUFFIXES = {
56
+ ".gif",
57
+ ".ico",
58
+ ".jpg",
59
+ ".jpeg",
60
+ ".lock",
61
+ ".mp4",
62
+ ".pdf",
63
+ ".png",
64
+ ".sqlite",
65
+ ".sqlite3",
66
+ ".svg",
67
+ ".tar",
68
+ ".tgz",
69
+ ".webp",
70
+ ".woff",
71
+ ".woff2",
72
+ ".zip",
73
+ ".jsonl",
74
+ }
75
+
76
+ TEXT_PATH_RE = re.compile(
77
+ r"""
78
+ (?P<path>
79
+ (?:file://)?(?:/[^\s)\]\}<>]+)+\.[A-Za-z0-9]{1,8}
80
+ |
81
+ (?:\./|\../)?(?:[\w.-]+/)+[\w.-]+\.[A-Za-z0-9]{1,8}
82
+ |
83
+ [\w.-]+\.[A-Za-z0-9]{1,8}
84
+ )
85
+ """,
86
+ re.VERBOSE,
87
+ )
88
+
89
+ NO_EDIT_PHRASES = (
90
+ "analysis only",
91
+ "did not modify",
92
+ "no files modified",
93
+ "no files were modified",
94
+ "read only",
95
+ "read-only",
96
+ "report only",
97
+ )
98
+
99
+ MODE_INFO = {
100
+ "edits": {
101
+ "label": "completed task touches",
102
+ "summary": "Completed swarm tasks that touched a file",
103
+ },
104
+ "failures": {
105
+ "label": "failed task touches",
106
+ "summary": "Failed swarm tasks that touched a file",
107
+ },
108
+ "authorship": {
109
+ "label": "distinct agent authors",
110
+ "summary": "Distinct agents that touched a file",
111
+ },
112
+ }
113
+
114
+ PALETTE = [
115
+ (15, 23, 42), # slate
116
+ (8, 145, 178), # cyan
117
+ (245, 158, 11), # amber
118
+ (239, 68, 68), # red
119
+ ]
120
+
121
+ DEFAULT_WIDTH = 1200
122
+ DEFAULT_HEIGHT = 630
123
+ HEADER_HEIGHT = 98
124
+ FOOTER_HEIGHT = 70
125
+ PADDING = 14
126
+
127
+
128
+ @dataclass
129
+ class TreeNode:
130
+ name: str
131
+ path: str
132
+ kind: str = "dir"
133
+ loc: float = 0.0
134
+ weight: float = 0.0
135
+ intensity: float = 0.0
136
+ agents: set[str] = field(default_factory=set)
137
+ children: dict[str, "TreeNode"] = field(default_factory=dict)
138
+ x: float = 0.0
139
+ y: float = 0.0
140
+ width: float = 0.0
141
+ height: float = 0.0
142
+
143
+
144
+ def _repo_root(target: pathlib.Path | str) -> pathlib.Path:
145
+ return pathlib.Path(target).expanduser().resolve()
146
+
147
+
148
+ def _utc_now_iso() -> str:
149
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
150
+
151
+
152
+ def _run_git(target: pathlib.Path, *args: str) -> str:
153
+ try:
154
+ result = subprocess.run(
155
+ ["git", *args],
156
+ cwd=str(target),
157
+ capture_output=True,
158
+ text=True,
159
+ encoding="utf-8",
160
+ errors="replace",
161
+ check=False,
162
+ timeout=30,
163
+ )
164
+ except Exception: # noqa: BLE001 — git subprocess error
165
+ return ""
166
+ if result.returncode != 0:
167
+ return ""
168
+ return result.stdout
169
+
170
+
171
+ def _git_file_list(target: pathlib.Path) -> list[str]:
172
+ raw = _run_git(
173
+ target,
174
+ "ls-files",
175
+ "-z",
176
+ "--cached",
177
+ "--others",
178
+ "--exclude-standard",
179
+ )
180
+ if not raw:
181
+ return []
182
+ return [entry for entry in raw.split("\0") if entry]
183
+
184
+
185
+ def _walk_file_list(target: pathlib.Path) -> list[str]:
186
+ files: list[str] = []
187
+ for item in target.rglob("*"):
188
+ if not item.is_file():
189
+ continue
190
+ try:
191
+ rel = item.relative_to(target)
192
+ except ValueError:
193
+ continue
194
+ files.append(rel.as_posix())
195
+ return files
196
+
197
+
198
+ def _should_render_file(rel_path: str) -> bool:
199
+ if not rel_path or rel_path.endswith("/"):
200
+ return False
201
+ path = pathlib.PurePosixPath(rel_path)
202
+ if any(part in RENDER_SKIP_PREFIXES for part in path.parts):
203
+ return False
204
+ if any(rel_path.startswith(prefix) for prefix in RENDER_SKIP_DIR_PREFIXES):
205
+ return False
206
+ if path.suffix.lower() in RENDER_SKIP_SUFFIXES:
207
+ return False
208
+ return True
209
+
210
+
211
+ def _read_text(path: pathlib.Path) -> str | None:
212
+ try:
213
+ data = path.read_bytes()
214
+ except OSError:
215
+ return None
216
+ if b"\0" in data[:4096]:
217
+ return None
218
+ try:
219
+ return data.decode("utf-8", errors="replace")
220
+ except UnicodeDecodeError:
221
+ return None
222
+
223
+
224
+ def _count_loc(text: str | None) -> int:
225
+ if not text:
226
+ return 0
227
+ return sum(1 for line in text.splitlines() if line.strip())
228
+
229
+
230
+ def _iter_string_values(value: Any) -> Iterable[str]:
231
+ if isinstance(value, str):
232
+ yield value
233
+ elif isinstance(value, dict):
234
+ for item in value.values():
235
+ yield from _iter_string_values(item)
236
+ elif isinstance(value, list):
237
+ for item in value:
238
+ yield from _iter_string_values(item)
239
+
240
+
241
+ def _normalize_candidate(raw: str, repo_root: pathlib.Path) -> str | None:
242
+ value = raw.strip().strip("()[]{}<>\"'`")
243
+ if not value:
244
+ return None
245
+ if value.startswith("file://"):
246
+ value = value[7:]
247
+ value = value.split("?", 1)[0].split("#", 1)[0]
248
+ if not value:
249
+ return None
250
+
251
+ candidate = pathlib.Path(value)
252
+ if candidate.is_absolute():
253
+ try:
254
+ candidate = candidate.resolve().relative_to(repo_root)
255
+ except (OSError, ValueError):
256
+ text = candidate.as_posix()
257
+ if text.startswith(repo_root.as_posix() + "/"):
258
+ text = text[len(repo_root.as_posix()) + 1 :]
259
+ candidate = pathlib.Path(text)
260
+ else:
261
+ return None
262
+ else:
263
+ candidate = pathlib.Path(value)
264
+
265
+ return candidate.as_posix()
266
+
267
+
268
+ def _extract_file_refs(record: dict[str, Any], repo_root: pathlib.Path, renderable_files: set[str]) -> list[str]:
269
+ refs: list[str] = []
270
+ seen: set[str] = set()
271
+
272
+ def add_candidate(raw: str) -> None:
273
+ normalized = _normalize_candidate(raw, repo_root)
274
+ if not normalized:
275
+ return
276
+ if normalized not in renderable_files:
277
+ return
278
+ if normalized in seen:
279
+ return
280
+ seen.add(normalized)
281
+ refs.append(normalized)
282
+
283
+ # Prefer explicit file lists when present.
284
+ context = record.get("context") if isinstance(record.get("context"), dict) else {}
285
+ explicit_lists = []
286
+ if isinstance(context, dict):
287
+ for key in ("files", "file_paths", "targets"):
288
+ values = context.get(key)
289
+ if isinstance(values, list):
290
+ explicit_lists.extend(str(item) for item in values if item)
291
+ for key in ("files_modified", "files", "paths"):
292
+ values = record.get(key)
293
+ if isinstance(values, list):
294
+ explicit_lists.extend(str(item) for item in values if item)
295
+
296
+ for item in explicit_lists:
297
+ add_candidate(item)
298
+
299
+ # Fall back to scanning the text-bearing fields for file-like references.
300
+ for text in _iter_string_values(record):
301
+ for match in TEXT_PATH_RE.finditer(text):
302
+ add_candidate(match.group("path"))
303
+
304
+ return refs
305
+
306
+
307
+ def _normal_status(record: dict[str, Any]) -> str:
308
+ raw = str(record.get("status") or "").strip().lower()
309
+ if raw:
310
+ return raw
311
+ result = str(record.get("result") or "").strip().lower()
312
+ if any(phrase in result for phrase in ("failed", "error", "timeout")):
313
+ return "failed"
314
+ if any(phrase in result for phrase in ("done", "complete", "committed", "implemented")):
315
+ return "done"
316
+ return ""
317
+
318
+
319
+ def _is_read_only(record: dict[str, Any]) -> bool:
320
+ text = " ".join(str(item).lower() for item in _iter_string_values(record))
321
+ return any(phrase in text for phrase in NO_EDIT_PHRASES)
322
+
323
+
324
+ def _agent_name(record: dict[str, Any]) -> str:
325
+ raw = str(record.get("completed_by") or record.get("assigned_to") or record.get("agent") or "").strip()
326
+ return raw.lower() or "unknown"
327
+
328
+
329
+ def _load_task_records(target: pathlib.Path) -> list[dict[str, Any]]:
330
+ records: list[dict[str, Any]] = []
331
+ swarm_dir = target / "ai" / "swarm"
332
+ for section in ("done", "archive"):
333
+ dir_path = swarm_dir / section
334
+ if not dir_path.is_dir():
335
+ continue
336
+ for file_path in sorted(dir_path.glob("*.json")):
337
+ try:
338
+ records.append(json.loads(file_path.read_text(encoding="utf-8")))
339
+ except (OSError, json.JSONDecodeError):
340
+ continue
341
+ return records
342
+
343
+
344
+ def _load_renderable_files(target: pathlib.Path) -> list[str]:
345
+ files = _git_file_list(target)
346
+ if not files:
347
+ files = _walk_file_list(target)
348
+ return sorted({f for f in files if _should_render_file(f)})
349
+
350
+
351
+ def _count_records(records: list[dict[str, Any]], mode: str, repo_root: pathlib.Path, renderable_files: set[str]) -> dict[str, dict[str, Any]]:
352
+ counts: dict[str, dict[str, Any]] = defaultdict(
353
+ lambda: {
354
+ "intensity": 0.0,
355
+ "agents": set(),
356
+ "records": 0,
357
+ "failed": 0,
358
+ "edits": 0,
359
+ }
360
+ )
361
+
362
+ for record in records:
363
+ refs = _extract_file_refs(record, repo_root, renderable_files)
364
+ if not refs:
365
+ continue
366
+
367
+ status = _normal_status(record)
368
+ agent = _agent_name(record)
369
+ read_only = _is_read_only(record)
370
+
371
+ should_count = False
372
+ if mode == "edits":
373
+ should_count = status in {"done", "completed", "success", "partial", "superseded"}
374
+ should_count = should_count and not read_only
375
+ elif mode == "failures":
376
+ should_count = status == "failed"
377
+ elif mode == "authorship":
378
+ should_count = True
379
+
380
+ if not should_count:
381
+ continue
382
+
383
+ for ref in refs:
384
+ bucket = counts[ref]
385
+ bucket["records"] += 1
386
+ bucket["agents"].add(agent)
387
+ if mode == "edits":
388
+ bucket["edits"] += 1
389
+ bucket["intensity"] += 1.0
390
+ elif mode == "failures":
391
+ bucket["failed"] += 1
392
+ bucket["intensity"] += 1.0
393
+ elif mode == "authorship":
394
+ bucket["intensity"] = float(len(bucket["agents"]))
395
+
396
+ return counts
397
+
398
+
399
+ def _build_tree(file_rows: list[dict[str, Any]]) -> TreeNode:
400
+ root = TreeNode(name="root", path="", kind="dir")
401
+ for row in file_rows:
402
+ parts = pathlib.PurePosixPath(str(row["path"])).parts
403
+ if not parts:
404
+ continue
405
+ node = root
406
+ accumulated: list[str] = []
407
+ for part in parts[:-1]:
408
+ accumulated.append(part)
409
+ child = node.children.get(part)
410
+ if child is None:
411
+ child = TreeNode(
412
+ name=part,
413
+ path="/".join(accumulated),
414
+ kind="dir",
415
+ )
416
+ node.children[part] = child
417
+ node = child
418
+ leaf_name = parts[-1]
419
+ accumulated.append(leaf_name)
420
+ leaf = node.children.get(leaf_name)
421
+ if leaf is None:
422
+ leaf = TreeNode(
423
+ name=leaf_name,
424
+ path="/".join(accumulated),
425
+ kind="file",
426
+ )
427
+ node.children[leaf_name] = leaf
428
+ leaf.kind = "file"
429
+ leaf.loc += float(row["loc"])
430
+ leaf.weight += float(row["weight"])
431
+ leaf.intensity += float(row["intensity"])
432
+ leaf.agents.update(str(agent) for agent in row.get("agents", []) if agent)
433
+
434
+ def aggregate(node: TreeNode) -> None:
435
+ if node.kind == "file" and not node.children:
436
+ node.weight = max(node.weight, 1.0 if node.intensity > 0 else 0.0)
437
+ node.loc = float(node.loc)
438
+ return
439
+
440
+ loc = 0.0
441
+ weight = 0.0
442
+ intensity = 0.0
443
+ agents: set[str] = set()
444
+ for child in node.children.values():
445
+ aggregate(child)
446
+ loc += child.loc
447
+ weight += child.weight
448
+ intensity += child.intensity
449
+ agents.update(child.agents)
450
+ node.loc = loc
451
+ node.weight = weight
452
+ node.intensity = intensity
453
+ node.agents = agents
454
+
455
+ aggregate(root)
456
+ return root
457
+
458
+
459
+ def _worst_ratio(row: list[float], side: float) -> float:
460
+ if not row or side <= 0:
461
+ return math.inf
462
+ total = sum(row)
463
+ max_item = max(row)
464
+ min_item = min(row)
465
+ total_sq = total * total
466
+ side_sq = side * side
467
+ return max((side_sq * max_item) / total_sq, total_sq / (side_sq * min_item))
468
+
469
+
470
+ def _layout_row(row: list[float], x: float, y: float, width: float, height: float) -> tuple[list[tuple[float, float, float, float]], tuple[float, float, float, float]]:
471
+ rects: list[tuple[float, float, float, float]] = []
472
+ if not row or width <= 0 or height <= 0:
473
+ return rects, (x, y, width, height)
474
+
475
+ total = sum(row)
476
+ if width >= height:
477
+ row_height = total / width if width else 0
478
+ cursor_x = x
479
+ for value in row:
480
+ rect_width = value / row_height if row_height else 0
481
+ rects.append((cursor_x, y, rect_width, row_height))
482
+ cursor_x += rect_width
483
+ return rects, (x, y + row_height, width, max(0.0, height - row_height))
484
+
485
+ row_width = total / height if height else 0
486
+ cursor_y = y
487
+ for value in row:
488
+ rect_height = value / row_width if row_width else 0
489
+ rects.append((x, cursor_y, row_width, rect_height))
490
+ cursor_y += rect_height
491
+ return rects, (x + row_width, y, max(0.0, width - row_width), height)
492
+
493
+
494
+ def _squarify(sizes: list[float], x: float, y: float, width: float, height: float) -> list[tuple[float, float, float, float]]:
495
+ if not sizes or width <= 0 or height <= 0:
496
+ return []
497
+ total = sum(sizes)
498
+ if total <= 0:
499
+ return []
500
+ scale = width * height / total
501
+ normalized = sorted((size * scale for size in sizes), reverse=True)
502
+
503
+ rects: list[tuple[float, float, float, float]] = []
504
+ row: list[float] = []
505
+ side = min(width, height)
506
+ remaining = normalized[:]
507
+ current = (x, y, width, height)
508
+
509
+ while remaining:
510
+ item = remaining[0]
511
+ test_row = row + [item]
512
+ if not row or _worst_ratio(test_row, side) <= _worst_ratio(row, side):
513
+ row = test_row
514
+ remaining.pop(0)
515
+ if not remaining:
516
+ row_rects, current = _layout_row(row, *current)
517
+ rects.extend(row_rects)
518
+ row = []
519
+ else:
520
+ row_rects, current = _layout_row(row, *current)
521
+ rects.extend(row_rects)
522
+ row = []
523
+ side = min(current[2], current[3])
524
+
525
+ if row:
526
+ row_rects, current = _layout_row(row, *current)
527
+ rects.extend(row_rects)
528
+
529
+ return rects
530
+
531
+
532
+ def _layout_tree(node: TreeNode, x: float, y: float, width: float, height: float, depth: int = 0) -> None:
533
+ node.x = x
534
+ node.y = y
535
+ node.width = width
536
+ node.height = height
537
+
538
+ if not node.children:
539
+ return
540
+
541
+ child_nodes = sorted(node.children.values(), key=lambda item: (item.weight, item.loc, item.path), reverse=True)
542
+ child_weights = [max(child.weight, 0.5 if child.intensity > 0 else 0.0) for child in child_nodes]
543
+ if sum(child_weights) <= 0:
544
+ return
545
+
546
+ inner_pad = 1.5 if depth < 3 else 1.0
547
+ inner_x = x + inner_pad
548
+ inner_y = y + inner_pad
549
+ inner_w = max(0.0, width - inner_pad * 2)
550
+ inner_h = max(0.0, height - inner_pad * 2)
551
+ rects = _squarify(child_weights, inner_x, inner_y, inner_w, inner_h)
552
+ for child, (cx, cy, cw, ch) in zip(child_nodes, rects):
553
+ _layout_tree(child, cx, cy, cw, ch, depth + 1)
554
+
555
+
556
+ def _collect_leaves(node: TreeNode) -> list[TreeNode]:
557
+ leaves: list[TreeNode] = []
558
+ if not node.children:
559
+ if node.kind == "file":
560
+ leaves.append(node)
561
+ return leaves
562
+ for child in node.children.values():
563
+ leaves.extend(_collect_leaves(child))
564
+ return leaves
565
+
566
+
567
+ def _hex_to_rgb(value: str) -> tuple[int, int, int]:
568
+ return ImageColor.getrgb(value)
569
+
570
+
571
+ def _lerp(a: float, b: float, t: float) -> float:
572
+ return a + (b - a) * t
573
+
574
+
575
+ def _color_for_value(value: float, max_value: float) -> str:
576
+ if max_value <= 0:
577
+ return "#334155"
578
+ if value <= 0:
579
+ return "#233044"
580
+ t = math.log1p(value) / math.log1p(max_value) if max_value > 1 else 1.0
581
+ t = max(0.0, min(1.0, t))
582
+ if t <= 0.33:
583
+ inner = t / 0.33
584
+ left, right = PALETTE[0], PALETTE[1]
585
+ elif t <= 0.66:
586
+ inner = (t - 0.33) / 0.33
587
+ left, right = PALETTE[1], PALETTE[2]
588
+ else:
589
+ inner = (t - 0.66) / 0.34
590
+ left, right = PALETTE[2], PALETTE[3]
591
+ rgb = tuple(int(round(_lerp(left[i], right[i], inner))) for i in range(3))
592
+ return "#%02x%02x%02x" % rgb
593
+
594
+
595
+ def _luminance(hex_value: str) -> float:
596
+ r, g, b = _hex_to_rgb(hex_value)
597
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255.0
598
+
599
+
600
+ def _fit_text(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: float) -> str:
601
+ if draw.textbbox((0, 0), text, font=font)[2] <= max_width:
602
+ return text
603
+ ellipsis = "…"
604
+ clipped = text
605
+ while clipped and draw.textbbox((0, 0), clipped + ellipsis, font=font)[2] > max_width:
606
+ clipped = clipped[:-1]
607
+ return clipped + ellipsis if clipped else ellipsis
608
+
609
+
610
+ def _load_font(size: int, bold: bool = False, mono: bool = False) -> ImageFont.ImageFont:
611
+ candidates = []
612
+ if mono:
613
+ candidates.extend(
614
+ [
615
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
616
+ "/usr/share/fonts/truetype/liberation2/LiberationMono-Regular.ttf",
617
+ ]
618
+ )
619
+ elif bold:
620
+ candidates.extend(
621
+ [
622
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
623
+ "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf",
624
+ ]
625
+ )
626
+ else:
627
+ candidates.extend(
628
+ [
629
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
630
+ "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
631
+ ]
632
+ )
633
+ for candidate in candidates:
634
+ font_path = pathlib.Path(candidate)
635
+ if font_path.exists():
636
+ try:
637
+ return ImageFont.truetype(str(font_path), size=size)
638
+ except OSError:
639
+ continue
640
+ return ImageFont.load_default()
641
+
642
+
643
+ def _flatten_leaf_records(root: TreeNode, mode: str) -> list[dict[str, Any]]:
644
+ leaves = _collect_leaves(root)
645
+ max_intensity = max((leaf.intensity for leaf in leaves), default=0.0)
646
+ max_weight = max((leaf.weight for leaf in leaves), default=0.0)
647
+
648
+ records: list[dict[str, Any]] = []
649
+ for leaf in leaves:
650
+ fill = _color_for_value(leaf.intensity, max_intensity)
651
+ rel_path = leaf.path
652
+ directory = str(pathlib.PurePosixPath(rel_path).parent)
653
+ label = pathlib.PurePosixPath(rel_path).name
654
+ if directory and directory != ".":
655
+ short_group = pathlib.PurePosixPath(directory).parts[0]
656
+ else:
657
+ short_group = "."
658
+ records.append(
659
+ {
660
+ "path": rel_path,
661
+ "directory": directory if directory != "." else "",
662
+ "group": short_group,
663
+ "label": label,
664
+ "loc": int(round(leaf.loc)),
665
+ "weight": float(leaf.weight),
666
+ "intensity": float(round(leaf.intensity, 3)),
667
+ "color": fill,
668
+ "x": round(leaf.x, 2),
669
+ "y": round(leaf.y, 2),
670
+ "width": round(leaf.width, 2),
671
+ "height": round(leaf.height, 2),
672
+ "agents": sorted(leaf.agents),
673
+ "max_weight": float(max_weight),
674
+ }
675
+ )
676
+ return records
677
+
678
+
679
+ def _rank_hotspots(records: list[dict[str, Any]], limit: int = 12) -> list[dict[str, Any]]:
680
+ def score(item: dict[str, Any]) -> float:
681
+ loc = float(item.get("loc") or 0)
682
+ intensity = float(item.get("intensity") or 0)
683
+ return intensity * math.log1p(loc)
684
+
685
+ return sorted(records, key=score, reverse=True)[:limit]
686
+
687
+
688
+ def build_heatmap_payload(target: pathlib.Path | str, mode: str = "edits", width: int = DEFAULT_WIDTH, height: int = DEFAULT_HEIGHT) -> dict[str, Any]:
689
+ if mode not in SUPPORTED_MODES:
690
+ raise ValueError(f"unsupported mode: {mode}")
691
+
692
+ repo_root = _repo_root(target)
693
+ renderable_files = _load_renderable_files(repo_root)
694
+ renderable_set = set(renderable_files)
695
+ task_records = _load_task_records(repo_root)
696
+ counts = _count_records(task_records, mode, repo_root, renderable_set)
697
+
698
+ rows: list[dict[str, Any]] = []
699
+ for rel_path in renderable_files:
700
+ file_path = repo_root / rel_path
701
+ text = _read_text(file_path)
702
+ loc = _count_loc(text)
703
+ intensity_data = counts.get(rel_path)
704
+ intensity = float(intensity_data["intensity"]) if intensity_data else 0.0
705
+ if loc <= 0 and intensity <= 0:
706
+ continue
707
+ weight = float(loc if loc > 0 else 1)
708
+ if intensity <= 0 and weight <= 0:
709
+ continue
710
+ rows.append(
711
+ {
712
+ "path": rel_path,
713
+ "loc": loc,
714
+ "weight": weight,
715
+ "intensity": intensity,
716
+ "agents": sorted(intensity_data["agents"]) if intensity_data else [],
717
+ }
718
+ )
719
+
720
+ tree = _build_tree(rows)
721
+ _layout_tree(tree, 0.0, 0.0, float(width), float(height), 0)
722
+ leaf_records = _flatten_leaf_records(tree, mode)
723
+ hot_spots = _rank_hotspots(leaf_records)
724
+
725
+ total_loc = sum(item["loc"] for item in leaf_records)
726
+ total_intensity = sum(float(item["intensity"]) for item in leaf_records)
727
+ touched_files = sum(1 for item in leaf_records if float(item["intensity"]) > 0)
728
+ max_intensity = max((float(item["intensity"]) for item in leaf_records), default=0.0)
729
+ max_loc = max((int(item["loc"]) for item in leaf_records), default=0)
730
+
731
+ return {
732
+ "mode": mode,
733
+ "mode_label": MODE_INFO[mode]["label"],
734
+ "mode_summary": MODE_INFO[mode]["summary"],
735
+ "generated_at": _utc_now_iso(),
736
+ "root": repo_root.as_posix(),
737
+ "width": width,
738
+ "height": height,
739
+ "legend": {
740
+ "area": "LOC",
741
+ "color": MODE_INFO[mode]["label"],
742
+ },
743
+ "stats": {
744
+ "files": len(leaf_records),
745
+ "total_loc": int(total_loc),
746
+ "total_intensity": round(total_intensity, 3),
747
+ "touched_files": touched_files,
748
+ "max_loc": int(max_loc),
749
+ "max_intensity": round(max_intensity, 3),
750
+ },
751
+ "nodes": leaf_records,
752
+ "top_hotspots": hot_spots,
753
+ }
754
+
755
+
756
+ def _draw_wrapped_text(draw: ImageDraw.ImageDraw, text: str, box: tuple[float, float, float, float], font: ImageFont.ImageFont, fill: str, max_lines: int = 2) -> None:
757
+ x, y, width, height = box
758
+ if width <= 0 or height <= 0:
759
+ return
760
+ max_width = width
761
+ words = text.split()
762
+ if not words:
763
+ return
764
+ lines: list[str] = []
765
+ current = words[0]
766
+ for word in words[1:]:
767
+ candidate = f"{current} {word}"
768
+ if draw.textbbox((0, 0), candidate, font=font)[2] <= max_width:
769
+ current = candidate
770
+ else:
771
+ lines.append(current)
772
+ current = word
773
+ if len(lines) >= max_lines:
774
+ break
775
+ if len(lines) < max_lines:
776
+ lines.append(current)
777
+ while len(lines) > max_lines:
778
+ lines.pop()
779
+ # Shrink the final line if needed.
780
+ if lines and draw.textbbox((0, 0), "\n".join(lines), font=font)[3] > height:
781
+ last = lines[-1]
782
+ lines[-1] = _fit_text(draw, last, font, max_width)
783
+ draw.multiline_text((x, y), "\n".join(lines), font=font, fill=fill, spacing=2)
784
+
785
+
786
+ def render_png(payload: dict[str, Any], output_path: pathlib.Path | str) -> pathlib.Path:
787
+ if Image is None: # Pillow absent (e.g. a fresh `npm install -g` without `pip install pillow`).
788
+ raise SystemExit(
789
+ "0dai heatmap --png needs Pillow, which is not installed.\n"
790
+ "Install it (pip install pillow) for PNG export; the default text and --json "
791
+ "summaries work without it."
792
+ )
793
+ width = int(payload.get("width") or DEFAULT_WIDTH)
794
+ height = int(payload.get("height") or DEFAULT_HEIGHT)
795
+ out = pathlib.Path(output_path).expanduser().resolve()
796
+ out.parent.mkdir(parents=True, exist_ok=True)
797
+
798
+ image = Image.new("RGBA", (width, height), "#0b0f14")
799
+ draw = ImageDraw.Draw(image, "RGBA")
800
+
801
+ # Background accents.
802
+ draw.rounded_rectangle((18, 18, width - 18, HEADER_HEIGHT - 8), radius=26, fill=(13, 17, 24, 230), outline=(255, 255, 255, 18), width=1)
803
+ draw.rounded_rectangle((18, HEADER_HEIGHT + 4, width - 18, height - FOOTER_HEIGHT - 14), radius=24, fill=(9, 13, 20, 255), outline=(255, 255, 255, 14), width=1)
804
+ draw.rounded_rectangle((18, height - FOOTER_HEIGHT + 2, width - 18, height - 12), radius=20, fill=(10, 14, 22, 240), outline=(255, 255, 255, 14), width=1)
805
+
806
+ title_font = _load_font(28, bold=True)
807
+ subtitle_font = _load_font(15)
808
+ stat_font = _load_font(17, bold=True)
809
+ body_font = _load_font(13)
810
+ mono_font = _load_font(12, mono=True)
811
+
812
+ mode = str(payload.get("mode") or "edits")
813
+ mode_label = str(payload.get("mode_label") or mode)
814
+ legend = payload.get("legend") or {}
815
+ stats = payload.get("stats") or {}
816
+
817
+ draw.text((42, 34), "Heatmap", font=title_font, fill="#f8fafc")
818
+ draw.text((42, 66), f"Area = {legend.get('area', 'LOC')} • Color = {mode_label}", font=subtitle_font, fill="#9ca3af")
819
+
820
+ stats_text = [
821
+ f"{stats.get('files', 0)} files",
822
+ f"{stats.get('total_loc', 0)} LOC",
823
+ f"{stats.get('touched_files', 0)} touched",
824
+ ]
825
+ cursor_x = width - 42
826
+ for item in reversed(stats_text):
827
+ bbox = draw.textbbox((0, 0), item, font=stat_font)
828
+ box_w = bbox[2] - bbox[0] + 24
829
+ cursor_x -= box_w
830
+ draw.rounded_rectangle((cursor_x, 36, cursor_x + box_w, 72), radius=16, fill=(17, 24, 39, 240), outline=(255, 255, 255, 18), width=1)
831
+ draw.text((cursor_x + 12, 45), item, font=stat_font, fill="#e2e8f0")
832
+ cursor_x -= 10
833
+
834
+ treemap_top = HEADER_HEIGHT + 18
835
+ treemap_bottom = height - FOOTER_HEIGHT - 22
836
+ treemap_left = 34
837
+ treemap_right = width - 34
838
+ treemap_w = treemap_right - treemap_left
839
+ treemap_h = treemap_bottom - treemap_top
840
+
841
+ nodes = payload.get("nodes") or []
842
+ if not nodes:
843
+ draw.text((treemap_left + 20, treemap_top + 20), "No renderable files found", font=stat_font, fill="#cbd5e1")
844
+ else:
845
+ max_intensity = max((float(node.get("intensity") or 0) for node in nodes), default=0.0)
846
+ for node in nodes:
847
+ x = treemap_left + float(node["x"])
848
+ y = treemap_top + float(node["y"])
849
+ w = float(node["width"])
850
+ h = float(node["height"])
851
+ if w <= 0 or h <= 0:
852
+ continue
853
+ color = str(node["color"])
854
+ fill = _hex_to_rgb(color)
855
+ rect = (x, y, x + w, y + h)
856
+ radius = 12 if min(w, h) > 26 else 7
857
+ draw.rounded_rectangle(rect, radius=radius, fill=(*fill, 230), outline=(255, 255, 255, 18), width=1)
858
+
859
+ if w < 64 or h < 36:
860
+ continue
861
+
862
+ label = str(node.get("label") or node.get("path") or "")
863
+ loc = int(node.get("loc") or 0)
864
+ intensity = float(node.get("intensity") or 0)
865
+ agents = node.get("agents") or []
866
+ display = _fit_text(draw, label, body_font, max(24, w - 14))
867
+ text_color = "#020617" if _luminance(color) > 0.56 else "#f8fafc"
868
+
869
+ _draw_wrapped_text(
870
+ draw,
871
+ display,
872
+ (x + 8, y + 7, max(0, w - 14), max(0, h - 22)),
873
+ body_font,
874
+ text_color,
875
+ max_lines=2,
876
+ )
877
+ if w > 115 and h > 54:
878
+ footer = f"LOC {loc} • intensity {intensity:g}"
879
+ if agents:
880
+ footer += f" • {len(agents)} agent(s)"
881
+ draw.text((x + 8, y + h - 18), _fit_text(draw, footer, mono_font, max(0, w - 16)), font=mono_font, fill=text_color)
882
+
883
+ hotspots = payload.get("top_hotspots") or []
884
+ footer_title = f"Top hotspots • {mode_label}"
885
+ draw.text((42, height - FOOTER_HEIGHT + 16), footer_title, font=subtitle_font, fill="#cbd5e1")
886
+ hot_text = []
887
+ for item in hotspots[:4]:
888
+ hot_text.append(f"{item['label']} ({item['loc']} LOC, {float(item['intensity']):g})")
889
+ draw.text((42, height - FOOTER_HEIGHT + 36), _fit_text(draw, " • ".join(hot_text) or "No hotspots yet", mono_font, width - 84), font=mono_font, fill="#9ca3af")
890
+
891
+ image.save(out, format="PNG")
892
+ return out
893
+
894
+
895
+ def _print_summary(payload: dict[str, Any]) -> None:
896
+ stats = payload.get("stats") or {}
897
+ print(f"Heatmap ({payload.get('mode')})")
898
+ print(
899
+ f" files: {stats.get('files', 0)} "
900
+ f"LOC: {stats.get('total_loc', 0)} "
901
+ f"touched: {stats.get('touched_files', 0)}"
902
+ )
903
+ print(
904
+ f" max LOC: {stats.get('max_loc', 0)} "
905
+ f"max intensity: {stats.get('max_intensity', 0)}"
906
+ )
907
+ print()
908
+ print("Top hotspots:")
909
+ for idx, item in enumerate((payload.get("top_hotspots") or [])[:8], start=1):
910
+ print(
911
+ f" {idx}. {item['path']} "
912
+ f"(LOC {item['loc']}, intensity {float(item['intensity']):g})"
913
+ )
914
+ print()
915
+ print("Export PNG:")
916
+ print(" 0dai heatmap --mode edits --png heatmap.png")
917
+
918
+
919
+ def main(argv: list[str] | None = None) -> int:
920
+ parser = argparse.ArgumentParser(description="Repo heatmap: LOC x agent-edit intensity")
921
+ parser.add_argument("--target", default=".", help="Repository root to analyze")
922
+ parser.add_argument("--mode", default="edits", choices=SUPPORTED_MODES, help="Intensity mode")
923
+ parser.add_argument("--width", type=int, default=DEFAULT_WIDTH)
924
+ parser.add_argument("--height", type=int, default=DEFAULT_HEIGHT)
925
+ parser.add_argument("--json", action="store_true", help="Print JSON to stdout")
926
+ parser.add_argument("--png", default="", help="Write a PNG export to this path")
927
+ args = parser.parse_args(argv)
928
+
929
+ payload = build_heatmap_payload(args.target, mode=args.mode, width=args.width, height=args.height)
930
+
931
+ if args.png:
932
+ render_png(payload, args.png)
933
+
934
+ if args.json:
935
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
936
+ return 0
937
+
938
+ _print_summary(payload)
939
+ return 0
940
+
941
+
942
+ if __name__ == "__main__":
943
+ raise SystemExit(main())