on master help sign in
.github/workflows/release.yml+23−8
since splitsnapshots
unifiedsplit
@@ -9,8 +9,11 @@ on:
9permissions:
9permissions:
10 contents: write
10 contents: write
11
11
12+# One release at a time, in push order: an older run finishing last must not become the latest release,
13+# which is what the Homebrew cask downloads.
12concurrency:
14concurrency:
13− group: release-${{ github.sha }}
15+ group: release
16+ cancel-in-progress: false
14
17
15jobs:
18jobs:
16 release:
19 release:
@@ -32,8 +35,23 @@ jobs:
32
35
33 - name: Version from commit time
36 - name: Version from commit time
34 id: version
37 id: version
38+ env:
39+ GH_TOKEN: ${{ github.token }}
35 run: |
40 run: |
36− version=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y.%m.%d.%H%M "$GITHUB_SHA")
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
37 echo "version=$version" >> "$GITHUB_OUTPUT"
55 echo "version=$version" >> "$GITHUB_OUTPUT"
38 echo "Version $version"
56 echo "Version $version"
39
57
@@ -54,22 +72,19 @@ jobs:
54 npm run zip:store
72 npm run zip:store
55
73
56 - name: Publish release
74 - name: Publish release
75+ if: steps.version.outputs.published != 'true'
57 env:
76 env:
58 GH_TOKEN: ${{ github.token }}
77 GH_TOKEN: ${{ github.token }}
59 VERSION: ${{ steps.version.outputs.version }}
78 VERSION: ${{ steps.version.outputs.version }}
60 run: |
79 run: |
61 asset="diagonal-extension-${VERSION}.zip"
80 asset="diagonal-extension-${VERSION}.zip"
62 notes="Built from ${GITHUB_SHA::7}. Install with: brew install iko-soy/tap/diagonal. Or by hand: unzip, load the folder at brave://extensions (Developer mode → Load unpacked), then install the native host once from Terminal: bash <that folder>/install-host.command"
81 notes="Built from ${GITHUB_SHA::7}. Install with: brew install iko-soy/tap/diagonal. Or by hand: unzip, load the folder at brave://extensions (Developer mode → Load unpacked), then install the native host once from Terminal: bash <that folder>/install-host.command"
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
82+ gh release create "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
68− fi
69
83
70 # Uploads the release to the Chrome Web Store and submits it for review, once the listing exists and
84 # Uploads the release to the Chrome Web Store and submits it for review, once the listing exists and
71 # its API credentials are set as repository secrets (docs/store/listing.md). Skipped until then.
85 # its API credentials are set as repository secrets (docs/store/listing.md). Skipped until then.
72 - name: Upload to the Chrome Web Store
86 - name: Upload to the Chrome Web Store
87+ if: steps.version.outputs.published != 'true'
73 env:
88 env:
74 CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
89 CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
75 CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
90 CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
PRIVACY.md+1−1
@@ -4,7 +4,7 @@ Diagonal groups and names your browser tabs. This page says what it reads, where
4
4
5## What Diagonal reads
5## What Diagonal reads
6
6
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.
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.
8
8
9## Where it goes
9## Where it goes
10
10
README.md+10−6
@@ -10,11 +10,12 @@ You open and close tabs as usual. Diagonal does the rest on its own; there are n
102. **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.
102. **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.
113. **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.
113. **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.
124. **Dissolve.** A group Diagonal made that drops to one tab is ungrouped, unless you gave it a title.
124. **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.
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.
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.
14
15
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.
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.
16
17
17−The model sees a tab's title, address and `<meta name="description">`, and nothing else.
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.
18
19
19## Requirements
20## Requirements
20
21
@@ -135,10 +136,13 @@ Every push to `master` runs `.github/workflows/release.yml`: it typechecks, runs
135Checked on a Mac running macOS 27.0 (26A428) on 2026-09-24; the tests' fake `fm` follows it.
136Checked on a Mac running macOS 27.0 (26A428) on 2026-09-24; the tests' fake `fm` follows it.
136
137
137- `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.
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.
138−- `fm schema object` builds the schemas. A nested object needs its own schema: `--object groups --schema "<json from another fm schema object>" --array`. The host does that for `organize.json` (see `SUB_SCHEMAS` in `host/diagonal-host.py`) and keeps the two flat schemas plus two calls as a fallback.
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.
139−- `fm respond` reads the prompt from stdin when no prompt argument is given, so the host never puts tab titles in the process list. There is no timeout flag; the host enforces 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.
140- The only model is `system` (`--model pcc` is rejected), so Diagonal always uses the on-device model.
142- The only model is `system` (`--model pcc` is rejected), so Diagonal always uses the on-device model.
141−- The context is about 8,000 tokens for prompt and reply together (38,000 characters worked, 40,000 overflowed). `CHAR_BUDGET` is 24,000 characters.
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`.
142- 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`.
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`.
143- 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.
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.
144
148
docs/store/listing.md+4−4
@@ -18,7 +18,7 @@ Everything the Chrome Web Store developer dashboard asks for, ready to paste. Th
18 >
18 >
19 > 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
19 > 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
20 >
20 >
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.
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.
22
22
23- **Icon:** `icons/128.png`
23- **Icon:** `icons/128.png`
24- **Screenshots (1280×800):** `docs/store/screenshot-popup.png`, `docs/store/screenshot-settings.png`
24- **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
35 - `storage`: keeps settings, which groups it manages, and archived tabs, on this computer.
35 - `storage`: keeps settings, which groups it manages, and archived tabs, on this computer.
36 - `alarms`: waits for tabs to settle before grouping, and runs the idle-tab tidy on a schedule.
36 - `alarms`: waits for tabs to settle before grouping, and runs the idle-tab tidy on a schedule.
37 - `nativeMessaging`: talks to Diagonal's helper on the Mac, which runs Apple's on-device model.
37 - `nativeMessaging`: talks to Diagonal's helper on the Mac, which runs Apple's on-device model.
38− - `scripting`: reads the meta description of tabs that were already open when Diagonal was installed.
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 each page's meta description, which helps the model name groups.
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.
40- **Remote code:** No, it does not use remote code.
40- **Remote code:** No, it does not use remote code.
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.
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.
42- **Privacy policy URL:** https://github.com/iko-soy/diagonal/blob/master/PRIVACY.md
42- **Privacy policy URL:** https://github.com/iko-soy/diagonal/blob/master/PRIVACY.md
43
43
44## Distribution
44## Distribution
host/diagonal-host.py+161−87
@@ -36,32 +36,28 @@ SUPPORT = os.environ.get("DIAGONAL_SUPPORT_DIR", os.path.expanduser("~/Library/A
36SCHEMAS = os.path.join(SUPPORT, "schemas")
36SCHEMAS = os.path.join(SUPPORT, "schemas")
37LOG_DIR = os.environ.get("DIAGONAL_LOG_DIR", os.path.expanduser("~/Library/Logs/Diagonal"))
37LOG_DIR = os.environ.get("DIAGONAL_LOG_DIR", os.path.expanduser("~/Library/Logs/Diagonal"))
38LOG_MAX_BYTES = 5 * 1024 * 1024
38LOG_MAX_BYTES = 5 * 1024 * 1024
39−# fm's context is about 8,000 tokens for prompt and reply together (measured on macOS 27: 38,000 chars of
39+# fm's context holds prompt and reply together. Measured on macOS 27 with the topics schema: a 7,400-token
40−# prompt worked, 40,000 overflowed, ~4.7 chars/token). 24,000 chars leaves room for the reply and dense text.
40+# prompt worked, 13,100 overflowed. 7,000 tokens leaves room for the reply.
41−CHAR_BUDGET = 24_000
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
42MAX_REQUEST = 64 * 1024 * 1024
46MAX_REQUEST = 64 * 1024 * 1024
43MAX_REPLY = 1024 * 1024
47MAX_REPLY = 1024 * 1024
44OPS = {"ping", "name", "organize"}
48OPS = {"ping", "name", "organize"}
45
49
46−# `fm schema` argument lists (section 8), checked against fm on macOS 27. `--object NAME` takes the
50+# `fm schema` argument lists (section 8), checked against fm on macOS 27. `--object NAME` takes the nested
47−# nested object's schema as JSON from `--schema`; "{Group}" is replaced by the output of SUB_SCHEMAS["Group"].
51+# object's schema as JSON from `--schema`; "{TabTopic}" is replaced by the output of SUB_SCHEMAS["TabTopic"].
48−# If the nested form fails, --install-schemas writes the two flat schemas and `organize` takes two calls.
49SUB_SCHEMAS = {
52SUB_SCHEMAS = {
50− "Group": ["schema", "object", "--name", "Group",
53+ "TabTopic": ["schema", "object", "--name", "TabTopic", "--integer", "index", "--string", "topic"],
51− "--string", "title", "--string", "emoji", "--string", "color",
52− "--integer", "existing", "--optional", "--integer", "members", "--array"],
53}
54}
54SCHEMA_COMMANDS = {
55SCHEMA_COMMANDS = {
55 "name.json": ["schema", "object", "--name", "GroupName", "--string", "title", "--string", "emoji"],
56 "name.json": ["schema", "object", "--name", "GroupName", "--string", "title", "--string", "emoji"],
56− "organize.json": ["schema", "object", "--name", "Organized",
57+ "topics.json": ["schema", "object", "--name", "Topics", "--object", "tabs", "--schema", "{TabTopic}", "--array"],
57− "--object", "groups", "--schema", "{Group}", "--array",
58− "--integer", "leftovers", "--array"],
59−}
60−FALLBACK_SCHEMA_COMMANDS = {
61− "organize-labels.json": ["schema", "object", "--name", "TopicLabels",
62− "--string", "labels", "--array", "--string", "emojis", "--array", "--string", "colors", "--array"],
63− "organize-assign.json": ["schema", "object", "--name", "Assignment", "--integer", "assignment", "--array"],
64}
58}
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"]
65
61
66
62
67class Fail(Exception):
63class Fail(Exception):
@@ -139,7 +135,7 @@ def validate_request(req):
139 if not isinstance(it, dict):
135 if not isinstance(it, dict):
140 raise Fail("BAD_REQUEST", f"item {n} is not an object")
136 raise Fail("BAD_REQUEST", f"item {n} is not an object")
141 clean.append({"i": n, "title": _trim(it.get("title"), 120), "url": _trim(it.get("url"), 300),
137 clean.append({"i": n, "title": _trim(it.get("title"), 120), "url": _trim(it.get("url"), 300),
142− "description": _trim(it.get("description"), 300)})
138+ "description": _trim(it.get("description"), 500)})
143 payload["items"] = clean
139 payload["items"] = clean
144 for key in ("siblingTitles", "mustDifferFrom"):
140 for key in ("siblingTitles", "mustDifferFrom"):
145 payload[key] = [_trim(t, 60) for t in payload.get(key) or [] if isinstance(t, str)][:20]
141 payload[key] = [_trim(t, 60) for t in payload.get(key) or [] if isinstance(t, str)][:20]
@@ -194,10 +190,46 @@ def schema_path(name):
194 return path if os.path.isfile(path) and os.path.getsize(path) > 0 else None
190 return path if os.path.isfile(path) and os.path.getsize(path) > 0 else None
195
191
196
192
197−def check_budget(prompt, n_items):
193+# How much of each page's text to send, most first: when a batch doesn't fit the budget, the pages' text
198− if len(prompt) > CHAR_BUDGET:
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
199− allowed = max(1, int(n_items * CHAR_BUDGET / len(prompt)))
231+ allowed = max(1, int(n_items * TOKEN_BUDGET / tokens))
200− raise Fail("OVER_BUDGET", f"prompt is {len(prompt)} chars, budget {CHAR_BUDGET}", retryable=True, allowedItems=allowed)
232+ raise Fail("OVER_BUDGET", f"prompt is about {tokens} tokens, budget {TOKEN_BUDGET}", retryable=True, allowedItems=allowed)
201
233
202
234
203def extract_json(stdout):
235def extract_json(stdout):
@@ -220,52 +252,67 @@ def extract_json(stdout):
220# fm's safety layer sometimes refuses harmless tab lists. Naming tabs only restates their own text, so a
252# fm's safety layer sometimes refuses harmless tab lists. Naming tabs only restates their own text, so a
221# refused call is retried once in the mode Apple provides for transforming given content.
253# refused call is retried once in the mode Apple provides for transforming given content.
222PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
254PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
255+USAGE_EXIT = 64
256+
223
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
224
261
262+
225−def run_fm(prompt, schema, model, timeout_s):
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."""
226 try:
266 try:
227− return _run_fm(prompt, schema, model, timeout_s, [])
267+ return _run_fm(prompt, schema, opts, [])
228 except Fail as f:
268 except Fail as f:
229− if f.code != "GUARDRAIL":
269+ if f.code != "GUARDRAIL" or time_left(opts) < MIN_CALL_S:
230 raise
270 raise
231− return _run_fm(prompt, schema, model, timeout_s, PERMISSIVE)
271+ return _run_fm(prompt, schema, opts, PERMISSIVE)
272+
232
273
274+def time_left(opts):
275+ return opts["deadline"] - time.time()
233
276
277+
234−def _run_fm(prompt, schema, model, timeout_s, extra):
278+def _run_fm(prompt, schema, opts, extra):
235− # The prompt goes on stdin (fm reads it there when no prompt argument is given), so tab titles never
279+ # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
236− # show up in the process list. fm has no timeout of its own; subprocess enforces ours.
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"]
237− args = [FM, "respond", "--model", model, "--no-stream", "--schema", schema, *extra]
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))
238 try:
287 try:
239− p = subprocess.run(args, input=prompt, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
288+ p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout_s, env=fm_env())
240 except FileNotFoundError:
289 except FileNotFoundError:
241 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
290 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
242 except subprocess.TimeoutExpired:
291 except subprocess.TimeoutExpired:
243− raise Fail("TIMEOUT", f"fm exceeded {timeout_s:g}s", retryable=True)
292+ raise Fail("TIMEOUT", f"fm exceeded {opts['timeout_s']:g}s", retryable=True)
244 if p.returncode != 0:
293 if p.returncode != 0:
245 code = classify(p.stderr, p.returncode)
294 code = classify(p.stderr, p.returncode)
246 if code == "LICENSE_REQUIRED":
295 if code == "LICENSE_REQUIRED":
247 raise Fail(code, LICENSE_MESSAGE, raw=(p.stderr or "")[:2000])
296 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.
248− raise Fail(code, (p.stderr or f"fm exited {p.returncode}").strip()[:500], retryable=True, raw=(p.stderr or "")[:2000])
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])
249 return extract_json(p.stdout)
300 return extract_json(p.stdout)
250
301
251
302
252# ----- ops --------------------------------------------------------------------------------------
303# ----- ops --------------------------------------------------------------------------------------
253
304
254def organize_mode():
305def organize_mode():
255− if schema_path("organize.json"):
306+ return "topics" if schema_path("topics.json") else "missing"
256− return "nested"
257− if schema_path("organize-labels.json") and schema_path("organize-assign.json"):
258− return "two-call"
259− return "missing"
260
307
261
308
262def op_ping(_payload, _opts):
309def op_ping(_payload, _opts):
263 if not os.path.exists(FM):
310 if not os.path.exists(FM):
264− return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False,
311+ return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False, "licenseRequired": False,
265 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
312 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
266 license_required = False
313 license_required = False
267 try:
314 try:
268− avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, timeout=15, env=fm_env())
315+ avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, env=fm_env())
269 ok, msg = avail.returncode == 0, (avail.stdout + avail.stderr).strip()[:300]
316 ok, msg = avail.returncode == 0, (avail.stdout + avail.stderr).strip()[:300]
270 if not ok and license_needed(avail.returncode, avail.stdout + avail.stderr):
317 if not ok and license_needed(avail.returncode, avail.stdout + avail.stderr):
271 license_required, msg = True, LICENSE_MESSAGE
318 license_required, msg = True, LICENSE_MESSAGE
@@ -285,9 +332,9 @@ def op_ping(_payload, _opts):
285 return reply
332 return reply
286
333
287
334
288−# Bumped when SCHEMA_COMMANDS change, so schemas written by an older host are rewritten. Version 2: the
335+# Bumped when SCHEMA_COMMANDS change, so schemas written by an older host are rewritten. Version 3: organize
289−# nested organize schema in the form fm actually accepts (older hosts fell back to two calls).
336+# asks for one topic per tab (topics.json) instead of whole groups.
290−SCHEMA_VERSION = "2"
337+SCHEMA_VERSION = "3"
291
338
292
339
293def schemas_current():
340def schemas_current():
@@ -318,42 +365,73 @@ def ensure_schemas():
318 raise Fail("SCHEMA_MISSING", f"fm could not write Diagonal's schema files: {why}"[:500], raw=out.getvalue()[-2000:])
365 raise Fail("SCHEMA_MISSING", f"fm could not write Diagonal's schema files: {why}"[:500], raw=out.getvalue()[-2000:])
319
366
320
367
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+
321def op_name(payload, opts):
376def op_name(payload, opts):
322 ensure_schemas()
377 ensure_schemas()
323 schema = schema_path("name.json")
378 schema = schema_path("name.json")
324 if not schema:
379 if not schema:
325 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
380 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
326− prompt = prompts.build_name_prompt(payload, strict=opts.get("strict", False))
381+ prompt = fit(lambda cap: prompts.build_name_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
327− check_budget(prompt, len(payload["items"]))
328− out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
329 try:
382 try:
383+ out = run_fm(prompt, schema, opts)
384+ except Fail as f:
385+ raise _fit_overflow(f, len(payload["items"]))
386+ try:
330− return validate.validate_name(out, payload), len(prompt), "name"
387+ return validate.validate_name(out, payload), prompts.size(prompt), "name"
331 except validate.ValidationError as e:
388 except validate.ValidationError as e:
332 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
389 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
333
390
334
391
335def op_organize(payload, opts):
392def op_organize(payload, opts):
336− strict = opts.get("strict", False)
337 ensure_schemas()
393 ensure_schemas()
338− mode = organize_mode()
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)
339 try:
403 try:
340− if mode == "nested":
404+ result = validate.organize_from_topics(out, payload, owner)
341− prompt = prompts.build_organize_prompt(payload, strict=strict)
342− check_budget(prompt, len(payload["items"]))
343− out = run_fm(prompt, schema_path("organize.json"), opts["model"], opts["timeout_s"])
344− return validate.validate_organize(out, payload), len(prompt), "nested"
345− if mode == "two-call":
346− p1 = prompts.build_labels_prompt(payload, strict=strict)
347− check_budget(p1, len(payload["items"]))
348− labels = run_fm(p1, schema_path("organize-labels.json"), opts["model"], opts["timeout_s"])
349− names = [x for x in (labels.get("labels") or []) if isinstance(x, str)] if isinstance(labels, dict) else []
350− p2 = prompts.build_assign_prompt(payload, names, strict=strict)
351− check_budget(p2, len(payload["items"]))
352− assign = run_fm(p2, schema_path("organize-assign.json"), opts["model"], opts["timeout_s"])
353− return validate.organize_from_two_calls(labels, assign, payload), len(p1) + len(p2), "two-call"
354 except validate.ValidationError as e:
405 except validate.ValidationError as e:
355− raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True)
406+ raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
356− raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'organize.json')}")
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
357
435
358
436
359def handle(req):
437def handle(req):
@@ -366,8 +444,8 @@ def handle(req):
366 raw_opts = req.get("opts") or {}
444 raw_opts = req.get("opts") or {}
367 timeout_ms = raw_opts.get("timeoutMs", 45000)
445 timeout_ms = raw_opts.get("timeoutMs", 45000)
368 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
446 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
369− opts = {"model": "system", "strict": bool(raw_opts.get("strict")),
370− "timeout_s": max(5.0, min(120.0, timeout_ms / 1000))}
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}
371 op = req["op"]
449 op = req["op"]
372 if op == "ping":
450 if op == "ping":
373 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
451 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
@@ -421,7 +499,7 @@ def install_schemas():
421 if "{%s}" % key not in args:
499 if "{%s}" % key not in args:
422 continue
500 continue
423 try:
501 try:
424− p = subprocess.run([FM, *sub_args], capture_output=True, text=True, timeout=30, env=fm_env())
502+ p = subprocess.run([FM, *sub_args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
425 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
503 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
426 print(f" {name}: fm failed ({e})", file=sys.stderr)
504 print(f" {name}: fm failed ({e})", file=sys.stderr)
427 return "error"
505 return "error"
@@ -433,7 +511,7 @@ def install_schemas():
433 subs["{%s}" % key] = p.stdout.strip()
511 subs["{%s}" % key] = p.stdout.strip()
434 args = [subs.get(a, a) for a in args]
512 args = [subs.get(a, a) for a in args]
435 try:
513 try:
436− p = subprocess.run([FM, *args], capture_output=True, text=True, timeout=30, env=fm_env())
514+ p = subprocess.run([FM, *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
437 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
515 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
438 print(f" {name}: fm failed ({e})", file=sys.stderr)
516 print(f" {name}: fm failed ({e})", file=sys.stderr)
439 return "error"
517 return "error"
@@ -453,24 +531,16 @@ def install_schemas():
453 print(" Diagonal writes its schemas on its own once that is done.", file=sys.stderr)
531 print(" Diagonal writes its schemas on its own once that is done.", file=sys.stderr)
454 return INSTALL_LICENSE_EXIT
532 return INSTALL_LICENSE_EXIT
455 ok &= first is True
533 ok &= first is True
456− nested = write("organize.json", SCHEMA_COMMANDS["organize.json"])
534+ topics = write("topics.json", SCHEMA_COMMANDS["topics.json"])
535+ if topics == "license":
536+ return INSTALL_LICENSE_EXIT
457− if nested is True:
537+ ok &= topics is True
458− for name in FALLBACK_SCHEMA_COMMANDS:
538+ if ok:
539+ for name in LEGACY_SCHEMAS:
459 try:
540 try:
460 os.remove(os.path.join(SCHEMAS, name))
541 os.remove(os.path.join(SCHEMAS, name))
461 except FileNotFoundError:
542 except FileNotFoundError:
462 pass
543 pass
463− elif nested != "rejected":
464− # fm did not get as far as judging the schema: no reason to think nesting is unsupported.
465− return INSTALL_LICENSE_EXIT if nested == "license" else 1
466− else:
467− print(" nested organize schema unsupported; using the two-call fallback", file=sys.stderr)
468− try:
469− os.remove(os.path.join(SCHEMAS, "organize.json"))
470− except FileNotFoundError:
471− pass
472− for name, args in FALLBACK_SCHEMA_COMMANDS.items():
473− ok &= write(name, args) is True
474 if ok:
544 if ok:
475 with open(os.path.join(SCHEMAS, "version"), "w", encoding="utf-8") as f:
545 with open(os.path.join(SCHEMAS, "version"), "w", encoding="utf-8") as f:
476 f.write(SCHEMA_VERSION + "\n")
546 f.write(SCHEMA_VERSION + "\n")
@@ -498,14 +568,16 @@ def selftest():
498
568
499 ping = op_ping({}, {})
569 ping = op_ping({}, {})
500 check("fm found", os.path.exists(FM), FM)
570 check("fm found", os.path.exists(FM), FM)
571+ if not os.path.exists(FM):
572+ return 1 # nothing else can work without it
501− if ping["licenseRequired"]:
573+ if ping.get("licenseRequired"):
502 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
574 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
503 else:
575 else:
504 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
576 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
505− for name in ["name.json", *(list(FALLBACK_SCHEMA_COMMANDS) if organize_mode() == "two-call" else ["organize.json"])]:
577+ for name in SCHEMA_COMMANDS:
506 path = os.path.join(SCHEMAS, name)
578 path = os.path.join(SCHEMAS, name)
507 readable = os.path.isfile(path) and os.path.getsize(path) > 0
579 readable = os.path.isfile(path) and os.path.getsize(path) > 0
508− hint = f"written on their own after {LICENSE_FIX}" if ping["licenseRequired"] else "missing: run diagonal-host --install-schemas"
580+ hint = f"written on their own after {LICENSE_FIX}" if ping.get("licenseRequired") else "missing: run diagonal-host --install-schemas"
509 check(f"schema {name}", readable, path if readable else hint)
581 check(f"schema {name}", readable, path if readable else hint)
510 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
582 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
511 if ping["fmAvailable"] and schema_path("name.json"):
583 if ping["fmAvailable"] and schema_path("name.json"):
@@ -615,7 +687,9 @@ def register(host_path):
615 f.write("\n".join(written) + "\n")
687 f.write("\n".join(written) + "\n")
616 except OSError:
688 except OSError:
617 pass
689 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.
618− return 1 if failures else 0
692+ return 0 if len(dirs) - failures > 0 else 1
619
693
620
694
621def unregister():
695def unregister():
host/prompts.py+64−81
@@ -1,4 +1,9 @@
1−"""Section 9 prompt templates. Pure functions: payload in, prompt text out."""
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
2from urllib.parse import urlsplit
7from urllib.parse import urlsplit
3
8
4from validate import COLORS, all_emoji
9from validate import COLORS, all_emoji
@@ -6,6 +11,12 @@ from validate import COLORS, all_emoji
6EMOJI_LIST = " ".join(all_emoji())
11EMOJI_LIST = " ".join(all_emoji())
7STRICT_LINE = "Reply with only the object, nothing before or after it."
12STRICT_LINE = "Reply with only the object, nothing before or after it."
8
13
14+Prompt = namedtuple("Prompt", "instructions text")
15+
16+
17+def size(p):
18+ return len(p.instructions) + len(p.text)
19+
9
20
10def _clean(s, limit):
21def _clean(s, limit):
11 s = " ".join(str(s or "").split())
22 s = " ".join(str(s or "").split())
@@ -26,10 +37,10 @@ def render_address(url):
26 return _clean(host + path + query, 160)
37 return _clean(host + path + query, 160)
27
38
28
39
29−def render_items(items):
40+def render_items(items, desc_cap=500):
30 lines = []
41 lines = []
31 for it in items:
42 for it in items:
32− desc = _clean(it.get("description"), 300) or "-"
43+ desc = _clean(it.get("description"), desc_cap) or "-"
33 lines.append(f"{it['i']} | {_clean(it.get('title'), 120)} | {render_address(it.get('url', ''))} | {desc}")
44 lines.append(f"{it['i']} | {_clean(it.get('title'), 120)} | {render_address(it.get('url', ''))} | {desc}")
34 return "\n".join(lines)
45 return "\n".join(lines)
35
46
@@ -38,94 +49,66 @@ def _quoted(titles):
38 return ", ".join(f'"{_clean(t, 60)}"' for t in titles)
49 return ", ".join(f'"{_clean(t, 60)}"' for t in titles)
39
50
40
51
41−def build_name_prompt(payload, strict=False):
52+def build_name_prompt(payload, strict=False, desc_cap=500):
42 rules = [
53 rules = [
43 "- Title: 2 to 4 words, sentence case, no punctuation at the end, no quotes.",
54 "- Title: 2 to 4 words, sentence case, no punctuation at the end, no quotes.",
44 "- Describe the topic or task, not the website, unless every tab is the same site.",
55 "- Describe the topic or task, not the website, unless every tab is the same site.",
45 "- Never use the words tab, tabs, group, misc, various, stuff.",
56 "- Never use the words tab, tabs, group, misc, various, stuff.",
46 "- Write the title in the language most tab titles use.",
57 "- Write the title in the language most tab titles use.",
47 f"- Emoji: exactly one, chosen from this list: {EMOJI_LIST}",
58 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.",
48 ]
61 ]
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 = []
49 current = payload.get("currentTitle")
69 current = payload.get("currentTitle")
50 if current:
70 if current:
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.')
71+ context.append(f'Current title: "{_clean(current, 60)}"')
52 siblings = payload.get("siblingTitles") or []
72 siblings = payload.get("siblingTitles") or []
53 if siblings:
73 if siblings:
54− rules.append(f"- Other groups in this window are named: {_quoted(siblings)}. Do not reuse those names.")
74+ context.append(f"Other groups in this window: {_quoted(siblings)}")
55 differ = payload.get("mustDifferFrom") or []
75 differ = payload.get("mustDifferFrom") or []
56 if differ:
76 if differ:
57− rules.append(f"- The title must not be any of: {_quoted(differ)}.")
77+ context.append(f"Not allowed: {_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"
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"
64− )
79+ return Prompt(instructions, text)
65−
80+
66−
81+
67−def _organize_rules(max_groups, strict):
82+# Organize asks for one broad topic per tab and the host groups tabs with the same topic. On fm (macOS 27)
68− rules = [
83+# this was reliable where asking the model for whole groups was not: it set "existing" on nearly every
69− "- Put each tab index into exactly one group, or into leftovers if it fits nowhere.",
84+# new group, and specific topics gave every tab its own.
70− f"- A group needs at least 2 tabs. Prefer fewer, broader groups; at most {max_groups} new groups.",
85+TOPICS_INSTRUCTIONS = (
71− '- Reuse an existing group when a tab fits it: set "existing" to that group\'s number and leave title, emoji and color empty.',
86+ "You label browser tabs with broad topics so that related tabs can be grouped.\n\n"
72− '- For a new group, leave "existing" out.',
87+ "Rules:\n"
73− f"- New groups: title of 2 to 4 words in sentence case, one emoji from: {EMOJI_LIST}, one color from: {', '.join(COLORS)}.",
88+ "- Give every listed index exactly one topic of one or two words.\n"
74− "- Group by topic or task, not by website, unless the tabs are the same site and nothing else links them.",
89+ "- Keep topics as general as possible, for example Programming, Travel, Cooking, Shopping, News, Finance, Music, Health, Work.\n"
75− "- Write titles in the language most tab titles use.",
90+ "- Use exactly the same topic words for tabs that belong together.\n"
76− ]
91+ "- Group by what the page is about, not by website.\n"
77− if strict:
92+ "- Write topics in English."
78− rules.append(f"- {STRICT_LINE}")
93+)
79− return rules
94+
80−
95+
81−
96+def topic_items(payload, desc_cap=500):
82−def _existing_block(existing):
97+ """The tabs to sort, then up to two example tabs of each existing group, numbered after them.
83− if not existing:
98+ Returns (lines, owner) where owner maps an example's index to its group number."""
84− return ""
99+ items = payload["items"]
85− 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]
100+ lines = [render_items(items, desc_cap)] if items else []
86− return "Existing groups (number | title | examples):\n" + "\n".join(lines) + "\n\n"
101+ owner = {}
87−
102+ n = len(items)
88−
103+ for g in payload.get("existingGroups") or []:
89−def build_organize_prompt(payload, strict=False):
104+ for sample in (g.get("samples") or [])[:2]:
90− return (
105+ owner[n] = g["g"]
91− "You sort browser tabs into topic groups.\n\n"
106+ lines.append(f"{n} | {_clean(sample, 120)} | - | -")
92− "Rules:\n" + "\n".join(_organize_rules(payload.get("maxGroups", 8), strict)) + "\n\n"
107+ n += 1
93− + _existing_block(payload.get("existingGroups") or [])
108+ return "\n".join(lines), owner
94− + "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
109+
95− )
110+
96−
111+def build_topics_prompt(payload, strict=False, desc_cap=500):
97−
112+ instructions = TOPICS_INSTRUCTIONS + (f"\n- {STRICT_LINE}" if strict else "")
98−# Two-call fallback (section 8) for an `fm schema` that cannot nest arrays of objects.
113+ body, _ = topic_items(payload, desc_cap)
99−
114+ return Prompt(instructions, "Tabs (index | title | address | what the page says):\n" + body + "\n")
100−def build_labels_prompt(payload, strict=False):
101− rules = [
102− f"- Propose at most {payload.get('maxGroups', 8)} new topic groups for tabs that do not fit an existing group.",
103− "- 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.",
104− f"- Emoji from: {EMOJI_LIST}",
105− f"- Color from: {', '.join(COLORS)}.",
106− "- Group by topic or task, not by website. Write labels in the language most tab titles use.",
107− ]
108− if strict:
109− rules.append(f"- {STRICT_LINE}")
110− return (
111− "You plan topic groups for browser tabs.\n\nRules:\n" + "\n".join(rules) + "\n\n"
112− + _existing_block(payload.get("existingGroups") or [])
113− + "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
114− )
115−
116−
117−def build_assign_prompt(payload, labels, strict=False):
118− existing = payload.get("existingGroups") or []
119− options = [f"{n} | {_clean(g.get('title'), 60)}" for n, g in enumerate(existing)]
120− options += [f"{len(existing) + n} | {_clean(label, 60)}" for n, label in enumerate(labels)]
121− rules = [
122− f"- assignment has exactly {len(payload['items'])} numbers, one per tab in index order.",
123− "- Each number is the group that tab belongs to, or -1 if it fits none.",
124− ]
125− if strict:
126− rules.append(f"- {STRICT_LINE}")
127− return (
128− "You assign browser tabs to groups.\n\nRules:\n" + "\n".join(rules) + "\n\n"
129− "Groups (number | title):\n" + "\n".join(options) + "\n\n"
130− "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
131− )
host/tests/fake_fm.py+5
@@ -6,6 +6,7 @@ control.json keys:
6 available: {"stdout": str, "stderr": str, "exit": int} for `fm available`
6 available: {"stdout": str, "stderr": str, "exit": int} for `fm available`
7 schema_nested: false → `fm schema` with --object fails (simulates no nested-array support)
7 schema_nested: false → `fm schema` with --object fails (simulates no nested-array support)
8 schema_fail: true → every `fm schema` fails
8 schema_fail: true → every `fm schema` fails
9+ count_tokens: {"stdout": str, "exit": int} for `fm count-tokens` (default: stdin length / 4)
9 license: true → every command exits 69 with the terms notice, as fm does before `sudo fm license`
10 license: true → every command exits 69 with the terms notice, as fm does before `sudo fm license`
10Every invocation's argv is appended to argv.log as one JSON line; `fm respond` also appends its stdin
11Every invocation's argv is appended to argv.log as one JSON line; `fm respond` also appends its stdin
11(the prompt) to stdin.log. Like the real fm: `--object` must be followed by `--schema <json>`, and the only
12(the prompt) to stdin.log. Like the real fm: `--object` must be followed by `--schema <json>`, and the only
@@ -41,6 +42,10 @@ def main():
41 spec = {"exit": 0 if agreed else 1}
42 spec = {"exit": 0 if agreed else 1}
42 elif control.get("license"):
43 elif control.get("license"):
43 spec = {"stderr": LICENSE_NOTICE, "exit": 69}
44 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})
44 elif cmd == "available":
49 elif cmd == "available":
45 spec = control.get("available", {"stdout": "available", "exit": 0})
50 spec = control.get("available", {"stdout": "available", "exit": 0})
46 elif "--model" in argv and argv[argv.index("--model") + 1] != "system":
51 elif "--model" in argv and argv[argv.index("--model") + 1] != "system":
host/tests/golden/labels.jsonnew file+102
@@ -0,0 +1,102 @@
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+5−1
@@ -1,3 +1,4 @@
1+[instructions]
1You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
2You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
2
3
3Rules:
4Rules:
@@ -6,8 +7,11 @@ Rules:
6- Never use the words tab, tabs, group, misc, various, stuff.
7- Never use the words tab, tabs, group, misc, various, stuff.
7- Write the title in the language most tab titles use.
8- Write the title in the language most tab titles use.
8- Emoji: exactly one, chosen from this list: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄
9- 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
12
13+[stdin]
10−Tabs (index | title | address | description):
14+Tabs (index | title | address | what the page says):
110 | tokio 0 - docs.rs | docs.rs/tokio/latest/tokio/?search=x0&utm_source=y | Documentation for the tokio crate, part 0.
150 | tokio 0 - docs.rs | docs.rs/tokio/latest/tokio/?search=x0&utm_source=y | Documentation for the tokio crate, part 0.
121 | serde 1 - docs.rs | docs.rs/serde/latest/serde/?search=x1&utm_source=y | Documentation for the serde crate, part 1.
161 | serde 1 - docs.rs | docs.rs/serde/latest/serde/?search=x1&utm_source=y | Documentation for the serde crate, part 1.
132 | axum 2 - docs.rs | docs.rs/axum/latest/axum/?search=x2&utm_source=y | Documentation for the axum crate, part 2.
172 | 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+8−3
@@ -1,3 +1,4 @@
1+[instructions]
1You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
2You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
2
3
3Rules:
4Rules:
@@ -6,10 +7,14 @@ Rules:
6- Never use the words tab, tabs, group, misc, various, stuff.
7- Never use the words tab, tabs, group, misc, various, stuff.
7- Write the title in the language most tab titles use.
8- Write the title in the language most tab titles use.
8- Emoji: exactly one, chosen from this list: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄
9- Emoji: exactly one, chosen from this list: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄
9−- Current title: "Rust async". Keep it exactly if it still fits; change it only if the tabs have clearly moved on.
10+- If a current title is given, 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.
11+- Never reuse a name listed under other groups or not allowed.
11
12
13+[stdin]
14+Current title: "Rust async"
15+Other groups in this window: "Lisbon trip"
16+
12−Tabs (index | title | address | description):
17+Tabs (index | title | address | what the page says):
130 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
180 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
141 | async-std | async.rs | Async version of the Rust standard library.
191 | async-std | async.rs | Async version of the Rust standard library.
152 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
202 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
host/tests/golden/organize-3.txtdeleted−18
@@ -1,18 +0,0 @@
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−- For a new group, leave "existing" out.
8−- 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.
9−- Group by topic or task, not by website, unless the tabs are the same site and nothing else links them.
10−- Write titles in the language most tab titles use.
11−
12−Existing groups (number | title | examples):
13−0 | Lisbon trip | Hotels; Flights
14−
15−Tabs (index | title | address | description):
16−0 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
17−1 | async-std | async.rs | Async version of the Rust standard library.
18−2 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
host/tests/golden/topics-3.txtnew file+17
@@ -0,0 +1,17 @@
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−1
@@ -60,7 +60,7 @@ class HostCase(unittest.TestCase):
60 os.chmod(self.fm, 0o755)
60 os.chmod(self.fm, 0o755)
61 self.support = os.path.join(self.tmp, "support")
61 self.support = os.path.join(self.tmp, "support")
62 os.makedirs(os.path.join(self.support, "schemas"))
62 os.makedirs(os.path.join(self.support, "schemas"))
63− for name in ("name.json", "organize.json"):
63+ for name in ("name.json", "topics.json"):
64 with open(os.path.join(self.support, "schemas", name), "w") as f:
64 with open(os.path.join(self.support, "schemas", name), "w") as f:
65 f.write("{}")
65 f.write("{}")
66 with open(os.path.join(self.support, "schemas", "version"), "w") as f:
66 with open(os.path.join(self.support, "schemas", "version"), "w") as f:
host/tests/test_host.py+196−55
@@ -77,7 +77,7 @@ class Ping(HostCase):
77 self.assertEqual(r["result"]["fmPath"], self.fm)
77 self.assertEqual(r["result"]["fmPath"], self.fm)
78 self.assertTrue(r["result"]["fmAvailable"])
78 self.assertTrue(r["result"]["fmAvailable"])
79 self.assertTrue(r["result"]["schemasOk"])
79 self.assertTrue(r["result"]["schemasOk"])
80− self.assertEqual(r["result"]["organizeMode"], "nested")
80+ self.assertEqual(r["result"]["organizeMode"], "topics")
81
81
82 def test_ping_model_unavailable(self):
82 def test_ping_model_unavailable(self):
83 self.control({"available": {"stderr": "Apple Intelligence is not enabled", "exit": 1}})
83 self.control({"available": {"stderr": "Apple Intelligence is not enabled", "exit": 1}})
@@ -107,7 +107,7 @@ class Ping(HostCase):
107 r = self.call("ping")
107 r = self.call("ping")
108 self.assertTrue(r["ok"], r)
108 self.assertTrue(r["ok"], r)
109 self.assertTrue(r["result"]["schemasOk"])
109 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", "organize.json"])
110+ self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "topics.json"])
111
111
112 def test_ping_without_fm(self):
112 def test_ping_without_fm(self):
113 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
113 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
@@ -124,9 +124,12 @@ class Name(HostCase):
124 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
124 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
125 self.assertGreater(r["meta"]["promptChars"], 0)
125 self.assertGreater(r["meta"]["promptChars"], 0)
126 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
126 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
127− self.assertEqual(argv, ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json")])
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)
128− # The prompt goes on stdin, never into argv where `ps` would show tab titles.
130+ # Tab text goes on stdin, never into argv where `ps` would show it.
129 self.assertIn("tokio.rs", self.stdin_log()[0])
131 self.assertIn("tokio.rs", self.stdin_log()[0])
132+ self.assertNotIn("tokio", " ".join(argv))
130
133
131 def test_json_wrapped_in_prose_and_fence(self):
134 def test_json_wrapped_in_prose_and_fence(self):
132 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
135 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
@@ -200,12 +203,52 @@ class ErrorCodes(HostCase):
200 def test_over_budget_from_fm(self):
203 def test_over_budget_from_fm(self):
201 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
204 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
202
205
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+
203 def test_over_budget_precheck_reports_allowed_items(self):
246 def test_over_budget_precheck_reports_allowed_items(self):
204− big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(60)]
247+ big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(120)]
205 r = self.call("name", {"items": big})
248 r = self.call("name", {"items": big})
206 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
249 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
207 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
250 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
208− self.assertLess(r["error"]["allowedItems"], 60)
251+ self.assertLess(r["error"]["allowedItems"], 120)
209 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
252 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
210
253
211 def test_guardrail(self):
254 def test_guardrail(self):
@@ -240,8 +283,21 @@ class ErrorCodes(HostCase):
240 def test_strict_retry_appends_line(self):
283 def test_strict_retry_appends_line(self):
241 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
284 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
242 self.call("name", {"items": ITEMS3}, strict=True)
285 self.call("name", {"items": ITEMS3}, strict=True)
243− prompt = self.stdin_log()[0]
286+ argv = [a for a in self.argv_log() if a[0] == "respond"][0]
244− self.assertIn("Reply with only the object", prompt)
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)
245
301
246
302
247class Organize(HostCase):
303class Organize(HostCase):
@@ -249,66 +305,142 @@ class Organize(HostCase):
249 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
305 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
250 {"title": "Lisbon travel guide", "url": "https://lonelyplanet.com/portugal/lisbon"},
306 {"title": "Lisbon travel guide", "url": "https://lonelyplanet.com/portugal/lisbon"},
251 {"title": "Random page", "url": "https://example.org/"},
307 {"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"},
252 ]
310 ]
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]}))
253
315
254− def test_nested_path(self):
316+ def test_tabs_with_one_topic_form_groups_and_join_existing_ones(self):
255− self.respond(json.dumps({"groups": [
317+ # Indexes 8 and 9 are the existing group's example tabs (numbered after the 8 tabs to sort).
256− {"title": "Rust async", "emoji": "🦀", "color": "orange", "existing": -1, "members": [0, 1, 2]},
318+ self.topics([(0, "Programming"), (1, "programming"), (2, "Programming."), (3, "Travel"), (4, "travel"),
257− {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]},
319+ (5, "Other"), (6, "Cooking"), (7, "Cooking"), (8, "Travel"), (9, "Travel")])
258− ], "leftovers": [5]}))
259− r = self.call("organize", {"items": self.ITEMS, "existingGroups": [{"g": 0, "title": "Lisbon trip"}], "maxGroups": 2})
320+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
260 self.assertTrue(r["ok"], r)
321 self.assertTrue(r["ok"], r)
322+ self.assertEqual(r["meta"]["path"], "topics")
323+ groups = r["result"]["groups"]
261− self.assertEqual(r["result"]["groups"][0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
324+ self.assertEqual(groups[0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
262− self.assertEqual(r["result"]["groups"][1]["title"], "Rust async")
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
263 self.assertEqual(r["result"]["leftovers"], [5])
328 self.assertEqual(r["result"]["leftovers"], [5])
264− self.assertEqual(r["meta"]["path"], "nested")
265
329
266− def test_two_call_fallback(self):
330+ def test_new_groups_are_named_right_away(self):
267− os.remove(os.path.join(self.support, "schemas", "organize.json"))
268− for name in ("organize-labels.json", "organize-assign.json"):
269− open(os.path.join(self.support, "schemas", name), "w").write("{}")
270 self.control({"respond_queue": [
331 self.control({"respond_queue": [
271− {"stdout": json.dumps({"labels": ["Rust async"], "emojis": ["🦀"], "colors": ["orange"]})},
332+ {"stdout": json.dumps({"tabs": [{"index": i, "topic": t} for i, t in
272− {"stdout": json.dumps({"assignment": [1, 1, 1, 0, 0, -1]})},
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},
273 ]})
337 ]})
274− r = self.call("organize", {"items": self.ITEMS, "existingGroups": [{"g": 0, "title": "Lisbon trip"}], "maxGroups": 2})
338+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
275 self.assertTrue(r["ok"], r)
339 self.assertTrue(r["ok"], r)
276− self.assertEqual(r["meta"]["path"], "two-call")
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):
348+ self.control({"respond_queue": [
349+ {"stdout": json.dumps({"tabs": [{"index": i, "topic": "Programming"} for i in (0, 1, 2)]})},
350+ {"stdout": json.dumps({"title": "Rust async", "emoji": "🦀"})},
351+ ]})
352+ r = self.call("organize", {"items": self.ITEMS, "siblingTitles": ["rust async"]})
353+ self.assertEqual(r["result"]["groups"][0]["title"], "")
354+ self.assertIn('"rust async"', self.stdin_log()[1]) # the name call was told, too
355+
356+ def test_a_topic_two_groups_share_equally_joins_neither_unless_the_words_say_which(self):
357+ existing = [{"g": 0, "title": "Lisbon trip", "samples": ["Flights to Lisbon"]},
358+ {"g": 1, "title": "Porto trip", "samples": ["Porto restaurants"]}]
359+ # Examples 8 (group 0) and 9 (group 1) both got Travel: a tie.
360+ self.topics([(3, "Travel"), (4, "Travel"), (8, "Travel"), (9, "Travel")])
361+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": existing})
362+ self.assertEqual([g.get("existing") for g in r["result"]["groups"]], [0]) # "Lisbon" is in both tabs' titles
363+ items = [{"title": "Beach day", "url": "https://a.example/"}, {"title": "City walk", "url": "https://b.example/"}]
364+ self.topics([(0, "Travel"), (1, "Travel"), (2, "Travel"), (3, "Travel")])
365+ r = self.call("organize", {"items": items, "existingGroups": existing})
366+ self.assertNotIn("existing", r["result"]["groups"][0])
367+
368+ def test_only_vague_topics_leave_every_tab_loose(self):
369+ self.topics([(0, "Other"), (1, "Misc"), (2, "General")])
370+ r = self.call("organize", {"items": ITEMS3})
371+ self.assertTrue(r["ok"], r)
372+ self.assertEqual(r["result"], {"groups": [], "leftovers": [0, 1, 2]})
373+
374+ def test_two_topics_joining_one_group_make_one_join(self):
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})
277− groups = {g.get("existing", -1): g for g in r["result"]["groups"]}
378+ joins = [g for g in r["result"]["groups"] if "existing" in g]
278− self.assertEqual(groups[0]["members"], [3, 4])
379+ self.assertEqual(joins, [{"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]}])
279− self.assertEqual(groups[-1]["title"], "Rust async")
380+
280− self.assertEqual(groups[-1]["members"], [0, 1, 2])
381+ def test_fm_answers_greedily(self):
281− self.assertEqual(r["result"]["leftovers"], [5])
382+ self.topics([(0, "Programming")])
282− prompts_sent = self.stdin_log()
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})
389+ self.assertTrue(r["ok"], r)
283− self.assertEqual(len(prompts_sent), 2)
390+ self.assertEqual(len([a for a in self.argv_log() if a[0] == "respond"]), 1)
284− self.assertIn("1 | Rust async", prompts_sent[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)
285
419
286
420
287class InstallSchemas(HostCase):
421class InstallSchemas(HostCase):
288− def test_nested_supported(self):
422+ def test_writes_name_and_topics(self):
289 for f in os.listdir(os.path.join(self.support, "schemas")):
423 for f in os.listdir(os.path.join(self.support, "schemas")):
290 os.remove(os.path.join(self.support, "schemas", f))
424 os.remove(os.path.join(self.support, "schemas", f))
291 code, _, p = self.run_host(args=["--install-schemas"])
425 code, _, p = self.run_host(args=["--install-schemas"])
292 self.assertEqual(code, 0, p.stderr)
426 self.assertEqual(code, 0, p.stderr)
293− self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "organize.json"])
427+ self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "topics.json", "version"])
294
428
295 def test_nested_object_gets_its_schema_from_fm_first(self):
429 def test_nested_object_gets_its_schema_from_fm_first(self):
296− # Real fm: `--object groups` must be followed by `--schema <json>` for the group's own schema.
430+ # Real fm: `--object tabs` must be followed by `--schema <json>` for the item's own schema.
297 for f in os.listdir(os.path.join(self.support, "schemas")):
431 for f in os.listdir(os.path.join(self.support, "schemas")):
298 os.remove(os.path.join(self.support, "schemas", f))
432 os.remove(os.path.join(self.support, "schemas", f))
299 code, _, p = self.run_host(args=["--install-schemas"])
433 code, _, p = self.run_host(args=["--install-schemas"])
300 self.assertEqual(code, 0, p.stderr)
434 self.assertEqual(code, 0, p.stderr)
301 calls = [a for a in self.argv_log() if a[0] == "schema"]
435 calls = [a for a in self.argv_log() if a[0] == "schema"]
302− group = next(a for a in calls if "Group" in a)
436+ self.assertTrue(any("TabTopic" in a for a in calls))
303− self.assertIn("--optional", group) # "existing" is left out for new groups
304− organize = next(a for a in calls if "Organized" in a)
437+ topics = next(a for a in calls if "Topics" in a)
305− i = organize.index("--object")
438+ i = topics.index("--object")
306− self.assertEqual(organize[i + 1:i + 3], ["groups", "--schema"])
439+ self.assertEqual(topics[i + 1:i + 3], ["tabs", "--schema"])
307− self.assertIn('"args"', organize[i + 3]) # the fake fm's output for the Group schema
440+ self.assertIn('"args"', topics[i + 3]) # the fake fm's output for the TabTopic schema
308− self.assertEqual(organize[i + 4], "--array")
441+ self.assertEqual(topics[i + 4], "--array")
309−
442+
310− def test_schemas_from_an_older_host_are_rewritten(self):
443+ def test_schemas_from_an_older_host_are_replaced(self):
311− # Hosts before schema version 2 could not build the nested schema on real fm and fell back to two calls.
312 d = os.path.join(self.support, "schemas")
444 d = os.path.join(self.support, "schemas")
313 for f in os.listdir(d):
445 for f in os.listdir(d):
314 os.remove(os.path.join(d, f))
446 os.remove(os.path.join(d, f))
@@ -316,15 +448,8 @@ class InstallSchemas(HostCase):
316 open(os.path.join(d, name), "w").write("{}")
448 open(os.path.join(d, name), "w").write("{}")
317 r = self.call("ping")
449 r = self.call("ping")
318 self.assertTrue(r["result"]["schemasOk"], r)
450 self.assertTrue(r["result"]["schemasOk"], r)
319− self.assertEqual(r["result"]["organizeMode"], "nested")
451+ self.assertEqual(r["result"]["organizeMode"], "topics")
320− self.assertEqual(open(os.path.join(d, "version")).read().strip(), host.SCHEMA_VERSION)
321−
322− def test_falls_back_to_two_flat_schemas(self):
323− self.control({"schema_nested": False})
324− code, _, p = self.run_host(args=["--install-schemas"])
325− self.assertEqual(code, 0, p.stderr)
326− self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "organize-assign.json", "organize-labels.json"])
452+ self.assertEqual(sorted(os.listdir(d)), ["name.json", "topics.json", "version"])
327−
328
453
329 def test_first_use_writes_missing_schemas(self):
454 def test_first_use_writes_missing_schemas(self):
330 for f in os.listdir(os.path.join(self.support, "schemas")):
455 for f in os.listdir(os.path.join(self.support, "schemas")):
@@ -402,6 +527,15 @@ class Register(HostCase):
402 self.assertEqual(p.returncode, 1)
527 self.assertEqual(p.returncode, 1)
403 self.assertIn("missing or not executable", p.stderr)
528 self.assertIn("missing or not executable", p.stderr)
404
529
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+
405 def test_no_browser_found(self):
539 def test_no_browser_found(self):
406 os.makedirs(self.env["DIAGONAL_APP_SUPPORT"])
540 os.makedirs(self.env["DIAGONAL_APP_SUPPORT"])
407 p = self.run_copy("--register", self.host_copy)
541 p = self.run_copy("--register", self.host_copy)
@@ -423,6 +557,13 @@ class SelfTest(HostCase):
423 self.assertEqual(code, 1)
557 self.assertEqual(code, 1)
424 self.assertIn("FAIL model available", p.stdout.decode())
558 self.assertIn("FAIL model available", p.stdout.decode())
425
559
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+
426 def test_selftest_names_the_license_fix(self):
567 def test_selftest_names_the_license_fix(self):
427 self.control({"license": True})
568 self.control({"license": True})
428 code, _, p = self.run_host(args=["--selftest"])
569 code, _, p = self.run_host(args=["--selftest"])
host/tests/test_prompts_validate.py+15−6
@@ -1,3 +1,4 @@
1+import json
1import os
2import os
2import unittest
3import unittest
3
4
@@ -17,7 +18,8 @@ def items24():
17
18
18
19
19class Golden(unittest.TestCase):
20class Golden(unittest.TestCase):
20− def check(self, name, text):
21+ def check(self, name, p):
22+ text = "[instructions]\n" + p.instructions + "\n\n[stdin]\n" + p.text
21 path = os.path.join(GOLDEN, name)
23 path = os.path.join(GOLDEN, name)
22 if UPDATE or not os.path.exists(path):
24 if UPDATE or not os.path.exists(path):
23 os.makedirs(GOLDEN, exist_ok=True)
25 os.makedirs(GOLDEN, exist_ok=True)
@@ -27,16 +29,16 @@ class Golden(unittest.TestCase):
27 def test_name_3(self):
29 def test_name_3(self):
28 p = prompts.build_name_prompt({"items": ITEMS3, "currentTitle": "Rust async", "siblingTitles": ["Lisbon trip"]})
30 p = prompts.build_name_prompt({"items": ITEMS3, "currentTitle": "Rust async", "siblingTitles": ["Lisbon trip"]})
29 self.check("name-3.txt", p)
31 self.check("name-3.txt", p)
30− self.assertLessEqual(len(p), 10_000)
32+ self.assertLessEqual(prompts.size(p), 24_000)
31
33
32 def test_name_24(self):
34 def test_name_24(self):
33 p = prompts.build_name_prompt({"items": items24(), "siblingTitles": []})
35 p = prompts.build_name_prompt({"items": items24(), "siblingTitles": []})
34 self.check("name-24.txt", p)
36 self.check("name-24.txt", p)
35− self.assertLessEqual(len(p), 10_000)
37+ self.assertLessEqual(prompts.size(p), 24_000)
36
38
37 def test_organize(self):
39 def test_organize(self):
38− p = prompts.build_organize_prompt({"items": ITEMS3, "existingGroups": [{"g": 0, "title": "Lisbon trip", "samples": ["Hotels", "Flights"]}], "maxGroups": 1})
40+ p = prompts.build_topics_prompt({"items": ITEMS3, "existingGroups": [{"g": 0, "title": "Lisbon trip", "samples": ["Hotels", "Flights"]}], "maxGroups": 1})
39− self.check("organize-3.txt", p)
41+ self.check("topics-3.txt", p)
40
42
41
43
42class Rendering(unittest.TestCase):
44class Rendering(unittest.TestCase):
@@ -46,7 +48,7 @@ class Rendering(unittest.TestCase):
46 idx, title, addr, desc = line.split(" | ")
48 idx, title, addr, desc = line.split(" | ")
47 self.assertLessEqual(len(title), 120)
49 self.assertLessEqual(len(title), 120)
48 self.assertLessEqual(len(addr), 160)
50 self.assertLessEqual(len(addr), 160)
49− self.assertLessEqual(len(desc), 300)
51+ self.assertLessEqual(len(desc), 500)
50 self.assertTrue(addr.startswith("example.com/"))
52 self.assertTrue(addr.startswith("example.com/"))
51
53
52 def test_address(self):
54 def test_address(self):
@@ -72,6 +74,13 @@ class ValidateName(unittest.TestCase):
72 with self.assertRaises(validate.ValidationError, msg=bad):
74 with self.assertRaises(validate.ValidationError, msg=bad):
73 self.v(bad)
75 self.v(bad)
74
76
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+
75 def test_emoji_default(self):
84 def test_emoji_default(self):
76 self.assertEqual(self.v("Rust async", "not-an-emoji")["emoji"], "🧭")
85 self.assertEqual(self.v("Rust async", "not-an-emoji")["emoji"], "🧭")
77 self.assertEqual(self.v("Rust async", "✈")["emoji"], "✈️")
86 self.assertEqual(self.v("Rust async", "✈")["emoji"], "✈️")
host/validate.py+136−34
@@ -7,8 +7,7 @@ from urllib.parse import urlsplit
7HERE = os.path.dirname(os.path.realpath(__file__))
7HERE = os.path.dirname(os.path.realpath(__file__))
8COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]
8COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]
9BANNED_WORDS = {"tab", "tabs", "group", "groups", "misc", "various", "stuff"}
9BANNED_WORDS = {"tab", "tabs", "group", "groups", "misc", "various", "stuff"}
10−QUOTES = "\"'“”‘’«»`"
11−TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—]+$")
10+TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—。、,!?:;]+$")
12
11
13
12
14class ValidationError(Exception):
13class ValidationError(Exception):
@@ -89,34 +88,82 @@ def default_emoji(items):
89 return CATEGORIES[category_for_host(majority)][0]
88 return CATEGORIES[category_for_host(majority)][0]
90
89
91
90
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+
92def repair_label(raw):
97def repair_label(raw):
93− """Trim, drop quotes, banned words and trailing punctuation, cap at 4 words / 28 chars, capitalise."""
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."""
94 if not isinstance(raw, str):
100 if not isinstance(raw, str):
95 return None
101 return None
96− s = raw.translate({ord(q): None for q in QUOTES})
102+ s = _QUOTE.sub("", raw)
97 s = TRAILING_PUNCT.sub("", " ".join(s.split()))
103 s = TRAILING_PUNCT.sub("", " ".join(s.split()))
98 words = [w for w in s.split(" ") if w and re.sub(r"[^\w]", "", w.lower()) not in BANNED_WORDS]
104 words = [w for w in s.split(" ") if w and re.sub(r"[^\w]", "", w.lower()) not in BANNED_WORDS]
99 words = words[:4]
105 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:
100− while len(words) > 2 and len(" ".join(words)) > 28:
112+ while len(words) > 2 and len(" ".join(words)) > 28:
113+ words.pop()
114+ while len(words) > 1 and words[-1].lower() in DANGLING:
101 words.pop()
115 words.pop()
102 label = TRAILING_PUNCT.sub("", " ".join(words))
116 label = TRAILING_PUNCT.sub("", " ".join(words))
103 if not label:
117 if not label:
104 return None
118 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:]):
105− label = label[0].upper() + label[1:]
121+ label = label[0].upper() + label[1:]
106 return label if is_valid_label(label) else None
122 return label if is_valid_label(label) else None
107
123
108
124
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+
109def is_valid_label(label):
151def is_valid_label(label):
110 words = label.split()
152 words = label.split()
153+ wide = _no_space(label)
111 return (
154 return (
112− 3 <= len(label) <= 28
155+ (2 <= len(label) <= 28 and _width(label) <= 16 if wide else 3 <= len(label) <= 28)
113− and 2 <= len(words) <= 4
156+ and (1 <= len(words) <= 4 if wide else 2 <= len(words) <= 4)
114− and not any(q in label for q in QUOTES)
157+ and not _QUOTE.search(label)
115 and not TRAILING_PUNCT.search(label)
158 and not TRAILING_PUNCT.search(label)
116 and not any(w.lower() in BANNED_WORDS for w in words)
159 and not any(w.lower() in BANNED_WORDS for w in words)
117 )
160 )
118
161
119
162
163+def same_label(a, b):
164+ return bool(a) and bool(b) and " ".join(a.split()).casefold() == " ".join(b.split()).casefold()
165+
166+
120def validate_name(out, payload):
167def validate_name(out, payload):
121 if not isinstance(out, dict):
168 if not isinstance(out, dict):
122 raise ValidationError("reply is not an object")
169 raise ValidationError("reply is not an object")
@@ -164,7 +211,12 @@ def validate_organize(out, payload):
164 if isinstance(ex, list): # fm has returned the optional integer as [] or [n]
211 if isinstance(ex, list): # fm has returned the optional integer as [] or [n]
165 ex = ex[0] if len(ex) == 1 else None
212 ex = ex[0] if len(ex) == 1 else None
166 if isinstance(ex, int) and not isinstance(ex, bool) and 0 <= ex < n_existing:
213 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:
167− joins.append({"title": "", "emoji": "", "color": "", "existing": ex, "members": members})
219+ joins.append({"title": "", "emoji": "", "color": "", "existing": ex, "members": members})
168 continue
220 continue
169 new.append((members, g))
221 new.append((members, g))
170
222
@@ -189,30 +241,80 @@ def validate_organize(out, payload):
189 return {"groups": groups, "leftovers": sorted(set(leftovers))}
241 return {"groups": groups, "leftovers": sorted(set(leftovers))}
190
242
191
243
192−def organize_from_two_calls(labels_out, assign_out, payload):
244+VAGUE_TOPICS = {"other", "others", "misc", "miscellaneous", "general", "various", "unknown", "none", "n/a", "-"}
193− """Rebuild the one-call shape from the flat fallback replies, then validate it as usual."""
245+
194− labels = [x for x in (labels_out or {}).get("labels") or [] if isinstance(x, str)]
246+
195− emojis = (labels_out or {}).get("emojis") or []
247+def _norm_topic(t):
196− colors = (labels_out or {}).get("colors") or []
248+ t = " ".join(str(t or "").split()).strip(" .,:;!?\"'").casefold()
197− n_existing = len(payload.get("existingGroups") or [])
249+ return "" if t in VAGUE_TOPICS else t
198− assignment = (assign_out or {}).get("assignment")
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)."""
199− if not isinstance(assignment, list):
256+ if not isinstance(out, dict) or not isinstance(out.get("tabs"), list):
200− raise ValidationError("assignment is not a list")
257+ raise ValidationError("tabs is not a list")
201− buckets = {}
202− for i, target in enumerate(assignment[: len(payload.get("items", []))]):
258+ n_items = len(payload.get("items", []))
203− if isinstance(target, int) and not isinstance(target, bool) and 0 <= target < n_existing + len(labels):
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):
204− buckets.setdefault(target, []).append(i)
278+ clusters.setdefault(tab_topic[i], []).append(i)
205 groups = []
279 groups = []
206− for target, members in sorted(buckets.items()):
280+ for topic, members in clusters.items():
207− if target < n_existing:
281+ g = _winner(group_votes.get(topic) or {}, members, payload)
282+ if g is not None:
208− groups.append({"existing": target, "members": members})
283+ groups.append({"existing": g, "members": members})
209 else:
284 else:
210− k = target - n_existing
211− groups.append({
212− "title": labels[k],
213− "emoji": emojis[k] if k < len(emojis) else "",
214− "color": colors[k] if k < len(colors) else "",
215− "existing": -1,
216− "members": members,
285+ groups.append({"title": "", "emoji": "", "color": "", "members": members})
217− })
218 return validate_organize({"groups": groups, "leftovers": []}, payload)
286 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+11−2
@@ -53,7 +53,16 @@ echo "host: $BIN -> $SHARE/diagonal-host.py"
53"$BIN" --version >/dev/null # the host must start with the python3 it was pinned to
53"$BIN" --version >/dev/null # the host must start with the python3 it was pinned to
54# Writes the manifest into every Chromium browser's NativeMessagingHosts folder (Chrome, Brave and
54# Writes the manifest into every Chromium browser's NativeMessagingHosts folder (Chrome, Brave and
55# Brave Origin, Edge, Vivaldi, Arc, Opera, …) and checks each one the way the browser reads it.
55# Brave Origin, Edge, Vivaldi, Arc, Opera, …) and checks each one the way the browser reads it.
56+# A browser that was never opened has no folder yet: the rest of the install still runs, and the end
57+# says how to finish.
58+register_rc=0
56−"$BIN" --register "$BIN"
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
57
66
58FM="${DIAGONAL_FM:-/usr/bin/fm}"
67FM="${DIAGONAL_FM:-/usr/bin/fm}"
59
68
@@ -89,7 +98,7 @@ fi
89# Diagonal also writes these on first use; doing it now lets the self-test below cover the model.
98# Diagonal also writes these on first use; doing it now lets the self-test below cover the model.
90schemas_out=$("$BIN" --install-schemas 2>&1) || true
99schemas_out=$("$BIN" --install-schemas 2>&1) || true
91if selftest_out=$("$BIN" --selftest 2>&1); then
100if selftest_out=$("$BIN" --selftest 2>&1); then
92− echo "Diagonal's host is ready."
101+ [[ $register_rc -ne 0 ]] || echo "Diagonal's host is ready."
93else
102else
94 echo "Diagonal's host is installed, but its self-test found a problem:"
103 echo "Diagonal's host is installed, but its self-test found a problem:"
95 echo "$schemas_out"
104 echo "$schemas_out"
src/background/engine.ts+137−6
@@ -1,8 +1,9 @@
1import type { GroupColor } from "../shared/colors";
1import type { GroupColor } from "../shared/colors";
2import { isColor } from "../shared/colors";
2import { isColor } from "../shared/colors";
3import { colorFor, isInternalUrl, parse, provisionalTitle, trimText } from "../shared/url";
3import { colorFor, isInternalUrl, parse, provisionalTitle, trimText } from "../shared/url";
4+import { titleKey } from "../shared/label";
4import type { Settings } from "./settings";
5import type { Settings } from "./settings";
5−import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type State, type TabRecord } from "./state";
6+import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type RemovedGroup, type State, type TabRecord } from "./state";
6
7
7/**
8/**
8 * Section 5: the structural rules as a pure function `(State, Event) → (State, Action[])`.
9 * Section 5: the structural rules as a pure function `(State, Event) → (State, Action[])`.
@@ -50,7 +51,8 @@ export type Action =
50 | { type: "scheduleDissolve"; groupId: number; delayMs: number }
51 | { type: "scheduleDissolve"; groupId: number; delayMs: number }
51 | { type: "dirty"; groupId: number }
52 | { type: "dirty"; groupId: number }
52 | { type: "loose"; windowId: number }
53 | { type: "loose"; windowId: number }
53− | { type: "unpark"; tabId: number; groupId: number };
54+ | { type: "unpark"; tabId: number; groupId: number }
55+ | { type: "checkFit" };
54
56
55export interface Ctx {
57export interface Ctx {
56 now: number;
58 now: number;
@@ -60,6 +62,10 @@ export interface Ctx {
60export const DISSOLVE_DELAY_MS = 1500;
62export const DISSOLVE_DELAY_MS = 1500;
61export const OWN_CREATE_WINDOW_MS = 2000;
63export const OWN_CREATE_WINDOW_MS = 2000;
62export const OWN_WRITE_WINDOW_MS = 60_000;
64export 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;
63
69
64/** The pure form: never touches `prev`. */
70/** The pure form: never touches `prev`. */
65export function step(prev: State, event: EngineEvent, ctx: Ctx): { state: State; actions: Action[] } {
71export function step(prev: State, event: EngineEvent, ctx: Ctx): { state: State; actions: Action[] } {
@@ -84,8 +90,16 @@ type Handlers = { [K in EngineEvent["type"]]: Handler<Extract<EngineEvent, { typ
84const handlers: Handlers = {
90const handlers: Handlers = {
85 tabCreated(s, { tab, opener }, ctx, out) {
91 tabCreated(s, { tab, opener }, ctx, out) {
86 if (tab.incognito) return;
92 if (tab.incognito) return;
93+ const known = !!s.tabs[tab.id];
87 const rec = upsertTab(s, tab, ctx.now);
94 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;
88− if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
101+ dirty(s, rec.groupId, ctx, out);
102+ }
89 const before = out.length;
103 const before = out.length;
90 openerRule(s, tab, opener ?? snapshotOf(s, tab.openerTabId), ctx, out);
104 openerRule(s, tab, opener ?? snapshotOf(s, tab.openerTabId), ctx, out);
91 if (out.length === before) loose(rec, ctx, out);
105 if (out.length === before) loose(rec, ctx, out);
@@ -100,11 +114,33 @@ const handlers: Handlers = {
100 else loose(rec, ctx, out);
114 else loose(rec, ctx, out);
101 return;
115 return;
102 }
116 }
103− const titleChanged = before.title !== rec.title;
117+ const titleChanged = titleKey(before.title) !== titleKey(rec.title); // not an unread count ticking over
104 const pathChanged = pathKey(before.url) !== pathKey(rec.url);
118 const pathChanged = pathKey(before.url) !== pathKey(rec.url);
105 if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
119 if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
106 if (before.groupId !== rec.groupId) {
120 if (before.groupId !== rec.groupId) {
107 left(s, before.groupId, ctx, out);
121 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+ }
108 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
144 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
109 else if (before.groupId !== -1) {
145 else if (before.groupId !== -1) {
110 // Out of a group: ours if the worker dissolved it, otherwise the user's choice to keep it loose.
146 // Out of a group: ours if the worker dissolved it, otherwise the user's choice to keep it loose.
@@ -122,6 +158,10 @@ const handlers: Handlers = {
122 if (titleChanged || pathChanged || loaded || before.pinned !== rec.pinned) loose(rec, ctx, out);
158 if (titleChanged || pathChanged || loaded || before.pinned !== rec.pinned) loose(rec, ctx, out);
123 return;
159 return;
124 }
160 }
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" });
125 if (titleChanged || pathChanged) dirty(s, rec.groupId, ctx, out);
165 if (titleChanged || pathChanged) dirty(s, rec.groupId, ctx, out);
126 else if (before.status !== "complete" && rec.status === "complete" && s.groups[rec.groupId]?.dirty) {
166 else if (before.status !== "complete" && rec.status === "complete" && s.groups[rec.groupId]?.dirty) {
127 // A member finished loading: the naming loop may have been waiting for it.
167 // A member finished loading: the naming loop may have been waiting for it.
@@ -140,9 +180,12 @@ const handlers: Handlers = {
140 const rec = s.tabs[tabId];
180 const rec = s.tabs[tabId];
141 if (!rec) return;
181 if (!rec) return;
142 rec.lastActivatedAt = ctx.now;
182 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" });
143 // Using a parked tab means it is not stale: it leaves Parked and auto-organize places it.
185 // Using a parked tab means it is not stale: it leaves Parked and auto-organize places it.
144 if (rec.groupId !== -1 && s.groups[rec.groupId]?.origin === "tidy") {
186 if (rec.groupId !== -1 && s.groups[rec.groupId]?.origin === "tidy") {
145 rec.parkedFrom = undefined;
187 rec.parkedFrom = undefined;
188+ rec.parkedAt = undefined;
146 out.push({ type: "unpark", tabId, groupId: rec.groupId });
189 out.push({ type: "unpark", tabId, groupId: rec.groupId });
147 }
190 }
148 },
191 },
@@ -157,6 +200,15 @@ const handlers: Handlers = {
157 registerGroup(s, group, ctx, out);
200 registerGroup(s, group, ctx, out);
158 return;
201 return;
159 }
202 }
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+ }
160 rec.windowId = group.windowId;
212 rec.windowId = group.windowId;
161 const own = s.ownWrites[group.id];
213 const own = s.ownWrites[group.id];
162 const title = group.title ?? "";
214 const title = group.title ?? "";
@@ -184,15 +236,17 @@ const handlers: Handlers = {
184 }
236 }
185 },
237 },
186
238
187− groupRemoved(s, { groupId }) {
239+ groupRemoved(s, { groupId }, ctx) {
240+ const rec = s.groups[groupId];
188 delete s.groups[groupId];
241 delete s.groups[groupId];
189 delete s.ownWrites[groupId];
242 delete s.ownWrites[groupId];
243+ if (rec) bury(s, rec, ctx.now);
190 },
244 },
191
245
192 meta(s, { tabId, description }, ctx, out) {
246 meta(s, { tabId, description }, ctx, out) {
193 const rec = s.tabs[tabId];
247 const rec = s.tabs[tabId];
194 if (!rec) return;
248 if (!rec) return;
195− const d = trimText(description, 300) || undefined;
249+ const d = trimText(description, 500) || undefined;
196 if (d === rec.description) return;
250 if (d === rec.description) return;
197 rec.description = d;
251 rec.description = d;
198 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
252 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
@@ -236,6 +290,21 @@ function openerRule(s: State, child: TabSnapshot, opener: TabSnapshot | undefine
236export const shouldDissolve = (g: GroupRecord, settings: Settings): boolean =>
290export const shouldDissolve = (g: GroupRecord, settings: Settings): boolean =>
237 g.managed && (g.origin === "opener" || g.origin === "organize") && !g.userNamed && settings.dissolveSingletons;
291 g.managed && (g.origin === "opener" || g.origin === "organize") && !g.userNamed && settings.dissolveSingletons;
238
292
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+
239/** An ungrouped tab changed: auto-organize should look at its window once things settle. */
308/** An ungrouped tab changed: auto-organize should look at its window once things settle. */
240function loose(rec: TabRecord, ctx: Ctx, out: Action[]): void {
309function loose(rec: TabRecord, ctx: Ctx, out: Action[]): void {
241 if (!ctx.settings.autoOrganize || rec.groupId !== -1 || rec.pinned || rec.keepLoose || isInternalUrl(rec.url)) return;
310 if (!ctx.settings.autoOrganize || rec.groupId !== -1 || rec.pinned || rec.keepLoose || isInternalUrl(rec.url)) return;
@@ -274,16 +343,64 @@ function registerGroup(s: State, group: GroupSnapshot, ctx: Ctx, out: Action[]):
274 s.groups[group.id] = newGroupRecord(group.id, group.windowId, pending.origin, color, { stripTitle: group.title ?? "" });
343 s.groups[group.id] = newGroupRecord(group.id, group.windowId, pending.origin, color, { stripTitle: group.title ?? "" });
275 return;
344 return;
276 }
345 }
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+ }
277 const titled = !!group.title;
353 const titled = !!group.title;
278 const rec = newGroupRecord(group.id, group.windowId, "user", color, {
354 const rec = newGroupRecord(group.id, group.windowId, "user", color, {
279 managed: ctx.settings.nameUserGroups,
355 managed: ctx.settings.nameUserGroups,
280 userNamed: titled,
356 userNamed: titled,
281 stripTitle: group.title ?? "",
357 stripTitle: group.title ?? "",
358+ registeredAt: ctx.now,
282 });
359 });
283 s.groups[group.id] = rec;
360 s.groups[group.id] = rec;
284 if (rec.managed && !titled) dirty(s, group.id, ctx, out);
361 if (rec.managed && !titled) dirty(s, group.id, ctx, out);
285}
362}
286
363
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+
287function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
404function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
288 const before = s.tabs[tab.id];
405 const before = s.tabs[tab.id];
289 const url = tab.url || tab.pendingUrl || "";
406 const url = tab.url || tab.pendingUrl || "";
@@ -304,7 +421,12 @@ function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
304 // A description belongs to a page: keep it only while the tab stays on that page.
421 // A description belongs to a page: keep it only while the tab stays on that page.
305 if (before?.description && pathKey(before.url) === pathKey(url)) rec.description = before.description;
422 if (before?.description && pathKey(before.url) === pathKey(url)) rec.description = before.description;
306 if (before?.keepLoose) rec.keepLoose = before.keepLoose;
423 if (before?.keepLoose) rec.keepLoose = before.keepLoose;
424+ if (before?.parkedFrom) rec.parkedFrom = before.parkedFrom;
425+ if (before?.parkedAt) rec.parkedAt = before.parkedAt;
307 if (before?.organizedKey) rec.organizedKey = before.organizedKey;
426 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;
308 s.tabs[tab.id] = rec;
430 s.tabs[tab.id] = rec;
309 return rec;
431 return rec;
310}
432}
@@ -327,4 +449,13 @@ function prune(s: State, now: number): void {
327 s.pendingCreates = s.pendingCreates.filter((p) => now - p.at <= OWN_CREATE_WINDOW_MS);
449 s.pendingCreates = s.pendingCreates.filter((p) => now - p.at <= OWN_CREATE_WINDOW_MS);
328 for (const [id, w] of Object.entries(s.ownWrites)) if (now - w.at > OWN_WRITE_WINDOW_MS) delete s.ownWrites[+id];
450 for (const [id, w] of Object.entries(s.ownWrites)) if (now - w.at > OWN_WRITE_WINDOW_MS) delete s.ownWrites[+id];
329 for (const [id, at] of Object.entries(s.ownUngroups)) if (now - at > OWN_WRITE_WINDOW_MS) delete s.ownUngroups[+id];
451 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+ });
330}
461}
src/background/fit.tsnew file+122
@@ -0,0 +1,122 @@
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+3
@@ -102,6 +102,9 @@ export function normalizeReply<T>(reply: unknown, at: number): HostReply<T> {
102 };
102 };
103}
103}
104
104
105+/** Errors about what one request carried, not about the host: they don't count toward pausing it. */
106+export const REQUEST_ERRORS = new Set<HostErrorCode>(["GUARDRAIL", "BAD_MODEL_OUTPUT", "OVER_BUDGET", "BAD_REQUEST"]);
107+
105/** Errors that mean "fix the setup", not "try this group again later". */
108/** Errors that mean "fix the setup", not "try this group again later". */
106export const SETUP_ERRORS = new Set<HostErrorCode>([
109export const SETUP_ERRORS = new Set<HostErrorCode>([
107 "HOST_NOT_FOUND", "HOST_FORBIDDEN", "FORBIDDEN_ORIGIN", "SCHEMA_MISSING", "MODEL_UNAVAILABLE", "LICENSE_REQUIRED",
110 "HOST_NOT_FOUND", "HOST_FORBIDDEN", "FORBIDDEN_ORIGIN", "SCHEMA_MISSING", "MODEL_UNAVAILABLE", "LICENSE_REQUIRED",
src/background/index.ts+128−36
@@ -1,13 +1,14 @@
1import { stripTitle } from "../shared/label";
1import { stripTitle } from "../shared/label";
2−import { applyEvent, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
2+import { applyEvent, bury, 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";
3+import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, REQUEST_ERRORS, SETUP_ERRORS, type HostOpts, type HostReply, type Op } from "./host";
4import { GLOBAL_PAUSE_AFTER, GLOBAL_PAUSE_MS, Naming, RATE_LIMIT_PAUSES_MS, type Member, type NamePayload, type NameResult } from "./naming";
4import { GLOBAL_PAUSE_AFTER, GLOBAL_PAUSE_MS, Naming, RATE_LIMIT_PAUSES_MS, type Member, type NamePayload, type NameResult } from "./naming";
5import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
5import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
6+import { FitChecker } from "./fit";
6import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
7import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
7import { withDefaults, type Settings } from "./settings";
8import { withDefaults, type Settings } from "./settings";
8−import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type State, type TabRecord } from "./state";
9+import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type RemovedGroup, type State, type TabRecord } from "./state";
9import { readDisk, shouldReload, TRIED_KEY, UPDATE_CHECK_MINUTES } from "./selfupdate";
10import { readDisk, shouldReload, TRIED_KEY, UPDATE_CHECK_MINUTES } from "./selfupdate";
10−import { forgetArchived, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
11+import { adoptParked, forgetArchived, looksParked, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
11
12
12/** Event wiring only: every rule lives in engine / naming / organize / tidy. */
13/** Event wiring only: every rule lives in engine / naming / organize / tidy. */
13
14
@@ -19,6 +20,8 @@ const ALARM_HOST = "host-retry";
19const ALARM_AUTO = "auto-organize";
20const ALARM_AUTO = "auto-organize";
20const ALARM_UPDATE = "self-update";
21const ALARM_UPDATE = "self-update";
21const HOST_RETRY_MS = 600_000;
22const 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();
22
25
23// ----- state cache ------------------------------------------------------------------------------
26// ----- state cache ------------------------------------------------------------------------------
24
27
@@ -90,7 +93,11 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
90 h.lastOkAt = Date.now();
93 h.lastOkAt = Date.now();
91 h.consecutiveFailures = 0;
94 h.consecutiveFailures = 0;
92 h.consecutiveTimeouts = 0;
95 h.consecutiveTimeouts = 0;
93− h.rateLimitStep = 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+ }
94 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
101 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
95 } else {
102 } else {
96 recordHostError(reply.error);
103 recordHostError(reply.error);
@@ -104,6 +111,8 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
104}
111}
105
112
106function recordHostError(e: HostError): void {
113function 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;
107 const h = state.host;
116 const h = state.host;
108 const now = Date.now();
117 const now = Date.now();
109 h.lastError = { ...e, at: now };
118 h.lastError = { ...e, at: now };
@@ -135,11 +144,7 @@ async function ping(): Promise<HostReply<PingResult>> {
135 } else if (!reply.result.schemasOk) {
144 } else if (!reply.result.schemasOk) {
136 recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
145 recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
137 } else {
146 } else {
138− h.pausedUntil = undefined;
147+ recovered();
139− h.lastError = undefined;
140− chrome.alarms.clear(ALARM_HOST);
141− void naming.sweepDirty();
142− void autoOrganizer.sweep();
143 }
148 }
144 }
149 }
145 commit();
150 commit();
@@ -147,6 +152,16 @@ async function ping(): Promise<HostReply<PingResult>> {
147 return reply;
152 return reply;
148}
153}
149
154
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+
150// ----- badge -----------------------------------------------------------------------------------
165// ----- badge -----------------------------------------------------------------------------------
151
166
152function busy(on: boolean): void {
167function busy(on: boolean): void {
@@ -158,7 +173,6 @@ function hostUnhealthy(): boolean {
158 const h = state.host;
173 const h = state.host;
159 const now = Date.now();
174 const now = Date.now();
160 if (h.pausedUntil && h.pausedUntil > now) return true;
175 if (h.pausedUntil && h.pausedUntil > now) return true;
161− if (h.consecutiveFailures >= 3) return true;
162 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
176 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
163}
177}
164
178
@@ -239,6 +253,7 @@ async function scheduleAutoFallback(when: number): Promise<void> {
239}
253}
240
254
241const autoOrganizer = new AutoOrganizer(rt, { scheduleFallback: (when) => void scheduleAutoFallback(when) });
255const autoOrganizer = new AutoOrganizer(rt, { scheduleFallback: (when) => void scheduleAutoFallback(when) });
256+const fitChecker = new FitChecker(rt);
242
257
243// ----- engine glue -----------------------------------------------------------------------------
258// ----- engine glue -----------------------------------------------------------------------------
244
259
@@ -327,6 +342,9 @@ async function run(a: Action): Promise<void> {
327 case "dirty":
342 case "dirty":
328 naming.touch(a.groupId);
343 naming.touch(a.groupId);
329 break;
344 break;
345+ case "checkFit":
346+ fitChecker.touch();
347+ break;
330 }
348 }
331}
349}
332
350
@@ -341,16 +359,24 @@ async function tabById(id: number): Promise<chrome.tabs.Tab | undefined> {
341 * across restarts, so records are matched by id first and then by URL (tabs) or title and colour
359 * across restarts, so records are matched by id first and then by URL (tabs) or title and colour
342 * (groups), which keeps managed groups managed after the browser restarts.
360 * (groups), which keeps managed groups managed after the browser restarts.
343 */
361 */
344−async function reconcile(): Promise<void> {
362+async function reconcile(reason: "startup" | "installed"): Promise<void> {
345 const [tabs, groups] = await Promise.all([chrome.tabs.query({}), chrome.tabGroups.query({})]);
363 const [tabs, groups] = await Promise.all([chrome.tabs.query({}), chrome.tabGroups.query({})]);
346 const now = Date.now();
364 const now = Date.now();
347− const oldTabs = Object.values(state.tabs);
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.
348− const byUrl = new Map<string, TabRecord>(oldTabs.map((t) => [t.url, t]));
367+ const byUrl = new Map<string, TabRecord>(Object.values(state.tabs).filter((t) => !liveTabIds.has(t.id)).map((t) => [t.url, t]));
349 const nextTabs: Record<number, TabRecord> = {};
368 const nextTabs: Record<number, TabRecord> = {};
350 for (const t of tabs) {
369 for (const t of tabs) {
351 if (t.incognito || t.id === undefined) continue;
370 if (t.incognito || t.id === undefined) continue;
352 const url = t.url || t.pendingUrl || "";
371 const url = t.url || t.pendingUrl || "";
353− const prev = state.tabs[t.id]?.url === url ? state.tabs[t.id] : byUrl.get(url);
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);
354 nextTabs[t.id] = {
380 nextTabs[t.id] = {
355 id: t.id,
381 id: t.id,
356 windowId: t.windowId,
382 windowId: t.windowId,
@@ -363,24 +389,51 @@ async function reconcile(): Promise<void> {
363 openerTabId: t.openerTabId,
389 openerTabId: t.openerTabId,
364 description: prev?.description,
390 description: prev?.description,
365 parkedFrom: prev?.parkedFrom,
391 parkedFrom: prev?.parkedFrom,
392+ parkedAt: prev?.parkedAt,
366 keepLoose: prev?.keepLoose,
393 keepLoose: prev?.keepLoose,
367 organizedKey: prev?.organizedKey,
394 organizedKey: prev?.organizedKey,
395+ organizeRefused: prev?.organizeRefused,
396+ handPlaced: prev?.handPlaced,
397+ fitPending: prev?.fitPending,
368 createdAt: prev?.createdAt ?? now,
398 createdAt: prev?.createdAt ?? now,
369− lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
399+ lastActivatedAt: lastSeen || now,
370 };
400 };
371 }
401 }
402+ const liveGroupIds = new Set(groups.map((g) => g.id));
372 const unmatched = new Map(Object.values(state.groups).map((g) => [g.id, g]));
403 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+ };
373 const nextGroups: Record<number, GroupRecord> = {};
417 const nextGroups: Record<number, GroupRecord> = {};
374 for (const g of groups) {
418 for (const g of groups) {
375 const title = g.title ?? "";
419 const title = g.title ?? "";
376− let prev = unmatched.get(g.id);
420+ let prev: GroupRecord | undefined = unmatched.get(g.id);
377− if (!prev || (prev.stripTitle ?? "") !== title) {
421+ if (prev && (prev.stripTitle ?? "") !== title) prev = undefined;
378− prev = [...unmatched.values()].find((o) => (o.stripTitle ?? "") === title && title !== "" && o.color === g.color);
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 });
379 }
434 }
380− if (prev) unmatched.delete(prev.id);
435+ // Earlier versions turned Parked into a plain group after a restart: give it back its job.
381− const rec: GroupRecord = prev
436+ if (rec.origin === "user" && looksParked(g, settings)) adoptParked(rec, settings);
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 });
384 if (rec.managed) {
437 if (rec.managed) {
385 rec.dirty = true;
438 rec.dirty = true;
386 rec.dirtyAt = 0;
439 rec.dirtyAt = 0;
@@ -388,13 +441,17 @@ async function reconcile(): Promise<void> {
388 }
441 }
389 nextGroups[g.id] = rec;
442 nextGroups[g.id] = rec;
390 }
443 }
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);
391 state.tabs = nextTabs;
446 state.tabs = nextTabs;
392 state.groups = nextGroups;
447 state.groups = nextGroups;
393 state.pendingCreates = [];
448 state.pendingCreates = [];
394 state.ownWrites = {};
449 state.ownWrites = {};
395 state.ownUngroups = {};
450 state.ownUngroups = {};
451+ state.ownAdds = {};
452+ // Undo lists name tabs by id, and after a restart none of those ids exist.
396− if (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS) state.lastSweep = undefined;
453+ if (reason === "startup" || (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;
454+ if (reason === "startup" || (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS)) state.lastOrganize = undefined;
398 commit();
455 commit();
399}
456}
400
457
@@ -407,9 +464,13 @@ async function injectBacklog(): Promise<void> {
407 }
464 }
408}
465}
409
466
467+let reconciled: Promise<void> | undefined;
468+
410async function boot(reason: "startup" | "installed"): Promise<void> {
469async function boot(reason: "startup" | "installed"): Promise<void> {
411− await ready;
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.
412− await serial("reconcile", reconcile);
472+ reconciled = serial("reconcile", () => reconcile(reason));
473+ await reconciled;
413 if (!(await chrome.alarms.get(ALARM_TIDY))) chrome.alarms.create(ALARM_TIDY, { periodInMinutes: 30, delayInMinutes: 1 });
474 if (!(await chrome.alarms.get(ALARM_TIDY))) chrome.alarms.create(ALARM_TIDY, { periodInMinutes: 30, delayInMinutes: 1 });
414 if (reason === "installed") void injectBacklog();
475 if (reason === "installed") void injectBacklog();
415 await ping();
476 await ping();
@@ -451,20 +512,29 @@ if (GROUPS_SUPPORTED) {
451
512
452 chrome.tabs.onReplaced.addListener((added, removed) =>
513 chrome.tabs.onReplaced.addListener((added, removed) =>
453 void serial("tabs.onReplaced", async () => {
514 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.
454 const rec = state.tabs[removed];
517 const rec = state.tabs[removed];
455 delete state.tabs[removed];
518 delete state.tabs[removed];
456 const t = await tabById(added);
519 const t = await tabById(added);
457 if (t) {
520 if (t) {
521+ if (rec && !state.tabs[added]) state.tabs[added] = { ...rec, id: added };
458 await feed({ type: "tabUpdated", tab: snapTab(t) });
522 await feed({ type: "tabUpdated", tab: snapTab(t) });
459− if (rec?.description && state.tabs[added]) state.tabs[added].description = rec.description;
460 }
523 }
461 }),
524 }),
462 );
525 );
463
526
464 chrome.tabs.onActivated.addListener(({ tabId }) => void serial("tabs.onActivated", () => feed({ type: "tabActivated", tabId })));
527 chrome.tabs.onActivated.addListener(({ tabId }) => void serial("tabs.onActivated", () => feed({ type: "tabActivated", tabId })));
465
528
466− chrome.tabGroups.onCreated.addListener((g) => void serial("tabGroups.onCreated", () => feed({ type: "groupCreated", group: snapGroup(g) })));
529+ // A group is read when its turn comes, not as Chromium sent it: a restored group reports itself
467− chrome.tabGroups.onUpdated.addListener((g) => void serial("tabGroups.onUpdated", () => feed({ type: "groupUpdated", group: snapGroup(g) })));
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"));
468 chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
538 chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
469}
539}
470
540
@@ -493,7 +563,10 @@ chrome.alarms.onAlarm.addListener((alarm) => {
493 await ready;
563 await ready;
494 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
564 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
495 else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
565 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
496− else if (alarm.name === ALARM_TIDY) await runSweep(rt).catch((e) => logError("tidy", e));
568+ await runSweep(rt).catch((e) => logError("tidy", e));
569+ }
497 else if (alarm.name === ALARM_HOST) {
570 else if (alarm.name === ALARM_HOST) {
498 const r = await ping();
571 const r = await ping();
499 if (!r.ok || hostUnhealthy()) chrome.alarms.create(ALARM_HOST, { when: Date.now() + HOST_RETRY_MS });
572 if (!r.ok || hostUnhealthy()) chrome.alarms.create(ALARM_HOST, { when: Date.now() + HOST_RETRY_MS });
@@ -507,27 +580,46 @@ chrome.storage.onChanged.addListener((changes, area) => {
507 await ready;
580 await ready;
508 const before = settings;
581 const before = settings;
509 settings = withDefaults(changes.settings.newValue);
582 settings = withDefaults(changes.settings.newValue);
510− if (before.emoji !== settings.emoji) await rewriteTitles();
583+ if (before.emoji !== settings.emoji) await rewriteTitles(before);
584+ if (before.nameUserGroups !== settings.nameUserGroups) applyNameUserGroups();
511 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
585 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
512 if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
586 if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
513 refreshBadge();
587 refreshBadge();
514 })();
588 })();
515});
589});
516
590
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+
517/** Emoji switched on or off: rewrite the titles the extension wrote. */
604/** Emoji switched on or off: rewrite the titles the extension wrote. */
518−async function rewriteTitles(): Promise<void> {
605+async function rewriteTitles(before: Settings): Promise<void> {
519 for (const g of Object.values(state.groups)) {
606 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") {
520− if (g.origin === "tidy") await updateGroup(rt, g.id, { title: parkedTitle(settings) });
609+ if ((g.stripTitle ?? "") === parkedTitle(before)) 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) });
610+ } else if (g.managed && !g.userNamed && g.title) await updateGroup(rt, g.id, { title: stripTitle(g.title, g.emoji, settings.emoji) });
522 }
611 }
523}
612}
524
613
525async function nameGroupNow(groupId: number): Promise<void> {
614async function nameGroupNow(groupId: number): Promise<void> {
526 const g = state.groups[groupId];
615 const g = state.groups[groupId];
527 if (!g) return;
616 if (!g) return;
528− // An explicit request overrides "don't name" and a hand-typed title for this group.
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.
529 g.managed = true;
619 g.managed = true;
530 g.userNamed = false;
620 g.userNamed = false;
621+ g.title = undefined;
622+ g.lastNamedAt = undefined;
531 commit();
623 commit();
532 await naming.nameNow(groupId);
624 await naming.nameNow(groupId);
533}
625}
src/background/naming.ts+86−28
@@ -1,6 +1,7 @@
1−import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, LABEL_MAX_CHARS } from "../shared/label";
1+import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, titleKey, LABEL_MAX_CHARS } from "../shared/label";
2import { sha1 } from "../shared/sha1";
2import { sha1 } from "../shared/sha1";
3import { isInternalUrl, promptUrl, trimText } from "../shared/url";
3import { isInternalUrl, promptUrl, trimText } from "../shared/url";
4+import { pathKey } from "./engine";
4import type { HostOpts, HostReply } from "./host";
5import type { HostOpts, HostReply } from "./host";
5import { SETUP_ERRORS } from "./host";
6import { SETUP_ERRORS } from "./host";
6import type { Settings } from "./settings";
7import type { Settings } from "./settings";
@@ -49,12 +50,18 @@ export interface NameResult {
49 emoji: string;
50 emoji: string;
50}
51}
51
52
52−/** Tabs that can describe a group: loaded web pages, not the new-tab page or internal pages. */
53+/** Tabs that can describe a group: web pages, not the new-tab page or internal pages. */
53export const nameable = (m: Member): boolean => !isInternalUrl(m.url);
54export const nameable = (m: Member): boolean => !isInternalUrl(m.url);
54
55
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+
55/** sha-1 of the sorted `url|title` lines: reorder-proof, changes when a member or its title does. */
62/** sha-1 of the sorted `url|title` lines: reorder-proof, changes when a member or its title does. */
56export const membersHash = (members: Pick<Member, "url" | "title">[]): string =>
63export const membersHash = (members: Pick<Member, "url" | "title">[]): string =>
57− sha1(members.map((m) => `${m.url}|${m.title}`).sort().join("\n"));
64+ sha1(members.map((m) => `${m.url}|${titleKey(m.title)}`).sort().join("\n"));
58
65
59export const memberUrls = (members: Pick<Member, "url">[]): string[] => members.map((m) => m.url).sort();
66export const memberUrls = (members: Pick<Member, "url">[]): string[] => members.map((m) => m.url).sort();
60
67
@@ -72,7 +79,7 @@ export function sampleMembers(members: Member[], cap = NAME_ITEM_CAP): Member[]
72export function toItems(members: Member[], settings: Pick<Settings, "sendDescription" | "sendFullUrl">, withDescriptions = true): NameItem[] {
79export function toItems(members: Member[], settings: Pick<Settings, "sendDescription" | "sendFullUrl">, withDescriptions = true): NameItem[] {
73 return members.map((m, i) => {
80 return members.map((m, i) => {
74 const item: NameItem = { i, title: trimText(m.title, 120), url: promptUrl(m.url, settings.sendFullUrl) };
81 const item: NameItem = { i, title: trimText(m.title, 120), url: promptUrl(m.url, settings.sendFullUrl) };
75− const d = withDescriptions && settings.sendDescription ? trimText(m.description, 300) : "";
82+ const d = withDescriptions && settings.sendDescription ? trimText(m.description, 500) : "";
76 if (d) item.description = d;
83 if (d) item.description = d;
77 return item;
84 return item;
78 });
85 });
@@ -107,8 +114,9 @@ export function shouldApply(g: GroupRecord, label: string, urls: string[]): bool
107 if (g.userNamed) return false;
114 if (g.userNamed) return false;
108 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
115 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
109 if (sameLabel(label, g.title)) return false;
116 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.
110− const before = new Set(g.memberUrls ?? []);
118+ const before = new Set((g.memberUrls ?? []).map(pathKey));
111− const after = new Set(urls);
119+ const after = new Set(urls.map(pathKey));
112 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
120 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
113 return changed; // a paraphrase with the same members keeps the old title
121 return changed; // a paraphrase with the same members keeps the old title
114}
122}
@@ -208,18 +216,32 @@ export class Naming {
208 }
216 }
209 const now = this.d.now();
217 const now = this.d.now();
210 if (!force) {
218 if (!force) {
219+ // A refusal waits for the members to change, which only a look at them can tell.
211− if (g.nextAttemptAt && g.nextAttemptAt > now) return;
220+ if (g.nextAttemptAt && g.nextAttemptAt > now && !g.refusedHash) return;
212 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
221 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
213 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
222 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
214 }
223 }
215 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
224 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
216 if (!members) return;
225 if (!members) return;
217− if (members.length < 2 || members.some((m) => m.status !== "complete")) return; // stay dirty; next event or alarm
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+ }
218− if (!force && membersHash(members) === g.membersHash) {
235+ const hash = membersHash(members);
219− g.dirty = false;
236+ if (!force && hash === g.membersHash) {
237+ settle(g);
220 this.d.commit();
238 this.d.commit();
221 return;
239 return;
222 }
240 }
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+ }
223 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
245 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
224 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
246 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
225 void this.pump();
247 void this.pump();
@@ -252,10 +274,10 @@ export class Naming {
252 if (!g || !eligible(g, settings)) return;
274 if (!g || !eligible(g, settings)) return;
253 // Built lazily: a group that changed three times while queued is described once.
275 // Built lazily: a group that changed three times while queued is described once.
254 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
276 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
255− if (!members || members.length < 2 || members.some((m) => m.status !== "complete")) return;
277+ if (!members || members.length < 2 || !members.every(settled)) return;
256 const hash = membersHash(members);
278 const hash = membersHash(members);
257 if (!force && hash === g.membersHash) {
279 if (!force && hash === g.membersHash) {
258− g.dirty = false;
280+ settle(g);
259 this.d.commit();
281 this.d.commit();
260 return;
282 return;
261 }
283 }
@@ -290,9 +312,10 @@ export class Naming {
290 }
312 }
291 if (e.code === "BAD_MODEL_OUTPUT" && !retried.output) {
313 if (e.code === "BAD_MODEL_OUTPUT" && !retried.output) {
292 retried.output = true;
314 retried.output = true;
315+ mustDifferFrom = withRejected(mustDifferFrom, e.raw);
293 continue;
316 continue;
294 }
317 }
295− this.fail(g, e);
318+ this.fail(g, e, hash);
296 return;
319 return;
297 }
320 }
298
321
@@ -301,9 +324,10 @@ export class Naming {
301 const e: HostError = { code: "BAD_MODEL_OUTPUT", message: "label failed worker validation", raw: JSON.stringify(reply.result).slice(0, 500), at: this.d.now() };
324 const e: HostError = { code: "BAD_MODEL_OUTPUT", message: "label failed worker validation", raw: JSON.stringify(reply.result).slice(0, 500), at: this.d.now() };
302 if (!retried.output) {
325 if (!retried.output) {
303 retried.output = true;
326 retried.output = true;
327+ mustDifferFrom = withRejected(mustDifferFrom, e.raw);
304 continue;
328 continue;
305 }
329 }
306− this.fail(g, e);
330+ this.fail(g, e, hash);
307 return;
331 return;
308 }
332 }
309 const emoji = safeEmoji(reply.result.emoji);
333 const emoji = safeEmoji(reply.result.emoji);
@@ -318,12 +342,15 @@ export class Naming {
318 finalLabel = suffixLabel(label, siblings);
342 finalLabel = suffixLabel(label, siblings);
319 }
343 }
320 this.d.hostOk();
344 this.d.hostOk();
321− await this.apply(g, finalLabel, emoji, members, hash);
345+ const written = await this.apply(g, finalLabel, emoji, members, hash);
322 g = this.d.state().groups[groupId];
346 g = this.d.state().groups[groupId];
323− if (g) {
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);
324− if (g.dirtySeq === seq) g.dirty = false;
352+ } else if (g.dirtySeq === seq) g.dirty = false;
325− else this.touch(groupId); // dirtied while in flight: go again with the new membership
353+ else this.touch(groupId); // dirtied while in flight: go again with the new membership
326− }
327 return;
354 return;
328 }
355 }
329 } finally {
356 } finally {
@@ -334,16 +361,20 @@ export class Naming {
334 }
361 }
335 }
362 }
336
363
364+ /** Put the label on the strip if the policy allows; false when the strip would not take the title. */
337− private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<void> {
365+ private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<boolean> {
338 const settings = this.d.settings();
366 const settings = this.d.settings();
339 const urls = memberUrls(members);
367 const urls = memberUrls(members);
340− if (g.userNamed) return; // renamed by hand while the model was thinking
368+ if (g.userNamed) return true; // renamed by hand while the model was thinking
341 if (shouldApply(g, label, urls)) {
369 if (shouldApply(g, label, urls)) {
342 const title = stripTitle(label, emoji, settings.emoji);
370 const title = stripTitle(label, emoji, settings.emoji);
343 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
371 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
344 this.d.commit();
372 this.d.commit();
345 const ok = await this.d.writeTitle(g.id, title);
373 const ok = await this.d.writeTitle(g.id, title);
346− if (!ok) return;
374+ if (!ok) {
375+ delete this.d.state().ownWrites[g.id];
376+ return false;
377+ }
347 g.title = label;
378 g.title = label;
348 g.emoji = emoji;
379 g.emoji = emoji;
349 g.stripTitle = title;
380 g.stripTitle = title;
@@ -351,21 +382,24 @@ export class Naming {
351 g.lastNamedAt = this.d.now();
382 g.lastNamedAt = this.d.now();
352 g.membersHash = hash;
383 g.membersHash = hash;
353 g.memberUrls = urls;
384 g.memberUrls = urls;
354− g.nameAttempts = 0;
385+ clearFailures(g);
355− g.firstFailureAt = undefined;
386+ return true;
356− g.nextAttemptAt = undefined;
357 }
387 }
358
388
359− private fail(g: GroupRecord, e: HostError): void {
389+ private fail(g: GroupRecord, e: HostError, hash: string): void {
360 const now = this.d.now();
390 const now = this.d.now();
361 this.d.hostFailed(e);
391 this.d.hostFailed(e);
362 if (e.code === "BAD_REQUEST") {
392 if (e.code === "BAD_REQUEST") {
363 g.dirty = false; // a bug on our side: log and drop
393 g.dirty = false; // a bug on our side: log and drop
364 return;
394 return;
365 }
395 }
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;
366 if (e.code === "GUARDRAIL") {
399 if (e.code === "GUARDRAIL") {
367 g.nameAttempts = MAX_ATTEMPTS;
400 g.nameAttempts = MAX_ATTEMPTS;
368 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
401 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
402+ g.refusedHash = hash;
369 this.d.scheduleFallback(g.nextAttemptAt);
403 this.d.scheduleFallback(g.nextAttemptAt);
370 return;
404 return;
371 }
405 }
@@ -375,11 +409,35 @@ export class Naming {
375 g.dirty = false; // retried for a day: keep the provisional title
409 g.dirty = false; // retried for a day: keep the provisional title
376 return;
410 return;
377 }
411 }
378− const wait = SETUP_ERRORS.has(e.code) ? Math.max(GLOBAL_PAUSE_MS, backoffFor(g.nameAttempts)) : backoffFor(g.nameAttempts);
379− g.nextAttemptAt = now + wait;
412+ g.nextAttemptAt = now + backoffFor(g.nameAttempts);
380 this.d.scheduleFallback(g.nextAttemptAt);
413 this.d.scheduleFallback(g.nextAttemptAt);
381 }
414 }
382}
415}
383
416
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+
384export const eligible = (g: GroupRecord, settings: Settings): boolean =>
442export const eligible = (g: GroupRecord, settings: Settings): boolean =>
385 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";
443 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";
src/background/organize.ts+204−111
@@ -1,16 +1,19 @@
1import { COLORS, isColor, type GroupColor } from "../shared/colors";
1import { COLORS, isColor, type GroupColor } from "../shared/colors";
2−import { labelOf, repairLabel, safeEmoji, stripTitle } from "../shared/label";
2+import { labelOf, repairLabel, safeEmoji, stripTitle, titleKey } from "../shared/label";
3import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
3import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
4import { pathKey } from "./engine";
4import { pathKey } from "./engine";
5−import { membersHash, memberUrls, toItems, type Member, type NameItem } from "./naming";
5+import { membersHash, memberUrls, settled, toItems, type Member, type NameItem } from "./naming";
6import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
6import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
7+import type { HostReply } from "./host";
7−import { markDirty, type State } from "./state";
8+import { markDirty, type GroupRecord, type HostError, type HostErrorCode, type State, type TabRecord } from "./state";
8
9
9/** Section 3/9: "Organize all tabs" — cluster ungrouped tabs in batches sized to the model's window. */
10/** Section 3/9: "Organize all tabs" — cluster ungrouped tabs in batches sized to the model's window. */
10
11
11export const ORGANIZE_ITEM_CAP = 36;
12export const ORGANIZE_ITEM_CAP = 36;
12export const EXISTING_GROUPS_CAP = 12;
13export 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.
13−export const CHAR_BUDGET = 24_000; // matches the host's budget for fm's ~8k-token context
16+export const CHAR_BUDGET = 24_000;
14export const INSTRUCTION_CHARS = 1_400; // ~320 tokens of rules, emoji and colour lists
17export const INSTRUCTION_CHARS = 1_400; // ~320 tokens of rules, emoji and colour lists
15export const EXISTING_GROUP_CHARS = 90;
18export const EXISTING_GROUP_CHARS = 90;
16export const UNDO_WINDOW_MS = 3_600_000;
19export const UNDO_WINDOW_MS = 3_600_000;
@@ -24,6 +27,8 @@ export interface ExistingGroup {
24export interface OrganizePayload {
27export interface OrganizePayload {
25 items: NameItem[];
28 items: NameItem[];
26 existingGroups: ExistingGroup[];
29 existingGroups: ExistingGroup[];
30+ /** Every group label in the window: a new group the host names must not repeat one. */
31+ siblingTitles?: string[];
27 allowNew: boolean;
32 allowNew: boolean;
28 maxGroups: number;
33 maxGroups: number;
29 minGroupSize: number;
34 minGroupSize: number;
@@ -53,9 +58,12 @@ export const maxGroupsFor = (n: number): number => Math.min(8, Math.ceil(n / 3))
53export const organizable = (t: { groupId?: number; pinned?: boolean; url?: string; incognito?: boolean }): boolean =>
58export const organizable = (t: { groupId?: number; pinned?: boolean; url?: string; incognito?: boolean }): boolean =>
54 (t.groupId ?? -1) === -1 && !t.pinned && !t.incognito && !isInternalUrl(t.url);
59 (t.groupId ?? -1) === -1 && !t.pinned && !t.incognito && !isInternalUrl(t.url);
55
60
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+
56/** Rough rendered size of one item line, the unit the character budget is spent in. */
64/** Rough rendered size of one item line, the unit the character budget is spent in. */
57export const itemChars = (m: Pick<Member, "title" | "url" | "description">): number =>
65export const itemChars = (m: Pick<Member, "title" | "url" | "description">): number =>
58− Math.min(120, m.title.length) + promptAddress(m.url).length + Math.min(300, m.description?.length ?? 1) + 12;
66+ weightedLength(m.title.slice(0, 120)) + promptAddress(m.url).length + weightedLength((m.description ?? " ").slice(0, 500)) + 12;
59
67
60/** Split tabs (in strip order) into batches of ≤ cap items that also fit the character budget. */
68/** Split tabs (in strip order) into batches of ≤ cap items that also fit the character budget. */
61export function makeBatches<T extends Pick<Member, "title" | "url" | "description">>(
69export function makeBatches<T extends Pick<Member, "title" | "url" | "description">>(
@@ -146,88 +154,198 @@ export interface OrganizeReport {
146 error?: string;
154 error?: string;
147}
155}
148
156
149−/** Existing groups a batch may add tabs to: extension-managed topic groups in this window. */
157+/** Existing groups a batch may add tabs to: Diagonal's topic groups in this window, the ones in use first. */
150export function existingGroupsFor(state: State, windowId: number, extraIds: number[] = []): { ids: number[]; list: ExistingGroup[] } {
158export 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));
151 const groups = Object.values(state.groups)
163 const groups = Object.values(state.groups)
152− .filter((g) => g.windowId === windowId && g.managed && g.origin !== "tidy" && (g.stripTitle || g.title))
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) }))
153− .sort((a, b) => Number(extraIds.includes(b.id)) - Number(extraIds.includes(a.id)))
166+ .sort((a, b) => Number(b.first) - Number(a.first) || b.at - a.at)
154− .slice(0, EXISTING_GROUPS_CAP);
167+ .slice(0, EXISTING_GROUPS_CAP)
168+ .map(({ g }) => g);
155 const list = groups.map((g, i) => ({
169 const list = groups.map((g, i) => ({
156 g: i,
170 g: i,
157 title: labelOf(g.stripTitle || g.title),
171 title: labelOf(g.stripTitle || g.title),
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)),
172+ samples: (members.get(g.id) ?? []).slice(0, 2).map((t) => t.title.slice(0, 60)),
162 }));
173 }));
163 return { ids: groups.map((g) => g.id), list };
174 return { ids: groups.map((g) => g.id), list };
164}
175}
165
176
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. */
166−export async function organizeWindow(rt: Runtime, windowId: number): Promise<OrganizeReport> {
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+ }
167 const settings = rt.settings();
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+
306+export async function organizeWindow(rt: Runtime, windowId: number): Promise<OrganizeReport> {
307+ const s0 = rt.state();
168 const tabs = (await chrome.tabs.query({ windowId })).filter((t) => organizable(t)).sort((a, b) => a.index - b.index);
308 const tabs = (await chrome.tabs.query({ windowId })).filter((t) => organizable(t)).sort((a, b) => a.index - b.index);
169 const report: OrganizeReport = { grouped: 0, groups: 0, left: 0 };
309 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));
170− if (tabs.length < 2) {
312+ if (sendable.length < 2) {
171 report.left = tabs.length;
313 report.left = tabs.length;
172 return report;
314 return report;
173 }
315 }
174− const members: Member[] = tabs.map((t) => ({
316+ const run = newRun(windowId, true); // auto-organize need not send the same tabs again right after
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);
186 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
317 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
187 rt.refreshBadge();
318 rt.refreshBadge();
188 try {
319 try {
189− while (queue.length) {
320+ for (const batch of makeBatches(sendable.map((t) => toMember(rt.state(), t)))) {
190− const batch = queue.shift()!;
321+ await sortPart(rt, batch, run);
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;
322+ if (run.stopped) 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;
219 }
323 }
220 } finally {
324 } finally {
221 const s = rt.state();
325 const s = rt.state();
222 if (s.inFlight?.groupId === -1) s.inFlight = undefined;
326 if (s.inFlight?.groupId === -1) s.inFlight = undefined;
327+ report.grouped = run.grouped;
223− report.groups = createdIds.length;
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}`;
224− if (Object.keys(previous).length) s.lastOrganize = { at: rt.now(), previous };
331+ if (Object.keys(run.previous).length) s.lastOrganize = { at: rt.now(), previous: run.previous };
225 rt.commit();
332 rt.commit();
226 rt.refreshBadge();
333 rt.refreshBadge();
227 }
334 }
228 return report;
335 return report;
229}
336}
230
337
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. */
231export async function applyPlan(
349export async function applyPlan(
232 rt: Runtime,
350 rt: Runtime,
233 plan: Plan,
351 plan: Plan,
@@ -235,11 +353,15 @@ export async function applyPlan(
235 members: Member[],
353 members: Member[],
236 previous: Record<number, number>,
354 previous: Record<number, number>,
237 createdIds: number[],
355 createdIds: number[],
238−): Promise<void> {
356+): Promise<Set<number>> {
239 const settings = rt.settings();
357 const settings = rt.settings();
240 const byId = new Map(members.map((m) => [m.id, m]));
358 const byId = new Map(members.map((m) => [m.id, m]));
359+ const failed = new Set<number>();
241 for (const j of plan.joins) {
360 for (const j of plan.joins) {
242− if (!(await addToGroup(rt, j.tabIds, j.groupId))) continue;
361+ if (!(await addToGroup(rt, j.tabIds, j.groupId))) {
362+ for (const id of j.tabIds) failed.add(id);
363+ continue;
364+ }
243 for (const id of j.tabIds) previous[id] = -1;
365 for (const id of j.tabIds) previous[id] = -1;
244 const g = rt.state().groups[j.groupId];
366 const g = rt.state().groups[j.groupId];
245 if (g) {
367 if (g) {
@@ -260,7 +382,10 @@ export async function applyPlan(
260 lastNamedAt: named ? rt.now() : undefined,
382 lastNamedAt: named ? rt.now() : undefined,
261 dirty: false,
383 dirty: false,
262 });
384 });
263− if (groupId === undefined) continue;
385+ if (groupId === undefined) {
386+ for (const id of c.tabIds) failed.add(id);
387+ continue;
388+ }
264 createdIds.push(groupId);
389 createdIds.push(groupId);
265 for (const id of c.tabIds) previous[id] = -1;
390 for (const id of c.tabIds) previous[id] = -1;
266 const g = rt.state().groups[groupId];
391 const g = rt.state().groups[groupId];
@@ -269,6 +394,7 @@ export async function applyPlan(
269 rt.naming.touch(groupId);
394 rt.naming.touch(groupId);
270 }
395 }
271 }
396 }
397+ return failed;
272}
398}
273
399
274/** Undo within the hour: every tab goes back to the group it was in (organize only takes loose tabs). */
400/** Undo within the hour: every tab goes back to the group it was in (organize only takes loose tabs). */
@@ -296,7 +422,7 @@ export async function undoOrganize(rt: Runtime): Promise<number> {
296}
422}
297
423
298/** What auto-organize remembers about a tab: it looks again only when the page or title changes. */
424/** What auto-organize remembers about a tab: it looks again only when the page or title changes. */
299−export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${t.title ?? ""}`;
425+export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${titleKey(t.title)}`;
300
426
301export const AUTO_RETRY_MS = 300_000;
427export const AUTO_RETRY_MS = 300_000;
302export const AUTO_CONTINUE_MS = 1_500;
428export const AUTO_CONTINUE_MS = 1_500;
@@ -372,60 +498,27 @@ export class AutoOrganizer {
372 if (pausedUntil && pausedUntil > rt.now()) return; // the host-retry ping sweeps again when it recovers
498 if (pausedUntil && pausedUntil > rt.now()) return; // the host-retry ping sweeps again when it recovers
373 const tabs = (await chrome.tabs.query({ windowId }).catch(() => [])).sort((a, b) => a.index - b.index);
499 const tabs = (await chrome.tabs.query({ windowId }).catch(() => [])).sort((a, b) => a.index - b.index);
374 const s = rt.state();
500 const s = rt.state();
375− // Still-loading tabs are skipped here; finishing the load touches the window again.
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.
376− const loose = tabs.filter((t) => organizable(t) && t.status === "complete" && !s.tabs[t.id!]?.keepLoose);
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+ });
377 const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
507 const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
378 if (!fresh.length) return;
508 if (!fresh.length) return;
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
509+ if (loose.length < 2 && !existingGroupsFor(s, windowId).ids.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);
510+ const ordered = [...fresh, ...loose.filter((t) => !fresh.includes(t))].map((t) => toMember(s, t));
391 const batch = makeBatches(ordered)[0];
511 const batch = makeBatches(ordered)[0];
392− const maxGroups = maxGroupsFor(batch.length);
512+ const run = newRun(windowId, true);
393− const payload: OrganizePayload = {
513+ await sortPart(rt, batch, run);
394− items: toItems(batch, settings),
514+ rt.commit();
395− existingGroups,
515+ if (run.stopped) {
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);
516+ rt.log("auto-organize", run.stopped.code);
413 this.deps.scheduleFallback(rt.now() + AUTO_RETRY_MS);
517 this.deps.scheduleFallback(rt.now() + AUTO_RETRY_MS);
414 return;
518 return;
415 }
519 }
416− const batchIds = batch.map((m) => m.id);
520+ const sent = new Set(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);
521+ if (fresh.some((t) => !sent.has(t.id!))) this.touch(windowId, AUTO_CONTINUE_MS);
429 }
522 }
430}
523}
431
524
src/background/runtime.ts+16−2
@@ -67,34 +67,48 @@ export async function createManagedGroup(
67 extra: Partial<GroupRecord> = {},
67 extra: Partial<GroupRecord> = {},
68): Promise<number | undefined> {
68): Promise<number | undefined> {
69 const s = rt.state();
69 const s = rt.state();
70− s.pendingCreates.push({ windowId, origin, at: rt.now() });
70+ const pending = { windowId, origin, at: rt.now() };
71+ s.pendingCreates.push(pending);
72+ for (const id of tabIds) s.ownAdds[id] = rt.now();
71 let groupId: number;
73 let groupId: number;
72 try {
74 try {
73 groupId = await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], createProperties: { windowId } }));
75 groupId = await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], createProperties: { windowId } }));
74 } catch (e) {
76 } catch (e) {
75 rt.log("tabs.group failed", e);
77 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);
76 return undefined;
81 return undefined;
77 }
82 }
78 const live = rt.state();
83 const live = rt.state();
79 const rec = live.groups[groupId] ?? newGroupRecord(groupId, windowId, origin, props.color);
84 const rec = live.groups[groupId] ?? newGroupRecord(groupId, windowId, origin, props.color);
80 Object.assign(rec, { origin, managed: true, windowId, color: props.color }, extra);
85 Object.assign(rec, { origin, managed: true, windowId, color: props.color }, extra);
81 live.groups[groupId] = rec;
86 live.groups[groupId] = rec;
82− for (const id of tabIds) if (live.tabs[id]) live.tabs[id].groupId = groupId;
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.
83 await updateGroup(rt, groupId, props);
89 await updateGroup(rt, groupId, props);
84 rt.commit();
90 rt.commit();
85 return groupId;
91 return groupId;
86}
92}
87
93
88export async function addToGroup(rt: Runtime, tabIds: number[], groupId: number): Promise<boolean> {
94export 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();
89 try {
97 try {
90 await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], groupId }));
98 await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], groupId }));
91 return true;
99 return true;
92 } catch (e) {
100 } catch (e) {
93 rt.log("tabs.group into existing failed", e);
101 rt.log("tabs.group into existing failed", e);
102+ forgetOwnAdds(rt.state(), tabIds);
94 return false;
103 return false;
95 }
104 }
96}
105}
97
106
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+
98export async function ungroup(rt: Runtime, tabIds: number[]): Promise<void> {
112export async function ungroup(rt: Runtime, tabIds: number[]): Promise<void> {
99 if (!tabIds.length) return;
113 if (!tabIds.length) return;
100 try {
114 try {
src/background/state.ts+25−1
@@ -10,7 +10,7 @@ export interface TabRecord {
10 index: number;
10 index: number;
11 url: string;
11 url: string;
12 title: string;
12 title: string;
13− description?: string; // trimmed to 300 chars
13+ description?: string; // what the page says (content/meta.ts), trimmed to 500 chars
14 openerTabId?: number;
14 openerTabId?: number;
15 pinned: boolean;
15 pinned: boolean;
16 incognito?: boolean;
16 incognito?: boolean;
@@ -18,8 +18,12 @@ export interface TabRecord {
18 createdAt: number;
18 createdAt: number;
19 lastActivatedAt: number; // epoch ms
19 lastActivatedAt: number; // epoch ms
20 parkedFrom?: string; // title of the group the tab was in before the tidy sweep parked it
20 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
21 keepLoose?: string; // the user took this tab out of a group on this page: auto-organize leaves it be
22 keepLoose?: string; // the user took this tab out of a group on this page: auto-organize leaves it be
22 organizedKey?: string; // page + title when auto-organize last considered this tab
23 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)
23}
27}
24
28
25export interface GroupRecord {
29export interface GroupRecord {
@@ -43,8 +47,15 @@ export interface GroupRecord {
43 nameAttempts: number;
47 nameAttempts: number;
44 firstFailureAt?: number;
48 firstFailureAt?: number;
45 nextAttemptAt?: number;
49 nextAttemptAt?: number;
50+ refusedHash?: string; // the members Apple's filter refused to name: not asked again until they change
51+ registeredAt?: number; // first seen as a group Diagonal did not know: it may yet turn out to be one of Diagonal's coming back
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
46}
54}
47
55
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+
48export interface ArchivedTab {
59export interface ArchivedTab {
49 url: string;
60 url: string;
50 title: string;
61 title: string;
@@ -105,6 +116,11 @@ export interface SweepMove {
105 groupId: number;
116 groupId: number;
106 index: number;
117 index: number;
107 windowId: number;
118 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+ >;
108}
124}
109
125
110export interface State {
126export interface State {
@@ -120,6 +136,10 @@ export interface State {
120 pendingCreates: { windowId: number; origin: GroupOrigin; at: number }[];
136 pendingCreates: { windowId: number; origin: GroupOrigin; at: number }[];
121 /** Tabs the worker itself is ungrouping (dissolve): leaving the group is not a user choice. */
137 /** Tabs the worker itself is ungrouping (dissolve): leaving the group is not a user choice. */
122 ownUngroups: Record<number, number>;
138 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>;
123 lastSweep?: { at: number; moves: SweepMove[] };
143 lastSweep?: { at: number; moves: SweepMove[] };
124 lastOrganize?: { at: number; previous: Record<number, number> };
144 lastOrganize?: { at: number; previous: Record<number, number> };
125 tidyCandidates?: number;
145 tidyCandidates?: number;
@@ -134,6 +154,8 @@ export const emptyState = (): State => ({
134 ownWrites: {},
154 ownWrites: {},
135 pendingCreates: [],
155 pendingCreates: [],
136 ownUngroups: {},
156 ownUngroups: {},
157+ ownAdds: {},
158+ removedGroups: {},
137});
159});
138
160
139/** Accept whatever was stored and bring it to the current shape. */
161/** Accept whatever was stored and bring it to the current shape. */
@@ -154,6 +176,8 @@ export function migrate(raw: unknown): State {
154 ownWrites: r.ownWrites ?? {},
176 ownWrites: r.ownWrites ?? {},
155 pendingCreates: r.pendingCreates ?? [],
177 pendingCreates: r.pendingCreates ?? [],
156 ownUngroups: r.ownUngroups ?? {},
178 ownUngroups: r.ownUngroups ?? {},
179+ ownAdds: r.ownAdds ?? {},
180+ removedGroups: r.removedGroups ?? {},
157 };
181 };
158}
182}
159
183
src/background/tidy.ts+129−17
@@ -3,7 +3,7 @@ import { labelOf, stripTitle } from "../shared/label";
3import { isInternalUrl } from "../shared/url";
3import { isInternalUrl } from "../shared/url";
4import { addToGroup, createManagedGroup, groupExists, ungroup, updateGroup, type Runtime } from "./runtime";
4import { addToGroup, createManagedGroup, groupExists, ungroup, updateGroup, type Runtime } from "./runtime";
5import type { Settings } from "./settings";
5import type { Settings } from "./settings";
6−import type { ArchivedTab, State, SweepMove } from "./state";
6+import { newGroupRecord, type ArchivedTab, type GroupRecord, type State, type SweepMove } from "./state";
7
7
8/** Section 7: park stale tabs in a collapsed group, then archive and close them. No model calls. */
8/** Section 7: park stale tabs in a collapsed group, then archive and close them. No model calls. */
9
9
@@ -23,11 +23,22 @@ export interface TidyTab {
23 audible?: boolean;
23 audible?: boolean;
24 incognito?: boolean;
24 incognito?: boolean;
25 discarded?: boolean;
25 discarded?: boolean;
26+ autoDiscardable?: boolean;
26 lastAccessed?: number;
27 lastAccessed?: number;
27}
28}
28
29
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+ */
29export const idleMs = (tab: TidyTab, state: State, now: number): number =>
34export const idleMs = (tab: TidyTab, state: State, now: number): number =>
30− now - Math.max(tab.lastAccessed ?? 0, state.tabs[tab.id]?.lastActivatedAt ?? 0);
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+};
31
42
32/** Exclusions are substrings of the URL; a pattern written as /…/ is a regular expression. */
43/** Exclusions are substrings of the URL; a pattern written as /…/ is a regular expression. */
33export function excluded(url: string, patterns: string[]): boolean {
44export function excluded(url: string, patterns: string[]): boolean {
@@ -55,7 +66,8 @@ export function isParkCandidate(tab: TidyTab, state: State, settings: Settings,
55 if (tab.groupId !== -1) {
66 if (tab.groupId !== -1) {
56 const g = state.groups[tab.groupId];
67 const g = state.groups[tab.groupId];
57 if (g?.keep) return false;
68 if (g?.keep) return false;
69+ // Groups you made or renamed are yours, like Diagonal's other rules treat them.
58− if ((!g || g.origin === "user") && !settings.tidyUserGroups) return false;
70+ if ((!g || g.origin === "user" || g.userNamed) && !settings.tidyUserGroups) return false;
59 }
71 }
60 if (excluded(tab.url, settings.tidyExclusions)) return false;
72 if (excluded(tab.url, settings.tidyExclusions)) return false;
61 return idleMs(tab, state, now) >= settings.parkAfterHours * HOUR;
73 return idleMs(tab, state, now) >= settings.parkAfterHours * HOUR;
@@ -66,10 +78,18 @@ export function parkCandidates(tabs: TidyTab[], state: State, settings: Settings
66 return tabs.filter((t) => isParkCandidate(t, state, settings, now, parking));
78 return tabs.filter((t) => isParkCandidate(t, state, settings, now, parking));
67}
79}
68
80
81+/** Tabs that have sat in Parked for the archive time, counted from when they were parked. */
69export function archiveCandidates(tabs: TidyTab[], state: State, settings: Settings, now: number): TidyTab[] {
82export function archiveCandidates(tabs: TidyTab[], state: State, settings: Settings, now: number): TidyTab[] {
70 if (!settings.archiveAfterHours) return [];
83 if (!settings.archiveAfterHours) return [];
71 const parking = parkingGroupIds(state);
84 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)) &&
72− return tabs.filter((t) => parking.has(t.groupId) && !t.active && !t.audible && idleMs(t, state, now) >= settings.archiveAfterHours * HOUR);
91+ parkedMs(t, state, now) >= settings.archiveAfterHours * HOUR,
92+ );
73}
93}
74
94
75/** Prepend entries newest first and drop the oldest past the cap. */
95/** Prepend entries newest first and drop the oldest past the cap. */
@@ -79,6 +99,15 @@ export function addToArchive(archive: ArchivedTab[], entries: ArchivedTab[], cap
79
99
80export const parkedTitle = (settings: Settings): string => stripTitle("Parked", PARKED_EMOJI, settings.emoji);
100export const parkedTitle = (settings: Settings): string => stripTitle("Parked", PARKED_EMOJI, settings.emoji);
81
101
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+
82// ---------------------------------------------------------------------------------------------
111// ---------------------------------------------------------------------------------------------
83
112
84const liveTabs = async (): Promise<TidyTab[]> =>
113const liveTabs = async (): Promise<TidyTab[]> =>
@@ -95,6 +124,7 @@ const liveTabs = async (): Promise<TidyTab[]> =>
95 audible: t.audible,
124 audible: t.audible,
96 incognito: t.incognito,
125 incognito: t.incognito,
97 discarded: t.discarded,
126 discarded: t.discarded,
127+ autoDiscardable: t.autoDiscardable,
98 lastAccessed: t.lastAccessed,
128 lastAccessed: t.lastAccessed,
99 }));
129 }));
100
130
@@ -104,11 +134,20 @@ export interface SweepReport {
104 archived: number;
134 archived: number;
105}
135}
106
136
137+let sweeps: Promise<unknown> = Promise.resolve();
138+
107/**
139/**
108 * The 30-minute sweep (manual = "Tidy now" / "Park N tabs": ignores the threshold and the Ask
140 * The 30-minute sweep (manual = "Tidy now" / "Park N tabs": ignores the threshold and the Ask
109− * consent, still respects eligibility).
141+ * consent, still respects eligibility). One at a time: two at once would park, and archive, the
142+ * same tabs twice.
110 */
143 */
111−export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport> {
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> {
112 const settings = rt.settings();
151 const settings = rt.settings();
113 const report: SweepReport = { candidates: 0, parked: 0, archived: 0 };
152 const report: SweepReport = { candidates: 0, parked: 0, archived: 0 };
114 if (settings.tidyMode === "off" && !manual) {
153 if (settings.tidyMode === "off" && !manual) {
@@ -118,6 +157,7 @@ export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport
118 }
157 }
119 const now = rt.now();
158 const now = rt.now();
120 let tabs = await liveTabs();
159 let tabs = await liveTabs();
160+ startParkedClocks(rt.state(), tabs, now);
121 const candidates = parkCandidates(tabs, rt.state(), settings, now);
161 const candidates = parkCandidates(tabs, rt.state(), settings, now);
122 report.candidates = candidates.length;
162 report.candidates = candidates.length;
123 // Auto parks whatever is stale; the threshold only decides when Ask mode puts a count on the badge.
163 // Auto parks whatever is stale; the threshold only decides when Ask mode puts a count on the badge.
@@ -133,8 +173,52 @@ export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport
133 return report;
173 return report;
134}
174}
135
175
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+
136async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
219async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
137 const settings = rt.settings();
220 const settings = rt.settings();
221+ const now = rt.now();
138 const moves: SweepMove[] = [];
222 const moves: SweepMove[] = [];
139 const byWindow = new Map<number, TidyTab[]>();
223 const byWindow = new Map<number, TidyTab[]>();
140 for (const t of candidates) byWindow.set(t.windowId, [...(byWindow.get(t.windowId) ?? []), t]);
224 for (const t of candidates) byWindow.set(t.windowId, [...(byWindow.get(t.windowId) ?? []), t]);
@@ -142,13 +226,15 @@ async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
142 const ids = tabs.map((t) => t.id);
226 const ids = tabs.map((t) => t.id);
143 const s = rt.state();
227 const s = rt.state();
144 for (const t of tabs) {
228 for (const t of tabs) {
145− moves.push({ tabId: t.id, groupId: t.groupId, index: t.index, windowId });
146− const rec = s.tabs[t.id];
147 const from = t.groupId !== -1 ? s.groups[t.groupId] : undefined;
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) });
231+ const rec = s.tabs[t.id];
232+ if (rec) {
148− if (rec) rec.parkedFrom = from ? labelOf(from.stripTitle || from.title) || undefined : undefined;
233+ rec.parkedFrom = from ? labelOf(from.stripTitle || from.title) || undefined : undefined;
234+ rec.parkedAt = now;
235+ }
149 }
236 }
150− let parking = Object.values(s.groups).find((g) => g.origin === "tidy" && g.windowId === windowId)?.id;
237+ let parking = await parkingGroupIn(rt, windowId);
151− if (parking !== undefined && !(await groupExists(parking))) parking = undefined;
152 if (parking === undefined) {
238 if (parking === undefined) {
153 parking = await createManagedGroup(rt, ids, windowId, "tidy", { title: parkedTitle(settings), color: "grey", collapsed: true }, { userNamed: true });
239 parking = await createManagedGroup(rt, ids, windowId, "tidy", { title: parkedTitle(settings), color: "grey", collapsed: true }, { userNamed: true });
154 } else {
240 } else {
@@ -162,12 +248,16 @@ async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
162 rt.log("could not move parking group to the end", e);
248 rt.log("could not move parking group to the end", e);
163 }
249 }
164 if (settings.discardParked) {
250 if (settings.discardParked) {
251+ // An extension's discard skips Chromium's own checks, so honour a tab marked "never discard".
165 for (const t of tabs) {
252 for (const t of tabs) {
166− if (!t.discarded) await chrome.tabs.discard(t.id).catch(() => undefined);
253+ if (!t.discarded && t.autoDiscardable !== false) await chrome.tabs.discard(t.id).catch(() => undefined);
167 }
254 }
168 }
255 }
169 }
256 }
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)) : [];
170− rt.state().lastSweep = { at: rt.now(), moves };
260+ rt.state().lastSweep = { at: now, moves: [...kept, ...moves] };
171 rt.commit();
261 rt.commit();
172 return moves.length;
262 return moves.length;
173}
263}
@@ -197,18 +287,40 @@ export async function undoSweep(rt: Runtime): Promise<number> {
197 const last = rt.state().lastSweep;
287 const last = rt.state().lastSweep;
198 if (!last || rt.now() - last.at > UNDO_WINDOW_MS) return 0;
288 if (!last || rt.now() - last.at > UNDO_WINDOW_MS) return 0;
199 const live = new Map((await chrome.tabs.query({})).map((t) => [t.id!, t]));
289 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+ };
200 let restored = 0;
298 let restored = 0;
201 for (const m of [...last.moves].sort((a, b) => a.index - b.index)) {
299 for (const m of [...last.moves].sort((a, b) => a.index - b.index)) {
202 const t = live.get(m.tabId);
300 const t = live.get(m.tabId);
203 if (!t) continue;
301 if (!t) continue;
204− if (m.groupId !== -1 && (await groupExists(m.groupId))) await addToGroup(rt, [m.tabId], m.groupId);
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
205− else await ungroup(rt, [m.tabId]);
310+ await ungroup(rt, [m.tabId]);
311+ }
206 await chrome.tabs.move(m.tabId, { windowId: m.windowId, index: m.index }).catch(() => undefined);
312 await chrome.tabs.move(m.tabId, { windowId: m.windowId, index: m.index }).catch(() => undefined);
207 // Moving a tab next to its old neighbours may pull it into a group they share; re-assert.
313 // Moving a tab next to its old neighbours may pull it into a group they share; re-assert.
208− if (m.groupId !== -1 && (await groupExists(m.groupId))) await addToGroup(rt, [m.tabId], m.groupId);
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+ }
209 const rec = rt.state().tabs[m.tabId];
320 const rec = rt.state().tabs[m.tabId];
210 if (rec) {
321 if (rec) {
211 rec.parkedFrom = undefined;
322 rec.parkedFrom = undefined;
323+ rec.parkedAt = undefined;
212 rec.lastActivatedAt = rt.now(); // un-parking is a touch: do not re-park on the next sweep
324 rec.lastActivatedAt = rt.now(); // un-parking is a touch: do not re-park on the next sweep
213 }
325 }
214 restored++;
326 restored++;
@@ -249,7 +361,7 @@ export async function restoreAll(rt: Runtime, windowId: number): Promise<number>
249 if (t.id !== undefined) ids.push(t.id);
361 if (t.id !== undefined) ids.push(t.id);
250 }
362 }
251 if (ids.length) {
363 if (ids.length) {
252− await createManagedGroup(rt, ids, windowId, "organize", { title: stripTitle("Restored", RESTORED_EMOJI, rt.settings().emoji), color: "grey" }, { userNamed: true });
364+ await createManagedGroup(rt, ids, windowId, "organize", { title: stripTitle("Restored", RESTORED_EMOJI, rt.settings().emoji), color: "grey" }, { userNamed: true, restored: true });
253 }
365 }
254 return ids.length;
366 return ids.length;
255}
367}
src/content/meta.ts+31−5
@@ -1,11 +1,37 @@
1/**
1/**
2− * Runs once at document_idle on http(s) pages: reads the page's description meta tag
2+ * Runs once at document_idle on http(s) pages and hands the worker a short summary of what the page is
3− * (falling back to og:description), trims it to 300 characters and hands it to the worker.
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.
4− * No DOM writes, no listeners, no page body text.
6+ * No DOM writes, no listeners, nothing from form fields.
5 */
7 */
6(() => {
8(() => {
7− const read = (sel: string) => document.querySelector<HTMLMetaElement>(sel)?.content?.replace(/\s+/g, " ").trim() ?? "";
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+
8− const description = (read('meta[name="description" i]') || read('meta[property="og:description" i]')).slice(0, 300);
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);
9 if (!description) return;
35 if (!description) return;
10 try {
36 try {
11 chrome.runtime.sendMessage({ kind: "meta", description }).catch(() => undefined);
37 chrome.runtime.sendMessage({ kind: "meta", description }).catch(() => undefined);
src/options/options.ts+1−1
@@ -40,7 +40,7 @@ const SECTIONS: Section[] = [
40 title: "Privacy",
40 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 Apple's on-device model sees when it names a group. It runs on this Mac; nothing leaves it.",
42 fields: [
42 fields: [
43− { key: "sendDescription", label: "Include page descriptions", help: "Off: titles and addresses only.", kind: "bool" },
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" },
44 { key: "sendFullUrl", label: "Include full addresses", help: "Off: just the site name.", kind: "bool" },
44 { key: "sendFullUrl", label: "Include full addresses", help: "Off: just the site name.", kind: "bool" },
45 ],
45 ],
46 },
46 },
src/shared/health.ts+4−2
@@ -46,7 +46,9 @@ export function notices(s: Health): Notice[] {
46 case "HOST_NOT_FOUND":
46 case "HOST_NOT_FOUND":
47 out.push({
47 out.push({
48 tone: "bad", title: "Finish setting up Diagonal",
48 tone: "bad", title: "Finish setting up Diagonal",
49+ body: chrome.runtime.getManifest().update_url
49− body: "Diagonal's helper isn't registered with this browser yet. Run this in Terminal, then reopen Diagonal.",
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",
50 // A Web Store install has an update_url and may never have had the cask; an unpacked one came from it.
52 // A Web Store install has an update_url and may never have had the cask; an unpacked one came from it.
51 command: chrome.runtime.getManifest().update_url ? "brew install iko-soy/tap/diagonal" : "brew reinstall diagonal",
53 command: chrome.runtime.getManifest().update_url ? "brew install iko-soy/tap/diagonal" : "brew reinstall diagonal",
52 });
54 });
@@ -80,7 +82,7 @@ export function notices(s: Health): Notice[] {
80 out.push({ tone: "bad", title: "Naming is paused", body: s.message || "The helper didn't answer. Diagonal retries on its own." });
82 out.push({ tone: "bad", title: "Naming is paused", body: s.message || "The helper didn't answer. Diagonal retries on its own." });
81 }
83 }
82 }
84 }
83− if (s.timeoutHint && s.health !== "degraded") {
85+ if (s.timeoutHint) {
84 out.push({ tone: "info", title: "The model is slow right now", body: "It timed out three times in a row. You can give it longer under Naming in Settings." });
86 out.push({ tone: "info", title: "The model is slow right now", body: "It timed out three times in a row. You can give it longer under Naming in Settings." });
85 }
87 }
86 return out;
88 return out;
src/shared/label.ts+49−8
@@ -7,9 +7,12 @@ export const LABEL_MAX_CHARS = 28;
7export const LABEL_MIN_WORDS = 2;
7export const LABEL_MIN_WORDS = 2;
8export const LABEL_MAX_WORDS = 4;
8export const LABEL_MAX_WORDS = 4;
9
9
10+// Quotes go; an apostrophe between two letters ("Children's", "Rock'n'roll") is part of the word and stays.
10−const QUOTES = /["'“”‘’«»`]/g;
11+const QUOTES = /(?<!\p{L})['’]|['’](?!\p{L})|["“”‘«»`]/gu;
11−const HAS_QUOTE = /["'“”‘’«»`]/;
12+const HAS_QUOTE = /(?<!\p{L})['’]|['’](?!\p{L})|["“”‘«»`]/u;
12−const TRAILING_PUNCT = /[\s.,;:!?…\-–—]+$/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"]);
13
16
14/** Repair what is repairable; return undefined when the label cannot be made valid. */
17/** Repair what is repairable; return undefined when the label cannot be made valid. */
15export function repairLabel(raw: unknown): string | undefined {
18export function repairLabel(raw: unknown): string | undefined {
@@ -17,19 +20,50 @@ export function repairLabel(raw: unknown): string | undefined {
17 let words = raw.replace(QUOTES, "").replace(/\s+/g, " ").trim().replace(TRAILING_PUNCT, "").split(" ").filter(Boolean);
20 let words = raw.replace(QUOTES, "").replace(/\s+/g, " ").trim().replace(TRAILING_PUNCT, "").split(" ").filter(Boolean);
18 words = words.filter((w) => !BANNED_WORDS.has(w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "")));
21 words = words.filter((w) => !BANNED_WORDS.has(w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "")));
19 if (words.length > LABEL_MAX_WORDS) words = words.slice(0, LABEL_MAX_WORDS);
22 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 {
20− while (words.length > LABEL_MIN_WORDS && words.join(" ").length > LABEL_MAX_CHARS) words.pop();
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();
21 let label = words.join(" ").replace(TRAILING_PUNCT, "");
30 let label = words.join(" ").replace(TRAILING_PUNCT, "");
22 if (!label) return undefined;
31 if (!label) return undefined;
32+ // "rust async" becomes "Rust async"; "iPhone tips" and "macOS updates" keep the brand's own casing.
23− label = label[0].toLocaleUpperCase() + label.slice(1);
33+ if (!/\p{Lu}/u.test(words[0].slice(1))) label = label[0].toLocaleUpperCase() + label.slice(1);
24 return isValidLabel(label) ? label : undefined;
34 return isValidLabel(label) ? label : undefined;
25}
35}
26
36
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+
27export function isValidLabel(label: string): boolean {
60export function isValidLabel(label: string): boolean {
28 const words = label.split(" ").filter(Boolean);
61 const words = label.split(" ").filter(Boolean);
62+ const cjk = noSpace(label);
29 return (
63 return (
30− label.length >= LABEL_MIN_CHARS &&
64+ label.length >= (cjk ? CJK_MIN_CHARS : LABEL_MIN_CHARS) &&
31− label.length <= LABEL_MAX_CHARS &&
65+ (cjk ? widthOf(label) <= CJK_MAX_CHARS && label.length <= LABEL_MAX_CHARS : label.length <= LABEL_MAX_CHARS) &&
32− words.length >= LABEL_MIN_WORDS &&
66+ words.length >= (cjk ? 1 : LABEL_MIN_WORDS) &&
33 words.length <= LABEL_MAX_WORDS &&
67 words.length <= LABEL_MAX_WORDS &&
34 !HAS_QUOTE.test(label) &&
68 !HAS_QUOTE.test(label) &&
35 !TRAILING_PUNCT.test(label) &&
69 !TRAILING_PUNCT.test(label) &&
@@ -53,3 +87,10 @@ export function labelOf(title: string | undefined): string {
53
87
54export const sameLabel = (a: string | undefined, b: string | undefined): boolean =>
88export const sameLabel = (a: string | undefined, b: string | undefined): boolean =>
55 !!a && !!b && labelOf(a).trim().toLocaleLowerCase() === labelOf(b).trim().toLocaleLowerCase();
89 !!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+90−2
@@ -15,6 +15,7 @@ let state: State;
15let calls: OrganizePayload[];
15let calls: OrganizePayload[];
16let replies: HostReply<unknown>[];
16let replies: HostReply<unknown>[];
17let fallbacks: number[];
17let fallbacks: number[];
18+let respond: ((payload: OrganizePayload, opts?: object) => HostReply<unknown> | undefined) | undefined;
18
19
19const liveTab = (id: number, over: Partial<LiveTab> = {}): LiveTab => ({
20const liveTab = (id: number, over: Partial<LiveTab> = {}): LiveTab => ({
20 id, windowId: 1, index: id, groupId: -1, url: `https://site${id}.com/page`, title: `Tab ${id}`, status: "complete", pinned: false, ...over,
21 id, windowId: 1, index: id, groupId: -1, url: `https://site${id}.com/page`, title: `Tab ${id}`, status: "complete", pinned: false, ...over,
@@ -32,9 +33,9 @@ function setup(tabs: LiveTab[]): AutoOrganizer {
32 settings: () => makeSettings(),
33 settings: () => makeSettings(),
33 commit: () => undefined,
34 commit: () => undefined,
34 now: () => 1_000_000,
35 now: () => 1_000_000,
35− host: async <T,>(_op: string, payload: object) => {
36+ host: async <T,>(_op: string, payload: object, opts?: object) => {
36 calls.push(JSON.parse(JSON.stringify(payload)));
37 calls.push(JSON.parse(JSON.stringify(payload)));
37− return (replies.shift() ?? { ok: true, result: { groups: [], leftovers: [] } }) as HostReply<T>;
38+ return (respond?.(payload as OrganizePayload, opts) ?? replies.shift() ?? { ok: true, result: { groups: [], leftovers: [] } }) as HostReply<T>;
38 },
39 },
39 naming: { touch: () => undefined } as unknown as Runtime["naming"],
40 naming: { touch: () => undefined } as unknown as Runtime["naming"],
40 refreshBadge: () => undefined,
41 refreshBadge: () => undefined,
@@ -48,6 +49,7 @@ beforeEach(() => {
48 calls = [];
49 calls = [];
49 replies = [];
50 replies = [];
50 fallbacks = [];
51 fallbacks = [];
52+ respond = undefined;
51 nextGroup = 100;
53 nextGroup = 100;
52 (globalThis as any).chrome = {
54 (globalThis as any).chrome = {
53 tabs: {
55 tabs: {
@@ -92,6 +94,7 @@ describe("auto-organize", () => {
92 await auto.run(1);
94 await auto.run(1);
93 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 3", "Tab 2"]);
95 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 3", "Tab 2"]);
94 expect(calls[0].existingGroups).toEqual([expect.objectContaining({ g: 0, title: "Rust" })]);
96 expect(calls[0].existingGroups).toEqual([expect.objectContaining({ g: 0, title: "Rust" })]);
97+ expect(calls[0].siblingTitles).toEqual(["Rust"]);
95 expect(live.find((t) => t.id === 3)!.groupId).toBe(50);
98 expect(live.find((t) => t.id === 3)!.groupId).toBe(50);
96 });
99 });
97
100
@@ -109,6 +112,12 @@ describe("auto-organize", () => {
109 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 5", "Tab 6"]);
112 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 5", "Tab 6"]);
110 });
113 });
111
114
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+
112 it("one loose tab with nothing to join waits for company", async () => {
121 it("one loose tab with nothing to join waits for company", async () => {
113 const auto = setup([liveTab(1)]);
122 const auto = setup([liveTab(1)]);
114 await auto.run(1);
123 await auto.run(1);
@@ -143,4 +152,83 @@ describe("auto-organize", () => {
143 vi.useRealTimers();
152 vi.useRealTimers();
144 }
153 }
145 });
154 });
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+ });
146});
234});
test/e2e/smoke-fm.py+8−11
@@ -19,7 +19,7 @@ def topic_of(text):
19
19
20
20
21def items(prompt):
21def items(prompt):
22− body = prompt.split("Tabs (index | title | address | description):", 1)[-1]
22+ body = prompt.split("Tabs (index | title | address | what the page says):", 1)[-1]
23 out = []
23 out = []
24 for line in body.strip().splitlines():
24 for line in body.strip().splitlines():
25 m = re.match(r"^(\d+) \| (.*)$", line)
25 m = re.match(r"^(\d+) \| (.*)$", line)
@@ -39,22 +39,19 @@ def main():
39 print(json.dumps({"schema": argv[1:]}))
39 print(json.dumps({"schema": argv[1:]}))
40 return
40 return
41 if argv[0] == "respond":
41 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 ""
42 prompt = sys.stdin.read()
44 prompt = sys.stdin.read()
43− if prompt.startswith("You name"):
45+ if instructions.startswith("You name"):
44 found = topic_of(prompt.split("Tabs (", 1)[-1]) or ("Reading list", "📚", "grey")
46 found = topic_of(prompt.split("Tabs (", 1)[-1]) or ("Reading list", "📚", "grey")
45 print(json.dumps({"title": found[0], "emoji": found[1]}))
47 print(json.dumps({"title": found[0], "emoji": found[1]}))
46 return
48 return
47− if prompt.startswith("You sort"):
49+ if instructions.startswith("You label"):
48− buckets = {}
50+ tabs = []
49− leftovers = []
50 for i, rest in items(prompt):
51 for i, rest in items(prompt):
51 t = topic_of(rest)
52 t = topic_of(rest)
52− if t:
53+ tabs.append({"index": i, "topic": t[0].split()[0] if t else f"Other {i}"})
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}))
54+ print(json.dumps({"tabs": tabs}))
58 return
55 return
59 sys.stderr.write("unexpected fm call\n")
56 sys.stderr.write("unexpected fm call\n")
60 sys.exit(1)
57 sys.exit(1)
test/e2e/smoke.mjs+27
@@ -47,6 +47,8 @@ const pages = {
47 "/pasta": ["Pasta recipe", "A weeknight pasta recipe.", ""],
47 "/pasta": ["Pasta recipe", "A weeknight pasta recipe.", ""],
48 "/cook": ["How to cook rice", "Cook rice perfectly.", ""],
48 "/cook": ["How to cook rice", "Cook rice perfectly.", ""],
49 "/misc": ["Something else", "Unrelated page.", ""],
49 "/misc": ["Something else", "Unrelated page.", ""],
50+ "/flight": ["Flights to Lisbon", "Cheap flights to Lisbon.", ""],
51+ "/risotto": ["Mushroom risotto recipe", "A creamy risotto recipe.", ""],
50};
52};
51const server = createServer((req, res) => {
53const server = createServer((req, res) => {
52 const p = pages[req.url.split("?")[0]];
54 const p = pages[req.url.split("?")[0]];
@@ -186,6 +188,31 @@ try {
186 const pasta = await tabIn(win2, "/pasta");
188 const pasta = await tabIn(win2, "/pasta");
187 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 }));
189 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 }));
188
190
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+
189 // 7. Popup renders the healthy state.
216 // 7. Popup renders the healthy state.
190 await popup.setViewportSize({ width: 360, height: 560 });
217 await popup.setViewportSize({ width: 360, height: 560 });
191 await popup.reload();
218 await popup.reload();
test/engine.test.ts+185−1
@@ -167,7 +167,10 @@ describe("dirty triggers", () => {
167
167
168 it("title change and path change mark the group dirty", () => {
168 it("title change and path change mark the group dirty", () => {
169 expect(step(base(), { type: "tabUpdated", tab: tab(1, { groupId: 10, title: "New" }) }, ctx()).actions).toEqual([{ type: "dirty", groupId: 10 }]);
169 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([{ 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+ ]);
171 });
174 });
172
175
173 it("reload, fragment and query changes do not", () => {
176 it("reload, fragment and query changes do not", () => {
@@ -237,6 +240,14 @@ describe("auto-organize triggers", () => {
237 expect(actions).toContainEqual({ type: "loose", windowId: 1 });
240 expect(actions).toContainEqual({ type: "loose", windowId: 1 });
238 });
241 });
239
242
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+
240 it("opening a parked tab takes it out of Parked", () => {
251 it("opening a parked tab takes it out of Parked", () => {
241 const s = stateWith([tab(1, { groupId: 20 }), tab(2, { groupId: 20 })], [{ id: 20, origin: "tidy" }]);
252 const s = stateWith([tab(1, { groupId: 20 }), tab(2, { groupId: 20 })], [{ id: 20, origin: "tidy" }]);
242 s.tabs[1].parkedFrom = "Rust async";
253 s.tabs[1].parkedFrom = "Rust async";
@@ -252,3 +263,176 @@ describe("auto-organize triggers", () => {
252 expect(state.tabs[1].lastActivatedAt).toBe(5_000_000);
263 expect(state.tabs[1].lastActivatedAt).toBe(5_000_000);
253 });
264 });
254});
265});
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.tsnew file+18
@@ -0,0 +1,18 @@
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+80−2
@@ -50,6 +50,10 @@ describe("evolving-name policy", () => {
50 it("same label is never rewritten", () => {
50 it("same label is never rewritten", () => {
51 expect(shouldApply(g(), "rust async runtimes", ["a", "b", "c"])).toBe(false);
51 expect(shouldApply(g(), "rust async runtimes", ["a", "b", "c"])).toBe(false);
52 });
52 });
53+ it("a member that only moved within its page is not a membership change", () => {
54+ const g2 = newGroupRecord(1, 1, "opener", "blue", { title: "Rust async runtimes", lastNamedAt: 1, memberUrls: ["https://a.com/x", "https://b.com/y"] });
55+ expect(shouldApply(g2, "Tokio and async-std", ["https://a.com/x?page=2", "https://b.com/y#top"])).toBe(false);
56+ });
53 it("user-named groups are skipped", () => {
57 it("user-named groups are skipped", () => {
54 expect(shouldApply({ ...g(), userNamed: true }, "Anything else", ["x"])).toBe(false);
58 expect(shouldApply({ ...g(), userNamed: true }, "Anything else", ["x"])).toBe(false);
55 });
59 });
@@ -151,15 +155,33 @@ describe("naming queue", () => {
151 expect(state.groups[7].dirty).toBe(false);
155 expect(state.groups[7].dirty).toBe(false);
152 });
156 });
153
157
154− it("waits while a member is still loading or the group has one tab", async () => {
158+ it("waits while a member is still loading", async () => {
155 members = [member(1), member(2, { status: "loading" })];
159 members = [member(1), member(2, { status: "loading" })];
156 naming.touch(7);
160 naming.touch(7);
157 await settle();
161 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 () => {
158 members = [member(1)];
167 members = [member(1)];
159 naming.touch(7);
168 naming.touch(7);
160 await settle();
169 await settle();
161 expect(calls).toHaveLength(0);
170 expect(calls).toHaveLength(0);
162− expect(state.groups[7].dirty).toBe(true);
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);
163 });
185 });
164
186
165 it("new-tab pages do not count as members", async () => {
187 it("new-tab pages do not count as members", async () => {
@@ -198,6 +220,54 @@ describe("naming queue", () => {
198 expect(state.groups[7].nextAttemptAt).toBe(now + 24 * 3_600_000);
220 expect(state.groups[7].nextAttemptAt).toBe(now + 24 * 3_600_000);
199 });
221 });
200
222
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+
201 it("failures back off 30 s, 2 min, 10 min, then hourly; the title is left alone", async () => {
271 it("failures back off 30 s, 2 min, 10 min, then hourly; the title is left alone", async () => {
202 const fail: HostReply<NameResult> = { ok: false, error: { code: "FM_ERROR", message: "boom" } };
272 const fail: HostReply<NameResult> = { ok: false, error: { code: "FM_ERROR", message: "boom" } };
203 replies.push(fail);
273 replies.push(fail);
@@ -229,6 +299,14 @@ describe("naming queue", () => {
229 expect(writes).toEqual([[7, "🦀 Rust crates"]]);
299 expect(writes).toEqual([[7, "🦀 Rust crates"]]);
230 });
300 });
231
301
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+
232 it("a group renamed by hand while the model runs keeps the user's title", async () => {
310 it("a group renamed by hand while the model runs keeps the user's title", async () => {
233 const n = new Naming({
311 const n = new Naming({
234 ...(naming as any).d,
312 ...(naming as any).d,
test/organize.test.ts+24
@@ -21,6 +21,13 @@ describe("batching", () => {
21 expect(b.flat()).toHaveLength(30);
21 expect(b.flat()).toHaveLength(30);
22 });
22 });
23
23
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+
24 it("maxGroups = min(8, ceil(items / 3))", () => {
31 it("maxGroups = min(8, ceil(items / 3))", () => {
25 expect(maxGroupsFor(18)).toBe(6);
32 expect(maxGroupsFor(18)).toBe(6);
26 expect(maxGroupsFor(4)).toBe(2);
33 expect(maxGroupsFor(4)).toBe(2);
@@ -100,6 +107,23 @@ describe("existing groups", () => {
100 expect(list[0]).toEqual({ g: 0, title: "Trip 0", samples: ["Hotel"] });
107 expect(list[0]).toEqual({ g: 0, title: "Trip 0", samples: ["Hotel"] });
101 });
108 });
102
109
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+
103 it("groups created by batch n come first for batch n+1", () => {
127 it("groups created by batch n come first for batch n+1", () => {
104 const s = emptyState();
128 const s = emptyState();
105 for (let i = 0; i < 15; i++) s.groups[i] = newGroupRecord(i, 1, "organize", "red", { stripTitle: `G ${i}` });
129 for (let i = 0; i < 15; i++) s.groups[i] = newGroupRecord(i, 1, "organize", "red", { stripTitle: `G ${i}` });
test/sweep.test.tsnew file+181
@@ -0,0 +1,181 @@
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+34−3
@@ -34,6 +34,19 @@ describe("park eligibility", () => {
34 expect(isParkCandidate(tab, s, cfg, NOW)).toBe(expected);
34 expect(isParkCandidate(tab, s, cfg, NOW)).toBe(expected);
35 });
35 });
36
36
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+
37 it("user groups are eligible when 'Tidy inside my own groups' is on", () => {
50 it("user groups are eligible when 'Tidy inside my own groups' is on", () => {
38 expect(isParkCandidate(tt({ groupId: 10 }), s, settings({ tidyUserGroups: true }), NOW)).toBe(true);
51 expect(isParkCandidate(tt({ groupId: 10 }), s, settings({ tidyUserGroups: true }), NOW)).toBe(true);
39 });
52 });
@@ -51,14 +64,32 @@ describe("park eligibility", () => {
51});
64});
52
65
53describe("archive", () => {
66describe("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+
54− it("parked tabs idle past archiveAfter are archived; 0 means never", () => {
69+ it("parked tabs that sat in Parked past archiveAfter are archived; 0 means never", () => {
55 const s = emptyState();
70 const s = emptyState();
56 s.groups[12] = newGroupRecord(12, 1, "tidy", "grey");
71 s.groups[12] = newGroupRecord(12, 1, "tidy", "grey");
72+ s.tabs[1] = record(1, { parkedAt: NOW - 49 * HOUR });
57− const parked = tt({ groupId: 12, lastAccessed: NOW - 49 * HOUR });
73+ const parked = tt({ groupId: 12, lastAccessed: NOW - 60 * HOUR });
58− expect(archiveCandidates([parked, tt()], s, settings(), NOW)).toEqual([parked]);
74+ expect(archiveCandidates([parked, tt({ id: 2 })], s, settings(), NOW)).toEqual([parked]);
59 expect(archiveCandidates([parked], s, settings({ archiveAfterHours: 0 }), NOW)).toEqual([]);
75 expect(archiveCandidates([parked], s, settings({ archiveAfterHours: 0 }), NOW)).toEqual([]);
60 });
76 });
61
77
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+
62 it("is newest first and capped, oldest dropped", () => {
93 it("is newest first and capped, oldest dropped", () => {
63 const entry = (n: number): ArchivedTab => ({ url: `u${n}`, title: `t${n}`, archivedAt: n, lastActivatedAt: 0 });
94 const entry = (n: number): ArchivedTab => ({ url: `u${n}`, title: `t${n}`, archivedAt: n, lastActivatedAt: 0 });
64 const existing = [3, 2, 1].map(entry);
95 const existing = [3, 2, 1].map(entry);
test/url.test.ts+20−13
@@ -1,7 +1,8 @@
1import { describe, expect, it } from "vitest";
1import { describe, expect, it } from "vitest";
2−import { isValidLabel, labelOf, repairLabel, safeEmoji } from "../src/shared/label";
2+import { isValidLabel, labelOf, repairLabel, safeEmoji, titleKey } from "../src/shared/label";
3import { colorFor, hostname, isInternalUrl, promptAddress, promptUrl, provisionalTitle, registrableDomain, trimText } from "../src/shared/url";
3import { colorFor, hostname, isInternalUrl, promptAddress, promptUrl, provisionalTitle, registrableDomain, trimText } from "../src/shared/url";
4import { withDefaults } from "../src/background/settings";
4import { withDefaults } from "../src/background/settings";
5+import labelCases from "../host/tests/golden/labels.json";
5
6
6describe("url helpers", () => {
7describe("url helpers", () => {
7 it("strips www.", () => {
8 it("strips www.", () => {
@@ -45,24 +46,21 @@ describe("url helpers", () => {
45 });
46 });
46});
47});
47
48
48−describe("labels", () => {
49+// The same cases run against the host's validate.repair_label, so the two sides agree on every label.
49− it.each([
50+const LABEL_CASES = labelCases as [string, string | null][];
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
51
59− it.each(["Tabs", "Misc stuff", "", "x", "Supercalifragilisticexpialidocious extraordinarily"])("rejects %s", (raw) => {
52+describe("labels", () => {
53+ it.each(LABEL_CASES)("repairs %s", (raw, fixed) => {
60− expect(repairLabel(raw)).toBeUndefined();
54+ expect(repairLabel(raw) ?? null).toBe(fixed);
61 });
55 });
62
56
63 it("validates", () => {
57 it("validates", () => {
64 expect(isValidLabel("Lisbon trip")).toBe(true);
58 expect(isValidLabel("Lisbon trip")).toBe(true);
65 expect(isValidLabel("Lisbon")).toBe(false);
59 expect(isValidLabel("Lisbon")).toBe(false);
60+ expect(isValidLabel("日本旅行")).toBe(true);
61+ expect(isValidLabel("京都の旅行計画")).toBe(true);
62+ expect(isValidLabel("日")).toBe(false);
63+ expect(repairLabel("日本旅行。")).toBe("日本旅行");
66 });
64 });
67
65
68 it("emoji outside the list fall back; FE0F is optional", () => {
66 it("emoji outside the list fall back; FE0F is optional", () => {
@@ -70,6 +68,15 @@ describe("labels", () => {
70 expect(safeEmoji("🦄")).toBe("🧭");
68 expect(safeEmoji("🦄")).toBe("🧭");
71 });
69 });
72
70
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+
73 it("labelOf strips our emoji prefix", () => {
80 it("labelOf strips our emoji prefix", () => {
74 expect(labelOf("🦀 Rust async")).toBe("Rust async");
81 expect(labelOf("🦀 Rust async")).toBe("Rust async");
75 expect(labelOf("Plain title")).toBe("Plain title");
82 expect(labelOf("Plain title")).toBe("Plain title");
tsconfig.json+1
@@ -9,6 +9,7 @@
9 "noEmit": true,
9 "noEmit": true,
10 "skipLibCheck": true,
10 "skipLibCheck": true,
11 "isolatedModules": true,
11 "isolatedModules": true,
12+ "resolveJsonModule": true,
12 "noFallthroughCasesInSwitch": true,
13 "noFallthroughCasesInSwitch": true,
13 "forceConsistentCasingInFileNames": true
14 "forceConsistentCasingInFileNames": true
14 },
15 },