2manytabs-mcp-host 3.0.0 → 3.1.1
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/README.md +38 -3
- 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 +73 -0
- package/package.json +10 -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/close-tabs.tool.js +27 -50
- package/tools/cluster-tabs.tool.js +47 -0
- package/tools/group-tabs.tool.js +14 -3
- package/tools/index.js +8 -0
- package/tools/list-tabs.tool.js +13 -5
- package/tools/live.js +17 -0
- package/tools/present.js +18 -9
- 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/update-tab-group.tool.js +3 -1
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 browser tabs
|
|
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()
|
|
@@ -25,9 +28,11 @@ export const listTabsTool = {
|
|
|
25
28
|
.describe('Only include tabs that are duplicates (share a URL with an earlier tab).'),
|
|
26
29
|
browser: z.string().optional()
|
|
27
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.'),
|
|
28
33
|
},
|
|
29
34
|
|
|
30
|
-
execute: async ({ query, group_by, duplicates_only, browser }) => {
|
|
35
|
+
execute: async ({ query, group_by, duplicates_only, browser, show_urls }) => {
|
|
31
36
|
let all = await callExtension('query_tabs');
|
|
32
37
|
if (browser) {
|
|
33
38
|
all = all.filter((t) => (t.browser || '').toLowerCase() === browser.toLowerCase());
|
|
@@ -49,6 +54,7 @@ export const listTabsTool = {
|
|
|
49
54
|
tabs = tabs.filter((t) => dupIds.has(t.id));
|
|
50
55
|
}
|
|
51
56
|
|
|
57
|
+
const matched = tabs.length;
|
|
52
58
|
if (tabs.length > MAX_TAB_LIST) {
|
|
53
59
|
tabs = tabs.slice(0, MAX_TAB_LIST);
|
|
54
60
|
}
|
|
@@ -66,6 +72,8 @@ export const listTabsTool = {
|
|
|
66
72
|
duplicates_only,
|
|
67
73
|
windows,
|
|
68
74
|
dupCount,
|
|
75
|
+
matched,
|
|
76
|
+
show_urls,
|
|
69
77
|
});
|
|
70
78
|
},
|
|
71
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/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,7 +49,7 @@ 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}`;
|
|
@@ -65,7 +65,7 @@ function renderByWindow(tabs, groupMap) {
|
|
|
65
65
|
const label = tabLabel(t);
|
|
66
66
|
const flags = buildFlags(t);
|
|
67
67
|
const flagStr = formatFlags(flags);
|
|
68
|
-
lines.push(` · ${formatIdPrefix(t)}${formatGroupPrefix(groupMap, t)}${label}${flagStr}`);
|
|
68
|
+
lines.push(` · ${formatIdPrefix(t)}${formatGroupPrefix(groupMap, t)}${label}${flagStr}${urlLine(t)}`);
|
|
69
69
|
}
|
|
70
70
|
lines.push('');
|
|
71
71
|
}
|
|
@@ -73,7 +73,7 @@ function renderByWindow(tabs, groupMap) {
|
|
|
73
73
|
return lines;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
function renderFlat(tabs, groupMap) {
|
|
76
|
+
function renderFlat(tabs, groupMap, urlLine) {
|
|
77
77
|
const lines = [];
|
|
78
78
|
for (const t of tabs) {
|
|
79
79
|
const c = compact(t);
|
|
@@ -83,7 +83,7 @@ function renderFlat(tabs, groupMap) {
|
|
|
83
83
|
: c.title;
|
|
84
84
|
const flags = buildFlags(c);
|
|
85
85
|
const flagStr = formatFlags(flags);
|
|
86
|
-
lines.push(` · ${formatIdPrefix(t)}${formatGroupPrefix(groupMap, t)}${label}${flagStr}`);
|
|
86
|
+
lines.push(` · ${formatIdPrefix(t)}${formatGroupPrefix(groupMap, t)}${label}${flagStr}${urlLine(t)}`);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
return lines;
|
|
@@ -99,9 +99,12 @@ export function formatTabData(data) {
|
|
|
99
99
|
duplicates_only,
|
|
100
100
|
windows,
|
|
101
101
|
dupCount,
|
|
102
|
+
matched = tabs.length,
|
|
103
|
+
show_urls = false,
|
|
102
104
|
} = data;
|
|
103
105
|
|
|
104
106
|
const lines = [];
|
|
107
|
+
const urlLine = (t) => (show_urls ? `\n ${t.url ?? ''}` : '');
|
|
105
108
|
|
|
106
109
|
const browsers = [...new Set(tabs.map((t) => t.browser).filter(Boolean))];
|
|
107
110
|
const browserSummary = browsers.length > 1
|
|
@@ -115,16 +118,22 @@ export function formatTabData(data) {
|
|
|
115
118
|
(dupCount > 0 ? ` · ${dupCount} duplicate(s) found` : '') +
|
|
116
119
|
'\n',
|
|
117
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
|
+
}
|
|
118
127
|
|
|
119
128
|
lines.push(...renderHistogram(histogram));
|
|
120
129
|
lines.push('');
|
|
121
130
|
|
|
122
131
|
if (group_by === 'domain') {
|
|
123
|
-
lines.push(...renderByDomain(tabs, groupMap));
|
|
132
|
+
lines.push(...renderByDomain(tabs, groupMap, urlLine));
|
|
124
133
|
} else if (group_by === 'window') {
|
|
125
|
-
lines.push(...renderByWindow(tabs, groupMap));
|
|
134
|
+
lines.push(...renderByWindow(tabs, groupMap, urlLine));
|
|
126
135
|
} else {
|
|
127
|
-
lines.push(...renderFlat(tabs, groupMap));
|
|
136
|
+
lines.push(...renderFlat(tabs, groupMap, urlLine));
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
return lines.join('\n');
|
|
@@ -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
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
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 updateTabGroupTool = {
|
|
5
6
|
name: 'update_tab_group',
|
|
@@ -17,7 +18,7 @@ export const updateTabGroupTool = {
|
|
|
17
18
|
group_id: z.union([z.number().int(), z.string()])
|
|
18
19
|
.describe('The ID (number or composite like "chrome:5") of the tab group to update (obtained from list_tab_groups or list_tabs).'),
|
|
19
20
|
title: z.string().optional()
|
|
20
|
-
.describe(
|
|
21
|
+
.describe(GROUP_TITLE_DESCRIBE),
|
|
21
22
|
color: z.enum(['grey', 'blue', 'red', 'yellow', 'green', 'pink', 'purple', 'cyan', 'orange']).optional()
|
|
22
23
|
.describe('New color for the group.'),
|
|
23
24
|
collapsed: z.boolean().optional()
|
|
@@ -31,6 +32,7 @@ export const updateTabGroupTool = {
|
|
|
31
32
|
return JSON.stringify({
|
|
32
33
|
message: `Successfully updated tab group ${group_id}.`,
|
|
33
34
|
groupId: res.updated,
|
|
35
|
+
title_note: groupTitleNote(title),
|
|
34
36
|
}, null, 2);
|
|
35
37
|
},
|
|
36
38
|
};
|