User1342 commited on
Commit
38cbc41
Β·
1 Parent(s): 9eba326

Library page with tested demos; no-agent veil; energy meters and J/token; 15 new skills; external MCP catalogue; py3.10 gate

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. assets/library-demos/academy_guide-1.gif +3 -0
  3. assets/library-demos/algorithmic_art-1.gif +3 -0
  4. assets/library-demos/brand_guidelines-1.gif +3 -0
  5. assets/library-demos/canvas_design-1.gif +3 -0
  6. assets/library-demos/claude_api-1.gif +3 -0
  7. assets/library-demos/create_deck-1.gif +3 -0
  8. assets/library-demos/create_docx-1.gif +3 -0
  9. assets/library-demos/create_pdf-1.gif +3 -0
  10. assets/library-demos/create_xlsx-1.gif +3 -0
  11. assets/library-demos/csv_table-1.gif +3 -0
  12. assets/library-demos/discernment_nudge-1.gif +3 -0
  13. assets/library-demos/frontend_design-1.gif +3 -0
  14. assets/library-demos/make_plan-1.gif +3 -0
  15. assets/library-demos/mcp_builder-1.gif +3 -0
  16. assets/library-demos/review_checklist-1.gif +3 -0
  17. assets/library-demos/skill_scaffold-1.gif +3 -0
  18. assets/library-demos/slack_gif_creator-1.gif +3 -0
  19. assets/library-demos/status_update-1.gif +3 -0
  20. assets/library-demos/theme_factory-1.gif +3 -0
  21. assets/library-demos/web_artifacts_builder-1.gif +3 -0
  22. assets/library-demos/webapp_testing-1.gif +3 -0
  23. distinct_agent/README.md +17 -6
  24. distinct_agent/__main__.py +12 -1
  25. distinct_agent/cli.py +13 -1
  26. distinct_agent/energy.py +217 -0
  27. distinct_agent/models.py +93 -77
  28. distinct_mcp/external.py +275 -0
  29. distinct_server.egg-info/PKG-INFO +14 -0
  30. distinct_server.egg-info/SOURCES.txt +92 -0
  31. distinct_server.egg-info/dependency_links.txt +1 -0
  32. distinct_server.egg-info/requires.txt +13 -0
  33. distinct_server.egg-info/top_level.txt +6 -0
  34. distinct_server/catalog.py +264 -120
  35. distinct_server/presentation.py +123 -0
  36. distinct_server/render.py +94 -7
  37. distinct_server/ui.py +188 -53
  38. distinct_skills/handlers.py +638 -1
  39. distinct_skills/manifest.py +21 -2
  40. distinct_skills/skills/academy-guide/SKILL.md +15 -0
  41. distinct_skills/skills/academy-guide/assets/guide.md +23 -0
  42. distinct_skills/skills/academy-guide/tool.json +28 -0
  43. distinct_skills/skills/algorithmic-art/SKILL.md +15 -0
  44. distinct_skills/skills/algorithmic-art/assets/guide.md +24 -0
  45. distinct_skills/skills/algorithmic-art/tool.json +28 -0
  46. distinct_skills/skills/brand-guidelines/SKILL.md +15 -0
  47. distinct_skills/skills/brand-guidelines/assets/guide.md +17 -0
  48. distinct_skills/skills/brand-guidelines/tool.json +28 -0
  49. distinct_skills/skills/canvas-design/SKILL.md +15 -0
  50. distinct_skills/skills/canvas-design/assets/guide.md +24 -0
.gitattributes CHANGED
@@ -34,3 +34,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  *.webp filter=lfs diff=lfs merge=lfs -text
 
 
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  *.webp filter=lfs diff=lfs merge=lfs -text
37
+ *.gif filter=lfs diff=lfs merge=lfs -text
assets/library-demos/academy_guide-1.gif ADDED

Git LFS Details

  • SHA256: a7581db4887795b4376d8bcaba304403a05335dceffa67020ebcdc9a9a3e1d7b
  • Pointer size: 131 Bytes
  • Size of remote file: 131 kB
assets/library-demos/algorithmic_art-1.gif ADDED

Git LFS Details

  • SHA256: 24e948eb23740a9cd7690a6a0176918b7223deeded1cbc91a52f35550edbaa55
  • Pointer size: 131 Bytes
  • Size of remote file: 134 kB
assets/library-demos/brand_guidelines-1.gif ADDED

Git LFS Details

  • SHA256: 8b92458e28f5a7aeb240f12f753869fee5c3c37de187eb62e0ede6dc3afa91a0
  • Pointer size: 131 Bytes
  • Size of remote file: 169 kB
assets/library-demos/canvas_design-1.gif ADDED

Git LFS Details

  • SHA256: 2a660604fce9e8c84c25a5d96e7b75698e65c6e6ace781e4ab1e4a5bbcb9a80a
  • Pointer size: 131 Bytes
  • Size of remote file: 138 kB
assets/library-demos/claude_api-1.gif ADDED

Git LFS Details

  • SHA256: e2ca63522dbecadd19d8388f5724b780807ce01fba702d5a8d4a9058e02f4e6f
  • Pointer size: 131 Bytes
  • Size of remote file: 181 kB
assets/library-demos/create_deck-1.gif ADDED

Git LFS Details

  • SHA256: 2902674f868d902a4a98700ffbb2db616d84ddae0ca509b237b336a98daad504
  • Pointer size: 131 Bytes
  • Size of remote file: 135 kB
assets/library-demos/create_docx-1.gif ADDED

Git LFS Details

  • SHA256: 0c558b81576b98aca28e913552f3f441b15580a46a557ddeee603160948ce846
  • Pointer size: 131 Bytes
  • Size of remote file: 151 kB
assets/library-demos/create_pdf-1.gif ADDED

Git LFS Details

  • SHA256: ae39a352baddd190fb4334d3afe6a4c03e5de9e22adf05479244874ddc2036e4
  • Pointer size: 131 Bytes
  • Size of remote file: 145 kB
assets/library-demos/create_xlsx-1.gif ADDED

Git LFS Details

  • SHA256: 3ab1c433d970104069aeca9a50d26b849f55ab27a213fe19e799e7679e3c6fb5
  • Pointer size: 131 Bytes
  • Size of remote file: 144 kB
assets/library-demos/csv_table-1.gif ADDED

Git LFS Details

  • SHA256: bdab202b76de062b9823051efbe93c52f7efb88e2c44ac71e05c1ffcdef76bb2
  • Pointer size: 131 Bytes
  • Size of remote file: 162 kB
assets/library-demos/discernment_nudge-1.gif ADDED

Git LFS Details

  • SHA256: a5d5fc150d2d405d5ac77b89cdae61b8e5a6f48f65bed92f84c3c141ceb53342
  • Pointer size: 131 Bytes
  • Size of remote file: 196 kB
assets/library-demos/frontend_design-1.gif ADDED

Git LFS Details

  • SHA256: 64b742dbd12476e11be711c461ccf0a624d9c033d0320889ec5be7e4da7980d1
  • Pointer size: 131 Bytes
  • Size of remote file: 151 kB
assets/library-demos/make_plan-1.gif ADDED

Git LFS Details

  • SHA256: 7dbcf416da1c6b49034fcfab2e9c8ec4d86166d6a1fc09e7a2161a456f83db9d
  • Pointer size: 131 Bytes
  • Size of remote file: 170 kB
assets/library-demos/mcp_builder-1.gif ADDED

Git LFS Details

  • SHA256: 4e5ba79a1825fcf96898da76fab8aafd47827a90e74cab97e82beac37e4c7579
  • Pointer size: 131 Bytes
  • Size of remote file: 201 kB
assets/library-demos/review_checklist-1.gif ADDED

Git LFS Details

  • SHA256: 1c263afc4154698eec30f16ee621304b783cfc3451db346cb4ae6a59970cb8f8
  • Pointer size: 131 Bytes
  • Size of remote file: 201 kB
assets/library-demos/skill_scaffold-1.gif ADDED

Git LFS Details

  • SHA256: 73e61434456717ab60604722e2b5424cc69c17a0d1ac449fb4678be4b9a5b2a0
  • Pointer size: 131 Bytes
  • Size of remote file: 156 kB
assets/library-demos/slack_gif_creator-1.gif ADDED

Git LFS Details

  • SHA256: 97931b56d97ce6c9a0fc833f3c087c3a5929ff8a41a2d25e031f84a09ab3b35d
  • Pointer size: 131 Bytes
  • Size of remote file: 154 kB
assets/library-demos/status_update-1.gif ADDED

Git LFS Details

  • SHA256: 09c8214372ba172b41d55d17d7095fc0fd6216c67cc864dff8c73f2fbd6259fa
  • Pointer size: 131 Bytes
  • Size of remote file: 199 kB
assets/library-demos/theme_factory-1.gif ADDED

Git LFS Details

  • SHA256: 04966f0ef15defdc90afcf15243dbc07cd159c8679d1c9adc2e0512ed4ee4f58
  • Pointer size: 131 Bytes
  • Size of remote file: 159 kB
assets/library-demos/web_artifacts_builder-1.gif ADDED

Git LFS Details

  • SHA256: 33109a137efdc9958bfbd459e89b98d6c914e6d39a764304395dd6bcd808a6bd
  • Pointer size: 131 Bytes
  • Size of remote file: 162 kB
assets/library-demos/webapp_testing-1.gif ADDED

Git LFS Details

  • SHA256: 27e5bb56ece3ab1bc3487031b161c96d0369c86063ee3c50fcdf9f7a8ca2d14f
  • Pointer size: 131 Bytes
  • Size of remote file: 204 kB
distinct_agent/README.md CHANGED
@@ -60,21 +60,32 @@ whole PC. Unsupported and failed measurements contain `available: false` and
60
 
61
  ## Models and runner
62
 
63
- Discovery recognizes local GGUF files for Llama-2-7B-Chat, gpt-oss-20b,
64
- Qwen3-0.6B, and Qwen3-4B. It performs no download and executes no remote model
65
- code. Production manifests should pin SHA-256 values. `LlamaCppRunner` invokes
66
- an installed `llama-cli` with an argument array and `shell=False`.
 
 
 
 
 
 
 
 
 
 
 
67
 
68
  An explicit deterministic smoke test is:
69
 
70
  ```console
71
- python -m distinct_agent --model qwen3-0.6b=/models/qwen.gguf --demo-prompt "hello"
72
  ```
73
 
74
  To pair with a server, log in there, generate a one-use code, and run:
75
 
76
  ```console
77
- python -m distinct_agent --server https://owner-space.hf.space --pair XXXX-XXXX-XXXX-XXXX --name "My efficient worker" --model qwen3-0.6b=/models/qwen.gguf --llama-cli /opt/llama/llama-cli
78
  ```
79
 
80
  The default harness is `dspy-rlm`; startup performs its cached-only
 
60
 
61
  ## Models and runner
62
 
63
+ Discovery recognizes local GGUF files for OLMoE-1B-7B-0924-Instruct,
64
+ OLMo-2-1124-7B-Instruct and Llama-2-7B-Chat. It performs no download and
65
+ executes no remote model code. `LlamaCppRunner` invokes an installed
66
+ `llama-cli` with an argument array and `shell=False`.
67
+
68
+ The catalogue is short on purpose. Every model in it has a published,
69
+ model-specific assessment of its training, recorded with its primary source in
70
+ `model_assessments.json`; a release nobody has published anything about is not
71
+ offered, because measured and disclosed environmental cost is the only claim
72
+ this project makes. The two OLMo entries are first party end to end -- Ai2
73
+ trained the models, measured the training, quantised the weights and published
74
+ the digests -- and both are pinned by repository, revision and SHA-256. Llama 2
75
+ is listed and withheld: Meta publish no GGUF and the source repository is
76
+ gated, so there is no digest to pin, and the worker names it as unavailable on
77
+ every start rather than dropping it silently.
78
 
79
  An explicit deterministic smoke test is:
80
 
81
  ```console
82
+ python -m distinct_agent --model olmoe-1b-7b-0924-instruct=/models/olmoe.gguf --demo-prompt "hello"
83
  ```
84
 
85
  To pair with a server, log in there, generate a one-use code, and run:
86
 
87
  ```console
88
+ python -m distinct_agent --server https://owner-space.hf.space --pair XXXX-XXXX-XXXX-XXXX --name "My efficient worker" --model olmoe-1b-7b-0924-instruct=/models/olmoe.gguf --llama-cli /opt/llama/llama-cli
89
  ```
90
 
91
  The default harness is `dspy-rlm`; startup performs its cached-only
distinct_agent/__main__.py CHANGED
@@ -1,4 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  from .cli import main
2
 
3
  raise SystemExit(main())
4
-
 
1
+ import sys
2
+
3
+ # The same gate cli.py carries, one frame earlier: ``python -m distinct_agent``
4
+ # on a pre-3.10 interpreter must say so before any project import runs.
5
+ if sys.version_info < (3, 10):
6
+ sys.exit(
7
+ "distinct needs Python 3.10 or newer; this is "
8
+ f"{sys.version_info.major}.{sys.version_info.minor} ({sys.executable}).\n"
9
+ "On Windows, run: py -3.12 -m distinct_agent ...\n"
10
+ "Elsewhere, run: python3.12 -m distinct_agent ..."
11
+ )
12
+
13
  from .cli import main
14
 
15
  raise SystemExit(main())
 
distinct_agent/cli.py CHANGED
@@ -18,6 +18,18 @@ from dataclasses import replace
18
  from pathlib import Path
19
  from urllib.parse import urlsplit
20
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  from distinct_protocol import (
22
  MODE_AGENT,
23
  MODE_SIMPLE,
@@ -42,6 +54,7 @@ from distinct_tools import (
42
  )
43
  from distinct_tools.approval import APPROVED_TOOLS_ENV_VAR
44
 
 
45
  from .energy import detect_energy_meter
46
  from .fanout import MultiServerTransport, ServerLink
47
  from .isolation import describe_request_isolation, describe_support
@@ -59,7 +72,6 @@ from .servers import (
59
  ServerRegistry,
60
  )
61
  from .tools import advertised_tool_refs
62
- from .access import announce_access_code, new_access_code
63
  from .transport import (
64
  AgentTransportError,
65
  GradioAgentTransport,
 
18
  from pathlib import Path
19
  from urllib.parse import urlsplit
20
 
21
+ # Before any project import: the project uses 3.10 syntax at module level
22
+ # (PEP 604 unions in isinstance), so on an older interpreter the failure is
23
+ # an unrelated-looking TypeError deep in an import. A person on Windows whose
24
+ # ``python`` is a stray 3.9 deserves the actual sentence instead.
25
+ if sys.version_info < (3, 10): # pragma: no cover - cannot run under test
26
+ sys.exit(
27
+ "distinct needs Python 3.10 or newer; this is "
28
+ f"{sys.version_info.major}.{sys.version_info.minor} ({sys.executable}).\n"
29
+ "On Windows, run: py -3.12 -m distinct_agent ...\n"
30
+ "Elsewhere, run: python3.12 -m distinct_agent ..."
31
+ )
32
+
33
  from distinct_protocol import (
34
  MODE_AGENT,
35
  MODE_SIMPLE,
 
54
  )
55
  from distinct_tools.approval import APPROVED_TOOLS_ENV_VAR
56
 
57
+ from .access import announce_access_code, new_access_code
58
  from .energy import detect_energy_meter
59
  from .fanout import MultiServerTransport, ServerLink
60
  from .isolation import describe_request_isolation, describe_support
 
72
  ServerRegistry,
73
  )
74
  from .tools import advertised_tool_refs
 
75
  from .transport import (
76
  AgentTransportError,
77
  GradioAgentTransport,
distinct_agent/energy.py CHANGED
@@ -1052,6 +1052,200 @@ class ZeusEnergyMeter:
1052
  )
1053
 
1054
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1055
  def detect_energy_meter(*, system: str | None = None) -> EnergyMeter:
1056
  """Choose a backend: measured counters first, the stated model last.
1057
 
@@ -1073,6 +1267,20 @@ def detect_energy_meter(*, system: str | None = None) -> EnergyMeter:
1073
  zeus = ZeusEnergyMeter()
1074
  if zeus.available:
1075
  return zeus
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1076
  if operating_system == "Linux":
1077
  # Direct powercap first: same hardware counter pyRAPL wraps, without
1078
  # importing an abandoned package that logs warnings at import time.
@@ -1082,6 +1290,12 @@ def detect_energy_meter(*, system: str | None = None) -> EnergyMeter:
1082
  pyrapl = PyRaplEnergyMeter()
1083
  if pyrapl.available:
1084
  return pyrapl
 
 
 
 
 
 
1085
  model = CpuLoadModelMeter()
1086
  if model.available:
1087
  return model
@@ -1095,6 +1309,9 @@ def detect_energy_meter(*, system: str | None = None) -> EnergyMeter:
1095
  counter = WindowsPerformanceCounterRaplMeter()
1096
  if counter.available:
1097
  return counter
 
 
 
1098
  # Modelled, and labelled as modelled. A Windows worker with no usable
1099
  # counter used to report nothing at all, which made every one of its
1100
  # runs an absence; the model is a real number about a real run and it
 
1052
  )
1053
 
1054
 
1055
+
1056
+ class NvidiaSmiPowerMeter:
1057
+ """Measured GPU board power, integrated from ``nvidia-smi`` samples.
1058
+
1059
+ ``nvidia-smi --query-gpu=power.draw`` reports the board's instantaneous
1060
+ draw in watts from the driver's own sensor. This meter samples it on a
1061
+ small interval for the life of a run and integrates by the trapezoid
1062
+ rule, which turns a power series into joules without a vendor library:
1063
+ the same figure ``nvml``'s energy counter gives on hardware that has one,
1064
+ obtained portably on hardware that does not.
1065
+
1066
+ The scope is the whole GPU board β€” memory, fans and idle draw included β€”
1067
+ which is broader than a compute-only figure and is named so it can never
1068
+ be summed with a cpu-package number. Sampling costs one subprocess per
1069
+ interval; 250 ms keeps that below the noise floor of inference itself.
1070
+ """
1071
+
1072
+ provider = "nvidia-smi"
1073
+ scope = "gpu-board-measured"
1074
+
1075
+ _INTERVAL_SECONDS = 0.25
1076
+
1077
+ def __init__(self, *, executable: str | None = None) -> None:
1078
+ import shutil as _shutil
1079
+
1080
+ self._meter_id = uuid.uuid4().hex
1081
+ self._executable = executable or _shutil.which("nvidia-smi") or ""
1082
+ self._sessions: dict[str, dict[str, object]] = {}
1083
+ self._available = False
1084
+ self._reason: str | None = None
1085
+ if not self._executable:
1086
+ self._reason = "nvidia-smi is not on PATH"
1087
+ return
1088
+ sample = self._sample_watts()
1089
+ if sample is None:
1090
+ self._reason = "nvidia-smi answered without a readable power figure"
1091
+ return
1092
+ self._available = True
1093
+
1094
+ @property
1095
+ def available(self) -> bool:
1096
+ return self._available
1097
+
1098
+ @property
1099
+ def unavailable_reason(self) -> str | None:
1100
+ return self._reason
1101
+
1102
+ def _sample_watts(self) -> float | None:
1103
+ import subprocess
1104
+
1105
+ try:
1106
+ completed = subprocess.run(
1107
+ [
1108
+ self._executable,
1109
+ "--query-gpu=power.draw",
1110
+ "--format=csv,noheader,nounits",
1111
+ ],
1112
+ capture_output=True,
1113
+ text=True,
1114
+ timeout=3,
1115
+ check=False,
1116
+ )
1117
+ except (OSError, subprocess.SubprocessError):
1118
+ return None
1119
+ if completed.returncode != 0:
1120
+ return None
1121
+ total = 0.0
1122
+ seen = False
1123
+ for line in completed.stdout.splitlines():
1124
+ line = line.strip()
1125
+ if not line:
1126
+ continue
1127
+ try:
1128
+ watts = float(line)
1129
+ except ValueError:
1130
+ continue
1131
+ if math.isfinite(watts) and 0.0 <= watts <= 10_000.0:
1132
+ total += watts
1133
+ seen = True
1134
+ return total if seen else None
1135
+
1136
+ def start(self) -> EnergyToken:
1137
+ token = EnergyToken(
1138
+ meter_id=self._meter_id,
1139
+ provider=self.provider,
1140
+ scope=self.scope,
1141
+ started_at=time.monotonic(),
1142
+ )
1143
+ if not self._available:
1144
+ return token
1145
+ stop = threading.Event()
1146
+ session: dict[str, object] = {"stop": stop, "joules": 0.0, "samples": 0}
1147
+
1148
+ def poll() -> None:
1149
+ last_time = time.monotonic()
1150
+ last_watts = self._sample_watts()
1151
+ while not stop.wait(self._INTERVAL_SECONDS):
1152
+ now = time.monotonic()
1153
+ watts = self._sample_watts()
1154
+ if watts is not None and last_watts is not None:
1155
+ session["joules"] = (
1156
+ float(session["joules"])
1157
+ + (watts + last_watts) / 2.0 * (now - last_time)
1158
+ )
1159
+ session["samples"] = int(session["samples"]) + 1
1160
+ last_time, last_watts = now, watts
1161
+
1162
+ thread = threading.Thread(target=poll, name="nvidia-smi-energy", daemon=True)
1163
+ session["thread"] = thread
1164
+ self._sessions[token.token_id] = session
1165
+ thread.start()
1166
+ return token
1167
+
1168
+ def stop(self, token: EnergyToken) -> EnergyUsage:
1169
+ _check_token_owner(token, self._meter_id)
1170
+ duration = max(0.0, time.monotonic() - token.started_at)
1171
+ session = self._sessions.pop(token.token_id, None)
1172
+ if not self._available or session is None:
1173
+ return _unavailable_usage(token, duration)
1174
+ stop = session["stop"]
1175
+ assert isinstance(stop, threading.Event)
1176
+ stop.set()
1177
+ thread = session.get("thread")
1178
+ if isinstance(thread, threading.Thread):
1179
+ thread.join(timeout=2.0)
1180
+ if int(session["samples"]) == 0:
1181
+ return EnergyUsage(
1182
+ self.provider,
1183
+ self.scope,
1184
+ available=False,
1185
+ joules=None,
1186
+ duration_seconds=duration,
1187
+ reason="run ended before a second power sample arrived",
1188
+ )
1189
+ return EnergyUsage(
1190
+ self.provider,
1191
+ self.scope,
1192
+ available=True,
1193
+ joules=float(session["joules"]),
1194
+ duration_seconds=duration,
1195
+ )
1196
+
1197
+
1198
+ class PowermetricsProbeMeter:
1199
+ """Apple Silicon: names the working path rather than faking one.
1200
+
1201
+ macOS exposes package power through ``powermetrics``, which requires
1202
+ root, and through the undocumented counters ``zeus-apple-silicon``
1203
+ wraps. When neither is usable this meter stays unavailable with the
1204
+ reason spelled out, so a Mac worker reports why its figure is absent
1205
+ instead of a bare absence. Zeus (tried before this in the ladder)
1206
+ remains the measured path when its Apple support is installed.
1207
+ """
1208
+
1209
+ provider = "powermetrics"
1210
+ scope = "cpu-package"
1211
+
1212
+ def __init__(self) -> None:
1213
+ import shutil as _shutil
1214
+
1215
+ self._meter_id = uuid.uuid4().hex
1216
+ self._available = False
1217
+ located = _shutil.which("powermetrics")
1218
+ if not located:
1219
+ self._reason = "powermetrics is not on PATH (macOS ships it in /usr/bin)"
1220
+ else:
1221
+ # Running it needs root; probing with sudo would prompt, and a
1222
+ # meter must never prompt. State the requirement instead.
1223
+ self._reason = (
1224
+ "powermetrics needs root. Run the worker with sudo, or install "
1225
+ "zeus-apple-silicon for the unprivileged counter path"
1226
+ )
1227
+
1228
+ @property
1229
+ def available(self) -> bool:
1230
+ return self._available
1231
+
1232
+ @property
1233
+ def unavailable_reason(self) -> str | None:
1234
+ return self._reason
1235
+
1236
+ def start(self) -> EnergyToken:
1237
+ return EnergyToken(
1238
+ meter_id=self._meter_id,
1239
+ provider=self.provider,
1240
+ scope=self.scope,
1241
+ started_at=time.monotonic(),
1242
+ )
1243
+
1244
+ def stop(self, token: EnergyToken) -> EnergyUsage:
1245
+ _check_token_owner(token, self._meter_id)
1246
+ return _unavailable_usage(token, max(0.0, time.monotonic() - token.started_at))
1247
+
1248
+
1249
  def detect_energy_meter(*, system: str | None = None) -> EnergyMeter:
1250
  """Choose a backend: measured counters first, the stated model last.
1251
 
 
1267
  zeus = ZeusEnergyMeter()
1268
  if zeus.available:
1269
  return zeus
1270
+ if operating_system == "Darwin":
1271
+ # Zeus above already covers a Mac with zeus-apple-silicon installed.
1272
+ # Below it, powermetrics is the OS's own meter but needs root; the
1273
+ # probe meter names that requirement, and the load model keeps a Mac
1274
+ # worker reporting a labelled figure rather than nothing.
1275
+ probe = PowermetricsProbeMeter()
1276
+ if probe.available:
1277
+ return probe
1278
+ model = CpuLoadModelMeter()
1279
+ if model.available:
1280
+ return model
1281
+ return UnavailableEnergyMeter(
1282
+ probe.unavailable_reason or "no macOS energy backend is available"
1283
+ )
1284
  if operating_system == "Linux":
1285
  # Direct powercap first: same hardware counter pyRAPL wraps, without
1286
  # importing an abandoned package that logs warnings at import time.
 
1290
  pyrapl = PyRaplEnergyMeter()
1291
  if pyrapl.available:
1292
  return pyrapl
1293
+ # A machine whose inference runs on the GPU: the board figure is the
1294
+ # honest one when the CPU counters are fenced off (as they are in
1295
+ # containers), and it is measured rather than modelled.
1296
+ smi = NvidiaSmiPowerMeter()
1297
+ if smi.available:
1298
+ return smi
1299
  model = CpuLoadModelMeter()
1300
  if model.available:
1301
  return model
 
1309
  counter = WindowsPerformanceCounterRaplMeter()
1310
  if counter.available:
1311
  return counter
1312
+ smi = NvidiaSmiPowerMeter()
1313
+ if smi.available:
1314
+ return smi
1315
  # Modelled, and labelled as modelled. A Windows worker with no usable
1316
  # counter used to report nothing at all, which made every one of its
1317
  # runs an absence; the model is a real number about a real run and it
distinct_agent/models.py CHANGED
@@ -27,12 +27,16 @@ class UnpinnedModelError(ModelVerificationError):
27
  # A model is advertised only after its file is found locally. Presence in this
28
  # catalogue never means usable.
29
  #
30
- # PUBLISHER POLICY. unsloth is the *default* publisher, not the only permitted
31
- # one. Their GGUF republications are smaller for the same nominal quantisation,
32
- # which on a project about energy per run is a defensible engineering choice
33
- # rather than a convenience: fewer bytes to download, less resident memory, less
34
- # work per token. Other publishers stay selectable, and the publisher is always
35
- # visible in the entry so nobody has to guess whose weights they are running.
 
 
 
 
36
  #
37
  # DIGESTS. A digest is meaningless without the repository and revision it came
38
  # from: the same nominal quantisation published twice is two different files.
@@ -40,10 +44,6 @@ class UnpinnedModelError(ModelVerificationError):
40
  # pending, and an empty digest is an unanswered question rather than a passing
41
  # check; `discover_models(verify_hashes=True)` refuses them loudly.
42
  #
43
- # Llama-2-7B-Chat has no unsloth GGUF (their Llama-2 publications are
44
- # safetensors and bitsandbytes only), so it stays unpinned pending a decision
45
- # on whether to convert locally or accept a third-party GGUF.
46
- #
47
  # WHAT AN UNPINNED ENTRY NOW MEANS. Weights are fetched on demand rather than
48
  # placed on disk by the operator, so a manifest is only usable when its
49
  # repository, revision *and* digest are all recorded: without a revision there
@@ -51,78 +51,98 @@ class UnpinnedModelError(ModelVerificationError):
51
  # was run. :func:`distinct_agent.weights.fetchable_manifests` filters this list
52
  # to the entries that satisfy all three, and a worker offers nothing else.
53
  #
54
- # The unpinned entries stay here rather than being deleted. They are the
55
- # record of what is missing and for which model, they keep the rubric surface
56
- # able to name a release it has assessed but cannot run, and completing one is
57
- # then a data change (three strings) rather than an archaeology exercise. The
58
- # worker prints every withheld model and its missing part on every start, so
59
- # an absence is never silent.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  KNOWN_MODEL_MANIFESTS: tuple[ModelManifest, ...] = (
61
- # DEFAULT. Chosen on assessed environmental impact, not on capability.
 
 
 
 
 
 
 
 
62
  #
63
- # Ai2 publish this GGUF themselves, so the provenance chain is first party
64
- # end to end: the same organisation trained the model, quantised it and
65
- # published the digest. Every other OLMo GGUF is a third-party requantisation,
66
- # which means the digest attests to a repackager's file rather than to the
67
- # publisher's. Given how much of this project rests on weights identity,
68
- # that distinction decided the pin. See PLAN.md 10.16.
 
69
  ModelManifest(
70
- id="olmo-2-1b-instruct",
71
- label="OLMo 2 1B Instruct Β· Ai2 Β· Q4_K_M",
72
- filename="OLMo-2-0425-1B-Instruct-Q4_K_M.gguf",
73
- min_ram_gb=2.0,
74
- sha256="abd8187934a438fbf7cfff0a1de5b9d2793ce913f158794df1951dcba6c93cc6",
75
- source_repo="allenai/OLMo-2-0425-1B-Instruct-GGUF",
76
- source_revision="62f8c199538474c3e33ed5d7e0580abd66686a27",
77
  context_length=4096,
78
  ),
79
- # Kept as the comparison point: the rubric sheet uses it to show what zero
80
- # environmental disclosure looks like. Not the default.
81
- ModelManifest(
82
- id="qwen3-0.6b",
83
- label="Qwen3-0.6B Β· unsloth Β· Q4_K_M",
84
- filename="Qwen3-0.6B-Q4_K_M.gguf",
85
- min_ram_gb=2.0,
86
- # Verified by downloading this exact revision and hashing the file;
87
- # see scripts/fetch_runtime.py.
88
- sha256="ac2d97712095a558e31573f62f466a3f9d93990898b0ec79d7c974c1780d524a",
89
- source_repo="unsloth/Qwen3-0.6B-GGUF",
90
- source_revision="50968a4468ef4233ed78cd7c3de230dd1d61a56b",
91
- context_length=40960,
92
- ),
93
- # OLMoE: first-party GGUF, and Ai2 tag these repos with co2_eq_emissions,
94
- # which is the disclosure the rubric rewards. Digest pending; the repo and
95
- # revision are recorded so it can be pinned without another search.
96
  ModelManifest(
97
- id="olmoe-1b-7b-instruct",
98
- label="OLMoE 1B-7B Instruct Β· Ai2 Β· Q4_K_M",
99
- filename="OLMoE-1B-7B-0125-Instruct-Q4_K_M.gguf",
100
  min_ram_gb=8.0,
101
- source_repo="allenai/OLMoE-1B-7B-0125-Instruct-GGUF",
 
 
102
  context_length=4096,
103
  ),
104
- ModelManifest(
105
- id="qwen3-4b",
106
- label="Qwen3-4B Β· unsloth Β· Q4_K_M",
107
- filename="Qwen3-4B-Q4_K_M.gguf",
108
- min_ram_gb=6.0,
109
- source_repo="unsloth/Qwen3-4B-GGUF",
110
- context_length=40960,
111
- ),
 
 
112
  ModelManifest(
113
  id="llama-2-7b-chat",
114
- label="Llama-2-7B-Chat Β· Q4_K_M",
115
  filename="llama-2-7b-chat.Q4_K_M.gguf",
116
  min_ram_gb=8.0,
117
  context_length=4096,
118
  ),
119
- ModelManifest(
120
- id="gpt-oss-20b",
121
- label="gpt-oss-20b Β· MXFP4",
122
- filename="gpt-oss-20b-mxfp4.gguf",
123
- min_ram_gb=16.0,
124
- context_length=131072,
125
- ),
126
  )
127
 
128
 
@@ -131,17 +151,13 @@ _FILENAME_ALIASES: Mapping[str, tuple[str, ...]] = {
131
  "llama-2-7b-chat.Q4_K_M.gguf",
132
  "Llama-2-7B-Chat.Q4_K_M.gguf",
133
  ),
134
- "gpt-oss-20b": (
135
- "gpt-oss-20b-mxfp4.gguf",
136
- "gpt-oss-20b-Q4_K_M.gguf",
137
- ),
138
- "qwen3-0.6b": (
139
- "Qwen3-0.6B-Q4_K_M.gguf",
140
- "qwen3-0.6b-q4_k_m.gguf",
141
  ),
142
- "qwen3-4b": (
143
- "Qwen3-4B-Q4_K_M.gguf",
144
- "qwen3-4b-q4_k_m.gguf",
145
  ),
146
  }
147
 
 
27
  # A model is advertised only after its file is found locally. Presence in this
28
  # catalogue never means usable.
29
  #
30
+ # PUBLISHER POLICY. Weights are taken from the organisation that trained them,
31
+ # and from nobody else. The earlier policy defaulted to unsloth's GGUF
32
+ # republications on the argument that a smaller file for the same nominal
33
+ # quantisation is less to download and less to hold in memory. That argument is
34
+ # still true and it is still not the deciding one: a digest attests to whoever
35
+ # built the file, so a third-party requantisation pins the repackager rather
36
+ # than the trainer, and this catalogue's whole claim is that you can check who
37
+ # made what you are running. Ai2 publish their own GGUFs, which is what makes
38
+ # the entries below first party end to end -- the same organisation trained the
39
+ # model, measured its training, quantised it and published the digest.
40
  #
41
  # DIGESTS. A digest is meaningless without the repository and revision it came
42
  # from: the same nominal quantisation published twice is two different files.
 
44
  # pending, and an empty digest is an unanswered question rather than a passing
45
  # check; `discover_models(verify_hashes=True)` refuses them loudly.
46
  #
 
 
 
 
47
  # WHAT AN UNPINNED ENTRY NOW MEANS. Weights are fetched on demand rather than
48
  # placed on disk by the operator, so a manifest is only usable when its
49
  # repository, revision *and* digest are all recorded: without a revision there
 
51
  # was run. :func:`distinct_agent.weights.fetchable_manifests` filters this list
52
  # to the entries that satisfy all three, and a worker offers nothing else.
53
  #
54
+ # The unpinned entry stays here rather than being deleted. It is the record of
55
+ # what is missing and for which model, it keeps the assessment surface able to
56
+ # name a release it has assessed but cannot run, and completing it is then a
57
+ # data change (three strings) rather than an archaeology exercise. The worker
58
+ # prints every withheld model and its missing part on every start, so an
59
+ # absence is never silent.
60
+ #
61
+ # WHY THESE THREE. Every entry here has a published, model-specific assessment
62
+ # of its training, recorded in ``model_assessments.json`` with its primary
63
+ # source. Models with no such assessment were removed rather than shown with an
64
+ # empty record: this project's only claim is about disclosed and measured
65
+ # environmental cost, and a model nobody has published anything about is one it
66
+ # has no business shipping. What was removed, and why:
67
+ #
68
+ # Qwen3 0.6B and 4B Qwen has published nothing for any release, in any
69
+ # version. Previously kept as the worked example of zero
70
+ # disclosure; that example now lives in the assessment
71
+ # surface's own copy, which does not require shipping the
72
+ # weights to make the point.
73
+ # gpt-oss-20b No disclosure of any kind on the model card or
74
+ # elsewhere.
75
+ # OLMo 2 0425 1B Released a month after Ai2's assessment closed. Ai2 have
76
+ # published nothing for it since, and a figure for a
77
+ # different OLMo checkpoint is not a figure for this one.
78
+ # OLMoE 0125 Superseded here by OLMoE 0924, which is the checkpoint
79
+ # Ai2 actually measured. Same architecture, same parameter
80
+ # count, four months apart -- and by this catalogue's own
81
+ # rule a different version is a different model, so the
82
+ # date stamp is the entire difference between an entry
83
+ # with evidence and an entry without.
84
  KNOWN_MODEL_MANIFESTS: tuple[ModelManifest, ...] = (
85
+ # DEFAULT. Chosen on published, measured environmental impact, not on
86
+ # capability, and not on file size.
87
+ #
88
+ # Ai2 measured this run: GPU power sampled at sub-second intervals rather
89
+ # than inferred from rated wattage, giving 54 MWh, 18 tCO2e and 70 kL of
90
+ # water for the pretraining run. That is the lowest published figure of any
91
+ # assessed release at a size this network can run, and it is roughly a third
92
+ # of the dense OLMo 2 7B measured by the same team on the same cluster.
93
+ # Mixture of experts: 6.9B parameters resident, about 1.3B active per token.
94
  #
95
+ # See model_assessments.json, record ``olmoe-1b-7b-0924``, and
96
+ # https://arxiv.org/abs/2503.05804 for the measurement.
97
+ #
98
+ # NOTE ON COVERAGE. The published figures cover the 0924 *pretraining* run.
99
+ # This entry is the Instruct checkpoint, whose tuning stage the source does
100
+ # not separately cost. The assessment record says so; do not quietly promote
101
+ # it to complete coverage.
102
  ModelManifest(
103
+ id="olmoe-1b-7b-0924-instruct",
104
+ label="OLMoE 1B-7B 0924 Instruct Β· Ai2 Β· Q4_K_M",
105
+ filename="olmoe-1b-7b-0924-instruct-q4_k_m.gguf",
106
+ min_ram_gb=8.0,
107
+ sha256="8c310f1435a1222338fd2d3d974975be9cd908180b644bab0c2a94da1ac32f3f",
108
+ source_repo="allenai/OLMoE-1B-7B-0924-Instruct-GGUF",
109
+ source_revision="02ab6ea6894a8418eb14f6d8ee1bfb08bd298080",
110
  context_length=4096,
111
  ),
112
+ # The dense comparison, and the fallback if the MoE architecture misbehaves
113
+ # under a given llama.cpp build. Same team, same cluster, same measurement
114
+ # method, same year, so the gap between this and OLMoE above is one of the
115
+ # few genuinely like-for-like comparisons in this literature: 157 MWh,
116
+ # 52 tCO2e, 202 kL. Roughly three times OLMoE on every disclosed axis.
117
+ #
118
+ # See model_assessments.json, record ``olmo-2-1124-7b``.
 
 
 
 
 
 
 
 
 
 
119
  ModelManifest(
120
+ id="olmo-2-1124-7b-instruct",
121
+ label="OLMo 2 1124 7B Instruct Β· Ai2 Β· Q4_K_M",
122
+ filename="olmo-2-1124-7B-instruct-Q4_K_M.gguf",
123
  min_ram_gb=8.0,
124
+ sha256="e08112e5f84aab7c05fa6e713c58e5214cd5d8e32ed773ff3354b006eed41b95",
125
+ source_repo="allenai/OLMo-2-1124-7B-Instruct-GGUF",
126
+ source_revision="410e0069f64869e4b1d17d8de04810b881fd824b",
127
  context_length=4096,
128
  ),
129
+ # Kept as the contrast case: the only non-Ai2 release in this catalogue with
130
+ # a published model-specific figure, and a partial one. Meta published
131
+ # GPU-hours and a modelled 31.22 tCO2e; no energy, no water, no materials.
132
+ #
133
+ # Still unpinned, and for two reasons that are worth keeping visible rather
134
+ # than resolving quietly. Meta publish no GGUF, so any file would be a third
135
+ # party's, which the publisher policy above rules out. And the source
136
+ # repository is gated, so there is no unauthenticated revision to pin even
137
+ # if that policy changed. ``fetchable_manifests`` therefore withholds it and
138
+ # the worker says so on every start. It is here to be named, not run.
139
  ModelManifest(
140
  id="llama-2-7b-chat",
141
+ label="Llama 2 7B Chat Β· Meta Β· gated licence",
142
  filename="llama-2-7b-chat.Q4_K_M.gguf",
143
  min_ram_gb=8.0,
144
  context_length=4096,
145
  ),
 
 
 
 
 
 
 
146
  )
147
 
148
 
 
151
  "llama-2-7b-chat.Q4_K_M.gguf",
152
  "Llama-2-7B-Chat.Q4_K_M.gguf",
153
  ),
154
+ "olmoe-1b-7b-0924-instruct": (
155
+ "olmoe-1b-7b-0924-instruct-q4_k_m.gguf",
156
+ "OLMoE-1B-7B-0924-Instruct-Q4_K_M.gguf",
 
 
 
 
157
  ),
158
+ "olmo-2-1124-7b-instruct": (
159
+ "olmo-2-1124-7B-instruct-Q4_K_M.gguf",
160
+ "OLMo-2-1124-7B-Instruct-Q4_K_M.gguf",
161
  ),
162
  }
163
 
distinct_mcp/external.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A catalogue of external, locally-run MCP servers, and the way in.
2
+
3
+ These are other people's programs. The in-process servers beside this module
4
+ run inside the worker and are governed by it; everything listed here runs as
5
+ a child process speaking MCP over stdio, does its own I/O, and is exactly as
6
+ trustworthy as its publisher. So this module is a catalogue and a doorway,
7
+ never an installer: nothing below downloads anything, nothing starts unless
8
+ an operator names the server, and a server whose credentials are absent
9
+ refuses at connect time with the variable names it wanted β€” the credential
10
+ values themselves are never read into this process beyond handing the child
11
+ its environment.
12
+
13
+ **Authentication is per server and stays in the environment.** Each entry
14
+ names the environment variables its server reads (an API key, or Google
15
+ Application Default Credentials established with ``gcloud auth login``).
16
+ This module checks presence, never value, and passes the environment through
17
+ to the child. Nothing here stores, logs or transmits a credential.
18
+
19
+ The Google entries come from https://github.com/google/mcp β€” the repository
20
+ is a directory of Google's official servers, and the ones catalogued here
21
+ are its "open-source, run locally" set. The Playwright and Postman entries
22
+ are the official publishers' packages. Remote/hosted MCP endpoints from that
23
+ list are deliberately not catalogued: a remote tool server is a different
24
+ trust conversation from a local child process, and this catalogue refuses to
25
+ blur it.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import shutil
32
+ import subprocess
33
+ from collections.abc import Mapping, Sequence
34
+ from dataclasses import dataclass
35
+
36
+ from distinct_tools.mcp import McpError, StdioMcpTransport
37
+
38
+ __all__ = [
39
+ "ExternalMcpServer",
40
+ "EXTERNAL_CATALOGUE",
41
+ "connect_external",
42
+ "describe_external",
43
+ "external_by_name",
44
+ ]
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ExternalMcpServer:
49
+ """One locally-runnable server: how to start it, what it needs, who made it."""
50
+
51
+ name: str
52
+ publisher: str
53
+ summary: str
54
+ #: The command line, exactly. ``npx --yes`` and ``uvx`` fetch on first
55
+ #: run; that is the publisher's distribution mechanism, stated rather
56
+ #: than hidden.
57
+ command: tuple[str, ...]
58
+ #: Environment variable NAMES the server authenticates with. Presence is
59
+ #: checked before spawn; values are never read by this module.
60
+ auth_env: tuple[str, ...] = ()
61
+ #: Auth that is a local login state rather than a variable, e.g. Google
62
+ #: Application Default Credentials. Recorded for the operator's console.
63
+ auth_note: str = ""
64
+ #: Hosts the operator consents to this server reaching. A record of
65
+ #: consent, not a control: the child does its own I/O.
66
+ hosts: tuple[str, ...] = ()
67
+ source: str = ""
68
+
69
+ @property
70
+ def runtime(self) -> str:
71
+ return self.command[0]
72
+
73
+
74
+ _GOOGLE_ADC = (
75
+ "Authenticate with Google Application Default Credentials: run "
76
+ "`gcloud auth application-default login` once as the operator."
77
+ )
78
+
79
+ EXTERNAL_CATALOGUE: tuple[ExternalMcpServer, ...] = (
80
+ ExternalMcpServer(
81
+ name="playwright",
82
+ publisher="Microsoft",
83
+ summary=(
84
+ "Drive a real browser: navigate, click, fill and read pages. Needs a "
85
+ "Chromium install; set PLAYWRIGHT_BROWSERS_PATH to reuse one already "
86
+ "on the machine instead of downloading another."
87
+ ),
88
+ command=("npx", "--yes", "@playwright/mcp@latest"),
89
+ auth_env=(),
90
+ auth_note="No account. The browser itself is the capability.",
91
+ hosts=("*",),
92
+ source="https://github.com/microsoft/playwright-mcp",
93
+ ),
94
+ ExternalMcpServer(
95
+ name="postman",
96
+ publisher="Postman",
97
+ summary=(
98
+ "Postman's official server: collections, environments and API "
99
+ "definitions in your workspaces, the public API network included."
100
+ ),
101
+ command=("npx", "--yes", "@postman/postman-mcp-server"),
102
+ auth_env=("POSTMAN_API_KEY",),
103
+ auth_note="Create a key at https://postman.com/settings/me/api-keys.",
104
+ hosts=("api.getpostman.com",),
105
+ source="https://github.com/postmanlabs/postman-mcp-server",
106
+ ),
107
+ ExternalMcpServer(
108
+ name="google-workspace",
109
+ publisher="Google",
110
+ summary="Docs, Sheets, Slides, Calendar and Gmail in the operator's Workspace.",
111
+ command=("npx", "--yes", "@googleworkspace/mcp"),
112
+ auth_env=(),
113
+ auth_note=_GOOGLE_ADC,
114
+ hosts=("*.googleapis.com",),
115
+ source="https://github.com/gemini-cli-extensions/workspace",
116
+ ),
117
+ ExternalMcpServer(
118
+ name="google-analytics",
119
+ publisher="Google",
120
+ summary="Report on Analytics properties: audiences, events, conversions.",
121
+ command=("uvx", "analytics-mcp"),
122
+ auth_env=(),
123
+ auth_note=_GOOGLE_ADC,
124
+ hosts=("analyticsdata.googleapis.com", "analyticsadmin.googleapis.com"),
125
+ source="https://github.com/googleanalytics/google-analytics-mcp",
126
+ ),
127
+ ExternalMcpServer(
128
+ name="google-cloud-storage",
129
+ publisher="Google",
130
+ summary="List, read and manage Cloud Storage buckets and objects.",
131
+ command=("npx", "--yes", "@google-cloud/storage-mcp"),
132
+ auth_env=(),
133
+ auth_note=_GOOGLE_ADC,
134
+ hosts=("storage.googleapis.com",),
135
+ source="https://github.com/googleapis/gcloud-mcp",
136
+ ),
137
+ ExternalMcpServer(
138
+ name="gcloud",
139
+ publisher="Google",
140
+ summary="The gcloud CLI as tools: inspect and manage Google Cloud projects.",
141
+ command=("npx", "--yes", "@google-cloud/gcloud-mcp"),
142
+ auth_env=(),
143
+ auth_note=_GOOGLE_ADC + " Also requires the gcloud CLI on PATH.",
144
+ hosts=("*.googleapis.com",),
145
+ source="https://github.com/googleapis/gcloud-mcp",
146
+ ),
147
+ ExternalMcpServer(
148
+ name="google-observability",
149
+ publisher="Google",
150
+ summary="Cloud Logging, Monitoring and Trace: read logs and metrics.",
151
+ command=("npx", "--yes", "@google-cloud/observability-mcp"),
152
+ auth_env=(),
153
+ auth_note=_GOOGLE_ADC,
154
+ hosts=("logging.googleapis.com", "monitoring.googleapis.com"),
155
+ source="https://github.com/googleapis/gcloud-mcp",
156
+ ),
157
+ ExternalMcpServer(
158
+ name="mcp-toolbox-databases",
159
+ publisher="Google",
160
+ summary=(
161
+ "Databases behind one server: BigQuery, Cloud SQL, AlloyDB, Spanner, "
162
+ "Postgres and more, from a tools.yaml the operator writes."
163
+ ),
164
+ command=("toolbox", "--stdio"),
165
+ auth_env=(),
166
+ auth_note=(
167
+ "Install the MCP Toolbox binary and write its tools.yaml; database "
168
+ "credentials live in that file or in " + _GOOGLE_ADC
169
+ ),
170
+ hosts=("*",),
171
+ source="https://github.com/googleapis/genai-toolbox",
172
+ ),
173
+ ExternalMcpServer(
174
+ name="gke",
175
+ publisher="Google",
176
+ summary="Kubernetes Engine: inspect clusters, workloads and events.",
177
+ command=("npx", "--yes", "@google-cloud/gke-mcp"),
178
+ auth_env=(),
179
+ auth_note=_GOOGLE_ADC,
180
+ hosts=("container.googleapis.com",),
181
+ source="https://github.com/GoogleCloudPlatform/gke-mcp",
182
+ ),
183
+ ExternalMcpServer(
184
+ name="chrome-devtools",
185
+ publisher="Google (Chrome DevTools)",
186
+ summary="Debug a running Chrome: inspect pages, console, network and traces.",
187
+ command=("npx", "--yes", "chrome-devtools-mcp@latest"),
188
+ auth_env=(),
189
+ auth_note="No account. Attaches to a local Chrome the operator starts.",
190
+ hosts=(),
191
+ source="https://github.com/ChromeDevTools/chrome-devtools-mcp",
192
+ ),
193
+ )
194
+
195
+
196
+ def external_by_name(name: str) -> ExternalMcpServer:
197
+ for entry in EXTERNAL_CATALOGUE:
198
+ if entry.name == name:
199
+ return entry
200
+ raise McpError(f"no external MCP server named {name!r} is catalogued")
201
+
202
+
203
+ def _missing_auth(entry: ExternalMcpServer, environ: Mapping[str, str]) -> tuple[str, ...]:
204
+ return tuple(var for var in entry.auth_env if not environ.get(var, "").strip())
205
+
206
+
207
+ def describe_external(environ: Mapping[str, str] | None = None) -> str:
208
+ """The operator's view: every entry, its runtime, and its auth state."""
209
+
210
+ values = os.environ if environ is None else environ
211
+ lines = [
212
+ "External MCP servers this worker can connect on request. Nothing below",
213
+ "is installed, running or approved until an operator names it.",
214
+ ]
215
+ for entry in EXTERNAL_CATALOGUE:
216
+ missing = _missing_auth(entry, values)
217
+ runtime_present = shutil.which(entry.runtime) is not None
218
+ state = []
219
+ state.append(f"runtime {entry.runtime}: {'present' if runtime_present else 'absent'}")
220
+ if entry.auth_env:
221
+ state.append(
222
+ "auth: ready" if not missing else "auth: missing " + ", ".join(missing)
223
+ )
224
+ else:
225
+ state.append("auth: " + (entry.auth_note or "none"))
226
+ lines.append(f" {entry.name} ({entry.publisher})")
227
+ lines.append(f" {entry.summary}")
228
+ lines.append(f" {'; '.join(state)}")
229
+ return "\n".join(lines)
230
+
231
+
232
+ def connect_external(
233
+ name: str,
234
+ *,
235
+ environ: Mapping[str, str] | None = None,
236
+ timeout: float = 60.0,
237
+ ) -> StdioMcpTransport:
238
+ """Spawn one catalogued server and complete the MCP handshake.
239
+
240
+ Fails closed, with the exact variable names, when its credentials are
241
+ absent β€” a server started without them would answer the handshake and
242
+ then fail every real call, which is a worse failure because it is a
243
+ later one. The caller owns the transport and must ``close()`` it; the
244
+ spawn uses no shell and passes the operator's environment through
245
+ untouched, which is the entire credential path.
246
+ """
247
+
248
+ entry = external_by_name(name)
249
+ values = os.environ if environ is None else environ
250
+ missing = _missing_auth(entry, values)
251
+ if missing:
252
+ raise McpError(
253
+ f"{entry.name} needs {', '.join(missing)} set in the environment. "
254
+ f"{entry.auth_note}".strip()
255
+ )
256
+ if shutil.which(entry.runtime) is None:
257
+ raise McpError(
258
+ f"{entry.name} needs {entry.runtime!r} on PATH; install it and retry"
259
+ )
260
+
261
+ def spawn(command: Sequence[str]) -> subprocess.Popen:
262
+ return subprocess.Popen(
263
+ list(command),
264
+ stdin=subprocess.PIPE,
265
+ stdout=subprocess.PIPE,
266
+ stderr=subprocess.DEVNULL,
267
+ env=dict(values),
268
+ text=True,
269
+ )
270
+
271
+ transport = StdioMcpTransport(
272
+ tuple(entry.command), spawn=spawn, handshake_timeout=timeout
273
+ )
274
+ transport.start()
275
+ return transport
distinct_server.egg-info/PKG-INFO ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: distinct-server
3
+ Version: 0.1.0
4
+ Summary: Session-only Gradio control plane for community-run open-weight LLM agents
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: gradio[oauth]==6.24.0
7
+ Provides-Extra: agent
8
+ Requires-Dist: dspy[deno]==3.3.0; extra == "agent"
9
+ Requires-Dist: gradio_client==2.6.0; extra == "agent"
10
+ Requires-Dist: pyRAPL==0.2.3.1; sys_platform == "linux" and extra == "agent"
11
+ Requires-Dist: textual==8.2.8; extra == "agent"
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest==8.4.2; extra == "dev"
14
+ Requires-Dist: ruff==0.12.11; extra == "dev"
distinct_server.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ distinct_agent/__init__.py
4
+ distinct_agent/__main__.py
5
+ distinct_agent/access.py
6
+ distinct_agent/auth.py
7
+ distinct_agent/cli.py
8
+ distinct_agent/containment.py
9
+ distinct_agent/dashboard.py
10
+ distinct_agent/deno.lock
11
+ distinct_agent/desktop.py
12
+ distinct_agent/energy.py
13
+ distinct_agent/fanout.py
14
+ distinct_agent/firstrun.py
15
+ distinct_agent/guard.py
16
+ distinct_agent/harness.py
17
+ distinct_agent/httpapi.py
18
+ distinct_agent/models.py
19
+ distinct_agent/queue.py
20
+ distinct_agent/rlm.py
21
+ distinct_agent/runners.py
22
+ distinct_agent/sandbox.py
23
+ distinct_agent/server_runner.py
24
+ distinct_agent/servers.py
25
+ distinct_agent/state.py
26
+ distinct_agent/tools.py
27
+ distinct_agent/transport.py
28
+ distinct_agent/tui.py
29
+ distinct_agent/weights.py
30
+ distinct_agent/worker.py
31
+ distinct_agent/isolation/__init__.py
32
+ distinct_agent/isolation/base.py
33
+ distinct_agent/isolation/linux.py
34
+ distinct_agent/isolation/request.py
35
+ distinct_agent/isolation/windows.py
36
+ distinct_agent/isolation/windows_appcontainer.py
37
+ distinct_mcp/__init__.py
38
+ distinct_mcp/common.py
39
+ distinct_mcp/files.py
40
+ distinct_mcp/lists.py
41
+ distinct_mcp/numbers.py
42
+ distinct_mcp/text.py
43
+ distinct_protocol/__init__.py
44
+ distinct_protocol/fences.py
45
+ distinct_protocol/handshake.py
46
+ distinct_protocol/models.py
47
+ distinct_protocol/netpolicy.py
48
+ distinct_protocol/telemetry.py
49
+ distinct_server/__init__.py
50
+ distinct_server/api.py
51
+ distinct_server/auth_routes.py
52
+ distinct_server/catalog.py
53
+ distinct_server/control_plane.py
54
+ distinct_server/identity.py
55
+ distinct_server/models.py
56
+ distinct_server/presentation.py
57
+ distinct_server/render.py
58
+ distinct_server/security.py
59
+ distinct_server/ui.py
60
+ distinct_server/ui_state.py
61
+ distinct_server.egg-info/PKG-INFO
62
+ distinct_server.egg-info/SOURCES.txt
63
+ distinct_server.egg-info/dependency_links.txt
64
+ distinct_server.egg-info/requires.txt
65
+ distinct_server.egg-info/top_level.txt
66
+ distinct_skills/NOTICE.md
67
+ distinct_skills/__init__.py
68
+ distinct_skills/__main__.py
69
+ distinct_skills/handlers.py
70
+ distinct_skills/loader.py
71
+ distinct_skills/manifest.py
72
+ distinct_skills/skills/csv-table/SKILL.md
73
+ distinct_skills/skills/csv-table/tool.json
74
+ distinct_skills/skills/review-checklist/SKILL.md
75
+ distinct_skills/skills/review-checklist/tool.json
76
+ distinct_skills/skills/review-checklist/assets/checklist.md
77
+ distinct_skills/skills/review-checklist/assets/claim-block.md
78
+ distinct_skills/skills/skill-scaffold/SKILL.md
79
+ distinct_skills/skills/skill-scaffold/tool.json
80
+ distinct_skills/skills/skill-scaffold/assets/skill-template.md
81
+ distinct_skills/skills/status-update/SKILL.md
82
+ distinct_skills/skills/status-update/tool.json
83
+ distinct_skills/skills/status-update/assets/status-update.md
84
+ distinct_tools/__init__.py
85
+ distinct_tools/approval.py
86
+ distinct_tools/core.py
87
+ distinct_tools/local.py
88
+ distinct_tools/mcp.py
89
+ distinct_tools/skills.py
90
+ distinct_tools/testing.py
91
+ distinct_tools/trace.py
92
+ distinct_tools/workspace.py
distinct_server.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
distinct_server.egg-info/requires.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio[oauth]==6.24.0
2
+
3
+ [agent]
4
+ dspy[deno]==3.3.0
5
+ gradio_client==2.6.0
6
+ textual==8.2.8
7
+
8
+ [agent:sys_platform == "linux"]
9
+ pyRAPL==0.2.3.1
10
+
11
+ [dev]
12
+ pytest==8.4.2
13
+ ruff==0.12.11
distinct_server.egg-info/top_level.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ distinct_agent
2
+ distinct_mcp
3
+ distinct_protocol
4
+ distinct_server
5
+ distinct_skills
6
+ distinct_tools
distinct_server/catalog.py CHANGED
@@ -20,7 +20,7 @@ never graded against each other, and never rendered in the same total.
20
 
21
  from __future__ import annotations
22
 
23
- from collections.abc import Iterable, Mapping
24
  from dataclasses import dataclass
25
  from enum import Enum
26
  from types import MappingProxyType
@@ -137,53 +137,78 @@ def _all_missing(reason: str) -> Mapping[str, AreaResult]:
137
  class ReleaseAssessment:
138
  """A cradle-to-release assessment of one model release.
139
 
140
- ``evidence_grade`` grades *evidence quality only* and is absolute, never
141
- cohort-relative. When there is no usable evidence it is ``None`` -- "No
142
- data" is not a grade and must not be rendered as one.
 
 
 
 
 
143
  """
144
 
145
  model_id: str
146
  coverage: str
147
  areas: Mapping[str, AreaResult]
148
- evidence_grade: Optional[str] = None
 
149
  weights_sha256: str = ""
150
  lineage: str = ""
151
  boundary: str = RELEASE_BOUNDARY
152
  reason: str = ""
 
153
 
154
  def __post_init__(self) -> None:
155
  missing_areas = set(RUBRIC_AREAS) - set(self.areas)
156
  if missing_areas:
157
  raise ValueError(f"release assessment omits areas: {sorted(missing_areas)}")
158
- if self.evidence_grade is not None and self.evidence_grade not in {"A", "B", "C", "D"}:
159
- raise ValueError("evidence grade must be A-D, or None for no data")
160
  if self.coverage not in {"Complete", "Partial", "Insufficient", "No data"}:
161
  raise ValueError("coverage must be Complete, Partial, Insufficient or No data")
162
- if self.coverage == "No data" and self.evidence_grade is not None:
163
- raise ValueError("'No data' is not a grade and cannot carry one")
 
 
 
 
 
164
  object.__setattr__(self, "areas", MappingProxyType(dict(self.areas)))
 
165
 
166
  @property
167
  def has_any_result(self) -> bool:
168
  return any(item.status is not AreaStatus.MISSING for item in self.areas.values())
169
 
170
  @property
171
- def grade_display(self) -> str:
172
- """Grade, coverage and boundary are visually inseparable."""
173
 
174
- grade = self.evidence_grade or "No data"
175
- return f"{grade} Β· coverage: {self.coverage}"
 
 
 
 
 
 
 
 
 
 
176
 
177
  def to_dict(self) -> dict:
178
  return {
179
  "model_id": self.model_id,
180
- "evidence_grade": self.evidence_grade,
181
- "grade_display": self.grade_display,
182
  "coverage": self.coverage,
 
 
 
 
 
183
  "boundary": self.boundary,
184
  "weights_sha256": self.weights_sha256,
185
  "lineage": self.lineage,
186
  "reason": self.reason,
 
187
  "areas": {area: item.to_dict() for area, item in self.areas.items()},
188
  }
189
 
@@ -191,143 +216,262 @@ class ReleaseAssessment:
191
  # LABELS NAME THE PUBLICATION, NOT THE SIZE.
192
  #
193
  # These used to read "smallest", "balanced test model" and "largest test
194
- # model". Size is not something this rubric evaluates, and putting it in the
195
- # name of the thing being chosen invited exactly the reasoning the rubric
196
  # exists to refuse: that a smaller file is a better environmental choice. It
197
  # might be, and it might not, and the only way to know is a published result.
198
  # What belongs in a label is who published the weights, because that is what
199
- # the evidence grade is about.
 
 
 
 
200
  MODEL_CATALOG: dict[str, ModelManifest] = {
201
- # DEFAULT of the worker catalogue: chosen on assessed environmental impact.
202
- # Kept in step with distinct_agent.models.KNOWN_MODEL_MANIFESTS; the drift
203
- # test in tests/test_catalog.py pins filename, RAM and context length.
204
- "olmo-2-1b-instruct": ModelManifest(
205
- id="olmo-2-1b-instruct",
206
- label="OLMo 2 1B Instruct Β· first-party GGUF",
207
- filename="OLMo-2-0425-1B-Instruct-Q4_K_M.gguf",
208
- min_ram_gb=2.0,
 
209
  context_length=4_096,
210
  ),
211
- "olmoe-1b-7b-instruct": ModelManifest(
212
- id="olmoe-1b-7b-instruct",
213
- label="OLMoE 1B-7B Instruct Β· first-party GGUF",
214
- filename="OLMoE-1B-7B-0125-Instruct-Q4_K_M.gguf",
215
  min_ram_gb=8.0,
216
  context_length=4_096,
217
  ),
218
- "qwen3-0.6b": ModelManifest(
219
- id="qwen3-0.6b",
220
- label="Qwen3 0.6B Β· unsloth",
221
- filename="Qwen3-0.6B-Q4_K_M.gguf",
222
- min_ram_gb=2,
223
- context_length=40_960,
224
- ),
225
- "qwen3-4b": ModelManifest(
226
- id="qwen3-4b",
227
- label="Qwen3 4B Β· unsloth",
228
- filename="Qwen3-4B-Q4_K_M.gguf",
229
- min_ram_gb=6,
230
- context_length=40_960,
231
- ),
232
  "llama-2-7b-chat": ModelManifest(
233
  id="llama-2-7b-chat",
234
- label="Llama 2 7B Chat Β· gated licence",
235
  filename="llama-2-7b-chat.Q4_K_M.gguf",
236
- min_ram_gb=8,
237
  context_length=4_096,
238
  ),
239
- "gpt-oss-20b": ModelManifest(
240
- id="gpt-oss-20b",
241
- label="gpt-oss-20b Β· MXFP4",
242
- filename="gpt-oss-20b-mxfp4.gguf",
243
- min_ram_gb=16,
244
- context_length=131_072,
245
- ),
246
  }
247
 
248
 
249
- # Why every entry below is "No data".
250
  #
251
- # The rubric forbids assessor-created impact estimates: GPU-hours, FLOPs,
252
- # tokens, parameters, TDP, PUE, grid factors or another model's LCA must not be
253
- # converted into invented MWh, tonnes or litres. Since rubric v1.1 exactly ONE
254
- # route exists: a published model-specific result, used exactly as published.
255
- # The contextual route that previously allowed an ordinal judgement from
256
- # "disclosed scale, hardware, location and controls" has been deleted, because
257
  # it was caught reasoning "a small 0.49B checkpoint moderates demand" into a
258
- # grade. That is the parameters-to-impact conversion below, wearing a letter
259
- # instead of a number. An area now either has a published result or it is
260
- # Missing.
261
  #
262
- # This repository holds no published model-specific assessment for any of the
263
- # four catalogue models, so there is nothing to enter by the direct route. In
264
- # particular the Qwen3 entries may NOT inherit the Qwen2.5-0.5B-Instruct
265
- # assessment: transferring an impact value from another model is exactly what
266
- # the grade sheet prohibits, and a different model version is a different
267
- # model.
 
268
  #
269
- # The previous code in this file derived tiers 1-4 from `min_ram_gb` and called
270
- # the result ENVIRONMENTAL_RUBRIC. That is the parameters-to-impact conversion
271
- # the grade sheet names explicitly. It has been removed rather than renamed,
272
- # because a renamed ordinal is the same invented quantity wearing a different
273
- # label. `min_ram_gb` is still shown -- it is a disclosed hardware requirement,
274
- # a fact about the file, and it makes no environmental claim.
 
 
 
 
 
 
 
 
 
275
  RELEASE_ASSESSMENT: dict[str, ReleaseAssessment] = {
276
- # The OLMo entries stay "No data" HERE because this structure is the
277
- # in-catalogue fallback only. The published Ai2 assessments are rendered
278
- # from model_assessments.json's website_display block, which carries the
279
- # per-category state discipline this flat structure cannot.
280
- "olmo-2-1b-instruct": ReleaseAssessment(
281
- model_id="olmo-2-1b-instruct",
282
- coverage="No data",
283
- areas=_all_missing(
284
- "Rendered from model_assessments.json where a published result exists; "
285
- "this in-catalogue record deliberately carries none."
286
  ),
287
- reason="see the rubric surface, which renders the published assessment set",
288
- ),
289
- "olmoe-1b-7b-instruct": ReleaseAssessment(
290
- model_id="olmoe-1b-7b-instruct",
291
- coverage="No data",
292
- areas=_all_missing(
293
- "Rendered from model_assessments.json where a published result exists; "
294
- "this in-catalogue record deliberately carries none."
295
  ),
296
- reason="see the rubric surface, which renders the published assessment set",
297
- ),
298
- "qwen3-0.6b": ReleaseAssessment(
299
- model_id="qwen3-0.6b",
300
- coverage="No data",
301
- areas=_all_missing(
302
- "No published assessment for this model release. The Qwen2.5-0.5B-Instruct "
303
- "assessment is not transferable to Qwen3-0.6B."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  ),
305
- reason="no published model-specific assessment for this release",
306
  ),
307
- "qwen3-4b": ReleaseAssessment(
308
- model_id="qwen3-4b",
309
- coverage="No data",
310
- areas=_all_missing(
311
- "No published assessment for this model release. The Qwen2.5-0.5B-Instruct "
312
- "assessment is not transferable to Qwen3-4B."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  ),
314
- reason="no published model-specific assessment for this release",
315
  ),
316
  "llama-2-7b-chat": ReleaseAssessment(
317
  model_id="llama-2-7b-chat",
318
- coverage="No data",
319
- areas=_all_missing(
320
- "No published whole-programme assessment recorded in this repository."
 
321
  ),
322
- reason="no published model-specific assessment recorded here",
323
- ),
324
- "gpt-oss-20b": ReleaseAssessment(
325
- model_id="gpt-oss-20b",
326
- coverage="No data",
327
- areas=_all_missing(
328
- "No published whole-programme assessment recorded in this repository."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  ),
330
- reason="no published model-specific assessment recorded here",
331
  ),
332
  }
333
 
 
20
 
21
  from __future__ import annotations
22
 
23
+ from collections.abc import Iterable, Mapping, Sequence
24
  from dataclasses import dataclass
25
  from enum import Enum
26
  from types import MappingProxyType
 
137
  class ReleaseAssessment:
138
  """A cradle-to-release assessment of one model release.
139
 
140
+ This used to carry an ``evidence_grade`` letter alongside the areas, and a
141
+ sibling ordinal for impact. Both are gone. A letter is a ranking this
142
+ catalogue invented on top of numbers somebody else published, and once the
143
+ published numbers are shown in full -- with their units, their method and
144
+ their primary source -- the letter adds no information and one more way to
145
+ be wrong. What replaced it is ``coverage``, which is not a score: it counts
146
+ how many of the six areas have a published result and says nothing about
147
+ whether those results are large or small.
148
  """
149
 
150
  model_id: str
151
  coverage: str
152
  areas: Mapping[str, AreaResult]
153
+ summary: str = ""
154
+ covers: str = ""
155
  weights_sha256: str = ""
156
  lineage: str = ""
157
  boundary: str = RELEASE_BOUNDARY
158
  reason: str = ""
159
+ links: Sequence[Mapping[str, str]] = ()
160
 
161
  def __post_init__(self) -> None:
162
  missing_areas = set(RUBRIC_AREAS) - set(self.areas)
163
  if missing_areas:
164
  raise ValueError(f"release assessment omits areas: {sorted(missing_areas)}")
 
 
165
  if self.coverage not in {"Complete", "Partial", "Insufficient", "No data"}:
166
  raise ValueError("coverage must be Complete, Partial, Insufficient or No data")
167
+ if self.coverage == "No data" and self.has_any_result:
168
+ raise ValueError("'No data' cannot carry a published result")
169
+ if self.coverage != "No data" and not self.has_any_result:
170
+ raise ValueError("a coverage other than 'No data' requires at least one result")
171
+ for link in self.links:
172
+ if not link.get("label") or not link.get("url"):
173
+ raise ValueError("every link needs both a label and a url")
174
  object.__setattr__(self, "areas", MappingProxyType(dict(self.areas)))
175
+ object.__setattr__(self, "links", tuple(MappingProxyType(dict(link)) for link in self.links))
176
 
177
  @property
178
  def has_any_result(self) -> bool:
179
  return any(item.status is not AreaStatus.MISSING for item in self.areas.values())
180
 
181
  @property
182
+ def assessed_areas(self) -> int:
183
+ return sum(1 for item in self.areas.values() if item.status is not AreaStatus.MISSING)
184
 
185
+ @property
186
+ def coverage_display(self) -> str:
187
+ """Coverage and its boundary, inseparable.
188
+
189
+ Coverage on its own invites the reading that four of six is a middling
190
+ score. It is not a score at all, so the count always travels with the
191
+ word that says what it counts.
192
+ """
193
+
194
+ if self.coverage == "No data":
195
+ return "no published result"
196
+ return f"{self.coverage.lower()} Β· {self.assessed_areas} of {len(RUBRIC_AREAS)} areas published"
197
 
198
  def to_dict(self) -> dict:
199
  return {
200
  "model_id": self.model_id,
 
 
201
  "coverage": self.coverage,
202
+ "coverage_display": self.coverage_display,
203
+ "assessed_areas": self.assessed_areas,
204
+ "total_areas": len(RUBRIC_AREAS),
205
+ "summary": self.summary,
206
+ "covers": self.covers,
207
  "boundary": self.boundary,
208
  "weights_sha256": self.weights_sha256,
209
  "lineage": self.lineage,
210
  "reason": self.reason,
211
+ "links": [dict(link) for link in self.links],
212
  "areas": {area: item.to_dict() for area, item in self.areas.items()},
213
  }
214
 
 
216
  # LABELS NAME THE PUBLICATION, NOT THE SIZE.
217
  #
218
  # These used to read "smallest", "balanced test model" and "largest test
219
+ # model". Size is not something this catalogue evaluates, and putting it in the
220
+ # name of the thing being chosen invited exactly the reasoning this surface
221
  # exists to refuse: that a smaller file is a better environmental choice. It
222
  # might be, and it might not, and the only way to know is a published result.
223
  # What belongs in a label is who published the weights, because that is what
224
+ # the reader has to be able to check.
225
+ #
226
+ # WHY THESE THREE. Every model here has a published, model-specific assessment
227
+ # of its training. Models with none were removed rather than listed with an
228
+ # empty record; see the note in ``distinct_agent.models`` for what went and why.
229
  MODEL_CATALOG: dict[str, ModelManifest] = {
230
+ # DEFAULT of the worker catalogue: the lowest published figures of any
231
+ # assessed release at a size this network can run. Kept in step with
232
+ # distinct_agent.models.KNOWN_MODEL_MANIFESTS; the drift test in
233
+ # tests/test_catalog.py pins filename, RAM and context length.
234
+ "olmoe-1b-7b-0924-instruct": ModelManifest(
235
+ id="olmoe-1b-7b-0924-instruct",
236
+ label="OLMoE 1B-7B 0924 Instruct Β· Ai2 first-party GGUF",
237
+ filename="olmoe-1b-7b-0924-instruct-q4_k_m.gguf",
238
+ min_ram_gb=8.0,
239
  context_length=4_096,
240
  ),
241
+ "olmo-2-1124-7b-instruct": ModelManifest(
242
+ id="olmo-2-1124-7b-instruct",
243
+ label="OLMo 2 1124 7B Instruct Β· Ai2 first-party GGUF",
244
+ filename="olmo-2-1124-7B-instruct-Q4_K_M.gguf",
245
  min_ram_gb=8.0,
246
  context_length=4_096,
247
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  "llama-2-7b-chat": ModelManifest(
249
  id="llama-2-7b-chat",
250
+ label="Llama 2 7B Chat Β· Meta Β· gated licence",
251
  filename="llama-2-7b-chat.Q4_K_M.gguf",
252
+ min_ram_gb=8.0,
253
  context_length=4_096,
254
  ),
 
 
 
 
 
 
 
255
  }
256
 
257
 
258
+ # THE ONE ROUTE, AND WHAT IT NOW LETS THROUGH.
259
  #
260
+ # The rule has not changed: a published, model-specific result, used exactly as
261
+ # published. GPU-hours, FLOPs, tokens, parameters, TDP, PUE, grid factors and
262
+ # another model's assessment must not be converted here into invented MWh,
263
+ # tonnes or litres. The contextual route that once allowed an ordinal judgement
264
+ # from "disclosed scale, hardware, location and controls" stays deleted, because
 
265
  # it was caught reasoning "a small 0.49B checkpoint moderates demand" into a
266
+ # grade, which is the parameters-to-impact conversion wearing a letter.
 
 
267
  #
268
+ # What changed is that the catalogue now holds models this rule has something to
269
+ # say about. Every entry below carries real published figures instead of the
270
+ # uniform "No data" this structure used to render, and the detail -- the method
271
+ # behind each number, the primary source, the plain-language summary -- lives in
272
+ # ``model_assessments.json`` and is rendered from there by ``distinct_server.ui``.
273
+ # This structure is the in-catalogue record: enough to be correct on its own if
274
+ # that file is absent, never the fuller surface.
275
  #
276
+ # WHAT IS STILL FORBIDDEN. Llama 2's energy area stays Missing even though Meta
277
+ # disclosed 184,320 GPU-hours at 400 W, because GPU-hours are not joules and the
278
+ # multiplication is ours, not Meta's. Naming the disclosure in the note is the
279
+ # honest handling; converting it is not.
280
+ _AI2_ASSESSMENT = "https://arxiv.org/abs/2503.05804"
281
+ _LLAMA2_ASSESSMENT = "https://arxiv.org/abs/2307.09288"
282
+
283
+ _NO_LAND = AreaResult(
284
+ AreaStatus.MISSING,
285
+ note=(
286
+ "No published per-model land or biodiversity figure exists for any model, from "
287
+ "any lab. ITU-T L.1801 lists the area and states the methodology is developing."
288
+ ),
289
+ )
290
+
291
  RELEASE_ASSESSMENT: dict[str, ReleaseAssessment] = {
292
+ "olmoe-1b-7b-0924-instruct": ReleaseAssessment(
293
+ model_id="olmoe-1b-7b-0924-instruct",
294
+ coverage="Partial",
295
+ covers=(
296
+ "The 0924 pretraining run. The tuning that produced this Instruct checkpoint "
297
+ "is not separately costed in the source."
 
 
 
 
298
  ),
299
+ summary=(
300
+ "The lowest published footprint of any assessed release at a size this network "
301
+ "can run. Ai2 sampled real GPU power at sub-second intervals rather than "
302
+ "assuming chips draw their rated wattage, which makes the energy figure one of "
303
+ "very few genuinely measured training numbers in print. Carbon and water follow "
304
+ "from that measured energy using the site's own grid factor and cooling "
305
+ "efficiency, so they describe Texas as much as they describe the model."
 
306
  ),
307
+ areas={
308
+ "energy": AreaResult(
309
+ AreaStatus.MEASURED,
310
+ value="54",
311
+ unit="MWh",
312
+ note="Final pretraining run, Jupiter cluster, Texas.",
313
+ source_url=_AI2_ASSESSMENT,
314
+ ),
315
+ "climate": AreaResult(
316
+ AreaStatus.MODELLED,
317
+ value="18",
318
+ unit="tCO2e",
319
+ note=(
320
+ "Measured energy at 0.332 kg CO2 per kWh, PUE 1.2. Location-based; "
321
+ "no offset applied."
322
+ ),
323
+ source_url=_AI2_ASSESSMENT,
324
+ ),
325
+ "water": AreaResult(
326
+ AreaStatus.MODELLED,
327
+ value="70",
328
+ unit="kL",
329
+ note=(
330
+ "Measured energy at a water usage effectiveness of 1.29 L per kWh. "
331
+ "On-site cooling only; upstream power-station water is not counted."
332
+ ),
333
+ source_url=_AI2_ASSESSMENT,
334
+ ),
335
+ "land": _NO_LAND,
336
+ "materials": AreaResult(
337
+ AreaStatus.MISSING,
338
+ note=(
339
+ "Ai2 report 22 tCO2e and 4.8 kL of embodied hardware impact across their "
340
+ "whole programme. That is a programme total, not this release's share."
341
+ ),
342
+ ),
343
+ "pollution": AreaResult(AreaStatus.MISSING, note="Not assessed in the source."),
344
+ },
345
+ links=(
346
+ {
347
+ "label": "Model card",
348
+ "url": "https://huggingface.co/allenai/OLMoE-1B-7B-0924-Instruct",
349
+ },
350
+ {
351
+ "label": "Pinned weights",
352
+ "url": "https://huggingface.co/allenai/OLMoE-1B-7B-0924-Instruct-GGUF",
353
+ },
354
+ {"label": "Published assessment", "url": _AI2_ASSESSMENT},
355
  ),
 
356
  ),
357
+ "olmo-2-1124-7b-instruct": ReleaseAssessment(
358
+ model_id="olmo-2-1124-7b-instruct",
359
+ coverage="Partial",
360
+ covers=(
361
+ "The 1124 pretraining run. The tuning that produced this Instruct checkpoint "
362
+ "is not separately costed in the source."
363
+ ),
364
+ summary=(
365
+ "A dense 7B measured by the same team, in the same data centre, in the same "
366
+ "year, by the same method as OLMoE. That makes the pair one of the few honest "
367
+ "like-for-like comparisons in this field: about three times the energy, carbon "
368
+ "and water for comparable capability. Offered as the fallback if the "
369
+ "mixture-of-experts architecture gives trouble locally, not because it is lighter."
370
+ ),
371
+ areas={
372
+ "energy": AreaResult(
373
+ AreaStatus.MEASURED,
374
+ value="157",
375
+ unit="MWh",
376
+ note="Final pretraining run, Jupiter cluster, Texas.",
377
+ source_url=_AI2_ASSESSMENT,
378
+ ),
379
+ "climate": AreaResult(
380
+ AreaStatus.MODELLED,
381
+ value="52",
382
+ unit="tCO2e",
383
+ note=(
384
+ "Measured energy at 0.332 kg CO2 per kWh, PUE 1.2. Location-based; "
385
+ "no offset applied."
386
+ ),
387
+ source_url=_AI2_ASSESSMENT,
388
+ ),
389
+ "water": AreaResult(
390
+ AreaStatus.MODELLED,
391
+ value="202",
392
+ unit="kL",
393
+ note=(
394
+ "Measured energy at 1.29 L per kWh. The 13B sibling drank four times as "
395
+ "much on the same architecture because it trained in Iowa, where the "
396
+ "figure is 3.1 L per kWh: water tracks the site more than the model."
397
+ ),
398
+ source_url=_AI2_ASSESSMENT,
399
+ ),
400
+ "land": _NO_LAND,
401
+ "materials": AreaResult(
402
+ AreaStatus.MISSING,
403
+ note=(
404
+ "Ai2 report 22 tCO2e and 4.8 kL of embodied hardware impact across their "
405
+ "whole programme. That is a programme total, not this release's share."
406
+ ),
407
+ ),
408
+ "pollution": AreaResult(AreaStatus.MISSING, note="Not assessed in the source."),
409
+ },
410
+ links=(
411
+ {
412
+ "label": "Model card",
413
+ "url": "https://huggingface.co/allenai/OLMo-2-1124-7B-Instruct",
414
+ },
415
+ {
416
+ "label": "Pinned weights",
417
+ "url": "https://huggingface.co/allenai/OLMo-2-1124-7B-Instruct-GGUF",
418
+ },
419
+ {"label": "Published assessment", "url": _AI2_ASSESSMENT},
420
  ),
 
421
  ),
422
  "llama-2-7b-chat": ReleaseAssessment(
423
  model_id="llama-2-7b-chat",
424
+ coverage="Partial",
425
+ covers=(
426
+ "Llama 2 7B pretraining. This Chat checkpoint is a post-trained variant; Meta "
427
+ "report fine-tuning compute inside the family total without breaking it out."
428
  ),
429
+ summary=(
430
+ "The only non-Ai2 release here with a published model-specific figure, and a "
431
+ "thin one: carbon and nothing else, modelled from rated chip wattage rather "
432
+ "than measured draw. It is a different kind of number from the two above and "
433
+ "should not be lined up beside them. Meta also report a second carbon column "
434
+ "reading zero on the grounds that they buy matching renewable electricity "
435
+ "annually; that is an accounting position, not a physical one, and the figure "
436
+ "shown here is what the grid actually delivered."
437
+ ),
438
+ reason="unpinned: Meta publish no GGUF and the source repository is gated",
439
+ areas={
440
+ "energy": AreaResult(
441
+ AreaStatus.MISSING,
442
+ note=(
443
+ "Meta disclosed 184,320 A100-80GB GPU-hours at 400 W rated draw but "
444
+ "published no energy figure. Multiplying those out would be an estimate "
445
+ "this catalogue invented, so the area stays Missing and the disclosure "
446
+ "is named instead."
447
+ ),
448
+ ),
449
+ "climate": AreaResult(
450
+ AreaStatus.MODELLED,
451
+ value="31.22",
452
+ unit="tCO2e",
453
+ note=(
454
+ "Modelled by Meta from GPU-hours and rated device power. Location-based. "
455
+ "Meta separately report it as fully offset, which nets to zero on paper "
456
+ "but not on the grid."
457
+ ),
458
+ source_url=_LLAMA2_ASSESSMENT,
459
+ ),
460
+ "water": AreaResult(AreaStatus.MISSING, note="Not disclosed."),
461
+ "land": _NO_LAND,
462
+ "materials": AreaResult(
463
+ AreaStatus.MISSING,
464
+ note="Not disclosed. Embodied hardware impact is absent from Meta's accounting.",
465
+ ),
466
+ "pollution": AreaResult(AreaStatus.MISSING, note="Not disclosed."),
467
+ },
468
+ links=(
469
+ {
470
+ "label": "Model card",
471
+ "url": "https://huggingface.co/meta-llama/Llama-2-7b-chat-hf",
472
+ },
473
+ {"label": "Published assessment", "url": _LLAMA2_ASSESSMENT},
474
  ),
 
475
  ),
476
  }
477
 
distinct_server/presentation.py CHANGED
@@ -84,6 +84,40 @@ HEAD = """
84
  href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400..800&display=swap">
85
  <link rel="stylesheet"
86
  href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400..700&display=swap">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  """
88
 
89
 
@@ -1320,4 +1354,93 @@ details[open] .c-libhelp__toggle::after{content:" -";}
1320
  .c-panel code,.c-panel pre{overflow-wrap:anywhere;}
1321
  .c-join__qr svg{width:128px; height:128px;}
1322
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1323
  """
 
84
  href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400..800&display=swap">
85
  <link rel="stylesheet"
86
  href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400..700&display=swap">
87
+
88
+ <script>
89
+ (function(){
90
+ function media(el){return el.closest ? el.closest('.c-libcard__media') : null;}
91
+ function show(m){
92
+ if(!m || m.classList.contains('is-playing')) return;
93
+ var src = m.getAttribute('data-gif');
94
+ if(!src) return;
95
+ var img = document.createElement('img');
96
+ img.src = src; img.alt='';
97
+ m.appendChild(img); m.classList.add('is-playing');
98
+ }
99
+ function hide(m){
100
+ if(!m) return;
101
+ m.classList.remove('is-playing');
102
+ m.querySelectorAll('img').forEach(function(i){i.remove();});
103
+ }
104
+ document.addEventListener('mouseover', function(e){show(media(e.target));});
105
+ document.addEventListener('mouseout', function(e){
106
+ var m = media(e.target);
107
+ if(m && !m.contains(e.relatedTarget)) hide(m);
108
+ });
109
+ var holdTimer=null;
110
+ document.addEventListener('touchstart', function(e){
111
+ var m = media(e.target); if(!m) return;
112
+ holdTimer = setTimeout(function(){show(m);}, 300);
113
+ }, {passive:true});
114
+ ['touchend','touchcancel'].forEach(function(name){
115
+ document.addEventListener(name, function(e){
116
+ clearTimeout(holdTimer); hide(media(e.target));
117
+ }, {passive:true});
118
+ });
119
+ })();
120
+ </script>
121
  """
122
 
123
 
 
1354
  .c-panel code,.c-panel pre{overflow-wrap:anywhere;}
1355
  .c-join__qr svg{width:128px; height:128px;}
1356
  }
1357
+
1358
+
1359
+ /* ---- Library page: cards over tick boxes ------------------------------ */
1360
+ .c-library-section{scroll-margin-top:24px;}
1361
+ .c-libgrid{display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr));
1362
+ gap:var(--s4); margin:var(--s4) 0;}
1363
+ .c-libcard{background:var(--c-surface); border:1px solid var(--c-hairline);
1364
+ border-radius:var(--r-lg); padding:var(--s4);
1365
+ display:flex; flex-direction:column; gap:6px;
1366
+ transition:box-shadow .18s ease, transform .18s ease;}
1367
+ .c-libcard:hover{box-shadow:var(--e2); transform:translateY(-2px);}
1368
+ .c-libcard__media{margin:calc(var(--s4)*-1) calc(var(--s4)*-1) var(--s2);
1369
+ aspect-ratio:16/9; border-radius:var(--r-lg) var(--r-lg) 0 0;
1370
+ background:var(--c-fill) center/cover no-repeat;
1371
+ display:flex; align-items:center; justify-content:center;
1372
+ overflow:hidden; position:relative;}
1373
+ .c-libcard__media img{width:100%; height:100%; object-fit:cover; display:block;}
1374
+ .c-libcard__play{font-size:22px; color:var(--c-muted); opacity:.8;}
1375
+ .c-libcard__media.is-playing .c-libcard__play{display:none;}
1376
+ .c-libcard__head{display:flex; align-items:center; gap:var(--s2);}
1377
+ .c-libcard__kind{font-size:var(--t-micro); letter-spacing:.06em; text-transform:uppercase;
1378
+ padding:2px 8px; border-radius:999px; background:var(--c-fill);
1379
+ color:var(--c-text-2);}
1380
+ .c-libcard__kind--skill{background:var(--c-sage-wash, #e7efe7); color:var(--c-sage-deep);}
1381
+ .c-libcard__default{margin-left:auto; font-size:var(--t-micro); color:var(--c-sage-deep);
1382
+ border:1px solid currentColor; border-radius:999px; padding:1px 8px;}
1383
+ .c-libcard__name{margin:0; font-size:var(--t-body); font-weight:650; color:var(--c-text);
1384
+ text-transform:capitalize;}
1385
+ .c-libcard__author{margin:0; font-size:var(--t-micro); color:var(--c-muted);}
1386
+ .c-libcard__desc{margin:2px 0 0; font-size:var(--t-small, 13px); line-height:1.5;
1387
+ color:var(--c-text-2);
1388
+ display:-webkit-box; -webkit-line-clamp:4; -webkit-box-orient:vertical;
1389
+ overflow:hidden;}
1390
+ .c-libcard__ref{margin-top:auto; padding-top:var(--s2); font-family:var(--mono);
1391
+ font-size:11px; color:var(--c-muted);}
1392
+ .c-librarydefaults-head{margin-top:var(--s4);}
1393
+
1394
+ /* ---- Quick nav under the identity box --------------------------------- */
1395
+ .c-quicknav{display:flex; gap:var(--s3); justify-content:flex-end;
1396
+ margin:calc(var(--s2)*-1) 0 var(--s2);}
1397
+ .c-quicknav__link{font-size:var(--t-micro); letter-spacing:.04em; text-decoration:none;
1398
+ color:var(--c-sage-deep); border:1px solid var(--c-hairline);
1399
+ background:var(--c-surface); padding:4px 12px; border-radius:999px;}
1400
+ .c-quicknav__link:hover{border-color:var(--c-sage-deep);}
1401
+
1402
+ /* ---- The no-agents veil ----------------------------------------------- */
1403
+ /* Shown only while somebody is signed in (the identity span has text) and
1404
+ the worker panel is in its empty state. Pure CSS state: no events. */
1405
+ .c-veil{display:none;}
1406
+ .c-conversation{position:relative;}
1407
+ .c-shell:has(.c-identity:not(:empty)):has(.c-noworker-hook) .c-veil{
1408
+ display:flex; position:absolute; inset:0; z-index:12;
1409
+ align-items:center; justify-content:center; padding:var(--s5);
1410
+ background:color-mix(in srgb, var(--c-bg, #f7f9f3) 62%, transparent);
1411
+ backdrop-filter:blur(2.5px); border-radius:var(--r-lg);
1412
+ /* The veil informs; it does not imprison. Pointer events pass through
1413
+ everywhere except its own panel, so a transient no-worker blip (a poll
1414
+ gap, a mode change) can never trap a click mid-flow. */
1415
+ pointer-events:none;}
1416
+ .c-veil__panel{pointer-events:auto;}
1417
+ .c-shell:has(.c-identity:not(:empty)):has(.c-noworker-hook) .c-conversation > *:not(.c-veil):not(:first-child){
1418
+ opacity:.45; filter:saturate(.7);}
1419
+ .c-veil__panel{max-width:380px; text-align:center; background:var(--c-surface);
1420
+ border:1px solid var(--c-hairline); border-radius:var(--r-lg);
1421
+ box-shadow:var(--e2); padding:var(--s5);}
1422
+ .c-veil__title{margin:0 0 6px; font-weight:650; font-size:var(--t-lg); color:var(--c-text);}
1423
+ .c-veil__body{margin:0 0 var(--s4); color:var(--c-text-2); line-height:1.55;}
1424
+ .c-veil__action{display:inline-block; text-decoration:none; background:var(--c-sage-deep);
1425
+ color:#fff; padding:10px 20px; border-radius:999px; font-weight:600;}
1426
+ .c-veil__action:hover{filter:brightness(1.08);}
1427
+
1428
+ /* ---- Professional polish pass ----------------------------------------- */
1429
+ /* Calmer sections: the tail accordions read as pages, not widgets. */
1430
+ .c-tail .gradio-accordion, .c-tail details{background:var(--c-surface);
1431
+ border:1px solid var(--c-hairline) !important; border-radius:var(--r-lg) !important;
1432
+ padding:var(--s3) var(--s4) !important; margin-bottom:var(--s4) !important;}
1433
+ .c-tail .label-wrap span{font-weight:650 !important; font-size:var(--t-lg) !important;}
1434
+ /* One shadow language everywhere. */
1435
+ .c-grade-card,.c-panel,.c-legend,.c-workers article{box-shadow:none !important;}
1436
+ /* Inputs: quiet until focused. */
1437
+ .gradio-container input[type="text"], .gradio-container textarea{
1438
+ border-radius:var(--r-md) !important;}
1439
+ @media (max-width:700px){
1440
+ .c-libgrid{grid-template-columns:1fr 1fr; gap:var(--s3);}
1441
+ .c-quicknav{justify-content:flex-start;}
1442
+ }
1443
+ @media (max-width:460px){
1444
+ .c-libgrid{grid-template-columns:1fr;}
1445
+ }
1446
  """
distinct_server/render.py CHANGED
@@ -177,7 +177,7 @@ def no_tools_note(notice_text: str) -> str:
177
  )
178
 
179
 
180
- def join_this_server(*, addresses, code: str, qr: str = "") -> str:
181
  """What to point an agent at, with the address filled in.
182
 
183
  This panel exists because the previous instruction was a command with
@@ -215,11 +215,13 @@ def join_this_server(*, addresses, code: str, qr: str = "") -> str:
215
  else '<p class="c-join__lead">Generate a pairing code below, then run the '
216
  "command with it.</p>"
217
  )
 
 
 
 
218
  command = (
219
- f"python -m distinct_agent \\\n"
220
- f" --server {first} \\\n"
221
- f" --pair {code or 'YOUR-CODE'} \\\n"
222
- f" --models qwen3-0.6b --tools local"
223
  )
224
  picture = f'<div class="c-join__qr">{qr}</div>' if qr else ""
225
  return (
@@ -509,7 +511,7 @@ def worker_cards(rows: Sequence[Sequence[object]], *, narrowed_by: str = "") ->
509
  """
510
 
511
  if not rows:
512
- return empty_state(
513
  glyph="β—‹",
514
  title="No compatible worker",
515
  # Which of the choices above emptied the list. Without this the
@@ -531,7 +533,7 @@ def worker_cards(rows: Sequence[Sequence[object]], *, narrowed_by: str = "") ->
531
  )
532
  ),
533
  compact=True,
534
- )
535
 
536
  cards: list[str] = []
537
  for row in rows:
@@ -781,6 +783,18 @@ def energy_headline(
781
  )
782
 
783
 
 
 
 
 
 
 
 
 
 
 
 
 
784
  def format_joules(joules: float) -> str:
785
  """A figure with a unit people read without converting.
786
 
@@ -1162,6 +1176,79 @@ def _energy_block(lines: Sequence[str], *, measured: bool, quiet: bool = False)
1162
  # --------------------------------------------------------------------------
1163
 
1164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1165
  # --------------------------------------------------------------------------
1166
  # Better Images of AI: the artwork set, and attribution that cannot drift
1167
  # --------------------------------------------------------------------------
 
177
  )
178
 
179
 
180
+ def join_this_server(*, addresses, code: str, qr: str = "", tools: str = "local") -> str:
181
  """What to point an agent at, with the address filled in.
182
 
183
  This panel exists because the previous instruction was a command with
 
215
  else '<p class="c-join__lead">Generate a pairing code below, then run the '
216
  "command with it.</p>"
217
  )
218
+ # One line on purpose. The previous form used backslash continuations,
219
+ # which are bash syntax: pasted into PowerShell they became stray
220
+ # arguments and the command half-ran. A single line pastes correctly
221
+ # into every shell anybody actually uses.
222
  command = (
223
+ f"python -m distinct_agent --server {first} "
224
+ f"--pair {code or 'YOUR-CODE'} --models qwen3-0.6b --tools {tools or 'local'}"
 
 
225
  )
226
  picture = f'<div class="c-join__qr">{qr}</div>' if qr else ""
227
  return (
 
511
  """
512
 
513
  if not rows:
514
+ return '<div class="c-noworker-hook">' + empty_state(
515
  glyph="β—‹",
516
  title="No compatible worker",
517
  # Which of the choices above emptied the list. Without this the
 
533
  )
534
  ),
535
  compact=True,
536
+ ) + "</div>"
537
 
538
  cards: list[str] = []
539
  for row in rows:
 
783
  )
784
 
785
 
786
+ def format_joules_per_token(value: float) -> str:
787
+ """Joules per token at a scale people can read.
788
+
789
+ A small model's figure is millijoules; a big machine's is joules. Two
790
+ decades of unit, chosen by size, so neither reads as zero.
791
+ """
792
+
793
+ if value >= 1.0:
794
+ return f"{value:.2f} J/tok"
795
+ return f"{value * 1000:.1f} mJ/tok"
796
+
797
+
798
  def format_joules(joules: float) -> str:
799
  """A figure with a unit people read without converting.
800
 
 
1176
  # --------------------------------------------------------------------------
1177
 
1178
 
1179
+
1180
+ # --------------------------------------------------------------------------
1181
+ # Library cards
1182
+ # --------------------------------------------------------------------------
1183
+
1184
+ _DEMO_DIR = (Path(__file__).resolve().parent.parent / "assets" / "library-demos")
1185
+ _DEMO_BASE = f"/gradio_api/file={_DEMO_DIR.as_posix()}"
1186
+
1187
+
1188
+ def _demo_gif(ref: str) -> str:
1189
+ """The demo GIF's URL for a library member, or nothing.
1190
+
1191
+ The file is looked for on disk at render time so a card never promises a
1192
+ demonstration the repository does not carry. Slug: the ref with ``@`` and
1193
+ ``.`` flattened, which is how the recorder names them.
1194
+ """
1195
+
1196
+ slug = ref.replace("@", "-").replace(".", "-")
1197
+ if (_DEMO_DIR / f"{slug}.gif").is_file():
1198
+ return f"{_DEMO_BASE}/{slug}.gif"
1199
+ return ""
1200
+
1201
+
1202
+ def library_cards(rows: Sequence[Mapping[str, object]], chosen: Sequence[str]) -> str:
1203
+ """The library as cards a person reads, not a list of tick boxes.
1204
+
1205
+ Every field is escaped: descriptions come from tool specs, which workers
1206
+ and third-party repositories can influence. The demo image is attached as
1207
+ ``data-gif`` and only swapped in by the page's own hover script, so a
1208
+ card with no recording is simply a card, not a broken image.
1209
+ """
1210
+
1211
+ if not rows:
1212
+ return empty_state(
1213
+ glyph="β—‡",
1214
+ title="Nothing matches",
1215
+ body="No library member matches that search. Clear it to see the shelf.",
1216
+ )
1217
+ picked = set(chosen)
1218
+ cards = []
1219
+ for row in rows:
1220
+ ref = str(row.get("ref", ""))
1221
+ kind = str(row.get("kind", "tool"))
1222
+ name = str(row.get("name", ref))
1223
+ author = str(row.get("author", "distinct"))
1224
+ description = str(row.get("description", ""))
1225
+ gif = _demo_gif(ref)
1226
+ media = (
1227
+ f'<figure class="c-libcard__media" data-gif="{esc(gif)}">'
1228
+ '<span class="c-libcard__play" aria-hidden="true">&#9656;</span>'
1229
+ "<figcaption class=\"c-visually-hidden\">Hover or hold to preview</figcaption>"
1230
+ "</figure>"
1231
+ if gif
1232
+ else ""
1233
+ )
1234
+ default_mark = (
1235
+ '<span class="c-libcard__default">default</span>' if ref in picked else ""
1236
+ )
1237
+ cards.append(
1238
+ f'<article class="c-libcard c-libcard--{esc(kind)}">'
1239
+ f"{media}"
1240
+ '<div class="c-libcard__head">'
1241
+ f'<span class="c-libcard__kind c-libcard__kind--{esc(kind)}">{esc(kind)}</span>'
1242
+ f"{default_mark}"
1243
+ "</div>"
1244
+ f'<h3 class="c-libcard__name">{esc(name)}</h3>'
1245
+ f'<p class="c-libcard__author">by {esc(author)}</p>'
1246
+ f'<p class="c-libcard__desc">{esc(description)}</p>'
1247
+ f'<code class="c-libcard__ref">{esc(ref)}</code>'
1248
+ "</article>"
1249
+ )
1250
+ return '<div class="c-libgrid">' + "".join(cards) + "</div>"
1251
+
1252
  # --------------------------------------------------------------------------
1253
  # Better Images of AI: the artwork set, and attribution that cannot drift
1254
  # --------------------------------------------------------------------------
distinct_server/ui.py CHANGED
@@ -13,9 +13,10 @@ from typing import Any, Mapping, Optional, Sequence
13
  import gradio as gr
14
 
15
  from distinct_protocol import (
16
- AGENT_MODES,
17
  MAX_LIVE_STEP_TEXT,
18
  MAX_RUN_STEPS,
 
 
19
  PHASE_CALLING_TOOL,
20
  PHASE_FETCHING_WEIGHTS,
21
  PHASE_GENERATING,
@@ -27,8 +28,6 @@ from distinct_protocol import (
27
  PHASE_QUEUED_ON_WORKER,
28
  PHASE_WAITING_FOR_WORKER,
29
  PHASE_WORKING,
30
- MODE_AGENT,
31
- MODE_SIMPLE,
32
  STEP_KINDS,
33
  STEP_PHASE,
34
  AgentStatus,
@@ -37,13 +36,13 @@ from distinct_protocol import (
37
  ToolSelection,
38
  normalise_phase,
39
  )
40
- from distinct_protocol.fences import FILE_FENCE_BODIES
41
  from distinct_protocol.fences import defang as _defang_fences
42
- from distinct_protocol.fences import defang_marker_value
43
  from distinct_protocol.handshake import Allowance
44
 
45
  from . import render
46
  from .api import AgentApi
 
47
  from .catalog import (
48
  AREA_LABELS,
49
  MODEL_CATALOG,
@@ -54,7 +53,6 @@ from .catalog import (
54
  model_choices,
55
  )
56
  from .control_plane import ControlPlane
57
- from .auth_routes import LOGIN_PATH, identity_from_session
58
  from .identity import (
59
  LoginRequired,
60
  SessionAuthorizer,
@@ -186,15 +184,24 @@ def _library_specs() -> Mapping[str, Mapping[str, str]]:
186
 
187
  try:
188
  import distinct_tools
 
189
 
190
- return {
191
- f"{spec.tool_id}@{spec.version}": {
 
 
 
 
 
 
 
 
192
  "kind": getattr(spec, "kind", "tool"),
193
  "name": str(spec.tool_id).replace("_", " "),
194
  "description": str(getattr(spec, "description", "")),
 
195
  }
196
- for spec in distinct_tools.INSTALLABLE_SPECS
197
- }
198
  except Exception:
199
  return {}
200
 
@@ -401,11 +408,29 @@ class EnergyReading:
401
  scope: str
402
  provider: str
403
  reason: Optional[str] = None
 
 
 
404
 
405
  @property
406
  def measured(self) -> bool:
407
  return self.status == ENERGY_MEASURED
408
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  @property
410
  def line(self) -> str:
411
  """The one line a completed run carries in the transcript.
@@ -427,7 +452,16 @@ class EnergyReading:
427
  return f"Electricity: β€” **No data**{detail} Β· not counted as zero"
428
  means = render.comparator(float(self.joules or 0.0))
429
  suffix = f" Β· {means}" if means else ""
430
- return f"Electricity: **{render.format_joules(float(self.joules))}**{suffix}"
 
 
 
 
 
 
 
 
 
431
 
432
 
433
  def _energy_reading(result: Any) -> EnergyReading:
@@ -470,7 +504,13 @@ def _energy_reading(result: Any) -> EnergyReading:
470
  return EnergyReading(
471
  ENERGY_MISSING, None, scope, provider, reason or "worker reported no joule value"
472
  )
473
- return EnergyReading(ENERGY_MEASURED, total, scope, provider)
 
 
 
 
 
 
474
 
475
 
476
  def _session_energy_markdown(readings: Sequence[EnergyReading]) -> str:
@@ -683,8 +723,27 @@ def _measurement_note(readings: Sequence[EnergyReading]) -> str:
683
  if providers:
684
  parts.append("Measured by: " + ", ".join(providers) + ".")
685
  parts.append(
686
- "Reported by the worker that ran it and not verifiable from here. A run "
687
- "that returned no measurement is counted as a run and never as zero."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688
  )
689
  return " ".join(parts)
690
 
@@ -1057,7 +1116,6 @@ def _grade_suffix(model_id: str) -> str:
1057
  return "no assessment recorded"
1058
  overall = record.get("overall") if isinstance(record.get("overall"), Mapping) else {}
1059
  evidence = record.get("evidence") if isinstance(record.get("evidence"), Mapping) else {}
1060
- parts: list[str] = []
1061
  letter = _letter(overall.get("letter"))
1062
  if rules.get("impact") and str(overall.get("state") or "") == "grade" and letter:
1063
  assessed = overall.get("areas_assessed")
@@ -1195,10 +1253,15 @@ def _default_server_catalogue() -> "Allowance":
1195
  tools: tuple[str, ...] = ()
1196
  try:
1197
  import distinct_tools
1198
-
1199
- tools = tuple(
1200
- sorted(f"{spec.tool_id}@{spec.version}" for spec in distinct_tools.INSTALLABLE_SPECS)
1201
- )
 
 
 
 
 
1202
  except Exception:
1203
  tools = ()
1204
  return Allowance(models=tuple(MODEL_CATALOG), tools=tools)
@@ -1283,19 +1346,27 @@ class DistinctUI:
1283
 
1284
  # Library ----------------------------------------------------------
1285
 
1286
- def _advertised_library_refs(self) -> tuple[str, ...]:
1287
- """Every exact ``id@version`` some agent currently advertises."""
 
 
 
 
 
 
 
 
1288
 
1289
  refs: set[str] = set()
1290
  try:
1291
- for view in self.control.list_agents():
1292
  for item in view.capabilities.tools:
1293
  refs.add(item if "@" in item else f"{item}@1.0.0")
1294
  except ControlPlaneError:
1295
  pass
1296
  return tuple(sorted(refs))
1297
 
1298
- def _library_choices(self) -> list[tuple[str, str]]:
1299
  """Options for the composer's library picker, named for people.
1300
 
1301
  The label leads with a readable name and its kind, because that is
@@ -1306,7 +1377,7 @@ class DistinctUI:
1306
 
1307
  specs = _library_specs()
1308
  choices: list[tuple[str, str]] = []
1309
- for ref in self._advertised_library_refs():
1310
  spec = specs.get(ref)
1311
  if spec is None:
1312
  choices.append((f"{ref} Β· library member", ref))
@@ -1333,8 +1404,12 @@ class DistinctUI:
1333
  )
1334
  return render.library_help(rows)
1335
 
1336
- def _tool_selections(self, values: Optional[Sequence[str]]) -> tuple[ToolSelection, ...]:
1337
- advertised = frozenset(self._advertised_library_refs())
 
 
 
 
1338
  result = []
1339
  for value in values or ():
1340
  if not isinstance(value, str) or "@" not in value:
@@ -1353,6 +1428,7 @@ class DistinctUI:
1353
  selected: Optional[Sequence[str]] = None,
1354
  *,
1355
  defaults: Optional[Sequence[str]] = None,
 
1356
  ) -> tuple[Any, str]:
1357
  """The refreshed selector plus its undernote, preserving selection.
1358
 
@@ -1365,7 +1441,7 @@ class DistinctUI:
1365
  what a run starts with, never what it is stuck with.
1366
  """
1367
 
1368
- choices = self._library_choices()
1369
  valid = {ref for _, ref in choices}
1370
  if selected is None and defaults:
1371
  value = [ref for ref in defaults if ref in valid]
@@ -1409,8 +1485,10 @@ class DistinctUI:
1409
  rows.append({"ref": ref, **spec})
1410
  return rows[: self.LIBRARY_PAGE_LIMIT], len(rows)
1411
 
1412
- def _library_page(self, query: str, defaults: Sequence[str]) -> tuple[Any, str]:
1413
- """The page a person picks their defaults on, and its summary line."""
 
 
1414
 
1415
  rows, matched = self._library_rows(query)
1416
  total = len(_library_specs())
@@ -1429,11 +1507,12 @@ class DistinctUI:
1429
  render.library_summary(
1430
  shown=len(rows), matched=matched, total=total, chosen=len(kept)
1431
  ),
 
1432
  )
1433
 
1434
  def update_library_page(
1435
  self, query: str, state: Mapping[str, Any]
1436
- ) -> tuple[Any, str]:
1437
  """Re-filter the page without touching what has been chosen."""
1438
 
1439
  return self._library_page(query, _library_defaults(state))
@@ -1443,7 +1522,9 @@ class DistinctUI:
1443
  visible_choice: Sequence[str],
1444
  query: str,
1445
  state: Mapping[str, Any],
1446
- ) -> tuple[Any, Any, str, Any, str]:
 
 
1447
  """Record the defaults, merging the visible page into the kept set.
1448
 
1449
  The subtlety is that the component only reports what it can see. A
@@ -1462,11 +1543,17 @@ class DistinctUI:
1462
  }
1463
  specs = _library_specs()
1464
  updated = {**state, "library_defaults": sorted(ref for ref in chosen if ref in specs)}
 
 
 
 
1465
  selector, note = self._library_components(
1466
- _library_defaults(updated), defaults=_library_defaults(updated)
 
 
1467
  )
1468
- _, summary = self._library_page(query, _library_defaults(updated))
1469
- return updated, updated, summary, selector, note
1470
 
1471
  # Outputs ----------------------------------------------------------
1472
 
@@ -1777,9 +1864,9 @@ class DistinctUI:
1777
  DEFAULT_MODEL_ID, (), "auto", MODE_SIMPLE, viewer_id=user_id
1778
  )
1779
  library, library_note = self._library_components(
1780
- None, defaults=_library_defaults(updated)
1781
  )
1782
- page, page_summary = self._library_page("", _library_defaults(updated))
1783
  return (
1784
  updated,
1785
  updated,
@@ -1800,6 +1887,7 @@ class DistinctUI:
1800
  # the moment the accordion is expanded.
1801
  page,
1802
  page_summary,
 
1803
  )
1804
 
1805
  def _live_steps(self, view: Any) -> list[Mapping[str, Any]]:
@@ -1893,7 +1981,7 @@ class DistinctUI:
1893
  """
1894
 
1895
  try:
1896
- tools = self._tool_selections(tool_values)
1897
  views = self.control.compatible_agents(
1898
  model_id, tools, viewer_id=viewer_id, include_offline=True
1899
  )
@@ -2181,7 +2269,7 @@ class DistinctUI:
2181
  raise ValueError("Enter a request")
2182
  if model_id not in MODEL_CATALOG:
2183
  raise ValueError("Select a supported model")
2184
- tools = self._tool_selections(tool_values)
2185
  conversation = active_conversation(state)
2186
  parent_job_id = conversation["job_ids"][-1] if conversation["job_ids"] else None
2187
  stored = self._store_attachments(state["session_id"], uploads or ())
@@ -2274,11 +2362,17 @@ class DistinctUI:
2274
  # CheckboxGroup on every tick races the user's own clicks: a box
2275
  # ticked while a tick was in flight would be silently unticked by the
2276
  # stale value coming back.
2277
- current_refs = self._advertised_library_refs()
2278
- if current_refs != getattr(self, "_last_library_refs", None):
2279
- self._last_library_refs = current_refs
 
 
 
 
2280
  library, library_note = self._library_components(
2281
- tool_values or None, defaults=_library_defaults(updated)
 
 
2282
  )
2283
  else:
2284
  library, library_note = gr.skip(), gr.skip()
@@ -2878,7 +2972,7 @@ class DistinctUI:
2878
  pass
2879
  return ""
2880
 
2881
- def _join_panel(self, code: str = "") -> str:
2882
  """The address an agent should be pointed at, and the code if minted.
2883
 
2884
  The port comes from the same policy the launch uses, so the panel
@@ -2909,7 +3003,11 @@ class DistinctUI:
2909
  qr = qr_svg(addresses[0])
2910
  except Exception: # noqa: BLE001 - the address alone is enough
2911
  qr = ""
2912
- return render.join_this_server(addresses=addresses, code=code, qr=qr)
 
 
 
 
2913
 
2914
  # Pairing/downloads ------------------------------------------------
2915
 
@@ -2922,7 +3020,7 @@ class DistinctUI:
2922
  try:
2923
  user_id = self._authorize(state, profile, request)
2924
  value = self.control.create_pairing_code(user_id)
2925
- return self._join_panel(value.code)
2926
  except (ControlPlaneError, LoginRequired, ValueError) as exc:
2927
  raise gr.Error(str(exc)) from exc
2928
 
@@ -3011,6 +3109,15 @@ class DistinctUI:
3011
  # button below is the one sign-in control, so a header link
3012
  # would only duplicate it.
3013
  identity = gr.HTML(_identity_html(""))
 
 
 
 
 
 
 
 
 
3014
 
3015
  # THE SIGN-IN CONTROL, AND WHY IT IS CONDITIONAL.
3016
  #
@@ -3177,6 +3284,23 @@ class DistinctUI:
3177
  )
3178
 
3179
  with gr.Column(elem_classes="c-conversation", min_width=0):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3180
  chat = gr.Chatbot(
3181
  label="Conversation",
3182
  show_label=False,
@@ -3297,7 +3421,9 @@ class DistinctUI:
3297
  # left edge of their own. Inside the tail they share the shell's
3298
  # padding box like everything else.
3299
  with gr.Column(elem_classes="c-tail"):
3300
- with gr.Accordion("Run a community agent", open=False):
 
 
3301
  gr.Markdown(
3302
  # "The agent owns its bounded FIFO and reports it
3303
  # on every poll" is true and is a sentence for
@@ -3327,7 +3453,7 @@ class DistinctUI:
3327
  "Download agent", variant="secondary"
3328
  )
3329
  create_code = gr.Button("Generate pairing code")
3330
- code_output = gr.Markdown()
3331
 
3332
  # THE LIBRARY, AT THE SIZE IT IS GOING TO BE.
3333
  #
@@ -3338,12 +3464,17 @@ class DistinctUI:
3338
  # person does here is choose their defaults once; those
3339
  # are remembered in this browser and tick themselves on
3340
  # every run where a worker offers them.
3341
- with gr.Accordion("Library and your defaults", open=False):
 
 
 
3342
  gr.Markdown(
3343
- "Everything this server knows about. Tick the ones you "
3344
- "usually want: they will be selected for you whenever a "
3345
- "worker offers them, and you can untick any of them for a "
3346
- "single run without changing what is saved here."
 
 
3347
  )
3348
  library_search = gr.Textbox(
3349
  label="Search the library",
@@ -3355,6 +3486,8 @@ class DistinctUI:
3355
  library_summary = gr.HTML(
3356
  render.library_summary(shown=0, matched=0, total=0, chosen=0)
3357
  )
 
 
3358
  library_defaults = gr.CheckboxGroup(
3359
  label="Your defaults",
3360
  show_label=False,
@@ -3412,6 +3545,7 @@ class DistinctUI:
3412
  activity_panel,
3413
  library_defaults,
3414
  library_summary,
 
3415
  ],
3416
  queue=False,
3417
  api_visibility="private",
@@ -3612,7 +3746,7 @@ class DistinctUI:
3612
  library_search.input(
3613
  self.update_library_page,
3614
  inputs=[library_search, session_state],
3615
- outputs=[library_defaults, library_summary],
3616
  queue=False,
3617
  api_visibility="private",
3618
  )
@@ -3623,6 +3757,7 @@ class DistinctUI:
3623
  session_state,
3624
  browser_state,
3625
  library_summary,
 
3626
  tools,
3627
  library_note,
3628
  ],
 
13
  import gradio as gr
14
 
15
  from distinct_protocol import (
 
16
  MAX_LIVE_STEP_TEXT,
17
  MAX_RUN_STEPS,
18
+ MODE_AGENT,
19
+ MODE_SIMPLE,
20
  PHASE_CALLING_TOOL,
21
  PHASE_FETCHING_WEIGHTS,
22
  PHASE_GENERATING,
 
28
  PHASE_QUEUED_ON_WORKER,
29
  PHASE_WAITING_FOR_WORKER,
30
  PHASE_WORKING,
 
 
31
  STEP_KINDS,
32
  STEP_PHASE,
33
  AgentStatus,
 
36
  ToolSelection,
37
  normalise_phase,
38
  )
39
+ from distinct_protocol.fences import FILE_FENCE_BODIES, defang_marker_value
40
  from distinct_protocol.fences import defang as _defang_fences
 
41
  from distinct_protocol.handshake import Allowance
42
 
43
  from . import render
44
  from .api import AgentApi
45
+ from .auth_routes import identity_from_session
46
  from .catalog import (
47
  AREA_LABELS,
48
  MODEL_CATALOG,
 
53
  model_choices,
54
  )
55
  from .control_plane import ControlPlane
 
56
  from .identity import (
57
  LoginRequired,
58
  SessionAuthorizer,
 
184
 
185
  try:
186
  import distinct_tools
187
+ from distinct_tools.skills import REPOSITORY_SKILLS, REPOSITORY_SPECS
188
 
189
+ adapted: dict[str, str] = {}
190
+ for skill in REPOSITORY_SKILLS:
191
+ source = str(getattr(skill, "source", "") or "")
192
+ adapted[skill.tool_id] = (
193
+ "Anthropic Β· adapted" if "anthropics/skills" in source else "distinct"
194
+ )
195
+ specs: dict[str, dict[str, str]] = {}
196
+ for spec in tuple(distinct_tools.INSTALLABLE_SPECS) + tuple(REPOSITORY_SPECS):
197
+ ref = f"{spec.tool_id}@{spec.version}"
198
+ specs[ref] = {
199
  "kind": getattr(spec, "kind", "tool"),
200
  "name": str(spec.tool_id).replace("_", " "),
201
  "description": str(getattr(spec, "description", "")),
202
+ "author": adapted.get(str(spec.tool_id), "distinct"),
203
  }
204
+ return specs
 
205
  except Exception:
206
  return {}
207
 
 
408
  scope: str
409
  provider: str
410
  reason: Optional[str] = None
411
+ #: Real output token count from the runner, when the runner counted one.
412
+ #: Never derived from characters: an estimated token is not a token.
413
+ output_tokens: Optional[int] = None
414
 
415
  @property
416
  def measured(self) -> bool:
417
  return self.status == ENERGY_MEASURED
418
 
419
+ @property
420
+ def joules_per_token(self) -> Optional[float]:
421
+ """Joules per generated token: the efficiency figure, when honest.
422
+
423
+ Defined only when both halves were actually measured β€” a joule figure
424
+ from the meter and a token count from the runner. This is the
425
+ ``average power Γ· generation speed`` identity, computed on totals.
426
+ """
427
+
428
+ if not self.measured or not self.joules or not self.output_tokens:
429
+ return None
430
+ if self.output_tokens <= 0:
431
+ return None
432
+ return float(self.joules) / float(self.output_tokens)
433
+
434
  @property
435
  def line(self) -> str:
436
  """The one line a completed run carries in the transcript.
 
452
  return f"Electricity: β€” **No data**{detail} Β· not counted as zero"
453
  means = render.comparator(float(self.joules or 0.0))
454
  suffix = f" Β· {means}" if means else ""
455
+ per_token = ""
456
+ if self.joules_per_token is not None:
457
+ per_token = (
458
+ f" Β· {self.output_tokens} tok"
459
+ f" Β· {render.format_joules_per_token(self.joules_per_token)}"
460
+ )
461
+ return (
462
+ f"Electricity: **{render.format_joules(float(self.joules))}**"
463
+ f"{per_token}{suffix}"
464
+ )
465
 
466
 
467
  def _energy_reading(result: Any) -> EnergyReading:
 
504
  return EnergyReading(
505
  ENERGY_MISSING, None, scope, provider, reason or "worker reported no joule value"
506
  )
507
+ tokens = None
508
+ usage = payload.get("usage")
509
+ if isinstance(usage, Mapping):
510
+ raw_tokens = usage.get("output_tokens")
511
+ if isinstance(raw_tokens, int) and not isinstance(raw_tokens, bool) and raw_tokens > 0:
512
+ tokens = raw_tokens
513
+ return EnergyReading(ENERGY_MEASURED, total, scope, provider, output_tokens=tokens)
514
 
515
 
516
  def _session_energy_markdown(readings: Sequence[EnergyReading]) -> str:
 
723
  if providers:
724
  parts.append("Measured by: " + ", ".join(providers) + ".")
725
  parts.append(
726
+ "Measured figures come from hardware counters β€” Intel RAPL through "
727
+ "powercap or the Windows EMI driver, an NVIDIA board through NVML or "
728
+ "nvidia-smi sampling, Apple silicon through its package counters β€” "
729
+ "and modelled figures come from CPU busy-time and say so in their "
730
+ "scope. The two kinds are never added together: different scopes are "
731
+ "different questions."
732
+ )
733
+ parts.append(
734
+ "Where the runner reported a real token count, each answer also shows "
735
+ "joules per generated token β€” total energy over total tokens, the "
736
+ "same identity as average power divided by generation speed. Token "
737
+ "counts are never estimated from characters."
738
+ )
739
+ parts.append(
740
+ "Agent-mode runs spend energy beyond generation: tool calls and the "
741
+ "safety screen are separate passes, and the guard's cost is recorded "
742
+ "against the run apart from the answer's own β€” the pipeline overhead "
743
+ "that measurement studies of agentic systems (e.g. arXiv:2604.00053) "
744
+ "find can dominate. Reported by the worker that ran it and not "
745
+ "verifiable from here. A run that returned no measurement is counted "
746
+ "as a run and never as zero."
747
  )
748
  return " ".join(parts)
749
 
 
1116
  return "no assessment recorded"
1117
  overall = record.get("overall") if isinstance(record.get("overall"), Mapping) else {}
1118
  evidence = record.get("evidence") if isinstance(record.get("evidence"), Mapping) else {}
 
1119
  letter = _letter(overall.get("letter"))
1120
  if rules.get("impact") and str(overall.get("state") or "") == "grade" and letter:
1121
  assessed = overall.get("areas_assessed")
 
1253
  tools: tuple[str, ...] = ()
1254
  try:
1255
  import distinct_tools
1256
+ from distinct_tools.skills import REPOSITORY_SPECS
1257
+
1258
+ # The installable set plus the digest-pinned repository: the
1259
+ # repository members are exactly as offerable by a worker, and a
1260
+ # catalogue that omits them silently strips them from every
1261
+ # operator approval β€” which is how a worker that offered a
1262
+ # repository skill ended up approved for none of them.
1263
+ members = tuple(distinct_tools.INSTALLABLE_SPECS) + tuple(REPOSITORY_SPECS)
1264
+ tools = tuple(sorted(f"{spec.tool_id}@{spec.version}" for spec in members))
1265
  except Exception:
1266
  tools = ()
1267
  return Allowance(models=tuple(MODEL_CATALOG), tools=tools)
 
1346
 
1347
  # Library ----------------------------------------------------------
1348
 
1349
+ def _advertised_library_refs(self, viewer_id: Optional[str] = None) -> tuple[str, ...]:
1350
+ """Every exact ``id@version`` an agent this viewer may see advertises.
1351
+
1352
+ The viewer matters: ``list_agents`` filters by visibility, so asking
1353
+ with no viewer returns nothing at all. That is exactly what happened β€”
1354
+ every caller here asked viewer-less, the picker's choice list was
1355
+ permanently empty, and "no tools installed" showed beside workers
1356
+ that were advertising a dozen. The library page's descriptions are
1357
+ public; which of them is *offered right now* is per-viewer.
1358
+ """
1359
 
1360
  refs: set[str] = set()
1361
  try:
1362
+ for view in self.control.list_agents(viewer_id=viewer_id):
1363
  for item in view.capabilities.tools:
1364
  refs.add(item if "@" in item else f"{item}@1.0.0")
1365
  except ControlPlaneError:
1366
  pass
1367
  return tuple(sorted(refs))
1368
 
1369
+ def _library_choices(self, viewer_id: Optional[str] = None) -> list[tuple[str, str]]:
1370
  """Options for the composer's library picker, named for people.
1371
 
1372
  The label leads with a readable name and its kind, because that is
 
1377
 
1378
  specs = _library_specs()
1379
  choices: list[tuple[str, str]] = []
1380
+ for ref in self._advertised_library_refs(viewer_id):
1381
  spec = specs.get(ref)
1382
  if spec is None:
1383
  choices.append((f"{ref} Β· library member", ref))
 
1404
  )
1405
  return render.library_help(rows)
1406
 
1407
+ def _tool_selections(
1408
+ self,
1409
+ values: Optional[Sequence[str]],
1410
+ viewer_id: Optional[str] = None,
1411
+ ) -> tuple[ToolSelection, ...]:
1412
+ advertised = frozenset(self._advertised_library_refs(viewer_id))
1413
  result = []
1414
  for value in values or ():
1415
  if not isinstance(value, str) or "@" not in value:
 
1428
  selected: Optional[Sequence[str]] = None,
1429
  *,
1430
  defaults: Optional[Sequence[str]] = None,
1431
+ viewer_id: Optional[str] = None,
1432
  ) -> tuple[Any, str]:
1433
  """The refreshed selector plus its undernote, preserving selection.
1434
 
 
1441
  what a run starts with, never what it is stuck with.
1442
  """
1443
 
1444
+ choices = self._library_choices(viewer_id)
1445
  valid = {ref for _, ref in choices}
1446
  if selected is None and defaults:
1447
  value = [ref for ref in defaults if ref in valid]
 
1485
  rows.append({"ref": ref, **spec})
1486
  return rows[: self.LIBRARY_PAGE_LIMIT], len(rows)
1487
 
1488
+ def _library_page(
1489
+ self, query: str, defaults: Sequence[str]
1490
+ ) -> tuple[Any, str, str]:
1491
+ """The page a person picks their defaults on: cards, summary, picker."""
1492
 
1493
  rows, matched = self._library_rows(query)
1494
  total = len(_library_specs())
 
1507
  render.library_summary(
1508
  shown=len(rows), matched=matched, total=total, chosen=len(kept)
1509
  ),
1510
+ render.library_cards(rows, kept),
1511
  )
1512
 
1513
  def update_library_page(
1514
  self, query: str, state: Mapping[str, Any]
1515
+ ) -> tuple[Any, str, str]:
1516
  """Re-filter the page without touching what has been chosen."""
1517
 
1518
  return self._library_page(query, _library_defaults(state))
 
1522
  visible_choice: Sequence[str],
1523
  query: str,
1524
  state: Mapping[str, Any],
1525
+ profile: "gr.OAuthProfile | None" = None,
1526
+ request: "gr.Request | None" = None,
1527
+ ) -> tuple[Any, Any, str, str, Any, str]:
1528
  """Record the defaults, merging the visible page into the kept set.
1529
 
1530
  The subtlety is that the component only reports what it can see. A
 
1543
  }
1544
  specs = _library_specs()
1545
  updated = {**state, "library_defaults": sorted(ref for ref in chosen if ref in specs)}
1546
+ try:
1547
+ viewer_id = self._authorize(dict(state), profile, request)
1548
+ except LoginRequired:
1549
+ viewer_id = None
1550
  selector, note = self._library_components(
1551
+ _library_defaults(updated),
1552
+ defaults=_library_defaults(updated),
1553
+ viewer_id=viewer_id,
1554
  )
1555
+ _, summary, cards = self._library_page(query, _library_defaults(updated))
1556
+ return updated, updated, summary, cards, selector, note
1557
 
1558
  # Outputs ----------------------------------------------------------
1559
 
 
1864
  DEFAULT_MODEL_ID, (), "auto", MODE_SIMPLE, viewer_id=user_id
1865
  )
1866
  library, library_note = self._library_components(
1867
+ None, defaults=_library_defaults(updated), viewer_id=user_id
1868
  )
1869
+ page, page_summary, page_cards = self._library_page("", _library_defaults(updated))
1870
  return (
1871
  updated,
1872
  updated,
 
1887
  # the moment the accordion is expanded.
1888
  page,
1889
  page_summary,
1890
+ page_cards,
1891
  )
1892
 
1893
  def _live_steps(self, view: Any) -> list[Mapping[str, Any]]:
 
1981
  """
1982
 
1983
  try:
1984
+ tools = self._tool_selections(tool_values, viewer_id)
1985
  views = self.control.compatible_agents(
1986
  model_id, tools, viewer_id=viewer_id, include_offline=True
1987
  )
 
2269
  raise ValueError("Enter a request")
2270
  if model_id not in MODEL_CATALOG:
2271
  raise ValueError("Select a supported model")
2272
+ tools = self._tool_selections(tool_values, user_id)
2273
  conversation = active_conversation(state)
2274
  parent_job_id = conversation["job_ids"][-1] if conversation["job_ids"] else None
2275
  stored = self._store_attachments(state["session_id"], uploads or ())
 
2362
  # CheckboxGroup on every tick races the user's own clicks: a box
2363
  # ticked while a tick was in flight would be silently unticked by the
2364
  # stale value coming back.
2365
+ current_refs = self._advertised_library_refs(viewer_id)
2366
+ cache = getattr(self, "_last_library_refs", None)
2367
+ if not isinstance(cache, dict):
2368
+ cache = {}
2369
+ self._last_library_refs = cache
2370
+ if current_refs != cache.get(viewer_id):
2371
+ cache[viewer_id] = current_refs
2372
  library, library_note = self._library_components(
2373
+ tool_values or None,
2374
+ defaults=_library_defaults(updated),
2375
+ viewer_id=viewer_id,
2376
  )
2377
  else:
2378
  library, library_note = gr.skip(), gr.skip()
 
2972
  pass
2973
  return ""
2974
 
2975
+ def _join_panel(self, code: str = "", state: Mapping[str, Any] | None = None) -> str:
2976
  """The address an agent should be pointed at, and the code if minted.
2977
 
2978
  The port comes from the same policy the launch uses, so the panel
 
3003
  qr = qr_svg(addresses[0])
3004
  except Exception: # noqa: BLE001 - the address alone is enough
3005
  qr = ""
3006
+ # The command mirrors the defaults this browser chose on the library
3007
+ # page, so what the person copies is what their runs will expect.
3008
+ chosen = _library_defaults(state) if state is not None else ()
3009
+ tools = ",".join(chosen) if chosen else "local"
3010
+ return render.join_this_server(addresses=addresses, code=code, qr=qr, tools=tools)
3011
 
3012
  # Pairing/downloads ------------------------------------------------
3013
 
 
3020
  try:
3021
  user_id = self._authorize(state, profile, request)
3022
  value = self.control.create_pairing_code(user_id)
3023
+ return self._join_panel(value.code, state)
3024
  except (ControlPlaneError, LoginRequired, ValueError) as exc:
3025
  raise gr.Error(str(exc)) from exc
3026
 
 
3109
  # button below is the one sign-in control, so a header link
3110
  # would only duplicate it.
3111
  identity = gr.HTML(_identity_html(""))
3112
+ # The quick nav sits directly under the identity box: one
3113
+ # anchor per page-worthy section. Links, not buttons, because
3114
+ # they navigate rather than act.
3115
+ gr.HTML(
3116
+ '<nav class="c-quicknav" aria-label="Sections">'
3117
+ '<a class="c-quicknav__link" href="#library">Library</a>'
3118
+ '<a class="c-quicknav__link" href="#join-agent">Run an agent</a>'
3119
+ "</nav>"
3120
+ )
3121
 
3122
  # THE SIGN-IN CONTROL, AND WHY IT IS CONDITIONAL.
3123
  #
 
3284
  )
3285
 
3286
  with gr.Column(elem_classes="c-conversation", min_width=0):
3287
+ # The no-agents veil. Static markup; presentation.py
3288
+ # shows it with :has() only while somebody is signed in
3289
+ # and the worker panel is in its empty state, so there
3290
+ # is no event plumbing to fall out of date. It softly
3291
+ # fades the chat rather than hiding it: the interface
3292
+ # is not broken, it is waiting for a worker.
3293
+ gr.HTML(
3294
+ '<div class="c-veil" role="status">'
3295
+ '<div class="c-veil__panel">'
3296
+ '<p class="c-veil__title">No agents available</p>'
3297
+ '<p class="c-veil__body">Nothing can run until a '
3298
+ "community agent pairs with this server. Set one up "
3299
+ "in a minute, or come back when one is online.</p>"
3300
+ '<a class="c-veil__action" href="#join-agent">'
3301
+ "Set up an agent</a>"
3302
+ "</div></div>"
3303
+ )
3304
  chat = gr.Chatbot(
3305
  label="Conversation",
3306
  show_label=False,
 
3421
  # left edge of their own. Inside the tail they share the shell's
3422
  # padding box like everything else.
3423
  with gr.Column(elem_classes="c-tail"):
3424
+ with gr.Accordion(
3425
+ "Run a community agent", open=False, elem_id="join-agent"
3426
+ ):
3427
  gr.Markdown(
3428
  # "The agent owns its bounded FIFO and reports it
3429
  # on every poll" is true and is a sentence for
 
3453
  "Download agent", variant="secondary"
3454
  )
3455
  create_code = gr.Button("Generate pairing code")
3456
+ gr.Markdown()
3457
 
3458
  # THE LIBRARY, AT THE SIZE IT IS GOING TO BE.
3459
  #
 
3464
  # person does here is choose their defaults once; those
3465
  # are remembered in this browser and tick themselves on
3466
  # every run where a worker offers them.
3467
+ with gr.Accordion(
3468
+ "Library", open=True, elem_id="library",
3469
+ elem_classes="c-library-section",
3470
+ ):
3471
  gr.Markdown(
3472
+ "Every tool and skill this server can offer, who wrote "
3473
+ "it, and what it does. Hover a card β€” or press and hold "
3474
+ "on a phone β€” to watch its demonstration. Tick the "
3475
+ "members you usually want below: they select themselves "
3476
+ "whenever a worker offers them, and any run can untick "
3477
+ "them without changing what is saved."
3478
  )
3479
  library_search = gr.Textbox(
3480
  label="Search the library",
 
3486
  library_summary = gr.HTML(
3487
  render.library_summary(shown=0, matched=0, total=0, chosen=0)
3488
  )
3489
+ library_cards_html = gr.HTML(elem_classes="c-librarycards")
3490
+ gr.Markdown("**Your defaults**", elem_classes="c-librarydefaults-head")
3491
  library_defaults = gr.CheckboxGroup(
3492
  label="Your defaults",
3493
  show_label=False,
 
3545
  activity_panel,
3546
  library_defaults,
3547
  library_summary,
3548
+ library_cards_html,
3549
  ],
3550
  queue=False,
3551
  api_visibility="private",
 
3746
  library_search.input(
3747
  self.update_library_page,
3748
  inputs=[library_search, session_state],
3749
+ outputs=[library_defaults, library_summary, library_cards_html],
3750
  queue=False,
3751
  api_visibility="private",
3752
  )
 
3757
  session_state,
3758
  browser_state,
3759
  library_summary,
3760
+ library_cards_html,
3761
  tools,
3762
  library_note,
3763
  ],
distinct_skills/handlers.py CHANGED
@@ -32,7 +32,7 @@ import base64
32
  import csv
33
  import io
34
  import re
35
- from collections.abc import Mapping
36
 
37
  from .loader import (
38
  MAX_DESCRIPTION_CHARACTERS,
@@ -363,6 +363,637 @@ def csv_table(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[s
363
  }
364
 
365
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  #: The fixed handler table. A skill directory may name one of these and
367
  #: nothing else.
368
  HANDLERS: Mapping[str, object] = {
@@ -370,6 +1001,12 @@ HANDLERS: Mapping[str, object] = {
370
  "skill_scaffold": skill_scaffold,
371
  "review_checklist": review_checklist,
372
  "csv_table": csv_table,
 
 
 
 
 
 
373
  }
374
 
375
  HANDLER_NAMES = frozenset(HANDLERS)
 
32
  import csv
33
  import io
34
  import re
35
+ from collections.abc import Mapping, Sequence
36
 
37
  from .loader import (
38
  MAX_DESCRIPTION_CHARACTERS,
 
363
  }
364
 
365
 
366
+ # --------------------------------------------------------------------------
367
+ # Binary artifacts, and the office formats built as zips of XML
368
+ # --------------------------------------------------------------------------
369
+
370
+
371
+ def _multiline(arguments: Mapping[str, object], name: str, *, limit: int) -> str:
372
+ """Like ``_line`` but newlines are structure here, so they survive.
373
+
374
+ Each line is cleaned individually; the line breaks between them are the
375
+ input format for every handler below that reads rows, slides or sections.
376
+ """
377
+
378
+ value = arguments.get(name)
379
+ if not isinstance(value, str) or not value.strip():
380
+ raise SkillInputError(f"{name} must be a non-empty string")
381
+ if len(value) > limit:
382
+ raise SkillInputError(f"{name} must be at most {limit} characters")
383
+ lines = [" ".join(clean_text(line, limit).split()) for line in value.splitlines()]
384
+ return "\n".join(lines)
385
+
386
+
387
+ def bytes_artifact(name: str, media_type: str, payload: bytes) -> dict[str, object]:
388
+ """The artifact envelope for content that is bytes rather than text.
389
+
390
+ ``text_artifact`` above encodes prose. The office formats are zip
391
+ archives, so they arrive here already encoded; wrapping them through a
392
+ text path would corrupt them on the first non-ASCII byte.
393
+ """
394
+
395
+ return {
396
+ "name": name,
397
+ "media_type": media_type,
398
+ "base64": base64.b64encode(payload).decode("ascii"),
399
+ "size_bytes": len(payload),
400
+ }
401
+
402
+
403
+ def _xml_escape(value: str) -> str:
404
+ return (
405
+ value.replace("&", "&amp;")
406
+ .replace("<", "&lt;")
407
+ .replace(">", "&gt;")
408
+ .replace('"', "&quot;")
409
+ )
410
+
411
+
412
+ def _zip_bytes(parts: Mapping[str, str]) -> bytes:
413
+ """One deterministic zip: fixed timestamps, fixed order, deflate.
414
+
415
+ Deterministic because two runs with the same arguments should produce
416
+ byte-identical files; a timestamp that encodes the wall clock would make
417
+ every artifact unique for no reason a user can see.
418
+ """
419
+
420
+ import io
421
+ import zipfile
422
+
423
+ buffer = io.BytesIO()
424
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
425
+ for name in parts:
426
+ info = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0))
427
+ info.compress_type = zipfile.ZIP_DEFLATED
428
+ archive.writestr(info, parts[name])
429
+ return buffer.getvalue()
430
+
431
+
432
+ _SEPARATORS: Mapping[str, str] = {"pipe": "|", "tab": "\t", "semicolon": ";"}
433
+
434
+
435
+ def _rows_from_text(arguments: Mapping[str, object]) -> list[list[str]]:
436
+ """Header-then-rows lines, split on the named separator, shape-checked.
437
+
438
+ The same input convention as ``csv_table`` and for the same reason: a
439
+ comma would collide with commas inside the data, and a model below 7B
440
+ handles "one line per row, fields split by a pipe" reliably.
441
+ """
442
+
443
+ text = _multiline(arguments, "text", limit=6000)
444
+ separator_name = str(arguments.get("separator") or "pipe")
445
+ if separator_name not in _SEPARATORS:
446
+ raise SkillInputError("separator must be pipe, tab or semicolon")
447
+ separator = _SEPARATORS[separator_name]
448
+ lines = [line for line in text.splitlines() if line.strip()]
449
+ if len(lines) < 2:
450
+ raise SkillInputError("text needs a header line and at least one row")
451
+ rows = [[cell.strip() for cell in line.split(separator)] for line in lines]
452
+ width = len(rows[0])
453
+ for index, row in enumerate(rows[1:], start=2):
454
+ if len(row) != width:
455
+ raise SkillInputError(
456
+ f"line {index} has {len(row)} fields where the header has {width}. "
457
+ "Fix the row rather than letting a column silently shift"
458
+ )
459
+ return rows
460
+
461
+
462
+ _DOCX_CONTENT_TYPES = (
463
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
464
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
465
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
466
+ '<Default Extension="xml" ContentType="application/xml"/>'
467
+ '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
468
+ '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
469
+ "</Types>"
470
+ )
471
+
472
+ _DOCX_ROOT_RELS = (
473
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
474
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
475
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
476
+ "</Relationships>"
477
+ )
478
+
479
+ _DOCX_DOCUMENT_RELS = (
480
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
481
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
482
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
483
+ "</Relationships>"
484
+ )
485
+
486
+ _DOCX_STYLES = (
487
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
488
+ '<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
489
+ '<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/>'
490
+ '<w:rPr><w:b/><w:sz w:val="48"/></w:rPr></w:style>'
491
+ '<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/>'
492
+ '<w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>'
493
+ "</w:styles>"
494
+ )
495
+
496
+ _W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
497
+
498
+
499
+ def _docx_paragraph(text: str, style: str | None = None) -> str:
500
+ properties = f'<w:pPr><w:pStyle w:val="{style}"/></w:pPr>' if style else ""
501
+ return (
502
+ f"<w:p>{properties}<w:r><w:t xml:space=\"preserve\">"
503
+ f"{_xml_escape(text)}</w:t></w:r></w:p>"
504
+ )
505
+
506
+
507
+ def document_docx(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[str, object]:
508
+ """A real, minimal ``.docx``: title, headings and paragraphs.
509
+
510
+ Lines are paragraphs. A line beginning ``# `` becomes a heading. That is
511
+ the entire input language, because the point of this skill is that the
512
+ model supplies sentences and the format arithmetic happens here.
513
+ """
514
+
515
+ del skill
516
+ _known(arguments, frozenset({"title", "body", "filename"}))
517
+ title = _line(arguments, "title", limit=200)
518
+ body = _multiline(arguments, "body", limit=6000)
519
+ blocks = [_docx_paragraph(title, "Title")]
520
+ for raw_line in body.splitlines():
521
+ line = raw_line.strip()
522
+ if not line:
523
+ continue
524
+ if line.startswith("# "):
525
+ blocks.append(_docx_paragraph(line[2:].strip(), "Heading1"))
526
+ else:
527
+ blocks.append(_docx_paragraph(line))
528
+ document = (
529
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
530
+ f'<w:document xmlns:w="{_W}"><w:body>' + "".join(blocks) + "</w:body></w:document>"
531
+ )
532
+ payload = _zip_bytes(
533
+ {
534
+ "[Content_Types].xml": _DOCX_CONTENT_TYPES,
535
+ "_rels/.rels": _DOCX_ROOT_RELS,
536
+ "word/_rels/document.xml.rels": _DOCX_DOCUMENT_RELS,
537
+ "word/styles.xml": _DOCX_STYLES,
538
+ "word/document.xml": document,
539
+ }
540
+ )
541
+ return {
542
+ "artifact": bytes_artifact(
543
+ _filename(arguments.get("filename"), "document.docx", ".docx"),
544
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
545
+ payload,
546
+ )
547
+ }
548
+
549
+
550
+ _XLSX_CONTENT_TYPES = (
551
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
552
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
553
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
554
+ '<Default Extension="xml" ContentType="application/xml"/>'
555
+ '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
556
+ '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
557
+ "</Types>"
558
+ )
559
+
560
+ _XLSX_ROOT_RELS = (
561
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
562
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
563
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
564
+ "</Relationships>"
565
+ )
566
+
567
+ _XLSX_WORKBOOK = (
568
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
569
+ '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
570
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
571
+ '<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>'
572
+ )
573
+
574
+ _XLSX_WORKBOOK_RELS = (
575
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
576
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
577
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
578
+ "</Relationships>"
579
+ )
580
+
581
+
582
+ def _column_name(index: int) -> str:
583
+ name = ""
584
+ while index >= 0:
585
+ name = chr(ord("A") + index % 26) + name
586
+ index = index // 26 - 1
587
+ return name
588
+
589
+
590
+ def _xlsx_cell(reference: str, value: str) -> str:
591
+ if _looks_numeric(value):
592
+ return f'<c r="{reference}" t="n"><v>{value.strip()}</v></c>'
593
+ return (
594
+ f'<c r="{reference}" t="inlineStr"><is><t xml:space="preserve">'
595
+ f"{_xml_escape(value)}</t></is></c>"
596
+ )
597
+
598
+
599
+ def spreadsheet_xlsx(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[str, object]:
600
+ """A real ``.xlsx`` from header-then-rows text.
601
+
602
+ Numbers become number cells so the spreadsheet can sum them; everything
603
+ else is an inline string, so nothing here can become a live formula. The
604
+ same guard reasoning as ``csv_table``: a cell of untrusted text must not
605
+ run when the file opens.
606
+ """
607
+
608
+ del skill
609
+ _known(arguments, frozenset({"text", "separator", "filename"}))
610
+ rows = _rows_from_text(arguments)
611
+ body: list[str] = []
612
+ for row_index, row in enumerate(rows, start=1):
613
+ cells = []
614
+ for column_index, value in enumerate(row):
615
+ reference = f"{_column_name(column_index)}{row_index}"
616
+ guarded, _ = _guard(value)
617
+ cells.append(_xlsx_cell(reference, guarded))
618
+ body.append(f'<row r="{row_index}">' + "".join(cells) + "</row>")
619
+ sheet = (
620
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
621
+ '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
622
+ "<sheetData>" + "".join(body) + "</sheetData></worksheet>"
623
+ )
624
+ payload = _zip_bytes(
625
+ {
626
+ "[Content_Types].xml": _XLSX_CONTENT_TYPES,
627
+ "_rels/.rels": _XLSX_ROOT_RELS,
628
+ "xl/workbook.xml": _XLSX_WORKBOOK,
629
+ "xl/_rels/workbook.xml.rels": _XLSX_WORKBOOK_RELS,
630
+ "xl/worksheets/sheet1.xml": sheet,
631
+ }
632
+ )
633
+ return {
634
+ "artifact": bytes_artifact(
635
+ _filename(arguments.get("filename"), "table.xlsx", ".xlsx"),
636
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
637
+ payload,
638
+ )
639
+ }
640
+
641
+
642
+ # --------------------------------------------------------------------------
643
+ # Slide decks: the smallest package python-pptx and PowerPoint both accept
644
+ # --------------------------------------------------------------------------
645
+
646
+ _PPTX_CONTENT_TYPES_HEAD = (
647
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
648
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
649
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
650
+ '<Default Extension="xml" ContentType="application/xml"/>'
651
+ '<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>'
652
+ '<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>'
653
+ '<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>'
654
+ '<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>'
655
+ )
656
+
657
+ _PPTX_ROOT_RELS = (
658
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
659
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
660
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>'
661
+ "</Relationships>"
662
+ )
663
+
664
+ _PPTX_THEME = (
665
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
666
+ '<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="distinct">'
667
+ '<a:themeElements>'
668
+ '<a:clrScheme name="distinct">'
669
+ '<a:dk1><a:srgbClr val="203127"/></a:dk1><a:lt1><a:srgbClr val="F7F9F3"/></a:lt1>'
670
+ '<a:dk2><a:srgbClr val="2E4636"/></a:dk2><a:lt2><a:srgbClr val="EDF1E6"/></a:lt2>'
671
+ '<a:accent1><a:srgbClr val="4C7A5E"/></a:accent1><a:accent2><a:srgbClr val="6E9A7F"/></a:accent2>'
672
+ '<a:accent3><a:srgbClr val="A5C1AE"/></a:accent3><a:accent4><a:srgbClr val="C9D9CC"/></a:accent4>'
673
+ '<a:accent5><a:srgbClr val="8AA893"/></a:accent5><a:accent6><a:srgbClr val="5C8A6D"/></a:accent6>'
674
+ '<a:hlink><a:srgbClr val="2E5E43"/></a:hlink><a:folHlink><a:srgbClr val="2E5E43"/></a:folHlink>'
675
+ "</a:clrScheme>"
676
+ '<a:fontScheme name="distinct"><a:majorFont><a:latin typeface="Georgia"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>'
677
+ '<a:minorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme>'
678
+ '<a:fmtScheme name="distinct">'
679
+ "<a:fillStyleLst><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>"
680
+ "<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill></a:fillStyleLst>"
681
+ '<a:lnStyleLst><a:ln w="6350"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln>'
682
+ '<a:ln w="12700"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln>'
683
+ '<a:ln w="19050"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst>'
684
+ "<a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle>"
685
+ "<a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>"
686
+ "<a:bgFillStyleLst><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>"
687
+ "<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill></a:bgFillStyleLst>"
688
+ "</a:fmtScheme></a:themeElements></a:theme>"
689
+ )
690
+
691
+ _PPTX_MASTER = (
692
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
693
+ '<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
694
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
695
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
696
+ '<p:cSld><p:bg><p:bgPr><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:effectLst/></p:bgPr></p:bg>'
697
+ "<p:spTree>"
698
+ '<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
699
+ "<p:grpSpPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"0\" cy=\"0\"/>"
700
+ "<a:chOff x=\"0\" y=\"0\"/><a:chExt cx=\"0\" cy=\"0\"/></a:xfrm></p:grpSpPr>"
701
+ "</p:spTree></p:cSld>"
702
+ '<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" '
703
+ 'accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>'
704
+ '<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>'
705
+ "</p:sldMaster>"
706
+ )
707
+
708
+ _PPTX_MASTER_RELS = (
709
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
710
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
711
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>'
712
+ '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>'
713
+ "</Relationships>"
714
+ )
715
+
716
+ _PPTX_LAYOUT = (
717
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
718
+ '<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
719
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
720
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank">'
721
+ "<p:cSld><p:spTree>"
722
+ '<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
723
+ "<p:grpSpPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"0\" cy=\"0\"/>"
724
+ "<a:chOff x=\"0\" y=\"0\"/><a:chExt cx=\"0\" cy=\"0\"/></a:xfrm></p:grpSpPr>"
725
+ "</p:spTree></p:cSld>"
726
+ "</p:sldLayout>"
727
+ )
728
+
729
+ _PPTX_LAYOUT_RELS = (
730
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
731
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
732
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>'
733
+ "</Relationships>"
734
+ )
735
+
736
+ _PPTX_SLIDE_RELS = (
737
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
738
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
739
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>'
740
+ "</Relationships>"
741
+ )
742
+
743
+ #: EMU geometry for a 16:9 slide: full width 12192000, height 6858000.
744
+ _PPTX_TITLE_SHAPE = (
745
+ '<p:sp><p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>'
746
+ "<p:nvPr/></p:nvSpPr>"
747
+ '<p:spPr><a:xfrm><a:off x="838200" y="838200"/><a:ext cx="10515600" cy="1325563"/></a:xfrm>'
748
+ '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>'
749
+ "<p:txBody><a:bodyPr/><a:lstStyle/>"
750
+ '<a:p><a:pPr/><a:r><a:rPr lang="en" sz="3600" b="1"><a:solidFill><a:schemeClr val="dk1"/></a:solidFill></a:rPr>'
751
+ "<a:t>{title}</a:t></a:r></a:p></p:txBody></p:sp>"
752
+ )
753
+
754
+ _PPTX_BODY_SHAPE_HEAD = (
755
+ '<p:sp><p:nvSpPr><p:cNvPr id="3" name="Body"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>'
756
+ "<p:nvPr/></p:nvSpPr>"
757
+ '<p:spPr><a:xfrm><a:off x="838200" y="2438400"/><a:ext cx="10515600" cy="3581400"/></a:xfrm>'
758
+ '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>'
759
+ "<p:txBody><a:bodyPr/><a:lstStyle/>"
760
+ )
761
+
762
+
763
+ def _pptx_slide(title: str, points: Sequence[str]) -> str:
764
+ paragraphs = []
765
+ for point in points:
766
+ paragraphs.append(
767
+ '<a:p><a:pPr marL="285750" indent="-285750"><a:buChar char="&#8226;"/></a:pPr>'
768
+ f'<a:r><a:rPr lang="en" sz="2000"><a:solidFill><a:schemeClr val="dk2"/></a:solidFill></a:rPr>'
769
+ f"<a:t>{_xml_escape(point)}</a:t></a:r></a:p>"
770
+ )
771
+ if not paragraphs:
772
+ paragraphs.append("<a:p><a:endParaRPr lang=\"en\"/></a:p>")
773
+ body = _PPTX_BODY_SHAPE_HEAD + "".join(paragraphs) + "</p:txBody></p:sp>"
774
+ return (
775
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
776
+ '<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
777
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
778
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
779
+ "<p:cSld><p:spTree>"
780
+ '<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
781
+ "<p:grpSpPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"0\" cy=\"0\"/>"
782
+ "<a:chOff x=\"0\" y=\"0\"/><a:chExt cx=\"0\" cy=\"0\"/></a:xfrm></p:grpSpPr>"
783
+ + _PPTX_TITLE_SHAPE.replace("{title}", _xml_escape(title))
784
+ + body
785
+ + "</p:spTree></p:cSld></p:sld>"
786
+ )
787
+
788
+
789
+ def deck_pptx(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[str, object]:
790
+ """A real ``.pptx``: one slide per input line.
791
+
792
+ A line is ``Title :: point; point; point``. The double colon separates
793
+ the title from its points; the points split on semicolons. One line, one
794
+ slide, so the model never has to reason about the package format at all.
795
+ """
796
+
797
+ del skill
798
+ _known(arguments, frozenset({"slides", "filename"}))
799
+ text = _multiline(arguments, "slides", limit=6000)
800
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
801
+ if not lines:
802
+ raise SkillInputError("slides needs at least one line")
803
+ if len(lines) > 30:
804
+ raise SkillInputError("a deck here is at most 30 slides")
805
+
806
+ slides: list[str] = []
807
+ for line in lines:
808
+ title, _, tail = line.partition("::")
809
+ points = [point.strip() for point in tail.split(";") if point.strip()]
810
+ slides.append(_pptx_slide(title.strip() or "Untitled", points[:10]))
811
+
812
+ parts: dict[str, str] = {}
813
+ overrides = []
814
+ slide_ids = []
815
+ slide_rels = []
816
+ for index, slide in enumerate(slides, start=1):
817
+ parts[f"ppt/slides/slide{index}.xml"] = slide
818
+ parts[f"ppt/slides/_rels/slide{index}.xml.rels"] = _PPTX_SLIDE_RELS
819
+ overrides.append(
820
+ f'<Override PartName="/ppt/slides/slide{index}.xml" '
821
+ 'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>'
822
+ )
823
+ slide_ids.append(f'<p:sldId id="{255 + index}" r:id="rId{2 + index}"/>')
824
+ slide_rels.append(
825
+ f'<Relationship Id="rId{2 + index}" '
826
+ 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" '
827
+ f'Target="slides/slide{index}.xml"/>'
828
+ )
829
+
830
+ parts["[Content_Types].xml"] = _PPTX_CONTENT_TYPES_HEAD + "".join(overrides) + "</Types>"
831
+ parts["_rels/.rels"] = _PPTX_ROOT_RELS
832
+ parts["ppt/theme/theme1.xml"] = _PPTX_THEME
833
+ parts["ppt/slideMasters/slideMaster1.xml"] = _PPTX_MASTER
834
+ parts["ppt/slideMasters/_rels/slideMaster1.xml.rels"] = _PPTX_MASTER_RELS
835
+ parts["ppt/slideLayouts/slideLayout1.xml"] = _PPTX_LAYOUT
836
+ parts["ppt/slideLayouts/_rels/slideLayout1.xml.rels"] = _PPTX_LAYOUT_RELS
837
+ parts["ppt/presentation.xml"] = (
838
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
839
+ '<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
840
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
841
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
842
+ '<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>'
843
+ "<p:sldIdLst>" + "".join(slide_ids) + "</p:sldIdLst>"
844
+ '<p:sldSz cx="12192000" cy="6858000"/><p:notesSz cx="6858000" cy="9144000"/>'
845
+ "</p:presentation>"
846
+ )
847
+ parts["ppt/_rels/presentation.xml.rels"] = (
848
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
849
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
850
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>'
851
+ '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/>'
852
+ + "".join(slide_rels)
853
+ + "</Relationships>"
854
+ )
855
+
856
+ payload = _zip_bytes(parts)
857
+ return {
858
+ "artifact": bytes_artifact(
859
+ _filename(arguments.get("filename"), "deck.pptx", ".pptx"),
860
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
861
+ payload,
862
+ )
863
+ }
864
+
865
+
866
+ # --------------------------------------------------------------------------
867
+ # Web pages, themes and tailored guides
868
+ # --------------------------------------------------------------------------
869
+
870
+
871
+ def web_page(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[str, object]:
872
+ """One clean, self-contained HTML page from a title and sections.
873
+
874
+ A section is ``Heading :: body text`` per line. The page carries its own
875
+ small stylesheet, no scripts and no external requests, so what the user
876
+ downloads is complete and inert.
877
+ """
878
+
879
+ del skill
880
+ _known(arguments, frozenset({"title", "sections", "accent", "filename"}))
881
+ title = _line(arguments, "title", limit=120)
882
+ text = _multiline(arguments, "sections", limit=6000)
883
+ accent = str(arguments.get("accent") or "#2E5E43").strip()
884
+ if not re.fullmatch(r"#[0-9A-Fa-f]{6}", accent):
885
+ raise SkillInputError("accent must be a #rrggbb colour")
886
+ blocks = []
887
+ for line in text.splitlines():
888
+ line = line.strip()
889
+ if not line:
890
+ continue
891
+ heading, _, body = line.partition("::")
892
+ blocks.append(
893
+ f"<section><h2>{_xml_escape(heading.strip())}</h2>"
894
+ f"<p>{_xml_escape(body.strip())}</p></section>"
895
+ )
896
+ if not blocks:
897
+ raise SkillInputError("sections needs at least one 'Heading :: body' line")
898
+ page = (
899
+ "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">"
900
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
901
+ f"<title>{_xml_escape(title)}</title><style>"
902
+ ":root{--accent:" + accent + ";}"
903
+ "body{margin:0;font:17px/1.6 Georgia,serif;color:#26302a;background:#faf9f5;}"
904
+ "main{max-width:680px;margin:0 auto;padding:64px 24px;}"
905
+ "h1{font-size:2.4rem;line-height:1.15;margin:0 0 8px;}"
906
+ "h1::after{content:'';display:block;width:56px;height:4px;background:var(--accent);margin-top:16px;}"
907
+ "h2{font-size:1.15rem;margin:40px 0 6px;color:var(--accent);"
908
+ "font-family:Arial,sans-serif;letter-spacing:.02em;}"
909
+ "p{margin:0;}"
910
+ "</style></head><body><main>"
911
+ f"<h1>{_xml_escape(title)}</h1>" + "".join(blocks) + "</main></body></html>"
912
+ )
913
+ return {
914
+ "artifact": text_artifact(
915
+ _filename(arguments.get("filename"), "page.html", ".html"), "text/html", page
916
+ )
917
+ }
918
+
919
+
920
+ def theme_css(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[str, object]:
921
+ """A CSS custom-property sheet from a named palette.
922
+
923
+ The output is variables plus a few base rules, because a theme that ships
924
+ opinionated component CSS stops being a theme and starts being a fork of
925
+ whatever it lands in.
926
+ """
927
+
928
+ del skill
929
+ _known(arguments, frozenset({"name", "colors", "heading_font", "body_font", "filename"}))
930
+ theme_name = _line(arguments, "name", limit=60)
931
+ colors_text = _multiline(arguments, "colors", limit=1200)
932
+ heading = str(arguments.get("heading_font") or "Georgia, serif").strip()[:80]
933
+ body_font = str(arguments.get("body_font") or "Arial, sans-serif").strip()[:80]
934
+ variables = []
935
+ for line in colors_text.splitlines():
936
+ line = line.strip()
937
+ if not line:
938
+ continue
939
+ key, _, value = line.partition(":")
940
+ key = key.strip().lower()
941
+ value = value.strip()
942
+ if not re.fullmatch(r"[a-z][a-z0-9-]{0,30}", key):
943
+ raise SkillInputError(f"colour name {key!r} must be lowercase-with-hyphens")
944
+ if not re.fullmatch(r"#[0-9A-Fa-f]{3,8}", value):
945
+ raise SkillInputError(f"colour {key!r} needs a #hex value, got {value!r}")
946
+ variables.append(f" --{key}: {value};")
947
+ if not variables:
948
+ raise SkillInputError("colors needs at least one 'name: #hex' line")
949
+ sheet = (
950
+ f"/* Theme: {theme_name} β€” generated by distinct */\n"
951
+ ":root {\n" + "\n".join(variables) + "\n"
952
+ f" --font-heading: {heading};\n"
953
+ f" --font-body: {body_font};\n"
954
+ "}\n\n"
955
+ "body { font-family: var(--font-body); }\n"
956
+ "h1, h2, h3, h4 { font-family: var(--font-heading); }\n"
957
+ )
958
+ return {
959
+ "artifact": text_artifact(
960
+ _filename(arguments.get("filename"), "theme.css", ".css"), "text/css", sheet
961
+ )
962
+ }
963
+
964
+
965
+ def tailored_guide(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[str, object]:
966
+ """The skill's own guide, applied to the user's situation.
967
+
968
+ The knowledge lives in the skill directory as ``assets/guide.md`` β€” data,
969
+ reviewed with the skill β€” and this handler fills its placeholders. One
970
+ handler therefore serves every adapted guidance skill, which is how a
971
+ library of upstream skills stays reviewable: each contributes text, none
972
+ contributes code.
973
+ """
974
+
975
+ _known(arguments, frozenset({"subject", "context", "filename"}))
976
+ subject = _line(arguments, "subject", limit=200)
977
+ context = str(arguments.get("context") or "").strip()[:4000]
978
+ template = skill.resource("assets/guide.md")
979
+ text = render(
980
+ template,
981
+ {
982
+ "subject": subject,
983
+ "context": context or "No further context was given.",
984
+ "skill": skill.name,
985
+ },
986
+ )
987
+ default_name = f"{skill.name}.md"
988
+ return {
989
+ "artifact": text_artifact(
990
+ _filename(arguments.get("filename"), default_name, ".md"),
991
+ "text/markdown",
992
+ text,
993
+ )
994
+ }
995
+
996
+
997
  #: The fixed handler table. A skill directory may name one of these and
998
  #: nothing else.
999
  HANDLERS: Mapping[str, object] = {
 
1001
  "skill_scaffold": skill_scaffold,
1002
  "review_checklist": review_checklist,
1003
  "csv_table": csv_table,
1004
+ "document_docx": document_docx,
1005
+ "spreadsheet_xlsx": spreadsheet_xlsx,
1006
+ "deck_pptx": deck_pptx,
1007
+ "web_page": web_page,
1008
+ "theme_css": theme_css,
1009
+ "tailored_guide": tailored_guide,
1010
  }
1011
 
1012
  HANDLER_NAMES = frozenset(HANDLERS)
distinct_skills/manifest.py CHANGED
@@ -44,18 +44,37 @@ PACKAGED_ROOT = Path(__file__).resolve().parent / "skills"
44
  #: expect the review to ask why the digest changed.
45
  PINNED_DIGESTS: Mapping[str, str] = MappingProxyType(
46
  {
 
 
 
 
 
 
 
 
47
  "csv-table": "ff87a44fe03901f0cfb61f46209b6cc7138acf07d87279c936cd8711b0a5e455",
 
 
 
48
  "review-checklist": "98af818e3e4c178021bb87bcf355934b5effca7240b738f48ee344f7c6b6686f",
49
  "skill-scaffold": "158d1d1ff1ff3ad83865537708cd4464cdd114f420912136917b9c143c32e18e",
 
50
  "status-update": "48b7b1914de88b70e0ff9d19262987aca01696f6ebce061d0793543cbacc309e",
 
 
 
51
  }
52
  )
53
 
54
  #: A bound on the repository itself, not on any one skill. Every member is a
55
  #: line in the tool menu a small model has to choose from, and selection error
56
  #: is the dominant failure below 7B, so growing the repository is a decision
57
- #: with a measurable cost rather than free shelf space.
58
- MAX_REPOSITORY_SKILLS = 16
 
 
 
 
59
 
60
  _SLUG_RE = re.compile(r"[^a-z0-9_-]+")
61
 
 
44
  #: expect the review to ask why the digest changed.
45
  PINNED_DIGESTS: Mapping[str, str] = MappingProxyType(
46
  {
47
+ "academy-guide": "489ddd4c963552d243d68aa71af3e9f86ea58b6eb15fa77676eb21297d20e683",
48
+ "algorithmic-art": "a20eeb2e787e8f01f66fefc61fa34d84c7046015ba39728952f97ebe29709822",
49
+ "brand-guidelines": "bf9abe6506aa7785d36b027fd90f5a5552b40a36f1201ee09b3a680bbfff74d3",
50
+ "canvas-design": "776e112543645c5b22515c6e503ab572cde35c4633e0fd09e2d7c53ed912cde0",
51
+ "claude-api": "7f41a0e79797eb14c67e8a7fbfbabdb02da229829997496d719af141a597d0ce",
52
+ "create-deck": "e7c9a709f4609af0e5560544ecd8b05cb32fe5e52c147a23690f700700ec5b88",
53
+ "create-docx": "b1e950ab46f324bca84f6aade1d6772f13090e1fb1cb57df761c41bcb4764b7d",
54
+ "create-xlsx": "73325b2bd0d99ec781610ed9da1af59fef2655fb6e7b3766a29e2915d5ec449d",
55
  "csv-table": "ff87a44fe03901f0cfb61f46209b6cc7138acf07d87279c936cd8711b0a5e455",
56
+ "discernment-nudge": "1901fbb6a6a0980fe3c8fc5297d492c39ef02f1c46bbcd77223cf7622cabcd5b",
57
+ "frontend-design": "283205e755626aae7c517f6a3b0f9b8d16b2d30f32d24822080b58393eae689a",
58
+ "mcp-builder": "fafc5acb2b8e408182ed8f2be49e9c1dcf41827ceca7bba6aacaccce3393b37b",
59
  "review-checklist": "98af818e3e4c178021bb87bcf355934b5effca7240b738f48ee344f7c6b6686f",
60
  "skill-scaffold": "158d1d1ff1ff3ad83865537708cd4464cdd114f420912136917b9c143c32e18e",
61
+ "slack-gif-creator": "854f079d2843b80cbf3bd261c099f187c07ad0edf69646ca122eee7e77007ae9",
62
  "status-update": "48b7b1914de88b70e0ff9d19262987aca01696f6ebce061d0793543cbacc309e",
63
+ "theme-factory": "1b5d9800d869e902757d1b1733f98f3936319ee090035ffe9a82603662ccebe8",
64
+ "web-artifacts-builder": "4d0576368ca16dea4d5dea809b4766146d49ef84354269289843f55e76f9615e",
65
+ "webapp-testing": "cde6767ba5c2fd1cca6e86007010b228cb9296ffe3764485122ade9dd88c52fd",
66
  }
67
  )
68
 
69
  #: A bound on the repository itself, not on any one skill. Every member is a
70
  #: line in the tool menu a small model has to choose from, and selection error
71
  #: is the dominant failure below 7B, so growing the repository is a decision
72
+ #: with a measurable cost rather than free shelf space. The bound was raised
73
+ #: from 16 when the adapted Anthropic skills arrived, because the cost moved:
74
+ #: a run's model only ever sees the members chosen for that run in the
75
+ #: composer, so the shelf can be larger than any one menu. The menu a model
76
+ #: reads is still the thing to keep small, per run, by choosing deliberately.
77
+ MAX_REPOSITORY_SKILLS = 24
78
 
79
  _SLUG_RE = re.compile(r"[^a-z0-9_-]+")
80
 
distinct_skills/skills/academy-guide/SKILL.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: academy-guide
3
+ description: "Build a structured learning path for a topic: modules, order, exercises and checkpoints, delivered as a course outline. Arguments: subject (the topic), context (learner's starting point, time available, goal) and an optional filename. Adapted from Anthropic's academy-guide skill."
4
+ license: Apache-2.0. Adapted for distinct from the Anthropic skills repository.
5
+ source: https://github.com/anthropics/skills
6
+ ---
7
+
8
+ # Academy guide
9
+
10
+ ## What this produces
11
+
12
+ One markdown document: the method below, applied to the subject the
13
+ caller names. The knowledge lives in `assets/guide.md` beside this
14
+ file; the handler substitutes the subject and context and nothing
15
+ else. Data, never code.
distinct_skills/skills/academy-guide/assets/guide.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Learning path: {{subject}}
2
+
3
+ {{context}}
4
+
5
+ ## Method β€” adapted from Anthropic's academy-guide skill
6
+
7
+ 1. **Start from the learner, not the syllabus.** State what they can already
8
+ do and the one capability the path exists to add.
9
+ 2. **Modules of one idea each.** Five to eight modules, each answering a
10
+ single question, ordered so every module uses only what came before.
11
+ 3. **Exercise before explanation length.** Each module gets one exercise a
12
+ learner completes in under thirty minutes; the explanation is only as
13
+ long as the exercise needs.
14
+ 4. **Checkpoints that prove capability.** After every second module, a task
15
+ with no scaffolding. Passing it is the syllabus; the modules are merely
16
+ the route.
17
+ 5. **A finishing project.** One realistic piece of work using the whole
18
+ path. Define what done looks like on day one.
19
+
20
+ ## Produce
21
+
22
+ Write the module list with a one-line goal, a resource and an exercise for
23
+ each, the checkpoints, and the finishing project for the subject.
distinct_skills/skills/academy-guide/tool.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "1",
3
+ "handler": "tailored_guide",
4
+ "input_schema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "subject": {
9
+ "type": "string",
10
+ "maxLength": 200,
11
+ "description": "what the guide should be applied to, in a few words"
12
+ },
13
+ "context": {
14
+ "type": "string",
15
+ "maxLength": 4000,
16
+ "description": "anything already known: audience, constraints, drafts, goals"
17
+ },
18
+ "filename": {
19
+ "type": "string",
20
+ "maxLength": 80,
21
+ "description": "optional file name for the result"
22
+ }
23
+ },
24
+ "required": [
25
+ "subject"
26
+ ]
27
+ }
28
+ }
distinct_skills/skills/algorithmic-art/SKILL.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: algorithmic-art
3
+ description: "Plan a piece of algorithmic art: a named philosophy, a seeded system and the p5.js sketch structure, delivered as a document. Arguments: subject (theme or feeling), context (palette, constraints, interactivity wanted) and an optional filename. Adapted from Anthropic's algorithmic-art skill."
4
+ license: Apache-2.0. Adapted for distinct from the Anthropic skills repository.
5
+ source: https://github.com/anthropics/skills
6
+ ---
7
+
8
+ # Algorithmic art
9
+
10
+ ## What this produces
11
+
12
+ One markdown document: the method below, applied to the subject the
13
+ caller names. The knowledge lives in `assets/guide.md` beside this
14
+ file; the handler substitutes the subject and context and nothing
15
+ else. Data, never code.
distinct_skills/skills/algorithmic-art/assets/guide.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Algorithmic art plan: {{subject}}
2
+
3
+ {{context}}
4
+
5
+ ## Method β€” adapted from Anthropic's algorithmic-art skill
6
+
7
+ 1. **Philosophy before pixels.** Write three sentences naming the idea the
8
+ piece explores β€” growth, erosion, interference, flocking β€” and the
9
+ feeling it should leave. Every later choice answers to these sentences.
10
+ 2. **Seeded randomness.** One integer seed drives every random draw, so a
11
+ piece can be reproduced, varied and signed. State the seed in the corner
12
+ of the canvas.
13
+ 3. **One system, explored.** Choose a single generative system (flow field,
14
+ subdivision, particle trails, reaction-diffusion) and push its
15
+ parameters, rather than layering three systems thinly.
16
+ 4. **Parameter map.** Name 3-5 parameters, their ranges, and what each does
17
+ visually. These become the piece's interactive controls.
18
+ 5. **Craft.** Margins, a considered palette of few colours, and density
19
+ tuned so the eye rests somewhere. Generative is not an excuse for noise.
20
+
21
+ ## Produce
22
+
23
+ Write the philosophy, the seed, the system, the parameter map and the p5.js
24
+ sketch outline (setup, draw, the data structure) for the subject.
distinct_skills/skills/algorithmic-art/tool.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "1",
3
+ "handler": "tailored_guide",
4
+ "input_schema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "subject": {
9
+ "type": "string",
10
+ "maxLength": 200,
11
+ "description": "what the guide should be applied to, in a few words"
12
+ },
13
+ "context": {
14
+ "type": "string",
15
+ "maxLength": 4000,
16
+ "description": "anything already known: audience, constraints, drafts, goals"
17
+ },
18
+ "filename": {
19
+ "type": "string",
20
+ "maxLength": 80,
21
+ "description": "optional file name for the result"
22
+ }
23
+ },
24
+ "required": [
25
+ "subject"
26
+ ]
27
+ }
28
+ }
distinct_skills/skills/brand-guidelines/SKILL.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: brand-guidelines
3
+ description: "Apply a written brand system to whatever is being made: colours, type and usage rules as a one-page reference. Arguments: subject (what the brand should be applied to), context (the brand's colours, fonts and rules if known) and an optional filename. Adapted from Anthropic's brand-guidelines skill."
4
+ license: Apache-2.0. Adapted for distinct from the Anthropic skills repository.
5
+ source: https://github.com/anthropics/skills
6
+ ---
7
+
8
+ # Brand guidelines
9
+
10
+ ## What this produces
11
+
12
+ One markdown document: the method below, applied to the subject the
13
+ caller names. The knowledge lives in `assets/guide.md` beside this
14
+ file; the handler substitutes the subject and context and nothing
15
+ else. Data, never code.
distinct_skills/skills/brand-guidelines/assets/guide.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Brand application: {{subject}}
2
+
3
+ {{context}}
4
+
5
+ ## Method β€” adapted from Anthropic's brand-guidelines skill
6
+
7
+ 1. **Palette.** Name the core colours with hex values and one line each on
8
+ where they belong (background, text, accent, warning). If the brand names
9
+ no palette, propose one and mark it proposed.
10
+ 2. **Typography.** One heading face, one body face, and the fallback stack.
11
+ State where each is used and the smallest size allowed.
12
+ 3. **Voice.** Three adjectives the writing should earn, with one sentence of
13
+ what each rules out.
14
+ 4. **Usage rules.** The five rules that stop the brand drifting: contrast
15
+ minimums, logo clearance, what never sits on what.
16
+ 5. **Applied to the subject.** For the subject named above, list the exact
17
+ colours, faces and rules that apply, ready to hand to whoever builds it.
distinct_skills/skills/brand-guidelines/tool.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "1",
3
+ "handler": "tailored_guide",
4
+ "input_schema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "subject": {
9
+ "type": "string",
10
+ "maxLength": 200,
11
+ "description": "what the guide should be applied to, in a few words"
12
+ },
13
+ "context": {
14
+ "type": "string",
15
+ "maxLength": 4000,
16
+ "description": "anything already known: audience, constraints, drafts, goals"
17
+ },
18
+ "filename": {
19
+ "type": "string",
20
+ "maxLength": 80,
21
+ "description": "optional file name for the result"
22
+ }
23
+ },
24
+ "required": [
25
+ "subject"
26
+ ]
27
+ }
28
+ }
distinct_skills/skills/canvas-design/SKILL.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: canvas-design
3
+ description: "Plan a poster or static visual design: philosophy, composition, palette and type, delivered as a design document. Arguments: subject (what the piece is for), context (format, audience, mood, copy) and an optional filename. Adapted from Anthropic's canvas-design skill."
4
+ license: Apache-2.0. Adapted for distinct from the Anthropic skills repository.
5
+ source: https://github.com/anthropics/skills
6
+ ---
7
+
8
+ # Canvas design
9
+
10
+ ## What this produces
11
+
12
+ One markdown document: the method below, applied to the subject the
13
+ caller names. The knowledge lives in `assets/guide.md` beside this
14
+ file; the handler substitutes the subject and context and nothing
15
+ else. Data, never code.
distinct_skills/skills/canvas-design/assets/guide.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Design plan: {{subject}}
2
+
3
+ {{context}}
4
+
5
+ ## Method β€” adapted from Anthropic's canvas-design skill
6
+
7
+ 1. **A visual philosophy in three sentences.** What the piece believes β€”
8
+ restraint, tension, warmth β€” written before any element is placed. If a
9
+ choice cannot be defended from these sentences, it goes.
10
+ 2. **One focal point.** Decide what the eye lands on first and make every
11
+ other element quieter than it. Two focal points are none.
12
+ 3. **Grid and margins.** Choose the canvas size, a simple grid, and margins
13
+ generous enough to be a design element themselves.
14
+ 4. **Palette of few.** Two or three colours plus a neutral. Name the exact
15
+ values and what each is for.
16
+ 5. **Type as hierarchy.** One display face, one text face, three sizes.
17
+ The headline earns its size; everything else steps down.
18
+ 6. **The subtle reference.** One quiet nod to the subject β€” a shape, a
19
+ texture, an angle β€” that rewards a second look without shouting.
20
+
21
+ ## Produce
22
+
23
+ Write the philosophy and then the concrete spec: canvas, grid, palette,
24
+ faces, sizes, and the placement of each element, ready to execute.