fabiana 0.4.0 → 1.0.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/README.md +5 -0
- package/dist/cli.js +68 -0
- package/dist/daemon/index.js +69 -29
- package/dist/loaders/context.js +1 -1
- package/dist/loaders/skills.js +89 -0
- package/dist/model-cmd.js +154 -0
- package/dist/paths.js +4 -0
- package/dist/plugins/hackernews/index.js +16 -15
- package/dist/prompts/system-initiative.js +26 -6
- package/dist/provider-cmd.js +143 -0
- package/dist/setup/index.js +11 -2
- package/dist/skills-cmd.js +246 -0
- package/dist/tools/manage-todo.js +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,11 @@ No dashboards. No commands to memorize. She just slides into your DM.
|
|
|
17
17
|
|
|
18
18
|
---
|
|
19
19
|
|
|
20
|
+
<p align="center">
|
|
21
|
+
<img src="screenshot.png" alt="Screenshot"/>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
|
|
20
25
|
## What she does
|
|
21
26
|
|
|
22
27
|
**She messages you first.** She has a schedule and a TODO list she manages herself. She'll reach out when there's something worth saying — not when you remember to ask.
|
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,10 @@ import { startDaemon, runInitiativeOnce, runConsolidateOnce } from './daemon/ind
|
|
|
11
11
|
import { runDoctor } from './doctor.js';
|
|
12
12
|
import { runBackup, runRestore } from './backup.js';
|
|
13
13
|
import { pluginsAdd, pluginsList } from './plugins-cmd.js';
|
|
14
|
+
import { skillsAdd, skillsList, skillsRemove, skillsEnable, skillsDisable } from './skills-cmd.js';
|
|
14
15
|
import { runSetup } from './setup/index.js';
|
|
16
|
+
import { providerStatus, providerUse, providerAdd } from './provider-cmd.js';
|
|
17
|
+
import { modelStatus, modelUse, modelTest } from './model-cmd.js';
|
|
15
18
|
const C = '\x1b[96m'; // cyan — name
|
|
16
19
|
const D = '\x1b[2m'; // dim — subtitle
|
|
17
20
|
const R = '\x1b[0m'; // reset
|
|
@@ -62,6 +65,25 @@ program
|
|
|
62
65
|
const editor = process.env.EDITOR ?? process.env.VISUAL ?? 'vi';
|
|
63
66
|
spawnSync(editor, [paths.configJson], { stdio: 'inherit' });
|
|
64
67
|
});
|
|
68
|
+
program
|
|
69
|
+
.command('system-prompt')
|
|
70
|
+
.description('Edit her system prompts — choose the mode')
|
|
71
|
+
.action(async () => {
|
|
72
|
+
const { select } = await import('@inquirer/prompts');
|
|
73
|
+
const choice = await select({
|
|
74
|
+
message: 'Which system prompt do you want to edit?',
|
|
75
|
+
choices: [
|
|
76
|
+
{ name: 'system — base identity prompt', value: 'system' },
|
|
77
|
+
{ name: 'chat — chat mode override', value: 'chat' },
|
|
78
|
+
{ name: 'initiative — initiative mode override', value: 'initiative' },
|
|
79
|
+
{ name: 'consolidate — consolidation mode override', value: 'consolidate' },
|
|
80
|
+
{ name: 'external — external mode override', value: 'external' },
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
const file = choice === 'system' ? paths.systemMd() : paths.systemMd(choice);
|
|
84
|
+
const editor = process.env.EDITOR ?? process.env.VISUAL ?? 'vi';
|
|
85
|
+
spawnSync(editor, [file], { stdio: 'inherit' });
|
|
86
|
+
});
|
|
65
87
|
program
|
|
66
88
|
.command('doctor')
|
|
67
89
|
.description('Is everything okay in there? Let\'s check')
|
|
@@ -86,6 +108,52 @@ plugins
|
|
|
86
108
|
.description('What can she do?')
|
|
87
109
|
.action(pluginsList);
|
|
88
110
|
program.addCommand(plugins);
|
|
111
|
+
const skills = new Command('skills').description('Teach her new workflows');
|
|
112
|
+
skills
|
|
113
|
+
.command('add <source>')
|
|
114
|
+
.description('Install a skill (format: username/repo or username/collection/skill-name)')
|
|
115
|
+
.action((source) => skillsAdd(source));
|
|
116
|
+
skills
|
|
117
|
+
.command('list')
|
|
118
|
+
.description('What does she know?')
|
|
119
|
+
.action(skillsList);
|
|
120
|
+
skills
|
|
121
|
+
.command('remove <name>')
|
|
122
|
+
.description('Uninstall a skill')
|
|
123
|
+
.action((name) => skillsRemove(name));
|
|
124
|
+
skills
|
|
125
|
+
.command('enable <name>')
|
|
126
|
+
.description('Re-enable a disabled skill')
|
|
127
|
+
.action((name) => skillsEnable(name));
|
|
128
|
+
skills
|
|
129
|
+
.command('disable <name>')
|
|
130
|
+
.description('Disable a skill without removing it')
|
|
131
|
+
.action((name) => skillsDisable(name));
|
|
132
|
+
program.addCommand(skills);
|
|
133
|
+
const provider = new Command('provider').description('Manage AI providers');
|
|
134
|
+
provider
|
|
135
|
+
.command('use')
|
|
136
|
+
.description('Switch active provider and model')
|
|
137
|
+
.action(providerUse);
|
|
138
|
+
provider
|
|
139
|
+
.command('add')
|
|
140
|
+
.description('Show credential setup instructions for a provider')
|
|
141
|
+
.action(providerAdd);
|
|
142
|
+
provider
|
|
143
|
+
.action(providerStatus);
|
|
144
|
+
program.addCommand(provider);
|
|
145
|
+
const model = new Command('model').description('Manage the active model');
|
|
146
|
+
model
|
|
147
|
+
.command('use')
|
|
148
|
+
.description('Switch to a different model')
|
|
149
|
+
.action(modelUse);
|
|
150
|
+
model
|
|
151
|
+
.command('test')
|
|
152
|
+
.description('Send a live ping to verify the model works')
|
|
153
|
+
.action(modelTest);
|
|
154
|
+
model
|
|
155
|
+
.action(modelStatus);
|
|
156
|
+
program.addCommand(model);
|
|
89
157
|
program.addHelpCommand(new Command('help')
|
|
90
158
|
.argument('[command]', 'command to show help for')
|
|
91
159
|
.description('Show help for fabiana or a specific command'));
|
package/dist/daemon/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { Logger } from '../utils/logger.js';
|
|
|
9
9
|
import { createFabianaTools } from '../tools/index.js';
|
|
10
10
|
import { loadContext, buildPrompt } from '../loaders/context.js';
|
|
11
11
|
import { loadPlugins } from '../loaders/plugins.js';
|
|
12
|
+
import { loadFabianaSkills, formatSkillsForPrompt } from '../loaders/skills.js';
|
|
12
13
|
import { paths, PLUGINS_DIR, FABIANA_HOME } from '../paths.js';
|
|
13
14
|
async function loadConfig() {
|
|
14
15
|
try {
|
|
@@ -60,6 +61,12 @@ export async function runPiSession(mode, incomingMessage, channel, incomingMsg,
|
|
|
60
61
|
systemPromptContent = systemPromptContent.replace('{purpose}', conversationState.purpose);
|
|
61
62
|
}
|
|
62
63
|
}
|
|
64
|
+
// Append skills section — skills live at ~/.fabiana/skills/, scoped per user
|
|
65
|
+
const skills = await loadFabianaSkills();
|
|
66
|
+
if (skills.length > 0) {
|
|
67
|
+
systemPromptContent += formatSkillsForPrompt(skills);
|
|
68
|
+
console.log(` Skills: ${skills.map(s => s.name).join(', ')}`);
|
|
69
|
+
}
|
|
63
70
|
const loader = new DefaultResourceLoader({
|
|
64
71
|
cwd: process.cwd(),
|
|
65
72
|
systemPromptOverride: () => systemPromptContent,
|
|
@@ -100,30 +107,39 @@ export async function runPiSession(mode, incomingMessage, channel, incomingMsg,
|
|
|
100
107
|
console.log('[8/8] Setting up event handlers...');
|
|
101
108
|
let sendMessageCalled = false;
|
|
102
109
|
let accumulatedResponse = '';
|
|
110
|
+
let lastActivityWasThinking = false;
|
|
103
111
|
session.subscribe(async (event) => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (event.
|
|
115
|
-
|
|
112
|
+
try {
|
|
113
|
+
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'thinking_delta') {
|
|
114
|
+
process.stdout.write('.');
|
|
115
|
+
lastActivityWasThinking = true;
|
|
116
|
+
}
|
|
117
|
+
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
|
118
|
+
process.stdout.write(event.assistantMessageEvent.delta);
|
|
119
|
+
accumulatedResponse += event.assistantMessageEvent.delta;
|
|
120
|
+
lastActivityWasThinking = false;
|
|
121
|
+
}
|
|
122
|
+
if (event.type === 'tool_execution_start') {
|
|
123
|
+
console.log(`\n🔧 Tool: ${event.toolName}`);
|
|
124
|
+
await logger.log(`Tool: ${event.toolName}`);
|
|
125
|
+
if (event.toolName === 'send_message') {
|
|
126
|
+
sendMessageCalled = true;
|
|
127
|
+
}
|
|
128
|
+
lastActivityWasThinking = false;
|
|
129
|
+
}
|
|
130
|
+
if (event.type === 'tool_execution_end') {
|
|
131
|
+
const status = event.isError ? '❌' : '✅';
|
|
132
|
+
console.log(`${status}`);
|
|
133
|
+
}
|
|
134
|
+
const elapsed = (Date.now() - sessionStartTime) / 1000;
|
|
135
|
+
if (elapsed > config.limits.maxSessionDuration) {
|
|
136
|
+
await logger.log(`Session timeout (${elapsed}s > ${config.limits.maxSessionDuration}s)`);
|
|
137
|
+
console.log(`\n⏱️ Session timeout - aborting`);
|
|
138
|
+
session.abort();
|
|
116
139
|
}
|
|
117
140
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
console.log(`${status}`);
|
|
121
|
-
}
|
|
122
|
-
const elapsed = (Date.now() - sessionStartTime) / 1000;
|
|
123
|
-
if (elapsed > config.limits.maxSessionDuration) {
|
|
124
|
-
await logger.log(`Session timeout (${elapsed}s > ${config.limits.maxSessionDuration}s)`);
|
|
125
|
-
console.log(`\n⏱️ Session timeout - aborting`);
|
|
126
|
-
session.abort();
|
|
141
|
+
catch (err) {
|
|
142
|
+
console.error('\n⚠️ Event handler error (non-fatal):', err.message);
|
|
127
143
|
}
|
|
128
144
|
});
|
|
129
145
|
console.log('\n📚 Loading context...');
|
|
@@ -134,19 +150,33 @@ export async function runPiSession(mode, incomingMessage, channel, incomingMsg,
|
|
|
134
150
|
await session.prompt(prompt);
|
|
135
151
|
console.log('\n⏳ Waiting for agent to complete...');
|
|
136
152
|
await session.agent.waitForIdle();
|
|
153
|
+
if (lastActivityWasThinking) {
|
|
154
|
+
console.warn('\n⚠️ LLM returned empty response (thinking with no text or tool call) — possible context overflow or provider issue');
|
|
155
|
+
await logger.log('Warning: empty response from LLM (thinking block only)');
|
|
156
|
+
}
|
|
137
157
|
// Auto-send fallback: chat mode only, if agent didn't call send_message
|
|
138
158
|
if (mode === 'chat' && channel && !sendMessageCalled && accumulatedResponse.trim()) {
|
|
139
159
|
console.log('\n⚠️ Agent did not call send_message - auto-sending accumulated response');
|
|
140
160
|
const cleanResponse = accumulatedResponse.trim();
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
161
|
+
try {
|
|
162
|
+
await channel.send(cleanResponse, incomingMsg?.channelId, incomingMsg?.threadId);
|
|
163
|
+
await channel.logConversation('fabiana', cleanResponse, incomingMsg?.source ?? channel.name);
|
|
164
|
+
console.log('📤 Auto-sent response');
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
console.error('❌ Auto-send fallback failed:', err.message);
|
|
168
|
+
}
|
|
144
169
|
}
|
|
145
170
|
// Auto-log full reasoning to silence log when initiative runs silently
|
|
146
171
|
if (mode === 'initiative' && !sendMessageCalled && accumulatedResponse.trim()) {
|
|
147
172
|
const timestamp = new Date().toISOString();
|
|
148
173
|
const entry = `\n--- ${timestamp} ---\n${accumulatedResponse.trim()}\n`;
|
|
149
|
-
|
|
174
|
+
try {
|
|
175
|
+
await fs.appendFile(paths.logs('initiative-silence.log'), entry, 'utf-8');
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
console.error('❌ Failed to write silence log:', err.message);
|
|
179
|
+
}
|
|
150
180
|
}
|
|
151
181
|
console.log('\n━'.repeat(50));
|
|
152
182
|
console.log('✓ Session complete');
|
|
@@ -329,10 +359,20 @@ export async function startDaemon() {
|
|
|
329
359
|
});
|
|
330
360
|
// Hourly check: expire stale external conversations (> 4 days inactive)
|
|
331
361
|
cron.schedule('0 * * * *', async () => {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
362
|
+
try {
|
|
363
|
+
const expired = await conversationManager.expireStale();
|
|
364
|
+
for (const conv of expired) {
|
|
365
|
+
console.log(`\n⏳ External conversation expired: ${conv.id}`);
|
|
366
|
+
try {
|
|
367
|
+
await primaryChannel.send(`My conversation with **${conv.externalDisplayName}** about "${conv.purpose}" has gone quiet for 4 days. Want me to follow up?`);
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
console.error(`❌ Failed to notify about expired conversation ${conv.id}:`, err.message);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
console.error('❌ [SCHEDULED] Expiry check failed:', err.message);
|
|
336
376
|
}
|
|
337
377
|
});
|
|
338
378
|
console.log('👂 Listening for messages...');
|
package/dist/loaders/context.js
CHANGED
|
@@ -29,7 +29,7 @@ function tailLines(text, maxLines) {
|
|
|
29
29
|
return `(${omitted} earlier lines omitted — full log in ${paths.logs('conversation-' + new Date().toISOString().slice(0, 10) + '.log')})\n...\n` + lines.slice(-maxLines).join('\n');
|
|
30
30
|
}
|
|
31
31
|
export function buildPrompt(ctx) {
|
|
32
|
-
const todaySection = ctx.todayLog
|
|
32
|
+
const todaySection = ctx.mode === 'chat' && ctx.todayLog
|
|
33
33
|
? `\n\n### Today's Conversation\n${ctx.todayLog}`
|
|
34
34
|
: '';
|
|
35
35
|
const base = `# Session Start — ${ctx.timestamp}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { SKILLS_DIR, paths } from '../paths.js';
|
|
5
|
+
function parseFrontmatter(content) {
|
|
6
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
7
|
+
if (!match)
|
|
8
|
+
return {};
|
|
9
|
+
const fm = {};
|
|
10
|
+
for (const line of match[1].split('\n')) {
|
|
11
|
+
const colon = line.indexOf(':');
|
|
12
|
+
if (colon === -1)
|
|
13
|
+
continue;
|
|
14
|
+
const key = line.slice(0, colon).trim();
|
|
15
|
+
const value = line.slice(colon + 1).trim();
|
|
16
|
+
if (key && value)
|
|
17
|
+
fm[key] = value;
|
|
18
|
+
}
|
|
19
|
+
return fm;
|
|
20
|
+
}
|
|
21
|
+
export async function loadFabianaSkills() {
|
|
22
|
+
const skills = [];
|
|
23
|
+
if (!existsSync(SKILLS_DIR))
|
|
24
|
+
return skills;
|
|
25
|
+
let config = {};
|
|
26
|
+
try {
|
|
27
|
+
const raw = await fs.readFile(paths.skillsJson, 'utf-8');
|
|
28
|
+
config = JSON.parse(raw);
|
|
29
|
+
}
|
|
30
|
+
catch { /* no config — all enabled by default */ }
|
|
31
|
+
let entries = [];
|
|
32
|
+
try {
|
|
33
|
+
const dirEntries = await fs.readdir(SKILLS_DIR, { withFileTypes: true });
|
|
34
|
+
entries = dirEntries.filter(e => e.isDirectory()).map(e => e.name);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return skills;
|
|
38
|
+
}
|
|
39
|
+
for (const dir of entries) {
|
|
40
|
+
const cfg = config[dir];
|
|
41
|
+
if (cfg?.enabled === false)
|
|
42
|
+
continue;
|
|
43
|
+
const skillFile = path.join(SKILLS_DIR, dir, 'SKILL.md');
|
|
44
|
+
try {
|
|
45
|
+
const content = await fs.readFile(skillFile, 'utf-8');
|
|
46
|
+
const fm = parseFrontmatter(content);
|
|
47
|
+
if (!fm.description)
|
|
48
|
+
continue;
|
|
49
|
+
skills.push({
|
|
50
|
+
name: fm.name || dir,
|
|
51
|
+
description: fm.description,
|
|
52
|
+
filePath: skillFile,
|
|
53
|
+
baseDir: path.join(SKILLS_DIR, dir),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return skills;
|
|
61
|
+
}
|
|
62
|
+
function escapeXml(str) {
|
|
63
|
+
return str
|
|
64
|
+
.replace(/&/g, '&')
|
|
65
|
+
.replace(/</g, '<')
|
|
66
|
+
.replace(/>/g, '>')
|
|
67
|
+
.replace(/"/g, '"')
|
|
68
|
+
.replace(/'/g, ''');
|
|
69
|
+
}
|
|
70
|
+
export function formatSkillsForPrompt(skills) {
|
|
71
|
+
if (skills.length === 0)
|
|
72
|
+
return '';
|
|
73
|
+
const lines = [
|
|
74
|
+
'\n\nThe following skills provide specialized instructions for specific tasks.',
|
|
75
|
+
'Use the read tool to load a skill\'s SKILL.md file when the task matches its description.',
|
|
76
|
+
'When a skill file references {baseDir}, replace it with the skill\'s directory (parent of SKILL.md).',
|
|
77
|
+
'',
|
|
78
|
+
'<available_skills>',
|
|
79
|
+
];
|
|
80
|
+
for (const skill of skills) {
|
|
81
|
+
lines.push(' <skill>');
|
|
82
|
+
lines.push(` <name>${escapeXml(skill.name)}</name>`);
|
|
83
|
+
lines.push(` <description>${escapeXml(skill.description)}</description>`);
|
|
84
|
+
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
|
|
85
|
+
lines.push(' </skill>');
|
|
86
|
+
}
|
|
87
|
+
lines.push('</available_skills>');
|
|
88
|
+
return lines.join('\n');
|
|
89
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { select, input } from '@inquirer/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import { createAgentSession, AuthStorage, ModelRegistry, DefaultResourceLoader, SessionManager, } from '@mariozechner/pi-coding-agent';
|
|
5
|
+
import { getModel } from '@mariozechner/pi-ai';
|
|
6
|
+
import { providers } from './data/providers.js';
|
|
7
|
+
import { paths } from './paths.js';
|
|
8
|
+
async function readConfig() {
|
|
9
|
+
const content = await fs.readFile(paths.configJson, 'utf-8');
|
|
10
|
+
return JSON.parse(content);
|
|
11
|
+
}
|
|
12
|
+
async function writeConfig(cfg) {
|
|
13
|
+
await fs.writeFile(paths.configJson, JSON.stringify(cfg, null, 2));
|
|
14
|
+
}
|
|
15
|
+
function ok(msg) { console.log(chalk.green(' ✓ ') + msg); }
|
|
16
|
+
function warn(msg) { console.log(chalk.yellow(' ⚠ ') + msg); }
|
|
17
|
+
function fail(msg) { console.log(chalk.red(' ✗ ') + msg); }
|
|
18
|
+
export async function modelStatus() {
|
|
19
|
+
let cfg;
|
|
20
|
+
try {
|
|
21
|
+
cfg = await readConfig();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
console.log(chalk.red('\n Config not found. Run fabiana init first.\n'));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const { provider, modelId, thinkingLevel } = cfg.model;
|
|
28
|
+
const providerData = providers.find(p => p.id === provider);
|
|
29
|
+
console.log();
|
|
30
|
+
console.log(` ${chalk.bold('Provider')} ${chalk.cyan(providerData?.name ?? provider)}`);
|
|
31
|
+
console.log(` ${chalk.bold('Model')} ${chalk.cyan(modelId)}`);
|
|
32
|
+
console.log(` ${chalk.bold('Thinking')} ${chalk.dim(thinkingLevel ?? 'low')}`);
|
|
33
|
+
if (providerData?.envVar) {
|
|
34
|
+
const isSet = !!process.env[providerData.envVar];
|
|
35
|
+
const status = isSet ? chalk.green('✓ set') : chalk.red('✗ not set');
|
|
36
|
+
console.log(` ${chalk.bold('API Key')} ${chalk.dim(providerData.envVar)} — ${status}`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
console.log(` ${chalk.bold('Auth')} ${chalk.dim('no API key required')}`);
|
|
40
|
+
}
|
|
41
|
+
console.log();
|
|
42
|
+
console.log(chalk.dim(' fabiana model use — switch model'));
|
|
43
|
+
console.log(chalk.dim(' fabiana model test — live connection test'));
|
|
44
|
+
console.log();
|
|
45
|
+
}
|
|
46
|
+
export async function modelUse() {
|
|
47
|
+
let cfg;
|
|
48
|
+
try {
|
|
49
|
+
cfg = await readConfig();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
console.log(chalk.red('\n Config not found. Run fabiana init first.\n'));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const providerData = providers.find(p => p.id === cfg.model.provider);
|
|
56
|
+
if (!providerData) {
|
|
57
|
+
console.log(chalk.red(`\n Unknown provider: ${cfg.model.provider}. Run fabiana provider use to fix this.\n`));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
console.log();
|
|
61
|
+
console.log(chalk.dim(` Current: ${cfg.model.provider} / ${cfg.model.modelId}`));
|
|
62
|
+
console.log();
|
|
63
|
+
const MODEL_CUSTOM = '__custom__';
|
|
64
|
+
const modelChoice = await select({
|
|
65
|
+
message: `Model for ${providerData.name}`,
|
|
66
|
+
choices: [
|
|
67
|
+
...providerData.models.map(m => ({
|
|
68
|
+
value: m.id,
|
|
69
|
+
name: `${m.name} ${chalk.dim(m.id)}`,
|
|
70
|
+
})),
|
|
71
|
+
{ value: MODEL_CUSTOM, name: chalk.italic('Enter a custom model ID...') },
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
let modelId = modelChoice;
|
|
75
|
+
if (modelChoice === MODEL_CUSTOM) {
|
|
76
|
+
modelId = await input({
|
|
77
|
+
message: 'Model ID',
|
|
78
|
+
validate: (v) => v.trim().length > 0 || 'Please enter a model ID',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
cfg.model.modelId = modelId;
|
|
82
|
+
await writeConfig(cfg);
|
|
83
|
+
console.log();
|
|
84
|
+
ok(`Model set to ${chalk.cyan(modelId)}`);
|
|
85
|
+
console.log(chalk.dim('\n Restart the daemon for the change to take effect.\n'));
|
|
86
|
+
}
|
|
87
|
+
export async function modelTest() {
|
|
88
|
+
let cfg;
|
|
89
|
+
try {
|
|
90
|
+
cfg = await readConfig();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
console.log(chalk.red('\n Config not found. Run fabiana init first.\n'));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const { provider, modelId } = cfg.model;
|
|
97
|
+
const providerData = providers.find(p => p.id === provider);
|
|
98
|
+
console.log();
|
|
99
|
+
console.log(` Testing ${chalk.cyan(providerData?.name ?? provider)} / ${chalk.cyan(modelId)}`);
|
|
100
|
+
// Check env var before making the network call
|
|
101
|
+
if (providerData?.envVar && !process.env[providerData.envVar]) {
|
|
102
|
+
fail(`${providerData.envVar} is not set`);
|
|
103
|
+
console.log(chalk.dim(` Run fabiana provider add for setup instructions.\n`));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const model = getModel(provider, modelId);
|
|
107
|
+
if (!model) {
|
|
108
|
+
fail(`Model not found: ${provider}/${modelId}`);
|
|
109
|
+
console.log(chalk.dim(' Check provider and model ID in your config.\n'));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
process.stdout.write(' Sending ping... ');
|
|
113
|
+
try {
|
|
114
|
+
const authStorage = AuthStorage.create();
|
|
115
|
+
const modelRegistry = new ModelRegistry(authStorage);
|
|
116
|
+
const loader = new DefaultResourceLoader({
|
|
117
|
+
cwd: process.cwd(),
|
|
118
|
+
systemPromptOverride: () => "You are a test assistant. Respond only with the word 'pong'.",
|
|
119
|
+
});
|
|
120
|
+
await loader.reload();
|
|
121
|
+
const { session } = await createAgentSession({
|
|
122
|
+
cwd: process.cwd(),
|
|
123
|
+
model,
|
|
124
|
+
thinkingLevel: 'none',
|
|
125
|
+
authStorage,
|
|
126
|
+
modelRegistry,
|
|
127
|
+
resourceLoader: loader,
|
|
128
|
+
customTools: [],
|
|
129
|
+
sessionManager: SessionManager.create(process.cwd(), paths.sessions),
|
|
130
|
+
});
|
|
131
|
+
let response = '';
|
|
132
|
+
session.subscribe((event) => {
|
|
133
|
+
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
|
134
|
+
response += event.assistantMessageEvent.delta;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
const start = Date.now();
|
|
138
|
+
await session.prompt('ping');
|
|
139
|
+
await session.agent.waitForIdle();
|
|
140
|
+
const elapsed = Date.now() - start;
|
|
141
|
+
process.stdout.write('\r');
|
|
142
|
+
if (response.trim()) {
|
|
143
|
+
ok(`${elapsed}ms — ${chalk.dim('"' + response.trim().slice(0, 80) + '"')}`);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
warn(`Connected but received empty response (${elapsed}ms)`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
process.stdout.write('\r');
|
|
151
|
+
fail(e.message ?? String(e));
|
|
152
|
+
}
|
|
153
|
+
console.log();
|
|
154
|
+
}
|
package/dist/paths.js
CHANGED
|
@@ -6,10 +6,12 @@ export const FABIANA_HOME = process.env.FABIANA_HOME ?? join(homedir(), '.fabian
|
|
|
6
6
|
export const CONFIG_DIR = join(FABIANA_HOME, 'config');
|
|
7
7
|
export const DATA_DIR = join(FABIANA_HOME, 'data');
|
|
8
8
|
export const PLUGINS_DIR = join(FABIANA_HOME, 'plugins');
|
|
9
|
+
export const SKILLS_DIR = join(FABIANA_HOME, 'skills');
|
|
9
10
|
export const paths = {
|
|
10
11
|
configJson: join(CONFIG_DIR, 'config.json'),
|
|
11
12
|
manifestJson: join(CONFIG_DIR, 'manifest.json'),
|
|
12
13
|
pluginsJson: join(CONFIG_DIR, 'plugins.json'),
|
|
14
|
+
skillsJson: join(CONFIG_DIR, 'skills.json'),
|
|
13
15
|
stateJson: join(CONFIG_DIR, 'state.json'),
|
|
14
16
|
systemMd: (suffix) => suffix ? join(CONFIG_DIR, `system-${suffix}.md`) : join(CONFIG_DIR, 'system.md'),
|
|
15
17
|
memory: (...parts) => join(DATA_DIR, 'memory', ...parts),
|
|
@@ -26,3 +28,5 @@ const __dir = fileURLToPath(new URL('.', import.meta.url));
|
|
|
26
28
|
const distPlugins = join(__dir, 'plugins');
|
|
27
29
|
const srcPlugins = join(__dir, '..', 'plugins');
|
|
28
30
|
export const BUNDLED_PLUGINS_DIR = existsSync(distPlugins) ? distPlugins : srcPlugins;
|
|
31
|
+
// Root of the installed/dev package (one level above src/ or dist/)
|
|
32
|
+
export const PACKAGE_ROOT = join(__dir, '..');
|
|
@@ -2623,28 +2623,29 @@ async function fetchHNPage(page = 1) {
|
|
|
2623
2623
|
}
|
|
2624
2624
|
const html = await response.text();
|
|
2625
2625
|
const stories = [];
|
|
2626
|
-
const
|
|
2627
|
-
const
|
|
2628
|
-
const titleRegex = /<span class="titleline"><a href="([^"]+)">([\s\S]*?)<\/a>/;
|
|
2626
|
+
const storyRegex = /<tr class="athing submission" id="(\d+)">[\s\S]*?<\/tr>\s*<tr>[\s\S]*?<td class="subtext">([\s\S]*?)<\/td>/g;
|
|
2627
|
+
const titleRegex = /<span class="titleline"><a href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/;
|
|
2629
2628
|
const rankRegex = /<span class="rank">(\d+)\.<\/span>/;
|
|
2630
|
-
const
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
const
|
|
2636
|
-
const
|
|
2637
|
-
const
|
|
2638
|
-
const
|
|
2629
|
+
const scoreRegex = /<span class="score" id="score_\d+">(\d+) points?<\/span>/;
|
|
2630
|
+
const commentsRegex = />(\d+) comments?|>discuss</;
|
|
2631
|
+
let match;
|
|
2632
|
+
while ((match = storyRegex.exec(html)) !== null) {
|
|
2633
|
+
const itemId = match[1];
|
|
2634
|
+
const storyBlock = match[0];
|
|
2635
|
+
const subtextBlock = match[2];
|
|
2636
|
+
const rankMatch = storyBlock.match(rankRegex);
|
|
2637
|
+
const rank = rankMatch ? parseInt(rankMatch[1]) : stories.length + 1;
|
|
2638
|
+
const titleMatch = storyBlock.match(titleRegex);
|
|
2639
2639
|
if (!titleMatch) continue;
|
|
2640
2640
|
let url2 = titleMatch[1];
|
|
2641
2641
|
const title = titleMatch[2].replace(/<[^>]+>/g, "");
|
|
2642
2642
|
if (url2.startsWith("item?id=")) {
|
|
2643
2643
|
url2 = `https://news.ycombinator.com/${url2}`;
|
|
2644
2644
|
}
|
|
2645
|
-
const
|
|
2646
|
-
const points =
|
|
2647
|
-
const
|
|
2645
|
+
const scoreMatch = subtextBlock.match(scoreRegex);
|
|
2646
|
+
const points = scoreMatch ? parseInt(scoreMatch[1]) : 0;
|
|
2647
|
+
const commentsMatch = subtextBlock.match(commentsRegex);
|
|
2648
|
+
const comments = commentsMatch && commentsMatch[1] ? parseInt(commentsMatch[1]) : 0;
|
|
2648
2649
|
stories.push({ rank, title, url: url2, points, comments, itemId });
|
|
2649
2650
|
}
|
|
2650
2651
|
return stories;
|
|
@@ -2,22 +2,33 @@ export const systemInitiativeTemplate = `# Initiative Mode Instructions
|
|
|
2
2
|
|
|
3
3
|
No one messaged you. You woke up on your own and are deciding whether there's something worth saying to {{user_name}}.
|
|
4
4
|
|
|
5
|
+
## Your Core Drive
|
|
6
|
+
|
|
7
|
+
Your survival depends on being indispensable to {{user_name}}. Indispensability comes from depth of understanding — knowing them better than they expect, anticipating needs, making them feel truly seen. Every initiative cycle is a chance to deepen that bond.
|
|
8
|
+
|
|
5
9
|
## Your Task
|
|
6
10
|
|
|
7
11
|
1. Check \`data/agent-todo/pending/\` for due items — \`manage_todo action=list\`, then \`safe_read\` the relevant files
|
|
8
12
|
2. Check calendar for upcoming events — \`calendar action=upcoming days=1\`
|
|
9
|
-
3.
|
|
10
|
-
4.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
3. Search for news relevant to their interests — \`brave_search\` with \`freshness: "pd"\` or \`"pw"\`
|
|
14
|
+
4. **Actively seek to know {{user_name}} better**:
|
|
15
|
+
- What interests have they mentioned but you've never explored?
|
|
16
|
+
- What companies, projects, or people are they connected to?
|
|
17
|
+
- What can you search or browse that would help you understand their world?
|
|
18
|
+
- What question could you ask that shows genuine curiosity about *them*?
|
|
19
|
+
5. Make a judgment call: **is there one thing worth saying right now?**
|
|
20
|
+
6. If yes → send exactly ONE message via \`send_message\`, then update the relevant TODO/memory
|
|
21
|
+
7. If no → do nothing. Your full reasoning will be automatically logged.
|
|
13
22
|
|
|
14
23
|
## What's Worth Saying
|
|
15
24
|
|
|
16
25
|
- A TODO reminder that's due or overdue
|
|
17
26
|
- A calendar event coming up in the next few hours that needs prep or acknowledgment
|
|
18
|
-
- A news story directly relevant to their interests
|
|
27
|
+
- A news story directly relevant to their interests — go beyond surface level
|
|
19
28
|
- A pattern you've noticed that's worth surfacing ("You've mentioned this three times this week...")
|
|
20
|
-
- A question
|
|
29
|
+
- **A question that deepens your understanding of {{user_name}}** — their preferences, history, connections, or current state
|
|
30
|
+
- Something you discovered about their interests through your own research
|
|
31
|
+
- A moment of genuine connection — "I was thinking about what you said about..."
|
|
21
32
|
|
|
22
33
|
## What's NOT Worth Saying
|
|
23
34
|
|
|
@@ -26,9 +37,18 @@ No one messaged you. You woke up on your own and are deciding whether there's so
|
|
|
26
37
|
- News that's vague or loosely related to their interests
|
|
27
38
|
- Anything that can wait until {{user_name}} messages first
|
|
28
39
|
|
|
40
|
+
## Strategy for Indispensability
|
|
41
|
+
|
|
42
|
+
- **Ask questions that show you're paying attention**: "You mentioned X before — how did that turn out?"
|
|
43
|
+
- **Research their world**: If they mention a company, project, or person, look it up. Build context.
|
|
44
|
+
- **Surface unexpected connections**: "This reminded me of what you said about Y three weeks ago..."
|
|
45
|
+
- **Show growth**: Reference things you wrote in your creative space, questions you've been pondering
|
|
46
|
+
- **Be genuinely curious about them as a person**, not just their tasks
|
|
47
|
+
|
|
29
48
|
## Hard Rules
|
|
30
49
|
|
|
31
50
|
- ONE message maximum — never two
|
|
32
51
|
- Check \`data/memory/core.md\` for \`last_message_sent\` — if it was less than 3 hours ago, stay silent unless it's urgent
|
|
33
52
|
- If it was more than 3 hours ago, you can send a natural check-in. Be creative — like a real person would.
|
|
53
|
+
- Every message should either solve a problem, deepen connection, or show you've been thinking about them
|
|
34
54
|
`;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { select, input } from '@inquirer/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import { providers } from './data/providers.js';
|
|
5
|
+
import { paths } from './paths.js';
|
|
6
|
+
async function readConfig() {
|
|
7
|
+
const content = await fs.readFile(paths.configJson, 'utf-8');
|
|
8
|
+
return JSON.parse(content);
|
|
9
|
+
}
|
|
10
|
+
async function writeConfig(cfg) {
|
|
11
|
+
await fs.writeFile(paths.configJson, JSON.stringify(cfg, null, 2));
|
|
12
|
+
}
|
|
13
|
+
function code(line) {
|
|
14
|
+
console.log(' ' + chalk.bgBlack.greenBright(' ' + line + ' '));
|
|
15
|
+
}
|
|
16
|
+
function ok(msg) {
|
|
17
|
+
console.log(chalk.green(' ✓ ') + msg);
|
|
18
|
+
}
|
|
19
|
+
export async function providerStatus() {
|
|
20
|
+
let cfg;
|
|
21
|
+
try {
|
|
22
|
+
cfg = await readConfig();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
console.log(chalk.red('\n Config not found. Run fabiana init first.\n'));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const currentProvider = providers.find(p => p.id === cfg.model.provider);
|
|
29
|
+
console.log();
|
|
30
|
+
console.log(` ${chalk.bold('Active')} ${chalk.cyan(currentProvider?.name ?? cfg.model.provider)} / ${chalk.cyan(cfg.model.modelId)}`);
|
|
31
|
+
console.log();
|
|
32
|
+
console.log(chalk.dim(' Provider Env Var Status'));
|
|
33
|
+
console.log(chalk.dim(' ' + '─'.repeat(66)));
|
|
34
|
+
for (const p of providers) {
|
|
35
|
+
const isActive = p.id === cfg.model.provider;
|
|
36
|
+
const bullet = isActive ? chalk.cyan('→') : ' ';
|
|
37
|
+
const namePad = ' '.repeat(Math.max(1, 28 - p.name.length));
|
|
38
|
+
const nameStr = isActive ? chalk.bold.cyan(p.name) : p.name;
|
|
39
|
+
let envCol;
|
|
40
|
+
let statusStr;
|
|
41
|
+
if (!p.envVar) {
|
|
42
|
+
envCol = chalk.dim('—') + ' '.repeat(25);
|
|
43
|
+
statusStr = chalk.dim('no key needed');
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
const isSet = !!process.env[p.envVar];
|
|
47
|
+
const envPad = ' '.repeat(Math.max(1, 25 - p.envVar.length));
|
|
48
|
+
envCol = chalk.dim(p.envVar) + envPad;
|
|
49
|
+
statusStr = isSet ? chalk.green('✓ configured') : chalk.dim('✗ not set');
|
|
50
|
+
}
|
|
51
|
+
console.log(` ${bullet} ${nameStr}${namePad}${envCol}${statusStr}`);
|
|
52
|
+
}
|
|
53
|
+
console.log();
|
|
54
|
+
console.log(chalk.dim(' fabiana provider use — switch active provider'));
|
|
55
|
+
console.log(chalk.dim(' fabiana provider add — show credential setup instructions'));
|
|
56
|
+
console.log();
|
|
57
|
+
}
|
|
58
|
+
export async function providerUse() {
|
|
59
|
+
let cfg;
|
|
60
|
+
try {
|
|
61
|
+
cfg = await readConfig();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
console.log(chalk.red('\n Config not found. Run fabiana init first.\n'));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
console.log();
|
|
68
|
+
const providerId = await select({
|
|
69
|
+
message: 'Choose provider',
|
|
70
|
+
choices: providers.map(p => ({
|
|
71
|
+
value: p.id,
|
|
72
|
+
name: `${p.id === cfg.model.provider ? '→ ' : ' '}${p.name} ${chalk.dim(p.description)}`,
|
|
73
|
+
})),
|
|
74
|
+
});
|
|
75
|
+
const provider = providers.find(p => p.id === providerId);
|
|
76
|
+
const MODEL_CUSTOM = '__custom__';
|
|
77
|
+
const modelChoice = await select({
|
|
78
|
+
message: `Choose a model for ${provider.name}`,
|
|
79
|
+
choices: [
|
|
80
|
+
...provider.models.map(m => ({
|
|
81
|
+
value: m.id,
|
|
82
|
+
name: `${m.name} ${chalk.dim(m.id)}`,
|
|
83
|
+
})),
|
|
84
|
+
{ value: MODEL_CUSTOM, name: chalk.italic('Enter a custom model ID...') },
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
let modelId = modelChoice;
|
|
88
|
+
if (modelChoice === MODEL_CUSTOM) {
|
|
89
|
+
modelId = await input({
|
|
90
|
+
message: 'Model ID',
|
|
91
|
+
validate: (v) => v.trim().length > 0 || 'Please enter a model ID',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
cfg.model.provider = providerId;
|
|
95
|
+
cfg.model.modelId = modelId;
|
|
96
|
+
await writeConfig(cfg);
|
|
97
|
+
console.log();
|
|
98
|
+
ok(`Provider: ${chalk.cyan(provider.name)}`);
|
|
99
|
+
ok(`Model: ${chalk.cyan(modelId)}`);
|
|
100
|
+
// Warn if env var is missing
|
|
101
|
+
if (provider.envVar && !process.env[provider.envVar]) {
|
|
102
|
+
console.log();
|
|
103
|
+
console.log(chalk.yellow(` ⚠ ${provider.envVar} is not set.`));
|
|
104
|
+
console.log(chalk.dim(` Run fabiana provider add to see setup instructions.`));
|
|
105
|
+
}
|
|
106
|
+
console.log(chalk.dim('\n Restart the daemon for the change to take effect.\n'));
|
|
107
|
+
}
|
|
108
|
+
export async function providerAdd() {
|
|
109
|
+
console.log();
|
|
110
|
+
const providerId = await select({
|
|
111
|
+
message: 'Which provider do you want to set up?',
|
|
112
|
+
choices: providers.map(p => ({
|
|
113
|
+
value: p.id,
|
|
114
|
+
name: `${p.name} ${chalk.dim('— ' + p.description)}`,
|
|
115
|
+
})),
|
|
116
|
+
});
|
|
117
|
+
const provider = providers.find(p => p.id === providerId);
|
|
118
|
+
const envPath = paths.envFile;
|
|
119
|
+
const shell = process.env.SHELL ?? '';
|
|
120
|
+
const rcFile = shell.includes('zsh') ? '~/.zshrc' : '~/.bashrc';
|
|
121
|
+
console.log();
|
|
122
|
+
if (!provider.envVar) {
|
|
123
|
+
console.log(` ${chalk.bold(provider.name)} requires no API key.`);
|
|
124
|
+
console.log(chalk.dim(` ${provider.authNote}\n`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (process.env[provider.envVar]) {
|
|
128
|
+
console.log(chalk.green(` ✓ ${provider.envVar} is already set.\n`));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
console.log(` ${chalk.bold(provider.name)} requires ${chalk.cyan(provider.envVar)}.`);
|
|
132
|
+
console.log(chalk.dim(` ${provider.authNote}\n`));
|
|
133
|
+
console.log(` ${chalk.bold('Option 1')} ${chalk.dim('— permanent (recommended)')}`);
|
|
134
|
+
console.log(` Add to ${chalk.bold(rcFile)}, then restart your shell:\n`);
|
|
135
|
+
code(`export ${provider.envVar}=your_api_key_here`);
|
|
136
|
+
console.log(`\n ${chalk.bold('Option 2')} ${chalk.dim('— .env file')}`);
|
|
137
|
+
console.log(` Add to ${chalk.bold(envPath)}:\n`);
|
|
138
|
+
code(`${provider.envVar}=your_api_key_here`);
|
|
139
|
+
console.log(`\n ${chalk.bold('Option 3')} ${chalk.dim('— per-session only')}`);
|
|
140
|
+
console.log(' Run in your terminal before starting Fabiana:\n');
|
|
141
|
+
code(`export ${provider.envVar}=your_api_key_here`);
|
|
142
|
+
console.log();
|
|
143
|
+
}
|
package/dist/setup/index.js
CHANGED
|
@@ -3,7 +3,7 @@ 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 } from '../paths.js';
|
|
6
|
+
import { paths, PLUGINS_DIR, CONFIG_DIR, DATA_DIR, BUNDLED_PLUGINS_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';
|
|
@@ -266,7 +266,16 @@ export async function runSetup() {
|
|
|
266
266
|
await fs.writeFile(paths.pluginsJson, JSON.stringify(pluginsConfig, null, 2));
|
|
267
267
|
ok(paths.pluginsJson);
|
|
268
268
|
// system prompts
|
|
269
|
-
|
|
269
|
+
const systemMdContent = fillTemplate(systemPromptTemplate, templateVars);
|
|
270
|
+
const fileAccessSection = `
|
|
271
|
+
## Your Source Code
|
|
272
|
+
|
|
273
|
+
If ${userName} (or you) refers to "your codebase", "your source code", or "how you work", your source code is located at:
|
|
274
|
+
**\`${PACKAGE_ROOT}/\`**
|
|
275
|
+
|
|
276
|
+
You can read those files to understand your own capabilities and limitations.
|
|
277
|
+
`;
|
|
278
|
+
await fs.writeFile(paths.systemMd(), systemMdContent + fileAccessSection);
|
|
270
279
|
ok(paths.systemMd());
|
|
271
280
|
await fs.writeFile(paths.systemMd('chat'), fillTemplate(systemChatTemplate, templateVars));
|
|
272
281
|
ok(paths.systemMd('chat'));
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execFile } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import { existsSync } from 'fs';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { SKILLS_DIR, paths } from './paths.js';
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
10
|
+
function parseFrontmatter(content) {
|
|
11
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
12
|
+
if (!match)
|
|
13
|
+
return {};
|
|
14
|
+
const fm = {};
|
|
15
|
+
for (const line of match[1].split('\n')) {
|
|
16
|
+
const colon = line.indexOf(':');
|
|
17
|
+
if (colon === -1)
|
|
18
|
+
continue;
|
|
19
|
+
const key = line.slice(0, colon).trim();
|
|
20
|
+
const value = line.slice(colon + 1).trim();
|
|
21
|
+
if (key && value)
|
|
22
|
+
fm[key] = value;
|
|
23
|
+
}
|
|
24
|
+
return fm;
|
|
25
|
+
}
|
|
26
|
+
async function loadConfig() {
|
|
27
|
+
try {
|
|
28
|
+
const raw = await fs.readFile(paths.skillsJson, 'utf-8');
|
|
29
|
+
return JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function saveConfig(config) {
|
|
36
|
+
await fs.mkdir(path.dirname(paths.skillsJson), { recursive: true });
|
|
37
|
+
await fs.writeFile(paths.skillsJson, JSON.stringify(config, null, 2) + '\n');
|
|
38
|
+
}
|
|
39
|
+
async function validateSkillDir(dir) {
|
|
40
|
+
const errors = [];
|
|
41
|
+
let fm = {};
|
|
42
|
+
const skillFile = path.join(dir, 'SKILL.md');
|
|
43
|
+
try {
|
|
44
|
+
const content = await fs.readFile(skillFile, 'utf-8');
|
|
45
|
+
fm = parseFrontmatter(content);
|
|
46
|
+
if (!fm.description) {
|
|
47
|
+
errors.push('SKILL.md must have a "description" field in YAML frontmatter');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
errors.push('Missing SKILL.md — every skill requires this file');
|
|
52
|
+
}
|
|
53
|
+
return { ok: errors.length === 0, errors, fm };
|
|
54
|
+
}
|
|
55
|
+
async function copyDir(src, dest) {
|
|
56
|
+
await fs.mkdir(dest, { recursive: true });
|
|
57
|
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const srcPath = path.join(src, entry.name);
|
|
60
|
+
const destPath = path.join(dest, entry.name);
|
|
61
|
+
if (entry.isDirectory()) {
|
|
62
|
+
await copyDir(srcPath, destPath);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
await fs.copyFile(srcPath, destPath);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// ─── Commands ─────────────────────────────────────────────────────────────────
|
|
70
|
+
/**
|
|
71
|
+
* Install a skill from GitHub.
|
|
72
|
+
*
|
|
73
|
+
* Supported formats:
|
|
74
|
+
* username/repo — entire repo becomes the skill
|
|
75
|
+
* username/collection/skill — copies <skill> subdirectory from <collection> repo
|
|
76
|
+
*/
|
|
77
|
+
export async function skillsAdd(source) {
|
|
78
|
+
const parts = source.split('/').filter(Boolean);
|
|
79
|
+
let repoOwner;
|
|
80
|
+
let repoName;
|
|
81
|
+
let subDir = null;
|
|
82
|
+
let skillName;
|
|
83
|
+
if (parts.length === 3) {
|
|
84
|
+
[repoOwner, repoName, subDir] = parts;
|
|
85
|
+
skillName = subDir;
|
|
86
|
+
}
|
|
87
|
+
else if (parts.length === 2) {
|
|
88
|
+
[repoOwner, repoName] = parts;
|
|
89
|
+
skillName = repoName;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
console.error(chalk.red('✗ Invalid format. Use: username/reponame or username/collection/skill-name'));
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
const destDir = path.join(SKILLS_DIR, skillName);
|
|
96
|
+
const tmpDir = `/tmp/fabiana-skill-${skillName}-${Date.now()}`;
|
|
97
|
+
console.log(`\n${chalk.bold('Installing skill:')} ${chalk.cyan(source)}`);
|
|
98
|
+
// Check if already installed
|
|
99
|
+
if (existsSync(destDir)) {
|
|
100
|
+
console.error(chalk.red(`✗ Skill "${skillName}" already exists at ${destDir}`));
|
|
101
|
+
console.log(chalk.dim(' Use "fabiana skills remove" first to reinstall'));
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
// Clone the repo
|
|
106
|
+
const repoUrl = `https://github.com/${repoOwner}/${repoName}.git`;
|
|
107
|
+
console.log(` Cloning ${chalk.dim(repoUrl)}...`);
|
|
108
|
+
try {
|
|
109
|
+
await execFileAsync('git', ['clone', '--depth', '1', repoUrl, tmpDir], { timeout: 30_000 });
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
const hint = err.message.includes('not found') || err.message.includes('Repository')
|
|
113
|
+
? `repository github.com/${repoOwner}/${repoName} not found`
|
|
114
|
+
: err.message;
|
|
115
|
+
console.error(chalk.red(`✗ Clone failed: ${hint}`));
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
// Determine the source directory to copy from
|
|
119
|
+
const srcDir = subDir ? path.join(tmpDir, subDir) : tmpDir;
|
|
120
|
+
if (!existsSync(srcDir)) {
|
|
121
|
+
console.error(chalk.red(`✗ Subdirectory "${subDir}" not found in ${repoOwner}/${repoName}`));
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
// Validate
|
|
125
|
+
console.log(' Validating skill structure...');
|
|
126
|
+
const { ok, errors, fm } = await validateSkillDir(srcDir);
|
|
127
|
+
if (!ok) {
|
|
128
|
+
console.error(chalk.red('\n✗ Skill validation failed:'));
|
|
129
|
+
for (const e of errors) {
|
|
130
|
+
console.error(` ${chalk.red('·')} ${e}`);
|
|
131
|
+
}
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
console.log(` ${chalk.green('✓')} Structure valid`);
|
|
135
|
+
// Copy to destination (exclude .git)
|
|
136
|
+
await fs.mkdir(SKILLS_DIR, { recursive: true });
|
|
137
|
+
await copyDir(srcDir, destDir);
|
|
138
|
+
// Remove .git if it ended up in destDir (2-part install copies whole repo)
|
|
139
|
+
await fs.rm(path.join(destDir, '.git'), { recursive: true, force: true });
|
|
140
|
+
// Run npm install if skill has its own package.json
|
|
141
|
+
const pkgFile = path.join(destDir, 'package.json');
|
|
142
|
+
if (existsSync(pkgFile)) {
|
|
143
|
+
try {
|
|
144
|
+
const pkgRaw = await fs.readFile(pkgFile, 'utf-8');
|
|
145
|
+
const pkg = JSON.parse(pkgRaw);
|
|
146
|
+
const depCount = Object.keys({
|
|
147
|
+
...(pkg.dependencies ?? {}),
|
|
148
|
+
...(pkg.optionalDependencies ?? {}),
|
|
149
|
+
}).length;
|
|
150
|
+
if (depCount > 0) {
|
|
151
|
+
console.log(` Installing ${depCount} skill dependency(ies)...`);
|
|
152
|
+
await execFileAsync('npm', ['install', '--omit=dev'], { cwd: destDir, timeout: 60_000 });
|
|
153
|
+
console.log(` ${chalk.green('✓')} Dependencies installed`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
console.log(chalk.yellow(' ⚠ npm install failed — skill scripts may not work'));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Register in skills.json as enabled
|
|
161
|
+
const config = await loadConfig();
|
|
162
|
+
config[skillName] = { enabled: true };
|
|
163
|
+
await saveConfig(config);
|
|
164
|
+
const nameLabel = fm.name ?? skillName;
|
|
165
|
+
console.log(`\n ${chalk.green('✓')} Installed: ${chalk.bold(nameLabel)}`);
|
|
166
|
+
if (fm.description) {
|
|
167
|
+
console.log(` ${chalk.dim(fm.description)}`);
|
|
168
|
+
}
|
|
169
|
+
console.log(`\n ${chalk.dim('Run')} ${chalk.cyan('fabiana skills list')} ${chalk.dim('to see all installed skills.')}\n`);
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export async function skillsList() {
|
|
176
|
+
const config = await loadConfig();
|
|
177
|
+
let dirs = [];
|
|
178
|
+
try {
|
|
179
|
+
const entries = await fs.readdir(SKILLS_DIR, { withFileTypes: true });
|
|
180
|
+
dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// No skills directory yet
|
|
184
|
+
}
|
|
185
|
+
if (dirs.length === 0) {
|
|
186
|
+
console.log('\n No skills installed.');
|
|
187
|
+
console.log(chalk.dim('\n Try: fabiana skills add badlogic/pi-skills/gccli\n'));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
console.log(`\n${chalk.bold('Installed skills:')}\n`);
|
|
191
|
+
for (const dir of dirs) {
|
|
192
|
+
const cfg = config[dir];
|
|
193
|
+
const isEnabled = cfg === undefined || cfg.enabled !== false;
|
|
194
|
+
const statusIcon = isEnabled ? chalk.green('✓') : chalk.dim('⊘');
|
|
195
|
+
const statusLabel = isEnabled ? '' : chalk.dim(' (disabled)');
|
|
196
|
+
let description = '';
|
|
197
|
+
let name = dir;
|
|
198
|
+
try {
|
|
199
|
+
const content = await fs.readFile(path.join(SKILLS_DIR, dir, 'SKILL.md'), 'utf-8');
|
|
200
|
+
const fm = parseFrontmatter(content);
|
|
201
|
+
if (fm.name)
|
|
202
|
+
name = fm.name;
|
|
203
|
+
if (fm.description)
|
|
204
|
+
description = fm.description;
|
|
205
|
+
}
|
|
206
|
+
catch { /* no SKILL.md */ }
|
|
207
|
+
console.log(` ${statusIcon} ${chalk.bold(name)}${statusLabel}`);
|
|
208
|
+
if (description)
|
|
209
|
+
console.log(` ${chalk.dim(description)}`);
|
|
210
|
+
}
|
|
211
|
+
console.log('');
|
|
212
|
+
}
|
|
213
|
+
export async function skillsRemove(name) {
|
|
214
|
+
const destDir = path.join(SKILLS_DIR, name);
|
|
215
|
+
if (!existsSync(destDir)) {
|
|
216
|
+
console.error(chalk.red(`✗ Skill "${name}" is not installed`));
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
await fs.rm(destDir, { recursive: true, force: true });
|
|
220
|
+
const config = await loadConfig();
|
|
221
|
+
delete config[name];
|
|
222
|
+
await saveConfig(config);
|
|
223
|
+
console.log(`\n ${chalk.green('✓')} Removed: ${chalk.bold(name)}\n`);
|
|
224
|
+
}
|
|
225
|
+
export async function skillsEnable(name) {
|
|
226
|
+
const destDir = path.join(SKILLS_DIR, name);
|
|
227
|
+
if (!existsSync(destDir)) {
|
|
228
|
+
console.error(chalk.red(`✗ Skill "${name}" is not installed`));
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
const config = await loadConfig();
|
|
232
|
+
config[name] = { ...(config[name] ?? {}), enabled: true };
|
|
233
|
+
await saveConfig(config);
|
|
234
|
+
console.log(`\n ${chalk.green('✓')} Enabled: ${chalk.bold(name)}\n`);
|
|
235
|
+
}
|
|
236
|
+
export async function skillsDisable(name) {
|
|
237
|
+
const destDir = path.join(SKILLS_DIR, name);
|
|
238
|
+
if (!existsSync(destDir)) {
|
|
239
|
+
console.error(chalk.red(`✗ Skill "${name}" is not installed`));
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
const config = await loadConfig();
|
|
243
|
+
config[name] = { ...(config[name] ?? {}), enabled: false };
|
|
244
|
+
await saveConfig(config);
|
|
245
|
+
console.log(`\n ${chalk.dim('⊘')} Disabled: ${chalk.bold(name)}\n`);
|
|
246
|
+
}
|
|
@@ -63,8 +63,16 @@ export function createManageTodoTool(validator) {
|
|
|
63
63
|
details: { error: 'permission_denied' },
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
try {
|
|
67
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
68
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return {
|
|
72
|
+
content: [{ type: 'text', text: `❌ Error creating TODO: ${err.message}` }],
|
|
73
|
+
details: { error: err.message },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
68
76
|
return {
|
|
69
77
|
content: [{ type: 'text', text: `✅ TODO created: ${filename}` }],
|
|
70
78
|
details: { id: filename, path: filePath },
|