on master help sign in
Budget prompts in tokens, answer greedily, keep one join per group, accept CJK titles

Measured with the real fm on macOS 27: - Chinese and Japanese cost about twice the tokens per character of English, so a 24,000-character Chinese batch overflowed fm's context. The host now budgets 7,000 tokens, estimates per script, and asks fm count-tokens near the limit. - Default sampling gave different topics between runs (Finance vs Banking), which left Russian tabs ungrouped. --greedy gave one answer in five runs. - Two topics joining the same existing group produced two one-tab joins; they merge now. - Chinese and Japanese group titles have no spaces, so they failed the 2-word rule and groups stayed untitled. They're checked by length instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YTy2MovVuA1R9w6f9gRDU2

commit5c7402220a7b253b0d0cc6662fe22c67f54100ec authorClaude <noreply@anthropic.com> dateThu 24 Sep 2026, 15:37 (2 weeks ago) parentf8c1445
10 files changed+147−29
README.md+3−1 host/diagonal-host.py+41−14 host/tests/fake_fm.py+5 host/tests/test_host.py+46−4 host/tests/test_prompts_validate.py+8 host/validate.py+14−4 src/background/organize.ts+7−2 src/shared/label.ts+12−4 test/organize.test.ts+7 test/url.test.ts+4
README.md+3−1
@@ -139,7 +139,9 @@ Checked on a Mac running macOS 27.0 (26A428) on 2026-09-24; the tests' fake `fm`
139139- 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.
140140- `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.
141141- The only model is `system` (`--model pcc` is rejected), so Diagonal always uses the on-device model.
142−- 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 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`.
143+- `--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.
144+- `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`.
143145- 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`.
144146- 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.
145147
host/diagonal-host.py+41−14
@@ -36,9 +36,13 @@ SUPPORT = os.environ.get("DIAGONAL_SUPPORT_DIR", os.path.expanduser("~/Library/A
3636SCHEMAS = os.path.join(SUPPORT, "schemas")
3737LOG_DIR = os.environ.get("DIAGONAL_LOG_DIR", os.path.expanduser("~/Library/Logs/Diagonal"))
3838LOG_MAX_BYTES = 5 * 1024 * 1024
39−# fm's context 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
39+# fm's context holds prompt and reply together. Measured on macOS 27 with the topics schema: a 7,400-token
40+# prompt worked, 13,100 overflowed. 7,000 tokens leaves room for the reply.
41+TOKEN_BUDGET = 7_000
42+# Tokens per character, measured with `fm count-tokens` on tab lists: English 0.26, Russian 0.31, Chinese 0.54,
43+# Japanese 0.51. The estimate rounds up, so a prompt it calls small enough is; near the limit fm counts exactly.
44+TOKENS_PER_CHAR = {"ascii": 0.3, "cjk": 0.6, "other": 0.36}
45+EXACT_COUNT_ABOVE = 0.75 # of TOKEN_BUDGET
4246MAX_REQUEST = 64 * 1024 * 1024
4347MAX_REPLY = 1024 * 1024
4448OPS = {"ping", "name", "organize"}
@@ -191,20 +195,41 @@ def schema_path(name):
191195DESC_CAPS = (500, 200, 0)
192196
193197
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, 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+
194223def 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."""
195226 for cap in DESC_CAPS:
196227 prompt = build(cap)
197− if prompts.size(prompt) <= CHAR_BUDGET:
228+ tokens = prompt_tokens(prompt)
229+ if tokens <= TOKEN_BUDGET:
198230 return prompt
199− check_budget(prompt, n_items) # raises OVER_BUDGET with how many items would fit
200− return prompt
201−
202−
203−def check_budget(prompt, n_items):
204− size = prompts.size(prompt)
205− if size > CHAR_BUDGET:
206− allowed = max(1, int(n_items * CHAR_BUDGET / size))
207− raise Fail("OVER_BUDGET", f"prompt is {size} chars, budget {CHAR_BUDGET}", retryable=True, allowedItems=allowed)
231+ allowed = max(1, int(n_items * TOKEN_BUDGET / tokens))
232+ raise Fail("OVER_BUDGET", f"prompt is about {tokens} tokens, budget {TOKEN_BUDGET}", retryable=True, allowedItems=allowed)
208233
209234
210235def extract_json(stdout):
@@ -243,7 +268,9 @@ def run_fm(prompt, schema, model, timeout_s):
243268def _run_fm(prompt, schema, model, timeout_s, extra):
244269 # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
245270 # in the process list. fm has no timeout of its own; subprocess enforces ours.
246− args = [FM, "respond", "--model", model, "--no-stream", "--schema", schema, "-i", prompt.instructions, *extra]
271+ # --greedy: the same tabs get the same topics and name on every run (measured on macOS 27: five runs,
272+ # one answer), so groups don't get renamed or reshuffled each time Diagonal looks at them.
273+ args = [FM, "respond", "--model", model, "--no-stream", "--greedy", "--schema", schema, "-i", prompt.instructions, *extra]
247274 try:
248275 p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
249276 except FileNotFoundError:
host/tests/fake_fm.py+5
@@ -6,6 +6,7 @@ control.json keys:
66 available: {"stdout": str, "stderr": str, "exit": int} for `fm available`
77 schema_nested: false → `fm schema` with --object fails (simulates no nested-array support)
88 schema_fail: true → every `fm schema` fails
9+ count_tokens: {"stdout": str, "exit": int} for `fm count-tokens` (default: stdin length / 4)
910 license: true → every command exits 69 with the terms notice, as fm does before `sudo fm license`
1011Every invocation's argv is appended to argv.log as one JSON line; `fm respond` also appends its stdin
1112(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():
4142 spec = {"exit": 0 if agreed else 1}
4243 elif control.get("license"):
4344 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})
4449 elif cmd == "available":
4550 spec = control.get("available", {"stdout": "available", "exit": 0})
4651 elif "--model" in argv and argv[argv.index("--model") + 1] != "system":
host/tests/test_host.py+46−4
@@ -124,9 +124,9 @@ class Name(HostCase):
124124 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
125125 self.assertGreater(r["meta"]["promptChars"], 0)
126126 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
127− self.assertEqual(argv[:7], ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json"), "-i"])
128− self.assertIn("You name a browser tab group", argv[7])
129− self.assertEqual(len(argv), 8)
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)
130130 # Tab text goes on stdin, never into argv where `ps` would show it.
131131 self.assertIn("tokio.rs", self.stdin_log()[0])
132132 self.assertNotIn("tokio", " ".join(argv))
@@ -210,9 +210,39 @@ class ErrorCodes(HostCase):
210210 r = self.call("name", {"items": items})
211211 self.assertTrue(r["ok"], r)
212212 body = self.stdin_log()[0]
213− self.assertLessEqual(len(body), host.CHAR_BUDGET)
213+ self.assertLessEqual(len(body) // 4, host.TOKEN_BUDGET)
214214 self.assertIn("word", body)
215215
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+
216246 def test_over_budget_precheck_reports_allowed_items(self):
217247 big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(120)]
218248 r = self.call("name", {"items": big})
@@ -301,6 +331,18 @@ class Organize(HostCase):
301331 self.assertIn("Tokio", name_calls[0])
302332 self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
303333
334+ def test_two_topics_joining_one_group_make_one_join(self):
335+ # The group's two example tabs (8 and 9) got different topics; both topics still join group 0 once.
336+ self.topics([(3, "Travel"), (4, "Hotels"), (8, "Travel"), (9, "Hotels"), (0, "Programming")])
337+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING})
338+ joins = [g for g in r["result"]["groups"] if "existing" in g]
339+ self.assertEqual(joins, [{"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]}])
340+
341+ def test_fm_answers_greedily(self):
342+ self.topics([(0, "Programming")])
343+ self.call("organize", {"items": self.ITEMS})
344+ self.assertIn("--greedy", [a for a in self.argv_log() if a[0] == "respond"][0])
345+
304346 def test_a_topic_of_one_tab_is_a_leftover(self):
305347 self.topics([(0, "Programming"), (1, "Programming"), (2, "Rust"), (3, "Travel"), (4, "Travel"), (5, "Web"), (6, "Food"), (7, "Cooking")])
306348 r = self.call("organize", {"items": self.ITEMS, "maxGroups": 4})
host/tests/test_prompts_validate.py+8
@@ -73,6 +73,14 @@ class ValidateName(unittest.TestCase):
7373 with self.assertRaises(validate.ValidationError, msg=bad):
7474 self.v(bad)
7575
76+ def test_chinese_and_japanese_titles_need_no_spaces(self):
77+ self.assertEqual(self.v("日本旅行")["title"], "日本旅行")
78+ self.assertEqual(self.v("京都の旅行計画")["title"], "京都の旅行計画")
79+ self.assertEqual(self.v("React 教程")["title"], "React 教程")
80+ for bad in ("日", "这是一个非常非常非常非常非常长的标题名字"):
81+ with self.assertRaises(validate.ValidationError, msg=bad):
82+ self.v(bad)
83+
7684 def test_emoji_default(self):
7785 self.assertEqual(self.v("Rust async", "not-an-emoji")["emoji"], "🧭")
7886 self.assertEqual(self.v("Rust async", "✈")["emoji"], "✈️")
host/validate.py+14−4
@@ -8,7 +8,7 @@ HERE = os.path.dirname(os.path.realpath(__file__))
88COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]
99BANNED_WORDS = {"tab", "tabs", "group", "groups", "misc", "various", "stuff"}
1010QUOTES = "\"'“”‘’«»`"
11−TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—]+$")
11+TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—。、,!?:;]+$")
1212
1313
1414class ValidationError(Exception):
@@ -106,11 +106,16 @@ def repair_label(raw):
106106 return label if is_valid_label(label) else None
107107
108108
109+# Chinese and Japanese titles have no spaces between words, so they're checked by length instead.
110+CJK = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]")
111+
112+
109113def is_valid_label(label):
110114 words = label.split()
115+ cjk = len(CJK.findall(label)) * 2 > len(label.replace(" ", ""))
111116 return (
112− 3 <= len(label) <= 28
113− and 2 <= len(words) <= 4
117+ (2 <= len(label) <= 16 if cjk else 3 <= len(label) <= 28)
118+ and (1 <= len(words) <= 4 if cjk else 2 <= len(words) <= 4)
114119 and not any(q in label for q in QUOTES)
115120 and not TRAILING_PUNCT.search(label)
116121 and not any(w.lower() in BANNED_WORDS for w in words)
@@ -164,7 +169,12 @@ def validate_organize(out, payload):
164169 if isinstance(ex, list): # fm has returned the optional integer as [] or [n]
165170 ex = ex[0] if len(ex) == 1 else None
166171 if isinstance(ex, int) and not isinstance(ex, bool) and 0 <= ex < n_existing:
167− joins.append({"title": "", "emoji": "", "color": "", "existing": ex, "members": members})
172+ # Two topics can both point at one group (its example tabs got different topics): one join each.
173+ join = next((j for j in joins if j["existing"] == ex), None)
174+ if join:
175+ join["members"].extend(members)
176+ else:
177+ joins.append({"title": "", "emoji": "", "color": "", "existing": ex, "members": members})
168178 continue
169179 new.append((members, g))
170180
src/background/organize.ts+7−2
@@ -10,7 +10,9 @@ import { markDirty, type State } from "./state";
1010
1111export const ORGANIZE_ITEM_CAP = 36;
1212export const EXISTING_GROUPS_CAP = 12;
13−export const CHAR_BUDGET = 24_000; // matches the host's budget for fm's ~8k-token context
13+// About the host's 7,000-token budget in English characters. Chinese and Japanese cost about twice the tokens
14+// per character, so their characters count double (weightedLength); the host measures exactly and shortens.
15+export const CHAR_BUDGET = 24_000;
1416export const INSTRUCTION_CHARS = 1_400; // ~320 tokens of rules, emoji and colour lists
1517export const EXISTING_GROUP_CHARS = 90;
1618export const UNDO_WINDOW_MS = 3_600_000;
@@ -53,9 +55,12 @@ export const maxGroupsFor = (n: number): number => Math.min(8, Math.ceil(n / 3))
5355export const organizable = (t: { groupId?: number; pinned?: boolean; url?: string; incognito?: boolean }): boolean =>
5456 (t.groupId ?? -1) === -1 && !t.pinned && !t.incognito && !isInternalUrl(t.url);
5557
58+const CJK = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/g;
59+export const weightedLength = (s: string): number => s.length + (s.match(CJK)?.length ?? 0);
60+
5661/** Rough rendered size of one item line, the unit the character budget is spent in. */
5762export const itemChars = (m: Pick<Member, "title" | "url" | "description">): number =>
58− Math.min(120, m.title.length) + promptAddress(m.url).length + Math.min(500, m.description?.length ?? 1) + 12;
63+ weightedLength(m.title.slice(0, 120)) + promptAddress(m.url).length + weightedLength((m.description ?? " ").slice(0, 500)) + 12;
5964
6065/** Split tabs (in strip order) into batches of ≤ cap items that also fit the character budget. */
6166export function makeBatches<T extends Pick<Member, "title" | "url" | "description">>(
src/shared/label.ts+12−4
@@ -9,7 +9,7 @@ export const LABEL_MAX_WORDS = 4;
99
1010const QUOTES = /["'“”‘’«»`]/g;
1111const HAS_QUOTE = /["'“”‘’«»`]/;
12−const TRAILING_PUNCT = /[\s.,;:!?…\-–—]+$/u;
12+const TRAILING_PUNCT = /[\s.,;:!?…\-–—。、,!?:;]+$/u;
1313
1414/** Repair what is repairable; return undefined when the label cannot be made valid. */
1515export function repairLabel(raw: unknown): string | undefined {
@@ -24,12 +24,20 @@ export function repairLabel(raw: unknown): string | undefined {
2424 return isValidLabel(label) ? label : undefined;
2525}
2626
27+/** Chinese, Japanese and Korean titles have no spaces between words, so they're checked by length instead. */
28+const CJK = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/g;
29+export const CJK_MIN_CHARS = 2;
30+export const CJK_MAX_CHARS = 16;
31+
32+const isCjk = (label: string): boolean => (label.match(CJK)?.length ?? 0) * 2 > label.replace(/ /g, "").length;
33+
2734export function isValidLabel(label: string): boolean {
2835 const words = label.split(" ").filter(Boolean);
36+ const cjk = isCjk(label);
2937 return (
30− label.length >= LABEL_MIN_CHARS &&
31− label.length <= LABEL_MAX_CHARS &&
32− words.length >= LABEL_MIN_WORDS &&
38+ label.length >= (cjk ? CJK_MIN_CHARS : LABEL_MIN_CHARS) &&
39+ label.length <= (cjk ? CJK_MAX_CHARS : LABEL_MAX_CHARS) &&
40+ words.length >= (cjk ? 1 : LABEL_MIN_WORDS) &&
3341 words.length <= LABEL_MAX_WORDS &&
3442 !HAS_QUOTE.test(label) &&
3543 !TRAILING_PUNCT.test(label) &&
test/organize.test.ts+7
@@ -21,6 +21,13 @@ describe("batching", () => {
2121 expect(b.flat()).toHaveLength(30);
2222 });
2323
24+ it("counts Chinese and Japanese characters double, as fm's tokenizer does", () => {
25+ const en = makeBatches(Array.from({ length: 30 }, (_, i) => t(i, { description: "x".repeat(300) })), 36, 5000);
26+ const zh = makeBatches(Array.from({ length: 30 }, (_, i) => t(i, { description: "中".repeat(300) })), 36, 5000);
27+ expect(zh.length).toBeGreaterThan(en.length);
28+ expect(zh.flat()).toHaveLength(30);
29+ });
30+
2431 it("maxGroups = min(8, ceil(items / 3))", () => {
2532 expect(maxGroupsFor(18)).toBe(6);
2633 expect(maxGroupsFor(4)).toBe(2);
test/url.test.ts+4
@@ -63,6 +63,10 @@ describe("labels", () => {
6363 it("validates", () => {
6464 expect(isValidLabel("Lisbon trip")).toBe(true);
6565 expect(isValidLabel("Lisbon")).toBe(false);
66+ expect(isValidLabel("日本旅行")).toBe(true);
67+ expect(isValidLabel("京都の旅行計画")).toBe(true);
68+ expect(isValidLabel("日")).toBe(false);
69+ expect(repairLabel("日本旅行。")).toBe("日本旅行");
6670 });
6771
6872 it("emoji outside the list fall back; FE0F is optional", () => {