@blockrun/franklin 3.2.3 → 3.3.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.
Files changed (41) hide show
  1. package/dist/agent/commands.js +30 -1
  2. package/dist/agent/context.js +13 -0
  3. package/dist/agent/permissions.js +3 -3
  4. package/dist/banner.js +61 -75
  5. package/dist/commands/start.js +33 -2
  6. package/dist/events/bridge.d.ts +1 -0
  7. package/dist/events/bridge.js +24 -0
  8. package/dist/events/bus.d.ts +17 -0
  9. package/dist/events/bus.js +55 -0
  10. package/dist/events/types.d.ts +49 -0
  11. package/dist/events/types.js +8 -0
  12. package/dist/learnings/extractor.d.ts +16 -0
  13. package/dist/learnings/extractor.js +234 -0
  14. package/dist/learnings/index.d.ts +3 -0
  15. package/dist/learnings/index.js +2 -0
  16. package/dist/learnings/store.d.ts +15 -0
  17. package/dist/learnings/store.js +130 -0
  18. package/dist/learnings/types.d.ts +24 -0
  19. package/dist/learnings/types.js +7 -0
  20. package/dist/narrative/state.d.ts +30 -0
  21. package/dist/narrative/state.js +69 -0
  22. package/dist/social/browser-pool.d.ts +29 -0
  23. package/dist/social/browser-pool.js +57 -0
  24. package/dist/social/preflight.d.ts +14 -0
  25. package/dist/social/preflight.js +26 -0
  26. package/dist/social/x.d.ts +8 -0
  27. package/dist/social/x.js +9 -1
  28. package/dist/tools/index.js +7 -0
  29. package/dist/tools/posttox.d.ts +7 -0
  30. package/dist/tools/posttox.js +137 -0
  31. package/dist/tools/searchx.d.ts +7 -0
  32. package/dist/tools/searchx.js +111 -0
  33. package/dist/tools/trading.d.ts +3 -0
  34. package/dist/tools/trading.js +168 -0
  35. package/dist/trading/config.d.ts +23 -0
  36. package/dist/trading/config.js +45 -0
  37. package/dist/trading/data.d.ts +30 -0
  38. package/dist/trading/data.js +112 -0
  39. package/dist/trading/metrics.d.ts +29 -0
  40. package/dist/trading/metrics.js +105 -0
  41. package/package.json +1 -1
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Extract user preferences from a completed session trace.
3
+ * Uses a cheap model to analyze the conversation and produce learnings.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import { loadLearnings, mergeLearning, saveLearnings } from './store.js';
9
+ // Cheapest models that reliably output structured JSON
10
+ const EXTRACTION_MODELS = [
11
+ 'google/gemini-2.5-flash-lite',
12
+ 'google/gemini-2.5-flash',
13
+ 'nvidia/nemotron-super-49b',
14
+ ];
15
+ const VALID_CATEGORIES = new Set([
16
+ 'language', 'model_preference', 'tool_pattern', 'coding_style',
17
+ 'communication', 'domain', 'correction', 'workflow', 'other',
18
+ ]);
19
+ const EXTRACTION_PROMPT = `You are analyzing a conversation between a user and an AI coding agent. Extract user preferences and behavioral patterns that would help personalize future interactions.
20
+
21
+ Analyze for:
22
+ 1. Language — what language does the user write in? (English, Chinese, mixed?)
23
+ 2. Model preferences — did they switch models or express a preference?
24
+ 3. Coding style — did they correct the agent's code style? (naming, formatting, conventions)
25
+ 4. Communication — are they terse or verbose? Do they want explanations or just code?
26
+ 5. Domain — what tech stack, frameworks, or project type?
27
+ 6. Corrections — did they repeatedly correct the same agent behavior?
28
+ 7. Workflow — do they prefer short tasks or long planning sessions?
29
+
30
+ Rules:
31
+ - ONLY extract signals clearly supported by evidence in the conversation.
32
+ - Do NOT speculate. If evidence is weak, set confidence below 0.5.
33
+ - If the conversation is too short or generic, return an empty array.
34
+ - Each learning should be one clear, actionable sentence.
35
+
36
+ Respond with ONLY a JSON object (no markdown fences, no commentary):
37
+ {"learnings":[{"learning":"...","category":"language|model_preference|tool_pattern|coding_style|communication|domain|correction|workflow|other","confidence":0.5}]}`;
38
+ /**
39
+ * Condense session history into a compact text for extraction.
40
+ * Only includes user messages and assistant text — skips tool calls/results.
41
+ */
42
+ function condenseHistory(history) {
43
+ const parts = [];
44
+ let chars = 0;
45
+ const CAP = 4000;
46
+ for (const msg of history) {
47
+ if (chars >= CAP)
48
+ break;
49
+ const role = msg.role === 'user' ? 'User' : 'Assistant';
50
+ let text = '';
51
+ if (typeof msg.content === 'string') {
52
+ text = msg.content;
53
+ }
54
+ else if (Array.isArray(msg.content)) {
55
+ text = msg.content
56
+ .filter(p => p.type === 'text')
57
+ .map(p => p.text)
58
+ .join('\n');
59
+ }
60
+ if (!text.trim())
61
+ continue;
62
+ // Truncate long messages
63
+ if (text.length > 500)
64
+ text = text.slice(0, 500) + '…';
65
+ const line = `${role}: ${text}`;
66
+ parts.push(line);
67
+ chars += line.length;
68
+ }
69
+ return parts.join('\n\n');
70
+ }
71
+ /**
72
+ * Parse JSON from LLM response, handling common quirks
73
+ * (markdown fences, trailing commas, commentary).
74
+ */
75
+ function parseExtraction(raw) {
76
+ // Strip markdown fences
77
+ let cleaned = raw.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
78
+ // Find the JSON object
79
+ const start = cleaned.indexOf('{');
80
+ const end = cleaned.lastIndexOf('}');
81
+ if (start === -1 || end === -1)
82
+ return { learnings: [] };
83
+ cleaned = cleaned.slice(start, end + 1);
84
+ const parsed = JSON.parse(cleaned);
85
+ if (!Array.isArray(parsed.learnings))
86
+ return { learnings: [] };
87
+ // Validate and sanitize each entry
88
+ return {
89
+ learnings: parsed.learnings
90
+ .filter((l) => typeof l.learning === 'string' &&
91
+ typeof l.category === 'string' &&
92
+ VALID_CATEGORIES.has(l.category) &&
93
+ typeof l.confidence === 'number' &&
94
+ l.confidence >= 0.1 && l.confidence <= 1.0 &&
95
+ l.learning.length > 5)
96
+ .map((l) => ({
97
+ learning: l.learning.slice(0, 200),
98
+ category: l.category,
99
+ confidence: Math.round(l.confidence * 100) / 100,
100
+ })),
101
+ };
102
+ }
103
+ // ─── Onboarding: bootstrap from Claude Code config ───────────────────────
104
+ const BOOTSTRAP_PROMPT = `You are analyzing a user's AI coding agent configuration file (CLAUDE.md). Extract user preferences that would help personalize a different AI agent's behavior.
105
+
106
+ Analyze for:
107
+ 1. Language — what language do they communicate in?
108
+ 2. Coding style — naming conventions, formatting, lint rules, type annotations?
109
+ 3. Communication — how do they want the agent to behave? (terse? formal? call them something?)
110
+ 4. Domain — what tech stack, frameworks, languages do they work with?
111
+ 5. Workflow — any specific git, commit, or deployment preferences?
112
+ 6. Corrections — any explicit "do NOT" rules or anti-patterns?
113
+ 7. Other — any other clear preferences?
114
+
115
+ Rules:
116
+ - Extract EVERY explicit preference. These are user-written rules, so confidence is high (0.8-1.0).
117
+ - Each learning should be one clear, actionable sentence.
118
+ - Do NOT include project-specific paths or secrets.
119
+ - Do NOT include things that are tool-specific to Claude Code and wouldn't apply to another agent.
120
+
121
+ Respond with ONLY a JSON object (no markdown fences, no commentary):
122
+ {"learnings":[{"learning":"...","category":"language|model_preference|tool_pattern|coding_style|communication|domain|correction|workflow|other","confidence":0.9}]}`;
123
+ /**
124
+ * Scan for Claude Code configuration and bootstrap learnings from it.
125
+ * Only runs once — skips if learnings already exist.
126
+ */
127
+ export async function bootstrapFromClaudeConfig(client) {
128
+ // Only bootstrap if no learnings exist yet (first run)
129
+ const existing = loadLearnings();
130
+ if (existing.length > 0)
131
+ return 0;
132
+ // Scan for Claude Code config files
133
+ const configPaths = [
134
+ path.join(os.homedir(), '.claude', 'CLAUDE.md'),
135
+ path.join(process.cwd(), 'CLAUDE.md'),
136
+ path.join(process.cwd(), '.claude', 'CLAUDE.md'),
137
+ ];
138
+ const contents = [];
139
+ for (const p of configPaths) {
140
+ try {
141
+ const text = fs.readFileSync(p, 'utf-8').trim();
142
+ if (text && text.length > 20) {
143
+ contents.push(`--- ${p} ---\n${text}`);
144
+ }
145
+ }
146
+ catch { /* file doesn't exist */ }
147
+ }
148
+ if (contents.length === 0)
149
+ return 0;
150
+ // Cap total content
151
+ let combined = contents.join('\n\n');
152
+ if (combined.length > 6000)
153
+ combined = combined.slice(0, 6000) + '\n…(truncated)';
154
+ // Extract learnings
155
+ let result = null;
156
+ for (const model of EXTRACTION_MODELS) {
157
+ try {
158
+ const response = await client.complete({
159
+ model,
160
+ messages: [{ role: 'user', content: combined }],
161
+ system: BOOTSTRAP_PROMPT,
162
+ max_tokens: 1500,
163
+ temperature: 0.2,
164
+ });
165
+ const text = response.content
166
+ .filter((p) => p.type === 'text')
167
+ .map((p) => p.text)
168
+ .join('');
169
+ result = parseExtraction(text);
170
+ break;
171
+ }
172
+ catch {
173
+ continue;
174
+ }
175
+ }
176
+ if (!result || result.learnings.length === 0)
177
+ return 0;
178
+ // Save all bootstrapped learnings
179
+ let learnings = loadLearnings();
180
+ for (const entry of result.learnings) {
181
+ learnings = mergeLearning(learnings, {
182
+ ...entry,
183
+ source_session: 'bootstrap:claude-config',
184
+ });
185
+ }
186
+ saveLearnings(learnings);
187
+ return result.learnings.length;
188
+ }
189
+ // ─── Session extraction ──────────────────────────────────────────────────
190
+ /**
191
+ * Extract learnings from a completed session.
192
+ * Runs asynchronously — caller should fire-and-forget.
193
+ */
194
+ export async function extractLearnings(history, sessionId, client) {
195
+ // Skip very short sessions
196
+ if (history.length < 4)
197
+ return;
198
+ const condensed = condenseHistory(history);
199
+ if (condensed.length < 100)
200
+ return; // Too little content
201
+ // Try each model until one succeeds
202
+ let result = null;
203
+ for (const model of EXTRACTION_MODELS) {
204
+ try {
205
+ const response = await client.complete({
206
+ model,
207
+ messages: [{ role: 'user', content: condensed }],
208
+ system: EXTRACTION_PROMPT,
209
+ max_tokens: 1000,
210
+ temperature: 0.3,
211
+ });
212
+ const text = response.content
213
+ .filter((p) => p.type === 'text')
214
+ .map((p) => p.text)
215
+ .join('');
216
+ result = parseExtraction(text);
217
+ break;
218
+ }
219
+ catch {
220
+ continue; // Try next model
221
+ }
222
+ }
223
+ if (!result || result.learnings.length === 0)
224
+ return;
225
+ // Merge with existing learnings
226
+ let existing = loadLearnings();
227
+ for (const entry of result.learnings) {
228
+ existing = mergeLearning(existing, {
229
+ ...entry,
230
+ source_session: sessionId,
231
+ });
232
+ }
233
+ saveLearnings(existing);
234
+ }
@@ -0,0 +1,3 @@
1
+ export type { Learning, LearningCategory, ExtractionResult } from './types.js';
2
+ export { loadLearnings, saveLearnings, mergeLearning, decayLearnings, formatForPrompt } from './store.js';
3
+ export { extractLearnings, bootstrapFromClaudeConfig } from './extractor.js';
@@ -0,0 +1,2 @@
1
+ export { loadLearnings, saveLearnings, mergeLearning, decayLearnings, formatForPrompt } from './store.js';
2
+ export { extractLearnings, bootstrapFromClaudeConfig } from './extractor.js';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Persistence layer for per-user learnings.
3
+ * Stored as JSONL at ~/.blockrun/learnings.jsonl.
4
+ */
5
+ import type { Learning, LearningCategory } from './types.js';
6
+ export declare function loadLearnings(): Learning[];
7
+ export declare function saveLearnings(learnings: Learning[]): void;
8
+ export declare function mergeLearning(existing: Learning[], newEntry: {
9
+ learning: string;
10
+ category: LearningCategory;
11
+ confidence: number;
12
+ source_session: string;
13
+ }): Learning[];
14
+ export declare function decayLearnings(learnings: Learning[]): Learning[];
15
+ export declare function formatForPrompt(learnings: Learning[]): string;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Persistence layer for per-user learnings.
3
+ * Stored as JSONL at ~/.blockrun/learnings.jsonl.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import crypto from 'node:crypto';
8
+ import { BLOCKRUN_DIR } from '../config.js';
9
+ const LEARNINGS_PATH = path.join(BLOCKRUN_DIR, 'learnings.jsonl');
10
+ const MAX_LEARNINGS = 50;
11
+ const DECAY_AFTER_DAYS = 30;
12
+ const DECAY_AMOUNT = 0.15;
13
+ const PRUNE_THRESHOLD = 0.2;
14
+ const MERGE_SIMILARITY = 0.6;
15
+ // ─── Load / Save ──────────────────────────────────────────────────────────
16
+ export function loadLearnings() {
17
+ try {
18
+ const raw = fs.readFileSync(LEARNINGS_PATH, 'utf-8');
19
+ const results = [];
20
+ for (const line of raw.split('\n')) {
21
+ if (!line.trim())
22
+ continue;
23
+ try {
24
+ results.push(JSON.parse(line));
25
+ }
26
+ catch { /* skip corrupted lines */ }
27
+ }
28
+ return results;
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
34
+ export function saveLearnings(learnings) {
35
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
36
+ const tmpPath = LEARNINGS_PATH + '.tmp';
37
+ const content = learnings.map(l => JSON.stringify(l)).join('\n') + '\n';
38
+ fs.writeFileSync(tmpPath, content);
39
+ fs.renameSync(tmpPath, LEARNINGS_PATH);
40
+ }
41
+ // ─── Merge / Dedup ────────────────────────────────────────────────────────
42
+ function tokenize(text) {
43
+ return new Set(text.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 2));
44
+ }
45
+ function jaccardSimilarity(a, b) {
46
+ if (a.size === 0 && b.size === 0)
47
+ return 1;
48
+ let intersection = 0;
49
+ for (const w of a)
50
+ if (b.has(w))
51
+ intersection++;
52
+ return intersection / (a.size + b.size - intersection);
53
+ }
54
+ export function mergeLearning(existing, newEntry) {
55
+ const now = Date.now();
56
+ const newTokens = tokenize(newEntry.learning);
57
+ // Find similar existing learning in same category
58
+ for (const entry of existing) {
59
+ if (entry.category !== newEntry.category)
60
+ continue;
61
+ const similarity = jaccardSimilarity(tokenize(entry.learning), newTokens);
62
+ if (similarity >= MERGE_SIMILARITY) {
63
+ // Merge: boost confidence, update timestamp
64
+ entry.times_confirmed++;
65
+ entry.last_confirmed = now;
66
+ entry.confidence = Math.min(entry.confidence + 0.1, 1.0);
67
+ // Prefer more specific wording
68
+ if (newEntry.learning.length > entry.learning.length) {
69
+ entry.learning = newEntry.learning;
70
+ }
71
+ return existing;
72
+ }
73
+ }
74
+ // No match — insert new
75
+ existing.push({
76
+ id: crypto.randomBytes(8).toString('hex'),
77
+ learning: newEntry.learning,
78
+ category: newEntry.category,
79
+ confidence: newEntry.confidence,
80
+ source_session: newEntry.source_session,
81
+ created_at: now,
82
+ last_confirmed: now,
83
+ times_confirmed: 1,
84
+ });
85
+ // Cap at MAX_LEARNINGS — drop lowest-scoring
86
+ if (existing.length > MAX_LEARNINGS) {
87
+ existing.sort((a, b) => score(b) - score(a));
88
+ existing.length = MAX_LEARNINGS;
89
+ }
90
+ return existing;
91
+ }
92
+ function score(l) {
93
+ return l.confidence * Math.log2(l.times_confirmed + 1);
94
+ }
95
+ // ─── Decay ────────────────────────────────────────────────────────────────
96
+ export function decayLearnings(learnings) {
97
+ const now = Date.now();
98
+ const cutoff = DECAY_AFTER_DAYS * 24 * 60 * 60 * 1000;
99
+ return learnings.filter(l => {
100
+ if (l.times_confirmed >= 3)
101
+ return true; // Immune to time decay
102
+ if (now - l.last_confirmed > cutoff) {
103
+ l.confidence -= DECAY_AMOUNT;
104
+ return l.confidence >= PRUNE_THRESHOLD;
105
+ }
106
+ return true;
107
+ });
108
+ }
109
+ // ─── Format for System Prompt ─────────────────────────────────────────────
110
+ const MAX_PROMPT_CHARS = 2000; // ~500 tokens
111
+ export function formatForPrompt(learnings) {
112
+ if (learnings.length === 0)
113
+ return '';
114
+ const sorted = [...learnings].sort((a, b) => score(b) - score(a));
115
+ const lines = [];
116
+ let chars = 0;
117
+ const header = '# Personal Context\nPreferences learned from previous sessions:\n';
118
+ chars += header.length;
119
+ for (const l of sorted) {
120
+ const conf = l.confidence >= 0.8 ? '●' : l.confidence >= 0.5 ? '◐' : '○';
121
+ const line = `- ${conf} ${l.learning}`;
122
+ if (chars + line.length + 1 > MAX_PROMPT_CHARS)
123
+ break;
124
+ lines.push(line);
125
+ chars += line.length + 1;
126
+ }
127
+ if (lines.length === 0)
128
+ return '';
129
+ return header + lines.join('\n');
130
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Types for Franklin's per-user self-evolution system.
3
+ *
4
+ * Each user's Franklin learns preferences from session traces and
5
+ * injects them into the system prompt on next startup.
6
+ */
7
+ export interface Learning {
8
+ id: string;
9
+ learning: string;
10
+ category: LearningCategory;
11
+ confidence: number;
12
+ source_session: string;
13
+ created_at: number;
14
+ last_confirmed: number;
15
+ times_confirmed: number;
16
+ }
17
+ export type LearningCategory = 'language' | 'model_preference' | 'tool_pattern' | 'coding_style' | 'communication' | 'domain' | 'correction' | 'workflow' | 'other';
18
+ export interface ExtractionResult {
19
+ learnings: Array<{
20
+ learning: string;
21
+ category: LearningCategory;
22
+ confidence: number;
23
+ }>;
24
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Types for Franklin's per-user self-evolution system.
3
+ *
4
+ * Each user's Franklin learns preferences from session traces and
5
+ * injects them into the system prompt on next startup.
6
+ */
7
+ export {};
@@ -0,0 +1,30 @@
1
+ export interface SignalRecord {
2
+ asset: string;
3
+ direction: 'bullish' | 'bearish' | 'neutral';
4
+ confidence: number;
5
+ summary: string;
6
+ ts: string;
7
+ }
8
+ export interface PostRecord {
9
+ platform: string;
10
+ url: string;
11
+ text: string;
12
+ referencesAssets?: string[];
13
+ ts: string;
14
+ }
15
+ export interface BudgetEnvelope {
16
+ dailyCapUsd: number;
17
+ spentTodayUsd: number;
18
+ date: string;
19
+ }
20
+ export interface NarrativeState {
21
+ watchlist: string[];
22
+ recentSignals: SignalRecord[];
23
+ recentPosts: PostRecord[];
24
+ budget: BudgetEnvelope;
25
+ }
26
+ export declare function loadNarrative(): NarrativeState;
27
+ export declare function saveNarrative(s: NarrativeState): void;
28
+ export declare function updateNarrative(patch: Partial<NarrativeState>): NarrativeState;
29
+ export declare function addSignal(signal: SignalRecord): void;
30
+ export declare function addPost(post: PostRecord): void;
@@ -0,0 +1,69 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ const STORE_DIR = path.join(os.homedir(), '.blockrun');
5
+ const STATE_PATH = path.join(STORE_DIR, 'narrative.json');
6
+ const MAX_ENTRIES = 50;
7
+ let loaded = false;
8
+ let state;
9
+ function today() {
10
+ return new Date().toISOString().slice(0, 10);
11
+ }
12
+ function defaults() {
13
+ return {
14
+ watchlist: [],
15
+ recentSignals: [],
16
+ recentPosts: [],
17
+ budget: { dailyCapUsd: 10, spentTodayUsd: 0, date: today() },
18
+ };
19
+ }
20
+ export function loadNarrative() {
21
+ if (loaded)
22
+ return state;
23
+ fs.mkdirSync(STORE_DIR, { recursive: true });
24
+ if (fs.existsSync(STATE_PATH)) {
25
+ try {
26
+ state = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
27
+ }
28
+ catch {
29
+ state = defaults();
30
+ }
31
+ }
32
+ else {
33
+ state = defaults();
34
+ }
35
+ if (state.budget.date !== today()) {
36
+ state.budget.spentTodayUsd = 0;
37
+ state.budget.date = today();
38
+ }
39
+ loaded = true;
40
+ return state;
41
+ }
42
+ export function saveNarrative(s) {
43
+ fs.mkdirSync(STORE_DIR, { recursive: true });
44
+ fs.writeFileSync(STATE_PATH, JSON.stringify(s, null, 2) + '\n');
45
+ state = s;
46
+ loaded = true;
47
+ }
48
+ export function updateNarrative(patch) {
49
+ const cur = loadNarrative();
50
+ const merged = { ...cur, ...patch };
51
+ if (patch.recentSignals) {
52
+ merged.recentSignals = [...patch.recentSignals, ...cur.recentSignals].slice(0, MAX_ENTRIES);
53
+ }
54
+ if (patch.recentPosts) {
55
+ merged.recentPosts = [...patch.recentPosts, ...cur.recentPosts].slice(0, MAX_ENTRIES);
56
+ }
57
+ saveNarrative(merged);
58
+ return merged;
59
+ }
60
+ export function addSignal(signal) {
61
+ const cur = loadNarrative();
62
+ cur.recentSignals = [signal, ...cur.recentSignals].slice(0, MAX_ENTRIES);
63
+ saveNarrative(cur);
64
+ }
65
+ export function addPost(post) {
66
+ const cur = loadNarrative();
67
+ cur.recentPosts = [post, ...cur.recentPosts].slice(0, MAX_ENTRIES);
68
+ saveNarrative(cur);
69
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Singleton browser pool for Franklin's social subsystem.
3
+ * Wraps SocialBrowser with idle-timeout lifecycle management so the
4
+ * browser stays warm across sequential social tool calls but shuts
5
+ * down automatically after 5 minutes of inactivity.
6
+ */
7
+ import { SocialBrowser } from './browser.js';
8
+ declare class BrowserPool {
9
+ private browser;
10
+ private idleTimer;
11
+ /**
12
+ * Get a ready-to-use browser instance. If one is already running,
13
+ * reset the idle timer and return it. Otherwise launch a new one.
14
+ */
15
+ getBrowser(): Promise<SocialBrowser>;
16
+ /**
17
+ * Signal that the caller is done with the browser for now.
18
+ * Starts (or resets) the idle timer. When it fires the browser
19
+ * is closed automatically.
20
+ */
21
+ releaseBrowser(): void;
22
+ /**
23
+ * Immediately close the browser and clear the idle timer.
24
+ */
25
+ closeBrowser(): Promise<void>;
26
+ private resetIdleTimer;
27
+ }
28
+ export declare const browserPool: BrowserPool;
29
+ export {};
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Singleton browser pool for Franklin's social subsystem.
3
+ * Wraps SocialBrowser with idle-timeout lifecycle management so the
4
+ * browser stays warm across sequential social tool calls but shuts
5
+ * down automatically after 5 minutes of inactivity.
6
+ */
7
+ import { SocialBrowser } from './browser.js';
8
+ const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
9
+ class BrowserPool {
10
+ browser = null;
11
+ idleTimer = null;
12
+ /**
13
+ * Get a ready-to-use browser instance. If one is already running,
14
+ * reset the idle timer and return it. Otherwise launch a new one.
15
+ */
16
+ async getBrowser() {
17
+ if (this.browser) {
18
+ this.resetIdleTimer();
19
+ return this.browser;
20
+ }
21
+ const browser = new SocialBrowser({ headless: false });
22
+ await browser.launch();
23
+ this.browser = browser;
24
+ this.resetIdleTimer();
25
+ return this.browser;
26
+ }
27
+ /**
28
+ * Signal that the caller is done with the browser for now.
29
+ * Starts (or resets) the idle timer. When it fires the browser
30
+ * is closed automatically.
31
+ */
32
+ releaseBrowser() {
33
+ this.resetIdleTimer();
34
+ }
35
+ /**
36
+ * Immediately close the browser and clear the idle timer.
37
+ */
38
+ async closeBrowser() {
39
+ if (this.idleTimer) {
40
+ clearTimeout(this.idleTimer);
41
+ this.idleTimer = null;
42
+ }
43
+ if (this.browser) {
44
+ await this.browser.close();
45
+ this.browser = null;
46
+ }
47
+ }
48
+ resetIdleTimer() {
49
+ if (this.idleTimer) {
50
+ clearTimeout(this.idleTimer);
51
+ }
52
+ this.idleTimer = setTimeout(async () => {
53
+ await this.closeBrowser();
54
+ }, IDLE_TIMEOUT);
55
+ }
56
+ }
57
+ export const browserPool = new BrowserPool();
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Pre-flight checks before social tools can run.
3
+ * Validates config readiness and browser login state.
4
+ */
5
+ import type { SocialBrowser } from './browser.js';
6
+ /**
7
+ * Verify that social config is ready and the user is logged in to X.
8
+ * Returns the browser instance on success so callers can reuse it.
9
+ */
10
+ export declare function checkSocialReady(): Promise<{
11
+ ready: boolean;
12
+ reason?: string;
13
+ browser?: SocialBrowser;
14
+ }>;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Pre-flight checks before social tools can run.
3
+ * Validates config readiness and browser login state.
4
+ */
5
+ import { loadConfig, isConfigReady } from './config.js';
6
+ import { browserPool } from './browser-pool.js';
7
+ /**
8
+ * Verify that social config is ready and the user is logged in to X.
9
+ * Returns the browser instance on success so callers can reuse it.
10
+ */
11
+ export async function checkSocialReady() {
12
+ const cfg = loadConfig();
13
+ const configStatus = isConfigReady(cfg);
14
+ if (!configStatus.ready) {
15
+ return { ready: false, reason: configStatus.reason };
16
+ }
17
+ const browser = await browserPool.getBrowser();
18
+ await browser.open('https://x.com/home');
19
+ await browser.waitForTimeout(2500);
20
+ const tree = await browser.snapshot();
21
+ if (!tree.includes(cfg.x.login_detection)) {
22
+ browserPool.releaseBrowser();
23
+ return { ready: false, reason: 'Not logged in to X. Run: franklin social login x' };
24
+ }
25
+ return { ready: true, browser };
26
+ }
@@ -13,6 +13,7 @@
13
13
  * Every browser interaction uses argv-based Playwright calls — zero shell
14
14
  * injection surface even if the LLM emits `$(rm -rf /)` in reply text.
15
15
  */
16
+ import { SocialBrowser } from './browser.js';
16
17
  import type { SocialConfig } from './config.js';
17
18
  import type { Chain } from '../config.js';
18
19
  export interface RunOptions {
@@ -44,3 +45,10 @@ export interface CandidatePost {
44
45
  * and processes every visible candidate until the daily target is hit.
45
46
  */
46
47
  export declare function runX(opts: RunOptions): Promise<RunResult>;
48
+ /**
49
+ * Post a reply to the currently-open tweet page.
50
+ * Locates the reply textbox, types the reply (paragraphs joined with
51
+ * Enter+Enter), clicks the reply button, confirms the "Your post was sent"
52
+ * banner.
53
+ */
54
+ export declare function postReply(browser: SocialBrowser, reply: string): Promise<void>;