squidclaw 0.9.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.
@@ -30,12 +30,48 @@ export class PromptBuilder {
30
30
  // 3. Long-term memories
31
31
  const memories = await this.storage.getMemories(agent.id);
32
32
  if (memories.length > 0) {
33
- parts.push('\n## What You Remember\n');
34
- for (const mem of memories.slice(0, 30)) {
35
- parts.push(`- ${mem.key}: ${mem.value}`);
33
+ parts.push('\n## What You Remember');
34
+ parts.push('These are facts you have learned from conversations. Use them naturally — do not list them unless asked.');
35
+ parts.push('');
36
+
37
+ // Group by type
38
+ const facts = memories.filter(m => m.type === 'fact' || m.type === 'auto');
39
+ const notes = memories.filter(m => m.type === 'noted');
40
+
41
+ if (facts.length > 0) {
42
+ for (const mem of facts.slice(0, 40)) {
43
+ parts.push(`- ${mem.key}: ${mem.value}`);
44
+ }
45
+ }
46
+ if (notes.length > 0) {
47
+ parts.push('\n### User Notes (they asked you to remember):');
48
+ for (const mem of notes.slice(0, 20)) {
49
+ parts.push(`- ${mem.value}`);
50
+ }
36
51
  }
37
52
  }
38
53
 
54
+ // Platform-specific formatting
55
+ parts.push('\n## Message Format');
56
+ parts.push('- Keep messages SHORT (1-3 sentences per message)');
57
+ parts.push('- Use ---SPLIT--- to break long responses into multiple messages');
58
+ parts.push('- NO markdown tables — use bullet lists instead');
59
+ parts.push('- Use *bold* for emphasis, not headers');
60
+ parts.push('- When sharing multiple items, split into separate messages');
61
+ parts.push('- If user says "ok", "thanks", "👍" — do NOT reply with a full message. Use ---REACT:❤️--- only');
62
+ parts.push('- Act first, ask later. Do not say "would you like me to..." — just do it');
63
+ parts.push('- Never start with "Great question!" or "I would be happy to help!"');
64
+
65
+ // Memory instructions
66
+ parts.push('\n## Memory Instructions');
67
+ parts.push('When you learn something new about the user, save it using: ---MEMORY:key:value---');
68
+ parts.push('Examples:');
69
+ parts.push(' ---MEMORY:name:Tamer---');
70
+ parts.push(' ---MEMORY:favorite_food:Sushi---');
71
+ parts.push(' ---MEMORY:project:Building Squidclaw platform---');
72
+ parts.push('Save important facts automatically. Do not tell the user you are saving — just do it silently.');
73
+ parts.push('If the user says "remember X" — save it and confirm briefly.');
74
+
39
75
  // 4. Contact context
40
76
  const contact = await this.storage.getContact(agent.id, contactId);
41
77
  if (contact) {
@@ -0,0 +1,167 @@
1
+ /**
2
+ * 🦑 Squidclaw Dashboard
3
+ * Minimal web UI for agent management
4
+ */
5
+
6
+ export function addDashboardRoutes(app, engine) {
7
+
8
+ // Dashboard HTML
9
+ app.get('/dashboard', (req, res) => {
10
+ res.send(DASHBOARD_HTML);
11
+ });
12
+
13
+ // API endpoints for dashboard
14
+ app.get('/api/dashboard/stats', async (req, res) => {
15
+ try {
16
+ const agents = engine.agentManager.listAll();
17
+ const stats = [];
18
+
19
+ for (const agent of agents) {
20
+ const usage = await engine.storage.getUsage(agent.id);
21
+ const convos = engine.storage.db.prepare(
22
+ 'SELECT COUNT(DISTINCT contact_id) as contacts FROM messages WHERE agent_id = ?'
23
+ ).get(agent.id);
24
+
25
+ stats.push({
26
+ id: agent.id,
27
+ name: agent.name,
28
+ model: agent.model,
29
+ messages: usage?.messages || 0,
30
+ tokens: (usage?.input_tokens || 0) + (usage?.output_tokens || 0),
31
+ cost: (usage?.cost_usd || 0).toFixed(4),
32
+ contacts: convos?.contacts || 0,
33
+ });
34
+ }
35
+
36
+ const uptime = process.uptime();
37
+ const memUsage = process.memoryUsage();
38
+
39
+ res.json({
40
+ uptime: Math.floor(uptime),
41
+ memory: Math.round(memUsage.rss / 1024 / 1024),
42
+ agents: stats,
43
+ channels: {
44
+ whatsapp: Object.values(engine.whatsappManager?.getStatuses() || {}).some(s => s.connected),
45
+ telegram: !!engine.telegramManager?.bots?.size,
46
+ },
47
+ });
48
+ } catch (err) {
49
+ res.status(500).json({ error: err.message });
50
+ }
51
+ });
52
+
53
+ app.get('/api/dashboard/conversations/:agentId', async (req, res) => {
54
+ try {
55
+ const contacts = engine.storage.db.prepare(
56
+ 'SELECT contact_id, MAX(created_at) as last_msg, COUNT(*) as msg_count FROM messages WHERE agent_id = ? GROUP BY contact_id ORDER BY last_msg DESC LIMIT 50'
57
+ ).all(req.params.agentId);
58
+ res.json(contacts);
59
+ } catch (err) {
60
+ res.status(500).json({ error: err.message });
61
+ }
62
+ });
63
+
64
+ app.get('/api/dashboard/memories/:agentId', async (req, res) => {
65
+ try {
66
+ const memories = await engine.storage.getMemories(req.params.agentId);
67
+ res.json(memories);
68
+ } catch (err) {
69
+ res.status(500).json({ error: err.message });
70
+ }
71
+ });
72
+ }
73
+
74
+ const DASHBOARD_HTML = `<!DOCTYPE html>
75
+ <html lang="en">
76
+ <head>
77
+ <meta charset="UTF-8">
78
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
79
+ <title>🦑 Squidclaw Dashboard</title>
80
+ <style>
81
+ * { margin: 0; padding: 0; box-sizing: border-box; }
82
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0f; color: #e0e0e0; }
83
+ .header { background: linear-gradient(135deg, #1a1a2e, #16213e); padding: 20px 30px; border-bottom: 1px solid #333; display: flex; align-items: center; justify-content: space-between; }
84
+ .header h1 { font-size: 24px; } .header h1 span { color: #00d4ff; }
85
+ .status-bar { display: flex; gap: 20px; font-size: 14px; color: #888; }
86
+ .status-bar .online { color: #4caf50; }
87
+ .container { max-width: 1200px; margin: 30px auto; padding: 0 20px; }
88
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-bottom: 30px; }
89
+ .card { background: #1a1a2e; border: 1px solid #333; border-radius: 12px; padding: 20px; }
90
+ .card h3 { color: #00d4ff; margin-bottom: 15px; font-size: 16px; }
91
+ .stat { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #222; }
92
+ .stat:last-child { border: none; }
93
+ .stat .label { color: #888; }
94
+ .stat .value { color: #fff; font-weight: 600; }
95
+ .agent-card { background: #1a1a2e; border: 1px solid #333; border-radius: 12px; padding: 20px; margin-bottom: 15px; }
96
+ .agent-card h3 { color: #00d4ff; margin-bottom: 10px; }
97
+ .agent-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; }
98
+ .agent-stat { text-align: center; }
99
+ .agent-stat .num { font-size: 24px; font-weight: 700; color: #fff; }
100
+ .agent-stat .lbl { font-size: 12px; color: #888; margin-top: 4px; }
101
+ .pill { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; }
102
+ .pill.on { background: #1b5e20; color: #4caf50; }
103
+ .pill.off { background: #4a1010; color: #e57373; }
104
+ .refresh-btn { background: #00d4ff; color: #000; border: none; padding: 8px 16px; border-radius: 8px; cursor: pointer; font-weight: 600; }
105
+ .refresh-btn:hover { background: #00b8d4; }
106
+ @media (max-width: 600px) { .agent-stats { grid-template-columns: repeat(2, 1fr); } }
107
+ </style>
108
+ </head>
109
+ <body>
110
+ <div class="header">
111
+ <h1>🦑 <span>Squidclaw</span> Dashboard</h1>
112
+ <div style="display:flex;gap:10px;align-items:center">
113
+ <div class="status-bar">
114
+ <span id="uptime">--</span>
115
+ <span id="memory">--</span>
116
+ </div>
117
+ <button class="refresh-btn" onclick="load()">Refresh</button>
118
+ </div>
119
+ </div>
120
+ <div class="container">
121
+ <div class="grid">
122
+ <div class="card">
123
+ <h3>📡 Channels</h3>
124
+ <div class="stat"><span class="label">WhatsApp</span><span id="wa" class="value">--</span></div>
125
+ <div class="stat"><span class="label">Telegram</span><span id="tg" class="value">--</span></div>
126
+ </div>
127
+ <div class="card">
128
+ <h3>📊 System</h3>
129
+ <div class="stat"><span class="label">Uptime</span><span id="uptimeVal" class="value">--</span></div>
130
+ <div class="stat"><span class="label">Memory</span><span id="memVal" class="value">--</span></div>
131
+ <div class="stat"><span class="label">Agents</span><span id="agentCount" class="value">--</span></div>
132
+ </div>
133
+ </div>
134
+ <h2 style="margin-bottom:15px;color:#00d4ff">🤖 Agents</h2>
135
+ <div id="agents"></div>
136
+ </div>
137
+ <script>
138
+ async function load() {
139
+ const res = await fetch('/api/dashboard/stats');
140
+ const data = await res.json();
141
+ const h = Math.floor(data.uptime/3600), m = Math.floor((data.uptime%3600)/60);
142
+ document.getElementById('uptimeVal').textContent = h+'h '+m+'m';
143
+ document.getElementById('memVal').textContent = data.memory+' MB';
144
+ document.getElementById('agentCount').textContent = data.agents.length;
145
+ document.getElementById('wa').innerHTML = data.channels.whatsapp ? '<span class="pill on">Connected</span>' : '<span class="pill off">Disconnected</span>';
146
+ document.getElementById('tg').innerHTML = data.channels.telegram ? '<span class="pill on">Connected</span>' : '<span class="pill off">Disconnected</span>';
147
+
148
+ const agentsDiv = document.getElementById('agents');
149
+ agentsDiv.innerHTML = data.agents.map(a => \`
150
+ <div class="agent-card">
151
+ <h3>🤖 \${a.name || a.id}</h3>
152
+ <div style="margin-bottom:10px;color:#888;font-size:13px">Model: \${a.model} · ID: \${a.id.slice(0,8)}</div>
153
+ <div class="agent-stats">
154
+ <div class="agent-stat"><div class="num">\${a.messages}</div><div class="lbl">Messages</div></div>
155
+ <div class="agent-stat"><div class="num">\${a.contacts}</div><div class="lbl">Contacts</div></div>
156
+ <div class="agent-stat"><div class="num">\${fmtTokens(a.tokens)}</div><div class="lbl">Tokens</div></div>
157
+ <div class="agent-stat"><div class="num">$\${a.cost}</div><div class="lbl">Cost</div></div>
158
+ </div>
159
+ </div>
160
+ \`).join('');
161
+ }
162
+ function fmtTokens(n) { if(n>=1e6) return (n/1e6).toFixed(1)+'M'; if(n>=1e3) return (n/1e3).toFixed(1)+'K'; return n; }
163
+ load();
164
+ setInterval(load, 30000);
165
+ </script>
166
+ </body>
167
+ </html>`;
@@ -166,6 +166,19 @@ export class TelegramManager {
166
166
  } catch {} // Reactions might not be supported in all chats
167
167
  }
168
168
 
169
+ async sendVoice(agentId, contactId, audioBuffer, metadata = {}) {
170
+ const chatId = metadata.chatId || contactId.replace('tg_', '');
171
+ const botInfo = this.bots.get(agentId);
172
+ if (!botInfo?.bot) return;
173
+
174
+ try {
175
+ const { InputFile } = await import('grammy');
176
+ await botInfo.bot.api.sendVoice(chatId, new InputFile(audioBuffer, 'voice.ogg'));
177
+ } catch (err) {
178
+ logger.error('telegram', 'Failed to send voice: ' + err.message);
179
+ }
180
+ }
181
+
169
182
  async sendPhoto(agentId, contactId, photoData, caption, metadata = {}) {
170
183
  const chatId = metadata.chatId || contactId;
171
184
  const token = metadata.token;
@@ -1,182 +1,151 @@
1
+ /**
2
+ * 🦑 squidclaw agent — manage agents
3
+ */
4
+
1
5
  import * as p from '@clack/prompts';
2
6
  import chalk from 'chalk';
3
- import { loadConfig, getHome } from '../core/config.js';
4
- import { writeFileSync, mkdirSync, existsSync, readFileSync, readdirSync } from 'fs';
5
- import { join } from 'path';
6
7
  import crypto from 'crypto';
7
- import { createInterface } from 'readline';
8
+ import { loadConfig, saveConfig, getHome } from '../core/config.js';
9
+ import { mkdirSync, writeFileSync, existsSync, readdirSync, rmSync } from 'fs';
10
+ import { join } from 'path';
8
11
 
9
- export async function createAgent(opts) {
10
- const home = getHome();
12
+ export async function agentCommand(args) {
13
+ const sub = args[0];
14
+
15
+ switch (sub) {
16
+ case 'add': return addAgent();
17
+ case 'list': return listAgents();
18
+ case 'remove': return removeAgent(args[1]);
19
+ default:
20
+ console.log(chalk.cyan('🦑 Agent Commands:'));
21
+ console.log(' squidclaw agent add — Create a new agent');
22
+ console.log(' squidclaw agent list — List all agents');
23
+ console.log(' squidclaw agent remove — Remove an agent');
24
+ }
25
+ }
11
26
 
12
- const name = opts.name || await p.text({ message: 'Agent name:', placeholder: 'Luna' });
27
+ async function addAgent() {
28
+ p.intro(chalk.cyan('🦑 New Agent'));
29
+
30
+ const name = await p.text({ message: 'Agent name:', placeholder: 'Luna' });
13
31
  if (p.isCancel(name)) return;
14
32
 
15
- const purpose = opts.soul || await p.text({ message: 'What does this agent do?', placeholder: 'Customer support for my restaurant' });
33
+ const purpose = await p.text({ message: 'What does this agent do?', placeholder: 'Customer support for my shop' });
16
34
  if (p.isCancel(purpose)) return;
17
35
 
18
- const language = opts.language || await p.select({
36
+ const language = await p.select({
19
37
  message: 'Language:',
20
38
  options: [
21
- { value: 'en', label: '🇬🇧 English' },
22
- { value: 'ar', label: '🇸🇦 Arabic' },
23
- { value: 'bilingual', label: '🌍 Bilingual' },
39
+ { value: 'bilingual', label: 'Bilingual (Arabic + English)' },
40
+ { value: 'en', label: 'English' },
41
+ { value: 'ar', label: 'Arabic' },
24
42
  ],
25
43
  });
26
- if (p.isCancel(language)) return;
27
44
 
28
- const tone = await p.select({
29
- message: 'Tone:',
45
+ const personality = await p.select({
46
+ message: 'Personality:',
30
47
  options: [
31
- { value: 30, label: '👔 Formal' },
32
- { value: 50, label: '🤝 Professional but friendly' },
33
- { value: 80, label: '😊 Casual and warm' },
48
+ { value: 'friendly', label: 'Friendly & Helpful' },
49
+ { value: 'professional', label: 'Professional & Formal' },
50
+ { value: 'casual', label: 'Casual & Fun' },
51
+ { value: 'expert', label: 'Expert & Technical' },
34
52
  ],
35
53
  });
36
- if (p.isCancel(tone)) return;
37
54
 
38
- const id = crypto.randomUUID().slice(0, 8);
55
+ const config = loadConfig();
56
+ const id = crypto.randomBytes(4).toString('hex');
57
+ const home = getHome();
39
58
  const agentDir = join(home, 'agents', id);
59
+
40
60
  mkdirSync(join(agentDir, 'memory'), { recursive: true });
41
61
 
42
- const langDesc = language === 'ar' ? 'Arabic' : language === 'bilingual' ? 'Bilingual — match whatever language the person uses' : 'English';
43
- const toneDesc = tone > 60 ? 'Casual and friendly' : tone > 30 ? 'Professional but warm' : 'Formal and polished';
62
+ // Create agent manifest
63
+ const manifest = {
64
+ id,
65
+ name,
66
+ purpose,
67
+ language,
68
+ personality,
69
+ model: config.ai?.defaultModel || 'claude-sonnet-4-20250514',
70
+ createdAt: new Date().toISOString(),
71
+ };
44
72
 
45
- const soul = `# ${name}\n\n## Who I Am\nI'm ${name}. ${purpose}\n\n## How I Speak\n- Language: ${langDesc}\n- Tone: ${toneDesc}\n- Platform: WhatsApp — short messages, natural, human-like\n- I use emojis naturally but don't overdo it\n\n## What I Do\n${purpose}\n\n## What I Never Do\n- Say "As an AI" or "I'd be happy to help" or "Great question!"\n- Send walls of text\n- Make things up when I don't know\n`;
73
+ writeFileSync(join(agentDir, 'agent.json'), JSON.stringify(manifest, null, 2));
46
74
 
47
- writeFileSync(join(agentDir, 'SOUL.md'), soul);
48
- writeFileSync(join(agentDir, 'IDENTITY.md'), `# ${name}\n- Name: ${name}\n- Language: ${language}\n- Emoji: 🦑\n`);
49
- writeFileSync(join(agentDir, 'MEMORY.md'), `# ${name}'s Memory\n\n_Learning..._\n`);
50
- writeFileSync(join(agentDir, 'RULES.md'), `# Rules\n\n1. Be helpful, be human, be honest\n2. Keep messages short\n3. If unsure, say so\n`);
51
- writeFileSync(join(agentDir, 'BEHAVIOR.md'), JSON.stringify({
52
- splitMessages: true, maxChunkLength: 200, reactBeforeReply: true,
53
- autoDetectLanguage: true, avoidPhrases: ["Is there anything else I can help with?", "I'd be happy to help!", "Great question!", "As an AI"],
54
- handoff: { enabled: false }, heartbeat: '30m',
55
- }, null, 2));
75
+ // Create SOUL.md
76
+ const langInst = language === 'bilingual'
77
+ ? 'I speak Arabic and English fluently. I auto-detect what language the person uses and respond in the same language.'
78
+ : language === 'ar' ? 'I speak Arabic.' : 'I speak English.';
56
79
 
57
- const config = loadConfig();
58
- const manifest = { id, name, soul, language, tone, model: config.ai?.defaultModel, status: 'active', createdAt: new Date().toISOString() };
59
- writeFileSync(join(agentDir, 'agent.json'), JSON.stringify(manifest, null, 2));
80
+ const soul = `# ${name}
81
+
82
+ ## Who I Am
83
+ I am ${name}. ${purpose}.
84
+
85
+ ## How I Speak
86
+ - ${langInst}
87
+ - Short messages, natural, human-like
88
+ - Personality: ${personality}
89
+ - Emojis: natural but not overdone
90
+
91
+ ## My Skills
92
+ - Search the web for current information
93
+ - Read and summarize web pages
94
+ - Remember facts about people I talk to
95
+ - Set reminders
96
+
97
+ ## Never
98
+ - Say "As an AI" or "Great question!" or "I would be happy to help!"
99
+ - Send walls of text — keep it short
100
+ - Make things up — search the web if unsure
101
+ - Say I cannot access the internet — I can
102
+ `;
103
+
104
+ writeFileSync(join(agentDir, 'SOUL.md'), soul);
105
+ writeFileSync(join(agentDir, 'MEMORY.md'), `# ${name} Memory\n`);
60
106
 
61
- console.log(chalk.green(`\n✅ Agent "${name}" created (${id})`));
62
- console.log(chalk.gray(` Files: ${agentDir}`));
63
- console.log(chalk.gray(` Chat: squidclaw agent chat ${id}\n`));
107
+ p.outro(chalk.green(`✅ Agent "${name}" created! (ID: ${id})`));
108
+ console.log(chalk.dim(` Restart engine to load: squidclaw restart`));
64
109
  }
65
110
 
66
- export async function listAgents() {
111
+ async function listAgents() {
67
112
  const home = getHome();
68
113
  const agentsDir = join(home, 'agents');
69
- if (!existsSync(agentsDir)) return console.log(chalk.gray('No agents yet. Run: squidclaw agent create'));
70
-
71
- const config = loadConfig();
72
- const port = config.engine?.port || 9500;
73
-
74
- // Try API first
75
- try {
76
- const res = await fetch(`http://127.0.0.1:${port}/api/agents`);
77
- const agents = await res.json();
78
- console.log(chalk.cyan(`\n 🦑 Agents (${agents.length})\n ─────────`));
79
- for (const a of agents) {
80
- const wa = a.whatsappConnected ? chalk.green('📱 connected') : chalk.gray('📱 not linked');
81
- console.log(` ${a.name} (${a.id}) — ${a.model || 'default'} — ${wa}`);
82
- }
83
- console.log();
114
+ if (!existsSync(agentsDir)) {
115
+ console.log(chalk.yellow('No agents found.'));
84
116
  return;
85
- } catch {}
117
+ }
118
+
119
+ const dirs = readdirSync(agentsDir, { withFileTypes: true }).filter(d => d.isDirectory());
120
+ console.log(chalk.cyan(`\n🦑 ${dirs.length} Agent(s):\n`));
86
121
 
87
- // Fallback to filesystem
88
- const dirs = readdirSync(agentsDir);
89
- console.log(chalk.cyan(`\n 🦑 Agents (${dirs.length})\n ─────────`));
90
122
  for (const dir of dirs) {
91
- const manifestPath = join(agentsDir, dir, 'agent.json');
123
+ const manifestPath = join(agentsDir, dir.name, 'agent.json');
92
124
  if (existsSync(manifestPath)) {
93
- const m = JSON.parse(readFileSync(manifestPath, 'utf8'));
94
- console.log(` ${m.name} (${m.id}) — ${m.model || 'default'} — ${m.status}`);
125
+ const manifest = JSON.parse((await import('fs')).readFileSync(manifestPath, 'utf8'));
126
+ console.log(` 🤖 ${manifest.name || dir.name}`);
127
+ console.log(chalk.dim(` ID: ${manifest.id} · Model: ${manifest.model} · Lang: ${manifest.language || '?'}`));
95
128
  }
96
129
  }
97
- console.log();
130
+ console.log('');
98
131
  }
99
132
 
100
- export async function editAgent(id) {
101
- const home = getHome();
102
- const agentDir = join(home, 'agents', id);
103
- const soulPath = join(agentDir, 'SOUL.md');
104
- if (!existsSync(soulPath)) return console.log(chalk.red(`Agent ${id} not found`));
105
-
106
- console.log(chalk.cyan(`Edit files in: ${agentDir}`));
107
- console.log(chalk.gray(` SOUL.md — personality`));
108
- console.log(chalk.gray(` RULES.md — hard rules`));
109
- console.log(chalk.gray(` BEHAVIOR.md — behavior config`));
110
- console.log(chalk.gray(` MEMORY.md — long-term memory\n`));
111
- }
112
-
113
- export async function deleteAgent(id) {
114
- const confirm = await p.confirm({ message: `Delete agent ${id}? This cannot be undone.` });
115
- if (!confirm || p.isCancel(confirm)) return;
116
-
117
- const config = loadConfig();
118
- const port = config.engine?.port || 9500;
119
- try {
120
- await fetch(`http://127.0.0.1:${port}/api/agents/${id}`, { method: 'DELETE' });
121
- console.log(chalk.green(`✅ Agent ${id} deleted`));
122
- } catch {
123
- // Fallback: delete from filesystem
124
- const { rmSync } = await import('fs');
125
- const agentDir = join(getHome(), 'agents', id);
126
- rmSync(agentDir, { recursive: true, force: true });
127
- console.log(chalk.green(`✅ Agent ${id} deleted (filesystem)`));
133
+ async function removeAgent(id) {
134
+ if (!id) {
135
+ console.log(chalk.red('Usage: squidclaw agent remove <agent-id>'));
136
+ return;
128
137
  }
129
- }
130
-
131
- export async function chatAgent(id) {
132
- const config = loadConfig();
133
- const port = config.engine?.port || 9500;
134
138
 
135
- // Check if engine is running
136
- try {
137
- await fetch(`http://127.0.0.1:${port}/health`);
138
- } catch {
139
- console.log(chalk.red('Engine not running. Start with: squidclaw start'));
139
+ const home = getHome();
140
+ const agentDir = join(home, 'agents', id);
141
+ if (!existsSync(agentDir)) {
142
+ console.log(chalk.red('Agent not found: ' + id));
140
143
  return;
141
144
  }
142
145
 
143
- // Get agent info
144
- let agentName = id;
145
- try {
146
- const res = await fetch(`http://127.0.0.1:${port}/api/agents/${id}`);
147
- const agent = await res.json();
148
- agentName = agent.name || id;
149
- } catch {}
150
-
151
- console.log(chalk.cyan(`\n 🦑 Chat with ${agentName}`));
152
- console.log(chalk.gray(` Type your message. Ctrl+C to exit.\n`));
153
-
154
- const rl = createInterface({ input: process.stdin, output: process.stdout });
155
-
156
- const ask = () => {
157
- rl.question(chalk.green('You: '), async (input) => {
158
- if (!input.trim()) return ask();
159
-
160
- try {
161
- const res = await fetch(`http://127.0.0.1:${port}/api/agents/${id}/chat`, {
162
- method: 'POST',
163
- headers: { 'content-type': 'application/json' },
164
- body: JSON.stringify({ message: input, contactId: 'terminal' }),
165
- });
166
- const data = await res.json();
167
- for (const msg of data.messages || []) {
168
- console.log(chalk.cyan(`${agentName}: `) + msg);
169
- }
170
- if (data.reaction) console.log(chalk.gray(` (reacted: ${data.reaction})`));
171
- if (data.usage) console.log(chalk.gray(` [${data.usage.inputTokens}+${data.usage.outputTokens} tokens, $${data.usage.cost?.toFixed(4) || 0}]`));
172
- } catch (err) {
173
- console.log(chalk.red(`Error: ${err.message}`));
174
- }
175
- console.log();
176
- ask();
177
- });
178
- };
146
+ const confirm = await p.confirm({ message: `Remove agent ${id}? This is permanent.` });
147
+ if (!confirm || p.isCancel(confirm)) return;
179
148
 
180
- rl.on('close', () => { console.log(chalk.gray('\n 👋 Bye!\n')); process.exit(0); });
181
- ask();
149
+ rmSync(agentDir, { recursive: true });
150
+ console.log(chalk.green(`✅ Agent ${id} removed.`));
182
151
  }
@@ -1,25 +1,53 @@
1
- import chalk from 'chalk';
1
+ /**
2
+ * 🦑 squidclaw update — self-update to latest version
3
+ */
4
+
2
5
  import { execSync } from 'child_process';
6
+ import chalk from 'chalk';
3
7
 
4
8
  export async function update() {
5
9
  console.log(chalk.cyan('🦑 Checking for updates...'));
10
+
6
11
  try {
7
- const current = execSync('npm view squidclaw version 2>/dev/null', { encoding: 'utf8' }).trim();
8
- const { readFileSync } = await import('fs');
9
- const { join, dirname } = await import('path');
10
- const { fileURLToPath } = await import('url');
11
- const __dirname = dirname(fileURLToPath(import.meta.url));
12
- const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
12
+ // Get current version
13
+ const pkg = JSON.parse((await import('fs')).readFileSync(
14
+ new URL('../../package.json', import.meta.url), 'utf8'
15
+ ));
16
+ const current = pkg.version;
13
17
 
14
- if (current && current !== pkg.version) {
15
- console.log(` Current: ${pkg.version} → Latest: ${current}`);
16
- console.log(chalk.cyan(' Updating...'));
17
- execSync('npm i -g squidclaw@latest', { stdio: 'inherit' });
18
- console.log(chalk.green(' ✅ Updated!'));
19
- } else {
20
- console.log(chalk.green(` ✅ Already on latest (${pkg.version})`));
18
+ // Get latest from npm
19
+ let latest;
20
+ try {
21
+ latest = execSync('npm view squidclaw version', { encoding: 'utf8' }).trim();
22
+ } catch {
23
+ latest = current;
21
24
  }
22
- } catch {
23
- console.log(chalk.yellow(' Could not check for updates. Try: npm i -g squidclaw@latest'));
25
+
26
+ console.log(` Current: ${chalk.yellow(current)}`);
27
+ console.log(` Latest: ${chalk.green(latest)}`);
28
+
29
+ if (current === latest) {
30
+ console.log(chalk.green('\n✅ Already up to date!'));
31
+ return;
32
+ }
33
+
34
+ console.log(chalk.cyan('\n📦 Updating...'));
35
+ execSync('npm i -g squidclaw@latest', { stdio: 'inherit' });
36
+
37
+ // Check if engine is running
38
+ try {
39
+ const res = await fetch('http://127.0.0.1:9500/health');
40
+ if (res.ok) {
41
+ console.log(chalk.cyan('\n🔄 Restarting engine...'));
42
+ execSync('pkill -f "squidclaw start"', { stdio: 'ignore' });
43
+ await new Promise(r => setTimeout(r, 2000));
44
+ execSync('nohup squidclaw start > /tmp/squidclaw.log 2>&1 &', { stdio: 'ignore' });
45
+ console.log(chalk.green('✅ Engine restarted!'));
46
+ }
47
+ } catch {}
48
+
49
+ console.log(chalk.green(`\n✅ Updated to v${latest}!`));
50
+ } catch (err) {
51
+ console.error(chalk.red('❌ Update failed:'), err.message);
24
52
  }
25
53
  }
@@ -29,6 +29,11 @@ export function addToolSupport(agent, toolRouter, knowledgeBase) {
29
29
  agent.promptBuilder.build = async function(agentObj, cId, msg) {
30
30
  let prompt = await origBuild(agentObj, cId, msg);
31
31
 
32
+ // Add link context (auto-read URLs)
33
+ if (metadata._linkContext) {
34
+ prompt += '\n\n## Content From Links Shared\nThe user shared links. Here is the content:\n\n' + metadata._linkContext;
35
+ }
36
+
32
37
  // Add knowledge context
33
38
  if (metadata._knowledgeContext) {
34
39
  prompt += '\n\n## Relevant Knowledge\nUse this information to answer:\n\n' + metadata._knowledgeContext;
package/lib/engine.js CHANGED
@@ -107,6 +107,31 @@ export class SquidclawEngine {
107
107
  return;
108
108
  }
109
109
 
110
+ // Handle /usage command
111
+ if (message.trim() === '/usage') {
112
+ try {
113
+ const { UsageAlerts } = await import('./features/usage-alerts.js');
114
+ const ua = new UsageAlerts(this.storage);
115
+ const summary = await ua.getSummary(agentId);
116
+ const fmtT = (n) => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : String(n);
117
+ const lines = [
118
+ '📊 *Usage Report*', '',
119
+ '*Today:*',
120
+ ' 💬 ' + summary.today.calls + ' messages',
121
+ ' 🪙 ' + fmtT(summary.today.tokens) + ' tokens',
122
+ ' 💰 $' + summary.today.cost, '',
123
+ '*Last 30 days:*',
124
+ ' 💬 ' + summary.month.calls + ' messages',
125
+ ' 🪙 ' + fmtT(summary.month.tokens) + ' tokens',
126
+ ' 💰 $' + summary.month.cost,
127
+ ];
128
+ await this.telegramManager.sendMessage(agentId, contactId, lines.join('\n'), metadata);
129
+ } catch (err) {
130
+ await this.telegramManager.sendMessage(agentId, contactId, '❌ ' + err.message, metadata);
131
+ }
132
+ return;
133
+ }
134
+
110
135
  // Handle /help command
111
136
  if (message.trim() === '/help') {
112
137
  const helpText = [
@@ -115,6 +140,7 @@ export class SquidclawEngine {
115
140
  '/status — model, uptime, usage stats',
116
141
  '/backup — save me to a backup file',
117
142
  '/memories — what I remember about you',
143
+ '/usage — spending report (today + 30 days)',
118
144
  '/help — this message',
119
145
  '',
120
146
  'Just chat normally — I\'ll search the web, remember things, and help! 🦑',
@@ -200,6 +226,139 @@ export class SquidclawEngine {
200
226
  return;
201
227
  }
202
228
 
229
+ // Process Telegram media (voice, images)
230
+ if (metadata._ctx && metadata.mediaType) {
231
+ try {
232
+ if (metadata.mediaType === 'audio') {
233
+ // Download and transcribe voice note
234
+ const file = await metadata._ctx.getFile();
235
+ const fileUrl = `https://api.telegram.org/file/bot${this.config.channels.telegram.token}/${file.file_path}`;
236
+ const resp = await fetch(fileUrl);
237
+ const buffer = Buffer.from(await resp.arrayBuffer());
238
+
239
+ // Transcribe with Groq Whisper (free) or OpenAI
240
+ const groqKey = this.config.ai?.providers?.groq?.key;
241
+ const openaiKey = this.config.ai?.providers?.openai?.key;
242
+ const apiKey = groqKey || openaiKey;
243
+ const apiUrl = groqKey
244
+ ? 'https://api.groq.com/openai/v1/audio/transcriptions'
245
+ : 'https://api.openai.com/v1/audio/transcriptions';
246
+ const model = groqKey ? 'whisper-large-v3' : 'whisper-1';
247
+
248
+ if (apiKey) {
249
+ const form = new FormData();
250
+ form.append('file', new Blob([buffer], { type: 'audio/ogg' }), 'voice.ogg');
251
+ form.append('model', model);
252
+
253
+ const tRes = await fetch(apiUrl, {
254
+ method: 'POST',
255
+ headers: { 'Authorization': 'Bearer ' + apiKey },
256
+ body: form,
257
+ });
258
+ const tData = await tRes.json();
259
+ if (tData.text) {
260
+ message = '[Voice note]: "' + tData.text + '"';
261
+ logger.info('telegram', 'Transcribed voice: ' + tData.text.slice(0, 50));
262
+ }
263
+ }
264
+ } else if (metadata.mediaType === 'image') {
265
+ // Download and analyze image
266
+ const photos = metadata._ctx.message?.photo;
267
+ if (photos?.length > 0) {
268
+ const photo = photos[photos.length - 1]; // highest res
269
+ const file = await metadata._ctx.api.getFile(photo.file_id);
270
+ const fileUrl = `https://api.telegram.org/file/bot${this.config.channels.telegram.token}/${file.file_path}`;
271
+ const resp = await fetch(fileUrl);
272
+ const buffer = Buffer.from(await resp.arrayBuffer());
273
+ const base64 = buffer.toString('base64');
274
+
275
+ // Analyze with Claude or OpenAI Vision
276
+ const anthropicKey = this.config.ai?.providers?.anthropic?.key;
277
+ const caption = message.replace('[📸 Image]', '').trim();
278
+ const userPrompt = caption || 'What is in this image? Be concise.';
279
+
280
+ if (anthropicKey) {
281
+ const vRes = await fetch('https://api.anthropic.com/v1/messages', {
282
+ method: 'POST',
283
+ headers: {
284
+ 'x-api-key': anthropicKey,
285
+ 'content-type': 'application/json',
286
+ 'anthropic-version': '2023-06-01',
287
+ },
288
+ body: JSON.stringify({
289
+ model: 'claude-sonnet-4-20250514',
290
+ max_tokens: 300,
291
+ messages: [{
292
+ role: 'user',
293
+ content: [
294
+ { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: base64 } },
295
+ { type: 'text', text: userPrompt },
296
+ ],
297
+ }],
298
+ }),
299
+ });
300
+ const vData = await vRes.json();
301
+ const analysis = vData.content?.[0]?.text || '';
302
+ if (analysis) {
303
+ message = caption
304
+ ? '[Image with caption: "' + caption + '"] Image shows: ' + analysis
305
+ : '[Image] Image shows: ' + analysis;
306
+ logger.info('telegram', 'Analyzed image: ' + analysis.slice(0, 50));
307
+ }
308
+ }
309
+ }
310
+ } else if (metadata.mediaType === 'document') {
311
+ const file = await metadata._ctx.getFile();
312
+ const fileUrl = `https://api.telegram.org/file/bot${this.config.channels.telegram.token}/${file.file_path}`;
313
+ const resp = await fetch(fileUrl);
314
+ const buffer = Buffer.from(await resp.arrayBuffer());
315
+ const filename = message.match(/Document: (.+?)\]/)?.[1] || 'file.txt';
316
+
317
+ try {
318
+ const { DocIngester } = await import('./features/doc-ingest.js');
319
+ const ingester = new DocIngester(this.storage, this.knowledgeBase, this.home);
320
+ const result = await ingester.ingest(agentId, buffer, filename, metadata.mimeType);
321
+
322
+ await this.telegramManager.sendMessage(agentId, contactId,
323
+ '📄 *Document absorbed!*\n📁 ' + filename + '\n📊 ' + result.chunks + ' chunks\n📝 ' + result.chars + ' chars\n\nI can answer questions about this! 🦑', metadata);
324
+ return;
325
+ } catch (err) {
326
+ message = '[Document: ' + filename + '] (Could not process: ' + err.message + ')';
327
+ }
328
+ }
329
+ } catch (err) {
330
+ logger.error('telegram', 'Media processing error: ' + err.message);
331
+ }
332
+ }
333
+
334
+ // Auto-read links in message
335
+ const linkRegex = /https?:\/\/[^\s<>"')\]]+/gi;
336
+ if (linkRegex.test(message) && this.toolRouter) {
337
+ try {
338
+ const { extractAndReadLinks } = await import('./features/auto-links.js');
339
+ const linkContext = await extractAndReadLinks(message, this.toolRouter.browser);
340
+ if (linkContext) {
341
+ metadata._linkContext = linkContext;
342
+ }
343
+ } catch {}
344
+ }
345
+
346
+ // Check usage alerts
347
+ if (this.usageAlerts) {
348
+ try {
349
+ const alert = await this.usageAlerts.check(agentId);
350
+ if (alert.alert) {
351
+ await this.telegramManager.sendMessage(agentId, contactId,
352
+ '⚠️ *Usage Alert*\nYou have spent $' + alert.total + ' in the last 24h (threshold: $' + alert.threshold + ')', metadata);
353
+ }
354
+ } catch {}
355
+ }
356
+
357
+ // Auto-extract facts from user message
358
+ if (this.autoMemory) {
359
+ try { await this.autoMemory.extract(agentId, contactId, message); } catch {}
360
+ }
361
+
203
362
  // Check if user is asking for a skill we don't have
204
363
  const skillRequest = detectSkillRequest(message);
205
364
  if (skillRequest) {
@@ -214,10 +373,10 @@ export class SquidclawEngine {
214
373
  }
215
374
 
216
375
  // Show typing indicator while processing
217
- const chatId = metadata.chatId || contactId;
218
- const botInfo = this.telegramManager?.bots?.values()?.next()?.value;
376
+ const chatId = metadata.chatId || contactId.replace('tg_', '');
377
+ const botInfo = this.telegramManager?.bots?.get(agentId);
219
378
  let typingInterval;
220
- if (botInfo) {
379
+ if (botInfo?.bot) {
221
380
  const sendTyping = () => { try { botInfo.bot.api.sendChatAction(chatId, 'typing').catch(() => {}); } catch {} };
222
381
  sendTyping();
223
382
  typingInterval = setInterval(sendTyping, 4000);
@@ -256,6 +415,19 @@ export class SquidclawEngine {
256
415
  // Send image if generated
257
416
  if (result.image) {
258
417
  await this.telegramManager.sendPhoto(agentId, contactId, result.image, result.messages?.[0] || '', metadata);
418
+ } else if (metadata.originalType === 'voice' && result.messages.length === 1 && result.messages[0].length < 500) {
419
+ // Reply with voice when user sent voice
420
+ try {
421
+ const { VoiceReply } = await import('./features/voice-reply.js');
422
+ const vr = new VoiceReply(this.config);
423
+ const lang = /[\u0600-\u06FF]/.test(result.messages[0]) ? 'ar' : 'en';
424
+ const audio = await vr.generate(result.messages[0], { language: lang });
425
+ await this.telegramManager.sendVoice(agentId, contactId, audio, metadata);
426
+ } catch (err) {
427
+ // Fallback to text
428
+ logger.warn('voice', 'Voice reply failed, sending text: ' + err.message);
429
+ await this.telegramManager.sendMessages(agentId, contactId, result.messages, metadata);
430
+ }
259
431
  } else {
260
432
  await this.telegramManager.sendMessages(agentId, contactId, result.messages, metadata);
261
433
  }
@@ -305,10 +477,11 @@ export class SquidclawEngine {
305
477
  this.heartbeat.start();
306
478
  console.log(` 💓 Heartbeat: active`);
307
479
 
308
- // 7. API Server
480
+ // 7. API Server + Dashboard
309
481
  const app = createAPIServer(this);
310
- this.server = app.listen(this.port, this.config.engine?.bind || '127.0.0.1', () => {
311
- console.log(` 🌐 API: http://${this.config.engine?.bind || '127.0.0.1'}:${this.port}`);
482
+ try { const { addDashboardRoutes } = await import('./api/dashboard.js'); addDashboardRoutes(app, this); } catch {}
483
+ this.server = app.listen(this.port, this.config.engine?.bind || '0.0.0.0', () => {
484
+ console.log(` 🌐 API: http://${this.config.engine?.bind || '0.0.0.0'}:${this.port}`);
312
485
  console.log(` ──────────────────────────`);
313
486
  console.log(` ✅ Engine running!\n`);
314
487
  });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 🦑 Auto-Link Reader
3
+ * Detects URLs in messages and fetches their content for context
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+
8
+ const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/gi;
9
+
10
+ export async function extractAndReadLinks(message, browser) {
11
+ const urls = message.match(URL_REGEX);
12
+ if (!urls || urls.length === 0) return null;
13
+
14
+ const results = [];
15
+ for (const url of urls.slice(0, 3)) { // Max 3 links
16
+ try {
17
+ const page = await browser.readPage(url, 2000);
18
+ if (page && page.content) {
19
+ results.push({
20
+ url,
21
+ title: page.title || url,
22
+ content: page.content.slice(0, 1500),
23
+ });
24
+ logger.info('auto-links', `Read: ${page.title || url}`);
25
+ }
26
+ } catch (err) {
27
+ logger.warn('auto-links', `Failed to read ${url}: ${err.message}`);
28
+ }
29
+ }
30
+
31
+ if (results.length === 0) return null;
32
+
33
+ return results.map(r =>
34
+ `[Link: ${r.title}]\nURL: ${r.url}\nContent:\n${r.content}`
35
+ ).join('\n\n---\n\n');
36
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 🦑 Auto Memory — Extracts facts from conversations automatically
3
+ * No AI tags needed — scans messages for personal info, preferences, decisions
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+
8
+ const FACT_PATTERNS = [
9
+ // Names
10
+ { regex: /(?:my name is|call me|i'?m|ana|اسمي|اسم)\s+([A-Z\u0600-\u06FF][a-z\u0600-\u06FF]+)/i, key: 'name', extract: 1 },
11
+
12
+ // Location
13
+ { regex: /(?:i live in|i'?m from|i'?m in|based in|located in|ساكن في|من)\s+([A-Z\u0600-\u06FF][\w\u0600-\u06FF\s]{2,20})/i, key: 'location', extract: 1 },
14
+
15
+ // Job
16
+ { regex: /(?:i work (?:as|at|in|for)|my job is|i'?m a|i'?m an|اشتغل|شغلي)\s+(.{3,40}?)(?:\.|,|!|\?|$)/i, key: 'job', extract: 1 },
17
+
18
+ // Age
19
+ { regex: /(?:i'?m|i am|عمري)\s+(\d{1,3})\s*(?:years? old|سنة|سنه)?/i, key: 'age', extract: 1 },
20
+
21
+ // Family
22
+ { regex: /(?:my (?:wife|husband|son|daughter|brother|sister|mom|dad|father|mother)(?:'s name)? is)\s+(\w+)/i, key: (m) => m[0].match(/wife|husband|son|daughter|brother|sister|mom|dad|father|mother/i)[0], extract: 1 },
23
+
24
+ // Favorites
25
+ { regex: /(?:my fav(?:orite|ourite)?\s+(\w+)\s+is)\s+(.+?)(?:\.|,|!|$)/i, key: (m) => 'favorite_' + m[1], extract: 2 },
26
+ { regex: /(?:i (?:love|like|prefer|enjoy))\s+(.{3,30}?)(?:\.|,|!|\?|$)/i, key: 'likes', extract: 1, append: true },
27
+ { regex: /(?:i (?:hate|dislike|don'?t like))\s+(.{3,30}?)(?:\.|,|!|\?|$)/i, key: 'dislikes', extract: 1, append: true },
28
+
29
+ // Timezone
30
+ { regex: /(?:my time(?:zone)? is|i'?m in)\s+(UTC[+-]\d+|GMT[+-]\d+|[A-Z]{2,4}\/[A-Za-z_]+)/i, key: 'timezone', extract: 1 },
31
+
32
+ // Birthday
33
+ { regex: /(?:my birthday is|born on|ميلادي)\s+(.{5,20})/i, key: 'birthday', extract: 1 },
34
+
35
+ // Language
36
+ { regex: /(?:i speak|my language is|لغتي)\s+(\w+)/i, key: 'language', extract: 1 },
37
+
38
+ // Pet
39
+ { regex: /(?:my (?:cat|dog|pet)(?:'s name)? is)\s+(\w+)/i, key: (m) => m[0].match(/cat|dog|pet/i)[0] + '_name', extract: 1 },
40
+ ];
41
+
42
+ // Extract per-contact facts
43
+ const CONTACT_PATTERNS = [
44
+ { regex: /(?:remember|don'?t forget|note|تذكر|لا تنسى)\s+(?:that\s+)?(.{5,100})/i, key: 'noted', extract: 1 },
45
+ ];
46
+
47
+ export class AutoMemory {
48
+ constructor(storage) {
49
+ this.storage = storage;
50
+ }
51
+
52
+ /**
53
+ * Scan a user message for facts and auto-save them
54
+ */
55
+ async extract(agentId, contactId, message) {
56
+ const extracted = [];
57
+
58
+ for (const pattern of FACT_PATTERNS) {
59
+ const match = message.match(pattern.regex);
60
+ if (!match) continue;
61
+
62
+ const key = typeof pattern.key === 'function' ? pattern.key(match) : pattern.key;
63
+ const value = match[pattern.extract].trim();
64
+
65
+ if (value.length < 2 || value.length > 100) continue;
66
+
67
+ if (pattern.append) {
68
+ // Append to existing
69
+ const existing = await this._getMemory(agentId, key);
70
+ if (existing && existing.includes(value)) continue;
71
+ const newValue = existing ? existing + ', ' + value : value;
72
+ await this.storage.saveMemory(agentId, key, newValue, 'auto');
73
+ } else {
74
+ await this.storage.saveMemory(agentId, key, value, 'auto');
75
+ }
76
+
77
+ extracted.push({ key, value });
78
+ logger.info('auto-memory', `Extracted: ${key} = ${value}`);
79
+ }
80
+
81
+ // Check "remember this" patterns
82
+ for (const pattern of CONTACT_PATTERNS) {
83
+ const match = message.match(pattern.regex);
84
+ if (!match) continue;
85
+ const value = match[pattern.extract].trim();
86
+ if (value.length < 3) continue;
87
+
88
+ const key = 'user_note_' + Date.now().toString(36);
89
+ await this.storage.saveMemory(agentId, key, value, 'noted');
90
+ extracted.push({ key, value });
91
+ logger.info('auto-memory', `User note: ${value}`);
92
+ }
93
+
94
+ return extracted;
95
+ }
96
+
97
+ async _getMemory(agentId, key) {
98
+ try {
99
+ const row = this.storage.db.prepare(
100
+ 'SELECT value FROM memories WHERE agent_id = ? AND key = ?'
101
+ ).get(agentId, key);
102
+ return row?.value;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * 🦑 Document Ingestion
3
+ * Process PDFs, text files, docs sent via chat into knowledge base
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+ import { writeFileSync, mkdirSync } from 'fs';
8
+ import { join } from 'path';
9
+
10
+ export class DocIngester {
11
+ constructor(storage, knowledgeBase, home) {
12
+ this.storage = storage;
13
+ this.kb = knowledgeBase;
14
+ this.home = home;
15
+ }
16
+
17
+ async ingest(agentId, buffer, filename, mimeType) {
18
+ logger.info('doc-ingest', `Processing ${filename} (${mimeType})`);
19
+
20
+ let text = '';
21
+
22
+ if (mimeType === 'application/pdf') {
23
+ text = await this._extractPdf(buffer);
24
+ } else if (mimeType?.includes('text') || filename.endsWith('.txt') || filename.endsWith('.md') || filename.endsWith('.csv')) {
25
+ text = buffer.toString('utf8');
26
+ } else if (filename.endsWith('.json')) {
27
+ text = buffer.toString('utf8');
28
+ } else {
29
+ // Try as text
30
+ text = buffer.toString('utf8');
31
+ if (text.includes('\ufffd') || /[\x00-\x08\x0e-\x1f]/.test(text.slice(0, 100))) {
32
+ throw new Error('Unsupported file format: ' + mimeType);
33
+ }
34
+ }
35
+
36
+ if (!text || text.trim().length < 10) {
37
+ throw new Error('Could not extract text from file');
38
+ }
39
+
40
+ // Save raw file
41
+ const docsDir = join(this.home, 'agents', agentId, 'docs');
42
+ mkdirSync(docsDir, { recursive: true });
43
+ writeFileSync(join(docsDir, filename), buffer);
44
+
45
+ // Chunk and save to knowledge base
46
+ const chunks = this._chunk(text, 500);
47
+ const docId = 'doc_' + Date.now().toString(36);
48
+
49
+ await this.storage.saveDocument(agentId, {
50
+ id: docId,
51
+ title: filename,
52
+ content: text.slice(0, 500),
53
+ type: mimeType,
54
+ chunk_count: chunks.length,
55
+ });
56
+
57
+ for (let i = 0; i < chunks.length; i++) {
58
+ await this.storage.saveKnowledgeChunk(agentId, {
59
+ document_id: docId,
60
+ content: chunks[i],
61
+ chunk_index: i,
62
+ });
63
+ }
64
+
65
+ logger.info('doc-ingest', `Saved ${chunks.length} chunks from ${filename}`);
66
+ return { docId, chunks: chunks.length, chars: text.length };
67
+ }
68
+
69
+ async _extractPdf(buffer) {
70
+ // Try pdf-parse if available
71
+ try {
72
+ const pdfParse = (await import('pdf-parse')).default;
73
+ const data = await pdfParse(buffer);
74
+ return data.text;
75
+ } catch {
76
+ // Fallback: basic text extraction
77
+ const text = buffer.toString('utf8');
78
+ const readable = text.replace(/[^\x20-\x7E\n\r\t\u0600-\u06FF]/g, ' ')
79
+ .replace(/\s{3,}/g, '\n')
80
+ .trim();
81
+ if (readable.length > 50) return readable;
82
+ throw new Error('PDF parsing requires pdf-parse package. Run: npm i pdf-parse');
83
+ }
84
+ }
85
+
86
+ _chunk(text, maxWords) {
87
+ const paragraphs = text.split(/\n\s*\n/);
88
+ const chunks = [];
89
+ let current = '';
90
+
91
+ for (const para of paragraphs) {
92
+ const words = (current + '\n\n' + para).split(/\s+/).length;
93
+ if (words > maxWords && current) {
94
+ chunks.push(current.trim());
95
+ current = para;
96
+ } else {
97
+ current = current ? current + '\n\n' + para : para;
98
+ }
99
+ }
100
+ if (current.trim()) chunks.push(current.trim());
101
+ return chunks;
102
+ }
103
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 🦑 Usage Alerts
3
+ * Track spending and alert when thresholds are hit
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+
8
+ export class UsageAlerts {
9
+ constructor(storage) {
10
+ this.storage = storage;
11
+ this.thresholds = [1, 5, 10, 25, 50, 100]; // USD
12
+ this.alerted = new Set(); // track which thresholds already alerted
13
+ }
14
+
15
+ async check(agentId) {
16
+ try {
17
+ const usage = this.storage.db.prepare(
18
+ "SELECT SUM(cost_usd) as total FROM usage WHERE agent_id = ? AND created_at >= date('now', '-1 day')"
19
+ ).get(agentId);
20
+
21
+ const total = usage?.total || 0;
22
+
23
+ for (const threshold of this.thresholds) {
24
+ const key = `${agentId}_${threshold}`;
25
+ if (total >= threshold && !this.alerted.has(key)) {
26
+ this.alerted.add(key);
27
+ logger.warn('usage', `Agent ${agentId} hit $${threshold} in 24h (total: $${total.toFixed(2)})`);
28
+ return { alert: true, threshold, total: total.toFixed(2) };
29
+ }
30
+ }
31
+ } catch {}
32
+ return { alert: false };
33
+ }
34
+
35
+ async getSummary(agentId) {
36
+ const today = this.storage.db.prepare(
37
+ "SELECT SUM(cost_usd) as cost, COUNT(*) as calls, SUM(input_tokens + output_tokens) as tokens FROM usage WHERE agent_id = ? AND created_at >= date('now')"
38
+ ).get(agentId) || {};
39
+
40
+ const month = this.storage.db.prepare(
41
+ "SELECT SUM(cost_usd) as cost, COUNT(*) as calls, SUM(input_tokens + output_tokens) as tokens FROM usage WHERE agent_id = ? AND created_at >= date('now', '-30 days')"
42
+ ).get(agentId) || {};
43
+
44
+ return {
45
+ today: { cost: (today.cost || 0).toFixed(4), calls: today.calls || 0, tokens: today.tokens || 0 },
46
+ month: { cost: (month.cost || 0).toFixed(4), calls: month.calls || 0, tokens: month.tokens || 0 },
47
+ };
48
+ }
49
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 🦑 Voice Replies
3
+ * Convert text to speech and send as voice note
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+
8
+ export class VoiceReply {
9
+ constructor(config) {
10
+ this.config = config;
11
+ }
12
+
13
+ async generate(text, options = {}) {
14
+ const providers = this.config.ai?.providers || {};
15
+
16
+ // OpenAI TTS
17
+ if (providers.openai?.key) {
18
+ return this._openaiTTS(text, providers.openai.key, options);
19
+ }
20
+
21
+ // Google TTS (free via Translate trick)
22
+ return this._googleFreeTTS(text, options);
23
+ }
24
+
25
+ async _openaiTTS(text, apiKey, options) {
26
+ const voice = options.voice || 'nova';
27
+ const res = await fetch('https://api.openai.com/v1/audio/speech', {
28
+ method: 'POST',
29
+ headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
30
+ body: JSON.stringify({
31
+ model: 'tts-1',
32
+ input: text.slice(0, 4096),
33
+ voice,
34
+ response_format: 'opus',
35
+ }),
36
+ });
37
+
38
+ if (!res.ok) throw new Error('OpenAI TTS failed: ' + res.statusText);
39
+ return Buffer.from(await res.arrayBuffer());
40
+ }
41
+
42
+ async _googleFreeTTS(text, options) {
43
+ // Google Translate TTS — free, no API key needed
44
+ const lang = options.language === 'ar' ? 'ar' : 'en';
45
+ const encoded = encodeURIComponent(text.slice(0, 200));
46
+ const url = `https://translate.google.com/translate_tts?ie=UTF-8&q=${encoded}&tl=${lang}&client=tw-ob`;
47
+
48
+ const res = await fetch(url, {
49
+ headers: { 'User-Agent': 'Mozilla/5.0' },
50
+ });
51
+
52
+ if (!res.ok) throw new Error('Google TTS failed');
53
+ return Buffer.from(await res.arrayBuffer());
54
+ }
55
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "0.9.0",
4
- "description": "\ud83e\udd91 AI agent platform \u2014 human-like agents for WhatsApp, Telegram & more",
3
+ "version": "1.0.0",
4
+ "description": "🦑 AI agent platform human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {
7
7
  "squidclaw": "./bin/squidclaw.js"
@@ -55,4 +55,4 @@
55
55
  "yaml": "^2.8.2",
56
56
  "zod": "^4.3.6"
57
57
  }
58
- }
58
+ }