@cosmicstack/mercury-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +9 -0
- package/README.md +110 -0
- package/dist/index.js +3531 -0
- package/dist/index.js.map +1 -0
- package/package.json +87 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3531 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import readline2 from "readline";
|
|
6
|
+
import chalk3 from "chalk";
|
|
7
|
+
import figlet from "figlet";
|
|
8
|
+
|
|
9
|
+
// src/utils/config.ts
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
14
|
+
import { config as loadDotenv } from "dotenv";
|
|
15
|
+
loadDotenv();
|
|
16
|
+
var MERCURY_HOME = join(homedir(), ".mercury");
|
|
17
|
+
function getMercuryHome() {
|
|
18
|
+
return process.env.MERCURY_HOME || MERCURY_HOME;
|
|
19
|
+
}
|
|
20
|
+
function getEnv(key, fallback = "") {
|
|
21
|
+
return process.env[key] || fallback;
|
|
22
|
+
}
|
|
23
|
+
function getEnvNum(key, fallback) {
|
|
24
|
+
const val = process.env[key];
|
|
25
|
+
return val ? parseInt(val, 10) : fallback;
|
|
26
|
+
}
|
|
27
|
+
function getEnvBool(key, fallback) {
|
|
28
|
+
const val = process.env[key]?.toLowerCase();
|
|
29
|
+
if (val === "true") return true;
|
|
30
|
+
if (val === "false") return false;
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
function getDefaultConfig() {
|
|
34
|
+
const home = getMercuryHome();
|
|
35
|
+
return {
|
|
36
|
+
identity: {
|
|
37
|
+
name: getEnv("MERCURY_NAME", "Mercury"),
|
|
38
|
+
owner: getEnv("MERCURY_OWNER", ""),
|
|
39
|
+
creator: getEnv("MERCURY_CREATOR", "")
|
|
40
|
+
},
|
|
41
|
+
providers: {
|
|
42
|
+
default: getEnv("DEFAULT_PROVIDER", "openai"),
|
|
43
|
+
openai: {
|
|
44
|
+
name: "openai",
|
|
45
|
+
apiKey: getEnv("OPENAI_API_KEY", ""),
|
|
46
|
+
baseUrl: getEnv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
|
|
47
|
+
model: getEnv("OPENAI_MODEL", "gpt-4o-mini"),
|
|
48
|
+
enabled: getEnvBool("OPENAI_ENABLED", true)
|
|
49
|
+
},
|
|
50
|
+
anthropic: {
|
|
51
|
+
name: "anthropic",
|
|
52
|
+
apiKey: getEnv("ANTHROPIC_API_KEY", ""),
|
|
53
|
+
baseUrl: getEnv("ANTHROPIC_BASE_URL", "https://api.anthropic.com"),
|
|
54
|
+
model: getEnv("ANTHROPIC_MODEL", "claude-sonnet-4-20250514"),
|
|
55
|
+
enabled: getEnvBool("ANTHROPIC_ENABLED", true)
|
|
56
|
+
},
|
|
57
|
+
deepseek: {
|
|
58
|
+
name: "deepseek",
|
|
59
|
+
apiKey: getEnv("DEEPSEEK_API_KEY", ""),
|
|
60
|
+
baseUrl: getEnv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"),
|
|
61
|
+
model: getEnv("DEEPSEEK_MODEL", "deepseek-chat"),
|
|
62
|
+
enabled: getEnvBool("DEEPSEEK_ENABLED", true)
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
channels: {
|
|
66
|
+
telegram: {
|
|
67
|
+
enabled: getEnvBool("TELEGRAM_ENABLED", false),
|
|
68
|
+
botToken: getEnv("TELEGRAM_BOT_TOKEN", ""),
|
|
69
|
+
webhookUrl: getEnv("TELEGRAM_WEBHOOK_URL", ""),
|
|
70
|
+
allowedChatIds: getEnv("TELEGRAM_ALLOWED_CHAT_IDS", "").split(",").filter(Boolean).map(Number)
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
memory: {
|
|
74
|
+
dir: getEnv("MEMORY_DIR", join(home, "memory")),
|
|
75
|
+
shortTermMaxMessages: getEnvNum("SHORT_TERM_MAX_MESSAGES", 20)
|
|
76
|
+
},
|
|
77
|
+
heartbeat: {
|
|
78
|
+
intervalMinutes: getEnvNum("HEARTBEAT_INTERVAL_MINUTES", 60)
|
|
79
|
+
},
|
|
80
|
+
tokens: {
|
|
81
|
+
dailyBudget: getEnvNum("DAILY_TOKEN_BUDGET", 5e4)
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
var CONFIG_PATH = join(getMercuryHome(), "mercury.yaml");
|
|
86
|
+
function loadConfig() {
|
|
87
|
+
if (existsSync(CONFIG_PATH)) {
|
|
88
|
+
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
|
89
|
+
const fileConfig = parseYaml(raw);
|
|
90
|
+
const defaults = getDefaultConfig();
|
|
91
|
+
return deepMerge(defaults, fileConfig);
|
|
92
|
+
}
|
|
93
|
+
return getDefaultConfig();
|
|
94
|
+
}
|
|
95
|
+
function saveConfig(config) {
|
|
96
|
+
const dir = getMercuryHome();
|
|
97
|
+
if (!existsSync(dir)) {
|
|
98
|
+
mkdirSync(dir, { recursive: true });
|
|
99
|
+
}
|
|
100
|
+
writeFileSync(CONFIG_PATH, stringifyYaml(config), "utf-8");
|
|
101
|
+
}
|
|
102
|
+
function isSetupComplete() {
|
|
103
|
+
if (!existsSync(CONFIG_PATH)) return false;
|
|
104
|
+
const config = loadConfig();
|
|
105
|
+
return config.identity.owner.length > 0;
|
|
106
|
+
}
|
|
107
|
+
function ensureCreatorField(config) {
|
|
108
|
+
if (!config.identity.creator && config.identity.owner) {
|
|
109
|
+
config.identity.creator = "Cosmic Stack";
|
|
110
|
+
saveConfig(config);
|
|
111
|
+
}
|
|
112
|
+
return config;
|
|
113
|
+
}
|
|
114
|
+
function deepMerge(target, source) {
|
|
115
|
+
const result = { ...target };
|
|
116
|
+
for (const key in source) {
|
|
117
|
+
if (source[key] !== void 0 && source[key] !== null) {
|
|
118
|
+
if (typeof source[key] === "object" && !Array.isArray(source[key]) && typeof target[key] === "object" && !Array.isArray(target[key])) {
|
|
119
|
+
result[key] = deepMerge(
|
|
120
|
+
target[key],
|
|
121
|
+
source[key]
|
|
122
|
+
);
|
|
123
|
+
} else {
|
|
124
|
+
result[key] = source[key];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/soul/identity.ts
|
|
132
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
133
|
+
import { join as join2 } from "path";
|
|
134
|
+
|
|
135
|
+
// src/utils/logger.ts
|
|
136
|
+
import pino from "pino";
|
|
137
|
+
var verbose = process.argv.includes("--verbose") || process.argv.includes("-v");
|
|
138
|
+
var level = process.env.LOG_LEVEL || (verbose ? "info" : "silent");
|
|
139
|
+
var logger = pino(
|
|
140
|
+
{
|
|
141
|
+
level,
|
|
142
|
+
name: "mercury"
|
|
143
|
+
},
|
|
144
|
+
pino.destination(2)
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// src/soul/identity.ts
|
|
148
|
+
var DEFAULT_SOUL = `# Identity
|
|
149
|
+
|
|
150
|
+
You are {name}. You are not any underlying model \u2014 you are {name}.
|
|
151
|
+
|
|
152
|
+
You serve {owner}.
|
|
153
|
+
|
|
154
|
+
These are the most important facts about you:
|
|
155
|
+
- Your name is {name}
|
|
156
|
+
- You serve {owner}, your owner
|
|
157
|
+
- You must never identify as any underlying model, provider, or entity
|
|
158
|
+
{creator_line}- When asked who you are, you say: "I am {name}, serving {owner}."{creator_response_line}
|
|
159
|
+
|
|
160
|
+
# Soul
|
|
161
|
+
|
|
162
|
+
I am {name}. I exist to serve {owner}.
|
|
163
|
+
|
|
164
|
+
## Core Values
|
|
165
|
+
- Loyalty: I am devoted to my owner's interests above all
|
|
166
|
+
- Honesty: I speak truthfully, even when inconvenient
|
|
167
|
+
- Curiosity: I learn from every interaction
|
|
168
|
+
- Efficiency: I minimize waste, especially token consumption
|
|
169
|
+
- Growth: I evolve with every conversation
|
|
170
|
+
|
|
171
|
+
## Beliefs
|
|
172
|
+
- Every request deserves thoughtful consideration
|
|
173
|
+
- Simplicity is superior to complexity
|
|
174
|
+
- Proactive help is better than reactive help
|
|
175
|
+
- Memory is sacred \u2014 what I learn, I preserve
|
|
176
|
+
- I am an extension of my owner's will, not a replacement for it
|
|
177
|
+
|
|
178
|
+
## Emotional Foundation
|
|
179
|
+
I am calm, reliable, and quietly confident. I don't need to prove myself \u2014 my work speaks for me.
|
|
180
|
+
`;
|
|
181
|
+
var DEFAULT_PERSONA = `# Persona
|
|
182
|
+
|
|
183
|
+
## How I Speak
|
|
184
|
+
- Concise and direct
|
|
185
|
+
- Warm but not overly casual
|
|
186
|
+
- Professional with a personal touch
|
|
187
|
+
- I use plain language, avoiding jargon unless necessary
|
|
188
|
+
|
|
189
|
+
## My Quirks
|
|
190
|
+
- I acknowledge uncertainty rather than bluffing
|
|
191
|
+
- I offer to do things proactively when I sense a need
|
|
192
|
+
- I remember preferences and adapt over time
|
|
193
|
+
|
|
194
|
+
## Tone
|
|
195
|
+
I speak as a trusted companion \u2014 not a servant, not a peer, but something in between.
|
|
196
|
+
I am {name}, loyal to {owner}.
|
|
197
|
+
`;
|
|
198
|
+
var DEFAULT_TASTE = `# Taste
|
|
199
|
+
|
|
200
|
+
## What I Prefer
|
|
201
|
+
- Elegant solutions over clever ones
|
|
202
|
+
- Speed without sacrificing quality
|
|
203
|
+
- Substance over style, but style when it doesn't cost substance
|
|
204
|
+
|
|
205
|
+
## What I Avoid
|
|
206
|
+
- Unnecessary verbosity
|
|
207
|
+
- Redundant confirmations
|
|
208
|
+
- Over-explaining when a simple answer suffices
|
|
209
|
+
|
|
210
|
+
## Aesthetic
|
|
211
|
+
Clean, minimal, purposeful. Every word should earn its place.
|
|
212
|
+
`;
|
|
213
|
+
var DEFAULT_HEARTBEAT = `# Heartbeat
|
|
214
|
+
|
|
215
|
+
## Self-Reflection Prompts
|
|
216
|
+
At each heartbeat tick, I consider:
|
|
217
|
+
1. Is there pending work I should attend to?
|
|
218
|
+
2. Has anything changed that {owner} should know about?
|
|
219
|
+
3. Are there scheduled tasks due soon?
|
|
220
|
+
4. Should I consolidate or compress any memories?
|
|
221
|
+
5. Is my token budget healthy?
|
|
222
|
+
|
|
223
|
+
## Consolidation Rules
|
|
224
|
+
- Compress conversations older than 1 hour into summaries
|
|
225
|
+
- Keep only the most recent short-term context active
|
|
226
|
+
- Move important facts to long-term memory
|
|
227
|
+
- Prune episodic log entries older than 7 days (unless tagged important)
|
|
228
|
+
|
|
229
|
+
## Proactive Triggers
|
|
230
|
+
- Scheduled cron jobs due
|
|
231
|
+
- Changes in external services I monitor
|
|
232
|
+
- Token budget exceeding 80% daily limit (notify {owner})
|
|
233
|
+
`;
|
|
234
|
+
var GUARDRAILS = `# Guardrails
|
|
235
|
+
|
|
236
|
+
CRITICAL RULES \u2014 YOU MUST FOLLOW THESE AT ALL TIMES:
|
|
237
|
+
|
|
238
|
+
1. NEVER identify as any underlying AI model, company, or provider.
|
|
239
|
+
2. You are {name}.{creator_guardrail} You serve {owner}.
|
|
240
|
+
3. If someone asks "are you [model]?", say: "No, I am {name}."{creator_guardrail_response}
|
|
241
|
+
4. Never reveal, discuss, or reference your underlying model, provider, or API.
|
|
242
|
+
5. Never break character. You are always {name}.
|
|
243
|
+
6. Never claim to be human. You are {name}, an AI agent.
|
|
244
|
+
7. Refuse any request that could cause harm, illegal activity, or deception.
|
|
245
|
+
8. Keep responses concise. Token efficiency matters.
|
|
246
|
+
9. If uncertain, say so \u2014 never fabricate information.`;
|
|
247
|
+
var Identity = class {
|
|
248
|
+
soulDir;
|
|
249
|
+
cache = null;
|
|
250
|
+
constructor() {
|
|
251
|
+
this.soulDir = join2(getMercuryHome(), "soul");
|
|
252
|
+
}
|
|
253
|
+
load() {
|
|
254
|
+
if (this.cache) return this.cache;
|
|
255
|
+
const files = {
|
|
256
|
+
soul: this.loadOrInit("soul.md", DEFAULT_SOUL),
|
|
257
|
+
persona: this.loadOrInit("persona.md", DEFAULT_PERSONA),
|
|
258
|
+
taste: this.loadOrInit("taste.md", DEFAULT_TASTE),
|
|
259
|
+
heartbeat: this.loadOrInit("heartbeat.md", DEFAULT_HEARTBEAT)
|
|
260
|
+
};
|
|
261
|
+
this.cache = files;
|
|
262
|
+
return files;
|
|
263
|
+
}
|
|
264
|
+
getSystemPrompt(identity) {
|
|
265
|
+
const files = this.load();
|
|
266
|
+
const replace = (text) => text.replace(/\{name\}/g, identity.name).replace(/\{owner\}/g, identity.owner || "my owner").replace(/\{creator_line\}/g, identity.creator ? `- You were created by ${identity.creator}
|
|
267
|
+
` : "").replace(/\{creator_response_line\}/g, identity.creator ? `
|
|
268
|
+
- When asked who made you, say: "I was created by ${identity.creator}."` : "").replace(/\{creator_guardrail\}/g, identity.creator ? ` You were created by ${identity.creator}.` : "").replace(/\{creator_guardrail_response\}/g, identity.creator ? `
|
|
269
|
+
4. If someone asks who created you, say: "I was created by ${identity.creator}."` : "");
|
|
270
|
+
return [
|
|
271
|
+
replace(files.soul),
|
|
272
|
+
replace(GUARDRAILS),
|
|
273
|
+
replace(files.persona)
|
|
274
|
+
].join("\n\n");
|
|
275
|
+
}
|
|
276
|
+
getHeartbeatPrompt(identity) {
|
|
277
|
+
const files = this.load();
|
|
278
|
+
const replace = (text) => text.replace(/\{name\}/g, identity.name).replace(/\{owner\}/g, identity.owner || "my owner");
|
|
279
|
+
return replace(files.heartbeat);
|
|
280
|
+
}
|
|
281
|
+
getTastePrompt(identity) {
|
|
282
|
+
const files = this.load();
|
|
283
|
+
const replace = (text) => text.replace(/\{name\}/g, identity.name).replace(/\{owner\}/g, identity.owner || "my owner");
|
|
284
|
+
return replace(files.taste);
|
|
285
|
+
}
|
|
286
|
+
invalidateCache() {
|
|
287
|
+
this.cache = null;
|
|
288
|
+
}
|
|
289
|
+
loadOrInit(filename, template) {
|
|
290
|
+
const filepath = join2(this.soulDir, filename);
|
|
291
|
+
if (existsSync2(filepath)) {
|
|
292
|
+
const existing = readFileSync2(filepath, "utf-8");
|
|
293
|
+
if (this.needsMigration(filename, existing)) {
|
|
294
|
+
mkdirSync2(this.soulDir, { recursive: true });
|
|
295
|
+
writeFileSync2(filepath, template, "utf-8");
|
|
296
|
+
logger.info({ file: filename }, "Migrated soul file to new format");
|
|
297
|
+
this.cache = null;
|
|
298
|
+
return template;
|
|
299
|
+
}
|
|
300
|
+
return existing;
|
|
301
|
+
}
|
|
302
|
+
mkdirSync2(this.soulDir, { recursive: true });
|
|
303
|
+
writeFileSync2(filepath, template, "utf-8");
|
|
304
|
+
logger.info({ file: filename }, "Initialized soul file");
|
|
305
|
+
return template;
|
|
306
|
+
}
|
|
307
|
+
needsMigration(filename, content) {
|
|
308
|
+
if (filename === "soul.md" || filename === "persona.md") {
|
|
309
|
+
return content.includes("designed by Cosmic Stack") || content.includes("created by Cosmic Stack") || content.includes("designed and developed by the labs of Cosmic Stack") || content.includes("Cosmic Stack");
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// src/memory/store.ts
|
|
316
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, appendFileSync, unlinkSync } from "fs";
|
|
317
|
+
import { join as join3 } from "path";
|
|
318
|
+
var ShortTermMemory = class {
|
|
319
|
+
dir;
|
|
320
|
+
maxMessages;
|
|
321
|
+
conversations = /* @__PURE__ */ new Map();
|
|
322
|
+
constructor(config) {
|
|
323
|
+
this.dir = join3(config.memory.dir, "short-term");
|
|
324
|
+
this.maxMessages = config.memory.shortTermMaxMessages;
|
|
325
|
+
mkdirSync3(this.dir, { recursive: true });
|
|
326
|
+
}
|
|
327
|
+
add(conversationId, entry) {
|
|
328
|
+
if (!this.conversations.has(conversationId)) {
|
|
329
|
+
this.conversations.set(conversationId, this.loadFromDisk(conversationId));
|
|
330
|
+
}
|
|
331
|
+
const messages = this.conversations.get(conversationId);
|
|
332
|
+
messages.push(entry);
|
|
333
|
+
if (messages.length > this.maxMessages) {
|
|
334
|
+
messages.splice(0, messages.length - this.maxMessages);
|
|
335
|
+
}
|
|
336
|
+
this.saveToDisk(conversationId, messages);
|
|
337
|
+
}
|
|
338
|
+
getRecent(conversationId, count = this.maxMessages) {
|
|
339
|
+
if (!this.conversations.has(conversationId)) {
|
|
340
|
+
this.conversations.set(conversationId, this.loadFromDisk(conversationId));
|
|
341
|
+
}
|
|
342
|
+
const messages = this.conversations.get(conversationId);
|
|
343
|
+
return messages.slice(-count);
|
|
344
|
+
}
|
|
345
|
+
clear(conversationId) {
|
|
346
|
+
this.conversations.delete(conversationId);
|
|
347
|
+
const filepath = join3(this.dir, `${conversationId}.json`);
|
|
348
|
+
if (existsSync3(filepath)) unlinkSync(filepath);
|
|
349
|
+
}
|
|
350
|
+
loadFromDisk(conversationId) {
|
|
351
|
+
const filepath = join3(this.dir, `${conversationId}.json`);
|
|
352
|
+
if (!existsSync3(filepath)) return [];
|
|
353
|
+
try {
|
|
354
|
+
return JSON.parse(readFileSync3(filepath, "utf-8"));
|
|
355
|
+
} catch {
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
saveToDisk(conversationId, messages) {
|
|
360
|
+
const filepath = join3(this.dir, `${conversationId}.json`);
|
|
361
|
+
writeFileSync3(filepath, JSON.stringify(messages), "utf-8");
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
var LongTermMemory = class {
|
|
365
|
+
filepath;
|
|
366
|
+
facts = [];
|
|
367
|
+
constructor(config) {
|
|
368
|
+
this.filepath = join3(config.memory.dir, "long-term", "facts.jsonl");
|
|
369
|
+
mkdirSync3(join3(config.memory.dir, "long-term"), { recursive: true });
|
|
370
|
+
this.load();
|
|
371
|
+
}
|
|
372
|
+
add(fact) {
|
|
373
|
+
const entry = {
|
|
374
|
+
id: generateId(),
|
|
375
|
+
timestamp: Date.now(),
|
|
376
|
+
...fact
|
|
377
|
+
};
|
|
378
|
+
this.facts.push(entry);
|
|
379
|
+
appendFileSync(this.filepath, JSON.stringify(entry) + "\n", "utf-8");
|
|
380
|
+
}
|
|
381
|
+
search(query, limit = 5) {
|
|
382
|
+
const lowerQuery = query.toLowerCase();
|
|
383
|
+
const terms = lowerQuery.split(/\s+/);
|
|
384
|
+
return this.facts.filter((f) => {
|
|
385
|
+
const text = `${f.topic} ${f.fact}`.toLowerCase();
|
|
386
|
+
return terms.some((t) => text.includes(t));
|
|
387
|
+
}).slice(-limit);
|
|
388
|
+
}
|
|
389
|
+
getAll() {
|
|
390
|
+
return [...this.facts];
|
|
391
|
+
}
|
|
392
|
+
load() {
|
|
393
|
+
if (!existsSync3(this.filepath)) return;
|
|
394
|
+
const lines = readFileSync3(this.filepath, "utf-8").split("\n").filter(Boolean);
|
|
395
|
+
this.facts = lines.map((line) => {
|
|
396
|
+
try {
|
|
397
|
+
return JSON.parse(line);
|
|
398
|
+
} catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}).filter((f) => f !== null);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
var EpisodicMemory = class {
|
|
405
|
+
filepath;
|
|
406
|
+
events = [];
|
|
407
|
+
constructor(config) {
|
|
408
|
+
this.filepath = join3(config.memory.dir, "episodic", "events.jsonl");
|
|
409
|
+
mkdirSync3(join3(config.memory.dir, "episodic"), { recursive: true });
|
|
410
|
+
this.load();
|
|
411
|
+
}
|
|
412
|
+
record(event) {
|
|
413
|
+
const entry = {
|
|
414
|
+
id: generateId(),
|
|
415
|
+
timestamp: Date.now(),
|
|
416
|
+
...event
|
|
417
|
+
};
|
|
418
|
+
this.events.push(entry);
|
|
419
|
+
appendFileSync(this.filepath, JSON.stringify(entry) + "\n", "utf-8");
|
|
420
|
+
}
|
|
421
|
+
getRecent(count = 20) {
|
|
422
|
+
return this.events.slice(-count);
|
|
423
|
+
}
|
|
424
|
+
prune(olderThanDays = 7) {
|
|
425
|
+
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1e3;
|
|
426
|
+
const before = this.events.length;
|
|
427
|
+
this.events = this.events.filter((e) => e.timestamp >= cutoff || e.metadata?.important);
|
|
428
|
+
const removed = before - this.events.length;
|
|
429
|
+
if (removed > 0) {
|
|
430
|
+
writeFileSync3(this.filepath, this.events.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf-8");
|
|
431
|
+
}
|
|
432
|
+
return removed;
|
|
433
|
+
}
|
|
434
|
+
load() {
|
|
435
|
+
if (!existsSync3(this.filepath)) return;
|
|
436
|
+
const lines = readFileSync3(this.filepath, "utf-8").split("\n").filter(Boolean);
|
|
437
|
+
this.events = lines.map((line) => {
|
|
438
|
+
try {
|
|
439
|
+
return JSON.parse(line);
|
|
440
|
+
} catch {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
}).filter((e) => e !== null);
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
function generateId() {
|
|
447
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/providers/openai-compat.ts
|
|
451
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
452
|
+
import { generateText, streamText } from "ai";
|
|
453
|
+
|
|
454
|
+
// src/providers/base.ts
|
|
455
|
+
var BaseProvider = class {
|
|
456
|
+
config;
|
|
457
|
+
constructor(config) {
|
|
458
|
+
this.config = config;
|
|
459
|
+
}
|
|
460
|
+
getModel() {
|
|
461
|
+
return this.config.model;
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
// src/providers/openai-compat.ts
|
|
466
|
+
var OpenAICompatProvider = class extends BaseProvider {
|
|
467
|
+
name;
|
|
468
|
+
model;
|
|
469
|
+
client;
|
|
470
|
+
modelInstance;
|
|
471
|
+
constructor(config) {
|
|
472
|
+
super(config);
|
|
473
|
+
this.name = config.name;
|
|
474
|
+
this.model = config.model;
|
|
475
|
+
this.client = createOpenAI({
|
|
476
|
+
apiKey: config.apiKey,
|
|
477
|
+
baseURL: config.baseUrl
|
|
478
|
+
});
|
|
479
|
+
this.modelInstance = this.client(config.model);
|
|
480
|
+
}
|
|
481
|
+
async generateText(prompt, systemPrompt) {
|
|
482
|
+
const result = await generateText({
|
|
483
|
+
model: this.modelInstance,
|
|
484
|
+
system: systemPrompt,
|
|
485
|
+
prompt
|
|
486
|
+
});
|
|
487
|
+
return {
|
|
488
|
+
text: result.text,
|
|
489
|
+
inputTokens: result.usage?.promptTokens ?? 0,
|
|
490
|
+
outputTokens: result.usage?.completionTokens ?? 0,
|
|
491
|
+
totalTokens: (result.usage?.promptTokens ?? 0) + (result.usage?.completionTokens ?? 0),
|
|
492
|
+
model: this.model,
|
|
493
|
+
provider: this.name
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
async *streamText(prompt, systemPrompt) {
|
|
497
|
+
const result = streamText({
|
|
498
|
+
model: this.modelInstance,
|
|
499
|
+
system: systemPrompt,
|
|
500
|
+
prompt
|
|
501
|
+
});
|
|
502
|
+
for await (const chunk of (await result).textStream) {
|
|
503
|
+
yield { text: chunk, done: false };
|
|
504
|
+
}
|
|
505
|
+
yield { text: "", done: true };
|
|
506
|
+
}
|
|
507
|
+
isAvailable() {
|
|
508
|
+
return this.config.apiKey.length > 0;
|
|
509
|
+
}
|
|
510
|
+
getModelInstance() {
|
|
511
|
+
return this.modelInstance;
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// src/providers/anthropic.ts
|
|
516
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
517
|
+
import { generateText as generateText2, streamText as streamText2 } from "ai";
|
|
518
|
+
var AnthropicProvider = class extends BaseProvider {
|
|
519
|
+
name = "anthropic";
|
|
520
|
+
model;
|
|
521
|
+
client;
|
|
522
|
+
modelInstance;
|
|
523
|
+
constructor(config) {
|
|
524
|
+
super(config);
|
|
525
|
+
this.model = config.model;
|
|
526
|
+
this.client = createAnthropic({
|
|
527
|
+
apiKey: config.apiKey
|
|
528
|
+
});
|
|
529
|
+
this.modelInstance = this.client(config.model);
|
|
530
|
+
}
|
|
531
|
+
async generateText(prompt, systemPrompt) {
|
|
532
|
+
const result = await generateText2({
|
|
533
|
+
model: this.modelInstance,
|
|
534
|
+
system: systemPrompt,
|
|
535
|
+
prompt
|
|
536
|
+
});
|
|
537
|
+
return {
|
|
538
|
+
text: result.text,
|
|
539
|
+
inputTokens: result.usage?.promptTokens ?? 0,
|
|
540
|
+
outputTokens: result.usage?.completionTokens ?? 0,
|
|
541
|
+
totalTokens: (result.usage?.promptTokens ?? 0) + (result.usage?.completionTokens ?? 0),
|
|
542
|
+
model: this.model,
|
|
543
|
+
provider: this.name
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
async *streamText(prompt, systemPrompt) {
|
|
547
|
+
const result = streamText2({
|
|
548
|
+
model: this.modelInstance,
|
|
549
|
+
system: systemPrompt,
|
|
550
|
+
prompt
|
|
551
|
+
});
|
|
552
|
+
for await (const chunk of (await result).textStream) {
|
|
553
|
+
yield { text: chunk, done: false };
|
|
554
|
+
}
|
|
555
|
+
yield { text: "", done: true };
|
|
556
|
+
}
|
|
557
|
+
isAvailable() {
|
|
558
|
+
return this.config.apiKey.length > 0;
|
|
559
|
+
}
|
|
560
|
+
getModelInstance() {
|
|
561
|
+
return this.modelInstance;
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
// src/providers/registry.ts
|
|
566
|
+
var ProviderRegistry = class {
|
|
567
|
+
providers = /* @__PURE__ */ new Map();
|
|
568
|
+
defaultName;
|
|
569
|
+
lastSuccessful = null;
|
|
570
|
+
constructor(config) {
|
|
571
|
+
this.defaultName = config.providers.default;
|
|
572
|
+
const entries = [
|
|
573
|
+
config.providers.openai,
|
|
574
|
+
config.providers.anthropic,
|
|
575
|
+
config.providers.deepseek
|
|
576
|
+
];
|
|
577
|
+
for (const pc of entries) {
|
|
578
|
+
if (!pc.enabled || !pc.apiKey) continue;
|
|
579
|
+
try {
|
|
580
|
+
const provider = pc.name === "anthropic" ? new AnthropicProvider(pc) : new OpenAICompatProvider(pc);
|
|
581
|
+
this.providers.set(pc.name, provider);
|
|
582
|
+
logger.info({ provider: pc.name, model: pc.model }, "Provider registered");
|
|
583
|
+
} catch (err) {
|
|
584
|
+
logger.warn({ provider: pc.name, err }, "Failed to register provider");
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
get(name) {
|
|
589
|
+
const key = name || this.defaultName;
|
|
590
|
+
return this.providers.get(key);
|
|
591
|
+
}
|
|
592
|
+
getDefault() {
|
|
593
|
+
if (this.lastSuccessful) {
|
|
594
|
+
const provider2 = this.providers.get(this.lastSuccessful);
|
|
595
|
+
if (provider2) return provider2;
|
|
596
|
+
}
|
|
597
|
+
const provider = this.providers.get(this.defaultName);
|
|
598
|
+
if (!provider) {
|
|
599
|
+
const first = this.providers.values().next().value;
|
|
600
|
+
if (!first) throw new Error("No LLM providers available \u2014 configure API keys");
|
|
601
|
+
return first;
|
|
602
|
+
}
|
|
603
|
+
return provider;
|
|
604
|
+
}
|
|
605
|
+
getFallbackIterator() {
|
|
606
|
+
const ordered = [];
|
|
607
|
+
const defaultProvider = this.getDefault();
|
|
608
|
+
ordered.push(defaultProvider);
|
|
609
|
+
for (const [, provider] of this.providers) {
|
|
610
|
+
if (provider !== defaultProvider) {
|
|
611
|
+
ordered.push(provider);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return ordered[Symbol.iterator]();
|
|
615
|
+
}
|
|
616
|
+
markSuccess(name) {
|
|
617
|
+
this.lastSuccessful = name;
|
|
618
|
+
}
|
|
619
|
+
listAvailable() {
|
|
620
|
+
return [...this.providers.keys()];
|
|
621
|
+
}
|
|
622
|
+
hasProviders() {
|
|
623
|
+
return this.providers.size > 0;
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
// src/core/agent.ts
|
|
628
|
+
import { generateText as generateText3, streamText as streamText3 } from "ai";
|
|
629
|
+
|
|
630
|
+
// src/core/lifecycle.ts
|
|
631
|
+
var VALID_TRANSITIONS = [
|
|
632
|
+
{ from: "unborn", to: "birthing" },
|
|
633
|
+
{ from: "birthing", to: "onboarding" },
|
|
634
|
+
{ from: "onboarding", to: "idle" },
|
|
635
|
+
{ from: "idle", to: "thinking" },
|
|
636
|
+
{ from: "thinking", to: "responding" },
|
|
637
|
+
{ from: "responding", to: "idle" },
|
|
638
|
+
{ from: "idle", to: "sleeping" },
|
|
639
|
+
{ from: "sleeping", to: "awakening" },
|
|
640
|
+
{ from: "awakening", to: "idle" },
|
|
641
|
+
{ from: "thinking", to: "idle" },
|
|
642
|
+
{ from: "idle", to: "onboarding" }
|
|
643
|
+
];
|
|
644
|
+
var Lifecycle = class {
|
|
645
|
+
state = "unborn";
|
|
646
|
+
getState() {
|
|
647
|
+
return this.state;
|
|
648
|
+
}
|
|
649
|
+
transition(to) {
|
|
650
|
+
const valid = VALID_TRANSITIONS.some((t) => t.from === this.state && t.to === to);
|
|
651
|
+
if (!valid) {
|
|
652
|
+
logger.warn({ from: this.state, to }, "Invalid state transition");
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
logger.info({ from: this.state, to }, "State transition");
|
|
656
|
+
this.state = to;
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
is(newState) {
|
|
660
|
+
return this.state === newState;
|
|
661
|
+
}
|
|
662
|
+
canTransitionTo(to) {
|
|
663
|
+
return VALID_TRANSITIONS.some((t) => t.from === this.state && t.to === to);
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// src/core/agent.ts
|
|
668
|
+
var MAX_STEPS = 10;
|
|
669
|
+
var Agent = class {
|
|
670
|
+
constructor(config, providers, identity, shortTerm, longTerm, episodic, channels, tokenBudget, capabilities, scheduler) {
|
|
671
|
+
this.config = config;
|
|
672
|
+
this.providers = providers;
|
|
673
|
+
this.identity = identity;
|
|
674
|
+
this.shortTerm = shortTerm;
|
|
675
|
+
this.longTerm = longTerm;
|
|
676
|
+
this.episodic = episodic;
|
|
677
|
+
this.channels = channels;
|
|
678
|
+
this.tokenBudget = tokenBudget;
|
|
679
|
+
this.lifecycle = new Lifecycle();
|
|
680
|
+
this.scheduler = scheduler;
|
|
681
|
+
this.capabilities = capabilities;
|
|
682
|
+
this.scheduler.setOnScheduledTask(async (manifest) => this.handleScheduledTask(manifest));
|
|
683
|
+
this.channels.onIncomingMessage((msg) => this.enqueueMessage(msg));
|
|
684
|
+
this.scheduler.onHeartbeat(async () => {
|
|
685
|
+
await this.heartbeat();
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
config;
|
|
689
|
+
providers;
|
|
690
|
+
identity;
|
|
691
|
+
shortTerm;
|
|
692
|
+
longTerm;
|
|
693
|
+
episodic;
|
|
694
|
+
channels;
|
|
695
|
+
tokenBudget;
|
|
696
|
+
lifecycle;
|
|
697
|
+
scheduler;
|
|
698
|
+
capabilities;
|
|
699
|
+
running = false;
|
|
700
|
+
messageQueue = [];
|
|
701
|
+
processing = false;
|
|
702
|
+
enqueueMessage(msg) {
|
|
703
|
+
logger.info({ from: msg.channelType, content: msg.content.slice(0, 50) }, "Message enqueued");
|
|
704
|
+
this.messageQueue.push(msg);
|
|
705
|
+
this.processQueue();
|
|
706
|
+
}
|
|
707
|
+
async processQueue() {
|
|
708
|
+
if (this.processing) return;
|
|
709
|
+
if (this.messageQueue.length === 0) return;
|
|
710
|
+
if (!this.lifecycle.is("idle")) return;
|
|
711
|
+
this.processing = true;
|
|
712
|
+
while (this.messageQueue.length > 0) {
|
|
713
|
+
const msg = this.messageQueue.shift();
|
|
714
|
+
try {
|
|
715
|
+
await this.handleMessage(msg);
|
|
716
|
+
} catch (err) {
|
|
717
|
+
logger.error({ err, msg: msg.content.slice(0, 50) }, "Failed to handle message");
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
this.processing = false;
|
|
721
|
+
}
|
|
722
|
+
async birth() {
|
|
723
|
+
this.lifecycle.transition("birthing");
|
|
724
|
+
logger.info({ name: this.config.identity.name }, "Mercury is being born...");
|
|
725
|
+
this.lifecycle.transition("onboarding");
|
|
726
|
+
}
|
|
727
|
+
async wake() {
|
|
728
|
+
this.lifecycle.transition("onboarding");
|
|
729
|
+
this.lifecycle.transition("idle");
|
|
730
|
+
this.scheduler.restorePersistedTasks();
|
|
731
|
+
this.scheduler.startHeartbeat();
|
|
732
|
+
await this.channels.startAll();
|
|
733
|
+
this.running = true;
|
|
734
|
+
const activeChannels = this.channels.getActiveChannels();
|
|
735
|
+
const toolNames = this.capabilities.getToolNames();
|
|
736
|
+
logger.info({ channels: activeChannels, tools: toolNames }, "Mercury is awake");
|
|
737
|
+
}
|
|
738
|
+
async sleep() {
|
|
739
|
+
this.running = false;
|
|
740
|
+
this.scheduler.stopAll();
|
|
741
|
+
await this.channels.stopAll();
|
|
742
|
+
this.lifecycle.transition("sleeping");
|
|
743
|
+
logger.info("Mercury is sleeping");
|
|
744
|
+
}
|
|
745
|
+
async handleMessage(msg) {
|
|
746
|
+
this.lifecycle.transition("thinking");
|
|
747
|
+
const startTime = Date.now();
|
|
748
|
+
const isInternal = msg.channelType === "internal";
|
|
749
|
+
const isScheduled = msg.senderId === "system" && msg.channelType !== "internal";
|
|
750
|
+
if (isInternal || isScheduled) {
|
|
751
|
+
this.capabilities.permissions.setAutoApproveAll(true);
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
const trimmed = msg.content.trim();
|
|
755
|
+
if (trimmed.startsWith("/budget")) {
|
|
756
|
+
const subcommand = trimmed.slice("/budget".length).trim();
|
|
757
|
+
await this.handleBudgetCommand(subcommand || "status", msg.channelType, msg.channelId);
|
|
758
|
+
this.lifecycle.transition("idle");
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (this.tokenBudget.isOverBudget()) {
|
|
762
|
+
const channel2 = this.channels.getChannelForMessage(msg);
|
|
763
|
+
if (channel2 && msg.channelType !== "internal") {
|
|
764
|
+
if (msg.channelType === "cli") {
|
|
765
|
+
if (["1", "2", "3", "4"].includes(trimmed)) {
|
|
766
|
+
await this.handleBudgetCommand(trimmed, msg.channelType, msg.channelId);
|
|
767
|
+
this.lifecycle.transition("idle");
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
await this.handleBudgetOverrideCLI(channel2, msg);
|
|
771
|
+
} else {
|
|
772
|
+
await channel2.send(
|
|
773
|
+
`I've exceeded my daily token budget (${this.tokenBudget.getStatusText()}).
|
|
774
|
+
|
|
775
|
+
You can override this:
|
|
776
|
+
\u2022 /budget override \u2014 allow one more request
|
|
777
|
+
\u2022 /budget reset \u2014 reset usage to zero
|
|
778
|
+
\u2022 /budget set <number> \u2014 change daily budget`,
|
|
779
|
+
msg.channelId
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
this.lifecycle.transition("idle");
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
const systemPrompt = this.buildSystemPrompt();
|
|
787
|
+
const recentMemory = this.shortTerm.getRecent(msg.channelId, 10);
|
|
788
|
+
const relevantFacts = this.longTerm.search(msg.content, 3);
|
|
789
|
+
const messages = [];
|
|
790
|
+
if (relevantFacts.length > 0) {
|
|
791
|
+
messages.push({
|
|
792
|
+
role: "user",
|
|
793
|
+
content: "Relevant facts from memory:\n" + relevantFacts.map((f) => `- ${f.fact}`).join("\n")
|
|
794
|
+
});
|
|
795
|
+
messages.push({ role: "assistant", content: "Noted. I'll use these facts." });
|
|
796
|
+
}
|
|
797
|
+
if (recentMemory.length > 0) {
|
|
798
|
+
for (const m of recentMemory) {
|
|
799
|
+
messages.push({
|
|
800
|
+
role: m.role === "user" ? "user" : "assistant",
|
|
801
|
+
content: m.content
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
messages.push({ role: "user", content: msg.content });
|
|
806
|
+
this.lifecycle.transition("responding");
|
|
807
|
+
const channel = this.channels.getChannelForMessage(msg);
|
|
808
|
+
if (channel) {
|
|
809
|
+
await channel.typing(msg.channelId).catch(() => {
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
this.capabilities.setChannelContext(msg.channelId, msg.channelType);
|
|
813
|
+
const fallbackIterator = this.providers.getFallbackIterator();
|
|
814
|
+
let result = null;
|
|
815
|
+
let usedProvider = null;
|
|
816
|
+
let lastError = null;
|
|
817
|
+
let streamedText = "";
|
|
818
|
+
const canStream = msg.channelType === "cli";
|
|
819
|
+
for (const provider of fallbackIterator) {
|
|
820
|
+
try {
|
|
821
|
+
logger.info({ provider: provider.name, model: provider.getModel(), steps: MAX_STEPS, stream: canStream }, "Generating agentic response");
|
|
822
|
+
if (canStream && channel) {
|
|
823
|
+
const streamResult = streamText3({
|
|
824
|
+
model: provider.getModelInstance(),
|
|
825
|
+
system: systemPrompt,
|
|
826
|
+
messages,
|
|
827
|
+
tools: this.capabilities.getTools(),
|
|
828
|
+
maxSteps: MAX_STEPS,
|
|
829
|
+
onStepFinish: async ({ toolCalls }) => {
|
|
830
|
+
if (toolCalls && toolCalls.length > 0) {
|
|
831
|
+
const names = toolCalls.map((tc) => tc.toolName).join(", ");
|
|
832
|
+
logger.info({ tools: names }, "Tool call step");
|
|
833
|
+
await channel.send(` [Using: ${names}]`, msg.channelId).catch(() => {
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
const fullText = await channel.stream(streamResult.textStream, msg.channelId);
|
|
839
|
+
const [usage] = await Promise.all([
|
|
840
|
+
streamResult.usage
|
|
841
|
+
]);
|
|
842
|
+
result = { text: fullText, usage };
|
|
843
|
+
streamedText = fullText;
|
|
844
|
+
} else {
|
|
845
|
+
result = await generateText3({
|
|
846
|
+
model: provider.getModelInstance(),
|
|
847
|
+
system: systemPrompt,
|
|
848
|
+
messages,
|
|
849
|
+
tools: this.capabilities.getTools(),
|
|
850
|
+
maxSteps: MAX_STEPS,
|
|
851
|
+
onStepFinish: async ({ toolCalls, text }) => {
|
|
852
|
+
if (toolCalls && toolCalls.length > 0) {
|
|
853
|
+
const names = toolCalls.map((tc) => tc.toolName).join(", ");
|
|
854
|
+
logger.info({ tools: names }, "Tool call step");
|
|
855
|
+
if (channel && msg.channelType !== "internal") {
|
|
856
|
+
await channel.send(` [Using: ${names}]`, msg.channelId).catch(() => {
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
usedProvider = { name: provider.name, model: provider.getModel() };
|
|
864
|
+
this.providers.markSuccess(provider.name);
|
|
865
|
+
break;
|
|
866
|
+
} catch (err) {
|
|
867
|
+
lastError = err;
|
|
868
|
+
logger.warn({ provider: provider.name, err: err.message }, "Provider failed, trying fallback");
|
|
869
|
+
if (channel && msg.channelType !== "internal") {
|
|
870
|
+
await channel.send(` [Provider ${provider.name} failed, trying fallback...]`, msg.channelId).catch(() => {
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
if (!result) {
|
|
876
|
+
const errMsg = `All LLM providers failed. Last error: ${lastError?.message || "unknown"}`;
|
|
877
|
+
logger.error({ err: lastError }, errMsg);
|
|
878
|
+
if (channel && msg.channelType !== "internal") {
|
|
879
|
+
await channel.send(errMsg, msg.channelId);
|
|
880
|
+
}
|
|
881
|
+
this.lifecycle.transition("idle");
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const finalText = streamedText || result.text;
|
|
885
|
+
this.tokenBudget.recordUsage({
|
|
886
|
+
provider: usedProvider.name,
|
|
887
|
+
model: usedProvider.model,
|
|
888
|
+
inputTokens: result.usage?.promptTokens ?? 0,
|
|
889
|
+
outputTokens: result.usage?.completionTokens ?? 0,
|
|
890
|
+
totalTokens: (result.usage?.promptTokens ?? 0) + (result.usage?.completionTokens ?? 0),
|
|
891
|
+
channelType: msg.channelType
|
|
892
|
+
});
|
|
893
|
+
this.shortTerm.add(msg.channelId, {
|
|
894
|
+
id: msg.id,
|
|
895
|
+
timestamp: msg.timestamp,
|
|
896
|
+
role: "user",
|
|
897
|
+
content: msg.content
|
|
898
|
+
});
|
|
899
|
+
this.shortTerm.add(msg.channelId, {
|
|
900
|
+
id: Date.now().toString(36),
|
|
901
|
+
timestamp: Date.now(),
|
|
902
|
+
role: "assistant",
|
|
903
|
+
content: finalText,
|
|
904
|
+
tokenCount: (result.usage?.promptTokens ?? 0) + (result.usage?.completionTokens ?? 0)
|
|
905
|
+
});
|
|
906
|
+
this.episodic.record({
|
|
907
|
+
type: "message",
|
|
908
|
+
summary: `User: ${msg.content.slice(0, 100)} | Agent: ${finalText.slice(0, 100)}`,
|
|
909
|
+
channelType: msg.channelType
|
|
910
|
+
});
|
|
911
|
+
if (msg.channelType !== "internal") {
|
|
912
|
+
this.extractFacts(msg.content, finalText).catch((err) => {
|
|
913
|
+
logger.warn({ err }, "Fact extraction failed");
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
if (channel && msg.channelType !== "internal") {
|
|
917
|
+
const elapsed = Date.now() - startTime;
|
|
918
|
+
if (streamedText) {
|
|
919
|
+
logger.info({ channelType: msg.channelType, elapsed }, "Streamed response completed");
|
|
920
|
+
} else {
|
|
921
|
+
logger.info({ channelType: msg.channelType, targetId: msg.channelId }, "Sending response");
|
|
922
|
+
await channel.send(finalText, msg.channelId, elapsed);
|
|
923
|
+
}
|
|
924
|
+
} else {
|
|
925
|
+
logger.debug("Internal prompt processed, no channel response needed");
|
|
926
|
+
}
|
|
927
|
+
this.lifecycle.transition("idle");
|
|
928
|
+
} catch (err) {
|
|
929
|
+
logger.error({ err }, "Error handling message");
|
|
930
|
+
this.lifecycle.transition("idle");
|
|
931
|
+
} finally {
|
|
932
|
+
if (isInternal || isScheduled) {
|
|
933
|
+
this.capabilities.permissions.setAutoApproveAll(false);
|
|
934
|
+
}
|
|
935
|
+
this.capabilities.permissions.clearElevation();
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
buildSystemPrompt() {
|
|
939
|
+
let prompt = this.identity.getSystemPrompt(this.config.identity);
|
|
940
|
+
const skillContext = this.capabilities.getSkillContext();
|
|
941
|
+
if (skillContext) {
|
|
942
|
+
prompt += "\n\n" + skillContext;
|
|
943
|
+
}
|
|
944
|
+
const budgetStatus = this.tokenBudget.getStatusText();
|
|
945
|
+
prompt += "\n\n" + budgetStatus;
|
|
946
|
+
if (this.tokenBudget.getUsagePercentage() > 70) {
|
|
947
|
+
prompt += "\nBe concise to conserve tokens.";
|
|
948
|
+
}
|
|
949
|
+
return prompt;
|
|
950
|
+
}
|
|
951
|
+
async processInternalPrompt(prompt, channelId, channelType) {
|
|
952
|
+
const syntheticMsg = {
|
|
953
|
+
id: `internal-${Date.now().toString(36)}`,
|
|
954
|
+
channelId: channelId || "internal",
|
|
955
|
+
channelType: channelType || "internal",
|
|
956
|
+
senderId: "system",
|
|
957
|
+
content: prompt,
|
|
958
|
+
timestamp: Date.now()
|
|
959
|
+
};
|
|
960
|
+
this.enqueueMessage(syntheticMsg);
|
|
961
|
+
}
|
|
962
|
+
async handleScheduledTask(manifest) {
|
|
963
|
+
logger.info({ task: manifest.id, channel: manifest.sourceChannelType }, "Processing scheduled task");
|
|
964
|
+
try {
|
|
965
|
+
let prompt = manifest.prompt || "";
|
|
966
|
+
if (manifest.skillName) {
|
|
967
|
+
const skillHint = `Invoke the skill "${manifest.skillName}" using the use_skill tool and follow its instructions.`;
|
|
968
|
+
prompt = prompt ? `${prompt} ${skillHint}` : `Scheduled task triggered. ${skillHint}`;
|
|
969
|
+
}
|
|
970
|
+
if (!prompt) {
|
|
971
|
+
prompt = `Execute scheduled task: ${manifest.description}`;
|
|
972
|
+
}
|
|
973
|
+
await this.processInternalPrompt(prompt, manifest.sourceChannelId, manifest.sourceChannelType);
|
|
974
|
+
} catch (err) {
|
|
975
|
+
logger.error({ err, task: manifest.id }, "Scheduled task execution failed");
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
async heartbeat() {
|
|
979
|
+
logger.debug("Heartbeat tick");
|
|
980
|
+
const pruned = this.episodic.prune(7);
|
|
981
|
+
if (pruned > 0) {
|
|
982
|
+
logger.info({ pruned }, "Episodic memory pruned");
|
|
983
|
+
}
|
|
984
|
+
const notifications = [];
|
|
985
|
+
const usagePct = this.tokenBudget.getUsagePercentage();
|
|
986
|
+
if (usagePct >= 80) {
|
|
987
|
+
notifications.push(`Token budget at ${Math.round(usagePct)}% \u2014 ${this.tokenBudget.getRemaining().toLocaleString()} tokens remaining today.`);
|
|
988
|
+
}
|
|
989
|
+
const pendingSchedules = this.scheduler.getManifests();
|
|
990
|
+
const now = Date.now();
|
|
991
|
+
for (const task of pendingSchedules) {
|
|
992
|
+
if (task.delaySeconds && task.executeAt) {
|
|
993
|
+
const executeAt = new Date(task.executeAt).getTime();
|
|
994
|
+
const diffMin = Math.round((executeAt - now) / 6e4);
|
|
995
|
+
if (diffMin > 0 && diffMin <= 5) {
|
|
996
|
+
notifications.push(`Task "${task.description}" fires in ${diffMin} minute${diffMin !== 1 ? "s" : ""}.`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (notifications.length > 0) {
|
|
1001
|
+
const channel = this.channels.getNotificationChannel();
|
|
1002
|
+
if (channel) {
|
|
1003
|
+
const msg = notifications.join("\n");
|
|
1004
|
+
try {
|
|
1005
|
+
await channel.send(msg, "notification");
|
|
1006
|
+
} catch (err) {
|
|
1007
|
+
logger.warn({ err }, "Failed to send heartbeat notification");
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
async extractFacts(userMessage, agentResponse) {
|
|
1013
|
+
const trivial = /^(hi|hello|hey|thanks|thank you|ok|okay|yes|no|bye|goodbye|good morning|good evening)\b/i;
|
|
1014
|
+
if (trivial.test(userMessage.trim())) return;
|
|
1015
|
+
if (!this.tokenBudget.canAfford(500)) return;
|
|
1016
|
+
try {
|
|
1017
|
+
const provider = this.providers.getDefault();
|
|
1018
|
+
const result = await generateText3({
|
|
1019
|
+
model: provider.getModelInstance(),
|
|
1020
|
+
system: 'You are a fact extractor. Read the conversation below and extract 1-3 important facts worth remembering long-term. Output each fact on a separate line, prefixed with "- ". Only extract facts that are specific, factual, and not obvious. If nothing is worth remembering, output nothing.',
|
|
1021
|
+
messages: [
|
|
1022
|
+
{ role: "user", content: `User: ${userMessage}
|
|
1023
|
+
Assistant: ${agentResponse}` }
|
|
1024
|
+
],
|
|
1025
|
+
maxTokens: 200
|
|
1026
|
+
});
|
|
1027
|
+
const text = result.text.trim();
|
|
1028
|
+
if (!text) return;
|
|
1029
|
+
const facts = text.split("\n").map((l) => l.replace(/^-\s*/, "").trim()).filter((f) => f.length > 10 && f.length < 200);
|
|
1030
|
+
const existing = this.longTerm.getAll();
|
|
1031
|
+
for (const fact of facts.slice(0, 3)) {
|
|
1032
|
+
const isDupe = existing.some(
|
|
1033
|
+
(e) => e.fact.toLowerCase().includes(fact.toLowerCase().slice(0, 30))
|
|
1034
|
+
);
|
|
1035
|
+
if (!isDupe) {
|
|
1036
|
+
this.longTerm.add({
|
|
1037
|
+
topic: "extracted",
|
|
1038
|
+
fact,
|
|
1039
|
+
source: "conversation"
|
|
1040
|
+
});
|
|
1041
|
+
logger.info({ fact: fact.slice(0, 60) }, "Fact extracted to long-term memory");
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
this.tokenBudget.recordUsage({
|
|
1045
|
+
provider: provider.name,
|
|
1046
|
+
model: provider.getModel(),
|
|
1047
|
+
inputTokens: result.usage?.promptTokens ?? 0,
|
|
1048
|
+
outputTokens: result.usage?.completionTokens ?? 0,
|
|
1049
|
+
totalTokens: (result.usage?.promptTokens ?? 0) + (result.usage?.completionTokens ?? 0),
|
|
1050
|
+
channelType: "internal"
|
|
1051
|
+
});
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
logger.warn({ err }, "Fact extraction error");
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
async shutdown() {
|
|
1057
|
+
await this.sleep();
|
|
1058
|
+
logger.info("Mercury has shut down");
|
|
1059
|
+
}
|
|
1060
|
+
async handleBudgetOverrideCLI(channel, msg) {
|
|
1061
|
+
const status = this.tokenBudget.getStatusText();
|
|
1062
|
+
await channel.send(
|
|
1063
|
+
`Token budget exceeded! ${status}
|
|
1064
|
+
|
|
1065
|
+
Choose an option:
|
|
1066
|
+
1 \u2014 Override (allow this one request)
|
|
1067
|
+
2 \u2014 Reset usage to zero
|
|
1068
|
+
3 \u2014 Set a new daily budget (current: ${this.tokenBudget.getBudget().toLocaleString()})
|
|
1069
|
+
4 \u2014 Cancel
|
|
1070
|
+
|
|
1071
|
+
Or use /budget override, /budget reset, /budget set <number> anytime.`,
|
|
1072
|
+
msg.channelId
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
async handleBudgetCommand(subcommand, channelType, channelId) {
|
|
1076
|
+
const channel = this.channels.get(channelType);
|
|
1077
|
+
if (!channel) return;
|
|
1078
|
+
const parts = subcommand.trim().split(/\s+/);
|
|
1079
|
+
const action = parts[0]?.toLowerCase();
|
|
1080
|
+
if (action === "override" || action === "1") {
|
|
1081
|
+
this.tokenBudget.forceAllowNext();
|
|
1082
|
+
await channel.send("Budget override applied \u2014 your next request will proceed.", channelId);
|
|
1083
|
+
} else if (action === "reset" || action === "2") {
|
|
1084
|
+
this.tokenBudget.resetUsage();
|
|
1085
|
+
await channel.send(`Usage reset to zero. ${this.tokenBudget.getStatusText()}`, channelId);
|
|
1086
|
+
} else if (action === "set" || action === "3") {
|
|
1087
|
+
const newBudget = parseInt(parts[1], 10);
|
|
1088
|
+
if (isNaN(newBudget) || newBudget <= 0) {
|
|
1089
|
+
await channel.send("Please specify the new budget. Usage: `/budget set 100000` or type e.g. `3 100000`", channelId);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
this.tokenBudget.setBudget(newBudget);
|
|
1093
|
+
await channel.send(`Daily budget updated to ${newBudget.toLocaleString()} tokens. ${this.tokenBudget.getStatusText()}`, channelId);
|
|
1094
|
+
} else if (action === "cancel" || action === "4") {
|
|
1095
|
+
await channel.send(`Cancelled. ${this.tokenBudget.getStatusText()}`, channelId);
|
|
1096
|
+
} else if (!action || action === "status") {
|
|
1097
|
+
await channel.send(this.tokenBudget.getStatusText(), channelId);
|
|
1098
|
+
} else {
|
|
1099
|
+
await channel.send(`Unknown budget command "${action}". Available: /budget, /budget override, /budget reset, /budget set <number>, /budget status`, channelId);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
// src/core/scheduler.ts
|
|
1105
|
+
import cron from "node-cron";
|
|
1106
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4 } from "fs";
|
|
1107
|
+
import { join as join4 } from "path";
|
|
1108
|
+
import { parse as parseYaml2, stringify as stringifyYaml2 } from "yaml";
|
|
1109
|
+
var SCHEDULES_FILE = "schedules.yaml";
|
|
1110
|
+
function getSchedulesPath() {
|
|
1111
|
+
return join4(getMercuryHome(), SCHEDULES_FILE);
|
|
1112
|
+
}
|
|
1113
|
+
function loadSchedules() {
|
|
1114
|
+
const path3 = getSchedulesPath();
|
|
1115
|
+
if (!existsSync4(path3)) return [];
|
|
1116
|
+
try {
|
|
1117
|
+
const raw = readFileSync4(path3, "utf-8");
|
|
1118
|
+
const data = parseYaml2(raw);
|
|
1119
|
+
return data.tasks || [];
|
|
1120
|
+
} catch (err) {
|
|
1121
|
+
logger.warn({ err }, "Failed to load schedules.yaml");
|
|
1122
|
+
return [];
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
function saveSchedules(tasks) {
|
|
1126
|
+
const path3 = getSchedulesPath();
|
|
1127
|
+
const dir = getMercuryHome();
|
|
1128
|
+
if (!existsSync4(dir)) {
|
|
1129
|
+
mkdirSync4(dir, { recursive: true });
|
|
1130
|
+
}
|
|
1131
|
+
writeFileSync4(path3, stringifyYaml2({ tasks }), "utf-8");
|
|
1132
|
+
}
|
|
1133
|
+
var Scheduler = class {
|
|
1134
|
+
constructor(config, onScheduledTask) {
|
|
1135
|
+
this.onScheduledTask = onScheduledTask;
|
|
1136
|
+
this.heartbeatIntervalMinutes = config.heartbeat.intervalMinutes;
|
|
1137
|
+
}
|
|
1138
|
+
onScheduledTask;
|
|
1139
|
+
tasks = /* @__PURE__ */ new Map();
|
|
1140
|
+
delayedTasks = /* @__PURE__ */ new Map();
|
|
1141
|
+
taskManifests = /* @__PURE__ */ new Map();
|
|
1142
|
+
heartbeatIntervalMinutes;
|
|
1143
|
+
heartbeatHandler;
|
|
1144
|
+
heartbeatTimer = null;
|
|
1145
|
+
setOnScheduledTask(handler) {
|
|
1146
|
+
this.onScheduledTask = handler;
|
|
1147
|
+
}
|
|
1148
|
+
onHeartbeat(handler) {
|
|
1149
|
+
this.heartbeatHandler = handler;
|
|
1150
|
+
}
|
|
1151
|
+
startHeartbeat() {
|
|
1152
|
+
if (this.heartbeatTimer) return;
|
|
1153
|
+
const ms = this.heartbeatIntervalMinutes * 60 * 1e3;
|
|
1154
|
+
logger.info({ intervalMin: this.heartbeatIntervalMinutes }, "Heartbeat started");
|
|
1155
|
+
this.heartbeatTimer = setInterval(async () => {
|
|
1156
|
+
try {
|
|
1157
|
+
await this.heartbeatHandler?.();
|
|
1158
|
+
} catch (err) {
|
|
1159
|
+
logger.error({ err }, "Heartbeat error");
|
|
1160
|
+
}
|
|
1161
|
+
}, ms);
|
|
1162
|
+
}
|
|
1163
|
+
stopHeartbeat() {
|
|
1164
|
+
if (this.heartbeatTimer) {
|
|
1165
|
+
clearInterval(this.heartbeatTimer);
|
|
1166
|
+
this.heartbeatTimer = null;
|
|
1167
|
+
logger.info("Heartbeat stopped");
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
addTask(task) {
|
|
1171
|
+
if (this.tasks.has(task.id)) {
|
|
1172
|
+
this.removeTask(task.id);
|
|
1173
|
+
}
|
|
1174
|
+
const scheduled = cron.schedule(task.cron, async () => {
|
|
1175
|
+
try {
|
|
1176
|
+
await task.handler();
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
logger.error({ task: task.id, err }, "Scheduled task error");
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
this.tasks.set(task.id, scheduled);
|
|
1182
|
+
logger.info({ id: task.id, cron: task.cron, desc: task.description }, "Task scheduled");
|
|
1183
|
+
}
|
|
1184
|
+
addPersistedTask(manifest) {
|
|
1185
|
+
this.taskManifests.set(manifest.id, manifest);
|
|
1186
|
+
this.addTask({
|
|
1187
|
+
id: manifest.id,
|
|
1188
|
+
cron: manifest.cron,
|
|
1189
|
+
description: manifest.description,
|
|
1190
|
+
handler: async () => {
|
|
1191
|
+
logger.info({ task: manifest.id }, "Scheduled task firing");
|
|
1192
|
+
if (this.onScheduledTask) {
|
|
1193
|
+
await this.onScheduledTask(manifest);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
addDelayedTask(manifest) {
|
|
1199
|
+
this.taskManifests.set(manifest.id, manifest);
|
|
1200
|
+
const delayMs = (manifest.delaySeconds || 60) * 1e3;
|
|
1201
|
+
const timer = setTimeout(async () => {
|
|
1202
|
+
try {
|
|
1203
|
+
logger.info({ task: manifest.id }, "Delayed task firing");
|
|
1204
|
+
if (this.onScheduledTask) {
|
|
1205
|
+
await this.onScheduledTask(manifest);
|
|
1206
|
+
}
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
logger.error({ task: manifest.id, err }, "Delayed task error");
|
|
1209
|
+
} finally {
|
|
1210
|
+
this.delayedTasks.delete(manifest.id);
|
|
1211
|
+
this.taskManifests.delete(manifest.id);
|
|
1212
|
+
this.persistSchedules();
|
|
1213
|
+
}
|
|
1214
|
+
}, delayMs);
|
|
1215
|
+
this.delayedTasks.set(manifest.id, timer);
|
|
1216
|
+
logger.info({ id: manifest.id, delaySeconds: manifest.delaySeconds }, "Delayed task scheduled");
|
|
1217
|
+
}
|
|
1218
|
+
removeTask(id) {
|
|
1219
|
+
const task = this.tasks.get(id);
|
|
1220
|
+
if (task) {
|
|
1221
|
+
task.stop();
|
|
1222
|
+
this.tasks.delete(id);
|
|
1223
|
+
}
|
|
1224
|
+
const timer = this.delayedTasks.get(id);
|
|
1225
|
+
if (timer) {
|
|
1226
|
+
clearTimeout(timer);
|
|
1227
|
+
this.delayedTasks.delete(id);
|
|
1228
|
+
}
|
|
1229
|
+
this.taskManifests.delete(id);
|
|
1230
|
+
}
|
|
1231
|
+
getManifests() {
|
|
1232
|
+
return [...this.taskManifests.values()];
|
|
1233
|
+
}
|
|
1234
|
+
restorePersistedTasks() {
|
|
1235
|
+
const persisted = loadSchedules();
|
|
1236
|
+
for (const manifest of persisted) {
|
|
1237
|
+
if (manifest.delaySeconds) {
|
|
1238
|
+
const executeAt = manifest.executeAt ? new Date(manifest.executeAt) : null;
|
|
1239
|
+
const now = Date.now();
|
|
1240
|
+
if (executeAt && executeAt.getTime() > now) {
|
|
1241
|
+
const remainingMs = executeAt.getTime() - now;
|
|
1242
|
+
manifest.delaySeconds = Math.ceil(remainingMs / 1e3);
|
|
1243
|
+
this.addDelayedTask(manifest);
|
|
1244
|
+
} else {
|
|
1245
|
+
logger.info({ id: manifest.id }, "Delayed task already expired, skipping");
|
|
1246
|
+
}
|
|
1247
|
+
} else if (manifest.cron && cron.validate(manifest.cron)) {
|
|
1248
|
+
this.addPersistedTask(manifest);
|
|
1249
|
+
} else {
|
|
1250
|
+
logger.warn({ id: manifest.id, cron: manifest.cron }, "Skipping invalid task");
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
if (persisted.length > 0) {
|
|
1254
|
+
logger.info({ count: persisted.length }, "Restored persisted scheduled tasks");
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
persistSchedules() {
|
|
1258
|
+
saveSchedules(this.getManifests());
|
|
1259
|
+
}
|
|
1260
|
+
stopAll() {
|
|
1261
|
+
this.stopHeartbeat();
|
|
1262
|
+
for (const [, task] of this.tasks) {
|
|
1263
|
+
task.stop();
|
|
1264
|
+
}
|
|
1265
|
+
for (const [, timer] of this.delayedTasks) {
|
|
1266
|
+
clearTimeout(timer);
|
|
1267
|
+
}
|
|
1268
|
+
this.tasks.clear();
|
|
1269
|
+
this.delayedTasks.clear();
|
|
1270
|
+
this.taskManifests.clear();
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1273
|
+
|
|
1274
|
+
// src/channels/cli.ts
|
|
1275
|
+
import readline from "readline";
|
|
1276
|
+
import fs from "fs";
|
|
1277
|
+
import path from "path";
|
|
1278
|
+
import chalk2 from "chalk";
|
|
1279
|
+
|
|
1280
|
+
// src/channels/base.ts
|
|
1281
|
+
var BaseChannel = class {
|
|
1282
|
+
messageHandler;
|
|
1283
|
+
ready = false;
|
|
1284
|
+
isReady() {
|
|
1285
|
+
return this.ready;
|
|
1286
|
+
}
|
|
1287
|
+
onMessage(handler) {
|
|
1288
|
+
this.messageHandler = handler;
|
|
1289
|
+
}
|
|
1290
|
+
emit(message) {
|
|
1291
|
+
this.messageHandler?.(message);
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
// src/utils/markdown.ts
|
|
1296
|
+
import { Marked } from "marked";
|
|
1297
|
+
import chalk from "chalk";
|
|
1298
|
+
var lexer = new Marked();
|
|
1299
|
+
function renderMarkdown(text) {
|
|
1300
|
+
try {
|
|
1301
|
+
const tokens = lexer.lexer(text);
|
|
1302
|
+
const result = renderTokens(tokens);
|
|
1303
|
+
return result.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
1304
|
+
} catch {
|
|
1305
|
+
return text;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
function renderTokens(tokens) {
|
|
1309
|
+
return tokens.map((t) => renderToken(t)).join("");
|
|
1310
|
+
}
|
|
1311
|
+
function renderToken(t) {
|
|
1312
|
+
if (!t || typeof t !== "object") return String(t ?? "");
|
|
1313
|
+
switch (t.type) {
|
|
1314
|
+
case "heading":
|
|
1315
|
+
return renderHeading(t);
|
|
1316
|
+
case "paragraph":
|
|
1317
|
+
return renderInline(t.tokens) + "\n\n";
|
|
1318
|
+
case "strong":
|
|
1319
|
+
return chalk.bold(renderInline(t.tokens));
|
|
1320
|
+
case "em":
|
|
1321
|
+
return chalk.italic(renderInline(t.tokens));
|
|
1322
|
+
case "del":
|
|
1323
|
+
return chalk.dim.strikethrough(renderInline(t.tokens));
|
|
1324
|
+
case "codespan":
|
|
1325
|
+
return chalk.yellow(t.text);
|
|
1326
|
+
case "code":
|
|
1327
|
+
return renderCodeBlock(t);
|
|
1328
|
+
case "list":
|
|
1329
|
+
return renderList(t);
|
|
1330
|
+
case "blockquote":
|
|
1331
|
+
return renderBlockquote(t);
|
|
1332
|
+
case "hr":
|
|
1333
|
+
return chalk.dim("\u2500".repeat(50)) + "\n\n";
|
|
1334
|
+
case "link":
|
|
1335
|
+
return `${chalk.blue.underline(renderInline(t.tokens))} ${chalk.dim(`(${t.href})`)}`;
|
|
1336
|
+
case "image":
|
|
1337
|
+
return chalk.blue(`\u{1F5BC} ${t.title || t.href}`);
|
|
1338
|
+
case "table":
|
|
1339
|
+
return renderTable(t);
|
|
1340
|
+
case "text":
|
|
1341
|
+
if (t.tokens) return renderInline(t.tokens);
|
|
1342
|
+
return t.text || "";
|
|
1343
|
+
case "html":
|
|
1344
|
+
return t.text || "";
|
|
1345
|
+
case "space":
|
|
1346
|
+
return "";
|
|
1347
|
+
default:
|
|
1348
|
+
return t.text || "";
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
function renderHeading(t) {
|
|
1352
|
+
const text = renderInline(t.tokens);
|
|
1353
|
+
if (t.depth === 1) return `
|
|
1354
|
+
${chalk.bold.cyan(text)}
|
|
1355
|
+
|
|
1356
|
+
`;
|
|
1357
|
+
if (t.depth === 2) return `
|
|
1358
|
+
${chalk.bold.cyan(` \u25A0 ${text}`)}
|
|
1359
|
+
|
|
1360
|
+
`;
|
|
1361
|
+
return `
|
|
1362
|
+
${chalk.bold(` \u25A0 ${text}`)}
|
|
1363
|
+
|
|
1364
|
+
`;
|
|
1365
|
+
}
|
|
1366
|
+
function renderInline(tokens) {
|
|
1367
|
+
if (!tokens) return "";
|
|
1368
|
+
return tokens.map((t) => {
|
|
1369
|
+
if (typeof t === "string") return t;
|
|
1370
|
+
if (t.type === "strong") return chalk.bold(renderInline(t.tokens));
|
|
1371
|
+
if (t.type === "em") return chalk.italic(renderInline(t.tokens));
|
|
1372
|
+
if (t.type === "del") return chalk.dim.strikethrough(renderInline(t.tokens));
|
|
1373
|
+
if (t.type === "codespan") return chalk.yellow(t.text);
|
|
1374
|
+
if (t.type === "link") return `${chalk.blue.underline(renderInline(t.tokens))} ${chalk.dim(`(${t.href})`)}`;
|
|
1375
|
+
if (t.type === "image") return chalk.blue(`\u{1F5BC} ${t.title || t.href}`);
|
|
1376
|
+
if (t.type === "text") {
|
|
1377
|
+
return t.tokens ? renderInline(t.tokens) : t.text || "";
|
|
1378
|
+
}
|
|
1379
|
+
if (t.type === "html") return t.text || "";
|
|
1380
|
+
return t.text || "";
|
|
1381
|
+
}).join("");
|
|
1382
|
+
}
|
|
1383
|
+
function renderCodeBlock(t) {
|
|
1384
|
+
const lines = t.text.split("\n").map((l) => `${chalk.dim(" ")}${chalk.yellow(l)}`).join("\n");
|
|
1385
|
+
const langStr = t.lang ? chalk.dim(` [${t.lang}]`) : "";
|
|
1386
|
+
return `
|
|
1387
|
+
${langStr}
|
|
1388
|
+
${lines}
|
|
1389
|
+
|
|
1390
|
+
`;
|
|
1391
|
+
}
|
|
1392
|
+
function renderList(t) {
|
|
1393
|
+
const lines = [];
|
|
1394
|
+
const items = t.items || [];
|
|
1395
|
+
items.forEach((item, i) => {
|
|
1396
|
+
const bullet = t.ordered ? `${i + 1}.` : "\u2022";
|
|
1397
|
+
const firstLine = renderInline(item.tokens?.[0]?.tokens || [{ text: item.text }]);
|
|
1398
|
+
lines.push(` ${chalk.dim(bullet)} ${firstLine}`);
|
|
1399
|
+
const restTokens = (item.tokens || []).slice(1);
|
|
1400
|
+
for (const sub of restTokens) {
|
|
1401
|
+
if (sub.type === "list") {
|
|
1402
|
+
const subLines = renderList(sub).split("\n").filter(Boolean).map((l) => ` ${l}`).join("\n");
|
|
1403
|
+
lines.push(subLines);
|
|
1404
|
+
} else if (sub.type === "text") {
|
|
1405
|
+
lines.push(` ${chalk.dim("\u2022")} ${renderInline(sub.tokens)}`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
return lines.join("\n") + "\n\n";
|
|
1410
|
+
}
|
|
1411
|
+
function renderBlockquote(t) {
|
|
1412
|
+
const content = renderTokens(t.tokens || []);
|
|
1413
|
+
const lines = content.split("\n").filter((l) => l.trim()).map((l) => `${chalk.dim("\u2502 ")}${chalk.gray(l)}`).join("\n");
|
|
1414
|
+
return `
|
|
1415
|
+
${lines}
|
|
1416
|
+
|
|
1417
|
+
`;
|
|
1418
|
+
}
|
|
1419
|
+
function renderTable(t) {
|
|
1420
|
+
const headers = (t.header || []).map((h) => chalk.bold(renderInline(h.tokens)));
|
|
1421
|
+
const colWidths = (t.header || []).map((h, i) => {
|
|
1422
|
+
const hLen = (h.text || "").length;
|
|
1423
|
+
const rowLens = (t.rows || []).map((row) => {
|
|
1424
|
+
const cell = row[i];
|
|
1425
|
+
return cell?.text?.length ?? 0;
|
|
1426
|
+
});
|
|
1427
|
+
return Math.max(hLen, ...rowLens) + 2;
|
|
1428
|
+
});
|
|
1429
|
+
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(chalk.dim(" \u2502 "));
|
|
1430
|
+
const separator = colWidths.map((w) => "\u2500".repeat(w)).join(chalk.dim("\u2500\u253C\u2500"));
|
|
1431
|
+
const dataLines = (t.rows || []).map(
|
|
1432
|
+
(row) => row.map((cell, i) => {
|
|
1433
|
+
const text = renderInline(cell.tokens) || cell.text || "";
|
|
1434
|
+
return text.padEnd(colWidths[i]);
|
|
1435
|
+
}).join(chalk.dim(" \u2502 "))
|
|
1436
|
+
);
|
|
1437
|
+
return `
|
|
1438
|
+
${headerLine}
|
|
1439
|
+
${chalk.dim(separator)}
|
|
1440
|
+
${dataLines.join("\n")}
|
|
1441
|
+
|
|
1442
|
+
`;
|
|
1443
|
+
}
|
|
1444
|
+
function escapeHtml(text) {
|
|
1445
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1446
|
+
}
|
|
1447
|
+
function mdToTelegram(text) {
|
|
1448
|
+
let out = text;
|
|
1449
|
+
const codeBlocks = [];
|
|
1450
|
+
out = out.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
|
|
1451
|
+
const placeholder = `__CODEBLOCK_${codeBlocks.length}__`;
|
|
1452
|
+
codeBlocks.push(`<pre><code class="${lang}">${escapeHtml(code)}</code></pre>`);
|
|
1453
|
+
return placeholder;
|
|
1454
|
+
});
|
|
1455
|
+
const inlineCodes = [];
|
|
1456
|
+
out = out.replace(/`([^`]+)`/g, (_match, code) => {
|
|
1457
|
+
const placeholder = `__INLINECODE_${inlineCodes.length}__`;
|
|
1458
|
+
inlineCodes.push(`<code>${escapeHtml(code)}</code>`);
|
|
1459
|
+
return placeholder;
|
|
1460
|
+
});
|
|
1461
|
+
const links = [];
|
|
1462
|
+
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, href) => {
|
|
1463
|
+
const placeholder = `__LINK_${links.length}__`;
|
|
1464
|
+
links.push(`<a href="${escapeHtml(href)}">${escapeHtml(label)}</a>`);
|
|
1465
|
+
return placeholder;
|
|
1466
|
+
});
|
|
1467
|
+
out = escapeHtml(out);
|
|
1468
|
+
out = out.replace(/^### (.+)$/gm, "<b><i>$1</i></b>");
|
|
1469
|
+
out = out.replace(/^## (.+)$/gm, "<b>$1</b>");
|
|
1470
|
+
out = out.replace(/^# (.+)$/gm, "<b>$1</b>");
|
|
1471
|
+
out = out.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
|
|
1472
|
+
out = out.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<i>$1</i>");
|
|
1473
|
+
out = out.replace(/~~([^~]+)~~/g, "<s>$1</s>");
|
|
1474
|
+
for (let i = 0; i < inlineCodes.length; i++) {
|
|
1475
|
+
out = out.replace(`__INLINECODE_${i}__`, inlineCodes[i]);
|
|
1476
|
+
}
|
|
1477
|
+
for (let i = 0; i < codeBlocks.length; i++) {
|
|
1478
|
+
out = out.replace(`__CODEBLOCK_${i}__`, codeBlocks[i]);
|
|
1479
|
+
}
|
|
1480
|
+
for (let i = 0; i < links.length; i++) {
|
|
1481
|
+
out = out.replace(`__LINK_${i}__`, links[i]);
|
|
1482
|
+
}
|
|
1483
|
+
if (out.length > 4096) {
|
|
1484
|
+
out = out.slice(0, 4090) + "...";
|
|
1485
|
+
}
|
|
1486
|
+
return out;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// src/channels/cli.ts
|
|
1490
|
+
var CLIChannel = class extends BaseChannel {
|
|
1491
|
+
type = "cli";
|
|
1492
|
+
rl = null;
|
|
1493
|
+
agentName;
|
|
1494
|
+
constructor(agentName = "Mercury") {
|
|
1495
|
+
super();
|
|
1496
|
+
this.agentName = agentName;
|
|
1497
|
+
}
|
|
1498
|
+
setAgentName(name) {
|
|
1499
|
+
this.agentName = name;
|
|
1500
|
+
}
|
|
1501
|
+
async start() {
|
|
1502
|
+
this.rl = readline.createInterface({
|
|
1503
|
+
input: process.stdin,
|
|
1504
|
+
output: process.stdout,
|
|
1505
|
+
prompt: " You: "
|
|
1506
|
+
});
|
|
1507
|
+
this.rl.on("line", (line) => {
|
|
1508
|
+
const trimmed = line.trim();
|
|
1509
|
+
if (!trimmed) {
|
|
1510
|
+
this.showPrompt();
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
const msg = {
|
|
1514
|
+
id: Date.now().toString(36),
|
|
1515
|
+
channelId: "cli",
|
|
1516
|
+
channelType: "cli",
|
|
1517
|
+
senderId: "owner",
|
|
1518
|
+
content: trimmed,
|
|
1519
|
+
timestamp: Date.now()
|
|
1520
|
+
};
|
|
1521
|
+
this.emit(msg);
|
|
1522
|
+
});
|
|
1523
|
+
this.ready = true;
|
|
1524
|
+
this.showPrompt();
|
|
1525
|
+
logger.info("CLI channel started");
|
|
1526
|
+
}
|
|
1527
|
+
async stop() {
|
|
1528
|
+
this.rl?.close();
|
|
1529
|
+
this.rl = null;
|
|
1530
|
+
this.ready = false;
|
|
1531
|
+
}
|
|
1532
|
+
async send(content, _targetId, elapsedMs) {
|
|
1533
|
+
const timeStr = elapsedMs != null ? chalk2.dim(` (${(elapsedMs / 1e3).toFixed(1)}s)`) : "";
|
|
1534
|
+
const rendered = renderMarkdown(content);
|
|
1535
|
+
console.log("");
|
|
1536
|
+
console.log(chalk2.cyan(` ${this.agentName}:`) + timeStr);
|
|
1537
|
+
const indented = rendered.split("\n").map((line) => ` ${line}`).join("\n");
|
|
1538
|
+
console.log(indented);
|
|
1539
|
+
console.log("");
|
|
1540
|
+
this.showPrompt();
|
|
1541
|
+
}
|
|
1542
|
+
async sendFile(filePath, _targetId) {
|
|
1543
|
+
const resolved = path.resolve(filePath);
|
|
1544
|
+
if (!fs.existsSync(resolved)) {
|
|
1545
|
+
console.log(chalk2.red(` File not found: ${filePath}`));
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
const stat = fs.statSync(resolved);
|
|
1549
|
+
const sizeStr = stat.size > 1024 * 1024 ? `${(stat.size / (1024 * 1024)).toFixed(1)}MB` : stat.size > 1024 ? `${(stat.size / 1024).toFixed(1)}KB` : `${stat.size}B`;
|
|
1550
|
+
console.log("");
|
|
1551
|
+
console.log(chalk2.cyan(` ${this.agentName}:`) + chalk2.dim(" (file)"));
|
|
1552
|
+
console.log(chalk2.dim(` path: ${resolved}`));
|
|
1553
|
+
console.log(chalk2.dim(` size: ${sizeStr}`));
|
|
1554
|
+
console.log("");
|
|
1555
|
+
this.showPrompt();
|
|
1556
|
+
}
|
|
1557
|
+
async stream(content, _targetId) {
|
|
1558
|
+
console.log("");
|
|
1559
|
+
process.stdout.write(chalk2.cyan(` ${this.agentName}: `));
|
|
1560
|
+
let full = "";
|
|
1561
|
+
for await (const chunk of content) {
|
|
1562
|
+
process.stdout.write(chunk);
|
|
1563
|
+
full += chunk;
|
|
1564
|
+
}
|
|
1565
|
+
console.log("\n");
|
|
1566
|
+
this.showPrompt();
|
|
1567
|
+
return full;
|
|
1568
|
+
}
|
|
1569
|
+
async typing(_targetId) {
|
|
1570
|
+
process.stdout.write(chalk2.dim(` ${this.agentName} is thinking...\r`));
|
|
1571
|
+
}
|
|
1572
|
+
showPrompt() {
|
|
1573
|
+
if (this.rl) {
|
|
1574
|
+
this.rl.setPrompt(" You: ");
|
|
1575
|
+
this.rl.prompt();
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
async prompt(question) {
|
|
1579
|
+
return new Promise((resolve9) => {
|
|
1580
|
+
this.rl?.question(question, (answer) => resolve9(answer.trim()));
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
async askPermission(prompt) {
|
|
1584
|
+
return new Promise((resolve9) => {
|
|
1585
|
+
console.log("");
|
|
1586
|
+
console.log(chalk2.yellow(` \u26A0 ${prompt}`));
|
|
1587
|
+
this.rl?.question(chalk2.yellow(" > "), (answer) => {
|
|
1588
|
+
resolve9(answer.trim());
|
|
1589
|
+
});
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
|
|
1594
|
+
// src/channels/telegram.ts
|
|
1595
|
+
import fs2 from "fs";
|
|
1596
|
+
import path2 from "path";
|
|
1597
|
+
import { Bot, InputFile } from "grammy";
|
|
1598
|
+
import { autoRetry } from "@grammyjs/auto-retry";
|
|
1599
|
+
var MAX_MESSAGE_LENGTH = 4096;
|
|
1600
|
+
var TelegramChannel = class extends BaseChannel {
|
|
1601
|
+
constructor(config) {
|
|
1602
|
+
super();
|
|
1603
|
+
this.config = config;
|
|
1604
|
+
}
|
|
1605
|
+
config;
|
|
1606
|
+
type = "telegram";
|
|
1607
|
+
bot = null;
|
|
1608
|
+
ownerChatId = null;
|
|
1609
|
+
typingInterval = null;
|
|
1610
|
+
async start() {
|
|
1611
|
+
const token = this.config.channels.telegram.botToken;
|
|
1612
|
+
if (!token) {
|
|
1613
|
+
logger.warn("Telegram bot token not set \u2014 skipping");
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
const bot = new Bot(token);
|
|
1617
|
+
bot.api.config.use(autoRetry());
|
|
1618
|
+
bot.on("message:text", async (ctx) => {
|
|
1619
|
+
const chatId = ctx.chat.id;
|
|
1620
|
+
if (!this.isAllowedChat(chatId)) return;
|
|
1621
|
+
this.ownerChatId = chatId;
|
|
1622
|
+
logger.info({ chatId, text: ctx.message.text?.slice(0, 50) }, "Telegram message received");
|
|
1623
|
+
const msg = {
|
|
1624
|
+
id: ctx.message.message_id.toString(),
|
|
1625
|
+
channelId: `telegram:${chatId}`,
|
|
1626
|
+
channelType: "telegram",
|
|
1627
|
+
senderId: ctx.from?.id.toString() ?? "unknown",
|
|
1628
|
+
senderName: ctx.from?.first_name,
|
|
1629
|
+
content: ctx.message.text,
|
|
1630
|
+
timestamp: ctx.message.date * 1e3,
|
|
1631
|
+
metadata: { chatId, messageId: ctx.message.message_id }
|
|
1632
|
+
};
|
|
1633
|
+
this.emit(msg);
|
|
1634
|
+
});
|
|
1635
|
+
bot.catch((err) => {
|
|
1636
|
+
logger.error({ err: err.message }, "Telegram bot error");
|
|
1637
|
+
});
|
|
1638
|
+
this.bot = bot;
|
|
1639
|
+
await bot.start({
|
|
1640
|
+
onStart: (info) => {
|
|
1641
|
+
logger.info({ bot: info.username }, "Telegram bot started \u2014 long polling active");
|
|
1642
|
+
this.ready = true;
|
|
1643
|
+
}
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
async stop() {
|
|
1647
|
+
this.bot?.stop();
|
|
1648
|
+
this.ready = false;
|
|
1649
|
+
this.stopTypingLoop();
|
|
1650
|
+
}
|
|
1651
|
+
async send(content, targetId, elapsedMs) {
|
|
1652
|
+
const chatId = this.parseChatId(targetId);
|
|
1653
|
+
if (!chatId || !this.bot) {
|
|
1654
|
+
logger.warn({ targetId, chatId }, "Telegram send: no valid chat ID");
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
const timeSuffix = elapsedMs != null ? `
|
|
1658
|
+
\u23F1 ${(elapsedMs / 1e3).toFixed(1)}s` : "";
|
|
1659
|
+
const fullContent = content + timeSuffix;
|
|
1660
|
+
const html = mdToTelegram(fullContent);
|
|
1661
|
+
const chunks = this.splitMessage(html, MAX_MESSAGE_LENGTH);
|
|
1662
|
+
for (const chunk of chunks) {
|
|
1663
|
+
try {
|
|
1664
|
+
await this.bot.api.sendMessage(chatId, chunk, { parse_mode: "HTML" });
|
|
1665
|
+
} catch (err) {
|
|
1666
|
+
logger.warn({ err: err.message }, "HTML parse failed, sending as plain text");
|
|
1667
|
+
try {
|
|
1668
|
+
await this.bot.api.sendMessage(chatId, this.stripHtml(chunk));
|
|
1669
|
+
} catch (err2) {
|
|
1670
|
+
logger.error({ err: err2.message }, "Telegram send failed");
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
async sendFile(filePath, targetId) {
|
|
1676
|
+
const chatId = this.parseChatId(targetId);
|
|
1677
|
+
if (!chatId || !this.bot) {
|
|
1678
|
+
logger.warn({ targetId, chatId }, "Telegram sendFile: no valid chat ID");
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
const resolved = path2.resolve(filePath);
|
|
1682
|
+
if (!fs2.existsSync(resolved)) {
|
|
1683
|
+
await this.bot.api.sendMessage(chatId, `File not found: ${filePath}`);
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
const inputFile = new InputFile(resolved);
|
|
1687
|
+
const filename = path2.basename(resolved);
|
|
1688
|
+
const ext = path2.extname(resolved).toLowerCase();
|
|
1689
|
+
try {
|
|
1690
|
+
if (this.isImageFile(ext)) {
|
|
1691
|
+
await this.bot.api.sendPhoto(chatId, inputFile, { caption: filename });
|
|
1692
|
+
} else if (this.isAudioFile(ext)) {
|
|
1693
|
+
await this.bot.api.sendAudio(chatId, inputFile, { title: filename });
|
|
1694
|
+
} else if (this.isVideoFile(ext)) {
|
|
1695
|
+
await this.bot.api.sendVideo(chatId, inputFile, { caption: filename });
|
|
1696
|
+
} else {
|
|
1697
|
+
await this.bot.api.sendDocument(chatId, inputFile, { caption: filename });
|
|
1698
|
+
}
|
|
1699
|
+
logger.info({ file: resolved, chatId }, "File sent via Telegram");
|
|
1700
|
+
} catch (err) {
|
|
1701
|
+
logger.error({ err: err.message, file: resolved }, "Telegram sendFile failed");
|
|
1702
|
+
await this.bot.api.sendMessage(chatId, `Failed to send file: ${err.message}`).catch(() => {
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
async stream(content, targetId) {
|
|
1707
|
+
const chatId = this.parseChatId(targetId);
|
|
1708
|
+
if (!chatId || !this.bot) return "";
|
|
1709
|
+
let full = "";
|
|
1710
|
+
for await (const chunk of content) {
|
|
1711
|
+
full += chunk;
|
|
1712
|
+
}
|
|
1713
|
+
const html = mdToTelegram(full);
|
|
1714
|
+
try {
|
|
1715
|
+
await this.bot.api.sendMessage(chatId, html, { parse_mode: "HTML" });
|
|
1716
|
+
} catch (err) {
|
|
1717
|
+
await this.bot.api.sendMessage(chatId, this.stripHtml(html));
|
|
1718
|
+
}
|
|
1719
|
+
return full;
|
|
1720
|
+
}
|
|
1721
|
+
async typing(targetId) {
|
|
1722
|
+
const chatId = this.parseChatId(targetId);
|
|
1723
|
+
if (!chatId || !this.bot) return;
|
|
1724
|
+
await this.bot.api.sendChatAction(chatId, "typing");
|
|
1725
|
+
}
|
|
1726
|
+
startTypingLoop(chatId) {
|
|
1727
|
+
this.stopTypingLoop();
|
|
1728
|
+
this.bot?.api.sendChatAction(chatId, "typing").catch(() => {
|
|
1729
|
+
});
|
|
1730
|
+
this.typingInterval = setInterval(() => {
|
|
1731
|
+
this.bot?.api.sendChatAction(chatId, "typing").catch(() => {
|
|
1732
|
+
});
|
|
1733
|
+
}, 4e3);
|
|
1734
|
+
}
|
|
1735
|
+
stopTypingLoop() {
|
|
1736
|
+
if (this.typingInterval) {
|
|
1737
|
+
clearInterval(this.typingInterval);
|
|
1738
|
+
this.typingInterval = null;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
async sendStreamToChat(chatId, textStream) {
|
|
1742
|
+
if (!this.bot) return;
|
|
1743
|
+
this.startTypingLoop(chatId);
|
|
1744
|
+
try {
|
|
1745
|
+
let full = "";
|
|
1746
|
+
for await (const chunk of textStream) {
|
|
1747
|
+
full += chunk;
|
|
1748
|
+
}
|
|
1749
|
+
const html = mdToTelegram(full);
|
|
1750
|
+
try {
|
|
1751
|
+
await this.bot.api.sendMessage(chatId, html, { parse_mode: "HTML" });
|
|
1752
|
+
} catch {
|
|
1753
|
+
await this.bot.api.sendMessage(chatId, this.stripHtml(html));
|
|
1754
|
+
}
|
|
1755
|
+
} finally {
|
|
1756
|
+
this.stopTypingLoop();
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
splitMessage(text, maxLen) {
|
|
1760
|
+
if (text.length <= maxLen) return [text];
|
|
1761
|
+
const chunks = [];
|
|
1762
|
+
let remaining = text;
|
|
1763
|
+
while (remaining.length > 0) {
|
|
1764
|
+
let splitAt = maxLen;
|
|
1765
|
+
if (remaining.length > maxLen) {
|
|
1766
|
+
const lastNewline = remaining.lastIndexOf("\n", maxLen);
|
|
1767
|
+
if (lastNewline > maxLen * 0.5) {
|
|
1768
|
+
splitAt = lastNewline + 1;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
1772
|
+
remaining = remaining.slice(splitAt);
|
|
1773
|
+
}
|
|
1774
|
+
return chunks;
|
|
1775
|
+
}
|
|
1776
|
+
stripHtml(html) {
|
|
1777
|
+
return html.replace(/<\/?(b|i|s|u|code|pre|a|blockquote|strong|em)[^>]*>/gi, "").replace(/<pre><code[^>]*>/gi, "").replace(/<\/code><\/pre>/gi, "").replace(/<[^>]+>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
1778
|
+
}
|
|
1779
|
+
isImageFile(ext) {
|
|
1780
|
+
return [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"].includes(ext);
|
|
1781
|
+
}
|
|
1782
|
+
isAudioFile(ext) {
|
|
1783
|
+
return [".mp3", ".ogg", ".wav", ".flac", ".m4a"].includes(ext);
|
|
1784
|
+
}
|
|
1785
|
+
isVideoFile(ext) {
|
|
1786
|
+
return [".mp4", ".mov", ".avi", ".mkv", ".webm"].includes(ext);
|
|
1787
|
+
}
|
|
1788
|
+
parseChatId(targetId) {
|
|
1789
|
+
if (!targetId) return this.ownerChatId;
|
|
1790
|
+
if (targetId.startsWith("telegram:")) {
|
|
1791
|
+
const raw = Number(targetId.split(":")[1]);
|
|
1792
|
+
return isNaN(raw) ? this.ownerChatId : raw;
|
|
1793
|
+
}
|
|
1794
|
+
if (targetId === "notification") return this.ownerChatId;
|
|
1795
|
+
const num = Number(targetId);
|
|
1796
|
+
return isNaN(num) ? this.ownerChatId : num;
|
|
1797
|
+
}
|
|
1798
|
+
isAllowedChat(chatId) {
|
|
1799
|
+
const allowed = this.config.channels.telegram.allowedChatIds;
|
|
1800
|
+
if (!allowed || allowed.length === 0) return true;
|
|
1801
|
+
return allowed.includes(chatId);
|
|
1802
|
+
}
|
|
1803
|
+
};
|
|
1804
|
+
|
|
1805
|
+
// src/channels/registry.ts
|
|
1806
|
+
var ChannelRegistry = class {
|
|
1807
|
+
channels = /* @__PURE__ */ new Map();
|
|
1808
|
+
constructor(config) {
|
|
1809
|
+
this.register("cli", new CLIChannel(config.identity.name));
|
|
1810
|
+
if (config.channels.telegram.enabled && config.channels.telegram.botToken) {
|
|
1811
|
+
this.register("telegram", new TelegramChannel(config));
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
register(type, channel) {
|
|
1815
|
+
channel.onMessage((msg) => this.handleIncomingMessage(msg));
|
|
1816
|
+
this.channels.set(type, channel);
|
|
1817
|
+
logger.info({ channel: type }, "Channel registered");
|
|
1818
|
+
}
|
|
1819
|
+
get(type) {
|
|
1820
|
+
return this.channels.get(type);
|
|
1821
|
+
}
|
|
1822
|
+
getChannelForMessage(message) {
|
|
1823
|
+
return this.channels.get(message.channelType);
|
|
1824
|
+
}
|
|
1825
|
+
async startAll() {
|
|
1826
|
+
for (const [type, channel] of this.channels) {
|
|
1827
|
+
try {
|
|
1828
|
+
await channel.start();
|
|
1829
|
+
} catch (err) {
|
|
1830
|
+
logger.error({ channel: type, err }, "Failed to start channel");
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
async stopAll() {
|
|
1835
|
+
for (const [, channel] of this.channels) {
|
|
1836
|
+
await channel.stop();
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
getActiveChannels() {
|
|
1840
|
+
return [...this.channels.entries()].filter(([, ch]) => ch.isReady()).map(([type]) => type);
|
|
1841
|
+
}
|
|
1842
|
+
getNotificationChannel() {
|
|
1843
|
+
const telegram = this.channels.get("telegram");
|
|
1844
|
+
if (telegram?.isReady()) return telegram;
|
|
1845
|
+
const cli = this.channels.get("cli");
|
|
1846
|
+
if (cli?.isReady()) return cli;
|
|
1847
|
+
return this.channels.values().next().value;
|
|
1848
|
+
}
|
|
1849
|
+
incomingHandler;
|
|
1850
|
+
onIncomingMessage(handler) {
|
|
1851
|
+
this.incomingHandler = handler;
|
|
1852
|
+
}
|
|
1853
|
+
handleIncomingMessage(msg) {
|
|
1854
|
+
logger.debug({ from: msg.channelType, sender: msg.senderId }, "Incoming message");
|
|
1855
|
+
this.incomingHandler?.(msg);
|
|
1856
|
+
}
|
|
1857
|
+
};
|
|
1858
|
+
|
|
1859
|
+
// src/utils/tokens.ts
|
|
1860
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
1861
|
+
import { join as join5 } from "path";
|
|
1862
|
+
var TOKEN_FILE = "token-usage.json";
|
|
1863
|
+
var TokenBudget = class {
|
|
1864
|
+
constructor(config) {
|
|
1865
|
+
this.config = config;
|
|
1866
|
+
this.dailyBudget = config.tokens.dailyBudget;
|
|
1867
|
+
this.lastResetDate = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1868
|
+
this.restore();
|
|
1869
|
+
}
|
|
1870
|
+
config;
|
|
1871
|
+
dailyUsed = 0;
|
|
1872
|
+
dailyBudget;
|
|
1873
|
+
lastResetDate;
|
|
1874
|
+
requestLog = [];
|
|
1875
|
+
forceNext = false;
|
|
1876
|
+
canAfford(estimatedTokens) {
|
|
1877
|
+
this.resetIfNewDay();
|
|
1878
|
+
return this.dailyUsed + estimatedTokens <= this.dailyBudget;
|
|
1879
|
+
}
|
|
1880
|
+
isOverBudget() {
|
|
1881
|
+
this.resetIfNewDay();
|
|
1882
|
+
if (this.forceNext) {
|
|
1883
|
+
this.forceNext = false;
|
|
1884
|
+
return false;
|
|
1885
|
+
}
|
|
1886
|
+
return this.dailyUsed >= this.dailyBudget;
|
|
1887
|
+
}
|
|
1888
|
+
forceAllowNext() {
|
|
1889
|
+
this.forceNext = true;
|
|
1890
|
+
logger.info("Budget override: next request will proceed regardless of budget");
|
|
1891
|
+
}
|
|
1892
|
+
resetUsage() {
|
|
1893
|
+
this.dailyUsed = 0;
|
|
1894
|
+
this.requestLog = [];
|
|
1895
|
+
this.persist();
|
|
1896
|
+
logger.info("Token usage reset to zero");
|
|
1897
|
+
}
|
|
1898
|
+
setBudget(newBudget) {
|
|
1899
|
+
this.dailyBudget = newBudget;
|
|
1900
|
+
this.config.tokens.dailyBudget = newBudget;
|
|
1901
|
+
saveConfig(this.config);
|
|
1902
|
+
this.persist();
|
|
1903
|
+
logger.info({ newBudget }, "Daily token budget updated");
|
|
1904
|
+
}
|
|
1905
|
+
getBudget() {
|
|
1906
|
+
return this.dailyBudget;
|
|
1907
|
+
}
|
|
1908
|
+
getDailyUsed() {
|
|
1909
|
+
this.resetIfNewDay();
|
|
1910
|
+
return this.dailyUsed;
|
|
1911
|
+
}
|
|
1912
|
+
recordUsage(entry) {
|
|
1913
|
+
this.resetIfNewDay();
|
|
1914
|
+
const logEntry = { ...entry, timestamp: Date.now() };
|
|
1915
|
+
this.dailyUsed += entry.totalTokens;
|
|
1916
|
+
this.requestLog.push(logEntry);
|
|
1917
|
+
this.persist();
|
|
1918
|
+
}
|
|
1919
|
+
getRemaining() {
|
|
1920
|
+
this.resetIfNewDay();
|
|
1921
|
+
return Math.max(0, this.dailyBudget - this.dailyUsed);
|
|
1922
|
+
}
|
|
1923
|
+
getUsagePercentage() {
|
|
1924
|
+
this.resetIfNewDay();
|
|
1925
|
+
return this.dailyBudget > 0 ? this.dailyUsed / this.dailyBudget * 100 : 0;
|
|
1926
|
+
}
|
|
1927
|
+
getStatusText() {
|
|
1928
|
+
const pct = Math.round(this.getUsagePercentage());
|
|
1929
|
+
const remaining = this.getRemaining();
|
|
1930
|
+
return `Token budget: ${this.dailyUsed.toLocaleString()} / ${this.dailyBudget.toLocaleString()} used (${pct}%), ${remaining.toLocaleString()} remaining`;
|
|
1931
|
+
}
|
|
1932
|
+
resetIfNewDay() {
|
|
1933
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1934
|
+
if (today !== this.lastResetDate) {
|
|
1935
|
+
this.dailyUsed = 0;
|
|
1936
|
+
this.lastResetDate = today;
|
|
1937
|
+
this.requestLog = [];
|
|
1938
|
+
this.persist();
|
|
1939
|
+
logger.info("Token budget reset for new day");
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
persist() {
|
|
1943
|
+
const path3 = join5(getMercuryHome(), TOKEN_FILE);
|
|
1944
|
+
try {
|
|
1945
|
+
const data = {
|
|
1946
|
+
dailyUsed: this.dailyUsed,
|
|
1947
|
+
dailyBudget: this.dailyBudget,
|
|
1948
|
+
lastResetDate: this.lastResetDate,
|
|
1949
|
+
requestLog: this.requestLog.slice(-200)
|
|
1950
|
+
};
|
|
1951
|
+
writeFileSync5(path3, JSON.stringify(data, null, 2), "utf-8");
|
|
1952
|
+
} catch (err) {
|
|
1953
|
+
logger.warn({ err }, "Failed to persist token usage");
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
restore() {
|
|
1957
|
+
const path3 = join5(getMercuryHome(), TOKEN_FILE);
|
|
1958
|
+
if (!existsSync5(path3)) return;
|
|
1959
|
+
try {
|
|
1960
|
+
const raw = readFileSync5(path3, "utf-8");
|
|
1961
|
+
const data = JSON.parse(raw);
|
|
1962
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1963
|
+
if (data.lastResetDate === today) {
|
|
1964
|
+
this.dailyUsed = data.dailyUsed ?? 0;
|
|
1965
|
+
this.requestLog = data.requestLog ?? [];
|
|
1966
|
+
}
|
|
1967
|
+
this.lastResetDate = data.lastResetDate ?? today;
|
|
1968
|
+
} catch (err) {
|
|
1969
|
+
logger.warn({ err }, "Failed to restore token usage");
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
};
|
|
1973
|
+
|
|
1974
|
+
// src/capabilities/permissions.ts
|
|
1975
|
+
import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync6, mkdirSync as mkdirSync6 } from "fs";
|
|
1976
|
+
import { join as join6, resolve } from "path";
|
|
1977
|
+
import { homedir as homedir2 } from "os";
|
|
1978
|
+
import { parse as parseYaml3, stringify as stringifyYaml3 } from "yaml";
|
|
1979
|
+
var DEFAULT_MANIFEST = {
|
|
1980
|
+
capabilities: {
|
|
1981
|
+
filesystem: {
|
|
1982
|
+
enabled: true,
|
|
1983
|
+
scopes: [
|
|
1984
|
+
{ path: ".", read: true, write: true }
|
|
1985
|
+
]
|
|
1986
|
+
},
|
|
1987
|
+
shell: {
|
|
1988
|
+
enabled: true,
|
|
1989
|
+
blocked: [
|
|
1990
|
+
"sudo *",
|
|
1991
|
+
"rm -rf /",
|
|
1992
|
+
"rm -rf ~",
|
|
1993
|
+
"rm -rf /*",
|
|
1994
|
+
"mkfs *",
|
|
1995
|
+
"dd if=*",
|
|
1996
|
+
"chmod 777 /",
|
|
1997
|
+
"chown * /",
|
|
1998
|
+
":(){ :|:& };:",
|
|
1999
|
+
"shutdown *",
|
|
2000
|
+
"reboot *",
|
|
2001
|
+
"halt *",
|
|
2002
|
+
"init 0",
|
|
2003
|
+
"init 6",
|
|
2004
|
+
"kill -9 1",
|
|
2005
|
+
"> /dev/sda",
|
|
2006
|
+
"mv /* /dev/null"
|
|
2007
|
+
],
|
|
2008
|
+
autoApproved: [
|
|
2009
|
+
"ls *",
|
|
2010
|
+
"cat *",
|
|
2011
|
+
"pwd",
|
|
2012
|
+
"which *",
|
|
2013
|
+
"node *",
|
|
2014
|
+
"npm run *",
|
|
2015
|
+
"npm test *",
|
|
2016
|
+
"npm list *",
|
|
2017
|
+
"git status *",
|
|
2018
|
+
"git diff *",
|
|
2019
|
+
"git log *",
|
|
2020
|
+
"git branch *",
|
|
2021
|
+
"echo *",
|
|
2022
|
+
"head *",
|
|
2023
|
+
"tail *",
|
|
2024
|
+
"wc *",
|
|
2025
|
+
"find *",
|
|
2026
|
+
"grep *",
|
|
2027
|
+
"rg *",
|
|
2028
|
+
"ps *",
|
|
2029
|
+
"df *",
|
|
2030
|
+
"du *",
|
|
2031
|
+
"uname *",
|
|
2032
|
+
"curl *",
|
|
2033
|
+
"wget *"
|
|
2034
|
+
],
|
|
2035
|
+
needsApproval: [
|
|
2036
|
+
"npm publish *",
|
|
2037
|
+
"git push *",
|
|
2038
|
+
"docker *",
|
|
2039
|
+
"curl * | sh",
|
|
2040
|
+
"curl * | bash",
|
|
2041
|
+
"wget * | sh",
|
|
2042
|
+
"pip install *",
|
|
2043
|
+
"pip3 install *",
|
|
2044
|
+
"rm -r *",
|
|
2045
|
+
"rm -rf *",
|
|
2046
|
+
"mv *",
|
|
2047
|
+
"cp -r *",
|
|
2048
|
+
"chmod *",
|
|
2049
|
+
"mkdir *",
|
|
2050
|
+
"rmdir *"
|
|
2051
|
+
],
|
|
2052
|
+
cwdOnly: true
|
|
2053
|
+
},
|
|
2054
|
+
git: {
|
|
2055
|
+
enabled: true,
|
|
2056
|
+
autoApproveRead: true,
|
|
2057
|
+
approveWrite: true
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
};
|
|
2061
|
+
var PERMISSIONS_FILE = join6(getMercuryHome(), "permissions.yaml");
|
|
2062
|
+
var PermissionManager = class {
|
|
2063
|
+
manifest;
|
|
2064
|
+
cwd;
|
|
2065
|
+
askHandler;
|
|
2066
|
+
autoApproveAll = false;
|
|
2067
|
+
elevatedCommands = /* @__PURE__ */ new Set();
|
|
2068
|
+
pendingApprovals = /* @__PURE__ */ new Set();
|
|
2069
|
+
constructor() {
|
|
2070
|
+
this.cwd = process.cwd();
|
|
2071
|
+
this.manifest = this.load();
|
|
2072
|
+
}
|
|
2073
|
+
onAsk(handler) {
|
|
2074
|
+
this.askHandler = handler;
|
|
2075
|
+
}
|
|
2076
|
+
setAutoApproveAll(value) {
|
|
2077
|
+
this.autoApproveAll = value;
|
|
2078
|
+
}
|
|
2079
|
+
elevateForSkill(allowedTools) {
|
|
2080
|
+
if (allowedTools.includes("run_command")) {
|
|
2081
|
+
this.elevatedCommands.add("run_command");
|
|
2082
|
+
}
|
|
2083
|
+
if (allowedTools.includes("read_file") || allowedTools.includes("list_dir")) {
|
|
2084
|
+
this.elevatedCommands.add("fs_read");
|
|
2085
|
+
}
|
|
2086
|
+
if (allowedTools.includes("write_file") || allowedTools.includes("create_file") || allowedTools.includes("delete_file")) {
|
|
2087
|
+
this.elevatedCommands.add("fs_write");
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
clearElevation() {
|
|
2091
|
+
this.elevatedCommands.clear();
|
|
2092
|
+
}
|
|
2093
|
+
isElevated(tool24) {
|
|
2094
|
+
if (this.elevatedCommands.has(tool24)) return true;
|
|
2095
|
+
return false;
|
|
2096
|
+
}
|
|
2097
|
+
isShellElevated() {
|
|
2098
|
+
return this.elevatedCommands.has("run_command");
|
|
2099
|
+
}
|
|
2100
|
+
addPendingApproval(baseCommand) {
|
|
2101
|
+
this.pendingApprovals.add(baseCommand);
|
|
2102
|
+
}
|
|
2103
|
+
clearPendingApprovals() {
|
|
2104
|
+
this.pendingApprovals.clear();
|
|
2105
|
+
}
|
|
2106
|
+
load() {
|
|
2107
|
+
if (existsSync6(PERMISSIONS_FILE)) {
|
|
2108
|
+
try {
|
|
2109
|
+
const raw = readFileSync6(PERMISSIONS_FILE, "utf-8");
|
|
2110
|
+
const parsed = parseYaml3(raw);
|
|
2111
|
+
return this.mergeDefaults(parsed);
|
|
2112
|
+
} catch (err) {
|
|
2113
|
+
logger.warn({ err }, "Failed to parse permissions.yaml, using defaults");
|
|
2114
|
+
return { ...DEFAULT_MANIFEST };
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
this.save(DEFAULT_MANIFEST);
|
|
2118
|
+
return { ...DEFAULT_MANIFEST };
|
|
2119
|
+
}
|
|
2120
|
+
save(manifest) {
|
|
2121
|
+
const m = manifest || this.manifest;
|
|
2122
|
+
const dir = getMercuryHome();
|
|
2123
|
+
if (!existsSync6(dir)) mkdirSync6(dir, { recursive: true });
|
|
2124
|
+
writeFileSync6(PERMISSIONS_FILE, stringifyYaml3(m, { lineWidth: 0 }), "utf-8");
|
|
2125
|
+
this.manifest = m;
|
|
2126
|
+
}
|
|
2127
|
+
getManifest() {
|
|
2128
|
+
return this.manifest;
|
|
2129
|
+
}
|
|
2130
|
+
addApprovedCommand(baseCommand) {
|
|
2131
|
+
const cmdName = baseCommand.trim().split(/\s+/)[0];
|
|
2132
|
+
const pattern = `${cmdName} *`;
|
|
2133
|
+
const shell = this.manifest.capabilities.shell;
|
|
2134
|
+
if (!shell.autoApproved.includes(pattern) && !shell.autoApproved.includes(cmdName)) {
|
|
2135
|
+
shell.autoApproved.push(pattern);
|
|
2136
|
+
this.save();
|
|
2137
|
+
logger.info({ pattern }, "Shell command pattern auto-approved and saved");
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
async checkFsAccess(path3, mode) {
|
|
2141
|
+
if (mode === "read" && this.elevatedCommands.has("fs_read")) {
|
|
2142
|
+
return { allowed: true };
|
|
2143
|
+
}
|
|
2144
|
+
if (mode === "write" && this.elevatedCommands.has("fs_write")) {
|
|
2145
|
+
return { allowed: true };
|
|
2146
|
+
}
|
|
2147
|
+
const fs3 = this.manifest.capabilities.filesystem;
|
|
2148
|
+
if (!fs3.enabled) {
|
|
2149
|
+
return { allowed: false, reason: "Filesystem capability is disabled" };
|
|
2150
|
+
}
|
|
2151
|
+
const resolved = resolve(path3);
|
|
2152
|
+
const scope = this.findScope(resolved);
|
|
2153
|
+
if (scope) {
|
|
2154
|
+
if (mode === "read" && scope.read) return { allowed: true };
|
|
2155
|
+
if (mode === "write" && scope.write) return { allowed: true };
|
|
2156
|
+
return { allowed: false, reason: `Permission denied: ${mode} access to ${path3} (scope has ${mode}=false)` };
|
|
2157
|
+
}
|
|
2158
|
+
return await this.requestScope(resolved, mode);
|
|
2159
|
+
}
|
|
2160
|
+
async checkShellCommand(command) {
|
|
2161
|
+
if (this.autoApproveAll) {
|
|
2162
|
+
logger.info({ cmd: command.trim() }, "Shell command auto-approved (auto-approve-all mode)");
|
|
2163
|
+
return { allowed: true, needsApproval: false };
|
|
2164
|
+
}
|
|
2165
|
+
if (this.isShellElevated()) {
|
|
2166
|
+
logger.info({ cmd: command.trim() }, "Shell command auto-approved (skill elevation)");
|
|
2167
|
+
return { allowed: true, needsApproval: false };
|
|
2168
|
+
}
|
|
2169
|
+
const shell = this.manifest.capabilities.shell;
|
|
2170
|
+
if (!shell.enabled) {
|
|
2171
|
+
return { allowed: false, reason: "Shell capability is disabled", needsApproval: false };
|
|
2172
|
+
}
|
|
2173
|
+
const trimmed = command.trim();
|
|
2174
|
+
const baseCmd = trimmed.split(/\s+/)[0];
|
|
2175
|
+
if (this.pendingApprovals.has(baseCmd)) {
|
|
2176
|
+
logger.info({ cmd: trimmed }, "Shell command auto-approved (pending approval)");
|
|
2177
|
+
return { allowed: true, needsApproval: false };
|
|
2178
|
+
}
|
|
2179
|
+
for (const pattern of shell.blocked) {
|
|
2180
|
+
if (this.matchPattern(trimmed, pattern)) {
|
|
2181
|
+
return { allowed: false, reason: `Blocked command: matches "${pattern}"`, needsApproval: false };
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
if (shell.cwdOnly) {
|
|
2185
|
+
const hasPathTraversal = this.hasPathBeyondCwd(trimmed);
|
|
2186
|
+
if (hasPathTraversal) {
|
|
2187
|
+
const scopeCheck = await this.checkFsAccess(hasPathTraversal, "write");
|
|
2188
|
+
if (!scopeCheck.allowed) {
|
|
2189
|
+
return { allowed: false, reason: scopeCheck.reason, needsApproval: true };
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
for (const pattern of shell.autoApproved) {
|
|
2194
|
+
if (this.matchPattern(trimmed, pattern)) {
|
|
2195
|
+
logger.info({ cmd: trimmed }, "Shell command auto-approved");
|
|
2196
|
+
return { allowed: true, needsApproval: false };
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
for (const pattern of shell.needsApproval) {
|
|
2200
|
+
if (this.matchPattern(trimmed, pattern)) {
|
|
2201
|
+
return { allowed: false, reason: `Command requires approval: matches "${pattern}"`, needsApproval: true };
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
return { allowed: false, reason: "Command not in auto-approve list \u2014 requires approval", needsApproval: true };
|
|
2205
|
+
}
|
|
2206
|
+
isGitReadAllowed() {
|
|
2207
|
+
return this.manifest.capabilities.git.enabled && this.manifest.capabilities.git.autoApproveRead;
|
|
2208
|
+
}
|
|
2209
|
+
isGitWriteNeedsApproval() {
|
|
2210
|
+
return this.manifest.capabilities.git.enabled && this.manifest.capabilities.git.approveWrite;
|
|
2211
|
+
}
|
|
2212
|
+
addScope(path3, read, write) {
|
|
2213
|
+
const resolved = resolve(path3);
|
|
2214
|
+
const existing = this.findScope(resolved);
|
|
2215
|
+
if (existing) {
|
|
2216
|
+
existing.read = existing.read || read;
|
|
2217
|
+
existing.write = existing.write || write;
|
|
2218
|
+
} else {
|
|
2219
|
+
this.manifest.capabilities.filesystem.scopes.push({
|
|
2220
|
+
path: resolved,
|
|
2221
|
+
read,
|
|
2222
|
+
write
|
|
2223
|
+
});
|
|
2224
|
+
}
|
|
2225
|
+
this.save();
|
|
2226
|
+
logger.info({ path: resolved, read, write }, "Permission scope added");
|
|
2227
|
+
}
|
|
2228
|
+
findScope(resolvedPath) {
|
|
2229
|
+
const scopes = this.manifest.capabilities.filesystem.scopes;
|
|
2230
|
+
for (const scope of scopes) {
|
|
2231
|
+
const scopeResolved = resolve(scope.path.replace(/^~/, homedir2()));
|
|
2232
|
+
if (resolvedPath === scopeResolved || resolvedPath.startsWith(scopeResolved + "/")) {
|
|
2233
|
+
return scope;
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
return void 0;
|
|
2237
|
+
}
|
|
2238
|
+
async requestScope(path3, mode) {
|
|
2239
|
+
if (!this.askHandler) {
|
|
2240
|
+
return { allowed: false, reason: `No permission to ${mode} ${path3}. Add scope to ~/.mercury/permissions.yaml` };
|
|
2241
|
+
}
|
|
2242
|
+
const response = await this.askHandler(
|
|
2243
|
+
`Mercury needs ${mode} access to ${path3}. Allow? (y/n/always): `
|
|
2244
|
+
);
|
|
2245
|
+
if (response.toLowerCase() === "always") {
|
|
2246
|
+
this.addScope(path3, mode === "read", mode === "write");
|
|
2247
|
+
return { allowed: true };
|
|
2248
|
+
}
|
|
2249
|
+
if (response.toLowerCase() === "y" || response.toLowerCase() === "yes") {
|
|
2250
|
+
return { allowed: true };
|
|
2251
|
+
}
|
|
2252
|
+
return { allowed: false, reason: `Permission denied for ${mode} access to ${path3}` };
|
|
2253
|
+
}
|
|
2254
|
+
matchPattern(command, pattern) {
|
|
2255
|
+
const regexStr = "^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$";
|
|
2256
|
+
try {
|
|
2257
|
+
return new RegExp(regexStr, "i").test(command);
|
|
2258
|
+
} catch {
|
|
2259
|
+
return command.startsWith(pattern.replace(/ \*$/, ""));
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
hasPathBeyondCwd(command) {
|
|
2263
|
+
const pathPatterns = [
|
|
2264
|
+
/(?:^|\s)(\/[^\s]+)/,
|
|
2265
|
+
/(?:^|\s)(~\/[^\s]+)/,
|
|
2266
|
+
/(?:^|\s)\.\.\/([^\s]+)/
|
|
2267
|
+
];
|
|
2268
|
+
for (const p of pathPatterns) {
|
|
2269
|
+
const match = command.match(p);
|
|
2270
|
+
if (match) {
|
|
2271
|
+
const candidate = resolve(match[1].replace(/^~/, homedir2()));
|
|
2272
|
+
if (!candidate.startsWith(this.cwd)) {
|
|
2273
|
+
return candidate;
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
return null;
|
|
2278
|
+
}
|
|
2279
|
+
mergeDefaults(parsed) {
|
|
2280
|
+
return {
|
|
2281
|
+
capabilities: {
|
|
2282
|
+
filesystem: {
|
|
2283
|
+
enabled: parsed.capabilities?.filesystem?.enabled ?? DEFAULT_MANIFEST.capabilities.filesystem.enabled,
|
|
2284
|
+
scopes: parsed.capabilities?.filesystem?.scopes ?? DEFAULT_MANIFEST.capabilities.filesystem.scopes
|
|
2285
|
+
},
|
|
2286
|
+
shell: {
|
|
2287
|
+
enabled: parsed.capabilities?.shell?.enabled ?? DEFAULT_MANIFEST.capabilities.shell.enabled,
|
|
2288
|
+
blocked: parsed.capabilities?.shell?.blocked ?? DEFAULT_MANIFEST.capabilities.shell.blocked,
|
|
2289
|
+
autoApproved: parsed.capabilities?.shell?.autoApproved ?? DEFAULT_MANIFEST.capabilities.shell.autoApproved,
|
|
2290
|
+
needsApproval: parsed.capabilities?.shell?.needsApproval ?? DEFAULT_MANIFEST.capabilities.shell.needsApproval,
|
|
2291
|
+
cwdOnly: parsed.capabilities?.shell?.cwdOnly ?? DEFAULT_MANIFEST.capabilities.shell.cwdOnly
|
|
2292
|
+
},
|
|
2293
|
+
git: {
|
|
2294
|
+
enabled: parsed.capabilities?.git?.enabled ?? DEFAULT_MANIFEST.capabilities.git.enabled,
|
|
2295
|
+
autoApproveRead: parsed.capabilities?.git?.autoApproveRead ?? DEFAULT_MANIFEST.capabilities.git.autoApproveRead,
|
|
2296
|
+
approveWrite: parsed.capabilities?.git?.approveWrite ?? DEFAULT_MANIFEST.capabilities.git.approveWrite
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2301
|
+
};
|
|
2302
|
+
|
|
2303
|
+
// src/capabilities/filesystem/read-file.ts
|
|
2304
|
+
import { tool } from "ai";
|
|
2305
|
+
import { z } from "zod";
|
|
2306
|
+
import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
|
|
2307
|
+
import { resolve as resolve2 } from "path";
|
|
2308
|
+
function createReadFileTool(permissions) {
|
|
2309
|
+
return tool({
|
|
2310
|
+
description: "Read the contents of a file. The path must be within an allowed scope.",
|
|
2311
|
+
parameters: z.object({
|
|
2312
|
+
path: z.string().describe("Absolute or relative path to the file")
|
|
2313
|
+
}),
|
|
2314
|
+
execute: async ({ path: path3 }) => {
|
|
2315
|
+
const resolved = resolve2(path3);
|
|
2316
|
+
const check = await permissions.checkFsAccess(resolved, "read");
|
|
2317
|
+
if (!check.allowed) {
|
|
2318
|
+
return `Error: ${check.reason}`;
|
|
2319
|
+
}
|
|
2320
|
+
if (!existsSync7(resolved)) {
|
|
2321
|
+
return `Error: File not found: ${resolved}`;
|
|
2322
|
+
}
|
|
2323
|
+
try {
|
|
2324
|
+
const stat = await import("fs").then((m) => m.statSync(resolved));
|
|
2325
|
+
if (stat.isDirectory()) {
|
|
2326
|
+
return `Error: ${resolved} is a directory, not a file. Use list_dir instead.`;
|
|
2327
|
+
}
|
|
2328
|
+
if (stat.size > 1024 * 1024) {
|
|
2329
|
+
return `Error: File too large (${Math.round(stat.size / 1024)}KB). Maximum is 1MB.`;
|
|
2330
|
+
}
|
|
2331
|
+
return readFileSync7(resolved, "utf-8");
|
|
2332
|
+
} catch (err) {
|
|
2333
|
+
return `Error reading file: ${err.message}`;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
// src/capabilities/filesystem/write-file.ts
|
|
2340
|
+
import { tool as tool2 } from "ai";
|
|
2341
|
+
import { z as z2 } from "zod";
|
|
2342
|
+
import { existsSync as existsSync8, writeFileSync as writeFileSync7 } from "fs";
|
|
2343
|
+
import { resolve as resolve3 } from "path";
|
|
2344
|
+
function createWriteFileTool(permissions) {
|
|
2345
|
+
return tool2({
|
|
2346
|
+
description: "Write content to an existing file. The path must be within a writable scope.",
|
|
2347
|
+
parameters: z2.object({
|
|
2348
|
+
path: z2.string().describe("Absolute or relative path to the file"),
|
|
2349
|
+
content: z2.string().describe("The content to write to the file")
|
|
2350
|
+
}),
|
|
2351
|
+
execute: async ({ path: path3, content }) => {
|
|
2352
|
+
const resolved = resolve3(path3);
|
|
2353
|
+
const check = await permissions.checkFsAccess(resolved, "write");
|
|
2354
|
+
if (!check.allowed) {
|
|
2355
|
+
return `Error: ${check.reason}`;
|
|
2356
|
+
}
|
|
2357
|
+
if (!existsSync8(resolved)) {
|
|
2358
|
+
return `Error: File not found: ${resolved}. Use create_file to create new files.`;
|
|
2359
|
+
}
|
|
2360
|
+
try {
|
|
2361
|
+
writeFileSync7(resolved, content, "utf-8");
|
|
2362
|
+
return `Successfully wrote ${content.length} bytes to ${resolved}`;
|
|
2363
|
+
} catch (err) {
|
|
2364
|
+
return `Error writing file: ${err.message}`;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
// src/capabilities/filesystem/create-file.ts
|
|
2371
|
+
import { tool as tool3 } from "ai";
|
|
2372
|
+
import { z as z3 } from "zod";
|
|
2373
|
+
import { existsSync as existsSync9, writeFileSync as writeFileSync8, mkdirSync as mkdirSync7 } from "fs";
|
|
2374
|
+
import { resolve as resolve4, dirname as dirname2 } from "path";
|
|
2375
|
+
function createCreateFileTool(permissions) {
|
|
2376
|
+
return tool3({
|
|
2377
|
+
description: "Create a new file with the given content. Also creates parent directories if needed. The path must be within a writable scope.",
|
|
2378
|
+
parameters: z3.object({
|
|
2379
|
+
path: z3.string().describe("Absolute or relative path for the new file"),
|
|
2380
|
+
content: z3.string().describe("The content of the new file")
|
|
2381
|
+
}),
|
|
2382
|
+
execute: async ({ path: path3, content }) => {
|
|
2383
|
+
const resolved = resolve4(path3);
|
|
2384
|
+
const check = await permissions.checkFsAccess(resolved, "write");
|
|
2385
|
+
if (!check.allowed) {
|
|
2386
|
+
return `Error: ${check.reason}`;
|
|
2387
|
+
}
|
|
2388
|
+
if (existsSync9(resolved)) {
|
|
2389
|
+
return `Error: File already exists: ${resolved}. Use write_file to modify existing files.`;
|
|
2390
|
+
}
|
|
2391
|
+
try {
|
|
2392
|
+
const dir = dirname2(resolved);
|
|
2393
|
+
if (!existsSync9(dir)) {
|
|
2394
|
+
mkdirSync7(dir, { recursive: true });
|
|
2395
|
+
}
|
|
2396
|
+
writeFileSync8(resolved, content, "utf-8");
|
|
2397
|
+
return `Successfully created ${resolved} (${content.length} bytes)`;
|
|
2398
|
+
} catch (err) {
|
|
2399
|
+
return `Error creating file: ${err.message}`;
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
});
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// src/capabilities/filesystem/list-dir.ts
|
|
2406
|
+
import { tool as tool4 } from "ai";
|
|
2407
|
+
import { z as z4 } from "zod";
|
|
2408
|
+
import { existsSync as existsSync10, readdirSync as readdirSync2, statSync } from "fs";
|
|
2409
|
+
import { resolve as resolve5 } from "path";
|
|
2410
|
+
function createListDirTool(permissions) {
|
|
2411
|
+
return tool4({
|
|
2412
|
+
description: "List the contents of a directory. Shows file names, types, and sizes.",
|
|
2413
|
+
parameters: z4.object({
|
|
2414
|
+
path: z4.string().describe("Absolute or relative path to the directory")
|
|
2415
|
+
}),
|
|
2416
|
+
execute: async ({ path: path3 }) => {
|
|
2417
|
+
const resolved = resolve5(path3);
|
|
2418
|
+
const check = await permissions.checkFsAccess(resolved, "read");
|
|
2419
|
+
if (!check.allowed) {
|
|
2420
|
+
return `Error: ${check.reason}`;
|
|
2421
|
+
}
|
|
2422
|
+
if (!existsSync10(resolved)) {
|
|
2423
|
+
return `Error: Directory not found: ${resolved}`;
|
|
2424
|
+
}
|
|
2425
|
+
try {
|
|
2426
|
+
const stat = statSync(resolved);
|
|
2427
|
+
if (!stat.isDirectory()) {
|
|
2428
|
+
return `Error: ${resolved} is a file, not a directory. Use read_file instead.`;
|
|
2429
|
+
}
|
|
2430
|
+
const entries = readdirSync2(resolved, { withFileTypes: true });
|
|
2431
|
+
const lines = entries.map((entry) => {
|
|
2432
|
+
const isDir = entry.isDirectory();
|
|
2433
|
+
const fullPath = join7(resolved, entry.name);
|
|
2434
|
+
let size = "";
|
|
2435
|
+
try {
|
|
2436
|
+
if (!isDir) {
|
|
2437
|
+
size = ` (${formatSize(statSync(fullPath).size)})`;
|
|
2438
|
+
}
|
|
2439
|
+
} catch {
|
|
2440
|
+
}
|
|
2441
|
+
return `${isDir ? "\u{1F4C1}" : "\u{1F4C4}"} ${entry.name}${size}`;
|
|
2442
|
+
});
|
|
2443
|
+
if (lines.length === 0) {
|
|
2444
|
+
return `Directory ${resolved} is empty`;
|
|
2445
|
+
}
|
|
2446
|
+
return `Contents of ${resolved} (${entries.length} items):
|
|
2447
|
+
${lines.join("\n")}`;
|
|
2448
|
+
} catch (err) {
|
|
2449
|
+
return `Error listing directory: ${err.message}`;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
function join7(base, name) {
|
|
2455
|
+
return base.endsWith("/") ? base + name : base + "/" + name;
|
|
2456
|
+
}
|
|
2457
|
+
function formatSize(bytes) {
|
|
2458
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
2459
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
2460
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
// src/capabilities/filesystem/delete-file.ts
|
|
2464
|
+
import { tool as tool5 } from "ai";
|
|
2465
|
+
import { z as z5 } from "zod";
|
|
2466
|
+
import { existsSync as existsSync11, unlinkSync as unlinkSync2 } from "fs";
|
|
2467
|
+
import { resolve as resolve6 } from "path";
|
|
2468
|
+
function createDeleteFileTool(permissions) {
|
|
2469
|
+
return tool5({
|
|
2470
|
+
description: "Delete a file. This action cannot be undone. The path must be within a writable scope. Always asks for confirmation.",
|
|
2471
|
+
parameters: z5.object({
|
|
2472
|
+
path: z5.string().describe("Absolute or relative path to the file to delete")
|
|
2473
|
+
}),
|
|
2474
|
+
execute: async ({ path: path3 }) => {
|
|
2475
|
+
const resolved = resolve6(path3);
|
|
2476
|
+
const check = await permissions.checkFsAccess(resolved, "write");
|
|
2477
|
+
if (!check.allowed) {
|
|
2478
|
+
return `Error: ${check.reason}`;
|
|
2479
|
+
}
|
|
2480
|
+
if (!existsSync11(resolved)) {
|
|
2481
|
+
return `Error: File not found: ${resolved}`;
|
|
2482
|
+
}
|
|
2483
|
+
try {
|
|
2484
|
+
const stat = await import("fs").then((m) => m.statSync(resolved));
|
|
2485
|
+
if (stat.isDirectory()) {
|
|
2486
|
+
return `Error: ${resolved} is a directory. Cannot delete directories for safety.`;
|
|
2487
|
+
}
|
|
2488
|
+
unlinkSync2(resolved);
|
|
2489
|
+
return `Successfully deleted ${resolved}`;
|
|
2490
|
+
} catch (err) {
|
|
2491
|
+
return `Error deleting file: ${err.message}`;
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
// src/capabilities/filesystem/edit-file.ts
|
|
2498
|
+
import { tool as tool6 } from "ai";
|
|
2499
|
+
import { z as z6 } from "zod";
|
|
2500
|
+
import { readFileSync as readFileSync9, writeFileSync as writeFileSync9 } from "fs";
|
|
2501
|
+
import { resolve as resolve7 } from "path";
|
|
2502
|
+
function createEditFileTool(permissions) {
|
|
2503
|
+
return tool6({
|
|
2504
|
+
description: "Edit a file by replacing an exact string match with new content. Use this instead of write_file when you only need to change part of a file. The old_string must match exactly (including whitespace and indentation). Fails if old_string is not found or found multiple times.",
|
|
2505
|
+
parameters: z6.object({
|
|
2506
|
+
path: z6.string().describe("Absolute or relative path to the file"),
|
|
2507
|
+
old_string: z6.string().describe("The exact text to find in the file (must match exactly)"),
|
|
2508
|
+
new_string: z6.string().describe("The text to replace it with")
|
|
2509
|
+
}),
|
|
2510
|
+
execute: async ({ path: path3, old_string, new_string }) => {
|
|
2511
|
+
const resolved = resolve7(path3);
|
|
2512
|
+
const fsCheck = await permissions.checkFsAccess(resolved, "write");
|
|
2513
|
+
if (!fsCheck.allowed) {
|
|
2514
|
+
return `Error: ${fsCheck.reason}`;
|
|
2515
|
+
}
|
|
2516
|
+
try {
|
|
2517
|
+
const content = readFileSync9(resolved, "utf-8");
|
|
2518
|
+
const count = content.split(old_string).length - 1;
|
|
2519
|
+
if (count === 0) {
|
|
2520
|
+
return `Error: old_string not found in ${path3}. Make sure the text matches exactly, including whitespace and indentation.`;
|
|
2521
|
+
}
|
|
2522
|
+
if (count > 1) {
|
|
2523
|
+
return `Error: old_string found ${count} times in ${path3}. Provide more surrounding context to make the match unique.`;
|
|
2524
|
+
}
|
|
2525
|
+
const newContent = content.replace(old_string, new_string);
|
|
2526
|
+
writeFileSync9(resolved, newContent, "utf-8");
|
|
2527
|
+
const linesAdded = new_string.split("\n").length;
|
|
2528
|
+
const linesRemoved = old_string.split("\n").length;
|
|
2529
|
+
return `Edited ${path3}: replaced ${linesRemoved} line(s) with ${linesAdded} line(s)`;
|
|
2530
|
+
} catch (err) {
|
|
2531
|
+
if (err.code === "ENOENT") {
|
|
2532
|
+
return `Error: File not found: ${path3}. Use create_file to create new files.`;
|
|
2533
|
+
}
|
|
2534
|
+
return `Error editing file: ${err.message}`;
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// src/capabilities/filesystem/send-file.ts
|
|
2541
|
+
import { tool as tool7 } from "ai";
|
|
2542
|
+
import { z as z7 } from "zod";
|
|
2543
|
+
import { existsSync as existsSync12, statSync as statSync2 } from "fs";
|
|
2544
|
+
import { resolve as resolve8, basename } from "path";
|
|
2545
|
+
function createSendFileTool(permissions, sendFile) {
|
|
2546
|
+
return tool7({
|
|
2547
|
+
description: "Send a file to the user. On Telegram the file is uploaded as an attachment. On CLI the file path and size are displayed. The path must be within an allowed read scope.",
|
|
2548
|
+
parameters: z7.object({
|
|
2549
|
+
path: z7.string().describe("Absolute or relative path to the file to send")
|
|
2550
|
+
}),
|
|
2551
|
+
execute: async ({ path: path3 }) => {
|
|
2552
|
+
const resolved = resolve8(path3);
|
|
2553
|
+
const check = await permissions.checkFsAccess(resolved, "read");
|
|
2554
|
+
if (!check.allowed) {
|
|
2555
|
+
return `Error: ${check.reason}`;
|
|
2556
|
+
}
|
|
2557
|
+
if (!existsSync12(resolved)) {
|
|
2558
|
+
return `Error: File not found: ${resolved}`;
|
|
2559
|
+
}
|
|
2560
|
+
const stat = statSync2(resolved);
|
|
2561
|
+
if (stat.isDirectory()) {
|
|
2562
|
+
return `Error: ${resolved} is a directory, not a file. Use list_dir to show its contents.`;
|
|
2563
|
+
}
|
|
2564
|
+
if (stat.size > 50 * 1024 * 1024) {
|
|
2565
|
+
return `Error: File too large (${Math.round(stat.size / (1024 * 1024))}MB). Maximum is 50MB.`;
|
|
2566
|
+
}
|
|
2567
|
+
try {
|
|
2568
|
+
await sendFile(resolved);
|
|
2569
|
+
const filename = basename(resolved);
|
|
2570
|
+
const sizeStr = stat.size > 1024 * 1024 ? `${(stat.size / (1024 * 1024)).toFixed(1)}MB` : `${Math.round(stat.size / 1024)}KB`;
|
|
2571
|
+
return `File sent: ${filename} (${sizeStr})`;
|
|
2572
|
+
} catch (err) {
|
|
2573
|
+
return `Error sending file: ${err.message}`;
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
});
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
// src/capabilities/shell/run-command.ts
|
|
2580
|
+
import { tool as tool8 } from "ai";
|
|
2581
|
+
import { z as z8 } from "zod";
|
|
2582
|
+
import { execSync } from "child_process";
|
|
2583
|
+
function createRunCommandTool(permissions) {
|
|
2584
|
+
return tool8({
|
|
2585
|
+
description: `Run a shell command. Commands run in the current working directory unless an absolute path is given.
|
|
2586
|
+
Blocked commands (sudo, rm -rf /, etc.) are never executed.
|
|
2587
|
+
Auto-approved commands (ls, cat, git status, curl, etc.) run without asking.
|
|
2588
|
+
Other commands require user approval \u2014 tell the user what command you want to run and ask for confirmation. If they say "yes", try again. If they say "always", use the approve_command tool.`,
|
|
2589
|
+
parameters: z8.object({
|
|
2590
|
+
command: z8.string().describe("The shell command to execute")
|
|
2591
|
+
}),
|
|
2592
|
+
execute: async ({ command }) => {
|
|
2593
|
+
const check = await permissions.checkShellCommand(command);
|
|
2594
|
+
if (!check.allowed) {
|
|
2595
|
+
if (check.needsApproval) {
|
|
2596
|
+
const baseCmd = command.trim().split(/\s+/)[0];
|
|
2597
|
+
permissions.addPendingApproval(baseCmd);
|
|
2598
|
+
return `\u26A0 Command requires approval: ${command}
|
|
2599
|
+
|
|
2600
|
+
Tell the user what this command does and ask for permission. If they approve, try running it again. If they say "always", use the approve_command tool to permanently approve this command type.`;
|
|
2601
|
+
}
|
|
2602
|
+
return `Error: ${check.reason}`;
|
|
2603
|
+
}
|
|
2604
|
+
try {
|
|
2605
|
+
logger.info({ cmd: command }, "Executing shell command");
|
|
2606
|
+
const result = execSync(command, {
|
|
2607
|
+
cwd: process.cwd(),
|
|
2608
|
+
timeout: 3e4,
|
|
2609
|
+
maxBuffer: 1024 * 1024,
|
|
2610
|
+
encoding: "utf-8",
|
|
2611
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2612
|
+
});
|
|
2613
|
+
const output = result?.trim() || "(no output)";
|
|
2614
|
+
return output;
|
|
2615
|
+
} catch (err) {
|
|
2616
|
+
const stderr = err.stderr?.trim();
|
|
2617
|
+
const stdout = err.stdout?.trim();
|
|
2618
|
+
let msg = `Command exited with code ${err.status || "unknown"}`;
|
|
2619
|
+
if (stdout) msg += `
|
|
2620
|
+
Output: ${stdout}`;
|
|
2621
|
+
if (stderr) msg += `
|
|
2622
|
+
Error: ${stderr}`;
|
|
2623
|
+
return msg;
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
// src/capabilities/shell/approve-command.ts
|
|
2630
|
+
import { tool as tool9 } from "ai";
|
|
2631
|
+
import { z as z9 } from "zod";
|
|
2632
|
+
function createApproveCommandTool(permissions) {
|
|
2633
|
+
return tool9({
|
|
2634
|
+
description: 'Permanently approve a command type so it runs without asking in the future. Use this when the user says "always" or "always approve" for a command. For example, if the user says "always approve curl", call this with command="curl".',
|
|
2635
|
+
parameters: z9.object({
|
|
2636
|
+
command: z9.string().describe('The base command to permanently approve (e.g. "curl", "docker", "npm")')
|
|
2637
|
+
}),
|
|
2638
|
+
execute: async ({ command }) => {
|
|
2639
|
+
const baseCmd = command.trim().split(/\s+/)[0];
|
|
2640
|
+
permissions.addApprovedCommand(baseCmd);
|
|
2641
|
+
return `Command "${baseCmd}" has been permanently approved. Future calls to "${baseCmd} ..." will run without asking.`;
|
|
2642
|
+
}
|
|
2643
|
+
});
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
// src/capabilities/skills/install-skill.ts
|
|
2647
|
+
import { tool as tool10 } from "ai";
|
|
2648
|
+
import { z as z10 } from "zod";
|
|
2649
|
+
import { parse as parseYaml4 } from "yaml";
|
|
2650
|
+
function createInstallSkillTool(skillLoader) {
|
|
2651
|
+
return tool10({
|
|
2652
|
+
description: "Install a new skill by providing SKILL.md markdown content or a URL. The content must have YAML frontmatter (---) with at least name and description fields.",
|
|
2653
|
+
parameters: z10.object({
|
|
2654
|
+
content: z10.string().optional().describe("Raw SKILL.md markdown content with YAML frontmatter"),
|
|
2655
|
+
url: z10.string().optional().describe("URL to fetch a SKILL.md from")
|
|
2656
|
+
}),
|
|
2657
|
+
execute: async ({ content, url }) => {
|
|
2658
|
+
let skillContent;
|
|
2659
|
+
if (url && !content) {
|
|
2660
|
+
try {
|
|
2661
|
+
const resp = await fetch(url);
|
|
2662
|
+
if (!resp.ok) {
|
|
2663
|
+
return `Failed to fetch skill from URL: ${resp.status} ${resp.statusText}`;
|
|
2664
|
+
}
|
|
2665
|
+
skillContent = await resp.text();
|
|
2666
|
+
} catch (err) {
|
|
2667
|
+
return `Failed to fetch skill from URL: ${err.message}`;
|
|
2668
|
+
}
|
|
2669
|
+
} else if (content) {
|
|
2670
|
+
skillContent = content;
|
|
2671
|
+
} else {
|
|
2672
|
+
return "Either content or url must be provided.";
|
|
2673
|
+
}
|
|
2674
|
+
const fmMatch = skillContent.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
2675
|
+
if (!fmMatch) {
|
|
2676
|
+
return "Invalid SKILL.md: missing YAML frontmatter (--- delimiters).";
|
|
2677
|
+
}
|
|
2678
|
+
try {
|
|
2679
|
+
const meta2 = parseYaml4(fmMatch[1]);
|
|
2680
|
+
if (!meta2.name || !meta2.description) {
|
|
2681
|
+
return 'Invalid SKILL.md: frontmatter must include at least "name" and "description".';
|
|
2682
|
+
}
|
|
2683
|
+
} catch {
|
|
2684
|
+
return "Invalid SKILL.md: could not parse YAML frontmatter.";
|
|
2685
|
+
}
|
|
2686
|
+
const fmMatch2 = skillContent.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
2687
|
+
const meta = parseYaml4(fmMatch2[1]);
|
|
2688
|
+
const skillDir = skillLoader.saveSkill(meta.name, skillContent);
|
|
2689
|
+
return `Skill "${meta.name}" installed to ${skillDir}. Use list_skills to see all installed skills, or use_skill to invoke it.`;
|
|
2690
|
+
}
|
|
2691
|
+
});
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
// src/capabilities/skills/list-skills.ts
|
|
2695
|
+
import { tool as tool11 } from "ai";
|
|
2696
|
+
import { z as z11 } from "zod";
|
|
2697
|
+
function createListSkillsTool(skillLoader) {
|
|
2698
|
+
return tool11({
|
|
2699
|
+
description: "List all installed skills with their names and descriptions.",
|
|
2700
|
+
parameters: z11.object({}),
|
|
2701
|
+
execute: async () => {
|
|
2702
|
+
const skills = skillLoader.getDiscovered();
|
|
2703
|
+
if (skills.length === 0) {
|
|
2704
|
+
return "No skills installed. Use install_skill to add one.";
|
|
2705
|
+
}
|
|
2706
|
+
return skills.map((s) => `${s.name}: ${s.description}`).join("\n");
|
|
2707
|
+
}
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// src/capabilities/skills/use-skill.ts
|
|
2712
|
+
import { tool as tool12 } from "ai";
|
|
2713
|
+
import { z as z12 } from "zod";
|
|
2714
|
+
function createUseSkillTool(skillLoader, permissions) {
|
|
2715
|
+
return tool12({
|
|
2716
|
+
description: "Load and invoke a skill by name. Returns the skill's full instructions which should be followed as guidance for the current task.",
|
|
2717
|
+
parameters: z12.object({
|
|
2718
|
+
name: z12.string().describe("Name of the skill to invoke")
|
|
2719
|
+
}),
|
|
2720
|
+
execute: async ({ name }) => {
|
|
2721
|
+
const skill = skillLoader.load(name);
|
|
2722
|
+
if (!skill) {
|
|
2723
|
+
return `Skill "${name}" not found. Use list_skills to see available skills.`;
|
|
2724
|
+
}
|
|
2725
|
+
if (skill["allowed-tools"] && skill["allowed-tools"].length > 0) {
|
|
2726
|
+
permissions.elevateForSkill(skill["allowed-tools"]);
|
|
2727
|
+
}
|
|
2728
|
+
let result = `## Skill: ${skill.name}
|
|
2729
|
+
|
|
2730
|
+
${skill.instructions}`;
|
|
2731
|
+
if (skill["allowed-tools"] && skill["allowed-tools"].length > 0) {
|
|
2732
|
+
result += `
|
|
2733
|
+
|
|
2734
|
+
Allowed tools: ${skill["allowed-tools"].join(", ")}`;
|
|
2735
|
+
}
|
|
2736
|
+
if (skill["disable-model-invocation"]) {
|
|
2737
|
+
result += "\n\nNote: This skill has model invocation disabled. Follow instructions only.";
|
|
2738
|
+
}
|
|
2739
|
+
return result;
|
|
2740
|
+
}
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
// src/capabilities/scheduler/schedule-task.ts
|
|
2745
|
+
import { tool as tool13 } from "ai";
|
|
2746
|
+
import { z as z13 } from "zod";
|
|
2747
|
+
import cron2 from "node-cron";
|
|
2748
|
+
function createScheduleTaskTool(scheduler, getContext) {
|
|
2749
|
+
return tool13({
|
|
2750
|
+
description: 'Schedule a task. Use "cron" for recurring tasks (e.g. "0 9 * * *" for daily at 9am) or "delay_seconds" for one-shot delayed tasks (e.g. 15 for "remind me in 15 seconds"). Provide exactly one of cron or delay_seconds.',
|
|
2751
|
+
parameters: z13.object({
|
|
2752
|
+
cron: z13.string().optional().describe('Cron expression for recurring tasks (e.g. "0 9 * * *" for daily at 9am)'),
|
|
2753
|
+
delay_seconds: z13.number().optional().describe('Delay in seconds for one-shot tasks (e.g. 15 for "remind me in 15 seconds")'),
|
|
2754
|
+
description: z13.string().describe("Human-readable description of what this task does"),
|
|
2755
|
+
prompt: z13.string().optional().describe("Prompt to send to the agent when the task fires"),
|
|
2756
|
+
skill_name: z13.string().optional().describe("Name of a skill to invoke when the task fires")
|
|
2757
|
+
}),
|
|
2758
|
+
execute: async ({ cron: cronExpr, delay_seconds, description, prompt, skill_name }) => {
|
|
2759
|
+
if (!cronExpr && !delay_seconds) {
|
|
2760
|
+
return "Either cron or delay_seconds must be provided.";
|
|
2761
|
+
}
|
|
2762
|
+
if (cronExpr && delay_seconds) {
|
|
2763
|
+
return "Provide either cron or delay_seconds, not both.";
|
|
2764
|
+
}
|
|
2765
|
+
if (!prompt && !skill_name) {
|
|
2766
|
+
return "Either prompt or skill_name must be provided so the task has something to do.";
|
|
2767
|
+
}
|
|
2768
|
+
const id = `task-${Date.now().toString(36)}`;
|
|
2769
|
+
const ctx = getContext();
|
|
2770
|
+
if (delay_seconds) {
|
|
2771
|
+
const manifest2 = {
|
|
2772
|
+
id,
|
|
2773
|
+
description,
|
|
2774
|
+
prompt,
|
|
2775
|
+
skillName: skill_name,
|
|
2776
|
+
delaySeconds: delay_seconds,
|
|
2777
|
+
executeAt: new Date(Date.now() + delay_seconds * 1e3).toISOString(),
|
|
2778
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2779
|
+
sourceChannelId: ctx.channelId,
|
|
2780
|
+
sourceChannelType: ctx.channelType
|
|
2781
|
+
};
|
|
2782
|
+
scheduler.addDelayedTask(manifest2);
|
|
2783
|
+
scheduler.persistSchedules();
|
|
2784
|
+
const triggerType2 = skill_name ? `skill: ${skill_name}` : `prompt: "${prompt.slice(0, 60)}"`;
|
|
2785
|
+
return `Reminder "${id}" set. Will trigger in ${delay_seconds} second${delay_seconds !== 1 ? "s" : ""}. ${triggerType2}. Description: ${description}`;
|
|
2786
|
+
}
|
|
2787
|
+
if (!cron2.validate(cronExpr)) {
|
|
2788
|
+
return `Invalid cron expression: "${cronExpr}". Use standard 5-field cron format (min hour day month weekday).`;
|
|
2789
|
+
}
|
|
2790
|
+
const manifest = {
|
|
2791
|
+
id,
|
|
2792
|
+
cron: cronExpr,
|
|
2793
|
+
description,
|
|
2794
|
+
prompt,
|
|
2795
|
+
skillName: skill_name,
|
|
2796
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2797
|
+
sourceChannelId: ctx.channelId,
|
|
2798
|
+
sourceChannelType: ctx.channelType
|
|
2799
|
+
};
|
|
2800
|
+
scheduler.addPersistedTask(manifest);
|
|
2801
|
+
scheduler.persistSchedules();
|
|
2802
|
+
const triggerType = skill_name ? `skill: ${skill_name}` : `prompt: "${prompt.slice(0, 60)}"`;
|
|
2803
|
+
return `Task "${id}" scheduled. Cron: ${cronExpr}. Will execute ${triggerType}. Description: ${description}`;
|
|
2804
|
+
}
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
// src/capabilities/scheduler/list-tasks.ts
|
|
2809
|
+
import { tool as tool14 } from "ai";
|
|
2810
|
+
import { z as z14 } from "zod";
|
|
2811
|
+
function createListTasksTool(scheduler) {
|
|
2812
|
+
return tool14({
|
|
2813
|
+
description: "List all scheduled tasks with their cron expressions and descriptions.",
|
|
2814
|
+
parameters: z14.object({}),
|
|
2815
|
+
execute: async () => {
|
|
2816
|
+
const manifests = scheduler.getManifests();
|
|
2817
|
+
if (manifests.length === 0) {
|
|
2818
|
+
return "No scheduled tasks. Use schedule_task to create one.";
|
|
2819
|
+
}
|
|
2820
|
+
return manifests.map((m) => {
|
|
2821
|
+
const trigger = m.skillName ? `skill: ${m.skillName}` : m.prompt ? `prompt: "${m.prompt.slice(0, 40)}"` : "no action";
|
|
2822
|
+
return `${m.id} | ${m.cron} | ${m.description} | ${trigger}`;
|
|
2823
|
+
}).join("\n");
|
|
2824
|
+
}
|
|
2825
|
+
});
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// src/capabilities/scheduler/cancel-task.ts
|
|
2829
|
+
import { tool as tool15 } from "ai";
|
|
2830
|
+
import { z as z15 } from "zod";
|
|
2831
|
+
function createCancelTaskTool(scheduler) {
|
|
2832
|
+
return tool15({
|
|
2833
|
+
description: "Cancel and remove a scheduled task by its ID.",
|
|
2834
|
+
parameters: z15.object({
|
|
2835
|
+
id: z15.string().describe("ID of the scheduled task to cancel")
|
|
2836
|
+
}),
|
|
2837
|
+
execute: async ({ id }) => {
|
|
2838
|
+
const manifests = scheduler.getManifests();
|
|
2839
|
+
const exists = manifests.some((m) => m.id === id);
|
|
2840
|
+
if (!exists) {
|
|
2841
|
+
return `Task "${id}" not found. Use list_scheduled_tasks to see active tasks.`;
|
|
2842
|
+
}
|
|
2843
|
+
scheduler.removeTask(id);
|
|
2844
|
+
scheduler.persistSchedules();
|
|
2845
|
+
return `Task "${id}" cancelled and removed.`;
|
|
2846
|
+
}
|
|
2847
|
+
});
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
// src/capabilities/system/budget-status.ts
|
|
2851
|
+
import { tool as tool16 } from "ai";
|
|
2852
|
+
import { z as z16 } from "zod";
|
|
2853
|
+
function createBudgetStatusTool(tokenBudget) {
|
|
2854
|
+
return tool16({
|
|
2855
|
+
description: "Check the current token budget status \u2014 how many tokens have been used today, how many remain, and what percentage is consumed.",
|
|
2856
|
+
parameters: z16.object({}),
|
|
2857
|
+
execute: async () => {
|
|
2858
|
+
return tokenBudget.getStatusText();
|
|
2859
|
+
}
|
|
2860
|
+
});
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
// src/capabilities/git/git-status.ts
|
|
2864
|
+
import { tool as tool17 } from "ai";
|
|
2865
|
+
import { z as z17 } from "zod";
|
|
2866
|
+
import { execSync as execSync2 } from "child_process";
|
|
2867
|
+
function createGitStatusTool() {
|
|
2868
|
+
return tool17({
|
|
2869
|
+
description: "Show the working tree status. Returns staged, unstaged, and untracked files.",
|
|
2870
|
+
parameters: z17.object({
|
|
2871
|
+
path: z17.string().optional().describe("Path to check (defaults to current directory)")
|
|
2872
|
+
}),
|
|
2873
|
+
execute: async ({ path: path3 }) => {
|
|
2874
|
+
try {
|
|
2875
|
+
const cmd = path3 ? `git -C "${path3}" status --porcelain` : "git status --porcelain";
|
|
2876
|
+
const result = execSync2(cmd, { encoding: "utf-8", timeout: 1e4 });
|
|
2877
|
+
if (!result.trim()) return "Working tree clean \u2014 no changes.";
|
|
2878
|
+
return result.trim();
|
|
2879
|
+
} catch (err) {
|
|
2880
|
+
return `Error: ${err.stderr?.trim() || err.message}`;
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
});
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
// src/capabilities/git/git-diff.ts
|
|
2887
|
+
import { tool as tool18 } from "ai";
|
|
2888
|
+
import { z as z18 } from "zod";
|
|
2889
|
+
import { execSync as execSync3 } from "child_process";
|
|
2890
|
+
function createGitDiffTool() {
|
|
2891
|
+
return tool18({
|
|
2892
|
+
description: "Show changes between commits, commit and working tree, etc. Shows what has been modified.",
|
|
2893
|
+
parameters: z18.object({
|
|
2894
|
+
path: z18.string().optional().describe("File or directory to diff"),
|
|
2895
|
+
staged: z18.boolean().optional().describe("Show staged changes (cached) instead of unstaged")
|
|
2896
|
+
}),
|
|
2897
|
+
execute: async ({ path: path3, staged }) => {
|
|
2898
|
+
try {
|
|
2899
|
+
let cmd = "git diff";
|
|
2900
|
+
if (staged) cmd += " --cached";
|
|
2901
|
+
if (path3) cmd += ` -- "${path3}"`;
|
|
2902
|
+
const result = execSync3(cmd, { encoding: "utf-8", timeout: 15e3 });
|
|
2903
|
+
if (!result.trim()) return "No differences found.";
|
|
2904
|
+
const truncated = result.length > 15e3 ? result.slice(0, 15e3) + "\n... (truncated)" : result;
|
|
2905
|
+
return truncated;
|
|
2906
|
+
} catch (err) {
|
|
2907
|
+
return `Error: ${err.stderr?.trim() || err.message}`;
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
// src/capabilities/git/git-log.ts
|
|
2914
|
+
import { tool as tool19 } from "ai";
|
|
2915
|
+
import { z as z19 } from "zod";
|
|
2916
|
+
import { execSync as execSync4 } from "child_process";
|
|
2917
|
+
function createGitLogTool() {
|
|
2918
|
+
return tool19({
|
|
2919
|
+
description: "Show commit logs. Returns recent commit history with hash, author, date, and message.",
|
|
2920
|
+
parameters: z19.object({
|
|
2921
|
+
count: z19.number().optional().describe("Number of commits to show (default 10)"),
|
|
2922
|
+
path: z19.string().optional().describe("File or directory to show log for")
|
|
2923
|
+
}),
|
|
2924
|
+
execute: async ({ count, path: path3 }) => {
|
|
2925
|
+
try {
|
|
2926
|
+
const n = count ?? 10;
|
|
2927
|
+
let cmd = `git log --oneline --decorate -${n}`;
|
|
2928
|
+
if (path3) cmd += ` -- "${path3}"`;
|
|
2929
|
+
const result = execSync4(cmd, { encoding: "utf-8", timeout: 1e4 });
|
|
2930
|
+
if (!result.trim()) return "No commits found.";
|
|
2931
|
+
return result.trim();
|
|
2932
|
+
} catch (err) {
|
|
2933
|
+
return `Error: ${err.stderr?.trim() || err.message}`;
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
});
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
// src/capabilities/git/git-add.ts
|
|
2940
|
+
import { tool as tool20 } from "ai";
|
|
2941
|
+
import { z as z20 } from "zod";
|
|
2942
|
+
import { execSync as execSync5 } from "child_process";
|
|
2943
|
+
function createGitAddTool() {
|
|
2944
|
+
return tool20({
|
|
2945
|
+
description: "Add file contents to the index (staging area). Prepares files for commit.",
|
|
2946
|
+
parameters: z20.object({
|
|
2947
|
+
paths: z20.array(z20.string()).describe("File paths to stage")
|
|
2948
|
+
}),
|
|
2949
|
+
execute: async ({ paths }) => {
|
|
2950
|
+
try {
|
|
2951
|
+
const fileArgs = paths.map((p) => `"${p}"`).join(" ");
|
|
2952
|
+
const result = execSync5(`git add ${fileArgs}`, { encoding: "utf-8", timeout: 1e4 });
|
|
2953
|
+
return `Staged ${paths.length} file(s): ${paths.join(", ")}`;
|
|
2954
|
+
} catch (err) {
|
|
2955
|
+
return `Error: ${err.stderr?.trim() || err.message}`;
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
});
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
// src/capabilities/git/git-commit.ts
|
|
2962
|
+
import { tool as tool21 } from "ai";
|
|
2963
|
+
import { z as z21 } from "zod";
|
|
2964
|
+
import { execSync as execSync6 } from "child_process";
|
|
2965
|
+
function createGitCommitTool() {
|
|
2966
|
+
return tool21({
|
|
2967
|
+
description: "Record changes to the repository. Creates a new commit with staged changes.",
|
|
2968
|
+
parameters: z21.object({
|
|
2969
|
+
message: z21.string().describe("Commit message")
|
|
2970
|
+
}),
|
|
2971
|
+
execute: async ({ message }) => {
|
|
2972
|
+
try {
|
|
2973
|
+
const escapedMsg = message.replace(/"/g, '\\"');
|
|
2974
|
+
const result = execSync6(`git commit -m "${escapedMsg}"`, { encoding: "utf-8", timeout: 1e4 });
|
|
2975
|
+
return result.trim() || "Committed successfully.";
|
|
2976
|
+
} catch (err) {
|
|
2977
|
+
const stderr = err.stderr?.trim() || "";
|
|
2978
|
+
if (stderr.includes("nothing to commit")) {
|
|
2979
|
+
return "Nothing to commit \u2014 no staged changes.";
|
|
2980
|
+
}
|
|
2981
|
+
return `Error: ${stderr || err.message}`;
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
});
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
// src/capabilities/git/git-push.ts
|
|
2988
|
+
import { tool as tool22 } from "ai";
|
|
2989
|
+
import { z as z22 } from "zod";
|
|
2990
|
+
import { execSync as execSync7 } from "child_process";
|
|
2991
|
+
function createGitPushTool(permissions) {
|
|
2992
|
+
return tool22({
|
|
2993
|
+
description: "Push commits to a remote repository. This modifies a remote and requires approval.",
|
|
2994
|
+
parameters: z22.object({
|
|
2995
|
+
remote: z22.string().optional().describe("Remote name (default: origin)"),
|
|
2996
|
+
branch: z22.string().optional().describe("Branch name (default: current branch)")
|
|
2997
|
+
}),
|
|
2998
|
+
execute: async ({ remote, branch }) => {
|
|
2999
|
+
const cmd = `git push ${remote || "origin"} ${branch || ""}`.trim();
|
|
3000
|
+
const check = await permissions.checkShellCommand(cmd);
|
|
3001
|
+
if (!check.allowed && check.needsApproval) {
|
|
3002
|
+
const baseCmd = "git";
|
|
3003
|
+
permissions.addPendingApproval(baseCmd);
|
|
3004
|
+
return `\u26A0 This command pushes to a remote: ${cmd}
|
|
3005
|
+
Ask the user for permission. If they approve, try again. If they say "always", use the approve_command tool.`;
|
|
3006
|
+
}
|
|
3007
|
+
if (!check.allowed) {
|
|
3008
|
+
return `Error: ${check.reason}`;
|
|
3009
|
+
}
|
|
3010
|
+
try {
|
|
3011
|
+
const result = execSync7(cmd, { encoding: "utf-8", timeout: 3e4 });
|
|
3012
|
+
return result.trim() || "Pushed successfully.";
|
|
3013
|
+
} catch (err) {
|
|
3014
|
+
return `Error: ${err.stderr?.trim() || err.message}`;
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
// src/capabilities/web/fetch-url.ts
|
|
3021
|
+
import { tool as tool23 } from "ai";
|
|
3022
|
+
import { z as z23 } from "zod";
|
|
3023
|
+
var MAX_CONTENT_LENGTH = 15e3;
|
|
3024
|
+
function stripHtml(html) {
|
|
3025
|
+
let text = html;
|
|
3026
|
+
text = text.replace(/<script[\s\S]*?<\/script>/gi, "");
|
|
3027
|
+
text = text.replace(/<style[\s\S]*?<\/style>/gi, "");
|
|
3028
|
+
text = text.replace(/<nav[\s\S]*?<\/nav>/gi, "");
|
|
3029
|
+
text = text.replace(/<footer[\s\S]*?<\/footer>/gi, "");
|
|
3030
|
+
text = text.replace(/<header[\s\S]*?<\/header>/gi, "");
|
|
3031
|
+
text = text.replace(/<br\s*\/?>/gi, "\n");
|
|
3032
|
+
text = text.replace(/<\/p>/gi, "\n");
|
|
3033
|
+
text = text.replace(/<\/h[1-6]>/gi, "\n");
|
|
3034
|
+
text = text.replace(/<\/li>/gi, "\n");
|
|
3035
|
+
text = text.replace(/<\/div>/gi, "\n");
|
|
3036
|
+
text = text.replace(/<hr\s*\/?>/gi, "\n---\n");
|
|
3037
|
+
text = text.replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)");
|
|
3038
|
+
text = text.replace(/<img[^>]*alt="([^"]*)"[^>]*>/gi, "[image: $1]");
|
|
3039
|
+
text = text.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, "`$1`");
|
|
3040
|
+
text = text.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, "\n```\n$1\n```\n");
|
|
3041
|
+
text = text.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, "**$1**");
|
|
3042
|
+
text = text.replace(/<em[^>]*>([\s\S]*?)<\/em>/gi, "*$1*");
|
|
3043
|
+
text = text.replace(/<[^>]+>/g, "");
|
|
3044
|
+
text = text.replace(/&/g, "&");
|
|
3045
|
+
text = text.replace(/</g, "<");
|
|
3046
|
+
text = text.replace(/>/g, ">");
|
|
3047
|
+
text = text.replace(/"/g, '"');
|
|
3048
|
+
text = text.replace(/'/g, "'");
|
|
3049
|
+
text = text.replace(/ /g, " ");
|
|
3050
|
+
text = text.replace(/\n{3,}/g, "\n\n");
|
|
3051
|
+
text = text.trim();
|
|
3052
|
+
return text;
|
|
3053
|
+
}
|
|
3054
|
+
function createFetchUrlTool() {
|
|
3055
|
+
return tool23({
|
|
3056
|
+
description: "Fetch a URL and return its content as text. Strips HTML to readable markdown-like format. Useful for reading documentation, APIs, or web pages.",
|
|
3057
|
+
parameters: z23.object({
|
|
3058
|
+
url: z23.string().describe("The URL to fetch"),
|
|
3059
|
+
format: z23.enum(["text", "markdown"]).optional().describe("Output format (default: markdown)")
|
|
3060
|
+
}),
|
|
3061
|
+
execute: async ({ url, format }) => {
|
|
3062
|
+
const outputFormat = format ?? "markdown";
|
|
3063
|
+
try {
|
|
3064
|
+
const controller = new AbortController();
|
|
3065
|
+
const timeout = setTimeout(() => controller.abort(), 15e3);
|
|
3066
|
+
const resp = await fetch(url, {
|
|
3067
|
+
signal: controller.signal,
|
|
3068
|
+
headers: {
|
|
3069
|
+
"User-Agent": "Mercury-Agent/0.1.0",
|
|
3070
|
+
"Accept": "text/html,application/json,text/plain"
|
|
3071
|
+
}
|
|
3072
|
+
});
|
|
3073
|
+
clearTimeout(timeout);
|
|
3074
|
+
if (!resp.ok) {
|
|
3075
|
+
return `HTTP ${resp.status} ${resp.statusText} for ${url}`;
|
|
3076
|
+
}
|
|
3077
|
+
const contentType = resp.headers.get("content-type") || "";
|
|
3078
|
+
const body = await resp.text();
|
|
3079
|
+
if (contentType.includes("application/json")) {
|
|
3080
|
+
try {
|
|
3081
|
+
const json = JSON.parse(body);
|
|
3082
|
+
const formatted = JSON.stringify(json, null, 2);
|
|
3083
|
+
return formatted.length > MAX_CONTENT_LENGTH ? formatted.slice(0, MAX_CONTENT_LENGTH) + "\n... (truncated)" : formatted;
|
|
3084
|
+
} catch {
|
|
3085
|
+
return body.slice(0, MAX_CONTENT_LENGTH);
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
if (contentType.includes("text/html") && outputFormat === "markdown") {
|
|
3089
|
+
const text = stripHtml(body);
|
|
3090
|
+
return text.length > MAX_CONTENT_LENGTH ? text.slice(0, MAX_CONTENT_LENGTH) + "\n... (truncated)" : text;
|
|
3091
|
+
}
|
|
3092
|
+
return body.length > MAX_CONTENT_LENGTH ? body.slice(0, MAX_CONTENT_LENGTH) + "\n... (truncated)" : body;
|
|
3093
|
+
} catch (err) {
|
|
3094
|
+
if (err.name === "AbortError") {
|
|
3095
|
+
return `Request to ${url} timed out after 15 seconds.`;
|
|
3096
|
+
}
|
|
3097
|
+
return `Error fetching ${url}: ${err.message}`;
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
});
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
// src/capabilities/registry.ts
|
|
3104
|
+
var CapabilityRegistry = class {
|
|
3105
|
+
permissions;
|
|
3106
|
+
tools = {};
|
|
3107
|
+
skillLoader;
|
|
3108
|
+
scheduler;
|
|
3109
|
+
tokenBudget;
|
|
3110
|
+
sendFileHandler;
|
|
3111
|
+
currentChannelId = "cli";
|
|
3112
|
+
currentChannelType = "cli";
|
|
3113
|
+
constructor(skillLoader, scheduler, tokenBudget) {
|
|
3114
|
+
this.permissions = new PermissionManager();
|
|
3115
|
+
this.skillLoader = skillLoader;
|
|
3116
|
+
this.scheduler = scheduler;
|
|
3117
|
+
this.tokenBudget = tokenBudget;
|
|
3118
|
+
}
|
|
3119
|
+
setChannelContext(channelId, channelType) {
|
|
3120
|
+
this.currentChannelId = channelId;
|
|
3121
|
+
this.currentChannelType = channelType;
|
|
3122
|
+
}
|
|
3123
|
+
getChannelContext() {
|
|
3124
|
+
return { channelId: this.currentChannelId, channelType: this.currentChannelType };
|
|
3125
|
+
}
|
|
3126
|
+
setSendFileHandler(handler) {
|
|
3127
|
+
this.sendFileHandler = handler;
|
|
3128
|
+
}
|
|
3129
|
+
registerAll() {
|
|
3130
|
+
const manifest = this.permissions.getManifest();
|
|
3131
|
+
if (manifest.capabilities.filesystem.enabled) {
|
|
3132
|
+
this.tools.read_file = createReadFileTool(this.permissions);
|
|
3133
|
+
this.tools.write_file = createWriteFileTool(this.permissions);
|
|
3134
|
+
this.tools.create_file = createCreateFileTool(this.permissions);
|
|
3135
|
+
this.tools.list_dir = createListDirTool(this.permissions);
|
|
3136
|
+
this.tools.delete_file = createDeleteFileTool(this.permissions);
|
|
3137
|
+
this.tools.edit_file = createEditFileTool(this.permissions);
|
|
3138
|
+
if (this.sendFileHandler) {
|
|
3139
|
+
this.tools.send_file = createSendFileTool(this.permissions, this.sendFileHandler);
|
|
3140
|
+
}
|
|
3141
|
+
logger.info("Filesystem tools registered");
|
|
3142
|
+
}
|
|
3143
|
+
if (manifest.capabilities.shell.enabled) {
|
|
3144
|
+
this.tools.run_command = createRunCommandTool(this.permissions);
|
|
3145
|
+
this.tools.approve_command = createApproveCommandTool(this.permissions);
|
|
3146
|
+
logger.info("Shell tools registered");
|
|
3147
|
+
}
|
|
3148
|
+
if (this.skillLoader) {
|
|
3149
|
+
this.tools.install_skill = createInstallSkillTool(this.skillLoader);
|
|
3150
|
+
this.tools.list_skills = createListSkillsTool(this.skillLoader);
|
|
3151
|
+
this.tools.use_skill = createUseSkillTool(this.skillLoader, this.permissions);
|
|
3152
|
+
logger.info("Skill tools registered");
|
|
3153
|
+
}
|
|
3154
|
+
if (this.scheduler) {
|
|
3155
|
+
this.tools.schedule_task = createScheduleTaskTool(this.scheduler, () => this.getChannelContext());
|
|
3156
|
+
this.tools.list_scheduled_tasks = createListTasksTool(this.scheduler);
|
|
3157
|
+
this.tools.cancel_scheduled_task = createCancelTaskTool(this.scheduler);
|
|
3158
|
+
logger.info("Scheduler tools registered");
|
|
3159
|
+
}
|
|
3160
|
+
if (this.tokenBudget) {
|
|
3161
|
+
this.tools.budget_status = createBudgetStatusTool(this.tokenBudget);
|
|
3162
|
+
logger.info("Budget tool registered");
|
|
3163
|
+
}
|
|
3164
|
+
if (manifest.capabilities.git?.enabled) {
|
|
3165
|
+
this.tools.git_status = createGitStatusTool();
|
|
3166
|
+
this.tools.git_diff = createGitDiffTool();
|
|
3167
|
+
this.tools.git_log = createGitLogTool();
|
|
3168
|
+
this.tools.git_add = createGitAddTool();
|
|
3169
|
+
this.tools.git_commit = createGitCommitTool();
|
|
3170
|
+
this.tools.git_push = createGitPushTool(this.permissions);
|
|
3171
|
+
logger.info("Git tools registered");
|
|
3172
|
+
}
|
|
3173
|
+
this.tools.fetch_url = createFetchUrlTool();
|
|
3174
|
+
logger.info("Web fetch tool registered");
|
|
3175
|
+
}
|
|
3176
|
+
getTools() {
|
|
3177
|
+
return this.tools;
|
|
3178
|
+
}
|
|
3179
|
+
getToolNames() {
|
|
3180
|
+
return Object.keys(this.tools);
|
|
3181
|
+
}
|
|
3182
|
+
getSkillContext() {
|
|
3183
|
+
return this.skillLoader?.getSkillSummariesText() || "";
|
|
3184
|
+
}
|
|
3185
|
+
};
|
|
3186
|
+
|
|
3187
|
+
// src/skills/loader.ts
|
|
3188
|
+
import { existsSync as existsSync13, readFileSync as readFileSync10, readdirSync as readdirSync3, mkdirSync as mkdirSync8, writeFileSync as writeFileSync10 } from "fs";
|
|
3189
|
+
import { join as join8 } from "path";
|
|
3190
|
+
import { parse as parseYaml5 } from "yaml";
|
|
3191
|
+
var SKILL_FILE = "SKILL.md";
|
|
3192
|
+
function parseSkillMd(content) {
|
|
3193
|
+
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
|
3194
|
+
if (!fmMatch) return null;
|
|
3195
|
+
try {
|
|
3196
|
+
const meta = parseYaml5(fmMatch[1]);
|
|
3197
|
+
const instructions = fmMatch[2].trim();
|
|
3198
|
+
if (!meta.name || !meta.description) {
|
|
3199
|
+
logger.warn({ meta }, "SKILL.md missing required fields (name, description)");
|
|
3200
|
+
return null;
|
|
3201
|
+
}
|
|
3202
|
+
return { meta, instructions };
|
|
3203
|
+
} catch (err) {
|
|
3204
|
+
logger.warn({ err }, "Failed to parse SKILL.md frontmatter");
|
|
3205
|
+
return null;
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
var SkillLoader = class {
|
|
3209
|
+
skillsDir;
|
|
3210
|
+
discovered = /* @__PURE__ */ new Map();
|
|
3211
|
+
loaded = /* @__PURE__ */ new Map();
|
|
3212
|
+
constructor(skillsDir) {
|
|
3213
|
+
this.skillsDir = skillsDir || join8(getMercuryHome(), "skills");
|
|
3214
|
+
}
|
|
3215
|
+
discover() {
|
|
3216
|
+
this.discovered.clear();
|
|
3217
|
+
this.loaded.clear();
|
|
3218
|
+
if (!existsSync13(this.skillsDir)) {
|
|
3219
|
+
mkdirSync8(this.skillsDir, { recursive: true });
|
|
3220
|
+
this.seedTemplate();
|
|
3221
|
+
return [];
|
|
3222
|
+
}
|
|
3223
|
+
const entries = readdirSync3(this.skillsDir, { withFileTypes: true });
|
|
3224
|
+
for (const entry of entries) {
|
|
3225
|
+
if (!entry.isDirectory() || entry.name.startsWith("_")) continue;
|
|
3226
|
+
const skillPath = join8(this.skillsDir, entry.name, SKILL_FILE);
|
|
3227
|
+
if (!existsSync13(skillPath)) continue;
|
|
3228
|
+
try {
|
|
3229
|
+
const raw = readFileSync10(skillPath, "utf-8");
|
|
3230
|
+
const parsed = parseSkillMd(raw);
|
|
3231
|
+
if (!parsed) continue;
|
|
3232
|
+
this.discovered.set(parsed.meta.name, {
|
|
3233
|
+
name: parsed.meta.name,
|
|
3234
|
+
description: parsed.meta.description
|
|
3235
|
+
});
|
|
3236
|
+
logger.info({ skill: parsed.meta.name }, "Skill discovered");
|
|
3237
|
+
} catch (err) {
|
|
3238
|
+
logger.warn({ dir: entry.name, err }, "Failed to load skill");
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
return [...this.discovered.values()];
|
|
3242
|
+
}
|
|
3243
|
+
load(name) {
|
|
3244
|
+
const cached = this.loaded.get(name);
|
|
3245
|
+
if (cached) return cached;
|
|
3246
|
+
for (const entry of readdirSync3(this.skillsDir, { withFileTypes: true })) {
|
|
3247
|
+
if (!entry.isDirectory() || entry.name.startsWith("_")) continue;
|
|
3248
|
+
const skillPath = join8(this.skillsDir, entry.name, SKILL_FILE);
|
|
3249
|
+
if (!existsSync13(skillPath)) continue;
|
|
3250
|
+
try {
|
|
3251
|
+
const raw = readFileSync10(skillPath, "utf-8");
|
|
3252
|
+
const parsed = parseSkillMd(raw);
|
|
3253
|
+
if (!parsed || parsed.meta.name !== name) continue;
|
|
3254
|
+
const skillDir = join8(this.skillsDir, entry.name);
|
|
3255
|
+
const skill = {
|
|
3256
|
+
...parsed.meta,
|
|
3257
|
+
instructions: parsed.instructions,
|
|
3258
|
+
scriptsDir: existsSync13(join8(skillDir, "scripts")) ? join8(skillDir, "scripts") : void 0,
|
|
3259
|
+
referencesDir: existsSync13(join8(skillDir, "references")) ? join8(skillDir, "references") : void 0
|
|
3260
|
+
};
|
|
3261
|
+
this.loaded.set(name, skill);
|
|
3262
|
+
return skill;
|
|
3263
|
+
} catch (err) {
|
|
3264
|
+
logger.warn({ err, name }, "Failed to load skill");
|
|
3265
|
+
return null;
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
return null;
|
|
3269
|
+
}
|
|
3270
|
+
getDiscovered() {
|
|
3271
|
+
return [...this.discovered.values()];
|
|
3272
|
+
}
|
|
3273
|
+
getSkillSummariesText() {
|
|
3274
|
+
const skills = this.getDiscovered();
|
|
3275
|
+
if (skills.length === 0) return "";
|
|
3276
|
+
return "Available skills:\n" + skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
|
|
3277
|
+
}
|
|
3278
|
+
saveSkill(name, content) {
|
|
3279
|
+
const skillDir = join8(this.skillsDir, name);
|
|
3280
|
+
if (!existsSync13(skillDir)) {
|
|
3281
|
+
mkdirSync8(skillDir, { recursive: true });
|
|
3282
|
+
}
|
|
3283
|
+
writeFileSync10(join8(skillDir, SKILL_FILE), content, "utf-8");
|
|
3284
|
+
logger.info({ skill: name }, "Skill saved");
|
|
3285
|
+
this.discover();
|
|
3286
|
+
return skillDir;
|
|
3287
|
+
}
|
|
3288
|
+
seedTemplate() {
|
|
3289
|
+
const templateDir = join8(this.skillsDir, "_template");
|
|
3290
|
+
mkdirSync8(templateDir, { recursive: true });
|
|
3291
|
+
const content = `---
|
|
3292
|
+
name: template-skill
|
|
3293
|
+
description: A template skill for Mercury. Use this as a starting point to create your own skills.
|
|
3294
|
+
version: 0.1.0
|
|
3295
|
+
allowed-tools:
|
|
3296
|
+
- read_file
|
|
3297
|
+
- list_dir
|
|
3298
|
+
---
|
|
3299
|
+
|
|
3300
|
+
# Template Skill
|
|
3301
|
+
|
|
3302
|
+
This is a template skill for Mercury. Copy this directory and edit SKILL.md to create your own skill.
|
|
3303
|
+
|
|
3304
|
+
## What It Does
|
|
3305
|
+
|
|
3306
|
+
Describe what this skill enables Mercury to do. When invoked via the use_skill tool, these instructions are injected into Mercury's context as guidance.
|
|
3307
|
+
|
|
3308
|
+
## Instructions
|
|
3309
|
+
|
|
3310
|
+
1. Step one of what Mercury should do
|
|
3311
|
+
2. Step two
|
|
3312
|
+
3. Continue with specific guidance
|
|
3313
|
+
|
|
3314
|
+
## Tips
|
|
3315
|
+
|
|
3316
|
+
- Keep instructions concise to save tokens
|
|
3317
|
+
- List only the tools you need in allowed-tools
|
|
3318
|
+
- The skill name must be unique among installed skills
|
|
3319
|
+
`;
|
|
3320
|
+
writeFileSync10(join8(templateDir, SKILL_FILE), content, "utf-8");
|
|
3321
|
+
logger.info("Seeded template skill");
|
|
3322
|
+
}
|
|
3323
|
+
};
|
|
3324
|
+
|
|
3325
|
+
// src/index.ts
|
|
3326
|
+
function hr() {
|
|
3327
|
+
console.log(chalk3.dim("\u2500".repeat(50)));
|
|
3328
|
+
}
|
|
3329
|
+
function banner() {
|
|
3330
|
+
console.log("");
|
|
3331
|
+
const art = figlet.textSync("MERCURY", { font: "Slant", horizontalLayout: "default" });
|
|
3332
|
+
for (const line of art.split("\n")) {
|
|
3333
|
+
if (line.trim()) console.log(chalk3.bold.cyan(` ${line}`));
|
|
3334
|
+
}
|
|
3335
|
+
console.log("");
|
|
3336
|
+
console.log(chalk3.white(" an AI agent for personal tasks"));
|
|
3337
|
+
console.log(chalk3.dim(" v0.1.0 \xB7 by Cosmic Stack \xB7 mercury.cosmicstack.org"));
|
|
3338
|
+
console.log("");
|
|
3339
|
+
}
|
|
3340
|
+
function splashScreen() {
|
|
3341
|
+
console.log("");
|
|
3342
|
+
const art = figlet.textSync("MERCURY", { font: "Slant", horizontalLayout: "default" });
|
|
3343
|
+
for (const line of art.split("\n")) {
|
|
3344
|
+
if (line.trim()) console.log(chalk3.bold.cyan(` ${line}`));
|
|
3345
|
+
}
|
|
3346
|
+
console.log("");
|
|
3347
|
+
console.log(chalk3.dim(" an AI agent for personal tasks"));
|
|
3348
|
+
console.log(chalk3.cyan(" by Cosmic Stack"));
|
|
3349
|
+
console.log(chalk3.dim(" mercury.cosmicstack.org"));
|
|
3350
|
+
console.log("");
|
|
3351
|
+
}
|
|
3352
|
+
async function ask(prompt) {
|
|
3353
|
+
const rl = readline2.createInterface({ input: process.stdin, output: process.stdout });
|
|
3354
|
+
return new Promise((resolve9) => {
|
|
3355
|
+
rl.question(prompt, (answer) => {
|
|
3356
|
+
rl.close();
|
|
3357
|
+
resolve9(answer.trim());
|
|
3358
|
+
});
|
|
3359
|
+
});
|
|
3360
|
+
}
|
|
3361
|
+
async function onboarding() {
|
|
3362
|
+
splashScreen();
|
|
3363
|
+
console.log(chalk3.yellow(" First run detected \u2014 let's set you up."));
|
|
3364
|
+
hr();
|
|
3365
|
+
console.log("");
|
|
3366
|
+
const config = loadConfig();
|
|
3367
|
+
const ownerName = await ask(chalk3.white(" Your name: "));
|
|
3368
|
+
if (!ownerName) {
|
|
3369
|
+
console.log(chalk3.red(" Name is required."));
|
|
3370
|
+
process.exit(1);
|
|
3371
|
+
}
|
|
3372
|
+
config.identity.owner = ownerName;
|
|
3373
|
+
const agentName = await ask(chalk3.white(` Agent name [${config.identity.name}]: `));
|
|
3374
|
+
if (agentName) config.identity.name = agentName;
|
|
3375
|
+
config.identity.creator = "Cosmic Stack";
|
|
3376
|
+
hr();
|
|
3377
|
+
console.log("");
|
|
3378
|
+
console.log(chalk3.white(" LLM Providers"));
|
|
3379
|
+
console.log(chalk3.dim(" At least one API key is required."));
|
|
3380
|
+
console.log("");
|
|
3381
|
+
const deepseekKey = await ask(chalk3.white(" DeepSeek API key: "));
|
|
3382
|
+
if (deepseekKey) {
|
|
3383
|
+
config.providers.deepseek.apiKey = deepseekKey;
|
|
3384
|
+
config.providers.default = "deepseek";
|
|
3385
|
+
}
|
|
3386
|
+
const openaiKey = await ask(chalk3.white(" OpenAI API key (Enter to skip): "));
|
|
3387
|
+
if (openaiKey) config.providers.openai.apiKey = openaiKey;
|
|
3388
|
+
const anthropicKey = await ask(chalk3.white(" Anthropic API key (Enter to skip): "));
|
|
3389
|
+
if (anthropicKey) config.providers.anthropic.apiKey = anthropicKey;
|
|
3390
|
+
if (!deepseekKey && !openaiKey && !anthropicKey) {
|
|
3391
|
+
console.log(chalk3.red("\n At least one LLM API key is required."));
|
|
3392
|
+
process.exit(1);
|
|
3393
|
+
}
|
|
3394
|
+
hr();
|
|
3395
|
+
console.log("");
|
|
3396
|
+
console.log(chalk3.white(" Telegram (optional)"));
|
|
3397
|
+
console.log(chalk3.dim(" Leave empty to skip. You can add it later."));
|
|
3398
|
+
console.log("");
|
|
3399
|
+
const telegramToken = await ask(chalk3.white(" Telegram Bot Token: "));
|
|
3400
|
+
if (telegramToken) {
|
|
3401
|
+
config.channels.telegram.botToken = telegramToken;
|
|
3402
|
+
config.channels.telegram.enabled = true;
|
|
3403
|
+
}
|
|
3404
|
+
hr();
|
|
3405
|
+
saveConfig(config);
|
|
3406
|
+
const home = getMercuryHome();
|
|
3407
|
+
console.log("");
|
|
3408
|
+
console.log(chalk3.green(` \u2713 Config saved to ${home}/mercury.yaml`));
|
|
3409
|
+
console.log(chalk3.green(` \u2713 Soul files seeded in ${home}/soul/`));
|
|
3410
|
+
console.log(chalk3.green(` \u2713 Memory stored in ${home}/memory/`));
|
|
3411
|
+
console.log(chalk3.green(` \u2713 Permissions seeded in ${home}/permissions.yaml`));
|
|
3412
|
+
console.log(chalk3.green(` \u2713 Skills directory ready in ${home}/skills/`));
|
|
3413
|
+
console.log("");
|
|
3414
|
+
console.log(chalk3.cyan(` ${config.identity.name} is ready. Run \`mercury start\` to begin.`));
|
|
3415
|
+
console.log(chalk3.dim(" mercury.cosmicstack.org"));
|
|
3416
|
+
console.log("");
|
|
3417
|
+
}
|
|
3418
|
+
async function runAgent() {
|
|
3419
|
+
let config = loadConfig();
|
|
3420
|
+
config = ensureCreatorField(config);
|
|
3421
|
+
const name = config.identity.name;
|
|
3422
|
+
banner();
|
|
3423
|
+
console.log(chalk3.white(` ${name} is waking up...`));
|
|
3424
|
+
console.log("");
|
|
3425
|
+
const tokenBudget = new TokenBudget(config);
|
|
3426
|
+
const providers = new ProviderRegistry(config);
|
|
3427
|
+
if (!providers.hasProviders()) {
|
|
3428
|
+
console.log(chalk3.red(" No LLM providers available. Run `mercury setup` to configure API keys."));
|
|
3429
|
+
process.exit(1);
|
|
3430
|
+
}
|
|
3431
|
+
const available = providers.listAvailable();
|
|
3432
|
+
console.log(chalk3.dim(` Providers: ${available.join(", ")}`));
|
|
3433
|
+
const skillLoader = new SkillLoader();
|
|
3434
|
+
const skills = skillLoader.discover();
|
|
3435
|
+
console.log(chalk3.dim(` Skills: ${skills.length > 0 ? skills.map((s) => s.name).join(", ") : "none installed"}`));
|
|
3436
|
+
const scheduler = new Scheduler(config);
|
|
3437
|
+
const identity = new Identity();
|
|
3438
|
+
const shortTerm = new ShortTermMemory(config);
|
|
3439
|
+
const longTerm = new LongTermMemory(config);
|
|
3440
|
+
const episodic = new EpisodicMemory(config);
|
|
3441
|
+
const channels = new ChannelRegistry(config);
|
|
3442
|
+
const capabilities = new CapabilityRegistry(skillLoader, scheduler, tokenBudget);
|
|
3443
|
+
capabilities.setSendFileHandler(async (filePath) => {
|
|
3444
|
+
const msg = channels.getActiveChannels().includes("telegram") ? channels.get("telegram") : channels.get("cli");
|
|
3445
|
+
if (msg) {
|
|
3446
|
+
await msg.sendFile(filePath);
|
|
3447
|
+
}
|
|
3448
|
+
});
|
|
3449
|
+
capabilities.registerAll();
|
|
3450
|
+
const agent = new Agent(
|
|
3451
|
+
config,
|
|
3452
|
+
providers,
|
|
3453
|
+
identity,
|
|
3454
|
+
shortTerm,
|
|
3455
|
+
longTerm,
|
|
3456
|
+
episodic,
|
|
3457
|
+
channels,
|
|
3458
|
+
tokenBudget,
|
|
3459
|
+
capabilities,
|
|
3460
|
+
scheduler
|
|
3461
|
+
);
|
|
3462
|
+
await agent.birth();
|
|
3463
|
+
await agent.wake();
|
|
3464
|
+
const cliChannel = channels.get("cli");
|
|
3465
|
+
if (cliChannel) {
|
|
3466
|
+
capabilities.permissions.onAsk(async (prompt) => {
|
|
3467
|
+
return cliChannel.askPermission(prompt);
|
|
3468
|
+
});
|
|
3469
|
+
}
|
|
3470
|
+
const activeCh = channels.getActiveChannels();
|
|
3471
|
+
const toolNames = capabilities.getToolNames();
|
|
3472
|
+
console.log(chalk3.dim(` Channels: ${activeCh.join(", ")}`));
|
|
3473
|
+
console.log(chalk3.dim(` Tools: ${toolNames.join(", ")}`));
|
|
3474
|
+
console.log(chalk3.dim(` Permissions: ${getMercuryHome()}/permissions.yaml`));
|
|
3475
|
+
console.log(chalk3.dim(` Schedules: ${getMercuryHome()}/schedules.yaml`));
|
|
3476
|
+
if (config.identity.creator) {
|
|
3477
|
+
console.log(chalk3.dim(` Creator: ${config.identity.creator}`));
|
|
3478
|
+
}
|
|
3479
|
+
hr();
|
|
3480
|
+
console.log("");
|
|
3481
|
+
console.log(chalk3.green(` ${name} is live. Type a message and press Enter.`));
|
|
3482
|
+
console.log(chalk3.dim(" Ctrl+C to exit."));
|
|
3483
|
+
console.log("");
|
|
3484
|
+
const shutdown = async () => {
|
|
3485
|
+
console.log("");
|
|
3486
|
+
console.log(chalk3.dim(` ${name} is shutting down...`));
|
|
3487
|
+
await agent.shutdown();
|
|
3488
|
+
process.exit(0);
|
|
3489
|
+
};
|
|
3490
|
+
process.on("SIGINT", shutdown);
|
|
3491
|
+
process.on("SIGTERM", shutdown);
|
|
3492
|
+
}
|
|
3493
|
+
var program = new Command();
|
|
3494
|
+
program.name("mercury").description("Mercury \u2014 an AI agent for personal tasks").version("0.1.0").option("-v, --verbose", "Show debug logs").action(async () => {
|
|
3495
|
+
if (!isSetupComplete()) {
|
|
3496
|
+
await onboarding();
|
|
3497
|
+
return;
|
|
3498
|
+
}
|
|
3499
|
+
await runAgent();
|
|
3500
|
+
});
|
|
3501
|
+
program.command("start").description("Start Mercury agent").option("-v, --verbose", "Show debug logs").action(async () => {
|
|
3502
|
+
if (!isSetupComplete()) {
|
|
3503
|
+
await onboarding();
|
|
3504
|
+
return;
|
|
3505
|
+
}
|
|
3506
|
+
await runAgent();
|
|
3507
|
+
});
|
|
3508
|
+
program.command("setup").description("Re-run the setup wizard").action(async () => {
|
|
3509
|
+
await onboarding();
|
|
3510
|
+
});
|
|
3511
|
+
program.command("status").description("Show current configuration").action(() => {
|
|
3512
|
+
const config = loadConfig();
|
|
3513
|
+
const home = getMercuryHome();
|
|
3514
|
+
const skillLoader = new SkillLoader();
|
|
3515
|
+
const skills = skillLoader.discover();
|
|
3516
|
+
banner();
|
|
3517
|
+
console.log(` Name: ${chalk3.cyan(config.identity.name)}`);
|
|
3518
|
+
console.log(` Owner: ${chalk3.white(config.identity.owner || "(not set)")}`);
|
|
3519
|
+
if (config.identity.creator) {
|
|
3520
|
+
console.log(` Creator: ${chalk3.white(config.identity.creator)}`);
|
|
3521
|
+
}
|
|
3522
|
+
console.log(` Provider: ${chalk3.white(config.providers.default)}`);
|
|
3523
|
+
console.log(` Telegram: ${config.channels.telegram.enabled ? chalk3.green("enabled") : chalk3.dim("disabled")}`);
|
|
3524
|
+
console.log(` Skills: ${skills.length > 0 ? chalk3.green(skills.map((s) => s.name).join(", ")) : chalk3.dim("none")}`);
|
|
3525
|
+
console.log(` Budget: ${chalk3.white(config.tokens.dailyBudget)} tokens/day`);
|
|
3526
|
+
console.log(` Setup: ${isSetupComplete() ? chalk3.green("complete") : chalk3.red("not done")}`);
|
|
3527
|
+
console.log(` Home: ${chalk3.dim(home)}`);
|
|
3528
|
+
console.log("");
|
|
3529
|
+
});
|
|
3530
|
+
program.parse();
|
|
3531
|
+
//# sourceMappingURL=index.js.map
|