on master help sign in
Sort loose tabs by broad topic, which fm does reliably
b8b4768Claude
README.md+3−2
@@ -135,8 +135,9 @@ Every push to `master` runs `.github/workflows/release.yml`: it typechecks, runs
135Checked on a Mac running macOS 27.0 (26A428) on 2026-09-24; the tests' fake `fm` follows it.
135Checked on a Mac running macOS 27.0 (26A428) on 2026-09-24; the tests' fake `fm` follows it.
136
136
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.
137- `fm` is at `/usr/bin/fm`. Until an admin runs `sudo fm license`, every command exits 69 with a terms notice; `fm license --status` reports the state. The installer offers the terms in the terminal; if they are declined, Diagonal reports `LICENSE_REQUIRED` with the fix. The host writes its schemas on first use, so install order does not matter.
138−- `fm 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.
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.
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+- `fm respond` reads the prompt from stdin when no prompt argument is given. The host passes its fixed rules with `-i` and the tab text on stdin, so tab titles never show in the process list and are harder to use to override the rules. There is no timeout flag; the host enforces its own.
140- The only model is `system` (`--model pcc` is rejected), so Diagonal always uses the on-device model.
141- The only model is `system` (`--model pcc` is rejected), so Diagonal always uses the on-device model.
141- The context is about 8,000 tokens for prompt and reply together (38,000 characters worked, 40,000 overflowed). `CHAR_BUDGET` is 24,000 characters.
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 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- 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
43MAX_REPLY = 1024 * 1024
43MAX_REPLY = 1024 * 1024
44OPS = {"ping", "name", "organize"}
44OPS = {"ping", "name", "organize"}
45
45
46−# `fm schema` argument lists (section 8), checked against fm on macOS 27. `--object NAME` takes the
46+# `fm schema` argument lists (section 8), checked against fm on macOS 27. `--object NAME` takes the nested
47−# nested object's schema as JSON from `--schema`; "{Group}" is replaced by the output of SUB_SCHEMAS["Group"].
47+# object's schema as JSON from `--schema`; "{TabTopic}" is replaced by the output of SUB_SCHEMAS["TabTopic"].
48−# If the nested form fails, --install-schemas writes the two flat schemas and `organize` takes two calls.
49SUB_SCHEMAS = {
48SUB_SCHEMAS = {
50− "Group": ["schema", "object", "--name", "Group",
49+ "TabTopic": ["schema", "object", "--name", "TabTopic", "--integer", "index", "--string", "topic"],
51− "--string", "title", "--string", "emoji", "--string", "color",
52− "--integer", "existing", "--optional", "--integer", "members", "--array"],
53}
50}
54SCHEMA_COMMANDS = {
51SCHEMA_COMMANDS = {
55 "name.json": ["schema", "object", "--name", "GroupName", "--string", "title", "--string", "emoji"],
52 "name.json": ["schema", "object", "--name", "GroupName", "--string", "title", "--string", "emoji"],
56− "organize.json": ["schema", "object", "--name", "Organized",
53+ "topics.json": ["schema", "object", "--name", "Topics", "--object", "tabs", "--schema", "{TabTopic}", "--array"],
57− "--object", "groups", "--schema", "{Group}", "--array",
58− "--integer", "leftovers", "--array"],
59−}
60−FALLBACK_SCHEMA_COMMANDS = {
61− "organize-labels.json": ["schema", "object", "--name", "TopicLabels",
62− "--string", "labels", "--array", "--string", "emojis", "--array", "--string", "colors", "--array"],
63− "organize-assign.json": ["schema", "object", "--name", "Assignment", "--integer", "assignment", "--array"],
64}
54}
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"]
65
57
66
58
67class Fail(Exception):
59class Fail(Exception):
@@ -195,9 +187,10 @@ def schema_path(name):
195
187
196
188
197def check_budget(prompt, n_items):
189def check_budget(prompt, n_items):
190+ size = prompts.size(prompt)
198− if len(prompt) > CHAR_BUDGET:
191+ if size > CHAR_BUDGET:
199− allowed = max(1, int(n_items * CHAR_BUDGET / len(prompt)))
192+ allowed = max(1, int(n_items * CHAR_BUDGET / size))
200− raise Fail("OVER_BUDGET", f"prompt is {len(prompt)} chars, budget {CHAR_BUDGET}", retryable=True, allowedItems=allowed)
193+ raise Fail("OVER_BUDGET", f"prompt is {size} chars, budget {CHAR_BUDGET}", retryable=True, allowedItems=allowed)
201
194
202
195
203def extract_json(stdout):
196def extract_json(stdout):
@@ -220,9 +213,11 @@ def extract_json(stdout):
220# fm's safety layer sometimes refuses harmless tab lists. Naming tabs only restates their own text, so a
213# 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.
214# refused call is retried once in the mode Apple provides for transforming given content.
222PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
215PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
216+USAGE_EXIT = 64
223
217
224
218
225def run_fm(prompt, schema, model, timeout_s):
219def run_fm(prompt, schema, model, timeout_s):
220+ """`prompt` is a prompts.Prompt: its fixed rules go to -i, the tab text on stdin."""
226 try:
221 try:
227 return _run_fm(prompt, schema, model, timeout_s, [])
222 return _run_fm(prompt, schema, model, timeout_s, [])
228 except Fail as f:
223 except Fail as f:
@@ -232,11 +227,11 @@ def run_fm(prompt, schema, model, timeout_s):
232
227
233
228
234def _run_fm(prompt, schema, model, timeout_s, extra):
229def _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
230+ # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
236− # show up in the process list. fm has no timeout of its own; subprocess enforces ours.
231+ # 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]
232+ args = [FM, "respond", "--model", model, "--no-stream", "--schema", schema, "-i", prompt.instructions, *extra]
238 try:
233 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())
240 except FileNotFoundError:
235 except FileNotFoundError:
241 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
236 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
242 except subprocess.TimeoutExpired:
237 except subprocess.TimeoutExpired:
@@ -245,18 +240,16 @@ def _run_fm(prompt, schema, model, timeout_s, extra):
245 code = classify(p.stderr, p.returncode)
240 code = classify(p.stderr, p.returncode)
246 if code == "LICENSE_REQUIRED":
241 if code == "LICENSE_REQUIRED":
247 raise Fail(code, LICENSE_MESSAGE, raw=(p.stderr or "")[:2000])
242 raise Fail(code, LICENSE_MESSAGE, raw=(p.stderr or "")[:2000])
243+ # 64 is fm's usage error (unknown flag or value): the same call fails the same way again.
248− raise Fail(code, (p.stderr or f"fm exited {p.returncode}").strip()[:500], retryable=True, raw=(p.stderr or "")[:2000])
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])
249 return extract_json(p.stdout)
246 return extract_json(p.stdout)
250
247
251
248
252# ----- ops --------------------------------------------------------------------------------------
249# ----- ops --------------------------------------------------------------------------------------
253
250
254def organize_mode():
251def organize_mode():
255− if schema_path("organize.json"):
252+ return "topics" if schema_path("topics.json") else "missing"
256− return "nested"
257− if schema_path("organize-labels.json") and schema_path("organize-assign.json"):
258− return "two-call"
259− return "missing"
260
253
261
254
262def op_ping(_payload, _opts):
255def op_ping(_payload, _opts):
@@ -285,9 +278,9 @@ def op_ping(_payload, _opts):
285 return reply
278 return reply
286
279
287
280
288−# Bumped when SCHEMA_COMMANDS change, so schemas written by an older host are rewritten. Version 2: the
281+# Bumped when SCHEMA_COMMANDS change, so schemas written by an older host are rewritten. Version 3: organize
289−# nested organize schema in the form fm actually accepts (older hosts fell back to two calls).
282+# asks for one topic per tab (topics.json) instead of whole groups.
290−SCHEMA_VERSION = "2"
283+SCHEMA_VERSION = "3"
291
284
292
285
293def schemas_current():
286def schemas_current():
@@ -318,6 +311,14 @@ def ensure_schemas():
318 raise Fail("SCHEMA_MISSING", f"fm could not write Diagonal's schema files: {why}"[:500], raw=out.getvalue()[-2000:])
311 raise Fail("SCHEMA_MISSING", f"fm could not write Diagonal's schema files: {why}"[:500], raw=out.getvalue()[-2000:])
319
312
320
313
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+
321def op_name(payload, opts):
322def op_name(payload, opts):
322 ensure_schemas()
323 ensure_schemas()
323 schema = schema_path("name.json")
324 schema = schema_path("name.json")
@@ -325,35 +326,56 @@ def op_name(payload, opts):
325 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
326 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
326 prompt = prompts.build_name_prompt(payload, strict=opts.get("strict", False))
327 prompt = prompts.build_name_prompt(payload, strict=opts.get("strict", False))
327 check_budget(prompt, len(payload["items"]))
328 check_budget(prompt, len(payload["items"]))
328− out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
329 try:
329 try:
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:
330− return validate.validate_name(out, payload), len(prompt), "name"
334+ return validate.validate_name(out, payload), prompts.size(prompt), "name"
331 except validate.ValidationError as e:
335 except validate.ValidationError as e:
332 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
336 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
333
337
334
338
335def op_organize(payload, opts):
339def op_organize(payload, opts):
336− strict = opts.get("strict", False)
337 ensure_schemas()
340 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"]))
339 try:
346 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"])
347+ out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
344− return validate.validate_organize(out, payload), len(prompt), "nested"
348+ except Fail as f:
345− if mode == "two-call":
346− p1 = prompts.build_labels_prompt(payload, strict=strict)
347− check_budget(p1, len(payload["items"]))
349+ raise _fit_overflow(f, len(payload["items"]))
348− labels = run_fm(p1, schema_path("organize-labels.json"), opts["model"], opts["timeout_s"])
350+ _, owner = prompts.topic_items(payload)
349− names = [x for x in (labels.get("labels") or []) if isinstance(x, str)] if isinstance(labels, dict) else []
351+ try:
350− p2 = prompts.build_assign_prompt(payload, names, strict=strict)
352+ result = validate.organize_from_topics(out, payload, owner)
351− check_budget(p2, len(payload["items"]))
352− assign = run_fm(p2, schema_path("organize-assign.json"), opts["model"], opts["timeout_s"])
353− return validate.organize_from_two_calls(labels, assign, payload), len(p1) + len(p2), "two-call"
354 except validate.ValidationError as e:
353 except validate.ValidationError as e:
355− raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True)
354+ raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
356− raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'organize.json')}")
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
357
379
358
380
359def handle(req):
381def handle(req):
@@ -453,24 +475,16 @@ def install_schemas():
453 print(" Diagonal writes its schemas on its own once that is done.", file=sys.stderr)
475 print(" Diagonal writes its schemas on its own once that is done.", file=sys.stderr)
454 return INSTALL_LICENSE_EXIT
476 return INSTALL_LICENSE_EXIT
455 ok &= first is True
477 ok &= first is True
456− nested = write("organize.json", SCHEMA_COMMANDS["organize.json"])
478+ topics = write("topics.json", SCHEMA_COMMANDS["topics.json"])
479+ if topics == "license":
480+ return INSTALL_LICENSE_EXIT
457− if nested is True:
481+ ok &= topics is True
458− for name in FALLBACK_SCHEMA_COMMANDS:
482+ if ok:
483+ for name in LEGACY_SCHEMAS:
459 try:
484 try:
460 os.remove(os.path.join(SCHEMAS, name))
485 os.remove(os.path.join(SCHEMAS, name))
461 except FileNotFoundError:
486 except FileNotFoundError:
462 pass
487 pass
463− elif nested != "rejected":
464− # fm did not get as far as judging the schema: no reason to think nesting is unsupported.
465− return INSTALL_LICENSE_EXIT if nested == "license" else 1
466− else:
467− print(" nested organize schema unsupported; using the two-call fallback", file=sys.stderr)
468− try:
469− os.remove(os.path.join(SCHEMAS, "organize.json"))
470− except FileNotFoundError:
471− pass
472− for name, args in FALLBACK_SCHEMA_COMMANDS.items():
473− ok &= write(name, args) is True
474 if ok:
488 if ok:
475 with open(os.path.join(SCHEMAS, "version"), "w", encoding="utf-8") as f:
489 with open(os.path.join(SCHEMAS, "version"), "w", encoding="utf-8") as f:
476 f.write(SCHEMA_VERSION + "\n")
490 f.write(SCHEMA_VERSION + "\n")
@@ -502,7 +516,7 @@ def selftest():
502 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
516 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
503 else:
517 else:
504 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
518 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:
506 path = os.path.join(SCHEMAS, name)
520 path = os.path.join(SCHEMAS, name)
507 readable = os.path.isfile(path) and os.path.getsize(path) > 0
521 readable = os.path.isfile(path) and os.path.getsize(path) > 0
508 hint = f"written on their own after {LICENSE_FIX}" if ping["licenseRequired"] else "missing: run diagonal-host --install-schemas"
522 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
2from urllib.parse import urlsplit
7from urllib.parse import urlsplit
3
8
4from validate import COLORS, all_emoji
9from validate import COLORS, all_emoji
@@ -6,6 +11,12 @@ from validate import COLORS, all_emoji
6EMOJI_LIST = " ".join(all_emoji())
11EMOJI_LIST = " ".join(all_emoji())
7STRICT_LINE = "Reply with only the object, nothing before or after it."
12STRICT_LINE = "Reply with only the object, nothing before or after it."
8
13
14+Prompt = namedtuple("Prompt", "instructions text")
15+
16+
17+def size(p):
18+ return len(p.instructions) + len(p.text)
19+
9
20
10def _clean(s, limit):
21def _clean(s, limit):
11 s = " ".join(str(s or "").split())
22 s = " ".join(str(s or "").split())
@@ -45,87 +56,59 @@ def build_name_prompt(payload, strict=False):
45 "- Never use the words tab, tabs, group, misc, various, stuff.",
56 "- Never use the words tab, tabs, group, misc, various, stuff.",
46 "- Write the title in the language most tab titles use.",
57 "- Write the title in the language most tab titles use.",
47 f"- Emoji: exactly one, chosen from this list: {EMOJI_LIST}",
58 f"- Emoji: exactly one, chosen from this list: {EMOJI_LIST}",
59+ "- If a current title is given, keep it exactly if it still fits; change it only if the tabs have clearly moved on.",
60+ "- Never reuse a name listed under other groups or not allowed.",
48 ]
61 ]
62+ if strict:
63+ rules.append(f"- {STRICT_LINE}")
64+ instructions = (
65+ "You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.\n\n"
66+ "Rules:\n" + "\n".join(rules)
67+ )
68+ context = []
49 current = payload.get("currentTitle")
69 current = payload.get("currentTitle")
50 if current:
70 if current:
51− rules.append(f'- Current title: "{_clean(current, 60)}". Keep it exactly if it still fits; change it only if the tabs have clearly moved on.')
71+ context.append(f'Current title: "{_clean(current, 60)}"')
52 siblings = payload.get("siblingTitles") or []
72 siblings = payload.get("siblingTitles") or []
53 if siblings:
73 if siblings:
54− rules.append(f"- Other groups in this window are named: {_quoted(siblings)}. Do not reuse those names.")
74+ context.append(f"Other groups in this window: {_quoted(siblings)}")
55 differ = payload.get("mustDifferFrom") or []
75 differ = payload.get("mustDifferFrom") or []
56 if differ:
76 if differ:
57− rules.append(f"- The title must not be any of: {_quoted(differ)}.")
77+ context.append(f"Not allowed: {_quoted(differ)}")
58− if strict:
78+ text = ("\n".join(context) + "\n\n" if context else "") + "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
59− rules.append(f"- {STRICT_LINE}")
79+ return Prompt(instructions, text)
60− return (
80+
61− "You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.\n\n"
81+
62− "Rules:\n" + "\n".join(rules) + "\n\n"
82+# Organize asks for one broad topic per tab and the host groups tabs with the same topic. On fm (macOS 27)
63− "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
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."
64− )
93+)
65−
94+
66−
95+
67−def _organize_rules(max_groups, strict):
96+def topic_items(payload):
68− rules = [
97+ """The tabs to sort, then up to two example tabs of each existing group, numbered after them.
69− "- Put each tab index into exactly one group, or into leftovers if it fits nowhere.",
98+ Returns (lines, owner) where owner maps an example's index to its group number."""
70− f"- A group needs at least 2 tabs. Prefer fewer, broader groups; at most {max_groups} new groups.",
99+ items = payload["items"]
71− '- Reuse an existing group when a tab fits it: set "existing" to that group\'s number and leave title, emoji and color empty.',
100+ lines = [render_items(items)] if items else []
72− '- For a new group, leave "existing" out.',
101+ owner = {}
73− f"- New groups: title of 2 to 4 words in sentence case, one emoji from: {EMOJI_LIST}, one color from: {', '.join(COLORS)}.",
102+ n = len(items)
74− "- Group by topic or task, not by website, unless the tabs are the same site and nothing else links them.",
103+ for g in payload.get("existingGroups") or []:
75− "- Write titles in the language most tab titles use.",
104+ for sample in (g.get("samples") or [])[:2]:
76− ]
105+ owner[n] = g["g"]
77− if strict:
106+ lines.append(f"{n} | {_clean(sample, 120)} | - | -")
78− rules.append(f"- {STRICT_LINE}")
107+ n += 1
79− return rules
108+ return "\n".join(lines), owner
80−
109+
81−
110+
82−def _existing_block(existing):
111+def build_topics_prompt(payload, strict=False):
83− if not existing:
112+ instructions = TOPICS_INSTRUCTIONS + (f"\n- {STRICT_LINE}" if strict else "")
84− return ""
113+ body, _ = topic_items(payload)
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"
114+ return Prompt(instructions, "Tabs (index | title | address | description):\n" + body + "\n")
131− )
host/tests/golden/name-24.txt+4
@@ -1,3 +1,4 @@
1+[instructions]
1You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
2You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
2
3
3Rules:
4Rules:
@@ -6,7 +7,10 @@ Rules:
6- Never use the words tab, tabs, group, misc, various, stuff.
7- Never use the words tab, tabs, group, misc, various, stuff.
7- Write the title in the language most tab titles use.
8- Write the title in the language most tab titles use.
8- Emoji: exactly one, chosen from this list: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄
9- Emoji: exactly one, chosen from this list: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄
10+- If a current title is given, keep it exactly if it still fits; change it only if the tabs have clearly moved on.
11+- Never reuse a name listed under other groups or not allowed.
9
12
13+[stdin]
10Tabs (index | title | address | description):
14Tabs (index | title | address | description):
110 | tokio 0 - docs.rs | docs.rs/tokio/latest/tokio/?search=x0&utm_source=y | Documentation for the tokio crate, part 0.
150 | tokio 0 - docs.rs | docs.rs/tokio/latest/tokio/?search=x0&utm_source=y | Documentation for the tokio crate, part 0.
121 | serde 1 - docs.rs | docs.rs/serde/latest/serde/?search=x1&utm_source=y | Documentation for the serde crate, part 1.
161 | serde 1 - docs.rs | docs.rs/serde/latest/serde/?search=x1&utm_source=y | Documentation for the serde crate, part 1.
host/tests/golden/name-3.txt+7−2
@@ -1,3 +1,4 @@
1+[instructions]
1You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
2You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
2
3
3Rules:
4Rules:
@@ -6,8 +7,12 @@ Rules:
6- Never use the words tab, tabs, group, misc, various, stuff.
7- Never use the words tab, tabs, group, misc, various, stuff.
7- Write the title in the language most tab titles use.
8- Write the title in the language most tab titles use.
8- Emoji: exactly one, chosen from this list: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄
9- Emoji: exactly one, chosen from this list: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄
9−- Current title: "Rust async". Keep it exactly if it still fits; change it only if the tabs have clearly moved on.
10+- If a current title is given, keep it exactly if it still fits; change it only if the tabs have clearly moved on.
11+- Never reuse a name listed under other groups or not allowed.
12+
13+[stdin]
14+Current title: "Rust async"
10−- Other groups in this window are named: "Lisbon trip". Do not reuse those names.
15+Other groups in this window: "Lisbon trip"
11
16
12Tabs (index | title | address | description):
17Tabs (index | title | address | description):
130 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
180 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
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):
60 os.chmod(self.fm, 0o755)
60 os.chmod(self.fm, 0o755)
61 self.support = os.path.join(self.tmp, "support")
61 self.support = os.path.join(self.tmp, "support")
62 os.makedirs(os.path.join(self.support, "schemas"))
62 os.makedirs(os.path.join(self.support, "schemas"))
63− for name in ("name.json", "organize.json"):
63+ for name in ("name.json", "topics.json"):
64 with open(os.path.join(self.support, "schemas", name), "w") as f:
64 with open(os.path.join(self.support, "schemas", name), "w") as f:
65 f.write("{}")
65 f.write("{}")
66 with open(os.path.join(self.support, "schemas", "version"), "w") as f:
66 with open(os.path.join(self.support, "schemas", "version"), "w") as f:
host/tests/test_host.py+80−53
@@ -77,7 +77,7 @@ class Ping(HostCase):
77 self.assertEqual(r["result"]["fmPath"], self.fm)
77 self.assertEqual(r["result"]["fmPath"], self.fm)
78 self.assertTrue(r["result"]["fmAvailable"])
78 self.assertTrue(r["result"]["fmAvailable"])
79 self.assertTrue(r["result"]["schemasOk"])
79 self.assertTrue(r["result"]["schemasOk"])
80− self.assertEqual(r["result"]["organizeMode"], "nested")
80+ self.assertEqual(r["result"]["organizeMode"], "topics")
81
81
82 def test_ping_model_unavailable(self):
82 def test_ping_model_unavailable(self):
83 self.control({"available": {"stderr": "Apple Intelligence is not enabled", "exit": 1}})
83 self.control({"available": {"stderr": "Apple Intelligence is not enabled", "exit": 1}})
@@ -107,7 +107,7 @@ class Ping(HostCase):
107 r = self.call("ping")
107 r = self.call("ping")
108 self.assertTrue(r["ok"], r)
108 self.assertTrue(r["ok"], r)
109 self.assertTrue(r["result"]["schemasOk"])
109 self.assertTrue(r["result"]["schemasOk"])
110− self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "organize.json"])
110+ self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "topics.json"])
111
111
112 def test_ping_without_fm(self):
112 def test_ping_without_fm(self):
113 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
113 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
@@ -124,9 +124,12 @@ class Name(HostCase):
124 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
124 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
125 self.assertGreater(r["meta"]["promptChars"], 0)
125 self.assertGreater(r["meta"]["promptChars"], 0)
126 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
126 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
127− self.assertEqual(argv, ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json")])
127+ self.assertEqual(argv[: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)
128− # The prompt goes on stdin, never into argv where `ps` would show tab titles.
130+ # Tab text goes on stdin, never into argv where `ps` would show it.
129 self.assertIn("tokio.rs", self.stdin_log()[0])
131 self.assertIn("tokio.rs", self.stdin_log()[0])
132+ self.assertNotIn("tokio", " ".join(argv))
130
133
131 def test_json_wrapped_in_prose_and_fence(self):
134 def test_json_wrapped_in_prose_and_fence(self):
132 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
135 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
@@ -240,8 +243,8 @@ class ErrorCodes(HostCase):
240 def test_strict_retry_appends_line(self):
243 def test_strict_retry_appends_line(self):
241 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
244 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
242 self.call("name", {"items": ITEMS3}, strict=True)
245 self.call("name", {"items": ITEMS3}, strict=True)
243− prompt = self.stdin_log()[0]
246+ argv = [a for a in self.argv_log() if a[0] == "respond"][0]
244− self.assertIn("Reply with only the object", prompt)
247+ self.assertIn("Reply with only the object", argv[argv.index("-i") + 1])
245
248
246
249
247class Organize(HostCase):
250class Organize(HostCase):
@@ -249,66 +252,97 @@ class Organize(HostCase):
249 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
252 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
250 {"title": "Lisbon travel guide", "url": "https://lonelyplanet.com/portugal/lisbon"},
253 {"title": "Lisbon travel guide", "url": "https://lonelyplanet.com/portugal/lisbon"},
251 {"title": "Random page", "url": "https://example.org/"},
254 {"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"},
252 ]
257 ]
258+ EXISTING = [{"g": 0, "title": "Lisbon trip", "samples": ["Flights to Lisbon", "Lisbon metro map"]}]
253
259
254− def test_nested_path(self):
260+ def topics(self, pairs):
255− self.respond(json.dumps({"groups": [
261+ self.respond(json.dumps({"tabs": [{"index": i, "topic": t} for i, t in pairs]}))
256− {"title": "Rust async", "emoji": "🦀", "color": "orange", "existing": -1, "members": [0, 1, 2]},
262+
257− {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]},
263+ def test_tabs_with_one_topic_form_groups_and_join_existing_ones(self):
258− ], "leftovers": [5]}))
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")])
259− r = self.call("organize", {"items": self.ITEMS, "existingGroups": [{"g": 0, "title": "Lisbon trip"}], "maxGroups": 2})
267+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
260 self.assertTrue(r["ok"], r)
268 self.assertTrue(r["ok"], r)
269+ self.assertEqual(r["meta"]["path"], "topics")
270+ groups = r["result"]["groups"]
261− self.assertEqual(r["result"]["groups"][0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
271+ self.assertEqual(groups[0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
262− self.assertEqual(r["result"]["groups"][1]["title"], "Rust async")
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
263 self.assertEqual(r["result"]["leftovers"], [5])
275 self.assertEqual(r["result"]["leftovers"], [5])
264− self.assertEqual(r["meta"]["path"], "nested")
265
276
266− def test_two_call_fallback(self):
277+ def test_new_groups_are_named_right_away(self):
267− os.remove(os.path.join(self.support, "schemas", "organize.json"))
268− for name in ("organize-labels.json", "organize-assign.json"):
269− open(os.path.join(self.support, "schemas", name), "w").write("{}")
270 self.control({"respond_queue": [
278 self.control({"respond_queue": [
271− {"stdout": json.dumps({"labels": ["Rust async"], "emojis": ["🦀"], "colors": ["orange"]})},
279+ {"stdout": json.dumps({"tabs": [{"index": i, "topic": t} for i, t in
272− {"stdout": json.dumps({"assignment": [1, 1, 1, 0, 0, -1]})},
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},
273 ]})
284 ]})
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})
275 self.assertTrue(r["ok"], r)
286 self.assertTrue(r["ok"], r)
276− self.assertEqual(r["meta"]["path"], "two-call")
287+ by_members = {tuple(g["members"]): g for g in r["result"]["groups"]}
277− groups = {g.get("existing", -1): 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", "🦀"))
278− self.assertEqual(groups[0]["members"], [3, 4])
289+ self.assertEqual(by_members[(6, 7)]["title"], "") # naming failed: the extension's naming loop takes it
279− self.assertEqual(groups[-1]["title"], "Rust async")
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})
280− self.assertEqual(groups[-1]["members"], [0, 1, 2])
297+ self.assertEqual(sorted(g["members"] for g in r["result"]["groups"]), [[0, 1], [3, 4]])
281− self.assertEqual(r["result"]["leftovers"], [5])
298+ self.assertEqual(r["result"]["leftovers"], [2, 5, 6, 7])
282− prompts_sent = self.stdin_log()
299+
283− self.assertEqual(len(prompts_sent), 2)
300+ def test_prompt_rules_in_instructions_and_tabs_on_stdin(self):
284− self.assertIn("1 | Rust async", prompts_sent[1])
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)
285
321
286
322
287class InstallSchemas(HostCase):
323class InstallSchemas(HostCase):
288− def test_nested_supported(self):
324+ def test_writes_name_and_topics(self):
289 for f in os.listdir(os.path.join(self.support, "schemas")):
325 for f in os.listdir(os.path.join(self.support, "schemas")):
290 os.remove(os.path.join(self.support, "schemas", f))
326 os.remove(os.path.join(self.support, "schemas", f))
291 code, _, p = self.run_host(args=["--install-schemas"])
327 code, _, p = self.run_host(args=["--install-schemas"])
292 self.assertEqual(code, 0, p.stderr)
328 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"])
294
330
295 def test_nested_object_gets_its_schema_from_fm_first(self):
331 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.
297 for f in os.listdir(os.path.join(self.support, "schemas")):
333 for f in os.listdir(os.path.join(self.support, "schemas")):
298 os.remove(os.path.join(self.support, "schemas", f))
334 os.remove(os.path.join(self.support, "schemas", f))
299 code, _, p = self.run_host(args=["--install-schemas"])
335 code, _, p = self.run_host(args=["--install-schemas"])
300 self.assertEqual(code, 0, p.stderr)
336 self.assertEqual(code, 0, p.stderr)
301 calls = [a for a in self.argv_log() if a[0] == "schema"]
337 calls = [a for a in self.argv_log() if a[0] == "schema"]
302− group = next(a for a in calls if "Group" in a)
338+ self.assertTrue(any("TabTopic" in a for a in calls))
303− self.assertIn("--optional", group) # "existing" is left out for new groups
304− organize = next(a for a in calls if "Organized" in a)
339+ topics = next(a for a in calls if "Topics" in a)
305− i = organize.index("--object")
340+ i = topics.index("--object")
306− self.assertEqual(organize[i + 1:i + 3], ["groups", "--schema"])
341+ self.assertEqual(topics[i + 1:i + 3], ["tabs", "--schema"])
307− self.assertIn('"args"', organize[i + 3]) # the fake fm's output for the Group schema
342+ self.assertIn('"args"', topics[i + 3]) # the fake fm's output for the TabTopic schema
308− self.assertEqual(organize[i + 4], "--array")
343+ self.assertEqual(topics[i + 4], "--array")
309−
344+
310− def test_schemas_from_an_older_host_are_rewritten(self):
345+ def test_schemas_from_an_older_host_are_replaced(self):
311− # Hosts before schema version 2 could not build the nested schema on real fm and fell back to two calls.
312 d = os.path.join(self.support, "schemas")
346 d = os.path.join(self.support, "schemas")
313 for f in os.listdir(d):
347 for f in os.listdir(d):
314 os.remove(os.path.join(d, f))
348 os.remove(os.path.join(d, f))
@@ -316,15 +350,8 @@ class InstallSchemas(HostCase):
316 open(os.path.join(d, name), "w").write("{}")
350 open(os.path.join(d, name), "w").write("{}")
317 r = self.call("ping")
351 r = self.call("ping")
318 self.assertTrue(r["result"]["schemasOk"], r)
352 self.assertTrue(r["result"]["schemasOk"], r)
319− self.assertEqual(r["result"]["organizeMode"], "nested")
353+ self.assertEqual(r["result"]["organizeMode"], "topics")
320− self.assertEqual(open(os.path.join(d, "version")).read().strip(), host.SCHEMA_VERSION)
321−
322− def test_falls_back_to_two_flat_schemas(self):
323− self.control({"schema_nested": False})
324− code, _, p = self.run_host(args=["--install-schemas"])
325− self.assertEqual(code, 0, p.stderr)
326− self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "organize-assign.json", "organize-labels.json"])
354+ self.assertEqual(sorted(os.listdir(d)), ["name.json", "topics.json", "version"])
327−
328
355
329 def test_first_use_writes_missing_schemas(self):
356 def test_first_use_writes_missing_schemas(self):
330 for f in os.listdir(os.path.join(self.support, "schemas")):
357 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():
17
17
18
18
19class Golden(unittest.TestCase):
19class 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
21 path = os.path.join(GOLDEN, name)
22 path = os.path.join(GOLDEN, name)
22 if UPDATE or not os.path.exists(path):
23 if UPDATE or not os.path.exists(path):
23 os.makedirs(GOLDEN, exist_ok=True)
24 os.makedirs(GOLDEN, exist_ok=True)
@@ -27,16 +28,16 @@ class Golden(unittest.TestCase):
27 def test_name_3(self):
28 def test_name_3(self):
28 p = prompts.build_name_prompt({"items": ITEMS3, "currentTitle": "Rust async", "siblingTitles": ["Lisbon trip"]})
29 p = prompts.build_name_prompt({"items": ITEMS3, "currentTitle": "Rust async", "siblingTitles": ["Lisbon trip"]})
29 self.check("name-3.txt", p)
30 self.check("name-3.txt", p)
30− self.assertLessEqual(len(p), 10_000)
31+ self.assertLessEqual(prompts.size(p), 24_000)
31
32
32 def test_name_24(self):
33 def test_name_24(self):
33 p = prompts.build_name_prompt({"items": items24(), "siblingTitles": []})
34 p = prompts.build_name_prompt({"items": items24(), "siblingTitles": []})
34 self.check("name-24.txt", p)
35 self.check("name-24.txt", p)
35− self.assertLessEqual(len(p), 10_000)
36+ self.assertLessEqual(prompts.size(p), 24_000)
36
37
37 def test_organize(self):
38 def test_organize(self):
38− p = prompts.build_organize_prompt({"items": ITEMS3, "existingGroups": [{"g": 0, "title": "Lisbon trip", "samples": ["Hotels", "Flights"]}], "maxGroups": 1})
39+ p = prompts.build_topics_prompt({"items": ITEMS3, "existingGroups": [{"g": 0, "title": "Lisbon trip", "samples": ["Hotels", "Flights"]}], "maxGroups": 1})
39− self.check("organize-3.txt", p)
40+ self.check("topics-3.txt", p)
40
41
41
42
42class Rendering(unittest.TestCase):
43class Rendering(unittest.TestCase):
host/validate.py+38−24
@@ -189,30 +189,44 @@ def validate_organize(out, payload):
189 return {"groups": groups, "leftovers": sorted(set(leftovers))}
189 return {"groups": groups, "leftovers": sorted(set(leftovers))}
190
190
191
191
192−def organize_from_two_calls(labels_out, assign_out, payload):
192+VAGUE_TOPICS = {"other", "others", "misc", "miscellaneous", "general", "various", "unknown", "none", "n/a", "-"}
193− """Rebuild the one-call shape from the flat fallback replies, then validate it as usual."""
193+
194− labels = [x for x in (labels_out or {}).get("labels") or [] if isinstance(x, str)]
194+
195− emojis = (labels_out or {}).get("emojis") or []
195+def _norm_topic(t):
196− colors = (labels_out or {}).get("colors") or []
196+ t = " ".join(str(t or "").split()).strip(" .,:;!?\"'").casefold()
197− n_existing = len(payload.get("existingGroups") or [])
197+ return "" if t in VAGUE_TOPICS else t
198− assignment = (assign_out or {}).get("assignment")
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)."""
199− if not isinstance(assignment, list):
204+ if not isinstance(out, dict) or not isinstance(out.get("tabs"), list):
200− raise ValidationError("assignment is not a list")
205+ raise ValidationError("tabs is not a list")
201− buckets = {}
202− for i, target in enumerate(assignment[: len(payload.get("items", []))]):
206+ n_items = len(payload.get("items", []))
203− if isinstance(target, int) and not isinstance(target, bool) and 0 <= target < n_existing + len(labels):
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):
204− buckets.setdefault(target, []).append(i)
223+ clusters.setdefault(tab_topic[i], []).append(i)
205 groups = []
224 groups = []
206− for target, members in sorted(buckets.items()):
225+ for topic, members in clusters.items():
207− if target < n_existing:
226+ votes = group_votes.get(topic)
227+ if votes:
228+ g = max(sorted(votes), key=lambda k: votes[k])
208− groups.append({"existing": target, "members": members})
229+ groups.append({"existing": g, "members": members})
209 else:
230 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,
231+ groups.append({"title": "", "emoji": "", "color": "", "members": members})
217− })
218 return validate_organize({"groups": groups, "leftovers": []}, payload)
232 return validate_organize({"groups": groups, "leftovers": []}, payload)
test/e2e/smoke-fm.py+7−10
@@ -39,22 +39,19 @@ def main():
39 print(json.dumps({"schema": argv[1:]}))
39 print(json.dumps({"schema": argv[1:]}))
40 return
40 return
41 if argv[0] == "respond":
41 if argv[0] == "respond":
42+ # Like the real fm: rules arrive with -i, the tab list on stdin.
43+ instructions = argv[argv.index("-i") + 1] if "-i" in argv else ""
42 prompt = sys.stdin.read()
44 prompt = sys.stdin.read()
43− if prompt.startswith("You name"):
45+ if instructions.startswith("You name"):
44 found = topic_of(prompt.split("Tabs (", 1)[-1]) or ("Reading list", "📚", "grey")
46 found = topic_of(prompt.split("Tabs (", 1)[-1]) or ("Reading list", "📚", "grey")
45 print(json.dumps({"title": found[0], "emoji": found[1]}))
47 print(json.dumps({"title": found[0], "emoji": found[1]}))
46 return
48 return
47− if prompt.startswith("You sort"):
49+ if instructions.startswith("You label"):
48− buckets = {}
50+ tabs = []
49− leftovers = []
50 for i, rest in items(prompt):
51 for i, rest in items(prompt):
51 t = topic_of(rest)
52 t = topic_of(rest)
52− if t:
53+ tabs.append({"index": i, "topic": t[0].split()[0] if t else f"Other {i}"})
53− buckets.setdefault(t, []).append(i)
54− else:
55− leftovers.append(i)
56− groups = [{"title": t[0], "emoji": t[1], "color": t[2], "existing": -1, "members": m} for t, m in buckets.items()]
57− print(json.dumps({"groups": groups, "leftovers": leftovers}))
54+ print(json.dumps({"tabs": tabs}))
58 return
55 return
59 sys.stderr.write("unexpected fm call\n")
56 sys.stderr.write("unexpected fm call\n")
60 sys.exit(1)
57 sys.exit(1)