User1342 commited on
Commit
1d4a48e
·
1 Parent(s): 3d71a3b

Drawn mockups instead of recorded demos; delete conversations; drop Llama; fix the weights cache re-downloading

Browse files
distinct_agent/README.md CHANGED
@@ -61,7 +61,7 @@ whole PC. Unsupported and failed measurements contain `available: false` and
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
 
@@ -71,10 +71,8 @@ model-specific assessment of its training, recorded with its primary source in
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
 
 
61
  ## Models and runner
62
 
63
  Discovery recognizes local GGUF files for OLMoE-1B-7B-0924-Instruct,
64
+ OLMo-2-1124-7B-Instruct. 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
 
 
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.
75
+
 
 
76
 
77
  An explicit deterministic smoke test is:
78
 
distinct_agent/cli.py CHANGED
@@ -124,6 +124,50 @@ class ConsoleTransport:
124
  self.done.set()
125
 
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  def _find_llama_server() -> str:
128
  """Look in runtime/ first, then PATH."""
129
 
@@ -315,6 +359,8 @@ def build_parser() -> argparse.ArgumentParser:
315
  )
316
  parser.add_argument(
317
  "--llama-server",
 
 
318
  help=(
319
  "path to llama-server. This is the default runner: clean output, "
320
  "real token counts, streaming, and a resident model across queued "
@@ -323,6 +369,8 @@ def build_parser() -> argparse.ArgumentParser:
323
  )
324
  parser.add_argument(
325
  "--llama-cli",
 
 
326
  help="legacy per-request runner. Only used when --llama-server is absent.",
327
  )
328
  parser.add_argument("--queue-capacity", type=int, default=4)
@@ -929,6 +977,19 @@ def main(
929
  # else's request, on a worker that advertised itself as ready, and if it
930
  # fails it fails as a failed run rather than as a worker that could not
931
  # start.
 
 
 
 
 
 
 
 
 
 
 
 
 
932
  if pending and not args.fetch_on_demand and not (args.demo_runner or args.demo_prompt is not None):
933
  print(
934
  f"Fetching {len(pending)} model(s) now: {', '.join(pending)}. "
@@ -1045,6 +1106,11 @@ def main(
1045
  if demo_runner:
1046
  runner = DeterministicDemoRunner()
1047
  elif args.llama_server or not args.llama_cli:
 
 
 
 
 
1048
  # llama-server is the default. It returns clean output with no chat
1049
  # furniture, reports real token counts, streams, keeps the model
1050
  # resident across a queue, and speaks the OpenAI-compatible protocol
@@ -1062,7 +1128,7 @@ def main(
1062
  )
1063
  return 2
1064
  else:
1065
- runner = LlamaCppRunner(args.llama_cli)
1066
  if not runner.available:
1067
  print(
1068
  "llama-cli was not found; supply --llama-cli or use --demo-runner.",
@@ -1404,7 +1470,18 @@ def _run_with_dashboard(
1404
  """
1405
 
1406
  from .dashboard import ActivityLog, WorkerView
1407
- from .tui import DashboardApp
 
 
 
 
 
 
 
 
 
 
 
1408
 
1409
  log = getattr(sys.stderr, "_distinct_activity_log", None)
1410
  if log is None:
 
124
  self.done.set()
125
 
126
 
127
+ def _runner_problem(args) -> str:
128
+ """Why this machine cannot run a model yet, in words, or empty if it can.
129
+
130
+ Written as an instruction rather than a diagnosis. "llama-server was not
131
+ found. Supply --llama-server" told somebody who had just waited out a nine
132
+ gigabyte download the name of a flag, not where to get the program that
133
+ flag points at, and read as though the flag took no value — which it does,
134
+ so the next thing they saw was an argparse usage dump.
135
+ """
136
+
137
+ if args.llama_cli and not args.llama_server:
138
+ from shutil import which
139
+
140
+ if which(args.llama_cli) or Path(args.llama_cli).is_file():
141
+ return ""
142
+ return (
143
+ f"llama-cli was not found at {args.llama_cli!r}. Give the path to the "
144
+ "binary, or run with --demo-runner to start without a model."
145
+ )
146
+ if args.llama_server and args.llama_server is not True:
147
+ if Path(str(args.llama_server)).is_file():
148
+ return ""
149
+ return (
150
+ f"llama-server was not found at {str(args.llama_server)!r}. Give the "
151
+ "path to the binary, or run with --demo-runner to start without a model."
152
+ )
153
+ if _find_llama_server():
154
+ return ""
155
+ return (
156
+ "This worker has no llama.cpp to run a model with.\n"
157
+ "\n"
158
+ " llama.cpp is a separate program, not part of this project. Get a build\n"
159
+ " from https://github.com/ggml-org/llama.cpp/releases, unzip it, and either\n"
160
+ " put llama-server on your PATH or point at it:\n"
161
+ "\n"
162
+ " --llama-server C:\\path\\to\\llama-server.exe\n"
163
+ "\n"
164
+ " Or start without a model. The worker still pairs and still runs every\n"
165
+ " tool and skill in the library; only the model's own replies are absent:\n"
166
+ "\n"
167
+ " --demo-runner\n"
168
+ )
169
+
170
+
171
  def _find_llama_server() -> str:
172
  """Look in runtime/ first, then PATH."""
173
 
 
359
  )
360
  parser.add_argument(
361
  "--llama-server",
362
+ nargs="?",
363
+ const=True,
364
  help=(
365
  "path to llama-server. This is the default runner: clean output, "
366
  "real token counts, streaming, and a resident model across queued "
 
369
  )
370
  parser.add_argument(
371
  "--llama-cli",
372
+ nargs="?",
373
+ const=True,
374
  help="legacy per-request runner. Only used when --llama-server is absent.",
375
  )
376
  parser.add_argument("--queue-capacity", type=int, default=4)
 
977
  # else's request, on a worker that advertised itself as ready, and if it
978
  # fails it fails as a failed run rather than as a worker that could not
979
  # start.
980
+ # CHECK FOR THE THING THAT RUNS THE MODEL BEFORE FETCHING THE MODEL.
981
+ #
982
+ # The runner was resolved after this block, so a machine without
983
+ # llama.cpp downloaded nine gigabytes of weights, verified both digests,
984
+ # printed its sandbox report, and only then said it had nothing to run
985
+ # them with. Every second of that was avoidable: whether `llama-server`
986
+ # exists is knowable before the first byte.
987
+ if not (args.demo_runner or args.demo_prompt is not None):
988
+ problem = _runner_problem(args)
989
+ if problem:
990
+ print(problem, file=sys.stderr)
991
+ return 2
992
+
993
  if pending and not args.fetch_on_demand and not (args.demo_runner or args.demo_prompt is not None):
994
  print(
995
  f"Fetching {len(pending)} model(s) now: {', '.join(pending)}. "
 
1106
  if demo_runner:
1107
  runner = DeterministicDemoRunner()
1108
  elif args.llama_server or not args.llama_cli:
1109
+ # `--llama-server` with no value means "look for it"; the finder is
1110
+ # what a bare flag asks for, and passing True into a path would have
1111
+ # been a confusing failure two screens later.
1112
+ if args.llama_server is True:
1113
+ args.llama_server = ""
1114
  # llama-server is the default. It returns clean output with no chat
1115
  # furniture, reports real token counts, streams, keeps the model
1116
  # resident across a queue, and speaks the OpenAI-compatible protocol
 
1128
  )
1129
  return 2
1130
  else:
1131
+ runner = LlamaCppRunner("" if args.llama_cli is True else args.llama_cli)
1132
  if not runner.available:
1133
  print(
1134
  "llama-cli was not found; supply --llama-cli or use --demo-runner.",
 
1470
  """
1471
 
1472
  from .dashboard import ActivityLog, WorkerView
1473
+
1474
+ try:
1475
+ from .tui import DashboardApp
1476
+ except ImportError as exc: # pragma: no cover - only without the dependency
1477
+ # A traceback about a module nobody asked for is not an answer. The
1478
+ # worker itself does not need Textual, so this is the one place its
1479
+ # absence matters and the one place worth explaining it.
1480
+ raise SystemExit(
1481
+ f"The live status screen needs the 'textual' package ({exc}). "
1482
+ "Install this project's dependencies with `pip install -e .` in the "
1483
+ "folder you cloned, or drop --dashboard to run with a scrolling log."
1484
+ ) from exc
1485
 
1486
  log = getattr(sys.stderr, "_distinct_activity_log", None)
1487
  if log is None:
distinct_agent/models.py CHANGED
@@ -44,7 +44,17 @@ class UnpinnedModelError(ModelVerificationError):
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
50
  # is no exact file to fetch, and without a digest there is no way to say what
@@ -126,31 +136,10 @@ KNOWN_MODEL_MANIFESTS: tuple[ModelManifest, ...] = (
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
 
149
  _FILENAME_ALIASES: Mapping[str, tuple[str, ...]] = {
150
- "llama-2-7b-chat": (
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",
 
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
+ # EVERY ENTRY HERE IS RUNNABLE.
48
+ #
49
+ # There was one that was not: Llama 2 7B Chat, kept to be named rather than
50
+ # run, because Meta publish no GGUF and the source repository is gated, so
51
+ # there was no file to pin and the worker withheld it on every start. It has
52
+ # been removed. A catalogue whose entries can all be fetched and verified is
53
+ # easier to reason about than one carrying a permanent exception, and nothing
54
+ # was lost from the assessment surface: describing what a publisher did or did
55
+ # not disclose never required shipping their weights.
56
+ #
57
+ # WHAT AN UNPINNED ENTRY WOULD MEAN. Weights are fetched on demand rather than
58
  # placed on disk by the operator, so a manifest is only usable when its
59
  # repository, revision *and* digest are all recorded: without a revision there
60
  # is no exact file to fetch, and without a digest there is no way to say what
 
136
  source_revision="410e0069f64869e4b1d17d8de04810b881fd824b",
137
  context_length=4096,
138
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  )
140
 
141
 
142
  _FILENAME_ALIASES: Mapping[str, tuple[str, ...]] = {
 
 
 
 
143
  "olmoe-1b-7b-0924-instruct": (
144
  "olmoe-1b-7b-0924-instruct-q4_k_m.gguf",
145
  "OLMoE-1B-7B-0924-Instruct-Q4_K_M.gguf",
distinct_agent/weights.py CHANGED
@@ -134,7 +134,21 @@ def _weights_opener() -> Callable[..., Any]:
134
 
135
  #: Default cache ceiling. Small enough to be a polite default on a volunteer's
136
  #: machine, large enough to hold the models in the shipped catalogue.
137
- DEFAULT_CACHE_GB = 8.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  #: Anything above this is almost certainly a typo rather than an intention.
140
  MAX_CACHE_GB = 512.0
@@ -357,18 +371,45 @@ class WeightsCache:
357
  ]
358
  return [root / name for root in roots for name in names]
359
 
360
- def usage(self) -> CacheUsage:
361
- used = 0
362
- present: list[str] = []
363
- for model_id, manifest in self.manifests.items():
364
- path = self.path_for(manifest)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  try:
366
- if path.is_file():
367
- used += path.stat().st_size
368
- present.append(model_id)
 
 
 
 
369
  except OSError:
370
  continue
371
- return CacheUsage(used, self.limit_bytes, tuple(sorted(present)))
 
 
 
 
 
 
372
 
373
  def installed(self) -> tuple[DiscoveredModel, ...]:
374
  """Cached models, each verified against its digest.
@@ -449,7 +490,19 @@ class WeightsCache:
449
  with self._opener(request, timeout=60) as response:
450
  declared = _content_length(response)
451
  if declared:
452
- self._make_room(declared, keep=manifest.id)
 
 
 
 
 
 
 
 
 
 
 
 
453
  with part.open("wb") as handle:
454
  while True:
455
  if cancel_event is not None and cancel_event.is_set():
@@ -520,9 +573,12 @@ class WeightsCache:
520
  usage = self.usage()
521
  if usage.used_bytes + needed_bytes <= self.limit_bytes:
522
  return evicted
 
 
 
523
  candidates = [
524
- (self._last_used(self.path_for(self.manifests[model_id])), model_id)
525
- for model_id in usage.model_ids
526
  if model_id != keep
527
  ]
528
  if not candidates:
 
134
 
135
  #: Default cache ceiling. Small enough to be a polite default on a volunteer's
136
  #: machine, large enough to hold the models in the shipped catalogue.
137
+ #: The cache ceiling, in gigabytes.
138
+ #:
139
+ #: A DEFAULT SMALLER THAN THE CATALOGUE IS A DEFAULT THAT THRASHES.
140
+ #:
141
+ #: This was 8, and the two models this project ships come to about nine
142
+ #: together. So a worker offering both downloaded the first, evicted it to fit
143
+ #: the second, and re-downloaded it on the next start — five gigabytes over
144
+ #: the wire, every run, for ever, on a project whose entire subject is the
145
+ #: energy cost of running models. Nothing reported it, because eviction was
146
+ #: silent and the usage line only counted the models that run offered.
147
+ #:
148
+ #: Sized to hold the shipped catalogue with room for one more, so the common
149
+ #: case never evicts anything. An operator with a small disk lowers it and is
150
+ #: told what that costs.
151
+ DEFAULT_CACHE_GB = 16.0
152
 
153
  #: Anything above this is almost certainly a typo rather than an intention.
154
  MAX_CACHE_GB = 512.0
 
371
  ]
372
  return [root / name for root in roots for name in names]
373
 
374
+ def _cached_entries(self) -> list[tuple[str, Path, int]]:
375
+ """Everything actually in the cache, as ``(model_id, path, bytes)``.
376
+
377
+ THE CACHE IS ON DISK, NOT IN THIS RUN'S ARGUMENTS.
378
+
379
+ This used to be derived from ``self.manifests``, which is only the
380
+ models the current invocation offers. A worker started with
381
+ ``--models olmoe`` therefore reported an empty cache while several
382
+ gigabytes of another model sat right there in it — the operator was
383
+ told "0.00 GB used, ready: none" about a directory that was nearly
384
+ full — and the eviction arithmetic below inherited the same blind
385
+ spot, so it believed there was room it did not have.
386
+ """
387
+
388
+ entries: list[tuple[str, Path, int]] = []
389
+ try:
390
+ folders = sorted(self.directory.iterdir())
391
+ except OSError:
392
+ return entries
393
+ for folder in folders:
394
+ if not folder.is_dir():
395
+ continue
396
  try:
397
+ for path in folder.iterdir():
398
+ # `.part` files are downloads in flight; they occupy space
399
+ # but they are not a cached model and must never be counted
400
+ # as one, or an interrupted fetch looks like a hit.
401
+ if not path.is_file() or path.suffix == ".part":
402
+ continue
403
+ entries.append((folder.name, path, path.stat().st_size))
404
  except OSError:
405
  continue
406
+ return entries
407
+
408
+ def usage(self) -> CacheUsage:
409
+ entries = self._cached_entries()
410
+ used = sum(size for _, _, size in entries)
411
+ present = sorted({model_id for model_id, _, size in entries if size > 0})
412
+ return CacheUsage(used, self.limit_bytes, tuple(present))
413
 
414
  def installed(self) -> tuple[DiscoveredModel, ...]:
415
  """Cached models, each verified against its digest.
 
490
  with self._opener(request, timeout=60) as response:
491
  declared = _content_length(response)
492
  if declared:
493
+ evicted = self._make_room(declared, keep=manifest.id)
494
+ if evicted and progress is not None:
495
+ # Deleting gigabytes somebody already waited for is
496
+ # not a detail. Silent, it looks like the download
497
+ # simply never persisted, which is exactly how it was
498
+ # reported to us.
499
+ progress(
500
+ 0.0,
501
+ "Evicted from the weights cache to make room: "
502
+ + ", ".join(evicted)
503
+ + f" — raise --model-cache-gb above {_gb(self.limit_bytes):.0f}"
504
+ " to keep them",
505
+ )
506
  with part.open("wb") as handle:
507
  while True:
508
  if cancel_event is not None and cancel_event.is_set():
 
573
  usage = self.usage()
574
  if usage.used_bytes + needed_bytes <= self.limit_bytes:
575
  return evicted
576
+ # Any model in the cache is a candidate, not only the ones this
577
+ # run happens to offer: they take up the same disk either way, and
578
+ # a run that could not see them could not free them.
579
  candidates = [
580
+ (self._last_used(path), model_id)
581
+ for model_id, path, _ in self._cached_entries()
582
  if model_id != keep
583
  ]
584
  if not candidates:
distinct_server/catalog.py CHANGED
@@ -245,13 +245,6 @@ MODEL_CATALOG: dict[str, ModelManifest] = {
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
 
@@ -273,12 +266,14 @@ MODEL_CATALOG: dict[str, ModelManifest] = {
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,
@@ -419,60 +414,6 @@ RELEASE_ASSESSMENT: dict[str, ReleaseAssessment] = {
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
 
478
 
 
245
  min_ram_gb=8.0,
246
  context_length=4_096,
247
  ),
 
 
 
 
 
 
 
248
  }
249
 
250
 
 
266
  # This structure is the in-catalogue record: enough to be correct on its own if
267
  # that file is absent, never the fuller surface.
268
  #
269
+ # WHAT IS STILL FORBIDDEN. GPU-hours are not energy. A publisher who reports
270
+ # hours on hardware of a stated wattage has not reported a measurement, and
271
+ # multiplying the two here would be this surface inventing the number it
272
+ # exists to report. An area with no published figure stays Missing.
273
+
274
+ #: The document each Ai2 figure is quoted from. Named once so a citation can
275
+ #: never drift from the number beside it.
276
  _AI2_ASSESSMENT = "https://arxiv.org/abs/2503.05804"
 
277
 
278
  _NO_LAND = AreaResult(
279
  AreaStatus.MISSING,
 
414
  {"label": "Published assessment", "url": _AI2_ASSESSMENT},
415
  ),
416
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  }
418
 
419
 
distinct_server/control_plane.py CHANGED
@@ -226,6 +226,22 @@ class ControlPlane:
226
  self._cleanup_pairing_codes_locked(current)
227
  if len(self._pairing_codes) >= self.limits.max_pairing_codes:
228
  raise CapacityError("too many active pairing codes")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  while True:
230
  code = new_pairing_code()
231
  digest = pairing_code_digest(code, self._pairing_pepper)
@@ -600,6 +616,53 @@ class ControlPlane:
600
  self._children.pop(job_id, None)
601
  return len(job_ids)
602
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  def create_conversation(
604
  self,
605
  session_id: str,
 
226
  self._cleanup_pairing_codes_locked(current)
227
  if len(self._pairing_codes) >= self.limits.max_pairing_codes:
228
  raise CapacityError("too many active pairing codes")
229
+ # THE GLOBAL CAP PROTECTS NOBODY ON ITS OWN.
230
+ #
231
+ # Filling it is cheap — one signed-in account asking in a loop —
232
+ # and the codes live ten minutes, so the pool can be held full and
233
+ # every other person on this server is then unable to pair a
234
+ # worker at all. A per-owner share makes that account spend only
235
+ # its own allowance. Counted after the expiry sweep above, so the
236
+ # number is codes still alive rather than codes ever asked for.
237
+ mine = sum(
238
+ 1 for record in self._pairing_codes.values() if record.owner_id == owner_id
239
+ )
240
+ if mine >= self.limits.max_pairing_codes_per_owner:
241
+ raise CapacityError(
242
+ "you already have the maximum number of unused pairing codes; "
243
+ "use one, or wait for them to expire"
244
+ )
245
  while True:
246
  code = new_pairing_code()
247
  digest = pairing_code_digest(code, self._pairing_pepper)
 
616
  self._children.pop(job_id, None)
617
  return len(job_ids)
618
 
619
+ def delete_conversation(
620
+ self,
621
+ session_id: str,
622
+ conversation_id: str,
623
+ *,
624
+ now: Optional[float] = None,
625
+ ) -> int:
626
+ """Remove one conversation and the jobs it held. Returns how many.
627
+
628
+ The same erasure :meth:`delete_session` performs, narrowed to a single
629
+ conversation, because a person wanting one exchange gone should not
630
+ have to take the whole session with it.
631
+
632
+ A job still in flight is cancelled rather than merely forgotten. Its
633
+ worker is holding it and will come back for it, so the cancellation is
634
+ left where that worker will find it on its next poll; dropping the
635
+ record alone would leave the machine working on an answer for a
636
+ conversation that no longer exists.
637
+
638
+ Unknown ids are not an error. A conversation whose runs have all
639
+ finished is cleaned up on its own, so a browser asking to delete one
640
+ that has already gone is asking for a state that already holds.
641
+ """
642
+
643
+ with self._lock:
644
+ current = self._now(now)
645
+ session = self._sessions.get(session_id)
646
+ if session is None:
647
+ return 0
648
+ conversation = self._conversations.pop((session_id, conversation_id), None)
649
+ if conversation is None:
650
+ return 0
651
+ if conversation_id in session.conversation_ids:
652
+ session.conversation_ids.remove(conversation_id)
653
+ job_ids = set(conversation.job_ids)
654
+ for children in self._children.values():
655
+ children.difference_update(job_ids)
656
+ for job_id in job_ids:
657
+ job = self._jobs.pop(job_id, None)
658
+ if job is not None and job.status not in _TERMINAL_STATUSES:
659
+ self._orphan_cancellations.setdefault(
660
+ job.spec.target_agent_id,
661
+ {},
662
+ )[job_id] = current + self.limits.job_ttl_seconds
663
+ self._children.pop(job_id, None)
664
+ return len(job_ids)
665
+
666
  def create_conversation(
667
  self,
668
  session_id: str,
distinct_server/models.py CHANGED
@@ -59,6 +59,14 @@ class ControlPlaneLimits:
59
  max_jobs: int = 50_000
60
  max_jobs_per_conversation: int = 1_024
61
  max_pairing_codes: int = 1_024
 
 
 
 
 
 
 
 
62
  max_nonces_per_agent: int = 2_048
63
  max_models_per_agent: int = 64
64
  max_tools_per_agent: int = 64
@@ -81,6 +89,7 @@ class ControlPlaneLimits:
81
  "max_jobs",
82
  "max_jobs_per_conversation",
83
  "max_pairing_codes",
 
84
  "max_nonces_per_agent",
85
  "max_models_per_agent",
86
  "max_tools_per_agent",
 
59
  max_jobs: int = 50_000
60
  max_jobs_per_conversation: int = 1_024
61
  max_pairing_codes: int = 1_024
62
+ #: And how many of that global pool any one identity may hold at once.
63
+ #:
64
+ #: The global cap alone is not a limit on anybody, it is a limit on
65
+ #: everybody: one signed-in account minting in a loop fills all 1024
66
+ #: slots, and since they live ten minutes it can hold them full, so
67
+ #: nobody else on the server can pair a worker. A per-owner share turns
68
+ #: that from an outage into one account wasting its own allowance.
69
+ max_pairing_codes_per_owner: int = 8
70
  max_nonces_per_agent: int = 2_048
71
  max_models_per_agent: int = 64
72
  max_tools_per_agent: int = 64
 
89
  "max_jobs",
90
  "max_jobs_per_conversation",
91
  "max_pairing_codes",
92
+ "max_pairing_codes_per_owner",
93
  "max_nonces_per_agent",
94
  "max_models_per_agent",
95
  "max_tools_per_agent",
distinct_server/presentation.py CHANGED
@@ -95,42 +95,11 @@ HEAD = """
95
  <script>
96
  (function(){
97
  function card(el){return el && el.closest ? el.closest('.c-libcard') : null;}
98
- function mediaOf(c){return c ? c.querySelector('.c-libcard__media') : null;}
99
- function show(m){
100
- if(!m || m.classList.contains('is-playing')) return;
101
- var src = m.getAttribute('data-gif');
102
- if(!src) return;
103
- var img = document.createElement('img');
104
- img.src = src; img.alt='';
105
- m.appendChild(img); m.classList.add('is-playing');
106
- }
107
- function hide(m){
108
- if(!m) return;
109
- m.classList.remove('is-playing');
110
- m.querySelectorAll('img').forEach(function(i){i.remove();});
111
- }
112
- /* Hover previews, scoped to the media block so sweeping the grid does not
113
- start twenty GIFs at once. */
114
- document.addEventListener('mouseover', function(e){
115
- var m = e.target.closest ? e.target.closest('.c-libcard__media') : null; show(m);
116
- });
117
- document.addEventListener('mouseout', function(e){
118
- var m = e.target.closest ? e.target.closest('.c-libcard__media') : null;
119
- if(m && !m.contains(e.relatedTarget)) hide(m);
120
- });
121
- /* Press-and-hold anywhere on the card previews on a phone; a plain tap is
122
- the selection. The two must not fire together, hence the flag. */
123
- var holdTimer=null, held=false;
124
- document.addEventListener('touchstart', function(e){
125
- var c = card(e.target); if(!c) return;
126
- held=false;
127
- holdTimer = setTimeout(function(){held=true; show(mediaOf(c));}, 350);
128
- }, {passive:true});
129
- ['touchend','touchcancel'].forEach(function(name){
130
- document.addEventListener(name, function(e){
131
- clearTimeout(holdTimer); hide(mediaOf(card(e.target)));
132
- }, {passive:true});
133
- });
134
  /* THE CARD IS THE CONTROL, and the way it saves is by forwarding its click
135
  to the framework's own box for that member. Writing a hidden field's
136
  value and dispatching an input event was tried first: it updates the DOM
@@ -152,7 +121,6 @@ HEAD = """
152
  document.addEventListener('click', function(e){
153
  var c = card(e.target);
154
  if(!c || !c.hasAttribute('data-ref')) return;
155
- if(held){held=false; return;}
156
  if(e.target.closest && e.target.closest('a')) return;
157
  var box = boxFor(c.getAttribute('data-ref'));
158
  if(!box) return; /* no transport, no pretend selection */
@@ -168,6 +136,61 @@ HEAD = """
168
  if(!c || e.target !== c) return;
169
  e.preventDefault(); c.click();
170
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  })();
172
  </script>
173
  """
@@ -1326,6 +1349,58 @@ details[open] .c-libhelp__toggle::after{content:" -";}
1326
  .c-join__note .c-join__code{display:inline-block; padding:2px 8px;
1327
  font-size:11.5px; margin-top:4px;}
1328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1329
  /* SETTING UP A WORKER, AS NUMBERED STEPS.
1330
  Three things happen in order and each can fail on its own, so each is
1331
  drawn as its own step with its own command. Told as one paragraph, a
@@ -1546,31 +1621,19 @@ details[open] .c-libhelp__toggle::after{content:" -";}
1546
  .c-libcard.is-on .c-libcard__check::before{content:"✓";}
1547
  .c-libcard__media{margin:calc(var(--s4)*-1) calc(var(--s4)*-1) var(--s2);
1548
  aspect-ratio:16/9; border-radius:calc(var(--r-lg) - 2px) calc(var(--r-lg) - 2px) 0 0;
1549
- background:var(--c-fill) center/cover no-repeat;
1550
  display:flex; align-items:center; justify-content:center;
1551
  overflow:hidden; position:relative;}
 
 
 
 
1552
  .c-libcard__media img{width:100%; height:100%; object-fit:cover;
1553
  object-position:top center; display:block;
1554
  position:absolute; inset:0;}
1555
  /* The still is the run's own result. It sits under the animation, and it is
1556
  what the card shows on a phone, where nothing hovers. */
1557
- .c-libcard__poster{z-index:0;}
1558
- .c-libcard__media.is-playing .c-libcard__poster{opacity:0;}
1559
- .c-libcard__play{position:relative; z-index:1;
1560
- display:inline-flex; align-items:center; gap:6px;
1561
- font-size:var(--t-micro); color:var(--c-text-2) !important;
1562
- background:color-mix(in srgb, var(--c-surface) 92%, transparent);
1563
- border:1px solid var(--c-hairline) !important; backdrop-filter:blur(3px);
1564
- border-radius:999px; padding:4px 10px; opacity:0;
1565
- transition:opacity .15s ease;}
1566
- .c-libcard__play::before{content:"▶"; font-size:9px; color:var(--c-sage-deep) !important;}
1567
- /* The invitation appears on approach and never sits permanently over the
1568
- result it is inviting you to watch. Always visible where there is no
1569
- poster to obscure, since then it is the only thing in the frame. */
1570
- .c-libcard:hover .c-libcard__play,
1571
- .c-libcard:focus-visible .c-libcard__play{opacity:1;}
1572
- .c-libcard__media:not(:has(.c-libcard__poster)) .c-libcard__play{opacity:1;}
1573
- .c-libcard__media.is-playing .c-libcard__play{display:none;}
1574
  .c-libcard__head{display:flex; align-items:center; gap:var(--s2); padding-right:28px;}
1575
  .c-libcard__kind{font-size:var(--t-micro); letter-spacing:.06em; text-transform:uppercase;
1576
  padding:2px 8px; border-radius:999px; background:var(--c-fill);
@@ -1586,6 +1649,17 @@ details[open] .c-libhelp__toggle::after{content:" -";}
1586
  .c-libcard__ref{margin-top:auto; padding-top:var(--s2); font-family:var(--mono);
1587
  font-size:11px; color:var(--c-muted) !important;}
1588
 
 
 
 
 
 
 
 
 
 
 
 
1589
  /* ---- Navigation, in the header band -----------------------------------
1590
  A button (the library, which navigates) beside a link (a section of this
1591
  page). They must look like one row of controls, so the button is styled to
 
95
  <script>
96
  (function(){
97
  function card(el){return el && el.closest ? el.closest('.c-libcard') : null;}
98
+ /* The preview machinery that lived here — hover to swap in a GIF, press and
99
+ hold to do the same on a phone, plus the flag that stopped a long press
100
+ also counting as a tap — went with the GIFs. Each card now draws what its
101
+ member makes, so there is nothing to reveal and nothing to time. */
102
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  /* THE CARD IS THE CONTROL, and the way it saves is by forwarding its click
104
  to the framework's own box for that member. Writing a hidden field's
105
  value and dispatching an input event was tried first: it updates the DOM
 
121
  document.addEventListener('click', function(e){
122
  var c = card(e.target);
123
  if(!c || !c.hasAttribute('data-ref')) return;
 
124
  if(e.target.closest && e.target.closest('a')) return;
125
  var box = boxFor(c.getAttribute('data-ref'));
126
  if(!box) return; /* no transport, no pretend selection */
 
136
  if(!c || e.target !== c) return;
137
  e.preventDefault(); c.click();
138
  });
139
+ /* COPY BUTTONS ON THE SETUP COMMANDS.
140
+ The text comes out of the <pre> the button sits with, never out of an
141
+ attribute written by the server. Two copies of a command line, one to
142
+ read and one to copy, is one escaping mistake away from a clipboard that
143
+ disagrees with the screen, and a command that looks right and pastes
144
+ wrong is worse than no button at all. */
145
+ function commandOf(btn){
146
+ var block = btn.closest ? btn.closest('.c-cmd') : null;
147
+ var src = block ? block.querySelector('pre, code') : null;
148
+ return src ? (src.textContent || '').trim() : '';
149
+ }
150
+ /* execCommand is deprecated and is still the only thing that copies on a
151
+ page that is not on a secure origin, which is where this panel is read:
152
+ a worker joins a server on 192.168.x.x over plain http, and there
153
+ navigator.clipboard does not exist. */
154
+ function legacyCopy(text){
155
+ var box = document.createElement('textarea');
156
+ box.value = text; box.setAttribute('readonly','');
157
+ box.style.position='fixed'; box.style.top='0'; box.style.opacity='0';
158
+ document.body.appendChild(box);
159
+ box.select(); box.setSelectionRange(0, text.length);
160
+ var ok = false;
161
+ try{ ok = document.execCommand('copy'); }catch(err){ ok = false; }
162
+ box.remove();
163
+ return ok;
164
+ }
165
+ /* A press that copied and a press that did nothing look identical unless
166
+ the button says which happened, so it says, and it says "Copy failed"
167
+ when it did rather than claiming a clipboard it never wrote. The command
168
+ stays selectable by hand either way. */
169
+ function flash(btn, word){
170
+ var slot = btn.querySelector('.c-cmd__word') || btn;
171
+ if(!btn.hasAttribute('data-word')) btn.setAttribute('data-word', slot.textContent);
172
+ slot.textContent = word;
173
+ btn.classList.add('is-copied');
174
+ clearTimeout(btn.copyTimer);
175
+ btn.copyTimer = setTimeout(function(){
176
+ slot.textContent = btn.getAttribute('data-word');
177
+ btn.classList.remove('is-copied');
178
+ }, 1600);
179
+ }
180
+ document.addEventListener('click', function(e){
181
+ var btn = e.target.closest ? e.target.closest('.c-cmd__copy') : null;
182
+ if(!btn) return;
183
+ e.preventDefault();
184
+ var text = commandOf(btn);
185
+ if(!text) return;
186
+ if(navigator.clipboard && navigator.clipboard.writeText){
187
+ navigator.clipboard.writeText(text).then(
188
+ function(){ flash(btn, 'Copied'); },
189
+ function(){ flash(btn, legacyCopy(text) ? 'Copied' : 'Copy failed'); });
190
+ } else {
191
+ flash(btn, legacyCopy(text) ? 'Copied' : 'Copy failed');
192
+ }
193
+ });
194
  })();
195
  </script>
196
  """
 
1349
  .c-join__note .c-join__code{display:inline-block; padding:2px 8px;
1350
  font-size:11.5px; margin-top:4px;}
1351
 
1352
+ /* ONE COMMAND PER BLOCK, AND A BUTTON THAT COPIES THAT ONE COMMAND.
1353
+ `cd distinct` and the install line used to share a <pre>. A <pre> is copied
1354
+ whole or not at all, so taking one of the two meant selecting a line by
1355
+ hand, which is fiddly with a mouse and impossible with a thumb. Each
1356
+ command is now its own block with its own button, and the bar above the
1357
+ command carries the button plus, where the command differs by platform, the
1358
+ name of the platform it is for. */
1359
+ .c-cmd{display:flex; flex-direction:column; gap:4px;}
1360
+ .c-cmd__bar{display:flex; align-items:center; gap:var(--s2); min-height:24px;}
1361
+ .c-cmd__for{font-size:var(--t-micro); font-weight:650; letter-spacing:.01em;
1362
+ color:var(--c-text-2) !important;}
1363
+ .c-cmd__copy{margin-left:auto; flex:none; cursor:pointer; appearance:none;
1364
+ font-family:var(--font) !important; font-size:var(--t-micro) !important;
1365
+ font-weight:600 !important; line-height:1.5 !important;
1366
+ padding:3px 10px !important; border-radius:var(--r-md) !important;
1367
+ background:var(--c-surface) !important; color:var(--c-text-2) !important;
1368
+ border:1px solid var(--c-border) !important;}
1369
+ .c-cmd__copy:hover{border-color:var(--c-accent) !important;
1370
+ color:var(--c-sage-deep) !important;}
1371
+ /* The confirmation is the button itself for a moment. A toast somewhere else
1372
+ on the page is a second thing to find, and the question being answered
1373
+ ("did that press do anything?") is asked about this button. */
1374
+ .c-cmd__copy.is-copied{background:var(--c-mint) !important;
1375
+ border-color:var(--c-sage-dark) !important;
1376
+ color:var(--c-sage-deep) !important;}
1377
+
1378
+ /* WHICH MACHINE, AS TWO RADIOS AND NO SCRIPT.
1379
+ Neither is checked when the panel renders, so both platforms' commands are
1380
+ visible and labelled until somebody chooses. A checked default would be
1381
+ silently wrong for half the readers, and a volunteer pasting a Windows
1382
+ command into a Mac terminal is exactly the failure this switch exists to
1383
+ prevent. Choosing hides the other platform's blocks. */
1384
+ .c-os{display:flex; flex-wrap:wrap; align-items:center; gap:var(--s2);}
1385
+ .c-os__ask{font-size:var(--t-micro); color:var(--c-muted) !important;}
1386
+ .c-os__radio{position:absolute; width:1px; height:1px; opacity:0;
1387
+ margin:0; pointer-events:none;}
1388
+ .c-os__tab{display:inline-flex; align-items:center; cursor:pointer;
1389
+ padding:4px 12px; border-radius:var(--r-pill);
1390
+ border:1px solid var(--c-border); background:var(--c-surface);
1391
+ font-size:var(--t-micro); font-weight:600;
1392
+ color:var(--c-text-2) !important;}
1393
+ .c-os__tab:hover{border-color:var(--c-accent);}
1394
+ #distinct-os-win:checked ~ .c-os label[for="distinct-os-win"],
1395
+ #distinct-os-nix:checked ~ .c-os label[for="distinct-os-nix"]{
1396
+ background:var(--c-mint); border-color:var(--c-sage-dark);
1397
+ color:var(--c-sage-deep) !important;}
1398
+ #distinct-os-win:focus-visible ~ .c-os label[for="distinct-os-win"],
1399
+ #distinct-os-nix:focus-visible ~ .c-os label[for="distinct-os-nix"]{
1400
+ outline:3px solid var(--c-focus); outline-offset:2px;}
1401
+ #distinct-os-win:checked ~ .c-steps-num .c-cmd--nix,
1402
+ #distinct-os-nix:checked ~ .c-steps-num .c-cmd--win{display:none;}
1403
+
1404
  /* SETTING UP A WORKER, AS NUMBERED STEPS.
1405
  Three things happen in order and each can fail on its own, so each is
1406
  drawn as its own step with its own command. Told as one paragraph, a
 
1621
  .c-libcard.is-on .c-libcard__check::before{content:"✓";}
1622
  .c-libcard__media{margin:calc(var(--s4)*-1) calc(var(--s4)*-1) var(--s2);
1623
  aspect-ratio:16/9; border-radius:calc(var(--r-lg) - 2px) calc(var(--r-lg) - 2px) 0 0;
1624
+ background:var(--c-fill);
1625
  display:flex; align-items:center; justify-content:center;
1626
  overflow:hidden; position:relative;}
1627
+ /* The drawing of what this member makes. It sits still and says its piece;
1628
+ there is nothing to hover, nothing to wait for, and nothing to fetch. */
1629
+ .c-libcard__mock{width:100%; height:100%; display:block;}
1630
+ .c-libcard.is-on .c-libcard__media{background:color-mix(in srgb, var(--c-mint) 55%, var(--c-fill));}
1631
  .c-libcard__media img{width:100%; height:100%; object-fit:cover;
1632
  object-position:top center; display:block;
1633
  position:absolute; inset:0;}
1634
  /* The still is the run's own result. It sits under the animation, and it is
1635
  what the card shows on a phone, where nothing hovers. */
1636
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1637
  .c-libcard__head{display:flex; align-items:center; gap:var(--s2); padding-right:28px;}
1638
  .c-libcard__kind{font-size:var(--t-micro); letter-spacing:.06em; text-transform:uppercase;
1639
  padding:2px 8px; border-radius:999px; background:var(--c-fill);
 
1649
  .c-libcard__ref{margin-top:auto; padding-top:var(--s2); font-family:var(--mono);
1650
  font-size:11px; color:var(--c-muted) !important;}
1651
 
1652
+ /* Deleting a conversation is a quiet control, not a red one. It sits under
1653
+ the list it acts on, at the size of the thing it removes, and says which
1654
+ conversation it means by acting on the selected one. */
1655
+ .c-rail__delete button,button.c-rail__delete{
1656
+ width:100% !important; margin-top:var(--s2) !important;
1657
+ background:transparent !important; border:1px solid var(--c-hairline) !important;
1658
+ color:var(--c-muted) !important; font-size:var(--t-micro) !important;
1659
+ font-weight:550 !important; padding:5px 10px !important; min-height:0 !important;}
1660
+ .c-rail__delete button:hover,button.c-rail__delete:hover{
1661
+ border-color:var(--c-border) !important; color:var(--c-text-2) !important;}
1662
+
1663
  /* ---- Navigation, in the header band -----------------------------------
1664
  A button (the library, which navigates) beside a link (a section of this
1665
  page). They must look like one row of controls, so the button is styled to
distinct_server/render.py CHANGED
@@ -177,6 +177,67 @@ def no_tools_note(notice_text: str) -> str:
177
  )
178
 
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  def join_this_server(
181
  *, addresses, code: str, qr: str = "", tools: str = "local", source: str = ""
182
  ) -> str:
@@ -189,6 +250,23 @@ def join_this_server(
189
  the three steps in between, so those are what this says: get the code,
190
  install it, run it.
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  ``source`` is where the code lives — this Space's own repository when the
193
  server knows it. Absent, the step says so plainly instead of guessing at
194
  a URL, because sending somebody to a repository that may not exist is
@@ -219,11 +297,12 @@ def join_this_server(
219
  )
220
 
221
  # STEP ONE: the code. A git clone, because a Space is a git repository and
222
- # a clone is the one form that also updates later with `git pull`.
 
223
  if source:
224
  get_it = (
225
- f'<pre class="c-join__cmd">git clone {esc(source)} distinct</pre>'
226
- '<p class="c-join__note">Or use "Download repository" on the Files tab '
227
  "and unzip it. Either way you end up with a folder of Python.</p>"
228
  )
229
  else:
@@ -234,10 +313,27 @@ def join_this_server(
234
 
235
  # STEP TWO: the install. `-e` so the worker runs from the folder that was
236
  # cloned, which is also the folder `git pull` updates.
 
 
 
 
 
 
 
 
237
  install = (
238
- '<pre class="c-join__cmd">cd distinct\npython -m pip install -e ".[agent]"</pre>'
239
- '<p class="c-join__note">Python 3.10 or newer. On Windows use '
240
- "<code>py -3.12 -m pip</code> if <code>python</code> is an older one.</p>"
 
 
 
 
 
 
 
 
 
241
  )
242
 
243
  # STEP THREE: the run, with this server's address and this browser's own
@@ -246,20 +342,25 @@ def join_this_server(
246
  f'<p class="c-join__note">Pairing code, one use and ten minutes: '
247
  f'<code class="c-join__code">{esc(code)}</code></p>'
248
  if code
249
- else '<p class="c-join__note">Generate a pairing code below, then run the '
250
- "command with it in place of YOUR-CODE.</p>"
 
251
  )
252
  # One line on purpose. The previous form used backslash continuations,
253
  # which are bash syntax: pasted into PowerShell they became stray
254
  # arguments and the command half-ran. A single line pastes correctly
255
  # into every shell anybody actually uses.
256
- command = (
257
- f"python -m distinct_agent --server {first} "
258
  f"--pair {code or 'YOUR-CODE'} --models olmoe-1b-7b-0924-instruct --tools {tools or 'local'}"
259
  )
 
 
 
260
  picture = f'<div class="c-join__qr">{qr}</div>' if qr else ""
261
  return (
262
  '<div class="c-join">'
 
263
  '<ol class="c-steps-num">'
264
  '<li class="c-step-num"><span class="c-step-num__n">1</span>'
265
  '<div class="c-step-num__body"><p class="c-step-num__head">Get the code</p>'
@@ -270,7 +371,7 @@ def join_this_server(
270
  '<li class="c-step-num"><span class="c-step-num__n">3</span>'
271
  '<div class="c-step-num__body"><p class="c-step-num__head">Point it at this server</p>'
272
  f'<code class="c-join__address">{esc(first)}</code>{alternates}{code_note}'
273
- f'<pre class="c-join__cmd">{esc(command)}</pre>'
274
  f"{picture}</div></li>"
275
  # STEP FOUR EXISTS BECAUSE PEOPLE ASK FOR IT AFTER READING STEP THREE.
276
  #
@@ -927,6 +1028,51 @@ STEP_LEAD = {
927
  #: this is the backstop that keeps a surprise out of the render path.
928
  _PREVIEW_READ_CAP = 400_000
929
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
930
  _TEXT_SUFFIXES = {
931
  ".txt", ".css", ".json", ".mermaid", ".py", ".js", ".html", ".svg",
932
  ".yaml", ".yml", ".toml", ".xml",
@@ -958,7 +1104,7 @@ def _docx_preview(blob: bytes) -> Optional[Mapping[str, object]]:
958
 
959
  try:
960
  with _zipfile.ZipFile(_io.BytesIO(blob)) as archive:
961
- document = archive.read("word/document.xml")
962
  except Exception:
963
  return None
964
  paragraphs: list[str] = []
@@ -977,12 +1123,13 @@ def _xlsx_preview(blob: bytes) -> Optional[Mapping[str, object]]:
977
  import re as _re
978
  import zipfile as _zipfile
979
 
 
980
  try:
981
  with _zipfile.ZipFile(_io.BytesIO(blob)) as archive:
982
- sheet = archive.read("xl/worksheets/sheet1.xml")
983
  try:
984
- shared = archive.read("xl/sharedStrings.xml")
985
- except KeyError:
986
  shared = b""
987
  except Exception:
988
  return None
@@ -1039,8 +1186,13 @@ def _pptx_preview(blob: bytes) -> Optional[Mapping[str, object]]:
1039
  if _re.fullmatch(r"ppt/slides/slide\d+\.xml", name)
1040
  )
1041
  slides: list[Mapping[str, str]] = []
 
1042
  for name in names[:8]:
1043
- texts = _xml_texts(archive.read(name), r"<a:t>(.*?)</a:t>")
 
 
 
 
1044
  texts = [text for text in (t.strip() for t in texts) if text]
1045
  if texts:
1046
  slides.append({"title": texts[0][:120]})
@@ -1061,11 +1213,20 @@ def _pdf_preview(blob: bytes) -> Optional[Mapping[str, object]]:
1061
  # Streams that will not inflate are skipped rather than guessed at, and a
1062
  # PDF nobody can read still reports its page count, which is a fact.
1063
  chunks = [blob]
 
1064
  for raw in _re.findall(rb"stream\r?\n(.*?)\r?\nendstream", blob, _re.S):
 
 
1065
  try:
1066
- chunks.append(_zlib.decompress(raw))
 
 
 
 
1067
  except Exception:
1068
  continue
 
 
1069
  lines: list[str] = []
1070
  seen: set[str] = set()
1071
  for chunk in chunks:
@@ -1604,32 +1765,231 @@ def _energy_block(lines: Sequence[str], *, measured: bool, quiet: bool = False)
1604
  # Library cards
1605
  # --------------------------------------------------------------------------
1606
 
1607
- _DEMO_DIR = (Path(__file__).resolve().parent.parent / "assets" / "library-demos")
1608
- _DEMO_BASE = f"/gradio_api/file={_DEMO_DIR.as_posix()}"
1609
 
1610
 
1611
- def _demo_media(ref: str) -> tuple[str, str]:
1612
- """``(poster, gif)`` URLs for a member's demonstration, either may be empty.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1613
 
1614
- Both are looked for on disk at render time so a card never promises a
1615
- demonstration the repository does not carry. Slug: the ref with ``@`` and
1616
- ``.`` flattened, which is how the recorder names them.
1617
 
1618
- The poster is the recording's last frame the artifact the run produced
1619
- so a card at rest already shows what the member makes. That matters most
1620
- on a phone, where there is no hover and the GIF is a deliberate act.
1621
  """
1622
 
1623
- slug = ref.replace("@", "-").replace(".", "-")
1624
- poster = f"{_DEMO_BASE}/{slug}.webp" if (_DEMO_DIR / f"{slug}.webp").is_file() else ""
1625
- gif = f"{_DEMO_BASE}/{slug}.gif" if (_DEMO_DIR / f"{slug}.gif").is_file() else ""
1626
- return poster, gif
 
 
 
 
1627
 
1628
 
1629
- def _demo_gif(ref: str) -> str:
1630
- """The demo GIF's URL for a library member, or nothing."""
1631
 
1632
- return _demo_media(ref)[1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1633
 
1634
 
1635
  def library_cards(rows: Sequence[Mapping[str, object]], chosen: Sequence[str]) -> str:
@@ -1655,23 +2015,15 @@ def library_cards(rows: Sequence[Mapping[str, object]], chosen: Sequence[str]) -
1655
  name = str(row.get("name", ref))
1656
  author = str(row.get("author", "distinct"))
1657
  description = str(row.get("description", ""))
1658
- poster, gif = _demo_media(ref)
1659
- media = ""
1660
- if poster or gif:
1661
- still = (
1662
- f'<img class="c-libcard__poster" src="{esc(poster)}" alt="" loading="lazy">'
1663
- if poster
1664
- else ""
1665
- )
1666
- media = (
1667
- f'<figure class="c-libcard__media" data-gif="{esc(gif)}">'
1668
- f"{still}"
1669
- '<span class="c-libcard__play">watch it work</span>'
1670
- '<figcaption class="c-visually-hidden">'
1671
- "Hover, or press and hold, to play this member's demonstration"
1672
- "</figcaption>"
1673
- "</figure>"
1674
- )
1675
  on = ref in picked
1676
  # The card itself is the control: role, state and keyboard focus are
1677
  # its own, and the check mark is drawn by the stylesheet from the
 
177
  )
178
 
179
 
180
+ _PLATFORM_LABELS = {"win": "Windows", "nix": "macOS or Linux"}
181
+
182
+
183
+ def _command_block(command: str, *, platform: str = "") -> str:
184
+ """One command, drawn as its own block with its own copy button.
185
+
186
+ Two commands used to share a ``<pre>``. A block holding two lines can only
187
+ be copied whole, and on a phone it cannot even be dragged apart, so
188
+ somebody following the panel had to retype half of it. A block now holds
189
+ one command and nothing else, and the button beside it copies that one.
190
+
191
+ The button carries no copy of the text. The handler in
192
+ :data:`distinct_server.presentation.HEAD` reads it out of the ``<pre>`` at
193
+ click time, which makes the clipboard equal to the screen by construction.
194
+ A second copy of the command in a ``data-`` attribute would be a second
195
+ string to escape and a second string to get wrong.
196
+
197
+ ``platform`` labels a command that differs between Windows and everything
198
+ else. The label sits on the block, not only on the switch above it,
199
+ because the switch starts on neither platform: nobody is shown a command
200
+ for a machine that may not be theirs without the block saying whose it is.
201
+ """
202
+
203
+ label = _PLATFORM_LABELS.get(platform, "")
204
+ tag = f'<span class="c-cmd__for">{esc(label)}</span>' if label else ""
205
+ variant = f" c-cmd--{esc(platform)}" if label else ""
206
+ whose = f'<span class="c-visually-hidden"> the {esc(label)} command</span>' if label else ""
207
+ return (
208
+ f'<div class="c-cmd{variant}">'
209
+ f'<div class="c-cmd__bar">{tag}'
210
+ '<button type="button" class="c-cmd__copy">'
211
+ f'<span class="c-cmd__word">Copy</span>{whose}</button></div>'
212
+ f'<pre class="c-join__cmd">{esc(command)}</pre>'
213
+ "</div>"
214
+ )
215
+
216
+
217
+ # WHICH MACHINE, ASKED BEFORE THE COMMANDS RATHER THAN AFTER THEM.
218
+ #
219
+ # Neither radio is checked, so both platforms' commands are on screen until
220
+ # somebody picks one. That is deliberate: a default would be right for one
221
+ # half of the readers and silently wrong for the other, and being silently
222
+ # wrong is the whole failure this panel is being rebuilt to stop. Picking
223
+ # hides the other platform; picking nothing costs a few extra lines, each of
224
+ # them labelled.
225
+ #
226
+ # The switch is two radios and CSS, with no script behind it, because this
227
+ # function returns a plain HTML string that has to work wherever it is
228
+ # rendered. The copy buttons need JavaScript and cannot avoid it; the choice
229
+ # of what a person reads should not.
230
+ _OS_SWITCH = (
231
+ '<input class="c-os__radio" type="radio" name="distinct-os" id="distinct-os-win">'
232
+ '<input class="c-os__radio" type="radio" name="distinct-os" id="distinct-os-nix">'
233
+ '<div class="c-os">'
234
+ '<span class="c-os__ask">Which machine are you on?</span>'
235
+ '<label class="c-os__tab" for="distinct-os-win">Windows</label>'
236
+ '<label class="c-os__tab" for="distinct-os-nix">macOS or Linux</label>'
237
+ "</div>"
238
+ )
239
+
240
+
241
  def join_this_server(
242
  *, addresses, code: str, qr: str = "", tools: str = "local", source: str = ""
243
  ) -> str:
 
250
  the three steps in between, so those are what this says: get the code,
251
  install it, run it.
252
 
253
+ THE COMMANDS ARE PER-PLATFORM BECAUSE THE FOOTNOTE VERSION DID NOT WORK.
254
+
255
+ The install step used to read ``python -m pip install -e .`` with
256
+ a line underneath saying to use something else on Windows. People paste
257
+ the command; they do not paste the footnote. On Windows the bare
258
+ ``python`` is routinely an old interpreter that arrived with some other
259
+ program, and the first volunteer to follow this panel got two failures in
260
+ a row from it: pip 20.2.3 refusing an editable install of a project with
261
+ no ``setup.py``, then the worker refusing to start on Python 3.9. Both
262
+ were the panel's fault, not theirs. So the command a person copies is now
263
+ the correct one for the machine they say they are on, and the caveat that
264
+ used to live underneath is spelled ``py -3`` inside the command itself.
265
+
266
+ ``py -3`` and not ``py -3.12``: the launcher picks the newest installed
267
+ Python 3, and naming a minor version asserts something about a machine
268
+ this server has never seen.
269
+
270
  ``source`` is where the code lives — this Space's own repository when the
271
  server knows it. Absent, the step says so plainly instead of guessing at
272
  a URL, because sending somebody to a repository that may not exist is
 
297
  )
298
 
299
  # STEP ONE: the code. A git clone, because a Space is a git repository and
300
+ # a clone is the one form that also updates later with `git pull`. Same
301
+ # command everywhere, so it carries no platform label.
302
  if source:
303
  get_it = (
304
+ _command_block(f"git clone {source} distinct")
305
+ + '<p class="c-join__note">Or use "Download repository" on the Files tab '
306
  "and unzip it. Either way you end up with a folder of Python.</p>"
307
  )
308
  else:
 
313
 
314
  # STEP TWO: the install. `-e` so the worker runs from the folder that was
315
  # cloned, which is also the folder `git pull` updates.
316
+ #
317
+ # THE PIP UPGRADE IS A STEP, NOT ADVICE. `py -3` fixes the interpreter and
318
+ # not the pip inside it: Python 3.10.0 shipped pip 21.2, PEP 660 editable
319
+ # installs landed in pip 21.3, and an old pip meeting a project with no
320
+ # `setup.py` stops with a message about setup.py that reads as though the
321
+ # repository is broken. One line ahead of the install turns that into a
322
+ # non-event, so it is a line people can copy rather than a warning they
323
+ # get to read after it has already failed.
324
  install = (
325
+ _command_block("cd distinct")
326
+ + _command_block("py -3 -m pip install --upgrade pip", platform="win")
327
+ + _command_block("python3 -m pip install --upgrade pip", platform="nix")
328
+ + _command_block('py -3 -m pip install -e .', platform="win")
329
+ + _command_block('python3 -m pip install -e .', platform="nix")
330
+ + '<p class="c-join__note">The worker needs Python 3.10 or newer. On Windows '
331
+ "<code>py -3</code> runs the newest Python 3 on the machine, which is why the "
332
+ "commands use it: the bare <code>python</code> there is usually whichever old "
333
+ "one arrived with some other program, and it fails on its own version rather "
334
+ "than on anything you did. Run the pip line even if pip looks current: pip "
335
+ "older than 21.3 cannot do the install below at all, and says so by asking for "
336
+ "a <code>setup.py</code> this project does not have.</p>"
337
  )
338
 
339
  # STEP THREE: the run, with this server's address and this browser's own
 
342
  f'<p class="c-join__note">Pairing code, one use and ten minutes: '
343
  f'<code class="c-join__code">{esc(code)}</code></p>'
344
  if code
345
+ else '<p class="c-join__note">Sign in at the top of this page and a pairing '
346
+ "code appears here. Until then the command below is complete apart from "
347
+ "that one value.</p>"
348
  )
349
  # One line on purpose. The previous form used backslash continuations,
350
  # which are bash syntax: pasted into PowerShell they became stray
351
  # arguments and the command half-ran. A single line pastes correctly
352
  # into every shell anybody actually uses.
353
+ arguments = (
354
+ f"-m distinct_agent --server {first} "
355
  f"--pair {code or 'YOUR-CODE'} --models olmoe-1b-7b-0924-instruct --tools {tools or 'local'}"
356
  )
357
+ run_it = _command_block(f"py -3 {arguments}", platform="win") + _command_block(
358
+ f"python3 {arguments}", platform="nix"
359
+ )
360
  picture = f'<div class="c-join__qr">{qr}</div>' if qr else ""
361
  return (
362
  '<div class="c-join">'
363
+ f"{_OS_SWITCH}"
364
  '<ol class="c-steps-num">'
365
  '<li class="c-step-num"><span class="c-step-num__n">1</span>'
366
  '<div class="c-step-num__body"><p class="c-step-num__head">Get the code</p>'
 
371
  '<li class="c-step-num"><span class="c-step-num__n">3</span>'
372
  '<div class="c-step-num__body"><p class="c-step-num__head">Point it at this server</p>'
373
  f'<code class="c-join__address">{esc(first)}</code>{alternates}{code_note}'
374
+ f"{run_it}"
375
  f"{picture}</div></li>"
376
  # STEP FOUR EXISTS BECAUSE PEOPLE ASK FOR IT AFTER READING STEP THREE.
377
  #
 
1028
  #: this is the backstop that keeps a surprise out of the render path.
1029
  _PREVIEW_READ_CAP = 400_000
1030
 
1031
+ #: How much a compressed member may be allowed to become, and how much of a
1032
+ #: whole file may be inflated across all its members.
1033
+ #:
1034
+ #: THE FILE ON THE WIRE IS NOT THE FILE IN MEMORY. A worker is somebody
1035
+ #: else's machine, and the protocol caps what it may send, not what that
1036
+ #: expands to: a 300 KB archive of compressible bytes inflates to 300 MB, and
1037
+ #: a run may carry eight of them. Read whole, that is 2.4 GB and half a minute
1038
+ #: of CPU inside the one process every viewer of this server shares — a worker
1039
+ #: could stop the service for everybody by answering a question.
1040
+ #:
1041
+ #: A preview needs the first few paragraphs, rows or slides, so these are
1042
+ #: generous by the standard of what is actually read and mean the arithmetic
1043
+ #: cannot run away.
1044
+ _MAX_INFLATED_MEMBER = 2 * 1024 * 1024
1045
+ _MAX_INFLATED_TOTAL = 8 * 1024 * 1024
1046
+
1047
+
1048
+ class _InflationRefused(Exception):
1049
+ """A member claimed, or turned out to be, more than a preview may inflate."""
1050
+
1051
+
1052
+ def _read_member(archive, name: str, budget: list[int]) -> bytes:
1053
+ """One zip member, refused rather than inflated when it is too large.
1054
+
1055
+ Two checks, because either alone is defeated. The header's ``file_size``
1056
+ is what the archive claims and can simply lie; reading one byte past the
1057
+ limit is what actually happened. ``budget`` is the file's remaining total,
1058
+ mutated as members are read, so many merely-large members cannot add up to
1059
+ the same attack that one huge one would.
1060
+ """
1061
+
1062
+ try:
1063
+ info = archive.getinfo(name)
1064
+ except KeyError as exc:
1065
+ raise _InflationRefused(f"no member {name!r}") from exc
1066
+ allowed = min(_MAX_INFLATED_MEMBER, budget[0])
1067
+ if info.file_size > allowed:
1068
+ raise _InflationRefused(f"{name} declares {info.file_size} bytes")
1069
+ with archive.open(name) as handle:
1070
+ data = handle.read(allowed + 1)
1071
+ if len(data) > allowed:
1072
+ raise _InflationRefused(f"{name} is larger than it declared")
1073
+ budget[0] -= len(data)
1074
+ return data
1075
+
1076
  _TEXT_SUFFIXES = {
1077
  ".txt", ".css", ".json", ".mermaid", ".py", ".js", ".html", ".svg",
1078
  ".yaml", ".yml", ".toml", ".xml",
 
1104
 
1105
  try:
1106
  with _zipfile.ZipFile(_io.BytesIO(blob)) as archive:
1107
+ document = _read_member(archive, "word/document.xml", [_MAX_INFLATED_TOTAL])
1108
  except Exception:
1109
  return None
1110
  paragraphs: list[str] = []
 
1123
  import re as _re
1124
  import zipfile as _zipfile
1125
 
1126
+ budget = [_MAX_INFLATED_TOTAL]
1127
  try:
1128
  with _zipfile.ZipFile(_io.BytesIO(blob)) as archive:
1129
+ sheet = _read_member(archive, "xl/worksheets/sheet1.xml", budget)
1130
  try:
1131
+ shared = _read_member(archive, "xl/sharedStrings.xml", budget)
1132
+ except (KeyError, _InflationRefused):
1133
  shared = b""
1134
  except Exception:
1135
  return None
 
1186
  if _re.fullmatch(r"ppt/slides/slide\d+\.xml", name)
1187
  )
1188
  slides: list[Mapping[str, str]] = []
1189
+ budget = [_MAX_INFLATED_TOTAL]
1190
  for name in names[:8]:
1191
+ try:
1192
+ member = _read_member(archive, name, budget)
1193
+ except _InflationRefused:
1194
+ continue
1195
+ texts = _xml_texts(member, r"<a:t>(.*?)</a:t>")
1196
  texts = [text for text in (t.strip() for t in texts) if text]
1197
  if texts:
1198
  slides.append({"title": texts[0][:120]})
 
1213
  # Streams that will not inflate are skipped rather than guessed at, and a
1214
  # PDF nobody can read still reports its page count, which is a fact.
1215
  chunks = [blob]
1216
+ budget = _MAX_INFLATED_TOTAL
1217
  for raw in _re.findall(rb"stream\r?\n(.*?)\r?\nendstream", blob, _re.S):
1218
+ if budget <= 0:
1219
+ break
1220
  try:
1221
+ # Bounded inflation: `decompress(data, max_length)` stops at the
1222
+ # limit instead of returning however many gigabytes the stream
1223
+ # encodes. What is left in the decompressor is dropped with it.
1224
+ allowed = min(_MAX_INFLATED_MEMBER, budget)
1225
+ piece = _zlib.decompressobj().decompress(raw, allowed)
1226
  except Exception:
1227
  continue
1228
+ budget -= len(piece)
1229
+ chunks.append(piece)
1230
  lines: list[str] = []
1231
  seen: set[str] = set()
1232
  for chunk in chunks:
 
1765
  # Library cards
1766
  # --------------------------------------------------------------------------
1767
 
 
 
1768
 
1769
 
1770
+ #: What a member leaves behind, keyed by reference. Drawn on its card as a
1771
+ #: small picture of that kind of thing.
1772
+ #:
1773
+ #: A RECORDING WAS THE WRONG ANSWER TO "WHAT IS THIS FOR".
1774
+ #:
1775
+ #: These cards used to carry a filmed demonstration: a GIF per member, with a
1776
+ #: still of the run's own output underneath, 2.5 MB of them in the repository.
1777
+ #: They were honest — every frame was a real run — and they answered the wrong
1778
+ #: question. Somebody scanning a shelf of thirty members wants to know what
1779
+ #: each one *makes*, at a glance, before deciding which to look at closely; a
1780
+ #: recording only answers that after it has played, one card at a time, and
1781
+ #: not at all on a phone where nothing hovers.
1782
+ #:
1783
+ #: A drawing of the artifact answers it instantly and identically for every
1784
+ #: member, weighs a few hundred bytes, needs no recording pass to stay true
1785
+ #: when a skill changes, and reads the same on any screen.
1786
+ _MEMBER_OUTPUT: Mapping[str, str] = {
1787
+ "create_pdf": "page",
1788
+ "create_docx": "page",
1789
+ "create_deck": "deck",
1790
+ "create_xlsx": "sheet",
1791
+ "csv_table": "sheet",
1792
+ "frontend_design": "web",
1793
+ "theme_factory": "palette",
1794
+ "make_plan": "list",
1795
+ "todo": "list",
1796
+ "review_checklist": "list",
1797
+ "skill_scaffold": "note",
1798
+ "status_update": "note",
1799
+ "scratchpad": "note",
1800
+ "search_document": "find",
1801
+ "extract_from_text": "find",
1802
+ "verify_quote": "find",
1803
+ "calculate": "value",
1804
+ "calculate_date": "value",
1805
+ "convert_units": "value",
1806
+ }
1807
+
1808
+ #: Everything driven by ``tailored_guide`` writes a guide in markdown, so it
1809
+ #: is one shape rather than nineteen near-identical entries above.
1810
+ _GUIDE_OUTPUT = "guide"
1811
+
1812
+ #: The palette the drawings use. Deliberately the page's own tokens rather
1813
+ #: than fixed colours, so a card follows the theme instead of sitting in a
1814
+ #: little rectangle of last year's palette.
1815
+ _MOCK_INK = "var(--c-text-2)"
1816
+ _MOCK_FAINT = "var(--c-hairline)"
1817
+ _MOCK_ACCENT = "var(--c-accent)"
1818
+
1819
+
1820
+ def _member_output(ref: str) -> str:
1821
+ """Which drawing belongs to a member, from its reference."""
1822
+
1823
+ tool_id = ref.split("@", 1)[0]
1824
+ if tool_id in _MEMBER_OUTPUT:
1825
+ return _MEMBER_OUTPUT[tool_id]
1826
+ try:
1827
+ from distinct_tools.skills import REPOSITORY_SKILLS
1828
+
1829
+ for skill in REPOSITORY_SKILLS:
1830
+ if skill.tool_id == tool_id and getattr(skill, "handler", "") == "tailored_guide":
1831
+ return _GUIDE_OUTPUT
1832
+ except Exception:
1833
+ pass
1834
+ return "generic"
1835
+
1836
 
1837
+ def _rows(count: int, *, x: float, y: float, gap: float, width: float, short: int = -1) -> str:
1838
+ """Horizontal rules standing in for lines of text.
 
1839
 
1840
+ ``short`` is the index drawn at two thirds width. A block of identical
1841
+ full-width bars reads as a barcode; one ragged edge is what makes it read
1842
+ as writing.
1843
  """
1844
 
1845
+ bars = []
1846
+ for index in range(count):
1847
+ length = width * (0.62 if index == short else 1.0)
1848
+ bars.append(
1849
+ f'<rect x="{x}" y="{y + index * gap:.1f}" width="{length:.1f}" height="2" '
1850
+ f'rx="1" fill="{_MOCK_FAINT}"/>'
1851
+ )
1852
+ return "".join(bars)
1853
 
1854
 
1855
+ def _mockup(kind: str) -> str:
1856
+ """One small drawing of the thing a member produces.
1857
 
1858
+ Inline SVG rather than an image file: it is a few hundred bytes, it is
1859
+ sharp at any size, it takes the page's colours, and there is no asset to
1860
+ fetch, cache, forget to ship, or let drift out of date.
1861
+ """
1862
+
1863
+ body = {
1864
+ # A page of writing: a heading, a rule, and paragraphs.
1865
+ "page": (
1866
+ f'<rect x="34" y="8" width="92" height="88" rx="3" fill="var(--c-surface)" '
1867
+ f'stroke="{_MOCK_FAINT}"/>'
1868
+ f'<rect x="44" y="20" width="46" height="5" rx="2" fill="{_MOCK_ACCENT}"/>'
1869
+ + _rows(6, x=44, y=34, gap=9, width=72, short=5)
1870
+ ),
1871
+ # Slides: one wide frame, two beneath it.
1872
+ "deck": (
1873
+ f'<rect x="26" y="10" width="108" height="46" rx="3" fill="var(--c-surface)" '
1874
+ f'stroke="{_MOCK_FAINT}"/>'
1875
+ f'<rect x="36" y="22" width="42" height="5" rx="2" fill="{_MOCK_ACCENT}"/>'
1876
+ f'<rect x="36" y="34" width="66" height="2" rx="1" fill="{_MOCK_FAINT}"/>'
1877
+ f'<rect x="26" y="62" width="50" height="32" rx="3" fill="var(--c-surface)" '
1878
+ f'stroke="{_MOCK_FAINT}"/>'
1879
+ f'<rect x="84" y="62" width="50" height="32" rx="3" fill="var(--c-surface)" '
1880
+ f'stroke="{_MOCK_FAINT}"/>'
1881
+ ),
1882
+ # A grid, with the header row filled in.
1883
+ "sheet": (
1884
+ f'<rect x="26" y="16" width="108" height="72" rx="3" fill="var(--c-surface)" '
1885
+ f'stroke="{_MOCK_FAINT}"/>'
1886
+ f'<rect x="26" y="16" width="108" height="16" fill="var(--c-fill)"/>'
1887
+ + "".join(
1888
+ f'<line x1="{26 + column * 36}" y1="16" x2="{26 + column * 36}" y2="88" '
1889
+ f'stroke="{_MOCK_FAINT}"/>'
1890
+ for column in (1, 2)
1891
+ )
1892
+ + "".join(
1893
+ f'<line x1="26" y1="{16 + row * 18}" x2="134" y2="{16 + row * 18}" '
1894
+ f'stroke="{_MOCK_FAINT}"/>'
1895
+ for row in (1, 2, 3)
1896
+ )
1897
+ + "".join(
1898
+ f'<rect x="{34 + column * 36}" y="22" width="18" height="4" rx="2" '
1899
+ f'fill="{_MOCK_ACCENT}"/>'
1900
+ for column in (0, 1, 2)
1901
+ )
1902
+ ),
1903
+ # A browser: chrome, a hero block, two columns.
1904
+ "web": (
1905
+ f'<rect x="20" y="14" width="120" height="76" rx="4" fill="var(--c-surface)" '
1906
+ f'stroke="{_MOCK_FAINT}"/>'
1907
+ f'<rect x="20" y="14" width="120" height="14" rx="4" fill="var(--c-fill)"/>'
1908
+ + "".join(
1909
+ f'<circle cx="{30 + dot * 9}" cy="21" r="2.4" fill="{_MOCK_FAINT}"/>'
1910
+ for dot in range(3)
1911
+ )
1912
+ + f'<rect x="30" y="38" width="54" height="6" rx="3" fill="{_MOCK_ACCENT}"/>'
1913
+ + _rows(2, x=30, y=52, gap=8, width=80, short=1)
1914
+ + '<rect x="30" y="72" width="46" height="10" rx="3" fill="var(--c-mint)"/>'
1915
+ + '<rect x="84" y="72" width="46" height="10" rx="3" fill="var(--c-fill)"/>'
1916
+ ),
1917
+ # A set of colours, which is what a theme is.
1918
+ "palette": (
1919
+ "".join(
1920
+ f'<rect x="{24 + column * 30}" y="{22 + row * 30}" width="24" height="24" '
1921
+ f'rx="4" fill="{fill}" stroke="{_MOCK_FAINT}"/>'
1922
+ for row, fills in enumerate(
1923
+ (
1924
+ (_MOCK_ACCENT, "var(--c-sage)", "var(--c-mint)", "var(--c-fill)"),
1925
+ ("var(--c-text)", "var(--c-text-2)", "var(--c-muted)", "var(--c-surface)"),
1926
+ )
1927
+ )
1928
+ for column, fill in enumerate(fills)
1929
+ )
1930
+ ),
1931
+ # Numbered steps.
1932
+ "list": (
1933
+ f'<rect x="30" y="10" width="100" height="84" rx="3" fill="var(--c-surface)" '
1934
+ f'stroke="{_MOCK_FAINT}"/>'
1935
+ + "".join(
1936
+ f'<circle cx="44" cy="{26 + row * 20}" r="5" fill="none" '
1937
+ f'stroke="{_MOCK_ACCENT}" stroke-width="1.5"/>'
1938
+ f'<rect x="56" y="{24 + row * 20}" width="{58 - row * 10}" height="3" rx="1.5" '
1939
+ f'fill="{_MOCK_FAINT}"/>'
1940
+ for row in range(4)
1941
+ )
1942
+ ),
1943
+ # A short note, torn from a pad.
1944
+ "note": (
1945
+ f'<path d="M40 12 h80 v66 l-12 12 h-68 z" fill="var(--c-surface)" '
1946
+ f'stroke="{_MOCK_FAINT}"/>'
1947
+ f'<path d="M120 78 h-12 v12 z" fill="var(--c-fill)" stroke="{_MOCK_FAINT}"/>'
1948
+ f'<rect x="50" y="26" width="38" height="5" rx="2" fill="{_MOCK_ACCENT}"/>'
1949
+ + _rows(4, x=50, y=40, gap=9, width=60, short=3)
1950
+ ),
1951
+ # A guide: a heading and bulleted method.
1952
+ "guide": (
1953
+ f'<rect x="32" y="8" width="96" height="88" rx="3" fill="var(--c-surface)" '
1954
+ f'stroke="{_MOCK_FAINT}"/>'
1955
+ f'<rect x="42" y="20" width="52" height="5" rx="2" fill="{_MOCK_ACCENT}"/>'
1956
+ + "".join(
1957
+ f'<circle cx="45" cy="{40 + row * 14}" r="2" fill="{_MOCK_ACCENT}"/>'
1958
+ f'<rect x="53" y="{38 + row * 14}" width="{62 - row * 8}" height="3" rx="1.5" '
1959
+ f'fill="{_MOCK_FAINT}"/>'
1960
+ for row in range(4)
1961
+ )
1962
+ ),
1963
+ # Looking through a document for something.
1964
+ "find": (
1965
+ f'<rect x="30" y="10" width="86" height="80" rx="3" fill="var(--c-surface)" '
1966
+ f'stroke="{_MOCK_FAINT}"/>'
1967
+ + _rows(5, x=40, y=26, gap=12, width=62, short=4)
1968
+ + '<rect x="40" y="48" width="40" height="7" rx="2" fill="var(--c-mint)"/>'
1969
+ + f'<circle cx="112" cy="70" r="17" fill="var(--c-surface)" stroke="{_MOCK_ACCENT}" '
1970
+ f'stroke-width="2.5"/>'
1971
+ + f'<line x1="124" y1="82" x2="134" y2="92" stroke="{_MOCK_ACCENT}" '
1972
+ f'stroke-width="2.5" stroke-linecap="round"/>'
1973
+ ),
1974
+ # An answer, which is what a calculation gives back.
1975
+ "value": (
1976
+ f'<rect x="34" y="24" width="92" height="52" rx="6" fill="var(--c-surface)" '
1977
+ f'stroke="{_MOCK_FAINT}"/>'
1978
+ f'<rect x="46" y="36" width="26" height="4" rx="2" fill="{_MOCK_FAINT}"/>'
1979
+ f'<rect x="46" y="48" width="52" height="12" rx="3" fill="{_MOCK_ACCENT}"/>'
1980
+ ),
1981
+ "generic": (
1982
+ f'<rect x="38" y="12" width="84" height="80" rx="3" fill="var(--c-surface)" '
1983
+ f'stroke="{_MOCK_FAINT}"/>' + _rows(5, x=50, y=30, gap=11, width=60, short=4)
1984
+ ),
1985
+ }.get(kind)
1986
+ if body is None:
1987
+ body = ""
1988
+ return (
1989
+ '<svg class="c-libcard__mock" viewBox="0 0 160 104" role="img" '
1990
+ 'aria-hidden="true" preserveAspectRatio="xMidYMid meet">'
1991
+ f"{body}</svg>"
1992
+ )
1993
 
1994
 
1995
  def library_cards(rows: Sequence[Mapping[str, object]], chosen: Sequence[str]) -> str:
 
2015
  name = str(row.get("name", ref))
2016
  author = str(row.get("author", "distinct"))
2017
  description = str(row.get("description", ""))
2018
+ # Every card carries one, because a shelf where some members have a
2019
+ # picture and some do not reads as a shelf where some are broken.
2020
+ output = _member_output(ref)
2021
+ media = (
2022
+ f'<figure class="c-libcard__media c-libcard__media--{esc(output)}">'
2023
+ f"{_mockup(output)}"
2024
+ f'<figcaption class="c-visually-hidden">Makes a {esc(output)}</figcaption>'
2025
+ "</figure>"
2026
+ )
 
 
 
 
 
 
 
 
2027
  on = ref in picked
2028
  # The card itself is the control: role, state and keyboard focus are
2029
  # its own, and the check mark is drawn by the stylesheet from the
distinct_server/ui.py CHANGED
@@ -3,6 +3,7 @@
3
  from __future__ import annotations
4
 
5
  import json
 
6
  import math
7
  import os
8
  import time
@@ -65,14 +66,18 @@ from .presentation import PALETTE
65
  from .ui_state import (
66
  active_conversation,
67
  add_conversation,
 
68
  append_job,
69
  conversation_choices,
70
  conversation_turns,
71
  new_session,
72
  record_answer,
 
73
  select_conversation,
74
  )
75
 
 
 
76
  # The composer's library choices are not a constant. They are derived, per
77
  # refresh, from what the connected agents actually advertise, by the same
78
  # route the model dropdown and the agent filter already use. A hardcoded empty
@@ -87,6 +92,28 @@ NO_TOOLS_NOTICE = (
87
  "the model on its own."
88
  )
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  #: What an empty transcript says. A module constant rather than a literal
91
  #: buried in the layout, because it is the only instruction on a page that has
92
  #: nothing else on it, and because the previous version pointed at the wrong
@@ -1105,13 +1132,6 @@ ASSESSMENT_ASSIGNMENT: Mapping[str, Mapping[str, Any]] = {
1105
  "checkpoint is not separately costed in the source."
1106
  ),
1107
  },
1108
- "llama-2-7b-chat": {
1109
- "record": "llama-2-7b",
1110
- "note": (
1111
- "Figures cover Llama 2 7B pretraining; Meta report fine-tuning compute inside "
1112
- "the family total without breaking it out per checkpoint."
1113
- ),
1114
- },
1115
  }
1116
 
1117
 
@@ -1794,9 +1814,8 @@ class DistinctUI:
1794
 
1795
  # Authentication/session helpers ----------------------------------
1796
 
1797
- def _authorize(
1798
  self,
1799
- state: Mapping[str, Any],
1800
  profile: Optional[Mapping[str, Any]],
1801
  request: Optional[Any] = None,
1802
  ) -> str:
@@ -1816,10 +1835,46 @@ class DistinctUI:
1816
  user_id = _identity_from_request(request)
1817
  if user_id is None:
1818
  user_id = user_id_from_profile(profile)
1819
- session_id = str(state.get("session_id", ""))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1820
  self.sessions.bind(session_id, user_id)
1821
  self.control.create_session(session_id)
1822
- for conversation_id in state.get("conversations", {}):
1823
  self.control.create_conversation(session_id, str(conversation_id))
1824
  return user_id
1825
 
@@ -1849,13 +1904,17 @@ class DistinctUI:
1849
  # the session and conversations server-side (in _authorize) and keeps
1850
  # every answer the browser already holds. This is the persistence the
1851
  # privacy notice promises: the user holds their own history.
1852
- if (
1853
- isinstance(saved_state, Mapping)
1854
- and isinstance(saved_state.get("session_id"), str)
1855
- and isinstance(saved_state.get("conversations"), Mapping)
1856
- and saved_state.get("active_conversation_id") in saved_state["conversations"]
1857
- ):
1858
- state = dict(saved_state)
 
 
 
 
1859
  updated = dict(state)
1860
  messages: list[dict[str, str]] = []
1861
  energy = _session_energy_markdown(())
@@ -1881,6 +1940,25 @@ class DistinctUI:
1881
  )
1882
  activity = gr.HTML(value="", visible=False)
1883
  user_id = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1884
  states = self._conversation_states(updated)
1885
  choices = conversation_choices(updated, states)
1886
  selected = updated["active_conversation_id"]
@@ -2198,16 +2276,23 @@ class DistinctUI:
2198
  selected_agent: str,
2199
  mode: str = MODE_SIMPLE,
2200
  state: Optional[Mapping[str, Any]] = None,
2201
- profile: Optional[Mapping[str, Any]] = None,
2202
  request: "gr.Request | None" = None,
2203
  ) -> tuple:
2204
  # Declared, because it is used two lines down. It was not, and the
2205
  # handler raised NameError on every change of the model dropdown, the
2206
  # library checkboxes or the mode: a live crash on three of the most
2207
  # common interactions in the interface, in a handler no test called.
2208
- # Nothing else here has changed. Gradio fills this from the annotation
2209
- # and pads the arguments it was not wired for with None, which is why
2210
- # `state` and `profile` arrive empty and are written to tolerate it.
 
 
 
 
 
 
 
2211
  #
2212
  # Re-checked here rather than trusted from the last render. A hidden
2213
  # component is still callable through the API, so every handler that
@@ -2260,6 +2345,61 @@ class DistinctUI:
2260
  except (ControlPlaneError, LoginRequired, ValueError) as exc:
2261
  raise gr.Error(str(exc)) from exc
2262
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2263
  def change_conversation(
2264
  self,
2265
  state: Mapping[str, Any],
@@ -2360,6 +2500,12 @@ class DistinctUI:
2360
  except (ControlPlaneError, LoginRequired, ValueError) as exc:
2361
  raise gr.Error(str(exc)) from exc
2362
 
 
 
 
 
 
 
2363
  def refresh_session(
2364
  self,
2365
  state: Mapping[str, Any],
@@ -2369,6 +2515,37 @@ class DistinctUI:
2369
  mode: str,
2370
  profile: gr.OAuthProfile | None,
2371
  request: gr.Request | None = None,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2372
  ) -> tuple:
2373
  updated = dict(state)
2374
  try:
@@ -2380,6 +2557,26 @@ class DistinctUI:
2380
  messages, status = [], _status_html("Sign in to run a model.")
2381
  energy = _session_energy_html(())
2382
  activity = gr.HTML(value="", visible=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2383
  rows, choices, value, narrowed = self._agent_components(
2384
  model_id, tool_values, selected_agent, mode, viewer_id=viewer_id
2385
  )
@@ -2493,6 +2690,15 @@ class DistinctUI:
2493
  A signed-out caller now gets a fresh blank state and touches nothing,
2494
  which is the honest answer to "erase my data" from somebody who has no
2495
  data here.
 
 
 
 
 
 
 
 
 
2496
  """
2497
 
2498
  try:
@@ -2502,14 +2708,21 @@ class DistinctUI:
2502
  )
2503
  except LoginRequired:
2504
  session_id = ""
 
2505
  if session_id:
2506
  self.sessions.release(session_id)
2507
  self.control.delete_session(session_id)
2508
  self._delete_outputs(session_id)
2509
- fresh = new_session()
2510
- self.control.create_session(fresh["session_id"])
2511
- for conversation_id in fresh["conversations"]:
2512
- self.control.create_conversation(fresh["session_id"], conversation_id)
 
 
 
 
 
 
2513
  library, library_note = self._library_components(())
2514
  fresh_states = self._conversation_states(fresh)
2515
  return (
@@ -3066,22 +3279,86 @@ class DistinctUI:
3066
  state: Mapping[str, Any],
3067
  profile: gr.OAuthProfile | None,
3068
  request: gr.Request | None = None,
3069
- ) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3070
  try:
3071
  user_id = self._authorize(state, profile, request)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3072
  value = self.control.create_pairing_code(user_id)
3073
- return self._join_panel(value.code, state)
3074
- except (ControlPlaneError, LoginRequired, ValueError) as exc:
3075
- raise gr.Error(str(exc)) from exc
3076
-
3077
- # The worker is Python, distributed as this repository's own source, so
3078
- # there is no build artifact to serve and no `download_agent` handler any
3079
- # more. The setup panel says where the code is and how to install it; see
3080
- # `render.join_this_server`. If frozen builds ever ship, they belong on a
3081
- # releases page, not behind a control that fails silently when the page
3082
- # for this revision was never built.
3083
 
3084
- # Component graph --------------------------------------------------
 
 
3085
 
3086
  def build(self) -> gr.Blocks:
3087
  # ``head`` belongs to launch() in Gradio 6, not to the constructor; the
@@ -3109,7 +3386,9 @@ class DistinctUI:
3109
  # delivery; the user holds their own history, and Clear my data
3110
  # erases this copy too.
3111
  browser_state = gr.BrowserState(
3112
- default_value=None, storage_key="distinct-transcript"
 
 
3113
  )
3114
  # One artwork per page load. Chosen here, once, and handed to both
3115
  # the splash and the in-app credit, so the two can never show
@@ -3225,6 +3504,21 @@ class DistinctUI:
3225
  container=False,
3226
  elem_classes="c-convlist",
3227
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3228
 
3229
  with gr.Column(elem_classes="c-side", min_width=0):
3230
  gr.HTML(render.section_heading("Run setup"))
@@ -3492,29 +3786,25 @@ class DistinctUI:
3492
  # is Python and always was; the panel above now says
3493
  # so, and the only control left is the one that mints
3494
  # the code those instructions need.
3495
- # NO BUTTON TO ASK FOR THE CODE. IT IS THERE WHEN YOU LOOK.
3496
  #
3497
- # Nobody opens this panel for any reason other than to
3498
- # attach a worker, so making them press a button to
3499
- # request the one thing the panel exists to give was
3500
- # ceremony rather than consent. Opening the section
3501
- # mints it, the way a router has its password printed
3502
- # on the box instead of a button to request one.
 
 
 
 
3503
  #
3504
- # It stays SERVER-issued, and that part is not
3505
- # ceremony: this code is what proves an arriving
3506
- # worker was invited by whoever owns this server. A
3507
- # code the worker invented would prove nothing, and
3508
- # anyone could then attach a machine to anyone's
3509
- # server. The code the *worker* makes is the other
3510
- # one — the access code it prints once paired, which
3511
- # is what you hand to the people you want to let use
3512
- # your machine, and which any number of them can
3513
- # redeem.
3514
- refresh_code = gr.Button(
3515
- "New code", size="sm", variant="secondary", scale=0,
3516
- elem_classes="c-join__refresh",
3517
- )
3518
 
3519
  with gr.Accordion("Model assessments and their sources", open=False):
3520
  gr.HTML(
@@ -3676,6 +3966,23 @@ class DistinctUI:
3676
  api_visibility="private",
3677
  js=_scroll_top,
3678
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3679
  new_conversation_button.click(
3680
  self.new_conversation,
3681
  inputs=[session_state],
@@ -3721,7 +4028,12 @@ class DistinctUI:
3721
  for component in (model, tools, mode):
3722
  component.input(
3723
  self.refresh_agents,
3724
- inputs=[model, tools, agent, mode],
 
 
 
 
 
3725
  outputs=[agent_table, agent, tools, library_note],
3726
  queue=False,
3727
  api_visibility="private",
@@ -3821,14 +4133,10 @@ class DistinctUI:
3821
  inputs=[session_state],
3822
  # Into the panel, so the address and the code appear in one
3823
  # command rather than in two places the reader has to join up.
3824
- outputs=[join_panel],
3825
- queue=False,
3826
- api_visibility="private",
3827
- )
3828
- refresh_code.click(
3829
- self.pairing_code,
3830
- inputs=[session_state],
3831
- outputs=[join_panel],
3832
  queue=False,
3833
  api_visibility="private",
3834
  )
@@ -3837,24 +4145,30 @@ class DistinctUI:
3837
  # every five seconds reads as a stall. The refresh is in-memory
3838
  # and unqueued; the worker's own poll cadence is unchanged.
3839
  timer = gr.Timer(2, active=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3840
  timer.tick(
3841
  self.refresh_session,
3842
  inputs=[session_state, model, tools, agent, mode],
3843
- outputs=[
3844
- session_state,
3845
- browser_state,
3846
- chat,
3847
- run_status,
3848
- energy_total,
3849
- agent_table,
3850
- agent,
3851
- tools,
3852
- library_note,
3853
- outputs_files,
3854
- activity_panel,
3855
- conversation,
3856
- rail_tones,
3857
- ],
3858
  queue=False,
3859
  api_visibility="private",
3860
  )
 
3
  from __future__ import annotations
4
 
5
  import json
6
+ import logging
7
  import math
8
  import os
9
  import time
 
66
  from .ui_state import (
67
  active_conversation,
68
  add_conversation,
69
+ adopt_saved_state,
70
  append_job,
71
  conversation_choices,
72
  conversation_turns,
73
  new_session,
74
  record_answer,
75
+ remove_conversation,
76
  select_conversation,
77
  )
78
 
79
+ _LOG = logging.getLogger(__name__)
80
+
81
  # The composer's library choices are not a constant. They are derived, per
82
  # refresh, from what the connected agents actually advertise, by the same
83
  # route the model dropdown and the agent filter already use. A hardcoded empty
 
92
  "the model on its own."
93
  )
94
 
95
+ #: What obfuscates the transcript in localStorage, and why it is written down.
96
+ #:
97
+ #: `gr.BrowserState` encrypts what it stores, and left to itself it invents a
98
+ #: sixteen-character key at start-up — which it then ships to the browser in
99
+ #: the page's own configuration, where anybody can read it. So the key is not
100
+ #: a secret and never was; what it is is a *version stamp on the stored data*,
101
+ #: and a random one means a new stamp on every process.
102
+ #:
103
+ #: The consequence was the whole of the persistence this product promises.
104
+ #: Every restart of the Space — every push, every wake from sleep — made the
105
+ #: saved transcript undecryptable, so gradio logged "Error reading from
106
+ #: localStorage" to the console and silently replaced the user's entire
107
+ #: history with the default value. The privacy notice says the user holds
108
+ #: their own history; in practice they held it until the next deploy.
109
+ #:
110
+ #: A written-down constant is the honest form of a key that is published in
111
+ #: the page anyway, and it is what makes the stored copy survive a restart.
112
+ #: The suffix is a data-format version: change it only to deliberately
113
+ #: abandon every transcript in every browser, which
114
+ #: `ui_state.adopt_saved_state` exists so that a shape change never needs.
115
+ BROWSER_STATE_SECRET = "distinct-transcript-v1"
116
+
117
  #: What an empty transcript says. A module constant rather than a literal
118
  #: buried in the layout, because it is the only instruction on a page that has
119
  #: nothing else on it, and because the previous version pointed at the wrong
 
1132
  "checkpoint is not separately costed in the source."
1133
  ),
1134
  },
 
 
 
 
 
 
 
1135
  }
1136
 
1137
 
 
1814
 
1815
  # Authentication/session helpers ----------------------------------
1816
 
1817
+ def _viewer(
1818
  self,
 
1819
  profile: Optional[Mapping[str, Any]],
1820
  request: Optional[Any] = None,
1821
  ) -> str:
 
1835
  user_id = _identity_from_request(request)
1836
  if user_id is None:
1837
  user_id = user_id_from_profile(profile)
1838
+ return user_id
1839
+
1840
+ def _authorize(
1841
+ self,
1842
+ state: Mapping[str, Any],
1843
+ profile: Optional[Mapping[str, Any]],
1844
+ request: Optional[Any] = None,
1845
+ ) -> str:
1846
+ """Who is asking, and this browser's session made real on the server.
1847
+
1848
+ A HANDLER WITH NO SESSION STATE MUST NOT INVENT ONE.
1849
+
1850
+ This used to pass `str(state.get("session_id", ""))` straight to
1851
+ `create_session`, and that call reads an empty id as "mint me a new
1852
+ one". `refresh_agents` is wired without the session state, so its
1853
+ `state` is always `{}` — which meant every change of the model
1854
+ dropdown, every tick of a library box and every change of mode minted
1855
+ a fresh server-side session that nothing would ever delete, since
1856
+ `cleanup_expired` does not reap sessions.
1857
+
1858
+ Two thousand and forty-eight of those and the registry is full. The
1859
+ failure is not local to the handler that filled it: `create_session`
1860
+ then raises `CapacityError` for *every* caller of this method, so
1861
+ `load_session` fails on every page load and the two-second timer fails
1862
+ on every tick, for everybody on the server at once. That is the
1863
+ "random errors" this was hunted for — it takes a while to build up and
1864
+ then it arrives everywhere.
1865
+
1866
+ Identity does not need a session, so a caller without one gets the
1867
+ identity and nothing else is touched.
1868
+ """
1869
+
1870
+ state = _as_mapping(state)
1871
+ user_id = self._viewer(profile, request)
1872
+ session_id = str(state.get("session_id", "") or "")
1873
+ if not session_id:
1874
+ return user_id
1875
  self.sessions.bind(session_id, user_id)
1876
  self.control.create_session(session_id)
1877
+ for conversation_id in state.get("conversations") or {}:
1878
  self.control.create_conversation(session_id, str(conversation_id))
1879
  return user_id
1880
 
 
1904
  # the session and conversations server-side (in _authorize) and keeps
1905
  # every answer the browser already holds. This is the persistence the
1906
  # privacy notice promises: the user holds their own history.
1907
+ #
1908
+ # Rebuilt rather than believed. See `ui_state.adopt_saved_state`: the
1909
+ # four top-level checks that used to stand here trusted everything one
1910
+ # level down, and a transcript written by an older build could make
1911
+ # this handler raise on every page load.
1912
+ #
1913
+ # The live state is put through the same rebuild, and a fresh session
1914
+ # is the last resort. This is the one handler that runs before any
1915
+ # other, so the shape it settles on is the shape every handler after
1916
+ # it can rely on — which is why none of them repeat this work.
1917
+ state = adopt_saved_state(saved_state) or adopt_saved_state(state) or new_session()
1918
  updated = dict(state)
1919
  messages: list[dict[str, str]] = []
1920
  energy = _session_energy_markdown(())
 
1940
  )
1941
  activity = gr.HTML(value="", visible=False)
1942
  user_id = None
1943
+ except (ControlPlaneError, ValueError) as exc:
1944
+ # THE PAGE HAS TO OPEN.
1945
+ #
1946
+ # This runs on every load, and anything that escapes it is a red
1947
+ # toast on arrival with no control to press and no way to guess
1948
+ # what went wrong. The one that actually happened was
1949
+ # `CapacityError: session registry is full`, which is a fact about
1950
+ # the server and not about this visitor, and it greeted every
1951
+ # visitor at once. The session is left un-materialised, the
1952
+ # transcript still draws from the browser's own copy, and the
1953
+ # reason goes to the log where somebody can act on it.
1954
+ _LOG.exception("load_session could not prepare this session")
1955
+ identity = _identity_html("")
1956
+ status = _status_html(
1957
+ "This server could not open a session just now. Your "
1958
+ f"conversation is safe in this browser. Reason: {exc}"
1959
+ )
1960
+ activity = gr.HTML(value="", visible=False)
1961
+ user_id = None
1962
  states = self._conversation_states(updated)
1963
  choices = conversation_choices(updated, states)
1964
  selected = updated["active_conversation_id"]
 
2276
  selected_agent: str,
2277
  mode: str = MODE_SIMPLE,
2278
  state: Optional[Mapping[str, Any]] = None,
2279
+ profile: "gr.OAuthProfile | None" = None,
2280
  request: "gr.Request | None" = None,
2281
  ) -> tuple:
2282
  # Declared, because it is used two lines down. It was not, and the
2283
  # handler raised NameError on every change of the model dropdown, the
2284
  # library checkboxes or the mode: a live crash on three of the most
2285
  # common interactions in the interface, in a handler no test called.
2286
+ #
2287
+ # `profile` was annotated `Optional[Mapping[str, Any]]`, and Gradio
2288
+ # injects a profile only into a parameter annotated with its own
2289
+ # `OAuthProfile`. On a Space that is the *only* way anybody is
2290
+ # identified, so this handler saw a signed-out visitor no matter who
2291
+ # was using it, and the worker table emptied itself every time
2292
+ # somebody picked a model. The annotation is now the one Gradio
2293
+ # matches on, and the session state is wired in beside it, so this
2294
+ # authorises against the same session as every other handler instead
2295
+ # of against `{}`.
2296
  #
2297
  # Re-checked here rather than trusted from the last render. A hidden
2298
  # component is still callable through the API, so every handler that
 
2345
  except (ControlPlaneError, LoginRequired, ValueError) as exc:
2346
  raise gr.Error(str(exc)) from exc
2347
 
2348
+ def delete_conversation(
2349
+ self,
2350
+ state: Mapping[str, Any],
2351
+ profile: gr.OAuthProfile | None,
2352
+ request: gr.Request | None = None,
2353
+ ) -> tuple:
2354
+ """Erase the conversation on screen, here and on the server.
2355
+
2356
+ There was no way to do this at all: a conversation could be started and
2357
+ never removed, so the rail only ever grew, and "Clear my data" — the one
2358
+ control that erased anything — took the whole session with it. Wanting
2359
+ one exchange gone is not the same as wanting everything gone.
2360
+
2361
+ No confirmation step. What it erases is already erasable by design and
2362
+ gone from the server within a run's lifetime anyway, and a dialog in
2363
+ front of every deletion is how people learn to dismiss dialogs. The row
2364
+ disappearing is the feedback.
2365
+ """
2366
+
2367
+ try:
2368
+ self._authorize(state, profile, request)
2369
+ removed = str(state.get("active_conversation_id") or "")
2370
+ updated = remove_conversation(state, removed)
2371
+ # The browser's copy is gone; tell the server so anything it still
2372
+ # holds for that conversation goes with it rather than ageing out.
2373
+ try:
2374
+ self.control.delete_conversation(updated["session_id"], removed)
2375
+ except (ControlPlaneError, ValueError):
2376
+ # It may already have gone: a conversation whose runs all
2377
+ # finished is cleaned up on its own. Nothing here depends on
2378
+ # it still being there.
2379
+ pass
2380
+ if removed in updated["conversations"]:
2381
+ # The last one was emptied rather than removed, so the server
2382
+ # needs it to exist again for the next run in it.
2383
+ self.control.create_conversation(updated["session_id"], removed)
2384
+ updated, messages, status, energy, activity = self._render(updated)
2385
+ states = self._conversation_states(updated)
2386
+ return (
2387
+ updated,
2388
+ updated,
2389
+ gr.Radio(
2390
+ choices=conversation_choices(updated, states),
2391
+ value=updated["active_conversation_id"],
2392
+ ),
2393
+ render.conversation_tones(states),
2394
+ messages,
2395
+ _status_html("Conversation deleted."),
2396
+ energy,
2397
+ activity,
2398
+ self._outputs_component(updated),
2399
+ )
2400
+ except (ControlPlaneError, LoginRequired, ValueError) as exc:
2401
+ raise gr.Error(str(exc)) from exc
2402
+
2403
  def change_conversation(
2404
  self,
2405
  state: Mapping[str, Any],
 
2500
  except (ControlPlaneError, LoginRequired, ValueError) as exc:
2501
  raise gr.Error(str(exc)) from exc
2502
 
2503
+ #: One `gr.skip()` per output the timer is wired to, for a tick that could
2504
+ #: not run at all. Asserted against the wiring in :meth:`build`, because a
2505
+ #: length that drifts from the outputs list would make the safety net
2506
+ #: itself the error it exists to prevent.
2507
+ TIMER_OUTPUTS = 13
2508
+
2509
  def refresh_session(
2510
  self,
2511
  state: Mapping[str, Any],
 
2515
  mode: str,
2516
  profile: gr.OAuthProfile | None,
2517
  request: gr.Request | None = None,
2518
+ ) -> tuple:
2519
+ """One tick of the two-second refresh, which can never fail loudly.
2520
+
2521
+ Everything below degrades in place: a failure inside the render is
2522
+ caught where it happens and the rest of the tick still runs. This
2523
+ outermost guard is for the failure nobody predicted, and it is a bare
2524
+ `Exception` on purpose. A user-initiated handler should be loud,
2525
+ because somebody is standing there having just pressed something and a
2526
+ toast tells them what happened. Nobody presses this. It runs unwatched
2527
+ thirty times a minute in every open tab, so a raise here is not an
2528
+ error message, it is an error message repeating forever, and the
2529
+ person reading it did nothing to cause it and can do nothing about it.
2530
+ """
2531
+
2532
+ try:
2533
+ return self._refresh_session(
2534
+ state, model_id, tool_values, selected_agent, mode, profile, request
2535
+ )
2536
+ except Exception: # noqa: BLE001 - see the docstring; the log keeps it visible
2537
+ _LOG.exception("refresh_session skipped a tick")
2538
+ return tuple(gr.skip() for _ in range(self.TIMER_OUTPUTS))
2539
+
2540
+ def _refresh_session(
2541
+ self,
2542
+ state: Mapping[str, Any],
2543
+ model_id: str,
2544
+ tool_values: Sequence[str],
2545
+ selected_agent: str,
2546
+ mode: str,
2547
+ profile: gr.OAuthProfile | None,
2548
+ request: gr.Request | None = None,
2549
  ) -> tuple:
2550
  updated = dict(state)
2551
  try:
 
2557
  messages, status = [], _status_html("Sign in to run a model.")
2558
  energy = _session_energy_html(())
2559
  activity = gr.HTML(value="", visible=False)
2560
+ except (ControlPlaneError, ValueError):
2561
+ # THE TRANSCRIPT IS LEFT ALONE, AND THE REST OF THE TICK RUNS.
2562
+ #
2563
+ # `LoginRequired` was caught here and `ControlPlaneError` was not,
2564
+ # and the one that actually arrived was `CapacityError: session
2565
+ # registry is full` from inside `_authorize` — a fact about the
2566
+ # server, raised for every user of it at the same instant, on a
2567
+ # handler that fires every two seconds. That is the shape of the
2568
+ # "random errors" this was hunted for.
2569
+ #
2570
+ # Skipping these four outputs leaves what is on screen exactly as
2571
+ # it is, which is right: the transcript is the browser's own copy
2572
+ # and the server has nothing to add this tick. The worker table
2573
+ # below still refreshes, so a server that recovers is visible
2574
+ # without a reload.
2575
+ _LOG.exception("refresh_session could not render this tick")
2576
+ viewer_id = None
2577
+ messages, status = gr.skip(), gr.skip()
2578
+ energy = gr.skip()
2579
+ activity = gr.skip()
2580
  rows, choices, value, narrowed = self._agent_components(
2581
  model_id, tool_values, selected_agent, mode, viewer_id=viewer_id
2582
  )
 
2690
  A signed-out caller now gets a fresh blank state and touches nothing,
2691
  which is the honest answer to "erase my data" from somebody who has no
2692
  data here.
2693
+
2694
+ **"Touches nothing" was still a sentence rather than the code.** The
2695
+ authorisation was added and the `create_session` under it was left
2696
+ unconditional, so the second half of the hole described above was
2697
+ never actually closed: a signed-out caller looping this handler filled
2698
+ the session registry exactly as before, and a full registry makes
2699
+ every other handler on the server raise. The fresh session is now
2700
+ materialised only for somebody the server knows, and a signed-out
2701
+ caller gets the blank state and no server-side trace at all.
2702
  """
2703
 
2704
  try:
 
2708
  )
2709
  except LoginRequired:
2710
  session_id = ""
2711
+ fresh = new_session()
2712
  if session_id:
2713
  self.sessions.release(session_id)
2714
  self.control.delete_session(session_id)
2715
  self._delete_outputs(session_id)
2716
+ # One out, one in. The replacement is materialised only when
2717
+ # there was something to replace, so this handler cannot add a
2718
+ # session to the registry however many times it is called — the
2719
+ # hole the docstring above describes and the code left open.
2720
+ # A caller with no session gets the blank state and no
2721
+ # server-side trace, and the next timer tick creates the session
2722
+ # for them if they ever need one.
2723
+ self.control.create_session(fresh["session_id"])
2724
+ for conversation_id in fresh["conversations"]:
2725
+ self.control.create_conversation(fresh["session_id"], conversation_id)
2726
  library, library_note = self._library_components(())
2727
  fresh_states = self._conversation_states(fresh)
2728
  return (
 
3279
  state: Mapping[str, Any],
3280
  profile: gr.OAuthProfile | None,
3281
  request: gr.Request | None = None,
3282
+ *,
3283
+ force: bool = False,
3284
+ ) -> tuple:
3285
+ """The code for this browser: the one it already has, or a new one.
3286
+
3287
+ MINTING ON EVERY OPEN WAS BOTH A BUG AND AN OUTAGE.
3288
+
3289
+ The bug: a person opens the panel, copies the code, opens it again to
3290
+ re-read the command, and is now looking at a different code from the
3291
+ one in their clipboard.
3292
+
3293
+ The outage: opening a section is cheap and scriptable, the pool of
3294
+ live codes is shared by everybody on this server, and they last ten
3295
+ minutes — so a loop over this handler fills the pool and stops anyone
3296
+ else pairing a worker. The control plane now caps codes per owner as
3297
+ well, which contains the damage; not minting one that nobody asked
3298
+ for is what stops it starting.
3299
+
3300
+ The plaintext lives in this browser's own state and nowhere else. The
3301
+ server keeps only a digest, deliberately, so this is the sole copy —
3302
+ which is exactly the right place for it, since this browser is the
3303
+ only party entitled to read it.
3304
+
3305
+ OPENING A SECTION IS NAVIGATION, SO IT CANNOT FAIL.
3306
+
3307
+ This raised `gr.Error`, and the accordion that opens the panel is
3308
+ wired straight to it, so a signed-out visitor who clicked "Run a
3309
+ community agent" — the ordinary way to find out what running one
3310
+ involves — got a red toast and a five hundred in the log for reading
3311
+ a page. The other way in was the per-owner cap: eight live codes is
3312
+ eight opens in a browser with no saved code, and the ninth open
3313
+ refused with a toast rather than a sentence.
3314
+
3315
+ Nothing here is worth a toast. The panel already reads correctly with
3316
+ no code in it — it is four steps, and the code belongs to one of them —
3317
+ so the reason a code was not minted is written into the panel, where
3318
+ the person is already looking, and the section opens either way.
3319
+ """
3320
+
3321
+ updated = dict(state) if isinstance(state, Mapping) else {}
3322
  try:
3323
  user_id = self._authorize(state, profile, request)
3324
+ except LoginRequired:
3325
+ return (
3326
+ updated,
3327
+ updated,
3328
+ _status_html(
3329
+ "Sign in with Hugging Face to get a pairing code. The rest of "
3330
+ "the setup is the same either way, so it is all here to read "
3331
+ "first."
3332
+ )
3333
+ + self._join_panel("", updated),
3334
+ )
3335
+ existing = str(updated.get("pairing_code") or "")
3336
+ expires = updated.get("pairing_expires")
3337
+ alive = (
3338
+ existing
3339
+ and isinstance(expires, (int, float))
3340
+ # A minute's margin: a code that expires while somebody is
3341
+ # pasting it is worse than one re-minted slightly early.
3342
+ and expires - 60 > time.time()
3343
+ )
3344
+ if alive and not force:
3345
+ return updated, updated, self._join_panel(existing, updated)
3346
+ try:
3347
  value = self.control.create_pairing_code(user_id)
3348
+ except (ControlPlaneError, ValueError) as exc:
3349
+ return (
3350
+ updated,
3351
+ updated,
3352
+ _status_html(f"No new pairing code just now: {exc}")
3353
+ + self._join_panel(existing, updated),
3354
+ )
3355
+ updated["pairing_code"] = value.code
3356
+ updated["pairing_expires"] = value.expires_at
3357
+ return updated, updated, self._join_panel(value.code, updated)
3358
 
3359
+ # `new_pairing_code` lived here to serve a "New code" button that no
3360
+ # longer exists. Opening the panel mints one when the held code has
3361
+ # expired, which is the only moment a second one was ever wanted.
3362
 
3363
  def build(self) -> gr.Blocks:
3364
  # ``head`` belongs to launch() in Gradio 6, not to the constructor; the
 
3386
  # delivery; the user holds their own history, and Clear my data
3387
  # erases this copy too.
3388
  browser_state = gr.BrowserState(
3389
+ default_value=None,
3390
+ storage_key="distinct-transcript",
3391
+ secret=BROWSER_STATE_SECRET,
3392
  )
3393
  # One artwork per page load. Chosen here, once, and handed to both
3394
  # the splash and the in-app credit, so the two can never show
 
3504
  container=False,
3505
  elem_classes="c-convlist",
3506
  )
3507
+ # DELETING THE ONE YOU ARE LOOKING AT.
3508
+ #
3509
+ # A per-row control would be the obvious design and
3510
+ # is not available: the list is a Radio, and a radio
3511
+ # group gives no per-option hook to hang a button
3512
+ # on. One control acting on the selected
3513
+ # conversation is the same gesture in two steps, and
3514
+ # it is the step order people already use — you open
3515
+ # a conversation, then decide it can go.
3516
+ delete_conversation_button = gr.Button(
3517
+ "Delete this conversation",
3518
+ size="sm",
3519
+ variant="secondary",
3520
+ elem_classes="c-rail__delete",
3521
+ )
3522
 
3523
  with gr.Column(elem_classes="c-side", min_width=0):
3524
  gr.HTML(render.section_heading("Run setup"))
 
3786
  # is Python and always was; the panel above now says
3787
  # so, and the only control left is the one that mints
3788
  # the code those instructions need.
3789
+ # NO BUTTON IN HERE AT ALL.
3790
  #
3791
+ # There were two, and both were ceremony. "Generate
3792
+ # pairing code" asked a person to request the one thing
3793
+ # the panel exists to give, and nobody opens this panel
3794
+ # for another reason. "New code" survived it for a
3795
+ # while on the theory that somebody would want to
3796
+ # replace a code that had expired or been used — but
3797
+ # opening the panel already does exactly that: the code
3798
+ # is reused while it is alive and reminted when it is
3799
+ # not, so the button could only ever mint one that was
3800
+ # about to be minted anyway.
3801
  #
3802
+ # The code stays SERVER-issued, which is not ceremony:
3803
+ # it is what proves an arriving worker was invited by
3804
+ # whoever owns this server. The code the *worker*
3805
+ # makes is the other one the access code it prints
3806
+ # once paired, which is what you give to the people you
3807
+ # want to let use your machine.
 
 
 
 
 
 
 
 
3808
 
3809
  with gr.Accordion("Model assessments and their sources", open=False):
3810
  gr.HTML(
 
3966
  api_visibility="private",
3967
  js=_scroll_top,
3968
  )
3969
+ delete_conversation_button.click(
3970
+ self.delete_conversation,
3971
+ inputs=[session_state],
3972
+ outputs=[
3973
+ session_state,
3974
+ browser_state,
3975
+ conversation,
3976
+ rail_tones,
3977
+ chat,
3978
+ run_status,
3979
+ energy_total,
3980
+ activity_panel,
3981
+ outputs_files,
3982
+ ],
3983
+ queue=False,
3984
+ api_visibility="private",
3985
+ )
3986
  new_conversation_button.click(
3987
  self.new_conversation,
3988
  inputs=[session_state],
 
4028
  for component in (model, tools, mode):
4029
  component.input(
4030
  self.refresh_agents,
4031
+ # The session travels with it. Without it the handler
4032
+ # authorised against an empty state, and an empty session
4033
+ # id is the one `create_session` reads as "mint a new
4034
+ # one" — one leaked session per model change, until the
4035
+ # registry filled and the whole server started erroring.
4036
+ inputs=[model, tools, agent, mode, session_state],
4037
  outputs=[agent_table, agent, tools, library_note],
4038
  queue=False,
4039
  api_visibility="private",
 
4133
  inputs=[session_state],
4134
  # Into the panel, so the address and the code appear in one
4135
  # command rather than in two places the reader has to join up.
4136
+ # The state travels with it because the code itself is kept
4137
+ # in this browser: the server has only a digest, so re-opening
4138
+ # can show the same code rather than mint another.
4139
+ outputs=[session_state, browser_state, join_panel],
 
 
 
 
4140
  queue=False,
4141
  api_visibility="private",
4142
  )
 
4145
  # every five seconds reads as a stall. The refresh is in-memory
4146
  # and unqueued; the worker's own poll cadence is unchanged.
4147
  timer = gr.Timer(2, active=True)
4148
+ timer_outputs = [
4149
+ session_state,
4150
+ browser_state,
4151
+ chat,
4152
+ run_status,
4153
+ energy_total,
4154
+ agent_table,
4155
+ agent,
4156
+ tools,
4157
+ library_note,
4158
+ outputs_files,
4159
+ activity_panel,
4160
+ conversation,
4161
+ rail_tones,
4162
+ ]
4163
+ # A skipped tick returns one `gr.skip()` per output, and a count
4164
+ # that disagreed with this list would raise inside the guard whose
4165
+ # whole job is to stop the timer raising. Checked here, where both
4166
+ # halves are visible, rather than trusted to stay in step.
4167
+ assert len(timer_outputs) == self.TIMER_OUTPUTS
4168
  timer.tick(
4169
  self.refresh_session,
4170
  inputs=[session_state, model, tools, agent, mode],
4171
+ outputs=timer_outputs,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4172
  queue=False,
4173
  api_visibility="private",
4174
  )
distinct_server/ui_state.py CHANGED
@@ -42,6 +42,167 @@ def new_session() -> Dict[str, Any]:
42
  }
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def _validated_copy(state: Mapping[str, Any]) -> Dict[str, Any]:
46
  value = copy.deepcopy(dict(state))
47
  if not isinstance(value.get("session_id"), str):
@@ -70,6 +231,43 @@ def select_conversation(state: Mapping[str, Any], conversation_id: str) -> Dict[
70
  return value
71
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  def append_job(
74
  state: Mapping[str, Any],
75
  *,
 
42
  }
43
 
44
 
45
+ #: The longest identifier the control plane will accept. Anything longer is
46
+ #: refused there with a `ValidationError`, and a browser holding one would
47
+ #: turn every page load into a five-hundred.
48
+ _MAX_ID = 128
49
+
50
+
51
+ def _identifier(value: Any) -> str:
52
+ """``value`` if it can be a session or conversation id, else empty."""
53
+
54
+ if not isinstance(value, str):
55
+ return ""
56
+ text = value.strip()
57
+ if not text or len(text) > _MAX_ID or any(c in text for c in "\r\n"):
58
+ return ""
59
+ return text
60
+
61
+
62
+ def _string_list(value: Any, *, limit: int) -> List[str]:
63
+ if not isinstance(value, (list, tuple)):
64
+ return []
65
+ return [str(item) for item in value if isinstance(item, str)][:limit]
66
+
67
+
68
+ def _mapping_list(value: Any, *, limit: int) -> List[Dict[str, Any]]:
69
+ if not isinstance(value, (list, tuple)):
70
+ return []
71
+ return [dict(item) for item in value if isinstance(item, Mapping)][:limit]
72
+
73
+
74
+ #: How much of one browser's history is worth carrying forward. A transcript
75
+ #: is per-browser and unbounded otherwise, and the whole of it is re-rendered
76
+ #: on a two-second timer.
77
+ _MAX_CONVERSATIONS = 128
78
+ _MAX_TURNS = 512
79
+
80
+
81
+ def _number(value: Any) -> float | None:
82
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
83
+ return None
84
+ return float(value)
85
+
86
+
87
+ def _adopt_answer(value: Any) -> Dict[str, Any] | None:
88
+ """One kept answer, rebuilt to the shape `DistinctUI._answer_payload` writes.
89
+
90
+ Every field is here, and each is the type the renderer indexes into: the
91
+ receipt walks `trace` and `steps` as mappings, the transcript walks `made`
92
+ as mappings, and the estimator reads `duration_seconds` as a number. An
93
+ older build that spelled any of them differently turned the transcript
94
+ into a `TypeError` on every render — which is to say on every page load
95
+ and every timer tick. Anything not listed here is dropped, because a key
96
+ nothing reads is a key nothing can be broken by.
97
+ """
98
+
99
+ if not isinstance(value, Mapping):
100
+ return None
101
+ answer: Dict[str, Any] = {
102
+ "output": str(value.get("output", "")),
103
+ "agent": str(value.get("agent", "")),
104
+ "model": str(value.get("model", "")),
105
+ "mode": str(value.get("mode", "")),
106
+ "tools": [item for item in _mapping_list(value.get("tools"), limit=64) if "id" in item],
107
+ "steps": _mapping_list(value.get("steps"), limit=64),
108
+ "trace": _mapping_list(value.get("trace"), limit=64),
109
+ "made": _mapping_list(value.get("made"), limit=32),
110
+ "artifacts": _string_list(value.get("artifacts"), limit=64),
111
+ "energy": dict(value["energy"]) if isinstance(value.get("energy"), Mapping) else {},
112
+ "guard": dict(value["guard"]) if isinstance(value.get("guard"), Mapping) else {},
113
+ "tool_calls": int(_number(value.get("tool_calls")) or 0),
114
+ }
115
+ duration = _number(value.get("duration_seconds"))
116
+ if duration is not None:
117
+ answer["duration_seconds"] = duration
118
+ return answer
119
+
120
+
121
+ def adopt_saved_state(saved: Any) -> Dict[str, Any] | None:
122
+ """Today's shape, rebuilt from whatever an older build left in the browser.
123
+
124
+ THE BROWSER'S COPY IS INPUT, NOT STATE.
125
+
126
+ It is JSON written by some earlier version of this file, kept in
127
+ localStorage across every deploy since, and editable by anyone with a
128
+ developer console. `load_session` used to check four facts about the top
129
+ level of it — an id, a conversations mapping, an active id inside that
130
+ mapping — and then hand the rest straight to the renderer. Everything one
131
+ level down was trusted: a conversation that was a string, a conversation
132
+ with no `title`, an answer that was a string, an answer whose `made` was
133
+ not a list. Each of those raised out of `load_session`, which runs on
134
+ every single page load, so the app opened on a red error toast and stayed
135
+ that way until the person cleared their site data — and they had no way
136
+ to know that was the fix.
137
+
138
+ So the saved copy is rebuilt here field by field instead of inspected.
139
+ What cannot be read is dropped rather than carried, and a transcript with
140
+ nothing readable left in it returns None, which the caller reads as "start
141
+ fresh". Dropping an unreadable turn loses something; refusing to load the
142
+ page loses everything.
143
+ """
144
+
145
+ if not isinstance(saved, Mapping):
146
+ return None
147
+ conversations: Dict[str, Any] = {}
148
+ raw = saved.get("conversations")
149
+ if not isinstance(raw, Mapping):
150
+ return None
151
+ for key, value in list(raw.items())[:_MAX_CONVERSATIONS]:
152
+ conversation_id = _identifier(key)
153
+ if not conversation_id or not isinstance(value, Mapping):
154
+ continue
155
+ job_ids = _string_list(value.get("job_ids"), limit=_MAX_TURNS)
156
+ prompts = _string_list(value.get("prompts"), limit=_MAX_TURNS)
157
+ # Padded rather than trimmed to the shorter of the two. A prompt list
158
+ # that fell behind its job list is an older bug's leftovers, and the
159
+ # turn is still worth showing with an empty question attached.
160
+ prompts += [""] * max(0, len(job_ids) - len(prompts))
161
+ answers: Dict[str, Any] = {}
162
+ raw_answers = value.get("answers")
163
+ if isinstance(raw_answers, Mapping):
164
+ for job_id, answer in raw_answers.items():
165
+ if not isinstance(job_id, str) or job_id not in job_ids:
166
+ continue
167
+ adopted = _adopt_answer(answer)
168
+ if adopted is not None:
169
+ answers[job_id] = adopted
170
+ title = value.get("title")
171
+ conversations[conversation_id] = {
172
+ "title": str(title) if isinstance(title, str) and title.strip() else UNTITLED,
173
+ "job_ids": job_ids,
174
+ "prompts": prompts[: len(job_ids)],
175
+ "answers": answers,
176
+ }
177
+ if not conversations:
178
+ return None
179
+ active = _identifier(saved.get("active_conversation_id"))
180
+ if active not in conversations:
181
+ active = next(iter(conversations))
182
+ state: Dict[str, Any] = {
183
+ # A session id this server would refuse is worse than no session id:
184
+ # it fails at the control plane, inside the handler, rather than here.
185
+ "session_id": _identifier(saved.get("session_id")) or _id("session"),
186
+ "active_conversation_id": active,
187
+ "conversations": conversations,
188
+ "created_at": saved.get("created_at")
189
+ if isinstance(saved.get("created_at"), (int, float))
190
+ else time.time(),
191
+ }
192
+ defaults = _string_list(saved.get("library_defaults"), limit=256)
193
+ if defaults:
194
+ state["library_defaults"] = defaults
195
+ # The pairing code is browser-owned and worth keeping: dropping it makes
196
+ # the next open of the setup panel mint another, and a person only gets
197
+ # eight live codes before the control plane refuses.
198
+ code = saved.get("pairing_code")
199
+ expires = saved.get("pairing_expires")
200
+ if isinstance(code, str) and code and isinstance(expires, (int, float)):
201
+ state["pairing_code"] = code
202
+ state["pairing_expires"] = float(expires)
203
+ return state
204
+
205
+
206
  def _validated_copy(state: Mapping[str, Any]) -> Dict[str, Any]:
207
  value = copy.deepcopy(dict(state))
208
  if not isinstance(value.get("session_id"), str):
 
231
  return value
232
 
233
 
234
+ def remove_conversation(state: Mapping[str, Any], conversation_id: str) -> Dict[str, Any]:
235
+ """Drop a conversation and everything it held, choosing the next active one.
236
+
237
+ THE LAST ONE IS EMPTIED RATHER THAN REMOVED. A session with no conversation
238
+ has no valid ``active_conversation_id``, and every renderer downstream reads
239
+ that key; deleting the only one would leave the state in a shape the rest of
240
+ this module is entitled to assume cannot happen. Emptying it gives the
241
+ person what they asked for — the transcript is gone — without inventing a
242
+ state nothing else can render.
243
+
244
+ The jobs are dropped from this state, which is the browser's copy and the
245
+ only durable one: the server erases a prompt when its run ends and the
246
+ answer once it has been collected. The caller is responsible for telling
247
+ the control plane, which owns anything still in flight.
248
+ """
249
+
250
+ value = _validated_copy(state)
251
+ if conversation_id not in value["conversations"]:
252
+ raise ValueError("conversation does not belong to this session")
253
+
254
+ if len(value["conversations"]) == 1:
255
+ fresh = _new_conversation()
256
+ value["conversations"] = {conversation_id: fresh}
257
+ value["active_conversation_id"] = conversation_id
258
+ return value
259
+
260
+ order = list(value["conversations"])
261
+ position = order.index(conversation_id)
262
+ del value["conversations"][conversation_id]
263
+ if value["active_conversation_id"] == conversation_id:
264
+ # The neighbour, preferring the one above, which is where the eye
265
+ # already is after a row disappears.
266
+ remaining = list(value["conversations"])
267
+ value["active_conversation_id"] = remaining[max(0, position - 1)]
268
+ return value
269
+
270
+
271
  def append_job(
272
  state: Mapping[str, Any],
273
  *,
model_assessments.json CHANGED
@@ -13,7 +13,7 @@
13
  "Cradle to release. Never added to, never averaged with, and never compared against the use-phase energy this network measures per run."
14
  ],
15
  "website_display": {
16
- "warning": "These figures are comparable within a lab and rarely between labs. The two Ai2 releases below were measured by the same team, on the same clusters, with the same method and the same boundary, so the difference between them is real. Nothing here is comparable with the Llama 2 figure, which Meta modelled from GPU-hours on different hardware in a different country. Every number covers the final pretraining run only: Ai2's own follow-up work found that development and failed runs accounted for 82.2% of total GPU-hours, so every figure on this page is a floor.",
17
  "further_reading": [
18
  {
19
  "label": "Ai2 and CMU on measuring a model's footprint (ICLR 2025)",
@@ -148,54 +148,6 @@
148
  "url": "https://arxiv.org/abs/2503.05804"
149
  }
150
  ]
151
- },
152
- {
153
- "model_id": "llama-2-7b",
154
- "name": "Llama 2 7B",
155
- "publisher": "Meta",
156
- "covers": "Llama 2 7B pretraining. The Chat checkpoint is a post-trained variant; Meta reports fine-tuning compute inside the family total without breaking it out per checkpoint.",
157
- "coverage": "Partial",
158
- "summary": "Kept because it is the only non-Ai2 release in this catalogue with a published, model-specific figure, and because it shows what a partial disclosure looks like next to a full one. Meta published GPU-hours and a carbon estimate, and nothing else: no energy, no water, no materials. The estimate is modelled from rated chip wattage rather than measured draw, so it is a different kind of number from the two above and should not be lined up against them. Meta also reports a second carbon column reading zero, on the grounds that they buy enough renewable electricity annually to match what they used. That is an accounting position, not a physical one; the figure shown here is the electricity the grid actually delivered.",
159
- "categories": {
160
- "energy": {
161
- "state": "missing",
162
- "note": "Meta disclosed 184,320 A100-80GB GPU-hours at 400 W rated draw, but published no energy figure. Multiplying those out here would be an estimate this catalogue invented, so the area stays Missing and the disclosure is named instead."
163
- },
164
- "climate": {
165
- "state": "reported",
166
- "status": "Modelled",
167
- "value": "31.22",
168
- "unit": "tCO2e",
169
- "note": "Modelled by Meta from GPU-hours and rated device power. Location-based. Meta separately reports the figure as fully offset, which nets to zero on paper but not on the grid.",
170
- "source_url": "https://arxiv.org/abs/2307.09288"
171
- },
172
- "water": {
173
- "state": "missing",
174
- "note": "Not disclosed."
175
- },
176
- "land": {
177
- "state": "missing",
178
- "note": "Not disclosed. No published per-model land figure exists for any model, from any lab."
179
- },
180
- "materials": {
181
- "state": "missing",
182
- "note": "Not disclosed. Embodied hardware impact is absent from Meta's accounting."
183
- },
184
- "pollution": {
185
- "state": "missing",
186
- "note": "Not disclosed."
187
- }
188
- },
189
- "links": [
190
- {
191
- "label": "Model card",
192
- "url": "https://huggingface.co/meta-llama/Llama-2-7b-chat-hf"
193
- },
194
- {
195
- "label": "Published assessment",
196
- "url": "https://arxiv.org/abs/2307.09288"
197
- }
198
- ]
199
  }
200
  ]
201
  }
 
13
  "Cradle to release. Never added to, never averaged with, and never compared against the use-phase energy this network measures per run."
14
  ],
15
  "website_display": {
16
+ "warning": "These figures are comparable within a lab and rarely between labs. The two Ai2 releases below were measured by the same team, on the same clusters, with the same method and the same boundary, so the difference between them is real. Every number covers the final pretraining run only: Ai2's own follow-up work found that development and failed runs accounted for 82.2% of total GPU-hours, so every figure on this page is a floor.",
17
  "further_reading": [
18
  {
19
  "label": "Ai2 and CMU on measuring a model's footprint (ICLR 2025)",
 
148
  "url": "https://arxiv.org/abs/2503.05804"
149
  }
150
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  }
152
  ]
153
  }
pyproject.toml CHANGED
@@ -7,16 +7,24 @@ name = "distinct-server"
7
  version = "0.1.0"
8
  description = "Session-only Gradio control plane for community-run open-weight LLM agents"
9
  requires-python = ">=3.10"
10
- dependencies = ["gradio[oauth]==6.24.0"]
11
-
12
- [project.optional-dependencies]
13
- agent = [
 
 
 
 
 
 
 
 
 
14
  # Ed25519, for the detached snapshot signature. A direct requirement, not
15
  # a transitive one: `distinct_protocol.handshake` imports it and refuses
16
- # to fall back to HMAC, so an agent install that happens to miss it fails
17
- # at the moment it pairs rather than at install time.
18
  "cryptography>=42",
19
- "dspy[deno]==3.3.0",
20
  "gradio_client==2.6.0",
21
  "pyRAPL==0.2.3.1; sys_platform == 'linux'",
22
  # The worker's own screen. Pinned rather than ranged because the frozen
@@ -25,6 +33,14 @@ agent = [
25
  # this repository would catch.
26
  "textual==8.2.8",
27
  ]
 
 
 
 
 
 
 
 
28
  dev = [
29
  "pytest==8.4.2",
30
  "ruff==0.12.11",
 
7
  version = "0.1.0"
8
  description = "Session-only Gradio control plane for community-run open-weight LLM agents"
9
  requires-python = ">=3.10"
10
+ # WHAT A PLAIN `pip install -e .` HAS TO GIVE YOU: A WORKING WORKER.
11
+ #
12
+ # These were all behind the `[agent]` extra, which meant `pip install -e .`
13
+ # produced a worker that paired, ran, and then died the first time somebody
14
+ # asked for its dashboard — an ImportError for a package the install had
15
+ # quietly decided was optional. Optional to whom? Everybody who installs this
16
+ # repository outside the Space is installing it to run a worker.
17
+ #
18
+ # Costing the server nothing is what makes this free: HuggingFace Spaces
19
+ # installs `requirements.txt` and the SDK version in README.md, and never
20
+ # runs `pip install .` at all, so nothing here reaches the deployment.
21
+ dependencies = [
22
+ "gradio[oauth]==6.24.0",
23
  # Ed25519, for the detached snapshot signature. A direct requirement, not
24
  # a transitive one: `distinct_protocol.handshake` imports it and refuses
25
+ # to fall back to HMAC, so an install that happens to miss it fails at the
26
+ # moment a worker pairs rather than at install time.
27
  "cryptography>=42",
 
28
  "gradio_client==2.6.0",
29
  "pyRAPL==0.2.3.1; sys_platform == 'linux'",
30
  # The worker's own screen. Pinned rather than ranged because the frozen
 
33
  # this repository would catch.
34
  "textual==8.2.8",
35
  ]
36
+
37
+ [project.optional-dependencies]
38
+ # The DSPy RLM harness only. It is a large install and the worker degrades to
39
+ # the structured harness without it, saying so on start, so it is the one
40
+ # thing here that is genuinely optional.
41
+ agent = [
42
+ "dspy[deno]==3.3.0",
43
+ ]
44
  dev = [
45
  "pytest==8.4.2",
46
  "ruff==0.12.11",
scripts/test_library_members.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test every library member through the real stack.
2
+
3
+ For each member: a fresh conversation, a request carrying the deterministic
4
+ ``[[demo-call:...]]`` directive, a real queue, a real worker executing the
5
+ real handler, and a real artifact back. Every member of the library is
6
+ exercised end to end, and one that cannot produce its artifact fails the run.
7
+
8
+ THIS USED TO FILM EACH MEMBER AND IT NO LONGER DOES.
9
+
10
+ The frames became a GIF on each library card. The cards now draw what their
11
+ member makes, which answers "what is this for" at a glance and on a phone,
12
+ where a hover-to-play recording answered nothing. The GIFs are gone and so is
13
+ the capture; the run they were evidence of is the part worth keeping, so this
14
+ is now a plain test.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ import subprocess
22
+ import sys
23
+ import threading
24
+ import time
25
+ from pathlib import Path
26
+
27
+ REPO_ROOT = Path(__file__).resolve().parent.parent
28
+ sys.path.insert(0, str(REPO_ROOT))
29
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
30
+
31
+ from live_e2e import ( # noqa: E402
32
+ CLIENT_ID,
33
+ CLIENT_SECRET,
34
+ enter_the_app,
35
+ free_port,
36
+ generate_pairing_code,
37
+ redeem,
38
+ sign_in,
39
+ start_provider,
40
+ wait_for,
41
+ )
42
+
43
+ #: ref -> (spoken request, directive arguments). The spoken half is what a
44
+ #: person would type; the directive half is what the deterministic runner
45
+ #: turns into the real tool call. Together they are the test.
46
+ CASES: dict[str, tuple[str, dict]] = {
47
+ "create_pdf@1": (
48
+ "Turn these notes into a PDF please",
49
+ {"title": "Field notes", "body": "Open weights on lent machines.\n\nEnergy read off a meter, never guessed."},
50
+ ),
51
+ "make_plan@1": (
52
+ "Write up a plan for the community launch",
53
+ {"goal": "Launch the community worker network",
54
+ "steps": ["Pair two volunteer workers", "Run the smoke suite", "Open sign-ups"]},
55
+ ),
56
+ "create_docx@1": (
57
+ "I need that as a Word document",
58
+ {"title": "Quarterly note", "body": "# Summary\nRevenue rose.\nCosts fell."},
59
+ ),
60
+ "create_xlsx@1": (
61
+ "Make me a spreadsheet of the inventory",
62
+ {"text": "item | qty | price\nWidget | 4 | 9.50\nBolt, large | 12 | 0.35"},
63
+ ),
64
+ "create_deck@1": (
65
+ "Turn this outline into slides",
66
+ {"slides": "distinct :: measured energy; open weights\nHow it works :: pair a worker; queue a run; read the meter"},
67
+ ),
68
+ "csv_table@1": (
69
+ "Give me that table as a CSV",
70
+ {"text": "name | role\nAda | analysis\nGrace | systems"},
71
+ ),
72
+ "status_update@1": (
73
+ "Draft this week's status update",
74
+ {"team": "Worker network", "period": "This week",
75
+ "progress": "Two new volunteer machines paired.",
76
+ "plans": "Add the GPU meter to the ladder.",
77
+ "problems": "One worker reports no usable counter."},
78
+ ),
79
+ "review_checklist@1": (
80
+ "Make a review checklist for the energy patch",
81
+ {"subject": "Energy meter patch",
82
+ "claims": ["The GPU figure is measured, not modelled",
83
+ "Scopes are never summed",
84
+ "A missing reading never counts as zero"]},
85
+ ),
86
+ "skill_scaffold@1": (
87
+ "Scaffold a new skill for meeting notes",
88
+ {"name": "meeting-notes",
89
+ "description": "Turn raw meeting notes into a clean summary document.",
90
+ "steps": ["Collect the raw notes", "Group them by decision", "Write the summary"]},
91
+ ),
92
+ "frontend_design@1": (
93
+ "Build me a landing page for the project",
94
+ {"title": "distinct", "sections": "What it is :: Small models on lent machines.\nWhy :: The meter is the product."},
95
+ ),
96
+ "web_artifacts_builder@1": (
97
+ "Plan the interactive dashboard artifact",
98
+ {"subject": "Energy dashboard artifact", "context": "Live joules per worker, one HTML file."},
99
+ ),
100
+ "theme_factory@1": (
101
+ "Make a CSS theme in sage tones",
102
+ {"name": "Sage", "colors": "primary: #2E5E43\nsurface: #FAF9F5\nink: #203127"},
103
+ ),
104
+ "brand_guidelines@1": (
105
+ "Apply our brand to the new deck",
106
+ {"subject": "Launch deck", "context": "Sage green on cream, Georgia headings."},
107
+ ),
108
+ "mcp_builder@1": (
109
+ "Plan an MCP server for our metrics API",
110
+ {"subject": "Metrics API MCP server", "context": "Read-only REST API, token auth."},
111
+ ),
112
+ "webapp_testing@1": (
113
+ "Write the browser test plan for sign-in",
114
+ {"subject": "Sign-in flow", "context": "OAuth redirect, then the app shell loads."},
115
+ ),
116
+ "claude_api@1": (
117
+ "Plan the Claude API integration",
118
+ {"subject": "Support inbox triage bot", "context": "Python, low volume, cost matters."},
119
+ ),
120
+ "algorithmic_art@1": (
121
+ "Plan a generative art piece about rivers",
122
+ {"subject": "River braiding", "context": "Blues and sand, seeded, printable."},
123
+ ),
124
+ "canvas_design@1": (
125
+ "Design a poster for the meetup",
126
+ {"subject": "Community compute meetup", "context": "A2 poster, evening light mood."},
127
+ ),
128
+ "academy_guide@1": (
129
+ "Build me a learning path for local LLMs",
130
+ {"subject": "Running local LLMs", "context": "Knows Python, has a gaming PC, 4 weeks."},
131
+ ),
132
+ "discernment_nudge@1": (
133
+ "Give me a discernment nudge on this rollout",
134
+ {"subject": "Shipping the worker auto-update", "context": "Team is eager; rollback story unclear."},
135
+ ),
136
+ "slack_gif_creator@1": (
137
+ "Plan a celebration GIF for the release",
138
+ {"subject": "v1 shipped", "context": "Confetti over the wordmark, two seconds."},
139
+ ),
140
+ }
141
+
142
+
143
+ def record(page, ref: str, spoken: str, arguments: dict) -> None:
144
+ """One member: request in, run through, artifact out.
145
+
146
+ The assertion at the end is the point of the whole script. Every member of
147
+ this library exists to produce something, so a run that completes without
148
+ an artifact card in the transcript has not demonstrated the member — it has
149
+ demonstrated the plumbing around it.
150
+ """
151
+
152
+ page.get_by_role("button", name=re.compile("^New conversation$", re.I)).click(timeout=15000)
153
+ page.wait_for_timeout(900)
154
+
155
+ prompt = f"{spoken}\n[[demo-call:{ref} {json.dumps(arguments)}]]"
156
+ box = page.get_by_label(re.compile("Your request", re.I))
157
+ box.fill(prompt, timeout=15000)
158
+
159
+ # Exactly one member selected: the one under test. A job carries at most
160
+ # sixteen selections, and a run that selects the whole shelf has not tested
161
+ # anything in particular.
162
+ wanted = ref.split("@", 1)[0].replace("_", " ")
163
+ picker = page.locator(".c-composer-tools input[type=checkbox]")
164
+ for index in range(picker.count()):
165
+ member = picker.nth(index)
166
+ if member.is_checked():
167
+ member.uncheck(timeout=5000, force=True)
168
+ page.wait_for_timeout(250)
169
+ target = page.locator(".c-composer-tools label", has_text=f"{wanted} ·")
170
+ if target.count() == 0:
171
+ raise RuntimeError(f"{ref}: not offered in the composer")
172
+ target.first.locator("input[type=checkbox]").check(timeout=5000, force=True)
173
+ page.wait_for_timeout(400)
174
+ page.get_by_role(
175
+ "checkbox", name=re.compile("plaintext to a community-operated worker", re.I)
176
+ ).check(timeout=15000)
177
+ page.wait_for_timeout(200)
178
+ page.get_by_role("button", name=re.compile("^Queue run$", re.I)).click(timeout=15000)
179
+
180
+ deadline = time.monotonic() + 45
181
+ done = False
182
+ while time.monotonic() < deadline:
183
+ if "requested library call" in page.inner_text("body"):
184
+ done = True
185
+ break
186
+ page.wait_for_timeout(1000)
187
+ if not done:
188
+ raise RuntimeError(f"{ref}: the run did not complete in time")
189
+ page.wait_for_timeout(1500)
190
+
191
+ if "requested library call" not in page.inner_text("body"):
192
+ raise RuntimeError(f"{ref}: the answer does not show the tool ran")
193
+ made = page.locator("figure.c-madecard")
194
+ if made.count() == 0:
195
+ raise RuntimeError(f"{ref}: the run produced no artifact")
196
+ summary = " | ".join(made.first.inner_text().split("\n"))[:70]
197
+ print(f" {ref:26s} PASS {summary}")
198
+
199
+
200
+ def main() -> int:
201
+ import os
202
+
203
+ from playwright.sync_api import sync_playwright
204
+
205
+ provider_url, provider = start_provider()
206
+ port = free_port()
207
+ server_url = f"http://127.0.0.1:{port}"
208
+ environment = {
209
+ **os.environ,
210
+ "DISTINCT_OAUTH_CLIENT_ID": CLIENT_ID,
211
+ "DISTINCT_OAUTH_CLIENT_SECRET": CLIENT_SECRET,
212
+ "DISTINCT_OAUTH_REDIRECT_URI": f"{server_url}/auth/callback",
213
+ "DISTINCT_OAUTH_PROVIDER_URL": provider_url,
214
+ "DISTINCT_SESSION_SECRET": "a-fixed-secret-so-this-run-is-repeatable",
215
+ "DISTINCT_BIND_HOST": "127.0.0.1",
216
+ "PORT": str(port),
217
+ "PYTHONPATH": str(REPO_ROOT),
218
+ }
219
+ environment.pop("DISTINCT_DEV_AUTH", None)
220
+ server_log = open("/tmp/record-server.log", "w")
221
+ server = subprocess.Popen(
222
+ [sys.executable, str(REPO_ROOT / "app.py")],
223
+ cwd=str(REPO_ROOT), env=environment,
224
+ stdout=server_log, stderr=subprocess.STDOUT, text=True,
225
+ )
226
+ workers: list[subprocess.Popen] = []
227
+ failures: list[str] = []
228
+ try:
229
+ wait_for(server_url)
230
+ with sync_playwright() as playwright:
231
+ browser = playwright.chromium.launch()
232
+ context = browser.new_context(viewport={"width": 1280, "height": 860})
233
+ page = context.new_page()
234
+ page.goto(server_url, wait_until="load")
235
+ sign_in(page, server_url, "alice")
236
+ enter_the_app(page)
237
+ pairing = generate_pairing_code(page)
238
+
239
+ tool_refs = ",".join(sorted(CASES))
240
+ worker_environment = {
241
+ **environment,
242
+ # The worker advertises what its operator policy names, not
243
+ # what --tools asks for: without this the registry is empty
244
+ # and the composer shows "no tools installed" forever.
245
+ "DISTINCT_APPROVED_TOOLS": tool_refs,
246
+ }
247
+ process = subprocess.Popen(
248
+ [
249
+ sys.executable, "-m", "distinct_agent",
250
+ "--server", server_url, "--pair", pairing,
251
+ "--name", "demo-recorder", "--approve", "--demo-runner",
252
+ "--tools", tool_refs,
253
+ ],
254
+ cwd=str(REPO_ROOT), env=worker_environment,
255
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
256
+ )
257
+ workers.append(process)
258
+ access = None
259
+ deadline = time.monotonic() + 120
260
+ while time.monotonic() < deadline and access is None:
261
+ line = process.stdout.readline()
262
+ if not line:
263
+ if process.poll() is not None:
264
+ raise RuntimeError("worker exited before printing an access code")
265
+ continue
266
+ found = re.search(r"\b([A-Z0-9]{5}(?:-[A-Z0-9]{4,5}){5,8})\b", line)
267
+ if found:
268
+ access = found.group(1)
269
+ if access is None:
270
+ raise RuntimeError("no access code appeared")
271
+ threading.Thread(
272
+ target=lambda: [None for _ in process.stdout], daemon=True
273
+ ).start()
274
+
275
+ page.reload(wait_until="load")
276
+ enter_the_app(page)
277
+ page.wait_for_timeout(2500)
278
+ redeem(page, access)
279
+ page.wait_for_timeout(4000)
280
+
281
+ # Wait for the worker's advertised members to reach the
282
+ # composer picker: they enter snapshots only after approval, so
283
+ # the first load can race them.
284
+ for _ in range(10):
285
+ if page.locator(".c-composer-tools input[type=checkbox]").count() > 0:
286
+ break
287
+ page.wait_for_timeout(1500)
288
+ page.reload(wait_until="load")
289
+ enter_the_app(page)
290
+ page.wait_for_timeout(2000)
291
+ offered = page.locator(".c-composer-tools input[type=checkbox]").count()
292
+ print(f" composer offers {offered} member(s)")
293
+ if offered == 0:
294
+ raise RuntimeError("the composer picker never populated")
295
+
296
+ for ref in sorted(CASES):
297
+ spoken, arguments = CASES[ref]
298
+ try:
299
+ record(page, ref, spoken, arguments)
300
+ except Exception as exc: # noqa: BLE001 - report and continue
301
+ failures.append(f"{ref}: {exc}")
302
+ print(f" {ref:26s} FAIL {exc}")
303
+ browser.close()
304
+ finally:
305
+ for process in workers:
306
+ if process.poll() is None:
307
+ process.terminate()
308
+ try:
309
+ process.wait(timeout=10)
310
+ except subprocess.TimeoutExpired:
311
+ process.kill()
312
+ server.terminate()
313
+ try:
314
+ server.wait(timeout=15)
315
+ except subprocess.TimeoutExpired:
316
+ server.kill()
317
+ provider.shutdown()
318
+ if failures:
319
+ print(f"\n{len(failures)} member(s) failed; no GIF was written for them.")
320
+ return 1
321
+ print(f"\nAll {len(CASES)} members produced their artifact.")
322
+ return 0
323
+
324
+
325
+ if __name__ == "__main__":
326
+ raise SystemExit(main())