on master help sign in
Give the model more of each page: heading and opening lines
f8c1445Claude
PRIVACY.md+1−1
@@ -4,7 +4,7 @@ Diagonal groups and names your browser tabs. This page says what it reads, where
4
4
5## What Diagonal reads
5## What Diagonal reads
6
6
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.
8
8
9## Where it goes
9## Where it goes
10
10
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
14
14
15Your 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.
15Your 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.
16
16
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.
18
18
19## Requirements
19## Requirements
20
20
docs/store/listing.md+4−4
@@ -18,7 +18,7 @@ Everything the Chrome Web Store developer dashboard asks for, ready to paste. Th
18 >
18 >
19 > 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
19 > 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
20 >
20 >
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.
22
22
23- **Icon:** `icons/128.png`
23- **Icon:** `icons/128.png`
24- **Screenshots (1280×800):** `docs/store/screenshot-popup.png`, `docs/store/screenshot-settings.png`
24- **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
35 - `storage`: keeps settings, which groups it manages, and archived tabs, on this computer.
35 - `storage`: keeps settings, which groups it manages, and archived tabs, on this computer.
36 - `alarms`: waits for tabs to settle before grouping, and runs the idle-tab tidy on a schedule.
36 - `alarms`: waits for tabs to settle before grouping, and runs the idle-tab tidy on a schedule.
37 - `nativeMessaging`: talks to Diagonal's helper on the Mac, which runs Apple's on-device model.
37 - `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.
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 each page's meta description, which helps the model name groups.
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.
40- **Remote code:** No, it does not use remote code.
40- **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.
42- **Privacy policy URL:** https://github.com/iko-soy/diagonal/blob/master/PRIVACY.md
42- **Privacy policy URL:** https://github.com/iko-soy/diagonal/blob/master/PRIVACY.md
43
43
44## Distribution
44## Distribution
host/diagonal-host.py+17−5
@@ -131,7 +131,7 @@ def validate_request(req):
131 if not isinstance(it, dict):
131 if not isinstance(it, dict):
132 raise Fail("BAD_REQUEST", f"item {n} is not an object")
132 raise Fail("BAD_REQUEST", f"item {n} is not an object")
133 clean.append({"i": n, "title": _trim(it.get("title"), 120), "url": _trim(it.get("url"), 300),
133 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)})
135 payload["items"] = clean
135 payload["items"] = clean
136 for key in ("siblingTitles", "mustDifferFrom"):
136 for key in ("siblingTitles", "mustDifferFrom"):
137 payload[key] = [_trim(t, 60) for t in payload.get(key) or [] if isinstance(t, str)][:20]
137 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):
186 return path if os.path.isfile(path) and os.path.getsize(path) > 0 else None
186 return path if os.path.isfile(path) and os.path.getsize(path) > 0 else None
187
187
188
188
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+
189def check_budget(prompt, n_items):
203def check_budget(prompt, n_items):
190 size = prompts.size(prompt)
204 size = prompts.size(prompt)
191 if size > CHAR_BUDGET:
205 if size > CHAR_BUDGET:
@@ -324,8 +338,7 @@ def op_name(payload, opts):
324 schema = schema_path("name.json")
338 schema = schema_path("name.json")
325 if not schema:
339 if not schema:
326 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
340 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))
341+ prompt = fit(lambda cap: prompts.build_name_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
328− check_budget(prompt, len(payload["items"]))
329 try:
342 try:
330 out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
343 out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
331 except Fail as f:
344 except Fail as f:
@@ -341,8 +354,7 @@ def op_organize(payload, opts):
341 schema = schema_path("topics.json")
354 schema = schema_path("topics.json")
342 if not schema:
355 if not schema:
343 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'topics.json')}")
356 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))
357+ prompt = fit(lambda cap: prompts.build_topics_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
345− check_budget(prompt, len(payload["items"]))
346 try:
358 try:
347 out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
359 out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
348 except Fail as f:
360 except Fail as f:
host/prompts.py+9−9
@@ -37,10 +37,10 @@ def render_address(url):
37 return _clean(host + path + query, 160)
37 return _clean(host + path + query, 160)
38
38
39
39
40−def render_items(items):
40+def render_items(items, desc_cap=500):
41 lines = []
41 lines = []
42 for it in items:
42 for it in items:
43− desc = _clean(it.get("description"), 300) or "-"
43+ desc = _clean(it.get("description"), desc_cap) or "-"
44 lines.append(f"{it['i']} | {_clean(it.get('title'), 120)} | {render_address(it.get('url', ''))} | {desc}")
44 lines.append(f"{it['i']} | {_clean(it.get('title'), 120)} | {render_address(it.get('url', ''))} | {desc}")
45 return "\n".join(lines)
45 return "\n".join(lines)
46
46
@@ -49,7 +49,7 @@ def _quoted(titles):
49 return ", ".join(f'"{_clean(t, 60)}"' for t in titles)
49 return ", ".join(f'"{_clean(t, 60)}"' for t in titles)
50
50
51
51
52−def build_name_prompt(payload, strict=False):
52+def build_name_prompt(payload, strict=False, desc_cap=500):
53 rules = [
53 rules = [
54 "- Title: 2 to 4 words, sentence case, no punctuation at the end, no quotes.",
54 "- Title: 2 to 4 words, sentence case, no punctuation at the end, no quotes.",
55 "- Describe the topic or task, not the website, unless every tab is the same site.",
55 "- 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):
75 differ = payload.get("mustDifferFrom") or []
75 differ = payload.get("mustDifferFrom") or []
76 if differ:
76 if differ:
77 context.append(f"Not allowed: {_quoted(differ)}")
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"
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"
79 return Prompt(instructions, text)
79 return Prompt(instructions, text)
80
80
81
81
@@ -93,11 +93,11 @@ TOPICS_INSTRUCTIONS = (
93)
93)
94
94
95
95
96−def topic_items(payload):
96+def topic_items(payload, desc_cap=500):
97 """The tabs to sort, then up to two example tabs of each existing group, numbered after them.
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."""
98 Returns (lines, owner) where owner maps an example's index to its group number."""
99 items = payload["items"]
99 items = payload["items"]
100− lines = [render_items(items)] if items else []
100+ lines = [render_items(items, desc_cap)] if items else []
101 owner = {}
101 owner = {}
102 n = len(items)
102 n = len(items)
103 for g in payload.get("existingGroups") or []:
103 for g in payload.get("existingGroups") or []:
@@ -108,7 +108,7 @@ def topic_items(payload):
108 return "\n".join(lines), owner
108 return "\n".join(lines), owner
109
109
110
110
111−def build_topics_prompt(payload, strict=False):
111+def build_topics_prompt(payload, strict=False, desc_cap=500):
112 instructions = TOPICS_INSTRUCTIONS + (f"\n- {STRICT_LINE}" if strict else "")
112 instructions = TOPICS_INSTRUCTIONS + (f"\n- {STRICT_LINE}" if strict else "")
113− body, _ = topic_items(payload)
113+ body, _ = topic_items(payload, desc_cap)
114− return Prompt(instructions, "Tabs (index | title | address | description):\n" + body + "\n")
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:
11- Never reuse a name listed under other groups or not allowed.
11- Never reuse a name listed under other groups or not allowed.
12
12
13[stdin]
13[stdin]
14−Tabs (index | title | address | description):
14+Tabs (index | title | address | what the page says):
150 | 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.
161 | 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.
172 | axum 2 - docs.rs | docs.rs/axum/latest/axum/?search=x2&utm_source=y | Documentation for the axum crate, part 2.
172 | 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:
14Current title: "Rust async"
14Current title: "Rust async"
15Other groups in this window: "Lisbon trip"
15Other groups in this window: "Lisbon trip"
16
16
17−Tabs (index | title | address | description):
17+Tabs (index | title | address | what the page says):
180 | 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.
191 | async-std | async.rs | Async version of the Rust standard library.
191 | async-std | async.rs | Async version of the Rust standard library.
202 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
202 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
host/tests/golden/topics-3.txt+1−1
@@ -9,7 +9,7 @@ Rules:
9- Write topics in English.
9- Write topics in English.
10
10
11[stdin]
11[stdin]
12−Tabs (index | title | address | description):
12+Tabs (index | title | address | what the page says):
130 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
130 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
141 | async-std | async.rs | Async version of the Rust standard library.
141 | async-std | async.rs | Async version of the Rust standard library.
152 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
152 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
host/tests/test_host.py+12−2
@@ -203,12 +203,22 @@ class ErrorCodes(HostCase):
203 def test_over_budget_from_fm(self):
203 def test_over_budget_from_fm(self):
204 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
204 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
205
205
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+
206 def test_over_budget_precheck_reports_allowed_items(self):
216 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)]
208 r = self.call("name", {"items": big})
218 r = self.call("name", {"items": big})
209 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
219 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
210 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
220 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
211− self.assertLess(r["error"]["allowedItems"], 60)
221+ self.assertLess(r["error"]["allowedItems"], 120)
212 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
222 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
213
223
214 def test_guardrail(self):
224 def test_guardrail(self):
host/tests/test_prompts_validate.py+1−1
@@ -47,7 +47,7 @@ class Rendering(unittest.TestCase):
47 idx, title, addr, desc = line.split(" | ")
47 idx, title, addr, desc = line.split(" | ")
48 self.assertLessEqual(len(title), 120)
48 self.assertLessEqual(len(title), 120)
49 self.assertLessEqual(len(addr), 160)
49 self.assertLessEqual(len(addr), 160)
50− self.assertLessEqual(len(desc), 300)
50+ self.assertLessEqual(len(desc), 500)
51 self.assertTrue(addr.startswith("example.com/"))
51 self.assertTrue(addr.startswith("example.com/"))
52
52
53 def test_address(self):
53 def test_address(self):
src/background/engine.ts+1−1
@@ -192,7 +192,7 @@ const handlers: Handlers = {
192 meta(s, { tabId, description }, ctx, out) {
192 meta(s, { tabId, description }, ctx, out) {
193 const rec = s.tabs[tabId];
193 const rec = s.tabs[tabId];
194 if (!rec) return;
194 if (!rec) return;
195− const d = trimText(description, 300) || undefined;
195+ const d = trimText(description, 500) || undefined;
196 if (d === rec.description) return;
196 if (d === rec.description) return;
197 rec.description = d;
197 rec.description = d;
198 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
198 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[]
72export function toItems(members: Member[], settings: Pick<Settings, "sendDescription" | "sendFullUrl">, withDescriptions = true): NameItem[] {
72export function toItems(members: Member[], settings: Pick<Settings, "sendDescription" | "sendFullUrl">, withDescriptions = true): NameItem[] {
73 return members.map((m, i) => {
73 return members.map((m, i) => {
74 const item: NameItem = { i, title: trimText(m.title, 120), url: promptUrl(m.url, settings.sendFullUrl) };
74 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) : "";
76 if (d) item.description = d;
76 if (d) item.description = d;
77 return item;
77 return item;
78 });
78 });
src/background/organize.ts+1−1
@@ -55,7 +55,7 @@ export const organizable = (t: { groupId?: number; pinned?: boolean; url?: strin
55
55
56/** Rough rendered size of one item line, the unit the character budget is spent in. */
56/** Rough rendered size of one item line, the unit the character budget is spent in. */
57export const itemChars = (m: Pick<Member, "title" | "url" | "description">): number =>
57export 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;
59
59
60/** Split tabs (in strip order) into batches of ≤ cap items that also fit the character budget. */
60/** Split tabs (in strip order) into batches of ≤ cap items that also fit the character budget. */
61export function makeBatches<T extends Pick<Member, "title" | "url" | "description">>(
61export function makeBatches<T extends Pick<Member, "title" | "url" | "description">>(
src/background/state.ts+1−1
@@ -10,7 +10,7 @@ export interface TabRecord {
10 index: number;
10 index: number;
11 url: string;
11 url: string;
12 title: string;
12 title: string;
13− description?: string; // trimmed to 300 chars
13+ description?: string; // what the page says (content/meta.ts), trimmed to 500 chars
14 openerTabId?: number;
14 openerTabId?: number;
15 pinned: boolean;
15 pinned: boolean;
16 incognito?: boolean;
16 incognito?: boolean;
src/content/meta.ts+31−5
@@ -1,11 +1,37 @@
1/**
1/**
2− * Runs once at document_idle on http(s) pages: reads the page's description meta tag
2+ * Runs once at document_idle on http(s) pages and hands the worker a short summary of what the page is
3− * (falling back to og:description), trims it to 300 characters and hands it to the worker.
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.
4− * No DOM writes, no listeners, no page body text.
6+ * No DOM writes, no listeners, nothing from form fields.
5 */
7 */
6(() => {
8(() => {
7− const read = (sel: string) => document.querySelector<HTMLMetaElement>(sel)?.content?.replace(/\s+/g, " ").trim() ?? "";
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+
8− const description = (read('meta[name="description" i]') || read('meta[property="og:description" i]')).slice(0, 300);
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);
9 if (!description) return;
35 if (!description) return;
10 try {
36 try {
11 chrome.runtime.sendMessage({ kind: "meta", description }).catch(() => undefined);
37 chrome.runtime.sendMessage({ kind: "meta", description }).catch(() => undefined);
src/options/options.ts+1−1
@@ -40,7 +40,7 @@ const SECTIONS: Section[] = [
40 title: "Privacy",
40 title: "Privacy",
41 intro: "What Apple's on-device model sees when it names a group. It runs on this Mac; nothing leaves it.",
41 intro: "What Apple's on-device model sees when it names a group. It runs on this Mac; nothing leaves it.",
42 fields: [
42 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" },
44 { key: "sendFullUrl", label: "Include full addresses", help: "Off: just the site name.", kind: "bool" },
44 { key: "sendFullUrl", label: "Include full addresses", help: "Off: just the site name.", kind: "bool" },
45 ],
45 ],
46 },
46 },
test/e2e/smoke-fm.py+1−1
@@ -19,7 +19,7 @@ def topic_of(text):
19
19
20
20
21def items(prompt):
21def 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]
23 out = []
23 out = []
24 for line in body.strip().splitlines():
24 for line in body.strip().splitlines():
25 m = re.match(r"^(\d+) \| (.*)$", line)
25 m = re.match(r"^(\d+) \| (.*)$", line)