on master help sign in
Give the model more of each page: heading and opening lines

The content script now sends up to 500 characters of what a page says: its description tag (or og:description), its main heading when the title doesn't already say it, and the opening lines of the main text, skipping navigation, banners, dialogs and forms. The host shortens that text, then drops it, when a batch doesn't fit fm's context, before asking for a smaller batch. The existing privacy setting ("Include what pages say") still turns it off. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YTy2MovVuA1R9w6f9gRDU2

commitf8c144569064e1c1fcfd442ae77f9cd18b279755 authorClaude <noreply@anthropic.com> dateThu 24 Sep 2026, 07:26 (2 weeks ago) parentb8b4768
17 files changed+85−37
PRIVACY.md+1−1 README.md+1−1 docs/store/listing.md+4−4 host/diagonal-host.py+17−5 host/prompts.py+9−9 host/tests/golden/name-24.txt+1−1 host/tests/golden/name-3.txt+1−1 host/tests/golden/topics-3.txt+1−1 host/tests/test_host.py+12−2 host/tests/test_prompts_validate.py+1−1 src/background/engine.ts+1−1 src/background/naming.ts+1−1 src/background/organize.ts+1−1 src/background/state.ts+1−1 src/content/meta.ts+31−5 src/options/options.ts+1−1 test/e2e/smoke-fm.py+1−1
PRIVACY.md+1−1
@@ -4,7 +4,7 @@ Diagonal groups and names your browser tabs. This page says what it reads, where
44
55## What Diagonal reads
66
7−For each normal (not incognito) tab: its title, its address and the page's `<meta name="description">`. In settings you can leave out descriptions, or send only the site name instead of the full address.
7+For each normal (not incognito) tab: its title, its address, and up to 500 characters of what the page says (its description tag, its main heading and the opening lines of its main text, never anything typed into forms). In settings you can leave out the page text, or send only the site name instead of the full address.
88
99## Where it goes
1010
README.md+1−1
@@ -14,7 +14,7 @@ You open and close tabs as usual. Diagonal does the rest on its own; there are n
1414
1515Your own choices win: a tab you take out of a group stays out until it goes to another page, groups you make yourself are left alone, and a title you type is kept. The popup still has **Organize now**, **Tidy now** and their undo, but none of them is needed.
1616
17−The model sees a tab's title, address and `<meta name="description">`, and nothing else.
17+The model sees a tab's title, its address, and up to 500 characters of what the page says: its description tag, its main heading and the opening lines of its main text (never form fields). Settings → Privacy can leave the page text out or send only the site name. When a batch of tabs doesn't fit the model's context, the page text is shortened first, then dropped.
1818
1919## Requirements
2020
docs/store/listing.md+4−4
@@ -18,7 +18,7 @@ Everything the Chrome Web Store developer dashboard asks for, ready to paste. Th
1818 >
1919 > Requirements: a Mac with Apple silicon running macOS 27 with Apple Intelligence on, plus Diagonal's small helper program, installed with: brew install iko-soy/tap/diagonal
2020 >
21− > Private by design: the model runs on your Mac and sees only each tab's title, address and description. Diagonal has no server and no analytics.
21+ > Private by design: the model runs on your Mac and sees only each tab's title, address and a short summary of the page. Diagonal has no server and no analytics.
2222
2323- **Icon:** `icons/128.png`
2424- **Screenshots (1280×800):** `docs/store/screenshot-popup.png`, `docs/store/screenshot-settings.png`
@@ -35,10 +35,10 @@ Everything the Chrome Web Store developer dashboard asks for, ready to paste. Th
3535 - `storage`: keeps settings, which groups it manages, and archived tabs, on this computer.
3636 - `alarms`: waits for tabs to settle before grouping, and runs the idle-tab tidy on a schedule.
3737 - `nativeMessaging`: talks to Diagonal's helper on the Mac, which runs Apple's on-device model.
38− - `scripting`: reads the meta description of tabs that were already open when Diagonal was installed.
39− - Host permissions `http://*/*`, `https://*/*`: the content script reads each page's meta description, which helps the model name groups.
38+ - `scripting`: reads the short page summary of tabs that were already open when Diagonal was installed.
39+ - Host permissions `http://*/*`, `https://*/*`: the content script reads a short summary of each page (description tag, main heading, opening lines), which helps the model group and name tabs.
4040- **Remote code:** No, it does not use remote code.
41−- **Data usage:** collects "Web history" (tab titles and addresses) and "Website content" (page descriptions). Check the three certifications: not sold to third parties, not used for unrelated purposes, not used for creditworthiness or lending.
41+- **Data usage:** collects "Web history" (tab titles and addresses) and "Website content" (a short summary of each page). Check the three certifications: not sold to third parties, not used for unrelated purposes, not used for creditworthiness or lending.
4242- **Privacy policy URL:** https://github.com/iko-soy/diagonal/blob/master/PRIVACY.md
4343
4444## Distribution
host/diagonal-host.py+17−5
@@ -131,7 +131,7 @@ def validate_request(req):
131131 if not isinstance(it, dict):
132132 raise Fail("BAD_REQUEST", f"item {n} is not an object")
133133 clean.append({"i": n, "title": _trim(it.get("title"), 120), "url": _trim(it.get("url"), 300),
134− "description": _trim(it.get("description"), 300)})
134+ "description": _trim(it.get("description"), 500)})
135135 payload["items"] = clean
136136 for key in ("siblingTitles", "mustDifferFrom"):
137137 payload[key] = [_trim(t, 60) for t in payload.get(key) or [] if isinstance(t, str)][:20]
@@ -186,6 +186,20 @@ def schema_path(name):
186186 return path if os.path.isfile(path) and os.path.getsize(path) > 0 else None
187187
188188
189+# How much of each page's text to send, most first: when a batch doesn't fit the budget, the pages' text
190+# is shortened, then dropped, before the host gives up and asks for a smaller batch.
191+DESC_CAPS = (500, 200, 0)
192+
193+
194+def fit(build, n_items):
195+ for cap in DESC_CAPS:
196+ prompt = build(cap)
197+ if prompts.size(prompt) <= CHAR_BUDGET:
198+ return prompt
199+ check_budget(prompt, n_items) # raises OVER_BUDGET with how many items would fit
200+ return prompt
201+
202+
189203def check_budget(prompt, n_items):
190204 size = prompts.size(prompt)
191205 if size > CHAR_BUDGET:
@@ -324,8 +338,7 @@ def op_name(payload, opts):
324338 schema = schema_path("name.json")
325339 if not schema:
326340 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
327− prompt = prompts.build_name_prompt(payload, strict=opts.get("strict", False))
328− check_budget(prompt, len(payload["items"]))
341+ prompt = fit(lambda cap: prompts.build_name_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
329342 try:
330343 out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
331344 except Fail as f:
@@ -341,8 +354,7 @@ def op_organize(payload, opts):
341354 schema = schema_path("topics.json")
342355 if not schema:
343356 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"]))
357+ prompt = fit(lambda cap: prompts.build_topics_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
346358 try:
347359 out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
348360 except Fail as f:
host/prompts.py+9−9
@@ -37,10 +37,10 @@ def render_address(url):
3737 return _clean(host + path + query, 160)
3838
3939
40−def render_items(items):
40+def render_items(items, desc_cap=500):
4141 lines = []
4242 for it in items:
43− desc = _clean(it.get("description"), 300) or "-"
43+ desc = _clean(it.get("description"), desc_cap) or "-"
4444 lines.append(f"{it['i']} | {_clean(it.get('title'), 120)} | {render_address(it.get('url', ''))} | {desc}")
4545 return "\n".join(lines)
4646
@@ -49,7 +49,7 @@ def _quoted(titles):
4949 return ", ".join(f'"{_clean(t, 60)}"' for t in titles)
5050
5151
52−def build_name_prompt(payload, strict=False):
52+def build_name_prompt(payload, strict=False, desc_cap=500):
5353 rules = [
5454 "- Title: 2 to 4 words, sentence case, no punctuation at the end, no quotes.",
5555 "- Describe the topic or task, not the website, unless every tab is the same site.",
@@ -75,7 +75,7 @@ def build_name_prompt(payload, strict=False):
7575 differ = payload.get("mustDifferFrom") or []
7676 if differ:
7777 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"
78+ text = ("\n".join(context) + "\n\n" if context else "") + "Tabs (index | title | address | what the page says):\n" + render_items(payload["items"], desc_cap) + "\n"
7979 return Prompt(instructions, text)
8080
8181
@@ -93,11 +93,11 @@ TOPICS_INSTRUCTIONS = (
9393)
9494
9595
96−def topic_items(payload):
96+def topic_items(payload, desc_cap=500):
9797 """The tabs to sort, then up to two example tabs of each existing group, numbered after them.
9898 Returns (lines, owner) where owner maps an example's index to its group number."""
9999 items = payload["items"]
100− lines = [render_items(items)] if items else []
100+ lines = [render_items(items, desc_cap)] if items else []
101101 owner = {}
102102 n = len(items)
103103 for g in payload.get("existingGroups") or []:
@@ -108,7 +108,7 @@ def topic_items(payload):
108108 return "\n".join(lines), owner
109109
110110
111−def build_topics_prompt(payload, strict=False):
111+def build_topics_prompt(payload, strict=False, desc_cap=500):
112112 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")
113+ body, _ = topic_items(payload, desc_cap)
114+ return Prompt(instructions, "Tabs (index | title | address | what the page says):\n" + body + "\n")
host/tests/golden/name-24.txt+1−1
@@ -11,7 +11,7 @@ Rules:
1111- Never reuse a name listed under other groups or not allowed.
1212
1313[stdin]
14−Tabs (index | title | address | description):
14+Tabs (index | title | address | what the page says):
15150 | tokio 0 - docs.rs | docs.rs/tokio/latest/tokio/?search=x0&utm_source=y | Documentation for the tokio crate, part 0.
16161 | serde 1 - docs.rs | docs.rs/serde/latest/serde/?search=x1&utm_source=y | Documentation for the serde crate, part 1.
17172 | axum 2 - docs.rs | docs.rs/axum/latest/axum/?search=x2&utm_source=y | Documentation for the axum crate, part 2.
host/tests/golden/name-3.txt+1−1
@@ -14,7 +14,7 @@ Rules:
1414Current title: "Rust async"
1515Other groups in this window: "Lisbon trip"
1616
17−Tabs (index | title | address | description):
17+Tabs (index | title | address | what the page says):
18180 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
19191 | async-std | async.rs | Async version of the Rust standard library.
20202 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
host/tests/golden/topics-3.txt+1−1
@@ -9,7 +9,7 @@ Rules:
99- Write topics in English.
1010
1111[stdin]
12−Tabs (index | title | address | description):
12+Tabs (index | title | address | what the page says):
13130 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
14141 | async-std | async.rs | Async version of the Rust standard library.
15152 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
host/tests/test_host.py+12−2
@@ -203,12 +203,22 @@ class ErrorCodes(HostCase):
203203 def test_over_budget_from_fm(self):
204204 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
205205
206+ def test_page_text_is_shortened_before_giving_up(self):
207+ # 40 tabs with long page text don't fit at 500 chars each, but do once the text is shortened.
208+ items = [{"title": f"Page {n}", "url": f"https://example.com/{n}", "description": "word " * 100} for n in range(40)]
209+ self.respond(json.dumps({"title": "Reading list", "emoji": "📚"}))
210+ r = self.call("name", {"items": items})
211+ self.assertTrue(r["ok"], r)
212+ body = self.stdin_log()[0]
213+ self.assertLessEqual(len(body), host.CHAR_BUDGET)
214+ self.assertIn("word", body)
215+
206216 def test_over_budget_precheck_reports_allowed_items(self):
207− big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(60)]
217+ big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(120)]
208218 r = self.call("name", {"items": big})
209219 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
210220 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
211− self.assertLess(r["error"]["allowedItems"], 60)
221+ self.assertLess(r["error"]["allowedItems"], 120)
212222 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
213223
214224 def test_guardrail(self):
host/tests/test_prompts_validate.py+1−1
@@ -47,7 +47,7 @@ class Rendering(unittest.TestCase):
4747 idx, title, addr, desc = line.split(" | ")
4848 self.assertLessEqual(len(title), 120)
4949 self.assertLessEqual(len(addr), 160)
50− self.assertLessEqual(len(desc), 300)
50+ self.assertLessEqual(len(desc), 500)
5151 self.assertTrue(addr.startswith("example.com/"))
5252
5353 def test_address(self):
src/background/engine.ts+1−1
@@ -192,7 +192,7 @@ const handlers: Handlers = {
192192 meta(s, { tabId, description }, ctx, out) {
193193 const rec = s.tabs[tabId];
194194 if (!rec) return;
195− const d = trimText(description, 300) || undefined;
195+ const d = trimText(description, 500) || undefined;
196196 if (d === rec.description) return;
197197 rec.description = d;
198198 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
src/background/naming.ts+1−1
@@ -72,7 +72,7 @@ export function sampleMembers(members: Member[], cap = NAME_ITEM_CAP): Member[]
7272export function toItems(members: Member[], settings: Pick<Settings, "sendDescription" | "sendFullUrl">, withDescriptions = true): NameItem[] {
7373 return members.map((m, i) => {
7474 const item: NameItem = { i, title: trimText(m.title, 120), url: promptUrl(m.url, settings.sendFullUrl) };
75− const d = withDescriptions && settings.sendDescription ? trimText(m.description, 300) : "";
75+ const d = withDescriptions && settings.sendDescription ? trimText(m.description, 500) : "";
7676 if (d) item.description = d;
7777 return item;
7878 });
src/background/organize.ts+1−1
@@ -55,7 +55,7 @@ export const organizable = (t: { groupId?: number; pinned?: boolean; url?: strin
5555
5656/** Rough rendered size of one item line, the unit the character budget is spent in. */
5757export const itemChars = (m: Pick<Member, "title" | "url" | "description">): number =>
58− Math.min(120, m.title.length) + promptAddress(m.url).length + Math.min(300, m.description?.length ?? 1) + 12;
58+ Math.min(120, m.title.length) + promptAddress(m.url).length + Math.min(500, m.description?.length ?? 1) + 12;
5959
6060/** Split tabs (in strip order) into batches of ≤ cap items that also fit the character budget. */
6161export function makeBatches<T extends Pick<Member, "title" | "url" | "description">>(
src/background/state.ts+1−1
@@ -10,7 +10,7 @@ export interface TabRecord {
1010 index: number;
1111 url: string;
1212 title: string;
13− description?: string; // trimmed to 300 chars
13+ description?: string; // what the page says (content/meta.ts), trimmed to 500 chars
1414 openerTabId?: number;
1515 pinned: boolean;
1616 incognito?: boolean;
src/content/meta.ts+31−5
@@ -1,11 +1,37 @@
11/**
2− * Runs once at document_idle on http(s) pages: reads the page's description meta tag
3− * (falling back to og:description), trims it to 300 characters and hands it to the worker.
4− * No DOM writes, no listeners, no page body text.
2+ * Runs once at document_idle on http(s) pages and hands the worker a short summary of what the page is
3+ * about, up to 500 characters: the description meta tag (or og:description), the main heading when it adds
4+ * to the tab title, and the opening lines of the page's main text. It stays on this Mac: the worker only
5+ * passes it to Apple's on-device model, and only while "Include what pages say" is on.
6+ * No DOM writes, no listeners, nothing from form fields.
57 */
68(() => {
7− const read = (sel: string) => document.querySelector<HTMLMetaElement>(sel)?.content?.replace(/\s+/g, " ").trim() ?? "";
8− const description = (read('meta[name="description" i]') || read('meta[property="og:description" i]')).slice(0, 300);
9+ const squash = (s: string | null | undefined) => (s ?? "").replace(/\s+/g, " ").trim();
10+ const meta = (sel: string) => squash(document.querySelector<HTMLMetaElement>(sel)?.content);
11+ const parts: string[] = [];
12+ const add = (s: string, max: number) => {
13+ const t = s.slice(0, max);
14+ if (t && !parts.some((p) => p.includes(t) || t.includes(p))) parts.push(t);
15+ };
16+
17+ add(meta('meta[name="description" i]') || meta('meta[property="og:description" i]'), 300);
18+
19+ const h1 = squash(document.querySelector("h1")?.textContent);
20+ if (h1 && !document.title.toLowerCase().includes(h1.toLowerCase())) add(h1, 120);
21+
22+ // Opening paragraphs of the main content, skipping navigation, short captions and cookie banners.
23+ const root = document.querySelector("main, article, [role=main]") ?? document.body;
24+ let text = "";
25+ for (const p of Array.from(root?.querySelectorAll("p") ?? []).slice(0, 30)) {
26+ if (p.closest("nav, header, footer, aside, form, [role=dialog], [aria-modal=true]")) continue;
27+ const line = squash(p.textContent);
28+ if (line.length < 40 || /cookie|consent|javascript/i.test(line)) continue;
29+ text += (text ? " " : "") + line;
30+ if (text.length >= 240) break;
31+ }
32+ add(text, 240);
33+
34+ const description = parts.join(" · ").slice(0, 500);
935 if (!description) return;
1036 try {
1137 chrome.runtime.sendMessage({ kind: "meta", description }).catch(() => undefined);
src/options/options.ts+1−1
@@ -40,7 +40,7 @@ const SECTIONS: Section[] = [
4040 title: "Privacy",
4141 intro: "What Apple's on-device model sees when it names a group. It runs on this Mac; nothing leaves it.",
4242 fields: [
43− { key: "sendDescription", label: "Include page descriptions", help: "Off: titles and addresses only.", kind: "bool" },
43+ { key: "sendDescription", label: "Include what pages say", help: "Each page's description, main heading and opening lines. Off: titles and addresses only.", kind: "bool" },
4444 { key: "sendFullUrl", label: "Include full addresses", help: "Off: just the site name.", kind: "bool" },
4545 ],
4646 },
test/e2e/smoke-fm.py+1−1
@@ -19,7 +19,7 @@ def topic_of(text):
1919
2020
2121def items(prompt):
22− body = prompt.split("Tabs (index | title | address | description):", 1)[-1]
22+ body = prompt.split("Tabs (index | title | address | what the page says):", 1)[-1]
2323 out = []
2424 for line in body.strip().splitlines():
2525 m = re.match(r"^(\d+) \| (.*)$", line)