gitclaw 0.3.1 → 0.4.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 +21 -0
- package/README.md +6 -2
- package/dist/composio/adapter.d.ts +26 -0
- package/dist/composio/adapter.js +92 -0
- package/dist/composio/client.d.ts +39 -0
- package/dist/composio/client.js +170 -0
- package/dist/composio/index.d.ts +2 -0
- package/dist/composio/index.js +2 -0
- package/dist/context.d.ts +20 -0
- package/dist/context.js +211 -0
- package/dist/exports.d.ts +2 -0
- package/dist/exports.js +1 -0
- package/dist/index.js +99 -7
- package/dist/learning/reinforcement.d.ts +11 -0
- package/dist/learning/reinforcement.js +91 -0
- package/dist/loader.js +34 -1
- package/dist/sdk.js +5 -1
- package/dist/skills.d.ts +5 -0
- package/dist/skills.js +58 -7
- package/dist/tools/capture-photo.d.ts +3 -0
- package/dist/tools/capture-photo.js +91 -0
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.js +12 -2
- package/dist/tools/read.js +4 -0
- package/dist/tools/shared.d.ts +20 -0
- package/dist/tools/shared.js +24 -0
- package/dist/tools/skill-learner.d.ts +3 -0
- package/dist/tools/skill-learner.js +358 -0
- package/dist/tools/task-tracker.d.ts +20 -0
- package/dist/tools/task-tracker.js +275 -0
- package/dist/tools/write.js +4 -0
- package/dist/voice/adapter.d.ts +97 -0
- package/dist/voice/adapter.js +30 -0
- package/dist/voice/chat-history.d.ts +8 -0
- package/dist/voice/chat-history.js +121 -0
- package/dist/voice/gemini-live.d.ts +20 -0
- package/dist/voice/gemini-live.js +279 -0
- package/dist/voice/index.d.ts +4 -0
- package/dist/voice/index.js +3 -0
- package/dist/voice/openai-realtime.d.ts +27 -0
- package/dist/voice/openai-realtime.js +291 -0
- package/dist/voice/server.d.ts +2 -0
- package/dist/voice/server.js +2319 -0
- package/dist/voice/ui.html +2556 -0
- package/package.json +21 -7
|
@@ -0,0 +1,2319 @@
|
|
|
1
|
+
import { createServer } from "http";
|
|
2
|
+
import { WebSocketServer, WebSocket as WS } from "ws";
|
|
3
|
+
import { query } from "../sdk.js";
|
|
4
|
+
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
|
5
|
+
import { execSync } from "child_process";
|
|
6
|
+
import { join, dirname, resolve, relative } from "path";
|
|
7
|
+
import { writeFile, readFile, mkdir, stat } from "fs/promises";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import { OpenAIRealtimeAdapter } from "./openai-realtime.js";
|
|
10
|
+
import { GeminiLiveAdapter } from "./gemini-live.js";
|
|
11
|
+
import { ComposioAdapter } from "../composio/index.js";
|
|
12
|
+
import { appendMessage, loadHistory, deleteHistory, summarizeHistory } from "./chat-history.js";
|
|
13
|
+
import { getVoiceContext, getAgentContext } from "../context.js";
|
|
14
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
15
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
16
|
+
// ── Background memory saver ────────────────────────────────────────────
|
|
17
|
+
// Patterns that indicate the user is sharing personal info worth saving.
|
|
18
|
+
// This runs server-side so we don't depend on the voice LLM deciding to save.
|
|
19
|
+
const MEMORY_PATTERNS = [
|
|
20
|
+
/\bi (?:like|love|enjoy|prefer|hate|dislike)\b/i,
|
|
21
|
+
/\bmy (?:name|dog|cat|favorite|fav|hobby|job|car|team)\b/i,
|
|
22
|
+
/\bi(?:'m| am) (?:a |into |from |working on )/i,
|
|
23
|
+
/\bi(?:'m| am) \w+$/i, // "I am Shreyas", "I'm Zeus"
|
|
24
|
+
/\bmy name is\b/i, // "my name is ..."
|
|
25
|
+
/\bcall me\b/i,
|
|
26
|
+
/\bremember (?:that|this)\b/i,
|
|
27
|
+
/\bi (?:play|watch|drive|use|work with|listen to)\b/i,
|
|
28
|
+
/\bi(?:'m| am) \d+/i, // "I'm 25", age
|
|
29
|
+
/\bi (?:live|grew up|was born) (?:in|at|near)\b/i, // location info
|
|
30
|
+
/\bpeople call me\b/i,
|
|
31
|
+
];
|
|
32
|
+
function isMemoryWorthy(text) {
|
|
33
|
+
return MEMORY_PATTERNS.some((p) => p.test(text));
|
|
34
|
+
}
|
|
35
|
+
// ── Moment detection for photo capture ─────────────────────────────────
|
|
36
|
+
const MOMENT_PATTERNS = [
|
|
37
|
+
/\bhaha\b/i,
|
|
38
|
+
/\blol\b/i,
|
|
39
|
+
/\blmao\b/i,
|
|
40
|
+
/\blove it\b/i,
|
|
41
|
+
/\bthat'?s amazing\b/i,
|
|
42
|
+
/\bso happy\b/i,
|
|
43
|
+
/\bbest day\b/i,
|
|
44
|
+
/\bwe did it\b/i,
|
|
45
|
+
/\bnailed it\b/i,
|
|
46
|
+
/\blet'?s go\b/i,
|
|
47
|
+
/\bhell yeah\b/i,
|
|
48
|
+
/\bawesome\b/i,
|
|
49
|
+
/\bthank you so much\b/i,
|
|
50
|
+
/\bfirst time\b/i,
|
|
51
|
+
/\bmilestone\b/i,
|
|
52
|
+
/\bcelebrat/i,
|
|
53
|
+
/\bincredible\b/i,
|
|
54
|
+
];
|
|
55
|
+
function isMomentWorthy(text) {
|
|
56
|
+
return MOMENT_PATTERNS.some((p) => p.test(text));
|
|
57
|
+
}
|
|
58
|
+
let vitalsTokenCount = 0;
|
|
59
|
+
const PHOTOS_DIR = "memory/photos";
|
|
60
|
+
const INDEX_FILE = "memory/photos/INDEX.md";
|
|
61
|
+
const LATEST_FRAME_FILE = "memory/.latest-frame.jpg";
|
|
62
|
+
const LATEST_SCREEN_FILE = "memory/.latest-screen.jpg";
|
|
63
|
+
function slugify(text) {
|
|
64
|
+
return text
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
67
|
+
.replace(/^-|-$/g, "")
|
|
68
|
+
.slice(0, 40);
|
|
69
|
+
}
|
|
70
|
+
const MOOD_SIGNALS = [
|
|
71
|
+
{ mood: "happy", patterns: [/\bhaha\b/i, /\blol\b/i, /\blove it\b/i, /\bthat'?s great\b/i, /\bnice\b/i, /\bawesome\b/i, /\bamazing\b/i] },
|
|
72
|
+
{ mood: "frustrated", patterns: [/\bugh\b/i, /\bwhat the\b/i, /\bdamn\b/i, /\bstill broken\b/i, /\bnot working\b/i, /\bwhy (?:is|does|won'?t)\b/i, /\bfuck\b/i] },
|
|
73
|
+
{ mood: "curious", patterns: [/\bhow (?:do|does|can|would)\b/i, /\bwhat (?:is|are|if)\b/i, /\bwhy (?:do|does|is)\b/i, /\bexplain\b/i, /\btell me about\b/i] },
|
|
74
|
+
{ mood: "excited", patterns: [/\blet'?s go\b/i, /\bhell yeah\b/i, /\bwe did it\b/i, /\bnailed it\b/i, /\byes!\b/i, /\bfinally\b/i] },
|
|
75
|
+
{ mood: "calm", patterns: [/\bokay\b/i, /\bsure\b/i, /\bcool\b/i, /\bsounds good\b/i, /\bgot it\b/i] },
|
|
76
|
+
];
|
|
77
|
+
function detectMood(text) {
|
|
78
|
+
for (const { mood, patterns } of MOOD_SIGNALS) {
|
|
79
|
+
if (patterns.some((p) => p.test(text)))
|
|
80
|
+
return mood;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
function dominantMood(counts) {
|
|
85
|
+
let best = "calm";
|
|
86
|
+
let max = 0;
|
|
87
|
+
for (const [mood, count] of Object.entries(counts)) {
|
|
88
|
+
if (count > max) {
|
|
89
|
+
max = count;
|
|
90
|
+
best = mood;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return best;
|
|
94
|
+
}
|
|
95
|
+
async function saveMoodEntry(agentDir, counts, messageCount) {
|
|
96
|
+
if (messageCount < 3)
|
|
97
|
+
return; // Skip trivially short sessions
|
|
98
|
+
const now = new Date();
|
|
99
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
100
|
+
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
101
|
+
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
102
|
+
const mood = dominantMood(counts);
|
|
103
|
+
const moodPath = join(agentDir, "memory", "mood.md");
|
|
104
|
+
let existing = "";
|
|
105
|
+
try {
|
|
106
|
+
existing = await readFile(moodPath, "utf-8");
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
existing = "# Mood Log\n\n";
|
|
110
|
+
}
|
|
111
|
+
const detail = Object.entries(counts).filter(([, v]) => v > 0).map(([k, v]) => `${k}:${v}`).join(" ");
|
|
112
|
+
existing += `- ${date} ${time} — **${mood}** (${detail}) [${messageCount} msgs]\n`;
|
|
113
|
+
await mkdir(join(agentDir, "memory"), { recursive: true });
|
|
114
|
+
await writeFile(moodPath, existing, "utf-8");
|
|
115
|
+
try {
|
|
116
|
+
execSync(`git add "memory/mood.md" && git commit -m "Mood: ${mood} session (${date} ${time})"`, {
|
|
117
|
+
cwd: agentDir, stdio: "pipe",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch { /* file saved even if commit fails */ }
|
|
121
|
+
}
|
|
122
|
+
// ── Session journaling ─────────────────────────────────────────────────
|
|
123
|
+
async function writeJournalEntry(agentDir, branch, moodCounts, model, env) {
|
|
124
|
+
const messages = loadHistory(agentDir, branch);
|
|
125
|
+
if (messages.length < 5)
|
|
126
|
+
return;
|
|
127
|
+
const lines = [];
|
|
128
|
+
for (const msg of messages.slice(-50)) {
|
|
129
|
+
if (msg.type === "transcript")
|
|
130
|
+
lines.push(`${msg.role}: ${msg.text}`);
|
|
131
|
+
else if (msg.type === "agent_done")
|
|
132
|
+
lines.push(`agent: ${msg.result.slice(0, 200)}`);
|
|
133
|
+
}
|
|
134
|
+
if (lines.length < 3)
|
|
135
|
+
return;
|
|
136
|
+
let transcript = lines.join("\n");
|
|
137
|
+
if (transcript.length > 3000)
|
|
138
|
+
transcript = transcript.slice(-3000);
|
|
139
|
+
const mood = dominantMood(moodCounts);
|
|
140
|
+
const prompt = `Write a brief journal entry (3-5 sentences) reflecting on this conversation session. Mood was mostly: ${mood}. Note what was accomplished, any unfinished threads, and how the user seemed. Write in first person as the agent. Be genuine, not corporate.\n\nTranscript:\n${transcript}`;
|
|
141
|
+
try {
|
|
142
|
+
const result = query({
|
|
143
|
+
prompt,
|
|
144
|
+
dir: agentDir,
|
|
145
|
+
model,
|
|
146
|
+
env,
|
|
147
|
+
maxTurns: 1,
|
|
148
|
+
replaceBuiltinTools: true,
|
|
149
|
+
tools: [],
|
|
150
|
+
systemPrompt: "You are journaling about your day as an AI assistant. Write naturally and briefly.",
|
|
151
|
+
});
|
|
152
|
+
let entry = "";
|
|
153
|
+
for await (const msg of result) {
|
|
154
|
+
if (msg.type === "assistant" && msg.content)
|
|
155
|
+
entry += msg.content;
|
|
156
|
+
}
|
|
157
|
+
entry = entry.trim();
|
|
158
|
+
if (!entry)
|
|
159
|
+
return;
|
|
160
|
+
const now = new Date();
|
|
161
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
162
|
+
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
163
|
+
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
164
|
+
const journalDir = join(agentDir, "memory", "journal");
|
|
165
|
+
await mkdir(journalDir, { recursive: true });
|
|
166
|
+
const journalPath = join(journalDir, `${date}.md`);
|
|
167
|
+
let existing = "";
|
|
168
|
+
try {
|
|
169
|
+
existing = await readFile(journalPath, "utf-8");
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
existing = `# Journal — ${date}\n\n`;
|
|
173
|
+
}
|
|
174
|
+
existing += `### ${time} (${mood})\n${entry}\n\n`;
|
|
175
|
+
await writeFile(journalPath, existing, "utf-8");
|
|
176
|
+
try {
|
|
177
|
+
execSync(`git add "memory/journal/${date}.md" && git commit -m "Journal: ${date} ${time} session reflection"`, {
|
|
178
|
+
cwd: agentDir, stdio: "pipe",
|
|
179
|
+
});
|
|
180
|
+
console.error(dim(`[voice] Journal entry written for ${date} ${time}`));
|
|
181
|
+
}
|
|
182
|
+
catch { /* saved even if commit fails */ }
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
console.error(dim(`[voice] Journal write failed: ${err.message}`));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function capturePhoto(agentDir, reason, frameData) {
|
|
189
|
+
// If no frame passed directly, read from temp file
|
|
190
|
+
let frame = frameData;
|
|
191
|
+
if (!frame) {
|
|
192
|
+
const framePath = join(agentDir, LATEST_FRAME_FILE);
|
|
193
|
+
try {
|
|
194
|
+
const frameStat = await stat(framePath);
|
|
195
|
+
if (Date.now() - frameStat.mtimeMs > 5000) {
|
|
196
|
+
console.error(dim("[voice] No recent camera frame, skipping photo capture"));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
frame = await readFile(framePath);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
console.error(dim("[voice] No camera frame available, skipping photo capture"));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const now = new Date();
|
|
207
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
208
|
+
const datePart = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
209
|
+
const timePart = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
210
|
+
const slug = slugify(reason);
|
|
211
|
+
const filename = `${datePart}_${timePart}_${slug}.jpg`;
|
|
212
|
+
const photoRelPath = `${PHOTOS_DIR}/${filename}`;
|
|
213
|
+
const photoAbsPath = join(agentDir, photoRelPath);
|
|
214
|
+
await mkdir(join(agentDir, PHOTOS_DIR), { recursive: true });
|
|
215
|
+
await writeFile(photoAbsPath, frame);
|
|
216
|
+
// Update INDEX.md
|
|
217
|
+
const indexPath = join(agentDir, INDEX_FILE);
|
|
218
|
+
let indexContent = "";
|
|
219
|
+
try {
|
|
220
|
+
indexContent = await readFile(indexPath, "utf-8");
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
indexContent = "# Memorable Moments\n\nPhotos captured during happy and memorable moments.\n\n";
|
|
224
|
+
}
|
|
225
|
+
const entry = `- **${datePart} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}** — ${reason} → [\`${filename}\`](${filename})\n`;
|
|
226
|
+
indexContent += entry;
|
|
227
|
+
await writeFile(indexPath, indexContent, "utf-8");
|
|
228
|
+
// Git add + commit
|
|
229
|
+
const commitMsg = `Capture moment: ${reason}`;
|
|
230
|
+
try {
|
|
231
|
+
execSync(`git add "${photoRelPath}" "${INDEX_FILE}" && git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, {
|
|
232
|
+
cwd: agentDir,
|
|
233
|
+
stdio: "pipe",
|
|
234
|
+
});
|
|
235
|
+
console.error(dim(`[voice] Photo captured: ${filename}`));
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
console.error(dim(`[voice] Photo saved but git commit failed: ${err.stderr?.toString().trim() || "unknown"}`));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function saveMemoryInBackground(text, agentDir, model, env, onStart, onComplete) {
|
|
242
|
+
const prompt = `The user just said: "${text}"\n\nSave any personal information, preferences, or facts about the user to memory. Use the memory tool to write or update a memory file. Use a descriptive commit message like "Remember: user likes mustangs" or "Save preference: favorite game is GTA 5". Be concise. If there's nothing meaningful to save, do nothing.`;
|
|
243
|
+
console.error(dim(`[voice] Background memory save triggered for: "${text.slice(0, 60)}..."`));
|
|
244
|
+
if (onStart)
|
|
245
|
+
onStart();
|
|
246
|
+
// Fire and forget — don't block the voice conversation
|
|
247
|
+
(async () => {
|
|
248
|
+
try {
|
|
249
|
+
const result = query({
|
|
250
|
+
prompt,
|
|
251
|
+
dir: agentDir,
|
|
252
|
+
model,
|
|
253
|
+
env,
|
|
254
|
+
maxTurns: 3,
|
|
255
|
+
});
|
|
256
|
+
// Drain the iterator to completion
|
|
257
|
+
for await (const msg of result) {
|
|
258
|
+
if (msg.type === "tool_use") {
|
|
259
|
+
console.error(dim(`[voice/memory] Tool: ${msg.toolName}`));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
console.error(dim("[voice/memory] Background save complete"));
|
|
263
|
+
if (onComplete)
|
|
264
|
+
onComplete();
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
console.error(dim(`[voice/memory] Background save failed: ${err.message}`));
|
|
268
|
+
if (onComplete)
|
|
269
|
+
onComplete();
|
|
270
|
+
}
|
|
271
|
+
})();
|
|
272
|
+
}
|
|
273
|
+
/** Load .env file into process.env (won't overwrite existing vars) */
|
|
274
|
+
function loadEnvFile(dir) {
|
|
275
|
+
const envPath = join(dir, ".env");
|
|
276
|
+
try {
|
|
277
|
+
const content = readFileSync(envPath, "utf-8");
|
|
278
|
+
for (const line of content.split("\n")) {
|
|
279
|
+
const trimmed = line.trim();
|
|
280
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
281
|
+
continue;
|
|
282
|
+
const eq = trimmed.indexOf("=");
|
|
283
|
+
if (eq < 1)
|
|
284
|
+
continue;
|
|
285
|
+
const key = trimmed.slice(0, eq).trim();
|
|
286
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
287
|
+
// Strip surrounding quotes
|
|
288
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
289
|
+
val = val.slice(1, -1);
|
|
290
|
+
}
|
|
291
|
+
if (!process.env[key]) {
|
|
292
|
+
process.env[key] = val;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
// No .env file — that's fine
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function createAdapter(opts) {
|
|
301
|
+
switch (opts.adapter) {
|
|
302
|
+
case "openai-realtime":
|
|
303
|
+
return new OpenAIRealtimeAdapter(opts.adapterConfig);
|
|
304
|
+
case "gemini-live":
|
|
305
|
+
return new GeminiLiveAdapter(opts.adapterConfig);
|
|
306
|
+
default:
|
|
307
|
+
throw new Error(`Unknown adapter: ${opts.adapter}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function loadUIHtml() {
|
|
311
|
+
// Try dist/voice/ui.html first (built), then src/voice/ui.html (dev)
|
|
312
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
313
|
+
const candidates = [
|
|
314
|
+
join(thisDir, "ui.html"),
|
|
315
|
+
join(thisDir, "..", "..", "src", "voice", "ui.html"),
|
|
316
|
+
];
|
|
317
|
+
for (const path of candidates) {
|
|
318
|
+
try {
|
|
319
|
+
return readFileSync(path, "utf-8");
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// try next
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return "<html><body><h1>UI not found</h1><p>Run: npm run build</p></body></html>";
|
|
326
|
+
}
|
|
327
|
+
export async function startVoiceServer(opts) {
|
|
328
|
+
// Load .env from agent directory (won't overwrite existing env vars)
|
|
329
|
+
loadEnvFile(resolve(opts.agentDir));
|
|
330
|
+
const port = opts.port || 3333;
|
|
331
|
+
let agentName = "GitClaw";
|
|
332
|
+
try {
|
|
333
|
+
const yamlRaw = readFileSync(join(resolve(opts.agentDir), "agent.yaml"), "utf-8");
|
|
334
|
+
const m = yamlRaw.match(/^name:\s*(.+)$/m);
|
|
335
|
+
if (m)
|
|
336
|
+
agentName = m[1].trim();
|
|
337
|
+
}
|
|
338
|
+
catch { /* fallback to default */ }
|
|
339
|
+
const uiHtml = loadUIHtml()
|
|
340
|
+
.replace(/\{\{AGENT_NAME\}\}/g, agentName)
|
|
341
|
+
.replace(/\{\{HAS_COMPOSIO\}\}/g, process.env.COMPOSIO_API_KEY ? "true" : "false");
|
|
342
|
+
// Current date/time context injected into every query
|
|
343
|
+
function getCurrentDateTimeContext() {
|
|
344
|
+
const now = new Date();
|
|
345
|
+
const day = now.toLocaleDateString("en-US", { weekday: "long" });
|
|
346
|
+
const date = now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
|
|
347
|
+
const time = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
|
348
|
+
return `Current date and time: ${day}, ${date}, ${time}.`;
|
|
349
|
+
}
|
|
350
|
+
// Shared helper: fetch Composio tools + build prompt suffix for any channel
|
|
351
|
+
async function getComposioContext(prompt) {
|
|
352
|
+
let composioTools = [];
|
|
353
|
+
let connectedSlugs = [];
|
|
354
|
+
if (composioAdapter) {
|
|
355
|
+
try {
|
|
356
|
+
connectedSlugs = await composioAdapter.getConnectedToolkitSlugs();
|
|
357
|
+
console.error(`[voice] Connected toolkit slugs: [${connectedSlugs.join(", ")}]`);
|
|
358
|
+
if (connectedSlugs.length > 0) {
|
|
359
|
+
composioTools = await composioAdapter.getToolsForQuery(prompt);
|
|
360
|
+
console.error(`[voice] Semantic search returned ${composioTools.length} tools`);
|
|
361
|
+
if (composioTools.length === 0) {
|
|
362
|
+
const allTools = await composioAdapter.getTools();
|
|
363
|
+
composioTools = allTools.slice(0, 15);
|
|
364
|
+
console.error(`[voice] Fallback capped to ${composioTools.length}/${allTools.length} tools`);
|
|
365
|
+
}
|
|
366
|
+
console.error(`[voice] Composio: ${composioTools.length} tools: ${composioTools.map(t => t.name).join(", ")}`);
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
console.error(`[voice] No connected toolkits found for user`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
console.error(`[voice] Composio tool fetch FAILED: ${err.message}\n${err.stack}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
console.error(`[voice] composioAdapter is NULL — COMPOSIO_API_KEY not set?`);
|
|
378
|
+
}
|
|
379
|
+
let promptSuffix;
|
|
380
|
+
if (composioAdapter) {
|
|
381
|
+
const parts = [
|
|
382
|
+
`You have access to external services via Composio integration (Gmail, Google Calendar, GitHub, Slack, and many more).`,
|
|
383
|
+
`You CAN perform real actions — send emails, read emails, check calendars, create events, manage repos, etc.`,
|
|
384
|
+
`NEVER tell the user you "can't access" or "don't have access to" external services. Always attempt to use the available Composio tools (prefixed "composio_") first.`,
|
|
385
|
+
`When the user asks to send an email, use the composio SEND_EMAIL tool directly — do NOT create a draft unless they explicitly ask for a draft.`,
|
|
386
|
+
`When the user asks about their calendar, use the composio calendar tools to fetch real events.`,
|
|
387
|
+
`Prefer Composio tools over CLI commands for any external service interaction.`,
|
|
388
|
+
];
|
|
389
|
+
if (connectedSlugs.length > 0) {
|
|
390
|
+
const services = connectedSlugs.map((s) => s.replace(/_/g, " ")).join(", ");
|
|
391
|
+
parts.unshift(`Currently connected services: ${services}.`);
|
|
392
|
+
}
|
|
393
|
+
promptSuffix = parts.join(" ");
|
|
394
|
+
}
|
|
395
|
+
return { tools: composioTools, promptSuffix };
|
|
396
|
+
}
|
|
397
|
+
// Creates a per-connection tool handler that can stream events to the browser
|
|
398
|
+
function createToolHandler(sendToBrowser) {
|
|
399
|
+
return async (prompt) => {
|
|
400
|
+
const { tools: composioTools, promptSuffix: composioPromptSuffix } = await getComposioContext(prompt);
|
|
401
|
+
let systemPromptSuffix = getCurrentDateTimeContext();
|
|
402
|
+
systemPromptSuffix += "\nIMPORTANT: All files you create (PDFs, images, documents, code output, etc.) MUST be written to the workspace/ directory. Never write to the project root or other locations.";
|
|
403
|
+
if (whatsappSock && whatsappConnected) {
|
|
404
|
+
systemPromptSuffix += "\nYou can send WhatsApp messages using the send_whatsapp_message tool and set up auto-response triggers using create_trigger.";
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
systemPromptSuffix += "\nYou can set up auto-response triggers using create_trigger for when messaging platforms are connected.";
|
|
408
|
+
}
|
|
409
|
+
if (composioPromptSuffix)
|
|
410
|
+
systemPromptSuffix += "\n\n" + composioPromptSuffix;
|
|
411
|
+
// Inject shared context (memory + conversation summary)
|
|
412
|
+
const agentContext = await getAgentContext(opts.agentDir, activeBranch);
|
|
413
|
+
if (agentContext) {
|
|
414
|
+
systemPromptSuffix = (systemPromptSuffix || "") + "\n\n" + agentContext;
|
|
415
|
+
}
|
|
416
|
+
const uiTools = [
|
|
417
|
+
...createTriggerTools(opts.agentDir),
|
|
418
|
+
...(whatsappSock && whatsappConnected ? createWhatsAppTools(whatsappSock, opts.agentDir) : []),
|
|
419
|
+
...composioTools,
|
|
420
|
+
];
|
|
421
|
+
const result = query({
|
|
422
|
+
prompt,
|
|
423
|
+
dir: opts.agentDir,
|
|
424
|
+
model: opts.model,
|
|
425
|
+
env: opts.env,
|
|
426
|
+
...(uiTools.length ? { tools: uiTools } : {}),
|
|
427
|
+
...(systemPromptSuffix ? { systemPromptSuffix } : {}),
|
|
428
|
+
});
|
|
429
|
+
let text = "";
|
|
430
|
+
const toolResults = [];
|
|
431
|
+
const errors = [];
|
|
432
|
+
for await (const msg of result) {
|
|
433
|
+
if (msg.type === "assistant" && msg.content) {
|
|
434
|
+
text += msg.content;
|
|
435
|
+
vitalsTokenCount += Math.ceil(msg.content.length / 4);
|
|
436
|
+
}
|
|
437
|
+
else if (msg.type === "tool_use") {
|
|
438
|
+
sendToBrowser({ type: "tool_call", toolName: msg.toolName, args: msg.args });
|
|
439
|
+
console.log(dim(`[voice] Tool call: ${msg.toolName}(${JSON.stringify(msg.args).slice(0, 80)})`));
|
|
440
|
+
}
|
|
441
|
+
else if (msg.type === "tool_result") {
|
|
442
|
+
sendToBrowser({ type: "tool_result", toolName: msg.toolName, content: msg.content, isError: msg.isError });
|
|
443
|
+
if (msg.content) {
|
|
444
|
+
toolResults.push(msg.content);
|
|
445
|
+
vitalsTokenCount += Math.ceil(msg.content.length / 4);
|
|
446
|
+
}
|
|
447
|
+
console.log(dim(`[voice] Tool ${msg.toolName}: ${msg.content.slice(0, 100)}${msg.content.length > 100 ? "..." : ""}`));
|
|
448
|
+
}
|
|
449
|
+
else if (msg.type === "system" && msg.subtype === "error") {
|
|
450
|
+
errors.push(msg.content);
|
|
451
|
+
console.error(dim(`[voice] Agent error: ${msg.content}`));
|
|
452
|
+
}
|
|
453
|
+
else if (msg.type === "delta" && msg.deltaType === "thinking") {
|
|
454
|
+
sendToBrowser({ type: "agent_thinking", text: msg.content });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (text)
|
|
458
|
+
return text;
|
|
459
|
+
if (errors.length > 0)
|
|
460
|
+
return `Error: ${errors.join("; ")}`;
|
|
461
|
+
if (toolResults.length > 0)
|
|
462
|
+
return toolResults.join("\n");
|
|
463
|
+
return "(no response)";
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
// ── File API helpers ────────────────────────────────────────────────
|
|
467
|
+
const HIDDEN_DIRS = new Set([".git", "node_modules", ".gitagent", "dist", ".next", "__pycache__", ".venv"]);
|
|
468
|
+
const agentRoot = resolve(opts.agentDir);
|
|
469
|
+
let activeBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: agentRoot, encoding: "utf-8" }).trim();
|
|
470
|
+
const pendingShutdownWork = [];
|
|
471
|
+
// ── Composio integration (optional) ────────────────────────────────
|
|
472
|
+
let composioAdapter = null;
|
|
473
|
+
if (process.env.COMPOSIO_API_KEY) {
|
|
474
|
+
composioAdapter = new ComposioAdapter({
|
|
475
|
+
apiKey: process.env.COMPOSIO_API_KEY,
|
|
476
|
+
userId: process.env.COMPOSIO_USER_ID || "default",
|
|
477
|
+
});
|
|
478
|
+
console.log(dim("[voice] Composio integration enabled"));
|
|
479
|
+
}
|
|
480
|
+
// ── Telegram bot state ──────────────────────────────────────────────
|
|
481
|
+
let telegramToken = process.env.TELEGRAM_BOT_TOKEN || "";
|
|
482
|
+
let telegramBotInfo = null;
|
|
483
|
+
let telegramPolling = false;
|
|
484
|
+
let telegramPollTimer = null;
|
|
485
|
+
let telegramOffset = 0;
|
|
486
|
+
// Allowed Telegram usernames — comma-separated in .env, empty = allow all
|
|
487
|
+
let telegramAllowedUsers = new Set((process.env.TELEGRAM_ALLOWED_USERS || "")
|
|
488
|
+
.split(",")
|
|
489
|
+
.map(s => s.trim().toLowerCase().replace(/^@/, ""))
|
|
490
|
+
.filter(Boolean));
|
|
491
|
+
function stopTelegramPolling() {
|
|
492
|
+
telegramPolling = false;
|
|
493
|
+
if (telegramPollTimer) {
|
|
494
|
+
clearTimeout(telegramPollTimer);
|
|
495
|
+
telegramPollTimer = null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/** Broadcast a message to all connected browser WebSocket clients */
|
|
499
|
+
function broadcastToBrowsers(msg) {
|
|
500
|
+
const payload = JSON.stringify(msg);
|
|
501
|
+
for (const client of wss.clients) {
|
|
502
|
+
if (client.readyState === 1)
|
|
503
|
+
client.send(payload);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async function downloadTelegramFile(fileId, agentDir) {
|
|
507
|
+
try {
|
|
508
|
+
const fRes = await fetch(`https://api.telegram.org/bot${telegramToken}/getFile?file_id=${fileId}`);
|
|
509
|
+
const fData = await fRes.json();
|
|
510
|
+
if (!fData.ok)
|
|
511
|
+
return null;
|
|
512
|
+
const filePath = fData.result.file_path;
|
|
513
|
+
const ext = filePath.split(".").pop() || "jpg";
|
|
514
|
+
const name = `telegram_${Date.now()}.${ext}`;
|
|
515
|
+
const dlUrl = `https://api.telegram.org/file/bot${telegramToken}/${filePath}`;
|
|
516
|
+
const dlRes = await fetch(dlUrl);
|
|
517
|
+
const buffer = Buffer.from(await dlRes.arrayBuffer());
|
|
518
|
+
const wsDir = join(agentDir, "workspace");
|
|
519
|
+
mkdirSync(wsDir, { recursive: true });
|
|
520
|
+
const savePath = join(wsDir, name);
|
|
521
|
+
writeFileSync(savePath, buffer);
|
|
522
|
+
return { path: `workspace/${name}`, name };
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/** Collect all files recursively under a dir with their mtimes */
|
|
529
|
+
function snapshotFiles(dir, base = "") {
|
|
530
|
+
const result = new Map();
|
|
531
|
+
try {
|
|
532
|
+
for (const name of readdirSync(dir)) {
|
|
533
|
+
if (name.startsWith(".") || name === "node_modules" || name === "dist")
|
|
534
|
+
continue;
|
|
535
|
+
const full = join(dir, name);
|
|
536
|
+
const rel = base ? `${base}/${name}` : name;
|
|
537
|
+
try {
|
|
538
|
+
const st = statSync(full);
|
|
539
|
+
if (st.isDirectory()) {
|
|
540
|
+
for (const [k, v] of snapshotFiles(full, rel))
|
|
541
|
+
result.set(k, v);
|
|
542
|
+
}
|
|
543
|
+
else if (st.isFile()) {
|
|
544
|
+
result.set(rel, st.mtimeMs);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
catch { /* skip */ }
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
catch { /* skip */ }
|
|
551
|
+
return result;
|
|
552
|
+
}
|
|
553
|
+
/** Find new or modified files by comparing snapshots */
|
|
554
|
+
function diffSnapshots(before, after) {
|
|
555
|
+
const changed = [];
|
|
556
|
+
for (const [path, mtime] of after) {
|
|
557
|
+
if (!before.has(path) || before.get(path) < mtime)
|
|
558
|
+
changed.push(path);
|
|
559
|
+
}
|
|
560
|
+
return changed;
|
|
561
|
+
}
|
|
562
|
+
const SENDABLE_EXTS = new Set([
|
|
563
|
+
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "csv", "txt", "rtf",
|
|
564
|
+
"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp",
|
|
565
|
+
"zip", "tar", "gz", "json", "xml", "html", "css", "js", "ts", "py", "md",
|
|
566
|
+
"mp3", "mp4", "wav", "ogg", "webm",
|
|
567
|
+
]);
|
|
568
|
+
async function sendTelegramFile(chatId, filePath, agentDir, caption) {
|
|
569
|
+
const abs = join(agentDir, filePath);
|
|
570
|
+
if (!existsSync(abs))
|
|
571
|
+
return;
|
|
572
|
+
const st = statSync(abs);
|
|
573
|
+
if (st.size > 50 * 1024 * 1024)
|
|
574
|
+
return; // Telegram 50MB limit
|
|
575
|
+
const ext = filePath.split(".").pop()?.toLowerCase() || "";
|
|
576
|
+
const isImage = /^(png|jpg|jpeg|gif|webp|bmp)$/.test(ext);
|
|
577
|
+
const formBoundary = `----FormBoundary${Date.now()}`;
|
|
578
|
+
const fileData = readFileSync(abs);
|
|
579
|
+
const fileName = filePath.split("/").pop() || "file";
|
|
580
|
+
// Build multipart form
|
|
581
|
+
const fieldName = isImage ? "photo" : "document";
|
|
582
|
+
const endpoint = isImage ? "sendPhoto" : "sendDocument";
|
|
583
|
+
const parts = [];
|
|
584
|
+
const nl = Buffer.from("\r\n");
|
|
585
|
+
// chat_id field
|
|
586
|
+
parts.push(Buffer.from(`--${formBoundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}`));
|
|
587
|
+
parts.push(nl);
|
|
588
|
+
// caption field
|
|
589
|
+
if (caption) {
|
|
590
|
+
const cap = caption.length > 1024 ? caption.slice(0, 1021) + "..." : caption;
|
|
591
|
+
parts.push(Buffer.from(`--${formBoundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\n${cap}`));
|
|
592
|
+
parts.push(nl);
|
|
593
|
+
}
|
|
594
|
+
// file field
|
|
595
|
+
const mimeMap = {
|
|
596
|
+
pdf: "application/pdf", doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
597
|
+
xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
598
|
+
ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
599
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
|
|
600
|
+
zip: "application/zip", csv: "text/csv", txt: "text/plain", json: "application/json", md: "text/markdown",
|
|
601
|
+
};
|
|
602
|
+
const mime = mimeMap[ext] || "application/octet-stream";
|
|
603
|
+
parts.push(Buffer.from(`--${formBoundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${fileName}"\r\nContent-Type: ${mime}\r\n\r\n`));
|
|
604
|
+
parts.push(fileData);
|
|
605
|
+
parts.push(nl);
|
|
606
|
+
parts.push(Buffer.from(`--${formBoundary}--\r\n`));
|
|
607
|
+
const body = Buffer.concat(parts);
|
|
608
|
+
try {
|
|
609
|
+
const resp = await fetch(`https://api.telegram.org/bot${telegramToken}/${endpoint}`, {
|
|
610
|
+
method: "POST",
|
|
611
|
+
headers: { "Content-Type": `multipart/form-data; boundary=${formBoundary}` },
|
|
612
|
+
body,
|
|
613
|
+
});
|
|
614
|
+
const rd = await resp.json();
|
|
615
|
+
if (rd.ok) {
|
|
616
|
+
console.log(dim(`[telegram] Sent file: ${fileName}`));
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
console.error(dim(`[telegram] Failed to send file ${fileName}: ${rd.description}`));
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
console.error(dim(`[telegram] File send error: ${err.message}`));
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function startTelegramPolling(agentDir, serverOpts) {
|
|
627
|
+
if (telegramPolling)
|
|
628
|
+
return;
|
|
629
|
+
telegramPolling = true;
|
|
630
|
+
console.log(dim("[voice] Telegram polling started"));
|
|
631
|
+
async function poll() {
|
|
632
|
+
if (!telegramPolling)
|
|
633
|
+
return;
|
|
634
|
+
try {
|
|
635
|
+
const res = await fetch(`https://api.telegram.org/bot${telegramToken}/getUpdates?offset=${telegramOffset}&timeout=30&allowed_updates=["message"]`);
|
|
636
|
+
const data = await res.json();
|
|
637
|
+
if (data.ok && data.result) {
|
|
638
|
+
for (const update of data.result) {
|
|
639
|
+
telegramOffset = update.update_id + 1;
|
|
640
|
+
const msg = update.message;
|
|
641
|
+
if (!msg)
|
|
642
|
+
continue;
|
|
643
|
+
const chatId = msg.chat.id;
|
|
644
|
+
const fromName = msg.from?.first_name || "User";
|
|
645
|
+
const fromUsername = (msg.from?.username || "").toLowerCase();
|
|
646
|
+
// Security: reject messages from unauthorized users
|
|
647
|
+
// Empty = block all, * = allow all, otherwise check username list
|
|
648
|
+
if (!telegramAllowedUsers.has("*")) {
|
|
649
|
+
if (telegramAllowedUsers.size === 0 || !telegramAllowedUsers.has(fromUsername)) {
|
|
650
|
+
console.log(dim(`[telegram] Blocked message from unauthorized user: @${fromUsername || "(no username)"} (${fromName})`));
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
let userText = msg.text || msg.caption || "";
|
|
655
|
+
let imageContext = "";
|
|
656
|
+
// Handle photo messages
|
|
657
|
+
if (msg.photo && msg.photo.length > 0) {
|
|
658
|
+
const largest = msg.photo[msg.photo.length - 1];
|
|
659
|
+
const dl = await downloadTelegramFile(largest.file_id, agentDir);
|
|
660
|
+
if (dl) {
|
|
661
|
+
imageContext = ` [Image saved to ${dl.path}]`;
|
|
662
|
+
// Notify browser of file change
|
|
663
|
+
broadcastToBrowsers({ type: "files_changed" });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// Handle document/file messages
|
|
667
|
+
if (msg.document) {
|
|
668
|
+
const dl = await downloadTelegramFile(msg.document.file_id, agentDir);
|
|
669
|
+
if (dl) {
|
|
670
|
+
imageContext = ` [File saved to ${dl.path}: ${msg.document.file_name || dl.name}]`;
|
|
671
|
+
broadcastToBrowsers({ type: "files_changed" });
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (!userText && !imageContext)
|
|
675
|
+
continue;
|
|
676
|
+
const fullText = `${userText}${imageContext}`.trim();
|
|
677
|
+
console.log(dim(`[telegram] ${fromName}: ${fullText.slice(0, 100)}`));
|
|
678
|
+
// ── Trigger check ──
|
|
679
|
+
if (userText) {
|
|
680
|
+
const trigger = matchTrigger(agentDir, "telegram", fromName, userText);
|
|
681
|
+
if (trigger) {
|
|
682
|
+
console.log(dim(`[triggers] Matched trigger ${trigger.id} for Telegram/${fromName}: "${userText.slice(0, 60)}" → "${trigger.reply.slice(0, 60)}"`));
|
|
683
|
+
try {
|
|
684
|
+
await fetch(`https://api.telegram.org/bot${telegramToken}/sendMessage`, {
|
|
685
|
+
method: "POST",
|
|
686
|
+
headers: { "Content-Type": "application/json" },
|
|
687
|
+
body: JSON.stringify({ chat_id: chatId, text: trigger.reply }),
|
|
688
|
+
});
|
|
689
|
+
const triggerLog = { type: "transcript", role: "assistant", text: `[Trigger → ${fromName}]: ${trigger.reply}` };
|
|
690
|
+
appendMessage(serverOpts.agentDir, activeBranch, triggerLog);
|
|
691
|
+
broadcastToBrowsers(triggerLog);
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
console.error(dim(`[triggers] Telegram auto-reply failed: ${err.message}`));
|
|
695
|
+
}
|
|
696
|
+
continue; // Skip agent processing for triggered messages
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
// Save to shared chat history & broadcast to web UI
|
|
700
|
+
const userMsg = { type: "transcript", role: "user", text: `[Telegram] ${fromName}: ${fullText}` };
|
|
701
|
+
appendMessage(serverOpts.agentDir, activeBranch, userMsg);
|
|
702
|
+
broadcastToBrowsers(userMsg);
|
|
703
|
+
// Send typing indicator
|
|
704
|
+
await fetch(`https://api.telegram.org/bot${telegramToken}/sendChatAction`, {
|
|
705
|
+
method: "POST",
|
|
706
|
+
headers: { "Content-Type": "application/json" },
|
|
707
|
+
body: JSON.stringify({ chat_id: chatId, action: "typing" }),
|
|
708
|
+
}).catch(() => { });
|
|
709
|
+
// Snapshot files before agent runs
|
|
710
|
+
const beforeFiles = snapshotFiles(agentDir);
|
|
711
|
+
// Run agent query
|
|
712
|
+
try {
|
|
713
|
+
const agentWorking = { type: "agent_working", query: fullText };
|
|
714
|
+
broadcastToBrowsers(agentWorking);
|
|
715
|
+
appendMessage(serverOpts.agentDir, activeBranch, agentWorking);
|
|
716
|
+
const tgContext = await getAgentContext(agentDir, activeBranch);
|
|
717
|
+
const tgComposio = await getComposioContext(fullText);
|
|
718
|
+
let tgSystemPrompt = "You are an AI assistant responding to a Telegram user. " +
|
|
719
|
+
"Any files you create or modify will be AUTOMATICALLY sent back to the user on Telegram. " +
|
|
720
|
+
"When asked to create documents (PDF, Word, PPT, spreadsheets, images, text files, etc.), " +
|
|
721
|
+
"write them to the workspace/ directory. The files will be delivered to the user immediately after you finish. " +
|
|
722
|
+
"Keep text responses concise since they appear in a chat interface.";
|
|
723
|
+
if (whatsappSock && whatsappConnected) {
|
|
724
|
+
tgSystemPrompt += " You can also send WhatsApp messages to contacts using the send_whatsapp_message tool. " +
|
|
725
|
+
"If you don't know a contact's number, ask the user or use list_whatsapp_contacts to check saved contacts.";
|
|
726
|
+
}
|
|
727
|
+
tgSystemPrompt += " You can set up auto-response triggers using create_trigger — e.g. 'when Kalps says hi on WhatsApp, reply hello friend'.";
|
|
728
|
+
tgSystemPrompt += "\n\n" + getCurrentDateTimeContext();
|
|
729
|
+
if (tgComposio.promptSuffix)
|
|
730
|
+
tgSystemPrompt += "\n\n" + tgComposio.promptSuffix;
|
|
731
|
+
if (tgContext)
|
|
732
|
+
tgSystemPrompt += "\n\n" + tgContext;
|
|
733
|
+
const tgTools = [
|
|
734
|
+
...(whatsappSock && whatsappConnected ? createWhatsAppTools(whatsappSock, agentDir) : []),
|
|
735
|
+
...createTriggerTools(agentDir),
|
|
736
|
+
...tgComposio.tools,
|
|
737
|
+
];
|
|
738
|
+
const result = query({
|
|
739
|
+
prompt: `[Telegram message from ${fromName}]: ${fullText}`,
|
|
740
|
+
dir: agentDir,
|
|
741
|
+
model: serverOpts.model,
|
|
742
|
+
env: serverOpts.env,
|
|
743
|
+
maxTurns: 10,
|
|
744
|
+
systemPrompt: tgSystemPrompt,
|
|
745
|
+
...(tgTools.length ? { tools: tgTools } : {}),
|
|
746
|
+
});
|
|
747
|
+
let reply = "";
|
|
748
|
+
for await (const m of result) {
|
|
749
|
+
if (m.type === "assistant" && m.content)
|
|
750
|
+
reply += m.content;
|
|
751
|
+
if (m.type === "tool_use") {
|
|
752
|
+
const toolMsg = { type: "tool_call", toolName: m.toolName, args: m.args ?? {} };
|
|
753
|
+
appendMessage(serverOpts.agentDir, activeBranch, toolMsg);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
reply = reply.trim();
|
|
757
|
+
// Save agent response to shared history & broadcast
|
|
758
|
+
const doneMsg = { type: "agent_done", result: reply.slice(0, 500) };
|
|
759
|
+
appendMessage(serverOpts.agentDir, activeBranch, doneMsg);
|
|
760
|
+
broadcastToBrowsers(doneMsg);
|
|
761
|
+
const assistantMsg = { type: "transcript", role: "assistant", text: reply };
|
|
762
|
+
appendMessage(serverOpts.agentDir, activeBranch, assistantMsg);
|
|
763
|
+
broadcastToBrowsers(assistantMsg);
|
|
764
|
+
if (reply) {
|
|
765
|
+
// Split long messages (Telegram 4096 char limit)
|
|
766
|
+
const chunks = [];
|
|
767
|
+
for (let i = 0; i < reply.length; i += 4096) {
|
|
768
|
+
chunks.push(reply.slice(i, i + 4096));
|
|
769
|
+
}
|
|
770
|
+
for (const chunk of chunks) {
|
|
771
|
+
await fetch(`https://api.telegram.org/bot${telegramToken}/sendMessage`, {
|
|
772
|
+
method: "POST",
|
|
773
|
+
headers: { "Content-Type": "application/json" },
|
|
774
|
+
body: JSON.stringify({ chat_id: chatId, text: chunk, parse_mode: "Markdown" }),
|
|
775
|
+
}).catch(async () => {
|
|
776
|
+
// Fallback without Markdown if parsing fails
|
|
777
|
+
await fetch(`https://api.telegram.org/bot${telegramToken}/sendMessage`, {
|
|
778
|
+
method: "POST",
|
|
779
|
+
headers: { "Content-Type": "application/json" },
|
|
780
|
+
body: JSON.stringify({ chat_id: chatId, text: chunk }),
|
|
781
|
+
}).catch(() => { });
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
// Detect new/modified files and send them back to Telegram
|
|
786
|
+
const afterFiles = snapshotFiles(agentDir);
|
|
787
|
+
const newFiles = diffSnapshots(beforeFiles, afterFiles);
|
|
788
|
+
const filesToSend = newFiles.filter((f) => {
|
|
789
|
+
const ext = f.split(".").pop()?.toLowerCase() || "";
|
|
790
|
+
// Skip chat history, internal files, and non-sendable types
|
|
791
|
+
if (f.startsWith(".gitagent/") || f.startsWith("node_modules/"))
|
|
792
|
+
return false;
|
|
793
|
+
if (f === ".env" || f === ".gitignore")
|
|
794
|
+
return false;
|
|
795
|
+
return SENDABLE_EXTS.has(ext);
|
|
796
|
+
});
|
|
797
|
+
for (const filePath of filesToSend) {
|
|
798
|
+
// Send upload_document action for each file
|
|
799
|
+
await fetch(`https://api.telegram.org/bot${telegramToken}/sendChatAction`, {
|
|
800
|
+
method: "POST",
|
|
801
|
+
headers: { "Content-Type": "application/json" },
|
|
802
|
+
body: JSON.stringify({ chat_id: chatId, action: "upload_document" }),
|
|
803
|
+
}).catch(() => { });
|
|
804
|
+
await sendTelegramFile(chatId, filePath, agentDir, filePath.split("/").pop());
|
|
805
|
+
}
|
|
806
|
+
// Notify browser of any file changes from agent
|
|
807
|
+
broadcastToBrowsers({ type: "files_changed" });
|
|
808
|
+
}
|
|
809
|
+
catch (err) {
|
|
810
|
+
console.error(dim(`[telegram] Agent error: ${err.message}`));
|
|
811
|
+
await fetch(`https://api.telegram.org/bot${telegramToken}/sendMessage`, {
|
|
812
|
+
method: "POST",
|
|
813
|
+
headers: { "Content-Type": "application/json" },
|
|
814
|
+
body: JSON.stringify({ chat_id: chatId, text: "Sorry, I encountered an error processing your message." }),
|
|
815
|
+
}).catch(() => { });
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
catch (err) {
|
|
821
|
+
console.error(dim(`[telegram] Poll error: ${err.message}`));
|
|
822
|
+
}
|
|
823
|
+
if (telegramPolling)
|
|
824
|
+
telegramPollTimer = setTimeout(poll, 500);
|
|
825
|
+
}
|
|
826
|
+
poll();
|
|
827
|
+
}
|
|
828
|
+
// Auto-connect if token is already configured
|
|
829
|
+
if (telegramToken) {
|
|
830
|
+
fetch(`https://api.telegram.org/bot${telegramToken}/getMe`)
|
|
831
|
+
.then((r) => r.json())
|
|
832
|
+
.then((d) => {
|
|
833
|
+
if (d.ok) {
|
|
834
|
+
telegramBotInfo = d.result;
|
|
835
|
+
startTelegramPolling(agentRoot, opts);
|
|
836
|
+
console.log(dim(`[voice] Telegram bot connected: @${d.result.username}`));
|
|
837
|
+
}
|
|
838
|
+
})
|
|
839
|
+
.catch(() => { });
|
|
840
|
+
}
|
|
841
|
+
// ── WhatsApp state ─────────────────────────────────────────────────
|
|
842
|
+
let whatsappSock = null;
|
|
843
|
+
let whatsappConnected = false;
|
|
844
|
+
let whatsappPhoneNumber = null;
|
|
845
|
+
let whatsappQrCode = null;
|
|
846
|
+
const whatsappSentIds = new Set();
|
|
847
|
+
function contactsPath(agentDir) {
|
|
848
|
+
return join(agentDir, ".gitagent", "whatsapp-contacts.json");
|
|
849
|
+
}
|
|
850
|
+
function loadContacts(agentDir) {
|
|
851
|
+
try {
|
|
852
|
+
return JSON.parse(readFileSync(contactsPath(agentDir), "utf-8"));
|
|
853
|
+
}
|
|
854
|
+
catch {
|
|
855
|
+
return [];
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
function saveContacts(agentDir, contacts) {
|
|
859
|
+
const dir = join(agentDir, ".gitagent");
|
|
860
|
+
mkdirSync(dir, { recursive: true });
|
|
861
|
+
writeFileSync(contactsPath(agentDir), JSON.stringify(contacts, null, 2));
|
|
862
|
+
}
|
|
863
|
+
function findContact(agentDir, nameQuery) {
|
|
864
|
+
const q = nameQuery.toLowerCase();
|
|
865
|
+
return loadContacts(agentDir).find(c => c.name.toLowerCase() === q || c.name.toLowerCase().includes(q));
|
|
866
|
+
}
|
|
867
|
+
function upsertContact(agentDir, contact) {
|
|
868
|
+
const contacts = loadContacts(agentDir);
|
|
869
|
+
const idx = contacts.findIndex(c => c.jid === contact.jid);
|
|
870
|
+
if (idx >= 0)
|
|
871
|
+
contacts[idx] = contact;
|
|
872
|
+
else
|
|
873
|
+
contacts.push(contact);
|
|
874
|
+
saveContacts(agentDir, contacts);
|
|
875
|
+
}
|
|
876
|
+
/** Build WhatsApp tools that use the live Baileys socket */
|
|
877
|
+
function createWhatsAppTools(sock, agentDir) {
|
|
878
|
+
return [
|
|
879
|
+
{
|
|
880
|
+
name: "send_whatsapp_message",
|
|
881
|
+
description: "Send a WhatsApp message to a contact. You can specify either a phone number (with country code, e.g. '919876543210') or a contact name (if previously saved). The message will be sent immediately.",
|
|
882
|
+
inputSchema: {
|
|
883
|
+
type: "object",
|
|
884
|
+
properties: {
|
|
885
|
+
to: { type: "string", description: "Contact name or phone number (with country code, no '+' prefix, e.g. '919876543210')" },
|
|
886
|
+
message: { type: "string", description: "Message text to send" },
|
|
887
|
+
},
|
|
888
|
+
required: ["to", "message"],
|
|
889
|
+
},
|
|
890
|
+
handler: async (args) => {
|
|
891
|
+
let jid;
|
|
892
|
+
let displayName = args.to;
|
|
893
|
+
// Try contact lookup first, then treat as phone number
|
|
894
|
+
const contact = findContact(agentDir, args.to);
|
|
895
|
+
if (contact) {
|
|
896
|
+
jid = contact.jid;
|
|
897
|
+
displayName = contact.name;
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
const digits = args.to.replace(/[^0-9]/g, "");
|
|
901
|
+
if (!digits || digits.length < 7) {
|
|
902
|
+
return `Contact "${args.to}" not found. Use save_whatsapp_contact to save them first, or provide a phone number with country code (e.g. 919876543210).`;
|
|
903
|
+
}
|
|
904
|
+
jid = `${digits}@s.whatsapp.net`;
|
|
905
|
+
}
|
|
906
|
+
const sent = await sock.sendMessage(jid, { text: args.message });
|
|
907
|
+
if (sent?.key?.id)
|
|
908
|
+
whatsappSentIds.add(sent.key.id);
|
|
909
|
+
console.log(dim(`[whatsapp] Sent message to ${displayName} (${jid}): ${args.message.slice(0, 80)}`));
|
|
910
|
+
return `Message sent to ${displayName}.`;
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
name: "save_whatsapp_contact",
|
|
915
|
+
description: "Save a WhatsApp contact for future use. This lets you send messages by name instead of phone number.",
|
|
916
|
+
inputSchema: {
|
|
917
|
+
type: "object",
|
|
918
|
+
properties: {
|
|
919
|
+
name: { type: "string", description: "Contact name (e.g. 'Kalps')" },
|
|
920
|
+
phone: { type: "string", description: "Phone number with country code, no '+' prefix (e.g. '919876543210')" },
|
|
921
|
+
},
|
|
922
|
+
required: ["name", "phone"],
|
|
923
|
+
},
|
|
924
|
+
handler: async (args) => {
|
|
925
|
+
const digits = args.phone.replace(/[^0-9]/g, "");
|
|
926
|
+
const jid = `${digits}@s.whatsapp.net`;
|
|
927
|
+
upsertContact(agentDir, { name: args.name, phone: digits, jid });
|
|
928
|
+
console.log(dim(`[whatsapp] Saved contact: ${args.name} → ${digits}`));
|
|
929
|
+
return `Contact "${args.name}" saved with phone ${digits}.`;
|
|
930
|
+
},
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
name: "list_whatsapp_contacts",
|
|
934
|
+
description: "List all saved WhatsApp contacts.",
|
|
935
|
+
inputSchema: { type: "object", properties: {} },
|
|
936
|
+
handler: async () => {
|
|
937
|
+
const contacts = loadContacts(agentDir);
|
|
938
|
+
if (!contacts.length)
|
|
939
|
+
return "No saved contacts. Use save_whatsapp_contact to add one.";
|
|
940
|
+
return contacts.map(c => `${c.name}: ${c.phone}`).join("\n");
|
|
941
|
+
},
|
|
942
|
+
},
|
|
943
|
+
];
|
|
944
|
+
}
|
|
945
|
+
function triggersPath(agentDir) {
|
|
946
|
+
return join(agentDir, ".gitagent", "triggers.json");
|
|
947
|
+
}
|
|
948
|
+
function loadTriggers(agentDir) {
|
|
949
|
+
try {
|
|
950
|
+
return JSON.parse(readFileSync(triggersPath(agentDir), "utf-8"));
|
|
951
|
+
}
|
|
952
|
+
catch {
|
|
953
|
+
return [];
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
function saveTriggers(agentDir, triggers) {
|
|
957
|
+
const dir = join(agentDir, ".gitagent");
|
|
958
|
+
mkdirSync(dir, { recursive: true });
|
|
959
|
+
writeFileSync(triggersPath(agentDir), JSON.stringify(triggers, null, 2));
|
|
960
|
+
}
|
|
961
|
+
function matchTrigger(agentDir, platform, from, message) {
|
|
962
|
+
const triggers = loadTriggers(agentDir);
|
|
963
|
+
const fromLower = from.toLowerCase();
|
|
964
|
+
const msgLower = message.toLowerCase();
|
|
965
|
+
return triggers.find(t => {
|
|
966
|
+
if (!t.enabled)
|
|
967
|
+
return false;
|
|
968
|
+
if (t.platform !== "*" && t.platform !== platform)
|
|
969
|
+
return false;
|
|
970
|
+
if (t.from !== "*") {
|
|
971
|
+
// Match by contact name or phone number
|
|
972
|
+
const contact = findContact(agentDir, t.from);
|
|
973
|
+
if (contact) {
|
|
974
|
+
if (fromLower !== contact.jid && fromLower !== contact.phone && fromLower !== contact.name.toLowerCase())
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
else if (fromLower !== t.from.toLowerCase())
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
// Pattern match — try regex first, fall back to substring
|
|
981
|
+
try {
|
|
982
|
+
if (new RegExp(t.pattern, "i").test(message))
|
|
983
|
+
return true;
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
if (msgLower.includes(t.pattern.toLowerCase()))
|
|
987
|
+
return true;
|
|
988
|
+
}
|
|
989
|
+
return false;
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
function createTriggerTools(agentDir) {
|
|
993
|
+
return [
|
|
994
|
+
{
|
|
995
|
+
name: "create_trigger",
|
|
996
|
+
description: "Create an auto-response trigger. When a message matching the pattern arrives from the specified contact, the reply is sent automatically. Use from='*' to match anyone. Use platform='*' for all platforms.",
|
|
997
|
+
inputSchema: {
|
|
998
|
+
type: "object",
|
|
999
|
+
properties: {
|
|
1000
|
+
from: { type: "string", description: "Contact name, phone number, or '*' for anyone" },
|
|
1001
|
+
pattern: { type: "string", description: "Text pattern to match (substring or regex)" },
|
|
1002
|
+
reply: { type: "string", description: "Auto-reply message to send" },
|
|
1003
|
+
platform: { type: "string", enum: ["whatsapp", "telegram", "*"], description: "Platform to trigger on (default: '*')" },
|
|
1004
|
+
},
|
|
1005
|
+
required: ["from", "pattern", "reply"],
|
|
1006
|
+
},
|
|
1007
|
+
handler: async (args) => {
|
|
1008
|
+
const trigger = {
|
|
1009
|
+
id: Date.now().toString(36),
|
|
1010
|
+
from: args.from,
|
|
1011
|
+
pattern: args.pattern,
|
|
1012
|
+
reply: args.reply,
|
|
1013
|
+
platform: args.platform || "*",
|
|
1014
|
+
enabled: true,
|
|
1015
|
+
};
|
|
1016
|
+
const triggers = loadTriggers(agentDir);
|
|
1017
|
+
triggers.push(trigger);
|
|
1018
|
+
saveTriggers(agentDir, triggers);
|
|
1019
|
+
console.log(dim(`[triggers] Created: when ${trigger.from} says "${trigger.pattern}" → "${trigger.reply}" (${trigger.platform})`));
|
|
1020
|
+
return `Trigger created (id: ${trigger.id}). When ${trigger.from} sends a message matching "${trigger.pattern}", I'll auto-reply: "${trigger.reply}"`;
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
name: "list_triggers",
|
|
1025
|
+
description: "List all message triggers.",
|
|
1026
|
+
inputSchema: { type: "object", properties: {} },
|
|
1027
|
+
handler: async () => {
|
|
1028
|
+
const triggers = loadTriggers(agentDir);
|
|
1029
|
+
if (!triggers.length)
|
|
1030
|
+
return "No triggers set up.";
|
|
1031
|
+
return triggers.map(t => `[${t.id}] ${t.enabled ? "ON" : "OFF"} | from: ${t.from} | pattern: "${t.pattern}" | reply: "${t.reply}" | platform: ${t.platform}`).join("\n");
|
|
1032
|
+
},
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
name: "delete_trigger",
|
|
1036
|
+
description: "Delete a trigger by its ID.",
|
|
1037
|
+
inputSchema: {
|
|
1038
|
+
type: "object",
|
|
1039
|
+
properties: { id: { type: "string", description: "Trigger ID to delete" } },
|
|
1040
|
+
required: ["id"],
|
|
1041
|
+
},
|
|
1042
|
+
handler: async (args) => {
|
|
1043
|
+
const triggers = loadTriggers(agentDir);
|
|
1044
|
+
const idx = triggers.findIndex(t => t.id === args.id);
|
|
1045
|
+
if (idx < 0)
|
|
1046
|
+
return `Trigger "${args.id}" not found.`;
|
|
1047
|
+
const removed = triggers.splice(idx, 1)[0];
|
|
1048
|
+
saveTriggers(agentDir, triggers);
|
|
1049
|
+
console.log(dim(`[triggers] Deleted: ${removed.id}`));
|
|
1050
|
+
return `Trigger "${removed.id}" deleted (was: ${removed.from} / "${removed.pattern}").`;
|
|
1051
|
+
},
|
|
1052
|
+
},
|
|
1053
|
+
{
|
|
1054
|
+
name: "toggle_trigger",
|
|
1055
|
+
description: "Enable or disable a trigger by its ID.",
|
|
1056
|
+
inputSchema: {
|
|
1057
|
+
type: "object",
|
|
1058
|
+
properties: {
|
|
1059
|
+
id: { type: "string", description: "Trigger ID" },
|
|
1060
|
+
enabled: { type: "boolean", description: "true to enable, false to disable" },
|
|
1061
|
+
},
|
|
1062
|
+
required: ["id", "enabled"],
|
|
1063
|
+
},
|
|
1064
|
+
handler: async (args) => {
|
|
1065
|
+
const triggers = loadTriggers(agentDir);
|
|
1066
|
+
const t = triggers.find(t => t.id === args.id);
|
|
1067
|
+
if (!t)
|
|
1068
|
+
return `Trigger "${args.id}" not found.`;
|
|
1069
|
+
t.enabled = args.enabled;
|
|
1070
|
+
saveTriggers(agentDir, triggers);
|
|
1071
|
+
return `Trigger "${t.id}" ${args.enabled ? "enabled" : "disabled"}.`;
|
|
1072
|
+
},
|
|
1073
|
+
},
|
|
1074
|
+
];
|
|
1075
|
+
}
|
|
1076
|
+
async function startWhatsApp(agentDir, serverOpts) {
|
|
1077
|
+
const { default: makeWASocket, useMultiFileAuthState, makeCacheableSignalKeyStore, fetchLatestBaileysVersion, DisconnectReason, jidNormalizedUser, } = await import("baileys");
|
|
1078
|
+
const authDir = join(agentDir, ".gitagent/whatsapp-auth");
|
|
1079
|
+
mkdirSync(authDir, { recursive: true });
|
|
1080
|
+
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
|
1081
|
+
const { version } = await fetchLatestBaileysVersion();
|
|
1082
|
+
const sock = makeWASocket({
|
|
1083
|
+
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys) },
|
|
1084
|
+
version,
|
|
1085
|
+
browser: ["GitClaw", "cli", "0.3.1"],
|
|
1086
|
+
printQRInTerminal: false,
|
|
1087
|
+
syncFullHistory: false,
|
|
1088
|
+
markOnlineOnConnect: false,
|
|
1089
|
+
});
|
|
1090
|
+
whatsappSock = sock;
|
|
1091
|
+
sock.ev.on("connection.update", (update) => {
|
|
1092
|
+
const { connection, lastDisconnect, qr } = update;
|
|
1093
|
+
if (qr) {
|
|
1094
|
+
whatsappQrCode = qr;
|
|
1095
|
+
broadcastToBrowsers({ type: "whatsapp_qr", qr });
|
|
1096
|
+
console.log(dim("[whatsapp] QR code generated — scan with WhatsApp"));
|
|
1097
|
+
}
|
|
1098
|
+
if (connection === "open") {
|
|
1099
|
+
whatsappConnected = true;
|
|
1100
|
+
whatsappQrCode = null;
|
|
1101
|
+
const jid = sock.user?.id || "";
|
|
1102
|
+
whatsappPhoneNumber = jid.replace(/:.*@/, "@").replace("@s.whatsapp.net", "");
|
|
1103
|
+
console.log(dim(`[whatsapp] Connected: ${whatsappPhoneNumber}`));
|
|
1104
|
+
broadcastToBrowsers({ type: "whatsapp_status", connected: true, phoneNumber: whatsappPhoneNumber });
|
|
1105
|
+
}
|
|
1106
|
+
if (connection === "close") {
|
|
1107
|
+
whatsappConnected = false;
|
|
1108
|
+
whatsappQrCode = null;
|
|
1109
|
+
const statusCode = lastDisconnect?.error?.output?.statusCode;
|
|
1110
|
+
const loggedOut = statusCode === DisconnectReason.loggedOut;
|
|
1111
|
+
console.log(dim(`[whatsapp] Disconnected (code=${statusCode}, loggedOut=${loggedOut})`));
|
|
1112
|
+
broadcastToBrowsers({ type: "whatsapp_status", connected: false });
|
|
1113
|
+
if (!loggedOut) {
|
|
1114
|
+
// Auto-reconnect
|
|
1115
|
+
setTimeout(() => startWhatsApp(agentDir, serverOpts).catch(() => { }), 3000);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
sock.ev.on("creds.update", saveCreds);
|
|
1120
|
+
sock.ev.on("messages.upsert", async ({ messages, type }) => {
|
|
1121
|
+
console.log(dim(`[whatsapp] upsert type=${type}, count=${messages.length}`));
|
|
1122
|
+
if (type !== "notify")
|
|
1123
|
+
return;
|
|
1124
|
+
const ownJid = sock.user?.id ? jidNormalizedUser(sock.user.id) : null;
|
|
1125
|
+
// Also track our LID (Linked Identity) — WhatsApp may route self-DMs via LID
|
|
1126
|
+
const ownLid = sock.user?.lid?.replace(/:.*@/, "@") || null;
|
|
1127
|
+
if (!ownJid)
|
|
1128
|
+
return;
|
|
1129
|
+
for (const msg of messages) {
|
|
1130
|
+
console.log(dim(`[whatsapp] msg: remoteJid=${msg.key.remoteJid}, fromMe=${msg.key.fromMe}, ownJid=${ownJid}, ownLid=${ownLid}, id=${msg.key.id}`));
|
|
1131
|
+
// Skip agent's own replies
|
|
1132
|
+
if (whatsappSentIds.has(msg.key.id))
|
|
1133
|
+
continue;
|
|
1134
|
+
const incomingText = msg.message?.conversation
|
|
1135
|
+
|| msg.message?.extendedTextMessage?.text || "";
|
|
1136
|
+
if (!incomingText)
|
|
1137
|
+
continue;
|
|
1138
|
+
const senderJid = msg.key.remoteJid;
|
|
1139
|
+
const isSelf = senderJid === ownJid || (ownLid && senderJid === ownLid);
|
|
1140
|
+
// ── Trigger check (runs on ALL incoming messages, not just self-DMs) ──
|
|
1141
|
+
if (!isSelf && !msg.key.fromMe) {
|
|
1142
|
+
// Resolve sender identity for trigger matching
|
|
1143
|
+
const senderPhone = senderJid.replace("@s.whatsapp.net", "");
|
|
1144
|
+
const senderContact = loadContacts(agentDir).find(c => c.jid === senderJid || c.phone === senderPhone);
|
|
1145
|
+
const senderName = senderContact?.name || senderPhone;
|
|
1146
|
+
const trigger = matchTrigger(agentDir, "whatsapp", senderContact?.name || senderJid, incomingText);
|
|
1147
|
+
if (trigger) {
|
|
1148
|
+
console.log(dim(`[triggers] Matched trigger ${trigger.id} for ${senderName}: "${incomingText.slice(0, 60)}" → "${trigger.reply.slice(0, 60)}"`));
|
|
1149
|
+
try {
|
|
1150
|
+
const sent = await sock.sendMessage(senderJid, { text: trigger.reply });
|
|
1151
|
+
if (sent?.key?.id)
|
|
1152
|
+
whatsappSentIds.add(sent.key.id);
|
|
1153
|
+
// Log to chat history
|
|
1154
|
+
const triggerLog = { type: "transcript", role: "assistant", text: `[Trigger → ${senderName}]: ${trigger.reply}` };
|
|
1155
|
+
appendMessage(serverOpts.agentDir, activeBranch, triggerLog);
|
|
1156
|
+
broadcastToBrowsers(triggerLog);
|
|
1157
|
+
}
|
|
1158
|
+
catch (err) {
|
|
1159
|
+
console.error(dim(`[triggers] Failed to send auto-reply: ${err.message}`));
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
continue; // Non-self messages are only processed for triggers
|
|
1163
|
+
}
|
|
1164
|
+
// ── Self-DM: full agent interaction ──
|
|
1165
|
+
const text = incomingText;
|
|
1166
|
+
const replyJid = senderJid;
|
|
1167
|
+
console.log(dim(`[whatsapp] Self-DM: ${text.slice(0, 100)}`));
|
|
1168
|
+
// Broadcast to browser UI
|
|
1169
|
+
const userMsg = { type: "transcript", role: "user", text: `[WhatsApp]: ${text}` };
|
|
1170
|
+
appendMessage(serverOpts.agentDir, activeBranch, userMsg);
|
|
1171
|
+
broadcastToBrowsers(userMsg);
|
|
1172
|
+
// Send typing presence
|
|
1173
|
+
try {
|
|
1174
|
+
await sock.presenceSubscribe(replyJid);
|
|
1175
|
+
await sock.sendPresenceUpdate("composing", replyJid);
|
|
1176
|
+
}
|
|
1177
|
+
catch { /* ignore */ }
|
|
1178
|
+
// Snapshot files before agent runs
|
|
1179
|
+
const beforeFiles = snapshotFiles(agentDir);
|
|
1180
|
+
try {
|
|
1181
|
+
const agentWorking = { type: "agent_working", query: text };
|
|
1182
|
+
broadcastToBrowsers(agentWorking);
|
|
1183
|
+
appendMessage(serverOpts.agentDir, activeBranch, agentWorking);
|
|
1184
|
+
const waContext = await getAgentContext(agentDir, activeBranch);
|
|
1185
|
+
const waComposio = await getComposioContext(text);
|
|
1186
|
+
let waSystemPrompt = "You are an AI assistant responding via WhatsApp. " +
|
|
1187
|
+
"Any files you create or modify will be AUTOMATICALLY sent back to the user on WhatsApp. " +
|
|
1188
|
+
"When asked to create documents, write them to the workspace/ directory. " +
|
|
1189
|
+
"Keep text responses concise since they appear in a chat interface. " +
|
|
1190
|
+
"You can send WhatsApp messages to other people using the send_whatsapp_message tool. " +
|
|
1191
|
+
"If you don't know a contact's number, ask the user or use list_whatsapp_contacts to check saved contacts. " +
|
|
1192
|
+
"You can also set up auto-response triggers using create_trigger — e.g. 'when Kalps says hi, reply hello friend'.";
|
|
1193
|
+
waSystemPrompt += "\n\n" + getCurrentDateTimeContext();
|
|
1194
|
+
if (waComposio.promptSuffix)
|
|
1195
|
+
waSystemPrompt += "\n\n" + waComposio.promptSuffix;
|
|
1196
|
+
if (waContext)
|
|
1197
|
+
waSystemPrompt += "\n\n" + waContext;
|
|
1198
|
+
const waTools = [...createWhatsAppTools(sock, agentDir), ...createTriggerTools(agentDir), ...waComposio.tools];
|
|
1199
|
+
const result = query({
|
|
1200
|
+
prompt: `[WhatsApp message]: ${text}`,
|
|
1201
|
+
dir: agentDir,
|
|
1202
|
+
model: serverOpts.model,
|
|
1203
|
+
env: serverOpts.env,
|
|
1204
|
+
maxTurns: 10,
|
|
1205
|
+
systemPrompt: waSystemPrompt,
|
|
1206
|
+
tools: waTools,
|
|
1207
|
+
});
|
|
1208
|
+
let reply = "";
|
|
1209
|
+
for await (const m of result) {
|
|
1210
|
+
if (m.type === "assistant" && m.content)
|
|
1211
|
+
reply += m.content;
|
|
1212
|
+
}
|
|
1213
|
+
reply = reply.trim();
|
|
1214
|
+
// Save agent response to shared history & broadcast
|
|
1215
|
+
const doneMsg = { type: "agent_done", result: reply.slice(0, 500) };
|
|
1216
|
+
appendMessage(serverOpts.agentDir, activeBranch, doneMsg);
|
|
1217
|
+
broadcastToBrowsers(doneMsg);
|
|
1218
|
+
const assistantMsg = { type: "transcript", role: "assistant", text: reply };
|
|
1219
|
+
appendMessage(serverOpts.agentDir, activeBranch, assistantMsg);
|
|
1220
|
+
broadcastToBrowsers(assistantMsg);
|
|
1221
|
+
// Send reply (chunk at 4000 chars for WhatsApp)
|
|
1222
|
+
if (reply) {
|
|
1223
|
+
const chunks = [];
|
|
1224
|
+
for (let i = 0; i < reply.length; i += 4000)
|
|
1225
|
+
chunks.push(reply.slice(i, i + 4000));
|
|
1226
|
+
for (const chunk of chunks) {
|
|
1227
|
+
const italicChunk = chunk.split("\n").map(line => line ? `_${line}_` : "").join("\n");
|
|
1228
|
+
const sent = await sock.sendMessage(replyJid, { text: `*GitClaw:*\n${italicChunk}` });
|
|
1229
|
+
if (sent?.key?.id)
|
|
1230
|
+
whatsappSentIds.add(sent.key.id);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
// Detect new/modified files and send them back
|
|
1234
|
+
const afterFiles = snapshotFiles(agentDir);
|
|
1235
|
+
const newFiles = diffSnapshots(beforeFiles, afterFiles).filter((f) => {
|
|
1236
|
+
const ext = f.split(".").pop()?.toLowerCase() || "";
|
|
1237
|
+
if (f.startsWith(".gitagent/") || f.startsWith("node_modules/"))
|
|
1238
|
+
return false;
|
|
1239
|
+
if (f === ".env" || f === ".gitignore")
|
|
1240
|
+
return false;
|
|
1241
|
+
return SENDABLE_EXTS.has(ext);
|
|
1242
|
+
});
|
|
1243
|
+
for (const filePath of newFiles) {
|
|
1244
|
+
const abs = join(agentDir, filePath);
|
|
1245
|
+
if (!existsSync(abs))
|
|
1246
|
+
continue;
|
|
1247
|
+
const buffer = readFileSync(abs);
|
|
1248
|
+
const sent = await sock.sendMessage(replyJid, {
|
|
1249
|
+
document: buffer,
|
|
1250
|
+
fileName: filePath.split("/").pop() || "file",
|
|
1251
|
+
mimetype: "application/octet-stream",
|
|
1252
|
+
});
|
|
1253
|
+
if (sent?.key?.id)
|
|
1254
|
+
whatsappSentIds.add(sent.key.id);
|
|
1255
|
+
}
|
|
1256
|
+
broadcastToBrowsers({ type: "files_changed" });
|
|
1257
|
+
}
|
|
1258
|
+
catch (err) {
|
|
1259
|
+
console.error(dim(`[whatsapp] Agent error: ${err.message}`));
|
|
1260
|
+
try {
|
|
1261
|
+
const sent = await sock.sendMessage(replyJid, { text: "*GitClaw:* _Sorry, I encountered an error processing your message._" });
|
|
1262
|
+
if (sent?.key?.id)
|
|
1263
|
+
whatsappSentIds.add(sent.key.id);
|
|
1264
|
+
}
|
|
1265
|
+
catch { /* ignore */ }
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
function stopWhatsApp(clearAuth = false) {
|
|
1271
|
+
if (whatsappSock) {
|
|
1272
|
+
try {
|
|
1273
|
+
whatsappSock.end(undefined);
|
|
1274
|
+
}
|
|
1275
|
+
catch { /* ignore */ }
|
|
1276
|
+
}
|
|
1277
|
+
whatsappSock = null;
|
|
1278
|
+
whatsappConnected = false;
|
|
1279
|
+
whatsappPhoneNumber = null;
|
|
1280
|
+
whatsappQrCode = null;
|
|
1281
|
+
whatsappSentIds.clear();
|
|
1282
|
+
if (clearAuth) {
|
|
1283
|
+
const authDir = join(agentRoot, ".gitagent/whatsapp-auth");
|
|
1284
|
+
try {
|
|
1285
|
+
rmSync(authDir, { recursive: true, force: true });
|
|
1286
|
+
}
|
|
1287
|
+
catch { /* ignore */ }
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
// Auto-connect WhatsApp if auth exists
|
|
1291
|
+
const waAuthDir = join(agentRoot, ".gitagent/whatsapp-auth");
|
|
1292
|
+
if (existsSync(join(waAuthDir, "creds.json"))) {
|
|
1293
|
+
startWhatsApp(agentRoot, opts).catch(() => { });
|
|
1294
|
+
}
|
|
1295
|
+
/** Resolve and validate a requested path stays within agentDir */
|
|
1296
|
+
function safePath(reqPath) {
|
|
1297
|
+
const abs = resolve(agentRoot, reqPath);
|
|
1298
|
+
if (!abs.startsWith(agentRoot))
|
|
1299
|
+
return null;
|
|
1300
|
+
return abs;
|
|
1301
|
+
}
|
|
1302
|
+
function listDir(dirPath, depth) {
|
|
1303
|
+
if (depth > 4)
|
|
1304
|
+
return [];
|
|
1305
|
+
try {
|
|
1306
|
+
const entries = readdirSync(dirPath);
|
|
1307
|
+
const result = [];
|
|
1308
|
+
for (const name of entries) {
|
|
1309
|
+
if (name.startsWith(".") && HIDDEN_DIRS.has(name))
|
|
1310
|
+
continue;
|
|
1311
|
+
if (HIDDEN_DIRS.has(name))
|
|
1312
|
+
continue;
|
|
1313
|
+
const fullPath = join(dirPath, name);
|
|
1314
|
+
const relPath = relative(agentRoot, fullPath);
|
|
1315
|
+
try {
|
|
1316
|
+
const st = statSync(fullPath);
|
|
1317
|
+
if (st.isDirectory()) {
|
|
1318
|
+
result.push({
|
|
1319
|
+
name,
|
|
1320
|
+
path: relPath,
|
|
1321
|
+
type: "directory",
|
|
1322
|
+
children: listDir(fullPath, depth + 1),
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
else if (st.isFile()) {
|
|
1326
|
+
result.push({ name, path: relPath, type: "file", mtime: st.mtimeMs });
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
catch {
|
|
1330
|
+
// skip unreadable entries
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
// Sort: directories first, then alphabetical
|
|
1334
|
+
result.sort((a, b) => {
|
|
1335
|
+
if (a.type !== b.type)
|
|
1336
|
+
return a.type === "directory" ? -1 : 1;
|
|
1337
|
+
return a.name.localeCompare(b.name);
|
|
1338
|
+
});
|
|
1339
|
+
return result;
|
|
1340
|
+
}
|
|
1341
|
+
catch {
|
|
1342
|
+
return [];
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
function readBody(req) {
|
|
1346
|
+
return new Promise((res, rej) => {
|
|
1347
|
+
let body = "";
|
|
1348
|
+
req.on("data", (c) => { body += c.toString(); });
|
|
1349
|
+
req.on("end", () => res(body));
|
|
1350
|
+
req.on("error", rej);
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
function jsonReply(res, status, data) {
|
|
1354
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1355
|
+
res.end(JSON.stringify(data));
|
|
1356
|
+
}
|
|
1357
|
+
function escapeXml(s) {
|
|
1358
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1359
|
+
}
|
|
1360
|
+
// HTTP server
|
|
1361
|
+
const httpServer = createServer(async (req, res) => {
|
|
1362
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1363
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
1364
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
1365
|
+
if (req.method === "OPTIONS") {
|
|
1366
|
+
res.writeHead(204);
|
|
1367
|
+
return res.end();
|
|
1368
|
+
}
|
|
1369
|
+
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
1370
|
+
if (url.pathname === "/health") {
|
|
1371
|
+
jsonReply(res, 200, { status: "ok" });
|
|
1372
|
+
}
|
|
1373
|
+
else if (url.pathname === "/api/vitals") {
|
|
1374
|
+
const mem = process.memoryUsage();
|
|
1375
|
+
const cpuUsage = process.cpuUsage();
|
|
1376
|
+
const cpuPercent = Math.min(100, Math.round((cpuUsage.user + cpuUsage.system) / 1000 / (process.uptime() * 10000)));
|
|
1377
|
+
jsonReply(res, 200, {
|
|
1378
|
+
cpu: cpuPercent,
|
|
1379
|
+
mem: Math.round(mem.rss / 1024 / 1024),
|
|
1380
|
+
heapUsed: Math.round(mem.heapUsed / 1024 / 1024),
|
|
1381
|
+
heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
|
|
1382
|
+
uptime: Math.round(process.uptime()),
|
|
1383
|
+
tokens: vitalsTokenCount,
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
else if (url.pathname === "/" || url.pathname === "/test") {
|
|
1387
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1388
|
+
res.end(uiHtml);
|
|
1389
|
+
}
|
|
1390
|
+
else if (url.pathname === "/api/files" && req.method === "GET") {
|
|
1391
|
+
// List files as a tree
|
|
1392
|
+
const reqPath = url.searchParams.get("path") || ".";
|
|
1393
|
+
const abs = safePath(reqPath);
|
|
1394
|
+
if (!abs)
|
|
1395
|
+
return jsonReply(res, 403, { error: "Path outside workspace" });
|
|
1396
|
+
const tree = listDir(abs, 0);
|
|
1397
|
+
jsonReply(res, 200, { root: relative(agentRoot, abs) || ".", entries: tree });
|
|
1398
|
+
}
|
|
1399
|
+
else if (url.pathname === "/api/file" && req.method === "GET") {
|
|
1400
|
+
// Read a file
|
|
1401
|
+
const reqPath = url.searchParams.get("path");
|
|
1402
|
+
if (!reqPath)
|
|
1403
|
+
return jsonReply(res, 400, { error: "Missing path param" });
|
|
1404
|
+
const abs = safePath(reqPath);
|
|
1405
|
+
if (!abs)
|
|
1406
|
+
return jsonReply(res, 403, { error: "Path outside workspace" });
|
|
1407
|
+
if (!existsSync(abs))
|
|
1408
|
+
return jsonReply(res, 404, { error: "File not found" });
|
|
1409
|
+
try {
|
|
1410
|
+
const st = statSync(abs);
|
|
1411
|
+
if (st.size > 1024 * 1024)
|
|
1412
|
+
return jsonReply(res, 413, { error: "File too large (>1MB)" });
|
|
1413
|
+
const content = readFileSync(abs, "utf-8");
|
|
1414
|
+
jsonReply(res, 200, { path: reqPath, content });
|
|
1415
|
+
}
|
|
1416
|
+
catch (err) {
|
|
1417
|
+
jsonReply(res, 500, { error: err.message });
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
else if (url.pathname === "/api/file/raw" && req.method === "GET") {
|
|
1421
|
+
// Serve raw file with correct MIME type (for images, etc.)
|
|
1422
|
+
const reqPath = url.searchParams.get("path");
|
|
1423
|
+
if (!reqPath)
|
|
1424
|
+
return jsonReply(res, 400, { error: "Missing path param" });
|
|
1425
|
+
const abs = safePath(reqPath);
|
|
1426
|
+
if (!abs)
|
|
1427
|
+
return jsonReply(res, 403, { error: "Path outside workspace" });
|
|
1428
|
+
if (!existsSync(abs))
|
|
1429
|
+
return jsonReply(res, 404, { error: "File not found" });
|
|
1430
|
+
try {
|
|
1431
|
+
const st = statSync(abs);
|
|
1432
|
+
if (st.size > 10 * 1024 * 1024)
|
|
1433
|
+
return jsonReply(res, 413, { error: "File too large (>10MB)" });
|
|
1434
|
+
const ext = reqPath.split(".").pop()?.toLowerCase() || "";
|
|
1435
|
+
const mimeMap = {
|
|
1436
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
|
|
1437
|
+
webp: "image/webp", svg: "image/svg+xml", ico: "image/x-icon", bmp: "image/bmp",
|
|
1438
|
+
};
|
|
1439
|
+
const mime = mimeMap[ext] || "application/octet-stream";
|
|
1440
|
+
const data = readFileSync(abs);
|
|
1441
|
+
res.writeHead(200, { "Content-Type": mime, "Content-Length": data.length, "Cache-Control": "no-cache" });
|
|
1442
|
+
res.end(data);
|
|
1443
|
+
}
|
|
1444
|
+
catch (err) {
|
|
1445
|
+
jsonReply(res, 500, { error: err.message });
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
else if (url.pathname === "/api/file" && req.method === "PUT") {
|
|
1449
|
+
// Write a file
|
|
1450
|
+
const body = await readBody(req);
|
|
1451
|
+
let parsed;
|
|
1452
|
+
try {
|
|
1453
|
+
parsed = JSON.parse(body);
|
|
1454
|
+
}
|
|
1455
|
+
catch {
|
|
1456
|
+
return jsonReply(res, 400, { error: "Invalid JSON body" });
|
|
1457
|
+
}
|
|
1458
|
+
if (!parsed.path || parsed.content === undefined)
|
|
1459
|
+
return jsonReply(res, 400, { error: "Missing path or content" });
|
|
1460
|
+
const abs = safePath(parsed.path);
|
|
1461
|
+
if (!abs)
|
|
1462
|
+
return jsonReply(res, 403, { error: "Path outside workspace" });
|
|
1463
|
+
try {
|
|
1464
|
+
writeFileSync(abs, parsed.content, "utf-8");
|
|
1465
|
+
jsonReply(res, 200, { ok: true, path: parsed.path });
|
|
1466
|
+
}
|
|
1467
|
+
catch (err) {
|
|
1468
|
+
jsonReply(res, 500, { error: err.message });
|
|
1469
|
+
}
|
|
1470
|
+
// ── Telegram bot routes ─────────────────────────────────────────
|
|
1471
|
+
}
|
|
1472
|
+
else if (url.pathname === "/api/telegram/status" && req.method === "GET") {
|
|
1473
|
+
jsonReply(res, 200, {
|
|
1474
|
+
connected: telegramPolling,
|
|
1475
|
+
botName: telegramBotInfo?.first_name || null,
|
|
1476
|
+
botUsername: telegramBotInfo?.username || null,
|
|
1477
|
+
hasToken: !!telegramToken,
|
|
1478
|
+
allowedUsers: [...telegramAllowedUsers],
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
else if (url.pathname === "/api/telegram/connect" && req.method === "POST") {
|
|
1482
|
+
const body = await readBody(req);
|
|
1483
|
+
try {
|
|
1484
|
+
const parsed = JSON.parse(body);
|
|
1485
|
+
if (parsed.token)
|
|
1486
|
+
telegramToken = parsed.token;
|
|
1487
|
+
if (parsed.allowedUsers !== undefined) {
|
|
1488
|
+
telegramAllowedUsers = new Set(parsed.allowedUsers.split(",")
|
|
1489
|
+
.map((s) => s.trim().toLowerCase().replace(/^@/, ""))
|
|
1490
|
+
.filter(Boolean));
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
catch { /* use existing token */ }
|
|
1494
|
+
if (!telegramToken)
|
|
1495
|
+
return jsonReply(res, 400, { error: "No bot token provided" });
|
|
1496
|
+
// Save token + allowed users to .env for persistence
|
|
1497
|
+
const envPath = join(agentRoot, ".env");
|
|
1498
|
+
let envContent = "";
|
|
1499
|
+
try {
|
|
1500
|
+
envContent = readFileSync(envPath, "utf-8");
|
|
1501
|
+
}
|
|
1502
|
+
catch { /* new file */ }
|
|
1503
|
+
// Save token
|
|
1504
|
+
if (envContent.includes("TELEGRAM_BOT_TOKEN=")) {
|
|
1505
|
+
envContent = envContent.replace(/^TELEGRAM_BOT_TOKEN=.*$/m, `TELEGRAM_BOT_TOKEN=${telegramToken}`);
|
|
1506
|
+
}
|
|
1507
|
+
else {
|
|
1508
|
+
envContent += `\nTELEGRAM_BOT_TOKEN=${telegramToken}\n`;
|
|
1509
|
+
}
|
|
1510
|
+
// Save allowed users
|
|
1511
|
+
const allowedStr = [...telegramAllowedUsers].join(",");
|
|
1512
|
+
if (envContent.includes("TELEGRAM_ALLOWED_USERS=")) {
|
|
1513
|
+
envContent = envContent.replace(/^TELEGRAM_ALLOWED_USERS=.*$/m, `TELEGRAM_ALLOWED_USERS=${allowedStr}`);
|
|
1514
|
+
}
|
|
1515
|
+
else if (allowedStr) {
|
|
1516
|
+
envContent += `TELEGRAM_ALLOWED_USERS=${allowedStr}\n`;
|
|
1517
|
+
}
|
|
1518
|
+
writeFileSync(envPath, envContent, "utf-8");
|
|
1519
|
+
// Validate token by calling getMe
|
|
1520
|
+
try {
|
|
1521
|
+
const meRes = await fetch(`https://api.telegram.org/bot${telegramToken}/getMe`);
|
|
1522
|
+
const meData = await meRes.json();
|
|
1523
|
+
if (!meData.ok)
|
|
1524
|
+
return jsonReply(res, 400, { error: meData.description || "Invalid token" });
|
|
1525
|
+
telegramBotInfo = meData.result;
|
|
1526
|
+
// Start polling
|
|
1527
|
+
startTelegramPolling(agentRoot, opts);
|
|
1528
|
+
jsonReply(res, 200, { ok: true, botName: telegramBotInfo.first_name, botUsername: telegramBotInfo.username });
|
|
1529
|
+
}
|
|
1530
|
+
catch (err) {
|
|
1531
|
+
jsonReply(res, 500, { error: err.message });
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
else if (url.pathname === "/api/telegram/allowed-users" && req.method === "POST") {
|
|
1535
|
+
const body = await readBody(req);
|
|
1536
|
+
try {
|
|
1537
|
+
const parsed = JSON.parse(body);
|
|
1538
|
+
telegramAllowedUsers = new Set((parsed.users || "").split(",")
|
|
1539
|
+
.map((s) => s.trim().toLowerCase().replace(/^@/, ""))
|
|
1540
|
+
.filter(Boolean));
|
|
1541
|
+
// Persist to .env
|
|
1542
|
+
const envPath = join(agentRoot, ".env");
|
|
1543
|
+
let envContent = "";
|
|
1544
|
+
try {
|
|
1545
|
+
envContent = readFileSync(envPath, "utf-8");
|
|
1546
|
+
}
|
|
1547
|
+
catch { /* new file */ }
|
|
1548
|
+
const allowedStr = [...telegramAllowedUsers].join(",");
|
|
1549
|
+
if (envContent.includes("TELEGRAM_ALLOWED_USERS=")) {
|
|
1550
|
+
envContent = envContent.replace(/^TELEGRAM_ALLOWED_USERS=.*$/m, `TELEGRAM_ALLOWED_USERS=${allowedStr}`);
|
|
1551
|
+
}
|
|
1552
|
+
else if (allowedStr) {
|
|
1553
|
+
envContent += `\nTELEGRAM_ALLOWED_USERS=${allowedStr}\n`;
|
|
1554
|
+
}
|
|
1555
|
+
else {
|
|
1556
|
+
envContent = envContent.replace(/^TELEGRAM_ALLOWED_USERS=.*\n?/m, "");
|
|
1557
|
+
}
|
|
1558
|
+
writeFileSync(envPath, envContent, "utf-8");
|
|
1559
|
+
jsonReply(res, 200, { ok: true, allowedUsers: [...telegramAllowedUsers] });
|
|
1560
|
+
}
|
|
1561
|
+
catch (err) {
|
|
1562
|
+
jsonReply(res, 400, { error: err.message });
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
else if (url.pathname === "/api/telegram/disconnect" && req.method === "POST") {
|
|
1566
|
+
stopTelegramPolling();
|
|
1567
|
+
telegramBotInfo = null;
|
|
1568
|
+
jsonReply(res, 200, { ok: true });
|
|
1569
|
+
// ── WhatsApp routes ─────────────────────────────────────────────
|
|
1570
|
+
}
|
|
1571
|
+
else if (url.pathname === "/api/whatsapp/status" && req.method === "GET") {
|
|
1572
|
+
jsonReply(res, 200, {
|
|
1573
|
+
connected: whatsappConnected,
|
|
1574
|
+
phoneNumber: whatsappPhoneNumber,
|
|
1575
|
+
hasAuth: existsSync(join(agentRoot, ".gitagent/whatsapp-auth/creds.json")),
|
|
1576
|
+
qrCode: whatsappQrCode,
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
else if (url.pathname === "/api/whatsapp/connect" && req.method === "POST") {
|
|
1580
|
+
if (whatsappConnected)
|
|
1581
|
+
return jsonReply(res, 200, { ok: true, connected: true, phoneNumber: whatsappPhoneNumber });
|
|
1582
|
+
try {
|
|
1583
|
+
await startWhatsApp(agentRoot, opts);
|
|
1584
|
+
jsonReply(res, 200, { ok: true, connecting: true });
|
|
1585
|
+
}
|
|
1586
|
+
catch (err) {
|
|
1587
|
+
jsonReply(res, 500, { error: err.message });
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
else if (url.pathname === "/api/whatsapp/disconnect" && req.method === "POST") {
|
|
1591
|
+
let clearAuth = false;
|
|
1592
|
+
try {
|
|
1593
|
+
const body = await readBody(req);
|
|
1594
|
+
const parsed = JSON.parse(body);
|
|
1595
|
+
clearAuth = !!parsed.clearAuth;
|
|
1596
|
+
}
|
|
1597
|
+
catch { /* no body is fine */ }
|
|
1598
|
+
stopWhatsApp(clearAuth);
|
|
1599
|
+
jsonReply(res, 200, { ok: true });
|
|
1600
|
+
}
|
|
1601
|
+
else if (url.pathname === "/api/whatsapp/qr" && req.method === "GET") {
|
|
1602
|
+
jsonReply(res, 200, { qrCode: whatsappQrCode, connected: whatsappConnected });
|
|
1603
|
+
// ── Phone / Twilio webhook ──────────────────────────────────────
|
|
1604
|
+
}
|
|
1605
|
+
else if (url.pathname === "/api/phone/webhook" && req.method === "POST") {
|
|
1606
|
+
// Twilio sends SMS/voice webhooks here as application/x-www-form-urlencoded
|
|
1607
|
+
const body = await readBody(req);
|
|
1608
|
+
const params = new URLSearchParams(body);
|
|
1609
|
+
const from = params.get("From") || "";
|
|
1610
|
+
const smsBody = params.get("Body") || "";
|
|
1611
|
+
const callStatus = params.get("CallStatus") || "";
|
|
1612
|
+
if (smsBody) {
|
|
1613
|
+
// Incoming SMS
|
|
1614
|
+
console.log(dim(`[phone] SMS from ${from}: ${smsBody.slice(0, 100)}`));
|
|
1615
|
+
const userMsg = { type: "transcript", role: "user", text: `[SMS ${from}]: ${smsBody}` };
|
|
1616
|
+
appendMessage(opts.agentDir, activeBranch, userMsg);
|
|
1617
|
+
broadcastToBrowsers(userMsg);
|
|
1618
|
+
// Check triggers
|
|
1619
|
+
const senderName = from.replace(/[^0-9]/g, "");
|
|
1620
|
+
const contact = loadContacts(opts.agentDir).find(c => c.phone === senderName || from.includes(c.phone));
|
|
1621
|
+
const trigger = matchTrigger(opts.agentDir, "phone", contact?.name || from, smsBody);
|
|
1622
|
+
if (trigger) {
|
|
1623
|
+
console.log(dim(`[triggers] Phone trigger ${trigger.id}: "${smsBody.slice(0, 40)}" → "${trigger.reply.slice(0, 40)}"`));
|
|
1624
|
+
// Reply with TwiML
|
|
1625
|
+
res.writeHead(200, { "Content-Type": "text/xml" });
|
|
1626
|
+
res.end(`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${escapeXml(trigger.reply)}</Message></Response>`);
|
|
1627
|
+
const triggerLog = { type: "transcript", role: "assistant", text: `[Trigger → ${from}]: ${trigger.reply}` };
|
|
1628
|
+
appendMessage(opts.agentDir, activeBranch, triggerLog);
|
|
1629
|
+
broadcastToBrowsers(triggerLog);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
// Run agent for non-triggered messages
|
|
1633
|
+
try {
|
|
1634
|
+
const phoneContext = await getAgentContext(opts.agentDir, activeBranch);
|
|
1635
|
+
const phoneComposio = await getComposioContext(smsBody);
|
|
1636
|
+
let phoneSystemPrompt = "You are an AI assistant responding to an SMS message via Twilio. " +
|
|
1637
|
+
"Keep responses concise — SMS has character limits. Respond in plain text only.";
|
|
1638
|
+
phoneSystemPrompt += "\n\n" + getCurrentDateTimeContext();
|
|
1639
|
+
if (phoneComposio.promptSuffix)
|
|
1640
|
+
phoneSystemPrompt += "\n\n" + phoneComposio.promptSuffix;
|
|
1641
|
+
if (phoneContext)
|
|
1642
|
+
phoneSystemPrompt += "\n\n" + phoneContext;
|
|
1643
|
+
const phoneTools = [
|
|
1644
|
+
...createTriggerTools(opts.agentDir),
|
|
1645
|
+
...(whatsappSock && whatsappConnected ? createWhatsAppTools(whatsappSock, opts.agentDir) : []),
|
|
1646
|
+
...phoneComposio.tools,
|
|
1647
|
+
];
|
|
1648
|
+
const result = query({
|
|
1649
|
+
prompt: `[SMS from ${from}]: ${smsBody}`,
|
|
1650
|
+
dir: opts.agentDir,
|
|
1651
|
+
model: opts.model,
|
|
1652
|
+
env: opts.env,
|
|
1653
|
+
maxTurns: 5,
|
|
1654
|
+
systemPrompt: phoneSystemPrompt,
|
|
1655
|
+
...(phoneTools.length ? { tools: phoneTools } : {}),
|
|
1656
|
+
});
|
|
1657
|
+
let reply = "";
|
|
1658
|
+
for await (const m of result) {
|
|
1659
|
+
if (m.type === "assistant" && m.content)
|
|
1660
|
+
reply += m.content;
|
|
1661
|
+
}
|
|
1662
|
+
reply = reply.trim().slice(0, 1600); // SMS limit
|
|
1663
|
+
const assistantMsg = { type: "transcript", role: "assistant", text: `[SMS → ${from}]: ${reply}` };
|
|
1664
|
+
appendMessage(opts.agentDir, activeBranch, assistantMsg);
|
|
1665
|
+
broadcastToBrowsers(assistantMsg);
|
|
1666
|
+
res.writeHead(200, { "Content-Type": "text/xml" });
|
|
1667
|
+
res.end(`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${escapeXml(reply)}</Message></Response>`);
|
|
1668
|
+
}
|
|
1669
|
+
catch (err) {
|
|
1670
|
+
console.error(dim(`[phone] Agent error: ${err.message}`));
|
|
1671
|
+
res.writeHead(200, { "Content-Type": "text/xml" });
|
|
1672
|
+
res.end(`<?xml version="1.0" encoding="UTF-8"?><Response><Message>Sorry, something went wrong.</Message></Response>`);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
else if (callStatus) {
|
|
1676
|
+
// Voice call webhook — just acknowledge for now
|
|
1677
|
+
console.log(dim(`[phone] Call from ${from}, status: ${callStatus}`));
|
|
1678
|
+
res.writeHead(200, { "Content-Type": "text/xml" });
|
|
1679
|
+
res.end(`<?xml version="1.0" encoding="UTF-8"?><Response><Say>This number is managed by ${agentName}. Please send a text message instead.</Say></Response>`);
|
|
1680
|
+
}
|
|
1681
|
+
else {
|
|
1682
|
+
res.writeHead(200, { "Content-Type": "text/xml" });
|
|
1683
|
+
res.end(`<?xml version="1.0" encoding="UTF-8"?><Response></Response>`);
|
|
1684
|
+
}
|
|
1685
|
+
// ── Composio OAuth callback ─────────────────────────────────────
|
|
1686
|
+
}
|
|
1687
|
+
else if (url.pathname === "/api/composio/callback") {
|
|
1688
|
+
// OAuth popup lands here after Composio processes the auth code.
|
|
1689
|
+
// Send a message to the opener window and close the popup.
|
|
1690
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1691
|
+
res.end(`<!DOCTYPE html><html><body><script>
|
|
1692
|
+
if(window.opener){window.opener.postMessage({type:'composio_auth_complete'},'*');}
|
|
1693
|
+
window.close();
|
|
1694
|
+
</script><p>Authentication complete. You can close this window.</p></body></html>`);
|
|
1695
|
+
// ── Chat branch API routes ──────────────────────────────────────
|
|
1696
|
+
}
|
|
1697
|
+
else if (url.pathname === "/api/chat/list" && req.method === "GET") {
|
|
1698
|
+
try {
|
|
1699
|
+
const git = (cmd) => execSync(cmd, { cwd: agentRoot, encoding: "utf-8" }).trim();
|
|
1700
|
+
const current = git("git rev-parse --abbrev-ref HEAD");
|
|
1701
|
+
// List branches matching chat/* pattern, plus the current branch
|
|
1702
|
+
let branches;
|
|
1703
|
+
try {
|
|
1704
|
+
branches = git("git branch --list 'chat/*' --sort=-committerdate --format='%(refname:short)|%(committerdate:relative)'")
|
|
1705
|
+
.split("\n").filter(Boolean);
|
|
1706
|
+
}
|
|
1707
|
+
catch {
|
|
1708
|
+
branches = [];
|
|
1709
|
+
}
|
|
1710
|
+
const chats = branches.map((line) => {
|
|
1711
|
+
const [branch, time] = line.split("|");
|
|
1712
|
+
const name = branch.replace("chat/", "");
|
|
1713
|
+
return { branch, name, time: time || "" };
|
|
1714
|
+
});
|
|
1715
|
+
// If current branch is not a chat/* branch, add it at the top
|
|
1716
|
+
if (!current.startsWith("chat/")) {
|
|
1717
|
+
chats.unshift({ branch: current, name: current, time: "current" });
|
|
1718
|
+
}
|
|
1719
|
+
jsonReply(res, 200, { current, chats });
|
|
1720
|
+
}
|
|
1721
|
+
catch (err) {
|
|
1722
|
+
jsonReply(res, 500, { error: err.message });
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
else if (url.pathname === "/api/chat/new" && req.method === "POST") {
|
|
1726
|
+
try {
|
|
1727
|
+
const git = (cmd) => execSync(cmd, { cwd: agentRoot, encoding: "utf-8" }).trim();
|
|
1728
|
+
// Generate branch name: chat/YYYY-MM-DD-HHMMSS
|
|
1729
|
+
const now = new Date();
|
|
1730
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1731
|
+
const branch = `chat/${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
1732
|
+
// Stage and commit any pending changes on current branch
|
|
1733
|
+
try {
|
|
1734
|
+
git("git add -A");
|
|
1735
|
+
git('git commit -m "auto-save before new chat" --allow-empty');
|
|
1736
|
+
}
|
|
1737
|
+
catch {
|
|
1738
|
+
// No changes to commit, that's fine
|
|
1739
|
+
}
|
|
1740
|
+
// Create and switch to new branch
|
|
1741
|
+
git(`git checkout -b ${branch}`);
|
|
1742
|
+
activeBranch = branch;
|
|
1743
|
+
jsonReply(res, 200, { branch });
|
|
1744
|
+
}
|
|
1745
|
+
catch (err) {
|
|
1746
|
+
jsonReply(res, 500, { error: err.message });
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
else if (url.pathname === "/api/chat/switch" && req.method === "POST") {
|
|
1750
|
+
try {
|
|
1751
|
+
const body = await readBody(req);
|
|
1752
|
+
const { branch } = JSON.parse(body);
|
|
1753
|
+
if (!branch)
|
|
1754
|
+
return jsonReply(res, 400, { error: "Missing branch" });
|
|
1755
|
+
const git = (cmd) => execSync(cmd, { cwd: agentRoot, encoding: "utf-8" }).trim();
|
|
1756
|
+
// Auto-save current branch
|
|
1757
|
+
try {
|
|
1758
|
+
git("git add -A");
|
|
1759
|
+
git('git commit -m "auto-save before switching chat" --allow-empty');
|
|
1760
|
+
}
|
|
1761
|
+
catch { }
|
|
1762
|
+
git(`git checkout ${branch}`);
|
|
1763
|
+
activeBranch = branch;
|
|
1764
|
+
jsonReply(res, 200, { branch });
|
|
1765
|
+
}
|
|
1766
|
+
catch (err) {
|
|
1767
|
+
jsonReply(res, 500, { error: err.message });
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
else if (url.pathname === "/api/chat/delete" && req.method === "POST") {
|
|
1771
|
+
try {
|
|
1772
|
+
const body = await readBody(req);
|
|
1773
|
+
const { branch } = JSON.parse(body);
|
|
1774
|
+
if (!branch)
|
|
1775
|
+
return jsonReply(res, 400, { error: "Missing branch" });
|
|
1776
|
+
const git = (cmd) => execSync(cmd, { cwd: agentRoot, encoding: "utf-8" }).trim();
|
|
1777
|
+
const current = git("git rev-parse --abbrev-ref HEAD");
|
|
1778
|
+
if (branch === current)
|
|
1779
|
+
return jsonReply(res, 400, { error: "Cannot delete the active branch" });
|
|
1780
|
+
git(`git branch -D ${branch}`);
|
|
1781
|
+
deleteHistory(opts.agentDir, branch);
|
|
1782
|
+
jsonReply(res, 200, { ok: true });
|
|
1783
|
+
}
|
|
1784
|
+
catch (err) {
|
|
1785
|
+
jsonReply(res, 500, { error: err.message });
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
else if (url.pathname === "/api/chat/history" && req.method === "GET") {
|
|
1789
|
+
const branch = url.searchParams.get("branch");
|
|
1790
|
+
if (!branch)
|
|
1791
|
+
return jsonReply(res, 400, { error: "Missing branch param" });
|
|
1792
|
+
const messages = loadHistory(opts.agentDir, branch);
|
|
1793
|
+
jsonReply(res, 200, { branch, messages });
|
|
1794
|
+
// ── Composio API routes ─────────────────────────────────────────
|
|
1795
|
+
}
|
|
1796
|
+
else if (url.pathname === "/api/composio/toolkits" && req.method === "GET") {
|
|
1797
|
+
if (!composioAdapter)
|
|
1798
|
+
return jsonReply(res, 501, { error: "Composio not configured" });
|
|
1799
|
+
try {
|
|
1800
|
+
const toolkits = await composioAdapter.getToolkits();
|
|
1801
|
+
jsonReply(res, 200, toolkits);
|
|
1802
|
+
}
|
|
1803
|
+
catch (err) {
|
|
1804
|
+
jsonReply(res, 502, { error: err.message });
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
else if (url.pathname === "/api/composio/connect" && req.method === "POST") {
|
|
1808
|
+
if (!composioAdapter)
|
|
1809
|
+
return jsonReply(res, 501, { error: "Composio not configured" });
|
|
1810
|
+
const body = await readBody(req);
|
|
1811
|
+
let parsed;
|
|
1812
|
+
try {
|
|
1813
|
+
parsed = JSON.parse(body);
|
|
1814
|
+
}
|
|
1815
|
+
catch {
|
|
1816
|
+
return jsonReply(res, 400, { error: "Invalid JSON" });
|
|
1817
|
+
}
|
|
1818
|
+
if (!parsed.toolkit)
|
|
1819
|
+
return jsonReply(res, 400, { error: "Missing toolkit" });
|
|
1820
|
+
try {
|
|
1821
|
+
const result = await composioAdapter.connect(parsed.toolkit, parsed.redirectUrl);
|
|
1822
|
+
jsonReply(res, 200, result);
|
|
1823
|
+
}
|
|
1824
|
+
catch (err) {
|
|
1825
|
+
jsonReply(res, 502, { error: err.message });
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
else if (url.pathname === "/api/composio/connections" && req.method === "GET") {
|
|
1829
|
+
if (!composioAdapter)
|
|
1830
|
+
return jsonReply(res, 501, { error: "Composio not configured" });
|
|
1831
|
+
try {
|
|
1832
|
+
const connections = await composioAdapter.getConnections();
|
|
1833
|
+
jsonReply(res, 200, connections);
|
|
1834
|
+
}
|
|
1835
|
+
catch (err) {
|
|
1836
|
+
jsonReply(res, 502, { error: err.message });
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
else if (url.pathname.match(/^\/api\/composio\/connections\/[^/]+$/) && req.method === "DELETE") {
|
|
1840
|
+
if (!composioAdapter)
|
|
1841
|
+
return jsonReply(res, 501, { error: "Composio not configured" });
|
|
1842
|
+
const connId = url.pathname.split("/").pop();
|
|
1843
|
+
try {
|
|
1844
|
+
await composioAdapter.disconnect(connId);
|
|
1845
|
+
jsonReply(res, 200, { ok: true });
|
|
1846
|
+
}
|
|
1847
|
+
catch (err) {
|
|
1848
|
+
jsonReply(res, 502, { error: err.message });
|
|
1849
|
+
}
|
|
1850
|
+
// ── Skills Marketplace proxy ────────────────────────────────────
|
|
1851
|
+
}
|
|
1852
|
+
else if (url.pathname === "/api/skills-mp/proxy" && req.method === "GET") {
|
|
1853
|
+
const proxyPath = url.searchParams.get("path") || "/";
|
|
1854
|
+
const targetUrl = `https://skills.sh${proxyPath.startsWith("/") ? proxyPath : "/" + proxyPath}`;
|
|
1855
|
+
try {
|
|
1856
|
+
const proxyRes = await fetch(targetUrl, {
|
|
1857
|
+
headers: {
|
|
1858
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
1859
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
1860
|
+
},
|
|
1861
|
+
redirect: "follow",
|
|
1862
|
+
});
|
|
1863
|
+
const contentType = proxyRes.headers.get("content-type") || "";
|
|
1864
|
+
// Non-HTML resources: pass through directly
|
|
1865
|
+
if (!contentType.includes("text/html")) {
|
|
1866
|
+
const buffer = Buffer.from(await proxyRes.arrayBuffer());
|
|
1867
|
+
res.writeHead(proxyRes.status, {
|
|
1868
|
+
"Content-Type": contentType,
|
|
1869
|
+
"Cache-Control": proxyRes.headers.get("cache-control") || "public, max-age=3600",
|
|
1870
|
+
});
|
|
1871
|
+
res.end(buffer);
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
let html = await proxyRes.text();
|
|
1875
|
+
// Rewrite relative src/href to absolute skills.sh URLs so assets load correctly
|
|
1876
|
+
// (Do NOT rewrite href for navigation links — that breaks React hydration.
|
|
1877
|
+
// Navigation is handled by client-side click/history interception instead.)
|
|
1878
|
+
html = html.replace(/src="\/(?!\/)/g, 'src="https://skills.sh/');
|
|
1879
|
+
html = html.replace(/src='\/(?!\/)/g, "src='https://skills.sh/");
|
|
1880
|
+
// Rewrite stylesheet/preload hrefs to load from skills.sh
|
|
1881
|
+
html = html.replace(/href="\/_(next|static)\//g, 'href="https://skills.sh/_$1/');
|
|
1882
|
+
html = html.replace(/href='\/_(next|static)\//g, "href='https://skills.sh/_$1/");
|
|
1883
|
+
// Inject our custom script before </body>
|
|
1884
|
+
const injectedScript = `
|
|
1885
|
+
<script>
|
|
1886
|
+
(function() {
|
|
1887
|
+
const PROXY_BASE = '/api/skills-mp/proxy?path=';
|
|
1888
|
+
|
|
1889
|
+
// Fetch installed skills from lock file
|
|
1890
|
+
var __installedSkills = new Set();
|
|
1891
|
+
var __installedSources = new Set();
|
|
1892
|
+
fetch('/api/skills-mp/installed').then(function(r){return r.json();}).then(function(d){
|
|
1893
|
+
if(d&&d.installed) __installedSkills = new Set(d.installed);
|
|
1894
|
+
if(d&&d.sources) __installedSources = new Set(d.sources);
|
|
1895
|
+
processSkillButtons();
|
|
1896
|
+
}).catch(function(){});
|
|
1897
|
+
|
|
1898
|
+
// Intercept fetch to route API calls to skills.sh (including full-origin URLs from Next.js)
|
|
1899
|
+
const _fetch = window.fetch;
|
|
1900
|
+
// e.g. fetch('http://localhost:3000/_next/data/...')
|
|
1901
|
+
window.fetch = function(url, opts) {
|
|
1902
|
+
if (typeof url === 'string') {
|
|
1903
|
+
if (url.startsWith('/') && !url.startsWith('//') && !url.startsWith('/api/skills-mp/')) {
|
|
1904
|
+
url = 'https://skills.sh' + url;
|
|
1905
|
+
} else if (url.startsWith(location.origin + '/')) {
|
|
1906
|
+
var path = url.slice(location.origin.length);
|
|
1907
|
+
if (!path.startsWith('/api/skills-mp/')) {
|
|
1908
|
+
url = 'https://skills.sh' + path;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
} else if (url instanceof Request) {
|
|
1912
|
+
var rUrl = url.url;
|
|
1913
|
+
if (rUrl.startsWith(location.origin + '/')) {
|
|
1914
|
+
var rPath = rUrl.slice(location.origin.length);
|
|
1915
|
+
if (!rPath.startsWith('/api/skills-mp/')) {
|
|
1916
|
+
url = new Request('https://skills.sh' + rPath, url);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
return _fetch.call(this, url, opts);
|
|
1921
|
+
};
|
|
1922
|
+
|
|
1923
|
+
// Intercept XMLHttpRequest.open
|
|
1924
|
+
const _xhrOpen = XMLHttpRequest.prototype.open;
|
|
1925
|
+
XMLHttpRequest.prototype.open = function(method, url) {
|
|
1926
|
+
var args = Array.prototype.slice.call(arguments, 2);
|
|
1927
|
+
if (typeof url === 'string') {
|
|
1928
|
+
if (url.startsWith('/') && !url.startsWith('//') && !url.startsWith('/api/skills-mp/')) {
|
|
1929
|
+
url = 'https://skills.sh' + url;
|
|
1930
|
+
} else if (url.startsWith(location.origin + '/')) {
|
|
1931
|
+
var path = url.slice(location.origin.length);
|
|
1932
|
+
if (!path.startsWith('/api/skills-mp/')) {
|
|
1933
|
+
url = 'https://skills.sh' + path;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
return _xhrOpen.apply(this, [method, url].concat(args));
|
|
1938
|
+
};
|
|
1939
|
+
|
|
1940
|
+
// Intercept history.pushState/replaceState for Next.js client-side navigation
|
|
1941
|
+
var _pushState = history.pushState;
|
|
1942
|
+
var _replaceState = history.replaceState;
|
|
1943
|
+
function rewriteHistoryUrl(originalFn, data, title, url) {
|
|
1944
|
+
if (typeof url === 'string' && url.startsWith('/') && !url.startsWith('//') && !url.startsWith('/api/skills-mp/')) {
|
|
1945
|
+
url = PROXY_BASE + url;
|
|
1946
|
+
}
|
|
1947
|
+
return originalFn.call(history, data, title, url);
|
|
1948
|
+
}
|
|
1949
|
+
history.pushState = function(data, title, url) { return rewriteHistoryUrl(_pushState, data, title, url); };
|
|
1950
|
+
history.replaceState = function(data, title, url) { return rewriteHistoryUrl(_replaceState, data, title, url); };
|
|
1951
|
+
|
|
1952
|
+
// Intercept form submissions
|
|
1953
|
+
document.addEventListener('submit', function(e) {
|
|
1954
|
+
var form = e.target;
|
|
1955
|
+
if (!form || !form.action) return;
|
|
1956
|
+
var action = form.getAttribute('action');
|
|
1957
|
+
if (action && action.startsWith('/') && !action.startsWith('//') && !action.startsWith('/api/skills-mp/')) {
|
|
1958
|
+
e.preventDefault();
|
|
1959
|
+
var params = new URLSearchParams(new FormData(form)).toString();
|
|
1960
|
+
window.location.href = PROXY_BASE + action + (params ? (action.includes('?') ? '&' : '?') + params : '');
|
|
1961
|
+
}
|
|
1962
|
+
}, true);
|
|
1963
|
+
|
|
1964
|
+
function processSkillButtons() {
|
|
1965
|
+
// Find elements with npx skills add commands
|
|
1966
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
1967
|
+
const textNodes = [];
|
|
1968
|
+
while (walker.nextNode()) textNodes.push(walker.currentNode);
|
|
1969
|
+
|
|
1970
|
+
for (const node of textNodes) {
|
|
1971
|
+
const match = node.textContent && node.textContent.match(/npx\\s+skills\\s+add\\s+(\\S+)/);
|
|
1972
|
+
if (!match) continue;
|
|
1973
|
+
|
|
1974
|
+
// Find the closest interactive parent (button, code block, pre)
|
|
1975
|
+
let target = node.parentElement;
|
|
1976
|
+
while (target && !['BUTTON','PRE','CODE','DIV'].includes(target.tagName)) {
|
|
1977
|
+
target = target.parentElement;
|
|
1978
|
+
}
|
|
1979
|
+
if (!target || target.dataset.gcProcessed) continue;
|
|
1980
|
+
target.dataset.gcProcessed = 'true';
|
|
1981
|
+
|
|
1982
|
+
const source = match[1].replace(/^https:\\/\\/github\\.com\\//, '');
|
|
1983
|
+
const alreadyInstalled = __installedSources.has(source) || __installedSkills.has(source.split('/').pop());
|
|
1984
|
+
const btn = document.createElement('button');
|
|
1985
|
+
if (alreadyInstalled) {
|
|
1986
|
+
btn.textContent = 'Installed';
|
|
1987
|
+
btn.disabled = true;
|
|
1988
|
+
btn.style.cssText = 'background:#1a7f37;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:default;font-size:14px;font-weight:600;margin:4px;opacity:0.85;';
|
|
1989
|
+
} else {
|
|
1990
|
+
btn.textContent = 'Install on GitClaw';
|
|
1991
|
+
btn.style.cssText = 'background:#238636;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:14px;font-weight:600;margin:4px;';
|
|
1992
|
+
btn.onmouseenter = function(){ btn.style.background='#2ea043'; };
|
|
1993
|
+
btn.onmouseleave = function(){ btn.style.background='#238636'; };
|
|
1994
|
+
btn.onclick = function(e) {
|
|
1995
|
+
e.preventDefault();
|
|
1996
|
+
e.stopPropagation();
|
|
1997
|
+
btn.disabled = true;
|
|
1998
|
+
btn.textContent = 'Installing...';
|
|
1999
|
+
window.parent.postMessage({ type: 'install_skill', source: source }, '*');
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
target.parentElement.insertBefore(btn, target.nextSibling);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
window.addEventListener('message', function(msg) {
|
|
2007
|
+
if (!msg.data || msg.data.type !== 'install_success') return;
|
|
2008
|
+
var btns = document.querySelectorAll('button');
|
|
2009
|
+
btns.forEach(function(b) {
|
|
2010
|
+
if (b.textContent === 'Installing...') {
|
|
2011
|
+
b.textContent = 'Installed';
|
|
2012
|
+
b.style.background = '#1a7f37';
|
|
2013
|
+
b.style.cursor = 'default';
|
|
2014
|
+
b.style.opacity = '0.85';
|
|
2015
|
+
}
|
|
2016
|
+
});
|
|
2017
|
+
// Refresh installed set so future processSkillButtons calls are up to date
|
|
2018
|
+
fetch('/api/skills-mp/installed').then(function(r){return r.json();}).then(function(d){
|
|
2019
|
+
if(d&&d.installed) __installedSkills = new Set(d.installed);
|
|
2020
|
+
if(d&&d.sources) __installedSources = new Set(d.sources);
|
|
2021
|
+
}).catch(function(){});
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
// Intercept link clicks to route through proxy
|
|
2025
|
+
document.addEventListener('click', function(e) {
|
|
2026
|
+
const a = e.target.closest('a');
|
|
2027
|
+
if (!a) return;
|
|
2028
|
+
const href = a.getAttribute('href');
|
|
2029
|
+
if (!href) return;
|
|
2030
|
+
// Already proxied
|
|
2031
|
+
if (href.startsWith('/api/skills-mp/proxy')) return;
|
|
2032
|
+
// Internal skills.sh links
|
|
2033
|
+
if (href.startsWith('/') && !href.startsWith('//')) {
|
|
2034
|
+
e.preventDefault();
|
|
2035
|
+
window.location.href = PROXY_BASE + href;
|
|
2036
|
+
} else if (href.startsWith('https://skills.sh/') || href.startsWith('https://skillsmp.com/')) {
|
|
2037
|
+
e.preventDefault();
|
|
2038
|
+
const path = new URL(href).pathname;
|
|
2039
|
+
window.location.href = PROXY_BASE + path;
|
|
2040
|
+
}
|
|
2041
|
+
}, true);
|
|
2042
|
+
|
|
2043
|
+
// Run on load + observe for SPA changes
|
|
2044
|
+
if (document.readyState === 'loading') {
|
|
2045
|
+
document.addEventListener('DOMContentLoaded', processSkillButtons);
|
|
2046
|
+
} else {
|
|
2047
|
+
processSkillButtons();
|
|
2048
|
+
}
|
|
2049
|
+
new MutationObserver(function() { processSkillButtons(); })
|
|
2050
|
+
.observe(document.body || document.documentElement, { childList: true, subtree: true });
|
|
2051
|
+
})();
|
|
2052
|
+
</script>`;
|
|
2053
|
+
html = html.replace(/<\/body>/i, injectedScript + "</body>");
|
|
2054
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2055
|
+
res.end(html);
|
|
2056
|
+
}
|
|
2057
|
+
catch (err) {
|
|
2058
|
+
// Fallback if skills.sh is unreachable
|
|
2059
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2060
|
+
res.end(`<!DOCTYPE html>
|
|
2061
|
+
<html><head><style>body{background:#0d1117;color:#c9d1d9;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;text-align:center;}
|
|
2062
|
+
a{color:#58a6ff;}</style></head>
|
|
2063
|
+
<body><div><h2>Skills Marketplace Unavailable</h2><p>Could not reach skills.sh: ${err.message}</p>
|
|
2064
|
+
<p><a href="https://skills.sh" target="_blank">Open skills.sh in a new tab</a></p></div></body></html>`);
|
|
2065
|
+
}
|
|
2066
|
+
// ── Skills Marketplace installed list ────────────────────────────
|
|
2067
|
+
}
|
|
2068
|
+
else if (url.pathname === "/api/skills-mp/installed" && req.method === "GET") {
|
|
2069
|
+
try {
|
|
2070
|
+
const lockPath = join(agentRoot, "skills-lock.json");
|
|
2071
|
+
if (existsSync(lockPath)) {
|
|
2072
|
+
const lock = JSON.parse(readFileSync(lockPath, "utf-8"));
|
|
2073
|
+
const skills = lock.skills || {};
|
|
2074
|
+
const names = Object.keys(skills);
|
|
2075
|
+
// Build a set of sources (repo slugs) that have installed skills
|
|
2076
|
+
const sources = [...new Set(Object.values(skills).map((s) => s.source))];
|
|
2077
|
+
jsonReply(res, 200, { installed: names, sources });
|
|
2078
|
+
}
|
|
2079
|
+
else {
|
|
2080
|
+
jsonReply(res, 200, { installed: [], sources: [] });
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
catch (err) {
|
|
2084
|
+
jsonReply(res, 200, { installed: [], sources: [] });
|
|
2085
|
+
}
|
|
2086
|
+
// ── Skills Marketplace install ──────────────────────────────────
|
|
2087
|
+
}
|
|
2088
|
+
else if (url.pathname === "/api/skills-mp/install" && req.method === "POST") {
|
|
2089
|
+
let body = "";
|
|
2090
|
+
for await (const chunk of req)
|
|
2091
|
+
body += chunk;
|
|
2092
|
+
try {
|
|
2093
|
+
const { source } = JSON.parse(body);
|
|
2094
|
+
if (!source)
|
|
2095
|
+
return jsonReply(res, 400, { error: "Missing source" });
|
|
2096
|
+
// Shell out to the skills CLI — it handles all install logic
|
|
2097
|
+
const cleanSource = source.replace(/^https?:\/\/github\.com\//, "");
|
|
2098
|
+
const skillsDir = join(agentRoot, "skills");
|
|
2099
|
+
const before = new Set(existsSync(skillsDir) ? readdirSync(skillsDir) : []);
|
|
2100
|
+
execSync(`npx -y skills add -y ${cleanSource}`, {
|
|
2101
|
+
cwd: agentRoot,
|
|
2102
|
+
encoding: "utf-8",
|
|
2103
|
+
timeout: 120000,
|
|
2104
|
+
});
|
|
2105
|
+
// Detect which skill directories were added (symlinked into skills/)
|
|
2106
|
+
const after = existsSync(skillsDir) ? readdirSync(skillsDir) : [];
|
|
2107
|
+
const added = after.filter(d => !before.has(d));
|
|
2108
|
+
const skillNames = added.length ? added : [cleanSource.split("/")[1] || cleanSource];
|
|
2109
|
+
console.log(dim(`[voice] Installed skill(s): ${skillNames.join(", ")} via npx skills add`));
|
|
2110
|
+
jsonReply(res, 200, { ok: true, skillName: skillNames.join(", "), path: `skills/`, installed: skillNames });
|
|
2111
|
+
}
|
|
2112
|
+
catch (err) {
|
|
2113
|
+
jsonReply(res, 500, { error: err.message });
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
else {
|
|
2117
|
+
res.writeHead(404);
|
|
2118
|
+
res.end();
|
|
2119
|
+
}
|
|
2120
|
+
});
|
|
2121
|
+
// WebSocket server — adapter-agnostic proxy
|
|
2122
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
2123
|
+
wss.on("connection", async (browserWs) => {
|
|
2124
|
+
console.log(dim("[voice] Browser connected"));
|
|
2125
|
+
// ── Per-connection frame buffer + moment capture state ──────────
|
|
2126
|
+
let latestVideoFrame = null;
|
|
2127
|
+
let lastFrameWriteTs = 0;
|
|
2128
|
+
let latestScreenFrame = null;
|
|
2129
|
+
let lastScreenWriteTs = 0;
|
|
2130
|
+
let lastMomentCaptureTs = 0;
|
|
2131
|
+
const FRAME_WRITE_INTERVAL = 2000; // Write temp frame to disk every 2s
|
|
2132
|
+
const MOMENT_COOLDOWN = 60000; // 60s between auto-captures
|
|
2133
|
+
const moodCounts = { happy: 0, frustrated: 0, curious: 0, excited: 0, calm: 0 };
|
|
2134
|
+
let sessionMessageCount = 0;
|
|
2135
|
+
// Inject shared context (memory + conversation summary) into voice LLM instructions
|
|
2136
|
+
const voiceContext = await getVoiceContext(opts.agentDir, activeBranch);
|
|
2137
|
+
let instructions = opts.adapterConfig.instructions || "";
|
|
2138
|
+
if (voiceContext) {
|
|
2139
|
+
instructions += "\n\n" + voiceContext;
|
|
2140
|
+
}
|
|
2141
|
+
// Inject Composio awareness into adapter instructions so the voice LLM
|
|
2142
|
+
// never tells the user "I can't access" external services
|
|
2143
|
+
const adapterOpts = composioAdapter ? {
|
|
2144
|
+
...opts,
|
|
2145
|
+
adapterConfig: {
|
|
2146
|
+
...opts.adapterConfig,
|
|
2147
|
+
instructions: instructions +
|
|
2148
|
+
" The agent has FULL access to external services via Composio — Gmail, Google Calendar, GitHub, Slack, and more. " +
|
|
2149
|
+
"When the user asks to send emails, check calendars, or interact with any external service, ALWAYS use run_agent to handle it. " +
|
|
2150
|
+
"NEVER say you can't access these services or that you don't have these tools. The agent has them. Just call run_agent.",
|
|
2151
|
+
},
|
|
2152
|
+
} : {
|
|
2153
|
+
...opts,
|
|
2154
|
+
adapterConfig: {
|
|
2155
|
+
...opts.adapterConfig,
|
|
2156
|
+
instructions,
|
|
2157
|
+
},
|
|
2158
|
+
};
|
|
2159
|
+
const adapter = createAdapter(adapterOpts);
|
|
2160
|
+
const sendToBrowser = (msg) => {
|
|
2161
|
+
safeSend(browserWs, JSON.stringify(msg));
|
|
2162
|
+
appendMessage(opts.agentDir, activeBranch, msg);
|
|
2163
|
+
// Track mood from user transcripts
|
|
2164
|
+
if (msg.type === "transcript" && msg.role === "user" && !msg.partial) {
|
|
2165
|
+
sessionMessageCount++;
|
|
2166
|
+
const mood = detectMood(msg.text);
|
|
2167
|
+
if (mood)
|
|
2168
|
+
moodCounts[mood]++;
|
|
2169
|
+
}
|
|
2170
|
+
// Detect personal info in voice transcripts and save to memory
|
|
2171
|
+
if (msg.type === "transcript" && msg.role === "user" && !msg.partial && isMemoryWorthy(msg.text)) {
|
|
2172
|
+
saveMemoryInBackground(msg.text, opts.agentDir, opts.model, opts.env, () => {
|
|
2173
|
+
broadcastToBrowsers({ type: "memory_saving", status: "start", text: msg.text.slice(0, 60) });
|
|
2174
|
+
}, () => {
|
|
2175
|
+
broadcastToBrowsers({ type: "memory_saving", status: "done" });
|
|
2176
|
+
safeSend(browserWs, JSON.stringify({ type: "files_changed" }));
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
// Auto-capture photo on memorable moments (with 60s cooldown)
|
|
2180
|
+
if (msg.type === "transcript" && msg.role === "user" && !msg.partial && isMomentWorthy(msg.text)) {
|
|
2181
|
+
const now = Date.now();
|
|
2182
|
+
if (now - lastMomentCaptureTs >= MOMENT_COOLDOWN) {
|
|
2183
|
+
lastMomentCaptureTs = now;
|
|
2184
|
+
// Use buffered frame if available and fresh (<5s)
|
|
2185
|
+
let frameBuffer;
|
|
2186
|
+
if (latestVideoFrame && (now - latestVideoFrame.ts) < 5000) {
|
|
2187
|
+
frameBuffer = Buffer.from(latestVideoFrame.frame, "base64");
|
|
2188
|
+
}
|
|
2189
|
+
capturePhoto(agentRoot, msg.text.slice(0, 60), frameBuffer).catch((err) => {
|
|
2190
|
+
console.error(dim(`[voice] Auto photo capture failed: ${err.message}`));
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
};
|
|
2195
|
+
try {
|
|
2196
|
+
await adapter.connect({
|
|
2197
|
+
toolHandler: createToolHandler(sendToBrowser),
|
|
2198
|
+
onMessage: sendToBrowser,
|
|
2199
|
+
});
|
|
2200
|
+
console.log(dim(`[voice] Adapter ready (${opts.adapter})`));
|
|
2201
|
+
}
|
|
2202
|
+
catch (err) {
|
|
2203
|
+
console.error(dim(`[voice] Adapter connection failed: ${err.message}`));
|
|
2204
|
+
safeSend(browserWs, JSON.stringify({ type: "error", message: `Adapter failed: ${err.message}` }));
|
|
2205
|
+
browserWs.close();
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
// Parse browser messages into ClientMessage and forward to adapter
|
|
2209
|
+
browserWs.on("message", (data) => {
|
|
2210
|
+
try {
|
|
2211
|
+
const msg = JSON.parse(data.toString());
|
|
2212
|
+
// Buffer video frames and throttle-write to disk for capture_photo tool
|
|
2213
|
+
if (msg.type === "video_frame") {
|
|
2214
|
+
const source = msg.source || "camera";
|
|
2215
|
+
if (source === "screen") {
|
|
2216
|
+
latestScreenFrame = { frame: msg.frame, mimeType: msg.mimeType, ts: Date.now() };
|
|
2217
|
+
const now = Date.now();
|
|
2218
|
+
if (now - lastScreenWriteTs >= 3000) {
|
|
2219
|
+
lastScreenWriteTs = now;
|
|
2220
|
+
const frameBuffer = Buffer.from(msg.frame, "base64");
|
|
2221
|
+
const framePath = join(agentRoot, LATEST_SCREEN_FILE);
|
|
2222
|
+
writeFile(framePath, frameBuffer).catch(() => { });
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
else {
|
|
2226
|
+
latestVideoFrame = { frame: msg.frame, mimeType: msg.mimeType, ts: Date.now() };
|
|
2227
|
+
const now = Date.now();
|
|
2228
|
+
if (now - lastFrameWriteTs >= FRAME_WRITE_INTERVAL) {
|
|
2229
|
+
lastFrameWriteTs = now;
|
|
2230
|
+
const frameBuffer = Buffer.from(msg.frame, "base64");
|
|
2231
|
+
const framePath = join(agentRoot, LATEST_FRAME_FILE);
|
|
2232
|
+
writeFile(framePath, frameBuffer).catch(() => { });
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
if (msg.type === "text") {
|
|
2237
|
+
appendMessage(opts.agentDir, activeBranch, { type: "transcript", role: "user", text: msg.text });
|
|
2238
|
+
// Detect personal info and save to memory in background
|
|
2239
|
+
if (isMemoryWorthy(msg.text)) {
|
|
2240
|
+
saveMemoryInBackground(msg.text, opts.agentDir, opts.model, opts.env, () => {
|
|
2241
|
+
broadcastToBrowsers({ type: "memory_saving", status: "start", text: msg.text.slice(0, 60) });
|
|
2242
|
+
}, () => {
|
|
2243
|
+
broadcastToBrowsers({ type: "memory_saving", status: "done" });
|
|
2244
|
+
safeSend(browserWs, JSON.stringify({ type: "files_changed" }));
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
else if (msg.type === "file") {
|
|
2249
|
+
// Save uploaded file to disk so the text agent can use it
|
|
2250
|
+
const uploadsDir = join(agentRoot, "workspace");
|
|
2251
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
2252
|
+
const safeName = msg.name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
2253
|
+
const filePath = join(uploadsDir, safeName);
|
|
2254
|
+
writeFileSync(filePath, Buffer.from(msg.data, "base64"));
|
|
2255
|
+
const relPath = relative(agentRoot, filePath);
|
|
2256
|
+
console.log(dim(`[voice] Saved uploaded file: ${relPath}`));
|
|
2257
|
+
// Inject path into message so voice LLM tells the agent where the file is
|
|
2258
|
+
const userText = msg.text || "";
|
|
2259
|
+
msg.text = `${userText}${userText ? " " : ""}[File saved to: ${relPath} (absolute: ${filePath})]`;
|
|
2260
|
+
appendMessage(opts.agentDir, activeBranch, {
|
|
2261
|
+
type: "transcript", role: "user",
|
|
2262
|
+
text: `${userText} [Attached: ${safeName} → ${relPath}]`.trim(),
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
adapter.send(msg);
|
|
2266
|
+
}
|
|
2267
|
+
catch {
|
|
2268
|
+
// Ignore unparseable messages
|
|
2269
|
+
}
|
|
2270
|
+
});
|
|
2271
|
+
browserWs.on("close", () => {
|
|
2272
|
+
console.log(dim("[voice] Browser disconnected"));
|
|
2273
|
+
adapter.disconnect().catch(() => { });
|
|
2274
|
+
// Summarize chat history, save mood, and write journal — track promises for graceful shutdown
|
|
2275
|
+
const p = Promise.allSettled([
|
|
2276
|
+
summarizeHistory(opts.agentDir, activeBranch).catch((err) => {
|
|
2277
|
+
console.error(dim(`[voice] Background summarization failed: ${err.message}`));
|
|
2278
|
+
}),
|
|
2279
|
+
saveMoodEntry(opts.agentDir, moodCounts, sessionMessageCount).catch((err) => {
|
|
2280
|
+
console.error(dim(`[voice] Mood save failed: ${err.message}`));
|
|
2281
|
+
}),
|
|
2282
|
+
writeJournalEntry(opts.agentDir, activeBranch, moodCounts, opts.model, opts.env).catch((err) => {
|
|
2283
|
+
console.error(dim(`[voice] Journal write failed: ${err.message}`));
|
|
2284
|
+
}),
|
|
2285
|
+
]);
|
|
2286
|
+
pendingShutdownWork.push(p);
|
|
2287
|
+
});
|
|
2288
|
+
});
|
|
2289
|
+
await new Promise((resolve) => {
|
|
2290
|
+
httpServer.listen(port, () => resolve());
|
|
2291
|
+
});
|
|
2292
|
+
console.log(bold(`Voice server running on :${port}`));
|
|
2293
|
+
console.log(dim(`[voice] Backend: ${opts.adapter}`));
|
|
2294
|
+
console.log(dim(`[voice] Open http://localhost:${port} in your browser`));
|
|
2295
|
+
return async () => {
|
|
2296
|
+
// Stop Telegram polling
|
|
2297
|
+
stopTelegramPolling();
|
|
2298
|
+
// Gracefully close WebSocket connections to trigger close handlers (journal, mood, etc.)
|
|
2299
|
+
for (const client of wss.clients) {
|
|
2300
|
+
client.close(1000, "Server shutting down");
|
|
2301
|
+
}
|
|
2302
|
+
// Wait for close handlers to fire, then await their async work (journal writes, etc.)
|
|
2303
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
2304
|
+
if (pendingShutdownWork.length > 0) {
|
|
2305
|
+
console.log(dim("[voice] Waiting for journal & mood saves..."));
|
|
2306
|
+
await Promise.allSettled(pendingShutdownWork);
|
|
2307
|
+
}
|
|
2308
|
+
wss.close();
|
|
2309
|
+
await new Promise((resolve) => {
|
|
2310
|
+
httpServer.close(() => resolve());
|
|
2311
|
+
});
|
|
2312
|
+
console.log(dim("[voice] Server stopped"));
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
function safeSend(ws, data) {
|
|
2316
|
+
if (ws.readyState === WS.OPEN) {
|
|
2317
|
+
ws.send(data);
|
|
2318
|
+
}
|
|
2319
|
+
}
|