fabiana 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/bin/fabiana.js +6 -0
  4. package/dist/backup.js +89 -0
  5. package/dist/channels/index.js +37 -0
  6. package/dist/channels/slack.js +96 -0
  7. package/dist/channels/telegram.js +70 -0
  8. package/dist/channels/types.js +1 -0
  9. package/dist/cli.js +84 -0
  10. package/dist/conversations/manager.js +144 -0
  11. package/dist/conversations/types.js +1 -0
  12. package/dist/daemon/index.js +419 -0
  13. package/dist/data/providers.js +134 -0
  14. package/dist/doctor.js +323 -0
  15. package/dist/loaders/context.js +72 -0
  16. package/dist/loaders/plugins.js +102 -0
  17. package/dist/paths.js +28 -0
  18. package/dist/plugins/brave-search/index.js +2692 -0
  19. package/dist/plugins/brave-search/package.json +9 -0
  20. package/dist/plugins/brave-search/plugin.json +11 -0
  21. package/dist/plugins/calendar/index.js +2720 -0
  22. package/dist/plugins/calendar/package.json +9 -0
  23. package/dist/plugins/calendar/plugin.json +13 -0
  24. package/dist/plugins/hackernews/index.js +2701 -0
  25. package/dist/plugins/hackernews/package.json +9 -0
  26. package/dist/plugins/hackernews/plugin.json +9 -0
  27. package/dist/plugins-cmd.js +269 -0
  28. package/dist/prompts/system-chat.js +26 -0
  29. package/dist/prompts/system-consolidate.js +49 -0
  30. package/dist/prompts/system-external.js +21 -0
  31. package/dist/prompts/system-initiative.js +34 -0
  32. package/dist/prompts/system.js +129 -0
  33. package/dist/setup/index.js +368 -0
  34. package/dist/telegram/poller.js +71 -0
  35. package/dist/tools/fetch-url.js +85 -0
  36. package/dist/tools/index.js +31 -0
  37. package/dist/tools/manage-todo.js +105 -0
  38. package/dist/tools/safe-edit.js +50 -0
  39. package/dist/tools/safe-read.js +35 -0
  40. package/dist/tools/safe-write.js +42 -0
  41. package/dist/tools/send-message.js +27 -0
  42. package/dist/tools/send-telegram.js +27 -0
  43. package/dist/tools/start-external-conversation.js +86 -0
  44. package/dist/utils/logger.js +34 -0
  45. package/dist/utils/permissions.js +68 -0
  46. package/package.json +55 -0
@@ -0,0 +1,50 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { FABIANA_HOME } from '../paths.js';
5
+ function resolve(filePath) {
6
+ return path.isAbsolute(filePath) ? filePath : path.join(FABIANA_HOME, filePath);
7
+ }
8
+ export function createSafeEditTool(validator) {
9
+ return {
10
+ name: 'safe_edit',
11
+ label: 'Edit File',
12
+ description: 'Edit a file by replacing exact text. oldText must match exactly. Paths are relative to ~/.fabiana.',
13
+ parameters: Type.Object({
14
+ path: Type.String({ description: 'File path relative to ~/.fabiana, or absolute' }),
15
+ oldText: Type.String({ description: 'Exact text to find and replace' }),
16
+ newText: Type.String({ description: 'New text to replace with' }),
17
+ }),
18
+ execute: async (_toolCallId, params) => {
19
+ const { path: filePath, oldText, newText } = params;
20
+ const resolved = resolve(filePath);
21
+ if (!validator.canEdit(resolved)) {
22
+ return {
23
+ content: [{ type: 'text', text: `❌ PERMISSION DENIED: Cannot edit ${filePath}` }],
24
+ details: { error: 'permission_denied' },
25
+ };
26
+ }
27
+ try {
28
+ const content = await fs.readFile(resolved, 'utf-8');
29
+ if (!content.includes(oldText)) {
30
+ return {
31
+ content: [{ type: 'text', text: `❌ Text not found in ${filePath}. No changes made.` }],
32
+ details: { error: 'text_not_found' },
33
+ };
34
+ }
35
+ const updated = content.replace(oldText, newText);
36
+ await fs.writeFile(resolved, updated, 'utf-8');
37
+ return {
38
+ content: [{ type: 'text', text: `✅ Edited ${filePath}` }],
39
+ details: { path: filePath },
40
+ };
41
+ }
42
+ catch (err) {
43
+ return {
44
+ content: [{ type: 'text', text: `❌ Error editing ${filePath}: ${err.message}` }],
45
+ details: { error: err.message },
46
+ };
47
+ }
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,35 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { FABIANA_HOME } from '../paths.js';
5
+ function resolve(filePath) {
6
+ return path.isAbsolute(filePath) ? filePath : path.join(FABIANA_HOME, filePath);
7
+ }
8
+ export function createSafeReadTool() {
9
+ return {
10
+ name: 'safe_read',
11
+ label: 'Read File',
12
+ description: 'Read the contents of any file. Paths are relative to the Fabiana home directory (~/.fabiana).',
13
+ parameters: Type.Object({
14
+ path: Type.String({ description: 'File path relative to ~/.fabiana, or absolute' }),
15
+ }),
16
+ execute: async (_toolCallId, params) => {
17
+ const { path: filePath } = params;
18
+ const resolved = resolve(filePath);
19
+ try {
20
+ const content = await fs.readFile(resolved, 'utf-8');
21
+ const lines = content.split('\n').length;
22
+ return {
23
+ content: [{ type: 'text', text: `File: ${filePath} (${lines} lines)\n\n${content}` }],
24
+ details: { path: filePath, lines },
25
+ };
26
+ }
27
+ catch (err) {
28
+ return {
29
+ content: [{ type: 'text', text: `❌ Error reading ${filePath}: ${err.message}` }],
30
+ details: { error: err.message },
31
+ };
32
+ }
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,42 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { FABIANA_HOME } from '../paths.js';
5
+ function resolve(filePath) {
6
+ return path.isAbsolute(filePath) ? filePath : path.join(FABIANA_HOME, filePath);
7
+ }
8
+ export function createSafeWriteTool(validator) {
9
+ return {
10
+ name: 'safe_write',
11
+ label: 'Write File',
12
+ description: 'Write content to a writable file. Creates file if it does not exist. Paths are relative to ~/.fabiana.',
13
+ parameters: Type.Object({
14
+ path: Type.String({ description: 'File path relative to ~/.fabiana, or absolute' }),
15
+ content: Type.String({ description: 'Content to write' }),
16
+ }),
17
+ execute: async (_toolCallId, params) => {
18
+ const { path: filePath, content } = params;
19
+ const resolved = resolve(filePath);
20
+ if (!validator.canWrite(resolved)) {
21
+ return {
22
+ content: [{ type: 'text', text: `❌ PERMISSION DENIED: Cannot write to ${filePath}` }],
23
+ details: { error: 'permission_denied' },
24
+ };
25
+ }
26
+ try {
27
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
28
+ await fs.writeFile(resolved, content, 'utf-8');
29
+ return {
30
+ content: [{ type: 'text', text: `✅ Written to ${filePath}` }],
31
+ details: { path: filePath },
32
+ };
33
+ }
34
+ catch (err) {
35
+ return {
36
+ content: [{ type: 'text', text: `❌ Error writing ${filePath}: ${err.message}` }],
37
+ details: { error: err.message },
38
+ };
39
+ }
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,27 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ export function createSendMessageTool(sendMessage) {
3
+ return {
4
+ name: 'send_message',
5
+ label: 'Send Message',
6
+ description: 'Send a message to the recipient. Use for responses and initiative messages. Supports Markdown formatting.',
7
+ parameters: Type.Object({
8
+ message: Type.String({ description: 'The message to send. Supports Markdown formatting.' }),
9
+ }),
10
+ execute: async (_toolCallId, params) => {
11
+ const { message } = params;
12
+ try {
13
+ await sendMessage(message);
14
+ return {
15
+ content: [{ type: 'text', text: `✅ Message sent` }],
16
+ details: { sent: true, length: message.length },
17
+ };
18
+ }
19
+ catch (err) {
20
+ return {
21
+ content: [{ type: 'text', text: `❌ Failed to send message: ${err.message}` }],
22
+ details: { error: err.message },
23
+ };
24
+ }
25
+ },
26
+ };
27
+ }
@@ -0,0 +1,27 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ export function createSendTelegramTool(sendMessage) {
3
+ return {
4
+ name: 'send_telegram',
5
+ label: 'Send Telegram Message',
6
+ description: 'Send a message to the human via Telegram. Use for responses and initiative messages.',
7
+ parameters: Type.Object({
8
+ message: Type.String({ description: 'The message to send. Supports Markdown formatting.' }),
9
+ }),
10
+ execute: async (_toolCallId, params) => {
11
+ const { message } = params;
12
+ try {
13
+ await sendMessage(message);
14
+ return {
15
+ content: [{ type: 'text', text: `✅ Message sent via Telegram` }],
16
+ details: { sent: true, length: message.length },
17
+ };
18
+ }
19
+ catch (err) {
20
+ return {
21
+ content: [{ type: 'text', text: `❌ Failed to send Telegram message: ${err.message}` }],
22
+ details: { error: err.message },
23
+ };
24
+ }
25
+ },
26
+ };
27
+ }
@@ -0,0 +1,86 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ export function createStartExternalConversationTool(channels, conversationManager) {
3
+ return {
4
+ name: 'start_external_conversation',
5
+ label: 'Start External Conversation',
6
+ description: 'Initiate a DM conversation with an external person on Slack. ' +
7
+ 'Creates the conversation state file, sends the opening message, and records the thread. ' +
8
+ 'Only works on enabled Slack channels.',
9
+ parameters: Type.Object({
10
+ channel: Type.String({
11
+ description: 'Which channel adapter to use. Currently only "slack" is supported.',
12
+ }),
13
+ userId: Type.String({
14
+ description: 'The Slack user ID of the person to message (e.g. U0123456).',
15
+ }),
16
+ displayName: Type.String({
17
+ description: 'Human-readable name of the person (used in conversation records).',
18
+ }),
19
+ purpose: Type.String({
20
+ description: 'What this conversation is about. This is injected into the external system prompt.',
21
+ }),
22
+ message: Type.String({
23
+ description: 'The opening message to send.',
24
+ }),
25
+ }),
26
+ execute: async (_toolCallId, params) => {
27
+ try {
28
+ const adapter = channels.find((c) => c.name === params.channel);
29
+ if (!adapter) {
30
+ return {
31
+ content: [
32
+ {
33
+ type: 'text',
34
+ text: `❌ Channel "${params.channel}" is not enabled. Available: ${channels.map((c) => c.name).join(', ')}`,
35
+ },
36
+ ],
37
+ details: { error: 'channel_not_found' },
38
+ };
39
+ }
40
+ const boltApp = adapter.getBoltApp?.();
41
+ if (!boltApp) {
42
+ return {
43
+ content: [{ type: 'text', text: `❌ Channel "${params.channel}" does not support start_external_conversation` }],
44
+ details: { error: 'unsupported_channel' },
45
+ };
46
+ }
47
+ // Open a DM channel with the user
48
+ const dmResult = await boltApp.client.conversations.open({ users: params.userId });
49
+ const channelId = dmResult.channel.id;
50
+ // Send the opening message
51
+ const msgResult = await boltApp.client.chat.postMessage({
52
+ channel: channelId,
53
+ text: params.message,
54
+ });
55
+ const threadId = msgResult.ts;
56
+ // Create conversation state file
57
+ const state = await conversationManager.create({
58
+ channel: params.channel,
59
+ externalUserId: params.userId,
60
+ externalDisplayName: params.displayName,
61
+ threadId,
62
+ channelId,
63
+ purpose: params.purpose,
64
+ initiatedBy: 'owner-initiative',
65
+ });
66
+ // Record the opening message in the exchange log
67
+ await conversationManager.append(state.id, 'fabiana', params.message);
68
+ return {
69
+ content: [
70
+ {
71
+ type: 'text',
72
+ text: `✅ Conversation started with ${params.displayName} (${params.userId}).\nConversation ID: ${state.id}\nThread: ${threadId}`,
73
+ },
74
+ ],
75
+ details: { conversationId: state.id, threadId, channelId },
76
+ };
77
+ }
78
+ catch (err) {
79
+ return {
80
+ content: [{ type: 'text', text: `❌ Failed to start external conversation: ${err.message}` }],
81
+ details: { error: err.message },
82
+ };
83
+ }
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,34 @@
1
+ import fs from 'fs/promises';
2
+ import { paths } from '../paths.js';
3
+ export class Logger {
4
+ logPath;
5
+ constructor(logPath) {
6
+ this.logPath = logPath;
7
+ }
8
+ static create() {
9
+ const now = new Date();
10
+ const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
11
+ return new Logger(paths.logs(`${month}.log`));
12
+ }
13
+ timestamp() {
14
+ return new Date().toISOString();
15
+ }
16
+ async log(message) {
17
+ const line = `[${this.timestamp()}] INFO ${message}\n`;
18
+ await fs.appendFile(this.logPath, line, 'utf-8').catch(() => { });
19
+ }
20
+ async error(message, err) {
21
+ const detail = err ? `: ${err.message}` : '';
22
+ const line = `[${this.timestamp()}] ERROR ${message}${detail}\n`;
23
+ await fs.appendFile(this.logPath, line, 'utf-8').catch(() => { });
24
+ }
25
+ async sessionStart(mode) {
26
+ const line = `\n[${this.timestamp()}] ===== SESSION START (${mode}) =====\n`;
27
+ await fs.appendFile(this.logPath, line, 'utf-8').catch(() => { });
28
+ }
29
+ async sessionEnd(success) {
30
+ const status = success ? 'OK' : 'FAILED';
31
+ const line = `[${this.timestamp()}] ===== SESSION END (${status}) =====\n`;
32
+ await fs.appendFile(this.logPath, line, 'utf-8').catch(() => { });
33
+ }
34
+ }
@@ -0,0 +1,68 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { minimatch } from 'minimatch';
4
+ export class PermissionValidator {
5
+ manifest;
6
+ baseDir;
7
+ constructor(manifest, baseDir) {
8
+ this.manifest = manifest;
9
+ this.baseDir = baseDir;
10
+ }
11
+ static async load(manifestPath) {
12
+ const content = await fs.readFile(manifestPath, 'utf-8');
13
+ const manifest = JSON.parse(content);
14
+ const { FABIANA_HOME } = await import('../paths.js');
15
+ return new PermissionValidator(manifest, FABIANA_HOME);
16
+ }
17
+ normalizePath(filePath) {
18
+ const absolute = path.isAbsolute(filePath)
19
+ ? filePath
20
+ : path.resolve(this.baseDir, filePath);
21
+ const relative = path.relative(this.baseDir, absolute);
22
+ if (relative.startsWith('..'))
23
+ throw new Error(`Path outside project: ${filePath}`);
24
+ return relative;
25
+ }
26
+ matchesPattern(filePath, patterns) {
27
+ const normalized = this.normalizePath(filePath);
28
+ return patterns.some(pattern => {
29
+ if (pattern === normalized)
30
+ return true;
31
+ if (pattern.includes('*'))
32
+ return minimatch(normalized, pattern, { dot: true });
33
+ if (pattern.endsWith('/**')) {
34
+ const dir = pattern.slice(0, -3);
35
+ return normalized === dir || normalized.startsWith(dir + '/');
36
+ }
37
+ return false;
38
+ });
39
+ }
40
+ canRead(_filePath) { return true; }
41
+ canWrite(filePath) {
42
+ try {
43
+ if (this.matchesPattern(filePath, this.manifest.permissions.readonly))
44
+ return false;
45
+ if (this.matchesPattern(filePath, this.manifest.permissions.writable))
46
+ return true;
47
+ return false;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ canEdit(filePath) { return this.canWrite(filePath); }
54
+ canAppend(filePath) {
55
+ try {
56
+ if (this.matchesPattern(filePath, this.manifest.permissions.readonly))
57
+ return false;
58
+ if (this.matchesPattern(filePath, this.manifest.permissions.appendonly))
59
+ return true;
60
+ if (this.matchesPattern(filePath, this.manifest.permissions.writable))
61
+ return true;
62
+ return false;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "fabiana",
3
+ "version": "0.1.0",
4
+ "description": "Virtual life assistant powered by pi",
5
+ "type": "module",
6
+ "bin": {
7
+ "fabiana": "bin/fabiana.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.build.json && node scripts/build-plugins.js",
11
+ "prepare": "npm run build",
12
+ "dev": "tsx src/cli.ts start",
13
+ "start": "tsx src/cli.ts start",
14
+ "initiative": "tsx src/cli.ts initiative",
15
+ "consolidate": "tsx src/cli.ts consolidate",
16
+ "doctor": "tsx src/cli.ts doctor"
17
+ },
18
+ "files": [
19
+ "bin/",
20
+ "dist/"
21
+ ],
22
+ "keywords": [
23
+ "ai",
24
+ "agent",
25
+ "assistant",
26
+ "pi",
27
+ "telegram"
28
+ ],
29
+ "author": "",
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "@inquirer/prompts": "^7.10.1",
33
+ "esbuild": "^0.25.0",
34
+ "@mariozechner/pi-ai": "latest",
35
+ "@mariozechner/pi-coding-agent": "latest",
36
+ "@mozilla/readability": "^0.6.0",
37
+ "@slack/bolt": "^4.6.0",
38
+ "chalk": "^5.6.2",
39
+ "commander": "^14.0.3",
40
+ "dotenv": "^17.3.1",
41
+ "linkedom": "^0.18.12",
42
+ "minimatch": "^10.0.0",
43
+ "node-cron": "^3.0.3",
44
+ "telegraf": "^4.16.3"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.0.0",
48
+ "@types/node-cron": "^3.0.11",
49
+ "tsx": "^4.7.0",
50
+ "typescript": "^5.4.0"
51
+ },
52
+ "engines": {
53
+ "node": ">=22.0.0"
54
+ }
55
+ }