on 9dba9d5 help sign in
11 files changed+55−238
since splitsnapshots
unifiedsplit
.github/workflows/release.yml+8−23 host/diagonal-host.py+30−49 host/tests/test_host.py−56 host/validate.py+4−44 scripts/install-manifest.sh+2−11 src/background/index.ts+7−19 src/background/naming.ts−13 src/background/organize.ts+3−11 src/shared/health.ts+1−3 test/auto.test.ts−1 test/naming.test.ts−8
.github/workflows/release.yml+8−23
@@ -9,11 +9,8 @@ on:
99permissions:
1010 contents: write
1111
12−# One release at a time, in push order: an older run finishing last must not become the latest release,
13−# which is what the Homebrew cask downloads.
1412concurrency:
15− group: release
16− cancel-in-progress: false
13+ group: release-${{ github.sha }}
1714
1815jobs:
1916 release:
@@ -35,23 +32,8 @@ jobs:
3532
3633 - name: Version from commit time
3734 id: version
38− env:
39− GH_TOKEN: ${{ github.token }}
4035 run: |
41− # The commit's minute, or the next free minute when another commit already released in it. A re-run
42− # for a commit that already released publishes nothing: a rebuilt zip has new bytes, which would
43− # break the sha256 the tap already has, and users on that version would never get the new one.
44− t=$(git log -1 --format=%ct "$GITHUB_SHA")
45− for _ in $(seq 1 30); do
46− version=$(date -u -d "@$t" +%Y.%m.%d.%H%M)
47− target=$(gh release view "$version" --json targetCommitish -q .targetCommitish 2>/dev/null || true)
48− [ -z "$target" ] && break
49− if [ "$target" = "$GITHUB_SHA" ]; then
50− echo "published=true" >> "$GITHUB_OUTPUT"
51− break
52− fi
53− t=$((t + 60))
54− done
36+ version=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y.%m.%d.%H%M "$GITHUB_SHA")
5537 echo "version=$version" >> "$GITHUB_OUTPUT"
5638 echo "Version $version"
5739
@@ -72,19 +54,22 @@ jobs:
7254 npm run zip:store
7355
7456 - name: Publish release
75− if: steps.version.outputs.published != 'true'
7657 env:
7758 GH_TOKEN: ${{ github.token }}
7859 VERSION: ${{ steps.version.outputs.version }}
7960 run: |
8061 asset="diagonal-extension-${VERSION}.zip"
8162 notes="Built from ${GITHUB_SHA::7}. Install with: brew install iko-soy/tap/diagonal. Or by hand: unzip, load the folder at brave://extensions (Developer mode → Load unpacked), then install the native host once from Terminal: bash <that folder>/install-host.command"
82− gh release create "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
63+ if gh release view "$VERSION" >/dev/null 2>&1; then
64+ # Two commits in the same minute share a version: refresh the asset on the existing release.
65+ gh release upload "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --clobber
66+ else
67+ gh release create "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
68+ fi
8369
8470 # Uploads the release to the Chrome Web Store and submits it for review, once the listing exists and
8571 # its API credentials are set as repository secrets (docs/store/listing.md). Skipped until then.
8672 - name: Upload to the Chrome Web Store
87− if: steps.version.outputs.published != 'true'
8873 env:
8974 CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
9075 CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
host/diagonal-host.py+30−49
@@ -206,7 +206,7 @@ def count_tokens(prompt):
206206 """fm's exact count (under 0.1 s), or None when it can't give one."""
207207 try:
208208 p = subprocess.run([FM, "count-tokens", "-q", "-i", prompt.instructions], input=prompt.text,
209− capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, env=fm_env())
209+ capture_output=True, text=True, timeout=10, env=fm_env())
210210 except (OSError, subprocess.TimeoutExpired):
211211 return None
212212 out = p.stdout.strip()
@@ -255,41 +255,28 @@ PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
255255USAGE_EXIT = 64
256256
257257
258−# Below this much time left in a request, a second fm call (the permissive retry, naming one more new
259−# group) is not started: it would only be cut off.
260−MIN_CALL_S = 5.0
261−
262−
263−def run_fm(prompt, schema, opts):
264− """`prompt` is a prompts.Prompt: its fixed rules go to -i, the tab text on stdin. Every call in one
265− request shares its deadline, so the extension's guard (its timeout plus 10 s) is a real upper bound."""
258+def run_fm(prompt, schema, model, timeout_s):
259+ """`prompt` is a prompts.Prompt: its fixed rules go to -i, the tab text on stdin."""
266260 try:
267− return _run_fm(prompt, schema, opts, [])
261+ return _run_fm(prompt, schema, model, timeout_s, [])
268262 except Fail as f:
269− if f.code != "GUARDRAIL" or time_left(opts) < MIN_CALL_S:
263+ if f.code != "GUARDRAIL":
270264 raise
271− return _run_fm(prompt, schema, opts, PERMISSIVE)
272−
273−
274−def time_left(opts):
275− return opts["deadline"] - time.time()
265+ return _run_fm(prompt, schema, model, timeout_s, PERMISSIVE)
276266
277267
278−def _run_fm(prompt, schema, opts, extra):
268+def _run_fm(prompt, schema, model, timeout_s, extra):
279269 # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
280270 # in the process list. fm has no timeout of its own; subprocess enforces ours.
281271 # --greedy: the same tabs get the same topics and name on every run (measured on macOS 27: five runs,
282− # one answer), so groups don't get renamed or reshuffled each time Diagonal looks at them. The strict
283− # retry after an unusable answer samples instead: greedy would give the same answer again.
284− greedy = [] if opts.get("strict") else ["--greedy"]
285− args = [FM, "respond", "--model", opts["model"], "--no-stream", *greedy, "--schema", schema, "-i", prompt.instructions, *extra]
286− timeout_s = max(1.0, time_left(opts))
272+ # one answer), so groups don't get renamed or reshuffled each time Diagonal looks at them.
273+ args = [FM, "respond", "--model", model, "--no-stream", "--greedy", "--schema", schema, "-i", prompt.instructions, *extra]
287274 try:
288− p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout_s, env=fm_env())
275+ p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
289276 except FileNotFoundError:
290277 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
291278 except subprocess.TimeoutExpired:
292− raise Fail("TIMEOUT", f"fm exceeded {opts['timeout_s']:g}s", retryable=True)
279+ raise Fail("TIMEOUT", f"fm exceeded {timeout_s:g}s", retryable=True)
293280 if p.returncode != 0:
294281 code = classify(p.stderr, p.returncode)
295282 if code == "LICENSE_REQUIRED":
@@ -308,11 +295,11 @@ def organize_mode():
308295
309296def op_ping(_payload, _opts):
310297 if not os.path.exists(FM):
311− return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False, "licenseRequired": False,
298+ return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False,
312299 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
313300 license_required = False
314301 try:
315− avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, env=fm_env())
302+ avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, timeout=15, env=fm_env())
316303 ok, msg = avail.returncode == 0, (avail.stdout + avail.stderr).strip()[:300]
317304 if not ok and license_needed(avail.returncode, avail.stdout + avail.stderr):
318305 license_required, msg = True, LICENSE_MESSAGE
@@ -380,7 +367,7 @@ def op_name(payload, opts):
380367 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
381368 prompt = fit(lambda cap: prompts.build_name_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
382369 try:
383− out = run_fm(prompt, schema, opts)
370+ out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
384371 except Fail as f:
385372 raise _fit_overflow(f, len(payload["items"]))
386373 try:
@@ -396,7 +383,7 @@ def op_organize(payload, opts):
396383 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'topics.json')}")
397384 prompt = fit(lambda cap: prompts.build_topics_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
398385 try:
399− out = run_fm(prompt, schema, opts)
386+ out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
400387 except Fail as f:
401388 raise _fit_overflow(f, len(payload["items"]))
402389 _, owner = prompts.topic_items(payload)
@@ -406,31 +393,29 @@ def op_organize(payload, opts):
406393 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
407394 chars = prompts.size(prompt)
408395 if payload.get("nameGroups") is not False: # the extension's fit check only needs the topics
409− chars += name_new_groups(result, payload, opts)
396+ chars += name_new_groups(result, payload, opts, time.time() + opts["timeout_s"])
410397 return result, chars, "topics"
411398
412399
413−def name_new_groups(result, payload, opts):
400+def name_new_groups(result, payload, opts, deadline):
414401 """Title each new group with the name op (well under a second each on fm), so groups appear named.
415− A group that fails, runs past the request's deadline, or gets a title another group in the window
416− already has keeps "" and the extension's naming loop, which checks every sibling, names it."""
417− taken = [t for t in [g.get("title") for g in payload.get("existingGroups") or []] + list(payload.get("siblingTitles") or []) if t]
402+ A group that fails, or runs past the deadline, keeps "" and the extension's naming loop names it."""
403+ taken = [g.get("title") for g in payload.get("existingGroups") or [] if g.get("title")]
418404 chars = 0
419405 for g in result["groups"]:
420406 if "existing" in g or g.get("title"):
421407 continue
422− if time_left(opts) < MIN_CALL_S:
408+ left = deadline - time.time()
409+ if left < 5:
423410 break
424411 sub = {"items": [dict(payload["items"][i], i=n) for n, i in enumerate(g["members"])], "siblingTitles": taken[:20]}
425412 try:
426− named, n, _ = op_name(sub, {**opts, "strict": False})
413+ named, n, _ = op_name(sub, {**opts, "timeout_s": min(opts["timeout_s"], left)})
427414 except Fail:
428415 continue
429− chars += n
430− if any(validate.same_label(named["title"], t) for t in taken):
431− continue
432416 g["title"], g["emoji"] = named["title"], named["emoji"]
433417 taken.append(named["title"])
418+ chars += n
434419 return chars
435420
436421
@@ -444,8 +429,8 @@ def handle(req):
444429 raw_opts = req.get("opts") or {}
445430 timeout_ms = raw_opts.get("timeoutMs", 45000)
446431 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
447− timeout_s = max(5.0, min(120.0, timeout_ms / 1000))
448− opts = {"model": "system", "strict": bool(raw_opts.get("strict")), "timeout_s": timeout_s, "deadline": t0 + timeout_s}
432+ opts = {"model": "system", "strict": bool(raw_opts.get("strict")),
433+ "timeout_s": max(5.0, min(120.0, timeout_ms / 1000))}
449434 op = req["op"]
450435 if op == "ping":
451436 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
@@ -499,7 +484,7 @@ def install_schemas():
499484 if "{%s}" % key not in args:
500485 continue
501486 try:
502− p = subprocess.run([FM, *sub_args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
487+ p = subprocess.run([FM, *sub_args], capture_output=True, text=True, timeout=30, env=fm_env())
503488 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
504489 print(f" {name}: fm failed ({e})", file=sys.stderr)
505490 return "error"
@@ -511,7 +496,7 @@ def install_schemas():
511496 subs["{%s}" % key] = p.stdout.strip()
512497 args = [subs.get(a, a) for a in args]
513498 try:
514− p = subprocess.run([FM, *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
499+ p = subprocess.run([FM, *args], capture_output=True, text=True, timeout=30, env=fm_env())
515500 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
516501 print(f" {name}: fm failed ({e})", file=sys.stderr)
517502 return "error"
@@ -568,16 +553,14 @@ def selftest():
568553
569554 ping = op_ping({}, {})
570555 check("fm found", os.path.exists(FM), FM)
571− if not os.path.exists(FM):
572− return 1 # nothing else can work without it
573− if ping.get("licenseRequired"):
556+ if ping["licenseRequired"]:
574557 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
575558 else:
576559 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
577560 for name in SCHEMA_COMMANDS:
578561 path = os.path.join(SCHEMAS, name)
579562 readable = os.path.isfile(path) and os.path.getsize(path) > 0
580− hint = f"written on their own after {LICENSE_FIX}" if ping.get("licenseRequired") else "missing: run diagonal-host --install-schemas"
563+ hint = f"written on their own after {LICENSE_FIX}" if ping["licenseRequired"] else "missing: run diagonal-host --install-schemas"
581564 check(f"schema {name}", readable, path if readable else hint)
582565 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
583566 if ping["fmAvailable"] and schema_path("name.json"):
@@ -687,9 +670,7 @@ def register(host_path):
687670 f.write("\n".join(written) + "\n")
688671 except OSError:
689672 pass
690− # One browser that could not be written (a locked folder, a stale profile) does not undo the others:
691− # the install goes on and the ones that failed were named above.
692− return 0 if len(dirs) - failures > 0 else 1
673+ return 1 if failures else 0
693674
694675
695676def unregister():
host/tests/test_host.py−56
@@ -287,19 +287,6 @@ class ErrorCodes(HostCase):
287287 self.assertIn("Reply with only the object", argv[argv.index("-i") + 1])
288288
289289
290− def test_strict_retry_samples_instead_of_repeating_the_greedy_answer(self):
291− self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
292− self.call("name", {"items": ITEMS3}, strict=True)
293− self.assertNotIn("--greedy", [a for a in self.argv_log() if a[0] == "respond"][0])
294−
295− def test_permissive_retry_shares_the_request_deadline(self):
296− # The first call uses up the request's time: no second call runs past what the extension waits for.
297− self.control({"respond_queue": [{"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1, "sleep": 2.5}]})
298− r = self.call("name", {"items": ITEMS3}, timeoutMs=5000)
299− self.assertEqual(r["error"]["code"], "GUARDRAIL")
300− self.assertEqual(len([a for a in self.argv_log() if a[0] == "respond"]), 1)
301−
302−
303290class Organize(HostCase):
304291 ITEMS = ITEMS3 + [
305292 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
@@ -344,33 +331,6 @@ class Organize(HostCase):
344331 self.assertIn("Tokio", name_calls[0])
345332 self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
346333
347− def test_a_new_group_named_like_another_group_is_left_to_the_naming_loop(self):
348− self.control({"respond_queue": [
349− {"stdout": json.dumps({"tabs": [{"index": i, "topic": "Programming"} for i in (0, 1, 2)]})},
350− {"stdout": json.dumps({"title": "Rust async", "emoji": "🦀"})},
351− ]})
352− r = self.call("organize", {"items": self.ITEMS, "siblingTitles": ["rust async"]})
353− self.assertEqual(r["result"]["groups"][0]["title"], "")
354− self.assertIn('"rust async"', self.stdin_log()[1]) # the name call was told, too
355−
356− def test_a_topic_two_groups_share_equally_joins_neither_unless_the_words_say_which(self):
357− existing = [{"g": 0, "title": "Lisbon trip", "samples": ["Flights to Lisbon"]},
358− {"g": 1, "title": "Porto trip", "samples": ["Porto restaurants"]}]
359− # Examples 8 (group 0) and 9 (group 1) both got Travel: a tie.
360− self.topics([(3, "Travel"), (4, "Travel"), (8, "Travel"), (9, "Travel")])
361− r = self.call("organize", {"items": self.ITEMS, "existingGroups": existing})
362− self.assertEqual([g.get("existing") for g in r["result"]["groups"]], [0]) # "Lisbon" is in both tabs' titles
363− items = [{"title": "Beach day", "url": "https://a.example/"}, {"title": "City walk", "url": "https://b.example/"}]
364− self.topics([(0, "Travel"), (1, "Travel"), (2, "Travel"), (3, "Travel")])
365− r = self.call("organize", {"items": items, "existingGroups": existing})
366− self.assertNotIn("existing", r["result"]["groups"][0])
367−
368− def test_only_vague_topics_leave_every_tab_loose(self):
369− self.topics([(0, "Other"), (1, "Misc"), (2, "General")])
370− r = self.call("organize", {"items": ITEMS3})
371− self.assertTrue(r["ok"], r)
372− self.assertEqual(r["result"], {"groups": [], "leftovers": [0, 1, 2]})
373−
374334 def test_two_topics_joining_one_group_make_one_join(self):
375335 # The group's two example tabs (8 and 9) got different topics; both topics still join group 0 once.
376336 self.topics([(3, "Travel"), (4, "Hotels"), (8, "Travel"), (9, "Hotels"), (0, "Programming")])
@@ -527,15 +487,6 @@ class Register(HostCase):
527487 self.assertEqual(p.returncode, 1)
528488 self.assertIn("missing or not executable", p.stderr)
529489
530− def test_one_browser_that_cannot_be_written_does_not_fail_the_rest(self):
531− chrome = self.make_profile("Google/Chrome")
532− brave = self.make_profile("BraveSoftware/Brave-Browser")
533− open(os.path.join(brave, "NativeMessagingHosts"), "w").close() # a file where the folder should be
534− p = self.run_copy("--register", self.host_copy)
535− self.assertEqual(p.returncode, 0, p.stderr)
536− self.assertIn("BraveSoftware/Brave-Browser: could not write", p.stderr)
537− self.assertTrue(os.path.exists(os.path.join(chrome, "NativeMessagingHosts", "io.diagonal.host.json")))
538−
539490 def test_no_browser_found(self):
540491 os.makedirs(self.env["DIAGONAL_APP_SUPPORT"])
541492 p = self.run_copy("--register", self.host_copy)
@@ -557,13 +508,6 @@ class SelfTest(HostCase):
557508 self.assertEqual(code, 1)
558509 self.assertIn("FAIL model available", p.stdout.decode())
559510
560− def test_selftest_without_fm_says_so_and_stops(self):
561− self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "no-such-fm")
562− code, _, p = self.run_host(args=["--selftest"])
563− self.assertEqual(code, 1)
564− self.assertIn("FAIL fm found", p.stdout.decode())
565− self.assertNotIn("Traceback", p.stderr.decode())
566−
567511 def test_selftest_names_the_license_fix(self):
568512 self.control({"license": True})
569513 code, _, p = self.run_host(args=["--selftest"])
host/validate.py+4−44
@@ -160,10 +160,6 @@ def is_valid_label(label):
160160 )
161161
162162
163−def same_label(a, b):
164− return bool(a) and bool(b) and " ".join(a.split()).casefold() == " ".join(b.split()).casefold()
165−
166−
167163def validate_name(out, payload):
168164 if not isinstance(out, dict):
169165 raise ValidationError("reply is not an object")
@@ -269,52 +265,16 @@ def organize_from_topics(out, payload, owner):
269265 votes = group_votes.setdefault(topic, {})
270266 votes[owner[i]] = votes.get(owner[i], 0) + 1
271267 if not tab_topic:
272− if not any(isinstance(t, dict) and _in_range(t.get("index"), n_items) for t in out["tabs"]):
273− raise ValidationError("no tab got a topic")
274− # Every tab got only a vague topic ("Other", "Misc"): the model found nothing in common.
275− return validate_organize({"groups": [], "leftovers": []}, payload)
268+ raise ValidationError("no tab got a topic")
276269 clusters = {}
277270 for i in sorted(tab_topic):
278271 clusters.setdefault(tab_topic[i], []).append(i)
279272 groups = []
280273 for topic, members in clusters.items():
281− g = _winner(group_votes.get(topic) or {}, members, payload)
282− if g is not None:
274+ votes = group_votes.get(topic)
275+ if votes:
276+ g = max(sorted(votes), key=lambda k: votes[k])
283277 groups.append({"existing": g, "members": members})
284278 else:
285279 groups.append({"title": "", "emoji": "", "color": "", "members": members})
286280 return validate_organize({"groups": groups, "leftovers": []}, payload)
287−
288−
289−def _in_range(i, n):
290− return isinstance(i, int) and not isinstance(i, bool) and 0 <= i < n
291−
292−
293−_WORD = re.compile(r"\w{3,}")
294−
295−
296−def _words(text):
297− return {w.casefold() for w in _WORD.findall(text or "")}
298−
299−
300−def _winner(votes, members, payload):
301− """The existing group a topic's tabs join: the one whose example tabs share it most. When two groups tie,
302− the one whose title and examples share more words with these tabs; when that ties too, none (the tabs
303− form a group of their own, or stay loose), rather than whichever group happens to be listed first."""
304− if not votes:
305− return None
306− top = max(votes.values())
307− tied = sorted(k for k, v in votes.items() if v == top)
308− if len(tied) == 1:
309− return tied[0]
310− tab_words = set().union(*(_words(payload["items"][i].get("title")) for i in members))
311− groups = {g["g"]: g for g in payload.get("existingGroups") or []}
312−
313− def overlap(k):
314− g = groups.get(k) or {}
315− return len(tab_words & _words(" ".join([g.get("title") or "", *(g.get("samples") or [])])))
316−
317− scores = {k: overlap(k) for k in tied}
318− best = max(scores.values())
319− leaders = [k for k in tied if scores[k] == best]
320− return leaders[0] if len(leaders) == 1 and best > 0 else None
scripts/install-manifest.sh+2−11
@@ -53,16 +53,7 @@ echo "host: $BIN -> $SHARE/diagonal-host.py"
5353"$BIN" --version >/dev/null # the host must start with the python3 it was pinned to
5454# Writes the manifest into every Chromium browser's NativeMessagingHosts folder (Chrome, Brave and
5555# Brave Origin, Edge, Vivaldi, Arc, Opera, …) and checks each one the way the browser reads it.
56−# A browser that was never opened has no folder yet: the rest of the install still runs, and the end
57−# says how to finish.
58−register_rc=0
59−"$BIN" --register "$BIN" || register_rc=$?
60−register_note() {
61− if [[ $register_rc -ne 0 ]]; then
62− echo "No browser could be set up for Diagonal yet. Open your browser once, then run: bash '$HERE/$(basename "$0")'"
63− fi
64−}
65−trap register_note EXIT
56+"$BIN" --register "$BIN"
6657
6758FM="${DIAGONAL_FM:-/usr/bin/fm}"
6859
@@ -98,7 +89,7 @@ fi
9889# Diagonal also writes these on first use; doing it now lets the self-test below cover the model.
9990schemas_out=$("$BIN" --install-schemas 2>&1) || true
10091if selftest_out=$("$BIN" --selftest 2>&1); then
101− [[ $register_rc -ne 0 ]] || echo "Diagonal's host is ready."
92+ echo "Diagonal's host is ready."
10293else
10394 echo "Diagonal's host is installed, but its self-test found a problem:"
10495 echo "$schemas_out"
src/background/index.ts+7−19
@@ -93,11 +93,7 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
9393 h.lastOkAt = Date.now();
9494 h.consecutiveFailures = 0;
9595 h.consecutiveTimeouts = 0;
96− if (op !== "ping") {
97− h.rateLimitStep = 0; // a ping doesn't use the model, so it says nothing about its limit
98− // A model answer (a manual Organize or Name now during a pause) proves the helper works again.
99− if (h.pausedUntil) recovered();
100− }
96+ if (op !== "ping") h.rateLimitStep = 0; // a ping doesn't use the model, so it says nothing about its limit
10197 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
10298 } else {
10399 recordHostError(reply.error);
@@ -144,7 +140,11 @@ async function ping(): Promise<HostReply<PingResult>> {
144140 } else if (!reply.result.schemasOk) {
145141 recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
146142 } else {
147− recovered();
143+ h.pausedUntil = undefined;
144+ h.lastError = undefined;
145+ chrome.alarms.clear(ALARM_HOST);
146+ void naming.sweepDirty();
147+ void autoOrganizer.sweep();
148148 }
149149 }
150150 commit();
@@ -152,16 +152,6 @@ async function ping(): Promise<HostReply<PingResult>> {
152152 return reply;
153153}
154154
155−/** The helper answers again: lift the pause and pick up what waited for it. */
156−function recovered(): void {
157− const h = state.host;
158− h.pausedUntil = undefined;
159− h.lastError = undefined;
160− chrome.alarms.clear(ALARM_HOST);
161− void naming.sweepDirty();
162− void autoOrganizer.sweep();
163−}
164−
165155// ----- badge -----------------------------------------------------------------------------------
166156
167157function busy(on: boolean): void {
@@ -512,14 +502,12 @@ if (GROUPS_SUPPORTED) {
512502
513503 chrome.tabs.onReplaced.addListener((added, removed) =>
514504 void serial("tabs.onReplaced", async () => {
515− // Chromium swapped in a new tab (a prerendered page, a discarded tab coming back): it is the same
516− // tab to the user, so it keeps what Diagonal knew about it, including choices the user made.
517505 const rec = state.tabs[removed];
518506 delete state.tabs[removed];
519507 const t = await tabById(added);
520508 if (t) {
521− if (rec && !state.tabs[added]) state.tabs[added] = { ...rec, id: added };
522509 await feed({ type: "tabUpdated", tab: snapTab(t) });
510+ if (rec?.description && state.tabs[added]) state.tabs[added].description = rec.description;
523511 }
524512 }),
525513 );
src/background/naming.ts−13
@@ -312,7 +312,6 @@ export class Naming {
312312 }
313313 if (e.code === "BAD_MODEL_OUTPUT" && !retried.output) {
314314 retried.output = true;
315− mustDifferFrom = withRejected(mustDifferFrom, e.raw);
316315 continue;
317316 }
318317 this.fail(g, e, hash);
@@ -324,7 +323,6 @@ export class Naming {
324323 const e: HostError = { code: "BAD_MODEL_OUTPUT", message: "label failed worker validation", raw: JSON.stringify(reply.result).slice(0, 500), at: this.d.now() };
325324 if (!retried.output) {
326325 retried.output = true;
327− mustDifferFrom = withRejected(mustDifferFrom, e.raw);
328326 continue;
329327 }
330328 this.fail(g, e, hash);
@@ -414,17 +412,6 @@ export class Naming {
414412 }
415413}
416414
417−/** The retry after an unusable title lists that title as not allowed, so the model doesn't give it again. */
418−function withRejected(list: string[] | undefined, raw: string | undefined): string[] | undefined {
419− let title: unknown;
420− try {
421− title = raw ? JSON.parse(raw)?.title : undefined;
422− } catch {
423− return list;
424− }
425− return typeof title === "string" && title.trim() ? [...(list ?? []), title.trim().slice(0, 60)] : list;
426−}
427−
428415/** The retry bookkeeping of earlier failures, which no longer applies. */
429416function clearFailures(g: GroupRecord): void {
430417 g.nameAttempts = 0;
src/background/organize.ts+3−11
@@ -27,8 +27,6 @@ export interface ExistingGroup {
2727export interface OrganizePayload {
2828 items: NameItem[];
2929 existingGroups: ExistingGroup[];
30− /** Every group label in the window: a new group the host names must not repeat one. */
31− siblingTitles?: string[];
3230 allowNew: boolean;
3331 maxGroups: number;
3432 minGroupSize: number;
@@ -179,17 +177,11 @@ const CONTENT_ERRORS = new Set<HostErrorCode>(["OVER_BUDGET", "GUARDRAIL", "BAD_
179177const MAX_TIMEOUT_SPLITS = 1;
180178
181179/** One organize call, with the retries that can rescue the same tabs: strict output, then no page text. */
182−async function askOrganize(rt: Runtime, part: Member[], windowId: number, existingGroups: ExistingGroup[], withDescriptions: boolean): Promise<HostReply<OrganizeResult>> {
180+async function askOrganize(rt: Runtime, part: Member[], existingGroups: ExistingGroup[], withDescriptions: boolean): Promise<HostReply<OrganizeResult>> {
183181 const settings = rt.settings();
184− const s = rt.state();
185182 const payload: OrganizePayload = {
186183 items: toItems(part, settings, withDescriptions),
187184 existingGroups,
188− siblingTitles: Object.values(s.groups)
189− .filter((g) => g.windowId === windowId && g.origin !== "tidy")
190− .map((g) => labelOf(g.stripTitle || g.title))
191− .filter(Boolean)
192− .slice(0, 20),
193185 allowNew: part.length >= 2,
194186 maxGroups: maxGroupsFor(part.length),
195187 minGroupSize: settings.organizeMinGroupSize,
@@ -232,7 +224,7 @@ export async function sortPart(rt: Runtime, part: Member[], run: SortRun, probe
232224 run.left += part.length;
233225 return; // one tab and nothing to join: no call can place it (a probe still learns whether the model takes it)
234226 }
235− const reply = await askOrganize(rt, part, run.windowId, existingGroups, true);
227+ const reply = await askOrganize(rt, part, existingGroups, true);
236228 if (!reply.ok) {
237229 const e = reply.error;
238230 const timedOut = e.code === "TIMEOUT" && part.length > 4 && run.timeoutSplits < MAX_TIMEOUT_SPLITS;
@@ -313,7 +305,7 @@ export async function organizeWindow(rt: Runtime, windowId: number): Promise<Org
313305 report.left = tabs.length;
314306 return report;
315307 }
316− const run = newRun(windowId, true); // auto-organize need not send the same tabs again right after
308+ const run = newRun(windowId, false);
317309 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
318310 rt.refreshBadge();
319311 try {
src/shared/health.ts+1−3
@@ -46,9 +46,7 @@ export function notices(s: Health): Notice[] {
4646 case "HOST_NOT_FOUND":
4747 out.push({
4848 tone: "bad", title: "Finish setting up Diagonal",
49− body: chrome.runtime.getManifest().update_url
50− ? "Diagonal's helper isn't registered with this browser yet. Run this in Terminal, then reopen Diagonal."
51− : "Diagonal's helper isn't registered with this browser yet. Installed with Homebrew? Run this in Terminal, then reopen Diagonal. Installed from a zip? Run: bash <the Diagonal folder>/install-host.command",
49+ body: "Diagonal's helper isn't registered with this browser yet. Run this in Terminal, then reopen Diagonal.",
5250 // A Web Store install has an update_url and may never have had the cask; an unpacked one came from it.
5351 command: chrome.runtime.getManifest().update_url ? "brew install iko-soy/tap/diagonal" : "brew reinstall diagonal",
5452 });
test/auto.test.ts−1
@@ -94,7 +94,6 @@ describe("auto-organize", () => {
9494 await auto.run(1);
9595 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 3", "Tab 2"]);
9696 expect(calls[0].existingGroups).toEqual([expect.objectContaining({ g: 0, title: "Rust" })]);
97− expect(calls[0].siblingTitles).toEqual(["Rust"]);
9897 expect(live.find((t) => t.id === 3)!.groupId).toBe(50);
9998 });
10099
test/naming.test.ts−8
@@ -299,14 +299,6 @@ describe("naming queue", () => {
299299 expect(writes).toEqual([[7, "🦀 Rust crates"]]);
300300 });
301301
302− it("the retry after a title the host rejected lists that title as not allowed", async () => {
303− replies.push({ ok: false, error: { code: "BAD_MODEL_OUTPUT", message: "rules", raw: JSON.stringify({ title: "Python", emoji: "🐍" }) } });
304− naming.touch(7);
305− await settle();
306− expect(calls).toHaveLength(2);
307− expect(calls[1].mustDifferFrom).toEqual(["Python"]);
308− });
309−
310302 it("a group renamed by hand while the model runs keeps the user's title", async () => {
311303 const n = new Naming({
312304 ...(naming as any).d,