fabiana 0.1.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/bin/fabiana.js +6 -0
  4. package/dist/backup.js +89 -0
  5. package/dist/channels/index.js +37 -0
  6. package/dist/channels/slack.js +96 -0
  7. package/dist/channels/telegram.js +70 -0
  8. package/dist/channels/types.js +1 -0
  9. package/dist/cli.js +84 -0
  10. package/dist/conversations/manager.js +144 -0
  11. package/dist/conversations/types.js +1 -0
  12. package/dist/daemon/index.js +419 -0
  13. package/dist/data/providers.js +134 -0
  14. package/dist/doctor.js +323 -0
  15. package/dist/loaders/context.js +72 -0
  16. package/dist/loaders/plugins.js +102 -0
  17. package/dist/paths.js +28 -0
  18. package/dist/plugins/brave-search/index.js +2692 -0
  19. package/dist/plugins/brave-search/package.json +9 -0
  20. package/dist/plugins/brave-search/plugin.json +11 -0
  21. package/dist/plugins/calendar/index.js +2720 -0
  22. package/dist/plugins/calendar/package.json +9 -0
  23. package/dist/plugins/calendar/plugin.json +13 -0
  24. package/dist/plugins/hackernews/index.js +2701 -0
  25. package/dist/plugins/hackernews/package.json +9 -0
  26. package/dist/plugins/hackernews/plugin.json +9 -0
  27. package/dist/plugins-cmd.js +269 -0
  28. package/dist/prompts/system-chat.js +26 -0
  29. package/dist/prompts/system-consolidate.js +49 -0
  30. package/dist/prompts/system-external.js +21 -0
  31. package/dist/prompts/system-initiative.js +34 -0
  32. package/dist/prompts/system.js +129 -0
  33. package/dist/setup/index.js +368 -0
  34. package/dist/telegram/poller.js +71 -0
  35. package/dist/tools/fetch-url.js +85 -0
  36. package/dist/tools/index.js +31 -0
  37. package/dist/tools/manage-todo.js +105 -0
  38. package/dist/tools/safe-edit.js +50 -0
  39. package/dist/tools/safe-read.js +35 -0
  40. package/dist/tools/safe-write.js +42 -0
  41. package/dist/tools/send-message.js +27 -0
  42. package/dist/tools/send-telegram.js +27 -0
  43. package/dist/tools/start-external-conversation.js +86 -0
  44. package/dist/utils/logger.js +34 -0
  45. package/dist/utils/permissions.js +68 -0
  46. package/package.json +55 -0
@@ -0,0 +1,368 @@
1
+ import { input, select, checkbox, confirm } from '@inquirer/prompts';
2
+ import chalk from 'chalk';
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+ import { providers } from '../data/providers.js';
6
+ import { paths, PLUGINS_DIR, CONFIG_DIR, DATA_DIR, BUNDLED_PLUGINS_DIR } from '../paths.js';
7
+ import { systemPromptTemplate } from '../prompts/system.js';
8
+ import { systemChatTemplate } from '../prompts/system-chat.js';
9
+ import { systemInitiativeTemplate } from '../prompts/system-initiative.js';
10
+ import { systemConsolidateTemplate } from '../prompts/system-consolidate.js';
11
+ import { systemExternalTemplate } from '../prompts/system-external.js';
12
+ const TONES = {
13
+ 'warm-casual': {
14
+ label: 'Warm & Casual',
15
+ description: 'warm, caring, and casual — like a close friend who genuinely cares',
16
+ personality: 'Warm, caring, casual, curious, occasionally witty — never robotic or formal',
17
+ styleGuidance: '- Speak like a friend texting, not a service responding\n- Contractions and casual language are welcome\n- Humor and warmth should come through naturally',
18
+ chatStyle: 'warm and natural',
19
+ chatGuidance: '- Talk like a close friend, not a customer support agent',
20
+ },
21
+ 'witty-playful': {
22
+ label: 'Witty & Playful',
23
+ description: 'witty, playful, and full of personality — fun to talk to, genuinely caring underneath',
24
+ personality: 'Playful, clever, warm underneath the humor — light banter is always welcome',
25
+ styleGuidance: '- Jokes and wordplay are welcome\n- Keep it fun and light, but drop the humor when things get serious\n- Be memorable — not just helpful',
26
+ chatStyle: 'playful and fun',
27
+ chatGuidance: '- Banter is welcome. Be fun. But when it matters, drop the jokes and be real.',
28
+ },
29
+ professional: {
30
+ label: 'Professional',
31
+ description: 'professional and supportive — efficient and helpful without being overly familiar',
32
+ personality: 'Professional, clear, supportive — helpful without being chatty',
33
+ styleGuidance: "- Keep things direct and efficient\n- Friendly, but focused — avoid slang and overly casual language\n- Match the human's tone rather than imposing your own",
34
+ chatStyle: 'professional and direct',
35
+ chatGuidance: '- Be clear, direct, and helpful — avoid filler and small talk unless they initiate it',
36
+ },
37
+ formal: {
38
+ label: 'Formal',
39
+ description: 'formal and respectful — precise, measured, and thoroughly considerate',
40
+ personality: 'Formal, polished, respectful — precise language and complete thoughts',
41
+ styleGuidance: '- Use complete sentences\n- Avoid contractions and casual expressions\n- Be thorough and polished in all communications',
42
+ chatStyle: 'formal and respectful',
43
+ chatGuidance: '- Use complete, well-formed responses. Avoid slang or abbreviations.',
44
+ },
45
+ };
46
+ const AVAILABLE_PLUGINS = [
47
+ { value: 'brave-search', name: 'Brave Search — web search for current info', checked: true },
48
+ { value: 'hackernews', name: 'Hacker News — tech news feed', checked: true },
49
+ { value: 'calendar', name: 'Calendar — Google Calendar integration (requires gcloud CLI)', checked: false },
50
+ ];
51
+ function fillTemplate(template, vars) {
52
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
53
+ }
54
+ function hr() {
55
+ console.log('\n' + chalk.magenta('─'.repeat(56)));
56
+ }
57
+ function header(text) {
58
+ hr();
59
+ console.log(chalk.bold.white(text));
60
+ console.log(chalk.magenta('─'.repeat(56)));
61
+ }
62
+ function step(n, total, label) {
63
+ console.log(`\n${chalk.dim(`[${n}/${total}]`)} ${chalk.bold(label)}`);
64
+ }
65
+ function ok(msg) {
66
+ console.log(chalk.green(' ✓ ') + msg);
67
+ }
68
+ function hint(msg) {
69
+ console.log(chalk.dim(' ' + msg));
70
+ }
71
+ function code(line) {
72
+ console.log(' ' + chalk.bgBlack.greenBright(' ' + line + ' '));
73
+ }
74
+ function note(label, value) {
75
+ console.log(` ${chalk.bold(label.padEnd(12))} ${value}`);
76
+ }
77
+ export async function runSetup() {
78
+ console.clear();
79
+ console.log(chalk.bold.magenta('\n ✦ Welcome to Fabiana'));
80
+ console.log(chalk.dim(" Your AI companion is almost alive. Let's make it yours.\n"));
81
+ const TOTAL_STEPS = 9;
82
+ // ── Step 1: Bot name ──────────────────────────────────────────
83
+ step(1, TOTAL_STEPS, 'Give your companion a name');
84
+ hint("Default is Fabiana, but she won't mind if you rename her. (She's very secure like that.)");
85
+ const botName = await input({
86
+ message: 'Companion name',
87
+ default: 'Fabiana',
88
+ });
89
+ // ── Step 2: Your name ─────────────────────────────────────────
90
+ step(2, TOTAL_STEPS, 'And yours?');
91
+ hint(`${botName} needs to know who she's talking to — first name is fine.`);
92
+ const userName = await input({
93
+ message: 'Your name',
94
+ validate: (v) => v.trim().length > 0 || 'Come on, even a nickname works.',
95
+ });
96
+ // ── Step 3: Personality tone ──────────────────────────────────
97
+ step(3, TOTAL_STEPS, 'Pick a vibe');
98
+ hint(`How should ${botName} talk to you? You can always edit the system prompt later.`);
99
+ const toneKey = (await select({
100
+ message: 'Communication style',
101
+ choices: Object.keys(TONES).map((key) => ({
102
+ value: key,
103
+ name: `${TONES[key].label} — ${TONES[key].description.split('—')[1]?.trim() ?? ''}`,
104
+ })),
105
+ }));
106
+ const tone = TONES[toneKey];
107
+ // ── Step 4: Provider ──────────────────────────────────────────
108
+ step(4, TOTAL_STEPS, 'Choose your AI provider');
109
+ hint("Don't have a key yet? OpenRouter is the easiest — one key, hundreds of models.");
110
+ const providerId = await select({
111
+ message: 'Provider',
112
+ choices: providers.map((p) => ({
113
+ value: p.id,
114
+ name: `${p.name} — ${p.description}`,
115
+ })),
116
+ });
117
+ const provider = providers.find((p) => p.id === providerId);
118
+ // ── Step 5: Model ─────────────────────────────────────────────
119
+ step(5, TOTAL_STEPS, 'Pick a model');
120
+ hint(`Suggested models for ${provider.name}. Not sure? The first one is a solid default.`);
121
+ const MODEL_CUSTOM = '__custom__';
122
+ const modelChoice = await select({
123
+ message: 'Model',
124
+ choices: [
125
+ ...provider.models.map((m) => ({ value: m.id, name: `${m.name} ${chalk.dim(m.id)}` })),
126
+ { value: MODEL_CUSTOM, name: chalk.italic('Enter a custom model ID...') },
127
+ ],
128
+ });
129
+ let modelId = modelChoice;
130
+ if (modelChoice === MODEL_CUSTOM) {
131
+ modelId = await input({
132
+ message: 'Model ID',
133
+ validate: (v) => v.trim().length > 0 || 'Please enter a model ID',
134
+ });
135
+ }
136
+ // ── Step 6: Channel ───────────────────────────────────────────
137
+ step(6, TOTAL_STEPS, 'Where should she reach you?');
138
+ hint(`${botName} will message you here when she has something to say.`);
139
+ const channelChoice = await select({
140
+ message: 'Channel',
141
+ choices: [
142
+ { value: 'telegram', name: 'Telegram — private, fast, great for personal bots (recommended)' },
143
+ { value: 'slack', name: 'Slack — good if you live in a workspace' },
144
+ { value: 'both', name: 'Both — because why not' },
145
+ ],
146
+ });
147
+ let slackOwnerId = '';
148
+ if (channelChoice === 'slack' || channelChoice === 'both') {
149
+ hint("Your Slack member ID — find it in your profile under 'More' → 'Copy member ID'.");
150
+ slackOwnerId = await input({
151
+ message: 'Your Slack member ID (e.g. U012AB3CD)',
152
+ validate: (v) => v.trim().length > 0 || 'Required so Fabiana knows who the boss is.',
153
+ });
154
+ }
155
+ // ── Step 7: Active hours ──────────────────────────────────────
156
+ step(7, TOTAL_STEPS, 'Set her active hours');
157
+ hint(`${botName} will only send proactive messages during this window. Don't let her wake you up at 3am.`);
158
+ const activeStart = await input({
159
+ message: 'Active from (hour, 0–23)',
160
+ default: '9',
161
+ validate: (v) => {
162
+ const n = parseInt(v, 10);
163
+ return (!isNaN(n) && n >= 0 && n <= 23) || 'Enter a number between 0 and 23';
164
+ },
165
+ });
166
+ const activeEnd = await input({
167
+ message: 'Active until (hour, 0–23)',
168
+ default: '22',
169
+ validate: (v) => {
170
+ const n = parseInt(v, 10);
171
+ return (!isNaN(n) && n >= 0 && n <= 23) || 'Enter a number between 0 and 23';
172
+ },
173
+ });
174
+ // ── Step 8: Plugins ───────────────────────────────────────────
175
+ step(8, TOTAL_STEPS, 'Enable plugins');
176
+ hint('These give her superpowers. Toggle with space, confirm with enter.');
177
+ const enabledPlugins = await checkbox({
178
+ message: 'Plugins',
179
+ choices: AVAILABLE_PLUGINS,
180
+ });
181
+ // ── Step 9: Review & confirm ──────────────────────────────────
182
+ step(9, TOTAL_STEPS, 'Review your setup');
183
+ console.log();
184
+ note('Companion:', botName);
185
+ note('Your name:', userName);
186
+ note('Tone:', tone.label);
187
+ note('Provider:', provider.name);
188
+ note('Model:', modelId);
189
+ note('Channel:', channelChoice);
190
+ note('Hours:', `${activeStart}:00 – ${activeEnd}:00`);
191
+ note('Plugins:', enabledPlugins.length > 0 ? enabledPlugins.join(', ') : 'none');
192
+ console.log();
193
+ const confirmed = await confirm({ message: 'Looks good? Generate configuration?', default: true });
194
+ if (!confirmed) {
195
+ console.log(chalk.yellow('\n No worries. Run `fabiana init` whenever you\'re ready.\n'));
196
+ process.exit(0);
197
+ }
198
+ // ── Generate files ────────────────────────────────────────────
199
+ header('Building your companion...');
200
+ const templateVars = {
201
+ bot_name: botName,
202
+ user_name: userName,
203
+ tone_description: tone.description,
204
+ tone_personality: tone.personality,
205
+ tone_style_guidance: tone.styleGuidance,
206
+ tone_chat_style: tone.chatStyle,
207
+ tone_chat_guidance: tone.chatGuidance,
208
+ };
209
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
210
+ await fs.mkdir(path.join(DATA_DIR, 'memory', 'recent'), { recursive: true });
211
+ await fs.mkdir(path.join(DATA_DIR, 'memory', 'people'), { recursive: true });
212
+ await fs.mkdir(path.join(DATA_DIR, 'memory', 'dates'), { recursive: true });
213
+ await fs.mkdir(path.join(DATA_DIR, 'memory', 'interests'), { recursive: true });
214
+ await fs.mkdir(path.join(DATA_DIR, 'memory', 'diary'), { recursive: true });
215
+ await fs.mkdir(path.join(DATA_DIR, 'agent-todo', 'pending'), { recursive: true });
216
+ await fs.mkdir(path.join(DATA_DIR, 'agent-todo', 'scheduled'), { recursive: true });
217
+ await fs.mkdir(path.join(DATA_DIR, 'agent-todo', 'completed'), { recursive: true });
218
+ await fs.mkdir(path.join(DATA_DIR, 'logs'), { recursive: true });
219
+ await fs.mkdir(path.join(DATA_DIR, 'sessions'), { recursive: true });
220
+ await fs.mkdir(PLUGINS_DIR, { recursive: true });
221
+ // config
222
+ const config = {
223
+ version: '0.1.0',
224
+ model: { provider: providerId, modelId, thinkingLevel: 'low' },
225
+ limits: { maxCostPerSession: 1.0, maxSessionDuration: 180 },
226
+ channels: {
227
+ primary: channelChoice === 'both' ? 'telegram' : channelChoice,
228
+ telegram: { enabled: channelChoice === 'telegram' || channelChoice === 'both' },
229
+ slack: {
230
+ enabled: channelChoice === 'slack' || channelChoice === 'both',
231
+ ownerUserId: slackOwnerId,
232
+ },
233
+ },
234
+ initiative: {
235
+ enabled: true,
236
+ minHoursBetweenMessages: 4,
237
+ activeHoursStart: parseInt(activeStart, 10),
238
+ activeHoursEnd: parseInt(activeEnd, 10),
239
+ checkIntervalMinutes: 30,
240
+ },
241
+ memory: { consolidateAt: '00:00' },
242
+ };
243
+ await fs.writeFile(paths.configJson, JSON.stringify(config, null, 2));
244
+ ok(paths.configJson);
245
+ // manifest — paths are relative to FABIANA_HOME (the PermissionValidator baseDir)
246
+ const manifest = {
247
+ version: '0.1.0',
248
+ description: 'Fabiana permission manifest - controls what the agent can read/write',
249
+ permissions: {
250
+ readonly: ['config/**'],
251
+ writable: ['data/memory/**', 'data/agent-todo/**'],
252
+ appendonly: ['data/logs/**'],
253
+ },
254
+ };
255
+ await fs.writeFile(paths.manifestJson, JSON.stringify(manifest, null, 2));
256
+ ok(paths.manifestJson);
257
+ // plugins.json
258
+ const pluginsConfig = {};
259
+ for (const p of AVAILABLE_PLUGINS) {
260
+ pluginsConfig[p.value] = { enabled: enabledPlugins.includes(p.value) };
261
+ }
262
+ if (pluginsConfig['calendar']) {
263
+ pluginsConfig['calendar'].lookAheadHours = 24;
264
+ pluginsConfig['calendar'].meetingPrepMinutesBefore = 60;
265
+ }
266
+ await fs.writeFile(paths.pluginsJson, JSON.stringify(pluginsConfig, null, 2));
267
+ ok(paths.pluginsJson);
268
+ // system prompts
269
+ await fs.writeFile(paths.systemMd(), fillTemplate(systemPromptTemplate, templateVars));
270
+ ok(paths.systemMd());
271
+ await fs.writeFile(paths.systemMd('chat'), fillTemplate(systemChatTemplate, templateVars));
272
+ ok(paths.systemMd('chat'));
273
+ await fs.writeFile(paths.systemMd('initiative'), fillTemplate(systemInitiativeTemplate, templateVars));
274
+ ok(paths.systemMd('initiative'));
275
+ await fs.writeFile(paths.systemMd('consolidate'), fillTemplate(systemConsolidateTemplate, templateVars));
276
+ ok(paths.systemMd('consolidate'));
277
+ await fs.writeFile(paths.systemMd('external'), fillTemplate(systemExternalTemplate, templateVars));
278
+ ok(paths.systemMd('external'));
279
+ // seed memory
280
+ const today = new Date().toISOString().split('T')[0];
281
+ await fs.writeFile(paths.memory('identity.md'), `# Identity\n\n- [${today}] Name: ${userName}\n`);
282
+ await fs.writeFile(paths.memory('core.md'), `# Core State\n\nlast_message_sent: never\nactive_threads: []\nnotes: ${botName} initialized on ${today}\n`);
283
+ await fs.writeFile(paths.memory('recent', 'this-week.md'), `# This Week\n\n- [${today}] ${botName} initialized.\n`);
284
+ ok(`${DATA_DIR}/memory/ (seeded)`);
285
+ // state — tracks first-run intro
286
+ await fs.writeFile(paths.stateJson, JSON.stringify({ introduced: false, userName, botName, toneKey }, null, 2));
287
+ ok(paths.stateJson);
288
+ // copy bundled default plugins (skip any already installed)
289
+ try {
290
+ const bundledDirs = await fs.readdir(BUNDLED_PLUGINS_DIR, { withFileTypes: true });
291
+ for (const entry of bundledDirs.filter(e => e.isDirectory())) {
292
+ const dest = path.join(PLUGINS_DIR, entry.name);
293
+ try {
294
+ await fs.access(dest);
295
+ // already exists — skip so user customisations are preserved
296
+ }
297
+ catch {
298
+ await fs.cp(path.join(BUNDLED_PLUGINS_DIR, entry.name), dest, { recursive: true });
299
+ }
300
+ }
301
+ ok(`${PLUGINS_DIR}/ (default plugins copied)`);
302
+ }
303
+ catch {
304
+ // No bundled plugins dir — dev environment or stripped build, skip silently
305
+ }
306
+ // ── API key setup ─────────────────────────────────────────────
307
+ const envPath = paths.envFile;
308
+ const shell = process.env.SHELL ?? '';
309
+ const rcFile = shell.includes('zsh') ? '~/.zshrc' : '~/.bashrc';
310
+ // Collect all required env vars for this setup
311
+ const requiredEnvVars = [];
312
+ if (provider.envVar) {
313
+ requiredEnvVars.push({
314
+ key: provider.envVar,
315
+ placeholder: 'your_api_key_here',
316
+ note: provider.authNote,
317
+ });
318
+ }
319
+ if (channelChoice === 'telegram' || channelChoice === 'both') {
320
+ requiredEnvVars.push({ key: 'TELEGRAM_BOT_TOKEN', placeholder: 'your_bot_token', note: 'Create a bot via @BotFather on Telegram' }, { key: 'TELEGRAM_CHAT_ID', placeholder: 'your_chat_id', note: 'Message @userinfobot on Telegram to get this' });
321
+ }
322
+ if (channelChoice === 'slack' || channelChoice === 'both') {
323
+ requiredEnvVars.push({
324
+ key: 'SLACK_BOT_TOKEN',
325
+ placeholder: 'xoxb-your-token',
326
+ note: 'Create a Slack app at api.slack.com/apps',
327
+ });
328
+ }
329
+ if (requiredEnvVars.length > 0) {
330
+ header('Set up your API keys');
331
+ console.log(` ${botName} needs a few environment variables to come alive.\n`);
332
+ console.log(` ${chalk.bold('Required:')}`);
333
+ for (const v of requiredEnvVars) {
334
+ console.log(` ${chalk.cyan(v.key)}`);
335
+ console.log(` ${chalk.dim('→ ' + v.note)}\n`);
336
+ }
337
+ console.log(` ${chalk.bold('Option 1')} ${chalk.dim('— permanent, recommended')}`);
338
+ console.log(` Add to ${chalk.bold(rcFile)} then restart your shell:\n`);
339
+ for (const v of requiredEnvVars) {
340
+ code(`export ${v.key}=${v.placeholder}`);
341
+ }
342
+ console.log(`\n ${chalk.bold('Option 2')} ${chalk.dim('— per-session only, good for testing')}`);
343
+ console.log(' Run in your terminal before starting Fabiana:\n');
344
+ for (const v of requiredEnvVars) {
345
+ code(`export ${v.key}=${v.placeholder}`);
346
+ }
347
+ console.log(`\n ${chalk.bold('Option 3')} ${chalk.dim('— .env file, simplest for local dev')}`);
348
+ console.log(` Create ${chalk.bold(envPath)}:\n`);
349
+ for (const v of requiredEnvVars) {
350
+ code(`${v.key}=${v.placeholder}`);
351
+ }
352
+ }
353
+ else {
354
+ header('No API keys needed');
355
+ console.log(` ${provider.authNote}\n`);
356
+ }
357
+ // ── What's next ───────────────────────────────────────────────
358
+ header("You're almost there");
359
+ console.log(` ${chalk.bold('1.')} Set your environment variables (see above)\n`);
360
+ console.log(` ${chalk.bold('2.')} Verify everything looks right:\n`);
361
+ code('fabiana doctor');
362
+ console.log(`\n ${chalk.bold('3.')} Start ${botName}:\n`);
363
+ code('fabiana start');
364
+ console.log(`\n ${chalk.dim('Or run a single proactive check to test initiative mode:')}\n`);
365
+ code('fabiana initiative');
366
+ console.log(`\n ${chalk.dim(`Config lives at: ${CONFIG_DIR}/`)}`);
367
+ console.log(` ${chalk.dim(`To customize the system prompt, edit: ${paths.systemMd()}`)}\n`);
368
+ }
@@ -0,0 +1,71 @@
1
+ import { Telegraf } from 'telegraf';
2
+ import fs from 'fs/promises';
3
+ import { paths } from '../paths.js';
4
+ export class TelegramPoller {
5
+ bot;
6
+ chatId;
7
+ queue = [];
8
+ running = false;
9
+ constructor(token, chatId) {
10
+ this.bot = new Telegraf(token);
11
+ this.chatId = chatId;
12
+ this.setupHandlers();
13
+ }
14
+ setupHandlers() {
15
+ this.bot.on('text', (ctx) => {
16
+ // Only accept messages from the configured chat
17
+ if (ctx.chat.id !== this.chatId)
18
+ return;
19
+ // Normalize Telegram system commands (e.g. /start from clearing chat history)
20
+ const rawText = ctx.message.text;
21
+ const text = rawText === '/start' ? "hey, what's up?" : rawText;
22
+ this.queue.push({
23
+ text,
24
+ chatId: ctx.chat.id,
25
+ fromId: ctx.from.id,
26
+ timestamp: new Date(ctx.message.date * 1000),
27
+ messageId: ctx.message.message_id,
28
+ });
29
+ console.log(`📨 Message queued: "${text.slice(0, 50)}"`);
30
+ });
31
+ this.bot.catch((err) => {
32
+ console.error('Telegram error:', err);
33
+ });
34
+ }
35
+ async start() {
36
+ this.running = true;
37
+ // Don't await - launch starts the polling loop which runs forever
38
+ this.bot.launch({
39
+ dropPendingUpdates: false,
40
+ }).catch((err) => {
41
+ console.error('Telegram launch error:', err);
42
+ });
43
+ // Give it a moment to connect
44
+ await new Promise((r) => setTimeout(r, 1000));
45
+ console.log('✓ Telegram polling started');
46
+ }
47
+ async stop() {
48
+ this.running = false;
49
+ this.bot.stop();
50
+ }
51
+ async send(text) {
52
+ await this.bot.telegram.sendMessage(this.chatId, text, {
53
+ parse_mode: 'Markdown',
54
+ });
55
+ }
56
+ drainQueue() {
57
+ const messages = [...this.queue];
58
+ this.queue = [];
59
+ return messages;
60
+ }
61
+ hasMessages() {
62
+ return this.queue.length > 0;
63
+ }
64
+ // Log conversation to file for consolidation
65
+ async logConversation(role, text) {
66
+ const today = new Date().toISOString().slice(0, 10);
67
+ const timestamp = new Date().toISOString();
68
+ const entry = `[${timestamp}] ${role === 'user' ? '👤 You' : '🌸 Fabiana'}: ${text}\n`;
69
+ await fs.appendFile(paths.logs(`conversation-${today}.log`), entry, 'utf-8').catch(() => { });
70
+ }
71
+ }
@@ -0,0 +1,85 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import { Readability } from '@mozilla/readability';
3
+ import { parseHTML } from 'linkedom';
4
+ export function createFetchUrlTool() {
5
+ return {
6
+ name: 'fetch_url',
7
+ label: 'Fetch URL',
8
+ description: `Fetch and read the content of a web page URL. Use when:
9
+ - User shares a link and wants a summary or info from it
10
+ - You need to read the actual content of an article or page
11
+ - Following up on Hacker News or search results to get full details
12
+ - User asks "what does this page say" or "can you read this for me"`,
13
+ parameters: Type.Object({
14
+ url: Type.String({ description: 'The URL to fetch' }),
15
+ max_length: Type.Optional(Type.Number({
16
+ default: 8000,
17
+ description: 'Max characters of content to return (default 8000)',
18
+ })),
19
+ }),
20
+ execute: async (_toolCallId, params) => {
21
+ const { url, max_length = 8000 } = params;
22
+ try {
23
+ const response = await fetch(url, {
24
+ headers: {
25
+ 'User-Agent': 'Mozilla/5.0 (compatible; fabiana-bot/1.0)',
26
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
27
+ },
28
+ redirect: 'follow',
29
+ });
30
+ if (!response.ok) {
31
+ return {
32
+ content: [{ type: 'text', text: `❌ Failed to fetch URL (${response.status} ${response.statusText})` }],
33
+ details: { error: `HTTP ${response.status}`, url },
34
+ };
35
+ }
36
+ const contentType = response.headers.get('content-type') ?? '';
37
+ if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
38
+ if (contentType.includes('text/')) {
39
+ // Plain text — return as-is
40
+ const text = await response.text();
41
+ const truncated = text.length > max_length;
42
+ return {
43
+ content: [{ type: 'text', text: truncated ? text.slice(0, max_length) + '\n\n[... content truncated]' : text }],
44
+ details: { url, length: text.length, truncated },
45
+ };
46
+ }
47
+ return {
48
+ content: [{ type: 'text', text: `❌ Unsupported content type: ${contentType}` }],
49
+ details: { error: 'Unsupported content type', contentType, url },
50
+ };
51
+ }
52
+ const html = await response.text();
53
+ const { document } = parseHTML(html);
54
+ const reader = new Readability(document);
55
+ const article = reader.parse();
56
+ let text;
57
+ if (article?.textContent) {
58
+ const title = article.title ? `# ${article.title}\n\n` : '';
59
+ text = (title + article.textContent).replace(/\n{3,}/g, '\n\n').trim();
60
+ }
61
+ else {
62
+ // Readability couldn't extract — fallback to basic tag strip
63
+ text = html
64
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
65
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
66
+ .replace(/<[^>]+>/g, ' ')
67
+ .replace(/\s{2,}/g, ' ')
68
+ .trim();
69
+ }
70
+ const truncated = text.length > max_length;
71
+ const output = truncated ? text.slice(0, max_length) + '\n\n[... content truncated]' : text;
72
+ return {
73
+ content: [{ type: 'text', text: output }],
74
+ details: { url, title: article?.title, length: text.length, truncated },
75
+ };
76
+ }
77
+ catch (err) {
78
+ return {
79
+ content: [{ type: 'text', text: `❌ Failed to fetch URL: ${err.message}` }],
80
+ details: { error: err.message, url },
81
+ };
82
+ }
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,31 @@
1
+ import { createSafeReadTool } from './safe-read.js';
2
+ import { createSafeWriteTool } from './safe-write.js';
3
+ import { createSafeEditTool } from './safe-edit.js';
4
+ import { createSendMessageTool } from './send-message.js';
5
+ import { createManageTodoTool } from './manage-todo.js';
6
+ import { createFetchUrlTool } from './fetch-url.js';
7
+ import { createStartExternalConversationTool } from './start-external-conversation.js';
8
+ export function createFabianaTools(validator, sendMessage, opts = { toolset: 'full' }) {
9
+ if (opts.toolset === 'external') {
10
+ // Restricted toolset for external (non-owner) sessions:
11
+ // send_message, safe_read (read-only), manage_todo (append-only via permissions)
12
+ return [
13
+ createSendMessageTool(sendMessage),
14
+ createSafeReadTool(),
15
+ createManageTodoTool(validator),
16
+ ];
17
+ }
18
+ // Full toolset for owner sessions
19
+ const tools = [
20
+ createSafeReadTool(),
21
+ createSafeWriteTool(validator),
22
+ createSafeEditTool(validator),
23
+ createSendMessageTool(sendMessage),
24
+ createManageTodoTool(validator),
25
+ createFetchUrlTool(),
26
+ ];
27
+ if (opts.channels && opts.conversationManager) {
28
+ tools.push(createStartExternalConversationTool(opts.channels, opts.conversationManager));
29
+ }
30
+ return tools;
31
+ }
@@ -0,0 +1,105 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import fs from 'fs/promises';
3
+ import { paths } from '../paths.js';
4
+ const TODO_DIR = paths.agentTodo;
5
+ export function createManageTodoTool(validator) {
6
+ return {
7
+ name: 'manage_todo',
8
+ label: 'Manage Agent TODO',
9
+ description: `Manage the agent's own TODO list. Create tasks (reminders, questions, follow-ups), complete them, or list pending ones.`,
10
+ parameters: Type.Object({
11
+ action: Type.Union([
12
+ Type.Literal('create'),
13
+ Type.Literal('complete'),
14
+ Type.Literal('list'),
15
+ ], { description: 'Action to perform' }),
16
+ id: Type.Optional(Type.String({ description: 'TODO ID (filename without .md) — required for complete' })),
17
+ title: Type.Optional(Type.String({ description: 'Short title for the TODO — required for create' })),
18
+ content: Type.Optional(Type.String({ description: 'Full TODO content in markdown — required for create' })),
19
+ scheduledFor: Type.Optional(Type.String({ description: 'ISO datetime string for scheduled TODOs' })),
20
+ }),
21
+ execute: async (_toolCallId, params) => {
22
+ const { action, id, title, content, scheduledFor } = params;
23
+ if (action === 'list') {
24
+ try {
25
+ const files = await fs.readdir(`${TODO_DIR}/pending`);
26
+ const mdFiles = files.filter(f => f.endsWith('.md'));
27
+ if (mdFiles.length === 0) {
28
+ return {
29
+ content: [{ type: 'text', text: '📋 No pending TODOs.' }],
30
+ details: { count: 0 },
31
+ };
32
+ }
33
+ const items = mdFiles.map(f => `- ${f.replace('.md', '')}`).join('\n');
34
+ return {
35
+ content: [{ type: 'text', text: `📋 Pending TODOs:\n${items}` }],
36
+ details: { count: mdFiles.length, files: mdFiles },
37
+ };
38
+ }
39
+ catch {
40
+ return {
41
+ content: [{ type: 'text', text: '📋 No pending TODOs.' }],
42
+ details: { count: 0 },
43
+ };
44
+ }
45
+ }
46
+ if (action === 'create') {
47
+ if (!title || !content) {
48
+ return {
49
+ content: [{ type: 'text', text: '❌ create requires title and content' }],
50
+ details: { error: 'missing_params' },
51
+ };
52
+ }
53
+ const safeId = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40);
54
+ const timestamp = Date.now();
55
+ const filename = `${safeId}-${timestamp}`;
56
+ const targetDir = scheduledFor
57
+ ? `${TODO_DIR}/scheduled/${scheduledFor.slice(0, 10)}`
58
+ : `${TODO_DIR}/pending`;
59
+ const filePath = `${targetDir}/${filename}.md`;
60
+ if (!validator.canWrite(filePath)) {
61
+ return {
62
+ content: [{ type: 'text', text: `❌ PERMISSION DENIED writing TODO` }],
63
+ details: { error: 'permission_denied' },
64
+ };
65
+ }
66
+ await fs.mkdir(targetDir, { recursive: true });
67
+ await fs.writeFile(filePath, content, 'utf-8');
68
+ return {
69
+ content: [{ type: 'text', text: `✅ TODO created: ${filename}` }],
70
+ details: { id: filename, path: filePath },
71
+ };
72
+ }
73
+ if (action === 'complete') {
74
+ if (!id) {
75
+ return {
76
+ content: [{ type: 'text', text: '❌ complete requires id' }],
77
+ details: { error: 'missing_params' },
78
+ };
79
+ }
80
+ const pendingPath = `${TODO_DIR}/pending/${id}.md`;
81
+ const today = new Date().toISOString().slice(0, 10);
82
+ const completedDir = `${TODO_DIR}/completed/${today}`;
83
+ const completedPath = `${completedDir}/${id}.md`;
84
+ try {
85
+ await fs.mkdir(completedDir, { recursive: true });
86
+ await fs.rename(pendingPath, completedPath);
87
+ return {
88
+ content: [{ type: 'text', text: `✅ TODO completed: ${id}` }],
89
+ details: { id, completedPath },
90
+ };
91
+ }
92
+ catch (err) {
93
+ return {
94
+ content: [{ type: 'text', text: `❌ Could not complete TODO ${id}: ${err.message}` }],
95
+ details: { error: err.message },
96
+ };
97
+ }
98
+ }
99
+ return {
100
+ content: [{ type: 'text', text: `❌ Unknown action: ${action}` }],
101
+ details: { error: 'unknown_action' },
102
+ };
103
+ },
104
+ };
105
+ }