@0dai-dev/cli 4.3.6 → 4.3.7

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