@blockrun/franklin 3.1.1 → 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,248 @@
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
+ import path from 'node:path';
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import crypto from 'node:crypto';
23
+ const STORE_DIR = path.join(os.homedir(), '.blockrun');
24
+ const REPLIES_PATH = path.join(STORE_DIR, 'social-replies.jsonl');
25
+ const PREKEYS_PATH = path.join(STORE_DIR, 'social-prekeys.jsonl');
26
+ // ─── In-memory indexes, loaded lazily on first use ─────────────────────────
27
+ let repliesLoaded = false;
28
+ let replies = [];
29
+ let repliesByUrl = new Map(); // canonical URL → records
30
+ let repliesToday = new Map(); // handle:platform:date → count
31
+ let preKeysLoaded = false;
32
+ let preKeysSet = new Set(); // composite key for O(1) lookup
33
+ function ensureDir() {
34
+ if (!fs.existsSync(STORE_DIR))
35
+ fs.mkdirSync(STORE_DIR, { recursive: true });
36
+ }
37
+ function loadReplies() {
38
+ if (repliesLoaded)
39
+ return;
40
+ ensureDir();
41
+ replies = [];
42
+ repliesByUrl.clear();
43
+ repliesToday.clear();
44
+ if (fs.existsSync(REPLIES_PATH)) {
45
+ const text = fs.readFileSync(REPLIES_PATH, 'utf8');
46
+ for (const line of text.split('\n')) {
47
+ if (!line.trim())
48
+ continue;
49
+ try {
50
+ const rec = JSON.parse(line);
51
+ replies.push(rec);
52
+ const list = repliesByUrl.get(rec.post_url) ?? [];
53
+ list.push(rec);
54
+ repliesByUrl.set(rec.post_url, list);
55
+ if (rec.status === 'posted') {
56
+ const dayKey = `${rec.handle}:${rec.platform}:${rec.created_at.slice(0, 10)}`;
57
+ repliesToday.set(dayKey, (repliesToday.get(dayKey) ?? 0) + 1);
58
+ }
59
+ }
60
+ catch {
61
+ // Skip malformed lines — append-only file may have a partial last line
62
+ }
63
+ }
64
+ }
65
+ repliesLoaded = true;
66
+ }
67
+ function loadPreKeys() {
68
+ if (preKeysLoaded)
69
+ return;
70
+ ensureDir();
71
+ preKeysSet.clear();
72
+ if (fs.existsSync(PREKEYS_PATH)) {
73
+ const text = fs.readFileSync(PREKEYS_PATH, 'utf8');
74
+ for (const line of text.split('\n')) {
75
+ if (!line.trim())
76
+ continue;
77
+ try {
78
+ const rec = JSON.parse(line);
79
+ preKeysSet.add(compositePreKey(rec.platform, rec.handle, rec.pre_key));
80
+ }
81
+ catch {
82
+ // Skip malformed lines
83
+ }
84
+ }
85
+ }
86
+ preKeysLoaded = true;
87
+ }
88
+ function compositePreKey(platform, handle, preKey) {
89
+ return `${platform}|${handle}|${preKey}`;
90
+ }
91
+ // ─── Public API ────────────────────────────────────────────────────────────
92
+ /**
93
+ * Compute a stable pre-key for a candidate post from its snippet fields.
94
+ * Used BEFORE the LLM generates a reply so we can skip duplicates without
95
+ * wasting any tokens.
96
+ */
97
+ export function computePreKey(parts) {
98
+ const normalised = (parts.author ?? '').trim().toLowerCase() + '|' +
99
+ parts.snippet.trim().slice(0, 80).toLowerCase() + '|' +
100
+ (parts.time ?? '').trim();
101
+ return crypto.createHash('sha256').update(normalised).digest('hex').slice(0, 16);
102
+ }
103
+ /**
104
+ * Has this post been seen before (by pre-key)? If true, skip generation.
105
+ */
106
+ export function hasPreKey(platform, handle, preKey) {
107
+ loadPreKeys();
108
+ return preKeysSet.has(compositePreKey(platform, handle, preKey));
109
+ }
110
+ /**
111
+ * Commit a pre-key so we don't re-consider this post. Called after we've
112
+ * decided to act on a post (either drafted, posted, or skipped by AI).
113
+ */
114
+ export function commitPreKey(platform, handle, preKey) {
115
+ loadPreKeys();
116
+ const composite = compositePreKey(platform, handle, preKey);
117
+ if (preKeysSet.has(composite))
118
+ return;
119
+ preKeysSet.add(composite);
120
+ const rec = {
121
+ platform,
122
+ handle,
123
+ pre_key: preKey,
124
+ created_at: new Date().toISOString(),
125
+ };
126
+ ensureDir();
127
+ fs.appendFileSync(PREKEYS_PATH, JSON.stringify(rec) + '\n');
128
+ }
129
+ /**
130
+ * Has this canonical URL been successfully posted to before?
131
+ *
132
+ * Only counts status='posted' — unlike social-bot, we do NOT permanently
133
+ * blacklist 'failed' attempts, so transient errors can be retried.
134
+ */
135
+ export function hasPosted(platform, handle, postUrl) {
136
+ loadReplies();
137
+ const recs = repliesByUrl.get(normaliseUrl(postUrl)) ?? [];
138
+ return recs.some((r) => r.platform === platform && r.handle === handle && r.status === 'posted');
139
+ }
140
+ /**
141
+ * Count today's successful posts for a handle/platform (used for daily caps).
142
+ */
143
+ export function countPostedToday(platform, handle) {
144
+ loadReplies();
145
+ const today = new Date().toISOString().slice(0, 10);
146
+ const key = `${handle}:${platform}:${today}`;
147
+ return repliesToday.get(key) ?? 0;
148
+ }
149
+ /**
150
+ * Append a reply record. Status can be 'drafted' (dry-run), 'posted',
151
+ * 'failed' (transient, retry OK), or 'skipped' (AI returned SKIP).
152
+ */
153
+ export function logReply(rec) {
154
+ loadReplies();
155
+ const record = {
156
+ ...rec,
157
+ post_url: normaliseUrl(rec.post_url),
158
+ created_at: new Date().toISOString(),
159
+ };
160
+ replies.push(record);
161
+ const list = repliesByUrl.get(record.post_url) ?? [];
162
+ list.push(record);
163
+ repliesByUrl.set(record.post_url, list);
164
+ if (record.status === 'posted') {
165
+ const dayKey = `${record.handle}:${record.platform}:${record.created_at.slice(0, 10)}`;
166
+ repliesToday.set(dayKey, (repliesToday.get(dayKey) ?? 0) + 1);
167
+ }
168
+ ensureDir();
169
+ fs.appendFileSync(REPLIES_PATH, JSON.stringify(record) + '\n');
170
+ }
171
+ /**
172
+ * Stats summary for `franklin social stats`.
173
+ */
174
+ export function getStats(platform, handle) {
175
+ loadReplies();
176
+ const today = new Date().toISOString().slice(0, 10);
177
+ const filtered = replies.filter((r) => {
178
+ if (platform && r.platform !== platform)
179
+ return false;
180
+ if (handle && r.handle !== handle)
181
+ return false;
182
+ return true;
183
+ });
184
+ const byProduct = {};
185
+ let totalCost = 0;
186
+ let todayCount = 0;
187
+ const statusCounts = { posted: 0, failed: 0, skipped: 0, drafted: 0 };
188
+ for (const r of filtered) {
189
+ statusCounts[r.status] =
190
+ (statusCounts[r.status] ?? 0) + 1;
191
+ if (r.product)
192
+ byProduct[r.product] = (byProduct[r.product] ?? 0) + 1;
193
+ if (r.cost_usd)
194
+ totalCost += r.cost_usd;
195
+ if (r.status === 'posted' && r.created_at.startsWith(today))
196
+ todayCount++;
197
+ }
198
+ return {
199
+ total: filtered.length,
200
+ ...statusCounts,
201
+ today: todayCount,
202
+ totalCost,
203
+ byProduct,
204
+ };
205
+ }
206
+ /**
207
+ * Canonicalise a URL for stable dedup keys:
208
+ * - lowercase host
209
+ * - strip trailing slash
210
+ * - strip tracking params (?s=, ?t=, utm_*)
211
+ * - x.com and twitter.com are aliases
212
+ */
213
+ export function normaliseUrl(raw) {
214
+ try {
215
+ const u = new URL(raw);
216
+ u.hostname = u.hostname.toLowerCase();
217
+ if (u.hostname === 'twitter.com' || u.hostname === 'mobile.twitter.com') {
218
+ u.hostname = 'x.com';
219
+ }
220
+ // Strip common tracking params
221
+ const toStrip = [];
222
+ u.searchParams.forEach((_v, k) => {
223
+ if (k.startsWith('utm_') || k === 's' || k === 't' || k === 'ref')
224
+ toStrip.push(k);
225
+ });
226
+ for (const k of toStrip)
227
+ u.searchParams.delete(k);
228
+ let s = u.toString();
229
+ if (s.endsWith('/'))
230
+ s = s.slice(0, -1);
231
+ return s;
232
+ }
233
+ catch {
234
+ return raw.trim();
235
+ }
236
+ }
237
+ /**
238
+ * Test helper — reset in-memory indexes so the next call re-reads from disk.
239
+ * Not exported from the public API via index.ts.
240
+ */
241
+ export function _resetForTest() {
242
+ repliesLoaded = false;
243
+ preKeysLoaded = false;
244
+ replies = [];
245
+ repliesByUrl.clear();
246
+ repliesToday.clear();
247
+ preKeysSet.clear();
248
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * X (Twitter) flow for Franklin's social subsystem.
3
+ *
4
+ * Port of social-bot/bot/x_bot.py with three meaningful changes:
5
+ *
6
+ * 1. Pre-key dedup runs BEFORE the LLM call (social-bot runs it after,
7
+ * wasting Sonnet tokens on every duplicate).
8
+ * 2. 'failed' status does NOT blacklist — only 'posted' does.
9
+ * A transient network error can be retried on the next run.
10
+ * 3. Reply textbox is located via Playwright role selectors, not by
11
+ * counting buttons in a list — less fragile to X DOM changes.
12
+ *
13
+ * Every browser interaction uses argv-based Playwright calls — zero shell
14
+ * injection surface even if the LLM emits `$(rm -rf /)` in reply text.
15
+ */
16
+ import type { SocialConfig } from './config.js';
17
+ import type { Chain } from '../config.js';
18
+ export interface RunOptions {
19
+ config: SocialConfig;
20
+ model: string;
21
+ apiUrl: string;
22
+ chain: Chain;
23
+ dryRun: boolean;
24
+ debug?: boolean;
25
+ onProgress?: (msg: string) => void;
26
+ }
27
+ export interface RunResult {
28
+ considered: number;
29
+ dedupSkipped: number;
30
+ llmSkipped: number;
31
+ drafted: number;
32
+ posted: number;
33
+ failed: number;
34
+ totalCost: number;
35
+ }
36
+ export interface CandidatePost {
37
+ snippetRef: string;
38
+ articleRef: string;
39
+ snippet: string;
40
+ timeText: string;
41
+ }
42
+ /**
43
+ * Main entry point. Iterates every search query in config.x.search_queries
44
+ * and processes every visible candidate until the daily target is hit.
45
+ */
46
+ export declare function runX(opts: RunOptions): Promise<RunResult>;
@@ -0,0 +1,284 @@
1
+ /**
2
+ * X (Twitter) flow for Franklin's social subsystem.
3
+ *
4
+ * Port of social-bot/bot/x_bot.py with three meaningful changes:
5
+ *
6
+ * 1. Pre-key dedup runs BEFORE the LLM call (social-bot runs it after,
7
+ * wasting Sonnet tokens on every duplicate).
8
+ * 2. 'failed' status does NOT blacklist — only 'posted' does.
9
+ * A transient network error can be retried on the next run.
10
+ * 3. Reply textbox is located via Playwright role selectors, not by
11
+ * counting buttons in a list — less fragile to X DOM changes.
12
+ *
13
+ * Every browser interaction uses argv-based Playwright calls — zero shell
14
+ * injection surface even if the LLM emits `$(rm -rf /)` in reply text.
15
+ */
16
+ import { SocialBrowser } from './browser.js';
17
+ import { findRefs, findStaticText, extractArticleBlocks, X_TIME_LINK_PATTERN } from './a11y.js';
18
+ import { computePreKey, hasPreKey, commitPreKey, hasPosted, countPostedToday, logReply, } from './db.js';
19
+ import { detectProduct, generateReply } from './ai.js';
20
+ /**
21
+ * Main entry point. Iterates every search query in config.x.search_queries
22
+ * and processes every visible candidate until the daily target is hit.
23
+ */
24
+ export async function runX(opts) {
25
+ const log = opts.onProgress ?? (() => { });
26
+ const handle = opts.config.handle || 'unknown';
27
+ const result = {
28
+ considered: 0,
29
+ dedupSkipped: 0,
30
+ llmSkipped: 0,
31
+ drafted: 0,
32
+ posted: 0,
33
+ failed: 0,
34
+ totalCost: 0,
35
+ };
36
+ const alreadyToday = countPostedToday('x', handle);
37
+ const remainingBudget = Math.max(0, opts.config.x.daily_target - alreadyToday);
38
+ if (remainingBudget === 0) {
39
+ log(`Daily target of ${opts.config.x.daily_target} already hit today. Nothing to do.`);
40
+ return result;
41
+ }
42
+ log(`Daily budget: ${remainingBudget} posts remaining (of ${opts.config.x.daily_target})`);
43
+ const browser = new SocialBrowser({ headless: false });
44
+ try {
45
+ await browser.launch();
46
+ log('Browser launched. Checking login state…');
47
+ // Verify we're logged in. If the login_detection string isn't visible on
48
+ // x.com's home page, the user needs to run `franklin social login x`.
49
+ await browser.open('https://x.com/home');
50
+ await browser.waitForTimeout(2500);
51
+ const homeTree = await browser.snapshot();
52
+ const loginMarker = opts.config.x.login_detection || opts.config.handle;
53
+ if (loginMarker && !homeTree.includes(loginMarker)) {
54
+ throw new Error(`Not logged in to x.com (looked for "${loginMarker}" on /home). ` +
55
+ `Run: franklin social login x`);
56
+ }
57
+ log('Login confirmed.');
58
+ let postedThisRun = 0;
59
+ for (const query of opts.config.x.search_queries) {
60
+ if (postedThisRun >= remainingBudget) {
61
+ log(`Hit daily budget (${postedThisRun}) — stopping early.`);
62
+ break;
63
+ }
64
+ log(`\nSearching X for: ${query}`);
65
+ const searchUrl = `https://x.com/search?q=${encodeURIComponent(query)}&src=typed_query&f=live`;
66
+ await browser.open(searchUrl);
67
+ await browser.waitForTimeout(3500);
68
+ const searchTree = await browser.snapshot();
69
+ const articles = extractArticleBlocks(searchTree);
70
+ log(` Found ${articles.length} posts in results`);
71
+ for (const article of articles) {
72
+ if (postedThisRun >= remainingBudget)
73
+ break;
74
+ result.considered++;
75
+ // Extract the clickable time-link (our open-tweet handle) and the
76
+ // first visible static text (our snippet).
77
+ const timeRefs = findRefs(article.text, 'link', X_TIME_LINK_PATTERN);
78
+ if (timeRefs.length === 0)
79
+ continue;
80
+ const timeRef = timeRefs[0];
81
+ const texts = findStaticText(article.text);
82
+ const snippet = texts.slice(0, 3).join(' ').trim();
83
+ if (!snippet || snippet.length < 20)
84
+ continue;
85
+ const timeLinkMatch = new RegExp(`\\[${timeRef}\\][^\\n]*`).exec(article.text);
86
+ const timeText = timeLinkMatch ? timeLinkMatch[0] : '';
87
+ // ── Pre-key dedup: BEFORE any LLM call ──
88
+ const preKey = computePreKey({ snippet, time: timeText });
89
+ if (hasPreKey('x', handle, preKey)) {
90
+ result.dedupSkipped++;
91
+ continue;
92
+ }
93
+ // Product routing (zero-cost keyword score)
94
+ const product = detectProduct(snippet, opts.config.products);
95
+ if (!product) {
96
+ commitPreKey('x', handle, preKey); // never retry — no product matches
97
+ continue;
98
+ }
99
+ log(`\n → ${snippet.slice(0, 80)}…`);
100
+ log(` product: ${product.name}`);
101
+ // Generate reply (this is where we spend LLM tokens)
102
+ let gen;
103
+ try {
104
+ gen = await generateReply({
105
+ post: { title: snippet.slice(0, 120), snippet, platform: 'x' },
106
+ product,
107
+ config: opts.config,
108
+ model: opts.model,
109
+ apiUrl: opts.apiUrl,
110
+ chain: opts.chain,
111
+ debug: opts.debug,
112
+ });
113
+ }
114
+ catch (err) {
115
+ log(` ✗ generateReply failed: ${err.message}`);
116
+ commitPreKey('x', handle, preKey);
117
+ continue;
118
+ }
119
+ result.totalCost += gen.cost;
120
+ if (!gen.reply) {
121
+ log(` AI said SKIP`);
122
+ result.llmSkipped++;
123
+ commitPreKey('x', handle, preKey);
124
+ logReply({
125
+ platform: 'x',
126
+ handle,
127
+ post_url: `preview:${preKey}`,
128
+ post_title: snippet.slice(0, 120),
129
+ post_snippet: snippet,
130
+ reply_text: '',
131
+ product: product.name,
132
+ status: 'skipped',
133
+ cost_usd: gen.cost,
134
+ });
135
+ continue;
136
+ }
137
+ result.drafted++;
138
+ log(` draft: ${gen.reply}`);
139
+ // ── Dry-run short-circuit ──
140
+ if (opts.dryRun) {
141
+ commitPreKey('x', handle, preKey);
142
+ logReply({
143
+ platform: 'x',
144
+ handle,
145
+ post_url: `preview:${preKey}`,
146
+ post_title: snippet.slice(0, 120),
147
+ post_snippet: snippet,
148
+ reply_text: gen.reply,
149
+ product: product.name,
150
+ status: 'drafted',
151
+ cost_usd: gen.cost,
152
+ });
153
+ continue;
154
+ }
155
+ // ── Live path: open the tweet, dedup by canonical URL, post ──
156
+ let canonicalUrl = '';
157
+ try {
158
+ await browser.click(timeRef);
159
+ await browser.waitForTimeout(3000);
160
+ canonicalUrl = await browser.getUrl();
161
+ }
162
+ catch (err) {
163
+ log(` ✗ failed to open tweet: ${err.message}`);
164
+ logReply({
165
+ platform: 'x',
166
+ handle,
167
+ post_url: `preview:${preKey}`,
168
+ post_title: snippet.slice(0, 120),
169
+ post_snippet: snippet,
170
+ reply_text: gen.reply,
171
+ product: product.name,
172
+ status: 'failed',
173
+ error_msg: `open-tweet: ${err.message}`,
174
+ cost_usd: gen.cost,
175
+ });
176
+ result.failed++;
177
+ commitPreKey('x', handle, preKey);
178
+ continue;
179
+ }
180
+ if (hasPosted('x', handle, canonicalUrl)) {
181
+ log(` already posted to ${canonicalUrl} — backing out`);
182
+ commitPreKey('x', handle, preKey);
183
+ await browser.press('Alt+ArrowLeft').catch(() => { });
184
+ await browser.waitForTimeout(1500);
185
+ continue;
186
+ }
187
+ // Post the reply
188
+ try {
189
+ await postReply(browser, gen.reply);
190
+ log(` ✓ posted to ${canonicalUrl}`);
191
+ result.posted++;
192
+ postedThisRun++;
193
+ logReply({
194
+ platform: 'x',
195
+ handle,
196
+ post_url: canonicalUrl,
197
+ post_title: snippet.slice(0, 120),
198
+ post_snippet: snippet,
199
+ reply_text: gen.reply,
200
+ product: product.name,
201
+ status: 'posted',
202
+ cost_usd: gen.cost,
203
+ });
204
+ commitPreKey('x', handle, preKey);
205
+ // Respect the rate-limit / anti-spam delay between successes
206
+ await browser.waitForTimeout(opts.config.x.min_delay_seconds * 1000);
207
+ }
208
+ catch (err) {
209
+ log(` ✗ post failed: ${err.message}`);
210
+ result.failed++;
211
+ logReply({
212
+ platform: 'x',
213
+ handle,
214
+ post_url: canonicalUrl,
215
+ post_title: snippet.slice(0, 120),
216
+ post_snippet: snippet,
217
+ reply_text: gen.reply,
218
+ product: product.name,
219
+ status: 'failed',
220
+ error_msg: err.message,
221
+ cost_usd: gen.cost,
222
+ });
223
+ // Don't commitPreKey — allow retry on next run
224
+ await browser.press('Escape').catch(() => { });
225
+ await browser.waitForTimeout(2000);
226
+ }
227
+ }
228
+ }
229
+ }
230
+ finally {
231
+ await browser.close();
232
+ }
233
+ return result;
234
+ }
235
+ /**
236
+ * Post a reply to the currently-open tweet page.
237
+ * Locates the reply textbox, types the reply (paragraphs joined with
238
+ * Enter+Enter), clicks the reply button, confirms the "Your post was sent"
239
+ * banner.
240
+ */
241
+ async function postReply(browser, reply) {
242
+ // Snapshot and find the reply textbox
243
+ const tree = await browser.snapshot();
244
+ const boxRefs = findRefs(tree, 'textbox', 'Post (your reply|text).*');
245
+ if (boxRefs.length === 0) {
246
+ // Fallback: any textbox containing "reply" or "post"
247
+ const fallback = findRefs(tree, 'textbox', '(?:[Rr]eply|[Pp]ost).*');
248
+ if (fallback.length === 0)
249
+ throw new Error('reply textbox not found');
250
+ await browser.click(fallback[0]);
251
+ }
252
+ else {
253
+ await browser.click(boxRefs[0]);
254
+ }
255
+ await browser.waitForTimeout(700);
256
+ // Type paragraphs separated by double-enter.
257
+ // Strip any `$` so it never triggers a variable interpolation in some
258
+ // downstream tool. (Not required for Playwright argv, but defense in depth.)
259
+ const paragraphs = reply.split(/\n{2,}/).map((p) => p.replace(/\s+$/, ''));
260
+ for (let i = 0; i < paragraphs.length; i++) {
261
+ if (i > 0) {
262
+ await browser.press('Enter');
263
+ await browser.press('Enter');
264
+ }
265
+ await browser.type(paragraphs[i]);
266
+ }
267
+ await browser.waitForTimeout(700);
268
+ // Click the reply (submit) button. The modal's submit button is labelled
269
+ // "Reply" — we take the FIRST match because the inline compose-below-tweet
270
+ // form and the modal don't coexist in the DOM.
271
+ const snapAfter = await browser.snapshot();
272
+ const replyBtns = findRefs(snapAfter, 'button', 'Reply');
273
+ if (replyBtns.length === 0)
274
+ throw new Error('reply submit button not found');
275
+ // If multiple reply buttons (e.g. a toolbar Reply + submit Reply), the
276
+ // submit is usually the last one with the 'Reply' label.
277
+ await browser.click(replyBtns[replyBtns.length - 1]);
278
+ await browser.waitForTimeout(2500);
279
+ // Confirm
280
+ const confirm = await browser.snapshot();
281
+ if (!/Your post was sent|Reply sent|Your reply was sent/.test(confirm)) {
282
+ throw new Error('post-send confirmation banner not found');
283
+ }
284
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -70,6 +70,7 @@
70
70
  "ink": "^6.8.0",
71
71
  "ink-spinner": "^5.0.0",
72
72
  "ink-text-input": "^6.0.0",
73
+ "playwright-core": "^1.49.1",
73
74
  "react": "^19.2.4"
74
75
  },
75
76
  "devDependencies": {