fabiana 1.1.0 → 1.2.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
12
  import { startDaemon, runInitiativeOnce, runConsolidateOnce, runSolitudeOnce } 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';
@@ -165,6 +168,12 @@ model
165
168
  model
166
169
  .action(modelStatus);
167
170
  program.addCommand(model);
171
+ const db = new Command('db').description('Manage the memory database');
172
+ db
173
+ .command('migrate')
174
+ .description('Import existing flat memory files into SQLite (run once)')
175
+ .action(runMigration);
176
+ program.addCommand(db);
168
177
  program.addHelpCommand(new Command('help')
169
178
  .argument('[command]', 'command to show help for')
170
179
  .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';
@@ -86,6 +87,13 @@ export async function runPiSession(mode, incomingMessage, channel, incomingMsg,
86
87
  if (conversationState && conversationManager) {
87
88
  await conversationManager.append(conversationState.id, 'fabiana', text);
88
89
  }
90
+ // Track all outbound messages as interactions (resets idle clock)
91
+ if (mode !== 'solitude') {
92
+ await updateLastInteraction();
93
+ }
94
+ else {
95
+ await updateLastSolitude();
96
+ }
89
97
  };
90
98
  console.log('[6/8] Creating tools...');
91
99
  const fabianaTools = createFabianaTools(permissions, sendMessage, {
@@ -338,24 +346,46 @@ export async function startDaemon() {
338
346
  await ch.start();
339
347
  }
340
348
  await sendStartupMessage(primaryChannel);
349
+ /**
350
+ * Schedules a recurring task with ±jitterMinutes random variation per invocation.
351
+ * This makes Fabiana's self-triggered behaviours feel less mechanical.
352
+ */
353
+ function scheduleWithJitter(intervalMinutes, jitterMinutes, handler) {
354
+ const schedule = () => {
355
+ const jitter = (Math.random() * 2 - 1) * jitterMinutes * 60_000; // ±jitter in ms
356
+ const delay = intervalMinutes * 60_000 + jitter;
357
+ setTimeout(async () => {
358
+ try {
359
+ await handler();
360
+ }
361
+ catch (err) {
362
+ console.error('❌ [JITTER-SCHEDULER] Uncaught error:', err.message);
363
+ }
364
+ schedule(); // re-schedule after each run
365
+ }, delay);
366
+ };
367
+ schedule();
368
+ }
341
369
  const initiative = config.initiative;
342
370
  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 () => {
371
+ const intervalMinutes = initiative.checkIntervalMinutes ?? 30;
372
+ console.log(`[INIT] Initiative checks every ~${intervalMinutes}min ±15min (active ${initiative.activeHoursStart}:00–${initiative.activeHoursEnd}:00)`);
373
+ scheduleWithJitter(intervalMinutes, 15, async () => {
349
374
  const hour = new Date().getHours();
350
375
  if (hour < initiative.activeHoursStart || hour >= initiative.activeHoursEnd) {
351
376
  console.log(`\n🌱 [SCHEDULED] Initiative skipped — outside active hours (${hour}:00)`);
352
377
  return;
353
378
  }
379
+ // Gate: skip if last interaction was too recent
380
+ const hoursSinceInteraction = await getLastInteractionHoursAgo();
381
+ if (hoursSinceInteraction !== null && hoursSinceInteraction < initiative.minHoursBetweenMessages) {
382
+ console.log(`\n🌱 [SCHEDULED] Initiative skipped — last interaction ${hoursSinceInteraction.toFixed(1)}h ago (min: ${initiative.minHoursBetweenMessages}h)`);
383
+ return;
384
+ }
354
385
  console.log('\n🌱 [SCHEDULED] Running initiative check...');
355
386
  try {
356
387
  const mood = await loadMood();
357
- const hoursSince = await getHoursSinceLastUserMessage();
358
- const { type: selectedType, reason } = selectInitiativeType(mood, hoursSince);
388
+ const { type: selectedType, reason } = selectInitiativeType(mood, hoursSinceInteraction);
359
389
  const typeInstruction = TYPE_INSTRUCTIONS[selectedType];
360
390
  console.log(` 🎯 Type: ${selectedType} (${reason})`);
361
391
  await runPiSession('initiative', undefined, primaryChannel, undefined, undefined, channels, conversationManager, { type: selectedType, typeInstruction });
@@ -366,6 +396,43 @@ export async function startDaemon() {
366
396
  }
367
397
  });
368
398
  }
399
+ const solitude = config.solitude;
400
+ if (solitude?.enabled) {
401
+ const solitudeInterval = solitude.checkIntervalMinutes ?? 30;
402
+ const minIdleHours = solitude.minIdleHours ?? 1;
403
+ const minCooldownHours = solitude.minCooldownHours ?? 2;
404
+ console.log(`[INIT] Solitude checks every ~${solitudeInterval}min ±15min (idle>${minIdleHours}h, cooldown>${minCooldownHours}h, active ${solitude.activeHoursStart}:00–${solitude.activeHoursEnd}:00)`);
405
+ scheduleWithJitter(solitudeInterval, 15, async () => {
406
+ const hour = new Date().getHours();
407
+ if (hour < (solitude.activeHoursStart ?? 7) || hour >= (solitude.activeHoursEnd ?? 23)) {
408
+ return;
409
+ }
410
+ const hoursSinceInteraction = await getLastInteractionHoursAgo();
411
+ const hoursSinceSolitude = await getLastSolitudeHoursAgo();
412
+ // Only enter solitude if idle long enough
413
+ if (hoursSinceInteraction !== null && hoursSinceInteraction < minIdleHours) {
414
+ return;
415
+ }
416
+ // Respect cooldown between solitude sessions
417
+ if (hoursSinceSolitude !== null && hoursSinceSolitude < minCooldownHours) {
418
+ console.log(`\n🌿 [SCHEDULED] Solitude skipped — last solitude ${hoursSinceSolitude.toFixed(1)}h ago (cooldown: ${minCooldownHours}h)`);
419
+ return;
420
+ }
421
+ console.log('\n🌿 [SCHEDULED] Entering solitude...');
422
+ try {
423
+ const selectedType = ALL_SOLITUDE_TYPES[Math.floor(Math.random() * ALL_SOLITUDE_TYPES.length)];
424
+ const typeInstruction = SOLITUDE_TYPE_INSTRUCTIONS[selectedType];
425
+ console.log(` 🌿 Type: ${selectedType}`);
426
+ await runPiSession('solitude', undefined, primaryChannel, undefined, undefined, channels, conversationManager, undefined, { type: selectedType, typeInstruction });
427
+ // Always update lastSolitude after a session, even if no message was sent
428
+ await updateLastSolitude();
429
+ console.log('✅ [SCHEDULED] Solitude complete');
430
+ }
431
+ catch (err) {
432
+ console.error('❌ [SCHEDULED] Solitude failed:', err.message);
433
+ }
434
+ });
435
+ }
369
436
  cron.schedule('0 0 * * *', async () => {
370
437
  console.log('\n🌙 [SCHEDULED] Running midnight consolidation...');
371
438
  try {
@@ -429,6 +496,7 @@ export async function startDaemon() {
429
496
  // Owner message — full chat session on the channel it arrived on
430
497
  console.log(`\n📨 [${msg.source}] Message: "${msg.text.slice(0, 50)}..."`);
431
498
  await msgChannel.logConversation('user', msg.text, msg.source);
499
+ await updateLastInteraction();
432
500
  try {
433
501
  await runPiSession('chat', msg.text, msgChannel, msg, undefined, channels, conversationManager);
434
502
  }
@@ -457,7 +525,7 @@ export async function runInitiativeOnce(forcedType, dryRun = false) {
457
525
  console.log('━'.repeat(50));
458
526
  // Resolve initiative type — forced via CLI or auto-selected by trigger engine
459
527
  const mood = await loadMood();
460
- const hoursSince = await getHoursSinceLastUserMessage();
528
+ const hoursSince = await getLastInteractionHoursAgo();
461
529
  let selectedType;
462
530
  let selectionReason;
463
531
  if (forcedType) {
@@ -480,7 +548,7 @@ export async function runInitiativeOnce(forcedType, dryRun = false) {
480
548
  console.log(`💭 Mood: ${mood.current} (intensity: ${mood.intensity.toFixed(2)})`);
481
549
  }
482
550
  if (hoursSince !== null) {
483
- console.log(`⏱️ Hours since last user message: ${hoursSince.toFixed(1)}h`);
551
+ console.log(`⏱️ Hours since last interaction: ${hoursSince.toFixed(1)}h`);
484
552
  }
485
553
  if (dryRun) {
486
554
  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 ?? '';
@@ -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.2.0",
4
4
  "description": "Personal AI assistant that actually feels personal",
5
5
  "type": "module",
6
6
  "bin": {