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.
- package/lib/ai/prompt-builder.js +39 -3
- package/lib/api/dashboard.js +167 -0
- package/lib/channels/telegram/bot.js +13 -0
- package/lib/cli/agent-cmd.js +107 -138
- package/lib/cli/update-cmd.js +44 -16
- package/lib/core/agent-tools-mixin.js +5 -0
- package/lib/engine.js +179 -6
- package/lib/features/auto-links.js +36 -0
- package/lib/features/auto-memory.js +107 -0
- package/lib/features/doc-ingest.js +103 -0
- package/lib/features/usage-alerts.js +49 -0
- package/lib/features/voice-reply.js +55 -0
- package/package.json +3 -3
package/lib/ai/prompt-builder.js
CHANGED
|
@@ -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
|
|
34
|
-
|
|
35
|
-
|
|
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;
|
package/lib/cli/agent-cmd.js
CHANGED
|
@@ -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 {
|
|
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
|
|
10
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
36
|
+
const language = await p.select({
|
|
19
37
|
message: 'Language:',
|
|
20
38
|
options: [
|
|
21
|
-
{ value: '
|
|
22
|
-
{ value: '
|
|
23
|
-
{ value: '
|
|
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
|
|
29
|
-
message: '
|
|
45
|
+
const personality = await p.select({
|
|
46
|
+
message: 'Personality:',
|
|
30
47
|
options: [
|
|
31
|
-
{ value:
|
|
32
|
-
{ value:
|
|
33
|
-
{ value:
|
|
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
|
|
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
|
-
|
|
43
|
-
const
|
|
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
|
-
|
|
73
|
+
writeFileSync(join(agentDir, 'agent.json'), JSON.stringify(manifest, null, 2));
|
|
46
74
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
console.log(chalk.
|
|
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
|
-
|
|
111
|
+
async function listAgents() {
|
|
67
112
|
const home = getHome();
|
|
68
113
|
const agentsDir = join(home, 'agents');
|
|
69
|
-
if (!existsSync(agentsDir))
|
|
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
|
-
}
|
|
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
|
|
94
|
-
console.log(` ${
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
181
|
-
|
|
149
|
+
rmSync(agentDir, { recursive: true });
|
|
150
|
+
console.log(chalk.green(`✅ Agent ${id} removed.`));
|
|
182
151
|
}
|
package/lib/cli/update-cmd.js
CHANGED
|
@@ -1,25 +1,53 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
execSync('npm
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
console.log(chalk.yellow(
|
|
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?.
|
|
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
|
-
|
|
311
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
+
}
|