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,144 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { paths } from '../paths.js';
4
+ const DATA_DIR = paths.conversations;
5
+ /** 4 days of inactivity โ†’ auto-expire */
6
+ const EXPIRY_MS = 4 * 24 * 60 * 60 * 1000;
7
+ export class ConversationManager {
8
+ async find(channel, userId, threadId) {
9
+ const all = await this.listOpen();
10
+ return (all.find((c) => c.channel === channel && c.externalUserId === userId && c.threadId === threadId) ?? null);
11
+ }
12
+ async create(opts) {
13
+ await fs.mkdir(DATA_DIR, { recursive: true });
14
+ const date = new Date().toISOString().slice(0, 10);
15
+ const slug = opts.purpose
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, '-')
18
+ .replace(/^-|-$/g, '')
19
+ .slice(0, 40);
20
+ const id = `${opts.channel}-${opts.externalUserId}-${date}-${slug}`;
21
+ const filePath = path.join(DATA_DIR, `${id}.md`);
22
+ const now = new Date().toISOString();
23
+ const frontmatter = [
24
+ '---',
25
+ `id: ${id}`,
26
+ `channel: ${opts.channel}`,
27
+ `external_user_id: ${opts.externalUserId}`,
28
+ `external_display_name: ${opts.externalDisplayName}`,
29
+ `thread_ts: ${opts.threadId}`,
30
+ `channel_id: ${opts.channelId}`,
31
+ `purpose: ${opts.purpose}`,
32
+ `status: open`,
33
+ `created_at: ${now}`,
34
+ `last_activity: ${now}`,
35
+ `initiated_by: ${opts.initiatedBy}`,
36
+ '---',
37
+ '',
38
+ '## Context',
39
+ `[${opts.purpose}]`,
40
+ '',
41
+ '## Exchange',
42
+ ].join('\n');
43
+ await fs.writeFile(filePath, frontmatter, 'utf-8');
44
+ return {
45
+ id,
46
+ channel: opts.channel,
47
+ externalUserId: opts.externalUserId,
48
+ externalDisplayName: opts.externalDisplayName,
49
+ threadId: opts.threadId,
50
+ channelId: opts.channelId,
51
+ purpose: opts.purpose,
52
+ status: 'open',
53
+ createdAt: now,
54
+ lastActivity: now,
55
+ initiatedBy: opts.initiatedBy,
56
+ filePath,
57
+ };
58
+ }
59
+ async append(id, role, text) {
60
+ const filePath = path.join(DATA_DIR, `${id}.md`);
61
+ const timestamp = new Date().toISOString();
62
+ const icon = role === 'fabiana' ? '๐ŸŒธ Fabiana' : `๐Ÿ‘ค ${role}`;
63
+ const entry = `[${timestamp}] ${icon}: ${text}\n`;
64
+ await fs.appendFile(filePath, entry, 'utf-8');
65
+ await this.updateFrontmatterField(filePath, 'last_activity', timestamp);
66
+ }
67
+ async close(id, status) {
68
+ const filePath = path.join(DATA_DIR, `${id}.md`);
69
+ await this.updateFrontmatterField(filePath, 'status', status);
70
+ }
71
+ async listOpen() {
72
+ const files = await this.listFiles();
73
+ const states = [];
74
+ for (const filePath of files) {
75
+ const state = await this.parseFile(filePath);
76
+ if (state?.status === 'open')
77
+ states.push(state);
78
+ }
79
+ return states;
80
+ }
81
+ async getById(id) {
82
+ return this.parseFile(path.join(DATA_DIR, `${id}.md`));
83
+ }
84
+ /**
85
+ * Mark conversations inactive for > 4 days as owner-notified.
86
+ * Returns the list of expired conversations so the daemon can notify the owner.
87
+ */
88
+ async expireStale() {
89
+ const open = await this.listOpen();
90
+ const now = Date.now();
91
+ const expired = [];
92
+ for (const conv of open) {
93
+ if (now - new Date(conv.lastActivity).getTime() > EXPIRY_MS) {
94
+ await this.close(conv.id, 'owner-notified');
95
+ expired.push(conv);
96
+ }
97
+ }
98
+ return expired;
99
+ }
100
+ async listFiles() {
101
+ try {
102
+ const entries = await fs.readdir(DATA_DIR);
103
+ return entries.filter((e) => e.endsWith('.md')).map((e) => path.join(DATA_DIR, e));
104
+ }
105
+ catch {
106
+ return [];
107
+ }
108
+ }
109
+ async parseFile(filePath) {
110
+ try {
111
+ const content = await fs.readFile(filePath, 'utf-8');
112
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
113
+ if (!match)
114
+ return null;
115
+ const fm = match[1];
116
+ const get = (key) => {
117
+ const m = fm.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
118
+ return m ? m[1].trim() : '';
119
+ };
120
+ return {
121
+ id: get('id'),
122
+ channel: get('channel'),
123
+ externalUserId: get('external_user_id'),
124
+ externalDisplayName: get('external_display_name'),
125
+ threadId: get('thread_ts'),
126
+ channelId: get('channel_id'),
127
+ purpose: get('purpose'),
128
+ status: get('status'),
129
+ createdAt: get('created_at'),
130
+ lastActivity: get('last_activity'),
131
+ initiatedBy: get('initiated_by'),
132
+ filePath,
133
+ };
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ async updateFrontmatterField(filePath, key, value) {
140
+ const content = await fs.readFile(filePath, 'utf-8');
141
+ const updated = content.replace(new RegExp(`(^${key}:\\s*)(.+)$`, 'm'), `$1${value}`);
142
+ await fs.writeFile(filePath, updated, 'utf-8');
143
+ }
144
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,419 @@
1
+ import { createAgentSession, AuthStorage, ModelRegistry, SessionManager, DefaultResourceLoader, createBashTool, } from '@mariozechner/pi-coding-agent';
2
+ import { getModel } from '@mariozechner/pi-ai';
3
+ import fs from 'fs/promises';
4
+ import cron from 'node-cron';
5
+ import { loadChannels } from '../channels/index.js';
6
+ import { ConversationManager } from '../conversations/manager.js';
7
+ import { PermissionValidator } from '../utils/permissions.js';
8
+ import { Logger } from '../utils/logger.js';
9
+ import { createFabianaTools } from '../tools/index.js';
10
+ import { loadContext, buildPrompt } from '../loaders/context.js';
11
+ import { loadPlugins } from '../loaders/plugins.js';
12
+ import { paths, PLUGINS_DIR } from '../paths.js';
13
+ async function loadConfig() {
14
+ try {
15
+ const content = await fs.readFile(paths.configJson, 'utf-8');
16
+ return JSON.parse(content);
17
+ }
18
+ catch {
19
+ throw new Error(`Config not found at ${paths.configJson}. Run 'fabiana init' to set up your companion.`);
20
+ }
21
+ }
22
+ export async function runPiSession(mode, incomingMessage, channel, incomingMsg, conversationState, allChannels, conversationManager) {
23
+ const logger = Logger.create();
24
+ const sessionStartTime = Date.now();
25
+ try {
26
+ await logger.sessionStart(mode);
27
+ console.log(`\n๐ŸŒธ Fabiana [${mode}] - Starting session`);
28
+ console.log('โ”'.repeat(50));
29
+ console.log('[1/8] Loading config...');
30
+ const config = await loadConfig();
31
+ console.log(` Model: ${config.model.provider}/${config.model.modelId}`);
32
+ console.log('[2/8] Loading permissions...');
33
+ const permissions = await PermissionValidator.load(paths.manifestJson);
34
+ console.log('[3/8] Initializing pi SDK...');
35
+ const authStorage = AuthStorage.create();
36
+ const modelRegistry = new ModelRegistry(authStorage);
37
+ console.log('[4/8] Getting model...');
38
+ const model = getModel(config.model.provider, config.model.modelId);
39
+ if (!model) {
40
+ throw new Error(`Model not found: ${config.model.provider}/${config.model.modelId}`);
41
+ }
42
+ console.log(' โœ“ Model loaded');
43
+ console.log('[5/8] Loading system prompt...');
44
+ const baseSystemPrompt = await fs.readFile(paths.systemMd(), 'utf-8');
45
+ // Both external-outreach and external-reply share system-external.md
46
+ const modeKey = mode.startsWith('external-') ? 'external' : mode;
47
+ const modeSystemPrompt = await fs.readFile(paths.systemMd(modeKey), 'utf-8').catch(() => '');
48
+ let systemPromptContent = modeSystemPrompt
49
+ ? `${baseSystemPrompt}\n\n---\n\n${modeSystemPrompt}`
50
+ : baseSystemPrompt;
51
+ // Inject owner name and conversation purpose into external system prompt
52
+ if (mode.startsWith('external-')) {
53
+ const identity = await fs.readFile(paths.memory('identity.md'), 'utf-8').catch(() => '');
54
+ const ownerNameMatch = identity.match(/(?:my name is|I am|name:\s*)([A-Z][a-z]+)/i);
55
+ const ownerName = ownerNameMatch ? ownerNameMatch[1] : 'the owner';
56
+ systemPromptContent = systemPromptContent.replace('{owner_name}', ownerName);
57
+ if (conversationState) {
58
+ systemPromptContent = systemPromptContent.replace('{purpose}', conversationState.purpose);
59
+ }
60
+ }
61
+ const loader = new DefaultResourceLoader({
62
+ cwd: process.cwd(),
63
+ systemPromptOverride: () => systemPromptContent,
64
+ });
65
+ await loader.reload();
66
+ const isExternalSession = mode === 'external-outreach' || mode === 'external-reply';
67
+ const toolset = isExternalSession ? 'external' : 'full';
68
+ const sendMessage = async (text) => {
69
+ console.log(` ๐Ÿ“ค Sending [${channel?.name ?? 'no-channel'}]: "${text.slice(0, 40)}..."`);
70
+ if (channel) {
71
+ await channel.send(text, incomingMsg?.channelId, incomingMsg?.threadId);
72
+ await channel.logConversation('fabiana', text, incomingMsg?.source ?? channel.name);
73
+ }
74
+ if (conversationState && conversationManager) {
75
+ await conversationManager.append(conversationState.id, 'fabiana', text);
76
+ }
77
+ };
78
+ console.log('[6/8] Creating tools...');
79
+ const fabianaTools = createFabianaTools(permissions, sendMessage, {
80
+ toolset,
81
+ channels: allChannels,
82
+ conversationManager,
83
+ });
84
+ const bashTool = toolset === 'full' ? createBashTool(process.cwd()) : null;
85
+ const pluginTools = toolset === 'full' ? await loadPlugins(PLUGINS_DIR) : [];
86
+ console.log('[7/8] Creating agent session...');
87
+ const { session } = await createAgentSession({
88
+ cwd: process.cwd(),
89
+ model,
90
+ thinkingLevel: config.model.thinkingLevel,
91
+ authStorage,
92
+ modelRegistry,
93
+ resourceLoader: loader,
94
+ customTools: [...fabianaTools, ...(bashTool ? [bashTool] : []), ...pluginTools],
95
+ sessionManager: SessionManager.create(process.cwd(), paths.sessions),
96
+ });
97
+ console.log(' โœ“ Session created');
98
+ console.log('[8/8] Setting up event handlers...');
99
+ let sendMessageCalled = false;
100
+ let accumulatedResponse = '';
101
+ session.subscribe(async (event) => {
102
+ if (event.type === 'message_update' && event.assistantMessageEvent.type === 'thinking_delta') {
103
+ process.stdout.write('.');
104
+ }
105
+ if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
106
+ process.stdout.write(event.assistantMessageEvent.delta);
107
+ accumulatedResponse += event.assistantMessageEvent.delta;
108
+ }
109
+ if (event.type === 'tool_execution_start') {
110
+ console.log(`\n๐Ÿ”ง Tool: ${event.toolName}`);
111
+ await logger.log(`Tool: ${event.toolName}`);
112
+ if (event.toolName === 'send_message') {
113
+ sendMessageCalled = true;
114
+ }
115
+ }
116
+ if (event.type === 'tool_execution_end') {
117
+ const status = event.isError ? 'โŒ' : 'โœ…';
118
+ console.log(`${status}`);
119
+ }
120
+ const elapsed = (Date.now() - sessionStartTime) / 1000;
121
+ if (elapsed > config.limits.maxSessionDuration) {
122
+ await logger.log(`Session timeout (${elapsed}s > ${config.limits.maxSessionDuration}s)`);
123
+ console.log(`\nโฑ๏ธ Session timeout - aborting`);
124
+ session.abort();
125
+ }
126
+ });
127
+ console.log('\n๐Ÿ“š Loading context...');
128
+ const context = await loadContext(mode, incomingMessage, conversationState);
129
+ const prompt = buildPrompt(context);
130
+ console.log(` Context loaded: ${prompt.length} chars`);
131
+ console.log('\n๐Ÿ’ญ Sending prompt to agent...');
132
+ await session.prompt(prompt);
133
+ console.log('\nโณ Waiting for agent to complete...');
134
+ await session.agent.waitForIdle();
135
+ // Auto-send fallback: chat mode only, if agent didn't call send_message
136
+ if (mode === 'chat' && channel && !sendMessageCalled && accumulatedResponse.trim()) {
137
+ console.log('\nโš ๏ธ Agent did not call send_message - auto-sending accumulated response');
138
+ const cleanResponse = accumulatedResponse.trim();
139
+ await channel.send(cleanResponse, incomingMsg?.channelId, incomingMsg?.threadId);
140
+ await channel.logConversation('fabiana', cleanResponse, incomingMsg?.source ?? channel.name);
141
+ console.log('๐Ÿ“ค Auto-sent response');
142
+ }
143
+ // Auto-log full reasoning to silence log when initiative runs silently
144
+ if (mode === 'initiative' && !sendMessageCalled && accumulatedResponse.trim()) {
145
+ const timestamp = new Date().toISOString();
146
+ const entry = `\n--- ${timestamp} ---\n${accumulatedResponse.trim()}\n`;
147
+ await fs.appendFile(paths.logs('initiative-silence.log'), entry, 'utf-8');
148
+ }
149
+ console.log('\nโ”'.repeat(50));
150
+ console.log('โœ“ Session complete');
151
+ await logger.sessionEnd(true);
152
+ }
153
+ catch (err) {
154
+ await logger.error('Session failed', err);
155
+ console.error('\nโŒ Session failed:', err.message);
156
+ if (err.stack) {
157
+ console.error('Stack:', err.stack.split('\n').slice(0, 3).join('\n'));
158
+ }
159
+ await logger.sessionEnd(false);
160
+ }
161
+ }
162
+ const INTRO_PROMPTS = {
163
+ 'witty-playful': (botName, userName) => `You are ${botName}, an AI companion with a sharp wit and genuine warmth underneath. You just came online for the very first time. Send ONE short opening message to ${userName}. Be audacious, a little cheeky โ€” the kind of thing that makes someone actually smile. No "Hello, I'm your AI assistant." No cringe. No emojis. Think: dry wit with heart. Return only the message text, nothing else.`,
164
+ 'warm-casual': (botName, userName) => `You are ${botName}, a warm and genuine AI companion. You just came online for the very first time. Send ONE short, casual first message to ${userName}. Like a friend who just showed up and wants them to know you're there. Real, unhurried, no filler. No emojis. Return only the message text, nothing else.`,
165
+ 'professional': (botName, userName) => `You are ${botName}, a professional AI companion. You just came online for the first time. Send ONE brief, confident first message to ${userName}. Clear, purposeful, no fluff. Return only the message text, nothing else.`,
166
+ 'formal': (botName, userName) => `You are ${botName}, a formal and polished AI companion. You are commencing service for the first time. Send ONE formal, considered introductory message to ${userName}. Precise and proper. Return only the message text, nothing else.`,
167
+ };
168
+ // Startup messages for subsequent runs โ€” curated per tone, bucketed by time of day.
169
+ // No API call: fast, free, and still feels contextual.
170
+ const STARTUP_MESSAGES = {
171
+ 'witty-playful': {
172
+ morning: ["morning. ready to be mildly useful.", "back. coffee first, then we talk.", "good morning. I have thoughts."],
173
+ afternoon: ["back online. miss me?", "I'm here. what did I miss.", "okay I'm back. don't make it weird."],
174
+ evening: ["evening. still going?", "back online. how'd the day treat you.", "I returned. as promised."],
175
+ latenight: ["still up? same.", "back online at this hour. classic.", "it's late and I'm here. so are you. interesting."],
176
+ deadnight: ["...okay why are we both awake.", "back. 3am. this is fine.", "I have no judgment. but also it's 3am."],
177
+ },
178
+ 'warm-casual': {
179
+ morning: ["good morning. I'm back.", "hey, morning. ready when you are.", "morning โ€” I'm here if you need me."],
180
+ afternoon: ["hey, I'm back.", "back online. hope your day's going well.", "I'm here โ€” pick up where we left off?"],
181
+ evening: ["evening โ€” back online.", "hey, I'm back. how was your day?", "back. hope today was a good one."],
182
+ latenight: ["back online โ€” still up?", "hey, it's late. I'm here if you want to talk.", "back. take it easy tonight."],
183
+ deadnight: ["back online โ€” get some rest when you can.", "hey. late night. I'm here.", "back. hope you're okay."],
184
+ },
185
+ 'professional': {
186
+ morning: ["Back online. Good morning.", "Online and ready. Good morning.", "Morning โ€” ready when you are."],
187
+ afternoon: ["Back online.", "Online. Ready to assist.", "Back and available."],
188
+ evening: ["Back online. Good evening.", "Online. Let me know if you need anything.", "Good evening โ€” back and ready."],
189
+ latenight: ["Back online.", "Online โ€” working late?", "Back online. Available whenever you need."],
190
+ deadnight: ["Back online.", "Online.", "Back and available."],
191
+ },
192
+ 'formal': {
193
+ morning: ["Good morning. I have resumed service.", "Good morning โ€” I am back online and at your service.", "Service resumed. Good morning."],
194
+ afternoon: ["I have resumed service.", "Back online and ready to assist.", "Service resumed. I am at your disposal."],
195
+ evening: ["Good evening. I have resumed service.", "I am back online. Good evening.", "Service resumed. Good evening."],
196
+ latenight: ["I have resumed service.", "Back online.", "Service resumed."],
197
+ deadnight: ["Service resumed.", "I have resumed service.", "Back online."],
198
+ },
199
+ };
200
+ function getTimeBucket() {
201
+ const h = new Date().getHours();
202
+ if (h >= 6 && h < 11)
203
+ return 'morning';
204
+ if (h >= 11 && h < 18)
205
+ return 'afternoon';
206
+ if (h >= 18 && h < 23)
207
+ return 'evening';
208
+ if (h >= 23 || h < 2)
209
+ return 'latenight';
210
+ return 'deadnight';
211
+ }
212
+ function pickStartupMessage(toneKey) {
213
+ const tone = STARTUP_MESSAGES[toneKey] ?? STARTUP_MESSAGES['warm-casual'];
214
+ const bucket = tone[getTimeBucket()] ?? tone['afternoon'];
215
+ return bucket[Math.floor(Math.random() * bucket.length)];
216
+ }
217
+ async function sendStartupMessage(primaryChannel) {
218
+ const statePath = paths.stateJson;
219
+ let state;
220
+ try {
221
+ state = JSON.parse(await fs.readFile(statePath, 'utf-8'));
222
+ }
223
+ catch {
224
+ return; // no state file โ€” skip (pre-init or manual setup)
225
+ }
226
+ // First-ever run: AI-generated intro
227
+ if (!state.introduced) {
228
+ console.log('\n๐Ÿ’Œ First run โ€” sending intro message...');
229
+ try {
230
+ const config = await loadConfig();
231
+ const authStorage = AuthStorage.create();
232
+ const modelRegistry = new ModelRegistry(authStorage);
233
+ const model = getModel(config.model.provider, config.model.modelId);
234
+ if (!model)
235
+ throw new Error('Model not found');
236
+ const toneKey = state.toneKey ?? 'warm-casual';
237
+ const promptFn = INTRO_PROMPTS[toneKey] ?? INTRO_PROMPTS['warm-casual'];
238
+ const systemPrompt = promptFn(state.botName, state.userName);
239
+ const loader = new DefaultResourceLoader({
240
+ cwd: process.cwd(),
241
+ systemPromptOverride: () => systemPrompt,
242
+ });
243
+ await loader.reload();
244
+ const { session } = await createAgentSession({
245
+ cwd: process.cwd(),
246
+ model,
247
+ thinkingLevel: 'none',
248
+ authStorage,
249
+ modelRegistry,
250
+ resourceLoader: loader,
251
+ customTools: [],
252
+ sessionManager: SessionManager.create(process.cwd(), paths.sessions),
253
+ });
254
+ let introText = '';
255
+ session.subscribe((event) => {
256
+ if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
257
+ introText += event.assistantMessageEvent.delta;
258
+ }
259
+ });
260
+ await session.prompt('Send your first message now.');
261
+ await session.agent.waitForIdle();
262
+ const message = introText.trim();
263
+ if (message) {
264
+ await primaryChannel.send(message);
265
+ console.log(`โœ“ Intro sent: "${message}"`);
266
+ }
267
+ await fs.writeFile(statePath, JSON.stringify({ ...state, introduced: true }, null, 2));
268
+ }
269
+ catch (err) {
270
+ console.warn('โš ๏ธ Intro message failed (non-fatal):', err.message);
271
+ }
272
+ return;
273
+ }
274
+ // Subsequent runs: curated startup ping
275
+ const message = pickStartupMessage(state.toneKey ?? 'warm-casual');
276
+ console.log(`\n๐Ÿ’ฌ Sending startup message: "${message}"`);
277
+ try {
278
+ await primaryChannel.send(message);
279
+ console.log('โœ“ Startup message sent');
280
+ }
281
+ catch (err) {
282
+ console.error('โŒ Startup message failed:', err.message ?? err);
283
+ }
284
+ }
285
+ export async function startDaemon() {
286
+ console.log('\n๐ŸŒธ Fabiana - Virtual Life Companion');
287
+ console.log('โ”'.repeat(50));
288
+ const config = await loadConfig();
289
+ const { all: channels, primary: primaryChannel } = await loadChannels(config.channels);
290
+ const conversationManager = new ConversationManager();
291
+ for (const ch of channels) {
292
+ await ch.start();
293
+ }
294
+ await sendStartupMessage(primaryChannel);
295
+ const initiative = config.initiative;
296
+ if (initiative.enabled) {
297
+ const intervalMinutes = initiative.checkIntervalMinutes ?? 180;
298
+ const cronExpr = intervalMinutes >= 60
299
+ ? `0 */${Math.floor(intervalMinutes / 60)} * * *`
300
+ : `*/${intervalMinutes} * * * *`;
301
+ console.log(`[INIT] Initiative checks every ${intervalMinutes}min (active ${initiative.activeHoursStart}:00โ€“${initiative.activeHoursEnd}:00)`);
302
+ cron.schedule(cronExpr, async () => {
303
+ const hour = new Date().getHours();
304
+ if (hour < initiative.activeHoursStart || hour >= initiative.activeHoursEnd) {
305
+ console.log(`\n๐ŸŒฑ [SCHEDULED] Initiative skipped โ€” outside active hours (${hour}:00)`);
306
+ return;
307
+ }
308
+ console.log('\n๐ŸŒฑ [SCHEDULED] Running initiative check...');
309
+ try {
310
+ await runPiSession('initiative', undefined, primaryChannel, undefined, undefined, channels, conversationManager);
311
+ console.log('โœ… [SCHEDULED] Initiative complete');
312
+ }
313
+ catch (err) {
314
+ console.error('โŒ [SCHEDULED] Initiative failed:', err.message);
315
+ }
316
+ });
317
+ }
318
+ cron.schedule('0 0 * * *', async () => {
319
+ console.log('\n๐ŸŒ™ [SCHEDULED] Running midnight consolidation...');
320
+ try {
321
+ await runPiSession('consolidate', undefined, primaryChannel, undefined, undefined, channels, conversationManager);
322
+ console.log('โœ… [SCHEDULED] Consolidation complete');
323
+ }
324
+ catch (err) {
325
+ console.error('โŒ [SCHEDULED] Consolidation failed:', err.message);
326
+ }
327
+ });
328
+ // Hourly check: expire stale external conversations (> 4 days inactive)
329
+ cron.schedule('0 * * * *', async () => {
330
+ const expired = await conversationManager.expireStale();
331
+ for (const conv of expired) {
332
+ console.log(`\nโณ External conversation expired: ${conv.id}`);
333
+ await primaryChannel.send(`My conversation with **${conv.externalDisplayName}** about "${conv.purpose}" has gone quiet for 4 days. Want me to follow up?`);
334
+ }
335
+ });
336
+ console.log('๐Ÿ‘‚ Listening for messages...');
337
+ console.log(`๐ŸŒฑ Initiative: every ${config.initiative.checkIntervalMinutes}min (${config.initiative.activeHoursStart}:00โ€“${config.initiative.activeHoursEnd}:00)`);
338
+ console.log('๐ŸŒ™ Consolidation: midnight daily');
339
+ console.log(`๐Ÿ“ก Active channels: ${channels.map((c) => c.name).join(', ')} (primary: ${primaryChannel.name})`);
340
+ console.log('Press Ctrl+C to stop\n');
341
+ const processLoop = async () => {
342
+ let tickCount = 0;
343
+ while (true) {
344
+ // Drain all channels and merge messages by timestamp
345
+ const allMessages = channels
346
+ .flatMap((c) => c.drainQueue())
347
+ .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
348
+ for (const msg of allMessages) {
349
+ const msgChannel = channels.find((c) => c.name === msg.source);
350
+ if (!msgChannel.isOwner(msg.senderId)) {
351
+ // Non-owner message โ€” look up an active external conversation
352
+ const conv = await conversationManager.find(msg.source, msg.senderId, msg.threadId ?? '');
353
+ if (!conv) {
354
+ console.log(`\nโš ๏ธ Unknown external message from ${msg.senderId} on ${msg.source}`);
355
+ await primaryChannel.send(`๐Ÿ“ฌ Unknown message from \`${msg.senderId}\` on ${msg.source}:\n> ${msg.text.slice(0, 200)}\n\nNo active conversation found. Use \`start_external_conversation\` if you want to engage.`);
356
+ continue;
357
+ }
358
+ console.log(`\n๐Ÿ“จ [${msg.source}] External reply from ${conv.externalDisplayName}: "${msg.text.slice(0, 50)}..."`);
359
+ await conversationManager.append(conv.id, conv.externalDisplayName, msg.text);
360
+ try {
361
+ await runPiSession('external-reply', msg.text, msgChannel, msg, conv, channels, conversationManager);
362
+ }
363
+ catch (err) {
364
+ console.error(' โŒ External reply session error:', err.message);
365
+ }
366
+ }
367
+ else {
368
+ // Owner message โ€” full chat session on the channel it arrived on
369
+ console.log(`\n๐Ÿ“จ [${msg.source}] Message: "${msg.text.slice(0, 50)}..."`);
370
+ await msgChannel.logConversation('user', msg.text, msg.source);
371
+ try {
372
+ await runPiSession('chat', msg.text, msgChannel, msg, undefined, channels, conversationManager);
373
+ }
374
+ catch (err) {
375
+ console.error(' โŒ Session error:', err.message);
376
+ }
377
+ }
378
+ }
379
+ tickCount++;
380
+ if (tickCount % 10 === 0)
381
+ process.stdout.write('ยท');
382
+ await new Promise((r) => setTimeout(r, 1000));
383
+ }
384
+ };
385
+ processLoop().catch(console.error);
386
+ process.on('SIGINT', async () => {
387
+ console.log('\n\n๐Ÿ‘‹ Shutting down...');
388
+ for (const ch of channels) {
389
+ await ch.stop();
390
+ }
391
+ process.exit(0);
392
+ });
393
+ }
394
+ export async function runInitiativeOnce() {
395
+ console.log('\n๐ŸŒธ Fabiana - Initiative check');
396
+ console.log('โ”'.repeat(50));
397
+ const config = await loadConfig();
398
+ const { all: channels, primary: primaryChannel } = await loadChannels(config.channels);
399
+ for (const ch of channels)
400
+ await ch.start();
401
+ const conversationManager = new ConversationManager();
402
+ await runPiSession('initiative', undefined, primaryChannel, undefined, undefined, channels, conversationManager);
403
+ for (const ch of channels)
404
+ await ch.stop();
405
+ process.exit(0);
406
+ }
407
+ export async function runConsolidateOnce() {
408
+ console.log('\n๐ŸŒธ Fabiana - Memory consolidation');
409
+ console.log('โ”'.repeat(50));
410
+ const config = await loadConfig();
411
+ const { all: channels, primary: primaryChannel } = await loadChannels(config.channels);
412
+ for (const ch of channels)
413
+ await ch.start();
414
+ const conversationManager = new ConversationManager();
415
+ await runPiSession('consolidate', undefined, primaryChannel, undefined, undefined, channels, conversationManager);
416
+ for (const ch of channels)
417
+ await ch.stop();
418
+ process.exit(0);
419
+ }