@blockrun/franklin 3.1.2 → 3.2.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/commands/social.d.ts +24 -0
- package/dist/commands/social.js +258 -0
- package/dist/index.js +20 -0
- package/dist/social/a11y.d.ts +54 -0
- package/dist/social/a11y.js +89 -0
- package/dist/social/ai.d.ts +61 -0
- package/dist/social/ai.js +103 -0
- package/dist/social/browser.d.ts +97 -0
- package/dist/social/browser.js +219 -0
- package/dist/social/config.d.ts +43 -0
- package/dist/social/config.js +83 -0
- package/dist/social/db.d.ts +102 -0
- package/dist/social/db.js +248 -0
- package/dist/social/x.d.ts +46 -0
- package/dist/social/x.js +284 -0
- package/package.json +2 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* franklin social <action>
|
|
3
|
+
*
|
|
4
|
+
* Native X bot subsystem. No MCP, no plugin SDK, no external CLI deps.
|
|
5
|
+
* Ships as part of the core npm package; only runtime dep is playwright-core,
|
|
6
|
+
* which is lazy-imported so startup stays fast.
|
|
7
|
+
*
|
|
8
|
+
* Actions:
|
|
9
|
+
* setup — install chromium via playwright, write default config
|
|
10
|
+
* login x — open browser to x.com and wait for user to log in; save state
|
|
11
|
+
* run — search X, generate drafts, post (requires --live) or dry-run
|
|
12
|
+
* stats — show posted/skipped/drafted counts and total cost
|
|
13
|
+
* config — open ~/.blockrun/social-config.json for manual editing
|
|
14
|
+
*/
|
|
15
|
+
export interface SocialCommandOptions {
|
|
16
|
+
dryRun?: boolean;
|
|
17
|
+
live?: boolean;
|
|
18
|
+
model?: string;
|
|
19
|
+
debug?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Entry point wired from src/index.ts as `franklin social [action] [arg]`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function socialCommand(action: string | undefined, arg: string | undefined, options: SocialCommandOptions): Promise<void>;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* franklin social <action>
|
|
3
|
+
*
|
|
4
|
+
* Native X bot subsystem. No MCP, no plugin SDK, no external CLI deps.
|
|
5
|
+
* Ships as part of the core npm package; only runtime dep is playwright-core,
|
|
6
|
+
* which is lazy-imported so startup stays fast.
|
|
7
|
+
*
|
|
8
|
+
* Actions:
|
|
9
|
+
* setup — install chromium via playwright, write default config
|
|
10
|
+
* login x — open browser to x.com and wait for user to log in; save state
|
|
11
|
+
* run — search X, generate drafts, post (requires --live) or dry-run
|
|
12
|
+
* stats — show posted/skipped/drafted counts and total cost
|
|
13
|
+
* config — open ~/.blockrun/social-config.json for manual editing
|
|
14
|
+
*/
|
|
15
|
+
import chalk from 'chalk';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { loadConfig as loadSocialConfig, saveConfig as saveSocialConfig, isConfigReady, CONFIG_PATH, } from '../social/config.js';
|
|
19
|
+
import { SocialBrowser, SOCIAL_PROFILE_DIR } from '../social/browser.js';
|
|
20
|
+
import { runX } from '../social/x.js';
|
|
21
|
+
import { getStats } from '../social/db.js';
|
|
22
|
+
import { loadChain, API_URLS } from '../config.js';
|
|
23
|
+
import { loadConfig as loadAppConfig } from './config.js';
|
|
24
|
+
/**
|
|
25
|
+
* Entry point wired from src/index.ts as `franklin social [action] [arg]`.
|
|
26
|
+
*/
|
|
27
|
+
export async function socialCommand(action, arg, options) {
|
|
28
|
+
switch (action) {
|
|
29
|
+
case undefined:
|
|
30
|
+
case 'help':
|
|
31
|
+
printHelp();
|
|
32
|
+
return;
|
|
33
|
+
case 'setup':
|
|
34
|
+
await setupCommand();
|
|
35
|
+
return;
|
|
36
|
+
case 'login':
|
|
37
|
+
await loginCommand(arg);
|
|
38
|
+
return;
|
|
39
|
+
case 'run':
|
|
40
|
+
await runCommand(options);
|
|
41
|
+
return;
|
|
42
|
+
case 'stats':
|
|
43
|
+
statsCommand();
|
|
44
|
+
return;
|
|
45
|
+
case 'config':
|
|
46
|
+
configCommand(arg);
|
|
47
|
+
return;
|
|
48
|
+
default:
|
|
49
|
+
console.log(chalk.red(`Unknown social action: ${action}`));
|
|
50
|
+
printHelp();
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ─── help ──────────────────────────────────────────────────────────────────
|
|
55
|
+
function printHelp() {
|
|
56
|
+
console.log('');
|
|
57
|
+
console.log(chalk.bold(' franklin social') + chalk.dim(' — native X bot (no MCP, no plugin deps)'));
|
|
58
|
+
console.log('');
|
|
59
|
+
console.log(' Actions:');
|
|
60
|
+
console.log(` ${chalk.cyan('setup')} Install chromium, create default config`);
|
|
61
|
+
console.log(` ${chalk.cyan('login x')} Open browser to x.com, save login state`);
|
|
62
|
+
console.log(` ${chalk.cyan('run')} Search X, generate + (optionally) post replies`);
|
|
63
|
+
console.log(` ${chalk.dim('--dry-run (default) generate drafts, do NOT post')}`);
|
|
64
|
+
console.log(` ${chalk.dim('--live actually post to X')}`);
|
|
65
|
+
console.log(` ${chalk.dim('-m <model> override the AI model')}`);
|
|
66
|
+
console.log(` ${chalk.cyan('stats')} Show posted / drafted / skipped totals`);
|
|
67
|
+
console.log(` ${chalk.cyan('config')} Print the path to the config file (or pass edit)`);
|
|
68
|
+
console.log('');
|
|
69
|
+
console.log(` Config: ${chalk.dim(CONFIG_PATH)}`);
|
|
70
|
+
console.log(` Profile: ${chalk.dim(SOCIAL_PROFILE_DIR)}`);
|
|
71
|
+
console.log('');
|
|
72
|
+
console.log(' Typical first-run flow:');
|
|
73
|
+
console.log(` ${chalk.cyan('$')} franklin social setup`);
|
|
74
|
+
console.log(` ${chalk.cyan('$')} franklin social config edit ${chalk.dim('# set handle, products, queries')}`);
|
|
75
|
+
console.log(` ${chalk.cyan('$')} franklin social login x ${chalk.dim('# log in once; cookies persist')}`);
|
|
76
|
+
console.log(` ${chalk.cyan('$')} franklin social run ${chalk.dim('# dry-run, preview drafts')}`);
|
|
77
|
+
console.log(` ${chalk.cyan('$')} franklin social run --live ${chalk.dim('# actually post')}`);
|
|
78
|
+
console.log('');
|
|
79
|
+
}
|
|
80
|
+
// ─── setup ────────────────────────────────────────────────────────────────
|
|
81
|
+
async function setupCommand() {
|
|
82
|
+
console.log(chalk.bold('\n Franklin social — setup\n'));
|
|
83
|
+
// 1. Install chromium via playwright CLI (ships with playwright-core)
|
|
84
|
+
console.log(chalk.dim(' Installing chromium for the social browser…'));
|
|
85
|
+
console.log(chalk.dim(' (~150MB, one-time download to ~/.cache/ms-playwright)\n'));
|
|
86
|
+
await runChild('npx', ['playwright', 'install', 'chromium']);
|
|
87
|
+
// 2. Ensure profile dir exists
|
|
88
|
+
if (!fs.existsSync(SOCIAL_PROFILE_DIR)) {
|
|
89
|
+
fs.mkdirSync(SOCIAL_PROFILE_DIR, { recursive: true });
|
|
90
|
+
console.log(chalk.green(` ✓ Created Chrome profile at ${SOCIAL_PROFILE_DIR}`));
|
|
91
|
+
}
|
|
92
|
+
// 3. Write default config if missing
|
|
93
|
+
const config = loadSocialConfig();
|
|
94
|
+
saveSocialConfig(config); // touches file so the user can edit
|
|
95
|
+
console.log(chalk.green(` ✓ Config ready at ${CONFIG_PATH}`));
|
|
96
|
+
console.log('');
|
|
97
|
+
console.log(chalk.bold(' Next steps:'));
|
|
98
|
+
console.log(` 1. ${chalk.cyan('franklin social config edit')} edit handle, products, search queries`);
|
|
99
|
+
console.log(` 2. ${chalk.cyan('franklin social login x')} log in to x.com (once — cookies persist)`);
|
|
100
|
+
console.log(` 3. ${chalk.cyan('franklin social run')} dry-run to preview drafts`);
|
|
101
|
+
console.log('');
|
|
102
|
+
}
|
|
103
|
+
// ─── login ─────────────────────────────────────────────────────────────────
|
|
104
|
+
async function loginCommand(platform) {
|
|
105
|
+
if (platform !== 'x') {
|
|
106
|
+
console.log(chalk.red(`Only "x" is supported. Usage: franklin social login x`));
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
console.log(chalk.bold('\n Opening x.com for login…\n'));
|
|
111
|
+
console.log(chalk.dim(' A Chrome window will open. Log in to your X account,'));
|
|
112
|
+
console.log(chalk.dim(' then close the window when done. Cookies will persist'));
|
|
113
|
+
console.log(chalk.dim(` at ${SOCIAL_PROFILE_DIR}\n`));
|
|
114
|
+
const browser = new SocialBrowser({ headless: false });
|
|
115
|
+
try {
|
|
116
|
+
await browser.launch();
|
|
117
|
+
await browser.open('https://x.com/login');
|
|
118
|
+
console.log(chalk.yellow(' Waiting for you to log in and close the browser…'));
|
|
119
|
+
await browser.waitForClose();
|
|
120
|
+
console.log(chalk.green('\n ✓ Browser closed — session state saved.'));
|
|
121
|
+
console.log(chalk.dim(` Next: franklin social config edit (then: franklin social run)\n`));
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
console.error(chalk.red(` ✗ ${err.message}`));
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
await browser.close().catch(() => { });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// ─── run ───────────────────────────────────────────────────────────────────
|
|
132
|
+
async function runCommand(options) {
|
|
133
|
+
let config;
|
|
134
|
+
try {
|
|
135
|
+
config = loadSocialConfig();
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
console.error(chalk.red(` ✗ Config error: ${err.message}`));
|
|
139
|
+
console.error(chalk.dim(` Run: franklin social setup`));
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const ready = isConfigReady(config);
|
|
144
|
+
if (!ready.ready) {
|
|
145
|
+
console.error(chalk.red(` ✗ Config not ready: ${ready.reason}`));
|
|
146
|
+
console.error(chalk.dim(` Edit: ${CONFIG_PATH}`));
|
|
147
|
+
process.exitCode = 1;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const dryRun = !options.live; // --live overrides default dry-run
|
|
151
|
+
const mode = dryRun ? 'DRY-RUN' : chalk.bold.red('LIVE');
|
|
152
|
+
console.log('');
|
|
153
|
+
console.log(chalk.bold(` franklin social run ${chalk.dim(`(${mode})`)}\n`));
|
|
154
|
+
console.log(` Handle: ${chalk.cyan(config.handle)}`);
|
|
155
|
+
console.log(` Products: ${config.products.map((p) => p.name).join(', ')}`);
|
|
156
|
+
console.log(` Queries: ${config.x.search_queries.length}`);
|
|
157
|
+
console.log(` Daily: ${config.x.daily_target} posts`);
|
|
158
|
+
console.log('');
|
|
159
|
+
const chain = loadChain();
|
|
160
|
+
const apiUrl = API_URLS[chain];
|
|
161
|
+
const appConfig = loadAppConfig();
|
|
162
|
+
const model = options.model || appConfig['default-model'] || 'nvidia/nemotron-ultra-253b';
|
|
163
|
+
console.log(chalk.dim(` Model: ${model}`));
|
|
164
|
+
console.log('');
|
|
165
|
+
let result;
|
|
166
|
+
try {
|
|
167
|
+
result = await runX({
|
|
168
|
+
config,
|
|
169
|
+
model,
|
|
170
|
+
apiUrl,
|
|
171
|
+
chain,
|
|
172
|
+
dryRun,
|
|
173
|
+
debug: options.debug,
|
|
174
|
+
onProgress: (msg) => process.stdout.write(msg + '\n'),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
console.error(chalk.red(`\n ✗ Run failed: ${err.message}`));
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
console.log('');
|
|
183
|
+
console.log(chalk.bold(' Run summary:'));
|
|
184
|
+
console.log(` Considered: ${result.considered}`);
|
|
185
|
+
console.log(` Dedup skips: ${chalk.dim(result.dedupSkipped)}`);
|
|
186
|
+
console.log(` AI SKIPs: ${chalk.dim(result.llmSkipped)}`);
|
|
187
|
+
console.log(` Drafted: ${chalk.green(result.drafted)}`);
|
|
188
|
+
if (!dryRun) {
|
|
189
|
+
console.log(` Posted: ${chalk.green.bold(result.posted)}`);
|
|
190
|
+
console.log(` Failed: ${result.failed > 0 ? chalk.red(result.failed) : 0}`);
|
|
191
|
+
}
|
|
192
|
+
console.log(` LLM cost: ${chalk.yellow('$' + result.totalCost.toFixed(4))}`);
|
|
193
|
+
console.log('');
|
|
194
|
+
}
|
|
195
|
+
// ─── stats ─────────────────────────────────────────────────────────────────
|
|
196
|
+
function statsCommand() {
|
|
197
|
+
const s = getStats('x');
|
|
198
|
+
console.log('');
|
|
199
|
+
console.log(chalk.bold(' franklin social stats — X'));
|
|
200
|
+
console.log('');
|
|
201
|
+
console.log(` Total events: ${s.total}`);
|
|
202
|
+
console.log(` ✓ Posted: ${chalk.green(s.posted)} ${s.today > 0 ? chalk.dim(`(${s.today} today)`) : ''}`);
|
|
203
|
+
console.log(` ≡ Drafted: ${s.drafted}`);
|
|
204
|
+
console.log(` · Skipped (AI): ${chalk.dim(s.skipped)}`);
|
|
205
|
+
console.log(` ✗ Failed: ${s.failed > 0 ? chalk.red(s.failed) : 0}`);
|
|
206
|
+
console.log(` Total LLM cost: ${chalk.yellow('$' + s.totalCost.toFixed(4))}`);
|
|
207
|
+
if (Object.keys(s.byProduct).length > 0) {
|
|
208
|
+
console.log('');
|
|
209
|
+
console.log(' By product:');
|
|
210
|
+
for (const [name, count] of Object.entries(s.byProduct)) {
|
|
211
|
+
console.log(` ${name.padEnd(20)} ${count}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
console.log('');
|
|
215
|
+
}
|
|
216
|
+
// ─── config ────────────────────────────────────────────────────────────────
|
|
217
|
+
function configCommand(subAction) {
|
|
218
|
+
if (!subAction || subAction === 'path') {
|
|
219
|
+
console.log(CONFIG_PATH);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (subAction === 'show' || subAction === 'print') {
|
|
223
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
224
|
+
console.log(chalk.yellow(` Config not found at ${CONFIG_PATH}`));
|
|
225
|
+
console.log(chalk.dim(` Run: franklin social setup`));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
console.log(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (subAction === 'edit' || subAction === 'open') {
|
|
232
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
233
|
+
loadSocialConfig(); // writes the default file
|
|
234
|
+
}
|
|
235
|
+
const editor = process.env.EDITOR || (process.platform === 'darwin' ? 'open' : 'vi');
|
|
236
|
+
const args = editor === 'open' ? ['-t', CONFIG_PATH] : [CONFIG_PATH];
|
|
237
|
+
const child = spawn(editor, args, { stdio: 'inherit' });
|
|
238
|
+
child.on('close', () => {
|
|
239
|
+
console.log(chalk.dim(`\n Saved to ${CONFIG_PATH}`));
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
console.log(chalk.red(` Unknown config subaction: ${subAction}`));
|
|
244
|
+
console.log(chalk.dim(` Try: path, show, edit`));
|
|
245
|
+
}
|
|
246
|
+
// ─── helpers ───────────────────────────────────────────────────────────────
|
|
247
|
+
function runChild(cmd, args) {
|
|
248
|
+
return new Promise((resolve, reject) => {
|
|
249
|
+
const child = spawn(cmd, args, { stdio: 'inherit' });
|
|
250
|
+
child.on('close', (code) => {
|
|
251
|
+
if (code === 0)
|
|
252
|
+
resolve();
|
|
253
|
+
else
|
|
254
|
+
reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
|
|
255
|
+
});
|
|
256
|
+
child.on('error', reject);
|
|
257
|
+
});
|
|
258
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -111,13 +111,33 @@ program
|
|
|
111
111
|
const matches = searchSessions(query, { limit, model: opts.model });
|
|
112
112
|
process.stdout.write(formatSearchResults(matches, query));
|
|
113
113
|
});
|
|
114
|
+
// ─── franklin social (native X bot) ───────────────────────────────────────
|
|
115
|
+
// First-class subcommand. Handles setup / login / run / stats / config
|
|
116
|
+
// subactions. No plugin SDK, no MCP — everything lives in src/social/.
|
|
117
|
+
program
|
|
118
|
+
.command('social [action] [arg]')
|
|
119
|
+
.description('Native X bot — franklin social setup | login x | run | stats | config')
|
|
120
|
+
.option('--dry-run', 'Generate drafts without posting (default for run)')
|
|
121
|
+
.option('--live', 'Actually post to X (overrides dry-run default)')
|
|
122
|
+
.option('-m, --model <model>', 'Override the model used for reply generation')
|
|
123
|
+
.option('--debug', 'Enable debug logging')
|
|
124
|
+
.action(async (action, arg, opts) => {
|
|
125
|
+
const { socialCommand } = await import('./commands/social.js');
|
|
126
|
+
await socialCommand(action, arg, opts);
|
|
127
|
+
});
|
|
114
128
|
// Plugin commands — dynamically registered from discovered plugins.
|
|
115
129
|
// Core stays plugin-agnostic: this loop adds a command for each installed plugin.
|
|
130
|
+
// Note: `social` is now a first-class native command above and NOT loaded as a
|
|
131
|
+
// plugin (the bundled social plugin was retired in v3.2.0 in favour of the
|
|
132
|
+
// src/social/ subsystem).
|
|
116
133
|
{
|
|
117
134
|
const { loadAllPlugins, listWorkflowPlugins } = await import('./plugins/registry.js');
|
|
118
135
|
await loadAllPlugins();
|
|
119
136
|
for (const lp of listWorkflowPlugins()) {
|
|
120
137
|
const { manifest } = lp;
|
|
138
|
+
// Skip any plugin whose id collides with a built-in command (e.g. social)
|
|
139
|
+
if (manifest.id === 'social')
|
|
140
|
+
continue;
|
|
121
141
|
program
|
|
122
142
|
.command(`${manifest.id} [action]`)
|
|
123
143
|
.description(manifest.description)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for finding elements in the flat [depth-idx] ref tree produced by
|
|
3
|
+
* SocialBrowser.snapshot(). Ported from social-bot's bot/browser.py regex
|
|
4
|
+
* model, where elements are located by role + label rather than CSS/XPath.
|
|
5
|
+
*
|
|
6
|
+
* The mental model: snapshot() returns a string like
|
|
7
|
+
*
|
|
8
|
+
* [0-0] main: Timeline
|
|
9
|
+
* [1-0] article: post by user
|
|
10
|
+
* [2-0] link: Mar 16
|
|
11
|
+
* [2-1] StaticText: hello world
|
|
12
|
+
* [1-1] button: Reply
|
|
13
|
+
* [1-2] textbox: Post text
|
|
14
|
+
*
|
|
15
|
+
* …and these helpers find the refs via regex on that string.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Find all refs matching a role and a label pattern.
|
|
19
|
+
*
|
|
20
|
+
* @param tree The snapshot output string
|
|
21
|
+
* @param role AX role, e.g. "button", "link", "textbox", "article"
|
|
22
|
+
* @param label Regex source for the label (default `.*` — any). Substring matches count.
|
|
23
|
+
* @returns Array of ref ids like ["0-0", "1-3"] in document order
|
|
24
|
+
*/
|
|
25
|
+
export declare function findRefs(tree: string, role: string, label?: string): string[];
|
|
26
|
+
/**
|
|
27
|
+
* Find refs AND their labels. Useful when you want both the click target
|
|
28
|
+
* (ref) and the visible text (label) in one pass.
|
|
29
|
+
*/
|
|
30
|
+
export declare function findRefsWithLabels(tree: string, role: string, label?: string): Array<{
|
|
31
|
+
ref: string;
|
|
32
|
+
label: string;
|
|
33
|
+
}>;
|
|
34
|
+
/**
|
|
35
|
+
* Find text content inside the tree (not a ref — just the visible string).
|
|
36
|
+
* Useful for reading static text like tweet snippets.
|
|
37
|
+
*/
|
|
38
|
+
export declare function findStaticText(tree: string): string[];
|
|
39
|
+
/**
|
|
40
|
+
* Split an X timeline/search snapshot into per-article blocks so we can
|
|
41
|
+
* process each tweet independently. Returns the text slice for each article,
|
|
42
|
+
* starting at the `[N-M] article:` line and ending at the next article or
|
|
43
|
+
* end-of-tree.
|
|
44
|
+
*/
|
|
45
|
+
export declare function extractArticleBlocks(tree: string): Array<{
|
|
46
|
+
ref: string;
|
|
47
|
+
text: string;
|
|
48
|
+
}>;
|
|
49
|
+
/**
|
|
50
|
+
* Regex pattern for X's "time-link" text: "Mar 16", "5h", "just now", "2d", etc.
|
|
51
|
+
* This doubles as the "this is a tweet" signal in social-bot — the only link
|
|
52
|
+
* inside an article block with this label shape is the permalink to the tweet.
|
|
53
|
+
*/
|
|
54
|
+
export declare const X_TIME_LINK_PATTERN = "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+|\\d+[smhd]|just now|now";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for finding elements in the flat [depth-idx] ref tree produced by
|
|
3
|
+
* SocialBrowser.snapshot(). Ported from social-bot's bot/browser.py regex
|
|
4
|
+
* model, where elements are located by role + label rather than CSS/XPath.
|
|
5
|
+
*
|
|
6
|
+
* The mental model: snapshot() returns a string like
|
|
7
|
+
*
|
|
8
|
+
* [0-0] main: Timeline
|
|
9
|
+
* [1-0] article: post by user
|
|
10
|
+
* [2-0] link: Mar 16
|
|
11
|
+
* [2-1] StaticText: hello world
|
|
12
|
+
* [1-1] button: Reply
|
|
13
|
+
* [1-2] textbox: Post text
|
|
14
|
+
*
|
|
15
|
+
* …and these helpers find the refs via regex on that string.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Find all refs matching a role and a label pattern.
|
|
19
|
+
*
|
|
20
|
+
* @param tree The snapshot output string
|
|
21
|
+
* @param role AX role, e.g. "button", "link", "textbox", "article"
|
|
22
|
+
* @param label Regex source for the label (default `.*` — any). Substring matches count.
|
|
23
|
+
* @returns Array of ref ids like ["0-0", "1-3"] in document order
|
|
24
|
+
*/
|
|
25
|
+
export function findRefs(tree, role, label = '.*') {
|
|
26
|
+
const re = new RegExp(`\\[(\\d+-\\d+)\\]\\s+${escapeRegex(role)}:\\s*${label}`, 'g');
|
|
27
|
+
const out = [];
|
|
28
|
+
let m;
|
|
29
|
+
while ((m = re.exec(tree)) !== null) {
|
|
30
|
+
out.push(m[1]);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Find refs AND their labels. Useful when you want both the click target
|
|
36
|
+
* (ref) and the visible text (label) in one pass.
|
|
37
|
+
*/
|
|
38
|
+
export function findRefsWithLabels(tree, role, label = '.*') {
|
|
39
|
+
const re = new RegExp(`\\[(\\d+-\\d+)\\]\\s+${escapeRegex(role)}:\\s*(${label})`, 'g');
|
|
40
|
+
const out = [];
|
|
41
|
+
let m;
|
|
42
|
+
while ((m = re.exec(tree)) !== null) {
|
|
43
|
+
out.push({ ref: m[1], label: m[2].trim() });
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Find text content inside the tree (not a ref — just the visible string).
|
|
49
|
+
* Useful for reading static text like tweet snippets.
|
|
50
|
+
*/
|
|
51
|
+
export function findStaticText(tree) {
|
|
52
|
+
const re = /\[\d+-\d+\]\s+StaticText:\s*(.+)/g;
|
|
53
|
+
const out = [];
|
|
54
|
+
let m;
|
|
55
|
+
while ((m = re.exec(tree)) !== null) {
|
|
56
|
+
out.push(m[1].trim());
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Split an X timeline/search snapshot into per-article blocks so we can
|
|
62
|
+
* process each tweet independently. Returns the text slice for each article,
|
|
63
|
+
* starting at the `[N-M] article:` line and ending at the next article or
|
|
64
|
+
* end-of-tree.
|
|
65
|
+
*/
|
|
66
|
+
export function extractArticleBlocks(tree) {
|
|
67
|
+
const articleStarts = [];
|
|
68
|
+
const re = /\[(\d+-\d+)\]\s+article:/g;
|
|
69
|
+
let m;
|
|
70
|
+
while ((m = re.exec(tree)) !== null) {
|
|
71
|
+
articleStarts.push({ ref: m[1], pos: m.index });
|
|
72
|
+
}
|
|
73
|
+
const out = [];
|
|
74
|
+
for (let i = 0; i < articleStarts.length; i++) {
|
|
75
|
+
const start = articleStarts[i].pos;
|
|
76
|
+
const end = i + 1 < articleStarts.length ? articleStarts[i + 1].pos : tree.length;
|
|
77
|
+
out.push({ ref: articleStarts[i].ref, text: tree.slice(start, end) });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Regex pattern for X's "time-link" text: "Mar 16", "5h", "just now", "2d", etc.
|
|
83
|
+
* This doubles as the "this is a tweet" signal in social-bot — the only link
|
|
84
|
+
* inside an article block with this label shape is the permalink to the tweet.
|
|
85
|
+
*/
|
|
86
|
+
export const X_TIME_LINK_PATTERN = '(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+|\\d+[smhd]|just now|now';
|
|
87
|
+
function escapeRegex(s) {
|
|
88
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
89
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI layer for Franklin's social subsystem.
|
|
3
|
+
*
|
|
4
|
+
* Two functions mirroring social-bot/bot/ai_engine.py:
|
|
5
|
+
* - detectProduct() — keyword-score product router (no LLM, zero cost)
|
|
6
|
+
* - generateReply() — calls Franklin's ModelClient for actual reply text
|
|
7
|
+
*
|
|
8
|
+
* Key improvements over social-bot:
|
|
9
|
+
* - Uses Franklin's multi-model router (tier-based: free / cheap / premium)
|
|
10
|
+
* instead of hardcoded Claude Sonnet for every call — throwaway replies
|
|
11
|
+
* can run on free NVIDIA models, high-value leads can escalate to Opus.
|
|
12
|
+
* - x402 payment flow handled by ModelClient — no Anthropic billing relationship.
|
|
13
|
+
* - SKIP detection lives in the caller so we can commit a 'skipped' record
|
|
14
|
+
* for visibility in stats.
|
|
15
|
+
*/
|
|
16
|
+
import type { ProductConfig, SocialConfig } from './config.js';
|
|
17
|
+
import type { Chain } from '../config.js';
|
|
18
|
+
export interface GenerateReplyOptions {
|
|
19
|
+
post: {
|
|
20
|
+
title: string;
|
|
21
|
+
snippet: string;
|
|
22
|
+
platform: 'x' | 'reddit';
|
|
23
|
+
};
|
|
24
|
+
product: ProductConfig;
|
|
25
|
+
config: SocialConfig;
|
|
26
|
+
model: string;
|
|
27
|
+
apiUrl: string;
|
|
28
|
+
chain: Chain;
|
|
29
|
+
debug?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface GenerateReplyResult {
|
|
32
|
+
reply: string | null;
|
|
33
|
+
raw: string;
|
|
34
|
+
usage: {
|
|
35
|
+
inputTokens: number;
|
|
36
|
+
outputTokens: number;
|
|
37
|
+
};
|
|
38
|
+
cost: number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Score each product by how many of its trigger_keywords appear in the post.
|
|
42
|
+
* Returns the top-scoring product, or null if no product has any matches.
|
|
43
|
+
*
|
|
44
|
+
* Deterministic, zero-cost, debuggable. Social-bot uses the exact same
|
|
45
|
+
* pattern and it's the right call for this stage — no need to pay an LLM
|
|
46
|
+
* to ask "which of my products does this post mention".
|
|
47
|
+
*/
|
|
48
|
+
export declare function detectProduct(postText: string, products: ProductConfig[]): ProductConfig | null;
|
|
49
|
+
/**
|
|
50
|
+
* Build the system prompt for a given product + style ruleset.
|
|
51
|
+
*/
|
|
52
|
+
export declare function buildSystemPrompt(product: ProductConfig, config: SocialConfig): string;
|
|
53
|
+
/**
|
|
54
|
+
* Build the user prompt containing the post content.
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildUserPrompt(post: GenerateReplyOptions['post']): string;
|
|
57
|
+
/**
|
|
58
|
+
* Generate a reply via Franklin's ModelClient. Returns { reply: null } if
|
|
59
|
+
* the model said SKIP or the output was too short to be useful.
|
|
60
|
+
*/
|
|
61
|
+
export declare function generateReply(opts: GenerateReplyOptions): Promise<GenerateReplyResult>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI layer for Franklin's social subsystem.
|
|
3
|
+
*
|
|
4
|
+
* Two functions mirroring social-bot/bot/ai_engine.py:
|
|
5
|
+
* - detectProduct() — keyword-score product router (no LLM, zero cost)
|
|
6
|
+
* - generateReply() — calls Franklin's ModelClient for actual reply text
|
|
7
|
+
*
|
|
8
|
+
* Key improvements over social-bot:
|
|
9
|
+
* - Uses Franklin's multi-model router (tier-based: free / cheap / premium)
|
|
10
|
+
* instead of hardcoded Claude Sonnet for every call — throwaway replies
|
|
11
|
+
* can run on free NVIDIA models, high-value leads can escalate to Opus.
|
|
12
|
+
* - x402 payment flow handled by ModelClient — no Anthropic billing relationship.
|
|
13
|
+
* - SKIP detection lives in the caller so we can commit a 'skipped' record
|
|
14
|
+
* for visibility in stats.
|
|
15
|
+
*/
|
|
16
|
+
import { ModelClient } from '../agent/llm.js';
|
|
17
|
+
import { estimateCost } from '../pricing.js';
|
|
18
|
+
/**
|
|
19
|
+
* Score each product by how many of its trigger_keywords appear in the post.
|
|
20
|
+
* Returns the top-scoring product, or null if no product has any matches.
|
|
21
|
+
*
|
|
22
|
+
* Deterministic, zero-cost, debuggable. Social-bot uses the exact same
|
|
23
|
+
* pattern and it's the right call for this stage — no need to pay an LLM
|
|
24
|
+
* to ask "which of my products does this post mention".
|
|
25
|
+
*/
|
|
26
|
+
export function detectProduct(postText, products) {
|
|
27
|
+
if (products.length === 0)
|
|
28
|
+
return null;
|
|
29
|
+
const text = postText.toLowerCase();
|
|
30
|
+
let best = null;
|
|
31
|
+
for (const p of products) {
|
|
32
|
+
let score = 0;
|
|
33
|
+
for (const kw of p.trigger_keywords) {
|
|
34
|
+
if (text.includes(kw.toLowerCase()))
|
|
35
|
+
score++;
|
|
36
|
+
}
|
|
37
|
+
if (!best || score > best.score) {
|
|
38
|
+
best = { product: p, score };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return best && best.score > 0 ? best.product : null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Build the system prompt for a given product + style ruleset.
|
|
45
|
+
*/
|
|
46
|
+
export function buildSystemPrompt(product, config) {
|
|
47
|
+
const rules = config.reply_style.rules.map((r) => `- ${r}`).join('\n');
|
|
48
|
+
return (`You are replying on behalf of the maker of "${product.name}".\n\n` +
|
|
49
|
+
`Product description:\n${product.description}\n\n` +
|
|
50
|
+
`Reply style rules:\n${rules}\n\n` +
|
|
51
|
+
`You are hands-on, experienced, and speak from lived reality. ` +
|
|
52
|
+
`You never sound like a marketer. You do not use emojis or hashtags. ` +
|
|
53
|
+
`If the post is not a good fit for the product, reply with exactly: SKIP`);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Build the user prompt containing the post content.
|
|
57
|
+
*/
|
|
58
|
+
export function buildUserPrompt(post) {
|
|
59
|
+
return (`Platform: ${post.platform}\n` +
|
|
60
|
+
`Post title: ${post.title.slice(0, 200)}\n\n` +
|
|
61
|
+
`Post content:\n${post.snippet.slice(0, 800)}\n\n` +
|
|
62
|
+
`Write a reply following the rules in the system prompt. ` +
|
|
63
|
+
`If the post is not relevant to the product, respond with SKIP only.`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Generate a reply via Franklin's ModelClient. Returns { reply: null } if
|
|
67
|
+
* the model said SKIP or the output was too short to be useful.
|
|
68
|
+
*/
|
|
69
|
+
export async function generateReply(opts) {
|
|
70
|
+
const system = buildSystemPrompt(opts.product, opts.config);
|
|
71
|
+
const user = buildUserPrompt(opts.post);
|
|
72
|
+
const maxLen = opts.config.x.max_length;
|
|
73
|
+
const client = new ModelClient({
|
|
74
|
+
apiUrl: opts.apiUrl,
|
|
75
|
+
chain: opts.chain,
|
|
76
|
+
debug: opts.debug,
|
|
77
|
+
});
|
|
78
|
+
const result = await client.complete({
|
|
79
|
+
model: opts.model,
|
|
80
|
+
messages: [{ role: 'user', content: user }],
|
|
81
|
+
system,
|
|
82
|
+
max_tokens: 400,
|
|
83
|
+
stream: true,
|
|
84
|
+
temperature: 0.7,
|
|
85
|
+
});
|
|
86
|
+
// Extract the text from content parts
|
|
87
|
+
const text = result.content
|
|
88
|
+
.filter((p) => p.type === 'text')
|
|
89
|
+
.map((p) => p.text)
|
|
90
|
+
.join('')
|
|
91
|
+
.trim();
|
|
92
|
+
const cost = estimateCost(opts.model, result.usage.inputTokens, result.usage.outputTokens, 1);
|
|
93
|
+
// SKIP detection — model may say "SKIP", "SKIP." or short/empty
|
|
94
|
+
if (!text || text.toUpperCase().startsWith('SKIP') || text.length < 20) {
|
|
95
|
+
return { reply: null, raw: text, usage: result.usage, cost };
|
|
96
|
+
}
|
|
97
|
+
// Trim to max length with a small buffer
|
|
98
|
+
let reply = text;
|
|
99
|
+
if (reply.length > maxLen + 50) {
|
|
100
|
+
reply = reply.slice(0, maxLen).replace(/\s+\S*$/, '') + '…';
|
|
101
|
+
}
|
|
102
|
+
return { reply, raw: text, usage: result.usage, cost };
|
|
103
|
+
}
|