@blockrun/franklin 3.1.2 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/banner.js +125 -9
- 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,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
|
+
}
|
|
@@ -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
|
+
}
|