@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.
- package/README.md +12 -11
- package/bin/0dai.js +133 -33
- package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
- package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
- package/lib/ai/registry/mcp-catalog.json +98 -0
- package/lib/commands/auth.js +2 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/doctor.js +707 -12
- package/lib/commands/experience.js +40 -5
- package/lib/commands/feedback.js +157 -15
- package/lib/commands/gh.js +26 -0
- package/lib/commands/graph.js +9 -4
- package/lib/commands/heatmap.js +1 -1
- package/lib/commands/init.js +298 -27
- package/lib/commands/mcp.js +111 -33
- package/lib/commands/models.js +138 -41
- package/lib/commands/play.js +20 -4
- package/lib/commands/provider.js +30 -59
- package/lib/commands/quota.js +1 -1
- package/lib/commands/receipt.js +1 -1
- package/lib/commands/run.js +14 -6
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +176 -11
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/trust.js +1 -1
- package/lib/commands/update.js +184 -38
- package/lib/commands/usage.js +1 -1
- package/lib/commands/validate.js +32 -3
- package/lib/commands/vault.js +43 -8
- package/lib/python/__init__.py +0 -0
- package/lib/python/agent_quotas.py +525 -0
- package/lib/python/anomaly_alert.py +397 -0
- package/lib/python/anti_pattern_detector.py +799 -0
- package/lib/python/auth.py +443 -0
- package/lib/python/capi_profile_guard.py +477 -0
- package/lib/python/compliance_report.py +581 -0
- package/lib/python/drift_detector.py +388 -0
- package/lib/python/experience_pipeline.py +1130 -0
- package/lib/python/graph.py +19 -0
- package/lib/python/graph_core.py +293 -0
- package/lib/python/graph_io.py +179 -0
- package/lib/python/graph_legacy.py +2052 -0
- package/lib/python/graph_legacy_helpers.py +221 -0
- package/lib/python/graph_outcomes_core.py +85 -0
- package/lib/python/graph_queries.py +171 -0
- package/lib/python/graph_slice.py +198 -0
- package/lib/python/graph_slicer.py +576 -0
- package/lib/python/graph_slicer_cli.py +60 -0
- package/lib/python/graph_validation.py +64 -0
- package/lib/python/heatmap.py +943 -0
- package/lib/python/json_utils.py +193 -0
- package/lib/python/mcp_exposure_check.py +247 -0
- package/lib/python/model_router.py +1434 -0
- package/lib/python/project_manager.py +621 -0
- package/lib/python/provider_profiles.py +1618 -0
- package/lib/python/provider_registry.py +1211 -0
- package/lib/python/provider_registry_cli.py +125 -0
- package/lib/python/receipt_png.py +727 -0
- package/lib/python/structural_memory.py +325 -0
- package/lib/python/swarm_cost.py +177 -0
- package/lib/python/usage_ledger.py +569 -0
- package/lib/scripts/mcp_tier_config.py +240 -0
- package/lib/shared.js +96 -12
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +1 -4
- package/lib/utils/constants.js +7 -1
- package/lib/utils/identity.js +184 -0
- package/lib/utils/mcp-auth.js +81 -15
- package/lib/utils/plan.js +1 -1
- package/lib/vault/index.js +19 -3
- package/lib/vault/storage.js +21 -2
- package/lib/wizard.js +5 -2
- package/package.json +9 -3
- package/scripts/build-python-bundle.js +106 -0
- package/scripts/build-tui.js +14 -1
- package/scripts/harvest_experience.py +523 -0
- 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())
|