@blockrun/franklin 3.8.2 → 3.8.3

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 (46) hide show
  1. package/README.md +23 -36
  2. package/dist/agent/commands.js +1 -1
  3. package/dist/agent/llm.d.ts +6 -0
  4. package/dist/agent/llm.js +103 -14
  5. package/dist/agent/loop.d.ts +9 -0
  6. package/dist/agent/loop.js +85 -0
  7. package/dist/agent/think-tag-stripper.d.ts +27 -0
  8. package/dist/agent/think-tag-stripper.js +75 -0
  9. package/dist/agent/tokens.js +2 -1
  10. package/dist/agent/types.d.ts +7 -0
  11. package/dist/brain/index.d.ts +1 -1
  12. package/dist/brain/index.js +1 -1
  13. package/dist/brain/store.d.ts +13 -1
  14. package/dist/brain/store.js +74 -5
  15. package/dist/channel/telegram.d.ts +46 -0
  16. package/dist/channel/telegram.js +367 -0
  17. package/dist/commands/migrate.d.ts +5 -3
  18. package/dist/commands/migrate.js +17 -15
  19. package/dist/commands/stats.js +1 -1
  20. package/dist/commands/telegram.d.ts +15 -0
  21. package/dist/commands/telegram.js +95 -0
  22. package/dist/content/library.js +2 -2
  23. package/dist/index.js +9 -0
  24. package/dist/panel/html.js +1 -1
  25. package/dist/router/index.js +5 -5
  26. package/dist/session/storage.d.ts +12 -0
  27. package/dist/session/storage.js +11 -0
  28. package/dist/social/ai.d.ts +3 -2
  29. package/dist/social/ai.js +3 -2
  30. package/dist/stats/insights.d.ts +1 -1
  31. package/dist/stats/tracker.js +1 -1
  32. package/dist/tools/content-execute.d.ts +1 -1
  33. package/dist/tools/content-execute.js +1 -1
  34. package/dist/tools/index.js +11 -3
  35. package/dist/tools/memory.d.ts +16 -0
  36. package/dist/tools/memory.js +86 -0
  37. package/dist/tools/trading-execute.d.ts +2 -2
  38. package/dist/tools/trading-execute.js +2 -2
  39. package/dist/tools/videogen.d.ts +17 -0
  40. package/dist/tools/videogen.js +237 -0
  41. package/dist/trading/trade-log.d.ts +2 -2
  42. package/dist/trading/trade-log.js +2 -2
  43. package/dist/ui/app.js +38 -3
  44. package/dist/ui/markdown.d.ts +16 -0
  45. package/dist/ui/markdown.js +26 -2
  46. package/package.json +5 -2
@@ -171,10 +171,10 @@ const MAX_BRAIN_CHARS = 1500;
171
171
  * Build context string for entities mentioned in the conversation.
172
172
  * Returns empty string if no relevant entities found.
173
173
  */
174
- export function buildEntityContext(mentionedNames) {
174
+ export function buildEntityContext(mentionedNames, entitiesCache) {
175
175
  if (mentionedNames.length === 0)
176
176
  return '';
177
- const entities = loadEntities();
177
+ const entities = entitiesCache ?? loadEntities();
178
178
  const matched = [];
179
179
  for (const name of mentionedNames) {
180
180
  const entity = findEntity(entities, name);
@@ -183,13 +183,39 @@ export function buildEntityContext(mentionedNames) {
183
183
  }
184
184
  if (matched.length === 0)
185
185
  return '';
186
+ // Load observations + relations ONCE and index by entity_id / endpoint
187
+ // rather than re-reading the JSONL for each matched entity. With N matches
188
+ // the old path did 2N file reads per turn; this is now 2 reads total.
189
+ const allObs = loadObservations();
190
+ const allRels = loadRelations();
191
+ const obsByEntity = new Map();
192
+ for (const o of allObs) {
193
+ const list = obsByEntity.get(o.entity_id);
194
+ if (list)
195
+ list.push(o);
196
+ else
197
+ obsByEntity.set(o.entity_id, [o]);
198
+ }
199
+ const relsByEntity = new Map();
200
+ for (const r of allRels) {
201
+ const fromList = relsByEntity.get(r.from_id);
202
+ if (fromList)
203
+ fromList.push(r);
204
+ else
205
+ relsByEntity.set(r.from_id, [r]);
206
+ const toList = relsByEntity.get(r.to_id);
207
+ if (toList)
208
+ toList.push(r);
209
+ else
210
+ relsByEntity.set(r.to_id, [r]);
211
+ }
186
212
  const lines = ['# Known Entities'];
187
213
  let chars = lines[0].length;
188
214
  for (const entity of matched) {
189
- const observations = getEntityObservations(entity.id)
215
+ const observations = (obsByEntity.get(entity.id) ?? [])
190
216
  .sort((a, b) => b.confidence - a.confidence)
191
217
  .slice(0, 5);
192
- const relations = getEntityRelations(entity.id);
218
+ const relations = relsByEntity.get(entity.id) ?? [];
193
219
  const header = `\n## ${entity.name} (${entity.type})`;
194
220
  if (chars + header.length > MAX_BRAIN_CHARS)
195
221
  break;
@@ -203,7 +229,8 @@ export function buildEntityContext(mentionedNames) {
203
229
  chars += line.length + 1;
204
230
  }
205
231
  for (const rel of relations.slice(0, 3)) {
206
- const otherEntity = entities.find(e => e.id === (rel.from_id === entity.id ? rel.to_id : rel.from_id));
232
+ const otherId = rel.from_id === entity.id ? rel.to_id : rel.from_id;
233
+ const otherEntity = entities.find(e => e.id === otherId);
207
234
  if (!otherEntity)
208
235
  continue;
209
236
  const line = `- ${rel.type} → ${otherEntity.name}`;
@@ -215,6 +242,48 @@ export function buildEntityContext(mentionedNames) {
215
242
  }
216
243
  return lines.length > 1 ? lines.join('\n') : '';
217
244
  }
245
+ // ─── Mention extraction (for auto-recall) ────────────────────────────────
246
+ /**
247
+ * Scan `text` for occurrences of any known entity's canonical name or alias
248
+ * and return the matched canonical names (deduped, case-preserving).
249
+ * Word-boundary match so "Base" in "Baseline" doesn't match entity "Base".
250
+ *
251
+ * This is the read half of the brain — the agent loop calls this on each
252
+ * user turn to decide which entities to auto-inject into the system prompt.
253
+ *
254
+ * Pass `entities` if the caller already has them loaded to avoid re-reading
255
+ * the JSONL; otherwise we load it ourselves.
256
+ */
257
+ export function extractMentions(text, entities) {
258
+ if (!text)
259
+ return [];
260
+ const pool = entities ?? loadEntities();
261
+ if (pool.length === 0)
262
+ return [];
263
+ const lower = text.toLowerCase();
264
+ const out = new Set();
265
+ for (const e of pool) {
266
+ const candidates = [e.name, ...e.aliases];
267
+ for (const c of candidates) {
268
+ const needle = c.toLowerCase();
269
+ if (needle.length < 2)
270
+ continue;
271
+ // Word boundary: require a non-alphanumeric char (or start/end of string)
272
+ // on each side of the match. Prevents "ai" matching inside "chain".
273
+ const idx = lower.indexOf(needle);
274
+ if (idx === -1)
275
+ continue;
276
+ const before = idx === 0 ? '' : lower[idx - 1];
277
+ const after = idx + needle.length >= lower.length ? '' : lower[idx + needle.length];
278
+ const wordChar = /[a-z0-9_]/;
279
+ if (wordChar.test(before) || wordChar.test(after))
280
+ continue;
281
+ out.add(e.name);
282
+ break; // one match per entity is enough
283
+ }
284
+ }
285
+ return [...out];
286
+ }
218
287
  // ─── Stats ────────────────────────────────────────────────────────────────
219
288
  export function getBrainStats() {
220
289
  return {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Telegram ingress channel — drive Franklin from a Telegram chat.
3
+ *
4
+ * Why this exists: a persistent agent with a wallet is most useful when the
5
+ * owner can reach it from anywhere, not just the laptop it runs on. This
6
+ * module wraps Franklin's `interactiveSession` with a Telegram long-polling
7
+ * loop: inbound text → agent turn → streamed text deltas delivered to the
8
+ * originating chat, chunked to stay under Telegram's 4096-char limit.
9
+ *
10
+ * Security: hard owner lock. Only the Telegram user id listed in
11
+ * `TELEGRAM_OWNER_ID` can talk to the bot. Anyone else gets a polite refusal
12
+ * and their message is dropped — the agent's wallet is real money.
13
+ *
14
+ * Transport: long polling (`getUpdates` with `timeout=25`), not webhook.
15
+ * Works behind NAT and through laptop sleep/wake without needing a public
16
+ * HTTPS endpoint. `node fetch` is the only HTTP dep.
17
+ */
18
+ import type { AgentConfig } from '../agent/types.js';
19
+ export interface TelegramOptions {
20
+ /** Bot token from @BotFather. */
21
+ token: string;
22
+ /** Numeric Telegram user id that's allowed to drive the bot. Required. */
23
+ ownerId: number;
24
+ /** Called with each user-facing log line so the CLI can print them. */
25
+ log?: (line: string) => void;
26
+ }
27
+ /**
28
+ * Split a long agent response into Telegram-sized chunks. Prefers newline
29
+ * boundaries, falls back to hard character split for pathological inputs
30
+ * (e.g. 10 KB of no-newline JSON). Short responses return a single chunk.
31
+ */
32
+ export declare function splitForTelegram(text: string, max?: number): string[];
33
+ /**
34
+ * Progressive flush: given a growing buffer, return `{flush, keep}` where
35
+ * `flush` is ready-to-send text ending at a paragraph boundary and `keep` is
36
+ * the trailing partial to hold until more arrives. Returns `{flush: '',
37
+ * keep: buffer}` if the buffer isn't big enough or has no boundary yet.
38
+ */
39
+ export declare function takeProgressiveChunk(buffer: string, threshold?: number, hardCap?: number): {
40
+ flush: string;
41
+ keep: string;
42
+ };
43
+ /**
44
+ * Start the bot. Resolves only on fatal error; the outer CLI handles SIGINT.
45
+ */
46
+ export declare function runTelegramBot(agentConfig: AgentConfig, opts: TelegramOptions): Promise<void>;
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Telegram ingress channel — drive Franklin from a Telegram chat.
3
+ *
4
+ * Why this exists: a persistent agent with a wallet is most useful when the
5
+ * owner can reach it from anywhere, not just the laptop it runs on. This
6
+ * module wraps Franklin's `interactiveSession` with a Telegram long-polling
7
+ * loop: inbound text → agent turn → streamed text deltas delivered to the
8
+ * originating chat, chunked to stay under Telegram's 4096-char limit.
9
+ *
10
+ * Security: hard owner lock. Only the Telegram user id listed in
11
+ * `TELEGRAM_OWNER_ID` can talk to the bot. Anyone else gets a polite refusal
12
+ * and their message is dropped — the agent's wallet is real money.
13
+ *
14
+ * Transport: long polling (`getUpdates` with `timeout=25`), not webhook.
15
+ * Works behind NAT and through laptop sleep/wake without needing a public
16
+ * HTTPS endpoint. `node fetch` is the only HTTP dep.
17
+ */
18
+ import { setupAgentWallet, setupAgentSolanaWallet } from '@blockrun/llm';
19
+ import { interactiveSession } from '../agent/loop.js';
20
+ import { ModelClient } from '../agent/llm.js';
21
+ import { extractBrainEntities } from '../brain/extract.js';
22
+ import { extractLearnings } from '../learnings/extractor.js';
23
+ const TG_API = 'https://api.telegram.org';
24
+ const POLL_TIMEOUT_SECONDS = 25;
25
+ // Telegram caps messages at 4096 chars; keep a margin so our chunk headers
26
+ // (e.g. "[1/3] ") plus any UTF-16 counting quirks stay inside the limit.
27
+ const CHUNK_MAX = 4000;
28
+ // Progressive flush: send a partial message once the buffer crosses this and
29
+ // hits a paragraph boundary. Tuned so a typical multi-paragraph answer
30
+ // arrives as 2–3 messages instead of one 4000-char wall.
31
+ const PROGRESSIVE_FLUSH_MIN = 1500;
32
+ /**
33
+ * Split a long agent response into Telegram-sized chunks. Prefers newline
34
+ * boundaries, falls back to hard character split for pathological inputs
35
+ * (e.g. 10 KB of no-newline JSON). Short responses return a single chunk.
36
+ */
37
+ export function splitForTelegram(text, max = CHUNK_MAX) {
38
+ if (text.length <= max)
39
+ return [text];
40
+ const chunks = [];
41
+ let remaining = text;
42
+ while (remaining.length > max) {
43
+ const windowEnd = Math.min(max, remaining.length);
44
+ const nlIdx = remaining.lastIndexOf('\n', windowEnd - 1);
45
+ const cut = nlIdx > Math.floor(max * 0.5) ? nlIdx + 1 : windowEnd;
46
+ chunks.push(remaining.slice(0, cut));
47
+ remaining = remaining.slice(cut);
48
+ }
49
+ if (remaining.length > 0)
50
+ chunks.push(remaining);
51
+ return chunks;
52
+ }
53
+ /**
54
+ * Progressive flush: given a growing buffer, return `{flush, keep}` where
55
+ * `flush` is ready-to-send text ending at a paragraph boundary and `keep` is
56
+ * the trailing partial to hold until more arrives. Returns `{flush: '',
57
+ * keep: buffer}` if the buffer isn't big enough or has no boundary yet.
58
+ */
59
+ export function takeProgressiveChunk(buffer, threshold = PROGRESSIVE_FLUSH_MIN, hardCap = CHUNK_MAX) {
60
+ // Hard cap overrides threshold: if we're above the cap we MUST flush
61
+ // something, boundary or not, to avoid a 4096 overrun on final send.
62
+ const mustFlush = buffer.length > hardCap;
63
+ if (!mustFlush && buffer.length < threshold) {
64
+ return { flush: '', keep: buffer };
65
+ }
66
+ // Prefer a paragraph break (double newline) near the threshold.
67
+ const preferPos = buffer.lastIndexOf('\n\n', Math.min(buffer.length, hardCap) - 1);
68
+ if (preferPos > Math.floor(threshold * 0.5)) {
69
+ return { flush: buffer.slice(0, preferPos + 2), keep: buffer.slice(preferPos + 2) };
70
+ }
71
+ // Fall back to any newline.
72
+ const nlPos = buffer.lastIndexOf('\n', Math.min(buffer.length, hardCap) - 1);
73
+ if (nlPos > Math.floor(threshold * 0.5)) {
74
+ return { flush: buffer.slice(0, nlPos + 1), keep: buffer.slice(nlPos + 1) };
75
+ }
76
+ // Must flush but no newline — hard split at hardCap only.
77
+ if (mustFlush) {
78
+ return { flush: buffer.slice(0, hardCap), keep: buffer.slice(hardCap) };
79
+ }
80
+ return { flush: '', keep: buffer };
81
+ }
82
+ /**
83
+ * Start the bot. Resolves only on fatal error; the outer CLI handles SIGINT.
84
+ */
85
+ export async function runTelegramBot(agentConfig, opts) {
86
+ const log = opts.log ?? (() => { });
87
+ const state = {
88
+ offset: 0,
89
+ inputQueue: [],
90
+ inputWaiters: [],
91
+ currentChatId: undefined,
92
+ responseBuffer: '',
93
+ running: true,
94
+ restartRequested: false,
95
+ stoppedBy: undefined,
96
+ };
97
+ // ── Telegram HTTP helpers ────────────────────────────────────────────
98
+ const api = async (method, body) => {
99
+ const res = await fetch(`${TG_API}/bot${opts.token}/${method}`, {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify(body),
103
+ });
104
+ const json = (await res.json());
105
+ if (!json.ok) {
106
+ throw new Error(`Telegram ${method} failed: ${json.description ?? 'unknown'}`);
107
+ }
108
+ return json.result;
109
+ };
110
+ const sendMessage = async (chatId, text) => {
111
+ for (let attempt = 0; attempt < 2; attempt++) {
112
+ try {
113
+ await api('sendMessage', { chat_id: chatId, text });
114
+ return;
115
+ }
116
+ catch (err) {
117
+ if (attempt === 1) {
118
+ log(`[telegram] sendMessage failed: ${err.message}`);
119
+ return;
120
+ }
121
+ await new Promise((r) => setTimeout(r, 2000));
122
+ }
123
+ }
124
+ };
125
+ const sendChunked = async (chatId, text) => {
126
+ const chunks = splitForTelegram(text);
127
+ if (chunks.length === 1) {
128
+ await sendMessage(chatId, chunks[0]);
129
+ return;
130
+ }
131
+ for (let i = 0; i < chunks.length; i++) {
132
+ await sendMessage(chatId, `[${i + 1}/${chunks.length}] ${chunks[i]}`);
133
+ }
134
+ };
135
+ // ── Slash commands (handled by the bot, not the agent) ──────────────
136
+ const handleSlashCommand = async (chatId, text) => {
137
+ const cmd = text.trim().toLowerCase();
138
+ switch (cmd) {
139
+ case '/start':
140
+ case '/help':
141
+ await sendMessage(chatId, 'Franklin bot\n\n' +
142
+ '/new — start a fresh conversation (clears history)\n' +
143
+ '/balance — show wallet USDC balance\n' +
144
+ '/status — show chain, model, and session stats\n' +
145
+ '/help — this message\n\n' +
146
+ 'Any other message is forwarded to the agent.');
147
+ return true;
148
+ case '/new':
149
+ state.restartRequested = true;
150
+ // Drain any pending input and wake the session so it unwinds.
151
+ state.inputQueue.length = 0;
152
+ {
153
+ const waiters = state.inputWaiters.splice(0);
154
+ for (const w of waiters)
155
+ w(null);
156
+ }
157
+ await sendMessage(chatId, '🔄 Starting a new conversation…');
158
+ return true;
159
+ case '/balance': {
160
+ try {
161
+ if (agentConfig.chain === 'solana') {
162
+ const c = await setupAgentSolanaWallet({ silent: true });
163
+ const addr = await c.getWalletAddress();
164
+ const bal = await c.getBalance();
165
+ await sendMessage(chatId, `Chain: solana\nWallet: ${addr}\nBalance: $${bal.toFixed(2)} USDC`);
166
+ }
167
+ else {
168
+ const c = setupAgentWallet({ silent: true });
169
+ const addr = c.getWalletAddress();
170
+ const bal = await c.getBalance();
171
+ await sendMessage(chatId, `Chain: base\nWallet: ${addr}\nBalance: $${bal.toFixed(2)} USDC`);
172
+ }
173
+ }
174
+ catch (err) {
175
+ await sendMessage(chatId, `Couldn't fetch balance: ${err.message}`);
176
+ }
177
+ return true;
178
+ }
179
+ case '/status':
180
+ await sendMessage(chatId, `chain: ${agentConfig.chain}\n` +
181
+ `model: ${agentConfig.model}\n` +
182
+ `permission: ${agentConfig.permissionMode ?? 'default'}`);
183
+ return true;
184
+ default:
185
+ return false;
186
+ }
187
+ };
188
+ // ── Input queue (feeds interactiveSession's getUserInput) ────────────
189
+ const enqueueInput = (chatId, text) => {
190
+ state.currentChatId = chatId;
191
+ if (state.inputWaiters.length > 0) {
192
+ const w = state.inputWaiters.shift();
193
+ w(text);
194
+ }
195
+ else {
196
+ state.inputQueue.push(text);
197
+ }
198
+ };
199
+ const waitNextInput = () => {
200
+ if (state.restartRequested)
201
+ return Promise.resolve(null);
202
+ if (state.inputQueue.length > 0) {
203
+ return Promise.resolve(state.inputQueue.shift());
204
+ }
205
+ if (!state.running)
206
+ return Promise.resolve(null);
207
+ return new Promise((resolve) => state.inputWaiters.push(resolve));
208
+ };
209
+ // ── Event sink — progressive flush with a final sweep on turn_done ──
210
+ const flushProgressive = () => {
211
+ if (state.currentChatId === undefined)
212
+ return;
213
+ const { flush, keep } = takeProgressiveChunk(state.responseBuffer);
214
+ if (flush.trim()) {
215
+ const chatId = state.currentChatId;
216
+ state.responseBuffer = keep;
217
+ void sendMessage(chatId, flush.trim());
218
+ }
219
+ };
220
+ const handleEvent = (event) => {
221
+ switch (event.kind) {
222
+ case 'text_delta':
223
+ state.responseBuffer += event.text;
224
+ if (state.responseBuffer.length >= PROGRESSIVE_FLUSH_MIN) {
225
+ flushProgressive();
226
+ }
227
+ break;
228
+ case 'capability_start':
229
+ // Best-effort signal that the agent is working. Flush any buffered
230
+ // text first so the user sees the narrative order correctly.
231
+ if (state.currentChatId !== undefined) {
232
+ if (state.responseBuffer.trim()) {
233
+ const chatId = state.currentChatId;
234
+ const text = state.responseBuffer.trim();
235
+ state.responseBuffer = '';
236
+ void sendMessage(chatId, text);
237
+ }
238
+ void sendMessage(state.currentChatId, `⏳ ${event.name}…`);
239
+ }
240
+ break;
241
+ case 'turn_done': {
242
+ const chatId = state.currentChatId;
243
+ const text = state.responseBuffer.trim();
244
+ state.responseBuffer = '';
245
+ if (chatId !== undefined && text)
246
+ void sendChunked(chatId, text);
247
+ if (event.reason === 'error' && event.error && chatId !== undefined) {
248
+ void sendMessage(chatId, `❌ Error: ${event.error}`);
249
+ }
250
+ break;
251
+ }
252
+ }
253
+ };
254
+ // ── Long-poll loop (runs concurrently with interactiveSession) ──────
255
+ const pollLoop = async () => {
256
+ try {
257
+ const me = await api('getMe', {});
258
+ log(`[telegram] connected as @${me.username ?? '(unknown)'} — owner=${opts.ownerId}`);
259
+ }
260
+ catch (err) {
261
+ state.stoppedBy = err;
262
+ state.running = false;
263
+ const waiters = state.inputWaiters.splice(0);
264
+ for (const w of waiters)
265
+ w(null);
266
+ return;
267
+ }
268
+ while (state.running) {
269
+ let updates = [];
270
+ try {
271
+ updates = await api('getUpdates', {
272
+ offset: state.offset,
273
+ timeout: POLL_TIMEOUT_SECONDS,
274
+ });
275
+ }
276
+ catch (err) {
277
+ log(`[telegram] getUpdates error: ${err.message}`);
278
+ await new Promise((r) => setTimeout(r, 3000));
279
+ continue;
280
+ }
281
+ for (const u of updates) {
282
+ state.offset = u.update_id + 1;
283
+ const msg = u.message;
284
+ if (!msg?.text || !msg.from)
285
+ continue;
286
+ if (msg.from.id !== opts.ownerId) {
287
+ void sendMessage(msg.chat.id, 'Not authorized.');
288
+ log(`[telegram] rejected unauthorized sender id=${msg.from.id} ` +
289
+ `username=@${msg.from.username ?? 'n/a'}`);
290
+ continue;
291
+ }
292
+ log(`[telegram] ← ${msg.text.slice(0, 80)}${msg.text.length > 80 ? '…' : ''}`);
293
+ // Intercept bot slash commands before handing off to the agent.
294
+ if (msg.text.trim().startsWith('/')) {
295
+ state.currentChatId = msg.chat.id;
296
+ const handled = await handleSlashCommand(msg.chat.id, msg.text);
297
+ if (handled)
298
+ continue;
299
+ // Unknown slash command: fall through to agent (which has its own
300
+ // slash handling for /retry, /model, /cost, …).
301
+ }
302
+ enqueueInput(msg.chat.id, msg.text);
303
+ }
304
+ }
305
+ };
306
+ const pollPromise = pollLoop();
307
+ // Shared LLM client used for post-session extraction. Built once so we
308
+ // don't re-create a wallet client for every /new cycle.
309
+ const extractor = new ModelClient({
310
+ apiUrl: agentConfig.apiUrl,
311
+ chain: agentConfig.chain,
312
+ });
313
+ const harvestSession = async (history) => {
314
+ // Match the startCommand gate — very short sessions rarely carry useful
315
+ // entities and the LLM call isn't free. 15s hard cap so extraction can't
316
+ // hang the bot between sessions.
317
+ if (history.length < 4)
318
+ return;
319
+ const sid = `telegram-${new Date().toISOString()}`;
320
+ try {
321
+ await Promise.race([
322
+ Promise.all([
323
+ extractLearnings(history, sid, extractor),
324
+ extractBrainEntities(history, sid, extractor),
325
+ ]),
326
+ new Promise((r) => setTimeout(r, 15_000)),
327
+ ]);
328
+ }
329
+ catch (err) {
330
+ log(`[telegram] post-session extraction failed: ${err.message}`);
331
+ }
332
+ };
333
+ try {
334
+ // Outer session loop: `/new` makes interactiveSession return (waiters
335
+ // drained to null), then we spin up a fresh session so the bot stays
336
+ // live without needing a process restart. After each session ends we
337
+ // run learnings + brain extraction so recall has something to recall.
338
+ //
339
+ // Resume semantics: the FIRST session honors agentConfig.resumeSessionId
340
+ // (set by the CLI command to pick up a prior cross-process session).
341
+ // After `/new` we clear it so the next session is genuinely fresh —
342
+ // otherwise every /new would re-hydrate the same history and defeat
343
+ // the point of the command.
344
+ let firstSession = true;
345
+ while (state.running) {
346
+ state.restartRequested = false;
347
+ if (!firstSession)
348
+ agentConfig.resumeSessionId = undefined;
349
+ firstSession = false;
350
+ const history = await interactiveSession(agentConfig, waitNextInput, handleEvent);
351
+ // Best-effort harvest — never block the next session on extraction.
352
+ void harvestSession(history);
353
+ if (!state.restartRequested)
354
+ break;
355
+ log('[telegram] session reset by /new');
356
+ }
357
+ }
358
+ finally {
359
+ state.running = false;
360
+ const waiters = state.inputWaiters.splice(0);
361
+ for (const w of waiters)
362
+ w(null);
363
+ await pollPromise;
364
+ }
365
+ if (state.stoppedBy)
366
+ throw state.stoppedBy;
367
+ }
@@ -1,8 +1,10 @@
1
1
  /**
2
- * franklin migrate — one-click import from other AI coding agents.
2
+ * franklin migrate — one-click import from existing AI-agent configs.
3
3
  *
4
- * Detects installed tools (Claude Code, Cline, Cursor, etc.),
5
- * shows what can be migrated, and imports with user confirmation.
4
+ * Detects standard config locations on disk (`~/.claude/`, VS Code extension
5
+ * storage, `~/Library/Application Support/` editor dirs) and imports what's
6
+ * there with user confirmation. Recognizes tools by their config layout,
7
+ * not by brand.
6
8
  */
7
9
  export declare function migrateCommand(): Promise<void>;
8
10
  /**
@@ -1,8 +1,10 @@
1
1
  /**
2
- * franklin migrate — one-click import from other AI coding agents.
2
+ * franklin migrate — one-click import from existing AI-agent configs.
3
3
  *
4
- * Detects installed tools (Claude Code, Cline, Cursor, etc.),
5
- * shows what can be migrated, and imports with user confirmation.
4
+ * Detects standard config locations on disk (`~/.claude/`, VS Code extension
5
+ * storage, `~/Library/Application Support/` editor dirs) and imports what's
6
+ * there with user confirmation. Recognizes tools by their config layout,
7
+ * not by brand.
6
8
  */
7
9
  import fs from 'node:fs';
8
10
  import path from 'node:path';
@@ -13,7 +15,7 @@ import { BLOCKRUN_DIR } from '../config.js';
13
15
  function detectSources() {
14
16
  const sources = [];
15
17
  const home = os.homedir();
16
- // ── Claude Code ──
18
+ // ── `~/.claude/` config dir (used by several agent CLIs) ──
17
19
  const claudeDir = path.join(home, '.claude');
18
20
  if (fs.existsSync(claudeDir)) {
19
21
  const items = [];
@@ -66,25 +68,25 @@ function detectSources() {
66
68
  }
67
69
  }
68
70
  if (items.length > 0) {
69
- sources.push({ name: 'Claude Code', dir: claudeDir, items });
71
+ sources.push({ name: '~/.claude/', dir: claudeDir, items });
70
72
  }
71
73
  }
72
- // ── Cline / OpenClaw ──
74
+ // ── VS Code agent extension storage ──
73
75
  const clineDir = path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev');
74
76
  if (fs.existsSync(clineDir)) {
75
77
  const items = [];
76
- // TODO: detect Cline data
78
+ // TODO: detect VS Code agent extension data
77
79
  if (items.length > 0) {
78
- sources.push({ name: 'Cline', dir: clineDir, items });
80
+ sources.push({ name: 'VS Code agent extension', dir: clineDir, items });
79
81
  }
80
82
  }
81
- // ── Cursor ──
83
+ // ── ~/Library/Application Support editor agent ──
82
84
  const cursorDir = path.join(home, 'Library', 'Application Support', 'Cursor');
83
85
  if (fs.existsSync(cursorDir)) {
84
86
  const items = [];
85
- // TODO: detect Cursor data
87
+ // TODO: detect editor agent data
86
88
  if (items.length > 0) {
87
- sources.push({ name: 'Cursor', dir: cursorDir, items });
89
+ sources.push({ name: 'editor agent config', dir: cursorDir, items });
88
90
  }
89
91
  }
90
92
  return sources;
@@ -93,8 +95,8 @@ function detectSources() {
93
95
  function migrateMcp(source) {
94
96
  const target = path.join(BLOCKRUN_DIR, 'mcp.json');
95
97
  const raw = JSON.parse(fs.readFileSync(source, 'utf-8'));
96
- // Claude Code format: { mcpServers: { name: { command, args, env } } }
97
- // Franklin format: { mcpServers: { name: { transport, command, args, label } } }
98
+ // Source format: { mcpServers: { name: { command, args, env } } }
99
+ // Franklin format: { mcpServers: { name: { transport, command, args, label } } }
98
100
  const servers = {};
99
101
  const skipped = [];
100
102
  if (raw.mcpServers) {
@@ -178,7 +180,7 @@ function migrateInstructions(source) {
178
180
  learning: text.slice(0, 200),
179
181
  category: 'other',
180
182
  confidence: 0.8,
181
- source_session: 'migrate:claude-code',
183
+ source_session: 'migrate:dot-claude',
182
184
  created_at: now,
183
185
  last_confirmed: now,
184
186
  times_confirmed: 1,
@@ -338,7 +340,7 @@ export async function migrateCommand() {
338
340
  const sources = detectSources();
339
341
  if (sources.length === 0) {
340
342
  console.log(chalk.dim(' No other AI tools detected. Nothing to migrate.\n'));
341
- console.log(chalk.dim(' Supported: Claude Code, Cline, Cursor\n'));
343
+ console.log(chalk.dim(' Looked for: ~/.claude/, VS Code agent extension, editor agent configs\n'));
342
344
  return;
343
345
  }
344
346
  // Show what was found
@@ -66,7 +66,7 @@ export function statsCommand(options) {
66
66
  }
67
67
  }
68
68
  // Savings comparison
69
- console.log(chalk.bold('\n 💰 Savings vs Claude Opus\n'));
69
+ console.log(chalk.bold('\n 💰 Savings vs Opus-tier baseline\n'));
70
70
  if (opusCost > 0) {
71
71
  console.log(` Opus equivalent: ${chalk.gray('$' + opusCost.toFixed(2))}`);
72
72
  console.log(` Your actual cost:${chalk.green(' $' + stats.totalCostUsd.toFixed(2))}`);
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `franklin telegram` — start the Telegram ingress bot.
3
+ *
4
+ * Designed to run on a server / always-on laptop. Reads the bot token and
5
+ * owner id from env (or falls back to ~/.blockrun/config). Uses trust-mode
6
+ * permissions because the operator is remote — there's no terminal prompt
7
+ * they can answer per tool call. The owner lock in `runTelegramBot` is the
8
+ * real security boundary.
9
+ */
10
+ interface TelegramCommandOptions {
11
+ model?: string;
12
+ debug?: boolean;
13
+ }
14
+ export declare function telegramCommand(opts: TelegramCommandOptions): Promise<void>;
15
+ export {};