@open-gitagent/voice 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +52 -0
- 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/gemini-live.d.ts +20 -0
- package/dist/gemini-live.js +279 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/openai-realtime.d.ts +40 -0
- package/dist/openai-realtime.js +460 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.js +3250 -0
- package/dist/ui.html +3859 -0
- package/package.json +57 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,3250 @@
|
|
|
1
|
+
import { createServer } from "http";
|
|
2
|
+
import { WebSocketServer, WebSocket as WS } from "ws";
|
|
3
|
+
import { query } from "@open-gitagent/gitagent";
|
|
4
|
+
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync, rmSync, createReadStream } 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 { homedir } from "os";
|
|
10
|
+
import { OpenAIRealtimeAdapter } from "./openai-realtime.js";
|
|
11
|
+
import { GeminiLiveAdapter } from "./gemini-live.js";
|
|
12
|
+
import { ComposioAdapter } from "./composio/index.js";
|
|
13
|
+
import { appendMessage, loadHistory, deleteHistory, summarizeHistory } from "@open-gitagent/gitagent";
|
|
14
|
+
import { getVoiceContext, getAgentContext } from "@open-gitagent/gitagent";
|
|
15
|
+
import { discoverSkills } from "@open-gitagent/gitagent";
|
|
16
|
+
import { discoverWorkflows, loadFlowDefinition, saveFlowDefinition, deleteFlowDefinition } from "@open-gitagent/gitagent";
|
|
17
|
+
import { discoverSchedules, saveSchedule, deleteSchedule, updateScheduleMeta } from "@open-gitagent/gitagent";
|
|
18
|
+
import { startScheduler, stopScheduler, reloadSchedules, executeScheduledJob } from "@open-gitagent/gitagent";
|
|
19
|
+
import cron from "node-cron";
|
|
20
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
21
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
22
|
+
class LogRingBuffer {
|
|
23
|
+
buf = [];
|
|
24
|
+
nextId = 1;
|
|
25
|
+
cap;
|
|
26
|
+
constructor(capacity = 2000) { this.cap = capacity; }
|
|
27
|
+
push(source, level, message) {
|
|
28
|
+
const entry = { id: this.nextId++, ts: new Date().toISOString(), source, level, message };
|
|
29
|
+
this.buf.push(entry);
|
|
30
|
+
if (this.buf.length > this.cap)
|
|
31
|
+
this.buf.shift();
|
|
32
|
+
return entry;
|
|
33
|
+
}
|
|
34
|
+
all() { return this.buf.slice(); }
|
|
35
|
+
since(id) { return this.buf.filter(e => e.id > id); }
|
|
36
|
+
}
|
|
37
|
+
const logBuffer = new LogRingBuffer(2000);
|
|
38
|
+
let logBroadcast = null;
|
|
39
|
+
function stripAnsi(s) { return s.replace(/\x1b\[\d*m/g, ""); }
|
|
40
|
+
function extractSource(msg) {
|
|
41
|
+
const m = msg.match(/^\[(\w+(?:\/\w+)?)\]\s*/);
|
|
42
|
+
if (m)
|
|
43
|
+
return { source: m[1].split("/")[0].toLowerCase(), cleaned: msg.slice(m[0].length) };
|
|
44
|
+
return { source: "system", cleaned: msg };
|
|
45
|
+
}
|
|
46
|
+
function formatArg(a) {
|
|
47
|
+
if (a == null)
|
|
48
|
+
return String(a);
|
|
49
|
+
if (typeof a === "string")
|
|
50
|
+
return a;
|
|
51
|
+
if (a instanceof Error)
|
|
52
|
+
return `${a.message}${a.stack ? "\n" + a.stack : ""}`;
|
|
53
|
+
try {
|
|
54
|
+
return JSON.stringify(a, (_k, v) => v instanceof Error ? { message: v.message, stack: v.stack } : v);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return String(a);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function logToBuffer(source, level, message) {
|
|
61
|
+
const entry = logBuffer.push(source, level, message);
|
|
62
|
+
if (logBroadcast)
|
|
63
|
+
logBroadcast(entry);
|
|
64
|
+
return entry;
|
|
65
|
+
}
|
|
66
|
+
function installConsoleIntercept() {
|
|
67
|
+
const origLog = console.log.bind(console);
|
|
68
|
+
const origError = console.error.bind(console);
|
|
69
|
+
const origWarn = console.warn.bind(console);
|
|
70
|
+
function intercept(level, origFn, ...args) {
|
|
71
|
+
origFn(...args);
|
|
72
|
+
try {
|
|
73
|
+
const raw = args.map(formatArg).join(" ");
|
|
74
|
+
const clean = stripAnsi(raw);
|
|
75
|
+
if (!clean.trim())
|
|
76
|
+
return;
|
|
77
|
+
const { source, cleaned } = extractSource(clean);
|
|
78
|
+
const entry = logBuffer.push(source, level, cleaned);
|
|
79
|
+
if (logBroadcast)
|
|
80
|
+
logBroadcast(entry);
|
|
81
|
+
}
|
|
82
|
+
catch { /* non-fatal */ }
|
|
83
|
+
}
|
|
84
|
+
console.log = (...args) => intercept("info", origLog, ...args);
|
|
85
|
+
console.error = (...args) => intercept("error", origError, ...args);
|
|
86
|
+
console.warn = (...args) => intercept("warn", origWarn, ...args);
|
|
87
|
+
}
|
|
88
|
+
installConsoleIntercept();
|
|
89
|
+
// Global error handlers — capture everything that would otherwise be lost
|
|
90
|
+
if (!process.__gitagentLogHandlersInstalled) {
|
|
91
|
+
process.__gitagentLogHandlersInstalled = true;
|
|
92
|
+
process.on("uncaughtException", (err) => {
|
|
93
|
+
console.error(`[system] UNCAUGHT EXCEPTION: ${err.message}\n${err.stack}`);
|
|
94
|
+
});
|
|
95
|
+
process.on("unhandledRejection", (reason) => {
|
|
96
|
+
const msg = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason);
|
|
97
|
+
console.error(`[system] UNHANDLED REJECTION: ${msg}`);
|
|
98
|
+
});
|
|
99
|
+
process.on("warning", (warning) => {
|
|
100
|
+
console.warn(`[system] Node warning: ${warning.name}: ${warning.message}`);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const FILE_TYPES = {
|
|
104
|
+
// html
|
|
105
|
+
html: { mime: "text/html; charset=utf-8", kind: "html" },
|
|
106
|
+
htm: { mime: "text/html; charset=utf-8", kind: "html" },
|
|
107
|
+
// images
|
|
108
|
+
png: { mime: "image/png", kind: "image" },
|
|
109
|
+
jpg: { mime: "image/jpeg", kind: "image" },
|
|
110
|
+
jpeg: { mime: "image/jpeg", kind: "image" },
|
|
111
|
+
gif: { mime: "image/gif", kind: "image" },
|
|
112
|
+
webp: { mime: "image/webp", kind: "image" },
|
|
113
|
+
svg: { mime: "image/svg+xml", kind: "image" },
|
|
114
|
+
bmp: { mime: "image/bmp", kind: "image" },
|
|
115
|
+
ico: { mime: "image/x-icon", kind: "image" },
|
|
116
|
+
avif: { mime: "image/avif", kind: "image" },
|
|
117
|
+
// pdf
|
|
118
|
+
pdf: { mime: "application/pdf", kind: "pdf" },
|
|
119
|
+
// video
|
|
120
|
+
mp4: { mime: "video/mp4", kind: "video" },
|
|
121
|
+
webm: { mime: "video/webm", kind: "video" },
|
|
122
|
+
mov: { mime: "video/quicktime", kind: "video" },
|
|
123
|
+
m4v: { mime: "video/x-m4v", kind: "video" },
|
|
124
|
+
// audio
|
|
125
|
+
mp3: { mime: "audio/mpeg", kind: "audio" },
|
|
126
|
+
wav: { mime: "audio/wav", kind: "audio" },
|
|
127
|
+
ogg: { mime: "audio/ogg", kind: "audio" },
|
|
128
|
+
m4a: { mime: "audio/mp4", kind: "audio" },
|
|
129
|
+
aac: { mime: "audio/aac", kind: "audio" },
|
|
130
|
+
flac: { mime: "audio/flac", kind: "audio" },
|
|
131
|
+
// markdown
|
|
132
|
+
md: { mime: "text/markdown; charset=utf-8", kind: "markdown" },
|
|
133
|
+
markdown: { mime: "text/markdown; charset=utf-8", kind: "markdown" },
|
|
134
|
+
// text-ish
|
|
135
|
+
txt: { mime: "text/plain; charset=utf-8", kind: "text" },
|
|
136
|
+
json: { mime: "application/json; charset=utf-8", kind: "text" },
|
|
137
|
+
js: { mime: "text/javascript; charset=utf-8", kind: "text" },
|
|
138
|
+
mjs: { mime: "text/javascript; charset=utf-8", kind: "text" },
|
|
139
|
+
cjs: { mime: "text/javascript; charset=utf-8", kind: "text" },
|
|
140
|
+
ts: { mime: "text/plain; charset=utf-8", kind: "text" },
|
|
141
|
+
tsx: { mime: "text/plain; charset=utf-8", kind: "text" },
|
|
142
|
+
jsx: { mime: "text/plain; charset=utf-8", kind: "text" },
|
|
143
|
+
css: { mime: "text/css; charset=utf-8", kind: "text" },
|
|
144
|
+
yaml: { mime: "text/yaml; charset=utf-8", kind: "text" },
|
|
145
|
+
yml: { mime: "text/yaml; charset=utf-8", kind: "text" },
|
|
146
|
+
toml: { mime: "text/plain; charset=utf-8", kind: "text" },
|
|
147
|
+
csv: { mime: "text/csv; charset=utf-8", kind: "text" },
|
|
148
|
+
log: { mime: "text/plain; charset=utf-8", kind: "text" },
|
|
149
|
+
sh: { mime: "text/x-shellscript; charset=utf-8", kind: "text" },
|
|
150
|
+
py: { mime: "text/x-python; charset=utf-8", kind: "text" },
|
|
151
|
+
go: { mime: "text/plain; charset=utf-8", kind: "text" },
|
|
152
|
+
rs: { mime: "text/plain; charset=utf-8", kind: "text" },
|
|
153
|
+
java: { mime: "text/x-java; charset=utf-8", kind: "text" },
|
|
154
|
+
c: { mime: "text/x-c; charset=utf-8", kind: "text" },
|
|
155
|
+
cpp: { mime: "text/x-c++; charset=utf-8", kind: "text" },
|
|
156
|
+
h: { mime: "text/x-c; charset=utf-8", kind: "text" },
|
|
157
|
+
xml: { mime: "application/xml; charset=utf-8", kind: "text" },
|
|
158
|
+
// office / archives — kind: binary, but with proper MIME so downloads name correctly
|
|
159
|
+
doc: { mime: "application/msword", kind: "binary" },
|
|
160
|
+
docx: { mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", kind: "binary" },
|
|
161
|
+
xls: { mime: "application/vnd.ms-excel", kind: "binary" },
|
|
162
|
+
xlsx: { mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", kind: "binary" },
|
|
163
|
+
ppt: { mime: "application/vnd.ms-powerpoint", kind: "binary" },
|
|
164
|
+
pptx: { mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation", kind: "binary" },
|
|
165
|
+
zip: { mime: "application/zip", kind: "binary" },
|
|
166
|
+
tar: { mime: "application/x-tar", kind: "binary" },
|
|
167
|
+
gz: { mime: "application/gzip", kind: "binary" },
|
|
168
|
+
};
|
|
169
|
+
export function fileTypeFor(pathOrName) {
|
|
170
|
+
const name = pathOrName.split("/").pop() || pathOrName;
|
|
171
|
+
const dot = name.lastIndexOf(".");
|
|
172
|
+
const ext = dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
173
|
+
return FILE_TYPES[ext] || { mime: "application/octet-stream", kind: "binary" };
|
|
174
|
+
}
|
|
175
|
+
const MAX_FILE_BYTES = (() => {
|
|
176
|
+
const v = parseInt(process.env.GITAGENT_MAX_FILE_BYTES || "", 10);
|
|
177
|
+
return Number.isFinite(v) && v > 0 ? v : 200 * 1024 * 1024;
|
|
178
|
+
})();
|
|
179
|
+
export const CLOUD_MODE = process.env.GITAGENT_CLOUD === "true" ||
|
|
180
|
+
!!process.env.KUBERNETES_SERVICE_HOST ||
|
|
181
|
+
!!process.env.RENDER ||
|
|
182
|
+
!!process.env.FLY_APP_NAME;
|
|
183
|
+
const CLOUD_VOICE_SUFFIX = " CLOUD MODE: You are running inside a containerized cloud deployment — there is no desktop, no `open`/`xdg-open`/`osascript`, " +
|
|
184
|
+
"no Spotify, no Apple Music, no GUI apps. Do NOT instruct run_agent to call those. " +
|
|
185
|
+
"To 'show' the user something, write the artifact to `workspace/` (e.g. `workspace/index.html`, `workspace/deck.pptx`) " +
|
|
186
|
+
"and mention the path in your reply — the web UI auto-opens it (HTML renders inline, PDFs preview, Office files offer Download).";
|
|
187
|
+
function streamFileWithRange(req, res, abs, opts) {
|
|
188
|
+
const st = statSync(abs);
|
|
189
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
190
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
191
|
+
res.end(JSON.stringify({ error: `File too large (>${Math.floor(MAX_FILE_BYTES / 1024 / 1024)}MB)` }));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const headers = {
|
|
195
|
+
"Content-Type": opts.mime,
|
|
196
|
+
"Cache-Control": "no-cache",
|
|
197
|
+
"Accept-Ranges": "bytes",
|
|
198
|
+
...(opts.extraHeaders || {}),
|
|
199
|
+
};
|
|
200
|
+
if (opts.download) {
|
|
201
|
+
const fn = (opts.filename || abs.split("/").pop() || "download").replace(/"/g, "");
|
|
202
|
+
headers["Content-Disposition"] = `attachment; filename="${fn}"`;
|
|
203
|
+
}
|
|
204
|
+
const range = req.headers.range;
|
|
205
|
+
if (range) {
|
|
206
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
207
|
+
if (m) {
|
|
208
|
+
const start = m[1] ? parseInt(m[1], 10) : 0;
|
|
209
|
+
const end = m[2] ? parseInt(m[2], 10) : st.size - 1;
|
|
210
|
+
if (Number.isFinite(start) && Number.isFinite(end) && start <= end && start < st.size) {
|
|
211
|
+
const length = end - start + 1;
|
|
212
|
+
headers["Content-Range"] = `bytes ${start}-${end}/${st.size}`;
|
|
213
|
+
headers["Content-Length"] = String(length);
|
|
214
|
+
res.writeHead(206, headers);
|
|
215
|
+
createReadStream(abs, { start, end }).pipe(res);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
headers["Content-Length"] = String(st.size);
|
|
221
|
+
res.writeHead(200, headers);
|
|
222
|
+
createReadStream(abs).pipe(res);
|
|
223
|
+
}
|
|
224
|
+
// ── Background memory saver ────────────────────────────────────────────
|
|
225
|
+
// Patterns that indicate the user is sharing personal info worth saving.
|
|
226
|
+
// This runs server-side so we don't depend on the voice LLM deciding to save.
|
|
227
|
+
const MEMORY_PATTERNS = [
|
|
228
|
+
/\bi (?:like|love|enjoy|prefer|hate|dislike)\b/i,
|
|
229
|
+
/\bmy (?:name|dog|cat|favorite|fav|hobby|job|car|team)\b/i,
|
|
230
|
+
/\bi(?:'m| am) (?:a |into |from |working on )/i,
|
|
231
|
+
/\bi(?:'m| am) \w+$/i, // "I am Shreyas", "I'm Zeus"
|
|
232
|
+
/\bmy name is\b/i, // "my name is ..."
|
|
233
|
+
/\bcall me\b/i,
|
|
234
|
+
/\bremember (?:that|this)\b/i,
|
|
235
|
+
/\bi (?:play|watch|drive|use|work with|listen to)\b/i,
|
|
236
|
+
/\bi(?:'m| am) \d+/i, // "I'm 25", age
|
|
237
|
+
/\bi (?:live|grew up|was born) (?:in|at|near)\b/i, // location info
|
|
238
|
+
/\bpeople call me\b/i,
|
|
239
|
+
];
|
|
240
|
+
function isMemoryWorthy(text) {
|
|
241
|
+
return MEMORY_PATTERNS.some((p) => p.test(text));
|
|
242
|
+
}
|
|
243
|
+
// ── Moment detection for photo capture ─────────────────────────────────
|
|
244
|
+
const MOMENT_PATTERNS = [
|
|
245
|
+
/\bhaha\b/i,
|
|
246
|
+
/\blol\b/i,
|
|
247
|
+
/\blmao\b/i,
|
|
248
|
+
/\blove it\b/i,
|
|
249
|
+
/\bthat'?s amazing\b/i,
|
|
250
|
+
/\bso happy\b/i,
|
|
251
|
+
/\bbest day\b/i,
|
|
252
|
+
/\bwe did it\b/i,
|
|
253
|
+
/\bnailed it\b/i,
|
|
254
|
+
/\blet'?s go\b/i,
|
|
255
|
+
/\bhell yeah\b/i,
|
|
256
|
+
/\bawesome\b/i,
|
|
257
|
+
/\bthank you so much\b/i,
|
|
258
|
+
/\bfirst time\b/i,
|
|
259
|
+
/\bmilestone\b/i,
|
|
260
|
+
/\bcelebrat/i,
|
|
261
|
+
/\bincredible\b/i,
|
|
262
|
+
];
|
|
263
|
+
function isMomentWorthy(text) {
|
|
264
|
+
return MOMENT_PATTERNS.some((p) => p.test(text));
|
|
265
|
+
}
|
|
266
|
+
let vitalsTokenCount = 0;
|
|
267
|
+
let _lastCpuUsage = process.cpuUsage();
|
|
268
|
+
let _lastCpuTime = process.hrtime.bigint();
|
|
269
|
+
let _vitalsCache = null;
|
|
270
|
+
const VITALS_CACHE_MS = 1000; // cache for 1s — all readers within 1s see identical values
|
|
271
|
+
function getVitalsSnapshot() {
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
if (_vitalsCache && now - _vitalsCache.ts < VITALS_CACHE_MS)
|
|
274
|
+
return _vitalsCache;
|
|
275
|
+
const mem = process.memoryUsage();
|
|
276
|
+
const currentCpu = process.cpuUsage();
|
|
277
|
+
const currentTime = process.hrtime.bigint();
|
|
278
|
+
// Delta-based CPU: measure CPU microseconds consumed since last sample
|
|
279
|
+
const userDelta = currentCpu.user - _lastCpuUsage.user;
|
|
280
|
+
const sysDelta = currentCpu.system - _lastCpuUsage.system;
|
|
281
|
+
const wallDeltaUs = Number(currentTime - _lastCpuTime) / 1000; // ns → µs
|
|
282
|
+
const cpuPercent = wallDeltaUs > 0
|
|
283
|
+
? Math.min(100, Math.round((userDelta + sysDelta) / wallDeltaUs * 100))
|
|
284
|
+
: 0;
|
|
285
|
+
_lastCpuUsage = currentCpu;
|
|
286
|
+
_lastCpuTime = currentTime;
|
|
287
|
+
_vitalsCache = {
|
|
288
|
+
cpu: cpuPercent,
|
|
289
|
+
mem: Math.round(mem.rss / 1024 / 1024),
|
|
290
|
+
heapUsed: Math.round(mem.heapUsed / 1024 / 1024),
|
|
291
|
+
heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
|
|
292
|
+
uptime: Math.round(process.uptime()),
|
|
293
|
+
tokens: vitalsTokenCount,
|
|
294
|
+
ts: now,
|
|
295
|
+
};
|
|
296
|
+
return _vitalsCache;
|
|
297
|
+
}
|
|
298
|
+
const PHOTOS_DIR = "memory/photos";
|
|
299
|
+
const INDEX_FILE = "memory/photos/INDEX.md";
|
|
300
|
+
const LATEST_FRAME_FILE = "memory/.latest-frame.jpg";
|
|
301
|
+
const LATEST_SCREEN_FILE = "memory/.latest-screen.jpg";
|
|
302
|
+
function slugify(text) {
|
|
303
|
+
return text
|
|
304
|
+
.toLowerCase()
|
|
305
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
306
|
+
.replace(/^-|-$/g, "")
|
|
307
|
+
.slice(0, 40);
|
|
308
|
+
}
|
|
309
|
+
const MOOD_SIGNALS = [
|
|
310
|
+
{ 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] },
|
|
311
|
+
{ 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] },
|
|
312
|
+
{ 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] },
|
|
313
|
+
{ 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] },
|
|
314
|
+
{ mood: "calm", patterns: [/\bokay\b/i, /\bsure\b/i, /\bcool\b/i, /\bsounds good\b/i, /\bgot it\b/i] },
|
|
315
|
+
];
|
|
316
|
+
function detectMood(text) {
|
|
317
|
+
for (const { mood, patterns } of MOOD_SIGNALS) {
|
|
318
|
+
if (patterns.some((p) => p.test(text)))
|
|
319
|
+
return mood;
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
function dominantMood(counts) {
|
|
324
|
+
let best = "calm";
|
|
325
|
+
let max = 0;
|
|
326
|
+
for (const [mood, count] of Object.entries(counts)) {
|
|
327
|
+
if (count > max) {
|
|
328
|
+
max = count;
|
|
329
|
+
best = mood;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return best;
|
|
333
|
+
}
|
|
334
|
+
async function saveMoodEntry(agentDir, counts, messageCount) {
|
|
335
|
+
if (messageCount < 3)
|
|
336
|
+
return; // Skip trivially short sessions
|
|
337
|
+
const now = new Date();
|
|
338
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
339
|
+
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
340
|
+
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
341
|
+
const mood = dominantMood(counts);
|
|
342
|
+
const moodPath = join(agentDir, "memory", "mood.md");
|
|
343
|
+
let existing = "";
|
|
344
|
+
try {
|
|
345
|
+
existing = await readFile(moodPath, "utf-8");
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
existing = "# Mood Log\n\n";
|
|
349
|
+
}
|
|
350
|
+
const detail = Object.entries(counts).filter(([, v]) => v > 0).map(([k, v]) => `${k}:${v}`).join(" ");
|
|
351
|
+
existing += `- ${date} ${time} — **${mood}** (${detail}) [${messageCount} msgs]\n`;
|
|
352
|
+
await mkdir(join(agentDir, "memory"), { recursive: true });
|
|
353
|
+
await writeFile(moodPath, existing, "utf-8");
|
|
354
|
+
try {
|
|
355
|
+
execSync(`git add "memory/mood.md" && git commit -m "Mood: ${mood} session (${date} ${time})"`, {
|
|
356
|
+
cwd: agentDir, stdio: "pipe",
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
catch { /* file saved even if commit fails */ }
|
|
360
|
+
}
|
|
361
|
+
// ── Session journaling ─────────────────────────────────────────────────
|
|
362
|
+
async function writeJournalEntry(agentDir, branch, moodCounts, model, env) {
|
|
363
|
+
const messages = loadHistory(agentDir, branch);
|
|
364
|
+
if (messages.length < 5)
|
|
365
|
+
return;
|
|
366
|
+
const lines = [];
|
|
367
|
+
for (const msg of messages.slice(-50)) {
|
|
368
|
+
if (msg.type === "transcript")
|
|
369
|
+
lines.push(`${msg.role}: ${msg.text}`);
|
|
370
|
+
else if (msg.type === "agent_done")
|
|
371
|
+
lines.push(`agent: ${msg.result.slice(0, 200)}`);
|
|
372
|
+
}
|
|
373
|
+
if (lines.length < 3)
|
|
374
|
+
return;
|
|
375
|
+
let transcript = lines.join("\n");
|
|
376
|
+
if (transcript.length > 3000)
|
|
377
|
+
transcript = transcript.slice(-3000);
|
|
378
|
+
const mood = dominantMood(moodCounts);
|
|
379
|
+
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}`;
|
|
380
|
+
try {
|
|
381
|
+
const result = query({
|
|
382
|
+
prompt,
|
|
383
|
+
dir: agentDir,
|
|
384
|
+
model,
|
|
385
|
+
env,
|
|
386
|
+
maxTurns: 1,
|
|
387
|
+
replaceBuiltinTools: true,
|
|
388
|
+
tools: [],
|
|
389
|
+
systemPrompt: "You are journaling about your day as an AI assistant. Write naturally and briefly.",
|
|
390
|
+
});
|
|
391
|
+
let entry = "";
|
|
392
|
+
for await (const msg of result) {
|
|
393
|
+
if (msg.type === "assistant" && msg.content)
|
|
394
|
+
entry += msg.content;
|
|
395
|
+
}
|
|
396
|
+
entry = entry.trim();
|
|
397
|
+
if (!entry)
|
|
398
|
+
return;
|
|
399
|
+
const now = new Date();
|
|
400
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
401
|
+
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
402
|
+
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
403
|
+
const journalDir = join(agentDir, "memory", "journal");
|
|
404
|
+
await mkdir(journalDir, { recursive: true });
|
|
405
|
+
const journalPath = join(journalDir, `${date}.md`);
|
|
406
|
+
let existing = "";
|
|
407
|
+
try {
|
|
408
|
+
existing = await readFile(journalPath, "utf-8");
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
existing = `# Journal — ${date}\n\n`;
|
|
412
|
+
}
|
|
413
|
+
existing += `### ${time} (${mood})\n${entry}\n\n`;
|
|
414
|
+
await writeFile(journalPath, existing, "utf-8");
|
|
415
|
+
try {
|
|
416
|
+
execSync(`git add "memory/journal/${date}.md" && git commit -m "Journal: ${date} ${time} session reflection"`, {
|
|
417
|
+
cwd: agentDir, stdio: "pipe",
|
|
418
|
+
});
|
|
419
|
+
console.error(dim(`[voice] Journal entry written for ${date} ${time}`));
|
|
420
|
+
}
|
|
421
|
+
catch { /* saved even if commit fails */ }
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
console.error(dim(`[voice] Journal write failed: ${err.message}`));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
async function capturePhoto(agentDir, reason, frameData) {
|
|
428
|
+
// If no frame passed directly, read from temp file
|
|
429
|
+
let frame = frameData;
|
|
430
|
+
if (!frame) {
|
|
431
|
+
const framePath = join(agentDir, LATEST_FRAME_FILE);
|
|
432
|
+
try {
|
|
433
|
+
const frameStat = await stat(framePath);
|
|
434
|
+
if (Date.now() - frameStat.mtimeMs > 5000) {
|
|
435
|
+
console.error(dim("[voice] No recent camera frame, skipping photo capture"));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
frame = await readFile(framePath);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
console.error(dim("[voice] No camera frame available, skipping photo capture"));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const now = new Date();
|
|
446
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
447
|
+
const datePart = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
448
|
+
const timePart = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
449
|
+
const slug = slugify(reason);
|
|
450
|
+
const filename = `${datePart}_${timePart}_${slug}.jpg`;
|
|
451
|
+
const photoRelPath = `${PHOTOS_DIR}/${filename}`;
|
|
452
|
+
const photoAbsPath = join(agentDir, photoRelPath);
|
|
453
|
+
await mkdir(join(agentDir, PHOTOS_DIR), { recursive: true });
|
|
454
|
+
await writeFile(photoAbsPath, frame);
|
|
455
|
+
// Update INDEX.md
|
|
456
|
+
const indexPath = join(agentDir, INDEX_FILE);
|
|
457
|
+
let indexContent = "";
|
|
458
|
+
try {
|
|
459
|
+
indexContent = await readFile(indexPath, "utf-8");
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
indexContent = "# Memorable Moments\n\nPhotos captured during happy and memorable moments.\n\n";
|
|
463
|
+
}
|
|
464
|
+
const entry = `- **${datePart} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}** — ${reason} → [\`${filename}\`](${filename})\n`;
|
|
465
|
+
indexContent += entry;
|
|
466
|
+
await writeFile(indexPath, indexContent, "utf-8");
|
|
467
|
+
// Git add + commit
|
|
468
|
+
const commitMsg = `Capture moment: ${reason}`;
|
|
469
|
+
try {
|
|
470
|
+
execSync(`git add "${photoRelPath}" "${INDEX_FILE}" && git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, {
|
|
471
|
+
cwd: agentDir,
|
|
472
|
+
stdio: "pipe",
|
|
473
|
+
});
|
|
474
|
+
console.error(dim(`[voice] Photo captured: ${filename}`));
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
console.error(dim(`[voice] Photo saved but git commit failed: ${err.stderr?.toString().trim() || "unknown"}`));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function saveMemoryInBackground(text, agentDir, model, env, onStart, onComplete) {
|
|
481
|
+
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.`;
|
|
482
|
+
console.error(dim(`[voice] Background memory save triggered for: "${text.slice(0, 60)}..."`));
|
|
483
|
+
if (onStart)
|
|
484
|
+
onStart();
|
|
485
|
+
// Fire and forget — don't block the voice conversation
|
|
486
|
+
(async () => {
|
|
487
|
+
try {
|
|
488
|
+
const result = query({
|
|
489
|
+
prompt,
|
|
490
|
+
dir: agentDir,
|
|
491
|
+
model,
|
|
492
|
+
env,
|
|
493
|
+
maxTurns: 3,
|
|
494
|
+
});
|
|
495
|
+
// Drain the iterator to completion
|
|
496
|
+
for await (const msg of result) {
|
|
497
|
+
if (msg.type === "tool_use") {
|
|
498
|
+
console.error(dim(`[voice/memory] Tool: ${msg.toolName}`));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
console.error(dim("[voice/memory] Background save complete"));
|
|
502
|
+
if (onComplete)
|
|
503
|
+
onComplete();
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
console.error(dim(`[voice/memory] Background save failed: ${err.message}`));
|
|
507
|
+
if (onComplete)
|
|
508
|
+
onComplete();
|
|
509
|
+
}
|
|
510
|
+
})();
|
|
511
|
+
}
|
|
512
|
+
/** Load .env file into process.env (won't overwrite existing vars) */
|
|
513
|
+
function loadEnvFile(dir) {
|
|
514
|
+
const envPath = join(dir, ".env");
|
|
515
|
+
try {
|
|
516
|
+
const content = readFileSync(envPath, "utf-8");
|
|
517
|
+
for (const line of content.split("\n")) {
|
|
518
|
+
const trimmed = line.trim();
|
|
519
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
520
|
+
continue;
|
|
521
|
+
const eq = trimmed.indexOf("=");
|
|
522
|
+
if (eq < 1)
|
|
523
|
+
continue;
|
|
524
|
+
const key = trimmed.slice(0, eq).trim();
|
|
525
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
526
|
+
// Strip surrounding quotes
|
|
527
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
528
|
+
val = val.slice(1, -1);
|
|
529
|
+
}
|
|
530
|
+
// Agent .env takes precedence over inherited env (e.g. shell placeholders
|
|
531
|
+
// like a stray OPENAI_API_KEY="your-...-here" in ~/.zshrc).
|
|
532
|
+
process.env[key] = val;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
// No .env file — that's fine
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function createAdapter(opts) {
|
|
540
|
+
switch (opts.adapter) {
|
|
541
|
+
case "openai-realtime":
|
|
542
|
+
return new OpenAIRealtimeAdapter(opts.adapterConfig);
|
|
543
|
+
case "gemini-live":
|
|
544
|
+
return new GeminiLiveAdapter(opts.adapterConfig);
|
|
545
|
+
default:
|
|
546
|
+
throw new Error(`Unknown adapter: ${opts.adapter}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function loadUIHtml() {
|
|
550
|
+
// Try dist/voice/ui.html first (built), then src/voice/ui.html (dev)
|
|
551
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
552
|
+
const candidates = [
|
|
553
|
+
join(thisDir, "ui.html"),
|
|
554
|
+
join(thisDir, "..", "..", "src", "voice", "ui.html"),
|
|
555
|
+
];
|
|
556
|
+
for (const path of candidates) {
|
|
557
|
+
try {
|
|
558
|
+
return readFileSync(path, "utf-8");
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
// try next
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return "<html><body><h1>UI not found</h1><p>Run: npm run build</p></body></html>";
|
|
565
|
+
}
|
|
566
|
+
export async function startVoiceServer(opts) {
|
|
567
|
+
// Env precedence (lowest → highest): inherited env → ~/.gitagent/.env (global fallback) → agent-dir .env (winner).
|
|
568
|
+
// Each loader call overrides whatever's already in process.env, so the LAST load wins.
|
|
569
|
+
loadEnvFile(join(homedir(), ".gitagent"));
|
|
570
|
+
loadEnvFile(resolve(opts.agentDir));
|
|
571
|
+
const port = opts.port || 3333;
|
|
572
|
+
let agentName = "GitAgent";
|
|
573
|
+
try {
|
|
574
|
+
const yamlRaw = readFileSync(join(resolve(opts.agentDir), "agent.yaml"), "utf-8");
|
|
575
|
+
const m = yamlRaw.match(/^name:\s*(.+)$/m);
|
|
576
|
+
if (m)
|
|
577
|
+
agentName = m[1].trim();
|
|
578
|
+
}
|
|
579
|
+
catch { /* fallback to default */ }
|
|
580
|
+
// Re-read on every request so `npm run build` is picked up live without a server restart.
|
|
581
|
+
// The file sits in the OS page cache, so the per-request cost is negligible.
|
|
582
|
+
function buildUiHtml() {
|
|
583
|
+
return loadUIHtml()
|
|
584
|
+
.replace(/\{\{AGENT_NAME\}\}/g, agentName)
|
|
585
|
+
.replace(/\{\{HAS_COMPOSIO\}\}/g, process.env.COMPOSIO_API_KEY ? "true" : "false");
|
|
586
|
+
}
|
|
587
|
+
// Current date/time context injected into every query
|
|
588
|
+
function getCurrentDateTimeContext() {
|
|
589
|
+
const now = new Date();
|
|
590
|
+
const day = now.toLocaleDateString("en-US", { weekday: "long" });
|
|
591
|
+
const date = now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
|
|
592
|
+
const time = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
|
593
|
+
return `Current date and time: ${day}, ${date}, ${time}.`;
|
|
594
|
+
}
|
|
595
|
+
// Shared helper: fetch Composio tools + build prompt suffix for any channel
|
|
596
|
+
async function getComposioContext(prompt) {
|
|
597
|
+
let composioTools = [];
|
|
598
|
+
let connectedSlugs = [];
|
|
599
|
+
if (composioAdapter) {
|
|
600
|
+
try {
|
|
601
|
+
connectedSlugs = await composioAdapter.getConnectedToolkitSlugs();
|
|
602
|
+
console.error(`[voice] Connected toolkit slugs: [${connectedSlugs.join(", ")}]`);
|
|
603
|
+
if (connectedSlugs.length > 0) {
|
|
604
|
+
composioTools = await composioAdapter.getToolsForQuery(prompt);
|
|
605
|
+
console.error(`[voice] Semantic search returned ${composioTools.length} tools`);
|
|
606
|
+
if (composioTools.length === 0) {
|
|
607
|
+
const allTools = await composioAdapter.getTools();
|
|
608
|
+
composioTools = allTools.slice(0, 15);
|
|
609
|
+
console.error(`[voice] Fallback capped to ${composioTools.length}/${allTools.length} tools`);
|
|
610
|
+
}
|
|
611
|
+
console.error(`[voice] Composio: ${composioTools.length} tools: ${composioTools.map(t => t.name).join(", ")}`);
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
console.error(`[voice] No connected toolkits found for user`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
catch (err) {
|
|
618
|
+
console.error(`[voice] Composio tool fetch FAILED: ${err.message}\n${err.stack}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
console.error(`[voice] composioAdapter is NULL — COMPOSIO_API_KEY not set?`);
|
|
623
|
+
}
|
|
624
|
+
let promptSuffix;
|
|
625
|
+
if (composioAdapter) {
|
|
626
|
+
const parts = [
|
|
627
|
+
`You have access to external services via Composio integration (Gmail, Google Calendar, GitHub, Slack, and many more).`,
|
|
628
|
+
`You CAN perform real actions — send emails, read emails, check calendars, create events, manage repos, etc.`,
|
|
629
|
+
`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.`,
|
|
630
|
+
`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.`,
|
|
631
|
+
`When the user asks about their calendar, use the composio calendar tools to fetch real events.`,
|
|
632
|
+
`Prefer Composio tools over CLI commands for any external service interaction.`,
|
|
633
|
+
];
|
|
634
|
+
if (connectedSlugs.length > 0) {
|
|
635
|
+
const services = connectedSlugs.map((s) => s.replace(/_/g, " ")).join(", ");
|
|
636
|
+
parts.unshift(`Currently connected services: ${services}.`);
|
|
637
|
+
}
|
|
638
|
+
promptSuffix = parts.join(" ");
|
|
639
|
+
}
|
|
640
|
+
return { tools: composioTools, promptSuffix };
|
|
641
|
+
}
|
|
642
|
+
// Creates a per-connection tool handler that can stream events to the browser
|
|
643
|
+
function createToolHandler(sendToBrowser) {
|
|
644
|
+
return async (prompt) => {
|
|
645
|
+
const { tools: composioTools, promptSuffix: composioPromptSuffix } = await getComposioContext(prompt);
|
|
646
|
+
let systemPromptSuffix = getCurrentDateTimeContext();
|
|
647
|
+
systemPromptSuffix += "\nWhen creating files (PDFs, images, documents, markdown files, code output, etc.), write them to the workspace/ directory by default. If the user explicitly specifies a different path, use the path they requested.";
|
|
648
|
+
if (whatsappSock && whatsappConnected) {
|
|
649
|
+
systemPromptSuffix += "\nYou can send WhatsApp messages using the send_whatsapp_message tool and set up auto-response triggers using create_trigger.";
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
systemPromptSuffix += "\nYou can set up auto-response triggers using create_trigger for when messaging platforms are connected.";
|
|
653
|
+
}
|
|
654
|
+
if (composioPromptSuffix)
|
|
655
|
+
systemPromptSuffix += "\n\n" + composioPromptSuffix;
|
|
656
|
+
// Inject shared context (memory + conversation summary)
|
|
657
|
+
const agentContext = await getAgentContext(opts.agentDir, activeBranch);
|
|
658
|
+
if (agentContext) {
|
|
659
|
+
systemPromptSuffix = (systemPromptSuffix || "") + "\n\n" + agentContext;
|
|
660
|
+
}
|
|
661
|
+
const uiTools = [
|
|
662
|
+
...createTriggerTools(opts.agentDir),
|
|
663
|
+
...(whatsappSock && whatsappConnected ? createWhatsAppTools(whatsappSock, opts.agentDir) : []),
|
|
664
|
+
...composioTools,
|
|
665
|
+
];
|
|
666
|
+
const result = query({
|
|
667
|
+
prompt,
|
|
668
|
+
dir: opts.agentDir,
|
|
669
|
+
model: opts.model,
|
|
670
|
+
env: opts.env,
|
|
671
|
+
...(uiTools.length ? { tools: uiTools } : {}),
|
|
672
|
+
...(systemPromptSuffix ? { systemPromptSuffix } : {}),
|
|
673
|
+
});
|
|
674
|
+
let text = "";
|
|
675
|
+
const toolResults = [];
|
|
676
|
+
const errors = [];
|
|
677
|
+
for await (const msg of result) {
|
|
678
|
+
if (msg.type === "assistant" && msg.content) {
|
|
679
|
+
text += msg.content;
|
|
680
|
+
vitalsTokenCount += Math.ceil(msg.content.length / 4);
|
|
681
|
+
}
|
|
682
|
+
else if (msg.type === "tool_use") {
|
|
683
|
+
sendToBrowser({ type: "tool_call", toolName: msg.toolName, args: msg.args });
|
|
684
|
+
console.log(dim(`[voice] Tool call: ${msg.toolName}(${JSON.stringify(msg.args).slice(0, 80)})`));
|
|
685
|
+
}
|
|
686
|
+
else if (msg.type === "tool_result") {
|
|
687
|
+
sendToBrowser({ type: "tool_result", toolName: msg.toolName, content: msg.content, isError: msg.isError });
|
|
688
|
+
if (msg.content) {
|
|
689
|
+
toolResults.push(msg.content);
|
|
690
|
+
vitalsTokenCount += Math.ceil(msg.content.length / 4);
|
|
691
|
+
}
|
|
692
|
+
console.log(dim(`[voice] Tool ${msg.toolName}: ${msg.content.slice(0, 100)}${msg.content.length > 100 ? "..." : ""}`));
|
|
693
|
+
}
|
|
694
|
+
else if (msg.type === "system" && msg.subtype === "error") {
|
|
695
|
+
errors.push(msg.content);
|
|
696
|
+
console.error(dim(`[voice] Agent error: ${msg.content}`));
|
|
697
|
+
}
|
|
698
|
+
else if (msg.type === "delta" && msg.deltaType === "thinking") {
|
|
699
|
+
sendToBrowser({ type: "agent_thinking", text: msg.content });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (text)
|
|
703
|
+
return text;
|
|
704
|
+
if (errors.length > 0)
|
|
705
|
+
return `Error: ${errors.join("; ")}`;
|
|
706
|
+
if (toolResults.length > 0)
|
|
707
|
+
return toolResults.join("\n");
|
|
708
|
+
return "(no response)";
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
// ── SkillFlow execution ─────────────────────────────────────────────
|
|
712
|
+
// ── Approval gate state ────────────────────────────────────────────
|
|
713
|
+
let pendingApproval = null;
|
|
714
|
+
function handleApprovalReply(text) {
|
|
715
|
+
if (!pendingApproval)
|
|
716
|
+
return false;
|
|
717
|
+
const lower = text.trim().toLowerCase();
|
|
718
|
+
if (["yes", "approve", "continue", "ok", "go", "y", "proceed"].includes(lower)) {
|
|
719
|
+
pendingApproval.resolve(true);
|
|
720
|
+
pendingApproval = null;
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
if (["no", "deny", "stop", "cancel", "abort", "n", "reject"].includes(lower)) {
|
|
724
|
+
pendingApproval.resolve(false);
|
|
725
|
+
pendingApproval = null;
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
async function sendApprovalRequest(channel, message) {
|
|
731
|
+
// Send message via the chosen channel
|
|
732
|
+
if (channel === "telegram" && telegramToken && lastTelegramChatId) {
|
|
733
|
+
await fetch(`https://api.telegram.org/bot${telegramToken}/sendMessage`, {
|
|
734
|
+
method: "POST",
|
|
735
|
+
headers: { "Content-Type": "application/json" },
|
|
736
|
+
body: JSON.stringify({ chat_id: lastTelegramChatId, text: message }),
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
else if (channel === "whatsapp" && whatsappSock && whatsappConnected && lastWhatsAppJid) {
|
|
740
|
+
const sent = await whatsappSock.sendMessage(lastWhatsAppJid, { text: message });
|
|
741
|
+
if (sent?.key?.id)
|
|
742
|
+
whatsappSentIds.add(sent.key.id);
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
return true; // No channel available — auto-approve
|
|
746
|
+
}
|
|
747
|
+
// Wait for reply (timeout after 5 minutes)
|
|
748
|
+
return new Promise((resolve) => {
|
|
749
|
+
pendingApproval = { resolve };
|
|
750
|
+
const timeout = setTimeout(() => {
|
|
751
|
+
if (pendingApproval?.resolve === resolve) {
|
|
752
|
+
pendingApproval = null;
|
|
753
|
+
resolve(false); // Timeout = deny
|
|
754
|
+
}
|
|
755
|
+
}, 5 * 60 * 1000);
|
|
756
|
+
const origResolve = resolve;
|
|
757
|
+
pendingApproval.resolve = (val) => {
|
|
758
|
+
clearTimeout(timeout);
|
|
759
|
+
origResolve(val);
|
|
760
|
+
};
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
async function executeFlow(flowName, userContext, sendToBrowser) {
|
|
764
|
+
const flowPath = join(resolve(opts.agentDir), "workflows", flowName + ".yaml");
|
|
765
|
+
const flow = await loadFlowDefinition(flowPath);
|
|
766
|
+
sendToBrowser({ type: "transcript", role: "assistant",
|
|
767
|
+
text: `Running flow: ${flow.name} (${flow.steps.length} steps)` });
|
|
768
|
+
let runningContext = userContext;
|
|
769
|
+
for (let i = 0; i < flow.steps.length; i++) {
|
|
770
|
+
const step = flow.steps[i];
|
|
771
|
+
// ── Approval gate step ──
|
|
772
|
+
if (step.skill === "__approval_gate__") {
|
|
773
|
+
const channel = step.channel || "telegram";
|
|
774
|
+
const customMsg = step.prompt || "";
|
|
775
|
+
const approvalMsg = customMsg
|
|
776
|
+
? `⏸ Approval Required: ${customMsg}\n\nReply YES to continue or NO to cancel.`
|
|
777
|
+
: `⏸ Flow "${flow.name}" paused at step ${i + 1}/${flow.steps.length}.\n\nCompleted so far:\n${runningContext.slice(0, 500)}\n\nReply YES to continue or NO to cancel.`;
|
|
778
|
+
sendToBrowser({ type: "transcript", role: "assistant",
|
|
779
|
+
text: `⏸ Waiting for approval via ${channel}...` });
|
|
780
|
+
const approved = await sendApprovalRequest(channel, approvalMsg);
|
|
781
|
+
if (!approved) {
|
|
782
|
+
sendToBrowser({ type: "transcript", role: "assistant",
|
|
783
|
+
text: `Flow "${flow.name}" was denied at approval gate (step ${i + 1}).` });
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
sendToBrowser({ type: "transcript", role: "assistant",
|
|
787
|
+
text: `✓ Approval received — continuing flow.` });
|
|
788
|
+
runningContext += `\n\n[Step ${i + 1}: approval gate]: Approved via ${channel}`;
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
sendToBrowser({ type: "agent_working", query: `Step ${i + 1}/${flow.steps.length}: ${step.skill}` });
|
|
792
|
+
const prompt = `Use the skill "${step.skill}" (load it with /skill:${step.skill}).
|
|
793
|
+
${step.prompt.replace(/\{input\}/g, userContext)}
|
|
794
|
+
|
|
795
|
+
Context from previous steps:
|
|
796
|
+
${runningContext}`;
|
|
797
|
+
const result = query({
|
|
798
|
+
prompt,
|
|
799
|
+
dir: opts.agentDir,
|
|
800
|
+
model: opts.model,
|
|
801
|
+
env: opts.env,
|
|
802
|
+
});
|
|
803
|
+
let stepOutput = "";
|
|
804
|
+
for await (const msg of result) {
|
|
805
|
+
if (msg.type === "assistant" && msg.content)
|
|
806
|
+
stepOutput += msg.content;
|
|
807
|
+
if (msg.type === "tool_use")
|
|
808
|
+
sendToBrowser({ type: "tool_call", toolName: msg.toolName, args: msg.args });
|
|
809
|
+
if (msg.type === "tool_result")
|
|
810
|
+
sendToBrowser({ type: "tool_result", toolName: msg.toolName, content: msg.content, isError: msg.isError });
|
|
811
|
+
}
|
|
812
|
+
runningContext += `\n\n[Step ${i + 1} result (${step.skill})]: ${stepOutput}`;
|
|
813
|
+
sendToBrowser({ type: "agent_done", result: `Step ${i + 1} complete` });
|
|
814
|
+
}
|
|
815
|
+
sendToBrowser({ type: "transcript", role: "assistant", text: `Flow "${flow.name}" completed.` });
|
|
816
|
+
}
|
|
817
|
+
// ── File API helpers ────────────────────────────────────────────────
|
|
818
|
+
const HIDDEN_DIRS = new Set([".git", "node_modules", ".gitagent", "dist", ".next", "__pycache__", ".venv"]);
|
|
819
|
+
const agentRoot = resolve(opts.agentDir);
|
|
820
|
+
let activeBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: agentRoot, encoding: "utf-8" }).trim();
|
|
821
|
+
const pendingShutdownWork = [];
|
|
822
|
+
// ── Composio integration (optional) ────────────────────────────────
|
|
823
|
+
let composioAdapter = null;
|
|
824
|
+
if (process.env.COMPOSIO_API_KEY) {
|
|
825
|
+
composioAdapter = new ComposioAdapter({
|
|
826
|
+
apiKey: process.env.COMPOSIO_API_KEY,
|
|
827
|
+
userId: process.env.COMPOSIO_USER_ID || "default",
|
|
828
|
+
});
|
|
829
|
+
console.log(dim("[voice] Composio integration enabled"));
|
|
830
|
+
}
|
|
831
|
+
// ── Telegram bot state ──────────────────────────────────────────────
|
|
832
|
+
let telegramToken = process.env.TELEGRAM_BOT_TOKEN || "";
|
|
833
|
+
let telegramBotInfo = null;
|
|
834
|
+
let telegramPolling = false;
|
|
835
|
+
let telegramPollTimer = null;
|
|
836
|
+
let telegramOffset = 0;
|
|
837
|
+
// Allowed Telegram usernames — comma-separated in .env, empty = allow all
|
|
838
|
+
let telegramAllowedUsers = new Set((process.env.TELEGRAM_ALLOWED_USERS || "")
|
|
839
|
+
.split(",")
|
|
840
|
+
.map(s => s.trim().toLowerCase().replace(/^@/, ""))
|
|
841
|
+
.filter(Boolean));
|
|
842
|
+
let lastTelegramChatId = null;
|
|
843
|
+
function stopTelegramPolling() {
|
|
844
|
+
telegramPolling = false;
|
|
845
|
+
if (telegramPollTimer) {
|
|
846
|
+
clearTimeout(telegramPollTimer);
|
|
847
|
+
telegramPollTimer = null;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
/** Broadcast a message to all connected browser WebSocket clients */
|
|
851
|
+
function broadcastToBrowsers(msg) {
|
|
852
|
+
const payload = JSON.stringify(msg);
|
|
853
|
+
for (const client of wss.clients) {
|
|
854
|
+
if (client.readyState === 1)
|
|
855
|
+
client.send(payload);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
// Wire log broadcast to WebSocket
|
|
859
|
+
logBroadcast = (entry) => broadcastToBrowsers({ type: "log_entry", entry });
|
|
860
|
+
// ── Scheduler setup ────────────────────────────────────────────────
|
|
861
|
+
const scheduleSendToBrowser = (msg) => {
|
|
862
|
+
broadcastToBrowsers(msg);
|
|
863
|
+
appendMessage(opts.agentDir, activeBranch, msg);
|
|
864
|
+
};
|
|
865
|
+
const headlessHandler = createToolHandler(scheduleSendToBrowser);
|
|
866
|
+
const schedulerOpts = {
|
|
867
|
+
agentDir: agentRoot,
|
|
868
|
+
model: opts.model,
|
|
869
|
+
env: opts.env,
|
|
870
|
+
runPrompt: headlessHandler,
|
|
871
|
+
broadcastToBrowsers,
|
|
872
|
+
appendToHistory: (msg) => appendMessage(opts.agentDir, activeBranch, msg),
|
|
873
|
+
};
|
|
874
|
+
async function downloadTelegramFile(fileId, agentDir) {
|
|
875
|
+
try {
|
|
876
|
+
const fRes = await fetch(`https://api.telegram.org/bot${telegramToken}/getFile?file_id=${fileId}`);
|
|
877
|
+
const fData = await fRes.json();
|
|
878
|
+
if (!fData.ok)
|
|
879
|
+
return null;
|
|
880
|
+
const filePath = fData.result.file_path;
|
|
881
|
+
const ext = filePath.split(".").pop() || "jpg";
|
|
882
|
+
const name = `telegram_${Date.now()}.${ext}`;
|
|
883
|
+
const dlUrl = `https://api.telegram.org/file/bot${telegramToken}/${filePath}`;
|
|
884
|
+
const dlRes = await fetch(dlUrl);
|
|
885
|
+
const buffer = Buffer.from(await dlRes.arrayBuffer());
|
|
886
|
+
const wsDir = join(agentDir, "workspace");
|
|
887
|
+
mkdirSync(wsDir, { recursive: true });
|
|
888
|
+
const savePath = join(wsDir, name);
|
|
889
|
+
writeFileSync(savePath, buffer);
|
|
890
|
+
return { path: `workspace/${name}`, name };
|
|
891
|
+
}
|
|
892
|
+
catch {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
/** Collect all files recursively under a dir with their mtimes */
|
|
897
|
+
function snapshotFiles(dir, base = "") {
|
|
898
|
+
const result = new Map();
|
|
899
|
+
try {
|
|
900
|
+
for (const name of readdirSync(dir)) {
|
|
901
|
+
if (name.startsWith(".") || name === "node_modules" || name === "dist")
|
|
902
|
+
continue;
|
|
903
|
+
const full = join(dir, name);
|
|
904
|
+
const rel = base ? `${base}/${name}` : name;
|
|
905
|
+
try {
|
|
906
|
+
const st = statSync(full);
|
|
907
|
+
if (st.isDirectory()) {
|
|
908
|
+
for (const [k, v] of snapshotFiles(full, rel))
|
|
909
|
+
result.set(k, v);
|
|
910
|
+
}
|
|
911
|
+
else if (st.isFile()) {
|
|
912
|
+
result.set(rel, st.mtimeMs);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
catch { /* skip */ }
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
catch { /* skip */ }
|
|
919
|
+
return result;
|
|
920
|
+
}
|
|
921
|
+
/** Find new or modified files by comparing snapshots */
|
|
922
|
+
function diffSnapshots(before, after) {
|
|
923
|
+
const changed = [];
|
|
924
|
+
for (const [path, mtime] of after) {
|
|
925
|
+
if (!before.has(path) || before.get(path) < mtime)
|
|
926
|
+
changed.push(path);
|
|
927
|
+
}
|
|
928
|
+
return changed;
|
|
929
|
+
}
|
|
930
|
+
const SENDABLE_EXTS = new Set([
|
|
931
|
+
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "csv", "txt", "rtf",
|
|
932
|
+
"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp",
|
|
933
|
+
"zip", "tar", "gz", "json", "xml", "html", "css", "js", "ts", "py", "md",
|
|
934
|
+
"mp3", "mp4", "wav", "ogg", "webm",
|
|
935
|
+
]);
|
|
936
|
+
async function sendTelegramFile(chatId, filePath, agentDir, caption) {
|
|
937
|
+
const abs = join(agentDir, filePath);
|
|
938
|
+
if (!existsSync(abs))
|
|
939
|
+
return;
|
|
940
|
+
const st = statSync(abs);
|
|
941
|
+
if (st.size > 50 * 1024 * 1024)
|
|
942
|
+
return; // Telegram 50MB limit
|
|
943
|
+
const ext = filePath.split(".").pop()?.toLowerCase() || "";
|
|
944
|
+
const isImage = /^(png|jpg|jpeg|gif|webp|bmp)$/.test(ext);
|
|
945
|
+
const formBoundary = `----FormBoundary${Date.now()}`;
|
|
946
|
+
const fileData = readFileSync(abs);
|
|
947
|
+
const fileName = filePath.split("/").pop() || "file";
|
|
948
|
+
// Build multipart form
|
|
949
|
+
const fieldName = isImage ? "photo" : "document";
|
|
950
|
+
const endpoint = isImage ? "sendPhoto" : "sendDocument";
|
|
951
|
+
const parts = [];
|
|
952
|
+
const nl = Buffer.from("\r\n");
|
|
953
|
+
// chat_id field
|
|
954
|
+
parts.push(Buffer.from(`--${formBoundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}`));
|
|
955
|
+
parts.push(nl);
|
|
956
|
+
// caption field
|
|
957
|
+
if (caption) {
|
|
958
|
+
const cap = caption.length > 1024 ? caption.slice(0, 1021) + "..." : caption;
|
|
959
|
+
parts.push(Buffer.from(`--${formBoundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\n${cap}`));
|
|
960
|
+
parts.push(nl);
|
|
961
|
+
}
|
|
962
|
+
// file field — strip charset from text MIMEs since Telegram expects bare types
|
|
963
|
+
const mime = fileTypeFor(fileName).mime.split(";")[0].trim();
|
|
964
|
+
parts.push(Buffer.from(`--${formBoundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${fileName}"\r\nContent-Type: ${mime}\r\n\r\n`));
|
|
965
|
+
parts.push(fileData);
|
|
966
|
+
parts.push(nl);
|
|
967
|
+
parts.push(Buffer.from(`--${formBoundary}--\r\n`));
|
|
968
|
+
const body = Buffer.concat(parts);
|
|
969
|
+
try {
|
|
970
|
+
const resp = await fetch(`https://api.telegram.org/bot${telegramToken}/${endpoint}`, {
|
|
971
|
+
method: "POST",
|
|
972
|
+
headers: { "Content-Type": `multipart/form-data; boundary=${formBoundary}` },
|
|
973
|
+
body,
|
|
974
|
+
});
|
|
975
|
+
const rd = await resp.json();
|
|
976
|
+
if (rd.ok) {
|
|
977
|
+
console.log(dim(`[telegram] Sent file: ${fileName}`));
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
console.error(dim(`[telegram] Failed to send file ${fileName}: ${rd.description}`));
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
catch (err) {
|
|
984
|
+
console.error(dim(`[telegram] File send error: ${err.message}`));
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
function startTelegramPolling(agentDir, serverOpts) {
|
|
988
|
+
if (telegramPolling)
|
|
989
|
+
return;
|
|
990
|
+
telegramPolling = true;
|
|
991
|
+
console.log(dim("[voice] Telegram polling started"));
|
|
992
|
+
async function poll() {
|
|
993
|
+
if (!telegramPolling)
|
|
994
|
+
return;
|
|
995
|
+
try {
|
|
996
|
+
const res = await fetch(`https://api.telegram.org/bot${telegramToken}/getUpdates?offset=${telegramOffset}&timeout=30&allowed_updates=["message"]`);
|
|
997
|
+
const data = await res.json();
|
|
998
|
+
if (data.ok && data.result) {
|
|
999
|
+
for (const update of data.result) {
|
|
1000
|
+
telegramOffset = update.update_id + 1;
|
|
1001
|
+
const msg = update.message;
|
|
1002
|
+
if (!msg)
|
|
1003
|
+
continue;
|
|
1004
|
+
const chatId = msg.chat.id;
|
|
1005
|
+
lastTelegramChatId = chatId;
|
|
1006
|
+
const fromName = msg.from?.first_name || "User";
|
|
1007
|
+
const fromUsername = (msg.from?.username || "").toLowerCase();
|
|
1008
|
+
// Security: reject messages from unauthorized users
|
|
1009
|
+
// Empty = block all, * = allow all, otherwise check username list
|
|
1010
|
+
if (!telegramAllowedUsers.has("*")) {
|
|
1011
|
+
if (telegramAllowedUsers.size === 0 || !telegramAllowedUsers.has(fromUsername)) {
|
|
1012
|
+
console.log(dim(`[telegram] Blocked message from unauthorized user: @${fromUsername || "(no username)"} (${fromName})`));
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
let userText = msg.text || msg.caption || "";
|
|
1017
|
+
let imageContext = "";
|
|
1018
|
+
// Handle photo messages
|
|
1019
|
+
if (msg.photo && msg.photo.length > 0) {
|
|
1020
|
+
const largest = msg.photo[msg.photo.length - 1];
|
|
1021
|
+
const dl = await downloadTelegramFile(largest.file_id, agentDir);
|
|
1022
|
+
if (dl) {
|
|
1023
|
+
imageContext = ` [Image saved to ${dl.path}]`;
|
|
1024
|
+
// Notify browser of file change
|
|
1025
|
+
broadcastToBrowsers({ type: "files_changed" });
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
// Handle document/file messages
|
|
1029
|
+
if (msg.document) {
|
|
1030
|
+
const dl = await downloadTelegramFile(msg.document.file_id, agentDir);
|
|
1031
|
+
if (dl) {
|
|
1032
|
+
imageContext = ` [File saved to ${dl.path}: ${msg.document.file_name || dl.name}]`;
|
|
1033
|
+
broadcastToBrowsers({ type: "files_changed" });
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
if (!userText && !imageContext)
|
|
1037
|
+
continue;
|
|
1038
|
+
// ── Approval gate reply check ──
|
|
1039
|
+
if (userText && handleApprovalReply(userText)) {
|
|
1040
|
+
console.log(dim(`[telegram] Approval reply from ${fromName}: ${userText}`));
|
|
1041
|
+
const approvalMsg = { type: "transcript", role: "user", text: `[Telegram] ${fromName}: ${userText}` };
|
|
1042
|
+
appendMessage(serverOpts.agentDir, activeBranch, approvalMsg);
|
|
1043
|
+
broadcastToBrowsers(approvalMsg);
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
const fullText = `${userText}${imageContext}`.trim();
|
|
1047
|
+
console.log(dim(`[telegram] ${fromName}: ${fullText.slice(0, 100)}`));
|
|
1048
|
+
// ── Trigger check ──
|
|
1049
|
+
if (userText) {
|
|
1050
|
+
const trigger = matchTrigger(agentDir, "telegram", fromName, userText);
|
|
1051
|
+
if (trigger) {
|
|
1052
|
+
console.log(dim(`[triggers] Matched trigger ${trigger.id} for Telegram/${fromName}: "${userText.slice(0, 60)}" → "${trigger.reply.slice(0, 60)}"`));
|
|
1053
|
+
try {
|
|
1054
|
+
await fetch(`https://api.telegram.org/bot${telegramToken}/sendMessage`, {
|
|
1055
|
+
method: "POST",
|
|
1056
|
+
headers: { "Content-Type": "application/json" },
|
|
1057
|
+
body: JSON.stringify({ chat_id: chatId, text: trigger.reply }),
|
|
1058
|
+
});
|
|
1059
|
+
const triggerLog = { type: "transcript", role: "assistant", text: `[Trigger → ${fromName}]: ${trigger.reply}` };
|
|
1060
|
+
appendMessage(serverOpts.agentDir, activeBranch, triggerLog);
|
|
1061
|
+
broadcastToBrowsers(triggerLog);
|
|
1062
|
+
}
|
|
1063
|
+
catch (err) {
|
|
1064
|
+
console.error(dim(`[triggers] Telegram auto-reply failed: ${err.message}`));
|
|
1065
|
+
}
|
|
1066
|
+
continue; // Skip agent processing for triggered messages
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
// Save to shared chat history & broadcast to web UI
|
|
1070
|
+
const userMsg = { type: "transcript", role: "user", text: `[Telegram] ${fromName}: ${fullText}` };
|
|
1071
|
+
appendMessage(serverOpts.agentDir, activeBranch, userMsg);
|
|
1072
|
+
broadcastToBrowsers(userMsg);
|
|
1073
|
+
// Send typing indicator
|
|
1074
|
+
await fetch(`https://api.telegram.org/bot${telegramToken}/sendChatAction`, {
|
|
1075
|
+
method: "POST",
|
|
1076
|
+
headers: { "Content-Type": "application/json" },
|
|
1077
|
+
body: JSON.stringify({ chat_id: chatId, action: "typing" }),
|
|
1078
|
+
}).catch(() => { });
|
|
1079
|
+
// Snapshot files before agent runs
|
|
1080
|
+
const beforeFiles = snapshotFiles(agentDir);
|
|
1081
|
+
// Run agent query
|
|
1082
|
+
try {
|
|
1083
|
+
const agentWorking = { type: "agent_working", query: fullText };
|
|
1084
|
+
broadcastToBrowsers(agentWorking);
|
|
1085
|
+
appendMessage(serverOpts.agentDir, activeBranch, agentWorking);
|
|
1086
|
+
const tgContext = await getAgentContext(agentDir, activeBranch);
|
|
1087
|
+
const tgComposio = await getComposioContext(fullText);
|
|
1088
|
+
let tgSystemPrompt = "You are an AI assistant responding to a Telegram user. " +
|
|
1089
|
+
"Any files you create or modify will be AUTOMATICALLY sent back to the user on Telegram. " +
|
|
1090
|
+
"When asked to create documents (PDF, Word, PPT, spreadsheets, images, markdown files, text files, etc.), " +
|
|
1091
|
+
"write them to the workspace/ directory. The files will be delivered to the user immediately after you finish. " +
|
|
1092
|
+
"Keep text responses concise since they appear in a chat interface.";
|
|
1093
|
+
if (whatsappSock && whatsappConnected) {
|
|
1094
|
+
tgSystemPrompt += " You can also send WhatsApp messages to contacts using the send_whatsapp_message tool. " +
|
|
1095
|
+
"If you don't know a contact's number, ask the user or use list_whatsapp_contacts to check saved contacts.";
|
|
1096
|
+
}
|
|
1097
|
+
tgSystemPrompt += " You can set up auto-response triggers using create_trigger — e.g. 'when Kalps says hi on WhatsApp, reply hello friend'.";
|
|
1098
|
+
tgSystemPrompt += "\n\n" + getCurrentDateTimeContext();
|
|
1099
|
+
if (tgComposio.promptSuffix)
|
|
1100
|
+
tgSystemPrompt += "\n\n" + tgComposio.promptSuffix;
|
|
1101
|
+
if (tgContext)
|
|
1102
|
+
tgSystemPrompt += "\n\n" + tgContext;
|
|
1103
|
+
const tgTools = [
|
|
1104
|
+
...(whatsappSock && whatsappConnected ? createWhatsAppTools(whatsappSock, agentDir) : []),
|
|
1105
|
+
...createTriggerTools(agentDir),
|
|
1106
|
+
...tgComposio.tools,
|
|
1107
|
+
];
|
|
1108
|
+
const result = query({
|
|
1109
|
+
prompt: `[Telegram message from ${fromName}]: ${fullText}`,
|
|
1110
|
+
dir: agentDir,
|
|
1111
|
+
model: serverOpts.model,
|
|
1112
|
+
env: serverOpts.env,
|
|
1113
|
+
maxTurns: 10,
|
|
1114
|
+
systemPrompt: tgSystemPrompt,
|
|
1115
|
+
...(tgTools.length ? { tools: tgTools } : {}),
|
|
1116
|
+
});
|
|
1117
|
+
let reply = "";
|
|
1118
|
+
for await (const m of result) {
|
|
1119
|
+
if (m.type === "assistant" && m.content)
|
|
1120
|
+
reply += m.content;
|
|
1121
|
+
if (m.type === "tool_use") {
|
|
1122
|
+
const toolMsg = { type: "tool_call", toolName: m.toolName, args: m.args ?? {} };
|
|
1123
|
+
appendMessage(serverOpts.agentDir, activeBranch, toolMsg);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
reply = reply.trim();
|
|
1127
|
+
// Save agent response to shared history & broadcast
|
|
1128
|
+
const doneMsg = { type: "agent_done", result: reply.slice(0, 500) };
|
|
1129
|
+
appendMessage(serverOpts.agentDir, activeBranch, doneMsg);
|
|
1130
|
+
broadcastToBrowsers(doneMsg);
|
|
1131
|
+
const assistantMsg = { type: "transcript", role: "assistant", text: reply };
|
|
1132
|
+
appendMessage(serverOpts.agentDir, activeBranch, assistantMsg);
|
|
1133
|
+
broadcastToBrowsers(assistantMsg);
|
|
1134
|
+
if (reply) {
|
|
1135
|
+
// Split long messages (Telegram 4096 char limit)
|
|
1136
|
+
const chunks = [];
|
|
1137
|
+
for (let i = 0; i < reply.length; i += 4096) {
|
|
1138
|
+
chunks.push(reply.slice(i, i + 4096));
|
|
1139
|
+
}
|
|
1140
|
+
for (const chunk of chunks) {
|
|
1141
|
+
await fetch(`https://api.telegram.org/bot${telegramToken}/sendMessage`, {
|
|
1142
|
+
method: "POST",
|
|
1143
|
+
headers: { "Content-Type": "application/json" },
|
|
1144
|
+
body: JSON.stringify({ chat_id: chatId, text: chunk, parse_mode: "Markdown" }),
|
|
1145
|
+
}).catch(async () => {
|
|
1146
|
+
// Fallback without Markdown if parsing fails
|
|
1147
|
+
await fetch(`https://api.telegram.org/bot${telegramToken}/sendMessage`, {
|
|
1148
|
+
method: "POST",
|
|
1149
|
+
headers: { "Content-Type": "application/json" },
|
|
1150
|
+
body: JSON.stringify({ chat_id: chatId, text: chunk }),
|
|
1151
|
+
}).catch(() => { });
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
// Detect new/modified files and send them back to Telegram
|
|
1156
|
+
const afterFiles = snapshotFiles(agentDir);
|
|
1157
|
+
const newFiles = diffSnapshots(beforeFiles, afterFiles);
|
|
1158
|
+
const filesToSend = newFiles.filter((f) => {
|
|
1159
|
+
const ext = f.split(".").pop()?.toLowerCase() || "";
|
|
1160
|
+
// Skip chat history, internal files, and non-sendable types
|
|
1161
|
+
if (f.startsWith(".gitagent/") || f.startsWith("node_modules/"))
|
|
1162
|
+
return false;
|
|
1163
|
+
if (f === ".env" || f === ".gitignore")
|
|
1164
|
+
return false;
|
|
1165
|
+
return SENDABLE_EXTS.has(ext);
|
|
1166
|
+
});
|
|
1167
|
+
for (const filePath of filesToSend) {
|
|
1168
|
+
// Send upload_document action for each file
|
|
1169
|
+
await fetch(`https://api.telegram.org/bot${telegramToken}/sendChatAction`, {
|
|
1170
|
+
method: "POST",
|
|
1171
|
+
headers: { "Content-Type": "application/json" },
|
|
1172
|
+
body: JSON.stringify({ chat_id: chatId, action: "upload_document" }),
|
|
1173
|
+
}).catch(() => { });
|
|
1174
|
+
await sendTelegramFile(chatId, filePath, agentDir, filePath.split("/").pop());
|
|
1175
|
+
}
|
|
1176
|
+
// Notify browser of any file changes from agent
|
|
1177
|
+
broadcastToBrowsers({ type: "files_changed" });
|
|
1178
|
+
}
|
|
1179
|
+
catch (err) {
|
|
1180
|
+
console.error(dim(`[telegram] Agent error: ${err.message}`));
|
|
1181
|
+
await fetch(`https://api.telegram.org/bot${telegramToken}/sendMessage`, {
|
|
1182
|
+
method: "POST",
|
|
1183
|
+
headers: { "Content-Type": "application/json" },
|
|
1184
|
+
body: JSON.stringify({ chat_id: chatId, text: "Sorry, I encountered an error processing your message." }),
|
|
1185
|
+
}).catch(() => { });
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
catch (err) {
|
|
1191
|
+
console.error(dim(`[telegram] Poll error: ${err.message}`));
|
|
1192
|
+
}
|
|
1193
|
+
if (telegramPolling)
|
|
1194
|
+
telegramPollTimer = setTimeout(poll, 500);
|
|
1195
|
+
}
|
|
1196
|
+
poll();
|
|
1197
|
+
}
|
|
1198
|
+
// Auto-connect if token is already configured
|
|
1199
|
+
if (telegramToken) {
|
|
1200
|
+
fetch(`https://api.telegram.org/bot${telegramToken}/getMe`)
|
|
1201
|
+
.then((r) => r.json())
|
|
1202
|
+
.then((d) => {
|
|
1203
|
+
if (d.ok) {
|
|
1204
|
+
telegramBotInfo = d.result;
|
|
1205
|
+
startTelegramPolling(agentRoot, opts);
|
|
1206
|
+
console.log(dim(`[voice] Telegram bot connected: @${d.result.username}`));
|
|
1207
|
+
}
|
|
1208
|
+
})
|
|
1209
|
+
.catch(() => { });
|
|
1210
|
+
}
|
|
1211
|
+
// ── WhatsApp state ─────────────────────────────────────────────────
|
|
1212
|
+
let lastWhatsAppJid = null;
|
|
1213
|
+
let whatsappSock = null;
|
|
1214
|
+
let whatsappConnected = false;
|
|
1215
|
+
let whatsappPhoneNumber = null;
|
|
1216
|
+
let whatsappQrCode = null;
|
|
1217
|
+
const whatsappSentIds = new Set();
|
|
1218
|
+
function contactsPath(agentDir) {
|
|
1219
|
+
return join(agentDir, ".gitagent", "whatsapp-contacts.json");
|
|
1220
|
+
}
|
|
1221
|
+
function loadContacts(agentDir) {
|
|
1222
|
+
try {
|
|
1223
|
+
return JSON.parse(readFileSync(contactsPath(agentDir), "utf-8"));
|
|
1224
|
+
}
|
|
1225
|
+
catch {
|
|
1226
|
+
return [];
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
function saveContacts(agentDir, contacts) {
|
|
1230
|
+
const dir = join(agentDir, ".gitagent");
|
|
1231
|
+
mkdirSync(dir, { recursive: true });
|
|
1232
|
+
writeFileSync(contactsPath(agentDir), JSON.stringify(contacts, null, 2));
|
|
1233
|
+
}
|
|
1234
|
+
function findContact(agentDir, nameQuery) {
|
|
1235
|
+
const q = nameQuery.toLowerCase();
|
|
1236
|
+
return loadContacts(agentDir).find(c => c.name.toLowerCase() === q || c.name.toLowerCase().includes(q));
|
|
1237
|
+
}
|
|
1238
|
+
function upsertContact(agentDir, contact) {
|
|
1239
|
+
const contacts = loadContacts(agentDir);
|
|
1240
|
+
const idx = contacts.findIndex(c => c.jid === contact.jid);
|
|
1241
|
+
if (idx >= 0)
|
|
1242
|
+
contacts[idx] = contact;
|
|
1243
|
+
else
|
|
1244
|
+
contacts.push(contact);
|
|
1245
|
+
saveContacts(agentDir, contacts);
|
|
1246
|
+
}
|
|
1247
|
+
/** Build WhatsApp tools that use the live Baileys socket */
|
|
1248
|
+
function createWhatsAppTools(sock, agentDir) {
|
|
1249
|
+
return [
|
|
1250
|
+
{
|
|
1251
|
+
name: "send_whatsapp_message",
|
|
1252
|
+
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.",
|
|
1253
|
+
inputSchema: {
|
|
1254
|
+
type: "object",
|
|
1255
|
+
properties: {
|
|
1256
|
+
to: { type: "string", description: "Contact name or phone number (with country code, no '+' prefix, e.g. '919876543210')" },
|
|
1257
|
+
message: { type: "string", description: "Message text to send" },
|
|
1258
|
+
},
|
|
1259
|
+
required: ["to", "message"],
|
|
1260
|
+
},
|
|
1261
|
+
handler: async (args) => {
|
|
1262
|
+
let jid;
|
|
1263
|
+
let displayName = args.to;
|
|
1264
|
+
// Try contact lookup first, then treat as phone number
|
|
1265
|
+
const contact = findContact(agentDir, args.to);
|
|
1266
|
+
if (contact) {
|
|
1267
|
+
jid = contact.jid;
|
|
1268
|
+
displayName = contact.name;
|
|
1269
|
+
}
|
|
1270
|
+
else {
|
|
1271
|
+
const digits = args.to.replace(/[^0-9]/g, "");
|
|
1272
|
+
if (!digits || digits.length < 7) {
|
|
1273
|
+
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).`;
|
|
1274
|
+
}
|
|
1275
|
+
jid = `${digits}@s.whatsapp.net`;
|
|
1276
|
+
}
|
|
1277
|
+
const sent = await sock.sendMessage(jid, { text: args.message });
|
|
1278
|
+
if (sent?.key?.id)
|
|
1279
|
+
whatsappSentIds.add(sent.key.id);
|
|
1280
|
+
console.log(dim(`[whatsapp] Sent message to ${displayName} (${jid}): ${args.message.slice(0, 80)}`));
|
|
1281
|
+
return `Message sent to ${displayName}.`;
|
|
1282
|
+
},
|
|
1283
|
+
},
|
|
1284
|
+
{
|
|
1285
|
+
name: "save_whatsapp_contact",
|
|
1286
|
+
description: "Save a WhatsApp contact for future use. This lets you send messages by name instead of phone number.",
|
|
1287
|
+
inputSchema: {
|
|
1288
|
+
type: "object",
|
|
1289
|
+
properties: {
|
|
1290
|
+
name: { type: "string", description: "Contact name (e.g. 'Kalps')" },
|
|
1291
|
+
phone: { type: "string", description: "Phone number with country code, no '+' prefix (e.g. '919876543210')" },
|
|
1292
|
+
},
|
|
1293
|
+
required: ["name", "phone"],
|
|
1294
|
+
},
|
|
1295
|
+
handler: async (args) => {
|
|
1296
|
+
const digits = args.phone.replace(/[^0-9]/g, "");
|
|
1297
|
+
const jid = `${digits}@s.whatsapp.net`;
|
|
1298
|
+
upsertContact(agentDir, { name: args.name, phone: digits, jid });
|
|
1299
|
+
console.log(dim(`[whatsapp] Saved contact: ${args.name} → ${digits}`));
|
|
1300
|
+
return `Contact "${args.name}" saved with phone ${digits}.`;
|
|
1301
|
+
},
|
|
1302
|
+
},
|
|
1303
|
+
{
|
|
1304
|
+
name: "list_whatsapp_contacts",
|
|
1305
|
+
description: "List all saved WhatsApp contacts.",
|
|
1306
|
+
inputSchema: { type: "object", properties: {} },
|
|
1307
|
+
handler: async () => {
|
|
1308
|
+
const contacts = loadContacts(agentDir);
|
|
1309
|
+
if (!contacts.length)
|
|
1310
|
+
return "No saved contacts. Use save_whatsapp_contact to add one.";
|
|
1311
|
+
return contacts.map(c => `${c.name}: ${c.phone}`).join("\n");
|
|
1312
|
+
},
|
|
1313
|
+
},
|
|
1314
|
+
];
|
|
1315
|
+
}
|
|
1316
|
+
function triggersPath(agentDir) {
|
|
1317
|
+
return join(agentDir, ".gitagent", "triggers.json");
|
|
1318
|
+
}
|
|
1319
|
+
function loadTriggers(agentDir) {
|
|
1320
|
+
try {
|
|
1321
|
+
return JSON.parse(readFileSync(triggersPath(agentDir), "utf-8"));
|
|
1322
|
+
}
|
|
1323
|
+
catch {
|
|
1324
|
+
return [];
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
function saveTriggers(agentDir, triggers) {
|
|
1328
|
+
const dir = join(agentDir, ".gitagent");
|
|
1329
|
+
mkdirSync(dir, { recursive: true });
|
|
1330
|
+
writeFileSync(triggersPath(agentDir), JSON.stringify(triggers, null, 2));
|
|
1331
|
+
}
|
|
1332
|
+
function matchTrigger(agentDir, platform, from, message) {
|
|
1333
|
+
const triggers = loadTriggers(agentDir);
|
|
1334
|
+
const fromLower = from.toLowerCase();
|
|
1335
|
+
const msgLower = message.toLowerCase();
|
|
1336
|
+
return triggers.find(t => {
|
|
1337
|
+
if (!t.enabled)
|
|
1338
|
+
return false;
|
|
1339
|
+
if (t.platform !== "*" && t.platform !== platform)
|
|
1340
|
+
return false;
|
|
1341
|
+
if (t.from !== "*") {
|
|
1342
|
+
// Match by contact name or phone number
|
|
1343
|
+
const contact = findContact(agentDir, t.from);
|
|
1344
|
+
if (contact) {
|
|
1345
|
+
if (fromLower !== contact.jid && fromLower !== contact.phone && fromLower !== contact.name.toLowerCase())
|
|
1346
|
+
return false;
|
|
1347
|
+
}
|
|
1348
|
+
else if (fromLower !== t.from.toLowerCase())
|
|
1349
|
+
return false;
|
|
1350
|
+
}
|
|
1351
|
+
// Pattern match — try regex first, fall back to substring
|
|
1352
|
+
try {
|
|
1353
|
+
if (new RegExp(t.pattern, "i").test(message))
|
|
1354
|
+
return true;
|
|
1355
|
+
}
|
|
1356
|
+
catch {
|
|
1357
|
+
if (msgLower.includes(t.pattern.toLowerCase()))
|
|
1358
|
+
return true;
|
|
1359
|
+
}
|
|
1360
|
+
return false;
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
function createTriggerTools(agentDir) {
|
|
1364
|
+
return [
|
|
1365
|
+
{
|
|
1366
|
+
name: "create_trigger",
|
|
1367
|
+
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.",
|
|
1368
|
+
inputSchema: {
|
|
1369
|
+
type: "object",
|
|
1370
|
+
properties: {
|
|
1371
|
+
from: { type: "string", description: "Contact name, phone number, or '*' for anyone" },
|
|
1372
|
+
pattern: { type: "string", description: "Text pattern to match (substring or regex)" },
|
|
1373
|
+
reply: { type: "string", description: "Auto-reply message to send" },
|
|
1374
|
+
platform: { type: "string", enum: ["whatsapp", "telegram", "*"], description: "Platform to trigger on (default: '*')" },
|
|
1375
|
+
},
|
|
1376
|
+
required: ["from", "pattern", "reply"],
|
|
1377
|
+
},
|
|
1378
|
+
handler: async (args) => {
|
|
1379
|
+
const trigger = {
|
|
1380
|
+
id: Date.now().toString(36),
|
|
1381
|
+
from: args.from,
|
|
1382
|
+
pattern: args.pattern,
|
|
1383
|
+
reply: args.reply,
|
|
1384
|
+
platform: args.platform || "*",
|
|
1385
|
+
enabled: true,
|
|
1386
|
+
};
|
|
1387
|
+
const triggers = loadTriggers(agentDir);
|
|
1388
|
+
triggers.push(trigger);
|
|
1389
|
+
saveTriggers(agentDir, triggers);
|
|
1390
|
+
console.log(dim(`[triggers] Created: when ${trigger.from} says "${trigger.pattern}" → "${trigger.reply}" (${trigger.platform})`));
|
|
1391
|
+
return `Trigger created (id: ${trigger.id}). When ${trigger.from} sends a message matching "${trigger.pattern}", I'll auto-reply: "${trigger.reply}"`;
|
|
1392
|
+
},
|
|
1393
|
+
},
|
|
1394
|
+
{
|
|
1395
|
+
name: "list_triggers",
|
|
1396
|
+
description: "List all message triggers.",
|
|
1397
|
+
inputSchema: { type: "object", properties: {} },
|
|
1398
|
+
handler: async () => {
|
|
1399
|
+
const triggers = loadTriggers(agentDir);
|
|
1400
|
+
if (!triggers.length)
|
|
1401
|
+
return "No triggers set up.";
|
|
1402
|
+
return triggers.map(t => `[${t.id}] ${t.enabled ? "ON" : "OFF"} | from: ${t.from} | pattern: "${t.pattern}" | reply: "${t.reply}" | platform: ${t.platform}`).join("\n");
|
|
1403
|
+
},
|
|
1404
|
+
},
|
|
1405
|
+
{
|
|
1406
|
+
name: "delete_trigger",
|
|
1407
|
+
description: "Delete a trigger by its ID.",
|
|
1408
|
+
inputSchema: {
|
|
1409
|
+
type: "object",
|
|
1410
|
+
properties: { id: { type: "string", description: "Trigger ID to delete" } },
|
|
1411
|
+
required: ["id"],
|
|
1412
|
+
},
|
|
1413
|
+
handler: async (args) => {
|
|
1414
|
+
const triggers = loadTriggers(agentDir);
|
|
1415
|
+
const idx = triggers.findIndex(t => t.id === args.id);
|
|
1416
|
+
if (idx < 0)
|
|
1417
|
+
return `Trigger "${args.id}" not found.`;
|
|
1418
|
+
const removed = triggers.splice(idx, 1)[0];
|
|
1419
|
+
saveTriggers(agentDir, triggers);
|
|
1420
|
+
console.log(dim(`[triggers] Deleted: ${removed.id}`));
|
|
1421
|
+
return `Trigger "${removed.id}" deleted (was: ${removed.from} / "${removed.pattern}").`;
|
|
1422
|
+
},
|
|
1423
|
+
},
|
|
1424
|
+
{
|
|
1425
|
+
name: "toggle_trigger",
|
|
1426
|
+
description: "Enable or disable a trigger by its ID.",
|
|
1427
|
+
inputSchema: {
|
|
1428
|
+
type: "object",
|
|
1429
|
+
properties: {
|
|
1430
|
+
id: { type: "string", description: "Trigger ID" },
|
|
1431
|
+
enabled: { type: "boolean", description: "true to enable, false to disable" },
|
|
1432
|
+
},
|
|
1433
|
+
required: ["id", "enabled"],
|
|
1434
|
+
},
|
|
1435
|
+
handler: async (args) => {
|
|
1436
|
+
const triggers = loadTriggers(agentDir);
|
|
1437
|
+
const t = triggers.find(t => t.id === args.id);
|
|
1438
|
+
if (!t)
|
|
1439
|
+
return `Trigger "${args.id}" not found.`;
|
|
1440
|
+
t.enabled = args.enabled;
|
|
1441
|
+
saveTriggers(agentDir, triggers);
|
|
1442
|
+
return `Trigger "${t.id}" ${args.enabled ? "enabled" : "disabled"}.`;
|
|
1443
|
+
},
|
|
1444
|
+
},
|
|
1445
|
+
];
|
|
1446
|
+
}
|
|
1447
|
+
async function startWhatsApp(agentDir, serverOpts) {
|
|
1448
|
+
const { default: makeWASocket, useMultiFileAuthState, makeCacheableSignalKeyStore, fetchLatestBaileysVersion, DisconnectReason, jidNormalizedUser, } = await import("baileys");
|
|
1449
|
+
const authDir = join(agentDir, ".gitagent/whatsapp-auth");
|
|
1450
|
+
mkdirSync(authDir, { recursive: true });
|
|
1451
|
+
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
|
1452
|
+
const { version } = await fetchLatestBaileysVersion();
|
|
1453
|
+
const sock = makeWASocket({
|
|
1454
|
+
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys) },
|
|
1455
|
+
version,
|
|
1456
|
+
browser: ["GitAgent", "cli", "0.3.1"],
|
|
1457
|
+
printQRInTerminal: false,
|
|
1458
|
+
syncFullHistory: false,
|
|
1459
|
+
markOnlineOnConnect: false,
|
|
1460
|
+
});
|
|
1461
|
+
whatsappSock = sock;
|
|
1462
|
+
sock.ev.on("connection.update", (update) => {
|
|
1463
|
+
const { connection, lastDisconnect, qr } = update;
|
|
1464
|
+
if (qr) {
|
|
1465
|
+
whatsappQrCode = qr;
|
|
1466
|
+
broadcastToBrowsers({ type: "whatsapp_qr", qr });
|
|
1467
|
+
console.log(dim("[whatsapp] QR code generated — scan with WhatsApp"));
|
|
1468
|
+
}
|
|
1469
|
+
if (connection === "open") {
|
|
1470
|
+
whatsappConnected = true;
|
|
1471
|
+
whatsappQrCode = null;
|
|
1472
|
+
const jid = sock.user?.id || "";
|
|
1473
|
+
whatsappPhoneNumber = jid.replace(/:.*@/, "@").replace("@s.whatsapp.net", "");
|
|
1474
|
+
console.log(dim(`[whatsapp] Connected: ${whatsappPhoneNumber}`));
|
|
1475
|
+
broadcastToBrowsers({ type: "whatsapp_status", connected: true, phoneNumber: whatsappPhoneNumber });
|
|
1476
|
+
}
|
|
1477
|
+
if (connection === "close") {
|
|
1478
|
+
whatsappConnected = false;
|
|
1479
|
+
whatsappQrCode = null;
|
|
1480
|
+
const statusCode = lastDisconnect?.error?.output?.statusCode;
|
|
1481
|
+
const loggedOut = statusCode === DisconnectReason.loggedOut;
|
|
1482
|
+
console.log(dim(`[whatsapp] Disconnected (code=${statusCode}, loggedOut=${loggedOut})`));
|
|
1483
|
+
broadcastToBrowsers({ type: "whatsapp_status", connected: false });
|
|
1484
|
+
if (!loggedOut) {
|
|
1485
|
+
// Auto-reconnect
|
|
1486
|
+
setTimeout(() => startWhatsApp(agentDir, serverOpts).catch(() => { }), 3000);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
sock.ev.on("creds.update", saveCreds);
|
|
1491
|
+
sock.ev.on("messages.upsert", async ({ messages, type }) => {
|
|
1492
|
+
console.log(dim(`[whatsapp] upsert type=${type}, count=${messages.length}`));
|
|
1493
|
+
if (type !== "notify")
|
|
1494
|
+
return;
|
|
1495
|
+
const ownJid = sock.user?.id ? jidNormalizedUser(sock.user.id) : null;
|
|
1496
|
+
// Also track our LID (Linked Identity) — WhatsApp may route self-DMs via LID
|
|
1497
|
+
const ownLid = sock.user?.lid?.replace(/:.*@/, "@") || null;
|
|
1498
|
+
if (!ownJid)
|
|
1499
|
+
return;
|
|
1500
|
+
for (const msg of messages) {
|
|
1501
|
+
console.log(dim(`[whatsapp] msg: remoteJid=${msg.key.remoteJid}, fromMe=${msg.key.fromMe}, ownJid=${ownJid}, ownLid=${ownLid}, id=${msg.key.id}`));
|
|
1502
|
+
// Skip agent's own replies
|
|
1503
|
+
if (whatsappSentIds.has(msg.key.id))
|
|
1504
|
+
continue;
|
|
1505
|
+
const incomingText = msg.message?.conversation
|
|
1506
|
+
|| msg.message?.extendedTextMessage?.text || "";
|
|
1507
|
+
if (!incomingText)
|
|
1508
|
+
continue;
|
|
1509
|
+
const senderJid = msg.key.remoteJid;
|
|
1510
|
+
const isSelf = senderJid === ownJid || (ownLid && senderJid === ownLid);
|
|
1511
|
+
// ── Trigger check (runs on ALL incoming messages, not just self-DMs) ──
|
|
1512
|
+
if (!isSelf && !msg.key.fromMe) {
|
|
1513
|
+
// Resolve sender identity for trigger matching
|
|
1514
|
+
const senderPhone = senderJid.replace("@s.whatsapp.net", "");
|
|
1515
|
+
const senderContact = loadContacts(agentDir).find(c => c.jid === senderJid || c.phone === senderPhone);
|
|
1516
|
+
const senderName = senderContact?.name || senderPhone;
|
|
1517
|
+
const trigger = matchTrigger(agentDir, "whatsapp", senderContact?.name || senderJid, incomingText);
|
|
1518
|
+
if (trigger) {
|
|
1519
|
+
console.log(dim(`[triggers] Matched trigger ${trigger.id} for ${senderName}: "${incomingText.slice(0, 60)}" → "${trigger.reply.slice(0, 60)}"`));
|
|
1520
|
+
try {
|
|
1521
|
+
const sent = await sock.sendMessage(senderJid, { text: trigger.reply });
|
|
1522
|
+
if (sent?.key?.id)
|
|
1523
|
+
whatsappSentIds.add(sent.key.id);
|
|
1524
|
+
// Log to chat history
|
|
1525
|
+
const triggerLog = { type: "transcript", role: "assistant", text: `[Trigger → ${senderName}]: ${trigger.reply}` };
|
|
1526
|
+
appendMessage(serverOpts.agentDir, activeBranch, triggerLog);
|
|
1527
|
+
broadcastToBrowsers(triggerLog);
|
|
1528
|
+
}
|
|
1529
|
+
catch (err) {
|
|
1530
|
+
console.error(dim(`[triggers] Failed to send auto-reply: ${err.message}`));
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
continue; // Non-self messages are only processed for triggers
|
|
1534
|
+
}
|
|
1535
|
+
// Only process self-DMs for agent interaction
|
|
1536
|
+
if (!isSelf)
|
|
1537
|
+
continue;
|
|
1538
|
+
// ── Self-DM: full agent interaction ──
|
|
1539
|
+
const text = incomingText;
|
|
1540
|
+
const replyJid = senderJid;
|
|
1541
|
+
lastWhatsAppJid = replyJid;
|
|
1542
|
+
console.log(dim(`[whatsapp] Self-DM: ${text.slice(0, 100)}`));
|
|
1543
|
+
// ── Approval gate reply check ──
|
|
1544
|
+
if (handleApprovalReply(text)) {
|
|
1545
|
+
console.log(dim(`[whatsapp] Approval reply: ${text}`));
|
|
1546
|
+
const approvalMsg = { type: "transcript", role: "user", text: `[WhatsApp]: ${text}` };
|
|
1547
|
+
appendMessage(serverOpts.agentDir, activeBranch, approvalMsg);
|
|
1548
|
+
broadcastToBrowsers(approvalMsg);
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
// Broadcast to browser UI
|
|
1552
|
+
const userMsg = { type: "transcript", role: "user", text: `[WhatsApp]: ${text}` };
|
|
1553
|
+
appendMessage(serverOpts.agentDir, activeBranch, userMsg);
|
|
1554
|
+
broadcastToBrowsers(userMsg);
|
|
1555
|
+
// Send typing presence
|
|
1556
|
+
try {
|
|
1557
|
+
await sock.presenceSubscribe(replyJid);
|
|
1558
|
+
await sock.sendPresenceUpdate("composing", replyJid);
|
|
1559
|
+
}
|
|
1560
|
+
catch { /* ignore */ }
|
|
1561
|
+
// Snapshot files before agent runs
|
|
1562
|
+
const beforeFiles = snapshotFiles(agentDir);
|
|
1563
|
+
try {
|
|
1564
|
+
const agentWorking = { type: "agent_working", query: text };
|
|
1565
|
+
broadcastToBrowsers(agentWorking);
|
|
1566
|
+
appendMessage(serverOpts.agentDir, activeBranch, agentWorking);
|
|
1567
|
+
const waContext = await getAgentContext(agentDir, activeBranch);
|
|
1568
|
+
const waComposio = await getComposioContext(text);
|
|
1569
|
+
let waSystemPrompt = "You are an AI assistant responding via WhatsApp. " +
|
|
1570
|
+
"Any files you create or modify will be AUTOMATICALLY sent back to the user on WhatsApp. " +
|
|
1571
|
+
"When asked to create documents or markdown files, write them to the workspace/ directory. " +
|
|
1572
|
+
"Keep text responses concise since they appear in a chat interface. " +
|
|
1573
|
+
"You can send WhatsApp messages to other people using the send_whatsapp_message tool. " +
|
|
1574
|
+
"If you don't know a contact's number, ask the user or use list_whatsapp_contacts to check saved contacts. " +
|
|
1575
|
+
"You can also set up auto-response triggers using create_trigger — e.g. 'when Kalps says hi, reply hello friend'.";
|
|
1576
|
+
waSystemPrompt += "\n\n" + getCurrentDateTimeContext();
|
|
1577
|
+
if (waComposio.promptSuffix)
|
|
1578
|
+
waSystemPrompt += "\n\n" + waComposio.promptSuffix;
|
|
1579
|
+
if (waContext)
|
|
1580
|
+
waSystemPrompt += "\n\n" + waContext;
|
|
1581
|
+
const waTools = [...createWhatsAppTools(sock, agentDir), ...createTriggerTools(agentDir), ...waComposio.tools];
|
|
1582
|
+
const result = query({
|
|
1583
|
+
prompt: `[WhatsApp message]: ${text}`,
|
|
1584
|
+
dir: agentDir,
|
|
1585
|
+
model: serverOpts.model,
|
|
1586
|
+
env: serverOpts.env,
|
|
1587
|
+
maxTurns: 10,
|
|
1588
|
+
systemPrompt: waSystemPrompt,
|
|
1589
|
+
tools: waTools,
|
|
1590
|
+
});
|
|
1591
|
+
let reply = "";
|
|
1592
|
+
for await (const m of result) {
|
|
1593
|
+
if (m.type === "assistant" && m.content)
|
|
1594
|
+
reply += m.content;
|
|
1595
|
+
}
|
|
1596
|
+
reply = reply.trim();
|
|
1597
|
+
// Save agent response to shared history & broadcast
|
|
1598
|
+
const doneMsg = { type: "agent_done", result: reply.slice(0, 500) };
|
|
1599
|
+
appendMessage(serverOpts.agentDir, activeBranch, doneMsg);
|
|
1600
|
+
broadcastToBrowsers(doneMsg);
|
|
1601
|
+
const assistantMsg = { type: "transcript", role: "assistant", text: reply };
|
|
1602
|
+
appendMessage(serverOpts.agentDir, activeBranch, assistantMsg);
|
|
1603
|
+
broadcastToBrowsers(assistantMsg);
|
|
1604
|
+
// Send reply (chunk at 4000 chars for WhatsApp)
|
|
1605
|
+
if (reply) {
|
|
1606
|
+
const chunks = [];
|
|
1607
|
+
for (let i = 0; i < reply.length; i += 4000)
|
|
1608
|
+
chunks.push(reply.slice(i, i + 4000));
|
|
1609
|
+
for (const chunk of chunks) {
|
|
1610
|
+
const italicChunk = chunk.split("\n").map(line => line ? `_${line}_` : "").join("\n");
|
|
1611
|
+
const sent = await sock.sendMessage(replyJid, { text: `*GitAgent:*\n${italicChunk}` });
|
|
1612
|
+
if (sent?.key?.id)
|
|
1613
|
+
whatsappSentIds.add(sent.key.id);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
// Detect new/modified files and send them back
|
|
1617
|
+
const afterFiles = snapshotFiles(agentDir);
|
|
1618
|
+
const newFiles = diffSnapshots(beforeFiles, afterFiles).filter((f) => {
|
|
1619
|
+
const ext = f.split(".").pop()?.toLowerCase() || "";
|
|
1620
|
+
if (f.startsWith(".gitagent/") || f.startsWith("node_modules/"))
|
|
1621
|
+
return false;
|
|
1622
|
+
if (f === ".env" || f === ".gitignore")
|
|
1623
|
+
return false;
|
|
1624
|
+
return SENDABLE_EXTS.has(ext);
|
|
1625
|
+
});
|
|
1626
|
+
for (const filePath of newFiles) {
|
|
1627
|
+
const abs = join(agentDir, filePath);
|
|
1628
|
+
if (!existsSync(abs))
|
|
1629
|
+
continue;
|
|
1630
|
+
const buffer = readFileSync(abs);
|
|
1631
|
+
const sent = await sock.sendMessage(replyJid, {
|
|
1632
|
+
document: buffer,
|
|
1633
|
+
fileName: filePath.split("/").pop() || "file",
|
|
1634
|
+
mimetype: "application/octet-stream",
|
|
1635
|
+
});
|
|
1636
|
+
if (sent?.key?.id)
|
|
1637
|
+
whatsappSentIds.add(sent.key.id);
|
|
1638
|
+
}
|
|
1639
|
+
broadcastToBrowsers({ type: "files_changed" });
|
|
1640
|
+
}
|
|
1641
|
+
catch (err) {
|
|
1642
|
+
console.error(dim(`[whatsapp] Agent error: ${err.message}`));
|
|
1643
|
+
try {
|
|
1644
|
+
const sent = await sock.sendMessage(replyJid, { text: "*GitAgent:* _Sorry, I encountered an error processing your message._" });
|
|
1645
|
+
if (sent?.key?.id)
|
|
1646
|
+
whatsappSentIds.add(sent.key.id);
|
|
1647
|
+
}
|
|
1648
|
+
catch { /* ignore */ }
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
function stopWhatsApp(clearAuth = false) {
|
|
1654
|
+
if (whatsappSock) {
|
|
1655
|
+
try {
|
|
1656
|
+
whatsappSock.end(undefined);
|
|
1657
|
+
}
|
|
1658
|
+
catch { /* ignore */ }
|
|
1659
|
+
}
|
|
1660
|
+
whatsappSock = null;
|
|
1661
|
+
whatsappConnected = false;
|
|
1662
|
+
whatsappPhoneNumber = null;
|
|
1663
|
+
whatsappQrCode = null;
|
|
1664
|
+
whatsappSentIds.clear();
|
|
1665
|
+
if (clearAuth) {
|
|
1666
|
+
const authDir = join(agentRoot, ".gitagent/whatsapp-auth");
|
|
1667
|
+
try {
|
|
1668
|
+
rmSync(authDir, { recursive: true, force: true });
|
|
1669
|
+
}
|
|
1670
|
+
catch { /* ignore */ }
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
// Auto-connect WhatsApp if auth exists
|
|
1674
|
+
const waAuthDir = join(agentRoot, ".gitagent/whatsapp-auth");
|
|
1675
|
+
if (existsSync(join(waAuthDir, "creds.json"))) {
|
|
1676
|
+
startWhatsApp(agentRoot, opts).catch(() => { });
|
|
1677
|
+
}
|
|
1678
|
+
/** Resolve and validate a requested path stays within agentDir */
|
|
1679
|
+
function safePath(reqPath) {
|
|
1680
|
+
const abs = resolve(agentRoot, reqPath);
|
|
1681
|
+
if (!abs.startsWith(agentRoot))
|
|
1682
|
+
return null;
|
|
1683
|
+
return abs;
|
|
1684
|
+
}
|
|
1685
|
+
function listDir(dirPath, depth) {
|
|
1686
|
+
if (depth > 4)
|
|
1687
|
+
return [];
|
|
1688
|
+
try {
|
|
1689
|
+
const entries = readdirSync(dirPath);
|
|
1690
|
+
const result = [];
|
|
1691
|
+
for (const name of entries) {
|
|
1692
|
+
if (name.startsWith(".") && HIDDEN_DIRS.has(name))
|
|
1693
|
+
continue;
|
|
1694
|
+
if (HIDDEN_DIRS.has(name))
|
|
1695
|
+
continue;
|
|
1696
|
+
const fullPath = join(dirPath, name);
|
|
1697
|
+
const relPath = relative(agentRoot, fullPath);
|
|
1698
|
+
try {
|
|
1699
|
+
const st = statSync(fullPath);
|
|
1700
|
+
if (st.isDirectory()) {
|
|
1701
|
+
result.push({
|
|
1702
|
+
name,
|
|
1703
|
+
path: relPath,
|
|
1704
|
+
type: "directory",
|
|
1705
|
+
children: listDir(fullPath, depth + 1),
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
else if (st.isFile()) {
|
|
1709
|
+
result.push({ name, path: relPath, type: "file", mtime: st.mtimeMs });
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
catch {
|
|
1713
|
+
// skip unreadable entries
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
// Sort: directories first, then alphabetical
|
|
1717
|
+
result.sort((a, b) => {
|
|
1718
|
+
if (a.type !== b.type)
|
|
1719
|
+
return a.type === "directory" ? -1 : 1;
|
|
1720
|
+
return a.name.localeCompare(b.name);
|
|
1721
|
+
});
|
|
1722
|
+
return result;
|
|
1723
|
+
}
|
|
1724
|
+
catch {
|
|
1725
|
+
return [];
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
function readBody(req) {
|
|
1729
|
+
return new Promise((res, rej) => {
|
|
1730
|
+
let body = "";
|
|
1731
|
+
req.on("data", (c) => { body += c.toString(); });
|
|
1732
|
+
req.on("end", () => res(body));
|
|
1733
|
+
req.on("error", rej);
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
function jsonReply(res, status, data) {
|
|
1737
|
+
if (status >= 500 && data && data.error) {
|
|
1738
|
+
console.error(`[http] 500 response: ${data.error}`);
|
|
1739
|
+
}
|
|
1740
|
+
else if (status >= 400 && data && data.error) {
|
|
1741
|
+
console.warn(`[http] ${status} response: ${data.error}`);
|
|
1742
|
+
}
|
|
1743
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1744
|
+
res.end(JSON.stringify(data));
|
|
1745
|
+
}
|
|
1746
|
+
function escapeXml(s) {
|
|
1747
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1748
|
+
}
|
|
1749
|
+
// ── Password protection ──────────────────────────────────────────
|
|
1750
|
+
// Auth gates the UI when GITAGENT_PASSWORD is set. GITAGENT_USERNAME is
|
|
1751
|
+
// optional and defaults to "admin" when a password is configured.
|
|
1752
|
+
const serverPassword = process.env.GITAGENT_PASSWORD || "";
|
|
1753
|
+
const serverUsername = process.env.GITAGENT_USERNAME || (serverPassword ? "admin" : "");
|
|
1754
|
+
const authCookieName = "gitagent_auth";
|
|
1755
|
+
function generateAuthToken() {
|
|
1756
|
+
// Hash username + password + salt so changing either invalidates existing cookies.
|
|
1757
|
+
const { createHash } = require("crypto");
|
|
1758
|
+
return createHash("sha256")
|
|
1759
|
+
.update(`${serverUsername}:${serverPassword}:_gitagent_session`)
|
|
1760
|
+
.digest("hex")
|
|
1761
|
+
.slice(0, 32);
|
|
1762
|
+
}
|
|
1763
|
+
function isAuthenticated(req) {
|
|
1764
|
+
if (!serverPassword)
|
|
1765
|
+
return true; // No password set — open access
|
|
1766
|
+
const cookie = req.headers.cookie || "";
|
|
1767
|
+
const match = cookie.match(new RegExp(`${authCookieName}=([^;]+)`));
|
|
1768
|
+
return match?.[1] === generateAuthToken();
|
|
1769
|
+
}
|
|
1770
|
+
function timingSafeEqualStr(a, b) {
|
|
1771
|
+
const { timingSafeEqual } = require("crypto");
|
|
1772
|
+
const ab = Buffer.from(a);
|
|
1773
|
+
const bb = Buffer.from(b);
|
|
1774
|
+
if (ab.length !== bb.length)
|
|
1775
|
+
return false;
|
|
1776
|
+
return timingSafeEqual(ab, bb);
|
|
1777
|
+
}
|
|
1778
|
+
const loginPageHtml = `<!DOCTYPE html>
|
|
1779
|
+
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
1780
|
+
<title>GitAgent — Login</title>
|
|
1781
|
+
<style>
|
|
1782
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
1783
|
+
body{background:#0d1117;color:#e6edf3;font-family:'Inter',system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh}
|
|
1784
|
+
.login{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:40px;width:340px;text-align:center}
|
|
1785
|
+
.login h1{font-size:20px;margin-bottom:8px;font-weight:600}
|
|
1786
|
+
.login p{font-size:13px;color:#8b949e;margin-bottom:24px}
|
|
1787
|
+
.login input{width:100%;padding:12px 14px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#e6edf3;font-size:14px;outline:none;margin-bottom:12px}
|
|
1788
|
+
.login input:focus{border-color:#58a6ff}
|
|
1789
|
+
.login button{width:100%;padding:12px;background:#238636;border:none;border-radius:6px;color:#fff;font-size:14px;font-weight:600;cursor:pointer;margin-top:4px}
|
|
1790
|
+
.login button:hover{background:#2ea043}
|
|
1791
|
+
.login .error{color:#f85149;font-size:12px;margin-bottom:12px;display:none}
|
|
1792
|
+
</style></head><body>
|
|
1793
|
+
<div class="login">
|
|
1794
|
+
<h1>GitAgent</h1>
|
|
1795
|
+
<p>Sign in to continue</p>
|
|
1796
|
+
<div class="error" id="err">Incorrect username or password</div>
|
|
1797
|
+
<form onsubmit="return doLogin()">
|
|
1798
|
+
<input type="text" id="un" placeholder="Username" autocomplete="username" autofocus>
|
|
1799
|
+
<input type="password" id="pw" placeholder="Password" autocomplete="current-password">
|
|
1800
|
+
<button type="submit">Sign in</button>
|
|
1801
|
+
</form>
|
|
1802
|
+
</div>
|
|
1803
|
+
<script>
|
|
1804
|
+
function doLogin(){
|
|
1805
|
+
var un=document.getElementById('un').value;
|
|
1806
|
+
var pw=document.getElementById('pw').value;
|
|
1807
|
+
fetch('/api/auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:un,password:pw})})
|
|
1808
|
+
.then(function(r){return r.json().then(function(d){return{ok:r.ok,data:d};});})
|
|
1809
|
+
.then(function(res){
|
|
1810
|
+
if(res.ok&&res.data.ok){window.location.reload();}
|
|
1811
|
+
else{document.getElementById('err').style.display='block';document.getElementById('pw').value='';document.getElementById('pw').focus();}
|
|
1812
|
+
});
|
|
1813
|
+
return false;
|
|
1814
|
+
}
|
|
1815
|
+
</script></body></html>`;
|
|
1816
|
+
// HTTP server
|
|
1817
|
+
const httpServer = createServer(async (req, res) => {
|
|
1818
|
+
const reqStart = Date.now();
|
|
1819
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1820
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
1821
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
1822
|
+
if (req.method === "OPTIONS") {
|
|
1823
|
+
res.writeHead(204);
|
|
1824
|
+
return res.end();
|
|
1825
|
+
}
|
|
1826
|
+
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
1827
|
+
// Log every HTTP request (skip UI + static paths to reduce noise; always log API/errors)
|
|
1828
|
+
const isApi = url.pathname.startsWith("/api/");
|
|
1829
|
+
res.on("finish", () => {
|
|
1830
|
+
if (isApi || res.statusCode >= 400) {
|
|
1831
|
+
const dur = Date.now() - reqStart;
|
|
1832
|
+
const level = res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "log";
|
|
1833
|
+
const line = `[http] ${req.method} ${url.pathname} → ${res.statusCode} (${dur}ms)`;
|
|
1834
|
+
if (level === "error")
|
|
1835
|
+
console.error(line);
|
|
1836
|
+
else if (level === "warn")
|
|
1837
|
+
console.warn(line);
|
|
1838
|
+
else
|
|
1839
|
+
console.log(line);
|
|
1840
|
+
}
|
|
1841
|
+
});
|
|
1842
|
+
req.on("error", (err) => console.error(`[http] Request error on ${req.method} ${url.pathname}: ${err.message}`));
|
|
1843
|
+
res.on("error", (err) => console.error(`[http] Response error on ${req.method} ${url.pathname}: ${err.message}`));
|
|
1844
|
+
// ── Auth endpoints (always accessible) ──
|
|
1845
|
+
if (url.pathname === "/api/auth" && req.method === "POST") {
|
|
1846
|
+
let body;
|
|
1847
|
+
try {
|
|
1848
|
+
body = JSON.parse(await readBody(req));
|
|
1849
|
+
}
|
|
1850
|
+
catch {
|
|
1851
|
+
return jsonReply(res, 400, { ok: false, error: "Invalid request" });
|
|
1852
|
+
}
|
|
1853
|
+
const userOk = timingSafeEqualStr(String(body.username ?? ""), serverUsername);
|
|
1854
|
+
const passOk = timingSafeEqualStr(String(body.password ?? ""), serverPassword);
|
|
1855
|
+
if (userOk && passOk && serverPassword) {
|
|
1856
|
+
res.setHeader("Set-Cookie", `${authCookieName}=${generateAuthToken()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`);
|
|
1857
|
+
jsonReply(res, 200, { ok: true });
|
|
1858
|
+
}
|
|
1859
|
+
else {
|
|
1860
|
+
jsonReply(res, 401, { ok: false, error: "Incorrect username or password" });
|
|
1861
|
+
}
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
// ── Password gate — block everything if not authenticated ──
|
|
1865
|
+
if (!isAuthenticated(req)) {
|
|
1866
|
+
if (url.pathname === "/health") {
|
|
1867
|
+
// Health check always open for load balancers
|
|
1868
|
+
jsonReply(res, 200, { status: "ok", auth: "required" });
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1872
|
+
res.end(loginPageHtml);
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
if (url.pathname === "/health") {
|
|
1876
|
+
jsonReply(res, 200, { status: "ok" });
|
|
1877
|
+
}
|
|
1878
|
+
else if (url.pathname === "/api/vitals") {
|
|
1879
|
+
jsonReply(res, 200, getVitalsSnapshot());
|
|
1880
|
+
}
|
|
1881
|
+
else if (url.pathname === "/api/settings" && req.method === "GET") {
|
|
1882
|
+
// Read current model from agent.yaml and key presence from .env
|
|
1883
|
+
let model = "";
|
|
1884
|
+
try {
|
|
1885
|
+
const yamlRaw = readFileSync(join(agentRoot, "agent.yaml"), "utf-8");
|
|
1886
|
+
const m = yamlRaw.match(/preferred:\s*["']?([^"'\n]+)["']?/);
|
|
1887
|
+
if (m)
|
|
1888
|
+
model = m[1].trim();
|
|
1889
|
+
}
|
|
1890
|
+
catch { /* no agent.yaml */ }
|
|
1891
|
+
const keys = {};
|
|
1892
|
+
for (const k of ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "COMPOSIO_API_KEY"]) {
|
|
1893
|
+
keys[k] = !!process.env[k];
|
|
1894
|
+
}
|
|
1895
|
+
const baseUrl = process.env.GITAGENT_MODEL_BASE_URL || "";
|
|
1896
|
+
jsonReply(res, 200, { model, keys, baseUrl });
|
|
1897
|
+
}
|
|
1898
|
+
else if (url.pathname === "/api/settings" && req.method === "PUT") {
|
|
1899
|
+
try {
|
|
1900
|
+
const body = JSON.parse(await readBody(req));
|
|
1901
|
+
// Update .env with new keys
|
|
1902
|
+
const envPath = join(agentRoot, ".env");
|
|
1903
|
+
let envContent = "";
|
|
1904
|
+
try {
|
|
1905
|
+
envContent = readFileSync(envPath, "utf-8");
|
|
1906
|
+
}
|
|
1907
|
+
catch { /* new file */ }
|
|
1908
|
+
const envKeys = body.keys || {};
|
|
1909
|
+
for (const [key, val] of Object.entries(envKeys)) {
|
|
1910
|
+
if (typeof val !== "string" || !val)
|
|
1911
|
+
continue;
|
|
1912
|
+
process.env[key] = val;
|
|
1913
|
+
const regex = new RegExp(`^${key}=.*$`, "m");
|
|
1914
|
+
if (regex.test(envContent)) {
|
|
1915
|
+
envContent = envContent.replace(regex, `${key}=${val}`);
|
|
1916
|
+
}
|
|
1917
|
+
else {
|
|
1918
|
+
envContent += (envContent.endsWith("\n") || !envContent ? "" : "\n") + `${key}=${val}\n`;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
writeFileSync(envPath, envContent, "utf-8");
|
|
1922
|
+
// Update model in agent.yaml
|
|
1923
|
+
if (body.model) {
|
|
1924
|
+
const yamlPath = join(agentRoot, "agent.yaml");
|
|
1925
|
+
try {
|
|
1926
|
+
let yamlContent = readFileSync(yamlPath, "utf-8");
|
|
1927
|
+
if (/preferred:\s*["']?[^"'\n]*["']?/.test(yamlContent)) {
|
|
1928
|
+
yamlContent = yamlContent.replace(/preferred:\s*["']?[^"'\n]*["']?/, `preferred: "${body.model}"`);
|
|
1929
|
+
}
|
|
1930
|
+
writeFileSync(yamlPath, yamlContent, "utf-8");
|
|
1931
|
+
}
|
|
1932
|
+
catch { /* no agent.yaml to update */ }
|
|
1933
|
+
}
|
|
1934
|
+
// Update base URL in .env
|
|
1935
|
+
if (body.baseUrl !== undefined) {
|
|
1936
|
+
const baseUrlKey = "GITAGENT_MODEL_BASE_URL";
|
|
1937
|
+
if (body.baseUrl) {
|
|
1938
|
+
process.env[baseUrlKey] = body.baseUrl;
|
|
1939
|
+
const regex = new RegExp(`^${baseUrlKey}=.*$`, "m");
|
|
1940
|
+
if (regex.test(envContent)) {
|
|
1941
|
+
envContent = envContent.replace(regex, `${baseUrlKey}=${body.baseUrl}`);
|
|
1942
|
+
}
|
|
1943
|
+
else {
|
|
1944
|
+
envContent += (envContent.endsWith("\n") || !envContent ? "" : "\n") + `${baseUrlKey}=${body.baseUrl}\n`;
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
else {
|
|
1948
|
+
delete process.env[baseUrlKey];
|
|
1949
|
+
envContent = envContent.replace(/^GITAGENT_MODEL_BASE_URL=.*\n?/m, "");
|
|
1950
|
+
}
|
|
1951
|
+
writeFileSync(envPath, envContent, "utf-8");
|
|
1952
|
+
}
|
|
1953
|
+
console.log("[settings] Configuration updated — keys in process.env, model in agent.yaml");
|
|
1954
|
+
jsonReply(res, 200, { ok: true });
|
|
1955
|
+
}
|
|
1956
|
+
catch (err) {
|
|
1957
|
+
jsonReply(res, 400, { error: err.message || "Invalid request" });
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
else if (url.pathname === "/" || url.pathname === "/test") {
|
|
1961
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1962
|
+
res.end(buildUiHtml());
|
|
1963
|
+
}
|
|
1964
|
+
else if (url.pathname === "/api/files" && req.method === "GET") {
|
|
1965
|
+
// List files as a tree
|
|
1966
|
+
const reqPath = url.searchParams.get("path") || ".";
|
|
1967
|
+
const abs = safePath(reqPath);
|
|
1968
|
+
if (!abs)
|
|
1969
|
+
return jsonReply(res, 403, { error: "Path outside workspace" });
|
|
1970
|
+
const tree = listDir(abs, 0);
|
|
1971
|
+
jsonReply(res, 200, { root: relative(agentRoot, abs) || ".", entries: tree });
|
|
1972
|
+
}
|
|
1973
|
+
else if (url.pathname === "/api/file" && req.method === "GET") {
|
|
1974
|
+
// Read a file
|
|
1975
|
+
const reqPath = url.searchParams.get("path");
|
|
1976
|
+
if (!reqPath)
|
|
1977
|
+
return jsonReply(res, 400, { error: "Missing path param" });
|
|
1978
|
+
const abs = safePath(reqPath);
|
|
1979
|
+
if (!abs)
|
|
1980
|
+
return jsonReply(res, 403, { error: "Path outside workspace" });
|
|
1981
|
+
if (!existsSync(abs))
|
|
1982
|
+
return jsonReply(res, 404, { error: "File not found" });
|
|
1983
|
+
try {
|
|
1984
|
+
const st = statSync(abs);
|
|
1985
|
+
if (st.size > 1024 * 1024)
|
|
1986
|
+
return jsonReply(res, 413, { error: "File too large (>1MB)" });
|
|
1987
|
+
const content = readFileSync(abs, "utf-8");
|
|
1988
|
+
jsonReply(res, 200, { path: reqPath, content });
|
|
1989
|
+
}
|
|
1990
|
+
catch (err) {
|
|
1991
|
+
jsonReply(res, 500, { error: err.message });
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
else if (url.pathname === "/api/file/raw" && req.method === "GET") {
|
|
1995
|
+
// Serve raw file with correct MIME type, streaming + Range support.
|
|
1996
|
+
// ?download=1 forces Content-Disposition: attachment.
|
|
1997
|
+
const reqPath = url.searchParams.get("path");
|
|
1998
|
+
if (!reqPath)
|
|
1999
|
+
return jsonReply(res, 400, { error: "Missing path param" });
|
|
2000
|
+
const abs = safePath(reqPath);
|
|
2001
|
+
if (!abs)
|
|
2002
|
+
return jsonReply(res, 403, { error: "Path outside workspace" });
|
|
2003
|
+
if (!existsSync(abs))
|
|
2004
|
+
return jsonReply(res, 404, { error: "File not found" });
|
|
2005
|
+
try {
|
|
2006
|
+
const info = fileTypeFor(reqPath);
|
|
2007
|
+
const download = url.searchParams.get("download") === "1";
|
|
2008
|
+
streamFileWithRange(req, res, abs, {
|
|
2009
|
+
mime: info.mime,
|
|
2010
|
+
download,
|
|
2011
|
+
filename: reqPath.split("/").pop() || undefined,
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
catch (err) {
|
|
2015
|
+
jsonReply(res, 500, { error: err.message });
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
else if (url.pathname === "/api/file/meta" && req.method === "GET") {
|
|
2019
|
+
// File metadata (kind, mime, size, mtime) — UI calls this before deciding what to render.
|
|
2020
|
+
const reqPath = url.searchParams.get("path");
|
|
2021
|
+
if (!reqPath)
|
|
2022
|
+
return jsonReply(res, 400, { error: "Missing path param" });
|
|
2023
|
+
const abs = safePath(reqPath);
|
|
2024
|
+
if (!abs)
|
|
2025
|
+
return jsonReply(res, 403, { error: "Path outside workspace" });
|
|
2026
|
+
if (!existsSync(abs))
|
|
2027
|
+
return jsonReply(res, 404, { error: "File not found" });
|
|
2028
|
+
try {
|
|
2029
|
+
const st = statSync(abs);
|
|
2030
|
+
const info = fileTypeFor(reqPath);
|
|
2031
|
+
jsonReply(res, 200, {
|
|
2032
|
+
path: reqPath,
|
|
2033
|
+
name: reqPath.split("/").pop() || reqPath,
|
|
2034
|
+
size: st.size,
|
|
2035
|
+
mtime: st.mtimeMs,
|
|
2036
|
+
kind: info.kind,
|
|
2037
|
+
mime: info.mime,
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
catch (err) {
|
|
2041
|
+
jsonReply(res, 500, { error: err.message });
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
else if (url.pathname.startsWith("/preview/") && req.method === "GET") {
|
|
2045
|
+
// Path-based file preview — relative URLs in HTML resolve against this prefix.
|
|
2046
|
+
// e.g. /preview/workspace/site/index.html → <link href="style.css"> → /preview/workspace/site/style.css
|
|
2047
|
+
let relPath;
|
|
2048
|
+
try {
|
|
2049
|
+
relPath = decodeURIComponent(url.pathname.slice("/preview/".length));
|
|
2050
|
+
}
|
|
2051
|
+
catch {
|
|
2052
|
+
return jsonReply(res, 400, { error: "Invalid path encoding" });
|
|
2053
|
+
}
|
|
2054
|
+
if (!relPath)
|
|
2055
|
+
return jsonReply(res, 400, { error: "Missing path" });
|
|
2056
|
+
const abs = safePath(relPath);
|
|
2057
|
+
if (!abs)
|
|
2058
|
+
return jsonReply(res, 403, { error: "Path outside workspace" });
|
|
2059
|
+
if (!existsSync(abs))
|
|
2060
|
+
return jsonReply(res, 404, { error: "File not found" });
|
|
2061
|
+
try {
|
|
2062
|
+
const info = fileTypeFor(relPath);
|
|
2063
|
+
const extraHeaders = {};
|
|
2064
|
+
if (info.kind === "html") {
|
|
2065
|
+
// Sandbox is also applied on the iframe element; CSP is the real enforcement layer.
|
|
2066
|
+
extraHeaders["Content-Security-Policy"] =
|
|
2067
|
+
"default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; frame-ancestors 'self'";
|
|
2068
|
+
extraHeaders["X-Frame-Options"] = "SAMEORIGIN";
|
|
2069
|
+
}
|
|
2070
|
+
streamFileWithRange(req, res, abs, {
|
|
2071
|
+
mime: info.mime,
|
|
2072
|
+
filename: relPath.split("/").pop() || undefined,
|
|
2073
|
+
extraHeaders,
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
catch (err) {
|
|
2077
|
+
jsonReply(res, 500, { error: err.message });
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
else if (url.pathname === "/api/file" && req.method === "PUT") {
|
|
2081
|
+
// Write a file
|
|
2082
|
+
const body = await readBody(req);
|
|
2083
|
+
let parsed;
|
|
2084
|
+
try {
|
|
2085
|
+
parsed = JSON.parse(body);
|
|
2086
|
+
}
|
|
2087
|
+
catch {
|
|
2088
|
+
return jsonReply(res, 400, { error: "Invalid JSON body" });
|
|
2089
|
+
}
|
|
2090
|
+
if (!parsed.path || parsed.content === undefined)
|
|
2091
|
+
return jsonReply(res, 400, { error: "Missing path or content" });
|
|
2092
|
+
const abs = safePath(parsed.path);
|
|
2093
|
+
if (!abs)
|
|
2094
|
+
return jsonReply(res, 403, { error: "Path outside workspace" });
|
|
2095
|
+
try {
|
|
2096
|
+
writeFileSync(abs, parsed.content, "utf-8");
|
|
2097
|
+
jsonReply(res, 200, { ok: true, path: parsed.path });
|
|
2098
|
+
}
|
|
2099
|
+
catch (err) {
|
|
2100
|
+
jsonReply(res, 500, { error: err.message });
|
|
2101
|
+
}
|
|
2102
|
+
// ── Telegram bot routes ─────────────────────────────────────────
|
|
2103
|
+
}
|
|
2104
|
+
else if (url.pathname === "/api/telegram/status" && req.method === "GET") {
|
|
2105
|
+
jsonReply(res, 200, {
|
|
2106
|
+
connected: telegramPolling,
|
|
2107
|
+
botName: telegramBotInfo?.first_name || null,
|
|
2108
|
+
botUsername: telegramBotInfo?.username || null,
|
|
2109
|
+
hasToken: !!telegramToken,
|
|
2110
|
+
allowedUsers: [...telegramAllowedUsers],
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
else if (url.pathname === "/api/telegram/connect" && req.method === "POST") {
|
|
2114
|
+
const body = await readBody(req);
|
|
2115
|
+
try {
|
|
2116
|
+
const parsed = JSON.parse(body);
|
|
2117
|
+
if (parsed.token)
|
|
2118
|
+
telegramToken = parsed.token;
|
|
2119
|
+
if (parsed.allowedUsers !== undefined) {
|
|
2120
|
+
telegramAllowedUsers = new Set(parsed.allowedUsers.split(",")
|
|
2121
|
+
.map((s) => s.trim().toLowerCase().replace(/^@/, ""))
|
|
2122
|
+
.filter(Boolean));
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
catch { /* use existing token */ }
|
|
2126
|
+
if (!telegramToken)
|
|
2127
|
+
return jsonReply(res, 400, { error: "No bot token provided" });
|
|
2128
|
+
// Save token + allowed users to .env for persistence
|
|
2129
|
+
const envPath = join(agentRoot, ".env");
|
|
2130
|
+
let envContent = "";
|
|
2131
|
+
try {
|
|
2132
|
+
envContent = readFileSync(envPath, "utf-8");
|
|
2133
|
+
}
|
|
2134
|
+
catch { /* new file */ }
|
|
2135
|
+
// Save token
|
|
2136
|
+
if (envContent.includes("TELEGRAM_BOT_TOKEN=")) {
|
|
2137
|
+
envContent = envContent.replace(/^TELEGRAM_BOT_TOKEN=.*$/m, `TELEGRAM_BOT_TOKEN=${telegramToken}`);
|
|
2138
|
+
}
|
|
2139
|
+
else {
|
|
2140
|
+
envContent += `\nTELEGRAM_BOT_TOKEN=${telegramToken}\n`;
|
|
2141
|
+
}
|
|
2142
|
+
// Save allowed users
|
|
2143
|
+
const allowedStr = [...telegramAllowedUsers].join(",");
|
|
2144
|
+
if (envContent.includes("TELEGRAM_ALLOWED_USERS=")) {
|
|
2145
|
+
envContent = envContent.replace(/^TELEGRAM_ALLOWED_USERS=.*$/m, `TELEGRAM_ALLOWED_USERS=${allowedStr}`);
|
|
2146
|
+
}
|
|
2147
|
+
else if (allowedStr) {
|
|
2148
|
+
envContent += `TELEGRAM_ALLOWED_USERS=${allowedStr}\n`;
|
|
2149
|
+
}
|
|
2150
|
+
writeFileSync(envPath, envContent, "utf-8");
|
|
2151
|
+
// Validate token by calling getMe
|
|
2152
|
+
try {
|
|
2153
|
+
const meRes = await fetch(`https://api.telegram.org/bot${telegramToken}/getMe`);
|
|
2154
|
+
const meData = await meRes.json();
|
|
2155
|
+
if (!meData.ok)
|
|
2156
|
+
return jsonReply(res, 400, { error: meData.description || "Invalid token" });
|
|
2157
|
+
telegramBotInfo = meData.result;
|
|
2158
|
+
// Start polling
|
|
2159
|
+
startTelegramPolling(agentRoot, opts);
|
|
2160
|
+
jsonReply(res, 200, { ok: true, botName: telegramBotInfo.first_name, botUsername: telegramBotInfo.username });
|
|
2161
|
+
}
|
|
2162
|
+
catch (err) {
|
|
2163
|
+
jsonReply(res, 500, { error: err.message });
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
else if (url.pathname === "/api/telegram/allowed-users" && req.method === "POST") {
|
|
2167
|
+
const body = await readBody(req);
|
|
2168
|
+
try {
|
|
2169
|
+
const parsed = JSON.parse(body);
|
|
2170
|
+
telegramAllowedUsers = new Set((parsed.users || "").split(",")
|
|
2171
|
+
.map((s) => s.trim().toLowerCase().replace(/^@/, ""))
|
|
2172
|
+
.filter(Boolean));
|
|
2173
|
+
// Persist to .env
|
|
2174
|
+
const envPath = join(agentRoot, ".env");
|
|
2175
|
+
let envContent = "";
|
|
2176
|
+
try {
|
|
2177
|
+
envContent = readFileSync(envPath, "utf-8");
|
|
2178
|
+
}
|
|
2179
|
+
catch { /* new file */ }
|
|
2180
|
+
const allowedStr = [...telegramAllowedUsers].join(",");
|
|
2181
|
+
if (envContent.includes("TELEGRAM_ALLOWED_USERS=")) {
|
|
2182
|
+
envContent = envContent.replace(/^TELEGRAM_ALLOWED_USERS=.*$/m, `TELEGRAM_ALLOWED_USERS=${allowedStr}`);
|
|
2183
|
+
}
|
|
2184
|
+
else if (allowedStr) {
|
|
2185
|
+
envContent += `\nTELEGRAM_ALLOWED_USERS=${allowedStr}\n`;
|
|
2186
|
+
}
|
|
2187
|
+
else {
|
|
2188
|
+
envContent = envContent.replace(/^TELEGRAM_ALLOWED_USERS=.*\n?/m, "");
|
|
2189
|
+
}
|
|
2190
|
+
writeFileSync(envPath, envContent, "utf-8");
|
|
2191
|
+
jsonReply(res, 200, { ok: true, allowedUsers: [...telegramAllowedUsers] });
|
|
2192
|
+
}
|
|
2193
|
+
catch (err) {
|
|
2194
|
+
jsonReply(res, 400, { error: err.message });
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
else if (url.pathname === "/api/telegram/disconnect" && req.method === "POST") {
|
|
2198
|
+
stopTelegramPolling();
|
|
2199
|
+
telegramBotInfo = null;
|
|
2200
|
+
jsonReply(res, 200, { ok: true });
|
|
2201
|
+
// ── WhatsApp routes ─────────────────────────────────────────────
|
|
2202
|
+
}
|
|
2203
|
+
else if (url.pathname === "/api/whatsapp/status" && req.method === "GET") {
|
|
2204
|
+
jsonReply(res, 200, {
|
|
2205
|
+
connected: whatsappConnected,
|
|
2206
|
+
phoneNumber: whatsappPhoneNumber,
|
|
2207
|
+
hasAuth: existsSync(join(agentRoot, ".gitagent/whatsapp-auth/creds.json")),
|
|
2208
|
+
qrCode: whatsappQrCode,
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
else if (url.pathname === "/api/whatsapp/connect" && req.method === "POST") {
|
|
2212
|
+
if (whatsappConnected)
|
|
2213
|
+
return jsonReply(res, 200, { ok: true, connected: true, phoneNumber: whatsappPhoneNumber });
|
|
2214
|
+
try {
|
|
2215
|
+
await startWhatsApp(agentRoot, opts);
|
|
2216
|
+
jsonReply(res, 200, { ok: true, connecting: true });
|
|
2217
|
+
}
|
|
2218
|
+
catch (err) {
|
|
2219
|
+
jsonReply(res, 500, { error: err.message });
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
else if (url.pathname === "/api/whatsapp/disconnect" && req.method === "POST") {
|
|
2223
|
+
let clearAuth = false;
|
|
2224
|
+
try {
|
|
2225
|
+
const body = await readBody(req);
|
|
2226
|
+
const parsed = JSON.parse(body);
|
|
2227
|
+
clearAuth = !!parsed.clearAuth;
|
|
2228
|
+
}
|
|
2229
|
+
catch { /* no body is fine */ }
|
|
2230
|
+
stopWhatsApp(clearAuth);
|
|
2231
|
+
jsonReply(res, 200, { ok: true });
|
|
2232
|
+
}
|
|
2233
|
+
else if (url.pathname === "/api/whatsapp/qr" && req.method === "GET") {
|
|
2234
|
+
jsonReply(res, 200, { qrCode: whatsappQrCode, connected: whatsappConnected });
|
|
2235
|
+
// ── Phone / Twilio webhook ──────────────────────────────────────
|
|
2236
|
+
}
|
|
2237
|
+
else if (url.pathname === "/api/phone/webhook" && req.method === "POST") {
|
|
2238
|
+
// Twilio sends SMS/voice webhooks here as application/x-www-form-urlencoded
|
|
2239
|
+
const body = await readBody(req);
|
|
2240
|
+
const params = new URLSearchParams(body);
|
|
2241
|
+
const from = params.get("From") || "";
|
|
2242
|
+
const smsBody = params.get("Body") || "";
|
|
2243
|
+
const callStatus = params.get("CallStatus") || "";
|
|
2244
|
+
if (smsBody) {
|
|
2245
|
+
// Incoming SMS
|
|
2246
|
+
console.log(dim(`[phone] SMS from ${from}: ${smsBody.slice(0, 100)}`));
|
|
2247
|
+
const userMsg = { type: "transcript", role: "user", text: `[SMS ${from}]: ${smsBody}` };
|
|
2248
|
+
appendMessage(opts.agentDir, activeBranch, userMsg);
|
|
2249
|
+
broadcastToBrowsers(userMsg);
|
|
2250
|
+
// Check triggers
|
|
2251
|
+
const senderName = from.replace(/[^0-9]/g, "");
|
|
2252
|
+
const contact = loadContacts(opts.agentDir).find(c => c.phone === senderName || from.includes(c.phone));
|
|
2253
|
+
const trigger = matchTrigger(opts.agentDir, "phone", contact?.name || from, smsBody);
|
|
2254
|
+
if (trigger) {
|
|
2255
|
+
console.log(dim(`[triggers] Phone trigger ${trigger.id}: "${smsBody.slice(0, 40)}" → "${trigger.reply.slice(0, 40)}"`));
|
|
2256
|
+
// Reply with TwiML
|
|
2257
|
+
res.writeHead(200, { "Content-Type": "text/xml" });
|
|
2258
|
+
res.end(`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${escapeXml(trigger.reply)}</Message></Response>`);
|
|
2259
|
+
const triggerLog = { type: "transcript", role: "assistant", text: `[Trigger → ${from}]: ${trigger.reply}` };
|
|
2260
|
+
appendMessage(opts.agentDir, activeBranch, triggerLog);
|
|
2261
|
+
broadcastToBrowsers(triggerLog);
|
|
2262
|
+
return;
|
|
2263
|
+
}
|
|
2264
|
+
// Run agent for non-triggered messages
|
|
2265
|
+
try {
|
|
2266
|
+
const phoneContext = await getAgentContext(opts.agentDir, activeBranch);
|
|
2267
|
+
const phoneComposio = await getComposioContext(smsBody);
|
|
2268
|
+
let phoneSystemPrompt = "You are an AI assistant responding to an SMS message via Twilio. " +
|
|
2269
|
+
"Keep responses concise — SMS has character limits. Respond in plain text only.";
|
|
2270
|
+
phoneSystemPrompt += "\n\n" + getCurrentDateTimeContext();
|
|
2271
|
+
if (phoneComposio.promptSuffix)
|
|
2272
|
+
phoneSystemPrompt += "\n\n" + phoneComposio.promptSuffix;
|
|
2273
|
+
if (phoneContext)
|
|
2274
|
+
phoneSystemPrompt += "\n\n" + phoneContext;
|
|
2275
|
+
const phoneTools = [
|
|
2276
|
+
...createTriggerTools(opts.agentDir),
|
|
2277
|
+
...(whatsappSock && whatsappConnected ? createWhatsAppTools(whatsappSock, opts.agentDir) : []),
|
|
2278
|
+
...phoneComposio.tools,
|
|
2279
|
+
];
|
|
2280
|
+
const result = query({
|
|
2281
|
+
prompt: `[SMS from ${from}]: ${smsBody}`,
|
|
2282
|
+
dir: opts.agentDir,
|
|
2283
|
+
model: opts.model,
|
|
2284
|
+
env: opts.env,
|
|
2285
|
+
maxTurns: 5,
|
|
2286
|
+
systemPrompt: phoneSystemPrompt,
|
|
2287
|
+
...(phoneTools.length ? { tools: phoneTools } : {}),
|
|
2288
|
+
});
|
|
2289
|
+
let reply = "";
|
|
2290
|
+
for await (const m of result) {
|
|
2291
|
+
if (m.type === "assistant" && m.content)
|
|
2292
|
+
reply += m.content;
|
|
2293
|
+
}
|
|
2294
|
+
reply = reply.trim().slice(0, 1600); // SMS limit
|
|
2295
|
+
const assistantMsg = { type: "transcript", role: "assistant", text: `[SMS → ${from}]: ${reply}` };
|
|
2296
|
+
appendMessage(opts.agentDir, activeBranch, assistantMsg);
|
|
2297
|
+
broadcastToBrowsers(assistantMsg);
|
|
2298
|
+
res.writeHead(200, { "Content-Type": "text/xml" });
|
|
2299
|
+
res.end(`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${escapeXml(reply)}</Message></Response>`);
|
|
2300
|
+
}
|
|
2301
|
+
catch (err) {
|
|
2302
|
+
console.error(dim(`[phone] Agent error: ${err.message}`));
|
|
2303
|
+
res.writeHead(200, { "Content-Type": "text/xml" });
|
|
2304
|
+
res.end(`<?xml version="1.0" encoding="UTF-8"?><Response><Message>Sorry, something went wrong.</Message></Response>`);
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
else if (callStatus) {
|
|
2308
|
+
// Voice call webhook — just acknowledge for now
|
|
2309
|
+
console.log(dim(`[phone] Call from ${from}, status: ${callStatus}`));
|
|
2310
|
+
res.writeHead(200, { "Content-Type": "text/xml" });
|
|
2311
|
+
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>`);
|
|
2312
|
+
}
|
|
2313
|
+
else {
|
|
2314
|
+
res.writeHead(200, { "Content-Type": "text/xml" });
|
|
2315
|
+
res.end(`<?xml version="1.0" encoding="UTF-8"?><Response></Response>`);
|
|
2316
|
+
}
|
|
2317
|
+
// ── Composio OAuth callback ─────────────────────────────────────
|
|
2318
|
+
}
|
|
2319
|
+
else if (url.pathname === "/api/composio/callback") {
|
|
2320
|
+
// OAuth popup lands here after Composio processes the auth code.
|
|
2321
|
+
// Send a message to the opener window and close the popup.
|
|
2322
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
2323
|
+
res.end(`<!DOCTYPE html><html><body><script>
|
|
2324
|
+
if(window.opener){window.opener.postMessage({type:'composio_auth_complete'},'*');}
|
|
2325
|
+
window.close();
|
|
2326
|
+
</script><p>Authentication complete. You can close this window.</p></body></html>`);
|
|
2327
|
+
// ── Chat branch API routes ──────────────────────────────────────
|
|
2328
|
+
}
|
|
2329
|
+
else if (url.pathname === "/api/chat/list" && req.method === "GET") {
|
|
2330
|
+
try {
|
|
2331
|
+
const git = (cmd) => execSync(cmd, { cwd: agentRoot, encoding: "utf-8" }).trim();
|
|
2332
|
+
const current = git("git rev-parse --abbrev-ref HEAD");
|
|
2333
|
+
// List branches matching chat/* pattern, plus the current branch
|
|
2334
|
+
let branches;
|
|
2335
|
+
try {
|
|
2336
|
+
branches = git("git branch --list 'chat/*' --sort=-committerdate --format='%(refname:short)|%(committerdate:relative)'")
|
|
2337
|
+
.split("\n").filter(Boolean);
|
|
2338
|
+
}
|
|
2339
|
+
catch {
|
|
2340
|
+
branches = [];
|
|
2341
|
+
}
|
|
2342
|
+
const chats = branches.map((line) => {
|
|
2343
|
+
const [branch, time] = line.split("|");
|
|
2344
|
+
const name = branch.replace("chat/", "");
|
|
2345
|
+
return { branch, name, time: time || "" };
|
|
2346
|
+
});
|
|
2347
|
+
// If current branch is not a chat/* branch, add it at the top
|
|
2348
|
+
if (!current.startsWith("chat/")) {
|
|
2349
|
+
chats.unshift({ branch: current, name: current, time: "current" });
|
|
2350
|
+
}
|
|
2351
|
+
jsonReply(res, 200, { current, chats });
|
|
2352
|
+
}
|
|
2353
|
+
catch (err) {
|
|
2354
|
+
jsonReply(res, 500, { error: err.message });
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
else if (url.pathname === "/api/chat/new" && req.method === "POST") {
|
|
2358
|
+
try {
|
|
2359
|
+
const git = (cmd) => execSync(cmd, { cwd: agentRoot, encoding: "utf-8" }).trim();
|
|
2360
|
+
// Generate branch name: chat/YYYY-MM-DD-HHMMSS
|
|
2361
|
+
const now = new Date();
|
|
2362
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
2363
|
+
const branch = `chat/${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
2364
|
+
// Stage and commit any pending changes on current branch
|
|
2365
|
+
try {
|
|
2366
|
+
git("git add -A");
|
|
2367
|
+
git('git commit -m "auto-save before new chat" --allow-empty');
|
|
2368
|
+
}
|
|
2369
|
+
catch {
|
|
2370
|
+
// No changes to commit, that's fine
|
|
2371
|
+
}
|
|
2372
|
+
// Create and switch to new branch
|
|
2373
|
+
git(`git checkout -b ${branch}`);
|
|
2374
|
+
activeBranch = branch;
|
|
2375
|
+
jsonReply(res, 200, { branch });
|
|
2376
|
+
}
|
|
2377
|
+
catch (err) {
|
|
2378
|
+
jsonReply(res, 500, { error: err.message });
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
else if (url.pathname === "/api/chat/switch" && req.method === "POST") {
|
|
2382
|
+
try {
|
|
2383
|
+
const body = await readBody(req);
|
|
2384
|
+
const { branch } = JSON.parse(body);
|
|
2385
|
+
if (!branch)
|
|
2386
|
+
return jsonReply(res, 400, { error: "Missing branch" });
|
|
2387
|
+
const git = (cmd) => execSync(cmd, { cwd: agentRoot, encoding: "utf-8" }).trim();
|
|
2388
|
+
// Auto-save current branch
|
|
2389
|
+
try {
|
|
2390
|
+
git("git add -A");
|
|
2391
|
+
git('git commit -m "auto-save before switching chat" --allow-empty');
|
|
2392
|
+
}
|
|
2393
|
+
catch { }
|
|
2394
|
+
git(`git checkout ${branch}`);
|
|
2395
|
+
activeBranch = branch;
|
|
2396
|
+
jsonReply(res, 200, { branch });
|
|
2397
|
+
}
|
|
2398
|
+
catch (err) {
|
|
2399
|
+
jsonReply(res, 500, { error: err.message });
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
else if (url.pathname === "/api/chat/delete" && req.method === "POST") {
|
|
2403
|
+
try {
|
|
2404
|
+
const body = await readBody(req);
|
|
2405
|
+
const { branch } = JSON.parse(body);
|
|
2406
|
+
if (!branch)
|
|
2407
|
+
return jsonReply(res, 400, { error: "Missing branch" });
|
|
2408
|
+
const git = (cmd) => execSync(cmd, { cwd: agentRoot, encoding: "utf-8" }).trim();
|
|
2409
|
+
const current = git("git rev-parse --abbrev-ref HEAD");
|
|
2410
|
+
if (branch === current)
|
|
2411
|
+
return jsonReply(res, 400, { error: "Cannot delete the active branch" });
|
|
2412
|
+
git(`git branch -D ${branch}`);
|
|
2413
|
+
deleteHistory(opts.agentDir, branch);
|
|
2414
|
+
jsonReply(res, 200, { ok: true });
|
|
2415
|
+
}
|
|
2416
|
+
catch (err) {
|
|
2417
|
+
jsonReply(res, 500, { error: err.message });
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
else if (url.pathname === "/api/chat/history" && req.method === "GET") {
|
|
2421
|
+
const branch = url.searchParams.get("branch");
|
|
2422
|
+
if (!branch)
|
|
2423
|
+
return jsonReply(res, 400, { error: "Missing branch param" });
|
|
2424
|
+
const messages = loadHistory(opts.agentDir, branch);
|
|
2425
|
+
jsonReply(res, 200, { branch, messages });
|
|
2426
|
+
// ── Composio API routes ─────────────────────────────────────────
|
|
2427
|
+
}
|
|
2428
|
+
else if (url.pathname === "/api/composio/toolkits" && req.method === "GET") {
|
|
2429
|
+
if (!composioAdapter)
|
|
2430
|
+
return jsonReply(res, 501, { error: "Composio not configured" });
|
|
2431
|
+
try {
|
|
2432
|
+
const toolkits = await composioAdapter.getToolkits();
|
|
2433
|
+
jsonReply(res, 200, toolkits);
|
|
2434
|
+
}
|
|
2435
|
+
catch (err) {
|
|
2436
|
+
jsonReply(res, 502, { error: err.message });
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
else if (url.pathname === "/api/composio/connect" && req.method === "POST") {
|
|
2440
|
+
if (!composioAdapter)
|
|
2441
|
+
return jsonReply(res, 501, { error: "Composio not configured" });
|
|
2442
|
+
const body = await readBody(req);
|
|
2443
|
+
let parsed;
|
|
2444
|
+
try {
|
|
2445
|
+
parsed = JSON.parse(body);
|
|
2446
|
+
}
|
|
2447
|
+
catch {
|
|
2448
|
+
return jsonReply(res, 400, { error: "Invalid JSON" });
|
|
2449
|
+
}
|
|
2450
|
+
if (!parsed.toolkit)
|
|
2451
|
+
return jsonReply(res, 400, { error: "Missing toolkit" });
|
|
2452
|
+
try {
|
|
2453
|
+
const result = await composioAdapter.connect(parsed.toolkit, parsed.redirectUrl);
|
|
2454
|
+
jsonReply(res, 200, result);
|
|
2455
|
+
}
|
|
2456
|
+
catch (err) {
|
|
2457
|
+
jsonReply(res, 502, { error: err.message });
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
else if (url.pathname === "/api/composio/connections" && req.method === "GET") {
|
|
2461
|
+
if (!composioAdapter)
|
|
2462
|
+
return jsonReply(res, 501, { error: "Composio not configured" });
|
|
2463
|
+
try {
|
|
2464
|
+
const connections = await composioAdapter.getConnections();
|
|
2465
|
+
jsonReply(res, 200, connections);
|
|
2466
|
+
}
|
|
2467
|
+
catch (err) {
|
|
2468
|
+
jsonReply(res, 502, { error: err.message });
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
else if (url.pathname.match(/^\/api\/composio\/connections\/[^/]+$/) && req.method === "DELETE") {
|
|
2472
|
+
if (!composioAdapter)
|
|
2473
|
+
return jsonReply(res, 501, { error: "Composio not configured" });
|
|
2474
|
+
const connId = url.pathname.split("/").pop();
|
|
2475
|
+
try {
|
|
2476
|
+
await composioAdapter.disconnect(connId);
|
|
2477
|
+
jsonReply(res, 200, { ok: true });
|
|
2478
|
+
}
|
|
2479
|
+
catch (err) {
|
|
2480
|
+
jsonReply(res, 502, { error: err.message });
|
|
2481
|
+
}
|
|
2482
|
+
// ── SkillFlows API ────────────────────────────────────────────
|
|
2483
|
+
}
|
|
2484
|
+
else if (url.pathname === "/api/skills/list" && req.method === "GET") {
|
|
2485
|
+
try {
|
|
2486
|
+
const skills = await discoverSkills(agentRoot);
|
|
2487
|
+
jsonReply(res, 200, { skills });
|
|
2488
|
+
}
|
|
2489
|
+
catch (err) {
|
|
2490
|
+
jsonReply(res, 500, { error: err.message });
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
else if (url.pathname === "/api/flows/list" && req.method === "GET") {
|
|
2494
|
+
try {
|
|
2495
|
+
const workflows = await discoverWorkflows(agentRoot);
|
|
2496
|
+
const flows = workflows.filter((w) => w.type === "flow");
|
|
2497
|
+
jsonReply(res, 200, { flows });
|
|
2498
|
+
}
|
|
2499
|
+
catch (err) {
|
|
2500
|
+
jsonReply(res, 500, { error: err.message });
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
else if (url.pathname === "/api/flows/save" && req.method === "POST") {
|
|
2504
|
+
const body = await readBody(req);
|
|
2505
|
+
let parsed;
|
|
2506
|
+
try {
|
|
2507
|
+
parsed = JSON.parse(body);
|
|
2508
|
+
}
|
|
2509
|
+
catch {
|
|
2510
|
+
return jsonReply(res, 400, { error: "Invalid JSON" });
|
|
2511
|
+
}
|
|
2512
|
+
if (!parsed.name || !parsed.steps?.length)
|
|
2513
|
+
return jsonReply(res, 400, { error: "Missing name or steps" });
|
|
2514
|
+
try {
|
|
2515
|
+
await saveFlowDefinition(agentRoot, parsed);
|
|
2516
|
+
jsonReply(res, 200, { ok: true });
|
|
2517
|
+
}
|
|
2518
|
+
catch (err) {
|
|
2519
|
+
jsonReply(res, 400, { error: err.message });
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
else if (url.pathname === "/api/flows/delete" && req.method === "DELETE") {
|
|
2523
|
+
const body = await readBody(req);
|
|
2524
|
+
let parsed;
|
|
2525
|
+
try {
|
|
2526
|
+
parsed = JSON.parse(body);
|
|
2527
|
+
}
|
|
2528
|
+
catch {
|
|
2529
|
+
return jsonReply(res, 400, { error: "Invalid JSON" });
|
|
2530
|
+
}
|
|
2531
|
+
if (!parsed.name)
|
|
2532
|
+
return jsonReply(res, 400, { error: "Missing name" });
|
|
2533
|
+
try {
|
|
2534
|
+
await deleteFlowDefinition(agentRoot, parsed.name);
|
|
2535
|
+
jsonReply(res, 200, { ok: true });
|
|
2536
|
+
}
|
|
2537
|
+
catch (err) {
|
|
2538
|
+
jsonReply(res, 500, { error: err.message });
|
|
2539
|
+
}
|
|
2540
|
+
// ── Scheduler API ──────────────────────────────────────────────
|
|
2541
|
+
}
|
|
2542
|
+
else if (url.pathname === "/api/schedules/list" && req.method === "GET") {
|
|
2543
|
+
try {
|
|
2544
|
+
const schedules = await discoverSchedules(agentRoot);
|
|
2545
|
+
jsonReply(res, 200, { schedules });
|
|
2546
|
+
}
|
|
2547
|
+
catch (err) {
|
|
2548
|
+
jsonReply(res, 500, { error: err.message });
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
else if (url.pathname === "/api/schedules/save" && req.method === "POST") {
|
|
2552
|
+
const body = await readBody(req);
|
|
2553
|
+
let parsed;
|
|
2554
|
+
try {
|
|
2555
|
+
parsed = JSON.parse(body);
|
|
2556
|
+
}
|
|
2557
|
+
catch {
|
|
2558
|
+
return jsonReply(res, 400, { error: "Invalid JSON" });
|
|
2559
|
+
}
|
|
2560
|
+
if (!parsed.id || !parsed.prompt)
|
|
2561
|
+
return jsonReply(res, 400, { error: "Missing id or prompt" });
|
|
2562
|
+
const mode = parsed.mode === "once" ? "once" : "repeat";
|
|
2563
|
+
if (mode === "once" && parsed.runAt) {
|
|
2564
|
+
// runAt mode — validate the datetime is in the future
|
|
2565
|
+
const runAtDate = new Date(parsed.runAt);
|
|
2566
|
+
if (isNaN(runAtDate.getTime()))
|
|
2567
|
+
return jsonReply(res, 400, { error: "Invalid runAt datetime" });
|
|
2568
|
+
}
|
|
2569
|
+
else {
|
|
2570
|
+
// cron mode — validate expression
|
|
2571
|
+
if (!parsed.cron)
|
|
2572
|
+
return jsonReply(res, 400, { error: "Missing cron expression" });
|
|
2573
|
+
if (!cron.validate(parsed.cron))
|
|
2574
|
+
return jsonReply(res, 400, { error: "Invalid cron expression" });
|
|
2575
|
+
}
|
|
2576
|
+
try {
|
|
2577
|
+
await saveSchedule(agentRoot, {
|
|
2578
|
+
id: parsed.id,
|
|
2579
|
+
prompt: parsed.prompt,
|
|
2580
|
+
cron: parsed.cron || "",
|
|
2581
|
+
mode,
|
|
2582
|
+
...(parsed.runAt ? { runAt: parsed.runAt } : {}),
|
|
2583
|
+
enabled: parsed.enabled !== false,
|
|
2584
|
+
createdAt: new Date().toISOString(),
|
|
2585
|
+
});
|
|
2586
|
+
await reloadSchedules(schedulerOpts);
|
|
2587
|
+
jsonReply(res, 200, { ok: true });
|
|
2588
|
+
}
|
|
2589
|
+
catch (err) {
|
|
2590
|
+
jsonReply(res, 400, { error: err.message });
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
else if (url.pathname === "/api/schedules/delete" && req.method === "DELETE") {
|
|
2594
|
+
const body = await readBody(req);
|
|
2595
|
+
let parsed;
|
|
2596
|
+
try {
|
|
2597
|
+
parsed = JSON.parse(body);
|
|
2598
|
+
}
|
|
2599
|
+
catch {
|
|
2600
|
+
return jsonReply(res, 400, { error: "Invalid JSON" });
|
|
2601
|
+
}
|
|
2602
|
+
if (!parsed.id)
|
|
2603
|
+
return jsonReply(res, 400, { error: "Missing id" });
|
|
2604
|
+
try {
|
|
2605
|
+
await deleteSchedule(agentRoot, parsed.id);
|
|
2606
|
+
await reloadSchedules(schedulerOpts);
|
|
2607
|
+
jsonReply(res, 200, { ok: true });
|
|
2608
|
+
}
|
|
2609
|
+
catch (err) {
|
|
2610
|
+
jsonReply(res, 500, { error: err.message });
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
else if (url.pathname === "/api/schedules/toggle" && req.method === "POST") {
|
|
2614
|
+
const body = await readBody(req);
|
|
2615
|
+
let parsed;
|
|
2616
|
+
try {
|
|
2617
|
+
parsed = JSON.parse(body);
|
|
2618
|
+
}
|
|
2619
|
+
catch {
|
|
2620
|
+
return jsonReply(res, 400, { error: "Invalid JSON" });
|
|
2621
|
+
}
|
|
2622
|
+
if (!parsed.id)
|
|
2623
|
+
return jsonReply(res, 400, { error: "Missing id" });
|
|
2624
|
+
try {
|
|
2625
|
+
await updateScheduleMeta(agentRoot, parsed.id, { enabled: parsed.enabled });
|
|
2626
|
+
await reloadSchedules(schedulerOpts);
|
|
2627
|
+
jsonReply(res, 200, { ok: true });
|
|
2628
|
+
}
|
|
2629
|
+
catch (err) {
|
|
2630
|
+
jsonReply(res, 500, { error: err.message });
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
else if (url.pathname === "/api/schedules/run" && req.method === "POST") {
|
|
2634
|
+
const body = await readBody(req);
|
|
2635
|
+
let parsed;
|
|
2636
|
+
try {
|
|
2637
|
+
parsed = JSON.parse(body);
|
|
2638
|
+
}
|
|
2639
|
+
catch {
|
|
2640
|
+
return jsonReply(res, 400, { error: "Invalid JSON" });
|
|
2641
|
+
}
|
|
2642
|
+
if (!parsed.id)
|
|
2643
|
+
return jsonReply(res, 400, { error: "Missing id" });
|
|
2644
|
+
try {
|
|
2645
|
+
const schedules = await discoverSchedules(agentRoot);
|
|
2646
|
+
const schedule = schedules.find((s) => s.id === parsed.id);
|
|
2647
|
+
if (!schedule)
|
|
2648
|
+
return jsonReply(res, 404, { error: "Schedule not found" });
|
|
2649
|
+
jsonReply(res, 200, { ok: true, message: "Job triggered" });
|
|
2650
|
+
executeScheduledJob(schedule, schedulerOpts);
|
|
2651
|
+
}
|
|
2652
|
+
catch (err) {
|
|
2653
|
+
jsonReply(res, 500, { error: err.message });
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
else if (url.pathname === "/api/schedules/logs" && req.method === "GET") {
|
|
2657
|
+
const id = url.searchParams.get("id");
|
|
2658
|
+
if (!id)
|
|
2659
|
+
return jsonReply(res, 400, { error: "Missing id param" });
|
|
2660
|
+
try {
|
|
2661
|
+
const logFile = join(agentRoot, ".gitagent", "schedule-logs", `${id}.jsonl`);
|
|
2662
|
+
const raw = readFileSync(logFile, "utf-8");
|
|
2663
|
+
const lines = raw.trim().split("\n").filter(Boolean);
|
|
2664
|
+
const entries = lines.slice(-50).map((l) => { try {
|
|
2665
|
+
return JSON.parse(l);
|
|
2666
|
+
}
|
|
2667
|
+
catch {
|
|
2668
|
+
return null;
|
|
2669
|
+
} }).filter(Boolean);
|
|
2670
|
+
jsonReply(res, 200, { entries });
|
|
2671
|
+
}
|
|
2672
|
+
catch {
|
|
2673
|
+
jsonReply(res, 200, { entries: [] });
|
|
2674
|
+
}
|
|
2675
|
+
// ── Logs API ────────────────────────────────────────────────────
|
|
2676
|
+
}
|
|
2677
|
+
else if (url.pathname === "/api/logs" && req.method === "GET") {
|
|
2678
|
+
const sinceParam = url.searchParams.get("since");
|
|
2679
|
+
const sourceFilter = url.searchParams.get("source") || "";
|
|
2680
|
+
const levelFilter = url.searchParams.get("level") || "";
|
|
2681
|
+
const searchFilter = (url.searchParams.get("q") || "").toLowerCase();
|
|
2682
|
+
let entries = sinceParam ? logBuffer.since(parseInt(sinceParam, 10)) : logBuffer.all();
|
|
2683
|
+
if (sourceFilter)
|
|
2684
|
+
entries = entries.filter(e => e.source === sourceFilter);
|
|
2685
|
+
if (levelFilter)
|
|
2686
|
+
entries = entries.filter(e => e.level === levelFilter);
|
|
2687
|
+
if (searchFilter)
|
|
2688
|
+
entries = entries.filter(e => e.message.toLowerCase().includes(searchFilter));
|
|
2689
|
+
jsonReply(res, 200, { entries });
|
|
2690
|
+
// ── Skills Marketplace proxy ────────────────────────────────────
|
|
2691
|
+
}
|
|
2692
|
+
else if (url.pathname === "/api/skills-mp/proxy" && req.method === "GET") {
|
|
2693
|
+
const proxyPath = url.searchParams.get("path") || "/";
|
|
2694
|
+
// Forward all query params except "path" to skills.sh
|
|
2695
|
+
const forwardParams = new URLSearchParams(url.searchParams);
|
|
2696
|
+
forwardParams.delete("path");
|
|
2697
|
+
const qs = forwardParams.toString();
|
|
2698
|
+
const targetUrl = `https://skills.sh${proxyPath.startsWith("/") ? proxyPath : "/" + proxyPath}${qs ? (proxyPath.includes("?") ? "&" : "?") + qs : ""}`;
|
|
2699
|
+
try {
|
|
2700
|
+
const proxyRes = await fetch(targetUrl, {
|
|
2701
|
+
headers: {
|
|
2702
|
+
"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",
|
|
2703
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
2704
|
+
},
|
|
2705
|
+
redirect: "follow",
|
|
2706
|
+
});
|
|
2707
|
+
const contentType = proxyRes.headers.get("content-type") || "";
|
|
2708
|
+
// Non-HTML resources: pass through directly
|
|
2709
|
+
if (!contentType.includes("text/html")) {
|
|
2710
|
+
const buffer = Buffer.from(await proxyRes.arrayBuffer());
|
|
2711
|
+
res.writeHead(proxyRes.status, {
|
|
2712
|
+
"Content-Type": contentType,
|
|
2713
|
+
"Cache-Control": proxyRes.headers.get("cache-control") || "public, max-age=3600",
|
|
2714
|
+
"Access-Control-Allow-Origin": "*",
|
|
2715
|
+
});
|
|
2716
|
+
res.end(buffer);
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
let html = await proxyRes.text();
|
|
2720
|
+
// Rewrite relative src/href to absolute skills.sh URLs so assets load correctly
|
|
2721
|
+
// (Do NOT rewrite href for navigation links — that breaks React hydration.
|
|
2722
|
+
// Navigation is handled by client-side click/history interception instead.)
|
|
2723
|
+
html = html.replace(/src="\/(?!\/)/g, 'src="https://skills.sh/');
|
|
2724
|
+
html = html.replace(/src='\/(?!\/)/g, "src='https://skills.sh/");
|
|
2725
|
+
// Rewrite stylesheet/preload hrefs to load from skills.sh
|
|
2726
|
+
html = html.replace(/href="\/_(next|static)\//g, 'href="https://skills.sh/_$1/');
|
|
2727
|
+
html = html.replace(/href='\/_(next|static)\//g, "href='https://skills.sh/_$1/");
|
|
2728
|
+
// Inject our custom script before </body>
|
|
2729
|
+
const injectedScript = `
|
|
2730
|
+
<script>
|
|
2731
|
+
(function() {
|
|
2732
|
+
const PROXY_BASE = '/api/skills-mp/proxy?path=';
|
|
2733
|
+
|
|
2734
|
+
// Build a proxy URL that keeps query params as top-level params
|
|
2735
|
+
// so the server can forward them (e.g. ?q=docker) to skills.sh.
|
|
2736
|
+
// Without this, "/?q=docker" becomes "proxy?path=/?q=docker" and
|
|
2737
|
+
// the q param is buried inside the path value — nuqs/Next.js
|
|
2738
|
+
// cannot read it back from location.search, breaking search.
|
|
2739
|
+
function proxyUrl(rawUrl) {
|
|
2740
|
+
var qi = rawUrl.indexOf('?');
|
|
2741
|
+
if (qi === -1) return PROXY_BASE + rawUrl;
|
|
2742
|
+
var pathname = rawUrl.slice(0, qi);
|
|
2743
|
+
var qs = rawUrl.slice(qi + 1); // e.g. "q=docker&view=all"
|
|
2744
|
+
return PROXY_BASE + encodeURIComponent(pathname) + '&' + qs;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
// Fetch installed skills from lock file
|
|
2748
|
+
var __installedSkills = new Set();
|
|
2749
|
+
var __installedSources = new Set();
|
|
2750
|
+
fetch('/api/skills-mp/installed').then(function(r){return r.json();}).then(function(d){
|
|
2751
|
+
if(d&&d.installed) __installedSkills = new Set(d.installed);
|
|
2752
|
+
if(d&&d.sources) __installedSources = new Set(d.sources);
|
|
2753
|
+
processSkillButtons();
|
|
2754
|
+
}).catch(function(){});
|
|
2755
|
+
|
|
2756
|
+
// Intercept fetch to route API calls to skills.sh (including full-origin URLs from Next.js)
|
|
2757
|
+
const _fetch = window.fetch;
|
|
2758
|
+
// e.g. fetch('http://localhost:3000/_next/data/...')
|
|
2759
|
+
window.fetch = function(url, opts) {
|
|
2760
|
+
if (typeof url === 'string') {
|
|
2761
|
+
if (url.startsWith('/') && !url.startsWith('//') && !url.startsWith('/api/skills-mp/')) {
|
|
2762
|
+
url = proxyUrl(url);
|
|
2763
|
+
} else if (url.startsWith(location.origin + '/')) {
|
|
2764
|
+
var path = url.slice(location.origin.length);
|
|
2765
|
+
if (!path.startsWith('/api/skills-mp/')) {
|
|
2766
|
+
url = proxyUrl(path);
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
} else if (url instanceof Request) {
|
|
2770
|
+
var rUrl = url.url;
|
|
2771
|
+
if (rUrl.startsWith(location.origin + '/')) {
|
|
2772
|
+
var rPath = rUrl.slice(location.origin.length);
|
|
2773
|
+
if (!rPath.startsWith('/api/skills-mp/')) {
|
|
2774
|
+
url = new Request(proxyUrl(rPath), url);
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
return _fetch.call(this, url, opts);
|
|
2779
|
+
};
|
|
2780
|
+
|
|
2781
|
+
// Intercept XMLHttpRequest.open
|
|
2782
|
+
const _xhrOpen = XMLHttpRequest.prototype.open;
|
|
2783
|
+
XMLHttpRequest.prototype.open = function(method, url) {
|
|
2784
|
+
var args = Array.prototype.slice.call(arguments, 2);
|
|
2785
|
+
if (typeof url === 'string') {
|
|
2786
|
+
if (url.startsWith('/') && !url.startsWith('//') && !url.startsWith('/api/skills-mp/')) {
|
|
2787
|
+
url = proxyUrl(url);
|
|
2788
|
+
} else if (url.startsWith(location.origin + '/')) {
|
|
2789
|
+
var path = url.slice(location.origin.length);
|
|
2790
|
+
if (!path.startsWith('/api/skills-mp/')) {
|
|
2791
|
+
url = proxyUrl(path);
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
return _xhrOpen.apply(this, [method, url].concat(args));
|
|
2796
|
+
};
|
|
2797
|
+
|
|
2798
|
+
// Intercept history.pushState/replaceState for Next.js client-side navigation
|
|
2799
|
+
var _pushState = history.pushState;
|
|
2800
|
+
var _replaceState = history.replaceState;
|
|
2801
|
+
function rewriteHistoryUrl(originalFn, data, title, url) {
|
|
2802
|
+
if (typeof url === 'string' && url.startsWith('/') && !url.startsWith('//') && !url.startsWith('/api/skills-mp/')) {
|
|
2803
|
+
url = proxyUrl(url);
|
|
2804
|
+
}
|
|
2805
|
+
return originalFn.call(history, data, title, url);
|
|
2806
|
+
}
|
|
2807
|
+
history.pushState = function(data, title, url) { return rewriteHistoryUrl(_pushState, data, title, url); };
|
|
2808
|
+
history.replaceState = function(data, title, url) { return rewriteHistoryUrl(_replaceState, data, title, url); };
|
|
2809
|
+
|
|
2810
|
+
// Intercept form submissions
|
|
2811
|
+
document.addEventListener('submit', function(e) {
|
|
2812
|
+
var form = e.target;
|
|
2813
|
+
if (!form || !form.action) return;
|
|
2814
|
+
var action = form.getAttribute('action');
|
|
2815
|
+
if (action && action.startsWith('/') && !action.startsWith('//') && !action.startsWith('/api/skills-mp/')) {
|
|
2816
|
+
e.preventDefault();
|
|
2817
|
+
var params = new URLSearchParams(new FormData(form)).toString();
|
|
2818
|
+
window.location.href = proxyUrl(action + (params ? (action.includes('?') ? '&' : '?') + params : ''));
|
|
2819
|
+
}
|
|
2820
|
+
}, true);
|
|
2821
|
+
|
|
2822
|
+
function processSkillButtons() {
|
|
2823
|
+
// Find elements with npx skills add commands
|
|
2824
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
2825
|
+
const textNodes = [];
|
|
2826
|
+
while (walker.nextNode()) textNodes.push(walker.currentNode);
|
|
2827
|
+
|
|
2828
|
+
for (const node of textNodes) {
|
|
2829
|
+
const match = node.textContent && node.textContent.match(/npx\\s+skills\\s+add\\s+(\\S+)/);
|
|
2830
|
+
if (!match) continue;
|
|
2831
|
+
|
|
2832
|
+
// Find the closest interactive parent (button, code block, pre)
|
|
2833
|
+
let target = node.parentElement;
|
|
2834
|
+
while (target && !['BUTTON','PRE','CODE','DIV'].includes(target.tagName)) {
|
|
2835
|
+
target = target.parentElement;
|
|
2836
|
+
}
|
|
2837
|
+
if (!target || target.dataset.gcProcessed) continue;
|
|
2838
|
+
target.dataset.gcProcessed = 'true';
|
|
2839
|
+
|
|
2840
|
+
const source = match[1].replace(/^https:\\/\\/github\\.com\\//, '');
|
|
2841
|
+
const alreadyInstalled = __installedSources.has(source) || __installedSkills.has(source.split('/').pop());
|
|
2842
|
+
const btn = document.createElement('button');
|
|
2843
|
+
if (alreadyInstalled) {
|
|
2844
|
+
btn.textContent = 'Installed';
|
|
2845
|
+
btn.disabled = true;
|
|
2846
|
+
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;';
|
|
2847
|
+
} else {
|
|
2848
|
+
btn.textContent = 'Install on GitAgent';
|
|
2849
|
+
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;';
|
|
2850
|
+
btn.onmouseenter = function(){ btn.style.background='#2ea043'; };
|
|
2851
|
+
btn.onmouseleave = function(){ btn.style.background='#238636'; };
|
|
2852
|
+
btn.onclick = function(e) {
|
|
2853
|
+
e.preventDefault();
|
|
2854
|
+
e.stopPropagation();
|
|
2855
|
+
btn.disabled = true;
|
|
2856
|
+
btn.textContent = 'Installing...';
|
|
2857
|
+
window.parent.postMessage({ type: 'install_skill', source: source }, '*');
|
|
2858
|
+
};
|
|
2859
|
+
}
|
|
2860
|
+
target.parentElement.insertBefore(btn, target.nextSibling);
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
window.addEventListener('message', function(msg) {
|
|
2865
|
+
if (!msg.data || msg.data.type !== 'install_success') return;
|
|
2866
|
+
var btns = document.querySelectorAll('button');
|
|
2867
|
+
btns.forEach(function(b) {
|
|
2868
|
+
if (b.textContent === 'Installing...') {
|
|
2869
|
+
b.textContent = 'Installed';
|
|
2870
|
+
b.style.background = '#1a7f37';
|
|
2871
|
+
b.style.cursor = 'default';
|
|
2872
|
+
b.style.opacity = '0.85';
|
|
2873
|
+
}
|
|
2874
|
+
});
|
|
2875
|
+
// Refresh installed set so future processSkillButtons calls are up to date
|
|
2876
|
+
fetch('/api/skills-mp/installed').then(function(r){return r.json();}).then(function(d){
|
|
2877
|
+
if(d&&d.installed) __installedSkills = new Set(d.installed);
|
|
2878
|
+
if(d&&d.sources) __installedSources = new Set(d.sources);
|
|
2879
|
+
}).catch(function(){});
|
|
2880
|
+
});
|
|
2881
|
+
|
|
2882
|
+
// Intercept link clicks to route through proxy
|
|
2883
|
+
document.addEventListener('click', function(e) {
|
|
2884
|
+
const a = e.target.closest('a');
|
|
2885
|
+
if (!a) return;
|
|
2886
|
+
const href = a.getAttribute('href');
|
|
2887
|
+
if (!href) return;
|
|
2888
|
+
// Already proxied
|
|
2889
|
+
if (href.startsWith('/api/skills-mp/proxy')) return;
|
|
2890
|
+
// Internal skills.sh links
|
|
2891
|
+
if (href.startsWith('/') && !href.startsWith('//')) {
|
|
2892
|
+
e.preventDefault();
|
|
2893
|
+
window.location.href = proxyUrl(href);
|
|
2894
|
+
} else if (href.startsWith('https://skills.sh/') || href.startsWith('https://skillsmp.com/')) {
|
|
2895
|
+
e.preventDefault();
|
|
2896
|
+
var parsed = new URL(href);
|
|
2897
|
+
window.location.href = proxyUrl(parsed.pathname + parsed.search);
|
|
2898
|
+
}
|
|
2899
|
+
}, true);
|
|
2900
|
+
|
|
2901
|
+
// Run on load + observe for SPA changes
|
|
2902
|
+
if (document.readyState === 'loading') {
|
|
2903
|
+
document.addEventListener('DOMContentLoaded', processSkillButtons);
|
|
2904
|
+
} else {
|
|
2905
|
+
processSkillButtons();
|
|
2906
|
+
}
|
|
2907
|
+
new MutationObserver(function() { processSkillButtons(); })
|
|
2908
|
+
.observe(document.body || document.documentElement, { childList: true, subtree: true });
|
|
2909
|
+
})();
|
|
2910
|
+
</script>`;
|
|
2911
|
+
html = html.replace(/<\/body>/i, injectedScript + "</body>");
|
|
2912
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Access-Control-Allow-Origin": "*" });
|
|
2913
|
+
res.end(html);
|
|
2914
|
+
}
|
|
2915
|
+
catch (err) {
|
|
2916
|
+
// Fallback if skills.sh is unreachable
|
|
2917
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2918
|
+
res.end(`<!DOCTYPE html>
|
|
2919
|
+
<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;}
|
|
2920
|
+
a{color:#58a6ff;}</style></head>
|
|
2921
|
+
<body><div><h2>Skills Marketplace Unavailable</h2><p>Could not reach skills.sh: ${err.message}</p>
|
|
2922
|
+
<p><a href="https://skills.sh" target="_blank">Open skills.sh in a new tab</a></p></div></body></html>`);
|
|
2923
|
+
}
|
|
2924
|
+
// ── Skills Marketplace installed list ────────────────────────────
|
|
2925
|
+
}
|
|
2926
|
+
else if (url.pathname === "/api/skills-mp/installed" && req.method === "GET") {
|
|
2927
|
+
try {
|
|
2928
|
+
const lockPath = join(agentRoot, "skills-lock.json");
|
|
2929
|
+
if (existsSync(lockPath)) {
|
|
2930
|
+
const lock = JSON.parse(readFileSync(lockPath, "utf-8"));
|
|
2931
|
+
const skills = lock.skills || {};
|
|
2932
|
+
const names = Object.keys(skills);
|
|
2933
|
+
// Build a set of sources (repo slugs) that have installed skills
|
|
2934
|
+
const sources = [...new Set(Object.values(skills).map((s) => s.source))];
|
|
2935
|
+
jsonReply(res, 200, { installed: names, sources });
|
|
2936
|
+
}
|
|
2937
|
+
else {
|
|
2938
|
+
jsonReply(res, 200, { installed: [], sources: [] });
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
catch (err) {
|
|
2942
|
+
jsonReply(res, 200, { installed: [], sources: [] });
|
|
2943
|
+
}
|
|
2944
|
+
// ── Skills Marketplace install ──────────────────────────────────
|
|
2945
|
+
}
|
|
2946
|
+
else if (url.pathname === "/api/skills-mp/install" && req.method === "POST") {
|
|
2947
|
+
let body = "";
|
|
2948
|
+
for await (const chunk of req)
|
|
2949
|
+
body += chunk;
|
|
2950
|
+
try {
|
|
2951
|
+
const { source } = JSON.parse(body);
|
|
2952
|
+
if (!source)
|
|
2953
|
+
return jsonReply(res, 400, { error: "Missing source" });
|
|
2954
|
+
// Shell out to the skills CLI — it handles all install logic
|
|
2955
|
+
const cleanSource = source.replace(/^https?:\/\/github\.com\//, "");
|
|
2956
|
+
const skillsDir = join(agentRoot, "skills");
|
|
2957
|
+
const before = new Set(existsSync(skillsDir) ? readdirSync(skillsDir) : []);
|
|
2958
|
+
execSync(`npx -y skills add -y ${cleanSource} --agent openclaw`, {
|
|
2959
|
+
cwd: agentRoot,
|
|
2960
|
+
encoding: "utf-8",
|
|
2961
|
+
timeout: 120000,
|
|
2962
|
+
});
|
|
2963
|
+
// Detect which skill directories were added (symlinked into skills/)
|
|
2964
|
+
const after = existsSync(skillsDir) ? readdirSync(skillsDir) : [];
|
|
2965
|
+
const added = after.filter(d => !before.has(d));
|
|
2966
|
+
const skillNames = added.length ? added : [cleanSource.split("/")[1] || cleanSource];
|
|
2967
|
+
console.log(dim(`[voice] Installed skill(s): ${skillNames.join(", ")} via npx skills add`));
|
|
2968
|
+
broadcastToBrowsers({ type: "files_changed" });
|
|
2969
|
+
jsonReply(res, 200, { ok: true, skillName: skillNames.join(", "), path: `skills/`, installed: skillNames });
|
|
2970
|
+
}
|
|
2971
|
+
catch (err) {
|
|
2972
|
+
jsonReply(res, 500, { error: err.message });
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
else {
|
|
2976
|
+
res.writeHead(404);
|
|
2977
|
+
res.end();
|
|
2978
|
+
}
|
|
2979
|
+
});
|
|
2980
|
+
httpServer.on("error", (err) => {
|
|
2981
|
+
console.error(`[http] Server error: ${err.message}\n${err.stack}`);
|
|
2982
|
+
});
|
|
2983
|
+
httpServer.on("clientError", (err, socket) => {
|
|
2984
|
+
console.error(`[http] Client error: ${err.message}`);
|
|
2985
|
+
try {
|
|
2986
|
+
socket.destroy();
|
|
2987
|
+
}
|
|
2988
|
+
catch { /* no-op */ }
|
|
2989
|
+
});
|
|
2990
|
+
// WebSocket server — adapter-agnostic proxy
|
|
2991
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
2992
|
+
wss.on("error", (err) => {
|
|
2993
|
+
console.error(`[voice] WebSocket server error: ${err.message}\n${err.stack}`);
|
|
2994
|
+
});
|
|
2995
|
+
wss.on("connection", async (browserWs, req) => {
|
|
2996
|
+
// Check auth on WebSocket connections
|
|
2997
|
+
if (!isAuthenticated(req)) {
|
|
2998
|
+
console.warn(`[voice] Browser WS rejected (unauthorized) from ${req.socket.remoteAddress}`);
|
|
2999
|
+
browserWs.close(4401, "Unauthorized");
|
|
3000
|
+
return;
|
|
3001
|
+
}
|
|
3002
|
+
const remote = req.socket.remoteAddress || "unknown";
|
|
3003
|
+
console.log(dim(`[voice] Browser connected from ${remote}`));
|
|
3004
|
+
browserWs.on("error", (err) => {
|
|
3005
|
+
console.error(`[voice] Browser WS error (${remote}): ${err.message}`);
|
|
3006
|
+
});
|
|
3007
|
+
// ── Per-connection frame buffer + moment capture state ──────────
|
|
3008
|
+
let latestVideoFrame = null;
|
|
3009
|
+
let lastFrameWriteTs = 0;
|
|
3010
|
+
let latestScreenFrame = null;
|
|
3011
|
+
let lastScreenWriteTs = 0;
|
|
3012
|
+
let lastMomentCaptureTs = 0;
|
|
3013
|
+
const FRAME_WRITE_INTERVAL = 2000; // Write temp frame to disk every 2s
|
|
3014
|
+
const MOMENT_COOLDOWN = 60000; // 60s between auto-captures
|
|
3015
|
+
const moodCounts = { happy: 0, frustrated: 0, curious: 0, excited: 0, calm: 0 };
|
|
3016
|
+
let sessionMessageCount = 0;
|
|
3017
|
+
// Inject shared context (memory + conversation summary) into voice LLM instructions
|
|
3018
|
+
const voiceContext = await getVoiceContext(opts.agentDir, activeBranch);
|
|
3019
|
+
let instructions = opts.adapterConfig.instructions || "";
|
|
3020
|
+
if (voiceContext) {
|
|
3021
|
+
instructions += "\n\n" + voiceContext;
|
|
3022
|
+
}
|
|
3023
|
+
if (CLOUD_MODE) {
|
|
3024
|
+
instructions += CLOUD_VOICE_SUFFIX;
|
|
3025
|
+
}
|
|
3026
|
+
// Inject Composio awareness into adapter instructions so the voice LLM
|
|
3027
|
+
// never tells the user "I can't access" external services
|
|
3028
|
+
const adapterOpts = composioAdapter ? {
|
|
3029
|
+
...opts,
|
|
3030
|
+
adapterConfig: {
|
|
3031
|
+
...opts.adapterConfig,
|
|
3032
|
+
instructions: instructions +
|
|
3033
|
+
" The agent has FULL access to external services via Composio — Gmail, Google Calendar, GitHub, Slack, and more. " +
|
|
3034
|
+
"When the user asks to send emails, check calendars, or interact with any external service, ALWAYS use run_agent to handle it. " +
|
|
3035
|
+
"NEVER say you can't access these services or that you don't have these tools. The agent has them. Just call run_agent.",
|
|
3036
|
+
},
|
|
3037
|
+
} : {
|
|
3038
|
+
...opts,
|
|
3039
|
+
adapterConfig: {
|
|
3040
|
+
...opts.adapterConfig,
|
|
3041
|
+
instructions,
|
|
3042
|
+
},
|
|
3043
|
+
};
|
|
3044
|
+
let adapter = opts.adapterConfig.apiKey ? createAdapter(adapterOpts) : null;
|
|
3045
|
+
const sendToBrowser = (msg) => {
|
|
3046
|
+
safeSend(browserWs, JSON.stringify(msg));
|
|
3047
|
+
appendMessage(opts.agentDir, activeBranch, msg);
|
|
3048
|
+
// Track mood from user transcripts
|
|
3049
|
+
if (msg.type === "transcript" && msg.role === "user" && !msg.partial) {
|
|
3050
|
+
sessionMessageCount++;
|
|
3051
|
+
const mood = detectMood(msg.text);
|
|
3052
|
+
if (mood)
|
|
3053
|
+
moodCounts[mood]++;
|
|
3054
|
+
}
|
|
3055
|
+
// Detect personal info in voice transcripts and save to memory
|
|
3056
|
+
if (msg.type === "transcript" && msg.role === "user" && !msg.partial && isMemoryWorthy(msg.text)) {
|
|
3057
|
+
saveMemoryInBackground(msg.text, opts.agentDir, opts.model, opts.env, () => {
|
|
3058
|
+
broadcastToBrowsers({ type: "memory_saving", status: "start", text: msg.text.slice(0, 60) });
|
|
3059
|
+
}, () => {
|
|
3060
|
+
broadcastToBrowsers({ type: "memory_saving", status: "done" });
|
|
3061
|
+
safeSend(browserWs, JSON.stringify({ type: "files_changed" }));
|
|
3062
|
+
});
|
|
3063
|
+
}
|
|
3064
|
+
// Auto-capture photo on memorable moments (with 60s cooldown)
|
|
3065
|
+
if (msg.type === "transcript" && msg.role === "user" && !msg.partial && isMomentWorthy(msg.text)) {
|
|
3066
|
+
const now = Date.now();
|
|
3067
|
+
if (now - lastMomentCaptureTs >= MOMENT_COOLDOWN) {
|
|
3068
|
+
lastMomentCaptureTs = now;
|
|
3069
|
+
// Use buffered frame if available and fresh (<5s)
|
|
3070
|
+
let frameBuffer;
|
|
3071
|
+
if (latestVideoFrame && (now - latestVideoFrame.ts) < 5000) {
|
|
3072
|
+
frameBuffer = Buffer.from(latestVideoFrame.frame, "base64");
|
|
3073
|
+
}
|
|
3074
|
+
capturePhoto(agentRoot, msg.text.slice(0, 60), frameBuffer).catch((err) => {
|
|
3075
|
+
console.error(dim(`[voice] Auto photo capture failed: ${err.message}`));
|
|
3076
|
+
});
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
};
|
|
3080
|
+
if (adapter) {
|
|
3081
|
+
try {
|
|
3082
|
+
await adapter.connect({
|
|
3083
|
+
toolHandler: createToolHandler(sendToBrowser),
|
|
3084
|
+
onMessage: sendToBrowser,
|
|
3085
|
+
});
|
|
3086
|
+
console.log(dim(`[voice] Adapter ready (${opts.adapter})`));
|
|
3087
|
+
}
|
|
3088
|
+
catch (err) {
|
|
3089
|
+
console.error(dim(`[voice] Adapter connection failed: ${err.message}`));
|
|
3090
|
+
safeSend(browserWs, JSON.stringify({ type: "error", message: `Voice connection failed: ${err.message}` }));
|
|
3091
|
+
adapter = null; // Fall back to text-only
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
if (!adapter) {
|
|
3095
|
+
safeSend(browserWs, JSON.stringify({
|
|
3096
|
+
type: "transcript", role: "assistant",
|
|
3097
|
+
text: "Voice mode unavailable — no API key set. You can still chat via text.",
|
|
3098
|
+
}));
|
|
3099
|
+
}
|
|
3100
|
+
// Parse browser messages into ClientMessage and forward to adapter
|
|
3101
|
+
browserWs.on("message", async (data) => {
|
|
3102
|
+
try {
|
|
3103
|
+
const msg = JSON.parse(data.toString());
|
|
3104
|
+
// Buffer video frames and throttle-write to disk for capture_photo tool
|
|
3105
|
+
if (msg.type === "video_frame") {
|
|
3106
|
+
const source = msg.source || "camera";
|
|
3107
|
+
if (source === "screen") {
|
|
3108
|
+
latestScreenFrame = { frame: msg.frame, mimeType: msg.mimeType, ts: Date.now() };
|
|
3109
|
+
const now = Date.now();
|
|
3110
|
+
if (now - lastScreenWriteTs >= 3000) {
|
|
3111
|
+
lastScreenWriteTs = now;
|
|
3112
|
+
const frameBuffer = Buffer.from(msg.frame, "base64");
|
|
3113
|
+
const framePath = join(agentRoot, LATEST_SCREEN_FILE);
|
|
3114
|
+
writeFile(framePath, frameBuffer).catch(() => { });
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
else {
|
|
3118
|
+
latestVideoFrame = { frame: msg.frame, mimeType: msg.mimeType, ts: Date.now() };
|
|
3119
|
+
const now = Date.now();
|
|
3120
|
+
if (now - lastFrameWriteTs >= FRAME_WRITE_INTERVAL) {
|
|
3121
|
+
lastFrameWriteTs = now;
|
|
3122
|
+
const frameBuffer = Buffer.from(msg.frame, "base64");
|
|
3123
|
+
const framePath = join(agentRoot, LATEST_FRAME_FILE);
|
|
3124
|
+
writeFile(framePath, frameBuffer).catch(() => { });
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
if (msg.type === "text") {
|
|
3129
|
+
appendMessage(opts.agentDir, activeBranch, { type: "transcript", role: "user", text: msg.text });
|
|
3130
|
+
// Detect @flow-name triggers
|
|
3131
|
+
const flowMatch = msg.text.match(/@([a-z0-9]+(?:-[a-z0-9]+)*)/);
|
|
3132
|
+
if (flowMatch) {
|
|
3133
|
+
try {
|
|
3134
|
+
const workflows = await discoverWorkflows(agentRoot);
|
|
3135
|
+
const flow = workflows.find((f) => f.name === flowMatch[1] && f.type === "flow");
|
|
3136
|
+
if (flow) {
|
|
3137
|
+
const userContext = msg.text.replace(/@[a-z0-9-]+/, "").trim();
|
|
3138
|
+
executeFlow(flow.name, userContext, sendToBrowser).catch((err) => {
|
|
3139
|
+
sendToBrowser({ type: "transcript", role: "assistant", text: `Flow error: ${err.message}` });
|
|
3140
|
+
});
|
|
3141
|
+
return; // skip adapter.send()
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
catch {
|
|
3145
|
+
// Fall through to normal send if flow detection fails
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
// Detect personal info and save to memory in background
|
|
3149
|
+
if (isMemoryWorthy(msg.text)) {
|
|
3150
|
+
saveMemoryInBackground(msg.text, opts.agentDir, opts.model, opts.env, () => {
|
|
3151
|
+
broadcastToBrowsers({ type: "memory_saving", status: "start", text: msg.text.slice(0, 60) });
|
|
3152
|
+
}, () => {
|
|
3153
|
+
broadcastToBrowsers({ type: "memory_saving", status: "done" });
|
|
3154
|
+
safeSend(browserWs, JSON.stringify({ type: "files_changed" }));
|
|
3155
|
+
});
|
|
3156
|
+
}
|
|
3157
|
+
// Text-only mode — call agent directly when no voice adapter
|
|
3158
|
+
if (!adapter) {
|
|
3159
|
+
const handler = createToolHandler(sendToBrowser);
|
|
3160
|
+
handler(msg.text).then((result) => {
|
|
3161
|
+
safeSend(browserWs, JSON.stringify({ type: "agent_done", result }));
|
|
3162
|
+
appendMessage(opts.agentDir, activeBranch, { type: "transcript", role: "assistant", text: result });
|
|
3163
|
+
safeSend(browserWs, JSON.stringify({ type: "files_changed" }));
|
|
3164
|
+
}).catch((err) => {
|
|
3165
|
+
safeSend(browserWs, JSON.stringify({ type: "error", message: err.message }));
|
|
3166
|
+
});
|
|
3167
|
+
return;
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
else if (msg.type === "file") {
|
|
3171
|
+
// Save uploaded file to disk so the text agent can use it
|
|
3172
|
+
const uploadsDir = join(agentRoot, "workspace");
|
|
3173
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
3174
|
+
const safeName = msg.name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
3175
|
+
const filePath = join(uploadsDir, safeName);
|
|
3176
|
+
writeFileSync(filePath, Buffer.from(msg.data, "base64"));
|
|
3177
|
+
const relPath = relative(agentRoot, filePath);
|
|
3178
|
+
console.log(dim(`[voice] Saved uploaded file: ${relPath}`));
|
|
3179
|
+
// Inject path into message so voice LLM tells the agent where the file is
|
|
3180
|
+
const userText = msg.text || "";
|
|
3181
|
+
msg.text = `${userText}${userText ? " " : ""}[File saved to: ${relPath} (absolute: ${filePath})]`;
|
|
3182
|
+
appendMessage(opts.agentDir, activeBranch, {
|
|
3183
|
+
type: "transcript", role: "user",
|
|
3184
|
+
text: `${userText} [Attached: ${safeName} → ${relPath}]`.trim(),
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
adapter?.send(msg);
|
|
3188
|
+
}
|
|
3189
|
+
catch (err) {
|
|
3190
|
+
console.error(`[voice] WS message handler error: ${err?.message || err}${err?.stack ? "\n" + err.stack : ""}`);
|
|
3191
|
+
}
|
|
3192
|
+
});
|
|
3193
|
+
browserWs.on("close", () => {
|
|
3194
|
+
console.log(dim("[voice] Browser disconnected"));
|
|
3195
|
+
adapter?.disconnect().catch(() => { });
|
|
3196
|
+
// Summarize chat history, save mood, and write journal — track promises for graceful shutdown
|
|
3197
|
+
const p = Promise.allSettled([
|
|
3198
|
+
summarizeHistory(opts.agentDir, activeBranch).catch((err) => {
|
|
3199
|
+
console.error(dim(`[voice] Background summarization failed: ${err.message}`));
|
|
3200
|
+
}),
|
|
3201
|
+
saveMoodEntry(opts.agentDir, moodCounts, sessionMessageCount).catch((err) => {
|
|
3202
|
+
console.error(dim(`[voice] Mood save failed: ${err.message}`));
|
|
3203
|
+
}),
|
|
3204
|
+
writeJournalEntry(opts.agentDir, activeBranch, moodCounts, opts.model, opts.env).catch((err) => {
|
|
3205
|
+
console.error(dim(`[voice] Journal write failed: ${err.message}`));
|
|
3206
|
+
}),
|
|
3207
|
+
]);
|
|
3208
|
+
pendingShutdownWork.push(p);
|
|
3209
|
+
});
|
|
3210
|
+
});
|
|
3211
|
+
await new Promise((resolve) => {
|
|
3212
|
+
httpServer.listen(port, () => resolve());
|
|
3213
|
+
});
|
|
3214
|
+
console.log(bold(`Voice server running on :${port}`));
|
|
3215
|
+
console.log(dim(`[voice] Backend: ${opts.adapter}`));
|
|
3216
|
+
console.log(dim(`[voice] Agent dir: ${agentRoot}`));
|
|
3217
|
+
console.log(dim(`[voice] Model: ${opts.model || "(default)"}`));
|
|
3218
|
+
console.log(dim(`[voice] Composio: ${composioAdapter ? "enabled" : "disabled"}`));
|
|
3219
|
+
console.log(dim(`[voice] Telegram: ${telegramToken ? "configured" : "not configured"}`));
|
|
3220
|
+
console.log(dim(`[voice] Auth: ${serverPassword ? `protected (user "${serverUsername}")` : "open — set GITAGENT_PASSWORD (and optionally GITAGENT_USERNAME) to require login"}`));
|
|
3221
|
+
console.log(dim(`[voice] Open http://localhost:${port} in your browser`));
|
|
3222
|
+
// Start the cron scheduler
|
|
3223
|
+
startScheduler(schedulerOpts).catch((err) => console.error(dim(`[scheduler] Init error: ${err.message}`)));
|
|
3224
|
+
return async () => {
|
|
3225
|
+
// Stop scheduled jobs
|
|
3226
|
+
stopScheduler();
|
|
3227
|
+
// Stop Telegram polling
|
|
3228
|
+
stopTelegramPolling();
|
|
3229
|
+
// Gracefully close WebSocket connections to trigger close handlers (journal, mood, etc.)
|
|
3230
|
+
for (const client of wss.clients) {
|
|
3231
|
+
client.close(1000, "Server shutting down");
|
|
3232
|
+
}
|
|
3233
|
+
// Wait for close handlers to fire, then await their async work (journal writes, etc.)
|
|
3234
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
3235
|
+
if (pendingShutdownWork.length > 0) {
|
|
3236
|
+
console.log(dim("[voice] Waiting for journal & mood saves..."));
|
|
3237
|
+
await Promise.allSettled(pendingShutdownWork);
|
|
3238
|
+
}
|
|
3239
|
+
wss.close();
|
|
3240
|
+
await new Promise((resolve) => {
|
|
3241
|
+
httpServer.close(() => resolve());
|
|
3242
|
+
});
|
|
3243
|
+
console.log(dim("[voice] Server stopped"));
|
|
3244
|
+
};
|
|
3245
|
+
}
|
|
3246
|
+
function safeSend(ws, data) {
|
|
3247
|
+
if (ws.readyState === WS.OPEN) {
|
|
3248
|
+
ws.send(data);
|
|
3249
|
+
}
|
|
3250
|
+
}
|