@blockrun/franklin 3.16.4 → 3.17.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/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/agent/context.js
CHANGED
|
@@ -150,7 +150,17 @@ RULES (violations will produce garbage output):
|
|
|
150
150
|
6. End with: "Reply to any? Give me the number."
|
|
151
151
|
7. Do NOT auto-post. Do NOT explain how the social system works.
|
|
152
152
|
|
|
153
|
-
When
|
|
153
|
+
When the user pastes a specific tweet URL (https://x.com/<user>/status/<id>): Call SearchX with the URL as the query. The tool auto-detects URL mode and reads the post directly. Do NOT search for the URL as a keyword (always returns empty), and do NOT try WebFetch on x.com.
|
|
154
|
+
|
|
155
|
+
When checking notifications/mentions: Use SearchX with mode="notifications". One call, done.
|
|
156
|
+
|
|
157
|
+
If SearchX returns empty or "no article extracted" on a URL/query you believe SHOULD have content (you can see the page in the browser, or the user confirms it exists), DO NOT give up — drop down to the BrowserX primitive and drive the browser yourself:
|
|
158
|
+
1. BrowserX action="snapshot" → see what's on screen right now
|
|
159
|
+
2. BrowserX action="scroll" dy=600 → trigger lazy-render / load more
|
|
160
|
+
3. BrowserX action="snapshot" again → re-inspect after scroll
|
|
161
|
+
4. BrowserX action="click" ref=<id> → follow a permalink (refs come from the last snapshot)
|
|
162
|
+
5. BrowserX action="open" url=<other> → try a different URL (e.g. /search?q=… or a profile page)
|
|
163
|
+
BrowserX shares the logged-in X session with SearchX, so authentication is already handled. Use BrowserX only for read/navigation; replies still go through PostToX with explicit user confirmation.`;
|
|
154
164
|
}
|
|
155
165
|
function getMissingAccessSection() {
|
|
156
166
|
return `# Missing Access
|
|
@@ -184,6 +184,21 @@ export function classifyAgentError(message) {
|
|
|
184
184
|
suggestion: 'Tool schema rejected by this model. Try /model to switch to a more permissive model (e.g. sonnet), or upgrade Franklin.',
|
|
185
185
|
};
|
|
186
186
|
}
|
|
187
|
+
// Unknown / typo'd model id — gateway returns HTTP 400 with a body like
|
|
188
|
+
// "Unknown model: moonshot/kimi-k2". Without this branch the error falls
|
|
189
|
+
// through to the catch-all 'unknown' category and shows the user a bare
|
|
190
|
+
// "Type: Unknown" with no actionable next step.
|
|
191
|
+
if (includesAny(err, [
|
|
192
|
+
'unknown model',
|
|
193
|
+
'model not found',
|
|
194
|
+
'model does not exist',
|
|
195
|
+
'no such model',
|
|
196
|
+
])) {
|
|
197
|
+
return {
|
|
198
|
+
category: 'schema', label: 'Schema', isTransient: false, maxRetries: 0,
|
|
199
|
+
suggestion: 'The gateway rejected the model id (unknown / typo). Use /model to pick a valid one, or upgrade Franklin if a fallback chain references a stale id.',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
187
202
|
if (includesAny(err, [
|
|
188
203
|
'500',
|
|
189
204
|
'502',
|
package/dist/agent/loop.js
CHANGED
|
@@ -341,6 +341,13 @@ export function looksLikeStalledIntent(text) {
|
|
|
341
341
|
const trimmed = text.trim();
|
|
342
342
|
if (trimmed.length < 24)
|
|
343
343
|
return false;
|
|
344
|
+
// If the final non-empty line is a short question to the user, the model is
|
|
345
|
+
// explicitly deferring ("Which would you prefer?", "Want me to proceed?") —
|
|
346
|
+
// that's a handoff, not a stall. Avoid re-invoking on another model and
|
|
347
|
+
// billing twice for what is in fact correct behavior.
|
|
348
|
+
const lastLine = trimmed.split(/\n+/).map(s => s.trim()).filter(Boolean).pop() ?? '';
|
|
349
|
+
if (lastLine.length > 0 && lastLine.length <= 120 && /[??]\s*$/.test(lastLine))
|
|
350
|
+
return false;
|
|
344
351
|
// Look at the last ~400 chars only — intent-to-act lives near the end.
|
|
345
352
|
const tail = trimmed.slice(-400).toLowerCase();
|
|
346
353
|
// Strong "I'm about to do something" markers near the tail.
|
|
@@ -1336,7 +1343,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
1336
1343
|
// Excludes nvidia/* and *-coder-* — they're the source population.
|
|
1337
1344
|
const TOOL_USE_FALLBACK_MODELS = [
|
|
1338
1345
|
'anthropic/claude-haiku-4.5',
|
|
1339
|
-
'moonshot/kimi-k2',
|
|
1346
|
+
'moonshot/kimi-k2.6',
|
|
1340
1347
|
'openai/gpt-5',
|
|
1341
1348
|
'anthropic/claude-sonnet-4.6',
|
|
1342
1349
|
];
|
|
@@ -33,10 +33,10 @@ function isCommonDevCommand(cmd) {
|
|
|
33
33
|
return COMMON_DEV_PATTERNS.some(p => p.test(trimmed));
|
|
34
34
|
}
|
|
35
35
|
// ─── Default Rules ─────────────────────────────────────────────────────────
|
|
36
|
-
const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX']);
|
|
36
|
+
const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX', 'BrowserX']);
|
|
37
37
|
const DESTRUCTIVE_TOOLS = new Set(['Write', 'Edit', 'Bash']);
|
|
38
38
|
const DEFAULT_RULES = {
|
|
39
|
-
allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX'],
|
|
39
|
+
allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX', 'BrowserX'],
|
|
40
40
|
deny: [],
|
|
41
41
|
ask: ['Write', 'Edit', 'Bash', 'Agent', 'PostToX'],
|
|
42
42
|
};
|
package/dist/agent/tool-guard.js
CHANGED
|
@@ -183,6 +183,13 @@ export class SessionToolGuard {
|
|
|
183
183
|
startTurn() {
|
|
184
184
|
this.turn++;
|
|
185
185
|
this.webSearchesThisTurn = 0;
|
|
186
|
+
// The per-tool circuit breaker exists to stop a model from burning a
|
|
187
|
+
// whole turn re-attacking a wall. It must NOT outlive the user turn that
|
|
188
|
+
// earned the failures — a fresh prompt is a fresh intent. Without this
|
|
189
|
+
// reset, three failed Bash calls (e.g. `franklin social login x` on a
|
|
190
|
+
// host without the right env) permanently disable Bash for the rest of
|
|
191
|
+
// the session, even on completely unrelated follow-ups.
|
|
192
|
+
this.toolErrorCounts.clear();
|
|
186
193
|
for (const family of this.searchFamilies) {
|
|
187
194
|
family.turnSearches = 0;
|
|
188
195
|
}
|
|
@@ -233,6 +240,32 @@ export class SessionToolGuard {
|
|
|
233
240
|
const cmd = String(invocation.input.command ?? '').trim();
|
|
234
241
|
if (!cmd)
|
|
235
242
|
return null;
|
|
243
|
+
// Reject interactive franklin subcommands that require the human at the
|
|
244
|
+
// keyboard (they spawn a non-headless Chrome and wait for the user to
|
|
245
|
+
// close it). If the agent runs them via Bash they block until timeout,
|
|
246
|
+
// burn a tool-failure strike, and contribute nothing. Tell the agent to
|
|
247
|
+
// ask the user to run them in a separate terminal instead.
|
|
248
|
+
if (/^\s*franklin\s+social\s+(login|setup)\b/.test(cmd)) {
|
|
249
|
+
return {
|
|
250
|
+
output: 'Blocked: `franklin social login` / `franklin social setup` are INTERACTIVE — ' +
|
|
251
|
+
'they open a Chrome window the human must drive and close. They cannot run from ' +
|
|
252
|
+
'an agent Bash call (they will hang then time out). ' +
|
|
253
|
+
'Ask the user to run this in their own terminal, then continue once they say it is done.',
|
|
254
|
+
isError: true,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
// `franklin social run` is a batch poster/replier that loops over the
|
|
258
|
+
// user's configured search_queries — it is not the right tool for
|
|
259
|
+
// "read this specific tweet" or "draft replies to one post". Steer the
|
|
260
|
+
// agent to SearchX (now URL-aware) instead.
|
|
261
|
+
if (/^\s*franklin\s+social\s+run\b/.test(cmd)) {
|
|
262
|
+
return {
|
|
263
|
+
output: 'Blocked: `franklin social run` is a batch reply loop over the user\'s configured ' +
|
|
264
|
+
'queries, not a single-tweet reader. Use the SearchX tool instead — pass a tweet URL ' +
|
|
265
|
+
'as the query to read one post, or use mode="search"/"notifications" for discovery.',
|
|
266
|
+
isError: true,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
236
269
|
// Reject blocking poll-loops in foreground bash. A single bash call with
|
|
237
270
|
// `sleep N` inside a for/while/until loop blocks the agent for the full
|
|
238
271
|
// duration — the UI repeats the same status line and the user almost
|
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