on master help sign in
Fit the host to the real fm on macOS 27
92b9721Claude
PRIVACY.md+1−2
@@ -8,8 +8,7 @@ For each normal (not incognito) tab: its title, its address and the page's `<met
8
8
9## Where it goes
9## Where it goes
10
10
11−- **To Apple's on-device model on your Mac.** Diagonal passes that text to Apple's `fm` tool through a small helper program on your Mac (the native messaging host installed with `brew install iko-soy/tap/diagonal`). By default the model runs on your Mac and nothing leaves it.
11+Only to Apple's on-device model on your Mac. Diagonal passes that text to Apple's `fm` tool through a small helper program on your Mac (the native messaging host installed with `brew install iko-soy/tap/diagonal`). The model runs on the Mac, and nothing leaves it.
12−- **To Apple's Private Cloud Compute, only if you choose it.** Settings → Privacy → Model offers Apple's larger server model. With that picked, the same text goes to Apple under Apple's Private Cloud Compute terms. It is off by default.
13
12
14Diagonal has no server of its own. It sends nothing to its developers and has no analytics. The only other request is the browser loading each archived tab's site icon from that site when you open the popup.
13Diagonal has no server of its own. It sends nothing to its developers and has no analytics. The only other request is the browser loading each archived tab's site icon from that site when you open the popup.
15
14
README.md+11−8
@@ -130,13 +130,16 @@ Every push to `master` runs `.github/workflows/release.yml`: it typechecks, runs
130- **An Organize group whose title fails validation is still created,** with the hostname as a placeholder, and the naming loop names it.
130- **An Organize group whose title fails validation is still created,** with the hostname as a placeholder, and the naming loop names it.
131- **Restarts.** Tab and group IDs change when the browser restarts, so records are matched to live groups by title and colour, which keeps managed groups managed.
131- **Restarts.** Tab and group IDs change when the browser restarts, so records are matched to live groups by title and colour, which keeps managed groups managed.
132
132
133−## Not verified on a real Mac yet
133+## How fm behaves on macOS 27
134
134
135−Everything was tested against a fake `fm`. These are open until someone runs it on macOS 27 (section 16 of the spec):
135+Checked on a Mac running macOS 27.0 (26A428) on 2026-09-24; the tests' fake `fm` follows it.
136
136
137−- **Confirmed on a Mac:** `fm` is at `/usr/bin/fm`, and until an admin runs `sudo fm license` every `fm` command exits 69 with a terms notice. The installer offers the terms in the terminal; if they are declined, Diagonal reports `LICENSE_REQUIRED` with the fix. The host writes its schemas on first use, so install order does not matter.
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−- The `fm schema` flags. `diagonal-host --install-schemas` tries the nested organize schema and falls back to the two flat schemas plus two calls if that fails. The flags are in `SCHEMA_COMMANDS` at the top of `host/diagonal-host.py`.
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` stderr wording. `classify()` matches keywords, so an unexpected message shows as `FM_ERROR` with the raw text on the settings page.
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.
140−- Whether `fm respond` can read the prompt from stdin. Until then, the prompt is an argument and is briefly visible in `ps`.
140+- The only model is `system` (`--model pcc` is rejected), so Diagonal always uses the on-device model.
141−- The context window. `CHAR_BUDGET` is 10,000 characters, assuming 4,096 tokens. Double it (and `NAME_ITEM_CAP` / `ORGANIZE_ITEM_CAP`) once 8,192 is confirmed.
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.
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`.
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.
144+
142−- The Nix flake builds both packages on x86_64-linux, and `.github/workflows/nix.yml` builds them on Linux and Apple silicon on every push. The home-manager module has not been activated on a real Mac.
145+Still open: the Nix flake builds both packages on Linux and Apple silicon in CI, but the home-manager module has not been activated on a real Mac.
docs/store/screenshot-settings.png

Binary file not shown.

host/diagonal-host.py+67−14
@@ -36,19 +36,25 @@ 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−CHAR_BUDGET = 10_000 # ≈ 2,850 tokens of prompt at 3.5 chars/token (section 9); double once 8,192 is confirmed
39+# fm's context is about 8,000 tokens for prompt and reply together (measured on macOS 27: 38,000 chars of
40+# prompt worked, 40,000 overflowed, ~4.7 chars/token). 24,000 chars leaves room for the reply and dense text.
41+CHAR_BUDGET = 24_000
40MAX_REQUEST = 64 * 1024 * 1024
42MAX_REQUEST = 64 * 1024 * 1024
41MAX_REPLY = 1024 * 1024
43MAX_REPLY = 1024 * 1024
42OPS = {"ping", "name", "organize"}
44OPS = {"ping", "name", "organize"}
43
45
44−# `fm schema` argument lists (section 8). The nested form is unverified on a real macOS 27 machine;
46+# `fm schema` argument lists (section 8), checked against fm on macOS 27. `--object NAME` takes the
47+# nested object's schema as JSON from `--schema`; "{Group}" is replaced by the output of SUB_SCHEMAS["Group"].
45−# when it fails, --install-schemas writes the two flat schemas and `organize` takes two calls.
48+# If the nested form fails, --install-schemas writes the two flat schemas and `organize` takes two calls.
49+SUB_SCHEMAS = {
50+ "Group": ["schema", "object", "--name", "Group",
51+ "--string", "title", "--string", "emoji", "--string", "color",
52+ "--integer", "existing", "--optional", "--integer", "members", "--array"],
53+}
46SCHEMA_COMMANDS = {
54SCHEMA_COMMANDS = {
47 "name.json": ["schema", "object", "--name", "GroupName", "--string", "title", "--string", "emoji"],
55 "name.json": ["schema", "object", "--name", "GroupName", "--string", "title", "--string", "emoji"],
48 "organize.json": ["schema", "object", "--name", "Organized",
56 "organize.json": ["schema", "object", "--name", "Organized",
49− "--object", "groups", "--array",
57+ "--object", "groups", "--schema", "{Group}", "--array",
50− "--string", "title", "--string", "emoji", "--string", "color",
51− "--integer", "existing", "--integer", "members", "--array",
52 "--integer", "leftovers", "--array"],
58 "--integer", "leftovers", "--array"],
53}
59}
54FALLBACK_SCHEMA_COMMANDS = {
60FALLBACK_SCHEMA_COMMANDS = {
@@ -146,9 +152,8 @@ def validate_request(req):
146 {"g": n, "title": _trim(g.get("title"), 60), "samples": [_trim(s, 60) for s in (g.get("samples") or [])[:2]]}
152 {"g": n, "title": _trim(g.get("title"), 60), "samples": [_trim(s, 60) for s in (g.get("samples") or [])[:2]]}
147 for n, g in enumerate(groups[:12]) if isinstance(g, dict)
153 for n, g in enumerate(groups[:12]) if isinstance(g, dict)
148 ]
154 ]
149− model = opts.get("model", "system")
155+ # fm on macOS 27 only has the on-device model; older extensions may still ask for "pcc".
150− if model not in ("system", "pcc"):
156+ opts["model"] = "system"
151− raise Fail("BAD_REQUEST", "opts.model must be system or pcc")
152 return req
157 return req
153
158
154
159
@@ -181,7 +186,7 @@ def classify(stderr, returncode=None):
181
186
182
187
183def fm_env():
188def fm_env():
184− return {"PATH": "/usr/bin:/bin", "HOME": os.environ.get("HOME", ""), "LANG": "en_US.UTF-8"}
189+ return {"PATH": "/usr/bin:/bin", "HOME": os.environ.get("HOME", ""), "LANG": "en_US.UTF-8", "NO_COLOR": "1"}
185
190
186
191
187def schema_path(name):
192def schema_path(name):
@@ -212,10 +217,26 @@ def extract_json(stdout):
212 raise Fail("BAD_MODEL_OUTPUT", "no JSON object in fm output", raw=text[:2000])
217 raise Fail("BAD_MODEL_OUTPUT", "no JSON object in fm output", raw=text[:2000])
213
218
214
219
220+# 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.
222+PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
223+
224+
215def run_fm(prompt, schema, model, timeout_s):
225def run_fm(prompt, schema, model, timeout_s):
216− args = [FM, "respond", "--model", model, "--no-stream", "--schema", schema, "--", prompt]
217 try:
226 try:
227+ return _run_fm(prompt, schema, model, timeout_s, [])
228+ except Fail as f:
229+ if f.code != "GUARDRAIL":
230+ raise
231+ return _run_fm(prompt, schema, model, timeout_s, PERMISSIVE)
232+
233+
234+def _run_fm(prompt, schema, model, timeout_s, extra):
235+ # The prompt goes on stdin (fm reads it there when no prompt argument is given), so tab titles never
236+ # show up in the process list. fm has no timeout of its own; subprocess enforces ours.
237+ args = [FM, "respond", "--model", model, "--no-stream", "--schema", schema, *extra]
238+ try:
218− p = subprocess.run(args, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
239+ p = subprocess.run(args, input=prompt, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
219 except FileNotFoundError:
240 except FileNotFoundError:
220 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
241 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
221 except subprocess.TimeoutExpired:
242 except subprocess.TimeoutExpired:
@@ -264,6 +285,19 @@ def op_ping(_payload, _opts):
264 return reply
285 return reply
265
286
266
287
288+# Bumped when SCHEMA_COMMANDS change, so schemas written by an older host are rewritten. Version 2: the
289+# nested organize schema in the form fm actually accepts (older hosts fell back to two calls).
290+SCHEMA_VERSION = "2"
291+
292+
293+def schemas_current():
294+ try:
295+ with open(os.path.join(SCHEMAS, "version"), encoding="utf-8") as f:
296+ return f.read().strip() == SCHEMA_VERSION
297+ except OSError:
298+ return False
299+
300+
267def schemas_ok():
301def schemas_ok():
268 return bool(schema_path("name.json")) and organize_mode() != "missing"
302 return bool(schema_path("name.json")) and organize_mode() != "missing"
269
303
@@ -271,7 +305,7 @@ def schemas_ok():
271def ensure_schemas():
305def ensure_schemas():
272 """Write the schema files on first use, so install order does not matter (e.g. the fm terms were
306 """Write the schema files on first use, so install order does not matter (e.g. the fm terms were
273 accepted after the installer ran). stdout is the native-messaging channel: progress lines are swallowed."""
307 accepted after the installer ran). stdout is the native-messaging channel: progress lines are swallowed."""
274− if schemas_ok():
308+ if schemas_ok() and schemas_current():
275 return
309 return
276 out = io.StringIO()
310 out = io.StringIO()
277 with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
311 with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
@@ -332,7 +366,7 @@ def handle(req):
332 raw_opts = req.get("opts") or {}
366 raw_opts = req.get("opts") or {}
333 timeout_ms = raw_opts.get("timeoutMs", 45000)
367 timeout_ms = raw_opts.get("timeoutMs", 45000)
334 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
368 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
335− opts = {"model": raw_opts.get("model", "system"), "strict": bool(raw_opts.get("strict")),
369+ opts = {"model": "system", "strict": bool(raw_opts.get("strict")),
336 "timeout_s": max(5.0, min(120.0, timeout_ms / 1000))}
370 "timeout_s": max(5.0, min(120.0, timeout_ms / 1000))}
337 op = req["op"]
371 op = req["op"]
338 if op == "ping":
372 if op == "ping":
@@ -382,6 +416,22 @@ def install_schemas():
382
416
383 def write(name, args):
417 def write(name, args):
384 """True when written; "license" or "rejected" (fm ran and refused) or "error" otherwise."""
418 """True when written; "license" or "rejected" (fm ran and refused) or "error" otherwise."""
419+ subs = {}
420+ for key, sub_args in SUB_SCHEMAS.items():
421+ if "{%s}" % key not in args:
422+ continue
423+ try:
424+ p = subprocess.run([FM, *sub_args], capture_output=True, text=True, timeout=30, env=fm_env())
425+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
426+ print(f" {name}: fm failed ({e})", file=sys.stderr)
427+ return "error"
428+ if license_needed(p.returncode, p.stdout + p.stderr):
429+ return "license"
430+ if p.returncode != 0 or not p.stdout.strip():
431+ print(f" {name}: fm schema ({key}) exited {p.returncode}: {p.stderr.strip()[:200]}", file=sys.stderr)
432+ return "rejected"
433+ subs["{%s}" % key] = p.stdout.strip()
434+ args = [subs.get(a, a) for a in args]
385 try:
435 try:
386 p = subprocess.run([FM, *args], capture_output=True, text=True, timeout=30, env=fm_env())
436 p = subprocess.run([FM, *args], capture_output=True, text=True, timeout=30, env=fm_env())
387 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
437 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
@@ -421,6 +471,9 @@ def install_schemas():
421 pass
471 pass
422 for name, args in FALLBACK_SCHEMA_COMMANDS.items():
472 for name, args in FALLBACK_SCHEMA_COMMANDS.items():
423 ok &= write(name, args) is True
473 ok &= write(name, args) is True
474+ if ok:
475+ with open(os.path.join(SCHEMAS, "version"), "w", encoding="utf-8") as f:
476+ f.write(SCHEMA_VERSION + "\n")
424 return 0 if ok else 1
477 return 0 if ok else 1
425
478
426
479
host/prompts.py+1
@@ -69,6 +69,7 @@ def _organize_rules(max_groups, strict):
69 "- Put each tab index into exactly one group, or into leftovers if it fits nowhere.",
69 "- Put each tab index into exactly one group, or into leftovers if it fits nowhere.",
70 f"- A group needs at least 2 tabs. Prefer fewer, broader groups; at most {max_groups} new groups.",
70 f"- A group needs at least 2 tabs. Prefer fewer, broader groups; at most {max_groups} new groups.",
71 '- Reuse an existing group when a tab fits it: set "existing" to that group\'s number and leave title, emoji and color empty.',
71 '- Reuse an existing group when a tab fits it: set "existing" to that group\'s number and leave title, emoji and color empty.',
72+ '- For a new group, leave "existing" out.',
72 f"- New groups: title of 2 to 4 words in sentence case, one emoji from: {EMOJI_LIST}, one color from: {', '.join(COLORS)}.",
73 f"- New groups: title of 2 to 4 words in sentence case, one emoji from: {EMOJI_LIST}, one color from: {', '.join(COLORS)}.",
73 "- Group by topic or task, not by website, unless the tabs are the same site and nothing else links them.",
74 "- Group by topic or task, not by website, unless the tabs are the same site and nothing else links them.",
74 "- Write titles in the language most tab titles use.",
75 "- Write titles in the language most tab titles use.",
host/tests/fake_fm.py+10−1
@@ -7,7 +7,9 @@ control.json keys:
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 license: true → every command exits 69 with the terms notice, as fm does before `sudo fm license`
9 license: true → every command exits 69 with the terms notice, as fm does before `sudo fm license`
10−Every invocation's argv is appended to argv.log as one JSON line.
10+Every 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+--model is "system".
11"""
13"""
12import json
14import json
13import os
15import os
@@ -41,14 +43,21 @@ def main():
41 spec = {"stderr": LICENSE_NOTICE, "exit": 69}
43 spec = {"stderr": LICENSE_NOTICE, "exit": 69}
42 elif cmd == "available":
44 elif cmd == "available":
43 spec = control.get("available", {"stdout": "available", "exit": 0})
45 spec = control.get("available", {"stdout": "available", "exit": 0})
46+ elif "--model" in argv and argv[argv.index("--model") + 1] != "system":
47+ spec = {"stderr": f"Error: The value '{argv[argv.index('--model') + 1]}' is invalid for '--model <model>'. Please provide one of 'system'.\n", "exit": 64}
44 elif cmd == "schema":
48 elif cmd == "schema":
45 if control.get("schema_fail"):
49 if control.get("schema_fail"):
46 spec = {"stderr": "error: schema generation failed", "exit": 1}
50 spec = {"stderr": "error: schema generation failed", "exit": 1}
47 elif "--object" in argv and control.get("schema_nested", True) is False:
51 elif "--object" in argv and control.get("schema_nested", True) is False:
48 spec = {"stderr": "error: unknown option --object", "exit": 64}
52 spec = {"stderr": "error: unknown option --object", "exit": 64}
53+ elif "--object" in argv and argv[argv.index("--object") + 2:argv.index("--object") + 3] != ["--schema"]:
54+ name = argv[argv.index("--object") + 1]
55+ spec = {"stderr": f"Error: SchemaError(errorDescription: Optional(\"--object '{name}' must be followed by --schema\"))\n", "exit": 1}
49 else:
56 else:
50 spec = {"stdout": json.dumps({"schema": argv[3] if len(argv) > 3 else "x", "args": argv[1:]}), "exit": 0}
57 spec = {"stdout": json.dumps({"schema": argv[3] if len(argv) > 3 else "x", "args": argv[1:]}), "exit": 0}
51 elif cmd == "respond":
58 elif cmd == "respond":
59+ with open(os.path.join(HERE, "stdin.log"), "a", encoding="utf-8") as f:
60+ f.write(json.dumps(sys.stdin.read()) + "\n")
52 queue = control.get("respond_queue") or []
61 queue = control.get("respond_queue") or []
53 if queue:
62 if queue:
54 spec = queue.pop(0)
63 spec = queue.pop(0)
host/tests/golden/organize-3.txt+1
@@ -4,6 +4,7 @@ Rules:
4- Put each tab index into exactly one group, or into leftovers if it fits nowhere.
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.
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.
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.
7- New groups: title of 2 to 4 words in sentence case, one emoji from: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄, one color from: grey, blue, red, yellow, green, pink, purple, cyan, orange.
8- New groups: title of 2 to 4 words in sentence case, one emoji from: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄, one color from: grey, blue, red, yellow, green, pink, purple, cyan, orange.
8- Group by topic or task, not by website, unless the tabs are the same site and nothing else links them.
9- Group by topic or task, not by website, unless the tabs are the same site and nothing else links them.
9- Write titles in the language most tab titles use.
10- Write titles in the language most tab titles use.
host/tests/support.py+9
@@ -63,6 +63,8 @@ class HostCase(unittest.TestCase):
63 for name in ("name.json", "organize.json"):
63 for name in ("name.json", "organize.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:
67+ f.write(host.SCHEMA_VERSION + "\n")
66 self.control({})
68 self.control({})
67 self.env = {**os.environ, "DIAGONAL_FM": self.fm, "DIAGONAL_SUPPORT_DIR": self.support,
69 self.env = {**os.environ, "DIAGONAL_FM": self.fm, "DIAGONAL_SUPPORT_DIR": self.support,
68 "DIAGONAL_LOG_DIR": os.path.join(self.tmp, "logs"), "HOME": self.tmp}
70 "DIAGONAL_LOG_DIR": os.path.join(self.tmp, "logs"), "HOME": self.tmp}
@@ -83,6 +85,13 @@ class HostCase(unittest.TestCase):
83 return []
85 return []
84 return [json.loads(line) for line in open(path, encoding="utf-8")]
86 return [json.loads(line) for line in open(path, encoding="utf-8")]
85
87
88+ def stdin_log(self):
89+ """The prompts `fm respond` read on stdin, in call order."""
90+ path = os.path.join(self.tmp, "stdin.log")
91+ if not os.path.exists(path):
92+ return []
93+ return [json.loads(line) for line in open(path, encoding="utf-8")]
94+
86 def run_host(self, request=None, origin=ORIGIN, raw=None, args=None, timeout=30):
95 def run_host(self, request=None, origin=ORIGIN, raw=None, args=None, timeout=30):
87 stdin = raw if raw is not None else (frame(request) if request is not None else b"")
96 stdin = raw if raw is not None else (frame(request) if request is not None else b"")
88 argv = args if args is not None else [origin]
97 argv = args if args is not None else [origin]
host/tests/test_host.py+59−15
@@ -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(os.listdir(os.path.join(self.support, "schemas"))), ["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", "organize.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,10 +124,9 @@ 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[:6], ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json")])
127+ self.assertEqual(argv, ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json")])
128− self.assertEqual(argv[6], "--")
128+ # The prompt goes on stdin, never into argv where `ps` would show tab titles.
129− self.assertIn("tokio.rs", argv[7])
129+ self.assertIn("tokio.rs", self.stdin_log()[0])
130− self.assertEqual(len(argv), 8)
131
130
132 def test_json_wrapped_in_prose_and_fence(self):
131 def test_json_wrapped_in_prose_and_fence(self):
133 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
132 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
@@ -140,16 +139,34 @@ class Name(HostCase):
140 r = self.call("name", {"items": items})
139 r = self.call("name", {"items": items})
141 self.assertEqual(r["result"]["emoji"], "💻")
140 self.assertEqual(r["result"]["emoji"], "💻")
142
141
143− def test_pcc_only_when_asked(self):
142+ def test_pcc_falls_back_to_the_on_device_model(self):
143+ # fm on macOS 27 rejects --model pcc; an older setting must not break naming.
144 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
144 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
145− self.call("name", {"items": ITEMS3}, model="pcc")
145+ r = self.call("name", {"items": ITEMS3}, model="pcc")
146+ self.assertTrue(r["ok"], r)
146 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
147 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
147− self.assertEqual(argv[1:3], ["--model", "pcc"])
148+ self.assertEqual(argv[1:3], ["--model", "system"])
149+
150+ def test_guardrail_refusal_is_retried_permissively_once(self):
151+ self.control({"respond_queue": [
152+ {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
153+ {"stdout": json.dumps({"title": "Rust async", "emoji": "🦀"})},
154+ ]})
155+ r = self.call("name", {"items": ITEMS3})
156+ self.assertTrue(r["ok"], r)
157+ calls = [a for a in self.argv_log() if a[0] == "respond"]
158+ self.assertEqual(len(calls), 2)
159+ self.assertNotIn("--guardrails", calls[0])
160+ self.assertEqual(calls[1][-2:], ["--guardrails", "permissive-content-transformations"])
161+
162+ def test_context_overflow_is_over_budget(self):
163+ self.respond(stderr="Error: The session's transcript exceeded the model's context size.\n", exit=1)
164+ self.assertEqual(self.call("name", {"items": ITEMS3})["error"]["code"], "OVER_BUDGET")
148
165
149 def test_prompt_never_contains_tab_ids(self):
166 def test_prompt_never_contains_tab_ids(self):
150 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
167 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
151 self.call("name", {"items": [dict(it, id=987654) for it in ITEMS3]})
168 self.call("name", {"items": [dict(it, id=987654) for it in ITEMS3]})
152− prompt = [a for a in self.argv_log() if a[0] == "respond"][0][-1]
169+ prompt = self.stdin_log()[0]
153 self.assertNotIn("987654", prompt)
170 self.assertNotIn("987654", prompt)
154
171
155
172
@@ -184,11 +201,11 @@ class ErrorCodes(HostCase):
184 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
201 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
185
202
186 def test_over_budget_precheck_reports_allowed_items(self):
203 def test_over_budget_precheck_reports_allowed_items(self):
187− big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(30)]
204+ big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(60)]
188 r = self.call("name", {"items": big})
205 r = self.call("name", {"items": big})
189 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
206 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
190 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
207 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
191− self.assertLess(r["error"]["allowedItems"], 30)
208+ self.assertLess(r["error"]["allowedItems"], 60)
192 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
209 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
193
210
194 def test_guardrail(self):
211 def test_guardrail(self):
@@ -223,7 +240,7 @@ class ErrorCodes(HostCase):
223 def test_strict_retry_appends_line(self):
240 def test_strict_retry_appends_line(self):
224 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
241 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
225 self.call("name", {"items": ITEMS3}, strict=True)
242 self.call("name", {"items": ITEMS3}, strict=True)
226− prompt = [a for a in self.argv_log() if a[0] == "respond"][0][-1]
243+ prompt = self.stdin_log()[0]
227 self.assertIn("Reply with only the object", prompt)
244 self.assertIn("Reply with only the object", prompt)
228
245
229
246
@@ -262,7 +279,7 @@ class Organize(HostCase):
262 self.assertEqual(groups[-1]["title"], "Rust async")
279 self.assertEqual(groups[-1]["title"], "Rust async")
263 self.assertEqual(groups[-1]["members"], [0, 1, 2])
280 self.assertEqual(groups[-1]["members"], [0, 1, 2])
264 self.assertEqual(r["result"]["leftovers"], [5])
281 self.assertEqual(r["result"]["leftovers"], [5])
265− prompts_sent = [a[-1] for a in self.argv_log() if a[0] == "respond"]
282+ prompts_sent = self.stdin_log()
266 self.assertEqual(len(prompts_sent), 2)
283 self.assertEqual(len(prompts_sent), 2)
267 self.assertIn("1 | Rust async", prompts_sent[1])
284 self.assertIn("1 | Rust async", prompts_sent[1])
268
285
@@ -273,13 +290,40 @@ class InstallSchemas(HostCase):
273 os.remove(os.path.join(self.support, "schemas", f))
290 os.remove(os.path.join(self.support, "schemas", f))
274 code, _, p = self.run_host(args=["--install-schemas"])
291 code, _, p = self.run_host(args=["--install-schemas"])
275 self.assertEqual(code, 0, p.stderr)
292 self.assertEqual(code, 0, p.stderr)
276− self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize.json"])
293+ self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "organize.json"])
294+
295+ 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.
297+ for f in os.listdir(os.path.join(self.support, "schemas")):
298+ os.remove(os.path.join(self.support, "schemas", f))
299+ code, _, p = self.run_host(args=["--install-schemas"])
300+ self.assertEqual(code, 0, p.stderr)
301+ calls = [a for a in self.argv_log() if a[0] == "schema"]
302+ group = next(a for a in calls if "Group" in a)
303+ self.assertIn("--optional", group) # "existing" is left out for new groups
304+ organize = next(a for a in calls if "Organized" in a)
305+ i = organize.index("--object")
306+ self.assertEqual(organize[i + 1:i + 3], ["groups", "--schema"])
307+ self.assertIn('"args"', organize[i + 3]) # the fake fm's output for the Group schema
308+ self.assertEqual(organize[i + 4], "--array")
309+
310+ def test_schemas_from_an_older_host_are_rewritten(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")
313+ for f in os.listdir(d):
314+ os.remove(os.path.join(d, f))
315+ for name in ("name.json", "organize-labels.json", "organize-assign.json"):
316+ open(os.path.join(d, name), "w").write("{}")
317+ r = self.call("ping")
318+ self.assertTrue(r["result"]["schemasOk"], r)
319+ self.assertEqual(r["result"]["organizeMode"], "nested")
320+ self.assertEqual(open(os.path.join(d, "version")).read().strip(), host.SCHEMA_VERSION)
277
321
278 def test_falls_back_to_two_flat_schemas(self):
322 def test_falls_back_to_two_flat_schemas(self):
279 self.control({"schema_nested": False})
323 self.control({"schema_nested": False})
280 code, _, p = self.run_host(args=["--install-schemas"])
324 code, _, p = self.run_host(args=["--install-schemas"])
281 self.assertEqual(code, 0, p.stderr)
325 self.assertEqual(code, 0, p.stderr)
282− self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize-assign.json", "organize-labels.json"])
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"])
283
327
284
328
285 def test_first_use_writes_missing_schemas(self):
329 def test_first_use_writes_missing_schemas(self):
host/validate.py+2
@@ -161,6 +161,8 @@ def validate_organize(out, payload):
161 if not members:
161 if not members:
162 continue
162 continue
163 ex = g.get("existing")
163 ex = g.get("existing")
164+ if isinstance(ex, list): # fm has returned the optional integer as [] or [n]
165+ ex = ex[0] if len(ex) == 1 else None
164 if isinstance(ex, int) and not isinstance(ex, bool) and 0 <= ex < n_existing:
166 if isinstance(ex, int) and not isinstance(ex, bool) and 0 <= ex < n_existing:
165 joins.append({"title": "", "emoji": "", "color": "", "existing": ex, "members": members})
167 joins.append({"title": "", "emoji": "", "color": "", "existing": ex, "members": members})
166 continue
168 continue
scripts/store-assets.mjs+1−1
@@ -15,7 +15,7 @@ h1{font-size:40px;margin:0 0 16px;line-height:1.15} p{font-size:20px;line-height
15
15
16const pages = [
16const pages = [
17 { file: "screenshot-popup.png", w: 1280, h: 800, html: `<div><h1>Tabs group themselves</h1><p>Open and close tabs as usual. Diagonal groups related tabs and names each group with Apple's on-device model.</p></div><img class="shot" src="${img(join(shots, "popup.png"))}" style="height:560px">` },
17 { file: "screenshot-popup.png", w: 1280, h: 800, html: `<div><h1>Tabs group themselves</h1><p>Open and close tabs as usual. Diagonal groups related tabs and names each group with Apple's on-device model.</p></div><img class="shot" src="${img(join(shots, "popup.png"))}" style="height:560px">` },
18− { file: "screenshot-settings.png", w: 1280, h: 800, html: `<div><h1>Private by design</h1><p>The model runs on your Mac. It sees a tab's title, address and description, and nothing leaves the Mac unless you pick Apple's Private Cloud Compute.</p></div><div class="shot" style="width:560px;height:680px;overflow:hidden"><img src="${img(join(shots, "options.png"))}" style="width:560px;display:block"></div>` },
18+ { file: "screenshot-settings.png", w: 1280, h: 800, html: `<div><h1>Private by design</h1><p>The model runs on your Mac and sees only a tab's title, address and description. Nothing leaves the Mac.</p></div><div class="shot" style="width:560px;height:680px;overflow:hidden"><img src="${img(join(shots, "options.png"))}" style="width:560px;display:block"></div>` },
19 { file: "promo-small.png", w: 440, h: 280, html: `<img src="${icon}" style="width:120px"><div style="font-size:34px;font-weight:700">Diagonal</div>`, gap: 24 },
19 { file: "promo-small.png", w: 440, h: 280, html: `<img src="${icon}" style="width:120px"><div style="font-size:34px;font-weight:700">Diagonal</div>`, gap: 24 },
20];
20];
21
21
src/background/host.ts+1−1
@@ -9,7 +9,7 @@ export const HOST_MANIFEST_PATH = `${HOST_MANIFEST_DIR}${HOST_NAME}.json`;
9export type Op = "ping" | "name" | "organize";
9export type Op = "ping" | "name" | "organize";
10
10
11export interface HostOpts {
11export interface HostOpts {
12− model: "system" | "pcc";
12+ model: "system";
13 timeoutMs: number;
13 timeoutMs: number;
14 emoji: boolean;
14 emoji: boolean;
15 strict?: boolean; // append "reply with only the object" (BAD_MODEL_OUTPUT retry)
15 strict?: boolean; // append "reply with only the object" (BAD_MODEL_OUTPUT retry)
src/background/naming.ts+1−1
@@ -8,7 +8,7 @@ import type { GroupRecord, HostError, State } from "./state";
8
8
9/** Section 6: debounced, sticky, sibling-aware naming of managed groups. */
9/** Section 6: debounced, sticky, sibling-aware naming of managed groups. */
10
10
11−export const NAME_ITEM_CAP = 24;
11+export const NAME_ITEM_CAP = 48;
12export const SAMPLE_EACH = 8;
12export const SAMPLE_EACH = 8;
13export const QUEUE_GAP_MS = 250;
13export const QUEUE_GAP_MS = 250;
14export const FALLBACK_ALARM_MS = 30_000;
14export const FALLBACK_ALARM_MS = 30_000;
src/background/organize.ts+2−2
@@ -8,9 +8,9 @@ import { markDirty, type State } from "./state";
8
8
9/** Section 3/9: "Organize all tabs" — cluster ungrouped tabs in batches sized to the model's window. */
9/** Section 3/9: "Organize all tabs" — cluster ungrouped tabs in batches sized to the model's window. */
10
10
11−export const ORGANIZE_ITEM_CAP = 18;
11+export const ORGANIZE_ITEM_CAP = 36;
12export const EXISTING_GROUPS_CAP = 12;
12export const EXISTING_GROUPS_CAP = 12;
13−export const CHAR_BUDGET = 10_000;
13+export const CHAR_BUDGET = 24_000; // matches the host's budget for fm's ~8k-token context
14export const INSTRUCTION_CHARS = 1_400; // ~320 tokens of rules, emoji and colour lists
14export const INSTRUCTION_CHARS = 1_400; // ~320 tokens of rules, emoji and colour lists
15export const EXISTING_GROUP_CHARS = 90;
15export const EXISTING_GROUP_CHARS = 90;
16export const UNDO_WINDOW_MS = 3_600_000;
16export const UNDO_WINDOW_MS = 3_600_000;
src/background/settings.ts+3−3
@@ -1,6 +1,7 @@
1/** Section 10: every setting, its default and its range. */
1/** Section 10: every setting, its default and its range. */
2export type TidyMode = "ask" | "auto" | "off";
2export type TidyMode = "ask" | "auto" | "off";
3+/** fm on macOS 27 has only the on-device model; a stored "pcc" from older versions reads as "system". */
3−export type ModelChoice = "system" | "pcc";
4+export type ModelChoice = "system";
4
5
5export interface Settings {
6export interface Settings {
6 openerGrouping: boolean;
7 openerGrouping: boolean;
@@ -78,8 +79,7 @@ export function withDefaults(raw: unknown): Settings {
78 (s as any)[key] = range ? Math.min(range[1], Math.max(range[0], Math.round(v))) : v;
79 (s as any)[key] = range ? Math.min(range[1], Math.max(range[0], Math.round(v))) : v;
79 } else if (Array.isArray(def) && Array.isArray(v)) {
80 } else if (Array.isArray(def) && Array.isArray(v)) {
80 (s as any)[key] = v.filter((x) => typeof x === "string" && x.trim()).map((x: string) => x.trim());
81 (s as any)[key] = v.filter((x) => typeof x === "string" && x.trim()).map((x: string) => x.trim());
81− } else if (key === "model" && (v === "system" || v === "pcc")) s.model = v;
82− else if (key === "tidyMode" && (v === "ask" || v === "auto" || v === "off")) s.tidyMode = v;
82+ } else if (key === "tidyMode" && (v === "ask" || v === "auto" || v === "off")) s.tidyMode = v;
83 }
83 }
84 if (s.archiveAfterHours !== 0 && s.archiveAfterHours < 1) s.archiveAfterHours = 1;
84 if (s.archiveAfterHours !== 0 && s.archiveAfterHours < 1) s.archiveAfterHours = 1;
85 return s;
85 return s;
src/options/options.ts+1−10
@@ -38,9 +38,8 @@ const SECTIONS: Section[] = [
38 },
38 },
39 {
39 {
40 title: "Privacy",
40 title: "Privacy",
41− intro: "What the model sees when it names a group.",
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: "model", label: "Model", kind: "select", options: [["system", "On this Mac"], ["pcc", "Private Cloud Compute"]] },
44 { key: "sendDescription", label: "Include page descriptions", help: "Off: titles and addresses only.", kind: "bool" },
43 { key: "sendDescription", label: "Include page descriptions", help: "Off: titles and addresses only.", kind: "bool" },
45 { 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" },
46 ],
45 ],
@@ -97,8 +96,6 @@ function applyDependencies(): void {
97 row.classList.toggle("off", !on);
96 row.classList.toggle("off", !on);
98 row.querySelectorAll<HTMLInputElement>("input, select, textarea").forEach((c) => (c.disabled = !on));
97 row.querySelectorAll<HTMLInputElement>("input, select, textarea").forEach((c) => (c.disabled = !on));
99 }
98 }
100− const w = document.getElementById("pcc-warning");
101− if (w) w.hidden = settings.model !== "pcc";
102}
99}
103
100
104function fieldRow(f: Field): HTMLElement {
101function fieldRow(f: Field): HTMLElement {
@@ -136,12 +133,6 @@ function fieldRow(f: Field): HTMLElement {
136 onchange: (e: Event) => save({ [f.key]: (e.target as HTMLTextAreaElement).value.split("\n") }),
133 onchange: (e: Event) => save({ [f.key]: (e.target as HTMLTextAreaElement).value.split("\n") }),
137 }));
134 }));
138 }
135 }
139− if (f.key === "model") {
140− row.append(el("div", {
141− className: "warn-text", id: "pcc-warning",
142− textContent: "Private Cloud Compute sends tab titles, addresses and descriptions to Apple's servers. On this Mac, nothing leaves the computer.",
143− }));
144− }
145 rows.push({ field: f, row });
136 rows.push({ field: f, row });
146 return row;
137 return row;
147}
138}
test/e2e/smoke-fm.py+1−1
@@ -39,7 +39,7 @@ 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− prompt = argv[-1]
42+ prompt = sys.stdin.read()
43 if prompt.startswith("You name"):
43 if prompt.startswith("You name"):
44 found = topic_of(prompt.split("Tabs (", 1)[-1]) or ("Reading list", "📚", "grey")
44 found = topic_of(prompt.split("Tabs (", 1)[-1]) or ("Reading list", "📚", "grey")
45 print(json.dumps({"title": found[0], "emoji": found[1]}))
45 print(json.dumps({"title": found[0], "emoji": found[1]}))
test/naming.test.ts+5−5
@@ -20,14 +20,14 @@ describe("membership hash", () => {
20});
20});
21
21
22describe("large group sampling", () => {
22describe("large group sampling", () => {
23− it("sends the 8 most recent, the first 8 and the last 8, in strip order, capped at 24", () => {
23+ it("sends the 16 most recent, the first 16 and the last 16, in strip order, capped at 48", () => {
24− const ms = Array.from({ length: 40 }, (_, i) => member(i, { lastActive: i === 20 || i === 21 ? 1000 + i : i }));
24+ const ms = Array.from({ length: 80 }, (_, i) => member(i, { lastActive: i === 40 || i === 41 ? 1000 + i : i }));
25 const picked = sampleMembers(ms);
25 const picked = sampleMembers(ms);
26− expect(picked.length).toBeLessThanOrEqual(24);
26+ expect(picked.length).toBeLessThanOrEqual(48);
27 const ids = picked.map((m) => m.id);
27 const ids = picked.map((m) => m.id);
28 expect(ids).toEqual([...ids].sort((a, b) => a - b));
28 expect(ids).toEqual([...ids].sort((a, b) => a - b));
29− for (const id of [0, 7, 32, 39, 20, 21]) expect(ids).toContain(id);
29+ for (const id of [0, 15, 64, 79, 40, 41]) expect(ids).toContain(id);
30− expect(ids).not.toContain(10);
30+ expect(ids).not.toContain(30);
31 });
31 });
32
32
33 it("leaves small groups whole", () => {
33 it("leaves small groups whole", () => {
test/organize.test.ts+3−3
@@ -8,9 +8,9 @@ const t = (n: number, over: { title?: string; description?: string } = {}) => ({
8});
8});
9
9
10describe("batching", () => {
10describe("batching", () => {
11− it("caps batches at 18 items", () => {
11+ it("caps batches at 36 items", () => {
12− const b = makeBatches(Array.from({ length: 60 }, (_, i) => t(i)));
12+ const b = makeBatches(Array.from({ length: 80 }, (_, i) => t(i)));
13− expect(b.map((x) => x.length)).toEqual([18, 18, 18, 6]);
13+ expect(b.map((x) => x.length)).toEqual([36, 36, 8]);
14 });
14 });
15
15
16 it("also respects the character budget", () => {
16 it("also respects the character budget", () => {
test/settings.test.tsnew file+8
@@ -0,0 +1,8 @@
1+import { describe, expect, it } from "vitest";
2+import { withDefaults } from "../src/background/settings";
3+
4+describe("withDefaults", () => {
5+ it("reads a stored Private Cloud Compute choice as the on-device model, the only one fm has", () => {
6+ expect(withDefaults({ model: "pcc" }).model).toBe("system");
7+ });
8+});