on master help sign in
Sort loose tabs by broad topic, which fm does reliably

Tried on the real fm: asked for whole groups, the model set "existing" on nearly every new group, so new topics landed in unrelated groups; the two-call fallback placed 2 of 7 tabs. Asked for one broad topic per tab, it gave the same correct answer every run. - organize: one topic per tab (topics.json); the host clusters equal topics, joins a cluster to an existing group whose example tabs share its topic, and names each new group with the name op. - Fixed rules go to `fm respond -i`, browser text on stdin. - Exit 64 (fm usage error) is not retryable; fm's context overflow reports allowedItems. - Schema version 3 replaces older organize schemas on first use. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YTy2MovVuA1R9w6f9gRDU2

commitb8b4768cca3282c2a0d6693ea03bb76ff927ba2b authorClaude <noreply@anthropic.com> dateThu 24 Sep 2026, 07:23 (2 weeks ago) parent92b9721
12 files changed+303−258
README.md+3−2 host/diagonal-host.py+79−65 host/prompts.py+61−78 host/tests/golden/name-24.txt+4 host/tests/golden/name-3.txt+7−2 host/tests/golden/organize-3.txt−18 host/tests/golden/topics-3.txt+17 host/tests/support.py+1−1 host/tests/test_host.py+80−53 host/tests/test_prompts_validate.py+6−5 host/validate.py+38−24 test/e2e/smoke-fm.py+7−10
README.md+3−2
@@ -135,8 +135,9 @@ Every push to `master` runs `.github/workflows/release.yml`: it typechecks, runs
135135Checked on a Mac running macOS 27.0 (26A428) on 2026-09-24; the tests' fake `fm` follows it.
136136
137137- `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 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.
138+- `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`).
139+- 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.
140+- `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.
140141- The only model is `system` (`--model pcc` is rejected), so Diagonal always uses the on-device model.
141142- 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.
142143- 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`.
host/diagonal-host.py+79−65
@@ -43,25 +43,17 @@ MAX_REQUEST = 64 * 1024 * 1024
4343MAX_REPLY = 1024 * 1024
4444OPS = {"ping", "name", "organize"}
4545
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"].
48−# If the nested form fails, --install-schemas writes the two flat schemas and `organize` takes two calls.
46+# `fm schema` argument lists (section 8), checked against fm on macOS 27. `--object NAME` takes the nested
47+# object's schema as JSON from `--schema`; "{TabTopic}" is replaced by the output of SUB_SCHEMAS["TabTopic"].
4948SUB_SCHEMAS = {
50− "Group": ["schema", "object", "--name", "Group",
51− "--string", "title", "--string", "emoji", "--string", "color",
52− "--integer", "existing", "--optional", "--integer", "members", "--array"],
49+ "TabTopic": ["schema", "object", "--name", "TabTopic", "--integer", "index", "--string", "topic"],
5350}
5451SCHEMA_COMMANDS = {
5552 "name.json": ["schema", "object", "--name", "GroupName", "--string", "title", "--string", "emoji"],
56− "organize.json": ["schema", "object", "--name", "Organized",
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"],
53+ "topics.json": ["schema", "object", "--name", "Topics", "--object", "tabs", "--schema", "{TabTopic}", "--array"],
6454}
55+# Organize schemas written by older hosts; removed when the current ones are installed.
56+LEGACY_SCHEMAS = ["organize.json", "organize-labels.json", "organize-assign.json"]
6557
6658
6759class Fail(Exception):
@@ -195,9 +187,10 @@ def schema_path(name):
195187
196188
197189def check_budget(prompt, n_items):
198− if len(prompt) > CHAR_BUDGET:
199− allowed = max(1, int(n_items * CHAR_BUDGET / len(prompt)))
200− raise Fail("OVER_BUDGET", f"prompt is {len(prompt)} chars, budget {CHAR_BUDGET}", retryable=True, allowedItems=allowed)
190+ size = prompts.size(prompt)
191+ if size > CHAR_BUDGET:
192+ allowed = max(1, int(n_items * CHAR_BUDGET / size))
193+ raise Fail("OVER_BUDGET", f"prompt is {size} chars, budget {CHAR_BUDGET}", retryable=True, allowedItems=allowed)
201194
202195
203196def extract_json(stdout):
@@ -220,9 +213,11 @@ def extract_json(stdout):
220213# fm's safety layer sometimes refuses harmless tab lists. Naming tabs only restates their own text, so a
221214# refused call is retried once in the mode Apple provides for transforming given content.
222215PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
216+USAGE_EXIT = 64
223217
224218
225219def run_fm(prompt, schema, model, timeout_s):
220+ """`prompt` is a prompts.Prompt: its fixed rules go to -i, the tab text on stdin."""
226221 try:
227222 return _run_fm(prompt, schema, model, timeout_s, [])
228223 except Fail as f:
@@ -232,11 +227,11 @@ def run_fm(prompt, schema, model, timeout_s):
232227
233228
234229def _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]
230+ # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
231+ # in the process list. fm has no timeout of its own; subprocess enforces ours.
232+ args = [FM, "respond", "--model", model, "--no-stream", "--schema", schema, "-i", prompt.instructions, *extra]
238233 try:
239− p = subprocess.run(args, input=prompt, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
234+ p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
240235 except FileNotFoundError:
241236 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
242237 except subprocess.TimeoutExpired:
@@ -245,18 +240,16 @@ def _run_fm(prompt, schema, model, timeout_s, extra):
245240 code = classify(p.stderr, p.returncode)
246241 if code == "LICENSE_REQUIRED":
247242 raise Fail(code, LICENSE_MESSAGE, raw=(p.stderr or "")[:2000])
248− raise Fail(code, (p.stderr or f"fm exited {p.returncode}").strip()[:500], retryable=True, raw=(p.stderr or "")[:2000])
243+ # 64 is fm's usage error (unknown flag or value): the same call fails the same way again.
244+ raise Fail(code, (p.stderr or f"fm exited {p.returncode}").strip()[:500], retryable=p.returncode != USAGE_EXIT,
245+ raw=(p.stderr or "")[:2000])
249246 return extract_json(p.stdout)
250247
251248
252249# ----- ops --------------------------------------------------------------------------------------
253250
254251def organize_mode():
255− if schema_path("organize.json"):
256− return "nested"
257− if schema_path("organize-labels.json") and schema_path("organize-assign.json"):
258− return "two-call"
259− return "missing"
252+ return "topics" if schema_path("topics.json") else "missing"
260253
261254
262255def op_ping(_payload, _opts):
@@ -285,9 +278,9 @@ def op_ping(_payload, _opts):
285278 return reply
286279
287280
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"
281+# Bumped when SCHEMA_COMMANDS change, so schemas written by an older host are rewritten. Version 3: organize
282+# asks for one topic per tab (topics.json) instead of whole groups.
283+SCHEMA_VERSION = "3"
291284
292285
293286def schemas_current():
@@ -318,6 +311,14 @@ def ensure_schemas():
318311 raise Fail("SCHEMA_MISSING", f"fm could not write Diagonal's schema files: {why}"[:500], raw=out.getvalue()[-2000:])
319312
320313
314+def _fit_overflow(f, n_items):
315+ """fm's own context-size error carries no item count; estimate one so the extension can split the batch."""
316+ if f.code == "OVER_BUDGET" and "allowedItems" not in f.extra:
317+ f.extra["allowedItems"] = max(1, (n_items * 2) // 3)
318+ f.retryable = True
319+ return f
320+
321+
321322def op_name(payload, opts):
322323 ensure_schemas()
323324 schema = schema_path("name.json")
@@ -325,35 +326,56 @@ def op_name(payload, opts):
325326 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
326327 prompt = prompts.build_name_prompt(payload, strict=opts.get("strict", False))
327328 check_budget(prompt, len(payload["items"]))
328− out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
329329 try:
330− return validate.validate_name(out, payload), len(prompt), "name"
330+ out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
331+ except Fail as f:
332+ raise _fit_overflow(f, len(payload["items"]))
333+ try:
334+ return validate.validate_name(out, payload), prompts.size(prompt), "name"
331335 except validate.ValidationError as e:
332336 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
333337
334338
335339def op_organize(payload, opts):
336− strict = opts.get("strict", False)
337340 ensure_schemas()
338− mode = organize_mode()
341+ schema = schema_path("topics.json")
342+ if not schema:
343+ raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'topics.json')}")
344+ prompt = prompts.build_topics_prompt(payload, strict=opts.get("strict", False))
345+ check_budget(prompt, len(payload["items"]))
339346 try:
340− if mode == "nested":
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"
347+ out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
348+ except Fail as f:
349+ raise _fit_overflow(f, len(payload["items"]))
350+ _, owner = prompts.topic_items(payload)
351+ try:
352+ result = validate.organize_from_topics(out, payload, owner)
354353 except validate.ValidationError as e:
355− raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True)
356− raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'organize.json')}")
354+ raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
355+ chars = prompts.size(prompt) + name_new_groups(result, payload, opts, time.time() + opts["timeout_s"])
356+ return result, chars, "topics"
357+
358+
359+def name_new_groups(result, payload, opts, deadline):
360+ """Title each new group with the name op (well under a second each on fm), so groups appear named.
361+ A group that fails, or runs past the deadline, keeps "" and the extension's naming loop names it."""
362+ taken = [g.get("title") for g in payload.get("existingGroups") or [] if g.get("title")]
363+ chars = 0
364+ for g in result["groups"]:
365+ if "existing" in g or g.get("title"):
366+ continue
367+ left = deadline - time.time()
368+ if left < 5:
369+ break
370+ sub = {"items": [dict(payload["items"][i], i=n) for n, i in enumerate(g["members"])], "siblingTitles": taken[:20]}
371+ try:
372+ named, n, _ = op_name(sub, {**opts, "timeout_s": min(opts["timeout_s"], left)})
373+ except Fail:
374+ continue
375+ g["title"], g["emoji"] = named["title"], named["emoji"]
376+ taken.append(named["title"])
377+ chars += n
378+ return chars
357379
358380
359381def handle(req):
@@ -453,24 +475,16 @@ def install_schemas():
453475 print(" Diagonal writes its schemas on its own once that is done.", file=sys.stderr)
454476 return INSTALL_LICENSE_EXIT
455477 ok &= first is True
456− nested = write("organize.json", SCHEMA_COMMANDS["organize.json"])
457− if nested is True:
458− for name in FALLBACK_SCHEMA_COMMANDS:
478+ topics = write("topics.json", SCHEMA_COMMANDS["topics.json"])
479+ if topics == "license":
480+ return INSTALL_LICENSE_EXIT
481+ ok &= topics is True
482+ if ok:
483+ for name in LEGACY_SCHEMAS:
459484 try:
460485 os.remove(os.path.join(SCHEMAS, name))
461486 except FileNotFoundError:
462487 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
474488 if ok:
475489 with open(os.path.join(SCHEMAS, "version"), "w", encoding="utf-8") as f:
476490 f.write(SCHEMA_VERSION + "\n")
@@ -502,7 +516,7 @@ def selftest():
502516 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
503517 else:
504518 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"])]:
519+ for name in SCHEMA_COMMANDS:
506520 path = os.path.join(SCHEMAS, name)
507521 readable = os.path.isfile(path) and os.path.getsize(path) > 0
508522 hint = f"written on their own after {LICENSE_FIX}" if ping["licenseRequired"] else "missing: run diagonal-host --install-schemas"
host/prompts.py+61−78
@@ -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
27from urllib.parse import urlsplit
38
49from validate import COLORS, all_emoji
@@ -6,6 +11,12 @@ from validate import COLORS, all_emoji
611EMOJI_LIST = " ".join(all_emoji())
712STRICT_LINE = "Reply with only the object, nothing before or after it."
813
14+Prompt = namedtuple("Prompt", "instructions text")
15+
16+
17+def size(p):
18+ return len(p.instructions) + len(p.text)
19+
920
1021def _clean(s, limit):
1122 s = " ".join(str(s or "").split())
@@ -45,87 +56,59 @@ def build_name_prompt(payload, strict=False):
4556 "- Never use the words tab, tabs, group, misc, various, stuff.",
4657 "- Write the title in the language most tab titles use.",
4758 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.",
4861 ]
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 = []
4969 current = payload.get("currentTitle")
5070 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)}"')
5272 siblings = payload.get("siblingTitles") or []
5373 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)}")
5575 differ = payload.get("mustDifferFrom") or []
5676 if differ:
57− rules.append(f"- The title must not be any of: {_quoted(differ)}.")
58− if strict:
59− rules.append(f"- {STRICT_LINE}")
60− return (
61− "You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.\n\n"
62− "Rules:\n" + "\n".join(rules) + "\n\n"
63− "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
64− )
65−
66−
67−def _organize_rules(max_groups, strict):
68− rules = [
69− "- Put each tab index into exactly one group, or into leftovers if it fits nowhere.",
70− f"- A group needs at least 2 tabs. Prefer fewer, broader groups; at most {max_groups} new groups.",
71− '- Reuse an existing group when a tab fits it: set "existing" to that group\'s number and leave title, emoji and color empty.',
72− '- For a new group, leave "existing" out.',
73− f"- New groups: title of 2 to 4 words in sentence case, one emoji from: {EMOJI_LIST}, one color from: {', '.join(COLORS)}.",
74− "- Group by topic or task, not by website, unless the tabs are the same site and nothing else links them.",
75− "- Write titles in the language most tab titles use.",
76− ]
77− if strict:
78− rules.append(f"- {STRICT_LINE}")
79− return rules
80−
81−
82−def _existing_block(existing):
83− if not existing:
84− return ""
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]
86− return "Existing groups (number | title | examples):\n" + "\n".join(lines) + "\n\n"
87−
88−
89−def build_organize_prompt(payload, strict=False):
90− return (
91− "You sort browser tabs into topic groups.\n\n"
92− "Rules:\n" + "\n".join(_organize_rules(payload.get("maxGroups", 8), strict)) + "\n\n"
93− + _existing_block(payload.get("existingGroups") or [])
94− + "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
95− )
96−
97−
98−# Two-call fallback (section 8) for an `fm schema` that cannot nest arrays of objects.
99−
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− )
77+ context.append(f"Not allowed: {_quoted(differ)}")
78+ text = ("\n".join(context) + "\n\n" if context else "") + "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
79+ return Prompt(instructions, text)
80+
81+
82+# Organize asks for one broad topic per tab and the host groups tabs with the same topic. On fm (macOS 27)
83+# this was reliable where asking the model for whole groups was not: it set "existing" on nearly every
84+# new group, and specific topics gave every tab its own.
85+TOPICS_INSTRUCTIONS = (
86+ "You label browser tabs with broad topics so that related tabs can be grouped.\n\n"
87+ "Rules:\n"
88+ "- Give every listed index exactly one topic of one or two words.\n"
89+ "- Keep topics as general as possible, for example Programming, Travel, Cooking, Shopping, News, Finance, Music, Health, Work.\n"
90+ "- Use exactly the same topic words for tabs that belong together.\n"
91+ "- Group by what the page is about, not by website.\n"
92+ "- Write topics in English."
93+)
94+
95+
96+def topic_items(payload):
97+ """The tabs to sort, then up to two example tabs of each existing group, numbered after them.
98+ Returns (lines, owner) where owner maps an example's index to its group number."""
99+ items = payload["items"]
100+ lines = [render_items(items)] if items else []
101+ owner = {}
102+ n = len(items)
103+ for g in payload.get("existingGroups") or []:
104+ for sample in (g.get("samples") or [])[:2]:
105+ owner[n] = g["g"]
106+ lines.append(f"{n} | {_clean(sample, 120)} | - | -")
107+ n += 1
108+ return "\n".join(lines), owner
109+
110+
111+def build_topics_prompt(payload, strict=False):
112+ instructions = TOPICS_INSTRUCTIONS + (f"\n- {STRICT_LINE}" if strict else "")
113+ body, _ = topic_items(payload)
114+ return Prompt(instructions, "Tabs (index | title | address | description):\n" + body + "\n")
host/tests/golden/name-24.txt+4
@@ -1,3 +1,4 @@
1+[instructions]
12You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
23
34Rules:
@@ -6,7 +7,10 @@ Rules:
67- Never use the words tab, tabs, group, misc, various, stuff.
78- Write the title in the language most tab titles use.
89- 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.
912
13+[stdin]
1014Tabs (index | title | address | description):
11150 | tokio 0 - docs.rs | docs.rs/tokio/latest/tokio/?search=x0&utm_source=y | Documentation for the tokio crate, part 0.
12161 | serde 1 - docs.rs | docs.rs/serde/latest/serde/?search=x1&utm_source=y | Documentation for the serde crate, part 1.
host/tests/golden/name-3.txt+7−2
@@ -1,3 +1,4 @@
1+[instructions]
12You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
23
34Rules:
@@ -6,8 +7,12 @@ Rules:
67- Never use the words tab, tabs, group, misc, various, stuff.
78- Write the title in the language most tab titles use.
89- 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−- Other groups in this window are named: "Lisbon trip". Do not reuse those names.
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.
12+
13+[stdin]
14+Current title: "Rust async"
15+Other groups in this window: "Lisbon trip"
1116
1217Tabs (index | title | address | description):
13180 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
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 | description):
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):
6060 os.chmod(self.fm, 0o755)
6161 self.support = os.path.join(self.tmp, "support")
6262 os.makedirs(os.path.join(self.support, "schemas"))
63− for name in ("name.json", "organize.json"):
63+ for name in ("name.json", "topics.json"):
6464 with open(os.path.join(self.support, "schemas", name), "w") as f:
6565 f.write("{}")
6666 with open(os.path.join(self.support, "schemas", "version"), "w") as f:
host/tests/test_host.py+80−53
@@ -77,7 +77,7 @@ class Ping(HostCase):
7777 self.assertEqual(r["result"]["fmPath"], self.fm)
7878 self.assertTrue(r["result"]["fmAvailable"])
7979 self.assertTrue(r["result"]["schemasOk"])
80− self.assertEqual(r["result"]["organizeMode"], "nested")
80+ self.assertEqual(r["result"]["organizeMode"], "topics")
8181
8282 def test_ping_model_unavailable(self):
8383 self.control({"available": {"stderr": "Apple Intelligence is not enabled", "exit": 1}})
@@ -107,7 +107,7 @@ class Ping(HostCase):
107107 r = self.call("ping")
108108 self.assertTrue(r["ok"], r)
109109 self.assertTrue(r["result"]["schemasOk"])
110− self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "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"])
111111
112112 def test_ping_without_fm(self):
113113 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
@@ -124,9 +124,12 @@ 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, ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json")])
128− # The prompt goes on stdin, never into argv where `ps` would show tab titles.
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)
130+ # Tab text goes on stdin, never into argv where `ps` would show it.
129131 self.assertIn("tokio.rs", self.stdin_log()[0])
132+ self.assertNotIn("tokio", " ".join(argv))
130133
131134 def test_json_wrapped_in_prose_and_fence(self):
132135 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
@@ -240,8 +243,8 @@ class ErrorCodes(HostCase):
240243 def test_strict_retry_appends_line(self):
241244 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
242245 self.call("name", {"items": ITEMS3}, strict=True)
243− prompt = self.stdin_log()[0]
244− self.assertIn("Reply with only the object", prompt)
246+ argv = [a for a in self.argv_log() if a[0] == "respond"][0]
247+ self.assertIn("Reply with only the object", argv[argv.index("-i") + 1])
245248
246249
247250class Organize(HostCase):
@@ -249,66 +252,97 @@ class Organize(HostCase):
249252 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
250253 {"title": "Lisbon travel guide", "url": "https://lonelyplanet.com/portugal/lisbon"},
251254 {"title": "Random page", "url": "https://example.org/"},
255+ {"title": "Pasta recipe", "url": "https://cooking.example/pasta"},
256+ {"title": "Rice recipe", "url": "https://cooking.example/rice"},
252257 ]
258+ EXISTING = [{"g": 0, "title": "Lisbon trip", "samples": ["Flights to Lisbon", "Lisbon metro map"]}]
253259
254− def test_nested_path(self):
255− self.respond(json.dumps({"groups": [
256− {"title": "Rust async", "emoji": "🦀", "color": "orange", "existing": -1, "members": [0, 1, 2]},
257− {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]},
258− ], "leftovers": [5]}))
259− r = self.call("organize", {"items": self.ITEMS, "existingGroups": [{"g": 0, "title": "Lisbon trip"}], "maxGroups": 2})
260+ def topics(self, pairs):
261+ self.respond(json.dumps({"tabs": [{"index": i, "topic": t} for i, t in pairs]}))
262+
263+ def test_tabs_with_one_topic_form_groups_and_join_existing_ones(self):
264+ # Indexes 8 and 9 are the existing group's example tabs (numbered after the 8 tabs to sort).
265+ self.topics([(0, "Programming"), (1, "programming"), (2, "Programming."), (3, "Travel"), (4, "travel"),
266+ (5, "Other"), (6, "Cooking"), (7, "Cooking"), (8, "Travel"), (9, "Travel")])
267+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
260268 self.assertTrue(r["ok"], r)
261− self.assertEqual(r["result"]["groups"][0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
262− self.assertEqual(r["result"]["groups"][1]["title"], "Rust async")
269+ self.assertEqual(r["meta"]["path"], "topics")
270+ groups = r["result"]["groups"]
271+ self.assertEqual(groups[0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
272+ new = sorted(g["members"] for g in groups[1:])
273+ self.assertEqual(new, [[0, 1, 2], [6, 7]])
274+ self.assertTrue(all(g["title"] == "" for g in groups[1:])) # the naming loop titles new groups
263275 self.assertEqual(r["result"]["leftovers"], [5])
264− self.assertEqual(r["meta"]["path"], "nested")
265276
266− def test_two_call_fallback(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("{}")
277+ def test_new_groups_are_named_right_away(self):
270278 self.control({"respond_queue": [
271− {"stdout": json.dumps({"labels": ["Rust async"], "emojis": ["🦀"], "colors": ["orange"]})},
272− {"stdout": json.dumps({"assignment": [1, 1, 1, 0, 0, -1]})},
279+ {"stdout": json.dumps({"tabs": [{"index": i, "topic": t} for i, t in
280+ [(0, "Programming"), (1, "Programming"), (2, "Programming"), (6, "Cooking"), (7, "Cooking")]]})},
281+ {"stdout": json.dumps({"title": "Rust async runtimes", "emoji": "🦀"})},
282+ {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
283+ {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
273284 ]})
274− r = self.call("organize", {"items": self.ITEMS, "existingGroups": [{"g": 0, "title": "Lisbon trip"}], "maxGroups": 2})
285+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
275286 self.assertTrue(r["ok"], r)
276− self.assertEqual(r["meta"]["path"], "two-call")
277− groups = {g.get("existing", -1): g for g in r["result"]["groups"]}
278− self.assertEqual(groups[0]["members"], [3, 4])
279− self.assertEqual(groups[-1]["title"], "Rust async")
280− self.assertEqual(groups[-1]["members"], [0, 1, 2])
281− self.assertEqual(r["result"]["leftovers"], [5])
282− prompts_sent = self.stdin_log()
283− self.assertEqual(len(prompts_sent), 2)
284− self.assertIn("1 | Rust async", prompts_sent[1])
287+ by_members = {tuple(g["members"]): g for g in r["result"]["groups"]}
288+ self.assertEqual((by_members[(0, 1, 2)]["title"], by_members[(0, 1, 2)]["emoji"]), ("Rust async runtimes", "🦀"))
289+ self.assertEqual(by_members[(6, 7)]["title"], "") # naming failed: the extension's naming loop takes it
290+ name_calls = self.stdin_log()[1:]
291+ self.assertIn("Tokio", name_calls[0])
292+ self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
293+
294+ def test_a_topic_of_one_tab_is_a_leftover(self):
295+ self.topics([(0, "Programming"), (1, "Programming"), (2, "Rust"), (3, "Travel"), (4, "Travel"), (5, "Web"), (6, "Food"), (7, "Cooking")])
296+ r = self.call("organize", {"items": self.ITEMS, "maxGroups": 4})
297+ self.assertEqual(sorted(g["members"] for g in r["result"]["groups"]), [[0, 1], [3, 4]])
298+ self.assertEqual(r["result"]["leftovers"], [2, 5, 6, 7])
299+
300+ def test_prompt_rules_in_instructions_and_tabs_on_stdin(self):
301+ self.topics([(0, "Programming")])
302+ self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING})
303+ argv = [a for a in self.argv_log() if a[0] == "respond"][0]
304+ instructions = argv[argv.index("-i") + 1]
305+ self.assertIn("broad topics", instructions)
306+ self.assertNotIn("Lisbon", " ".join(argv)) # no tab or group text in the process list
307+ body = self.stdin_log()[0]
308+ self.assertIn("4 | Lisbon travel guide", body)
309+ self.assertIn("8 | Flights to Lisbon | - | -", body)
310+
311+ def test_no_topics_is_bad_output(self):
312+ self.respond(json.dumps({"tabs": []}))
313+ r = self.call("organize", {"items": self.ITEMS})
314+ self.assertEqual(r["error"]["code"], "BAD_MODEL_OUTPUT")
315+
316+ def test_context_overflow_suggests_a_smaller_batch(self):
317+ self.respond(stderr="Error: The session's transcript exceeded the model's context size.\n", exit=1)
318+ r = self.call("organize", {"items": self.ITEMS})
319+ self.assertEqual(r["error"]["code"], "OVER_BUDGET")
320+ self.assertEqual(r["error"]["allowedItems"], 5)
285321
286322
287323class InstallSchemas(HostCase):
288− def test_nested_supported(self):
324+ def test_writes_name_and_topics(self):
289325 for f in os.listdir(os.path.join(self.support, "schemas")):
290326 os.remove(os.path.join(self.support, "schemas", f))
291327 code, _, p = self.run_host(args=["--install-schemas"])
292328 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"])
329+ self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "topics.json", "version"])
294330
295331 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.
332+ # Real fm: `--object tabs` must be followed by `--schema <json>` for the item's own schema.
297333 for f in os.listdir(os.path.join(self.support, "schemas")):
298334 os.remove(os.path.join(self.support, "schemas", f))
299335 code, _, p = self.run_host(args=["--install-schemas"])
300336 self.assertEqual(code, 0, p.stderr)
301337 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.
338+ self.assertTrue(any("TabTopic" in a for a in calls))
339+ topics = next(a for a in calls if "Topics" in a)
340+ i = topics.index("--object")
341+ self.assertEqual(topics[i + 1:i + 3], ["tabs", "--schema"])
342+ self.assertIn('"args"', topics[i + 3]) # the fake fm's output for the TabTopic schema
343+ self.assertEqual(topics[i + 4], "--array")
344+
345+ def test_schemas_from_an_older_host_are_replaced(self):
312346 d = os.path.join(self.support, "schemas")
313347 for f in os.listdir(d):
314348 os.remove(os.path.join(d, f))
@@ -316,15 +350,8 @@ class InstallSchemas(HostCase):
316350 open(os.path.join(d, name), "w").write("{}")
317351 r = self.call("ping")
318352 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)
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"])
327−
353+ self.assertEqual(r["result"]["organizeMode"], "topics")
354+ self.assertEqual(sorted(os.listdir(d)), ["name.json", "topics.json", "version"])
328355
329356 def test_first_use_writes_missing_schemas(self):
330357 for f in os.listdir(os.path.join(self.support, "schemas")):
host/tests/test_prompts_validate.py+6−5
@@ -17,7 +17,8 @@ def items24():
1717
1818
1919class Golden(unittest.TestCase):
20− def check(self, name, text):
20+ def check(self, name, p):
21+ text = "[instructions]\n" + p.instructions + "\n\n[stdin]\n" + p.text
2122 path = os.path.join(GOLDEN, name)
2223 if UPDATE or not os.path.exists(path):
2324 os.makedirs(GOLDEN, exist_ok=True)
@@ -27,16 +28,16 @@ class Golden(unittest.TestCase):
2728 def test_name_3(self):
2829 p = prompts.build_name_prompt({"items": ITEMS3, "currentTitle": "Rust async", "siblingTitles": ["Lisbon trip"]})
2930 self.check("name-3.txt", p)
30− self.assertLessEqual(len(p), 10_000)
31+ self.assertLessEqual(prompts.size(p), 24_000)
3132
3233 def test_name_24(self):
3334 p = prompts.build_name_prompt({"items": items24(), "siblingTitles": []})
3435 self.check("name-24.txt", p)
35− self.assertLessEqual(len(p), 10_000)
36+ self.assertLessEqual(prompts.size(p), 24_000)
3637
3738 def test_organize(self):
38− p = prompts.build_organize_prompt({"items": ITEMS3, "existingGroups": [{"g": 0, "title": "Lisbon trip", "samples": ["Hotels", "Flights"]}], "maxGroups": 1})
39− self.check("organize-3.txt", p)
39+ p = prompts.build_topics_prompt({"items": ITEMS3, "existingGroups": [{"g": 0, "title": "Lisbon trip", "samples": ["Hotels", "Flights"]}], "maxGroups": 1})
40+ self.check("topics-3.txt", p)
4041
4142
4243class Rendering(unittest.TestCase):
host/validate.py+38−24
@@ -189,30 +189,44 @@ def validate_organize(out, payload):
189189 return {"groups": groups, "leftovers": sorted(set(leftovers))}
190190
191191
192−def organize_from_two_calls(labels_out, assign_out, payload):
193− """Rebuild the one-call shape from the flat fallback replies, then validate it as usual."""
194− labels = [x for x in (labels_out or {}).get("labels") or [] if isinstance(x, str)]
195− emojis = (labels_out or {}).get("emojis") or []
196− colors = (labels_out or {}).get("colors") or []
197− n_existing = len(payload.get("existingGroups") or [])
198− assignment = (assign_out or {}).get("assignment")
199− if not isinstance(assignment, list):
200− raise ValidationError("assignment is not a list")
201− buckets = {}
202− for i, target in enumerate(assignment[: len(payload.get("items", []))]):
203− if isinstance(target, int) and not isinstance(target, bool) and 0 <= target < n_existing + len(labels):
204− buckets.setdefault(target, []).append(i)
192+VAGUE_TOPICS = {"other", "others", "misc", "miscellaneous", "general", "various", "unknown", "none", "n/a", "-"}
193+
194+
195+def _norm_topic(t):
196+ t = " ".join(str(t or "").split()).strip(" .,:;!?\"'").casefold()
197+ return "" if t in VAGUE_TOPICS else t
198+
199+
200+def organize_from_topics(out, payload, owner):
201+ """Tabs that share a topic form a group. A topic that an existing group's example tabs have joins that
202+ group; others become new groups (titled later by the naming loop) or leftovers when too small.
203+ `owner` maps the example tabs' indexes to their group number (prompts.topic_items)."""
204+ if not isinstance(out, dict) or not isinstance(out.get("tabs"), list):
205+ raise ValidationError("tabs is not a list")
206+ n_items = len(payload.get("items", []))
207+ tab_topic, group_votes = {}, {}
208+ for t in out["tabs"]:
209+ if not isinstance(t, dict):
210+ continue
211+ i, topic = t.get("index"), _norm_topic(t.get("topic"))
212+ if isinstance(i, bool) or not isinstance(i, int) or not topic:
213+ continue
214+ if 0 <= i < n_items:
215+ tab_topic.setdefault(i, topic)
216+ elif i in owner:
217+ votes = group_votes.setdefault(topic, {})
218+ votes[owner[i]] = votes.get(owner[i], 0) + 1
219+ if not tab_topic:
220+ raise ValidationError("no tab got a topic")
221+ clusters = {}
222+ for i in sorted(tab_topic):
223+ clusters.setdefault(tab_topic[i], []).append(i)
205224 groups = []
206− for target, members in sorted(buckets.items()):
207− if target < n_existing:
208− groups.append({"existing": target, "members": members})
225+ for topic, members in clusters.items():
226+ votes = group_votes.get(topic)
227+ if votes:
228+ g = max(sorted(votes), key=lambda k: votes[k])
229+ groups.append({"existing": g, "members": members})
209230 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,
217− })
231+ groups.append({"title": "", "emoji": "", "color": "", "members": members})
218232 return validate_organize({"groups": groups, "leftovers": []}, payload)
test/e2e/smoke-fm.py+7−10
@@ -39,22 +39,19 @@ def main():
3939 print(json.dumps({"schema": argv[1:]}))
4040 return
4141 if argv[0] == "respond":
42+ # Like the real fm: rules arrive with -i, the tab list on stdin.
43+ instructions = argv[argv.index("-i") + 1] if "-i" in argv else ""
4244 prompt = sys.stdin.read()
43− if prompt.startswith("You name"):
45+ if instructions.startswith("You name"):
4446 found = topic_of(prompt.split("Tabs (", 1)[-1]) or ("Reading list", "📚", "grey")
4547 print(json.dumps({"title": found[0], "emoji": found[1]}))
4648 return
47− if prompt.startswith("You sort"):
48− buckets = {}
49− leftovers = []
49+ if instructions.startswith("You label"):
50+ tabs = []
5051 for i, rest in items(prompt):
5152 t = topic_of(rest)
52− if t:
53− buckets.setdefault(t, []).append(i)
54− else:
55− leftovers.append(i)
56− groups = [{"title": t[0], "emoji": t[1], "color": t[2], "existing": -1, "members": m} for t, m in buckets.items()]
57− print(json.dumps({"groups": groups, "leftovers": leftovers}))
53+ tabs.append({"index": i, "topic": t[0].split()[0] if t else f"Other {i}"})
54+ print(json.dumps({"tabs": tabs}))
5855 return
5956 sys.stderr.write("unexpected fm call\n")
6057 sys.exit(1)