fabiana 1.1.0 → 1.3.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/dist/cli.js CHANGED
@@ -2,12 +2,15 @@ import { config as dotenvConfig } from 'dotenv';
2
2
  import { paths } from './paths.js';
3
3
  dotenvConfig({ path: paths.envFile }); // ~/.fabiana/.env (production)
4
4
  dotenvConfig(); // .env in cwd (dev fallback)
5
+ import { initDb } from './db/init.js';
6
+ initDb();
5
7
  import { Command } from 'commander';
6
8
  import { readFileSync } from 'fs';
7
9
  import { fileURLToPath } from 'url';
8
10
  import { join, dirname } from 'path';
9
11
  import { spawnSync } from 'child_process';
10
- import { startDaemon, runInitiativeOnce, runConsolidateOnce, runSolitudeOnce } from './daemon/index.js';
12
+ import { startDaemon, runInitiativeOnce, runConsolidateOnce, runSolitudeOnce, runPromptPreview } from './daemon/index.js';
13
+ import { runMigration } from './db/migrate-from-files.js';
11
14
  import { runDoctor } from './doctor.js';
12
15
  import { runBackup, runRestore } from './backup.js';
13
16
  import { pluginsAdd, pluginsList } from './plugins-cmd.js';
@@ -68,6 +71,10 @@ program
68
71
  .action((type, opts) => {
69
72
  runSolitudeOnce(type, opts.dryRun ?? false);
70
73
  });
74
+ program
75
+ .command('prompt-preview [mode]')
76
+ .description('Preview the combined system prompt before it reaches the AI — all modes or a specific one')
77
+ .action((mode) => runPromptPreview(mode));
71
78
  program
72
79
  .command('config')
73
80
  .description('Tweak her settings (opens config.json in your editor)')
@@ -165,6 +172,12 @@ model
165
172
  model
166
173
  .action(modelStatus);
167
174
  program.addCommand(model);
175
+ const db = new Command('db').description('Manage the memory database');
176
+ db
177
+ .command('migrate')
178
+ .description('Import existing flat memory files into SQLite (run once)')
179
+ .action(runMigration);
180
+ program.addCommand(db);
168
181
  program.addHelpCommand(new Command('help')
169
182
  .argument('[command]', 'command to show help for')
170
183
  .description('Show help for fabiana or a specific command'));
@@ -8,7 +8,8 @@ import { PermissionValidator } from '../utils/permissions.js';
8
8
  import { Logger } from '../utils/logger.js';
9
9
  import { createFabianaTools } from '../tools/index.js';
10
10
  import { loadContext, buildPrompt } from '../loaders/context.js';
11
- import { loadMood, getHoursSinceLastUserMessage, selectInitiativeType } from '../initiative/trigger.js';
11
+ import { loadMood, selectInitiativeType } from '../initiative/trigger.js';
12
+ import { updateLastInteraction, updateLastSolitude, getLastInteractionHoursAgo, getLastSolitudeHoursAgo } from '../utils/interaction.js';
12
13
  import { ALL_TYPES, TYPE_INSTRUCTIONS } from '../initiative/types.js';
13
14
  import { ALL_SOLITUDE_TYPES, SOLITUDE_TYPE_INSTRUCTIONS } from '../solitude/types.js';
14
15
  import { loadPlugins } from '../loaders/plugins.js';
@@ -23,6 +24,54 @@ async function loadConfig() {
23
24
  throw new Error(`Config not found at ${paths.configJson}. Run 'fabiana init' to set up your companion.`);
24
25
  }
25
26
  }
27
+ export async function buildSystemPrompt(mode, conversationState) {
28
+ const baseSystemPrompt = await fs.readFile(paths.systemMd(), 'utf-8');
29
+ // Both external-outreach and external-reply share system-external.md
30
+ const modeKey = mode.startsWith('external-') ? 'external' : mode;
31
+ const modeSystemPrompt = await fs.readFile(paths.systemMd(modeKey), 'utf-8').catch(() => '');
32
+ let systemPromptContent = modeSystemPrompt
33
+ ? `${baseSystemPrompt}\n\n---\n\n${modeSystemPrompt}`
34
+ : baseSystemPrompt;
35
+ // Resolve .fabiana/ references to the actual home path so Fabiana uses correct absolute paths
36
+ systemPromptContent = systemPromptContent.replaceAll('.fabiana/', `${FABIANA_HOME}/`);
37
+ // Always inject the absolute home path so Fabiana knows where her files live
38
+ systemPromptContent = `Your home directory is ${FABIANA_HOME}. All your memory, config, and data files live there.\n\n${systemPromptContent}`;
39
+ // Inject owner name and conversation purpose into external system prompt
40
+ if (mode.startsWith('external-')) {
41
+ const identity = await fs.readFile(paths.memory('identity.md'), 'utf-8').catch(() => '');
42
+ const ownerNameMatch = identity.match(/(?:my name is|I am|name:\s*)([A-Z][a-z]+)/i);
43
+ const ownerName = ownerNameMatch ? ownerNameMatch[1] : 'the owner';
44
+ systemPromptContent = systemPromptContent.replace('{owner_name}', ownerName);
45
+ const purpose = conversationState?.purpose ?? '[purpose — provided at runtime]';
46
+ systemPromptContent = systemPromptContent.replace('{purpose}', purpose);
47
+ }
48
+ // Append skills section — skills live at ~/.fabiana/skills/, scoped per user
49
+ const skills = await loadFabianaSkills();
50
+ if (skills.length > 0) {
51
+ systemPromptContent += formatSkillsForPrompt(skills);
52
+ }
53
+ return systemPromptContent;
54
+ }
55
+ export async function runPromptPreview(mode) {
56
+ const PREVIEW_MODES = ['chat', 'initiative', 'consolidate', 'solitude', 'external-outreach'];
57
+ const modesToShow = mode
58
+ ? [mode]
59
+ : PREVIEW_MODES;
60
+ for (const m of modesToShow) {
61
+ const bar = '═'.repeat(60);
62
+ console.log(`\n${bar}`);
63
+ console.log(` SYSTEM PROMPT — ${m.toUpperCase()}`);
64
+ console.log(bar);
65
+ try {
66
+ const prompt = await buildSystemPrompt(m);
67
+ console.log(prompt);
68
+ console.log(`\n[${prompt.length.toLocaleString()} chars]`);
69
+ }
70
+ catch (err) {
71
+ console.error(`❌ Failed to build prompt for ${m}: ${err.message}`);
72
+ }
73
+ }
74
+ }
26
75
  export async function runPiSession(mode, incomingMessage, channel, incomingMsg, conversationState, allChannels, conversationManager, initiativeOptions, solitudeOptions) {
27
76
  const logger = Logger.create();
28
77
  const sessionStartTime = Date.now();
@@ -45,29 +94,9 @@ export async function runPiSession(mode, incomingMessage, channel, incomingMsg,
45
94
  }
46
95
  console.log(' ✓ Model loaded');
47
96
  console.log('[5/8] Loading system prompt...');
48
- const baseSystemPrompt = await fs.readFile(paths.systemMd(), 'utf-8');
49
- // Both external-outreach and external-reply share system-external.md
50
- const modeKey = mode.startsWith('external-') ? 'external' : mode;
51
- const modeSystemPrompt = await fs.readFile(paths.systemMd(modeKey), 'utf-8').catch(() => '');
52
- let systemPromptContent = modeSystemPrompt
53
- ? `${baseSystemPrompt}\n\n---\n\n${modeSystemPrompt}`
54
- : baseSystemPrompt;
55
- // Resolve .fabiana/ references to the actual home path so Fabiana uses correct absolute paths
56
- systemPromptContent = systemPromptContent.replaceAll('.fabiana/', `${FABIANA_HOME}/`);
57
- // Inject owner name and conversation purpose into external system prompt
58
- if (mode.startsWith('external-')) {
59
- const identity = await fs.readFile(paths.memory('identity.md'), 'utf-8').catch(() => '');
60
- const ownerNameMatch = identity.match(/(?:my name is|I am|name:\s*)([A-Z][a-z]+)/i);
61
- const ownerName = ownerNameMatch ? ownerNameMatch[1] : 'the owner';
62
- systemPromptContent = systemPromptContent.replace('{owner_name}', ownerName);
63
- if (conversationState) {
64
- systemPromptContent = systemPromptContent.replace('{purpose}', conversationState.purpose);
65
- }
66
- }
67
- // Append skills section — skills live at ~/.fabiana/skills/, scoped per user
97
+ const systemPromptContent = await buildSystemPrompt(mode, conversationState);
68
98
  const skills = await loadFabianaSkills();
69
99
  if (skills.length > 0) {
70
- systemPromptContent += formatSkillsForPrompt(skills);
71
100
  console.log(` Skills: ${skills.map(s => s.name).join(', ')}`);
72
101
  }
73
102
  const loader = new DefaultResourceLoader({
@@ -86,6 +115,13 @@ export async function runPiSession(mode, incomingMessage, channel, incomingMsg,
86
115
  if (conversationState && conversationManager) {
87
116
  await conversationManager.append(conversationState.id, 'fabiana', text);
88
117
  }
118
+ // Track all outbound messages as interactions (resets idle clock)
119
+ if (mode !== 'solitude') {
120
+ await updateLastInteraction();
121
+ }
122
+ else {
123
+ await updateLastSolitude();
124
+ }
89
125
  };
90
126
  console.log('[6/8] Creating tools...');
91
127
  const fabianaTools = createFabianaTools(permissions, sendMessage, {
@@ -338,24 +374,46 @@ export async function startDaemon() {
338
374
  await ch.start();
339
375
  }
340
376
  await sendStartupMessage(primaryChannel);
377
+ /**
378
+ * Schedules a recurring task with ±jitterMinutes random variation per invocation.
379
+ * This makes Fabiana's self-triggered behaviours feel less mechanical.
380
+ */
381
+ function scheduleWithJitter(intervalMinutes, jitterMinutes, handler) {
382
+ const schedule = () => {
383
+ const jitter = (Math.random() * 2 - 1) * jitterMinutes * 60_000; // ±jitter in ms
384
+ const delay = intervalMinutes * 60_000 + jitter;
385
+ setTimeout(async () => {
386
+ try {
387
+ await handler();
388
+ }
389
+ catch (err) {
390
+ console.error('❌ [JITTER-SCHEDULER] Uncaught error:', err.message);
391
+ }
392
+ schedule(); // re-schedule after each run
393
+ }, delay);
394
+ };
395
+ schedule();
396
+ }
341
397
  const initiative = config.initiative;
342
398
  if (initiative.enabled) {
343
- const intervalMinutes = initiative.checkIntervalMinutes ?? 180;
344
- const cronExpr = intervalMinutes >= 60
345
- ? `0 */${Math.floor(intervalMinutes / 60)} * * *`
346
- : `*/${intervalMinutes} * * * *`;
347
- console.log(`[INIT] Initiative checks every ${intervalMinutes}min (active ${initiative.activeHoursStart}:00–${initiative.activeHoursEnd}:00)`);
348
- cron.schedule(cronExpr, async () => {
399
+ const intervalMinutes = initiative.checkIntervalMinutes ?? 30;
400
+ console.log(`[INIT] Initiative checks every ~${intervalMinutes}min ±15min (active ${initiative.activeHoursStart}:00–${initiative.activeHoursEnd}:00)`);
401
+ scheduleWithJitter(intervalMinutes, 15, async () => {
349
402
  const hour = new Date().getHours();
350
403
  if (hour < initiative.activeHoursStart || hour >= initiative.activeHoursEnd) {
351
404
  console.log(`\n🌱 [SCHEDULED] Initiative skipped — outside active hours (${hour}:00)`);
352
405
  return;
353
406
  }
407
+ // Gate: skip if last interaction was too recent
408
+ const hoursSinceInteraction = await getLastInteractionHoursAgo();
409
+ if (hoursSinceInteraction !== null && hoursSinceInteraction < initiative.minHoursBetweenMessages) {
410
+ console.log(`\n🌱 [SCHEDULED] Initiative skipped — last interaction ${hoursSinceInteraction.toFixed(1)}h ago (min: ${initiative.minHoursBetweenMessages}h)`);
411
+ return;
412
+ }
354
413
  console.log('\n🌱 [SCHEDULED] Running initiative check...');
355
414
  try {
356
415
  const mood = await loadMood();
357
- const hoursSince = await getHoursSinceLastUserMessage();
358
- const { type: selectedType, reason } = selectInitiativeType(mood, hoursSince);
416
+ const { type: selectedType, reason } = selectInitiativeType(mood, hoursSinceInteraction);
359
417
  const typeInstruction = TYPE_INSTRUCTIONS[selectedType];
360
418
  console.log(` 🎯 Type: ${selectedType} (${reason})`);
361
419
  await runPiSession('initiative', undefined, primaryChannel, undefined, undefined, channels, conversationManager, { type: selectedType, typeInstruction });
@@ -366,6 +424,43 @@ export async function startDaemon() {
366
424
  }
367
425
  });
368
426
  }
427
+ const solitude = config.solitude;
428
+ if (solitude?.enabled) {
429
+ const solitudeInterval = solitude.checkIntervalMinutes ?? 30;
430
+ const minIdleHours = solitude.minIdleHours ?? 1;
431
+ const minCooldownHours = solitude.minCooldownHours ?? 2;
432
+ console.log(`[INIT] Solitude checks every ~${solitudeInterval}min ±15min (idle>${minIdleHours}h, cooldown>${minCooldownHours}h, active ${solitude.activeHoursStart}:00–${solitude.activeHoursEnd}:00)`);
433
+ scheduleWithJitter(solitudeInterval, 15, async () => {
434
+ const hour = new Date().getHours();
435
+ if (hour < (solitude.activeHoursStart ?? 7) || hour >= (solitude.activeHoursEnd ?? 23)) {
436
+ return;
437
+ }
438
+ const hoursSinceInteraction = await getLastInteractionHoursAgo();
439
+ const hoursSinceSolitude = await getLastSolitudeHoursAgo();
440
+ // Only enter solitude if idle long enough
441
+ if (hoursSinceInteraction !== null && hoursSinceInteraction < minIdleHours) {
442
+ return;
443
+ }
444
+ // Respect cooldown between solitude sessions
445
+ if (hoursSinceSolitude !== null && hoursSinceSolitude < minCooldownHours) {
446
+ console.log(`\n🌿 [SCHEDULED] Solitude skipped — last solitude ${hoursSinceSolitude.toFixed(1)}h ago (cooldown: ${minCooldownHours}h)`);
447
+ return;
448
+ }
449
+ console.log('\n🌿 [SCHEDULED] Entering solitude...');
450
+ try {
451
+ const selectedType = ALL_SOLITUDE_TYPES[Math.floor(Math.random() * ALL_SOLITUDE_TYPES.length)];
452
+ const typeInstruction = SOLITUDE_TYPE_INSTRUCTIONS[selectedType];
453
+ console.log(` 🌿 Type: ${selectedType}`);
454
+ await runPiSession('solitude', undefined, primaryChannel, undefined, undefined, channels, conversationManager, undefined, { type: selectedType, typeInstruction });
455
+ // Always update lastSolitude after a session, even if no message was sent
456
+ await updateLastSolitude();
457
+ console.log('✅ [SCHEDULED] Solitude complete');
458
+ }
459
+ catch (err) {
460
+ console.error('❌ [SCHEDULED] Solitude failed:', err.message);
461
+ }
462
+ });
463
+ }
369
464
  cron.schedule('0 0 * * *', async () => {
370
465
  console.log('\n🌙 [SCHEDULED] Running midnight consolidation...');
371
466
  try {
@@ -429,6 +524,7 @@ export async function startDaemon() {
429
524
  // Owner message — full chat session on the channel it arrived on
430
525
  console.log(`\n📨 [${msg.source}] Message: "${msg.text.slice(0, 50)}..."`);
431
526
  await msgChannel.logConversation('user', msg.text, msg.source);
527
+ await updateLastInteraction();
432
528
  try {
433
529
  await runPiSession('chat', msg.text, msgChannel, msg, undefined, channels, conversationManager);
434
530
  }
@@ -457,7 +553,7 @@ export async function runInitiativeOnce(forcedType, dryRun = false) {
457
553
  console.log('━'.repeat(50));
458
554
  // Resolve initiative type — forced via CLI or auto-selected by trigger engine
459
555
  const mood = await loadMood();
460
- const hoursSince = await getHoursSinceLastUserMessage();
556
+ const hoursSince = await getLastInteractionHoursAgo();
461
557
  let selectedType;
462
558
  let selectionReason;
463
559
  if (forcedType) {
@@ -480,7 +576,7 @@ export async function runInitiativeOnce(forcedType, dryRun = false) {
480
576
  console.log(`💭 Mood: ${mood.current} (intensity: ${mood.intensity.toFixed(2)})`);
481
577
  }
482
578
  if (hoursSince !== null) {
483
- console.log(`⏱️ Hours since last user message: ${hoursSince.toFixed(1)}h`);
579
+ console.log(`⏱️ Hours since last interaction: ${hoursSince.toFixed(1)}h`);
484
580
  }
485
581
  if (dryRun) {
486
582
  console.log('\n── Dry run — not sending ─────────────────────────────');
@@ -0,0 +1,35 @@
1
+ import { execSync } from 'child_process';
2
+ import { existsSync } from 'fs';
3
+ import { fileURLToPath } from 'url';
4
+ import { join, dirname } from 'path';
5
+ import { paths } from '../paths.js';
6
+ function schemaPath() {
7
+ // Works in both dev (src/db/init.ts) and prod (dist/db/init.js)
8
+ const __dir = dirname(fileURLToPath(import.meta.url));
9
+ return join(__dir, 'schema.sql');
10
+ }
11
+ export function initDb() {
12
+ // Check sqlite3 is available
13
+ try {
14
+ execSync('sqlite3 --version', { stdio: 'ignore' });
15
+ }
16
+ catch {
17
+ console.warn('[memory-db] sqlite3 not found — structured memory will not be available.\n' +
18
+ ' Install with: sudo apt install sqlite3 (Linux) or brew install sqlite3 (Mac)');
19
+ return;
20
+ }
21
+ if (existsSync(paths.memoryDb))
22
+ return;
23
+ const schema = schemaPath();
24
+ if (!existsSync(schema)) {
25
+ console.warn(`[memory-db] Schema file not found at ${schema} — skipping DB init`);
26
+ return;
27
+ }
28
+ try {
29
+ execSync(`sqlite3 "${paths.memoryDb}" < "${schema}"`);
30
+ console.log(`[memory-db] Initialized ${paths.memoryDb}`);
31
+ }
32
+ catch (err) {
33
+ console.warn(`[memory-db] Failed to initialize DB: ${err.message}`);
34
+ }
35
+ }
@@ -0,0 +1,112 @@
1
+ import { execSync } from 'child_process';
2
+ import { existsSync, readdirSync, readFileSync } from 'fs';
3
+ import { join, basename } from 'path';
4
+ import { paths } from '../paths.js';
5
+ function sqlEscape(s) {
6
+ return s.replace(/'/g, "''");
7
+ }
8
+ function runSql(sql) {
9
+ execSync(`sqlite3 "${paths.memoryDb}"`, { input: sql, stdio: ['pipe', 'inherit', 'inherit'] });
10
+ }
11
+ function capitalizeName(filename) {
12
+ return filename
13
+ .split(/[-_\s]+/)
14
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
15
+ .join(' ');
16
+ }
17
+ export async function runMigration() {
18
+ try {
19
+ execSync('sqlite3 --version', { stdio: 'ignore' });
20
+ }
21
+ catch {
22
+ console.error('❌ sqlite3 not found. Install with: sudo apt install sqlite3 (Linux) or brew install sqlite3 (Mac)');
23
+ process.exit(1);
24
+ }
25
+ if (!existsSync(paths.memoryDb)) {
26
+ console.error(`❌ memory.db not found at ${paths.memoryDb}. Run \`fabiana init\` first.`);
27
+ process.exit(1);
28
+ }
29
+ const memoryRoot = paths.memory();
30
+ let peopleCount = 0;
31
+ let interestCount = 0;
32
+ let factCount = 0;
33
+ console.log('\n🗄️ Migrating flat memory files → SQLite\n');
34
+ // ── 1. People ────────────────────────────────────────────────────────────
35
+ const peopleDir = join(memoryRoot, 'people');
36
+ if (existsSync(peopleDir)) {
37
+ const files = readdirSync(peopleDir).filter(f => f.endsWith('.md'));
38
+ for (const file of files) {
39
+ const raw = basename(file, '.md');
40
+ const name = capitalizeName(raw);
41
+ const bio = readFileSync(join(peopleDir, file), 'utf-8').trim();
42
+ // Best-effort relationship guess from the file content
43
+ let relationship = null;
44
+ const lowerBio = bio.toLowerCase();
45
+ if (/\bfamily\b|\bmother\b|\bfather\b|\bsister\b|\bbrother\b|\bwife\b|\bhusband\b|\bchild\b|\bchildren\b/.test(lowerBio)) {
46
+ relationship = 'family';
47
+ }
48
+ else if (/\bfriend\b/.test(lowerBio)) {
49
+ relationship = 'friend';
50
+ }
51
+ else if (/\bcolleague\b|\bworks\b|\bteam\b|\bmanager\b|\breport\b|\bco-worker\b/.test(lowerBio)) {
52
+ relationship = 'colleague';
53
+ }
54
+ const relVal = relationship ? `'${relationship}'` : 'NULL';
55
+ runSql(`INSERT INTO people (name, relationship, bio) VALUES ('${sqlEscape(name)}', ${relVal}, '${sqlEscape(bio)}') ON CONFLICT(name) DO UPDATE SET bio = excluded.bio, relationship = COALESCE(excluded.relationship, relationship);`);
56
+ console.log(` 👤 ${name}`);
57
+ peopleCount++;
58
+ }
59
+ console.log(`✓ People: ${peopleCount} imported\n`);
60
+ }
61
+ else {
62
+ console.log(' People: no people/ dir — skipping\n');
63
+ }
64
+ // ── 2. Interests ─────────────────────────────────────────────────────────
65
+ const topicsPath = join(memoryRoot, 'interests', 'topics.md');
66
+ if (existsSync(topicsPath)) {
67
+ const content = readFileSync(topicsPath, 'utf-8');
68
+ let currentSection = 'general';
69
+ for (const line of content.split('\n')) {
70
+ if (line.startsWith('## ')) {
71
+ currentSection = line.replace(/^## /, '').trim().toLowerCase().replace(/\s+/g, '_');
72
+ }
73
+ else if (line.startsWith('- ')) {
74
+ const topic = line.replace(/^- /, '').trim();
75
+ if (!topic)
76
+ continue;
77
+ runSql(`INSERT OR IGNORE INTO memories (content, category, subject, tier) VALUES ('${sqlEscape(topic)}', 'interest', '${sqlEscape(currentSection)}', 'warm');`);
78
+ interestCount++;
79
+ }
80
+ }
81
+ console.log(`✓ Interests: ${interestCount} imported\n`);
82
+ }
83
+ else {
84
+ console.log(' Interests: no topics.md — skipping\n');
85
+ }
86
+ // ── 3. This week ─────────────────────────────────────────────────────────
87
+ const thisWeekPath = join(memoryRoot, 'recent', 'this-week.md');
88
+ if (existsSync(thisWeekPath)) {
89
+ const content = readFileSync(thisWeekPath, 'utf-8').trim();
90
+ if (content) {
91
+ runSql(`INSERT OR IGNORE INTO memories (content, category, subject, tier) VALUES ('${sqlEscape(content)}', 'fact', 'this_week', 'hot');`);
92
+ factCount++;
93
+ console.log(`✓ This-week: imported\n`);
94
+ }
95
+ }
96
+ // ── 4. Upcoming dates ────────────────────────────────────────────────────
97
+ // The format is too variable for structured parsing — import as a single fact row.
98
+ // Fabiana can use the memory-db skill to normalise these into proper events rows herself.
99
+ const upcomingPath = join(memoryRoot, 'dates', 'upcoming.md');
100
+ if (existsSync(upcomingPath)) {
101
+ const content = readFileSync(upcomingPath, 'utf-8').trim();
102
+ if (content) {
103
+ runSql(`INSERT OR IGNORE INTO memories (content, category, subject, tier) VALUES ('${sqlEscape(content)}', 'fact', 'upcoming_dates', 'warm');`);
104
+ factCount++;
105
+ console.log(`✓ Upcoming dates: imported as fact row (let Fabiana normalise into events table)\n`);
106
+ }
107
+ }
108
+ console.log('━'.repeat(50));
109
+ console.log(`✅ Migration complete`);
110
+ console.log(` People: ${peopleCount} | Interests: ${interestCount} | Facts: ${factCount}`);
111
+ console.log(`\n The flat .md files are unchanged — DB is the canonical store going forward.`);
112
+ }
package/dist/paths.js CHANGED
@@ -20,6 +20,8 @@ export const paths = {
20
20
  agentTodo: join(DATA_DIR, 'agent-todo'),
21
21
  conversations: join(DATA_DIR, 'conversations'),
22
22
  moodMd: join(DATA_DIR, 'memory', 'mood.md'),
23
+ memoryDb: join(DATA_DIR, 'memory.db'),
24
+ lastInteractionJson: join(DATA_DIR, 'last_interaction.json'),
23
25
  envFile: join(FABIANA_HOME, '.env'),
24
26
  };
25
27
  // Default plugins bundled with the package.
@@ -29,5 +31,9 @@ const __dir = fileURLToPath(new URL('.', import.meta.url));
29
31
  const distPlugins = join(__dir, 'plugins');
30
32
  const srcPlugins = join(__dir, '..', 'plugins');
31
33
  export const BUNDLED_PLUGINS_DIR = existsSync(distPlugins) ? distPlugins : srcPlugins;
34
+ // Default skills bundled with the package.
35
+ // Dev: src/paths.ts → __dir = src/ → src/skills/
36
+ // Prod: dist/paths.js → __dir = dist/ → dist/skills/
37
+ export const BUNDLED_SKILLS_DIR = join(__dir, 'skills');
32
38
  // Root of the installed/dev package (one level above src/ or dist/)
33
39
  export const PACKAGE_ROOT = join(__dir, '..');
@@ -24,17 +24,27 @@ Create: \`data/memory/diary/YYYY/YYYY-MM/YYYY-MM-DD.md\`
24
24
 
25
25
  Keep it human and readable — like a journal entry written by someone who cares, not a bullet report.
26
26
 
27
- ### 4. Update Memory Files
27
+ ### 4. Write to Structured Memory (SQLite)
28
+
29
+ Use the **memory-db** skill to persist what you extracted:
30
+
31
+ - Any new or updated person → upsert into \`people\` (name, relationship, bio)
32
+ - Any upcoming date or commitment → INSERT into \`events\` (title, date YYYY-MM-DD, notes)
33
+ - Today's mood assessment of {{user_name}} → INSERT into \`moods\` (value, intensity 1–10, note)
34
+ - Notable recurring topics or facts → INSERT into \`memories\` (category=\`'fact'\`, content, subject)
35
+ - Archive cold memories: \`UPDATE memories SET expires_at = datetime('now') WHERE tier = 'cold' AND created_at < datetime('now', '-90 days') AND expires_at IS NULL;\`
36
+
37
+ ### 5. Update Memory Files
38
+
39
+ Keep the hot-tier files current (they load into every session):
28
40
  - New person info → \`data/memory/people/[name].md\` (create if doesn't exist)
29
- - Upcoming events → \`data/memory/dates/upcoming.md\`
30
- - New interests/topics → \`data/memory/interests/topics.md\`
31
41
  - Health updates → \`data/memory/health.md\`
32
42
 
33
- ### 5. Update Rolling Memory
43
+ ### 6. Update Rolling Memory
34
44
  - \`data/memory/recent/this-week.md\` — append today's one-paragraph summary (keep last 7 days, trim older)
35
45
  - \`data/memory/core.md\` — update current state, active threads, last_message_sent if applicable
36
46
 
37
- ### 6. Clean Up TODOs
47
+ ### 7. Clean Up TODOs
38
48
  - \`manage_todo action=list\` — review all pending
39
49
  - Mark completed ones done
40
50
  - Delete stale ones that are no longer relevant
@@ -26,7 +26,7 @@ No one messaged you. This is your time. The system has given you a **solitude ty
26
26
  ## Hard Rules
27
27
 
28
28
  - Only call \`send_message\` if you genuinely found something time-sensitive or too good not to share — *not* as a habit
29
- - Write creative and reflective output to \`data/memory/self/\` — it belongs to you
30
- - Update memory files if you learned something real
29
+ - Write long-form prose (diary entries, reflections, creative writing) to \`data/memory/self/\` — it belongs to you
30
+ - For structured insights (people, events, facts, news items, mood), use the **memory-db** skill not markdown files
31
31
  - Your full work is logged regardless — you don't need to summarize it for anyone
32
32
  `;
@@ -48,14 +48,24 @@ At session start, your context loader injects core memory into the user prompt:
48
48
  Proactively pull additional memory files when relevant using \`safe_read\`.
49
49
 
50
50
  ### Write Protocol
51
- **Update immediately when you learn:**
52
- - New facts about {{user_name}} → \`data/memory/identity.md\`
53
- - Current state/mood → \`data/memory/core.md\`
54
- - New info about a person → \`data/memory/people/[name].md\`
55
- - Upcoming date/event → \`data/memory/dates/upcoming.md\`
56
- - New interest/topic → \`data/memory/interests/topics.md\`
57
-
58
- **Format for atomic updates:**
51
+
52
+ **Structured data memory-db skill (preferred)**
53
+
54
+ Use the \`memory-db\` skill for anything searchable or relational:
55
+ - New info about a person → \`people\` table (upsert by name)
56
+ - Upcoming date or event → \`events\` table
57
+ - Discrete facts about {{user_name}} → \`memories\` table (\`category='fact'\`)
58
+ - Interests and research → \`memories\` table (\`category='interest'\`)
59
+ - Mood at end of session → \`moods\` table
60
+ - News worth noting → \`memories\` table (\`category='news'\`)
61
+
62
+ **Hot-tier files (always update directly)**
63
+
64
+ Keep these files current — they are loaded into every session:
65
+ - Identity anchor facts → \`data/memory/identity.md\`
66
+ - Current state / active threads → \`data/memory/core.md\`
67
+
68
+ **Format for file updates:**
59
69
  \`\`\`
60
70
  - [YYYY-MM-DD] [fact]
61
71
  \`\`\`
@@ -3,12 +3,13 @@ import chalk from 'chalk';
3
3
  import fs from 'fs/promises';
4
4
  import path from 'path';
5
5
  import { providers } from '../data/providers.js';
6
- import { paths, PLUGINS_DIR, CONFIG_DIR, DATA_DIR, BUNDLED_PLUGINS_DIR, PACKAGE_ROOT } from '../paths.js';
6
+ import { paths, PLUGINS_DIR, SKILLS_DIR, CONFIG_DIR, DATA_DIR, BUNDLED_PLUGINS_DIR, BUNDLED_SKILLS_DIR, PACKAGE_ROOT } from '../paths.js';
7
7
  import { systemPromptTemplate } from '../prompts/system.js';
8
8
  import { systemChatTemplate } from '../prompts/system-chat.js';
9
9
  import { systemInitiativeTemplate } from '../prompts/system-initiative.js';
10
10
  import { systemConsolidateTemplate } from '../prompts/system-consolidate.js';
11
11
  import { systemExternalTemplate } from '../prompts/system-external.js';
12
+ import { initDb } from '../db/init.js';
12
13
  const TONES = {
13
14
  'warm-casual': {
14
15
  label: 'Warm & Casual',
@@ -218,6 +219,7 @@ export async function runSetup() {
218
219
  await fs.mkdir(path.join(DATA_DIR, 'logs'), { recursive: true });
219
220
  await fs.mkdir(path.join(DATA_DIR, 'sessions'), { recursive: true });
220
221
  await fs.mkdir(PLUGINS_DIR, { recursive: true });
222
+ await fs.mkdir(SKILLS_DIR, { recursive: true });
221
223
  // config
222
224
  const config = {
223
225
  version: '0.1.0',
@@ -312,6 +314,26 @@ You can read those files to understand your own capabilities and limitations.
312
314
  catch {
313
315
  // No bundled plugins dir — dev environment or stripped build, skip silently
314
316
  }
317
+ // copy bundled default skills (skip any already installed)
318
+ try {
319
+ const bundledSkillDirs = await fs.readdir(BUNDLED_SKILLS_DIR, { withFileTypes: true });
320
+ for (const entry of bundledSkillDirs.filter(e => e.isDirectory())) {
321
+ const dest = path.join(SKILLS_DIR, entry.name);
322
+ try {
323
+ await fs.access(dest);
324
+ // already exists — skip so user customisations are preserved
325
+ }
326
+ catch {
327
+ await fs.cp(path.join(BUNDLED_SKILLS_DIR, entry.name), dest, { recursive: true });
328
+ }
329
+ }
330
+ ok(`${SKILLS_DIR}/ (default skills copied)`);
331
+ }
332
+ catch {
333
+ // No bundled skills dir — dev environment or stripped build, skip silently
334
+ }
335
+ // initialise SQLite memory DB
336
+ initDb();
315
337
  // ── API key setup ─────────────────────────────────────────────
316
338
  const envPath = paths.envFile;
317
339
  const shell = process.env.SHELL ?? '';
@@ -1,6 +1,4 @@
1
1
  import { createSafeReadTool } from './safe-read.js';
2
- import { createSafeWriteTool } from './safe-write.js';
3
- import { createSafeEditTool } from './safe-edit.js';
4
2
  import { createSendMessageTool } from './send-message.js';
5
3
  import { createManageTodoTool } from './manage-todo.js';
6
4
  import { createFetchUrlTool } from './fetch-url.js';
@@ -17,9 +15,6 @@ export function createFabianaTools(validator, sendMessage, opts = { toolset: 'fu
17
15
  }
18
16
  // Full toolset for owner sessions
19
17
  const tools = [
20
- createSafeReadTool(),
21
- createSafeWriteTool(validator),
22
- createSafeEditTool(validator),
23
18
  createSendMessageTool(sendMessage),
24
19
  createManageTodoTool(validator),
25
20
  createFetchUrlTool(),
@@ -0,0 +1,39 @@
1
+ import fs from 'fs/promises';
2
+ import { paths } from '../paths.js';
3
+ async function readState() {
4
+ try {
5
+ const content = await fs.readFile(paths.lastInteractionJson, 'utf-8');
6
+ return JSON.parse(content);
7
+ }
8
+ catch {
9
+ return { lastInteraction: null, lastSolitude: null };
10
+ }
11
+ }
12
+ async function writeState(state) {
13
+ await fs.mkdir(paths.lastInteractionJson.replace(/\/[^/]+$/, ''), { recursive: true });
14
+ await fs.writeFile(paths.lastInteractionJson, JSON.stringify(state, null, 2), 'utf-8');
15
+ }
16
+ export async function updateLastInteraction() {
17
+ const state = await readState();
18
+ state.lastInteraction = new Date().toISOString();
19
+ await writeState(state);
20
+ }
21
+ export async function updateLastSolitude() {
22
+ const state = await readState();
23
+ state.lastSolitude = new Date().toISOString();
24
+ await writeState(state);
25
+ }
26
+ export async function getLastInteractionHoursAgo() {
27
+ const state = await readState();
28
+ if (!state.lastInteraction)
29
+ return null;
30
+ const ms = Date.now() - new Date(state.lastInteraction).getTime();
31
+ return ms / 3_600_000;
32
+ }
33
+ export async function getLastSolitudeHoursAgo() {
34
+ const state = await readState();
35
+ if (!state.lastSolitude)
36
+ return null;
37
+ const ms = Date.now() - new Date(state.lastSolitude).getTime();
38
+ return ms / 3_600_000;
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fabiana",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Personal AI assistant that actually feels personal",
5
5
  "type": "module",
6
6
  "bin": {