2manytabs-mcp-host 2.1.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/bridge.js +285 -120
- package/host.js +29 -1
- package/lib/clusters.js +250 -0
- package/lib/guide.js +48 -0
- package/lib/snapshots.js +99 -0
- package/lib/tabs.js +75 -21
- package/package.json +33 -5
- package/skills/closing-tab-loops/SKILL.md +117 -0
- package/skills/closing-tab-loops/references/evidence.md +75 -0
- package/skills/closing-tab-loops/references/parking.md +43 -0
- package/skills/closing-tab-loops/references/tool-notes.md +70 -0
- package/skills/closing-tab-loops/references/triage.md +55 -0
- package/tools/activate-tab.tool.js +6 -4
- package/tools/close-tabs.tool.js +43 -42
- package/tools/cluster-tabs.tool.js +47 -0
- package/tools/execute-script.tool.js +54 -0
- package/tools/get-tab-text.tool.js +6 -4
- package/tools/group-tabs.tool.js +23 -10
- package/tools/index.js +10 -4
- package/tools/list-tab-groups.tool.js +2 -3
- package/tools/list-tabs.tool.js +24 -13
- package/tools/live.js +17 -0
- package/tools/open-tabs.tool.js +6 -4
- package/tools/present.js +30 -13
- package/tools/rendering.js +7 -15
- package/tools/reopen-tabs.tool.js +56 -0
- package/tools/snapshot-tabs.tool.js +36 -0
- package/tools/start-tab-cleanup.tool.js +16 -0
- package/tools/ungroup-tabs.tool.js +7 -5
- package/tools/update-tab-group.tool.js +10 -6
- package/tools/update-tab.tool.js +6 -4
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { callExtension } from '../bridge.js';
|
|
3
|
+
|
|
4
|
+
const MAX_OUTPUT_CHARS = 50_000;
|
|
5
|
+
|
|
6
|
+
export const executeScriptTool = {
|
|
7
|
+
name: 'execute_script',
|
|
8
|
+
title: 'Execute Script in Tab',
|
|
9
|
+
description:
|
|
10
|
+
'Execute a JavaScript expression or script in the context of an open browser tab and return the serialized result. ' +
|
|
11
|
+
'Useful for pulling elements from pages (e.g. images, links, tables, article bodies), clicking elements, or extracting dynamic DOM state. ' +
|
|
12
|
+
'Firefox only: Chrome tabs return an error, so use a firefox: tab id. ' +
|
|
13
|
+
'Fails on restricted system URLs (e.g. chrome://, about:, extensions).',
|
|
14
|
+
annotations: {
|
|
15
|
+
readOnlyHint: false,
|
|
16
|
+
destructiveHint: false,
|
|
17
|
+
idempotentHint: false,
|
|
18
|
+
openWorldHint: true,
|
|
19
|
+
},
|
|
20
|
+
inputSchema: {
|
|
21
|
+
tab_id: z.union([z.number().int(), z.string()])
|
|
22
|
+
.describe('The target tab ID (number or composite ID like "chrome:12" or "firefox:34").'),
|
|
23
|
+
script: z.string()
|
|
24
|
+
.describe('The JavaScript code or expression to run in the tab. Can return a value or a Promise (e.g. Array.from(document.images).map(i => i.src)).'),
|
|
25
|
+
browser: z.string().optional()
|
|
26
|
+
.describe('Optional browser hint ("chrome", "firefox") if tab_id is numeric and ambiguous.'),
|
|
27
|
+
world: z.enum(['ISOLATED', 'MAIN']).default('ISOLATED')
|
|
28
|
+
.describe('Execution context world. "ISOLATED" (default) has full DOM access. "MAIN" runs in the page JavaScript window context.'),
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
execute: async ({ tab_id, script, browser, world }) => {
|
|
32
|
+
const res = await callExtension('execute_script', { tab_id, script, browser, world });
|
|
33
|
+
if (!res || res.success === false) {
|
|
34
|
+
throw new Error(res?.error || 'Script execution failed without error details.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const value = res.result;
|
|
38
|
+
let output;
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
output = value;
|
|
41
|
+
} else if (value === null || value === undefined) {
|
|
42
|
+
output = '(null / undefined returned)';
|
|
43
|
+
} else {
|
|
44
|
+
output = JSON.stringify(value, null, 2);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (output.length > MAX_OUTPUT_CHARS) {
|
|
48
|
+
output = output.slice(0, MAX_OUTPUT_CHARS) +
|
|
49
|
+
`\n\n... [Result truncated: exceeded ${MAX_OUTPUT_CHARS} characters]`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return output;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -15,12 +15,14 @@ export const getTabTextTool = {
|
|
|
15
15
|
openWorldHint: true,
|
|
16
16
|
},
|
|
17
17
|
inputSchema: {
|
|
18
|
-
tab_id: z.number().int()
|
|
19
|
-
.describe('The numeric ID of the tab whose body text to extract.'),
|
|
18
|
+
tab_id: z.union([z.number().int(), z.string()])
|
|
19
|
+
.describe('The numeric ID (or composite like "chrome:123") of the tab whose body text to extract.'),
|
|
20
|
+
browser: z.string().optional()
|
|
21
|
+
.describe('Optional browser hint ("chrome", "firefox") if tab_id is numeric.'),
|
|
20
22
|
},
|
|
21
23
|
|
|
22
|
-
execute: async ({ tab_id }) => {
|
|
23
|
-
const res = await callExtension('get_tab_text', { tab_id });
|
|
24
|
+
execute: async ({ tab_id, browser }) => {
|
|
25
|
+
const res = await callExtension('get_tab_text', { tab_id, browser });
|
|
24
26
|
if (!res.text) {
|
|
25
27
|
return `Tab ${tab_id} returned no text content (it might be empty, loading, or restricted).`;
|
|
26
28
|
}
|
package/tools/group-tabs.tool.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { callExtension } from '../bridge.js';
|
|
3
|
+
import { GROUP_TITLE_DESCRIBE, groupTitleNote } from '../lib/tabs.js';
|
|
3
4
|
|
|
4
5
|
export const groupTabsTool = {
|
|
5
6
|
name: 'group_tabs',
|
|
6
7
|
title: 'Group Tabs',
|
|
7
8
|
description:
|
|
8
|
-
'Group open
|
|
9
|
-
'add tabs to an existing group. Optionally set a title and a color for the group.'
|
|
9
|
+
'Group open tabs into native browser tab groups. Can create a new group or ' +
|
|
10
|
+
'add tabs to an existing group. Optionally set a title and a color for the group. ' +
|
|
11
|
+
'Tabs stay in their own windows: a group cannot span windows, so tabs from several ' +
|
|
12
|
+
'windows become one matching group per window. Grouping does move tabs next to each ' +
|
|
13
|
+
'other in the tab bar while the user may be using it, so say that before you group. ' +
|
|
14
|
+
'The user is left on the tab they were on.',
|
|
10
15
|
annotations: {
|
|
11
16
|
readOnlyHint: false,
|
|
12
17
|
destructiveHint: false,
|
|
@@ -14,21 +19,29 @@ export const groupTabsTool = {
|
|
|
14
19
|
openWorldHint: true,
|
|
15
20
|
},
|
|
16
21
|
inputSchema: {
|
|
17
|
-
tab_ids: z.array(z.number().int()).min(1)
|
|
18
|
-
.describe('
|
|
19
|
-
group_id: z.number().int().optional()
|
|
20
|
-
.describe('Add to an existing group ID. If omitted, a new group is created.'),
|
|
22
|
+
tab_ids: z.array(z.union([z.number().int(), z.string()])).min(1)
|
|
23
|
+
.describe('Tab IDs to add to the group (numbers or composite like "chrome:12"). All must be in the same browser.'),
|
|
24
|
+
group_id: z.union([z.number().int(), z.string()]).optional()
|
|
25
|
+
.describe('Add to an existing group ID (number or composite like "chrome:5"). If omitted, a new group is created.'),
|
|
21
26
|
title: z.string().optional()
|
|
22
|
-
.describe(
|
|
27
|
+
.describe(GROUP_TITLE_DESCRIBE),
|
|
23
28
|
color: z.enum(['grey', 'blue', 'red', 'yellow', 'green', 'pink', 'purple', 'cyan', 'orange']).optional()
|
|
24
29
|
.describe('Color to set on the group (new or existing).'),
|
|
30
|
+
browser: z.string().optional()
|
|
31
|
+
.describe('Optional browser hint ("chrome", "firefox") if ids are numeric.'),
|
|
25
32
|
},
|
|
26
33
|
|
|
27
|
-
execute: async ({ tab_ids, group_id, title, color }) => {
|
|
28
|
-
const res = await callExtension('group_tabs', { tab_ids, group_id, title, color });
|
|
34
|
+
execute: async ({ tab_ids, group_id, title, color, browser }) => {
|
|
35
|
+
const res = await callExtension('group_tabs', { tab_ids, group_id, title, color, browser });
|
|
36
|
+
const grouped = res.grouped ?? tab_ids.length;
|
|
37
|
+
const windows = res.windows ?? 1;
|
|
29
38
|
return JSON.stringify({
|
|
30
|
-
message:
|
|
39
|
+
message: windows > 1
|
|
40
|
+
? `Grouped ${grouped} tab(s) as ${windows} matching groups, one per window. No tab changed window.`
|
|
41
|
+
: `Grouped ${grouped} tab(s).`,
|
|
31
42
|
groupId: res.groupId,
|
|
43
|
+
groupIds: res.groupIds ?? [res.groupId],
|
|
44
|
+
title_note: groupTitleNote(title),
|
|
32
45
|
}, null, 2);
|
|
33
46
|
},
|
|
34
47
|
};
|
package/tools/index.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Tool registry — the single place that knows which tools exist.
|
|
2
|
-
// To add a tool: create ./my-thing.tool.js exporting a tool object, then add it here.
|
|
3
|
-
// host.js handles all SDK wiring (schema generation, validation, dispatch) generically.
|
|
4
|
-
|
|
5
1
|
import { listTabsTool } from './list-tabs.tool.js';
|
|
6
2
|
import { closeTabsTool } from './close-tabs.tool.js';
|
|
7
3
|
import { openTabsTool } from './open-tabs.tool.js';
|
|
@@ -12,10 +8,19 @@ import { updateTabGroupTool } from './update-tab-group.tool.js';
|
|
|
12
8
|
import { activateTabTool } from './activate-tab.tool.js';
|
|
13
9
|
import { updateTabTool } from './update-tab.tool.js';
|
|
14
10
|
import { getTabTextTool } from './get-tab-text.tool.js';
|
|
11
|
+
import { executeScriptTool } from './execute-script.tool.js';
|
|
12
|
+
import { startTabCleanupTool } from './start-tab-cleanup.tool.js';
|
|
13
|
+
import { snapshotTabsTool } from './snapshot-tabs.tool.js';
|
|
14
|
+
import { clusterTabsTool } from './cluster-tabs.tool.js';
|
|
15
|
+
import { reopenTabsTool } from './reopen-tabs.tool.js';
|
|
15
16
|
|
|
16
17
|
export const toolRegistry = [
|
|
18
|
+
startTabCleanupTool,
|
|
17
19
|
listTabsTool,
|
|
20
|
+
clusterTabsTool,
|
|
21
|
+
snapshotTabsTool,
|
|
18
22
|
closeTabsTool,
|
|
23
|
+
reopenTabsTool,
|
|
19
24
|
openTabsTool,
|
|
20
25
|
listTabGroupsTool,
|
|
21
26
|
groupTabsTool,
|
|
@@ -24,4 +29,5 @@ export const toolRegistry = [
|
|
|
24
29
|
activateTabTool,
|
|
25
30
|
updateTabTool,
|
|
26
31
|
getTabTextTool,
|
|
32
|
+
executeScriptTool,
|
|
27
33
|
];
|
|
@@ -5,7 +5,7 @@ export const listTabGroupsTool = {
|
|
|
5
5
|
name: 'list_tab_groups',
|
|
6
6
|
title: 'List Tab Groups',
|
|
7
7
|
description:
|
|
8
|
-
'List all existing native
|
|
8
|
+
'List all existing native tab groups across all connected browsers. ' +
|
|
9
9
|
'Returns their IDs, titles, colors, collapsed states, and parent window IDs.',
|
|
10
10
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
11
11
|
inputSchema: {
|
|
@@ -13,7 +13,6 @@ export const listTabGroupsTool = {
|
|
|
13
13
|
.describe('Case-insensitive substring filter for group titles.'),
|
|
14
14
|
},
|
|
15
15
|
|
|
16
|
-
// fallow-ignore-next-line complexity
|
|
17
16
|
execute: async ({ title_query }) => {
|
|
18
17
|
let groups;
|
|
19
18
|
try {
|
|
@@ -38,7 +37,7 @@ export const listTabGroupsTool = {
|
|
|
38
37
|
for (const g of filtered) {
|
|
39
38
|
const titleStr = g.title ? `"${g.title}"` : '(unnamed)';
|
|
40
39
|
const collapsedStr = g.collapsed ? ' ⏸ (collapsed)' : ' ▶ (expanded)';
|
|
41
|
-
lines.push(` · [ID: ${g.id}] ${titleStr} [Color: ${g.color}] [Window: ${g.windowId}]${collapsedStr}`);
|
|
40
|
+
lines.push(` · [ID: ${g.browser ? `${g.browser}:${g.id}` : g.id}] ${titleStr} [Color: ${g.color}] [Window: ${g.windowId}]${collapsedStr}`);
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
return lines.join('\n');
|
package/tools/list-tabs.tool.js
CHANGED
|
@@ -10,10 +10,13 @@ export const listTabsTool = {
|
|
|
10
10
|
name: 'list_tabs',
|
|
11
11
|
title: 'List Tabs',
|
|
12
12
|
description:
|
|
13
|
-
'Read open
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
13
|
+
'Read open browser tabs: a domain histogram plus one line per tab. Best for finding ' +
|
|
14
|
+
'specific tabs with `query`. To survey, sort or clean up many tabs, use cluster_tabs ' +
|
|
15
|
+
'instead: it summarises any number of tabs in a few lines, where this prints every tab. ' +
|
|
16
|
+
'Filter with `query`, reshape with ' +
|
|
17
|
+
'`group_by`, or isolate redundant tabs with `duplicates_only`. Shows at most ' +
|
|
18
|
+
`${MAX_TAB_LIST} tabs and says so when there are more; cluster_tabs summarises any number. ` +
|
|
19
|
+
'Read-only — never closes anything.',
|
|
17
20
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
18
21
|
inputSchema: {
|
|
19
22
|
query: z.string().optional()
|
|
@@ -23,20 +26,26 @@ export const listTabsTool = {
|
|
|
23
26
|
"'window' groups by browser window; 'none' returns a flat list."),
|
|
24
27
|
duplicates_only: z.boolean().default(false)
|
|
25
28
|
.describe('Only include tabs that are duplicates (share a URL with an earlier tab).'),
|
|
29
|
+
browser: z.string().optional()
|
|
30
|
+
.describe('Filter tabs to a specific browser (e.g. "chrome", "firefox"). Omit to list tabs across all open browsers.'),
|
|
31
|
+
show_urls: z.boolean().default(false)
|
|
32
|
+
.describe('Print each tab\'s URL under its title. Long output for many tabs: narrow with `query` first.'),
|
|
26
33
|
},
|
|
27
34
|
|
|
28
|
-
execute: async ({ query, group_by, duplicates_only }) => {
|
|
29
|
-
|
|
35
|
+
execute: async ({ query, group_by, duplicates_only, browser, show_urls }) => {
|
|
36
|
+
let all = await callExtension('query_tabs');
|
|
37
|
+
if (browser) {
|
|
38
|
+
all = all.filter((t) => (t.browser || '').toLowerCase() === browser.toLowerCase());
|
|
39
|
+
}
|
|
30
40
|
|
|
31
|
-
let groups;
|
|
41
|
+
let groups = [];
|
|
32
42
|
try {
|
|
33
43
|
groups = await callExtension('query_groups');
|
|
34
|
-
} catch
|
|
35
|
-
|
|
36
|
-
throw new Error('Failed to fetch tab groups from extension.');
|
|
44
|
+
} catch {
|
|
45
|
+
groups = [];
|
|
37
46
|
}
|
|
38
47
|
|
|
39
|
-
const groupMap = new Map(groups.map(g => [g.id, g]));
|
|
48
|
+
const groupMap = new Map((Array.isArray(groups) ? groups : []).map(g => [g.browser ? `${g.browser}:${g.id}` : g.id, g]));
|
|
40
49
|
|
|
41
50
|
let tabs = all;
|
|
42
51
|
if (query) tabs = tabs.filter((t) => matchesQuery(t, query));
|
|
@@ -45,13 +54,13 @@ export const listTabsTool = {
|
|
|
45
54
|
tabs = tabs.filter((t) => dupIds.has(t.id));
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
|
|
57
|
+
const matched = tabs.length;
|
|
49
58
|
if (tabs.length > MAX_TAB_LIST) {
|
|
50
59
|
tabs = tabs.slice(0, MAX_TAB_LIST);
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
const histogram = domainHistogram(tabs);
|
|
54
|
-
const windows = new Set(tabs.map((t) => t.windowId)).size;
|
|
63
|
+
const windows = new Set(tabs.map((t) => t.browser ? `${t.browser}:${t.windowId}` : t.windowId)).size;
|
|
55
64
|
const dupCount = findDuplicateIds(tabs).length;
|
|
56
65
|
|
|
57
66
|
return formatTabData({
|
|
@@ -63,6 +72,8 @@ export const listTabsTool = {
|
|
|
63
72
|
duplicates_only,
|
|
64
73
|
windows,
|
|
65
74
|
dupCount,
|
|
75
|
+
matched,
|
|
76
|
+
show_urls,
|
|
66
77
|
});
|
|
67
78
|
},
|
|
68
79
|
};
|
package/tools/live.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { callExtension } from '../bridge.js';
|
|
2
|
+
|
|
3
|
+
export async function liveTabs(browser) {
|
|
4
|
+
const all = await callExtension('query_tabs');
|
|
5
|
+
if (!browser) return all;
|
|
6
|
+
return all.filter((t) => (t.browser || '').toLowerCase() === browser.toLowerCase());
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Tab groups are optional: older Firefox has none, and a failure must not hide the tabs.
|
|
10
|
+
export async function liveGroups() {
|
|
11
|
+
try {
|
|
12
|
+
const groups = await callExtension('query_groups');
|
|
13
|
+
return Array.isArray(groups) ? groups : [];
|
|
14
|
+
} catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
package/tools/open-tabs.tool.js
CHANGED
|
@@ -5,7 +5,7 @@ export const openTabsTool = {
|
|
|
5
5
|
name: 'open_tabs',
|
|
6
6
|
title: 'Open Tabs',
|
|
7
7
|
description:
|
|
8
|
-
'Open one or more URLs in new
|
|
8
|
+
'Open one or more URLs in new browser tabs. This tool allows you to navigate the user to ' +
|
|
9
9
|
'specific web pages or search queries.\n\n' +
|
|
10
10
|
'Examples:\n' +
|
|
11
11
|
'- {"urls": ["https://github.com", "https://youtube.com"]}\n' +
|
|
@@ -15,21 +15,23 @@ export const openTabsTool = {
|
|
|
15
15
|
' - opened (int): Number of tabs successfully opened',
|
|
16
16
|
annotations: {
|
|
17
17
|
readOnlyHint: false,
|
|
18
|
-
destructiveHint: false,
|
|
18
|
+
destructiveHint: false,
|
|
19
19
|
idempotentHint: false,
|
|
20
20
|
openWorldHint: true,
|
|
21
21
|
},
|
|
22
22
|
inputSchema: {
|
|
23
23
|
urls: z.array(z.string()).min(1)
|
|
24
24
|
.describe('An array of URLs to open. If no protocol is provided, https:// will be prepended automatically.'),
|
|
25
|
+
browser: z.string().optional()
|
|
26
|
+
.describe('Target browser to open tabs in (e.g. "chrome", "firefox"). Defaults to the active browser.'),
|
|
25
27
|
},
|
|
26
28
|
|
|
27
|
-
execute: async ({ urls }) => {
|
|
29
|
+
execute: async ({ urls, browser }) => {
|
|
28
30
|
if (!urls || urls.length === 0) {
|
|
29
31
|
throw new Error('Provide at least one URL to open.');
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
const res = await callExtension('open_tabs', { urls });
|
|
34
|
+
const res = await callExtension('open_tabs', { urls, browser });
|
|
33
35
|
return JSON.stringify({ opened: res.opened }, null, 2);
|
|
34
36
|
},
|
|
35
37
|
};
|
package/tools/present.js
CHANGED
|
@@ -27,7 +27,7 @@ function renderHistogram(histogram) {
|
|
|
27
27
|
return lines;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function renderByDomain(tabs, groupMap) {
|
|
30
|
+
function renderByDomain(tabs, groupMap, urlLine) {
|
|
31
31
|
const grouped = {};
|
|
32
32
|
for (const t of tabs) {
|
|
33
33
|
const domain = t.url ? new URL(t.url).hostname : '(local)';
|
|
@@ -41,7 +41,7 @@ function renderByDomain(tabs, groupMap) {
|
|
|
41
41
|
const label = tabLabel(t);
|
|
42
42
|
const flags = buildFlags(t);
|
|
43
43
|
const flagStr = formatFlags(flags);
|
|
44
|
-
lines.push(` · ${formatIdPrefix(t)}${formatGroupPrefix(groupMap, t)}${label}${flagStr}`);
|
|
44
|
+
lines.push(` · ${formatIdPrefix(t)}${formatGroupPrefix(groupMap, t)}${label}${flagStr}${urlLine(t)}`);
|
|
45
45
|
}
|
|
46
46
|
lines.push('');
|
|
47
47
|
}
|
|
@@ -49,20 +49,23 @@ function renderByDomain(tabs, groupMap) {
|
|
|
49
49
|
return lines;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function renderByWindow(tabs, groupMap) {
|
|
52
|
+
function renderByWindow(tabs, groupMap, urlLine) {
|
|
53
53
|
const grouped = {};
|
|
54
54
|
for (const t of tabs) {
|
|
55
|
-
|
|
55
|
+
const key = t.browser ? `${t.browser}:${t.windowId}` : `${t.windowId}`;
|
|
56
|
+
(grouped[key] ??= []).push(t);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
const lines = [];
|
|
59
|
-
for (const [
|
|
60
|
-
|
|
60
|
+
for (const [key, tabsInWin] of Object.entries(grouped)) {
|
|
61
|
+
const browser = tabsInWin[0]?.browser;
|
|
62
|
+
const winId = tabsInWin[0]?.windowId ?? key;
|
|
63
|
+
lines.push(formatWindowHeader(winId, tabsInWin.length, browser));
|
|
61
64
|
for (const t of tabsInWin) {
|
|
62
65
|
const label = tabLabel(t);
|
|
63
66
|
const flags = buildFlags(t);
|
|
64
67
|
const flagStr = formatFlags(flags);
|
|
65
|
-
lines.push(` · ${formatIdPrefix(t)}${formatGroupPrefix(groupMap, t)}${label}${flagStr}`);
|
|
68
|
+
lines.push(` · ${formatIdPrefix(t)}${formatGroupPrefix(groupMap, t)}${label}${flagStr}${urlLine(t)}`);
|
|
66
69
|
}
|
|
67
70
|
lines.push('');
|
|
68
71
|
}
|
|
@@ -70,7 +73,7 @@ function renderByWindow(tabs, groupMap) {
|
|
|
70
73
|
return lines;
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
function renderFlat(tabs, groupMap) {
|
|
76
|
+
function renderFlat(tabs, groupMap, urlLine) {
|
|
74
77
|
const lines = [];
|
|
75
78
|
for (const t of tabs) {
|
|
76
79
|
const c = compact(t);
|
|
@@ -80,7 +83,7 @@ function renderFlat(tabs, groupMap) {
|
|
|
80
83
|
: c.title;
|
|
81
84
|
const flags = buildFlags(c);
|
|
82
85
|
const flagStr = formatFlags(flags);
|
|
83
|
-
lines.push(` · ${formatIdPrefix(t)}${formatGroupPrefix(groupMap, t)}${label}${flagStr}`);
|
|
86
|
+
lines.push(` · ${formatIdPrefix(t)}${formatGroupPrefix(groupMap, t)}${label}${flagStr}${urlLine(t)}`);
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
return lines;
|
|
@@ -96,27 +99,41 @@ export function formatTabData(data) {
|
|
|
96
99
|
duplicates_only,
|
|
97
100
|
windows,
|
|
98
101
|
dupCount,
|
|
102
|
+
matched = tabs.length,
|
|
103
|
+
show_urls = false,
|
|
99
104
|
} = data;
|
|
100
105
|
|
|
101
106
|
const lines = [];
|
|
107
|
+
const urlLine = (t) => (show_urls ? `\n ${t.url ?? ''}` : '');
|
|
108
|
+
|
|
109
|
+
const browsers = [...new Set(tabs.map((t) => t.browser).filter(Boolean))];
|
|
110
|
+
const browserSummary = browsers.length > 1
|
|
111
|
+
? ` across ${browsers.length} browsers (${browsers.map((b) => `${b}: ${tabs.filter((t) => t.browser === b).length}`).join(', ')}) and`
|
|
112
|
+
: ' across';
|
|
102
113
|
|
|
103
114
|
lines.push(
|
|
104
|
-
`📊 ${tabs.length} tab(s)
|
|
115
|
+
`📊 ${tabs.length} tab(s)${browserSummary} ${windows} window(s)` +
|
|
105
116
|
(query ? ` matching "${query}"` : '') +
|
|
106
117
|
(duplicates_only ? ' (duplicates only)' : '') +
|
|
107
118
|
(dupCount > 0 ? ` · ${dupCount} duplicate(s) found` : '') +
|
|
108
119
|
'\n',
|
|
109
120
|
);
|
|
121
|
+
if (matched > tabs.length) {
|
|
122
|
+
lines.push(
|
|
123
|
+
`⚠️ Showing the first ${tabs.length} of ${matched} tabs. Counts, histogram and duplicates above ` +
|
|
124
|
+
'cover only those shown. Narrow with `query` or `browser`, or use cluster_tabs for the whole set.\n',
|
|
125
|
+
);
|
|
126
|
+
}
|
|
110
127
|
|
|
111
128
|
lines.push(...renderHistogram(histogram));
|
|
112
129
|
lines.push('');
|
|
113
130
|
|
|
114
131
|
if (group_by === 'domain') {
|
|
115
|
-
lines.push(...renderByDomain(tabs, groupMap));
|
|
132
|
+
lines.push(...renderByDomain(tabs, groupMap, urlLine));
|
|
116
133
|
} else if (group_by === 'window') {
|
|
117
|
-
lines.push(...renderByWindow(tabs, groupMap));
|
|
134
|
+
lines.push(...renderByWindow(tabs, groupMap, urlLine));
|
|
118
135
|
} else {
|
|
119
|
-
lines.push(...renderFlat(tabs, groupMap));
|
|
136
|
+
lines.push(...renderFlat(tabs, groupMap, urlLine));
|
|
120
137
|
}
|
|
121
138
|
|
|
122
139
|
return lines.join('\n');
|
package/tools/rendering.js
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
/** Rendering adapters for tab display - delegates to dedicated formatters. */
|
|
2
|
-
|
|
3
|
-
/** Build emoji flags for tab metadata attributes. */
|
|
4
1
|
export function buildFlags(tab) {
|
|
5
2
|
const flags = [];
|
|
6
3
|
if (tab.pinned) flags.push('📌');
|
|
@@ -8,41 +5,36 @@ export function buildFlags(tab) {
|
|
|
8
5
|
return flags;
|
|
9
6
|
}
|
|
10
7
|
|
|
11
|
-
/** Format emoji flags as display string. */
|
|
12
8
|
export function formatFlags(flags) {
|
|
13
9
|
return flags.length ? ' ' + flags.join('') : '';
|
|
14
10
|
}
|
|
15
11
|
|
|
16
|
-
/** Draw ASCII histogram bar for a domain count. */
|
|
17
12
|
export function drawBar(count, maxCount, maxWidth) {
|
|
18
13
|
return '▇'.repeat(Math.max(1, Math.round((count / maxCount) * maxWidth)));
|
|
19
14
|
}
|
|
20
15
|
|
|
21
|
-
/** Draw a single histogram line. */
|
|
22
16
|
export function formatHistogramLine(domain, count, bar) {
|
|
23
17
|
return ` ${bar} ${domain.padEnd(28)} ${count} tab(s)`;
|
|
24
18
|
}
|
|
25
19
|
|
|
26
|
-
/** Draw domain header. */
|
|
27
20
|
export function formatDomainHeader(domain, count) {
|
|
28
21
|
return `📁 ${domain} — ${count} tab(s)`;
|
|
29
22
|
}
|
|
30
23
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return `🪟 Window ${winId} — ${count} tab(s)`;
|
|
24
|
+
export function formatWindowHeader(winId, count, browser) {
|
|
25
|
+
const browserPrefix = browser ? `[${browser.charAt(0).toUpperCase() + browser.slice(1)}] ` : '';
|
|
26
|
+
return `🪟 ${browserPrefix}Window ${winId} — ${count} tab(s)`;
|
|
34
27
|
}
|
|
35
28
|
|
|
36
|
-
/** Draw group prefix. */
|
|
37
29
|
export function formatGroupPrefix(groupMap, tab) {
|
|
38
30
|
if (tab.groupId === undefined || tab.groupId === -1) return '';
|
|
39
|
-
const g = groupMap.get(tab.groupId);
|
|
31
|
+
const g = groupMap.get(tab.browser ? `${tab.browser}:${tab.groupId}` : tab.groupId);
|
|
40
32
|
return g ? `[Group: ${g.title || 'Group ' + g.id}] ` : '';
|
|
41
33
|
}
|
|
42
34
|
|
|
43
|
-
/** Draw the tab's numeric id - every id-based tool (activate_tab, update_tab,
|
|
44
|
-
* get_tab_text, group_tabs, ungroup_tabs, close_tabs' tab_ids mode) needs one,
|
|
45
|
-
* and this is the only place callers can read it from. */
|
|
46
35
|
export function formatIdPrefix(tab) {
|
|
36
|
+
if (tab.browser) {
|
|
37
|
+
return `[id:${tab.browser}:${tab.id}] `;
|
|
38
|
+
}
|
|
47
39
|
return `[id:${tab.id}] `;
|
|
48
40
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { callExtension } from '../bridge.js';
|
|
3
|
+
import { checkExpectedCount, CONFIRM_COUNT_OVER } from '../lib/tabs.js';
|
|
4
|
+
import { readRecord, reopenCandidates, snapshotDir } from '../lib/snapshots.js';
|
|
5
|
+
import { liveTabs } from './live.js';
|
|
6
|
+
|
|
7
|
+
const MAX_REOPEN = 100; // more at once can stall a browser; ask for a narrower match
|
|
8
|
+
|
|
9
|
+
export const reopenTabsTool = {
|
|
10
|
+
name: 'reopen_tabs',
|
|
11
|
+
title: 'Reopen Tabs from a Record',
|
|
12
|
+
description:
|
|
13
|
+
'Reopen tabs saved by snapshot_tabs that are no longer open, in the browser they came ' +
|
|
14
|
+
'from. Narrow with `match` (substring of title or URL). Preview with `dry_run` first. ' +
|
|
15
|
+
`Reopening more than ${CONFIRM_COUNT_OVER} tabs needs \`expected_count\` from the preview, ` +
|
|
16
|
+
`and at most ${MAX_REOPEN} open per call. Only web and file links are reopened.\n\n` +
|
|
17
|
+
'Returns JSON: record, dry_run, count (or opened), sample of up to 10 {title, url}.',
|
|
18
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
19
|
+
inputSchema: {
|
|
20
|
+
record: z.string().optional()
|
|
21
|
+
.describe('Record name returned by snapshot_tabs, e.g. "tabs-20261012-141500.json". Omit for the newest.'),
|
|
22
|
+
match: z.string().optional()
|
|
23
|
+
.describe('Only reopen tabs whose title or URL contains this substring (case-insensitive).'),
|
|
24
|
+
dry_run: z.boolean().default(false)
|
|
25
|
+
.describe('Preview what would reopen without opening anything.'),
|
|
26
|
+
expected_count: z.number().int().min(1).optional()
|
|
27
|
+
.describe(`The number of tabs the user agreed to reopen, from the preview. Required above ${CONFIRM_COUNT_OVER}.`),
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
execute: async ({ record, match, dry_run, expected_count }) => {
|
|
31
|
+
const { name, record: rec } = readRecord(snapshotDir(), record);
|
|
32
|
+
const targets = reopenCandidates(rec, await liveTabs(), match);
|
|
33
|
+
const sample = targets.slice(0, 10).map(({ title, url }) => ({ title, url }));
|
|
34
|
+
if (targets.length === 0) {
|
|
35
|
+
return `Nothing to reopen from ${name}${match ? ` matching "${match}"` : ''}: every such tab is already open.`;
|
|
36
|
+
}
|
|
37
|
+
if (dry_run) return JSON.stringify({ record: name, dry_run: true, count: targets.length, sample }, null, 2);
|
|
38
|
+
|
|
39
|
+
checkExpectedCount(targets.length, expected_count, 'reopen');
|
|
40
|
+
if (targets.length > MAX_REOPEN) {
|
|
41
|
+
throw new Error(`That is ${targets.length} tabs; reopen at most ${MAX_REOPEN} at a time. Use a narrower match.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const byBrowser = new Map();
|
|
45
|
+
for (const t of targets) {
|
|
46
|
+
if (!byBrowser.has(t.browser)) byBrowser.set(t.browser, []);
|
|
47
|
+
byBrowser.get(t.browser).push(t.url);
|
|
48
|
+
}
|
|
49
|
+
let opened = 0;
|
|
50
|
+
for (const [browser, urls] of byBrowser) {
|
|
51
|
+
const res = await callExtension('open_tabs', browser ? { urls, browser } : { urls });
|
|
52
|
+
opened += res?.opened ?? 0;
|
|
53
|
+
}
|
|
54
|
+
return JSON.stringify({ record: name, dry_run: false, opened, sample }, null, 2);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildRecord, snapshotDir, writeRecord } from '../lib/snapshots.js';
|
|
3
|
+
import { liveTabs, liveGroups } from './live.js';
|
|
4
|
+
|
|
5
|
+
export const snapshotTabsTool = {
|
|
6
|
+
name: 'snapshot_tabs',
|
|
7
|
+
title: 'Save a Record of Open Tabs',
|
|
8
|
+
description:
|
|
9
|
+
'Save a record of every open tab (title, link, window, tab group, pinned, last used) to a ' +
|
|
10
|
+
'file in this computer\'s app-data folder, before closing anything. Nothing leaves the ' +
|
|
11
|
+
'computer, and no cookies or typed form text are saved. Call it once at the start of a ' +
|
|
12
|
+
'clean-up; reopen_tabs can later reopen tabs from the record.\n\n' +
|
|
13
|
+
'Returns JSON: record (name to pass to reopen_tabs), path, tabs, windows, by_browser, pinned.',
|
|
14
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
15
|
+
inputSchema: {
|
|
16
|
+
browser: z.string().optional()
|
|
17
|
+
.describe('Only record this browser ("chrome", "firefox"). Omit to record every connected browser.'),
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
execute: async ({ browser }) => {
|
|
21
|
+
const tabs = await liveTabs(browser);
|
|
22
|
+
const record = buildRecord(tabs, await liveGroups());
|
|
23
|
+
const { name, path } = writeRecord(record, snapshotDir());
|
|
24
|
+
const byBrowser = {};
|
|
25
|
+
for (const t of tabs) byBrowser[t.browser ?? 'unknown'] = (byBrowser[t.browser ?? 'unknown'] ?? 0) + 1;
|
|
26
|
+
return JSON.stringify({
|
|
27
|
+
record: name,
|
|
28
|
+
path,
|
|
29
|
+
tabs: record.tab_count,
|
|
30
|
+
windows: record.windows.length,
|
|
31
|
+
by_browser: byBrowser,
|
|
32
|
+
pinned: tabs.filter((t) => t.pinned).length,
|
|
33
|
+
for_your_reply: 'Say once that a list of their open tabs was saved first. Give the name or path only if they ask.',
|
|
34
|
+
}, null, 2);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { cleanupGuide } from '../lib/guide.js';
|
|
2
|
+
|
|
3
|
+
export const startTabCleanupTool = {
|
|
4
|
+
name: 'start_tab_cleanup',
|
|
5
|
+
title: 'Start a Tab Clean-up',
|
|
6
|
+
description:
|
|
7
|
+
'Call this first when the user has too many tabs, wants to clean up, close, sort or dedupe ' +
|
|
8
|
+
'lots of tabs, or feels stuck or stressed about closing them. Returns the step-by-step ' +
|
|
9
|
+
'method to follow: save a record, sort tabs into clusters, close what the user confirms, ' +
|
|
10
|
+
'and park the few real to-dos with a next step and a when, in the user\'s own words. ' +
|
|
11
|
+
'Read-only; it does not touch any tab.',
|
|
12
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
13
|
+
inputSchema: {},
|
|
14
|
+
|
|
15
|
+
execute: async () => cleanupGuide(),
|
|
16
|
+
};
|
|
@@ -4,7 +4,7 @@ import { callExtension } from '../bridge.js';
|
|
|
4
4
|
export const ungroupTabsTool = {
|
|
5
5
|
name: 'ungroup_tabs',
|
|
6
6
|
title: 'Ungroup Tabs',
|
|
7
|
-
description: 'Remove one or more open
|
|
7
|
+
description: 'Remove one or more open tabs from their current native tab groups.',
|
|
8
8
|
annotations: {
|
|
9
9
|
readOnlyHint: false,
|
|
10
10
|
destructiveHint: false,
|
|
@@ -12,12 +12,14 @@ export const ungroupTabsTool = {
|
|
|
12
12
|
openWorldHint: true,
|
|
13
13
|
},
|
|
14
14
|
inputSchema: {
|
|
15
|
-
tab_ids: z.array(z.number().int()).min(1)
|
|
16
|
-
.describe('
|
|
15
|
+
tab_ids: z.array(z.union([z.number().int(), z.string()])).min(1)
|
|
16
|
+
.describe('Tab IDs to remove from any groups (numbers or composite like "chrome:12").'),
|
|
17
|
+
browser: z.string().optional()
|
|
18
|
+
.describe('Optional browser hint ("chrome", "firefox") if ids are numeric.'),
|
|
17
19
|
},
|
|
18
20
|
|
|
19
|
-
execute: async ({ tab_ids }) => {
|
|
20
|
-
const res = await callExtension('ungroup_tabs', { tab_ids });
|
|
21
|
+
execute: async ({ tab_ids, browser }) => {
|
|
22
|
+
const res = await callExtension('ungroup_tabs', { tab_ids, browser });
|
|
21
23
|
return JSON.stringify({
|
|
22
24
|
message: `Successfully ungrouped ${res.ungrouped} tab(s).`,
|
|
23
25
|
ungrouped: res.ungrouped,
|