on d0ca350 help sign in
45 files changed+569−2495
since splitsnapshots
unifiedsplit
.github/workflows/release.yml+8−23 PRIVACY.md+3−2 README.md+11−18 docs/store/listing.md+4−4 docs/store/screenshot-settings.png host/diagonal-host.py+78−205 host/prompts.py+80−64 host/tests/fake_fm.py+1−15 host/tests/golden/labels.json−102 host/tests/golden/name-24.txt+1−5 host/tests/golden/name-3.txt+3−8 host/tests/golden/organize-3.txt+17 host/tests/golden/topics-3.txt−17 host/tests/support.py+1−10 host/tests/test_host.py+45−230 host/tests/test_prompts_validate.py+6−15 host/validate.py+34−138 scripts/install-manifest.sh+2−11 scripts/store-assets.mjs+1−1 src/background/engine.ts+6−137 src/background/fit.ts−122 src/background/host.ts+1−4 src/background/index.ts+36−128 src/background/naming.ts+29−87 src/background/organize.ts+112−205 src/background/runtime.ts+2−16 src/background/settings.ts+3−3 src/background/state.ts+1−25 src/background/tidy.ts+17−129 src/content/meta.ts+5−31 src/options/options.ts+11−2 src/shared/health.ts+2−4 src/shared/label.ts+8−49 test/auto.test.ts+2−90 test/e2e/smoke-fm.py+12−9 test/e2e/smoke.mjs−27 test/engine.test.ts+1−185 test/fit.test.ts−18 test/naming.test.ts+7−85 test/organize.test.ts+3−27 test/settings.test.ts−8 test/sweep.test.ts−181 test/tidy.test.ts+3−34 test/url.test.ts+13−20 tsconfig.json−1
.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 }}
PRIVACY.md+3−2
@@ -4,11 +4,12 @@ Diagonal groups and names your browser tabs. This page says what it reads, where
44
55## What Diagonal reads
66
7−For each normal (not incognito) tab: its title, its address, and up to 500 characters of what the page says (its description tag, its main heading and the opening lines of its main text, never anything typed into forms). In settings you can leave out the page text, or send only the site name instead of the full address.
7+For each normal (not incognito) tab: its title, its address and the page's `<meta name="description">`. In settings you can leave out descriptions, or send only the site name instead of the full address.
88
99## Where it goes
1010
11−Only to Apple's on-device model on your Mac. Diagonal passes that text to Apple's `fm` tool through a small helper program on your Mac (the native messaging host installed with `brew install iko-soy/tap/diagonal`). The model runs on the Mac, and nothing leaves it.
11+- **To Apple's on-device model on your Mac.** Diagonal passes that text to Apple's `fm` tool through a small helper program on your Mac (the native messaging host installed with `brew install iko-soy/tap/diagonal`). By default the model runs on your Mac and nothing leaves it.
12+- **To Apple's Private Cloud Compute, only if you choose it.** Settings → Privacy → Model offers Apple's larger server model. With that picked, the same text goes to Apple under Apple's Private Cloud Compute terms. It is off by default.
1213
1314Diagonal has no server of its own. It sends nothing to its developers and has no analytics. The only other request is the browser loading each archived tab's site icon from that site when you open the popup.
1415
README.md+11−18
@@ -10,12 +10,11 @@ You open and close tabs as usual. Diagonal does the rest on its own; there are n
10102. **Auto-organize.** Any other loose tab (typed address, bookmark, new window) is sorted once the window has been quiet for 8 s: it joins one of Diagonal's groups, pairs up with other loose tabs on the same topic, or stays loose if nothing fits. A loose tab is looked at again only when it goes to another page or a new tab arrives that it might pair with.
11113. **Naming loop.** Groups Diagonal made get an `emoji + 2–4 word` title from the model. The title is recomputed after 4 s of quiet when membership changes, stays put when the model only paraphrases, and is never touched again once you type a title yourself.
12124. **Dissolve.** A group Diagonal made that drops to one tab is ungrouped, unless you gave it a title.
13−5. **Drift.** When a tab in a group Diagonal made goes to a page on a different topic, it leaves the group once you switch away from it, and auto-organize files it with its new topic. Diagonal compares its topic with up to five groupmates (`src/background/fit.ts`) and only moves it when those groupmates still agree with each other.
14−6. **Tidy.** Tabs idle for 24 h move to a collapsed grey "Parked" group at the end of the strip. Opening a parked tab takes it back out and auto-organize places it. After another 48 h in Parked, tabs are archived and closed, with restore from the popup.
13+5. **Tidy.** Tabs idle for 24 h move to a collapsed grey "Parked" group at the end of the strip. Opening a parked tab takes it back out and auto-organize places it. After another 48 h in Parked, tabs are archived and closed, with restore from the popup.
1514
16−Your own choices win: a tab you take out of a group stays out until it goes to another page, a tab you drag into a group stays there, groups you make or rename yourself are left alone, and a title you type is kept. The popup still has **Organize now**, **Tidy now** and their undo, but none of them is needed.
15+Your own choices win: a tab you take out of a group stays out until it goes to another page, groups you make yourself are left alone, and a title you type is kept. The popup still has **Organize now**, **Tidy now** and their undo, but none of them is needed.
1716
18−The model sees a tab's title, its address, and up to 500 characters of what the page says: its description tag, its main heading and the opening lines of its main text (never form fields). Settings → Privacy can leave the page text out or send only the site name. When a batch of tabs doesn't fit the model's context, the page text is shortened first, then dropped.
17+The model sees a tab's title, address and `<meta name="description">`, and nothing else.
1918
2019## Requirements
2120
@@ -131,19 +130,13 @@ Every push to `master` runs `.github/workflows/release.yml`: it typechecks, runs
131130- **An Organize group whose title fails validation is still created,** with the hostname as a placeholder, and the naming loop names it.
132131- **Restarts.** Tab and group IDs change when the browser restarts, so records are matched to live groups by title and colour, which keeps managed groups managed.
133132
134−## How fm behaves on macOS 27
133+## Not verified on a real Mac yet
135134
136−Checked on a Mac running macOS 27.0 (26A428) on 2026-09-24; the tests' fake `fm` follows it.
135+Everything was tested against a fake `fm`. These are open until someone runs it on macOS 27 (section 16 of the spec):
137136
138−- `fm` is at `/usr/bin/fm`. Until an admin runs `sudo fm license`, every command exits 69 with a terms notice; `fm license --status` reports the state. The installer offers the terms in the terminal; if they are declined, Diagonal reports `LICENSE_REQUIRED` with the fix. The host writes its schemas on first use, so install order does not matter.
139−- `fm schema object` builds the schemas. A nested object needs its own schema: `--object tabs --schema "<json from another fm schema object>" --array` (see `SUB_SCHEMAS` in `host/diagonal-host.py`).
140−- Organize asks for one broad topic per tab ("Programming", "Travel"), and the host groups tabs that share one, joins a topic to an existing group when that group's example tabs got the same topic, and names each new group with the name call. Asking the model for whole groups did not work: it put nearly every new group into existing group 0, and specific topics gave every tab its own.
141−- `fm respond` reads the prompt from stdin when no prompt argument is given. The host passes its fixed rules with `-i` and the tab text on stdin, so tab titles never show in the process list and are harder to use to override the rules. There is no timeout flag; the host enforces its own.
142−- The only model is `system` (`--model pcc` is rejected), so Diagonal always uses the on-device model.
143−- The context holds prompt and reply together: a 7,400-token prompt worked and 13,100 overflowed. Tokens per character differ a lot by language (English 0.26, Russian 0.31, Chinese 0.54, Japanese 0.51), so the host budgets tokens, not characters: `TOKEN_BUDGET` is 7,000. It estimates from the characters and, near the limit, asks `fm count-tokens -q -i ...` (under 0.1 s) for the exact number. `count-tokens` accepts `-i` but not `--schema`.
144−- `--greedy` makes answers repeatable: five runs gave one answer, where default sampling gave three different names and sometimes split topics ("Finance" and "Banking"). The host always passes it.
145−- `fm serve` (an OpenAI-style server on a port or unix socket, with `json_schema` output) saved only about 100 ms per call once warm, can't take `--greedy` or guardrail settings, and on a TCP port answers any web page (it reflects any CORS origin). Diagonal keeps spawning `fm respond`.
146−- The safety layer sometimes refuses harmless text ("The model's safety guardrails were triggered."). The host retries such a call once with `--guardrails permissive-content-transformations`.
147−- Reply JSON key order is not stable, and the model can list a tab both in a group and in leftovers; the validator handles both.
148−
149−Still open: the Nix flake builds both packages on Linux and Apple silicon in CI, but the home-manager module has not been activated on a real Mac.
137+- **Confirmed on a Mac:** `fm` is at `/usr/bin/fm`, and until an admin runs `sudo fm license` every `fm` command exits 69 with a terms notice. The installer offers the terms in the terminal; if they are declined, Diagonal reports `LICENSE_REQUIRED` with the fix. The host writes its schemas on first use, so install order does not matter.
138+- The `fm schema` flags. `diagonal-host --install-schemas` tries the nested organize schema and falls back to the two flat schemas plus two calls if that fails. The flags are in `SCHEMA_COMMANDS` at the top of `host/diagonal-host.py`.
139+- `fm` stderr wording. `classify()` matches keywords, so an unexpected message shows as `FM_ERROR` with the raw text on the settings page.
140+- Whether `fm respond` can read the prompt from stdin. Until then, the prompt is an argument and is briefly visible in `ps`.
141+- The context window. `CHAR_BUDGET` is 10,000 characters, assuming 4,096 tokens. Double it (and `NAME_ITEM_CAP` / `ORGANIZE_ITEM_CAP`) once 8,192 is confirmed.
142+- The Nix flake builds both packages on x86_64-linux, and `.github/workflows/nix.yml` builds them on Linux and Apple silicon on every push. The home-manager module has not been activated on a real Mac.
docs/store/listing.md+4−4
@@ -18,7 +18,7 @@ Everything the Chrome Web Store developer dashboard asks for, ready to paste. Th
1818 >
1919 > Requirements: a Mac with Apple silicon running macOS 27 with Apple Intelligence on, plus Diagonal's small helper program, installed with: brew install iko-soy/tap/diagonal
2020 >
21− > Private by design: the model runs on your Mac and sees only each tab's title, address and a short summary of the page. Diagonal has no server and no analytics.
21+ > Private by design: the model runs on your Mac and sees only each tab's title, address and description. Diagonal has no server and no analytics.
2222
2323- **Icon:** `icons/128.png`
2424- **Screenshots (1280×800):** `docs/store/screenshot-popup.png`, `docs/store/screenshot-settings.png`
@@ -35,10 +35,10 @@ Everything the Chrome Web Store developer dashboard asks for, ready to paste. Th
3535 - `storage`: keeps settings, which groups it manages, and archived tabs, on this computer.
3636 - `alarms`: waits for tabs to settle before grouping, and runs the idle-tab tidy on a schedule.
3737 - `nativeMessaging`: talks to Diagonal's helper on the Mac, which runs Apple's on-device model.
38− - `scripting`: reads the short page summary of tabs that were already open when Diagonal was installed.
39− - Host permissions `http://*/*`, `https://*/*`: the content script reads a short summary of each page (description tag, main heading, opening lines), which helps the model group and name tabs.
38+ - `scripting`: reads the meta description of tabs that were already open when Diagonal was installed.
39+ - Host permissions `http://*/*`, `https://*/*`: the content script reads each page's meta description, which helps the model name groups.
4040- **Remote code:** No, it does not use remote code.
41−- **Data usage:** collects "Web history" (tab titles and addresses) and "Website content" (a short summary of each page). Check the three certifications: not sold to third parties, not used for unrelated purposes, not used for creditworthiness or lending.
41+- **Data usage:** collects "Web history" (tab titles and addresses) and "Website content" (page descriptions). Check the three certifications: not sold to third parties, not used for unrelated purposes, not used for creditworthiness or lending.
4242- **Privacy policy URL:** https://github.com/iko-soy/diagonal/blob/master/PRIVACY.md
4343
4444## Distribution
docs/store/screenshot-settings.png

Binary file not shown.

host/diagonal-host.py+78−205
@@ -36,28 +36,26 @@ SUPPORT = os.environ.get("DIAGONAL_SUPPORT_DIR", os.path.expanduser("~/Library/A
3636SCHEMAS = os.path.join(SUPPORT, "schemas")
3737LOG_DIR = os.environ.get("DIAGONAL_LOG_DIR", os.path.expanduser("~/Library/Logs/Diagonal"))
3838LOG_MAX_BYTES = 5 * 1024 * 1024
39−# fm's context holds prompt and reply together. Measured on macOS 27 with the topics schema: a 7,400-token
40−# prompt worked, 13,100 overflowed. 7,000 tokens leaves room for the reply.
41−TOKEN_BUDGET = 7_000
42−# Tokens per character, measured with `fm count-tokens` on tab lists: English 0.26, Russian 0.31, Chinese 0.54,
43−# Japanese 0.51. The estimate rounds up, so a prompt it calls small enough is; near the limit fm counts exactly.
44−TOKENS_PER_CHAR = {"ascii": 0.3, "cjk": 0.6, "other": 0.36}
45−EXACT_COUNT_ABOVE = 0.75 # of TOKEN_BUDGET
39+CHAR_BUDGET = 10_000 # ≈ 2,850 tokens of prompt at 3.5 chars/token (section 9); double once 8,192 is confirmed
4640MAX_REQUEST = 64 * 1024 * 1024
4741MAX_REPLY = 1024 * 1024
4842OPS = {"ping", "name", "organize"}
4943
50−# `fm schema` argument lists (section 8), checked against fm on macOS 27. `--object NAME` takes the nested
51−# object's schema as JSON from `--schema`; "{TabTopic}" is replaced by the output of SUB_SCHEMAS["TabTopic"].
52−SUB_SCHEMAS = {
53− "TabTopic": ["schema", "object", "--name", "TabTopic", "--integer", "index", "--string", "topic"],
54−}
44+# `fm schema` argument lists (section 8). The nested form is unverified on a real macOS 27 machine;
45+# when it fails, --install-schemas writes the two flat schemas and `organize` takes two calls.
5546SCHEMA_COMMANDS = {
5647 "name.json": ["schema", "object", "--name", "GroupName", "--string", "title", "--string", "emoji"],
57− "topics.json": ["schema", "object", "--name", "Topics", "--object", "tabs", "--schema", "{TabTopic}", "--array"],
48+ "organize.json": ["schema", "object", "--name", "Organized",
49+ "--object", "groups", "--array",
50+ "--string", "title", "--string", "emoji", "--string", "color",
51+ "--integer", "existing", "--integer", "members", "--array",
52+ "--integer", "leftovers", "--array"],
53+}
54+FALLBACK_SCHEMA_COMMANDS = {
55+ "organize-labels.json": ["schema", "object", "--name", "TopicLabels",
56+ "--string", "labels", "--array", "--string", "emojis", "--array", "--string", "colors", "--array"],
57+ "organize-assign.json": ["schema", "object", "--name", "Assignment", "--integer", "assignment", "--array"],
5858}
59−# Organize schemas written by older hosts; removed when the current ones are installed.
60−LEGACY_SCHEMAS = ["organize.json", "organize-labels.json", "organize-assign.json"]
6159
6260
6361class Fail(Exception):
@@ -135,7 +133,7 @@ def validate_request(req):
135133 if not isinstance(it, dict):
136134 raise Fail("BAD_REQUEST", f"item {n} is not an object")
137135 clean.append({"i": n, "title": _trim(it.get("title"), 120), "url": _trim(it.get("url"), 300),
138− "description": _trim(it.get("description"), 500)})
136+ "description": _trim(it.get("description"), 300)})
139137 payload["items"] = clean
140138 for key in ("siblingTitles", "mustDifferFrom"):
141139 payload[key] = [_trim(t, 60) for t in payload.get(key) or [] if isinstance(t, str)][:20]
@@ -148,8 +146,9 @@ def validate_request(req):
148146 {"g": n, "title": _trim(g.get("title"), 60), "samples": [_trim(s, 60) for s in (g.get("samples") or [])[:2]]}
149147 for n, g in enumerate(groups[:12]) if isinstance(g, dict)
150148 ]
151− # fm on macOS 27 only has the on-device model; older extensions may still ask for "pcc".
152− opts["model"] = "system"
149+ model = opts.get("model", "system")
150+ if model not in ("system", "pcc"):
151+ raise Fail("BAD_REQUEST", "opts.model must be system or pcc")
153152 return req
154153
155154
@@ -182,7 +181,7 @@ def classify(stderr, returncode=None):
182181
183182
184183def fm_env():
185− return {"PATH": "/usr/bin:/bin", "HOME": os.environ.get("HOME", ""), "LANG": "en_US.UTF-8", "NO_COLOR": "1"}
184+ return {"PATH": "/usr/bin:/bin", "HOME": os.environ.get("HOME", ""), "LANG": "en_US.UTF-8"}
186185
187186
188187def schema_path(name):
@@ -190,46 +189,10 @@ def schema_path(name):
190189 return path if os.path.isfile(path) and os.path.getsize(path) > 0 else None
191190
192191
193−# How much of each page's text to send, most first: when a batch doesn't fit the budget, the pages' text
194−# is shortened, then dropped, before the host gives up and asks for a smaller batch.
195−DESC_CAPS = (500, 200, 0)
196−
197−
198−def estimate_tokens(prompt):
199− n = 0.0
200− for ch in prompt.instructions + prompt.text:
201− n += TOKENS_PER_CHAR["ascii" if ch < "\x80" else "cjk" if validate.CJK.match(ch) else "other"]
202− return int(n) + 60 # fm's own framing around instructions and prompt
203−
204−
205−def count_tokens(prompt):
206− """fm's exact count (under 0.1 s), or None when it can't give one."""
207− try:
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())
210− except (OSError, subprocess.TimeoutExpired):
211− return None
212− out = p.stdout.strip()
213− return int(out) if p.returncode == 0 and out.isdigit() else None
214−
215−
216−def prompt_tokens(prompt):
217− estimate = estimate_tokens(prompt)
218− if estimate <= TOKEN_BUDGET * EXACT_COUNT_ABOVE:
219− return estimate
220− return count_tokens(prompt) or estimate
221−
222−
223−def fit(build, n_items):
224− """The prompt with as much page text as fits TOKEN_BUDGET; OVER_BUDGET with how many items would fit if
225− even the titles alone are too long."""
226− for cap in DESC_CAPS:
227− prompt = build(cap)
228− tokens = prompt_tokens(prompt)
229− if tokens <= TOKEN_BUDGET:
230− return prompt
231− allowed = max(1, int(n_items * TOKEN_BUDGET / tokens))
232− raise Fail("OVER_BUDGET", f"prompt is about {tokens} tokens, budget {TOKEN_BUDGET}", retryable=True, allowedItems=allowed)
192+def check_budget(prompt, n_items):
193+ if len(prompt) > CHAR_BUDGET:
194+ allowed = max(1, int(n_items * CHAR_BUDGET / len(prompt)))
195+ raise Fail("OVER_BUDGET", f"prompt is {len(prompt)} chars, budget {CHAR_BUDGET}", retryable=True, allowedItems=allowed)
233196
234197
235198def extract_json(stdout):
@@ -249,70 +212,39 @@ def extract_json(stdout):
249212 raise Fail("BAD_MODEL_OUTPUT", "no JSON object in fm output", raw=text[:2000])
250213
251214
252−# fm's safety layer sometimes refuses harmless tab lists. Naming tabs only restates their own text, so a
253−# refused call is retried once in the mode Apple provides for transforming given content.
254−PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
255−USAGE_EXIT = 64
256−
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):
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."""
266− try:
267− return _run_fm(prompt, schema, opts, [])
268− except Fail as f:
269− if f.code != "GUARDRAIL" or time_left(opts) < MIN_CALL_S:
270− raise
271− return _run_fm(prompt, schema, opts, PERMISSIVE)
272−
273−
274−def time_left(opts):
275− return opts["deadline"] - time.time()
276−
277−
278−def _run_fm(prompt, schema, opts, extra):
279− # 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.
281− # --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))
215+def run_fm(prompt, schema, model, timeout_s):
216+ args = [FM, "respond", "--model", model, "--no-stream", "--schema", schema, "--", prompt]
287217 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())
218+ p = subprocess.run(args, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
289219 except FileNotFoundError:
290220 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
291221 except subprocess.TimeoutExpired:
292− raise Fail("TIMEOUT", f"fm exceeded {opts['timeout_s']:g}s", retryable=True)
222+ raise Fail("TIMEOUT", f"fm exceeded {timeout_s:g}s", retryable=True)
293223 if p.returncode != 0:
294224 code = classify(p.stderr, p.returncode)
295225 if code == "LICENSE_REQUIRED":
296226 raise Fail(code, LICENSE_MESSAGE, raw=(p.stderr or "")[:2000])
297− # 64 is fm's usage error (unknown flag or value): the same call fails the same way again.
298− raise Fail(code, (p.stderr or f"fm exited {p.returncode}").strip()[:500], retryable=p.returncode != USAGE_EXIT,
299− raw=(p.stderr or "")[:2000])
227+ raise Fail(code, (p.stderr or f"fm exited {p.returncode}").strip()[:500], retryable=True, raw=(p.stderr or "")[:2000])
300228 return extract_json(p.stdout)
301229
302230
303231# ----- ops --------------------------------------------------------------------------------------
304232
305233def organize_mode():
306− return "topics" if schema_path("topics.json") else "missing"
234+ if schema_path("organize.json"):
235+ return "nested"
236+ if schema_path("organize-labels.json") and schema_path("organize-assign.json"):
237+ return "two-call"
238+ return "missing"
307239
308240
309241def op_ping(_payload, _opts):
310242 if not os.path.exists(FM):
311− return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False, "licenseRequired": False,
243+ return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False,
312244 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
313245 license_required = False
314246 try:
315− avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, env=fm_env())
247+ avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, timeout=15, env=fm_env())
316248 ok, msg = avail.returncode == 0, (avail.stdout + avail.stderr).strip()[:300]
317249 if not ok and license_needed(avail.returncode, avail.stdout + avail.stderr):
318250 license_required, msg = True, LICENSE_MESSAGE
@@ -332,19 +264,6 @@ def op_ping(_payload, _opts):
332264 return reply
333265
334266
335−# Bumped when SCHEMA_COMMANDS change, so schemas written by an older host are rewritten. Version 3: organize
336−# asks for one topic per tab (topics.json) instead of whole groups.
337−SCHEMA_VERSION = "3"
338−
339−
340−def schemas_current():
341− try:
342− with open(os.path.join(SCHEMAS, "version"), encoding="utf-8") as f:
343− return f.read().strip() == SCHEMA_VERSION
344− except OSError:
345− return False
346−
347−
348267def schemas_ok():
349268 return bool(schema_path("name.json")) and organize_mode() != "missing"
350269
@@ -352,7 +271,7 @@ def schemas_ok():
352271def ensure_schemas():
353272 """Write the schema files on first use, so install order does not matter (e.g. the fm terms were
354273 accepted after the installer ran). stdout is the native-messaging channel: progress lines are swallowed."""
355− if schemas_ok() and schemas_current():
274+ if schemas_ok():
356275 return
357276 out = io.StringIO()
358277 with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
@@ -365,73 +284,42 @@ def ensure_schemas():
365284 raise Fail("SCHEMA_MISSING", f"fm could not write Diagonal's schema files: {why}"[:500], raw=out.getvalue()[-2000:])
366285
367286
368−def _fit_overflow(f, n_items):
369− """fm's own context-size error carries no item count; estimate one so the extension can split the batch."""
370− if f.code == "OVER_BUDGET" and "allowedItems" not in f.extra:
371− f.extra["allowedItems"] = max(1, (n_items * 2) // 3)
372− f.retryable = True
373− return f
374−
375−
376287def op_name(payload, opts):
377288 ensure_schemas()
378289 schema = schema_path("name.json")
379290 if not schema:
380291 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"]))
382− try:
383− out = run_fm(prompt, schema, opts)
384− except Fail as f:
385− raise _fit_overflow(f, len(payload["items"]))
292+ prompt = prompts.build_name_prompt(payload, strict=opts.get("strict", False))
293+ check_budget(prompt, len(payload["items"]))
294+ out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
386295 try:
387− return validate.validate_name(out, payload), prompts.size(prompt), "name"
296+ return validate.validate_name(out, payload), len(prompt), "name"
388297 except validate.ValidationError as e:
389298 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
390299
391300
392301def op_organize(payload, opts):
302+ strict = opts.get("strict", False)
393303 ensure_schemas()
394− schema = schema_path("topics.json")
395− if not schema:
396− 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"]))
398− try:
399− out = run_fm(prompt, schema, opts)
400− except Fail as f:
401− raise _fit_overflow(f, len(payload["items"]))
402− _, owner = prompts.topic_items(payload)
304+ mode = organize_mode()
403305 try:
404− result = validate.organize_from_topics(out, payload, owner)
306+ if mode == "nested":
307+ prompt = prompts.build_organize_prompt(payload, strict=strict)
308+ check_budget(prompt, len(payload["items"]))
309+ out = run_fm(prompt, schema_path("organize.json"), opts["model"], opts["timeout_s"])
310+ return validate.validate_organize(out, payload), len(prompt), "nested"
311+ if mode == "two-call":
312+ p1 = prompts.build_labels_prompt(payload, strict=strict)
313+ check_budget(p1, len(payload["items"]))
314+ labels = run_fm(p1, schema_path("organize-labels.json"), opts["model"], opts["timeout_s"])
315+ names = [x for x in (labels.get("labels") or []) if isinstance(x, str)] if isinstance(labels, dict) else []
316+ p2 = prompts.build_assign_prompt(payload, names, strict=strict)
317+ check_budget(p2, len(payload["items"]))
318+ assign = run_fm(p2, schema_path("organize-assign.json"), opts["model"], opts["timeout_s"])
319+ return validate.organize_from_two_calls(labels, assign, payload), len(p1) + len(p2), "two-call"
405320 except validate.ValidationError as e:
406− raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
407− chars = prompts.size(prompt)
408− if payload.get("nameGroups") is not False: # the extension's fit check only needs the topics
409− chars += name_new_groups(result, payload, opts)
410− return result, chars, "topics"
411−
412−
413−def name_new_groups(result, payload, opts):
414− """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]
418− chars = 0
419− for g in result["groups"]:
420− if "existing" in g or g.get("title"):
421− continue
422− if time_left(opts) < MIN_CALL_S:
423− break
424− sub = {"items": [dict(payload["items"][i], i=n) for n, i in enumerate(g["members"])], "siblingTitles": taken[:20]}
425− try:
426− named, n, _ = op_name(sub, {**opts, "strict": False})
427− except Fail:
428− 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"]
433− taken.append(named["title"])
434− return chars
321+ raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True)
322+ raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'organize.json')}")
435323
436324
437325def handle(req):
@@ -444,8 +332,8 @@ def handle(req):
444332 raw_opts = req.get("opts") or {}
445333 timeout_ms = raw_opts.get("timeoutMs", 45000)
446334 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}
335+ opts = {"model": raw_opts.get("model", "system"), "strict": bool(raw_opts.get("strict")),
336+ "timeout_s": max(5.0, min(120.0, timeout_ms / 1000))}
449337 op = req["op"]
450338 if op == "ping":
451339 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
@@ -494,24 +382,8 @@ def install_schemas():
494382
495383 def write(name, args):
496384 """True when written; "license" or "rejected" (fm ran and refused) or "error" otherwise."""
497− subs = {}
498− for key, sub_args in SUB_SCHEMAS.items():
499− if "{%s}" % key not in args:
500− continue
501− try:
502− p = subprocess.run([FM, *sub_args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
503− except (FileNotFoundError, subprocess.TimeoutExpired) as e:
504− print(f" {name}: fm failed ({e})", file=sys.stderr)
505− return "error"
506− if license_needed(p.returncode, p.stdout + p.stderr):
507− return "license"
508− if p.returncode != 0 or not p.stdout.strip():
509− print(f" {name}: fm schema ({key}) exited {p.returncode}: {p.stderr.strip()[:200]}", file=sys.stderr)
510− return "rejected"
511− subs["{%s}" % key] = p.stdout.strip()
512− args = [subs.get(a, a) for a in args]
513385 try:
514− p = subprocess.run([FM, *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
386+ p = subprocess.run([FM, *args], capture_output=True, text=True, timeout=30, env=fm_env())
515387 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
516388 print(f" {name}: fm failed ({e})", file=sys.stderr)
517389 return "error"
@@ -531,19 +403,24 @@ def install_schemas():
531403 print(" Diagonal writes its schemas on its own once that is done.", file=sys.stderr)
532404 return INSTALL_LICENSE_EXIT
533405 ok &= first is True
534− topics = write("topics.json", SCHEMA_COMMANDS["topics.json"])
535− if topics == "license":
536− return INSTALL_LICENSE_EXIT
537− ok &= topics is True
538− if ok:
539− for name in LEGACY_SCHEMAS:
406+ nested = write("organize.json", SCHEMA_COMMANDS["organize.json"])
407+ if nested is True:
408+ for name in FALLBACK_SCHEMA_COMMANDS:
540409 try:
541410 os.remove(os.path.join(SCHEMAS, name))
542411 except FileNotFoundError:
543412 pass
544− if ok:
545− with open(os.path.join(SCHEMAS, "version"), "w", encoding="utf-8") as f:
546− f.write(SCHEMA_VERSION + "\n")
413+ elif nested != "rejected":
414+ # fm did not get as far as judging the schema: no reason to think nesting is unsupported.
415+ return INSTALL_LICENSE_EXIT if nested == "license" else 1
416+ else:
417+ print(" nested organize schema unsupported; using the two-call fallback", file=sys.stderr)
418+ try:
419+ os.remove(os.path.join(SCHEMAS, "organize.json"))
420+ except FileNotFoundError:
421+ pass
422+ for name, args in FALLBACK_SCHEMA_COMMANDS.items():
423+ ok &= write(name, args) is True
547424 return 0 if ok else 1
548425
549426
@@ -568,16 +445,14 @@ def selftest():
568445
569446 ping = op_ping({}, {})
570447 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"):
448+ if ping["licenseRequired"]:
574449 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
575450 else:
576451 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
577− for name in SCHEMA_COMMANDS:
452+ for name in ["name.json", *(list(FALLBACK_SCHEMA_COMMANDS) if organize_mode() == "two-call" else ["organize.json"])]:
578453 path = os.path.join(SCHEMAS, name)
579454 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"
455+ hint = f"written on their own after {LICENSE_FIX}" if ping["licenseRequired"] else "missing: run diagonal-host --install-schemas"
581456 check(f"schema {name}", readable, path if readable else hint)
582457 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
583458 if ping["fmAvailable"] and schema_path("name.json"):
@@ -687,9 +562,7 @@ def register(host_path):
687562 f.write("\n".join(written) + "\n")
688563 except OSError:
689564 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
565+ return 1 if failures else 0
693566
694567
695568def unregister():
host/prompts.py+80−64
@@ -1,9 +1,4 @@
1−"""Section 9 prompt templates. Pure functions: payload in, Prompt out.
2−
3−A Prompt has fixed `instructions` (passed to `fm respond -i`, so it may show in the process list and must hold
4−no tab or group text) and a `text` with everything from the browser (sent on stdin). Keeping the rules in
5−the instructions also makes it harder for a tab title to override them."""
6−from collections import namedtuple
1+"""Section 9 prompt templates. Pure functions: payload in, prompt text out."""
72from urllib.parse import urlsplit
83
94from validate import COLORS, all_emoji
@@ -11,12 +6,6 @@ from validate import COLORS, all_emoji
116EMOJI_LIST = " ".join(all_emoji())
127STRICT_LINE = "Reply with only the object, nothing before or after it."
138
14−Prompt = namedtuple("Prompt", "instructions text")
15−
16−
17−def size(p):
18− return len(p.instructions) + len(p.text)
19−
209
2110def _clean(s, limit):
2211 s = " ".join(str(s or "").split())
@@ -37,10 +26,10 @@ def render_address(url):
3726 return _clean(host + path + query, 160)
3827
3928
40−def render_items(items, desc_cap=500):
29+def render_items(items):
4130 lines = []
4231 for it in items:
43− desc = _clean(it.get("description"), desc_cap) or "-"
32+ desc = _clean(it.get("description"), 300) or "-"
4433 lines.append(f"{it['i']} | {_clean(it.get('title'), 120)} | {render_address(it.get('url', ''))} | {desc}")
4534 return "\n".join(lines)
4635
@@ -49,66 +38,93 @@ def _quoted(titles):
4938 return ", ".join(f'"{_clean(t, 60)}"' for t in titles)
5039
5140
52−def build_name_prompt(payload, strict=False, desc_cap=500):
41+def build_name_prompt(payload, strict=False):
5342 rules = [
5443 "- Title: 2 to 4 words, sentence case, no punctuation at the end, no quotes.",
5544 "- Describe the topic or task, not the website, unless every tab is the same site.",
5645 "- Never use the words tab, tabs, group, misc, various, stuff.",
5746 "- Write the title in the language most tab titles use.",
5847 f"- Emoji: exactly one, chosen from this list: {EMOJI_LIST}",
59− "- If a current title is given, keep it exactly if it still fits; change it only if the tabs have clearly moved on.",
60− "- Never reuse a name listed under other groups or not allowed.",
6148 ]
62− if strict:
63− rules.append(f"- {STRICT_LINE}")
64− instructions = (
65− "You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.\n\n"
66− "Rules:\n" + "\n".join(rules)
67− )
68− context = []
6949 current = payload.get("currentTitle")
7050 if current:
71− context.append(f'Current title: "{_clean(current, 60)}"')
51+ rules.append(f'- Current title: "{_clean(current, 60)}". Keep it exactly if it still fits; change it only if the tabs have clearly moved on.')
7252 siblings = payload.get("siblingTitles") or []
7353 if siblings:
74− context.append(f"Other groups in this window: {_quoted(siblings)}")
54+ rules.append(f"- Other groups in this window are named: {_quoted(siblings)}. Do not reuse those names.")
7555 differ = payload.get("mustDifferFrom") or []
7656 if differ:
77− context.append(f"Not allowed: {_quoted(differ)}")
78− text = ("\n".join(context) + "\n\n" if context else "") + "Tabs (index | title | address | what the page says):\n" + render_items(payload["items"], desc_cap) + "\n"
79− return Prompt(instructions, text)
80−
81−
82−# Organize asks for one broad topic per tab and the host groups tabs with the same topic. On fm (macOS 27)
83−# this was reliable where asking the model for whole groups was not: it set "existing" on nearly every
84−# new group, and specific topics gave every tab its own.
85−TOPICS_INSTRUCTIONS = (
86− "You label browser tabs with broad topics so that related tabs can be grouped.\n\n"
87− "Rules:\n"
88− "- Give every listed index exactly one topic of one or two words.\n"
89− "- Keep topics as general as possible, for example Programming, Travel, Cooking, Shopping, News, Finance, Music, Health, Work.\n"
90− "- Use exactly the same topic words for tabs that belong together.\n"
91− "- Group by what the page is about, not by website.\n"
92− "- Write topics in English."
93−)
94−
95−
96−def topic_items(payload, desc_cap=500):
97− """The tabs to sort, then up to two example tabs of each existing group, numbered after them.
98− Returns (lines, owner) where owner maps an example's index to its group number."""
99− items = payload["items"]
100− lines = [render_items(items, desc_cap)] if items else []
101− owner = {}
102− n = len(items)
103− for g in payload.get("existingGroups") or []:
104− for sample in (g.get("samples") or [])[:2]:
105− owner[n] = g["g"]
106− lines.append(f"{n} | {_clean(sample, 120)} | - | -")
107− n += 1
108− return "\n".join(lines), owner
109−
110−
111−def build_topics_prompt(payload, strict=False, desc_cap=500):
112− instructions = TOPICS_INSTRUCTIONS + (f"\n- {STRICT_LINE}" if strict else "")
113− body, _ = topic_items(payload, desc_cap)
114− return Prompt(instructions, "Tabs (index | title | address | what the page says):\n" + body + "\n")
57+ rules.append(f"- The title must not be any of: {_quoted(differ)}.")
58+ if strict:
59+ rules.append(f"- {STRICT_LINE}")
60+ return (
61+ "You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.\n\n"
62+ "Rules:\n" + "\n".join(rules) + "\n\n"
63+ "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
64+ )
65+
66+
67+def _organize_rules(max_groups, strict):
68+ rules = [
69+ "- Put each tab index into exactly one group, or into leftovers if it fits nowhere.",
70+ f"- A group needs at least 2 tabs. Prefer fewer, broader groups; at most {max_groups} new groups.",
71+ '- Reuse an existing group when a tab fits it: set "existing" to that group\'s number and leave title, emoji and color empty.',
72+ f"- New groups: title of 2 to 4 words in sentence case, one emoji from: {EMOJI_LIST}, one color from: {', '.join(COLORS)}.",
73+ "- Group by topic or task, not by website, unless the tabs are the same site and nothing else links them.",
74+ "- Write titles in the language most tab titles use.",
75+ ]
76+ if strict:
77+ rules.append(f"- {STRICT_LINE}")
78+ return rules
79+
80+
81+def _existing_block(existing):
82+ if not existing:
83+ return ""
84+ lines = [f"{g['g']} | {_clean(g.get('title'), 60)} | {'; '.join(_clean(s, 60) for s in (g.get('samples') or [])[:2]) or '-'}" for g in existing]
85+ return "Existing groups (number | title | examples):\n" + "\n".join(lines) + "\n\n"
86+
87+
88+def build_organize_prompt(payload, strict=False):
89+ return (
90+ "You sort browser tabs into topic groups.\n\n"
91+ "Rules:\n" + "\n".join(_organize_rules(payload.get("maxGroups", 8), strict)) + "\n\n"
92+ + _existing_block(payload.get("existingGroups") or [])
93+ + "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
94+ )
95+
96+
97+# Two-call fallback (section 8) for an `fm schema` that cannot nest arrays of objects.
98+
99+def build_labels_prompt(payload, strict=False):
100+ rules = [
101+ f"- Propose at most {payload.get('maxGroups', 8)} new topic groups for tabs that do not fit an existing group.",
102+ "- Each needs a label of 2 to 4 words in sentence case, one emoji and one color, at the same position in labels, emojis and colors.",
103+ f"- Emoji from: {EMOJI_LIST}",
104+ f"- Color from: {', '.join(COLORS)}.",
105+ "- Group by topic or task, not by website. Write labels in the language most tab titles use.",
106+ ]
107+ if strict:
108+ rules.append(f"- {STRICT_LINE}")
109+ return (
110+ "You plan topic groups for browser tabs.\n\nRules:\n" + "\n".join(rules) + "\n\n"
111+ + _existing_block(payload.get("existingGroups") or [])
112+ + "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
113+ )
114+
115+
116+def build_assign_prompt(payload, labels, strict=False):
117+ existing = payload.get("existingGroups") or []
118+ options = [f"{n} | {_clean(g.get('title'), 60)}" for n, g in enumerate(existing)]
119+ options += [f"{len(existing) + n} | {_clean(label, 60)}" for n, label in enumerate(labels)]
120+ rules = [
121+ f"- assignment has exactly {len(payload['items'])} numbers, one per tab in index order.",
122+ "- Each number is the group that tab belongs to, or -1 if it fits none.",
123+ ]
124+ if strict:
125+ rules.append(f"- {STRICT_LINE}")
126+ return (
127+ "You assign browser tabs to groups.\n\nRules:\n" + "\n".join(rules) + "\n\n"
128+ "Groups (number | title):\n" + "\n".join(options) + "\n\n"
129+ "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
130+ )
host/tests/fake_fm.py+1−15
@@ -6,11 +6,8 @@ control.json keys:
66 available: {"stdout": str, "stderr": str, "exit": int} for `fm available`
77 schema_nested: false → `fm schema` with --object fails (simulates no nested-array support)
88 schema_fail: true → every `fm schema` fails
9− count_tokens: {"stdout": str, "exit": int} for `fm count-tokens` (default: stdin length / 4)
109 license: true → every command exits 69 with the terms notice, as fm does before `sudo fm license`
11−Every invocation's argv is appended to argv.log as one JSON line; `fm respond` also appends its stdin
12−(the prompt) to stdin.log. Like the real fm: `--object` must be followed by `--schema <json>`, and the only
13−--model is "system".
10+Every invocation's argv is appended to argv.log as one JSON line.
1411"""
1512import json
1613import os
@@ -42,27 +39,16 @@ def main():
4239 spec = {"exit": 0 if agreed else 1}
4340 elif control.get("license"):
4441 spec = {"stderr": LICENSE_NOTICE, "exit": 69}
45− elif cmd == "count-tokens":
46− # Like fm: a bare integer. Tests can pin it; otherwise a rough 4 characters per token of the text.
47− text = sys.stdin.read()
48− spec = control.get("count_tokens", {"stdout": f"{len(text) // 4}\n", "exit": 0})
4942 elif cmd == "available":
5043 spec = control.get("available", {"stdout": "available", "exit": 0})
51− elif "--model" in argv and argv[argv.index("--model") + 1] != "system":
52− spec = {"stderr": f"Error: The value '{argv[argv.index('--model') + 1]}' is invalid for '--model <model>'. Please provide one of 'system'.\n", "exit": 64}
5344 elif cmd == "schema":
5445 if control.get("schema_fail"):
5546 spec = {"stderr": "error: schema generation failed", "exit": 1}
5647 elif "--object" in argv and control.get("schema_nested", True) is False:
5748 spec = {"stderr": "error: unknown option --object", "exit": 64}
58− elif "--object" in argv and argv[argv.index("--object") + 2:argv.index("--object") + 3] != ["--schema"]:
59− name = argv[argv.index("--object") + 1]
60− spec = {"stderr": f"Error: SchemaError(errorDescription: Optional(\"--object '{name}' must be followed by --schema\"))\n", "exit": 1}
6149 else:
6250 spec = {"stdout": json.dumps({"schema": argv[3] if len(argv) > 3 else "x", "args": argv[1:]}), "exit": 0}
6351 elif cmd == "respond":
64− with open(os.path.join(HERE, "stdin.log"), "a", encoding="utf-8") as f:
65− f.write(json.dumps(sys.stdin.read()) + "\n")
6652 queue = control.get("respond_queue") or []
6753 if queue:
6854 spec = queue.pop(0)
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/golden/name-24.txt+1−5
@@ -1,4 +1,3 @@
1−[instructions]
21You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
32
43Rules:
@@ -7,11 +6,8 @@ Rules:
76- Never use the words tab, tabs, group, misc, various, stuff.
87- Write the title in the language most tab titles use.
98- Emoji: exactly one, chosen from this list: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄
10−- If a current title is given, keep it exactly if it still fits; change it only if the tabs have clearly moved on.
11−- Never reuse a name listed under other groups or not allowed.
129
13−[stdin]
14−Tabs (index | title | address | what the page says):
10+Tabs (index | title | address | description):
15110 | tokio 0 - docs.rs | docs.rs/tokio/latest/tokio/?search=x0&utm_source=y | Documentation for the tokio crate, part 0.
16121 | serde 1 - docs.rs | docs.rs/serde/latest/serde/?search=x1&utm_source=y | Documentation for the serde crate, part 1.
17132 | axum 2 - docs.rs | docs.rs/axum/latest/axum/?search=x2&utm_source=y | Documentation for the axum crate, part 2.
host/tests/golden/name-3.txt+3−8
@@ -1,4 +1,3 @@
1−[instructions]
21You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
32
43Rules:
@@ -7,14 +6,10 @@ Rules:
76- Never use the words tab, tabs, group, misc, various, stuff.
87- Write the title in the language most tab titles use.
98- Emoji: exactly one, chosen from this list: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄
10−- If a current title is given, keep it exactly if it still fits; change it only if the tabs have clearly moved on.
11−- Never reuse a name listed under other groups or not allowed.
9+- Current title: "Rust async". Keep it exactly if it still fits; change it only if the tabs have clearly moved on.
10+- Other groups in this window are named: "Lisbon trip". Do not reuse those names.
1211
13−[stdin]
14−Current title: "Rust async"
15−Other groups in this window: "Lisbon trip"
16−
17−Tabs (index | title | address | what the page says):
12+Tabs (index | title | address | description):
18130 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
19141 | async-std | async.rs | Async version of the Rust standard library.
20152 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
host/tests/golden/organize-3.txtnew file+17
@@ -0,0 +1,17 @@
1+You sort browser tabs into topic groups.
2+
3+Rules:
4+- Put each tab index into exactly one group, or into leftovers if it fits nowhere.
5+- A group needs at least 2 tabs. Prefer fewer, broader groups; at most 1 new groups.
6+- Reuse an existing group when a tab fits it: set "existing" to that group's number and leave title, emoji and color empty.
7+- New groups: title of 2 to 4 words in sentence case, one emoji from: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄, one color from: grey, blue, red, yellow, green, pink, purple, cyan, orange.
8+- Group by topic or task, not by website, unless the tabs are the same site and nothing else links them.
9+- Write titles in the language most tab titles use.
10+
11+Existing groups (number | title | examples):
12+0 | Lisbon trip | Hotels; Flights
13+
14+Tabs (index | title | address | description):
15+0 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
16+1 | async-std | async.rs | Async version of the Rust standard library.
17+2 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
host/tests/golden/topics-3.txtdeleted−17
@@ -1,17 +0,0 @@
1−[instructions]
2−You label browser tabs with broad topics so that related tabs can be grouped.
3−
4−Rules:
5−- Give every listed index exactly one topic of one or two words.
6−- Keep topics as general as possible, for example Programming, Travel, Cooking, Shopping, News, Finance, Music, Health, Work.
7−- Use exactly the same topic words for tabs that belong together.
8−- Group by what the page is about, not by website.
9−- Write topics in English.
10−
11−[stdin]
12−Tabs (index | title | address | what the page says):
13−0 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
14−1 | async-std | async.rs | Async version of the Rust standard library.
15−2 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
16−3 | Hotels | - | -
17−4 | Flights | - | -
host/tests/support.py+1−10
@@ -60,11 +60,9 @@ class HostCase(unittest.TestCase):
6060 os.chmod(self.fm, 0o755)
6161 self.support = os.path.join(self.tmp, "support")
6262 os.makedirs(os.path.join(self.support, "schemas"))
63− for name in ("name.json", "topics.json"):
63+ for name in ("name.json", "organize.json"):
6464 with open(os.path.join(self.support, "schemas", name), "w") as f:
6565 f.write("{}")
66− with open(os.path.join(self.support, "schemas", "version"), "w") as f:
67− f.write(host.SCHEMA_VERSION + "\n")
6866 self.control({})
6967 self.env = {**os.environ, "DIAGONAL_FM": self.fm, "DIAGONAL_SUPPORT_DIR": self.support,
7068 "DIAGONAL_LOG_DIR": os.path.join(self.tmp, "logs"), "HOME": self.tmp}
@@ -85,13 +83,6 @@ class HostCase(unittest.TestCase):
8583 return []
8684 return [json.loads(line) for line in open(path, encoding="utf-8")]
8785
88− def stdin_log(self):
89− """The prompts `fm respond` read on stdin, in call order."""
90− path = os.path.join(self.tmp, "stdin.log")
91− if not os.path.exists(path):
92− return []
93− return [json.loads(line) for line in open(path, encoding="utf-8")]
94−
9586 def run_host(self, request=None, origin=ORIGIN, raw=None, args=None, timeout=30):
9687 stdin = raw if raw is not None else (frame(request) if request is not None else b"")
9788 argv = args if args is not None else [origin]
host/tests/test_host.py+45−230
@@ -77,7 +77,7 @@ class Ping(HostCase):
7777 self.assertEqual(r["result"]["fmPath"], self.fm)
7878 self.assertTrue(r["result"]["fmAvailable"])
7979 self.assertTrue(r["result"]["schemasOk"])
80− self.assertEqual(r["result"]["organizeMode"], "topics")
80+ self.assertEqual(r["result"]["organizeMode"], "nested")
8181
8282 def test_ping_model_unavailable(self):
8383 self.control({"available": {"stderr": "Apple Intelligence is not enabled", "exit": 1}})
@@ -107,7 +107,7 @@ class Ping(HostCase):
107107 r = self.call("ping")
108108 self.assertTrue(r["ok"], r)
109109 self.assertTrue(r["result"]["schemasOk"])
110− self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "topics.json"])
110+ self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize.json"])
111111
112112 def test_ping_without_fm(self):
113113 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
@@ -124,12 +124,10 @@ class Name(HostCase):
124124 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
125125 self.assertGreater(r["meta"]["promptChars"], 0)
126126 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
127− self.assertEqual(argv[:8], ["respond", "--model", "system", "--no-stream", "--greedy", "--schema", os.path.join(self.support, "schemas", "name.json"), "-i"])
128− self.assertIn("You name a browser tab group", argv[8])
129− self.assertEqual(len(argv), 9)
130− # Tab text goes on stdin, never into argv where `ps` would show it.
131− self.assertIn("tokio.rs", self.stdin_log()[0])
132− self.assertNotIn("tokio", " ".join(argv))
127+ self.assertEqual(argv[:6], ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json")])
128+ self.assertEqual(argv[6], "--")
129+ self.assertIn("tokio.rs", argv[7])
130+ self.assertEqual(len(argv), 8)
133131
134132 def test_json_wrapped_in_prose_and_fence(self):
135133 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
@@ -142,34 +140,16 @@ class Name(HostCase):
142140 r = self.call("name", {"items": items})
143141 self.assertEqual(r["result"]["emoji"], "💻")
144142
145− def test_pcc_falls_back_to_the_on_device_model(self):
146− # fm on macOS 27 rejects --model pcc; an older setting must not break naming.
143+ def test_pcc_only_when_asked(self):
147144 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
148− r = self.call("name", {"items": ITEMS3}, model="pcc")
149− self.assertTrue(r["ok"], r)
145+ self.call("name", {"items": ITEMS3}, model="pcc")
150146 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
151− self.assertEqual(argv[1:3], ["--model", "system"])
152−
153− def test_guardrail_refusal_is_retried_permissively_once(self):
154− self.control({"respond_queue": [
155− {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
156− {"stdout": json.dumps({"title": "Rust async", "emoji": "🦀"})},
157− ]})
158− r = self.call("name", {"items": ITEMS3})
159− self.assertTrue(r["ok"], r)
160− calls = [a for a in self.argv_log() if a[0] == "respond"]
161− self.assertEqual(len(calls), 2)
162− self.assertNotIn("--guardrails", calls[0])
163− self.assertEqual(calls[1][-2:], ["--guardrails", "permissive-content-transformations"])
164−
165− def test_context_overflow_is_over_budget(self):
166− self.respond(stderr="Error: The session's transcript exceeded the model's context size.\n", exit=1)
167− self.assertEqual(self.call("name", {"items": ITEMS3})["error"]["code"], "OVER_BUDGET")
147+ self.assertEqual(argv[1:3], ["--model", "pcc"])
168148
169149 def test_prompt_never_contains_tab_ids(self):
170150 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
171151 self.call("name", {"items": [dict(it, id=987654) for it in ITEMS3]})
172− prompt = self.stdin_log()[0]
152+ prompt = [a for a in self.argv_log() if a[0] == "respond"][0][-1]
173153 self.assertNotIn("987654", prompt)
174154
175155
@@ -203,52 +183,12 @@ class ErrorCodes(HostCase):
203183 def test_over_budget_from_fm(self):
204184 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
205185
206− def test_page_text_is_shortened_before_giving_up(self):
207− # 40 tabs with long page text don't fit at 500 chars each, but do once the text is shortened.
208− items = [{"title": f"Page {n}", "url": f"https://example.com/{n}", "description": "word " * 100} for n in range(40)]
209− self.respond(json.dumps({"title": "Reading list", "emoji": "📚"}))
210− r = self.call("name", {"items": items})
211− self.assertTrue(r["ok"], r)
212− body = self.stdin_log()[0]
213− self.assertLessEqual(len(body) // 4, host.TOKEN_BUDGET)
214− self.assertIn("word", body)
215−
216− def test_chinese_pages_are_measured_in_tokens(self):
217− # 30 Chinese tabs: few characters, but about twice the tokens per character of English. fm's own
218− # count says it's too long, so the page text is shortened.
219− items = [{"title": f"页面 {n}", "url": f"https://example.cn/{n}", "description": "中文内容" * 100} for n in range(30)]
220− self.control({"count_tokens": {"stdout": "9000\n", "exit": 0},
221− "respond": {"stdout": json.dumps({"title": "阅读清单", "emoji": "📚"}), "exit": 0}})
222− r = self.call("name", {"items": items})
223− self.assertTrue(r["ok"], r)
224− self.assertIn("count-tokens", [a[0] for a in self.argv_log()])
225− body = self.stdin_log()[0]
226− self.assertIn("中文内容", body)
227− self.assertNotIn("中文内容" * 60, body) # 500 characters didn't fit; 200 did
228−
229− def test_over_budget_by_fms_count_reports_allowed_items(self):
230− items = [{"title": "中文标题" * 30, "url": f"https://example.cn/{n}"} for n in range(100)]
231− self.control({"count_tokens": {"stdout": "14000\n", "exit": 0}})
232− r = self.call("name", {"items": items})
233− self.assertEqual(r["error"]["code"], "OVER_BUDGET")
234− self.assertEqual(r["error"]["allowedItems"], 50)
235−
236− def test_short_prompts_skip_the_exact_count(self):
237− self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
238− self.call("name", {"items": ITEMS3})
239− self.assertNotIn("count-tokens", [a[0] for a in self.argv_log()])
240−
241− def test_estimate_covers_the_measured_counts(self):
242− # Measured with fm count-tokens on macOS 27 (tab lists): the estimate must never be lower.
243− for text, measured in (("a" * 3072, 810), ("я" * 1274, 400), ("中" * 476, 255), ("の" * 384, 195)):
244− self.assertGreaterEqual(host.estimate_tokens(host.prompts.Prompt("", text)), measured, text[0])
245−
246186 def test_over_budget_precheck_reports_allowed_items(self):
247− big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(120)]
187+ big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(30)]
248188 r = self.call("name", {"items": big})
249189 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
250190 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
251− self.assertLess(r["error"]["allowedItems"], 120)
191+ self.assertLess(r["error"]["allowedItems"], 30)
252192 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
253193
254194 def test_guardrail(self):
@@ -283,21 +223,8 @@ class ErrorCodes(HostCase):
283223 def test_strict_retry_appends_line(self):
284224 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
285225 self.call("name", {"items": ITEMS3}, strict=True)
286− argv = [a for a in self.argv_log() if a[0] == "respond"][0]
287− self.assertIn("Reply with only the object", argv[argv.index("-i") + 1])
288−
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)
226+ prompt = [a for a in self.argv_log() if a[0] == "respond"][0][-1]
227+ self.assertIn("Reply with only the object", prompt)
301228
302229
303230class Organize(HostCase):
@@ -305,151 +232,55 @@ class Organize(HostCase):
305232 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
306233 {"title": "Lisbon travel guide", "url": "https://lonelyplanet.com/portugal/lisbon"},
307234 {"title": "Random page", "url": "https://example.org/"},
308− {"title": "Pasta recipe", "url": "https://cooking.example/pasta"},
309− {"title": "Rice recipe", "url": "https://cooking.example/rice"},
310235 ]
311− EXISTING = [{"g": 0, "title": "Lisbon trip", "samples": ["Flights to Lisbon", "Lisbon metro map"]}]
312−
313− def topics(self, pairs):
314− self.respond(json.dumps({"tabs": [{"index": i, "topic": t} for i, t in pairs]}))
315236
316− def test_tabs_with_one_topic_form_groups_and_join_existing_ones(self):
317− # Indexes 8 and 9 are the existing group's example tabs (numbered after the 8 tabs to sort).
318− self.topics([(0, "Programming"), (1, "programming"), (2, "Programming."), (3, "Travel"), (4, "travel"),
319− (5, "Other"), (6, "Cooking"), (7, "Cooking"), (8, "Travel"), (9, "Travel")])
320− r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
237+ def test_nested_path(self):
238+ self.respond(json.dumps({"groups": [
239+ {"title": "Rust async", "emoji": "🦀", "color": "orange", "existing": -1, "members": [0, 1, 2]},
240+ {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]},
241+ ], "leftovers": [5]}))
242+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": [{"g": 0, "title": "Lisbon trip"}], "maxGroups": 2})
321243 self.assertTrue(r["ok"], r)
322− self.assertEqual(r["meta"]["path"], "topics")
323− groups = r["result"]["groups"]
324− self.assertEqual(groups[0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
325− new = sorted(g["members"] for g in groups[1:])
326− self.assertEqual(new, [[0, 1, 2], [6, 7]])
327− self.assertTrue(all(g["title"] == "" for g in groups[1:])) # the naming loop titles new groups
244+ self.assertEqual(r["result"]["groups"][0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
245+ self.assertEqual(r["result"]["groups"][1]["title"], "Rust async")
328246 self.assertEqual(r["result"]["leftovers"], [5])
247+ self.assertEqual(r["meta"]["path"], "nested")
329248
330− def test_new_groups_are_named_right_away(self):
331− self.control({"respond_queue": [
332− {"stdout": json.dumps({"tabs": [{"index": i, "topic": t} for i, t in
333− [(0, "Programming"), (1, "Programming"), (2, "Programming"), (6, "Cooking"), (7, "Cooking")]]})},
334− {"stdout": json.dumps({"title": "Rust async runtimes", "emoji": "🦀"})},
335− {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
336− {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
337− ]})
338− r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
339− self.assertTrue(r["ok"], r)
340− by_members = {tuple(g["members"]): g for g in r["result"]["groups"]}
341− self.assertEqual((by_members[(0, 1, 2)]["title"], by_members[(0, 1, 2)]["emoji"]), ("Rust async runtimes", "🦀"))
342− self.assertEqual(by_members[(6, 7)]["title"], "") # naming failed: the extension's naming loop takes it
343− name_calls = self.stdin_log()[1:]
344− self.assertIn("Tokio", name_calls[0])
345− self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
346−
347− def test_a_new_group_named_like_another_group_is_left_to_the_naming_loop(self):
249+ def test_two_call_fallback(self):
250+ os.remove(os.path.join(self.support, "schemas", "organize.json"))
251+ for name in ("organize-labels.json", "organize-assign.json"):
252+ open(os.path.join(self.support, "schemas", name), "w").write("{}")
348253 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": "🦀"})},
254+ {"stdout": json.dumps({"labels": ["Rust async"], "emojis": ["🦀"], "colors": ["orange"]})},
255+ {"stdout": json.dumps({"assignment": [1, 1, 1, 0, 0, -1]})},
351256 ]})
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):
375− # 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")])
377− r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING})
378− joins = [g for g in r["result"]["groups"] if "existing" in g]
379− self.assertEqual(joins, [{"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]}])
380−
381− def test_fm_answers_greedily(self):
382− self.topics([(0, "Programming")])
383− self.call("organize", {"items": self.ITEMS})
384− self.assertIn("--greedy", [a for a in self.argv_log() if a[0] == "respond"][0])
385−
386− def test_topics_only_when_asked_not_to_name(self):
387− self.topics([(0, "Programming"), (1, "Programming"), (2, "Travel")])
388− r = self.call("organize", {"items": self.ITEMS, "nameGroups": False})
257+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": [{"g": 0, "title": "Lisbon trip"}], "maxGroups": 2})
389258 self.assertTrue(r["ok"], r)
390− self.assertEqual(len([a for a in self.argv_log() if a[0] == "respond"]), 1)
391−
392− def test_a_topic_of_one_tab_is_a_leftover(self):
393− self.topics([(0, "Programming"), (1, "Programming"), (2, "Rust"), (3, "Travel"), (4, "Travel"), (5, "Web"), (6, "Food"), (7, "Cooking")])
394− r = self.call("organize", {"items": self.ITEMS, "maxGroups": 4})
395− self.assertEqual(sorted(g["members"] for g in r["result"]["groups"]), [[0, 1], [3, 4]])
396− self.assertEqual(r["result"]["leftovers"], [2, 5, 6, 7])
397−
398− def test_prompt_rules_in_instructions_and_tabs_on_stdin(self):
399− self.topics([(0, "Programming")])
400− self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING})
401− argv = [a for a in self.argv_log() if a[0] == "respond"][0]
402− instructions = argv[argv.index("-i") + 1]
403− self.assertIn("broad topics", instructions)
404− self.assertNotIn("Lisbon", " ".join(argv)) # no tab or group text in the process list
405− body = self.stdin_log()[0]
406− self.assertIn("4 | Lisbon travel guide", body)
407− self.assertIn("8 | Flights to Lisbon | - | -", body)
408−
409− def test_no_topics_is_bad_output(self):
410− self.respond(json.dumps({"tabs": []}))
411− r = self.call("organize", {"items": self.ITEMS})
412− self.assertEqual(r["error"]["code"], "BAD_MODEL_OUTPUT")
413−
414− def test_context_overflow_suggests_a_smaller_batch(self):
415− self.respond(stderr="Error: The session's transcript exceeded the model's context size.\n", exit=1)
416− r = self.call("organize", {"items": self.ITEMS})
417− self.assertEqual(r["error"]["code"], "OVER_BUDGET")
418− self.assertEqual(r["error"]["allowedItems"], 5)
259+ self.assertEqual(r["meta"]["path"], "two-call")
260+ groups = {g.get("existing", -1): g for g in r["result"]["groups"]}
261+ self.assertEqual(groups[0]["members"], [3, 4])
262+ self.assertEqual(groups[-1]["title"], "Rust async")
263+ self.assertEqual(groups[-1]["members"], [0, 1, 2])
264+ self.assertEqual(r["result"]["leftovers"], [5])
265+ prompts_sent = [a[-1] for a in self.argv_log() if a[0] == "respond"]
266+ self.assertEqual(len(prompts_sent), 2)
267+ self.assertIn("1 | Rust async", prompts_sent[1])
419268
420269
421270class InstallSchemas(HostCase):
422− def test_writes_name_and_topics(self):
271+ def test_nested_supported(self):
423272 for f in os.listdir(os.path.join(self.support, "schemas")):
424273 os.remove(os.path.join(self.support, "schemas", f))
425274 code, _, p = self.run_host(args=["--install-schemas"])
426275 self.assertEqual(code, 0, p.stderr)
427− self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "topics.json", "version"])
276+ self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize.json"])
428277
429− def test_nested_object_gets_its_schema_from_fm_first(self):
430− # Real fm: `--object tabs` must be followed by `--schema <json>` for the item's own schema.
431− for f in os.listdir(os.path.join(self.support, "schemas")):
432− os.remove(os.path.join(self.support, "schemas", f))
278+ def test_falls_back_to_two_flat_schemas(self):
279+ self.control({"schema_nested": False})
433280 code, _, p = self.run_host(args=["--install-schemas"])
434281 self.assertEqual(code, 0, p.stderr)
435− calls = [a for a in self.argv_log() if a[0] == "schema"]
436− self.assertTrue(any("TabTopic" in a for a in calls))
437− topics = next(a for a in calls if "Topics" in a)
438− i = topics.index("--object")
439− self.assertEqual(topics[i + 1:i + 3], ["tabs", "--schema"])
440− self.assertIn('"args"', topics[i + 3]) # the fake fm's output for the TabTopic schema
441− self.assertEqual(topics[i + 4], "--array")
442−
443− def test_schemas_from_an_older_host_are_replaced(self):
444− d = os.path.join(self.support, "schemas")
445− for f in os.listdir(d):
446− os.remove(os.path.join(d, f))
447− for name in ("name.json", "organize-labels.json", "organize-assign.json"):
448− open(os.path.join(d, name), "w").write("{}")
449− r = self.call("ping")
450− self.assertTrue(r["result"]["schemasOk"], r)
451− self.assertEqual(r["result"]["organizeMode"], "topics")
452− self.assertEqual(sorted(os.listdir(d)), ["name.json", "topics.json", "version"])
282+ self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize-assign.json", "organize-labels.json"])
283+
453284
454285 def test_first_use_writes_missing_schemas(self):
455286 for f in os.listdir(os.path.join(self.support, "schemas")):
@@ -527,15 +358,6 @@ class Register(HostCase):
527358 self.assertEqual(p.returncode, 1)
528359 self.assertIn("missing or not executable", p.stderr)
529360
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−
539361 def test_no_browser_found(self):
540362 os.makedirs(self.env["DIAGONAL_APP_SUPPORT"])
541363 p = self.run_copy("--register", self.host_copy)
@@ -557,13 +379,6 @@ class SelfTest(HostCase):
557379 self.assertEqual(code, 1)
558380 self.assertIn("FAIL model available", p.stdout.decode())
559381
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−
567382 def test_selftest_names_the_license_fix(self):
568383 self.control({"license": True})
569384 code, _, p = self.run_host(args=["--selftest"])
host/tests/test_prompts_validate.py+6−15
@@ -1,4 +1,3 @@
1−import json
21import os
32import unittest
43
@@ -18,8 +17,7 @@ def items24():
1817
1918
2019class Golden(unittest.TestCase):
21− def check(self, name, p):
22− text = "[instructions]\n" + p.instructions + "\n\n[stdin]\n" + p.text
20+ def check(self, name, text):
2321 path = os.path.join(GOLDEN, name)
2422 if UPDATE or not os.path.exists(path):
2523 os.makedirs(GOLDEN, exist_ok=True)
@@ -29,16 +27,16 @@ class Golden(unittest.TestCase):
2927 def test_name_3(self):
3028 p = prompts.build_name_prompt({"items": ITEMS3, "currentTitle": "Rust async", "siblingTitles": ["Lisbon trip"]})
3129 self.check("name-3.txt", p)
32− self.assertLessEqual(prompts.size(p), 24_000)
30+ self.assertLessEqual(len(p), 10_000)
3331
3432 def test_name_24(self):
3533 p = prompts.build_name_prompt({"items": items24(), "siblingTitles": []})
3634 self.check("name-24.txt", p)
37− self.assertLessEqual(prompts.size(p), 24_000)
35+ self.assertLessEqual(len(p), 10_000)
3836
3937 def test_organize(self):
40− p = prompts.build_topics_prompt({"items": ITEMS3, "existingGroups": [{"g": 0, "title": "Lisbon trip", "samples": ["Hotels", "Flights"]}], "maxGroups": 1})
41− self.check("topics-3.txt", p)
38+ p = prompts.build_organize_prompt({"items": ITEMS3, "existingGroups": [{"g": 0, "title": "Lisbon trip", "samples": ["Hotels", "Flights"]}], "maxGroups": 1})
39+ self.check("organize-3.txt", p)
4240
4341
4442class Rendering(unittest.TestCase):
@@ -48,7 +46,7 @@ class Rendering(unittest.TestCase):
4846 idx, title, addr, desc = line.split(" | ")
4947 self.assertLessEqual(len(title), 120)
5048 self.assertLessEqual(len(addr), 160)
51− self.assertLessEqual(len(desc), 500)
49+ self.assertLessEqual(len(desc), 300)
5250 self.assertTrue(addr.startswith("example.com/"))
5351
5452 def test_address(self):
@@ -74,13 +72,6 @@ class ValidateName(unittest.TestCase):
7472 with self.assertRaises(validate.ValidationError, msg=bad):
7573 self.v(bad)
7674
77− def test_same_labels_as_the_extension(self):
78− # test/url.test.ts runs these cases against src/shared/label.ts.
79− with open(os.path.join(GOLDEN, "labels.json"), encoding="utf-8") as f:
80− cases = json.load(f)
81− for raw, fixed in cases:
82− self.assertEqual(validate.repair_label(raw), fixed, msg=raw)
83−
8475 def test_emoji_default(self):
8576 self.assertEqual(self.v("Rust async", "not-an-emoji")["emoji"], "🧭")
8677 self.assertEqual(self.v("Rust async", "✈")["emoji"], "✈️")
host/validate.py+34−138
@@ -7,7 +7,8 @@ from urllib.parse import urlsplit
77HERE = os.path.dirname(os.path.realpath(__file__))
88COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]
99BANNED_WORDS = {"tab", "tabs", "group", "groups", "misc", "various", "stuff"}
10−TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—。、,!?:;]+$")
10+QUOTES = "\"'“”‘’«»`"
11+TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—]+$")
1112
1213
1314class ValidationError(Exception):
@@ -88,82 +89,34 @@ def default_emoji(items):
8889 return CATEGORIES[category_for_host(majority)][0]
8990
9091
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−
9792def repair_label(raw):
98− """Trim, drop quotes, banned words and trailing punctuation, cap at 4 words / 28 chars (16 wide characters
99− for no-space scripts), drop a dangling "to"/"of", capitalise. Kept in step with src/shared/label.ts."""
93+ """Trim, drop quotes, banned words and trailing punctuation, cap at 4 words / 28 chars, capitalise."""
10094 if not isinstance(raw, str):
10195 return None
102− s = _QUOTE.sub("", raw)
96+ s = raw.translate({ord(q): None for q in QUOTES})
10397 s = TRAILING_PUNCT.sub("", " ".join(s.split()))
10498 words = [w for w in s.split(" ") if w and re.sub(r"[^\w]", "", w.lower()) not in BANNED_WORDS]
10599 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:
113− words.pop()
114− while len(words) > 1 and words[-1].lower() in DANGLING:
100+ while len(words) > 2 and len(" ".join(words)) > 28:
115101 words.pop()
116102 label = TRAILING_PUNCT.sub("", " ".join(words))
117103 if not label:
118104 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:]
122106 return label if is_valid_label(label) else None
123107
124108
125−# Chinese, Japanese, Korean, Thai, Lao, Khmer and Myanmar titles don't put spaces between words, so a label
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.
130−CJK = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]")
131−
132−
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−
151109def is_valid_label(label):
152110 words = label.split()
153− wide = _no_space(label)
154111 return (
155− (2 <= len(label) <= 28 and _width(label) <= 16 if wide else 3 <= len(label) <= 28)
156− and (1 <= len(words) <= 4 if wide else 2 <= len(words) <= 4)
157− and not _QUOTE.search(label)
112+ 3 <= len(label) <= 28
113+ and 2 <= len(words) <= 4
114+ and not any(q in label for q in QUOTES)
158115 and not TRAILING_PUNCT.search(label)
159116 and not any(w.lower() in BANNED_WORDS for w in words)
160117 )
161118
162119
163−def same_label(a, b):
164− return bool(a) and bool(b) and " ".join(a.split()).casefold() == " ".join(b.split()).casefold()
165−
166−
167120def validate_name(out, payload):
168121 if not isinstance(out, dict):
169122 raise ValidationError("reply is not an object")
@@ -208,15 +161,8 @@ def validate_organize(out, payload):
208161 if not members:
209162 continue
210163 ex = g.get("existing")
211− if isinstance(ex, list): # fm has returned the optional integer as [] or [n]
212− ex = ex[0] if len(ex) == 1 else None
213164 if isinstance(ex, int) and not isinstance(ex, bool) and 0 <= ex < n_existing:
214− # Two topics can both point at one group (its example tabs got different topics): one join each.
215− join = next((j for j in joins if j["existing"] == ex), None)
216− if join:
217− join["members"].extend(members)
218− else:
219− joins.append({"title": "", "emoji": "", "color": "", "existing": ex, "members": members})
165+ joins.append({"title": "", "emoji": "", "color": "", "existing": ex, "members": members})
220166 continue
221167 new.append((members, g))
222168
@@ -241,80 +187,30 @@ def validate_organize(out, payload):
241187 return {"groups": groups, "leftovers": sorted(set(leftovers))}
242188
243189
244−VAGUE_TOPICS = {"other", "others", "misc", "miscellaneous", "general", "various", "unknown", "none", "n/a", "-"}
245−
246−
247−def _norm_topic(t):
248− t = " ".join(str(t or "").split()).strip(" .,:;!?\"'").casefold()
249− return "" if t in VAGUE_TOPICS else t
250−
251−
252−def organize_from_topics(out, payload, owner):
253− """Tabs that share a topic form a group. A topic that an existing group's example tabs have joins that
254− group; others become new groups (titled later by the naming loop) or leftovers when too small.
255− `owner` maps the example tabs' indexes to their group number (prompts.topic_items)."""
256− if not isinstance(out, dict) or not isinstance(out.get("tabs"), list):
257− raise ValidationError("tabs is not a list")
258− n_items = len(payload.get("items", []))
259− tab_topic, group_votes = {}, {}
260− for t in out["tabs"]:
261− if not isinstance(t, dict):
262− continue
263− i, topic = t.get("index"), _norm_topic(t.get("topic"))
264− if isinstance(i, bool) or not isinstance(i, int) or not topic:
265− continue
266− if 0 <= i < n_items:
267− tab_topic.setdefault(i, topic)
268− elif i in owner:
269− votes = group_votes.setdefault(topic, {})
270− votes[owner[i]] = votes.get(owner[i], 0) + 1
271− 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)
276− clusters = {}
277− for i in sorted(tab_topic):
278− clusters.setdefault(tab_topic[i], []).append(i)
190+def organize_from_two_calls(labels_out, assign_out, payload):
191+ """Rebuild the one-call shape from the flat fallback replies, then validate it as usual."""
192+ labels = [x for x in (labels_out or {}).get("labels") or [] if isinstance(x, str)]
193+ emojis = (labels_out or {}).get("emojis") or []
194+ colors = (labels_out or {}).get("colors") or []
195+ n_existing = len(payload.get("existingGroups") or [])
196+ assignment = (assign_out or {}).get("assignment")
197+ if not isinstance(assignment, list):
198+ raise ValidationError("assignment is not a list")
199+ buckets = {}
200+ for i, target in enumerate(assignment[: len(payload.get("items", []))]):
201+ if isinstance(target, int) and not isinstance(target, bool) and 0 <= target < n_existing + len(labels):
202+ buckets.setdefault(target, []).append(i)
279203 groups = []
280− for topic, members in clusters.items():
281− g = _winner(group_votes.get(topic) or {}, members, payload)
282− if g is not None:
283− groups.append({"existing": g, "members": members})
204+ for target, members in sorted(buckets.items()):
205+ if target < n_existing:
206+ groups.append({"existing": target, "members": members})
284207 else:
285− groups.append({"title": "", "emoji": "", "color": "", "members": members})
208+ k = target - n_existing
209+ groups.append({
210+ "title": labels[k],
211+ "emoji": emojis[k] if k < len(emojis) else "",
212+ "color": colors[k] if k < len(colors) else "",
213+ "existing": -1,
214+ "members": members,
215+ })
286216 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"
scripts/store-assets.mjs+1−1
@@ -15,7 +15,7 @@ h1{font-size:40px;margin:0 0 16px;line-height:1.15} p{font-size:20px;line-height
1515
1616const pages = [
1717 { file: "screenshot-popup.png", w: 1280, h: 800, html: `<div><h1>Tabs group themselves</h1><p>Open and close tabs as usual. Diagonal groups related tabs and names each group with Apple's on-device model.</p></div><img class="shot" src="${img(join(shots, "popup.png"))}" style="height:560px">` },
18− { file: "screenshot-settings.png", w: 1280, h: 800, html: `<div><h1>Private by design</h1><p>The model runs on your Mac and sees only a tab's title, address and description. Nothing leaves the Mac.</p></div><div class="shot" style="width:560px;height:680px;overflow:hidden"><img src="${img(join(shots, "options.png"))}" style="width:560px;display:block"></div>` },
18+ { file: "screenshot-settings.png", w: 1280, h: 800, html: `<div><h1>Private by design</h1><p>The model runs on your Mac. It sees a tab's title, address and description, and nothing leaves the Mac unless you pick Apple's Private Cloud Compute.</p></div><div class="shot" style="width:560px;height:680px;overflow:hidden"><img src="${img(join(shots, "options.png"))}" style="width:560px;display:block"></div>` },
1919 { file: "promo-small.png", w: 440, h: 280, html: `<img src="${icon}" style="width:120px"><div style="font-size:34px;font-weight:700">Diagonal</div>`, gap: 24 },
2020];
2121
src/background/engine.ts+6−137
@@ -1,9 +1,8 @@
11import type { GroupColor } from "../shared/colors";
22import { isColor } from "../shared/colors";
33import { colorFor, isInternalUrl, parse, provisionalTitle, trimText } from "../shared/url";
4−import { titleKey } from "../shared/label";
54import type { Settings } from "./settings";
6−import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type RemovedGroup, type State, type TabRecord } from "./state";
5+import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type State, type TabRecord } from "./state";
76
87/**
98 * Section 5: the structural rules as a pure function `(State, Event) → (State, Action[])`.
@@ -51,8 +50,7 @@ export type Action =
5150 | { type: "scheduleDissolve"; groupId: number; delayMs: number }
5251 | { type: "dirty"; groupId: number }
5352 | { type: "loose"; windowId: number }
54− | { type: "unpark"; tabId: number; groupId: number }
55− | { type: "checkFit" };
53+ | { type: "unpark"; tabId: number; groupId: number };
5654
5755export interface Ctx {
5856 now: number;
@@ -62,10 +60,6 @@ export interface Ctx {
6260export const DISSOLVE_DELAY_MS = 1500;
6361export const OWN_CREATE_WINDOW_MS = 2000;
6462export const OWN_WRITE_WINDOW_MS = 60_000;
65−/** A group moved to another window is removed and recreated with the same id within milliseconds. */
66−export const REVIVE_WINDOW_MS = 10_000;
67−export const REMOVED_KEEP_MS = 24 * 3_600_000;
68−export const REMOVED_CAP = 20;
6963
7064/** The pure form: never touches `prev`. */
7165export function step(prev: State, event: EngineEvent, ctx: Ctx): { state: State; actions: Action[] } {
@@ -90,16 +84,8 @@ type Handlers = { [K in EngineEvent["type"]]: Handler<Extract<EngineEvent, { typ
9084const handlers: Handlers = {
9185 tabCreated(s, { tab, opener }, ctx, out) {
9286 if (tab.incognito) return;
93− const known = !!s.tabs[tab.id];
9487 const rec = upsertTab(s, tab, ctx.now);
95− if (rec.groupId !== -1) {
96− // Born inside a group with no page that led to it ("New tab in group"): the user put it there.
97− // A link opened from a grouped tab has an opener and stays Diagonal's to check.
98− const g = s.groups[rec.groupId];
99− const newPage = tab.openerTabId === undefined || isInternalUrl(tab.pendingUrl || tab.url);
100− if (!known && newPage && g && !revivedLately(g, ctx.now)) rec.handPlaced = true;
101− dirty(s, rec.groupId, ctx, out);
102− }
88+ if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
10389 const before = out.length;
10490 openerRule(s, tab, opener ?? snapshotOf(s, tab.openerTabId), ctx, out);
10591 if (out.length === before) loose(rec, ctx, out);
@@ -114,33 +100,11 @@ const handlers: Handlers = {
114100 else loose(rec, ctx, out);
115101 return;
116102 }
117− const titleChanged = titleKey(before.title) !== titleKey(rec.title); // not an unread count ticking over
103+ const titleChanged = before.title !== rec.title;
118104 const pathChanged = pathKey(before.url) !== pathKey(rec.url);
119105 if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
120106 if (before.groupId !== rec.groupId) {
121107 left(s, before.groupId, ctx, out);
122− if (inTransit(s, before.groupId, rec.groupId, ctx.now)) {
123− // Chromium moves a group to another window by removing it, ungrouping its tabs and grouping
124− // them again under the same id: nobody chose anything, so the tab keeps what it had.
125− delete s.ownAdds[rec.id];
126− if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
127− else loose(rec, ctx, out);
128− return;
129− }
130− rec.fitPending = undefined;
131− if (s.groups[before.groupId]?.origin === "tidy") rec.parkedAt = undefined; // out of Parked
132− if (rec.groupId !== -1) {
133− rec.keepLoose = undefined; // in a group now: there is no loose choice left to protect
134− // Into a group: ours if the worker put it there, otherwise the user did and it stays.
135− if (s.ownAdds[rec.id] !== undefined) {
136− delete s.ownAdds[rec.id];
137− rec.handPlaced = undefined;
138− } else {
139− rec.handPlaced = true;
140− }
141− } else {
142− rec.handPlaced = undefined;
143− }
144108 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
145109 else if (before.groupId !== -1) {
146110 // Out of a group: ours if the worker dissolved it, otherwise the user's choice to keep it loose.
@@ -158,10 +122,6 @@ const handlers: Handlers = {
158122 if (titleChanged || pathChanged || loaded || before.pinned !== rec.pinned) loose(rec, ctx, out);
159123 return;
160124 }
161− // Only a tab that had finished a page moves on: a new tab's first load redirecting (http to https,
162− // a link shortener) is still its first page.
163− if (pathChanged && before.status === "complete" && fitCheckable(s, rec, ctx.settings)) rec.fitPending = true;
164− if (rec.fitPending && rec.status === "complete") out.push({ type: "checkFit" });
165125 if (titleChanged || pathChanged) dirty(s, rec.groupId, ctx, out);
166126 else if (before.status !== "complete" && rec.status === "complete" && s.groups[rec.groupId]?.dirty) {
167127 // A member finished loading: the naming loop may have been waiting for it.
@@ -180,12 +140,9 @@ const handlers: Handlers = {
180140 const rec = s.tabs[tabId];
181141 if (!rec) return;
182142 rec.lastActivatedAt = ctx.now;
183− // A tab waiting for its fit check is checked once the user has moved on from it.
184− if (Object.values(s.tabs).some((t) => t.fitPending && t.id !== tabId)) out.push({ type: "checkFit" });
185143 // Using a parked tab means it is not stale: it leaves Parked and auto-organize places it.
186144 if (rec.groupId !== -1 && s.groups[rec.groupId]?.origin === "tidy") {
187145 rec.parkedFrom = undefined;
188− rec.parkedAt = undefined;
189146 out.push({ type: "unpark", tabId, groupId: rec.groupId });
190147 }
191148 },
@@ -200,15 +157,6 @@ const handlers: Handlers = {
200157 registerGroup(s, group, ctx, out);
201158 return;
202159 }
203− // A group Chromium restores (a reopened window, the session after a restart) is created untitled
204− // and gets its title a moment later: that title and colour can say it is one of Diagonal's back.
205− if (rec.registeredAt !== undefined && ctx.now - rec.registeredAt <= REVIVE_WINDOW_MS) {
206− const back = buried(s, group.title ?? "", group.color);
207− if (back) {
208− revive(s, back, group, ctx);
209− return;
210− }
211− }
212160 rec.windowId = group.windowId;
213161 const own = s.ownWrites[group.id];
214162 const title = group.title ?? "";
@@ -236,17 +184,15 @@ const handlers: Handlers = {
236184 }
237185 },
238186
239− groupRemoved(s, { groupId }, ctx) {
240− const rec = s.groups[groupId];
187+ groupRemoved(s, { groupId }) {
241188 delete s.groups[groupId];
242189 delete s.ownWrites[groupId];
243− if (rec) bury(s, rec, ctx.now);
244190 },
245191
246192 meta(s, { tabId, description }, ctx, out) {
247193 const rec = s.tabs[tabId];
248194 if (!rec) return;
249− const d = trimText(description, 500) || undefined;
195+ const d = trimText(description, 300) || undefined;
250196 if (d === rec.description) return;
251197 rec.description = d;
252198 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
@@ -290,21 +236,6 @@ function openerRule(s: State, child: TabSnapshot, opener: TabSnapshot | undefine
290236export const shouldDissolve = (g: GroupRecord, settings: Settings): boolean =>
291237 g.managed && (g.origin === "opener" || g.origin === "organize") && !g.userNamed && settings.dissolveSingletons;
292238
293−/** Only groups Diagonal made and still names itself, and only tabs it put there, are checked for fit. */
294−export function fitCheckable(s: State, rec: TabRecord, settings: Settings): boolean {
295− const g = s.groups[rec.groupId];
296− return (
297− settings.autoOrganize &&
298− !!g &&
299− g.managed &&
300− (g.origin === "opener" || g.origin === "organize") &&
301− !g.userNamed &&
302− !rec.handPlaced &&
303− !rec.pinned &&
304− !isInternalUrl(rec.url)
305− );
306−}
307−
308239/** An ungrouped tab changed: auto-organize should look at its window once things settle. */
309240function loose(rec: TabRecord, ctx: Ctx, out: Action[]): void {
310241 if (!ctx.settings.autoOrganize || rec.groupId !== -1 || rec.pinned || rec.keepLoose || isInternalUrl(rec.url)) return;
@@ -343,64 +274,16 @@ function registerGroup(s: State, group: GroupSnapshot, ctx: Ctx, out: Action[]):
343274 s.groups[group.id] = newGroupRecord(group.id, group.windowId, pending.origin, color, { stripTitle: group.title ?? "" });
344275 return;
345276 }
346− // Moved to another window (same id, just removed) or reopened (same title and colour): the same group.
347− const moved = s.removedGroups[group.id];
348− const back = moved && ctx.now - moved.removedAt <= REVIVE_WINDOW_MS ? moved : buried(s, group.title ?? "", group.color);
349− if (back) {
350− revive(s, back, group, ctx);
351− return;
352− }
353277 const titled = !!group.title;
354278 const rec = newGroupRecord(group.id, group.windowId, "user", color, {
355279 managed: ctx.settings.nameUserGroups,
356280 userNamed: titled,
357281 stripTitle: group.title ?? "",
358− registeredAt: ctx.now,
359282 });
360283 s.groups[group.id] = rec;
361284 if (rec.managed && !titled) dirty(s, group.id, ctx, out);
362285}
363286
364−/** Keep a removed group's record for a while, so the group is still Diagonal's if Chromium brings it back. */
365−export function bury(s: State, rec: GroupRecord, now: number): void {
366− s.removedGroups[rec.id] = { ...rec, removedAt: now };
367− pruneRemoved(s, now);
368−}
369−
370−/** The most recently removed group with this title and colour, if any. */
371−function buried(s: State, title: string, color: string): RemovedGroup | undefined {
372− if (!title) return undefined;
373− let best: RemovedGroup | undefined;
374− for (const g of Object.values(s.removedGroups)) {
375− if ((g.stripTitle ?? "") === title && g.color === color && (!best || g.removedAt > best.removedAt)) best = g;
376− }
377− return best;
378−}
379−
380−function revive(s: State, back: RemovedGroup, group: GroupSnapshot, ctx: Ctx): void {
381− const { removedAt: _, registeredAt: __, ...rec } = back;
382− delete s.removedGroups[back.id];
383− delete s.groups[group.id];
384− s.groups[group.id] = {
385− ...rec,
386− id: group.id,
387− windowId: group.windowId,
388− color: isColor(group.color) ? group.color : rec.color,
389− // An untitled restore gets its title next: keep the known one so that update is not read as an edit.
390− stripTitle: group.title || rec.stripTitle,
391− revivedAt: ctx.now,
392− };
393−}
394−
395−const revivedLately = (g: GroupRecord | undefined, now: number): boolean => !!g?.revivedAt && now - g.revivedAt <= REVIVE_WINDOW_MS;
396−
397−/** Leaving a group Chromium just removed, or joining one it is bringing back: a move, not a choice. */
398−function inTransit(s: State, from: number, to: number, now: number): boolean {
399− const gone = (id: number) => !s.groups[id] && !!s.removedGroups[id];
400− if (to === -1) return from !== -1 && gone(from);
401− return from === -1 && (gone(to) || revivedLately(s.groups[to], now));
402−}
403−
404287function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
405288 const before = s.tabs[tab.id];
406289 const url = tab.url || tab.pendingUrl || "";
@@ -421,12 +304,7 @@ function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
421304 // A description belongs to a page: keep it only while the tab stays on that page.
422305 if (before?.description && pathKey(before.url) === pathKey(url)) rec.description = before.description;
423306 if (before?.keepLoose) rec.keepLoose = before.keepLoose;
424− if (before?.parkedFrom) rec.parkedFrom = before.parkedFrom;
425− if (before?.parkedAt) rec.parkedAt = before.parkedAt;
426307 if (before?.organizedKey) rec.organizedKey = before.organizedKey;
427− if (before?.organizeRefused) rec.organizeRefused = before.organizeRefused;
428− if (before?.handPlaced) rec.handPlaced = true;
429− if (before?.fitPending) rec.fitPending = true;
430308 s.tabs[tab.id] = rec;
431309 return rec;
432310}
@@ -449,13 +327,4 @@ function prune(s: State, now: number): void {
449327 s.pendingCreates = s.pendingCreates.filter((p) => now - p.at <= OWN_CREATE_WINDOW_MS);
450328 for (const [id, w] of Object.entries(s.ownWrites)) if (now - w.at > OWN_WRITE_WINDOW_MS) delete s.ownWrites[+id];
451329 for (const [id, at] of Object.entries(s.ownUngroups)) if (now - at > OWN_WRITE_WINDOW_MS) delete s.ownUngroups[+id];
452− for (const [id, at] of Object.entries(s.ownAdds)) if (now - at > OWN_WRITE_WINDOW_MS) delete s.ownAdds[+id];
453− pruneRemoved(s, now);
454−}
455−
456−function pruneRemoved(s: State, now: number): void {
457− const removed = Object.values(s.removedGroups).sort((a, b) => b.removedAt - a.removedAt);
458− removed.forEach((g, i) => {
459− if (i >= REMOVED_CAP || now - g.removedAt > REMOVED_KEEP_MS) delete s.removedGroups[g.id];
460− });
461330}
src/background/fit.tsdeleted−122
@@ -1,122 +0,0 @@
1−import { isInternalUrl } from "../shared/url";
2−import { fitCheckable } from "./engine";
3−import { settled, toItems, type Member } from "./naming";
4−import type { OrganizePayload, OrganizeResult } from "./organize";
5−import { ungroup, type Runtime } from "./runtime";
6−
7−/**
8− * A tab in a group Diagonal made that moves on to an unrelated page leaves the group; auto-organize then
9− * files it with its new topic. The check asks the host's topic question for the tab and a few groupmates:
10− * the tab fits when it shares a topic with at least one of them.
11− */
12−export const FIT_DELAY_MS = 2000;
13−export const FIT_SAMPLE = 5; // groupmates to compare with, most recently used first
14−const MIN_OTHERS = 2; // with fewer, one topic answer decides too much
15−
16−export type FitVerdict = "fits" | "leave" | "unsure";
17−
18−/** Index 0 is the tab being checked, the rest its groupmates. */
19−export function fitVerdict(result: OrganizeResult): FitVerdict {
20− const withTab = result.groups.find((g) => g.members.includes(0));
21− if (withTab && withTab.members.some((i) => i !== 0)) return "fits";
22− // Only move it when its groupmates still agree with each other; otherwise the group itself is mixed.
23− return result.groups.some((g) => !g.members.includes(0) && g.members.length >= 2) ? "leave" : "unsure";
24−}
25−
26−export class FitChecker {
27− private timer: ReturnType<typeof setTimeout> | undefined;
28− private running = false;
29− private again = false;
30−
31− constructor(private rt: Runtime) {}
32−
33− touch(delayMs = FIT_DELAY_MS): void {
34− if (this.timer) clearTimeout(this.timer);
35− this.timer = setTimeout(() => {
36− this.timer = undefined;
37− void this.run();
38− }, delayMs);
39− }
40−
41− async run(): Promise<void> {
42− if (this.running) {
43− this.again = true;
44− return;
45− }
46− this.running = true;
47− try {
48− const pending = Object.values(this.rt.state().tabs).filter((t) => t.fitPending).map((t) => t.id);
49− for (const id of pending) await this.check(id);
50− } catch (e) {
51− this.rt.log("fit check failed", e);
52− } finally {
53− this.running = false;
54− if (this.again) {
55− this.again = false;
56− this.touch();
57− }
58− }
59− }
60−
61− private async check(tabId: number): Promise<void> {
62− const rt = this.rt;
63− const rec = rt.state().tabs[tabId];
64− if (!rec?.fitPending) return;
65− const tab = await chrome.tabs.get(tabId).catch(() => undefined);
66− const done = () => {
67− const live = rt.state().tabs[tabId];
68− if (live) live.fitPending = undefined;
69− rt.commit();
70− };
71− if (!tab || (tab.groupId ?? -1) !== rec.groupId || !fitCheckable(rt.state(), rec, rt.settings())) return done();
72− // Still loading, or the user is reading it: the load or the next tab switch checks again.
73− if (tab.status !== "complete" || tab.active) return;
74− const pausedUntil = rt.state().host.pausedUntil;
75− if (pausedUntil && pausedUntil > rt.now()) return;
76−
77− const others = (await chrome.tabs.query({ groupId: rec.groupId }).catch(() => []))
78− .filter((t) => t.id !== tabId && settled(t) && !isInternalUrl(t.url))
79− .sort((a, b) => (b.lastAccessed ?? 0) - (a.lastAccessed ?? 0))
80− .slice(0, FIT_SAMPLE);
81− if (others.length < MIN_OTHERS) return done();
82−
83− const s = rt.state();
84− const toMember = (t: chrome.tabs.Tab): Member => ({
85− id: t.id!,
86− index: t.index,
87− url: t.url ?? "",
88− title: t.title ?? "",
89− status: t.status,
90− description: s.tabs[t.id!]?.description,
91− lastActive: t.lastAccessed ?? 0,
92− });
93− const url = tab.url ?? "";
94− const payload: OrganizePayload & { nameGroups: false } = {
95− items: toItems([tab, ...others].map(toMember), rt.settings()),
96− existingGroups: [],
97− allowNew: true,
98− maxGroups: 8,
99− minGroupSize: 2,
100− nameGroups: false, // only the topics matter here
101− };
102− const reply = await rt.host<OrganizeResult>("organize", payload);
103−
104− const live = rt.state().tabs[tabId];
105− // It moved on again (or out) while fm was thinking: that newer page gets its own check.
106− if (!live || live.url !== url || live.groupId !== rec.groupId) return;
107− if (!reply.ok) {
108− rt.log("fit check", reply.error.code);
109− return done();
110− }
111− const verdict = fitVerdict(reply.result);
112− done();
113− if (verdict !== "leave") return;
114− const current = await chrome.tabs.get(tabId).catch(() => undefined);
115− if (!current || current.active || current.groupId !== rec.groupId) return;
116− rt.log("fit check: tab left its group", tabId, rec.groupId);
117− live.organizedKey = undefined; // auto-organize looks at it again
118− rt.state().ownUngroups[tabId] = rt.now();
119− rt.commit();
120− await ungroup(rt, [tabId]);
121− }
122−}
src/background/host.ts+1−4
@@ -9,7 +9,7 @@ export const HOST_MANIFEST_PATH = `${HOST_MANIFEST_DIR}${HOST_NAME}.json`;
99export type Op = "ping" | "name" | "organize";
1010
1111export interface HostOpts {
12− model: "system";
12+ model: "system" | "pcc";
1313 timeoutMs: number;
1414 emoji: boolean;
1515 strict?: boolean; // append "reply with only the object" (BAD_MODEL_OUTPUT retry)
@@ -102,9 +102,6 @@ export function normalizeReply<T>(reply: unknown, at: number): HostReply<T> {
102102 };
103103}
104104
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−
108105/** Errors that mean "fix the setup", not "try this group again later". */
109106export const SETUP_ERRORS = new Set<HostErrorCode>([
110107 "HOST_NOT_FOUND", "HOST_FORBIDDEN", "FORBIDDEN_ORIGIN", "SCHEMA_MISSING", "MODEL_UNAVAILABLE", "LICENSE_REQUIRED",
src/background/index.ts+36−128
@@ -1,14 +1,13 @@
11import { stripTitle } from "../shared/label";
2−import { 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";
2+import { applyEvent, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
3+import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, SETUP_ERRORS, type HostOpts, type HostReply, type Op } from "./host";
44import { GLOBAL_PAUSE_AFTER, GLOBAL_PAUSE_MS, Naming, RATE_LIMIT_PAUSES_MS, type Member, type NamePayload, type NameResult } from "./naming";
55import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
6−import { FitChecker } from "./fit";
76import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
87import { withDefaults, type Settings } from "./settings";
9−import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type RemovedGroup, type State, type TabRecord } from "./state";
8+import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type State, type TabRecord } from "./state";
109import { readDisk, shouldReload, TRIED_KEY, UPDATE_CHECK_MINUTES } from "./selfupdate";
11−import { adoptParked, forgetArchived, looksParked, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
10+import { forgetArchived, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
1211
1312/** Event wiring only: every rule lives in engine / naming / organize / tidy. */
1413
@@ -20,8 +19,6 @@ const ALARM_HOST = "host-retry";
2019const ALARM_AUTO = "auto-organize";
2120const ALARM_UPDATE = "self-update";
2221const HOST_RETRY_MS = 600_000;
23−/** Records made after this were made by this worker, so after a restart they describe the new session. */
24−const WORKER_STARTED_AT = Date.now();
2522
2623// ----- state cache ------------------------------------------------------------------------------
2724
@@ -93,11 +90,7 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
9390 h.lastOkAt = Date.now();
9491 h.consecutiveFailures = 0;
9592 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− }
93+ h.rateLimitStep = 0;
10194 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
10295 } else {
10396 recordHostError(reply.error);
@@ -111,8 +104,6 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
111104}
112105
113106function 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;
116107 const h = state.host;
117108 const now = Date.now();
118109 h.lastError = { ...e, at: now };
@@ -144,7 +135,11 @@ async function ping(): Promise<HostReply<PingResult>> {
144135 } else if (!reply.result.schemasOk) {
145136 recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
146137 } else {
147− recovered();
138+ h.pausedUntil = undefined;
139+ h.lastError = undefined;
140+ chrome.alarms.clear(ALARM_HOST);
141+ void naming.sweepDirty();
142+ void autoOrganizer.sweep();
148143 }
149144 }
150145 commit();
@@ -152,16 +147,6 @@ async function ping(): Promise<HostReply<PingResult>> {
152147 return reply;
153148}
154149
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−
165150// ----- badge -----------------------------------------------------------------------------------
166151
167152function busy(on: boolean): void {
@@ -173,6 +158,7 @@ function hostUnhealthy(): boolean {
173158 const h = state.host;
174159 const now = Date.now();
175160 if (h.pausedUntil && h.pausedUntil > now) return true;
161+ if (h.consecutiveFailures >= 3) return true;
176162 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
177163}
178164
@@ -253,7 +239,6 @@ async function scheduleAutoFallback(when: number): Promise<void> {
253239}
254240
255241const autoOrganizer = new AutoOrganizer(rt, { scheduleFallback: (when) => void scheduleAutoFallback(when) });
256−const fitChecker = new FitChecker(rt);
257242
258243// ----- engine glue -----------------------------------------------------------------------------
259244
@@ -342,9 +327,6 @@ async function run(a: Action): Promise<void> {
342327 case "dirty":
343328 naming.touch(a.groupId);
344329 break;
345− case "checkFit":
346− fitChecker.touch();
347− break;
348330 }
349331}
350332
@@ -359,24 +341,16 @@ async function tabById(id: number): Promise<chrome.tabs.Tab | undefined> {
359341 * across restarts, so records are matched by id first and then by URL (tabs) or title and colour
360342 * (groups), which keeps managed groups managed after the browser restarts.
361343 */
362−async function reconcile(reason: "startup" | "installed"): Promise<void> {
344+async function reconcile(): Promise<void> {
363345 const [tabs, groups] = await Promise.all([chrome.tabs.query({}), chrome.tabGroups.query({})]);
364346 const now = Date.now();
365− const liveTabIds = new Set(tabs.map((t) => t.id));
366− // Records of tabs that are gone, by address: after a restart every tab has a new id.
367− const byUrl = new Map<string, TabRecord>(Object.values(state.tabs).filter((t) => !liveTabIds.has(t.id)).map((t) => [t.url, t]));
347+ const oldTabs = Object.values(state.tabs);
348+ const byUrl = new Map<string, TabRecord>(oldTabs.map((t) => [t.url, t]));
368349 const nextTabs: Record<number, TabRecord> = {};
369350 for (const t of tabs) {
370351 if (t.incognito || t.id === undefined) continue;
371352 const url = t.url || t.pendingUrl || "";
372− const same = state.tabs[t.id]?.url === url ? state.tabs[t.id] : undefined;
373− // An event handled before this ran may have recorded a restored tab as new: its old record knows more.
374− const old = byUrl.get(url);
375− const prev = old && (!same || same.createdAt >= WORKER_STARTED_AT) ? old : same;
376− // Chromium stamps a restored tab it has not shown yet with the restore time, so after a restart the
377− // stored time is the truer one for every tab but the one on screen.
378− const lastSeen =
379− reason === "startup" && prev && !t.active ? prev.lastActivatedAt : Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0);
353+ const prev = state.tabs[t.id]?.url === url ? state.tabs[t.id] : byUrl.get(url);
380354 nextTabs[t.id] = {
381355 id: t.id,
382356 windowId: t.windowId,
@@ -389,51 +363,24 @@ async function reconcile(reason: "startup" | "installed"): Promise<void> {
389363 openerTabId: t.openerTabId,
390364 description: prev?.description,
391365 parkedFrom: prev?.parkedFrom,
392− parkedAt: prev?.parkedAt,
393366 keepLoose: prev?.keepLoose,
394367 organizedKey: prev?.organizedKey,
395− organizeRefused: prev?.organizeRefused,
396− handPlaced: prev?.handPlaced,
397− fitPending: prev?.fitPending,
398368 createdAt: prev?.createdAt ?? now,
399− lastActivatedAt: lastSeen || now,
369+ lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
400370 };
401371 }
402− const liveGroupIds = new Set(groups.map((g) => g.id));
403372 const unmatched = new Map(Object.values(state.groups).map((g) => [g.id, g]));
404− // Made by this worker for a group it did not know: after a restart, that is a restored group whose
405− // events were handled before this ran, and the record from before the restart is the one to keep.
406− const fresh = (g: GroupRecord) => g.origin === "user" && (g.registeredAt ?? 0) >= WORKER_STARTED_AT;
407− const lookalike = (title: string, color: string): GroupRecord | RemovedGroup | undefined => {
408− if (title === "") return undefined;
409− const alike = (o: GroupRecord) => (o.stripTitle ?? "") === title && o.color === color;
410− return (
411− [...unmatched.values()].find((o) => alike(o) && !liveGroupIds.has(o.id) && !fresh(o)) ??
412− Object.values(state.removedGroups)
413− .filter(alike)
414− .sort((a, b) => b.removedAt - a.removedAt)[0]
415− );
416− };
417373 const nextGroups: Record<number, GroupRecord> = {};
418374 for (const g of groups) {
419375 const title = g.title ?? "";
420− let prev: GroupRecord | undefined = unmatched.get(g.id);
421− if (prev && (prev.stripTitle ?? "") !== title) prev = undefined;
422− const before = !prev || fresh(prev) ? lookalike(title, g.color) : undefined;
423− if (before) prev = before;
424− let rec: GroupRecord;
425− if (prev) {
426− unmatched.delete(prev.id);
427− delete state.removedGroups[prev.id];
428− const { removedAt: _, ...kept } = prev as RemovedGroup;
429− rec = { ...kept, id: g.id, windowId: g.windowId, color: g.color, stripTitle: title };
430− if (before) rec.registeredAt = undefined;
431− } else {
432− // Untitled so far may still mean restored: its title can arrive next and match (engine.ts groupUpdated).
433− rec = newGroupRecord(g.id, g.windowId, "user", g.color, { managed: settings.nameUserGroups, userNamed: !!title, stripTitle: title, registeredAt: now });
376+ let prev = unmatched.get(g.id);
377+ if (!prev || (prev.stripTitle ?? "") !== title) {
378+ prev = [...unmatched.values()].find((o) => (o.stripTitle ?? "") === title && title !== "" && o.color === g.color);
434379 }
435− // Earlier versions turned Parked into a plain group after a restart: give it back its job.
436− if (rec.origin === "user" && looksParked(g, settings)) adoptParked(rec, settings);
380+ if (prev) unmatched.delete(prev.id);
381+ const rec: GroupRecord = prev
382+ ? { ...prev, id: g.id, windowId: g.windowId, color: g.color, stripTitle: title }
383+ : newGroupRecord(g.id, g.windowId, "user", g.color, { managed: settings.nameUserGroups, userNamed: !!title, stripTitle: title });
437384 if (rec.managed) {
438385 rec.dirty = true;
439386 rec.dirtyAt = 0;
@@ -441,17 +388,13 @@ async function reconcile(reason: "startup" | "installed"): Promise<void> {
441388 }
442389 nextGroups[g.id] = rec;
443390 }
444− // Groups not seen now (a closed window, a quit that did not save) may still come back: keep them a day.
445− for (const old of unmatched.values()) if (!liveGroupIds.has(old.id) && !fresh(old)) bury(state, old, now);
446391 state.tabs = nextTabs;
447392 state.groups = nextGroups;
448393 state.pendingCreates = [];
449394 state.ownWrites = {};
450395 state.ownUngroups = {};
451− state.ownAdds = {};
452− // Undo lists name tabs by id, and after a restart none of those ids exist.
453− if (reason === "startup" || (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS)) state.lastSweep = undefined;
454− if (reason === "startup" || (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS)) state.lastOrganize = undefined;
396+ if (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS) state.lastSweep = undefined;
397+ if (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS) state.lastOrganize = undefined;
455398 commit();
456399}
457400
@@ -464,13 +407,9 @@ async function injectBacklog(): Promise<void> {
464407 }
465408}
466409
467−let reconciled: Promise<void> | undefined;
468−
469410async function boot(reason: "startup" | "installed"): Promise<void> {
470− // Queued before anything else this wake handles (serial waits for `ready` itself): after a restart
471− // Chromium replays the restored groups right after onStartup, and they must meet the reconciled state.
472− reconciled = serial("reconcile", () => reconcile(reason));
473− await reconciled;
411+ await ready;
412+ await serial("reconcile", reconcile);
474413 if (!(await chrome.alarms.get(ALARM_TIDY))) chrome.alarms.create(ALARM_TIDY, { periodInMinutes: 30, delayInMinutes: 1 });
475414 if (reason === "installed") void injectBacklog();
476415 await ping();
@@ -512,29 +451,20 @@ if (GROUPS_SUPPORTED) {
512451
513452 chrome.tabs.onReplaced.addListener((added, removed) =>
514453 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.
517454 const rec = state.tabs[removed];
518455 delete state.tabs[removed];
519456 const t = await tabById(added);
520457 if (t) {
521− if (rec && !state.tabs[added]) state.tabs[added] = { ...rec, id: added };
522458 await feed({ type: "tabUpdated", tab: snapTab(t) });
459+ if (rec?.description && state.tabs[added]) state.tabs[added].description = rec.description;
523460 }
524461 }),
525462 );
526463
527464 chrome.tabs.onActivated.addListener(({ tabId }) => void serial("tabs.onActivated", () => feed({ type: "tabActivated", tabId })));
528465
529− // A group is read when its turn comes, not as Chromium sent it: a restored group reports itself
530− // untitled and then titled, and replaying the stale "untitled" would read as you clearing the title.
531− const refetchGroup = (where: string, type: "groupCreated" | "groupUpdated") => (g: chrome.tabGroups.TabGroup) =>
532− void serial(where, async () => {
533− const now = await chrome.tabGroups.get(g.id).catch(() => undefined);
534− if (now) await feed({ type, group: snapGroup(now) });
535− });
536− chrome.tabGroups.onCreated.addListener(refetchGroup("tabGroups.onCreated", "groupCreated"));
537− chrome.tabGroups.onUpdated.addListener(refetchGroup("tabGroups.onUpdated", "groupUpdated"));
466+ chrome.tabGroups.onCreated.addListener((g) => void serial("tabGroups.onCreated", () => feed({ type: "groupCreated", group: snapGroup(g) })));
467+ chrome.tabGroups.onUpdated.addListener((g) => void serial("tabGroups.onUpdated", () => feed({ type: "groupUpdated", group: snapGroup(g) })));
538468 chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
539469}
540470
@@ -563,10 +493,7 @@ chrome.alarms.onAlarm.addListener((alarm) => {
563493 await ready;
564494 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
565495 else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
566− else if (alarm.name === ALARM_TIDY) {
567− await reconciled; // an overdue sweep right after a restart waits for the new tab and group ids
568− await runSweep(rt).catch((e) => logError("tidy", e));
569− }
496+ else if (alarm.name === ALARM_TIDY) await runSweep(rt).catch((e) => logError("tidy", e));
570497 else if (alarm.name === ALARM_HOST) {
571498 const r = await ping();
572499 if (!r.ok || hostUnhealthy()) chrome.alarms.create(ALARM_HOST, { when: Date.now() + HOST_RETRY_MS });
@@ -580,46 +507,27 @@ chrome.storage.onChanged.addListener((changes, area) => {
580507 await ready;
581508 const before = settings;
582509 settings = withDefaults(changes.settings.newValue);
583− if (before.emoji !== settings.emoji) await rewriteTitles(before);
584− if (before.nameUserGroups !== settings.nameUserGroups) applyNameUserGroups();
510+ if (before.emoji !== settings.emoji) await rewriteTitles();
585511 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
586512 if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
587513 refreshBadge();
588514 })();
589515});
590516
591−/** "Name groups I make too" applies to the groups you already made, not only to new ones. */
592−function applyNameUserGroups(): void {
593− for (const g of Object.values(state.groups)) {
594− if (g.origin !== "user" || g.userNamed) continue;
595− g.managed = settings.nameUserGroups;
596− if (g.managed) {
597− markDirty(g, Date.now());
598− naming.touch(g.id);
599− }
600− }
601− commit();
602−}
603−
604517/** Emoji switched on or off: rewrite the titles the extension wrote. */
605−async function rewriteTitles(before: Settings): Promise<void> {
518+async function rewriteTitles(): Promise<void> {
606519 for (const g of Object.values(state.groups)) {
607− // Parked is always "user named", so its title is compared instead: one you typed stays.
608− if (g.origin === "tidy") {
609− if ((g.stripTitle ?? "") === parkedTitle(before)) await updateGroup(rt, g.id, { title: parkedTitle(settings) });
610− } else if (g.managed && !g.userNamed && g.title) await updateGroup(rt, g.id, { title: stripTitle(g.title, g.emoji, settings.emoji) });
520+ if (g.origin === "tidy") await updateGroup(rt, g.id, { title: parkedTitle(settings) });
521+ else if (g.managed && !g.userNamed && g.title) await updateGroup(rt, g.id, { title: stripTitle(g.title, g.emoji, settings.emoji) });
611522 }
612523}
613524
614525async function nameGroupNow(groupId: number): Promise<void> {
615526 const g = state.groups[groupId];
616527 if (!g) return;
617− // An explicit request overrides "don't name" and a hand-typed title for this group, and the model's
618− // answer goes on the strip even when it is close to the label it gave before.
528+ // An explicit request overrides "don't name" and a hand-typed title for this group.
619529 g.managed = true;
620530 g.userNamed = false;
621− g.title = undefined;
622− g.lastNamedAt = undefined;
623531 commit();
624532 await naming.nameNow(groupId);
625533}
src/background/naming.ts+29−87
@@ -1,7 +1,6 @@
1−import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, titleKey, LABEL_MAX_CHARS } from "../shared/label";
1+import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, LABEL_MAX_CHARS } from "../shared/label";
22import { sha1 } from "../shared/sha1";
33import { isInternalUrl, promptUrl, trimText } from "../shared/url";
4−import { pathKey } from "./engine";
54import type { HostOpts, HostReply } from "./host";
65import { SETUP_ERRORS } from "./host";
76import type { Settings } from "./settings";
@@ -9,7 +8,7 @@ import type { GroupRecord, HostError, State } from "./state";
98
109/** Section 6: debounced, sticky, sibling-aware naming of managed groups. */
1110
12−export const NAME_ITEM_CAP = 48;
11+export const NAME_ITEM_CAP = 24;
1312export const SAMPLE_EACH = 8;
1413export const QUEUE_GAP_MS = 250;
1514export const FALLBACK_ALARM_MS = 30_000;
@@ -50,18 +49,12 @@ export interface NameResult {
5049 emoji: string;
5150}
5251
53−/** Tabs that can describe a group: web pages, not the new-tab page or internal pages. */
52+/** Tabs that can describe a group: loaded web pages, not the new-tab page or internal pages. */
5453export const nameable = (m: Member): boolean => !isInternalUrl(m.url);
5554
56−/**
57− * Done loading, or not loaded at all: a tab the browser discarded to save memory (status "unloaded") keeps
58− * its title and address, so it can describe its group without being loaded again.
59− */
60−export const settled = (t: { status?: string }): boolean => t.status !== "loading";
61−
6255/** sha-1 of the sorted `url|title` lines: reorder-proof, changes when a member or its title does. */
6356export const membersHash = (members: Pick<Member, "url" | "title">[]): string =>
64− sha1(members.map((m) => `${m.url}|${titleKey(m.title)}`).sort().join("\n"));
57+ sha1(members.map((m) => `${m.url}|${m.title}`).sort().join("\n"));
6558
6659export const memberUrls = (members: Pick<Member, "url">[]): string[] => members.map((m) => m.url).sort();
6760
@@ -79,7 +72,7 @@ export function sampleMembers(members: Member[], cap = NAME_ITEM_CAP): Member[]
7972export function toItems(members: Member[], settings: Pick<Settings, "sendDescription" | "sendFullUrl">, withDescriptions = true): NameItem[] {
8073 return members.map((m, i) => {
8174 const item: NameItem = { i, title: trimText(m.title, 120), url: promptUrl(m.url, settings.sendFullUrl) };
82− const d = withDescriptions && settings.sendDescription ? trimText(m.description, 500) : "";
75+ const d = withDescriptions && settings.sendDescription ? trimText(m.description, 300) : "";
8376 if (d) item.description = d;
8477 return item;
8578 });
@@ -114,9 +107,8 @@ export function shouldApply(g: GroupRecord, label: string, urls: string[]): bool
114107 if (g.userNamed) return false;
115108 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
116109 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));
119− const after = new Set(urls.map(pathKey));
110+ const before = new Set(g.memberUrls ?? []);
111+ const after = new Set(urls);
120112 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
121113 return changed; // a paraphrase with the same members keeps the old title
122114}
@@ -216,32 +208,18 @@ export class Naming {
216208 }
217209 const now = this.d.now();
218210 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;
211+ if (g.nextAttemptAt && g.nextAttemptAt > now) return;
221212 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
222213 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
223214 }
224215 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
225216 if (!members) return;
226− if (!members.every(settled)) return; // stay dirty; the load finishing touches it again
227− if (members.length < 2) {
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;
231− this.d.commit();
232− }
233− return;
234− }
235− const hash = membersHash(members);
236− if (!force && hash === g.membersHash) {
237− settle(g);
217+ if (members.length < 2 || members.some((m) => m.status !== "complete")) return; // stay dirty; next event or alarm
218+ if (!force && membersHash(members) === g.membersHash) {
219+ g.dirty = false;
238220 this.d.commit();
239221 return;
240222 }
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− }
245223 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
246224 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
247225 void this.pump();
@@ -274,10 +252,10 @@ export class Naming {
274252 if (!g || !eligible(g, settings)) return;
275253 // Built lazily: a group that changed three times while queued is described once.
276254 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
277− if (!members || members.length < 2 || !members.every(settled)) return;
255+ if (!members || members.length < 2 || members.some((m) => m.status !== "complete")) return;
278256 const hash = membersHash(members);
279257 if (!force && hash === g.membersHash) {
280− settle(g);
258+ g.dirty = false;
281259 this.d.commit();
282260 return;
283261 }
@@ -312,10 +290,9 @@ export class Naming {
312290 }
313291 if (e.code === "BAD_MODEL_OUTPUT" && !retried.output) {
314292 retried.output = true;
315− mustDifferFrom = withRejected(mustDifferFrom, e.raw);
316293 continue;
317294 }
318− this.fail(g, e, hash);
295+ this.fail(g, e);
319296 return;
320297 }
321298
@@ -324,10 +301,9 @@ export class Naming {
324301 const e: HostError = { code: "BAD_MODEL_OUTPUT", message: "label failed worker validation", raw: JSON.stringify(reply.result).slice(0, 500), at: this.d.now() };
325302 if (!retried.output) {
326303 retried.output = true;
327− mustDifferFrom = withRejected(mustDifferFrom, e.raw);
328304 continue;
329305 }
330− this.fail(g, e, hash);
306+ this.fail(g, e);
331307 return;
332308 }
333309 const emoji = safeEmoji(reply.result.emoji);
@@ -342,15 +318,12 @@ export class Naming {
342318 finalLabel = suffixLabel(label, siblings);
343319 }
344320 this.d.hostOk();
345− const written = await this.apply(g, finalLabel, emoji, members, hash);
321+ await this.apply(g, finalLabel, emoji, members, hash);
346322 g = this.d.state().groups[groupId];
347− if (!g) return;
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;
353− else this.touch(groupId); // dirtied while in flight: go again with the new membership
323+ if (g) {
324+ if (g.dirtySeq === seq) g.dirty = false;
325+ else this.touch(groupId); // dirtied while in flight: go again with the new membership
326+ }
354327 return;
355328 }
356329 } finally {
@@ -361,20 +334,16 @@ export class Naming {
361334 }
362335 }
363336
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> {
337+ private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<void> {
366338 const settings = this.d.settings();
367339 const urls = memberUrls(members);
368− if (g.userNamed) return true; // renamed by hand while the model was thinking
340+ if (g.userNamed) return; // renamed by hand while the model was thinking
369341 if (shouldApply(g, label, urls)) {
370342 const title = stripTitle(label, emoji, settings.emoji);
371343 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
372344 this.d.commit();
373345 const ok = await this.d.writeTitle(g.id, title);
374− if (!ok) {
375− delete this.d.state().ownWrites[g.id];
376− return false;
377− }
346+ if (!ok) return;
378347 g.title = label;
379348 g.emoji = emoji;
380349 g.stripTitle = title;
@@ -382,24 +351,21 @@ export class Naming {
382351 g.lastNamedAt = this.d.now();
383352 g.membersHash = hash;
384353 g.memberUrls = urls;
385− clearFailures(g);
386− return true;
354+ g.nameAttempts = 0;
355+ g.firstFailureAt = undefined;
356+ g.nextAttemptAt = undefined;
387357 }
388358
389− private fail(g: GroupRecord, e: HostError, hash: string): void {
359+ private fail(g: GroupRecord, e: HostError): void {
390360 const now = this.d.now();
391361 this.d.hostFailed(e);
392362 if (e.code === "BAD_REQUEST") {
393363 g.dirty = false; // a bug on our side: log and drop
394364 return;
395365 }
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;
399366 if (e.code === "GUARDRAIL") {
400367 g.nameAttempts = MAX_ATTEMPTS;
401368 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
402− g.refusedHash = hash;
403369 this.d.scheduleFallback(g.nextAttemptAt);
404370 return;
405371 }
@@ -409,35 +375,11 @@ export class Naming {
409375 g.dirty = false; // retried for a day: keep the provisional title
410376 return;
411377 }
412− g.nextAttemptAt = now + backoffFor(g.nameAttempts);
378+ const wait = SETUP_ERRORS.has(e.code) ? Math.max(GLOBAL_PAUSE_MS, backoffFor(g.nameAttempts)) : backoffFor(g.nameAttempts);
379+ g.nextAttemptAt = now + wait;
413380 this.d.scheduleFallback(g.nextAttemptAt);
414381 }
415382}
416383
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−
442384export const eligible = (g: GroupRecord, settings: Settings): boolean =>
443385 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";
src/background/organize.ts+112−205
@@ -1,19 +1,16 @@
11import { COLORS, isColor, type GroupColor } from "../shared/colors";
2−import { labelOf, repairLabel, safeEmoji, stripTitle, titleKey } from "../shared/label";
2+import { labelOf, repairLabel, safeEmoji, stripTitle } from "../shared/label";
33import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
44import { pathKey } from "./engine";
5−import { membersHash, memberUrls, settled, toItems, type Member, type NameItem } from "./naming";
5+import { membersHash, memberUrls, toItems, type Member, type NameItem } from "./naming";
66import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
7−import type { HostReply } from "./host";
8−import { markDirty, type GroupRecord, type HostError, type HostErrorCode, type State, type TabRecord } from "./state";
7+import { markDirty, type State } from "./state";
98
109/** Section 3/9: "Organize all tabs" — cluster ungrouped tabs in batches sized to the model's window. */
1110
12−export const ORGANIZE_ITEM_CAP = 36;
11+export const ORGANIZE_ITEM_CAP = 18;
1312export const EXISTING_GROUPS_CAP = 12;
14−// About the host's 7,000-token budget in English characters. Chinese and Japanese cost about twice the tokens
15−// per character, so their characters count double (weightedLength); the host measures exactly and shortens.
16−export const CHAR_BUDGET = 24_000;
13+export const CHAR_BUDGET = 10_000;
1714export const INSTRUCTION_CHARS = 1_400; // ~320 tokens of rules, emoji and colour lists
1815export const EXISTING_GROUP_CHARS = 90;
1916export const UNDO_WINDOW_MS = 3_600_000;
@@ -27,8 +24,6 @@ export interface ExistingGroup {
2724export interface OrganizePayload {
2825 items: NameItem[];
2926 existingGroups: ExistingGroup[];
30− /** Every group label in the window: a new group the host names must not repeat one. */
31− siblingTitles?: string[];
3227 allowNew: boolean;
3328 maxGroups: number;
3429 minGroupSize: number;
@@ -58,12 +53,9 @@ export const maxGroupsFor = (n: number): number => Math.min(8, Math.ceil(n / 3))
5853export const organizable = (t: { groupId?: number; pinned?: boolean; url?: string; incognito?: boolean }): boolean =>
5954 (t.groupId ?? -1) === -1 && !t.pinned && !t.incognito && !isInternalUrl(t.url);
6055
61−const CJK = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/g;
62−export const weightedLength = (s: string): number => s.length + (s.match(CJK)?.length ?? 0);
63−
6456/** Rough rendered size of one item line, the unit the character budget is spent in. */
6557export const itemChars = (m: Pick<Member, "title" | "url" | "description">): number =>
66− weightedLength(m.title.slice(0, 120)) + promptAddress(m.url).length + weightedLength((m.description ?? " ").slice(0, 500)) + 12;
58+ Math.min(120, m.title.length) + promptAddress(m.url).length + Math.min(300, m.description?.length ?? 1) + 12;
6759
6860/** Split tabs (in strip order) into batches of ≤ cap items that also fit the character budget. */
6961export function makeBatches<T extends Pick<Member, "title" | "url" | "description">>(
@@ -154,198 +146,88 @@ export interface OrganizeReport {
154146 error?: string;
155147}
156148
157−/** Existing groups a batch may add tabs to: Diagonal's topic groups in this window, the ones in use first. */
149+/** Existing groups a batch may add tabs to: extension-managed topic groups in this window. */
158150export function existingGroupsFor(state: State, windowId: number, extraIds: number[] = []): { ids: number[]; list: ExistingGroup[] } {
159− const members = new Map<number, TabRecord[]>();
160− for (const t of Object.values(state.tabs)) if (t.groupId !== -1) members.set(t.groupId, [...(members.get(t.groupId) ?? []), t]);
161− const inUse = (g: GroupRecord): number =>
162− Math.max(g.dirtyAt ?? 0, g.lastNamedAt ?? 0, ...(members.get(g.id) ?? []).map((t) => t.lastActivatedAt ?? 0));
163151 const groups = Object.values(state.groups)
164− .filter((g) => g.windowId === windowId && g.managed && g.origin !== "tidy" && !g.restored && (g.stripTitle || g.title))
165− .map((g) => ({ g, first: extraIds.includes(g.id), at: inUse(g) }))
166− .sort((a, b) => Number(b.first) - Number(a.first) || b.at - a.at)
167− .slice(0, EXISTING_GROUPS_CAP)
168− .map(({ g }) => g);
152+ .filter((g) => g.windowId === windowId && g.managed && g.origin !== "tidy" && (g.stripTitle || g.title))
153+ .sort((a, b) => Number(extraIds.includes(b.id)) - Number(extraIds.includes(a.id)))
154+ .slice(0, EXISTING_GROUPS_CAP);
169155 const list = groups.map((g, i) => ({
170156 g: i,
171157 title: labelOf(g.stripTitle || g.title),
172− samples: (members.get(g.id) ?? []).slice(0, 2).map((t) => t.title.slice(0, 60)),
158+ samples: Object.values(state.tabs)
159+ .filter((t) => t.groupId === g.id)
160+ .slice(0, 2)
161+ .map((t) => t.title.slice(0, 60)),
173162 }));
174163 return { ids: groups.map((g) => g.id), list };
175164}
176165
177−/** Errors about what was sent rather than about the host: a smaller or plainer request may go through. */
178−const CONTENT_ERRORS = new Set<HostErrorCode>(["OVER_BUDGET", "GUARDRAIL", "BAD_MODEL_OUTPUT"]);
179−const MAX_TIMEOUT_SPLITS = 1;
180−
181−/** 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>> {
183− const settings = rt.settings();
184− const s = rt.state();
185− const payload: OrganizePayload = {
186− items: toItems(part, settings, withDescriptions),
187− 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,
194− maxGroups: maxGroupsFor(part.length),
195− minGroupSize: settings.organizeMinGroupSize,
196− };
197− let reply = await rt.host<OrganizeResult>("organize", payload);
198− if (!reply.ok && reply.error.code === "BAD_MODEL_OUTPUT") reply = await rt.host<OrganizeResult>("organize", payload, { strict: true });
199− if (!reply.ok && reply.error.code === "GUARDRAIL" && payload.items.some((i) => i.description)) {
200− // What a page says is the likeliest thing to trip Apple's filter; its title and address rarely do.
201− payload.items = toItems(part, settings, false);
202− reply = await rt.host<OrganizeResult>("organize", payload);
203− }
204− return reply;
205−}
206−
207−/** One organize run over a window: what it made, what it moved, and whether the host stopped it. */
208−export interface SortRun {
209− windowId: number;
210− createdIds: number[];
211− previous: Record<number, number>;
212− grouped: number;
213− left: number;
214− timeoutSplits: number;
215− /** Auto-organize: remember what was looked at, so the same tabs are not sent again unchanged. */
216− stamp: boolean;
217− stopped?: HostError;
218−}
219−
220−export const newRun = (windowId: number, stamp: boolean): SortRun => ({ windowId, createdIds: [], previous: {}, grouped: 0, left: 0, timeoutSplits: 0, stamp });
221−
222−/**
223− * Sort one part of a window. A reply about the tabs themselves (too long, refused by Apple's filter, not
224− * valid output) splits the part in halves, so one page the model won't handle leaves the others sorted; a
225− * tab that fails alone is set aside until its page or title changes. Tabs that changed while the model was
226− * thinking are left out of its answer.
227− */
228−export async function sortPart(rt: Runtime, part: Member[], run: SortRun, probe = false): Promise<void> {
229− if (!part.length || run.stopped) return;
230− const { ids: existingIds, list: existingGroups } = existingGroupsFor(rt.state(), run.windowId, run.createdIds);
231− if (part.length < 2 && !existingIds.length && !probe) {
232− 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)
234− }
235− const reply = await askOrganize(rt, part, run.windowId, existingGroups, true);
236− if (!reply.ok) {
237− const e = reply.error;
238− const timedOut = e.code === "TIMEOUT" && part.length > 4 && run.timeoutSplits < MAX_TIMEOUT_SPLITS;
239− if (part.length > 1 && (CONTENT_ERRORS.has(e.code) || timedOut)) {
240− if (timedOut) run.timeoutSplits++;
241− const half = Math.ceil(part.length / 2);
242− await sortPart(rt, part.slice(0, half), run, true);
243− await sortPart(rt, part.slice(half), run, true);
244− return;
245− }
246− if (CONTENT_ERRORS.has(e.code)) {
247− const rec = rt.state().tabs[part[0].id];
248− if (rec) rec.organizeRefused = organizedKeyOf(part[0]);
249− rt.log("organize: set aside a tab the model would not sort", e.code);
250− run.left += 1;
251− return;
252− }
253− run.stopped = e;
254− return;
255− }
256− const settings = rt.settings();
257− const plan = planFromResult(reply.result, part.map((m) => m.id), existingIds, settings.organizeMinGroupSize, maxGroupsFor(part.length), (ids) =>
258− colorFor(part.find((m) => m.id === ids[0])?.url ?? ""),
259− );
260− const unchanged = await stillAsSent(rt, part, run.windowId);
261− const trimmed = trimPlan(rt.state(), plan, unchanged, settings.organizeMinGroupSize);
262− const failed = await applyPlan(rt, trimmed, run.windowId, part, run.previous, run.createdIds);
263− const placed = [...trimmed.joins, ...trimmed.creates].reduce((n, x) => n + x.tabIds.filter((id) => !failed.has(id)).length, 0);
264− run.grouped += placed;
265− run.left += part.length - placed;
266− if (run.stamp) {
267− const s = rt.state();
268− for (const m of part) {
269− const rec = s.tabs[m.id];
270− if (rec && unchanged.has(m.id) && !failed.has(m.id)) rec.organizedKey = organizedKeyOf(m);
271− }
272− }
273−}
274−
275−/** The tabs of a part that are still loose, in this window, on the page and title the model saw. */
276−async function stillAsSent(rt: Runtime, part: Member[], windowId: number): Promise<Set<number>> {
277− const live = new Map((await chrome.tabs.query({ windowId }).catch(() => [])).map((t) => [t.id!, t]));
278− const s = rt.state();
279− return new Set(
280− part
281− .filter((m) => {
282− const t = live.get(m.id);
283− return !!t && organizable(t) && !s.tabs[m.id]?.keepLoose && organizedKeyOf(t) === organizedKeyOf(m);
284− })
285− .map((m) => m.id),
286− );
287−}
288−
289−/** A plan with the tabs that moved on taken out: a new group left too small is not made, a join into a group gone is dropped. */
290−export function trimPlan(state: State, plan: Plan, keep: Set<number>, minGroupSize: number): Plan {
291− const out: Plan = { joins: [], creates: [], leftovers: plan.leftovers.filter((id) => keep.has(id)) };
292− for (const j of plan.joins) {
293− const tabIds = j.tabIds.filter((id) => keep.has(id));
294− const g = state.groups[j.groupId];
295− if (tabIds.length && g?.managed && !g.restored) out.joins.push({ ...j, tabIds });
296− else out.leftovers.push(...tabIds);
297− }
298− for (const c of plan.creates) {
299− const tabIds = c.tabIds.filter((id) => keep.has(id));
300− if (tabIds.length >= Math.max(2, minGroupSize)) out.creates.push({ ...c, tabIds });
301− else out.leftovers.push(...tabIds);
302− }
303− return out;
304−}
305−
306166export async function organizeWindow(rt: Runtime, windowId: number): Promise<OrganizeReport> {
307− const s0 = rt.state();
167+ const settings = rt.settings();
308168 const tabs = (await chrome.tabs.query({ windowId })).filter((t) => organizable(t)).sort((a, b) => a.index - b.index);
309169 const report: OrganizeReport = { grouped: 0, groups: 0, left: 0 };
310− // A tab the model already would not sort, on the same page, is not sent again.
311− const sendable = tabs.filter((t) => s0.tabs[t.id!]?.organizeRefused !== organizedKeyOf(t));
312− if (sendable.length < 2) {
170+ if (tabs.length < 2) {
313171 report.left = tabs.length;
314172 return report;
315173 }
316− const run = newRun(windowId, true); // auto-organize need not send the same tabs again right after
174+ const members: Member[] = tabs.map((t) => ({
175+ id: t.id!,
176+ index: t.index,
177+ url: t.url ?? "",
178+ title: t.title ?? "",
179+ status: t.status,
180+ description: rt.state().tabs[t.id!]?.description,
181+ lastActive: t.lastAccessed ?? 0,
182+ }));
183+ const previous: Record<number, number> = {};
184+ const createdIds: number[] = [];
185+ const queue = makeBatches(members);
317186 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
318187 rt.refreshBadge();
319188 try {
320− for (const batch of makeBatches(sendable.map((t) => toMember(rt.state(), t)))) {
321− await sortPart(rt, batch, run);
322− if (run.stopped) break;
189+ while (queue.length) {
190+ const batch = queue.shift()!;
191+ const { ids: existingIds, list: existingGroups } = existingGroupsFor(rt.state(), windowId, createdIds);
192+ const maxGroups = maxGroupsFor(batch.length);
193+ const payload: OrganizePayload = {
194+ items: toItems(batch, settings),
195+ existingGroups,
196+ allowNew: true,
197+ maxGroups,
198+ minGroupSize: settings.organizeMinGroupSize,
199+ };
200+ let reply = await rt.host<OrganizeResult>("organize", payload);
201+ if (!reply.ok && reply.error.code === "OVER_BUDGET" && batch.length > 1) {
202+ const half = Math.ceil(batch.length / 2);
203+ queue.unshift(batch.slice(0, half), batch.slice(half));
204+ continue;
205+ }
206+ if (!reply.ok && reply.error.code === "BAD_MODEL_OUTPUT") reply = await rt.host<OrganizeResult>("organize", payload, { strict: true });
207+ if (!reply.ok) {
208+ report.error = `${reply.error.code}: ${reply.error.message}`;
209+ report.left += batch.length + queue.flat().length;
210+ break;
211+ }
212+ const batchIds = batch.map((m) => m.id);
213+ const plan = planFromResult(reply.result, batchIds, existingIds, settings.organizeMinGroupSize, maxGroups, (ids) =>
214+ colorFor(members.find((m) => m.id === ids[0])?.url ?? ""),
215+ );
216+ await applyPlan(rt, plan, windowId, members, previous, createdIds);
217+ report.grouped += plan.joins.reduce((n, j) => n + j.tabIds.length, 0) + plan.creates.reduce((n, c) => n + c.tabIds.length, 0);
218+ report.left += plan.leftovers.length;
323219 }
324220 } finally {
325221 const s = rt.state();
326222 if (s.inFlight?.groupId === -1) s.inFlight = undefined;
327− report.grouped = run.grouped;
328− report.groups = run.createdIds.length;
329− report.left = tabs.length - run.grouped;
330− if (run.stopped) report.error = `${run.stopped.code}: ${run.stopped.message}`;
331− if (Object.keys(run.previous).length) s.lastOrganize = { at: rt.now(), previous: run.previous };
223+ report.groups = createdIds.length;
224+ if (Object.keys(previous).length) s.lastOrganize = { at: rt.now(), previous };
332225 rt.commit();
333226 rt.refreshBadge();
334227 }
335228 return report;
336229}
337230
338−const toMember = (s: State, t: chrome.tabs.Tab): Member => ({
339− id: t.id!,
340− index: t.index,
341− url: t.url ?? "",
342− title: t.title ?? "",
343− status: t.status,
344− description: s.tabs[t.id!]?.description,
345− lastActive: t.lastAccessed ?? 0,
346−});
347−
348−/** Carry out a plan; returns the tabs whose move failed, which stay as they were. */
349231export async function applyPlan(
350232 rt: Runtime,
351233 plan: Plan,
@@ -353,15 +235,11 @@ export async function applyPlan(
353235 members: Member[],
354236 previous: Record<number, number>,
355237 createdIds: number[],
356−): Promise<Set<number>> {
238+): Promise<void> {
357239 const settings = rt.settings();
358240 const byId = new Map(members.map((m) => [m.id, m]));
359− const failed = new Set<number>();
360241 for (const j of plan.joins) {
361− if (!(await addToGroup(rt, j.tabIds, j.groupId))) {
362− for (const id of j.tabIds) failed.add(id);
363− continue;
364− }
242+ if (!(await addToGroup(rt, j.tabIds, j.groupId))) continue;
365243 for (const id of j.tabIds) previous[id] = -1;
366244 const g = rt.state().groups[j.groupId];
367245 if (g) {
@@ -382,10 +260,7 @@ export async function applyPlan(
382260 lastNamedAt: named ? rt.now() : undefined,
383261 dirty: false,
384262 });
385− if (groupId === undefined) {
386− for (const id of c.tabIds) failed.add(id);
387− continue;
388− }
263+ if (groupId === undefined) continue;
389264 createdIds.push(groupId);
390265 for (const id of c.tabIds) previous[id] = -1;
391266 const g = rt.state().groups[groupId];
@@ -394,7 +269,6 @@ export async function applyPlan(
394269 rt.naming.touch(groupId);
395270 }
396271 }
397− return failed;
398272}
399273
400274/** Undo within the hour: every tab goes back to the group it was in (organize only takes loose tabs). */
@@ -422,7 +296,7 @@ export async function undoOrganize(rt: Runtime): Promise<number> {
422296}
423297
424298/** What auto-organize remembers about a tab: it looks again only when the page or title changes. */
425−export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${titleKey(t.title)}`;
299+export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${t.title ?? ""}`;
426300
427301export const AUTO_RETRY_MS = 300_000;
428302export const AUTO_CONTINUE_MS = 1_500;
@@ -498,27 +372,60 @@ export class AutoOrganizer {
498372 if (pausedUntil && pausedUntil > rt.now()) return; // the host-retry ping sweeps again when it recovers
499373 const tabs = (await chrome.tabs.query({ windowId }).catch(() => [])).sort((a, b) => a.index - b.index);
500374 const s = rt.state();
501− // Still-loading tabs are skipped here; finishing the load touches the window again. Discarded tabs keep
502− // their title and address, so they're sorted like loaded ones.
503− const loose = tabs.filter((t) => {
504− const rec = s.tabs[t.id!];
505− return organizable(t) && settled(t) && !rec?.keepLoose && rec?.organizeRefused !== organizedKeyOf(t);
506− });
375+ // Still-loading tabs are skipped here; finishing the load touches the window again.
376+ const loose = tabs.filter((t) => organizable(t) && t.status === "complete" && !s.tabs[t.id!]?.keepLoose);
507377 const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
508378 if (!fresh.length) return;
509− if (loose.length < 2 && !existingGroupsFor(s, windowId).ids.length) return; // one loose tab and nothing to join: wait for company
510− const ordered = [...fresh, ...loose.filter((t) => !fresh.includes(t))].map((t) => toMember(s, t));
379+ const { ids: existingIds, list: existingGroups } = existingGroupsFor(s, windowId);
380+ if (loose.length < 2 && !existingIds.length) return; // one loose tab and nothing to join: wait for company
381+ const toMember = (t: chrome.tabs.Tab): Member => ({
382+ id: t.id!,
383+ index: t.index,
384+ url: t.url ?? "",
385+ title: t.title ?? "",
386+ status: t.status,
387+ description: s.tabs[t.id!]?.description,
388+ lastActive: t.lastAccessed ?? 0,
389+ });
390+ const ordered = [...fresh, ...loose.filter((t) => !fresh.includes(t))].map(toMember);
511391 const batch = makeBatches(ordered)[0];
512− const run = newRun(windowId, true);
513− await sortPart(rt, batch, run);
514− rt.commit();
515− if (run.stopped) {
516− rt.log("auto-organize", run.stopped.code);
392+ const maxGroups = maxGroupsFor(batch.length);
393+ const payload: OrganizePayload = {
394+ items: toItems(batch, settings),
395+ existingGroups,
396+ allowNew: batch.length >= 2,
397+ maxGroups,
398+ minGroupSize: settings.organizeMinGroupSize,
399+ };
400+ let reply = await rt.host<OrganizeResult>("organize", payload);
401+ if (!reply.ok && reply.error.code === "OVER_BUDGET" && batch.length > 1) {
402+ // Rare with the budgeted batch; try once with half, the rest follows on the next run.
403+ const half = batch.slice(0, Math.ceil(batch.length / 2));
404+ payload.items = toItems(half, settings);
405+ payload.maxGroups = maxGroupsFor(half.length);
406+ payload.allowNew = half.length >= 2;
407+ batch.length = half.length;
408+ reply = await rt.host<OrganizeResult>("organize", payload);
409+ }
410+ if (!reply.ok && reply.error.code === "BAD_MODEL_OUTPUT") reply = await rt.host<OrganizeResult>("organize", payload, { strict: true });
411+ if (!reply.ok) {
412+ rt.log("auto-organize", reply.error.code);
517413 this.deps.scheduleFallback(rt.now() + AUTO_RETRY_MS);
518414 return;
519415 }
520− const sent = new Set(batch.map((m) => m.id));
521− if (fresh.some((t) => !sent.has(t.id!))) this.touch(windowId, AUTO_CONTINUE_MS);
416+ const batchIds = batch.map((m) => m.id);
417+ const plan = planFromResult(reply.result, batchIds, existingIds, settings.organizeMinGroupSize, payload.maxGroups, (ids) =>
418+ colorFor(batch.find((m) => m.id === ids[0])?.url ?? ""),
419+ );
420+ await applyPlan(rt, plan, windowId, batch, {}, []);
421+ const live = rt.state();
422+ for (const m of batch) {
423+ const rec = live.tabs[m.id];
424+ if (rec) rec.organizedKey = organizedKeyOf(m);
425+ }
426+ rt.commit();
427+ const remaining = fresh.filter((t) => !batchIds.includes(t.id!));
428+ if (remaining.length) this.touch(windowId, AUTO_CONTINUE_MS);
522429 }
523430}
524431
src/background/runtime.ts+2−16
@@ -67,48 +67,34 @@ export async function createManagedGroup(
6767 extra: Partial<GroupRecord> = {},
6868): Promise<number | undefined> {
6969 const s = rt.state();
70− const pending = { windowId, origin, at: rt.now() };
71− s.pendingCreates.push(pending);
72− for (const id of tabIds) s.ownAdds[id] = rt.now();
70+ s.pendingCreates.push({ windowId, origin, at: rt.now() });
7371 let groupId: number;
7472 try {
7573 groupId = await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], createProperties: { windowId } }));
7674 } catch (e) {
7775 rt.log("tabs.group failed", e);
78− forgetOwnAdds(rt.state(), tabIds);
79− const live = rt.state();
80− live.pendingCreates = live.pendingCreates.filter((p) => p !== pending);
8176 return undefined;
8277 }
8378 const live = rt.state();
8479 const rec = live.groups[groupId] ?? newGroupRecord(groupId, windowId, origin, props.color);
8580 Object.assign(rec, { origin, managed: true, windowId, color: props.color }, extra);
8681 live.groups[groupId] = rec;
87− // Tab records move when tabs.onUpdated reports the new groupId: that event also tells the group
88− // each tab left that it lost one, and uses up the ownAdds marker.
82+ for (const id of tabIds) if (live.tabs[id]) live.tabs[id].groupId = groupId;
8983 await updateGroup(rt, groupId, props);
9084 rt.commit();
9185 return groupId;
9286}
9387
9488export async function addToGroup(rt: Runtime, tabIds: number[], groupId: number): Promise<boolean> {
95− const s = rt.state();
96− for (const id of tabIds) s.ownAdds[id] = rt.now();
9789 try {
9890 await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], groupId }));
9991 return true;
10092 } catch (e) {
10193 rt.log("tabs.group into existing failed", e);
102− forgetOwnAdds(rt.state(), tabIds);
10394 return false;
10495 }
10596}
10697
107−/** A grouping call failed: nothing moved, so a later move of these tabs is not the worker's. */
108−function forgetOwnAdds(s: State, tabIds: number[]): void {
109− for (const id of tabIds) delete s.ownAdds[id];
110−}
111−
11298export async function ungroup(rt: Runtime, tabIds: number[]): Promise<void> {
11399 if (!tabIds.length) return;
114100 try {
src/background/settings.ts+3−3
@@ -1,7 +1,6 @@
11/** Section 10: every setting, its default and its range. */
22export type TidyMode = "ask" | "auto" | "off";
3−/** fm on macOS 27 has only the on-device model; a stored "pcc" from older versions reads as "system". */
4−export type ModelChoice = "system";
3+export type ModelChoice = "system" | "pcc";
54
65export interface Settings {
76 openerGrouping: boolean;
@@ -79,7 +78,8 @@ export function withDefaults(raw: unknown): Settings {
7978 (s as any)[key] = range ? Math.min(range[1], Math.max(range[0], Math.round(v))) : v;
8079 } else if (Array.isArray(def) && Array.isArray(v)) {
8180 (s as any)[key] = v.filter((x) => typeof x === "string" && x.trim()).map((x: string) => x.trim());
82− } else if (key === "tidyMode" && (v === "ask" || v === "auto" || v === "off")) s.tidyMode = v;
81+ } else if (key === "model" && (v === "system" || v === "pcc")) s.model = v;
82+ else if (key === "tidyMode" && (v === "ask" || v === "auto" || v === "off")) s.tidyMode = v;
8383 }
8484 if (s.archiveAfterHours !== 0 && s.archiveAfterHours < 1) s.archiveAfterHours = 1;
8585 return s;
src/background/state.ts+1−25
@@ -10,7 +10,7 @@ export interface TabRecord {
1010 index: number;
1111 url: string;
1212 title: string;
13− description?: string; // what the page says (content/meta.ts), trimmed to 500 chars
13+ description?: string; // trimmed to 300 chars
1414 openerTabId?: number;
1515 pinned: boolean;
1616 incognito?: boolean;
@@ -18,12 +18,8 @@ export interface TabRecord {
1818 createdAt: number;
1919 lastActivatedAt: number; // epoch ms
2020 parkedFrom?: string; // title of the group the tab was in before the tidy sweep parked it
21− parkedAt?: number; // when the tidy sweep parked it: the archive clock starts here
2221 keepLoose?: string; // the user took this tab out of a group on this page: auto-organize leaves it be
2322 organizedKey?: string; // page + title when auto-organize last considered this tab
24− organizeRefused?: string; // page + title the model would not sort even alone: not sent again until it changes
25− handPlaced?: boolean; // the user put this tab into its group: Diagonal won't take it out
26− fitPending?: boolean; // moved to a new page inside a Diagonal group: check it still fits (fit.ts)
2723}
2824
2925export interface GroupRecord {
@@ -47,15 +43,8 @@ export interface GroupRecord {
4743 nameAttempts: number;
4844 firstFailureAt?: number;
4945 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
52− 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
5446}
5547
56−/** A group Chromium removed, kept for a day: it may come back (moved to another window, reopened, restored). */
57−export type RemovedGroup = GroupRecord & { removedAt: number };
58−
5948export interface ArchivedTab {
6049 url: string;
6150 title: string;
@@ -116,11 +105,6 @@ export interface SweepMove {
116105 groupId: number;
117106 index: number;
118107 windowId: number;
119− /** The group the tab was in, so undo can make it again if parking emptied it. */
120− group?: Pick<
121− GroupRecord,
122− "origin" | "managed" | "userNamed" | "title" | "emoji" | "stripTitle" | "color" | "colorLocked" | "keep" | "membersHash" | "memberUrls" | "lastNamedAt"
123− >;
124108}
125109
126110export interface State {
@@ -136,10 +120,6 @@ export interface State {
136120 pendingCreates: { windowId: number; origin: GroupOrigin; at: number }[];
137121 /** Tabs the worker itself is ungrouping (dissolve): leaving the group is not a user choice. */
138122 ownUngroups: Record<number, number>;
139− /** Tabs the worker itself is adding to a group: joining it is not the user's choice. */
140− ownAdds: Record<number, number>;
141− /** Groups Chromium removed lately, by their last id. */
142− removedGroups: Record<number, RemovedGroup>;
143123 lastSweep?: { at: number; moves: SweepMove[] };
144124 lastOrganize?: { at: number; previous: Record<number, number> };
145125 tidyCandidates?: number;
@@ -154,8 +134,6 @@ export const emptyState = (): State => ({
154134 ownWrites: {},
155135 pendingCreates: [],
156136 ownUngroups: {},
157− ownAdds: {},
158− removedGroups: {},
159137});
160138
161139/** Accept whatever was stored and bring it to the current shape. */
@@ -176,8 +154,6 @@ export function migrate(raw: unknown): State {
176154 ownWrites: r.ownWrites ?? {},
177155 pendingCreates: r.pendingCreates ?? [],
178156 ownUngroups: r.ownUngroups ?? {},
179− ownAdds: r.ownAdds ?? {},
180− removedGroups: r.removedGroups ?? {},
181157 };
182158}
183159
src/background/tidy.ts+17−129
@@ -3,7 +3,7 @@ import { labelOf, stripTitle } from "../shared/label";
33import { isInternalUrl } from "../shared/url";
44import { addToGroup, createManagedGroup, groupExists, ungroup, updateGroup, type Runtime } from "./runtime";
55import type { Settings } from "./settings";
6−import { newGroupRecord, type ArchivedTab, type GroupRecord, type State, type SweepMove } from "./state";
6+import type { ArchivedTab, State, SweepMove } from "./state";
77
88/** Section 7: park stale tabs in a collapsed group, then archive and close them. No model calls. */
99
@@ -23,22 +23,11 @@ export interface TidyTab {
2323 audible?: boolean;
2424 incognito?: boolean;
2525 discarded?: boolean;
26− autoDiscardable?: boolean;
2726 lastAccessed?: number;
2827}
2928
30−/**
31− * The engine's record wins over Chromium's lastAccessed: after a restart Chromium stamps restored tabs
32− * nobody has looked at yet with the restore time, which would keep them from ever going stale.
33− */
3429export const idleMs = (tab: TidyTab, state: State, now: number): number =>
35− now - (state.tabs[tab.id]?.lastActivatedAt ?? tab.lastAccessed ?? now);
36−
37−/** How long a tab has sat in Parked. Tabs found there with no parking time start their clock now. */
38−export const parkedMs = (tab: TidyTab, state: State, now: number): number => {
39− const at = state.tabs[tab.id]?.parkedAt;
40− return at === undefined ? 0 : now - at;
41−};
30+ now - Math.max(tab.lastAccessed ?? 0, state.tabs[tab.id]?.lastActivatedAt ?? 0);
4231
4332/** Exclusions are substrings of the URL; a pattern written as /…/ is a regular expression. */
4433export function excluded(url: string, patterns: string[]): boolean {
@@ -66,8 +55,7 @@ export function isParkCandidate(tab: TidyTab, state: State, settings: Settings,
6655 if (tab.groupId !== -1) {
6756 const g = state.groups[tab.groupId];
6857 if (g?.keep) return false;
69− // Groups you made or renamed are yours, like Diagonal's other rules treat them.
70− if ((!g || g.origin === "user" || g.userNamed) && !settings.tidyUserGroups) return false;
58+ if ((!g || g.origin === "user") && !settings.tidyUserGroups) return false;
7159 }
7260 if (excluded(tab.url, settings.tidyExclusions)) return false;
7361 return idleMs(tab, state, now) >= settings.parkAfterHours * HOUR;
@@ -78,18 +66,10 @@ export function parkCandidates(tabs: TidyTab[], state: State, settings: Settings
7866 return tabs.filter((t) => isParkCandidate(t, state, settings, now, parking));
7967}
8068
81−/** Tabs that have sat in Parked for the archive time, counted from when they were parked. */
8269export function archiveCandidates(tabs: TidyTab[], state: State, settings: Settings, now: number): TidyTab[] {
8370 if (!settings.archiveAfterHours) return [];
8471 const parking = parkingGroupIds(state);
85− return tabs.filter(
86− (t) =>
87− parking.has(t.groupId) &&
88− !t.active &&
89− !t.audible &&
90− !(t.url && excluded(t.url, settings.tidyExclusions)) &&
91− parkedMs(t, state, now) >= settings.archiveAfterHours * HOUR,
92− );
72+ return tabs.filter((t) => parking.has(t.groupId) && !t.active && !t.audible && idleMs(t, state, now) >= settings.archiveAfterHours * HOUR);
9373}
9474
9575/** Prepend entries newest first and drop the oldest past the cap. */
@@ -99,15 +79,6 @@ export function addToArchive(archive: ArchivedTab[], entries: ArchivedTab[], cap
9979
10080export const parkedTitle = (settings: Settings): string => stripTitle("Parked", PARKED_EMOJI, settings.emoji);
10181
102−/** A grey group titled like Parked: Diagonal's Parked group, even where an older version lost track of it. */
103−export const looksParked = (g: { title?: string; color: string }, settings: Settings): boolean =>
104− g.color === "grey" && (g.title ?? "") === parkedTitle(settings);
105−
106−/** Make a group Diagonal's Parked group again. */
107−export function adoptParked(g: GroupRecord, settings: Settings): void {
108− Object.assign(g, { origin: "tidy", managed: true, userNamed: true, stripTitle: parkedTitle(settings) });
109−}
110−
11182// ---------------------------------------------------------------------------------------------
11283
11384const liveTabs = async (): Promise<TidyTab[]> =>
@@ -124,7 +95,6 @@ const liveTabs = async (): Promise<TidyTab[]> =>
12495 audible: t.audible,
12596 incognito: t.incognito,
12697 discarded: t.discarded,
127− autoDiscardable: t.autoDiscardable,
12898 lastAccessed: t.lastAccessed,
12999 }));
130100
@@ -134,20 +104,11 @@ export interface SweepReport {
134104 archived: number;
135105}
136106
137−let sweeps: Promise<unknown> = Promise.resolve();
138−
139107/**
140108 * The 30-minute sweep (manual = "Tidy now" / "Park N tabs": ignores the threshold and the Ask
141− * consent, still respects eligibility). One at a time: two at once would park, and archive, the
142− * same tabs twice.
109+ * consent, still respects eligibility).
143110 */
144−export function runSweep(rt: Runtime, manual = false): Promise<SweepReport> {
145− const next = sweeps.then(() => sweep(rt, manual));
146− sweeps = next.catch(() => undefined);
147− return next;
148−}
149−
150−async function sweep(rt: Runtime, manual: boolean): Promise<SweepReport> {
111+export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport> {
151112 const settings = rt.settings();
152113 const report: SweepReport = { candidates: 0, parked: 0, archived: 0 };
153114 if (settings.tidyMode === "off" && !manual) {
@@ -157,7 +118,6 @@ async function sweep(rt: Runtime, manual: boolean): Promise<SweepReport> {
157118 }
158119 const now = rt.now();
159120 let tabs = await liveTabs();
160− startParkedClocks(rt.state(), tabs, now);
161121 const candidates = parkCandidates(tabs, rt.state(), settings, now);
162122 report.candidates = candidates.length;
163123 // Auto parks whatever is stale; the threshold only decides when Ask mode puts a count on the badge.
@@ -173,52 +133,8 @@ async function sweep(rt: Runtime, manual: boolean): Promise<SweepReport> {
173133 return report;
174134}
175135
176−/** Tabs already in Parked with no parking time (from before it was recorded) start their archive clock now. */
177−function startParkedClocks(s: State, tabs: TidyTab[], now: number): void {
178− const parking = parkingGroupIds(s);
179− for (const t of tabs) {
180− const rec = s.tabs[t.id];
181− if (rec && parking.has(t.groupId) && rec.parkedAt === undefined) rec.parkedAt = now;
182− }
183−}
184−
185−/** What undo needs to make a group again if parking emptied it, named as it was. */
186−const groupShape = (g: GroupRecord): SweepMove["group"] => ({
187− origin: g.origin,
188− managed: g.managed,
189− userNamed: g.userNamed,
190− title: g.title,
191− emoji: g.emoji,
192− stripTitle: g.stripTitle,
193− color: g.color,
194− colorLocked: g.colorLocked,
195− keep: g.keep,
196− membersHash: g.membersHash,
197− memberUrls: g.memberUrls,
198− lastNamedAt: g.lastNamedAt,
199−});
200−
201−/**
202− * The window's Parked group: the one on record, or a group already showing the Parked title (one an
203− * older version lost track of after a restart), which becomes Parked again.
204− */
205−async function parkingGroupIn(rt: Runtime, windowId: number): Promise<number | undefined> {
206− const s = rt.state();
207− const known = Object.values(s.groups).find((g) => g.origin === "tidy" && g.windowId === windowId)?.id;
208− if (known !== undefined && (await groupExists(known))) return known;
209− const settings = rt.settings();
210− const shown = (await chrome.tabGroups.query({ windowId }).catch(() => [])).filter((g) => looksParked(g, settings));
211− for (const g of shown) {
212− const rec = s.groups[g.id] ?? newGroupRecord(g.id, windowId, "tidy", "grey");
213− adoptParked(rec, settings);
214− s.groups[g.id] = rec;
215− }
216− return shown[0]?.id;
217−}
218−
219136async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
220137 const settings = rt.settings();
221− const now = rt.now();
222138 const moves: SweepMove[] = [];
223139 const byWindow = new Map<number, TidyTab[]>();
224140 for (const t of candidates) byWindow.set(t.windowId, [...(byWindow.get(t.windowId) ?? []), t]);
@@ -226,15 +142,13 @@ async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
226142 const ids = tabs.map((t) => t.id);
227143 const s = rt.state();
228144 for (const t of tabs) {
229− const from = t.groupId !== -1 ? s.groups[t.groupId] : undefined;
230− moves.push({ tabId: t.id, groupId: t.groupId, index: t.index, windowId, group: from && groupShape(from) });
145+ moves.push({ tabId: t.id, groupId: t.groupId, index: t.index, windowId });
231146 const rec = s.tabs[t.id];
232− if (rec) {
233− rec.parkedFrom = from ? labelOf(from.stripTitle || from.title) || undefined : undefined;
234− rec.parkedAt = now;
235− }
147+ const from = t.groupId !== -1 ? s.groups[t.groupId] : undefined;
148+ if (rec) rec.parkedFrom = from ? labelOf(from.stripTitle || from.title) || undefined : undefined;
236149 }
237− let parking = await parkingGroupIn(rt, windowId);
150+ let parking = Object.values(s.groups).find((g) => g.origin === "tidy" && g.windowId === windowId)?.id;
151+ if (parking !== undefined && !(await groupExists(parking))) parking = undefined;
238152 if (parking === undefined) {
239153 parking = await createManagedGroup(rt, ids, windowId, "tidy", { title: parkedTitle(settings), color: "grey", collapsed: true }, { userNamed: true });
240154 } else {
@@ -248,16 +162,12 @@ async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
248162 rt.log("could not move parking group to the end", e);
249163 }
250164 if (settings.discardParked) {
251− // An extension's discard skips Chromium's own checks, so honour a tab marked "never discard".
252165 for (const t of tabs) {
253− if (!t.discarded && t.autoDiscardable !== false) await chrome.tabs.discard(t.id).catch(() => undefined);
166+ if (!t.discarded) await chrome.tabs.discard(t.id).catch(() => undefined);
254167 }
255168 }
256169 }
257− // Sweeps inside the undo window add up, so Undo puts back everything parked in that hour.
258− const last = rt.state().lastSweep;
259− const kept = last && now - last.at < UNDO_WINDOW_MS ? last.moves.filter((m) => !moves.some((n) => n.tabId === m.tabId)) : [];
260− rt.state().lastSweep = { at: now, moves: [...kept, ...moves] };
170+ rt.state().lastSweep = { at: rt.now(), moves };
261171 rt.commit();
262172 return moves.length;
263173}
@@ -287,40 +197,18 @@ export async function undoSweep(rt: Runtime): Promise<number> {
287197 const last = rt.state().lastSweep;
288198 if (!last || rt.now() - last.at > UNDO_WINDOW_MS) return 0;
289199 const live = new Map((await chrome.tabs.query({})).map((t) => [t.id!, t]));
290− // Parking every tab of a group removes it: undo makes it again, once, with its title and colour.
291− const remade = new Map<number, number>();
292− const target = async (m: SweepMove): Promise<number | undefined> => {
293− if (m.groupId === -1) return undefined;
294− const again = remade.get(m.groupId);
295− if (again !== undefined) return again;
296− return (await groupExists(m.groupId)) ? m.groupId : undefined;
297− };
298200 let restored = 0;
299201 for (const m of [...last.moves].sort((a, b) => a.index - b.index)) {
300202 const t = live.get(m.tabId);
301203 if (!t) continue;
302− let groupId = await target(m);
303− if (groupId !== undefined) await addToGroup(rt, [m.tabId], groupId);
304− else if (m.groupId !== -1 && m.group) {
305− const g = m.group;
306− groupId = await createManagedGroup(rt, [m.tabId], m.windowId, g.origin, { title: g.stripTitle ?? "", color: g.color }, { ...g });
307− if (groupId !== undefined) remade.set(m.groupId, groupId);
308− } else {
309− rt.state().ownUngroups[m.tabId] = rt.now(); // back to loose is the worker's move, not the user pulling it out
310− await ungroup(rt, [m.tabId]);
311− }
204+ if (m.groupId !== -1 && (await groupExists(m.groupId))) await addToGroup(rt, [m.tabId], m.groupId);
205+ else await ungroup(rt, [m.tabId]);
312206 await chrome.tabs.move(m.tabId, { windowId: m.windowId, index: m.index }).catch(() => undefined);
313207 // Moving a tab next to its old neighbours may pull it into a group they share; re-assert.
314− groupId = await target(m);
315− if (groupId !== undefined) await addToGroup(rt, [m.tabId], groupId);
316− else if ((await chrome.tabs.get(m.tabId).catch(() => undefined))?.groupId !== -1) {
317− rt.state().ownUngroups[m.tabId] = rt.now();
318− await ungroup(rt, [m.tabId]);
319− }
208+ if (m.groupId !== -1 && (await groupExists(m.groupId))) await addToGroup(rt, [m.tabId], m.groupId);
320209 const rec = rt.state().tabs[m.tabId];
321210 if (rec) {
322211 rec.parkedFrom = undefined;
323− rec.parkedAt = undefined;
324212 rec.lastActivatedAt = rt.now(); // un-parking is a touch: do not re-park on the next sweep
325213 }
326214 restored++;
@@ -361,7 +249,7 @@ export async function restoreAll(rt: Runtime, windowId: number): Promise<number>
361249 if (t.id !== undefined) ids.push(t.id);
362250 }
363251 if (ids.length) {
364− await createManagedGroup(rt, ids, windowId, "organize", { title: stripTitle("Restored", RESTORED_EMOJI, rt.settings().emoji), color: "grey" }, { userNamed: true, restored: true });
252+ await createManagedGroup(rt, ids, windowId, "organize", { title: stripTitle("Restored", RESTORED_EMOJI, rt.settings().emoji), color: "grey" }, { userNamed: true });
365253 }
366254 return ids.length;
367255}
src/content/meta.ts+5−31
@@ -1,37 +1,11 @@
11/**
2− * Runs once at document_idle on http(s) pages and hands the worker a short summary of what the page is
3− * about, up to 500 characters: the description meta tag (or og:description), the main heading when it adds
4− * to the tab title, and the opening lines of the page's main text. It stays on this Mac: the worker only
5− * passes it to Apple's on-device model, and only while "Include what pages say" is on.
6− * No DOM writes, no listeners, nothing from form fields.
2+ * Runs once at document_idle on http(s) pages: reads the page's description meta tag
3+ * (falling back to og:description), trims it to 300 characters and hands it to the worker.
4+ * No DOM writes, no listeners, no page body text.
75 */
86(() => {
9− const squash = (s: string | null | undefined) => (s ?? "").replace(/\s+/g, " ").trim();
10− const meta = (sel: string) => squash(document.querySelector<HTMLMetaElement>(sel)?.content);
11− const parts: string[] = [];
12− const add = (s: string, max: number) => {
13− const t = s.slice(0, max);
14− if (t && !parts.some((p) => p.includes(t) || t.includes(p))) parts.push(t);
15− };
16−
17− add(meta('meta[name="description" i]') || meta('meta[property="og:description" i]'), 300);
18−
19− const h1 = squash(document.querySelector("h1")?.textContent);
20− if (h1 && !document.title.toLowerCase().includes(h1.toLowerCase())) add(h1, 120);
21−
22− // Opening paragraphs of the main content, skipping navigation, short captions and cookie banners.
23− const root = document.querySelector("main, article, [role=main]") ?? document.body;
24− let text = "";
25− for (const p of Array.from(root?.querySelectorAll("p") ?? []).slice(0, 30)) {
26− if (p.closest("nav, header, footer, aside, form, [role=dialog], [aria-modal=true]")) continue;
27− const line = squash(p.textContent);
28− if (line.length < 40 || /cookie|consent|javascript/i.test(line)) continue;
29− text += (text ? " " : "") + line;
30− if (text.length >= 240) break;
31− }
32− add(text, 240);
33−
34− const description = parts.join(" · ").slice(0, 500);
7+ const read = (sel: string) => document.querySelector<HTMLMetaElement>(sel)?.content?.replace(/\s+/g, " ").trim() ?? "";
8+ const description = (read('meta[name="description" i]') || read('meta[property="og:description" i]')).slice(0, 300);
359 if (!description) return;
3610 try {
3711 chrome.runtime.sendMessage({ kind: "meta", description }).catch(() => undefined);
src/options/options.ts+11−2
@@ -38,9 +38,10 @@ const SECTIONS: Section[] = [
3838 },
3939 {
4040 title: "Privacy",
41− intro: "What Apple's on-device model sees when it names a group. It runs on this Mac; nothing leaves it.",
41+ intro: "What the model sees when it names a group.",
4242 fields: [
43− { key: "sendDescription", label: "Include what pages say", help: "Each page's description, main heading and opening lines. Off: titles and addresses only.", kind: "bool" },
43+ { key: "model", label: "Model", kind: "select", options: [["system", "On this Mac"], ["pcc", "Private Cloud Compute"]] },
44+ { key: "sendDescription", label: "Include page descriptions", help: "Off: titles and addresses only.", kind: "bool" },
4445 { key: "sendFullUrl", label: "Include full addresses", help: "Off: just the site name.", kind: "bool" },
4546 ],
4647 },
@@ -96,6 +97,8 @@ function applyDependencies(): void {
9697 row.classList.toggle("off", !on);
9798 row.querySelectorAll<HTMLInputElement>("input, select, textarea").forEach((c) => (c.disabled = !on));
9899 }
100+ const w = document.getElementById("pcc-warning");
101+ if (w) w.hidden = settings.model !== "pcc";
99102}
100103
101104function fieldRow(f: Field): HTMLElement {
@@ -133,6 +136,12 @@ function fieldRow(f: Field): HTMLElement {
133136 onchange: (e: Event) => save({ [f.key]: (e.target as HTMLTextAreaElement).value.split("\n") }),
134137 }));
135138 }
139+ if (f.key === "model") {
140+ row.append(el("div", {
141+ className: "warn-text", id: "pcc-warning",
142+ textContent: "Private Cloud Compute sends tab titles, addresses and descriptions to Apple's servers. On this Mac, nothing leaves the computer.",
143+ }));
144+ }
136145 rows.push({ field: f, row });
137146 return row;
138147}
src/shared/health.ts+2−4
@@ -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 });
@@ -82,7 +80,7 @@ export function notices(s: Health): Notice[] {
8280 out.push({ tone: "bad", title: "Naming is paused", body: s.message || "The helper didn't answer. Diagonal retries on its own." });
8381 }
8482 }
85− if (s.timeoutHint) {
83+ if (s.timeoutHint && s.health !== "degraded") {
8684 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." });
8785 }
8886 return out;
src/shared/label.ts+8−49
@@ -7,12 +7,9 @@ export const LABEL_MAX_CHARS = 28;
77export const LABEL_MIN_WORDS = 2;
88export const LABEL_MAX_WORDS = 4;
99
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;
12−const HAS_QUOTE = /(?<!\p{L})['’]|['’](?!\p{L})|["“”‘«»`]/u;
13−const 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"]);
10+const QUOTES = /["'“”‘’«»`]/g;
11+const HAS_QUOTE = /["'“”‘’«»`]/;
12+const TRAILING_PUNCT = /[\s.,;:!?…\-–—]+$/u;
1613
1714/** Repair what is repairable; return undefined when the label cannot be made valid. */
1815export function repairLabel(raw: unknown): string | undefined {
@@ -20,50 +17,19 @@ export function repairLabel(raw: unknown): string | undefined {
2017 let words = raw.replace(QUOTES, "").replace(/\s+/g, " ").trim().replace(TRAILING_PUNCT, "").split(" ").filter(Boolean);
2118 words = words.filter((w) => !BANNED_WORDS.has(w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "")));
2219 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();
28− }
29− while (words.length > 1 && DANGLING.has(words[words.length - 1].toLowerCase())) words.pop();
20+ while (words.length > LABEL_MIN_WORDS && words.join(" ").length > LABEL_MAX_CHARS) words.pop();
3021 let label = words.join(" ").replace(TRAILING_PUNCT, "");
3122 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);
3424 return isValidLabel(label) ? label : undefined;
3525}
3626
37−/**
38− * Chinese, Japanese, Korean, Thai, Lao, Khmer and Myanmar titles don't put spaces between words, so a label
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;
43−export const CJK_MIN_CHARS = 2;
44−export const CJK_MAX_CHARS = 16;
45−
46−const noSpace = (label: string): boolean => (label.match(NO_SPACE)?.length ?? 0) > 0;
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−
6027export function isValidLabel(label: string): boolean {
6128 const words = label.split(" ").filter(Boolean);
62− const cjk = noSpace(label);
6329 return (
64− 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) &&
66− words.length >= (cjk ? 1 : LABEL_MIN_WORDS) &&
30+ label.length >= LABEL_MIN_CHARS &&
31+ label.length <= LABEL_MAX_CHARS &&
32+ words.length >= LABEL_MIN_WORDS &&
6733 words.length <= LABEL_MAX_WORDS &&
6834 !HAS_QUOTE.test(label) &&
6935 !TRAILING_PUNCT.test(label) &&
@@ -87,10 +53,3 @@ export function labelOf(title: string | undefined): string {
8753
8854export const sameLabel = (a: string | undefined, b: string | undefined): boolean =>
8955 !!a && !!b && labelOf(a).trim().toLocaleLowerCase() === labelOf(b).trim().toLocaleLowerCase();
90−
91−/**
92− * A page title without the unread counts and bullets sites add to it, "(3) Inbox" or "• Slack", so a count
93− * ticking over does not read as a new page. Only for noticing change: the model still sees the full title.
94− */
95−export const titleKey = (title: string | undefined): string =>
96− (title ?? "").replace(/[(\[]\d[\d,.]*\+?[)\]]/g, "").replace(/^[\s•●▶*]+/u, "").replace(/\s+/g, " ").trim();
test/auto.test.ts+2−90
@@ -15,7 +15,6 @@ let state: State;
1515let calls: OrganizePayload[];
1616let replies: HostReply<unknown>[];
1717let fallbacks: number[];
18−let respond: ((payload: OrganizePayload, opts?: object) => HostReply<unknown> | undefined) | undefined;
1918
2019const liveTab = (id: number, over: Partial<LiveTab> = {}): LiveTab => ({
2120 id, windowId: 1, index: id, groupId: -1, url: `https://site${id}.com/page`, title: `Tab ${id}`, status: "complete", pinned: false, ...over,
@@ -33,9 +32,9 @@ function setup(tabs: LiveTab[]): AutoOrganizer {
3332 settings: () => makeSettings(),
3433 commit: () => undefined,
3534 now: () => 1_000_000,
36− host: async <T,>(_op: string, payload: object, opts?: object) => {
35+ host: async <T,>(_op: string, payload: object) => {
3736 calls.push(JSON.parse(JSON.stringify(payload)));
38− return (respond?.(payload as OrganizePayload, opts) ?? replies.shift() ?? { ok: true, result: { groups: [], leftovers: [] } }) as HostReply<T>;
37+ return (replies.shift() ?? { ok: true, result: { groups: [], leftovers: [] } }) as HostReply<T>;
3938 },
4039 naming: { touch: () => undefined } as unknown as Runtime["naming"],
4140 refreshBadge: () => undefined,
@@ -49,7 +48,6 @@ beforeEach(() => {
4948 calls = [];
5049 replies = [];
5150 fallbacks = [];
52− respond = undefined;
5351 nextGroup = 100;
5452 (globalThis as any).chrome = {
5553 tabs: {
@@ -94,7 +92,6 @@ describe("auto-organize", () => {
9492 await auto.run(1);
9593 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 3", "Tab 2"]);
9694 expect(calls[0].existingGroups).toEqual([expect.objectContaining({ g: 0, title: "Rust" })]);
97− expect(calls[0].siblingTitles).toEqual(["Rust"]);
9895 expect(live.find((t) => t.id === 3)!.groupId).toBe(50);
9996 });
10097
@@ -112,12 +109,6 @@ describe("auto-organize", () => {
112109 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 5", "Tab 6"]);
113110 });
114111
115− it("sorts tabs the browser discarded, which keep their title and address", async () => {
116− const auto = setup([liveTab(1, { status: "unloaded" }), liveTab(2)]);
117− await auto.run(1);
118− expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 1", "Tab 2"]);
119− });
120−
121112 it("one loose tab with nothing to join waits for company", async () => {
122113 const auto = setup([liveTab(1)]);
123114 await auto.run(1);
@@ -152,83 +143,4 @@ describe("auto-organize", () => {
152143 vi.useRealTimers();
153144 }
154145 });
155−
156− it("one tab the model refuses is found and set aside; the others are still sorted", async () => {
157− const auto = setup([liveTab(1), liveTab(2), liveTab(3, { title: "Refused" }), liveTab(4), liveTab(5)]);
158− // Apple's filter refuses any request carrying tab 3; anything else becomes one topic.
159− respond = (p) =>
160− p.items.some((i) => i.title === "Refused")
161− ? { ok: false, error: { code: "GUARDRAIL", message: "blocked" } }
162− : { ok: true, result: { groups: [{ title: "Site pages", emoji: "📚", color: "blue", existing: p.existingGroups.length ? 0 : undefined, members: p.items.map((i) => i.i) }], leftovers: [] } };
163− await auto.run(1);
164− expect(live.find((t) => t.id === 3)!.groupId).toBe(-1);
165− expect(live.filter((t) => t.id !== 3).every((t) => t.groupId === 100)).toBe(true);
166− expect(state.tabs[3].organizeRefused).toBe(organizedKeyOf(live[2]));
167− expect(fallbacks).toHaveLength(0); // not a host problem: no retry scheduled
168−
169− const n = calls.length;
170− live.push(liveTab(6));
171− remember(live[5]);
172− await auto.run(1);
173− expect(calls.slice(n).flatMap((c) => c.items.map((i) => i.title))).toEqual(["Tab 6"]); // the refused tab is not sent again
174−
175− const m = calls.length;
176− live[2].title = "Readable now";
177− await auto.run(1);
178− expect(calls.length).toBeGreaterThan(m); // a new title: it gets another chance
179− });
180−
181− it("a refused request is tried once more without what the pages say", async () => {
182− const auto = setup([liveTab(1), liveTab(2)]);
183− state.tabs[1].description = "Something the filter dislikes";
184− respond = (p) =>
185− p.items.some((i) => i.description)
186− ? { ok: false, error: { code: "GUARDRAIL", message: "blocked" } }
187− : { ok: true, result: { groups: [{ title: "Pair", emoji: "📚", color: "blue", members: [0, 1] }], leftovers: [] } };
188− await auto.run(1);
189− expect(calls).toHaveLength(2);
190− expect(live.map((t) => t.groupId)).toEqual([100, 100]);
191− });
192−
193− it("tabs that changed while the model was thinking are left out of its answer", async () => {
194− const auto = setup([liveTab(1), liveTab(2), liveTab(3), liveTab(4)]);
195− respond = () => {
196− live[1].url = "https://elsewhere.com/"; // tab 2 went to another page
197− live = live.filter((t) => t.id !== 4); // tab 4 was closed
198− return { ok: true, result: { groups: [{ title: "Site pages", emoji: "📚", color: "blue", members: [0, 1, 2] }, { title: "Other", emoji: "📚", color: "red", members: [3] }], leftovers: [] } };
199− };
200− await auto.run(1);
201− expect(live.map((t) => [t.id, t.groupId])).toEqual([[1, 100], [2, -1], [3, 100]]);
202− expect(state.tabs[1].organizedKey).toBeDefined();
203− expect(state.tabs[2].organizedKey).toBeUndefined(); // looked at again on its new page
204− });
205−
206− it("a new group left with one tab after the others moved on is not made", async () => {
207− const auto = setup([liveTab(1), liveTab(2), liveTab(3)]);
208− respond = () => {
209− live[1].groupId = 77; // you dragged tab 2 into a group of yours meanwhile
210− return { ok: true, result: { groups: [{ title: "Pair", emoji: "📚", color: "blue", members: [0, 1] }], leftovers: [2] } };
211− };
212− await auto.run(1);
213− expect(live.find((t) => t.id === 1)!.groupId).toBe(-1);
214− expect(nextGroup).toBe(100);
215− });
216−
217− it("an unread count ticking over is not a new page", async () => {
218− const auto = setup([liveTab(1, { title: "(3) Inbox" }), liveTab(2)]);
219− await auto.run(1);
220− expect(calls).toHaveLength(1);
221− live[0].title = "(4) Inbox";
222− await auto.run(1);
223− expect(calls).toHaveLength(1);
224− });
225−
226− it("a host that stops answering stops the run after one smaller try", async () => {
227− const auto = setup(Array.from({ length: 10 }, (_, i) => liveTab(i + 1)));
228− respond = () => ({ ok: false, error: { code: "TIMEOUT", message: "slow" } });
229− await auto.run(1);
230− expect(calls.map((c) => c.items.length)).toEqual([10, 5]);
231− expect(fallbacks).toHaveLength(1);
232− expect(Object.values(state.tabs).some((t) => t.organizeRefused)).toBe(false);
233− });
234146});
test/e2e/smoke-fm.py+12−9
@@ -19,7 +19,7 @@ def topic_of(text):
1919
2020
2121def items(prompt):
22− body = prompt.split("Tabs (index | title | address | what the page says):", 1)[-1]
22+ body = prompt.split("Tabs (index | title | address | description):", 1)[-1]
2323 out = []
2424 for line in body.strip().splitlines():
2525 m = re.match(r"^(\d+) \| (.*)$", line)
@@ -39,19 +39,22 @@ def main():
3939 print(json.dumps({"schema": argv[1:]}))
4040 return
4141 if argv[0] == "respond":
42− # Like the real fm: rules arrive with -i, the tab list on stdin.
43− instructions = argv[argv.index("-i") + 1] if "-i" in argv else ""
44− prompt = sys.stdin.read()
45− if instructions.startswith("You name"):
42+ prompt = argv[-1]
43+ if prompt.startswith("You name"):
4644 found = topic_of(prompt.split("Tabs (", 1)[-1]) or ("Reading list", "📚", "grey")
4745 print(json.dumps({"title": found[0], "emoji": found[1]}))
4846 return
49− if instructions.startswith("You label"):
50− tabs = []
47+ if prompt.startswith("You sort"):
48+ buckets = {}
49+ leftovers = []
5150 for i, rest in items(prompt):
5251 t = topic_of(rest)
53− tabs.append({"index": i, "topic": t[0].split()[0] if t else f"Other {i}"})
54− print(json.dumps({"tabs": tabs}))
52+ if t:
53+ buckets.setdefault(t, []).append(i)
54+ else:
55+ leftovers.append(i)
56+ groups = [{"title": t[0], "emoji": t[1], "color": t[2], "existing": -1, "members": m} for t, m in buckets.items()]
57+ print(json.dumps({"groups": groups, "leftovers": leftovers}))
5558 return
5659 sys.stderr.write("unexpected fm call\n")
5760 sys.exit(1)
test/e2e/smoke.mjs−27
@@ -47,8 +47,6 @@ const pages = {
4747 "/pasta": ["Pasta recipe", "A weeknight pasta recipe.", ""],
4848 "/cook": ["How to cook rice", "Cook rice perfectly.", ""],
4949 "/misc": ["Something else", "Unrelated page.", ""],
50− "/flight": ["Flights to Lisbon", "Cheap flights to Lisbon.", ""],
51− "/risotto": ["Mushroom risotto recipe", "A creamy risotto recipe.", ""],
5250};
5351const server = createServer((req, res) => {
5452 const p = pages[req.url.split("?")[0]];
@@ -188,31 +186,6 @@ try {
188186 const pasta = await tabIn(win2, "/pasta");
189187 check("an auto-made group closed down to one tab dissolves, and the tab is not regrouped alone", !!gone && pasta.groupId === -1, JSON.stringify({ gone, pasta: pasta.groupId }));
190188
191− // 6b. A grouped tab that moves on to an unrelated page leaves its group and joins a better one.
192− const win3 = await sw.evaluate(async (b) => {
193− const w = await chrome.windows.create({ url: `${b}/flight` });
194− for (const p of ["travel", "hotel", "pasta", "cook"]) await chrome.tabs.create({ windowId: w.id, url: `${b}/${p}` });
195− return w.id;
196− }, base);
197− const sorted3 = await until(async () => {
198− const t = await titlesIn(win3);
199− return t.split("|").length === 2 ? t : undefined;
200− }, 25000);
201− const flight = await tabIn(win3, "/flight");
202− const lisbonGroup = flight.groupId;
203− const pastaGroup3 = (await tabIn(win3, "/pasta")).groupId;
204− await sw.evaluate(async ([id, url]) => chrome.tabs.update(id, { url }), [flight.id, `${base}/risotto`]);
205− const moved = await until(async () => {
206− const t = await sw.evaluate(async (id) => chrome.tabs.get(id), flight.id);
207− return t.groupId === pastaGroup3 ? t : undefined;
208− }, 25000);
209− const lisbonLeft = await sw.evaluate(async (g) => (await chrome.tabs.query({ groupId: g })).length, lisbonGroup);
210− check(
211− "a grouped tab that moves to an unrelated page leaves for the matching group",
212− !!sorted3 && !!moved && lisbonLeft === 2,
213− JSON.stringify({ sorted3, now: (await sw.evaluate(async (id) => chrome.tabs.get(id), flight.id)).groupId, lisbonGroup, pastaGroup3, lisbonLeft }),
214− );
215−
216189 // 7. Popup renders the healthy state.
217190 await popup.setViewportSize({ width: 360, height: 560 });
218191 await popup.reload();
test/engine.test.ts+1−185
@@ -167,10 +167,7 @@ describe("dirty triggers", () => {
167167
168168 it("title change and path change mark the group dirty", () => {
169169 expect(step(base(), { type: "tabUpdated", tab: tab(1, { groupId: 10, title: "New" }) }, ctx()).actions).toEqual([{ type: "dirty", groupId: 10 }]);
170− expect(step(base(), { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://example.com/other" }) }, ctx()).actions).toEqual([
171− { type: "checkFit" },
172− { type: "dirty", groupId: 10 },
173− ]);
170+ expect(step(base(), { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://example.com/other" }) }, ctx()).actions).toEqual([{ type: "dirty", groupId: 10 }]);
174171 });
175172
176173 it("reload, fragment and query changes do not", () => {
@@ -240,14 +237,6 @@ describe("auto-organize triggers", () => {
240237 expect(actions).toContainEqual({ type: "loose", windowId: 1 });
241238 });
242239
243− it("parking keeps the title of the group the tab came from, for the archive", () => {
244− const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 20 })], [{ id: 10, origin: "organize", title: "Rust async" }, { id: 20, origin: "tidy" }]);
245− s.tabs[1].parkedFrom = "Rust async"; // set by the sweep just before it moves the tab
246− const moved = step(s, { type: "tabUpdated", tab: tab(1, { groupId: 20 }) }, ctx());
247− const discarded = step(moved.state, { type: "tabUpdated", tab: tab(1, { groupId: 20, status: "unloaded" }) }, ctx());
248− expect(discarded.state.tabs[1].parkedFrom).toBe("Rust async");
249− });
250−
251240 it("opening a parked tab takes it out of Parked", () => {
252241 const s = stateWith([tab(1, { groupId: 20 }), tab(2, { groupId: 20 })], [{ id: 20, origin: "tidy" }]);
253242 s.tabs[1].parkedFrom = "Rust async";
@@ -263,176 +252,3 @@ describe("auto-organize triggers", () => {
263252 expect(state.tabs[1].lastActivatedAt).toBe(5_000_000);
264253 });
265254});
266−
267−describe("fit check", () => {
268− const group = () => stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 }), tab(3, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
269− const moveOn = (s = group(), over = {}) =>
270− step(s, { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://news.example/story", ...over }) }, ctx());
271−
272− it("a tab in a Diagonal group that goes to a new page is checked once it has loaded", () => {
273− const loading = moveOn(group(), { status: "loading" });
274− expect(loading.state.tabs[1].fitPending).toBe(true);
275− expect(loading.actions).not.toContainEqual({ type: "checkFit" });
276− const loaded = step(loading.state, { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://news.example/story" }) }, ctx());
277− expect(loaded.actions).toContainEqual({ type: "checkFit" });
278− });
279−
280− it("switching to another tab checks the pending one", () => {
281− const { state } = moveOn(group(), { status: "loading" });
282− expect(step(state, { type: "tabActivated", tabId: 2 }, ctx()).actions).toContainEqual({ type: "checkFit" });
283− expect(step(state, { type: "tabActivated", tabId: 1 }, ctx()).actions).not.toContainEqual({ type: "checkFit" });
284− });
285−
286− it("leaves groups you named and groups you made alone", () => {
287− const named = group();
288− named.groups[10].userNamed = true;
289− expect(moveOn(named).state.tabs[1].fitPending).toBeUndefined();
290− const yours = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 })], [{ id: 10, origin: "user" }]);
291− expect(moveOn(yours).state.tabs[1].fitPending).toBeUndefined();
292− expect(moveOn(group(), {}).state.tabs[1].fitPending).toBe(true);
293− const off = step(group(), { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://news.example/story" }) }, ctx(0, { autoOrganize: false }));
294− expect(off.state.tabs[1].fitPending).toBeUndefined();
295− });
296−
297− it("a tab you dragged into a group stays there", () => {
298− const s = stateWith([tab(1), tab(2, { groupId: 10 }), tab(3, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
299− const dragged = step(s, { type: "tabUpdated", tab: tab(1, { groupId: 10 }) }, ctx());
300− expect(dragged.state.tabs[1].handPlaced).toBe(true);
301− expect(moveOn(dragged.state).state.tabs[1].fitPending).toBeUndefined();
302− });
303−
304− it("a tab Diagonal put in the group is still checked", () => {
305− const s = stateWith([tab(1), tab(2, { groupId: 10 }), tab(3, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
306− s.ownAdds[1] = 1_000_000;
307− const added = step(s, { type: "tabUpdated", tab: tab(1, { groupId: 10 }) }, ctx());
308− expect(added.state.tabs[1].handPlaced).toBeUndefined();
309− expect(added.state.ownAdds[1]).toBeUndefined();
310− expect(moveOn(added.state).state.tabs[1].fitPending).toBe(true);
311− });
312−
313− it("leaving the group clears both marks", () => {
314− const pending = moveOn(group(), { status: "loading" }).state;
315− pending.tabs[1].handPlaced = true;
316− const out = step(pending, { type: "tabUpdated", tab: tab(1, { url: "https://news.example/story" }) }, ctx());
317− expect(out.state.tabs[1].fitPending).toBeUndefined();
318− expect(out.state.tabs[1].handPlaced).toBeUndefined();
319− });
320−});
321−
322−describe("groups Chromium removes and brings back", () => {
323− const opener = () => {
324− const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 })], [{ id: 10, origin: "opener", title: "🦀 Rust async" }]);
325− s.groups[10].color = "orange";
326− return s;
327− };
328− const run = (s: ReturnType<typeof opener>, events: Parameters<typeof step>[1][], now = 1_000_000) =>
329− events.reduce((acc, e) => step(acc, e, ctx(now)).state, s);
330−
331− it("a group moved to another window stays Diagonal's, and its tabs are not taken for your choices", () => {
332− const moved = run(opener(), [
333− { type: "groupRemoved", groupId: 10 },
334− { type: "tabUpdated", tab: tab(1) },
335− { type: "tabUpdated", tab: tab(2) },
336− { type: "groupCreated", group: { id: 10, windowId: 2, title: "🦀 Rust async", color: "orange" } },
337− { type: "tabUpdated", tab: tab(1, { groupId: 10, windowId: 2 }) },
338− { type: "tabUpdated", tab: tab(2, { groupId: 10, windowId: 2 }) },
339− ]);
340− expect(moved.groups[10]).toMatchObject({ origin: "opener", managed: true, userNamed: false, windowId: 2 });
341− for (const id of [1, 2]) {
342− expect(moved.tabs[id].groupId).toBe(10);
343− expect(moved.tabs[id].keepLoose).toBeUndefined();
344− expect(moved.tabs[id].handPlaced).toBeUndefined();
345− }
346− expect(moved.removedGroups[10]).toBeUndefined();
347− });
348−
349− it("the same when a tab's new group is reported before the group itself", () => {
350− const moved = run(opener(), [
351− { type: "groupRemoved", groupId: 10 },
352− { type: "tabUpdated", tab: tab(1) },
353− { type: "tabUpdated", tab: tab(1, { groupId: 10, windowId: 2 }) },
354− { type: "groupCreated", group: { id: 10, windowId: 2, title: "🦀 Rust async", color: "orange" } },
355− ]);
356− expect(moved.groups[10].origin).toBe("opener");
357− expect(moved.tabs[1].keepLoose).toBeUndefined();
358− expect(moved.tabs[1].handPlaced).toBeUndefined();
359− });
360−
361− it("a closed group reopened untitled, then titled, is Diagonal's again", () => {
362− const closed = run(opener(), [
363− { type: "tabRemoved", tabId: 1 },
364− { type: "tabRemoved", tabId: 2 },
365− { type: "groupRemoved", groupId: 10 },
366− ]);
367− const later = 1_000_000 + 3_600_000;
368− const back = run(closed, [
369− { type: "groupCreated", group: { id: 77, windowId: 1, title: "", color: "orange" } },
370− { type: "groupUpdated", group: { id: 77, windowId: 1, title: "🦀 Rust async", color: "orange" } },
371− ], later);
372− expect(back.groups[77]).toMatchObject({ origin: "opener", managed: true, userNamed: false, stripTitle: "🦀 Rust async" });
373− });
374−
375− it("a group you make and title yourself later is still yours", () => {
376− const closed = run(opener(), [{ type: "groupRemoved", groupId: 10 }]);
377− const made = run(closed, [{ type: "groupCreated", group: { id: 77, windowId: 1, title: "", color: "orange" } }]);
378− const titled = step(made, { type: "groupUpdated", group: { id: 77, windowId: 1, title: "🦀 Rust async", color: "orange" } }, ctx(1_000_000 + 60_000)).state;
379− expect(titled.groups[77]).toMatchObject({ origin: "user", userNamed: true });
380− });
381−
382− it("ungrouping by hand still keeps the tabs loose", () => {
383− const out = run(opener(), [
384− { type: "tabUpdated", tab: tab(1) },
385− { type: "tabUpdated", tab: tab(2) },
386− { type: "groupRemoved", groupId: 10 },
387− ]);
388− expect(out.tabs[1].keepLoose).toBe("https://example.com/1");
389− });
390−
391− it("keeps a day of removed groups, at most 20", () => {
392− let s = opener();
393− for (let id = 100; id < 125; id++) {
394− s.groups[id] = { ...s.groups[10], id };
395− s = step(s, { type: "groupRemoved", groupId: id }, ctx(1_000_000 + id)).state;
396− }
397− expect(Object.keys(s.removedGroups)).toHaveLength(20);
398− s = step(s, { type: "tabActivated", tabId: 1 }, ctx(1_000_000 + 25 * 3_600_000)).state;
399− expect(Object.keys(s.removedGroups)).toHaveLength(0);
400− });
401−});
402−
403−describe("tabs you place", () => {
404− it("a new tab made inside a group ('New tab in group') is yours there", () => {
405− const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
406− const { state } = step(s, { type: "tabCreated", tab: tab(3, { groupId: 10, url: "brave://newtab/" }) }, ctx());
407− expect(state.tabs[3].handPlaced).toBe(true);
408− });
409−
410− it("a link opened from a grouped tab is not", () => {
411− const opener = tab(1, { groupId: 10 });
412− const s = stateWith([opener, tab(2, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
413− const { state } = step(s, { type: "tabCreated", tab: tab(3, { groupId: 10, openerTabId: 1 }), opener }, ctx());
414− expect(state.tabs[3].handPlaced).toBeUndefined();
415− });
416−
417− it("a tab that joins a group has no loose choice left, even if a late event said it left", () => {
418− const s = stateWith([tab(1), tab(2)], [{ id: 10, origin: "opener" }]);
419− s.tabs[1].keepLoose = "https://example.com/1";
420− s.ownAdds[1] = 1_000_000;
421− const { state } = step(s, { type: "tabUpdated", tab: tab(1, { groupId: 10 }) }, ctx());
422− expect(state.tabs[1].keepLoose).toBeUndefined();
423− });
424−
425− it("a new tab whose first load redirects is not fit-checked", () => {
426− const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 }), tab(3, { groupId: 10 })], [{ id: 10, origin: "opener" }]);
427− const opened = step(s, { type: "tabCreated", tab: tab(4, { groupId: 10, openerTabId: 1, url: "", pendingUrl: "http://blog.example.org/post", status: "loading" }), opener: tab(1, { groupId: 10 }) }, ctx()).state;
428− const landed = step(opened, { type: "tabUpdated", tab: tab(4, { groupId: 10, openerTabId: 1, url: "https://blog.example.org/post" }) }, ctx());
429− expect(landed.state.tabs[4].fitPending).toBeUndefined();
430− });
431−
432− it("leaving Parked stops the archive clock", () => {
433− const s = stateWith([tab(1, { groupId: 20 }), tab(2, { groupId: 20 })], [{ id: 20, origin: "tidy" }]);
434− s.tabs[1].parkedAt = 5;
435− expect(step(s, { type: "tabUpdated", tab: tab(1) }, ctx()).state.tabs[1].parkedAt).toBeUndefined();
436− expect(step(s, { type: "tabActivated", tabId: 1 }, ctx()).state.tabs[1].parkedAt).toBeUndefined();
437− });
438−});
test/fit.test.tsdeleted−18
@@ -1,18 +0,0 @@
1−import { describe, expect, it } from "vitest";
2−import { fitVerdict } from "../src/background/fit";
3−
4−const g = (...members: number[]) => ({ title: "", emoji: "", color: "", members });
5−
6−describe("fit verdict (tab 0 against its groupmates)", () => {
7− it("fits when it shares a topic with any groupmate", () => {
8− expect(fitVerdict({ groups: [g(0, 2), g(1, 3)], leftovers: [] })).toBe("fits");
9− });
10−
11− it("leaves when its groupmates agree and it matches none of them", () => {
12− expect(fitVerdict({ groups: [g(1, 2, 3)], leftovers: [0] })).toBe("leave");
13− });
14−
15− it("is unsure when the groupmates don't agree with each other either", () => {
16− expect(fitVerdict({ groups: [], leftovers: [0, 1, 2] })).toBe("unsure");
17− });
18−});
test/naming.test.ts+7−85
@@ -20,14 +20,14 @@ describe("membership hash", () => {
2020});
2121
2222describe("large group sampling", () => {
23− it("sends the 16 most recent, the first 16 and the last 16, in strip order, capped at 48", () => {
24− const ms = Array.from({ length: 80 }, (_, i) => member(i, { lastActive: i === 40 || i === 41 ? 1000 + i : i }));
23+ it("sends the 8 most recent, the first 8 and the last 8, in strip order, capped at 24", () => {
24+ const ms = Array.from({ length: 40 }, (_, i) => member(i, { lastActive: i === 20 || i === 21 ? 1000 + i : i }));
2525 const picked = sampleMembers(ms);
26− expect(picked.length).toBeLessThanOrEqual(48);
26+ expect(picked.length).toBeLessThanOrEqual(24);
2727 const ids = picked.map((m) => m.id);
2828 expect(ids).toEqual([...ids].sort((a, b) => a - b));
29− for (const id of [0, 15, 64, 79, 40, 41]) expect(ids).toContain(id);
30− expect(ids).not.toContain(30);
29+ for (const id of [0, 7, 32, 39, 20, 21]) expect(ids).toContain(id);
30+ expect(ids).not.toContain(10);
3131 });
3232
3333 it("leaves small groups whole", () => {
@@ -50,10 +50,6 @@ describe("evolving-name policy", () => {
5050 it("same label is never rewritten", () => {
5151 expect(shouldApply(g(), "rust async runtimes", ["a", "b", "c"])).toBe(false);
5252 });
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− });
5753 it("user-named groups are skipped", () => {
5854 expect(shouldApply({ ...g(), userNamed: true }, "Anything else", ["x"])).toBe(false);
5955 });
@@ -155,33 +151,15 @@ describe("naming queue", () => {
155151 expect(state.groups[7].dirty).toBe(false);
156152 });
157153
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 () => {
159155 members = [member(1), member(2, { status: "loading" })];
160156 naming.touch(7);
161157 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 () => {
167158 members = [member(1)];
168159 naming.touch(7);
169160 await settle();
170161 expect(calls).toHaveLength(0);
171− expect(state.groups[7].dirty).toBe(false);
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− });
178−
179− it("names a group whose member the browser discarded to save memory", async () => {
180− members = [member(1), member(2, { status: "unloaded" })];
181− naming.touch(7);
182− await settle();
183− expect(calls).toHaveLength(1);
184− expect(state.groups[7].dirty).toBe(false);
162+ expect(state.groups[7].dirty).toBe(true);
185163 });
186164
187165 it("new-tab pages do not count as members", async () => {
@@ -220,54 +198,6 @@ describe("naming queue", () => {
220198 expect(state.groups[7].nextAttemptAt).toBe(now + 24 * 3_600_000);
221199 });
222200
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−
271201 it("failures back off 30 s, 2 min, 10 min, then hourly; the title is left alone", async () => {
272202 const fail: HostReply<NameResult> = { ok: false, error: { code: "FM_ERROR", message: "boom" } };
273203 replies.push(fail);
@@ -299,14 +229,6 @@ describe("naming queue", () => {
299229 expect(writes).toEqual([[7, "🦀 Rust crates"]]);
300230 });
301231
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−
310232 it("a group renamed by hand while the model runs keeps the user's title", async () => {
311233 const n = new Naming({
312234 ...(naming as any).d,
test/organize.test.ts+3−27
@@ -8,9 +8,9 @@ const t = (n: number, over: { title?: string; description?: string } = {}) => ({
88});
99
1010describe("batching", () => {
11− it("caps batches at 36 items", () => {
12− const b = makeBatches(Array.from({ length: 80 }, (_, i) => t(i)));
13− expect(b.map((x) => x.length)).toEqual([36, 36, 8]);
11+ it("caps batches at 18 items", () => {
12+ const b = makeBatches(Array.from({ length: 60 }, (_, i) => t(i)));
13+ expect(b.map((x) => x.length)).toEqual([18, 18, 18, 6]);
1414 });
1515
1616 it("also respects the character budget", () => {
@@ -21,13 +21,6 @@ describe("batching", () => {
2121 expect(b.flat()).toHaveLength(30);
2222 });
2323
24− it("counts Chinese and Japanese characters double, as fm's tokenizer does", () => {
25− const en = makeBatches(Array.from({ length: 30 }, (_, i) => t(i, { description: "x".repeat(300) })), 36, 5000);
26− const zh = makeBatches(Array.from({ length: 30 }, (_, i) => t(i, { description: "中".repeat(300) })), 36, 5000);
27− expect(zh.length).toBeGreaterThan(en.length);
28− expect(zh.flat()).toHaveLength(30);
29− });
30−
3124 it("maxGroups = min(8, ceil(items / 3))", () => {
3225 expect(maxGroupsFor(18)).toBe(6);
3326 expect(maxGroupsFor(4)).toBe(2);
@@ -107,23 +100,6 @@ describe("existing groups", () => {
107100 expect(list[0]).toEqual({ g: 0, title: "Trip 0", samples: ["Hotel"] });
108101 });
109102
110− it("offers the groups in use when there are more than 12, not the oldest", () => {
111− const s = emptyState();
112− for (let i = 0; i < 15; i++) s.groups[i] = newGroupRecord(i, 1, "organize", "red", { stripTitle: `G ${i}`, lastNamedAt: 1000 - i });
113− s.tabs[1] = { id: 1, windowId: 1, groupId: 14, index: 0, url: "u", title: "Now", pinned: false, createdAt: 0, lastActivatedAt: 5000 };
114− const { ids } = existingGroupsFor(s, 1);
115− expect(ids[0]).toBe(14);
116− expect(ids).toContain(0);
117− expect(ids).not.toContain(13);
118− });
119−
120− it("leaves out the Restored group, which is not a topic", () => {
121− const s = emptyState();
122− s.groups[1] = newGroupRecord(1, 1, "organize", "grey", { userNamed: true, stripTitle: "♻️ Restored", restored: true });
123− s.groups[2] = newGroupRecord(2, 1, "organize", "red", { stripTitle: "🦀 Rust" });
124− expect(existingGroupsFor(s, 1).ids).toEqual([2]);
125− });
126−
127103 it("groups created by batch n come first for batch n+1", () => {
128104 const s = emptyState();
129105 for (let i = 0; i < 15; i++) s.groups[i] = newGroupRecord(i, 1, "organize", "red", { stripTitle: `G ${i}` });
test/settings.test.tsdeleted−8
@@ -1,8 +0,0 @@
1−import { describe, expect, it } from "vitest";
2−import { withDefaults } from "../src/background/settings";
3−
4−describe("withDefaults", () => {
5− it("reads a stored Private Cloud Compute choice as the on-device model, the only one fm has", () => {
6− expect(withDefaults({ model: "pcc" }).model).toBe("system");
7− });
8−});
test/sweep.test.tsdeleted−181
@@ -1,181 +0,0 @@
1−import { afterEach, beforeEach, describe, expect, it } from "vitest";
2−import type { Runtime } from "../src/background/runtime";
3−import { emptyState, newGroupRecord, type State } from "../src/background/state";
4−import { HOUR, runSweep, undoSweep } from "../src/background/tidy";
5−import { settings as makeSettings } from "./helpers";
6−
7−/** The tidy sweep against a small in-memory strip with Chromium's group rules: an emptied group is removed. */
8−
9−type LiveTab = {
10− id: number; windowId: number; index: number; groupId: number; url: string; title: string;
11− pinned: boolean; active: boolean; discarded: boolean; autoDiscardable: boolean; lastAccessed: number;
12−};
13−type LiveGroup = { id: number; windowId: number; title: string; color: string; collapsed: boolean };
14−
15−const T0 = 10_000 * HOUR;
16−let now: number;
17−let live: LiveTab[];
18−let groups: Map<number, LiveGroup>;
19−let nextGroup: number;
20−let state: State;
21−let discarded: number[];
22−
23−const liveTab = (id: number, over: Partial<LiveTab> = {}): LiveTab => ({
24− id, windowId: 1, index: id, groupId: -1, url: `https://site${id}.com/p`, title: `Tab ${id}`,
25− pinned: false, active: false, discarded: false, autoDiscardable: true, lastAccessed: T0 - 60 * HOUR, ...over,
26−});
27−
28−function remember(t: LiveTab): void {
29− state.tabs[t.id] = { id: t.id, windowId: t.windowId, groupId: t.groupId, index: t.index, url: t.url, title: t.title, pinned: t.pinned, createdAt: 0, lastActivatedAt: t.lastAccessed };
30−}
31−
32−const dropEmpty = () => {
33− for (const id of [...groups.keys()]) if (!live.some((t) => t.groupId === id)) groups.delete(id);
34−};
35−
36−const rt: Runtime = {
37− state: () => state,
38− settings: () => makeSettings(),
39− commit: () => undefined,
40− now: () => now,
41− host: async () => ({ ok: false, error: { code: "FM_ERROR", message: "not used" } }) as never,
42− naming: { touch: () => undefined } as unknown as Runtime["naming"],
43− refreshBadge: () => undefined,
44− log: () => undefined,
45−};
46−
47−beforeEach(() => {
48− now = T0;
49− state = emptyState();
50− groups = new Map();
51− nextGroup = 500;
52− discarded = [];
53− (globalThis as any).chrome = {
54− tabs: {
55− query: async (q: { windowId?: number; groupId?: number }) =>
56− live.filter((t) => (q.windowId === undefined || t.windowId === q.windowId) && (q.groupId === undefined || t.groupId === q.groupId)).map((t) => ({ ...t })),
57− get: async (id: number) => ({ ...live.find((t) => t.id === id)! }),
58− group: async ({ tabIds, groupId, createProperties }: { tabIds: number[]; groupId?: number; createProperties?: { windowId: number } }) => {
59− let id = groupId;
60− if (id === undefined) {
61− id = nextGroup++;
62− groups.set(id, { id, windowId: createProperties?.windowId ?? 1, title: "", color: "grey", collapsed: false });
63− }
64− for (const t of live) if (tabIds.includes(t.id)) t.groupId = id;
65− dropEmpty();
66− return id;
67− },
68− ungroup: async (ids: number[]) => {
69− for (const t of live) if (ids.includes(t.id)) t.groupId = -1;
70− dropEmpty();
71− },
72− move: async () => undefined,
73− discard: async (id: number) => {
74− discarded.push(id);
75− live.find((t) => t.id === id)!.discarded = true;
76− },
77− remove: async (ids: number[]) => {
78− live = live.filter((t) => !ids.includes(t.id));
79− dropEmpty();
80− },
81− },
82− tabGroups: {
83− get: async (id: number) => {
84− const g = groups.get(id);
85− if (!g) throw new Error(`No group with id: ${id}.`);
86− return { ...g };
87− },
88− query: async (q: { windowId?: number }) => [...groups.values()].filter((g) => q.windowId === undefined || g.windowId === q.windowId).map((g) => ({ ...g })),
89− update: async (id: number, props: Partial<LiveGroup>) => Object.assign(groups.get(id)!, props),
90− move: async () => undefined,
91− },
92− };
93−});
94−
95−afterEach(() => {
96− delete (globalThis as any).chrome;
97−});
98−
99−function strip(tabs: LiveTab[], made: LiveGroup[] = []): void {
100− live = tabs;
101− for (const g of made) groups.set(g.id, g);
102− for (const t of tabs) remember(t);
103−}
104−
105−const parkedGroupId = () => Object.values(state.groups).find((g) => g.origin === "tidy")?.id;
106−
107−describe("tidy sweep", () => {
108− it("parks a stale tab and closes it only after it has sat in Parked for the archive time", async () => {
109− strip([liveTab(1), liveTab(2, { active: true })]);
110− expect(await runSweep(rt)).toMatchObject({ parked: 1, archived: 0 });
111− expect(live.find((t) => t.id === 1)!.groupId).toBe(parkedGroupId());
112− expect(state.tabs[1].parkedAt).toBe(T0);
113−
114− now = T0 + 47 * HOUR;
115− expect(await runSweep(rt)).toMatchObject({ archived: 0 });
116− now = T0 + 48 * HOUR;
117− expect(await runSweep(rt)).toMatchObject({ archived: 1 });
118− expect(state.archive.map((a) => a.url)).toEqual(["https://site1.com/p"]);
119− });
120−
121− it("two sweeps at once archive a tab once", async () => {
122− strip([liveTab(1, { groupId: 9 }), liveTab(2, { active: true })], [{ id: 9, windowId: 1, title: "💤 Parked", color: "grey", collapsed: true }]);
123− state.groups[9] = newGroupRecord(9, 1, "tidy", "grey", { userNamed: true, stripTitle: "💤 Parked" });
124− state.tabs[1].parkedAt = T0 - 49 * HOUR;
125− await Promise.all([runSweep(rt), runSweep(rt, true)]);
126− expect(state.archive).toHaveLength(1);
127− });
128−
129− it("a Parked group whose record an older version lost is Parked again", async () => {
130− strip([liveTab(1, { groupId: 50 }), liveTab(2)], [{ id: 50, windowId: 1, title: "💤 Parked", color: "grey", collapsed: true }]);
131− state.groups[50] = newGroupRecord(50, 1, "user", "grey", { userNamed: true, stripTitle: "💤 Parked" });
132− await runSweep(rt);
133− expect(live.find((t) => t.id === 2)!.groupId).toBe(50);
134− expect(groups.size).toBe(1);
135− expect(state.groups[50]).toMatchObject({ origin: "tidy", managed: true });
136− });
137−
138− it("does not discard a tab marked never to be discarded", async () => {
139− strip([liveTab(1), liveTab(2, { autoDiscardable: false }), liveTab(3, { active: true })]);
140− await runSweep(rt);
141− expect(discarded).toEqual([1]);
142− });
143−});
144−
145−describe("undo tidy", () => {
146− it("brings back a group that parking emptied, with its title", async () => {
147− strip(
148− [liveTab(1, { active: true }), liveTab(2, { groupId: 7 }), liveTab(3, { groupId: 7 })],
149− [{ id: 7, windowId: 1, title: "🦀 Rust async", color: "orange", collapsed: false }],
150− );
151− state.groups[7] = newGroupRecord(7, 1, "organize", "orange", { title: "Rust async", emoji: "🦀", stripTitle: "🦀 Rust async" });
152− await runSweep(rt);
153− expect(groups.has(7)).toBe(false);
154−
155− expect(await undoSweep(rt)).toBe(2);
156− const back = live.find((t) => t.id === 2)!.groupId;
157− expect(back).not.toBe(-1);
158− expect(live.find((t) => t.id === 3)!.groupId).toBe(back);
159− expect(groups.get(back)).toMatchObject({ title: "🦀 Rust async", color: "orange" });
160− expect(state.groups[back]).toMatchObject({ origin: "organize", title: "Rust async", userNamed: false });
161− expect(state.tabs[2].parkedAt).toBeUndefined();
162− });
163−
164− it("puts loose tabs back as the worker's own move, so they are not taken for tabs you pulled out", async () => {
165− strip([liveTab(1, { active: true }), liveTab(4)]);
166− await runSweep(rt);
167− await undoSweep(rt);
168− expect(live.find((t) => t.id === 4)!.groupId).toBe(-1);
169− expect(state.ownUngroups[4]).toBeDefined();
170− });
171−
172− it("covers every sweep in the last hour, not only the latest", async () => {
173− strip([liveTab(1, { active: true }), liveTab(2), liveTab(3, { lastAccessed: T0 - 23.5 * HOUR })]);
174− state.tabs[3].lastActivatedAt = T0 - 23.5 * HOUR;
175− await runSweep(rt, true);
176− now = T0 + HOUR / 2;
177− await runSweep(rt);
178− expect(live.filter((t) => t.groupId !== -1).map((t) => t.id)).toEqual([2, 3]);
179− expect(await undoSweep(rt)).toBe(2);
180− });
181−});
test/tidy.test.ts+3−34
@@ -34,19 +34,6 @@ describe("park eligibility", () => {
3434 expect(isParkCandidate(tab, s, cfg, NOW)).toBe(expected);
3535 });
3636
37− it("a Diagonal group you renamed counts as yours", () => {
38− const s2 = emptyState();
39− s2.groups[14] = newGroupRecord(14, 1, "organize", "red", { userNamed: true, stripTitle: "Tax 2026" });
40− expect(isParkCandidate(tt({ groupId: 14 }), s2, cfg, NOW)).toBe(false);
41− expect(isParkCandidate(tt({ groupId: 14 }), s2, settings({ tidyUserGroups: true }), NOW)).toBe(true);
42− });
43−
44− it("after a restart, the stored last use wins over Chromium's restore time", () => {
45− const s2 = emptyState();
46− s2.tabs[1] = { id: 1, windowId: 1, groupId: -1, index: 0, url: "", title: "", pinned: false, createdAt: 0, lastActivatedAt: NOW - 30 * HOUR };
47− expect(isParkCandidate(tt({ lastAccessed: NOW - 60_000 }), s2, cfg, NOW)).toBe(true);
48− });
49−
5037 it("user groups are eligible when 'Tidy inside my own groups' is on", () => {
5138 expect(isParkCandidate(tt({ groupId: 10 }), s, settings({ tidyUserGroups: true }), NOW)).toBe(true);
5239 });
@@ -64,32 +51,14 @@ describe("park eligibility", () => {
6451});
6552
6653describe("archive", () => {
67− const record = (id: number, over: object = {}) => ({ id, windowId: 1, groupId: 12, index: 0, url: "", title: "", pinned: false, createdAt: 0, lastActivatedAt: NOW - 60 * HOUR, ...over });
68−
69− it("parked tabs that sat in Parked past archiveAfter are archived; 0 means never", () => {
54+ it("parked tabs idle past archiveAfter are archived; 0 means never", () => {
7055 const s = emptyState();
7156 s.groups[12] = newGroupRecord(12, 1, "tidy", "grey");
72− s.tabs[1] = record(1, { parkedAt: NOW - 49 * HOUR });
73− const parked = tt({ groupId: 12, lastAccessed: NOW - 60 * HOUR });
74− expect(archiveCandidates([parked, tt({ id: 2 })], s, settings(), NOW)).toEqual([parked]);
57+ const parked = tt({ groupId: 12, lastAccessed: NOW - 49 * HOUR });
58+ expect(archiveCandidates([parked, tt()], s, settings(), NOW)).toEqual([parked]);
7559 expect(archiveCandidates([parked], s, settings({ archiveAfterHours: 0 }), NOW)).toEqual([]);
7660 });
7761
78− it("counts from when the tab was parked, not from when it was last used", () => {
79− const s = emptyState();
80− s.groups[12] = newGroupRecord(12, 1, "tidy", "grey");
81− s.tabs[1] = record(1, { parkedAt: NOW - HOUR }); // unused for 60 h, but parked only an hour ago
82− s.tabs[2] = record(2); // in Parked with no parking time: its clock has not started
83− expect(archiveCandidates([tt({ groupId: 12, lastAccessed: NOW - 60 * HOUR }), tt({ id: 2, groupId: 12 })], s, settings(), NOW)).toEqual([]);
84− });
85−
86− it("an address added to Never tidy is not closed from Parked", () => {
87− const s = emptyState();
88− s.groups[12] = newGroupRecord(12, 1, "tidy", "grey");
89− s.tabs[1] = record(1, { parkedAt: NOW - 49 * HOUR });
90− expect(archiveCandidates([tt({ groupId: 12, url: "https://jira.corp/x" })], s, settings({ tidyExclusions: ["jira."] }), NOW)).toEqual([]);
91− });
92−
9362 it("is newest first and capped, oldest dropped", () => {
9463 const entry = (n: number): ArchivedTab => ({ url: `u${n}`, title: `t${n}`, archivedAt: n, lastActivatedAt: 0 });
9564 const existing = [3, 2, 1].map(entry);
test/url.test.ts+13−20
@@ -1,8 +1,7 @@
11import { describe, expect, it } from "vitest";
2−import { isValidLabel, labelOf, repairLabel, safeEmoji, titleKey } from "../src/shared/label";
2+import { isValidLabel, labelOf, repairLabel, safeEmoji } from "../src/shared/label";
33import { colorFor, hostname, isInternalUrl, promptAddress, promptUrl, provisionalTitle, registrableDomain, trimText } from "../src/shared/url";
44import { withDefaults } from "../src/background/settings";
5−import labelCases from "../host/tests/golden/labels.json";
65
76describe("url helpers", () => {
87 it("strips www.", () => {
@@ -46,21 +45,24 @@ describe("url helpers", () => {
4645 });
4746});
4847
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−
5248describe("labels", () => {
53− it.each(LABEL_CASES)("repairs %s", (raw, fixed) => {
54− expect(repairLabel(raw) ?? null).toBe(fixed);
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"],
55+ ])("repairs %s", (raw, 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();
5561 });
5662
5763 it("validates", () => {
5864 expect(isValidLabel("Lisbon trip")).toBe(true);
5965 expect(isValidLabel("Lisbon")).toBe(false);
60− expect(isValidLabel("日本旅行")).toBe(true);
61− expect(isValidLabel("京都の旅行計画")).toBe(true);
62− expect(isValidLabel("日")).toBe(false);
63− expect(repairLabel("日本旅行。")).toBe("日本旅行");
6466 });
6567
6668 it("emoji outside the list fall back; FE0F is optional", () => {
@@ -68,15 +70,6 @@ describe("labels", () => {
6870 expect(safeEmoji("🦄")).toBe("🧭");
6971 });
7072
71− it("titleKey ignores the unread counts and bullets sites put in titles", () => {
72− expect(titleKey("(3) Inbox - Mail")).toBe(titleKey("(12) Inbox - Mail"));
73− expect(titleKey("Inbox (1,204) - me@example.com - Gmail")).toBe("Inbox - me@example.com - Gmail");
74− expect(titleKey("• Slack | general")).toBe("Slack | general");
75− expect(titleKey("[99+] Chat")).toBe("Chat");
76− expect(titleKey("Rust async")).toBe("Rust async");
77− expect(titleKey("Rust async")).not.toBe(titleKey("Rust sync"));
78− });
79−
8073 it("labelOf strips our emoji prefix", () => {
8174 expect(labelOf("🦀 Rust async")).toBe("Rust async");
8275 expect(labelOf("Plain title")).toBe("Plain title");
tsconfig.json−1
@@ -9,7 +9,6 @@
99 "noEmit": true,
1010 "skipLibCheck": true,
1111 "isolatedModules": true,
12− "resolveJsonModule": true,
1312 "noFallthroughCasesInSwitch": true,
1413 "forceConsistentCasingInFileNames": true
1514 },