@blockrun/franklin 3.16.4 → 3.18.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/dist/agent/context.js +11 -1
- package/dist/agent/error-classifier.js +15 -0
- package/dist/agent/loop.js +8 -1
- package/dist/agent/permissions.js +2 -2
- package/dist/agent/tool-guard.js +33 -0
- package/dist/panel/html.js +403 -0
- package/dist/panel/server.js +217 -6
- package/dist/phone/cache.d.ts +44 -0
- package/dist/phone/cache.js +74 -0
- package/dist/phone/client.d.ts +50 -0
- package/dist/phone/client.js +162 -0
- package/dist/social/browser.js +97 -12
- package/dist/tools/browsex.d.ts +17 -0
- package/dist/tools/browsex.js +156 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/searchx.d.ts +1 -0
- package/dist/tools/searchx.js +121 -8
- package/dist/tools/webfetch.js +2 -2
- package/package.json +1 -1
package/dist/social/browser.js
CHANGED
|
@@ -21,6 +21,64 @@ function ensureProfileDir() {
|
|
|
21
21
|
fs.mkdirSync(SOCIAL_PROFILE_DIR, { recursive: true });
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
// Chrome leaves a few singleton lock files in the user-data-dir when running.
|
|
25
|
+
// If the previous Chromium process crashed (or franklin was killed with -9)
|
|
26
|
+
// these files survive even though no real Chrome owns the profile. The next
|
|
27
|
+
// launchPersistentContext sees them and refuses to start the browser. We
|
|
28
|
+
// detect that case (lock exists + no process), remove the locks, and retry.
|
|
29
|
+
const SINGLETON_LOCKS = ['SingletonLock', 'SingletonCookie', 'SingletonSocket'];
|
|
30
|
+
function readSingletonOwnerPid() {
|
|
31
|
+
// On macOS/Linux, Chrome writes the PID into SingletonLock as a symlink
|
|
32
|
+
// target like "hostname-12345". Parse it; if any token is a live PID, the
|
|
33
|
+
// profile is genuinely in use.
|
|
34
|
+
const lockPath = path.join(SOCIAL_PROFILE_DIR, 'SingletonLock');
|
|
35
|
+
try {
|
|
36
|
+
const target = fs.readlinkSync(lockPath);
|
|
37
|
+
const match = /-(\d+)$/.exec(target);
|
|
38
|
+
if (!match)
|
|
39
|
+
return null;
|
|
40
|
+
const pid = Number.parseInt(match[1], 10);
|
|
41
|
+
return Number.isFinite(pid) ? pid : null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function isPidAlive(pid) {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 0);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
// ESRCH = no such process. EPERM = exists but we can't signal it (still alive).
|
|
54
|
+
return err.code === 'EPERM';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function clearStaleSingletonLocks() {
|
|
58
|
+
const pid = readSingletonOwnerPid();
|
|
59
|
+
if (pid !== null && isPidAlive(pid))
|
|
60
|
+
return false; // real Chrome still using it
|
|
61
|
+
let removedAny = false;
|
|
62
|
+
for (const name of SINGLETON_LOCKS) {
|
|
63
|
+
const p = path.join(SOCIAL_PROFILE_DIR, name);
|
|
64
|
+
try {
|
|
65
|
+
fs.rmSync(p, { force: true });
|
|
66
|
+
removedAny = true;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// ignore — file may simply not exist
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return removedAny;
|
|
73
|
+
}
|
|
74
|
+
function isProfileLockError(msg) {
|
|
75
|
+
const m = msg.toLowerCase();
|
|
76
|
+
return (m.includes('profile') && (m.includes('in use') || m.includes('locked')) ||
|
|
77
|
+
m.includes('singletonlock') ||
|
|
78
|
+
m.includes('processsingleton') ||
|
|
79
|
+
m.includes('user data directory is already in use') ||
|
|
80
|
+
m.includes('failed to create a chromedriver'));
|
|
81
|
+
}
|
|
24
82
|
/**
|
|
25
83
|
* Walk an AX tree and produce:
|
|
26
84
|
* 1. A flat text dump with [depth-idx] refs (for regex-based element finding)
|
|
@@ -148,18 +206,19 @@ export class SocialBrowser {
|
|
|
148
206
|
// Lazy import — playwright-core is ~2MB and we don't want to pay the
|
|
149
207
|
// import cost on every franklin command (e.g. `franklin --version`)
|
|
150
208
|
const { chromium } = await import('playwright-core');
|
|
209
|
+
const launchOnce = () => chromium.launchPersistentContext(SOCIAL_PROFILE_DIR, {
|
|
210
|
+
headless: this.opts.headless,
|
|
211
|
+
channel: this.opts.channel,
|
|
212
|
+
slowMo: this.opts.slowMo,
|
|
213
|
+
viewport: this.opts.viewport,
|
|
214
|
+
// Pretend to be a regular Chrome (not headless fingerprint)
|
|
215
|
+
args: [
|
|
216
|
+
'--disable-blink-features=AutomationControlled',
|
|
217
|
+
'--no-default-browser-check',
|
|
218
|
+
],
|
|
219
|
+
});
|
|
151
220
|
try {
|
|
152
|
-
this.context = await
|
|
153
|
-
headless: this.opts.headless,
|
|
154
|
-
channel: this.opts.channel,
|
|
155
|
-
slowMo: this.opts.slowMo,
|
|
156
|
-
viewport: this.opts.viewport,
|
|
157
|
-
// Pretend to be a regular Chrome (not headless fingerprint)
|
|
158
|
-
args: [
|
|
159
|
-
'--disable-blink-features=AutomationControlled',
|
|
160
|
-
'--no-default-browser-check',
|
|
161
|
-
],
|
|
162
|
-
});
|
|
221
|
+
this.context = await launchOnce();
|
|
163
222
|
}
|
|
164
223
|
catch (err) {
|
|
165
224
|
const msg = err.message;
|
|
@@ -168,7 +227,33 @@ export class SocialBrowser {
|
|
|
168
227
|
`Or install manually:\n npx playwright install chromium\n\n` +
|
|
169
228
|
`Original error: ${msg}`);
|
|
170
229
|
}
|
|
171
|
-
|
|
230
|
+
// Stale singleton-lock from a crashed Chrome / killed franklin. If no
|
|
231
|
+
// live PID owns the lock, scrub it and retry once. Don't auto-clean
|
|
232
|
+
// when a real process still owns the profile — that would corrupt
|
|
233
|
+
// their running session.
|
|
234
|
+
if (isProfileLockError(msg) || msg.toLowerCase().includes('failed to launch')) {
|
|
235
|
+
const cleared = clearStaleSingletonLocks();
|
|
236
|
+
if (cleared) {
|
|
237
|
+
try {
|
|
238
|
+
this.context = await launchOnce();
|
|
239
|
+
}
|
|
240
|
+
catch (err2) {
|
|
241
|
+
throw new Error(`Chrome profile lock recovery failed. The profile dir at\n ${SOCIAL_PROFILE_DIR}\n` +
|
|
242
|
+
`had stale lock files; we removed them and retried, but launch still failed.\n` +
|
|
243
|
+
`Close any running Chrome/Chromium using this profile and try again.\n\n` +
|
|
244
|
+
`Original error: ${msg}\nRetry error: ${err2.message}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
throw new Error(`Chrome profile is in use at\n ${SOCIAL_PROFILE_DIR}\n` +
|
|
249
|
+
`Another franklin instance (or a Chrome with that user-data-dir) is running.\n` +
|
|
250
|
+
`Close it and retry, or run: pkill -f social-chrome-profile\n\n` +
|
|
251
|
+
`Original error: ${msg}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
throw err;
|
|
256
|
+
}
|
|
172
257
|
}
|
|
173
258
|
// Reuse existing tab if any, else open new
|
|
174
259
|
const existing = this.context.pages();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserX capability — low-level primitives over Franklin's social Chrome
|
|
3
|
+
* profile. Use this when SearchX's pattern matcher is too rigid and the
|
|
4
|
+
* agent needs to drive the browser iteratively: open arbitrary URLs, take
|
|
5
|
+
* fresh snapshots, scroll to load more, screenshot the viewport.
|
|
6
|
+
*
|
|
7
|
+
* Shares the same persistent profile as SearchX/PostToX (~/.blockrun/
|
|
8
|
+
* social-chrome-profile) so the X login session is reused for free.
|
|
9
|
+
*
|
|
10
|
+
* Intentionally OMITS post-side actions (type/press/click on form fields).
|
|
11
|
+
* Replying still goes through PostToX, which has its own confirmation flow.
|
|
12
|
+
* `click` IS exposed because clicking is how you navigate on an SPA — the
|
|
13
|
+
* model is instructed to only click navigation elements (tweet permalinks,
|
|
14
|
+
* profile links, "Show more"), never reply/like/follow buttons.
|
|
15
|
+
*/
|
|
16
|
+
import type { CapabilityHandler } from '../agent/types.js';
|
|
17
|
+
export declare const browserXCapability: CapabilityHandler;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserX capability — low-level primitives over Franklin's social Chrome
|
|
3
|
+
* profile. Use this when SearchX's pattern matcher is too rigid and the
|
|
4
|
+
* agent needs to drive the browser iteratively: open arbitrary URLs, take
|
|
5
|
+
* fresh snapshots, scroll to load more, screenshot the viewport.
|
|
6
|
+
*
|
|
7
|
+
* Shares the same persistent profile as SearchX/PostToX (~/.blockrun/
|
|
8
|
+
* social-chrome-profile) so the X login session is reused for free.
|
|
9
|
+
*
|
|
10
|
+
* Intentionally OMITS post-side actions (type/press/click on form fields).
|
|
11
|
+
* Replying still goes through PostToX, which has its own confirmation flow.
|
|
12
|
+
* `click` IS exposed because clicking is how you navigate on an SPA — the
|
|
13
|
+
* model is instructed to only click navigation elements (tweet permalinks,
|
|
14
|
+
* profile links, "Show more"), never reply/like/follow buttons.
|
|
15
|
+
*/
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import { browserPool } from '../social/browser-pool.js';
|
|
19
|
+
const MAX_WAIT_MS = 15_000;
|
|
20
|
+
function summariseTree(tree, max = 8000) {
|
|
21
|
+
if (tree.length <= max)
|
|
22
|
+
return tree;
|
|
23
|
+
return tree.slice(0, max) + `\n\n[…truncated; tree was ${tree.length} chars total]`;
|
|
24
|
+
}
|
|
25
|
+
async function execute(input, _ctx) {
|
|
26
|
+
const { action, url, ref, dy, ms, path: outPath } = input;
|
|
27
|
+
if (!action) {
|
|
28
|
+
return { output: 'Error: action is required (open|snapshot|click|scroll|screenshot|getUrl|wait)', isError: true };
|
|
29
|
+
}
|
|
30
|
+
let browser;
|
|
31
|
+
try {
|
|
32
|
+
browser = await browserPool.getBrowser();
|
|
33
|
+
switch (action) {
|
|
34
|
+
case 'open': {
|
|
35
|
+
if (!url)
|
|
36
|
+
return { output: 'Error: open requires url', isError: true };
|
|
37
|
+
try {
|
|
38
|
+
await browser.open(url);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
42
|
+
return { output: `BrowserX open(${url}) failed: ${msg.slice(0, 200)}`, isError: true };
|
|
43
|
+
}
|
|
44
|
+
return { output: `Opened ${url}. Call action="snapshot" next to inspect the page.` };
|
|
45
|
+
}
|
|
46
|
+
case 'snapshot': {
|
|
47
|
+
try {
|
|
48
|
+
const tree = await browser.snapshot();
|
|
49
|
+
const out = `Page snapshot (${tree.length} chars):\n\n${summariseTree(tree)}\n\n` +
|
|
50
|
+
`Refs are valid until the next snapshot. Use action="click" with a ref to navigate.`;
|
|
51
|
+
return { output: out };
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
55
|
+
return { output: `BrowserX snapshot failed: ${msg.slice(0, 200)}`, isError: true };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
case 'click': {
|
|
59
|
+
if (!ref)
|
|
60
|
+
return { output: 'Error: click requires ref (from last snapshot)', isError: true };
|
|
61
|
+
try {
|
|
62
|
+
await browser.click(ref);
|
|
63
|
+
// Give the navigation a moment to start, then return — model can
|
|
64
|
+
// call snapshot next to see the result.
|
|
65
|
+
await browser.waitForTimeout(1500);
|
|
66
|
+
const newUrl = await browser.getUrl();
|
|
67
|
+
return { output: `Clicked ref [${ref}]. Current URL: ${newUrl}. Call action="snapshot" to see the result.` };
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
71
|
+
return { output: `BrowserX click(${ref}) failed: ${msg.slice(0, 200)}`, isError: true };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
case 'scroll': {
|
|
75
|
+
const delta = Number.isFinite(dy) ? Math.max(-2000, Math.min(2000, Number(dy))) : 600;
|
|
76
|
+
try {
|
|
77
|
+
await browser.scroll(640, 450, 0, delta);
|
|
78
|
+
await browser.waitForTimeout(800);
|
|
79
|
+
return { output: `Scrolled ${delta > 0 ? 'down' : 'up'} ${Math.abs(delta)}px. Call action="snapshot" to see new content.` };
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
83
|
+
return { output: `BrowserX scroll failed: ${msg.slice(0, 200)}`, isError: true };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
case 'screenshot': {
|
|
87
|
+
const finalPath = outPath
|
|
88
|
+
? outPath
|
|
89
|
+
: path.join(os.homedir(), '.blockrun', 'screenshots', `browsex-${Date.now()}.png`);
|
|
90
|
+
try {
|
|
91
|
+
const fs = await import('node:fs');
|
|
92
|
+
fs.mkdirSync(path.dirname(finalPath), { recursive: true });
|
|
93
|
+
await browser.screenshot(finalPath);
|
|
94
|
+
return { output: `Screenshot saved to ${finalPath}. Use the Read tool to view it.` };
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
98
|
+
return { output: `BrowserX screenshot failed: ${msg.slice(0, 200)}`, isError: true };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
case 'getUrl': {
|
|
102
|
+
try {
|
|
103
|
+
const u = await browser.getUrl();
|
|
104
|
+
return { output: `Current URL: ${u}` };
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
108
|
+
return { output: `BrowserX getUrl failed: ${msg.slice(0, 200)}`, isError: true };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
case 'wait': {
|
|
112
|
+
const waitMs = Number.isFinite(ms) ? Math.min(MAX_WAIT_MS, Math.max(0, Number(ms))) : 2000;
|
|
113
|
+
await browser.waitForTimeout(waitMs);
|
|
114
|
+
return { output: `Waited ${waitMs}ms.` };
|
|
115
|
+
}
|
|
116
|
+
default:
|
|
117
|
+
return { output: `Error: unknown action "${action}" (use open|snapshot|click|scroll|screenshot|getUrl|wait)`, isError: true };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
122
|
+
return { output: `BrowserX error: ${msg}`, isError: true };
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
browserPool.releaseBrowser();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export const browserXCapability = {
|
|
129
|
+
spec: {
|
|
130
|
+
name: 'BrowserX',
|
|
131
|
+
description: 'Drive Franklin\'s social Chrome profile (logged-in to X) iteratively. ' +
|
|
132
|
+
'Use when SearchX returns empty or the page needs scrolling/clicking to surface content. ' +
|
|
133
|
+
'Actions: open(url), snapshot(), click(ref), scroll(dy), screenshot(), getUrl(), wait(ms). ' +
|
|
134
|
+
'Snapshot returns an accessibility tree with [depth-idx] refs you pass to click. ' +
|
|
135
|
+
'Refs reset on every snapshot. SAFE: do NOT use this to like/follow/post — replies go through PostToX with confirmation. ' +
|
|
136
|
+
'Typical flow: open → snapshot → click a permalink → snapshot → read content.',
|
|
137
|
+
input_schema: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: {
|
|
140
|
+
action: {
|
|
141
|
+
type: 'string',
|
|
142
|
+
enum: ['open', 'snapshot', 'click', 'scroll', 'screenshot', 'getUrl', 'wait'],
|
|
143
|
+
description: 'open: navigate to a URL. snapshot: capture the page as a ref tree. click: click an element by ref. scroll: scroll vertically by dy px. screenshot: save a PNG. getUrl: return the current URL. wait: pause for ms.',
|
|
144
|
+
},
|
|
145
|
+
url: { type: 'string', description: 'URL for open' },
|
|
146
|
+
ref: { type: 'string', description: 'AX ref (e.g. "2-17") from the last snapshot, for click' },
|
|
147
|
+
dy: { type: 'number', description: 'Vertical scroll delta in px (positive = down). Defaults to 600. Clamped to [-2000, 2000].' },
|
|
148
|
+
ms: { type: 'number', description: 'Milliseconds to wait. Defaults to 2000. Capped at 15000.' },
|
|
149
|
+
path: { type: 'string', description: 'Output path for screenshot. Defaults to ~/.blockrun/screenshots/browsex-<ts>.png.' },
|
|
150
|
+
},
|
|
151
|
+
required: ['action'],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
execute,
|
|
155
|
+
concurrent: false,
|
|
156
|
+
};
|
package/dist/tools/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { askUserCapability } from './askuser.js';
|
|
|
22
22
|
import { tradingSignalCapability, tradingMarketCapability } from './trading.js';
|
|
23
23
|
import { searchXCapability } from './searchx.js';
|
|
24
24
|
import { postToXCapability } from './posttox.js';
|
|
25
|
+
import { browserXCapability } from './browsex.js';
|
|
25
26
|
import { moaCapability } from './moa.js';
|
|
26
27
|
import { webhookPostCapability } from './webhook.js';
|
|
27
28
|
import { walletCapability } from './wallet.js';
|
|
@@ -147,6 +148,7 @@ export const allCapabilities = [
|
|
|
147
148
|
...defaultContentCapabilities, // ContentCreate, ContentAddAsset, ContentShow, ContentList
|
|
148
149
|
searchXCapability,
|
|
149
150
|
postToXCapability,
|
|
151
|
+
browserXCapability,
|
|
150
152
|
moaCapability,
|
|
151
153
|
webhookPostCapability,
|
|
152
154
|
walletCapability,
|
package/dist/tools/searchx.d.ts
CHANGED
|
@@ -7,5 +7,6 @@
|
|
|
7
7
|
* - **Enhanced** (with social config): adds product routing, dedup, login detection
|
|
8
8
|
*/
|
|
9
9
|
import type { CapabilityHandler } from '../agent/types.js';
|
|
10
|
+
export declare function canonicalTweetUrl(input: string): string | null;
|
|
10
11
|
export declare function detectNotificationsIntent(query: string | undefined, handle: string, knownHandles?: string[]): boolean;
|
|
11
12
|
export declare const searchXCapability: CapabilityHandler;
|
package/dist/tools/searchx.js
CHANGED
|
@@ -12,6 +12,18 @@ import { computePreKey, hasPreKey } from '../social/db.js';
|
|
|
12
12
|
import { detectProduct } from '../social/ai.js';
|
|
13
13
|
import { loadConfig, isConfigReady } from '../social/config.js';
|
|
14
14
|
import { browserPool } from '../social/browser-pool.js';
|
|
15
|
+
// Detect a tweet permalink the user (or a paste) handed us instead of a
|
|
16
|
+
// keyword. Treat twitter.com and x.com interchangeably; trim the tracking
|
|
17
|
+
// suffix (?s=20 etc.) and normalise to the canonical x.com host so the
|
|
18
|
+
// browser doesn't waste a redirect hop.
|
|
19
|
+
const TWEET_URL_RE = /^https?:\/\/(?:www\.|mobile\.)?(?:x|twitter)\.com\/[^/\s]+\/status\/(\d+)/i;
|
|
20
|
+
export function canonicalTweetUrl(input) {
|
|
21
|
+
const m = TWEET_URL_RE.exec((input ?? '').trim());
|
|
22
|
+
if (!m)
|
|
23
|
+
return null;
|
|
24
|
+
return input.trim().replace(/^https?:\/\/(?:www\.|mobile\.)?(?:x|twitter)\.com/i, 'https://x.com')
|
|
25
|
+
.replace(/[?#].*$/, '');
|
|
26
|
+
}
|
|
15
27
|
// ─── Intent detection (code-level, not LLM-level) ──────────────────────────
|
|
16
28
|
// When the user asks "check my @handle mentions/notifications", the tool
|
|
17
29
|
// itself routes to x.com/notifications. English-only keyword fast-path;
|
|
@@ -57,12 +69,111 @@ export function detectNotificationsIntent(query, handle, knownHandles) {
|
|
|
57
69
|
return true;
|
|
58
70
|
return false;
|
|
59
71
|
}
|
|
72
|
+
async function readTweetByUrl(rawUrl) {
|
|
73
|
+
const url = canonicalTweetUrl(rawUrl) ?? rawUrl;
|
|
74
|
+
let browser;
|
|
75
|
+
try {
|
|
76
|
+
browser = await browserPool.getBrowser();
|
|
77
|
+
try {
|
|
78
|
+
await browser.open(url);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
82
|
+
return {
|
|
83
|
+
output: `SearchX (url mode): failed to open ${url}: ${msg.slice(0, 200)}`,
|
|
84
|
+
isError: true,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// Tweet pages are SPAs that lazy-render the article block. A single
|
|
88
|
+
// 4s wait + single snapshot misses content on slow networks or when
|
|
89
|
+
// X briefly shows the auth-wall during hydration even for logged-in
|
|
90
|
+
// sessions. Retry up to 3 times with progressive backoff and a small
|
|
91
|
+
// scroll to nudge the virtual list into rendering.
|
|
92
|
+
let tree = '';
|
|
93
|
+
let articles = [];
|
|
94
|
+
const WAIT_MS = [2500, 4000, 5000];
|
|
95
|
+
let attempt = 0;
|
|
96
|
+
while (attempt < WAIT_MS.length) {
|
|
97
|
+
await browser.waitForTimeout(WAIT_MS[attempt]);
|
|
98
|
+
try {
|
|
99
|
+
tree = await browser.snapshot();
|
|
100
|
+
}
|
|
101
|
+
catch (snapErr) {
|
|
102
|
+
const snapMsg = snapErr instanceof Error ? snapErr.message : String(snapErr);
|
|
103
|
+
return {
|
|
104
|
+
output: `SearchX (url mode): snapshot failed (${snapMsg.slice(0, 100)}). The browser session likely closed mid-flight — retry, or ask the user to run \`franklin social setup\` in a separate terminal.`,
|
|
105
|
+
isError: true,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (tree.includes('Rate limit') || tree.includes('Something went wrong')) {
|
|
109
|
+
return {
|
|
110
|
+
output: `SearchX: X returned an error page on ${url} (rate limit or server issue). Try again in a minute.`,
|
|
111
|
+
isError: true,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (/this post is unavailable|tweet was deleted|page (doesn'?t|does not) exist|account.*suspended/i.test(tree)) {
|
|
115
|
+
return {
|
|
116
|
+
output: `SearchX: tweet at ${url} is unavailable, deleted, or its author is suspended.`,
|
|
117
|
+
isError: true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
articles = extractArticleBlocks(tree);
|
|
121
|
+
if (articles.length > 0)
|
|
122
|
+
break;
|
|
123
|
+
// Nudge the page so X mounts the lazy article block.
|
|
124
|
+
try {
|
|
125
|
+
await browser.scroll(400, 400, 0, 400);
|
|
126
|
+
}
|
|
127
|
+
catch { /* ignore */ }
|
|
128
|
+
attempt++;
|
|
129
|
+
}
|
|
130
|
+
const treeLen = tree.length;
|
|
131
|
+
if (articles.length === 0 && tree.includes('Sign in') && tree.includes('Create account')) {
|
|
132
|
+
return {
|
|
133
|
+
output: `SearchX: X is showing a login wall on ${url} after ${WAIT_MS.length} attempts. If you ARE logged in, the cached session may have expired — ask the user to run \`franklin social login x\` in a separate terminal (interactive: opens a Chrome window).`,
|
|
134
|
+
isError: true,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (articles.length === 0) {
|
|
138
|
+
return {
|
|
139
|
+
output: `SearchX (url mode): no article extracted from ${url} after ${WAIT_MS.length} attempts. ` +
|
|
140
|
+
`Page rendered ${treeLen} chars. The tweet may load with a non-standard layout — drive the browser ` +
|
|
141
|
+
`directly with BrowserX (action="snapshot" to inspect, action="scroll" to load more, ` +
|
|
142
|
+
`action="open" url=<other> to navigate).\n\n[debug] tree preview:\n${tree.slice(0, 600)}`,
|
|
143
|
+
isError: true,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const primary = articles[0];
|
|
147
|
+
const texts = findStaticText(primary.text);
|
|
148
|
+
const snippet = texts.join(' ').trim().slice(0, 1200);
|
|
149
|
+
let output = `Tweet at ${url}:\n\n${snippet}\n\n---\n`;
|
|
150
|
+
output += 'IMPORTANT: This is the real post content. ';
|
|
151
|
+
output += 'Do NOT fabricate additional context, replies, or metrics. ';
|
|
152
|
+
output += 'If the user asked for replies/comments, draft them from THIS text only.';
|
|
153
|
+
return { output };
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
157
|
+
return { output: `SearchX (url mode) error: ${msg}`, isError: true };
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
browserPool.releaseBrowser();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
60
163
|
async function execute(input, _ctx) {
|
|
61
164
|
const { query, max_results, mode } = input;
|
|
62
165
|
if (!query && mode !== 'notifications') {
|
|
63
166
|
return { output: 'Error: query is required (or set mode to "notifications")', isError: true };
|
|
64
167
|
}
|
|
65
168
|
const maxResults = Math.min(Math.max(max_results ?? 10, 1), 50);
|
|
169
|
+
// ── URL fast-path: user pasted a tweet permalink ────────────────────
|
|
170
|
+
// SearchX is the only X-aware tool. If the input is a tweet URL we read
|
|
171
|
+
// the post directly instead of searching for its URL as a keyword (which
|
|
172
|
+
// always returns empty). Triggers on mode="url" OR auto-detected URL.
|
|
173
|
+
const tweetUrl = canonicalTweetUrl(query ?? '');
|
|
174
|
+
if (mode === 'url' || tweetUrl) {
|
|
175
|
+
return await readTweetByUrl(tweetUrl ?? query);
|
|
176
|
+
}
|
|
66
177
|
// ── Config: load if available, degrade gracefully if not ────────────
|
|
67
178
|
const config = loadConfig();
|
|
68
179
|
const configStatus = isConfigReady(config);
|
|
@@ -93,7 +204,7 @@ async function execute(input, _ctx) {
|
|
|
93
204
|
if (!preflight.ready) {
|
|
94
205
|
if (isNotifications) {
|
|
95
206
|
return {
|
|
96
|
-
output: 'Not logged in to X.
|
|
207
|
+
output: 'Not logged in to X. Ask the user to run `franklin social login x` in a separate terminal (it opens a Chrome window for them to log in and is NOT runnable by you via Bash) first — notifications require authentication.',
|
|
97
208
|
isError: true,
|
|
98
209
|
};
|
|
99
210
|
}
|
|
@@ -144,7 +255,7 @@ async function execute(input, _ctx) {
|
|
|
144
255
|
const treeLen = tree.length;
|
|
145
256
|
if (isLoginWall) {
|
|
146
257
|
return {
|
|
147
|
-
output: `SearchX: X is showing a login wall.
|
|
258
|
+
output: `SearchX: X is showing a login wall. Ask the user to run \`franklin social login x\` in a separate terminal (interactive — opens a Chrome window they must drive). Do NOT try to run that command from Bash; it will hang and time out.\n\nTree preview (${treeLen} chars):\n${tree.slice(0, 500)}`,
|
|
148
259
|
isError: true,
|
|
149
260
|
};
|
|
150
261
|
}
|
|
@@ -279,20 +390,22 @@ export const searchXCapability = {
|
|
|
279
390
|
spec: {
|
|
280
391
|
name: 'SearchX',
|
|
281
392
|
description: 'The ONLY tool that can access X (Twitter). Returns real posts with URLs. ' +
|
|
282
|
-
'Use mode "search" to find posts by keyword
|
|
283
|
-
'
|
|
393
|
+
'Use mode "search" to find posts by keyword, "notifications" to check mentions/replies, ' +
|
|
394
|
+
'or "url" to read a specific tweet — you can also just pass a tweet URL as the query and ' +
|
|
395
|
+
'this tool will auto-detect URL mode. Call ONCE per topic — do not retry. ' +
|
|
396
|
+
'WebSearch/WebFetch CANNOT access X.com.',
|
|
284
397
|
input_schema: {
|
|
285
398
|
type: 'object',
|
|
286
399
|
properties: {
|
|
287
|
-
query: { type: 'string', description: 'Search query
|
|
400
|
+
query: { type: 'string', description: 'Search query for "search" mode, OR a tweet URL (https://x.com/<user>/status/<id>) for "url" mode — URL is auto-detected if passed in query. Optional for "notifications" mode.' },
|
|
288
401
|
max_results: {
|
|
289
402
|
type: 'number',
|
|
290
|
-
description: 'Max posts to return (default 10)',
|
|
403
|
+
description: 'Max posts to return (default 10, ignored in "url" mode)',
|
|
291
404
|
},
|
|
292
405
|
mode: {
|
|
293
406
|
type: 'string',
|
|
294
|
-
enum: ['search', 'notifications'],
|
|
295
|
-
description: 'Mode: "search"
|
|
407
|
+
enum: ['search', 'notifications', 'url'],
|
|
408
|
+
description: 'Mode: "search" finds posts by keyword, "notifications" checks your mentions/replies, "url" reads a specific tweet. Default: auto (URL → "url" mode, otherwise "search").',
|
|
296
409
|
},
|
|
297
410
|
},
|
|
298
411
|
required: [],
|
package/dist/tools/webfetch.js
CHANGED
|
@@ -237,12 +237,12 @@ const BLOCKED_DOMAINS = [
|
|
|
237
237
|
{
|
|
238
238
|
pattern: /(^|\.)x\.com$/i,
|
|
239
239
|
reason: 'X.com requires authenticated API',
|
|
240
|
-
alternative: 'use SearchX (the
|
|
240
|
+
alternative: 'use SearchX. For a specific tweet URL pass it as the query (SearchX auto-detects /status/<id> URLs and reads the post directly). For keyword discovery use mode="search". WebFetch on x.com will not work.',
|
|
241
241
|
},
|
|
242
242
|
{
|
|
243
243
|
pattern: /(^|\.)twitter\.com$/i,
|
|
244
244
|
reason: 'X.com requires authenticated API',
|
|
245
|
-
alternative: 'use SearchX (the
|
|
245
|
+
alternative: 'use SearchX. For a specific tweet URL pass it as the query (SearchX auto-detects /status/<id> URLs and reads the post directly). For keyword discovery use mode="search". WebFetch on twitter.com will not work.',
|
|
246
246
|
},
|
|
247
247
|
{
|
|
248
248
|
pattern: /(^|\.)tiktok\.com$/i,
|
package/package.json
CHANGED