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 +14 -1
- package/dist/daemon/index.js +128 -32
- package/dist/db/init.js +35 -0
- package/dist/db/migrate-from-files.js +112 -0
- package/dist/paths.js +6 -0
- package/dist/prompts/system-consolidate.js +15 -5
- package/dist/prompts/system-solitude.js +2 -2
- package/dist/prompts/system.js +18 -8
- package/dist/setup/index.js +23 -1
- package/dist/tools/index.js +0 -5
- package/dist/utils/interaction.js +39 -0
- package/package.json +1 -1
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'));
|
package/dist/daemon/index.js
CHANGED
|
@@ -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,
|
|
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
|
|
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 ??
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
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
|
|
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
|
|
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 ─────────────────────────────');
|
package/dist/db/init.js
ADDED
|
@@ -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.
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
|
30
|
-
-
|
|
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
|
`;
|
package/dist/prompts/system.js
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
\`\`\`
|
package/dist/setup/index.js
CHANGED
|
@@ -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 ?? '';
|
package/dist/tools/index.js
CHANGED
|
@@ -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
|
+
}
|