on 333a70e help sign in
.github/workflows/release.yml+8−23
since splitsnapshots
unifiedsplit
@@ -9,11 +9,8 @@ on:
9permissions:
9permissions:
10 contents: write
10 contents: write
11
11
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.
14concurrency:
12concurrency:
15− group: release
13+ group: release-${{ github.sha }}
16− cancel-in-progress: false
17
14
18jobs:
15jobs:
19 release:
16 release:
@@ -35,23 +32,8 @@ jobs:
35
32
36 - name: Version from commit time
33 - name: Version from commit time
37 id: version
34 id: version
38− env:
39− GH_TOKEN: ${{ github.token }}
40 run: |
35 run: |
41− # The commit's minute, or the next free minute when another commit already released in it. A re-run
36+ version=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y.%m.%d.%H%M "$GITHUB_SHA")
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
55 echo "version=$version" >> "$GITHUB_OUTPUT"
37 echo "version=$version" >> "$GITHUB_OUTPUT"
56 echo "Version $version"
38 echo "Version $version"
57
39
@@ -72,19 +54,22 @@ jobs:
72 npm run zip:store
54 npm run zip:store
73
55
74 - name: Publish release
56 - name: Publish release
75− if: steps.version.outputs.published != 'true'
76 env:
57 env:
77 GH_TOKEN: ${{ github.token }}
58 GH_TOKEN: ${{ github.token }}
78 VERSION: ${{ steps.version.outputs.version }}
59 VERSION: ${{ steps.version.outputs.version }}
79 run: |
60 run: |
80 asset="diagonal-extension-${VERSION}.zip"
61 asset="diagonal-extension-${VERSION}.zip"
81 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"
62 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"
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
82− gh release create "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
67+ gh release create "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
68+ fi
83
69
84 # Uploads the release to the Chrome Web Store and submits it for review, once the listing exists and
70 # Uploads the release to the Chrome Web Store and submits it for review, once the listing exists and
85 # its API credentials are set as repository secrets (docs/store/listing.md). Skipped until then.
71 # its API credentials are set as repository secrets (docs/store/listing.md). Skipped until then.
86 - name: Upload to the Chrome Web Store
72 - name: Upload to the Chrome Web Store
87− if: steps.version.outputs.published != 'true'
88 env:
73 env:
89 CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
74 CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
90 CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
75 CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
host/diagonal-host.py+30−49
@@ -206,7 +206,7 @@ def count_tokens(prompt):
206 """fm's exact count (under 0.1 s), or None when it can't give one."""
206 """fm's exact count (under 0.1 s), or None when it can't give one."""
207 try:
207 try:
208 p = subprocess.run([FM, "count-tokens", "-q", "-i", prompt.instructions], input=prompt.text,
208 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())
210 except (OSError, subprocess.TimeoutExpired):
210 except (OSError, subprocess.TimeoutExpired):
211 return None
211 return None
212 out = p.stdout.strip()
212 out = p.stdout.strip()
@@ -255,41 +255,28 @@ PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
255USAGE_EXIT = 64
255USAGE_EXIT = 64
256
256
257
257
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):
258+def run_fm(prompt, schema, model, timeout_s):
264− """`prompt` is a prompts.Prompt: its fixed rules go to -i, the tab text on stdin. Every call in one
259+ """`prompt` is a prompts.Prompt: its fixed rules go to -i, the tab text on stdin."""
265− request shares its deadline, so the extension's guard (its timeout plus 10 s) is a real upper bound."""
266 try:
260 try:
267− return _run_fm(prompt, schema, opts, [])
261+ return _run_fm(prompt, schema, model, timeout_s, [])
268 except Fail as f:
262 except Fail as f:
269− if f.code != "GUARDRAIL" or time_left(opts) < MIN_CALL_S:
263+ if f.code != "GUARDRAIL":
270 raise
264 raise
271− return _run_fm(prompt, schema, opts, PERMISSIVE)
265+ return _run_fm(prompt, schema, model, timeout_s, PERMISSIVE)
272−
273−
274−def time_left(opts):
275− return opts["deadline"] - time.time()
276
266
277
267
278−def _run_fm(prompt, schema, opts, extra):
268+def _run_fm(prompt, schema, model, timeout_s, extra):
279 # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
269 # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
280 # in the process list. fm has no timeout of its own; subprocess enforces ours.
270 # in the process list. fm has no timeout of its own; subprocess enforces ours.
281 # --greedy: the same tabs get the same topics and name on every run (measured on macOS 27: five runs,
271 # --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
272+ # one answer), so groups don't get renamed or reshuffled each time Diagonal looks at them.
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]
273+ args = [FM, "respond", "--model", model, "--no-stream", "--greedy", "--schema", schema, "-i", prompt.instructions, *extra]
286− timeout_s = max(1.0, time_left(opts))
287 try:
274 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())
289 except FileNotFoundError:
276 except FileNotFoundError:
290 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
277 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
291 except subprocess.TimeoutExpired:
278 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)
293 if p.returncode != 0:
280 if p.returncode != 0:
294 code = classify(p.stderr, p.returncode)
281 code = classify(p.stderr, p.returncode)
295 if code == "LICENSE_REQUIRED":
282 if code == "LICENSE_REQUIRED":
@@ -308,11 +295,11 @@ def organize_mode():
308
295
309def op_ping(_payload, _opts):
296def op_ping(_payload, _opts):
310 if not os.path.exists(FM):
297 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,
312 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
299 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
313 license_required = False
300 license_required = False
314 try:
301 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())
316 ok, msg = avail.returncode == 0, (avail.stdout + avail.stderr).strip()[:300]
303 ok, msg = avail.returncode == 0, (avail.stdout + avail.stderr).strip()[:300]
317 if not ok and license_needed(avail.returncode, avail.stdout + avail.stderr):
304 if not ok and license_needed(avail.returncode, avail.stdout + avail.stderr):
318 license_required, msg = True, LICENSE_MESSAGE
305 license_required, msg = True, LICENSE_MESSAGE
@@ -380,7 +367,7 @@ def op_name(payload, opts):
380 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
367 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
381 prompt = fit(lambda cap: prompts.build_name_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
368 prompt = fit(lambda cap: prompts.build_name_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
382 try:
369 try:
383− out = run_fm(prompt, schema, opts)
370+ out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
384 except Fail as f:
371 except Fail as f:
385 raise _fit_overflow(f, len(payload["items"]))
372 raise _fit_overflow(f, len(payload["items"]))
386 try:
373 try:
@@ -396,7 +383,7 @@ def op_organize(payload, opts):
396 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'topics.json')}")
383 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'topics.json')}")
397 prompt = fit(lambda cap: prompts.build_topics_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
384 prompt = fit(lambda cap: prompts.build_topics_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
398 try:
385 try:
399− out = run_fm(prompt, schema, opts)
386+ out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
400 except Fail as f:
387 except Fail as f:
401 raise _fit_overflow(f, len(payload["items"]))
388 raise _fit_overflow(f, len(payload["items"]))
402 _, owner = prompts.topic_items(payload)
389 _, owner = prompts.topic_items(payload)
@@ -406,31 +393,29 @@ def op_organize(payload, opts):
406 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
393 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
407 chars = prompts.size(prompt)
394 chars = prompts.size(prompt)
408 if payload.get("nameGroups") is not False: # the extension's fit check only needs the topics
395 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"])
410 return result, chars, "topics"
397 return result, chars, "topics"
411
398
412
399
413−def name_new_groups(result, payload, opts):
400+def name_new_groups(result, payload, opts, deadline):
414 """Title each new group with the name op (well under a second each on fm), so groups appear named.
401 """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."""
402+ A group that fails, or runs past the deadline, keeps "" and the extension's naming loop 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]
403+ taken = [g.get("title") for g in payload.get("existingGroups") or [] if g.get("title")]
418 chars = 0
404 chars = 0
419 for g in result["groups"]:
405 for g in result["groups"]:
420 if "existing" in g or g.get("title"):
406 if "existing" in g or g.get("title"):
421 continue
407 continue
422− if time_left(opts) < MIN_CALL_S:
408+ left = deadline - time.time()
409+ if left < 5:
423 break
410 break
424 sub = {"items": [dict(payload["items"][i], i=n) for n, i in enumerate(g["members"])], "siblingTitles": taken[:20]}
411 sub = {"items": [dict(payload["items"][i], i=n) for n, i in enumerate(g["members"])], "siblingTitles": taken[:20]}
425 try:
412 try:
426− named, n, _ = op_name(sub, {**opts, "strict": False})
413+ named, n, _ = op_name(sub, {**opts, "timeout_s": min(opts["timeout_s"], left)})
427 except Fail:
414 except Fail:
428 continue
415 continue
429− chars += n
430− if any(validate.same_label(named["title"], t) for t in taken):
431− continue
432 g["title"], g["emoji"] = named["title"], named["emoji"]
416 g["title"], g["emoji"] = named["title"], named["emoji"]
433 taken.append(named["title"])
417 taken.append(named["title"])
418+ chars += n
434 return chars
419 return chars
435
420
436
421
@@ -444,8 +429,8 @@ def handle(req):
444 raw_opts = req.get("opts") or {}
429 raw_opts = req.get("opts") or {}
445 timeout_ms = raw_opts.get("timeoutMs", 45000)
430 timeout_ms = raw_opts.get("timeoutMs", 45000)
446 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
431 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))}
449 op = req["op"]
434 op = req["op"]
450 if op == "ping":
435 if op == "ping":
451 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
436 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
@@ -499,7 +484,7 @@ def install_schemas():
499 if "{%s}" % key not in args:
484 if "{%s}" % key not in args:
500 continue
485 continue
501 try:
486 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())
503 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
488 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
504 print(f" {name}: fm failed ({e})", file=sys.stderr)
489 print(f" {name}: fm failed ({e})", file=sys.stderr)
505 return "error"
490 return "error"
@@ -511,7 +496,7 @@ def install_schemas():
511 subs["{%s}" % key] = p.stdout.strip()
496 subs["{%s}" % key] = p.stdout.strip()
512 args = [subs.get(a, a) for a in args]
497 args = [subs.get(a, a) for a in args]
513 try:
498 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())
515 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
500 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
516 print(f" {name}: fm failed ({e})", file=sys.stderr)
501 print(f" {name}: fm failed ({e})", file=sys.stderr)
517 return "error"
502 return "error"
@@ -568,16 +553,14 @@ def selftest():
568
553
569 ping = op_ping({}, {})
554 ping = op_ping({}, {})
570 check("fm found", os.path.exists(FM), FM)
555 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"]:
574 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
557 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
575 else:
558 else:
576 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
559 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
577 for name in SCHEMA_COMMANDS:
560 for name in SCHEMA_COMMANDS:
578 path = os.path.join(SCHEMAS, name)
561 path = os.path.join(SCHEMAS, name)
579 readable = os.path.isfile(path) and os.path.getsize(path) > 0
562 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"
581 check(f"schema {name}", readable, path if readable else hint)
564 check(f"schema {name}", readable, path if readable else hint)
582 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
565 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
583 if ping["fmAvailable"] and schema_path("name.json"):
566 if ping["fmAvailable"] and schema_path("name.json"):
@@ -687,9 +670,7 @@ def register(host_path):
687 f.write("\n".join(written) + "\n")
670 f.write("\n".join(written) + "\n")
688 except OSError:
671 except OSError:
689 pass
672 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
693
674
694
675
695def unregister():
676def unregister():
host/tests/golden/labels.jsondeleted−102
@@ -1,102 +0,0 @@
1−[
2− [
3− "\"Rust async runtimes.\"",
4− "Rust async runtimes"
5− ],
6− [
7− "rust async runtimes",
8− "Rust async runtimes"
9− ],
10− [
11− "Rust tabs and docs",
12− "Rust and docs"
13− ],
14− [
15− "One two three four five six",
16− "One two three four"
17− ],
18− [
19− "Machine learning research papers reading list",
20− "Machine learning research"
21− ],
22− [
23− "iPhone camera tips",
24− "iPhone camera tips"
25− ],
26− [
27− "macOS updates",
28− "macOS updates"
29− ],
30− [
31− "Children's books",
32− "Children's books"
33− ],
34− [
35− "'Quoted' title words",
36− "Quoted title words"
37− ],
38− [
39− "Planning a trip to Japan",
40− "Planning a trip"
41− ],
42− [
43− "Recipes for",
44− null
45− ],
46− [
47− "Python教程",
48− "Python教程"
49− ],
50− [
51− "React 教程",
52− "React 教程"
53− ],
54− [
55− "日本旅行",
56− "日本旅行"
57− ],
58− [
59− "京都の旅行計画",
60− "京都の旅行計画"
61− ],
62− [
63− "日本旅行。",
64− "日本旅行"
65− ],
66− [
67− "这是一个非常非常非常非常非常长的标题名字",
68− "这是一个非常非常非常非常非常长的"
69− ],
70− [
71− "Kubernetes 集群部署与运维指南 最佳实践",
72− "Kubernetes 集群部署与运维指南"
73− ],
74− [
75− "ทริปญี่ปุ่น",
76− "ทริปญี่ปุ่น"
77− ],
78− [
79− "日",
80− null
81− ],
82− [
83− "Tabs",
84− null
85− ],
86− [
87− "Misc stuff",
88− null
89− ],
90− [
91− "",
92− null
93− ],
94− [
95− "x",
96− null
97− ],
98− [
99− "Supercalifragilisticexpialidocious extraordinarily",
100− null
101− ]
102−]
host/tests/test_host.py−56
@@ -287,19 +287,6 @@ class ErrorCodes(HostCase):
287 self.assertIn("Reply with only the object", argv[argv.index("-i") + 1])
287 self.assertIn("Reply with only the object", argv[argv.index("-i") + 1])
288
288
289
289
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−
303class Organize(HostCase):
290class Organize(HostCase):
304 ITEMS = ITEMS3 + [
291 ITEMS = ITEMS3 + [
305 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
292 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
@@ -344,33 +331,6 @@ class Organize(HostCase):
344 self.assertIn("Tokio", name_calls[0])
331 self.assertIn("Tokio", name_calls[0])
345 self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
332 self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
346
333
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−
374 def test_two_topics_joining_one_group_make_one_join(self):
334 def test_two_topics_joining_one_group_make_one_join(self):
375 # The group's two example tabs (8 and 9) got different topics; both topics still join group 0 once.
335 # The group's two example tabs (8 and 9) got different topics; both topics still join group 0 once.
376 self.topics([(3, "Travel"), (4, "Hotels"), (8, "Travel"), (9, "Hotels"), (0, "Programming")])
336 self.topics([(3, "Travel"), (4, "Hotels"), (8, "Travel"), (9, "Hotels"), (0, "Programming")])
@@ -527,15 +487,6 @@ class Register(HostCase):
527 self.assertEqual(p.returncode, 1)
487 self.assertEqual(p.returncode, 1)
528 self.assertIn("missing or not executable", p.stderr)
488 self.assertIn("missing or not executable", p.stderr)
529
489
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−
539 def test_no_browser_found(self):
490 def test_no_browser_found(self):
540 os.makedirs(self.env["DIAGONAL_APP_SUPPORT"])
491 os.makedirs(self.env["DIAGONAL_APP_SUPPORT"])
541 p = self.run_copy("--register", self.host_copy)
492 p = self.run_copy("--register", self.host_copy)
@@ -557,13 +508,6 @@ class SelfTest(HostCase):
557 self.assertEqual(code, 1)
508 self.assertEqual(code, 1)
558 self.assertIn("FAIL model available", p.stdout.decode())
509 self.assertIn("FAIL model available", p.stdout.decode())
559
510
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−
567 def test_selftest_names_the_license_fix(self):
511 def test_selftest_names_the_license_fix(self):
568 self.control({"license": True})
512 self.control({"license": True})
569 code, _, p = self.run_host(args=["--selftest"])
513 code, _, p = self.run_host(args=["--selftest"])
host/tests/test_prompts_validate.py+7−7
@@ -1,4 +1,3 @@
1−import json
2import os
1import os
3import unittest
2import unittest
4
3
@@ -74,12 +73,13 @@ class ValidateName(unittest.TestCase):
74 with self.assertRaises(validate.ValidationError, msg=bad):
73 with self.assertRaises(validate.ValidationError, msg=bad):
75 self.v(bad)
74 self.v(bad)
76
75
77− def test_same_labels_as_the_extension(self):
76+ def test_chinese_and_japanese_titles_need_no_spaces(self):
78− # test/url.test.ts runs these cases against src/shared/label.ts.
77+ self.assertEqual(self.v("日本旅行")["title"], "日本旅行")
79− with open(os.path.join(GOLDEN, "labels.json"), encoding="utf-8") as f:
78+ self.assertEqual(self.v("京都の旅行計画")["title"], "京都の旅行計画")
80− cases = json.load(f)
79+ self.assertEqual(self.v("React 教程")["title"], "React 教程")
81− for raw, fixed in cases:
80+ for bad in ("日", "这是一个非常非常非常非常非常长的标题名字"):
82− self.assertEqual(validate.repair_label(raw), fixed, msg=raw)
81+ with self.assertRaises(validate.ValidationError, msg=bad):
82+ self.v(bad)
83
83
84 def test_emoji_default(self):
84 def test_emoji_default(self):
85 self.assertEqual(self.v("Rust async", "not-an-emoji")["emoji"], "🧭")
85 self.assertEqual(self.v("Rust async", "not-an-emoji")["emoji"], "🧭")
host/validate.py+14−92
@@ -7,6 +7,7 @@ from urllib.parse import urlsplit
7HERE = os.path.dirname(os.path.realpath(__file__))
7HERE = os.path.dirname(os.path.realpath(__file__))
8COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]
8COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]
9BANNED_WORDS = {"tab", "tabs", "group", "groups", "misc", "various", "stuff"}
9BANNED_WORDS = {"tab", "tabs", "group", "groups", "misc", "various", "stuff"}
10+QUOTES = "\"'“”‘’«»`"
10TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—。、,!?:;]+$")
11TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—。、,!?:;]+$")
11
12
12
13
@@ -88,82 +89,39 @@ def default_emoji(items):
88 return CATEGORIES[category_for_host(majority)][0]
89 return CATEGORIES[category_for_host(majority)][0]
89
90
90
91
91−# Quotes go; an apostrophe between two letters ("Children's", "Rock'n'roll") is part of the word and stays.
92−_QUOTE = re.compile(r"(?<![^\W\d_])['’]|['’](?![^\W\d_])|[\"“”‘«»`]")
93−# A label cut short should not end mid-phrase: "Planning a trip to" reads as "Planning a trip".
94−DANGLING = {"a", "an", "the", "to", "of", "for", "in", "on", "at", "by", "and", "or", "with", "from"}
95−
96−
97def repair_label(raw):
92def repair_label(raw):
98− """Trim, drop quotes, banned words and trailing punctuation, cap at 4 words / 28 chars (16 wide characters
93+ """Trim, drop quotes, banned words and trailing punctuation, cap at 4 words / 28 chars, capitalise."""
99− for no-space scripts), drop a dangling "to"/"of", capitalise. Kept in step with src/shared/label.ts."""
100 if not isinstance(raw, str):
94 if not isinstance(raw, str):
101 return None
95 return None
102− s = _QUOTE.sub("", raw)
96+ s = raw.translate({ord(q): None for q in QUOTES})
103 s = TRAILING_PUNCT.sub("", " ".join(s.split()))
97 s = TRAILING_PUNCT.sub("", " ".join(s.split()))
104 words = [w for w in s.split(" ") if w and re.sub(r"[^\w]", "", w.lower()) not in BANNED_WORDS]
98 words = [w for w in s.split(" ") if w and re.sub(r"[^\w]", "", w.lower()) not in BANNED_WORDS]
105 words = words[:4]
99 words = words[:4]
106− if _no_space(" ".join(words)):
107− while len(words) > 1 and _width(" ".join(words)) > 16:
108− words.pop()
109− if _width(" ".join(words)) > 16:
110− words = [_cut_to_width(" ".join(words), 16)]
111− else:
112− while len(words) > 2 and len(" ".join(words)) > 28:
100+ while len(words) > 2 and len(" ".join(words)) > 28:
113− words.pop()
114− while len(words) > 1 and words[-1].lower() in DANGLING:
115 words.pop()
101 words.pop()
116 label = TRAILING_PUNCT.sub("", " ".join(words))
102 label = TRAILING_PUNCT.sub("", " ".join(words))
117 if not label:
103 if not label:
118 return None
104 return None
119− # "rust async" becomes "Rust async"; "iPhone tips" and "macOS updates" keep the brand's own casing.
120− if not any(c.isupper() for c in words[0][1:]):
121− label = label[0].upper() + label[1:]
105+ label = label[0].upper() + label[1:]
122 return label if is_valid_label(label) else None
106 return label if is_valid_label(label) else None
123
107
124
108
125−# Chinese, Japanese, Korean, Thai, Lao, Khmer and Myanmar titles don't put spaces between words, so a label
109+# Chinese and Japanese titles have no spaces between words, so they're checked by length instead.
126−# with any of their characters ("Python教程") is checked by width instead of word count. Each such character
127−# is about as wide as two Latin letters on the tab strip.
128−NO_SPACE = re.compile(r"[\u0e00-\u0eff\u1000-\u109f\u1780-\u17ff\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]")
129−# Chinese, Japanese and Korean, for the host's token estimate.
130CJK = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]")
110CJK = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]")
131
111
132
112
133−def _no_space(label):
134− return bool(NO_SPACE.search(label))
135−
136−
137−def _width(label):
138− wide = len(NO_SPACE.findall(label))
139− return wide + (len(label) - wide) / 2
140−
141−
142−def _cut_to_width(label, most):
143− out = ""
144− for ch in label:
145− if _width(out + ch) > most:
146− break
147− out += ch
148− return out.strip()
149−
150−
151def is_valid_label(label):
113def is_valid_label(label):
152 words = label.split()
114 words = label.split()
153− wide = _no_space(label)
115+ cjk = len(CJK.findall(label)) * 2 > len(label.replace(" ", ""))
154 return (
116 return (
155− (2 <= len(label) <= 28 and _width(label) <= 16 if wide else 3 <= len(label) <= 28)
117+ (2 <= len(label) <= 16 if cjk else 3 <= len(label) <= 28)
156− and (1 <= len(words) <= 4 if wide else 2 <= len(words) <= 4)
118+ and (1 <= len(words) <= 4 if cjk else 2 <= len(words) <= 4)
157− and not _QUOTE.search(label)
119+ and not any(q in label for q in QUOTES)
158 and not TRAILING_PUNCT.search(label)
120 and not TRAILING_PUNCT.search(label)
159 and not any(w.lower() in BANNED_WORDS for w in words)
121 and not any(w.lower() in BANNED_WORDS for w in words)
160 )
122 )
161
123
162
124
163−def same_label(a, b):
164− return bool(a) and bool(b) and " ".join(a.split()).casefold() == " ".join(b.split()).casefold()
165−
166−
167def validate_name(out, payload):
125def validate_name(out, payload):
168 if not isinstance(out, dict):
126 if not isinstance(out, dict):
169 raise ValidationError("reply is not an object")
127 raise ValidationError("reply is not an object")
@@ -269,52 +227,16 @@ def organize_from_topics(out, payload, owner):
269 votes = group_votes.setdefault(topic, {})
227 votes = group_votes.setdefault(topic, {})
270 votes[owner[i]] = votes.get(owner[i], 0) + 1
228 votes[owner[i]] = votes.get(owner[i], 0) + 1
271 if not tab_topic:
229 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")
230+ 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)
276 clusters = {}
231 clusters = {}
277 for i in sorted(tab_topic):
232 for i in sorted(tab_topic):
278 clusters.setdefault(tab_topic[i], []).append(i)
233 clusters.setdefault(tab_topic[i], []).append(i)
279 groups = []
234 groups = []
280 for topic, members in clusters.items():
235 for topic, members in clusters.items():
281− g = _winner(group_votes.get(topic) or {}, members, payload)
236+ votes = group_votes.get(topic)
282− if g is not None:
237+ if votes:
238+ g = max(sorted(votes), key=lambda k: votes[k])
283 groups.append({"existing": g, "members": members})
239 groups.append({"existing": g, "members": members})
284 else:
240 else:
285 groups.append({"title": "", "emoji": "", "color": "", "members": members})
241 groups.append({"title": "", "emoji": "", "color": "", "members": members})
286 return validate_organize({"groups": groups, "leftovers": []}, payload)
242 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"
53"$BIN" --version >/dev/null # the host must start with the python3 it was pinned to
53"$BIN" --version >/dev/null # the host must start with the python3 it was pinned to
54# Writes the manifest into every Chromium browser's NativeMessagingHosts folder (Chrome, Brave and
54# Writes the manifest into every Chromium browser's NativeMessagingHosts folder (Chrome, Brave and
55# Brave Origin, Edge, Vivaldi, Arc, Opera, …) and checks each one the way the browser reads it.
55# 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=$?
56+"$BIN" --register "$BIN"
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
66
57
67FM="${DIAGONAL_FM:-/usr/bin/fm}"
58FM="${DIAGONAL_FM:-/usr/bin/fm}"
68
59
@@ -98,7 +89,7 @@ fi
98# Diagonal also writes these on first use; doing it now lets the self-test below cover the model.
89# Diagonal also writes these on first use; doing it now lets the self-test below cover the model.
99schemas_out=$("$BIN" --install-schemas 2>&1) || true
90schemas_out=$("$BIN" --install-schemas 2>&1) || true
100if selftest_out=$("$BIN" --selftest 2>&1); then
91if 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."
102else
93else
103 echo "Diagonal's host is installed, but its self-test found a problem:"
94 echo "Diagonal's host is installed, but its self-test found a problem:"
104 echo "$schemas_out"
95 echo "$schemas_out"
src/background/host.ts−3
@@ -102,9 +102,6 @@ export function normalizeReply<T>(reply: unknown, at: number): HostReply<T> {
102 };
102 };
103}
103}
104
104
105−/** Errors about what one request carried, not about the host: they don't count toward pausing it. */
106−export const REQUEST_ERRORS = new Set<HostErrorCode>(["GUARDRAIL", "BAD_MODEL_OUTPUT", "OVER_BUDGET", "BAD_REQUEST"]);
107−
108/** Errors that mean "fix the setup", not "try this group again later". */
105/** Errors that mean "fix the setup", not "try this group again later". */
109export const SETUP_ERRORS = new Set<HostErrorCode>([
106export const SETUP_ERRORS = new Set<HostErrorCode>([
110 "HOST_NOT_FOUND", "HOST_FORBIDDEN", "FORBIDDEN_ORIGIN", "SCHEMA_MISSING", "MODEL_UNAVAILABLE", "LICENSE_REQUIRED",
107 "HOST_NOT_FOUND", "HOST_FORBIDDEN", "FORBIDDEN_ORIGIN", "SCHEMA_MISSING", "MODEL_UNAVAILABLE", "LICENSE_REQUIRED",
src/background/index.ts+10−26
@@ -1,6 +1,6 @@
1import { stripTitle } from "../shared/label";
1import { stripTitle } from "../shared/label";
2import { applyEvent, bury, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
2import { applyEvent, bury, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
3−import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, REQUEST_ERRORS, SETUP_ERRORS, type HostOpts, type HostReply, type Op } from "./host";
3+import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, SETUP_ERRORS, type HostOpts, type HostReply, type Op } from "./host";
4import { GLOBAL_PAUSE_AFTER, GLOBAL_PAUSE_MS, Naming, RATE_LIMIT_PAUSES_MS, type Member, type NamePayload, type NameResult } from "./naming";
4import { GLOBAL_PAUSE_AFTER, GLOBAL_PAUSE_MS, Naming, RATE_LIMIT_PAUSES_MS, type Member, type NamePayload, type NameResult } from "./naming";
5import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
5import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
6import { FitChecker } from "./fit";
6import { FitChecker } from "./fit";
@@ -93,11 +93,7 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
93 h.lastOkAt = Date.now();
93 h.lastOkAt = Date.now();
94 h.consecutiveFailures = 0;
94 h.consecutiveFailures = 0;
95 h.consecutiveTimeouts = 0;
95 h.consecutiveTimeouts = 0;
96− if (op !== "ping") {
96+ h.rateLimitStep = 0;
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− }
101 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
97 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
102 } else {
98 } else {
103 recordHostError(reply.error);
99 recordHostError(reply.error);
@@ -111,8 +107,6 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
111}
107}
112
108
113function recordHostError(e: HostError): void {
109function recordHostError(e: HostError): void {
114− // About one request's tabs, not the host's health: the caller retries or sets those tabs aside.
115− if (REQUEST_ERRORS.has(e.code)) return;
116 const h = state.host;
110 const h = state.host;
117 const now = Date.now();
111 const now = Date.now();
118 h.lastError = { ...e, at: now };
112 h.lastError = { ...e, at: now };
@@ -144,7 +138,11 @@ async function ping(): Promise<HostReply<PingResult>> {
144 } else if (!reply.result.schemasOk) {
138 } else if (!reply.result.schemasOk) {
145 recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
139 recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
146 } else {
140 } else {
147− recovered();
141+ h.pausedUntil = undefined;
142+ h.lastError = undefined;
143+ chrome.alarms.clear(ALARM_HOST);
144+ void naming.sweepDirty();
145+ void autoOrganizer.sweep();
148 }
146 }
149 }
147 }
150 commit();
148 commit();
@@ -152,16 +150,6 @@ async function ping(): Promise<HostReply<PingResult>> {
152 return reply;
150 return reply;
153}
151}
154
152
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−
165// ----- badge -----------------------------------------------------------------------------------
153// ----- badge -----------------------------------------------------------------------------------
166
154
167function busy(on: boolean): void {
155function busy(on: boolean): void {
@@ -173,6 +161,7 @@ function hostUnhealthy(): boolean {
173 const h = state.host;
161 const h = state.host;
174 const now = Date.now();
162 const now = Date.now();
175 if (h.pausedUntil && h.pausedUntil > now) return true;
163 if (h.pausedUntil && h.pausedUntil > now) return true;
164+ if (h.consecutiveFailures >= 3) return true;
176 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
165 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
177}
166}
178
167
@@ -512,14 +501,12 @@ if (GROUPS_SUPPORTED) {
512
501
513 chrome.tabs.onReplaced.addListener((added, removed) =>
502 chrome.tabs.onReplaced.addListener((added, removed) =>
514 void serial("tabs.onReplaced", async () => {
503 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.
517 const rec = state.tabs[removed];
504 const rec = state.tabs[removed];
518 delete state.tabs[removed];
505 delete state.tabs[removed];
519 const t = await tabById(added);
506 const t = await tabById(added);
520 if (t) {
507 if (t) {
521− if (rec && !state.tabs[added]) state.tabs[added] = { ...rec, id: added };
522 await feed({ type: "tabUpdated", tab: snapTab(t) });
508 await feed({ type: "tabUpdated", tab: snapTab(t) });
509+ if (rec?.description && state.tabs[added]) state.tabs[added].description = rec.description;
523 }
510 }
524 }),
511 }),
525 );
512 );
@@ -614,12 +601,9 @@ async function rewriteTitles(before: Settings): Promise<void> {
614async function nameGroupNow(groupId: number): Promise<void> {
601async function nameGroupNow(groupId: number): Promise<void> {
615 const g = state.groups[groupId];
602 const g = state.groups[groupId];
616 if (!g) return;
603 if (!g) return;
617− // An explicit request overrides "don't name" and a hand-typed title for this group, and the model's
604+ // An explicit request overrides "don't name" and a hand-typed title for this group.
618− // answer goes on the strip even when it is close to the label it gave before.
619 g.managed = true;
605 g.managed = true;
620 g.userNamed = false;
606 g.userNamed = false;
621− g.title = undefined;
622− g.lastNamedAt = undefined;
623 commit();
607 commit();
624 await naming.nameNow(groupId);
608 await naming.nameNow(groupId);
625}
609}
src/background/naming.ts+23−75
@@ -1,7 +1,6 @@
1import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, titleKey, LABEL_MAX_CHARS } from "../shared/label";
1import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, titleKey, LABEL_MAX_CHARS } from "../shared/label";
2import { sha1 } from "../shared/sha1";
2import { sha1 } from "../shared/sha1";
3import { isInternalUrl, promptUrl, trimText } from "../shared/url";
3import { isInternalUrl, promptUrl, trimText } from "../shared/url";
4−import { pathKey } from "./engine";
5import type { HostOpts, HostReply } from "./host";
4import type { HostOpts, HostReply } from "./host";
6import { SETUP_ERRORS } from "./host";
5import { SETUP_ERRORS } from "./host";
7import type { Settings } from "./settings";
6import type { Settings } from "./settings";
@@ -114,9 +113,8 @@ export function shouldApply(g: GroupRecord, label: string, urls: string[]): bool
114 if (g.userNamed) return false;
113 if (g.userNamed) return false;
115 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
114 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
116 if (sameLabel(label, g.title)) return false;
115 if (sameLabel(label, g.title)) return false;
117− // By page, not by exact address: a tab that moved to ?page=2 or #comments is the same member.
118− const before = new Set((g.memberUrls ?? []).map(pathKey));
116+ const before = new Set(g.memberUrls ?? []);
119− const after = new Set(urls.map(pathKey));
117+ const after = new Set(urls);
120 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
118 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
121 return changed; // a paraphrase with the same members keeps the old title
119 return changed; // a paraphrase with the same members keeps the old title
122}
120}
@@ -216,32 +214,18 @@ export class Naming {
216 }
214 }
217 const now = this.d.now();
215 const now = this.d.now();
218 if (!force) {
216 if (!force) {
219− // A refusal waits for the members to change, which only a look at them can tell.
220− if (g.nextAttemptAt && g.nextAttemptAt > now && !g.refusedHash) return;
217+ if (g.nextAttemptAt && g.nextAttemptAt > now) return;
221 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
218 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
222 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
219 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
223 }
220 }
224 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
221 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
225 if (!members) return;
222 if (!members) return;
226− if (!members.every(settled)) return; // stay dirty; the load finishing touches it again
223+ if (members.length < 2 || !members.every(settled)) return; // stay dirty; the load finishing touches it again
227− if (members.length < 2) {
224+ if (!force && membersHash(members) === g.membersHash) {
228− // Nothing to name from yet. A tab joining or loading a page marks the group dirty again.
229− if (g.dirty && !force) {
230− g.dirty = false;
225+ g.dirty = false;
231− this.d.commit();
232− }
233− return;
234− }
235− const hash = membersHash(members);
236− if (!force && hash === g.membersHash) {
237− settle(g);
238 this.d.commit();
226 this.d.commit();
239 return;
227 return;
240 }
228 }
241− if (g.refusedHash && !force) {
242− if (hash === g.refusedHash && g.nextAttemptAt && g.nextAttemptAt > now) return;
243− clearFailures(g); // different tabs now: Apple's filter may well take them
244− }
245 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
229 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
246 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
230 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
247 void this.pump();
231 void this.pump();
@@ -277,7 +261,7 @@ export class Naming {
277 if (!members || members.length < 2 || !members.every(settled)) return;
261 if (!members || members.length < 2 || !members.every(settled)) return;
278 const hash = membersHash(members);
262 const hash = membersHash(members);
279 if (!force && hash === g.membersHash) {
263 if (!force && hash === g.membersHash) {
280− settle(g);
264+ g.dirty = false;
281 this.d.commit();
265 this.d.commit();
282 return;
266 return;
283 }
267 }
@@ -312,10 +296,9 @@ export class Naming {
312 }
296 }
313 if (e.code === "BAD_MODEL_OUTPUT" && !retried.output) {
297 if (e.code === "BAD_MODEL_OUTPUT" && !retried.output) {
314 retried.output = true;
298 retried.output = true;
315− mustDifferFrom = withRejected(mustDifferFrom, e.raw);
316 continue;
299 continue;
317 }
300 }
318− this.fail(g, e, hash);
301+ this.fail(g, e);
319 return;
302 return;
320 }
303 }
321
304
@@ -324,10 +307,9 @@ export class Naming {
324 const e: HostError = { code: "BAD_MODEL_OUTPUT", message: "label failed worker validation", raw: JSON.stringify(reply.result).slice(0, 500), at: this.d.now() };
307 const e: HostError = { code: "BAD_MODEL_OUTPUT", message: "label failed worker validation", raw: JSON.stringify(reply.result).slice(0, 500), at: this.d.now() };
325 if (!retried.output) {
308 if (!retried.output) {
326 retried.output = true;
309 retried.output = true;
327− mustDifferFrom = withRejected(mustDifferFrom, e.raw);
328 continue;
310 continue;
329 }
311 }
330− this.fail(g, e, hash);
312+ this.fail(g, e);
331 return;
313 return;
332 }
314 }
333 const emoji = safeEmoji(reply.result.emoji);
315 const emoji = safeEmoji(reply.result.emoji);
@@ -342,15 +324,12 @@ export class Naming {
342 finalLabel = suffixLabel(label, siblings);
324 finalLabel = suffixLabel(label, siblings);
343 }
325 }
344 this.d.hostOk();
326 this.d.hostOk();
345− const written = await this.apply(g, finalLabel, emoji, members, hash);
327+ await this.apply(g, finalLabel, emoji, members, hash);
346 g = this.d.state().groups[groupId];
328 g = this.d.state().groups[groupId];
347− if (!g) return;
329+ if (g) {
348− if (!written) {
349− // The strip refused the title (a drag in progress, the window closing): try again shortly.
350− g.nextAttemptAt = this.d.now() + BACKOFF_MS[0];
351− this.d.scheduleFallback(g.nextAttemptAt);
352− } else if (g.dirtySeq === seq) g.dirty = false;
330+ if (g.dirtySeq === seq) g.dirty = false;
353− else this.touch(groupId); // dirtied while in flight: go again with the new membership
331+ else this.touch(groupId); // dirtied while in flight: go again with the new membership
332+ }
354 return;
333 return;
355 }
334 }
356 } finally {
335 } finally {
@@ -361,20 +340,16 @@ export class Naming {
361 }
340 }
362 }
341 }
363
342
364− /** Put the label on the strip if the policy allows; false when the strip would not take the title. */
365− private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<boolean> {
343+ private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<void> {
366 const settings = this.d.settings();
344 const settings = this.d.settings();
367 const urls = memberUrls(members);
345 const urls = memberUrls(members);
368− if (g.userNamed) return true; // renamed by hand while the model was thinking
346+ if (g.userNamed) return; // renamed by hand while the model was thinking
369 if (shouldApply(g, label, urls)) {
347 if (shouldApply(g, label, urls)) {
370 const title = stripTitle(label, emoji, settings.emoji);
348 const title = stripTitle(label, emoji, settings.emoji);
371 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
349 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
372 this.d.commit();
350 this.d.commit();
373 const ok = await this.d.writeTitle(g.id, title);
351 const ok = await this.d.writeTitle(g.id, title);
374− if (!ok) {
352+ if (!ok) return;
375− delete this.d.state().ownWrites[g.id];
376− return false;
377− }
378 g.title = label;
353 g.title = label;
379 g.emoji = emoji;
354 g.emoji = emoji;
380 g.stripTitle = title;
355 g.stripTitle = title;
@@ -382,24 +357,21 @@ export class Naming {
382 g.lastNamedAt = this.d.now();
357 g.lastNamedAt = this.d.now();
383 g.membersHash = hash;
358 g.membersHash = hash;
384 g.memberUrls = urls;
359 g.memberUrls = urls;
385− clearFailures(g);
360+ g.nameAttempts = 0;
386− return true;
361+ g.firstFailureAt = undefined;
362+ g.nextAttemptAt = undefined;
387 }
363 }
388
364
389− private fail(g: GroupRecord, e: HostError, hash: string): void {
365+ private fail(g: GroupRecord, e: HostError): void {
390 const now = this.d.now();
366 const now = this.d.now();
391 this.d.hostFailed(e);
367 this.d.hostFailed(e);
392 if (e.code === "BAD_REQUEST") {
368 if (e.code === "BAD_REQUEST") {
393 g.dirty = false; // a bug on our side: log and drop
369 g.dirty = false; // a bug on our side: log and drop
394 return;
370 return;
395 }
371 }
396− // Setup errors pause every model call until the host is fixed, and its recovery picks this group up
397− // again: they say nothing about this group, so they don't use up its retries.
398− if (SETUP_ERRORS.has(e.code)) return;
399 if (e.code === "GUARDRAIL") {
372 if (e.code === "GUARDRAIL") {
400 g.nameAttempts = MAX_ATTEMPTS;
373 g.nameAttempts = MAX_ATTEMPTS;
401 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
374 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
402− g.refusedHash = hash;
403 this.d.scheduleFallback(g.nextAttemptAt);
375 this.d.scheduleFallback(g.nextAttemptAt);
404 return;
376 return;
405 }
377 }
@@ -409,35 +381,11 @@ export class Naming {
409 g.dirty = false; // retried for a day: keep the provisional title
381 g.dirty = false; // retried for a day: keep the provisional title
410 return;
382 return;
411 }
383 }
384+ const wait = SETUP_ERRORS.has(e.code) ? Math.max(GLOBAL_PAUSE_MS, backoffFor(g.nameAttempts)) : backoffFor(g.nameAttempts);
412− g.nextAttemptAt = now + backoffFor(g.nameAttempts);
385+ g.nextAttemptAt = now + wait;
413 this.d.scheduleFallback(g.nextAttemptAt);
386 this.d.scheduleFallback(g.nextAttemptAt);
414 }
387 }
415}
388}
416
389
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−
428−/** The retry bookkeeping of earlier failures, which no longer applies. */
429−function clearFailures(g: GroupRecord): void {
430− g.nameAttempts = 0;
431− g.firstFailureAt = undefined;
432− g.nextAttemptAt = undefined;
433− g.refusedHash = undefined;
434−}
435−
436−/** The members are the ones last named: nothing to do, and nothing left to retry. */
437−function settle(g: GroupRecord): void {
438− g.dirty = false;
439− clearFailures(g);
440−}
441−
442export const eligible = (g: GroupRecord, settings: Settings): boolean =>
390export const eligible = (g: GroupRecord, settings: Settings): boolean =>
443 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";
391 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";
src/background/organize.ts+3−11
@@ -27,8 +27,6 @@ export interface ExistingGroup {
27export interface OrganizePayload {
27export interface OrganizePayload {
28 items: NameItem[];
28 items: NameItem[];
29 existingGroups: ExistingGroup[];
29 existingGroups: ExistingGroup[];
30− /** Every group label in the window: a new group the host names must not repeat one. */
31− siblingTitles?: string[];
32 allowNew: boolean;
30 allowNew: boolean;
33 maxGroups: number;
31 maxGroups: number;
34 minGroupSize: number;
32 minGroupSize: number;
@@ -179,17 +177,11 @@ const CONTENT_ERRORS = new Set<HostErrorCode>(["OVER_BUDGET", "GUARDRAIL", "BAD_
179const MAX_TIMEOUT_SPLITS = 1;
177const MAX_TIMEOUT_SPLITS = 1;
180
178
181/** One organize call, with the retries that can rescue the same tabs: strict output, then no page text. */
179/** 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>> {
183 const settings = rt.settings();
181 const settings = rt.settings();
184− const s = rt.state();
185 const payload: OrganizePayload = {
182 const payload: OrganizePayload = {
186 items: toItems(part, settings, withDescriptions),
183 items: toItems(part, settings, withDescriptions),
187 existingGroups,
184 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),
193 allowNew: part.length >= 2,
185 allowNew: part.length >= 2,
194 maxGroups: maxGroupsFor(part.length),
186 maxGroups: maxGroupsFor(part.length),
195 minGroupSize: settings.organizeMinGroupSize,
187 minGroupSize: settings.organizeMinGroupSize,
@@ -232,7 +224,7 @@ export async function sortPart(rt: Runtime, part: Member[], run: SortRun, probe
232 run.left += part.length;
224 run.left += part.length;
233 return; // one tab and nothing to join: no call can place it (a probe still learns whether the model takes it)
225 return; // one tab and nothing to join: no call can place it (a probe still learns whether the model takes it)
234 }
226 }
235− const reply = await askOrganize(rt, part, run.windowId, existingGroups, true);
227+ const reply = await askOrganize(rt, part, existingGroups, true);
236 if (!reply.ok) {
228 if (!reply.ok) {
237 const e = reply.error;
229 const e = reply.error;
238 const timedOut = e.code === "TIMEOUT" && part.length > 4 && run.timeoutSplits < MAX_TIMEOUT_SPLITS;
230 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
313 report.left = tabs.length;
305 report.left = tabs.length;
314 return report;
306 return report;
315 }
307 }
316− const run = newRun(windowId, true); // auto-organize need not send the same tabs again right after
308+ const run = newRun(windowId, false);
317 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
309 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
318 rt.refreshBadge();
310 rt.refreshBadge();
319 try {
311 try {
src/background/state.ts−1
@@ -47,7 +47,6 @@ export interface GroupRecord {
47 nameAttempts: number;
47 nameAttempts: number;
48 firstFailureAt?: number;
48 firstFailureAt?: number;
49 nextAttemptAt?: number;
49 nextAttemptAt?: number;
50− refusedHash?: string; // the members Apple's filter refused to name: not asked again until they change
51 registeredAt?: number; // first seen as a group Diagonal did not know: it may yet turn out to be one of Diagonal's coming back
50 registeredAt?: number; // first seen as a group Diagonal did not know: it may yet turn out to be one of Diagonal's coming back
52 revivedAt?: number; // came back after Chromium removed it (window move, reopen, restart)
51 revivedAt?: number; // came back after Chromium removed it (window move, reopen, restart)
53 restored?: boolean; // "Restored": the archive brought back, not a topic other tabs should join
52 restored?: boolean; // "Restored": the archive brought back, not a topic other tabs should join
src/shared/health.ts+2−4
@@ -46,9 +46,7 @@ export function notices(s: Health): Notice[] {
46 case "HOST_NOT_FOUND":
46 case "HOST_NOT_FOUND":
47 out.push({
47 out.push({
48 tone: "bad", title: "Finish setting up Diagonal",
48 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."
49+ body: "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",
52 // A Web Store install has an update_url and may never have had the cask; an unpacked one came from it.
50 // A Web Store install has an update_url and may never have had the cask; an unpacked one came from it.
53 command: chrome.runtime.getManifest().update_url ? "brew install iko-soy/tap/diagonal" : "brew reinstall diagonal",
51 command: chrome.runtime.getManifest().update_url ? "brew install iko-soy/tap/diagonal" : "brew reinstall diagonal",
54 });
52 });
@@ -82,7 +80,7 @@ export function notices(s: Health): Notice[] {
82 out.push({ tone: "bad", title: "Naming is paused", body: s.message || "The helper didn't answer. Diagonal retries on its own." });
80 out.push({ tone: "bad", title: "Naming is paused", body: s.message || "The helper didn't answer. Diagonal retries on its own." });
83 }
81 }
84 }
82 }
85− if (s.timeoutHint) {
83+ if (s.timeoutHint && s.health !== "degraded") {
86 out.push({ tone: "info", title: "The model is slow right now", body: "It timed out three times in a row. You can give it longer under Naming in Settings." });
84 out.push({ tone: "info", title: "The model is slow right now", body: "It timed out three times in a row. You can give it longer under Naming in Settings." });
87 }
85 }
88 return out;
86 return out;
src/shared/label.ts+9−35
@@ -7,12 +7,9 @@ export const LABEL_MAX_CHARS = 28;
7export const LABEL_MIN_WORDS = 2;
7export const LABEL_MIN_WORDS = 2;
8export const LABEL_MAX_WORDS = 4;
8export const LABEL_MAX_WORDS = 4;
9
9
10−// Quotes go; an apostrophe between two letters ("Children's", "Rock'n'roll") is part of the word and stays.
11−const QUOTES = /(?<!\p{L})['’]|['’](?!\p{L})|["“”‘«»`]/gu;
10+const QUOTES = /["'“”‘’«»`]/g;
12−const HAS_QUOTE = /(?<!\p{L})['’]|['’](?!\p{L})|["“”‘«»`]/u;
11+const HAS_QUOTE = /["'“”‘’«»`]/;
13const TRAILING_PUNCT = /[\s.,;:!?…\-–—。、,!?:;]+$/u;
12const TRAILING_PUNCT = /[\s.,;:!?…\-–—。、,!?:;]+$/u;
14−// A label cut short should not end mid-phrase: "Planning a trip to" reads as "Planning a trip".
15−const DANGLING = new Set(["a", "an", "the", "to", "of", "for", "in", "on", "at", "by", "and", "or", "with", "from"]);
16
13
17/** Repair what is repairable; return undefined when the label cannot be made valid. */
14/** Repair what is repairable; return undefined when the label cannot be made valid. */
18export function repairLabel(raw: unknown): string | undefined {
15export function repairLabel(raw: unknown): string | undefined {
@@ -20,49 +17,26 @@ export function repairLabel(raw: unknown): string | undefined {
20 let words = raw.replace(QUOTES, "").replace(/\s+/g, " ").trim().replace(TRAILING_PUNCT, "").split(" ").filter(Boolean);
17 let words = raw.replace(QUOTES, "").replace(/\s+/g, " ").trim().replace(TRAILING_PUNCT, "").split(" ").filter(Boolean);
21 words = words.filter((w) => !BANNED_WORDS.has(w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "")));
18 words = words.filter((w) => !BANNED_WORDS.has(w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "")));
22 if (words.length > LABEL_MAX_WORDS) words = words.slice(0, LABEL_MAX_WORDS);
19 if (words.length > LABEL_MAX_WORDS) words = words.slice(0, LABEL_MAX_WORDS);
23− if (noSpace(words.join(" "))) {
24− while (words.length > 1 && widthOf(words.join(" ")) > CJK_MAX_CHARS) words.pop();
25− if (widthOf(words.join(" ")) > CJK_MAX_CHARS) words = [cutToWidth(words.join(" "), CJK_MAX_CHARS)];
26− } else {
27− while (words.length > LABEL_MIN_WORDS && words.join(" ").length > LABEL_MAX_CHARS) words.pop();
20+ while (words.length > LABEL_MIN_WORDS && words.join(" ").length > LABEL_MAX_CHARS) words.pop();
28− }
29− while (words.length > 1 && DANGLING.has(words[words.length - 1].toLowerCase())) words.pop();
30 let label = words.join(" ").replace(TRAILING_PUNCT, "");
21 let label = words.join(" ").replace(TRAILING_PUNCT, "");
31 if (!label) return undefined;
22 if (!label) return undefined;
32− // "rust async" becomes "Rust async"; "iPhone tips" and "macOS updates" keep the brand's own casing.
33− if (!/\p{Lu}/u.test(words[0].slice(1))) label = label[0].toLocaleUpperCase() + label.slice(1);
23+ label = label[0].toLocaleUpperCase() + label.slice(1);
34 return isValidLabel(label) ? label : undefined;
24 return isValidLabel(label) ? label : undefined;
35}
25}
36
26
37−/**
38− * Chinese, Japanese, Korean, Thai, Lao, Khmer and Myanmar titles don't put spaces between words, so a label
27+/** Chinese, Japanese and Korean titles have no spaces between words, so they're checked by length instead. */
39− * with any of their characters ("Python教程") is checked by width instead of word count. Each such character
40− * is about as wide as two Latin letters on the tab strip.
41− */
42−const NO_SPACE = /[\u0e00-\u0eff\u1000-\u109f\u1780-\u17ff\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/g;
28+const CJK = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/g;
43export const CJK_MIN_CHARS = 2;
29export const CJK_MIN_CHARS = 2;
44export const CJK_MAX_CHARS = 16;
30export const CJK_MAX_CHARS = 16;
45
31
46−const noSpace = (label: string): boolean => (label.match(NO_SPACE)?.length ?? 0) > 0;
32+const isCjk = (label: string): boolean => (label.match(CJK)?.length ?? 0) * 2 > label.replace(/ /g, "").length;
47−const widthOf = (label: string): number => {
48− const wide = label.match(NO_SPACE)?.length ?? 0;
49− return wide + (label.length - wide) / 2;
50−};
51−const cutToWidth = (label: string, max: number): string => {
52− let out = "";
53− for (const ch of label) {
54− if (widthOf(out + ch) > max) break;
55− out += ch;
56− }
57− return out.trim();
58−};
59
33
60export function isValidLabel(label: string): boolean {
34export function isValidLabel(label: string): boolean {
61 const words = label.split(" ").filter(Boolean);
35 const words = label.split(" ").filter(Boolean);
62− const cjk = noSpace(label);
36+ const cjk = isCjk(label);
63 return (
37 return (
64 label.length >= (cjk ? CJK_MIN_CHARS : LABEL_MIN_CHARS) &&
38 label.length >= (cjk ? CJK_MIN_CHARS : LABEL_MIN_CHARS) &&
65− (cjk ? widthOf(label) <= CJK_MAX_CHARS && label.length <= LABEL_MAX_CHARS : label.length <= LABEL_MAX_CHARS) &&
39+ label.length <= (cjk ? CJK_MAX_CHARS : LABEL_MAX_CHARS) &&
66 words.length >= (cjk ? 1 : LABEL_MIN_WORDS) &&
40 words.length >= (cjk ? 1 : LABEL_MIN_WORDS) &&
67 words.length <= LABEL_MAX_WORDS &&
41 words.length <= LABEL_MAX_WORDS &&
68 !HAS_QUOTE.test(label) &&
42 !HAS_QUOTE.test(label) &&
test/auto.test.ts−1
@@ -94,7 +94,6 @@ describe("auto-organize", () => {
94 await auto.run(1);
94 await auto.run(1);
95 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 3", "Tab 2"]);
95 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 3", "Tab 2"]);
96 expect(calls[0].existingGroups).toEqual([expect.objectContaining({ g: 0, title: "Rust" })]);
96 expect(calls[0].existingGroups).toEqual([expect.objectContaining({ g: 0, title: "Rust" })]);
97− expect(calls[0].siblingTitles).toEqual(["Rust"]);
98 expect(live.find((t) => t.id === 3)!.groupId).toBe(50);
97 expect(live.find((t) => t.id === 3)!.groupId).toBe(50);
99 });
98 });
100
99
test/naming.test.ts+2−72
@@ -50,10 +50,6 @@ describe("evolving-name policy", () => {
50 it("same label is never rewritten", () => {
50 it("same label is never rewritten", () => {
51 expect(shouldApply(g(), "rust async runtimes", ["a", "b", "c"])).toBe(false);
51 expect(shouldApply(g(), "rust async runtimes", ["a", "b", "c"])).toBe(false);
52 });
52 });
53− it("a member that only moved within its page is not a membership change", () => {
54− const g2 = newGroupRecord(1, 1, "opener", "blue", { title: "Rust async runtimes", lastNamedAt: 1, memberUrls: ["https://a.com/x", "https://b.com/y"] });
55− expect(shouldApply(g2, "Tokio and async-std", ["https://a.com/x?page=2", "https://b.com/y#top"])).toBe(false);
56− });
57 it("user-named groups are skipped", () => {
53 it("user-named groups are skipped", () => {
58 expect(shouldApply({ ...g(), userNamed: true }, "Anything else", ["x"])).toBe(false);
54 expect(shouldApply({ ...g(), userNamed: true }, "Anything else", ["x"])).toBe(false);
59 });
55 });
@@ -155,25 +151,15 @@ describe("naming queue", () => {
155 expect(state.groups[7].dirty).toBe(false);
151 expect(state.groups[7].dirty).toBe(false);
156 });
152 });
157
153
158− it("waits while a member is still loading", async () => {
154+ it("waits while a member is still loading or the group has one tab", async () => {
159 members = [member(1), member(2, { status: "loading" })];
155 members = [member(1), member(2, { status: "loading" })];
160 naming.touch(7);
156 naming.touch(7);
161 await settle();
157 await settle();
162− expect(calls).toHaveLength(0);
163− expect(state.groups[7].dirty).toBe(true);
164− });
165−
166− it("a group with one tab to name from is not kept waiting; the next tab to join names it", async () => {
167 members = [member(1)];
158 members = [member(1)];
168 naming.touch(7);
159 naming.touch(7);
169 await settle();
160 await settle();
170 expect(calls).toHaveLength(0);
161 expect(calls).toHaveLength(0);
171− expect(state.groups[7].dirty).toBe(false);
162+ expect(state.groups[7].dirty).toBe(true);
172− members = [member(1), member(2)];
173− markDirty(state.groups[7], 0);
174− naming.touch(7);
175− await settle();
176− expect(calls).toHaveLength(1);
177 });
163 });
178
164
179 it("names a group whose member the browser discarded to save memory", async () => {
165 it("names a group whose member the browser discarded to save memory", async () => {
@@ -220,54 +206,6 @@ describe("naming queue", () => {
220 expect(state.groups[7].nextAttemptAt).toBe(now + 24 * 3_600_000);
206 expect(state.groups[7].nextAttemptAt).toBe(now + 24 * 3_600_000);
221 });
207 });
222
208
223− it("a group Apple's filter refused is asked again as soon as its tabs change, not only after a day", async () => {
224− replies.push({ ok: false, error: { code: "GUARDRAIL", message: "no" } }, { ok: false, error: { code: "GUARDRAIL", message: "no" } });
225− naming.touch(7);
226− await settle();
227− expect(calls).toHaveLength(2);
228− naming.touch(7);
229− await settle();
230− expect(calls).toHaveLength(2); // the same tabs: still refused
231− members = [member(1), member(3)];
232− naming.touch(7);
233− await settle();
234− expect(calls).toHaveLength(3);
235− expect(state.groups[7]).toMatchObject({ title: "Rust crate docs", dirty: false, nameAttempts: 0 });
236− expect(state.groups[7].refusedHash).toBeUndefined();
237− });
238−
239− it("setup errors don't use up a group's retries; the host's recovery picks it up", async () => {
240− replies.push({ ok: false, error: { code: "LICENSE_REQUIRED", message: "terms" } });
241− naming.touch(7);
242− await settle();
243− expect(state.groups[7]).toMatchObject({ dirty: true, nameAttempts: 0 });
244− expect(state.groups[7].firstFailureAt).toBeUndefined();
245− expect(state.groups[7].nextAttemptAt).toBeUndefined();
246− });
247−
248− it("a title the strip would not take is tried again shortly", async () => {
249− let refuse = true;
250− naming = new Naming({ ...(naming as any).d, writeTitle: async () => !refuse });
251− naming.touch(7);
252− await settle();
253− expect(state.groups[7]).toMatchObject({ dirty: true, nextAttemptAt: now + 30_000 });
254− expect(state.groups[7].title).toBeUndefined();
255− expect(state.ownWrites[7]).toBeUndefined();
256− refuse = false;
257− now += 30_000;
258− await naming.consider(7);
259− await settle();
260− expect(state.groups[7]).toMatchObject({ dirty: false, title: "Rust crate docs" });
261− });
262−
263− it("members back as they were last named clear old failures", async () => {
264− Object.assign(state.groups[7], { membersHash: membersHash(members), nameAttempts: 3, firstFailureAt: 1, nextAttemptAt: 1 });
265− naming.touch(7);
266− await settle();
267− expect(state.groups[7]).toMatchObject({ dirty: false, nameAttempts: 0 });
268− expect(state.groups[7].firstFailureAt).toBeUndefined();
269− });
270−
271 it("failures back off 30 s, 2 min, 10 min, then hourly; the title is left alone", async () => {
209 it("failures back off 30 s, 2 min, 10 min, then hourly; the title is left alone", async () => {
272 const fail: HostReply<NameResult> = { ok: false, error: { code: "FM_ERROR", message: "boom" } };
210 const fail: HostReply<NameResult> = { ok: false, error: { code: "FM_ERROR", message: "boom" } };
273 replies.push(fail);
211 replies.push(fail);
@@ -299,14 +237,6 @@ describe("naming queue", () => {
299 expect(writes).toEqual([[7, "🦀 Rust crates"]]);
237 expect(writes).toEqual([[7, "🦀 Rust crates"]]);
300 });
238 });
301
239
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−
310 it("a group renamed by hand while the model runs keeps the user's title", async () => {
240 it("a group renamed by hand while the model runs keeps the user's title", async () => {
311 const n = new Naming({
241 const n = new Naming({
312 ...(naming as any).d,
242 ...(naming as any).d,
test/url.test.ts+12−6
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
2import { isValidLabel, labelOf, repairLabel, safeEmoji, titleKey } from "../src/shared/label";
2import { isValidLabel, labelOf, repairLabel, safeEmoji, titleKey } from "../src/shared/label";
3import { colorFor, hostname, isInternalUrl, promptAddress, promptUrl, provisionalTitle, registrableDomain, trimText } from "../src/shared/url";
3import { colorFor, hostname, isInternalUrl, promptAddress, promptUrl, provisionalTitle, registrableDomain, trimText } from "../src/shared/url";
4import { withDefaults } from "../src/background/settings";
4import { withDefaults } from "../src/background/settings";
5−import labelCases from "../host/tests/golden/labels.json";
6
5
7describe("url helpers", () => {
6describe("url helpers", () => {
8 it("strips www.", () => {
7 it("strips www.", () => {
@@ -46,12 +45,19 @@ describe("url helpers", () => {
46 });
45 });
47});
46});
48
47
49−// The same cases run against the host's validate.repair_label, so the two sides agree on every label.
50−const LABEL_CASES = labelCases as [string, string | null][];
51−
52describe("labels", () => {
48describe("labels", () => {
49+ it.each([
50+ ['"Rust async runtimes."', "Rust async runtimes"],
51+ ["rust async runtimes", "Rust async runtimes"],
52+ ["Rust tabs and docs", "Rust and docs"],
53+ ["One two three four five six", "One two three four"],
54+ ["Machine learning research papers reading list", "Machine learning research"],
53− it.each(LABEL_CASES)("repairs %s", (raw, fixed) => {
55+ ])("repairs %s", (raw, fixed) => {
54− expect(repairLabel(raw) ?? null).toBe(fixed);
56+ expect(repairLabel(raw)).toBe(fixed);
57+ });
58+
59+ it.each(["Tabs", "Misc stuff", "", "x", "Supercalifragilisticexpialidocious extraordinarily"])("rejects %s", (raw) => {
60+ expect(repairLabel(raw)).toBeUndefined();
55 });
61 });
56
62
57 it("validates", () => {
63 it("validates", () => {
tsconfig.json−1
@@ -9,7 +9,6 @@
9 "noEmit": true,
9 "noEmit": true,
10 "skipLibCheck": true,
10 "skipLibCheck": true,
11 "isolatedModules": true,
11 "isolatedModules": true,
12− "resolveJsonModule": true,
13 "noFallthroughCasesInSwitch": true,
12 "noFallthroughCasesInSwitch": true,
14 "forceConsistentCasingInFileNames": true
13 "forceConsistentCasingInFileNames": true
15 },
14 },