@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.
- package/README.md +23 -36
- package/dist/agent/commands.js +1 -1
- package/dist/agent/llm.d.ts +6 -0
- package/dist/agent/llm.js +103 -14
- package/dist/agent/loop.d.ts +9 -0
- package/dist/agent/loop.js +85 -0
- package/dist/agent/think-tag-stripper.d.ts +27 -0
- package/dist/agent/think-tag-stripper.js +75 -0
- package/dist/agent/tokens.js +2 -1
- package/dist/agent/types.d.ts +7 -0
- package/dist/brain/index.d.ts +1 -1
- package/dist/brain/index.js +1 -1
- package/dist/brain/store.d.ts +13 -1
- package/dist/brain/store.js +74 -5
- package/dist/channel/telegram.d.ts +46 -0
- package/dist/channel/telegram.js +367 -0
- package/dist/commands/migrate.d.ts +5 -3
- package/dist/commands/migrate.js +17 -15
- package/dist/commands/stats.js +1 -1
- package/dist/commands/telegram.d.ts +15 -0
- package/dist/commands/telegram.js +95 -0
- package/dist/content/library.js +2 -2
- package/dist/index.js +9 -0
- package/dist/panel/html.js +1 -1
- package/dist/router/index.js +5 -5
- package/dist/session/storage.d.ts +12 -0
- package/dist/session/storage.js +11 -0
- package/dist/social/ai.d.ts +3 -2
- package/dist/social/ai.js +3 -2
- package/dist/stats/insights.d.ts +1 -1
- package/dist/stats/tracker.js +1 -1
- package/dist/tools/content-execute.d.ts +1 -1
- package/dist/tools/content-execute.js +1 -1
- package/dist/tools/index.js +11 -3
- package/dist/tools/memory.d.ts +16 -0
- package/dist/tools/memory.js +86 -0
- package/dist/tools/trading-execute.d.ts +2 -2
- package/dist/tools/trading-execute.js +2 -2
- package/dist/tools/videogen.d.ts +17 -0
- package/dist/tools/videogen.js +237 -0
- package/dist/trading/trade-log.d.ts +2 -2
- package/dist/trading/trade-log.js +2 -2
- package/dist/ui/app.js +38 -3
- package/dist/ui/markdown.d.ts +16 -0
- package/dist/ui/markdown.js +26 -2
- package/package.json +5 -2
package/dist/brain/store.js
CHANGED
|
@@ -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 =
|
|
215
|
+
const observations = (obsByEntity.get(entity.id) ?? [])
|
|
190
216
|
.sort((a, b) => b.confidence - a.confidence)
|
|
191
217
|
.slice(0, 5);
|
|
192
|
-
const relations =
|
|
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
|
|
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
|
|
2
|
+
* franklin migrate — one-click import from existing AI-agent configs.
|
|
3
3
|
*
|
|
4
|
-
* Detects
|
|
5
|
-
*
|
|
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
|
/**
|
package/dist/commands/migrate.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* franklin migrate — one-click import from
|
|
2
|
+
* franklin migrate — one-click import from existing AI-agent configs.
|
|
3
3
|
*
|
|
4
|
-
* Detects
|
|
5
|
-
*
|
|
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
|
-
// ──
|
|
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: '
|
|
71
|
+
sources.push({ name: '~/.claude/', dir: claudeDir, items });
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
|
-
// ──
|
|
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
|
|
78
|
+
// TODO: detect VS Code agent extension data
|
|
77
79
|
if (items.length > 0) {
|
|
78
|
-
sources.push({ name: '
|
|
80
|
+
sources.push({ name: 'VS Code agent extension', dir: clineDir, items });
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
|
-
// ──
|
|
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
|
|
87
|
+
// TODO: detect editor agent data
|
|
86
88
|
if (items.length > 0) {
|
|
87
|
-
sources.push({ name: '
|
|
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
|
-
//
|
|
97
|
-
// Franklin format:
|
|
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
|
|
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('
|
|
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
|
package/dist/commands/stats.js
CHANGED
|
@@ -66,7 +66,7 @@ export function statsCommand(options) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
// Savings comparison
|
|
69
|
-
console.log(chalk.bold('\n 💰 Savings vs
|
|
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 {};
|