@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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Native Playwright-core wrapper for Franklin's social subsystem.
3
+ *
4
+ * Mirrors the 9 browser primitives social-bot exposes via its `browse` CLI
5
+ * (open, snapshot, click, type, press, scroll, screenshot, getUrl, close).
6
+ * Persistent context so login state survives across runs:
7
+ *
8
+ * ~/.blockrun/social-chrome-profile/
9
+ *
10
+ * Unlike social-bot's shell=True subprocess calls, every interaction goes
11
+ * through Playwright's argv-based API — no shell injection surface even if
12
+ * the LLM generates `$(rm -rf /)` as reply text.
13
+ */
14
+ export declare const SOCIAL_PROFILE_DIR: string;
15
+ /**
16
+ * Ref assigned to every interactive AX node. Format matches social-bot:
17
+ * [depth-index]
18
+ * e.g. [0-3], [2-17]. Depth is the tree nesting level; index is the
19
+ * order within that level.
20
+ */
21
+ export interface AxRef {
22
+ id: string;
23
+ role: string;
24
+ name: string;
25
+ selector: string;
26
+ }
27
+ interface AxNode {
28
+ role?: string;
29
+ name?: string;
30
+ value?: string;
31
+ description?: string;
32
+ children?: AxNode[];
33
+ }
34
+ /**
35
+ * Walk an AX tree and produce:
36
+ * 1. A flat text dump with [depth-idx] refs (for regex-based element finding)
37
+ * 2. A map of ref ID → role/name/selector for click-by-ref lookups
38
+ *
39
+ * The flat text shape intentionally mirrors social-bot's `browse snapshot`
40
+ * output so code patterns and regexes are directly portable.
41
+ */
42
+ export declare function serializeAxTree(root: AxNode): {
43
+ tree: string;
44
+ refs: Map<string, AxRef>;
45
+ };
46
+ export interface BrowserOptions {
47
+ headless?: boolean;
48
+ channel?: 'chrome' | 'chromium' | 'msedge';
49
+ slowMo?: number;
50
+ viewport?: {
51
+ width: number;
52
+ height: number;
53
+ };
54
+ }
55
+ /**
56
+ * Franklin's social browser driver. Lazy-imports playwright-core so the
57
+ * rest of the CLI stays fast to start.
58
+ */
59
+ export declare class SocialBrowser {
60
+ private context;
61
+ private page;
62
+ private lastRefs;
63
+ private opts;
64
+ constructor(opts?: BrowserOptions);
65
+ launch(): Promise<void>;
66
+ close(): Promise<void>;
67
+ open(url: string): Promise<void>;
68
+ /**
69
+ * Capture the page as a flat [N-M] ref tree (social-bot style).
70
+ * Also stores the ref map internally so click(ref) can find the node.
71
+ */
72
+ snapshot(): Promise<string>;
73
+ /**
74
+ * Click by ref from the last snapshot. Throws if the ref isn't known.
75
+ * The ref map is reset on every snapshot() call.
76
+ */
77
+ click(ref: string): Promise<void>;
78
+ clickXY(x: number, y: number): Promise<void>;
79
+ /**
80
+ * Type text into the currently focused element. Safe against any content
81
+ * in `text` — Playwright passes it as argv, not through a shell.
82
+ */
83
+ type(text: string): Promise<void>;
84
+ press(key: string): Promise<void>;
85
+ scroll(x: number, y: number, dx: number, dy: number): Promise<void>;
86
+ screenshot(filePath: string): Promise<void>;
87
+ getUrl(): Promise<string>;
88
+ getTitle(): Promise<string>;
89
+ waitForTimeout(ms: number): Promise<void>;
90
+ /**
91
+ * Block until the user closes the browser tab (used by the login flow).
92
+ * Resolves when the context is closed.
93
+ */
94
+ waitForClose(): Promise<void>;
95
+ private requirePage;
96
+ }
97
+ export {};
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Native Playwright-core wrapper for Franklin's social subsystem.
3
+ *
4
+ * Mirrors the 9 browser primitives social-bot exposes via its `browse` CLI
5
+ * (open, snapshot, click, type, press, scroll, screenshot, getUrl, close).
6
+ * Persistent context so login state survives across runs:
7
+ *
8
+ * ~/.blockrun/social-chrome-profile/
9
+ *
10
+ * Unlike social-bot's shell=True subprocess calls, every interaction goes
11
+ * through Playwright's argv-based API — no shell injection surface even if
12
+ * the LLM generates `$(rm -rf /)` as reply text.
13
+ */
14
+ import path from 'node:path';
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ // ─── Persistent profile location ───────────────────────────────────────────
18
+ export const SOCIAL_PROFILE_DIR = path.join(os.homedir(), '.blockrun', 'social-chrome-profile');
19
+ function ensureProfileDir() {
20
+ if (!fs.existsSync(SOCIAL_PROFILE_DIR)) {
21
+ fs.mkdirSync(SOCIAL_PROFILE_DIR, { recursive: true });
22
+ }
23
+ }
24
+ /**
25
+ * Walk an AX tree and produce:
26
+ * 1. A flat text dump with [depth-idx] refs (for regex-based element finding)
27
+ * 2. A map of ref ID → role/name/selector for click-by-ref lookups
28
+ *
29
+ * The flat text shape intentionally mirrors social-bot's `browse snapshot`
30
+ * output so code patterns and regexes are directly portable.
31
+ */
32
+ export function serializeAxTree(root) {
33
+ const lines = [];
34
+ const refs = new Map();
35
+ // Counter per-depth so each depth gets sequential indexes
36
+ const depthCounters = [];
37
+ // Counter per (role,name) to disambiguate multiple same-named elements
38
+ const nameOccurrences = new Map();
39
+ function walk(node, depth) {
40
+ if (!node)
41
+ return;
42
+ const role = node.role || '';
43
+ const name = (node.name || '').trim().slice(0, 120);
44
+ // Skip uninteresting nodes — they'd pollute the tree
45
+ const isInteresting = role && role !== 'none' && role !== 'presentation' && role !== 'generic';
46
+ if (isInteresting) {
47
+ while (depthCounters.length <= depth)
48
+ depthCounters.push(0);
49
+ const idx = depthCounters[depth]++;
50
+ const id = `${depth}-${idx}`;
51
+ const labelStr = name || (node.value || '').trim().slice(0, 120);
52
+ const indent = ' '.repeat(depth);
53
+ lines.push(`${indent}[${id}] ${role}: ${labelStr}`);
54
+ // Build a Playwright locator. Prefer getByRole+name, fall back to
55
+ // nth match if there are duplicates.
56
+ const key = `${role}||${labelStr}`;
57
+ const occ = nameOccurrences.get(key) || 0;
58
+ nameOccurrences.set(key, occ + 1);
59
+ let selector;
60
+ if (labelStr) {
61
+ // Escape quotes in the name
62
+ const escaped = labelStr.replace(/"/g, '\\"');
63
+ selector = occ === 0
64
+ ? `role=${role}[name="${escaped}"]`
65
+ : `role=${role}[name="${escaped}"] >> nth=${occ}`;
66
+ }
67
+ else {
68
+ selector = `role=${role} >> nth=${idx}`;
69
+ }
70
+ refs.set(id, { id, role, name: labelStr, selector });
71
+ }
72
+ if (node.children) {
73
+ for (const child of node.children) {
74
+ walk(child, isInteresting ? depth + 1 : depth);
75
+ }
76
+ }
77
+ }
78
+ walk(root, 0);
79
+ return { tree: lines.join('\n'), refs };
80
+ }
81
+ /**
82
+ * Franklin's social browser driver. Lazy-imports playwright-core so the
83
+ * rest of the CLI stays fast to start.
84
+ */
85
+ export class SocialBrowser {
86
+ context = null;
87
+ page = null;
88
+ lastRefs = new Map();
89
+ opts;
90
+ constructor(opts = {}) {
91
+ this.opts = {
92
+ headless: opts.headless ?? false,
93
+ channel: opts.channel ?? 'chrome',
94
+ slowMo: opts.slowMo ?? 150,
95
+ viewport: opts.viewport ?? { width: 1280, height: 900 },
96
+ };
97
+ }
98
+ async launch() {
99
+ ensureProfileDir();
100
+ // Lazy import — playwright-core is ~2MB and we don't want to pay the
101
+ // import cost on every franklin command (e.g. `franklin --version`)
102
+ const { chromium } = await import('playwright-core');
103
+ try {
104
+ this.context = await chromium.launchPersistentContext(SOCIAL_PROFILE_DIR, {
105
+ headless: this.opts.headless,
106
+ channel: this.opts.channel,
107
+ slowMo: this.opts.slowMo,
108
+ viewport: this.opts.viewport,
109
+ // Pretend to be a regular Chrome (not headless fingerprint)
110
+ args: [
111
+ '--disable-blink-features=AutomationControlled',
112
+ '--no-default-browser-check',
113
+ ],
114
+ });
115
+ }
116
+ catch (err) {
117
+ const msg = err.message;
118
+ if (msg.includes('Executable doesn') || msg.includes("wasn't found")) {
119
+ throw new Error(`Chrome/Chromium not found. Run:\n franklin social setup\n\n` +
120
+ `Or install manually:\n npx playwright install chromium\n\n` +
121
+ `Original error: ${msg}`);
122
+ }
123
+ throw err;
124
+ }
125
+ // Reuse existing tab if any, else open new
126
+ const existing = this.context.pages();
127
+ this.page = existing.length > 0 ? existing[0] : await this.context.newPage();
128
+ }
129
+ async close() {
130
+ if (this.context) {
131
+ await this.context.close().catch(() => { });
132
+ this.context = null;
133
+ this.page = null;
134
+ }
135
+ }
136
+ // ─── Primitives ────────────────────────────────────────────────────────
137
+ async open(url) {
138
+ this.requirePage();
139
+ await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
140
+ }
141
+ /**
142
+ * Capture the page as a flat [N-M] ref tree (social-bot style).
143
+ * Also stores the ref map internally so click(ref) can find the node.
144
+ */
145
+ async snapshot() {
146
+ this.requirePage();
147
+ // Playwright's accessibility snapshot returns a full AX tree
148
+ const axRoot = await this.page.accessibility.snapshot({ interestingOnly: false });
149
+ if (!axRoot)
150
+ return '';
151
+ const { tree, refs } = serializeAxTree(axRoot);
152
+ this.lastRefs = refs;
153
+ return tree;
154
+ }
155
+ /**
156
+ * Click by ref from the last snapshot. Throws if the ref isn't known.
157
+ * The ref map is reset on every snapshot() call.
158
+ */
159
+ async click(ref) {
160
+ this.requirePage();
161
+ const axRef = this.lastRefs.get(ref);
162
+ if (!axRef) {
163
+ throw new Error(`Unknown ref "${ref}". Refs are only valid until the next snapshot() call. Known refs: ${this.lastRefs.size}`);
164
+ }
165
+ await this.page.locator(axRef.selector).first().click({ timeout: 15000 });
166
+ }
167
+ async clickXY(x, y) {
168
+ this.requirePage();
169
+ await this.page.mouse.click(x, y);
170
+ }
171
+ /**
172
+ * Type text into the currently focused element. Safe against any content
173
+ * in `text` — Playwright passes it as argv, not through a shell.
174
+ */
175
+ async type(text) {
176
+ this.requirePage();
177
+ await this.page.keyboard.type(text, { delay: 20 });
178
+ }
179
+ async press(key) {
180
+ this.requirePage();
181
+ await this.page.keyboard.press(key);
182
+ }
183
+ async scroll(x, y, dx, dy) {
184
+ this.requirePage();
185
+ await this.page.mouse.move(x, y);
186
+ await this.page.mouse.wheel(dx, dy);
187
+ }
188
+ async screenshot(filePath) {
189
+ this.requirePage();
190
+ await this.page.screenshot({ path: filePath, fullPage: false });
191
+ }
192
+ async getUrl() {
193
+ this.requirePage();
194
+ return this.page.url();
195
+ }
196
+ async getTitle() {
197
+ this.requirePage();
198
+ return this.page.title();
199
+ }
200
+ async waitForTimeout(ms) {
201
+ this.requirePage();
202
+ await this.page.waitForTimeout(ms);
203
+ }
204
+ /**
205
+ * Block until the user closes the browser tab (used by the login flow).
206
+ * Resolves when the context is closed.
207
+ */
208
+ async waitForClose() {
209
+ this.requirePage();
210
+ await new Promise((resolve) => {
211
+ this.context.on('close', () => resolve());
212
+ this.page.on('close', () => resolve());
213
+ });
214
+ }
215
+ requirePage() {
216
+ if (!this.page)
217
+ throw new Error('SocialBrowser not launched — call launch() first');
218
+ }
219
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Typed config for Franklin's social subsystem.
3
+ * Stored at ~/.blockrun/social-config.json. Default written on first run.
4
+ */
5
+ export interface ProductConfig {
6
+ name: string;
7
+ description: string;
8
+ trigger_keywords: string[];
9
+ }
10
+ export interface SocialConfig {
11
+ version: 1;
12
+ handle: string;
13
+ products: ProductConfig[];
14
+ x: {
15
+ search_queries: string[];
16
+ daily_target: number;
17
+ min_delay_seconds: number;
18
+ max_length: number;
19
+ login_detection: string;
20
+ };
21
+ reply_style: {
22
+ rules: string[];
23
+ model_tier: 'free' | 'cheap' | 'premium';
24
+ };
25
+ }
26
+ export declare const CONFIG_PATH: string;
27
+ /**
28
+ * Load config from disk. If missing, write defaults and return them.
29
+ * Returns the parsed config or throws on malformed JSON.
30
+ */
31
+ export declare function loadConfig(): SocialConfig;
32
+ /**
33
+ * Persist config back to disk.
34
+ */
35
+ export declare function saveConfig(cfg: SocialConfig): void;
36
+ /**
37
+ * Whether the config is "ready" to run — has a handle and at least one
38
+ * product with keywords.
39
+ */
40
+ export declare function isConfigReady(cfg: SocialConfig): {
41
+ ready: boolean;
42
+ reason?: string;
43
+ };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Typed config for Franklin's social subsystem.
3
+ * Stored at ~/.blockrun/social-config.json. Default written on first run.
4
+ */
5
+ import path from 'node:path';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ export const CONFIG_PATH = path.join(os.homedir(), '.blockrun', 'social-config.json');
9
+ const DEFAULT_CONFIG = {
10
+ version: 1,
11
+ handle: '',
12
+ products: [
13
+ {
14
+ name: 'Your Product',
15
+ description: 'Replace this with a one-paragraph description of what your product does, ' +
16
+ 'who it is for, and what pain it solves. Franklin will use this verbatim as ' +
17
+ 'the AI persona when replying to relevant posts.',
18
+ trigger_keywords: [],
19
+ },
20
+ ],
21
+ x: {
22
+ search_queries: [],
23
+ daily_target: 20,
24
+ min_delay_seconds: 300,
25
+ max_length: 260,
26
+ login_detection: '',
27
+ },
28
+ reply_style: {
29
+ rules: [
30
+ 'Sound like a real human with experience, not a bot',
31
+ 'Be specific — reference details from the post you are replying to',
32
+ 'Maximum 2-3 sentences, conversational tone',
33
+ 'No marketing speak, no emojis, no hashtags',
34
+ 'If the product fits naturally, mention it once and only once',
35
+ 'If the product does not fit, reply with just: SKIP',
36
+ ],
37
+ model_tier: 'cheap',
38
+ },
39
+ };
40
+ /**
41
+ * Load config from disk. If missing, write defaults and return them.
42
+ * Returns the parsed config or throws on malformed JSON.
43
+ */
44
+ export function loadConfig() {
45
+ const dir = path.dirname(CONFIG_PATH);
46
+ if (!fs.existsSync(dir))
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ if (!fs.existsSync(CONFIG_PATH)) {
49
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2));
50
+ return { ...DEFAULT_CONFIG };
51
+ }
52
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
53
+ const parsed = JSON.parse(raw);
54
+ if (parsed.version !== 1) {
55
+ throw new Error(`Unsupported social config version ${parsed.version} (expected 1)`);
56
+ }
57
+ return parsed;
58
+ }
59
+ /**
60
+ * Persist config back to disk.
61
+ */
62
+ export function saveConfig(cfg) {
63
+ const dir = path.dirname(CONFIG_PATH);
64
+ if (!fs.existsSync(dir))
65
+ fs.mkdirSync(dir, { recursive: true });
66
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
67
+ }
68
+ /**
69
+ * Whether the config is "ready" to run — has a handle and at least one
70
+ * product with keywords.
71
+ */
72
+ export function isConfigReady(cfg) {
73
+ if (!cfg.handle)
74
+ return { ready: false, reason: 'handle not set' };
75
+ if (cfg.products.length === 0)
76
+ return { ready: false, reason: 'no products configured' };
77
+ const hasKeywords = cfg.products.some((p) => p.trigger_keywords.length > 0);
78
+ if (!hasKeywords)
79
+ return { ready: false, reason: 'no trigger keywords on any product' };
80
+ if (cfg.x.search_queries.length === 0)
81
+ return { ready: false, reason: 'no x.search_queries configured' };
82
+ return { ready: true };
83
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * JSONL-backed dedup + reply log for Franklin's social subsystem.
3
+ *
4
+ * Deliberately avoids SQLite (no new native dep). Two files at:
5
+ *
6
+ * ~/.blockrun/social-replies.jsonl — append-only reply log
7
+ * ~/.blockrun/social-prekeys.jsonl — append-only snippet-level dedup
8
+ *
9
+ * Both are read into memory at startup for O(1) lookups. At 30 replies/day
10
+ * this hits 10K rows after a year — still <1MB, still fits in memory.
11
+ *
12
+ * Schema improvements over social-bot's bot/db.py:
13
+ * - `status: 'failed'` does NOT block retry (social-bot blacklists failures
14
+ * permanently, which breaks on transient network errors)
15
+ * - Pre-key dedup happens BEFORE LLM call, saving tokens on duplicates
16
+ * - Per-platform + per-handle scoping so running the bot with two accounts
17
+ * against the same DB doesn't cross-contaminate
18
+ */
19
+ export type ReplyStatus = 'posted' | 'failed' | 'skipped' | 'drafted';
20
+ export type Platform = 'x' | 'reddit';
21
+ export interface ReplyRecord {
22
+ platform: Platform;
23
+ handle: string;
24
+ post_url: string;
25
+ post_title: string;
26
+ post_snippet: string;
27
+ reply_text: string;
28
+ product?: string;
29
+ status: ReplyStatus;
30
+ error_msg?: string;
31
+ cost_usd?: number;
32
+ created_at: string;
33
+ }
34
+ export interface PreKeyRecord {
35
+ platform: Platform;
36
+ handle: string;
37
+ pre_key: string;
38
+ created_at: string;
39
+ }
40
+ /**
41
+ * Compute a stable pre-key for a candidate post from its snippet fields.
42
+ * Used BEFORE the LLM generates a reply so we can skip duplicates without
43
+ * wasting any tokens.
44
+ */
45
+ export declare function computePreKey(parts: {
46
+ author?: string;
47
+ snippet: string;
48
+ time?: string;
49
+ }): string;
50
+ /**
51
+ * Has this post been seen before (by pre-key)? If true, skip generation.
52
+ */
53
+ export declare function hasPreKey(platform: Platform, handle: string, preKey: string): boolean;
54
+ /**
55
+ * Commit a pre-key so we don't re-consider this post. Called after we've
56
+ * decided to act on a post (either drafted, posted, or skipped by AI).
57
+ */
58
+ export declare function commitPreKey(platform: Platform, handle: string, preKey: string): void;
59
+ /**
60
+ * Has this canonical URL been successfully posted to before?
61
+ *
62
+ * Only counts status='posted' — unlike social-bot, we do NOT permanently
63
+ * blacklist 'failed' attempts, so transient errors can be retried.
64
+ */
65
+ export declare function hasPosted(platform: Platform, handle: string, postUrl: string): boolean;
66
+ /**
67
+ * Count today's successful posts for a handle/platform (used for daily caps).
68
+ */
69
+ export declare function countPostedToday(platform: Platform, handle: string): number;
70
+ /**
71
+ * Append a reply record. Status can be 'drafted' (dry-run), 'posted',
72
+ * 'failed' (transient, retry OK), or 'skipped' (AI returned SKIP).
73
+ */
74
+ export declare function logReply(rec: Omit<ReplyRecord, 'created_at' | 'post_url'> & {
75
+ post_url: string;
76
+ }): void;
77
+ /**
78
+ * Stats summary for `franklin social stats`.
79
+ */
80
+ export declare function getStats(platform?: Platform, handle?: string): {
81
+ total: number;
82
+ posted: number;
83
+ failed: number;
84
+ skipped: number;
85
+ drafted: number;
86
+ today: number;
87
+ totalCost: number;
88
+ byProduct: Record<string, number>;
89
+ };
90
+ /**
91
+ * Canonicalise a URL for stable dedup keys:
92
+ * - lowercase host
93
+ * - strip trailing slash
94
+ * - strip tracking params (?s=, ?t=, utm_*)
95
+ * - x.com and twitter.com are aliases
96
+ */
97
+ export declare function normaliseUrl(raw: string): string;
98
+ /**
99
+ * Test helper — reset in-memory indexes so the next call re-reads from disk.
100
+ * Not exported from the public API via index.ts.
101
+ */
102
+ export declare function _resetForTest(): void;