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.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/bin/fabiana.js +6 -0
- package/dist/backup.js +89 -0
- package/dist/channels/index.js +37 -0
- package/dist/channels/slack.js +96 -0
- package/dist/channels/telegram.js +70 -0
- package/dist/channels/types.js +1 -0
- package/dist/cli.js +84 -0
- package/dist/conversations/manager.js +144 -0
- package/dist/conversations/types.js +1 -0
- package/dist/daemon/index.js +419 -0
- package/dist/data/providers.js +134 -0
- package/dist/doctor.js +323 -0
- package/dist/loaders/context.js +72 -0
- package/dist/loaders/plugins.js +102 -0
- package/dist/paths.js +28 -0
- package/dist/plugins/brave-search/index.js +2692 -0
- package/dist/plugins/brave-search/package.json +9 -0
- package/dist/plugins/brave-search/plugin.json +11 -0
- package/dist/plugins/calendar/index.js +2720 -0
- package/dist/plugins/calendar/package.json +9 -0
- package/dist/plugins/calendar/plugin.json +13 -0
- package/dist/plugins/hackernews/index.js +2701 -0
- package/dist/plugins/hackernews/package.json +9 -0
- package/dist/plugins/hackernews/plugin.json +9 -0
- package/dist/plugins-cmd.js +269 -0
- package/dist/prompts/system-chat.js +26 -0
- package/dist/prompts/system-consolidate.js +49 -0
- package/dist/prompts/system-external.js +21 -0
- package/dist/prompts/system-initiative.js +34 -0
- package/dist/prompts/system.js +129 -0
- package/dist/setup/index.js +368 -0
- package/dist/telegram/poller.js +71 -0
- package/dist/tools/fetch-url.js +85 -0
- package/dist/tools/index.js +31 -0
- package/dist/tools/manage-todo.js +105 -0
- package/dist/tools/safe-edit.js +50 -0
- package/dist/tools/safe-read.js +35 -0
- package/dist/tools/safe-write.js +42 -0
- package/dist/tools/send-message.js +27 -0
- package/dist/tools/send-telegram.js +27 -0
- package/dist/tools/start-external-conversation.js +86 -0
- package/dist/utils/logger.js +34 -0
- package/dist/utils/permissions.js +68 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 arifcamp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="fabiana.png" alt="Fabiana" width="180" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">Fabiana</h1>
|
|
6
|
+
<p align="center"><em>Your personal AI companion that actually feels personal</em></p>
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## The pitch
|
|
11
|
+
|
|
12
|
+
Every other AI assistant sits there, waiting for your command, answering like an overly polite receptionist with a forced smile. Fabiana doesn’t wait. She texts you first, asks about your day, and remembers your habits. She has things on her mind, patterns she noticed, stories she thinks you’d enjoy.
|
|
13
|
+
|
|
14
|
+
Fabiana is not your typical obedient worker. She's independent, proactive, and has her own agenda. She's not an assistant who you tried to befriends with. She's a friend who slowly learn to help you with your tasks. The kind who remembers your sister’s name, knows you refuse to schedule meetings before 10am, and will roast you—gently—when you promised to sleep early but it’s 1am and you’re asking her about world news again.
|
|
15
|
+
|
|
16
|
+
No dashboards. No commands to memorize. She just slides into your DM.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## What she does
|
|
21
|
+
|
|
22
|
+
**She messages you first.** She has a schedule and a TODO list she manages herself. She'll reach out when there's something worth saying — not when you remember to ask.
|
|
23
|
+
|
|
24
|
+
**She remembers everything.** Every conversation gets distilled into plain-text memory. Next week she still knows what you're working on, who you mentioned, and what's stressing you out. Next month too.
|
|
25
|
+
|
|
26
|
+
**Her memory is yours.** All data lives in `.fabiana/data/` as plain text files. Read it, edit it, back it up, delete it. No black boxes. No vector embeddings. No vendor lock-in.
|
|
27
|
+
|
|
28
|
+
**She learns new tricks.** Drop a plugin into `plugins/` and she wakes up with a new capability. Web search, calendar, Hacker News — or whatever you build.
|
|
29
|
+
|
|
30
|
+
**She's small enough to trust.** The codebase is intentionally tiny. TypeScript, a handful of dependencies, plain text files. You can read the whole thing in an afternoon.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## How it works
|
|
35
|
+
|
|
36
|
+
Fabiana runs as a background daemon doing three things on a loop:
|
|
37
|
+
|
|
38
|
+
| Mode | What it does |
|
|
39
|
+
|------|-------------|
|
|
40
|
+
| **Chat** | Listens for your Telegram messages and responds |
|
|
41
|
+
| **Initiative** | Checks her TODO list and calendar, decides if there's something worth telling you |
|
|
42
|
+
| **Consolidation** | Every night at midnight, distills the day's conversations into structured memory |
|
|
43
|
+
|
|
44
|
+
She's built on [Pi SDK](https://github.com/mariozechner/pi) — which means she runs on Anthropic, OpenAI, Google Gemini, Groq, Mistral, Amazon Bedrock, and more. [OpenRouter](https://openrouter.ai) is the default because one key gets you 240+ models.
|
|
45
|
+
|
|
46
|
+
### Memory — plain text, always
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
.fabiana/data/memory/
|
|
50
|
+
├── identity.md ← who you are
|
|
51
|
+
├── core.md ← what's happening in your life right now
|
|
52
|
+
├── people/ ← one file per person you mention
|
|
53
|
+
├── interests/topics.md ← what you care about
|
|
54
|
+
├── recent/this-week.md ← short-term context
|
|
55
|
+
└── diary/ ← daily entries (auto-written)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Memory is tiered — hot files load every session, warm files load when relevant, cold files sit in the archive, searchable when needed. She writes and organizes it herself. You can read any of it any time.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
### What you need
|
|
65
|
+
|
|
66
|
+
- **Node.js ≥ 22**
|
|
67
|
+
- A **Telegram bot** — takes 2 minutes via [@BotFather](https://t.me/BotFather)
|
|
68
|
+
- An LLM API key — [OpenRouter](https://openrouter.ai/keys) is the easiest starting point (one key, 240+ models)
|
|
69
|
+
|
|
70
|
+
### Setup
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
git clone https://github.com/your-username/fabiana
|
|
74
|
+
cd fabiana
|
|
75
|
+
npm install
|
|
76
|
+
fabiana init
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`fabiana init` walks you through the whole setup. Or do it manually:
|
|
80
|
+
|
|
81
|
+
Create a `.env` file:
|
|
82
|
+
|
|
83
|
+
```env
|
|
84
|
+
TELEGRAM_BOT_TOKEN=your_token_from_botfather
|
|
85
|
+
TELEGRAM_CHAT_ID=your_chat_id
|
|
86
|
+
|
|
87
|
+
# Pick one (or more)
|
|
88
|
+
OPENROUTER_API_KEY=sk-or-v1-... # OpenRouter (recommended — covers everything)
|
|
89
|
+
ANTHROPIC_API_KEY=... # Direct Anthropic
|
|
90
|
+
OPENAI_API_KEY=... # Direct OpenAI
|
|
91
|
+
GEMINI_API_KEY=... # Direct Google
|
|
92
|
+
|
|
93
|
+
# Optional extras
|
|
94
|
+
BRAVE_API_KEY=... # Web search
|
|
95
|
+
GOOGLE_CALENDAR_EMAIL=your@gmail.com # Calendar awareness
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Getting your Telegram credentials:**
|
|
99
|
+
1. Message [@BotFather](https://t.me/BotFather) → `/newbot` → copy the token
|
|
100
|
+
2. Message [@userinfobot](https://t.me/userinfobot) → copy your numeric ID
|
|
101
|
+
|
|
102
|
+
### Check everything's wired up
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
fabiana doctor
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Verifies your environment, credentials, plugins, and data directories. Fix anything it flags, then:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
fabiana start
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
She'll start listening on Telegram and schedule herself from there.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Commands
|
|
119
|
+
|
|
120
|
+
| Command | What it does |
|
|
121
|
+
|---------|-------------|
|
|
122
|
+
| `fabiana init` | First time? Let's get acquainted |
|
|
123
|
+
| `fabiana start` | Wake her up — she'll take it from there |
|
|
124
|
+
| `fabiana initiative` | Make her think. Just once. (good for testing) |
|
|
125
|
+
| `fabiana consolidate` | Tidy up the mind palace |
|
|
126
|
+
| `fabiana doctor` | Is everything okay in there? Let's check |
|
|
127
|
+
| `fabiana backup` | Save her brain to a zip file |
|
|
128
|
+
| `fabiana restore <file>` | Bring her back from the archive |
|
|
129
|
+
| `fabiana plugins add <user/repo>` | Teach her a new trick from GitHub |
|
|
130
|
+
| `fabiana plugins list` | What can she do? |
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Choosing a model
|
|
135
|
+
|
|
136
|
+
Edit `config.json`:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"model": {
|
|
141
|
+
"provider": "openrouter",
|
|
142
|
+
"modelId": "anthropic/claude-sonnet-4-5",
|
|
143
|
+
"thinkingLevel": "low"
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Popular choices:**
|
|
149
|
+
|
|
150
|
+
| Provider | Model | Notes |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| `openrouter` | `anthropic/claude-sonnet-4-5` | Best quality via OpenRouter |
|
|
153
|
+
| `openrouter` | `google/gemini-2.5-flash` | Fast and cheap |
|
|
154
|
+
| `anthropic` | `claude-sonnet-4-6` | Direct Anthropic |
|
|
155
|
+
| `google` | `gemini-2.5-flash` | Direct Google |
|
|
156
|
+
| `groq` | `llama-3.3-70b-versatile` | Very fast, generous free tier |
|
|
157
|
+
|
|
158
|
+
See [docs/providers.md](docs/providers.md) for the full list.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Optional: Google Calendar
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
npm install -g @mariozechner/gccli
|
|
166
|
+
gccli accounts credentials ~/path/to/oauth-credentials.json
|
|
167
|
+
gccli accounts add your@gmail.com
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Then add `GOOGLE_CALENDAR_EMAIL=your@gmail.com` to `.env`. Now she'll actually know when you have that meeting you keep forgetting.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Optional: Brave Search
|
|
175
|
+
|
|
176
|
+
1. Create a free account at [api-dashboard.search.brave.com](https://api-dashboard.search.brave.com/register)
|
|
177
|
+
2. Grab an API key and add `BRAVE_API_KEY=your_key` to `.env`
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Backup & restore
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
# Save everything
|
|
185
|
+
fabiana backup
|
|
186
|
+
# → fabiana-2026-03-14T09-51-08.tar.gz
|
|
187
|
+
|
|
188
|
+
# Bring it back
|
|
189
|
+
fabiana restore fabiana-2026-03-14T09-51-08.tar.gz
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Memory, diary, conversations — all of it, safely portable.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Plugin development
|
|
197
|
+
|
|
198
|
+
Plugins live in `plugins/` and are auto-discovered at startup. A plugin is just a TypeScript file that exports a tool definition. See [docs/plugins.md](docs/plugins.md) for the full guide.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
*Built with the Pi SDK · For Arif — who wanted a companion, not a chatbot*
|
package/bin/fabiana.js
ADDED
package/dist/backup.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execFile } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import readline from 'readline';
|
|
7
|
+
import { DATA_DIR, FABIANA_HOME } from './paths.js';
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
function makeFilename() {
|
|
10
|
+
const now = new Date();
|
|
11
|
+
const iso = now.toISOString().replace(/:/g, '-').replace(/\..+/, '');
|
|
12
|
+
return `fabiana-${iso}.tar.gz`;
|
|
13
|
+
}
|
|
14
|
+
async function confirm(question) {
|
|
15
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
16
|
+
return new Promise(resolve => {
|
|
17
|
+
rl.question(question, answer => {
|
|
18
|
+
rl.close();
|
|
19
|
+
resolve(answer.trim().toLowerCase() === 'y');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export async function runBackup(options) {
|
|
24
|
+
// Verify data directory exists
|
|
25
|
+
try {
|
|
26
|
+
await fs.access(DATA_DIR);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
console.error(chalk.red(`✗ ${DATA_DIR} not found — nothing to back up`));
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
const filename = options.output ?? makeFilename();
|
|
33
|
+
const outPath = path.resolve(filename);
|
|
34
|
+
console.log(chalk.bold('\nBacking up Fabiana data...'));
|
|
35
|
+
console.log(chalk.dim(` Source: ${DATA_DIR}`));
|
|
36
|
+
console.log(chalk.dim(` Output: ${outPath}`));
|
|
37
|
+
try {
|
|
38
|
+
await execFileAsync('tar', ['-czf', outPath, '-C', FABIANA_HOME, 'data']);
|
|
39
|
+
const stat = await fs.stat(outPath);
|
|
40
|
+
const kb = (stat.size / 1024).toFixed(1);
|
|
41
|
+
console.log(`\n${chalk.green('✓')} Backup saved: ${chalk.cyan(path.basename(outPath))} ${chalk.dim(`(${kb} KB)`)}\n`);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
console.error(chalk.red(`✗ Backup failed: ${e.message}`));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function runRestore(filepath, options) {
|
|
49
|
+
const absPath = path.resolve(filepath);
|
|
50
|
+
// Verify archive exists
|
|
51
|
+
try {
|
|
52
|
+
await fs.access(absPath);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
console.error(chalk.red(`✗ File not found: ${absPath}`));
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
console.log(chalk.bold('\nRestoring Fabiana data...'));
|
|
59
|
+
console.log(chalk.dim(` Archive: ${absPath}`));
|
|
60
|
+
// Warn if data directory already exists
|
|
61
|
+
let dataExists = false;
|
|
62
|
+
try {
|
|
63
|
+
await fs.access(DATA_DIR);
|
|
64
|
+
dataExists = true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// doesn't exist — no conflict
|
|
68
|
+
}
|
|
69
|
+
if (dataExists) {
|
|
70
|
+
console.log(`\n${chalk.yellow('⚠')} ${chalk.yellow(`${DATA_DIR} already exists and will be overwritten.`)}`);
|
|
71
|
+
if (!options.force) {
|
|
72
|
+
const ok = await confirm(' Continue? (y/N) ');
|
|
73
|
+
if (!ok) {
|
|
74
|
+
console.log(chalk.dim(' Restore cancelled.\n'));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
await fs.rm(DATA_DIR, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
await fs.mkdir(FABIANA_HOME, { recursive: true });
|
|
82
|
+
await execFileAsync('tar', ['-xzf', absPath, '-C', FABIANA_HOME]);
|
|
83
|
+
console.log(`\n${chalk.green('✓')} Data restored to ${chalk.cyan(DATA_DIR)}\n`);
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
console.error(chalk.red(`✗ Restore failed: ${e.message}`));
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { TelegramAdapter } from './telegram.js';
|
|
2
|
+
import { SlackAdapter } from './slack.js';
|
|
3
|
+
export async function loadChannels(channels) {
|
|
4
|
+
// Fallback for installs that don't have a channels block yet
|
|
5
|
+
if (!channels) {
|
|
6
|
+
channels = { primary: 'telegram', telegram: { enabled: true } };
|
|
7
|
+
}
|
|
8
|
+
const adapters = [];
|
|
9
|
+
// Telegram — enabled by default if the block exists or there is no channels config
|
|
10
|
+
if (channels.telegram?.enabled !== false) {
|
|
11
|
+
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
12
|
+
const chatId = process.env.TELEGRAM_CHAT_ID
|
|
13
|
+
? parseInt(process.env.TELEGRAM_CHAT_ID)
|
|
14
|
+
: undefined;
|
|
15
|
+
if (!token || !chatId) {
|
|
16
|
+
throw new Error('TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID required for the Telegram channel');
|
|
17
|
+
}
|
|
18
|
+
adapters.push(new TelegramAdapter(token, chatId));
|
|
19
|
+
}
|
|
20
|
+
// Slack — opt-in only
|
|
21
|
+
if (channels.slack?.enabled) {
|
|
22
|
+
const ownerUserId = channels.slack.ownerUserId;
|
|
23
|
+
if (!ownerUserId) {
|
|
24
|
+
throw new Error('channels.slack.ownerUserId is required in config.json when Slack is enabled');
|
|
25
|
+
}
|
|
26
|
+
adapters.push(new SlackAdapter(ownerUserId));
|
|
27
|
+
}
|
|
28
|
+
if (adapters.length === 0) {
|
|
29
|
+
throw new Error('No channels enabled — enable at least one channel in config.json');
|
|
30
|
+
}
|
|
31
|
+
const primaryName = channels.primary ?? adapters[0].name;
|
|
32
|
+
const primary = adapters.find((a) => a.name === primaryName);
|
|
33
|
+
if (!primary) {
|
|
34
|
+
throw new Error(`Primary channel "${primaryName}" is not among enabled channels: ${adapters.map((a) => a.name).join(', ')}`);
|
|
35
|
+
}
|
|
36
|
+
return { all: adapters, primary };
|
|
37
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { paths } from '../paths.js';
|
|
3
|
+
export class SlackAdapter {
|
|
4
|
+
name = 'slack';
|
|
5
|
+
app = null;
|
|
6
|
+
ownerUserId;
|
|
7
|
+
ownerDmChannelId = null;
|
|
8
|
+
queue = [];
|
|
9
|
+
constructor(ownerUserId) {
|
|
10
|
+
this.ownerUserId = ownerUserId;
|
|
11
|
+
}
|
|
12
|
+
async start() {
|
|
13
|
+
const botToken = process.env.SLACK_BOT_TOKEN;
|
|
14
|
+
const appToken = process.env.SLACK_APP_TOKEN;
|
|
15
|
+
if (!botToken || !appToken) {
|
|
16
|
+
throw new Error('SLACK_BOT_TOKEN and SLACK_APP_TOKEN are required for the Slack channel');
|
|
17
|
+
}
|
|
18
|
+
let App;
|
|
19
|
+
try {
|
|
20
|
+
({ App } = await import('@slack/bolt'));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new Error('@slack/bolt is not installed — run: npm install @slack/bolt');
|
|
24
|
+
}
|
|
25
|
+
this.app = new App({
|
|
26
|
+
token: botToken,
|
|
27
|
+
appToken,
|
|
28
|
+
socketMode: true,
|
|
29
|
+
});
|
|
30
|
+
this.app.message(async ({ message }) => {
|
|
31
|
+
// Skip bot messages, edits, and other subtypes
|
|
32
|
+
if (message.subtype)
|
|
33
|
+
return;
|
|
34
|
+
const text = (message.text || '').trim();
|
|
35
|
+
if (!text)
|
|
36
|
+
return;
|
|
37
|
+
this.queue.push({
|
|
38
|
+
text,
|
|
39
|
+
senderId: message.user,
|
|
40
|
+
channelId: message.channel,
|
|
41
|
+
threadId: message.thread_ts || message.ts,
|
|
42
|
+
timestamp: new Date(parseFloat(message.ts) * 1000),
|
|
43
|
+
source: 'slack',
|
|
44
|
+
});
|
|
45
|
+
console.log(`📨 [slack] Message queued: "${text.slice(0, 50)}"`);
|
|
46
|
+
});
|
|
47
|
+
await this.app.start();
|
|
48
|
+
console.log('✓ Slack Socket Mode started');
|
|
49
|
+
}
|
|
50
|
+
async stop() {
|
|
51
|
+
if (this.app)
|
|
52
|
+
await this.app.stop();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Send a message. If channelId is provided, sends there (with optional thread_ts).
|
|
56
|
+
* If omitted, opens/reuses a DM with the owner.
|
|
57
|
+
*/
|
|
58
|
+
async send(text, channelId, threadId) {
|
|
59
|
+
if (!this.app)
|
|
60
|
+
throw new Error('Slack adapter not started');
|
|
61
|
+
const target = channelId ?? (await this.getOwnerDmChannelId());
|
|
62
|
+
await this.app.client.chat.postMessage({
|
|
63
|
+
channel: target,
|
|
64
|
+
text,
|
|
65
|
+
...(threadId ? { thread_ts: threadId } : {}),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
async getOwnerDmChannelId() {
|
|
69
|
+
if (this.ownerDmChannelId)
|
|
70
|
+
return this.ownerDmChannelId;
|
|
71
|
+
const result = await this.app.client.conversations.open({ users: this.ownerUserId });
|
|
72
|
+
this.ownerDmChannelId = result.channel.id;
|
|
73
|
+
return this.ownerDmChannelId;
|
|
74
|
+
}
|
|
75
|
+
drainQueue() {
|
|
76
|
+
const messages = [...this.queue];
|
|
77
|
+
this.queue = [];
|
|
78
|
+
return messages;
|
|
79
|
+
}
|
|
80
|
+
hasMessages() {
|
|
81
|
+
return this.queue.length > 0;
|
|
82
|
+
}
|
|
83
|
+
isOwner(senderId) {
|
|
84
|
+
return senderId === this.ownerUserId;
|
|
85
|
+
}
|
|
86
|
+
async logConversation(role, text, source = 'slack') {
|
|
87
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
88
|
+
const timestamp = new Date().toISOString();
|
|
89
|
+
const entry = `[${timestamp}] [${source}] ${role === 'user' ? '👤 You' : '🌸 Fabiana'}: ${text}\n`;
|
|
90
|
+
await fs.appendFile(paths.logs(`conversation-${today}.log`), entry, 'utf-8').catch(() => { });
|
|
91
|
+
}
|
|
92
|
+
/** Expose the Bolt app for use by the start_external_conversation tool */
|
|
93
|
+
getBoltApp() {
|
|
94
|
+
return this.app;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Telegraf } from 'telegraf';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import { paths } from '../paths.js';
|
|
4
|
+
export class TelegramAdapter {
|
|
5
|
+
name = 'telegram';
|
|
6
|
+
bot;
|
|
7
|
+
chatId;
|
|
8
|
+
queue = [];
|
|
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
|
+
senderId: String(ctx.from.id),
|
|
25
|
+
channelId: String(ctx.chat.id),
|
|
26
|
+
threadId: String(ctx.message.message_id),
|
|
27
|
+
timestamp: new Date(ctx.message.date * 1000),
|
|
28
|
+
source: 'telegram',
|
|
29
|
+
});
|
|
30
|
+
console.log(`📨 [telegram] Message queued: "${text.slice(0, 50)}"`);
|
|
31
|
+
});
|
|
32
|
+
this.bot.catch((err) => {
|
|
33
|
+
console.error('Telegram error:', err);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async start() {
|
|
37
|
+
this.bot.launch({ dropPendingUpdates: false }).catch((err) => {
|
|
38
|
+
console.error('Telegram launch error:', err);
|
|
39
|
+
});
|
|
40
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
41
|
+
console.log('✓ Telegram polling started');
|
|
42
|
+
}
|
|
43
|
+
async stop() {
|
|
44
|
+
this.bot.stop();
|
|
45
|
+
}
|
|
46
|
+
// channelId and threadId are ignored — Telegram always replies to the configured chatId
|
|
47
|
+
async send(text, _channelId, _threadId) {
|
|
48
|
+
await this.bot.telegram.sendMessage(this.chatId, text, {
|
|
49
|
+
parse_mode: 'Markdown',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
drainQueue() {
|
|
53
|
+
const messages = [...this.queue];
|
|
54
|
+
this.queue = [];
|
|
55
|
+
return messages;
|
|
56
|
+
}
|
|
57
|
+
hasMessages() {
|
|
58
|
+
return this.queue.length > 0;
|
|
59
|
+
}
|
|
60
|
+
// All Telegram messages are from the owner — chatId filter already applied
|
|
61
|
+
isOwner(_senderId) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
async logConversation(role, text, source = 'telegram') {
|
|
65
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
66
|
+
const timestamp = new Date().toISOString();
|
|
67
|
+
const entry = `[${timestamp}] [${source}] ${role === 'user' ? '👤 You' : '🌸 Fabiana'}: ${text}\n`;
|
|
68
|
+
await fs.appendFile(paths.logs(`conversation-${today}.log`), entry, 'utf-8').catch(() => { });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { config as dotenvConfig } from 'dotenv';
|
|
2
|
+
import { paths } from './paths.js';
|
|
3
|
+
dotenvConfig({ path: paths.envFile }); // ~/.fabiana/.env (production)
|
|
4
|
+
dotenvConfig(); // .env in cwd (dev fallback)
|
|
5
|
+
import { Command } from 'commander';
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { join, dirname } from 'path';
|
|
9
|
+
import { startDaemon, runInitiativeOnce, runConsolidateOnce } from './daemon/index.js';
|
|
10
|
+
import { runDoctor } from './doctor.js';
|
|
11
|
+
import { runBackup, runRestore } from './backup.js';
|
|
12
|
+
import { pluginsAdd, pluginsList } from './plugins-cmd.js';
|
|
13
|
+
import { runSetup } from './setup/index.js';
|
|
14
|
+
const C = '\x1b[96m'; // cyan — name
|
|
15
|
+
const D = '\x1b[2m'; // dim — subtitle
|
|
16
|
+
const R = '\x1b[0m'; // reset
|
|
17
|
+
function printBanner() {
|
|
18
|
+
let asciiLines = [];
|
|
19
|
+
try {
|
|
20
|
+
const asciiPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'ascii.txt');
|
|
21
|
+
asciiLines = readFileSync(asciiPath, 'utf8').trimEnd().split('\n');
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// ascii.txt not found — skip the big title
|
|
25
|
+
}
|
|
26
|
+
console.log();
|
|
27
|
+
if (asciiLines.length) {
|
|
28
|
+
asciiLines.forEach(line => console.log(`${C}${line}${R}`));
|
|
29
|
+
}
|
|
30
|
+
console.log(`\n${D} AI companion who texts you first.${R}\n`);
|
|
31
|
+
}
|
|
32
|
+
printBanner();
|
|
33
|
+
const program = new Command();
|
|
34
|
+
program
|
|
35
|
+
.name('fabiana')
|
|
36
|
+
.description('She remembers. She reaches out. She cares.')
|
|
37
|
+
.version('0.1.0')
|
|
38
|
+
.addHelpText('after', `
|
|
39
|
+
She's not waiting to be asked. She'll message you first.
|
|
40
|
+
Run \`fabiana start\` and get out of her way.`);
|
|
41
|
+
program
|
|
42
|
+
.command('init')
|
|
43
|
+
.description('First time? Let\'s get acquainted')
|
|
44
|
+
.action(runSetup);
|
|
45
|
+
program
|
|
46
|
+
.command('start', { isDefault: true })
|
|
47
|
+
.description('Wake her up — she\'ll take it from there')
|
|
48
|
+
.action(startDaemon);
|
|
49
|
+
program
|
|
50
|
+
.command('initiative')
|
|
51
|
+
.description('Make her think. Just once. (good for testing)')
|
|
52
|
+
.action(runInitiativeOnce);
|
|
53
|
+
program
|
|
54
|
+
.command('consolidate')
|
|
55
|
+
.description('Tidy up the mind palace')
|
|
56
|
+
.action(runConsolidateOnce);
|
|
57
|
+
program
|
|
58
|
+
.command('doctor')
|
|
59
|
+
.description('Is everything okay in there? Let\'s check')
|
|
60
|
+
.action(runDoctor);
|
|
61
|
+
program
|
|
62
|
+
.command('backup')
|
|
63
|
+
.description('Save her brain to a zip file')
|
|
64
|
+
.option('-o, --output <filename>', 'override output filename')
|
|
65
|
+
.action((opts) => runBackup(opts));
|
|
66
|
+
program
|
|
67
|
+
.command('restore <filepath>')
|
|
68
|
+
.description('Bring her back from the archive')
|
|
69
|
+
.option('-f, --force', 'skip confirmation prompt if data directory exists')
|
|
70
|
+
.action((filepath, opts) => runRestore(filepath, opts));
|
|
71
|
+
const plugins = new Command('plugins').description('Teach her new tricks');
|
|
72
|
+
plugins
|
|
73
|
+
.command('add <repo>')
|
|
74
|
+
.description('Install a plugin from GitHub (format: username/reponame)')
|
|
75
|
+
.action((repo) => pluginsAdd(repo));
|
|
76
|
+
plugins
|
|
77
|
+
.command('list')
|
|
78
|
+
.description('What can she do?')
|
|
79
|
+
.action(pluginsList);
|
|
80
|
+
program.addCommand(plugins);
|
|
81
|
+
program.addHelpCommand(new Command('help')
|
|
82
|
+
.argument('[command]', 'command to show help for')
|
|
83
|
+
.description('Show help for fabiana or a specific command'));
|
|
84
|
+
program.parse();
|