@@ -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"}
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"],
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"],
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"],
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"]
67class Fail(Exception):
59class Fail(Exception):
@@ -195,9 +187,10 @@ def schema_path(name):
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)
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"]
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."""
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):
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]
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)
252# ----- ops --------------------------------------------------------------------------------------
249# ----- ops --------------------------------------------------------------------------------------
254def organize_mode():
251def organize_mode():
255− if schema_path("organize.json"):
252+ return "topics" if schema_path("topics.json") else "missing"
257− if schema_path("organize-labels.json") and schema_path("organize-assign.json"):
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
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"
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:])
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)
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"])
330+ out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
332+ raise _fit_overflow(f, len(payload["items"]))
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])
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")
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"]))
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"
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")]
364+ for g in result["groups"]:
365+ if "existing" in g or g.get("title"):
367+ left = deadline - time.time()
370+ sub = {"items": [dict(payload["items"][i], i=n) for n, i in enumerate(g["members"])], "siblingTitles": taken[:20]}
372+ named, n, _ = op_name(sub, {**opts, "timeout_s": min(opts["timeout_s"], left)})
375+ g["title"], g["emoji"] = named["title"], named["emoji"]
376+ taken.append(named["title"])
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:
460 os.remove(os.path.join(SCHEMAS, name))
485 os.remove(os.path.join(SCHEMAS, name))
461 except FileNotFoundError:
486 except FileNotFoundError:
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
467− print(" nested organize schema unsupported; using the two-call fallback", file=sys.stderr)
469− os.remove(os.path.join(SCHEMAS, "organize.json"))
470− except FileNotFoundError:
472− for name, args in FALLBACK_SCHEMA_COMMANDS.items():
473− ok &= write(name, args) is True
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}")
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"