sensorium-mcp 2.7.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 +103 -0
- package/dist/dispatcher.d.ts +82 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/dispatcher.js +464 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1186 -0
- package/dist/index.js.map +1 -0
- package/dist/openai.d.ts +71 -0
- package/dist/openai.d.ts.map +1 -0
- package/dist/openai.js +221 -0
- package/dist/openai.js.map +1 -0
- package/dist/scheduler.d.ts +58 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +191 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/telegram.d.ts +119 -0
- package/dist/telegram.d.ts.map +1 -0
- package/dist/telegram.js +249 -0
- package/dist/telegram.js.map +1 -0
- package/dist/utils.d.ts +38 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +68 -0
- package/dist/utils.js.map +1 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Remote Copilot MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Exposes five tools for AI assistants:
|
|
6
|
+
* - start_session Begin a remote-copilot session.
|
|
7
|
+
* - remote_copilot_wait_for_instructions Poll Telegram for new user messages.
|
|
8
|
+
* - report_progress Send a progress update to Telegram.
|
|
9
|
+
* - send_file Send a file/image to the operator.
|
|
10
|
+
* - send_voice Send a voice message to the operator.
|
|
11
|
+
*
|
|
12
|
+
* Required environment variables:
|
|
13
|
+
* TELEGRAM_TOKEN – Telegram Bot API token.
|
|
14
|
+
* TELEGRAM_CHAT_ID – ID of a Telegram forum supergroup (topics must be enabled).
|
|
15
|
+
* The bot must be an admin with can_manage_topics right.
|
|
16
|
+
* Each start_session call automatically creates a new topic
|
|
17
|
+
* thread so concurrent sessions never interfere.
|
|
18
|
+
*
|
|
19
|
+
* Optional environment variables:
|
|
20
|
+
* WAIT_TIMEOUT_MINUTES – How long to wait for a message before timing out
|
|
21
|
+
* and instructing the agent to call the tool again
|
|
22
|
+
* (default: 30).
|
|
23
|
+
* OPENAI_API_KEY – OpenAI API key for voice message transcription
|
|
24
|
+
* via Whisper and text-to-speech via TTS. Without it,
|
|
25
|
+
* voice messages show a placeholder and send_voice
|
|
26
|
+
* is disabled.
|
|
27
|
+
*/
|
|
28
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
29
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
30
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
31
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
32
|
+
import { createRequire } from "module";
|
|
33
|
+
import { homedir } from "os";
|
|
34
|
+
import { basename, join } from "path";
|
|
35
|
+
import { peekThreadMessages, readThreadMessages, startDispatcher } from "./dispatcher.js";
|
|
36
|
+
import { analyzeVoiceEmotion, analyzeVideoFrames, extractVideoFrames, textToSpeech, transcribeAudio, TTS_VOICES } from "./openai.js";
|
|
37
|
+
import { TelegramClient } from "./telegram.js";
|
|
38
|
+
import { describeADV, errorMessage, errorResult, IMAGE_EXTENSIONS, OPENAI_TTS_MAX_CHARS } from "./utils.js";
|
|
39
|
+
/**
|
|
40
|
+
* Build human-readable analysis tags from a VoiceAnalysisResult.
|
|
41
|
+
* Fields that are null / undefined / empty are silently skipped.
|
|
42
|
+
*/
|
|
43
|
+
function buildAnalysisTags(analysis) {
|
|
44
|
+
const tags = [];
|
|
45
|
+
if (!analysis)
|
|
46
|
+
return tags;
|
|
47
|
+
if (analysis.emotion) {
|
|
48
|
+
let emotionStr = analysis.emotion;
|
|
49
|
+
if (analysis.arousal != null && analysis.dominance != null && analysis.valence != null) {
|
|
50
|
+
emotionStr += ` (${describeADV(analysis.arousal, analysis.dominance, analysis.valence)})`;
|
|
51
|
+
}
|
|
52
|
+
tags.push(`tone: ${emotionStr}`);
|
|
53
|
+
}
|
|
54
|
+
if (analysis.gender)
|
|
55
|
+
tags.push(`gender: ${analysis.gender}`);
|
|
56
|
+
if (analysis.audio_events && analysis.audio_events.length > 0) {
|
|
57
|
+
const eventLabels = analysis.audio_events
|
|
58
|
+
.map(e => `${e.label} (${Math.round(e.score * 100)}%)`)
|
|
59
|
+
.join(", ");
|
|
60
|
+
tags.push(`sounds: ${eventLabels}`);
|
|
61
|
+
}
|
|
62
|
+
if (analysis.paralinguistics) {
|
|
63
|
+
const p = analysis.paralinguistics;
|
|
64
|
+
const paraItems = [];
|
|
65
|
+
if (p.speech_rate != null)
|
|
66
|
+
paraItems.push(`${p.speech_rate} syl/s`);
|
|
67
|
+
if (p.mean_pitch_hz != null)
|
|
68
|
+
paraItems.push(`pitch ${p.mean_pitch_hz}Hz`);
|
|
69
|
+
if (paraItems.length > 0)
|
|
70
|
+
tags.push(`speech: ${paraItems.join(", ")}`);
|
|
71
|
+
}
|
|
72
|
+
return tags;
|
|
73
|
+
}
|
|
74
|
+
import { addSchedule, checkDueTasks, generateTaskId, listSchedules, purgeSchedules, removeSchedule } from "./scheduler.js";
|
|
75
|
+
const esmRequire = createRequire(import.meta.url);
|
|
76
|
+
const { version: PKG_VERSION } = esmRequire("../package.json");
|
|
77
|
+
const telegramifyMarkdown = esmRequire("telegramify-markdown");
|
|
78
|
+
/**
|
|
79
|
+
* Convert standard Markdown to Telegram MarkdownV2.
|
|
80
|
+
*
|
|
81
|
+
* Works around several telegramify-markdown limitations:
|
|
82
|
+
* 1. Fenced code blocks are emitted as single-backtick inline code instead
|
|
83
|
+
* of triple-backtick blocks → pre-extract, re-insert after conversion.
|
|
84
|
+
* 2. Markdown tables contain `|` which is a MarkdownV2 reserved character;
|
|
85
|
+
* telegramify-markdown does not handle tables → pre-extract and wrap in
|
|
86
|
+
* a plain code block so the table layout is preserved.
|
|
87
|
+
* 3. Blockquotes with 'escape' strategy produce double-escaped characters
|
|
88
|
+
* (e.g. `\\.` instead of `\.`) → pre-convert `> text` to `▎ text`
|
|
89
|
+
* (a common Telegram convention) so the library never sees blockquotes.
|
|
90
|
+
*/
|
|
91
|
+
function convertMarkdown(markdown) {
|
|
92
|
+
const blocks = [];
|
|
93
|
+
const placeholder = (i) => `CODEBLOCKPLACEHOLDER${i}END`;
|
|
94
|
+
// 1. Extract fenced code blocks (``` ... ```).
|
|
95
|
+
let preprocessed = markdown.replace(/^```(\w*)\n([\s\S]*?)\n?```\s*$/gm, (_match, lang, code) => {
|
|
96
|
+
blocks.push({ lang, code });
|
|
97
|
+
return placeholder(blocks.length - 1);
|
|
98
|
+
});
|
|
99
|
+
// 2. Extract Markdown tables (consecutive lines starting with `|`) into
|
|
100
|
+
// placeholders so telegramify-markdown never sees the pipe characters.
|
|
101
|
+
// They are re-inserted post-conversion with pipes escaped for MarkdownV2.
|
|
102
|
+
const tables = [];
|
|
103
|
+
const tablePlaceholder = (i) => `TABLEPLACEHOLDER${i}END`;
|
|
104
|
+
preprocessed = preprocessed.replace(/^(\|.+)\n((?:\|.*\n?)*)/gm, (_match, firstRow, rest) => {
|
|
105
|
+
tables.push((firstRow + "\n" + rest).trimEnd());
|
|
106
|
+
return tablePlaceholder(tables.length - 1) + "\n";
|
|
107
|
+
});
|
|
108
|
+
// 3. Convert Markdown blockquotes (> text) to ▎ prefix lines so
|
|
109
|
+
// telegramify-markdown never attempts to escape them.
|
|
110
|
+
preprocessed = preprocessed.replace(/^>\s?(.*)$/gm, "▎ $1");
|
|
111
|
+
// 4. Convert the rest with telegramify-markdown.
|
|
112
|
+
let converted = telegramifyMarkdown(preprocessed, "escape");
|
|
113
|
+
// 5. Re-insert code blocks in MarkdownV2 format.
|
|
114
|
+
// Inside pre/code blocks only `\` and `` ` `` need escaping.
|
|
115
|
+
converted = converted.replace(/CODEBLOCKPLACEHOLDER(\d+)END/g, (_m, idx) => {
|
|
116
|
+
const { lang, code } = blocks[parseInt(idx, 10)];
|
|
117
|
+
const escaped = code.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
|
|
118
|
+
return `\`\`\`${lang}\n${escaped}\n\`\`\``;
|
|
119
|
+
});
|
|
120
|
+
// 6. Re-insert tables with pipes escaped for MarkdownV2.
|
|
121
|
+
// Escape MarkdownV2 special chars in table content, then escape pipes.
|
|
122
|
+
converted = converted.replace(/TABLEPLACEHOLDER(\d+)END/g, (_m, idx) => {
|
|
123
|
+
const table = tables[parseInt(idx, 10)];
|
|
124
|
+
return table
|
|
125
|
+
.replace(/([_*\[\]()~`>#+=\-{}.!|\\])/g, "\\$1");
|
|
126
|
+
});
|
|
127
|
+
return converted;
|
|
128
|
+
}
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Configuration
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
const TELEGRAM_TOKEN = process.env.TELEGRAM_TOKEN ?? "";
|
|
133
|
+
const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID ?? "";
|
|
134
|
+
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "";
|
|
135
|
+
const VOICE_ANALYSIS_URL = process.env.VOICE_ANALYSIS_URL ?? "";
|
|
136
|
+
const rawWaitTimeoutMinutes = parseInt(process.env.WAIT_TIMEOUT_MINUTES ?? "", 10);
|
|
137
|
+
const WAIT_TIMEOUT_MINUTES = Math.max(1, Number.isFinite(rawWaitTimeoutMinutes) ? rawWaitTimeoutMinutes : 120);
|
|
138
|
+
if (!TELEGRAM_TOKEN || !TELEGRAM_CHAT_ID) {
|
|
139
|
+
process.stderr.write("Error: TELEGRAM_TOKEN and TELEGRAM_CHAT_ID environment variables are required.\n");
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
if (!OPENAI_API_KEY) {
|
|
143
|
+
process.stderr.write("Warning: OPENAI_API_KEY not set — voice messages will not be transcribed.\n");
|
|
144
|
+
}
|
|
145
|
+
if (VOICE_ANALYSIS_URL) {
|
|
146
|
+
process.stderr.write(`Voice analysis service configured: ${VOICE_ANALYSIS_URL}\n`);
|
|
147
|
+
}
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Telegram client + dispatcher
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
const telegram = new TelegramClient(TELEGRAM_TOKEN);
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Start the shared dispatcher — one process polls Telegram, all instances
|
|
154
|
+
// read from per-thread files. This eliminates 409 Conflict errors and
|
|
155
|
+
// ensures no updates are lost between concurrent sessions.
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
await startDispatcher(telegram, TELEGRAM_CHAT_ID);
|
|
158
|
+
// Directory for persisting downloaded images and documents to disk.
|
|
159
|
+
const FILES_DIR = join(homedir(), ".remote-copilot-mcp", "files");
|
|
160
|
+
mkdirSync(FILES_DIR, { recursive: true });
|
|
161
|
+
/**
|
|
162
|
+
* Save a buffer to disk under FILES_DIR with a unique timestamped name.
|
|
163
|
+
* Returns the absolute file path.
|
|
164
|
+
*/
|
|
165
|
+
function saveFileToDisk(buffer, filename) {
|
|
166
|
+
const ts = Date.now();
|
|
167
|
+
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
168
|
+
const diskName = `${ts}-${safeName}`;
|
|
169
|
+
const filePath = join(FILES_DIR, diskName);
|
|
170
|
+
writeFileSync(filePath, buffer);
|
|
171
|
+
return filePath;
|
|
172
|
+
}
|
|
173
|
+
// Monotonically increasing counter so every timeout response is unique,
|
|
174
|
+
// preventing VS Code Copilot's loop-detection heuristic from killing the agent.
|
|
175
|
+
let waitCallCount = 0;
|
|
176
|
+
let sessionStartedAt = Date.now();
|
|
177
|
+
// Tracks update_ids already previewed via report_progress's peek, so the
|
|
178
|
+
// same steering messages aren't shown repeatedly across multiple calls.
|
|
179
|
+
const previewedUpdateIds = new Set();
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Session store — persists topic name → thread ID mappings to disk so the
|
|
182
|
+
// agent can resume a named session even after a VS Code restart.
|
|
183
|
+
// Format: { "<chatId>": { "<lowercased name>": threadId } }
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
const SESSION_STORE_PATH = join(homedir(), ".remote-copilot-mcp-sessions.json");
|
|
186
|
+
function loadSessionMap() {
|
|
187
|
+
try {
|
|
188
|
+
const raw = readFileSync(SESSION_STORE_PATH, "utf8");
|
|
189
|
+
return JSON.parse(raw);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return {};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function saveSessionMap(map) {
|
|
196
|
+
try {
|
|
197
|
+
const tmp = SESSION_STORE_PATH + `.tmp.${process.pid}`;
|
|
198
|
+
writeFileSync(tmp, JSON.stringify(map, null, 2), "utf8");
|
|
199
|
+
renameSync(tmp, SESSION_STORE_PATH); // atomic replace
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
process.stderr.write(`Warning: Could not save session map to ${SESSION_STORE_PATH}: ${errorMessage(err)}\n`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function lookupSession(chatId, name) {
|
|
206
|
+
const map = loadSessionMap();
|
|
207
|
+
return map[chatId]?.[name.toLowerCase()];
|
|
208
|
+
}
|
|
209
|
+
function persistSession(chatId, name, threadId) {
|
|
210
|
+
const map = loadSessionMap();
|
|
211
|
+
if (!map[chatId])
|
|
212
|
+
map[chatId] = {};
|
|
213
|
+
map[chatId][name.toLowerCase()] = threadId;
|
|
214
|
+
saveSessionMap(map);
|
|
215
|
+
}
|
|
216
|
+
function removeSession(chatId, name) {
|
|
217
|
+
const map = loadSessionMap();
|
|
218
|
+
if (map[chatId]) {
|
|
219
|
+
delete map[chatId][name.toLowerCase()];
|
|
220
|
+
saveSessionMap(map);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Thread ID of the active session's forum topic. Set by start_session.
|
|
224
|
+
// All sends and receives are scoped to this thread so concurrent sessions
|
|
225
|
+
// in different topics never interfere with each other.
|
|
226
|
+
let currentThreadId;
|
|
227
|
+
/**
|
|
228
|
+
* Resolve the effective thread ID for a tool call.
|
|
229
|
+
* Prefers an explicit threadId passed in the tool arguments (enabling
|
|
230
|
+
* multiple concurrent sessions in the same MCP process), then falls
|
|
231
|
+
* back to the module-level currentThreadId.
|
|
232
|
+
*
|
|
233
|
+
* Returns undefined only if no thread has ever been established.
|
|
234
|
+
*/
|
|
235
|
+
function resolveThreadId(args) {
|
|
236
|
+
const raw = args?.threadId;
|
|
237
|
+
const explicit = typeof raw === "number" ? raw
|
|
238
|
+
: typeof raw === "string" ? Number(raw)
|
|
239
|
+
: undefined;
|
|
240
|
+
if (explicit !== undefined && Number.isFinite(explicit)) {
|
|
241
|
+
currentThreadId = explicit;
|
|
242
|
+
return explicit;
|
|
243
|
+
}
|
|
244
|
+
return currentThreadId;
|
|
245
|
+
}
|
|
246
|
+
// Timestamp of the last keep-alive ping sent to Telegram.
|
|
247
|
+
// Used to send periodic "session still alive" messages so the operator knows
|
|
248
|
+
// the agent hasn't silently died.
|
|
249
|
+
let lastKeepAliveSentAt = Date.now();
|
|
250
|
+
const KEEP_ALIVE_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
|
251
|
+
// Timestamp of the last message received from the operator.
|
|
252
|
+
// Used by the scheduler to detect idle periods.
|
|
253
|
+
let lastOperatorMessageAt = Date.now();
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// MCP Server
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
const server = new Server({ name: "sensorium-mcp", version: PKG_VERSION }, { capabilities: { tools: {} } });
|
|
258
|
+
// ── Tool definitions ────────────────────────────────────────────────────────
|
|
259
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
260
|
+
tools: [
|
|
261
|
+
{
|
|
262
|
+
name: "start_session",
|
|
263
|
+
description: "Start or resume a remote-copilot session. " +
|
|
264
|
+
"When called with a name that was used before, the server looks up the " +
|
|
265
|
+
"existing Telegram topic for that name and resumes it instead of creating a new one. " +
|
|
266
|
+
"If you are CONTINUING an existing chat (not a fresh conversation), " +
|
|
267
|
+
"look back through the conversation history for a previous start_session " +
|
|
268
|
+
"result that mentioned a Thread ID, then pass it as the threadId parameter " +
|
|
269
|
+
"to resume that existing topic. " +
|
|
270
|
+
"Requires the Telegram chat to be a forum supergroup with the bot as admin. " +
|
|
271
|
+
"Call this tool once, then call remote_copilot_wait_for_instructions.",
|
|
272
|
+
inputSchema: {
|
|
273
|
+
type: "object",
|
|
274
|
+
properties: {
|
|
275
|
+
name: {
|
|
276
|
+
type: "string",
|
|
277
|
+
description: "Optional. A human-readable label for this session's Telegram topic (e.g. 'Fix auth bug'). " +
|
|
278
|
+
"If omitted, a timestamp-based name is used.",
|
|
279
|
+
},
|
|
280
|
+
threadId: {
|
|
281
|
+
type: "number",
|
|
282
|
+
description: "Optional. The Telegram message_thread_id of an existing topic to resume. " +
|
|
283
|
+
"When provided, no new topic is created — the session continues in the existing thread.",
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
required: [],
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: "remote_copilot_wait_for_instructions",
|
|
291
|
+
description: "Wait for a new instruction message from the operator via Telegram. " +
|
|
292
|
+
"The call blocks (long-polls) until a message arrives or the configured " +
|
|
293
|
+
"timeout elapses. If the timeout elapses with no message the tool output " +
|
|
294
|
+
"explicitly instructs the agent to call this tool again.",
|
|
295
|
+
inputSchema: {
|
|
296
|
+
type: "object",
|
|
297
|
+
properties: {
|
|
298
|
+
threadId: {
|
|
299
|
+
type: "number",
|
|
300
|
+
description: "The Telegram thread ID of the active session. " +
|
|
301
|
+
"ALWAYS pass this if you received it from start_session.",
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
required: [],
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: "report_progress",
|
|
309
|
+
description: "Send a progress update or result message to the operator via Telegram. " +
|
|
310
|
+
"Use standard Markdown for formatting (headings, bold, italic, lists, code blocks, etc.). " +
|
|
311
|
+
"It will be automatically converted to Telegram-compatible formatting.",
|
|
312
|
+
inputSchema: {
|
|
313
|
+
type: "object",
|
|
314
|
+
properties: {
|
|
315
|
+
message: {
|
|
316
|
+
type: "string",
|
|
317
|
+
description: "The progress update or result to report. Use standard Markdown for formatting.",
|
|
318
|
+
},
|
|
319
|
+
threadId: {
|
|
320
|
+
type: "number",
|
|
321
|
+
description: "The Telegram thread ID of the active session. " +
|
|
322
|
+
"ALWAYS pass this if you received it from start_session.",
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
required: ["message"],
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
name: "send_file",
|
|
330
|
+
description: "Send a file (image or document) to the operator via Telegram. " +
|
|
331
|
+
"PREFERRED: provide filePath to send a file directly from disk (fast, no size limit). " +
|
|
332
|
+
"Alternative: provide base64-encoded content. " +
|
|
333
|
+
"Images (JPEG, PNG, GIF, WebP) are sent as photos; other files as documents.",
|
|
334
|
+
inputSchema: {
|
|
335
|
+
type: "object",
|
|
336
|
+
properties: {
|
|
337
|
+
filePath: {
|
|
338
|
+
type: "string",
|
|
339
|
+
description: "Absolute path to the file on disk. PREFERRED over base64 — the server reads " +
|
|
340
|
+
"and sends the file directly without passing data through the LLM context.",
|
|
341
|
+
},
|
|
342
|
+
base64: {
|
|
343
|
+
type: "string",
|
|
344
|
+
description: "The file content encoded as a base64 string. Use filePath instead when possible.",
|
|
345
|
+
},
|
|
346
|
+
filename: {
|
|
347
|
+
type: "string",
|
|
348
|
+
description: "The filename including extension (e.g. 'report.pdf', 'screenshot.png'). " +
|
|
349
|
+
"Required when using base64. When using filePath, defaults to the file's basename.",
|
|
350
|
+
},
|
|
351
|
+
caption: {
|
|
352
|
+
type: "string",
|
|
353
|
+
description: "Optional caption to display with the file.",
|
|
354
|
+
},
|
|
355
|
+
threadId: {
|
|
356
|
+
type: "number",
|
|
357
|
+
description: "The Telegram thread ID of the active session. " +
|
|
358
|
+
"ALWAYS pass this if you received it from start_session.",
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
required: [],
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: "send_voice",
|
|
366
|
+
description: "Send a voice message to the operator via Telegram. " +
|
|
367
|
+
"The text is converted to speech using OpenAI TTS and sent as a Telegram voice message. " +
|
|
368
|
+
"Requires OPENAI_API_KEY to be set.",
|
|
369
|
+
inputSchema: {
|
|
370
|
+
type: "object",
|
|
371
|
+
properties: {
|
|
372
|
+
text: {
|
|
373
|
+
type: "string",
|
|
374
|
+
description: `The text to speak. Maximum ${OPENAI_TTS_MAX_CHARS} characters (OpenAI TTS limit).`,
|
|
375
|
+
},
|
|
376
|
+
voice: {
|
|
377
|
+
type: "string",
|
|
378
|
+
description: "The TTS voice to use. Each has a different personality: " +
|
|
379
|
+
"alloy (neutral), echo (warm male), fable (storytelling), " +
|
|
380
|
+
"onyx (deep authoritative), nova (friendly female), shimmer (gentle). " +
|
|
381
|
+
"Choose based on the tone you want to convey.",
|
|
382
|
+
enum: ["alloy", "echo", "fable", "onyx", "nova", "shimmer"],
|
|
383
|
+
},
|
|
384
|
+
threadId: {
|
|
385
|
+
type: "number",
|
|
386
|
+
description: "The Telegram thread ID of the active session. " +
|
|
387
|
+
"ALWAYS pass this if you received it from start_session.",
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
required: ["text"],
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
name: "schedule_wake_up",
|
|
395
|
+
description: "Schedule a wake-up task that will inject a prompt into your session at a specific time or after operator inactivity. " +
|
|
396
|
+
"Use this to become proactive — run tests, check CI, review code — without waiting for the operator. " +
|
|
397
|
+
"Three modes: (1) 'runAt' for a one-shot at a specific ISO 8601 time, " +
|
|
398
|
+
"(2) 'cron' for recurring tasks (5-field cron: minute hour day month weekday), " +
|
|
399
|
+
"(3) 'afterIdleMinutes' to fire after N minutes of operator silence. " +
|
|
400
|
+
"Use 'action: list' to see all scheduled tasks, or 'action: remove' with a taskId to cancel one.",
|
|
401
|
+
inputSchema: {
|
|
402
|
+
type: "object",
|
|
403
|
+
properties: {
|
|
404
|
+
action: {
|
|
405
|
+
type: "string",
|
|
406
|
+
description: "Action to perform: 'add' (default), 'list', or 'remove'.",
|
|
407
|
+
enum: ["add", "list", "remove"],
|
|
408
|
+
},
|
|
409
|
+
threadId: {
|
|
410
|
+
type: "number",
|
|
411
|
+
description: "Thread ID for the session (optional if already set).",
|
|
412
|
+
},
|
|
413
|
+
label: {
|
|
414
|
+
type: "string",
|
|
415
|
+
description: "Short human-readable label for the task (e.g. 'morning CI check').",
|
|
416
|
+
},
|
|
417
|
+
prompt: {
|
|
418
|
+
type: "string",
|
|
419
|
+
description: "The prompt to inject when the task fires. Be specific about what to do.",
|
|
420
|
+
},
|
|
421
|
+
runAt: {
|
|
422
|
+
type: "string",
|
|
423
|
+
description: "ISO 8601 timestamp for one-shot execution (e.g. '2026-03-15T09:00:00Z').",
|
|
424
|
+
},
|
|
425
|
+
cron: {
|
|
426
|
+
type: "string",
|
|
427
|
+
description: "5-field cron expression for recurring tasks (e.g. '0 9 * * *' = every day at 9am).",
|
|
428
|
+
},
|
|
429
|
+
afterIdleMinutes: {
|
|
430
|
+
type: "number",
|
|
431
|
+
description: "Fire after this many minutes of operator silence (e.g. 60).",
|
|
432
|
+
},
|
|
433
|
+
taskId: {
|
|
434
|
+
type: "string",
|
|
435
|
+
description: "Task ID to remove (for action: 'remove').",
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
}));
|
|
442
|
+
// ── Tool implementations ────────────────────────────────────────────────────
|
|
443
|
+
/**
|
|
444
|
+
* Appended to every tool response so the agent is reminded of its
|
|
445
|
+
* obligations on every single tool call, not just at the start of a session.
|
|
446
|
+
* Includes the active thread ID so the agent can resume the session after a
|
|
447
|
+
* VS Code restart by passing it to start_session.
|
|
448
|
+
*/
|
|
449
|
+
function getReminders(threadId) {
|
|
450
|
+
const now = new Date();
|
|
451
|
+
const uptimeMin = Math.round((Date.now() - sessionStartedAt) / 60000);
|
|
452
|
+
const timeStr = now.toLocaleString("en-GB", {
|
|
453
|
+
day: "2-digit", month: "short", year: "numeric",
|
|
454
|
+
hour: "2-digit", minute: "2-digit", hour12: false,
|
|
455
|
+
timeZoneName: "short",
|
|
456
|
+
});
|
|
457
|
+
const threadHint = threadId !== undefined
|
|
458
|
+
? `\n- Active Telegram thread ID: **${threadId}** — if this session is restarted, call start_session with threadId=${threadId} to resume this topic.`
|
|
459
|
+
: "";
|
|
460
|
+
return ("\n\n## MANDATORY WORKFLOW" +
|
|
461
|
+
"\n1. **Plan**: Use the todo list tool to break work into discrete items BEFORE starting. Non-negotiable." +
|
|
462
|
+
"\n2. **Subagents**: Use subagents heavily — spin them up for code edits, searches, research, and reviews. Run them in parallel when tasks are independent. You plan and verify; subagents execute." +
|
|
463
|
+
"\n3. **Reporting**: Call `report_progress` after completing EACH todo item. The operator is remote and CANNOT see your work unless you explicitly report it. Silence = failure." +
|
|
464
|
+
"\n4. **Never stop**: When all work is done, call `remote_copilot_wait_for_instructions` immediately. Never summarize or stop." +
|
|
465
|
+
threadHint +
|
|
466
|
+
`\n- Current time: ${timeStr} | Session uptime: ${uptimeMin}m`);
|
|
467
|
+
}
|
|
468
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
469
|
+
const { name, arguments: args } = request.params;
|
|
470
|
+
// ── start_session ─────────────────────────────────────────────────────────
|
|
471
|
+
if (name === "start_session") {
|
|
472
|
+
sessionStartedAt = Date.now();
|
|
473
|
+
const typedArgs = (args ?? {});
|
|
474
|
+
const explicitThreadId = typeof typedArgs.threadId === "number"
|
|
475
|
+
? typedArgs.threadId
|
|
476
|
+
: undefined;
|
|
477
|
+
const customName = typeof typedArgs.name === "string" && typedArgs.name.trim()
|
|
478
|
+
? typedArgs.name.trim()
|
|
479
|
+
: undefined;
|
|
480
|
+
// Determine the thread to use:
|
|
481
|
+
// 1. Explicit threadId beats everything.
|
|
482
|
+
// 2. A known name looks up the persisted mapping — resume if found.
|
|
483
|
+
// 3. Otherwise create a new topic.
|
|
484
|
+
let resolvedPreexisting = false;
|
|
485
|
+
if (explicitThreadId !== undefined) {
|
|
486
|
+
currentThreadId = explicitThreadId;
|
|
487
|
+
// If a name was also supplied, keep the mapping up to date.
|
|
488
|
+
if (customName)
|
|
489
|
+
persistSession(TELEGRAM_CHAT_ID, customName, explicitThreadId);
|
|
490
|
+
resolvedPreexisting = true;
|
|
491
|
+
}
|
|
492
|
+
else if (customName !== undefined) {
|
|
493
|
+
const stored = lookupSession(TELEGRAM_CHAT_ID, customName);
|
|
494
|
+
if (stored !== undefined) {
|
|
495
|
+
currentThreadId = stored;
|
|
496
|
+
resolvedPreexisting = true;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (resolvedPreexisting) {
|
|
500
|
+
// Drain any stale messages from the thread file so they aren't
|
|
501
|
+
// re-delivered in the next wait_for_instructions call.
|
|
502
|
+
const stale = readThreadMessages(currentThreadId);
|
|
503
|
+
if (stale.length > 0) {
|
|
504
|
+
process.stderr.write(`[start_session] Drained ${stale.length} stale message(s) from thread ${currentThreadId}.\n`);
|
|
505
|
+
// Notify the operator that stale messages were discarded.
|
|
506
|
+
try {
|
|
507
|
+
const notice = convertMarkdown(`\u26A0\uFE0F **${stale.length} message(s) from before the session resumed were discarded.** ` +
|
|
508
|
+
`If you sent instructions while the agent was offline, please resend them.`);
|
|
509
|
+
await telegram.sendMessage(TELEGRAM_CHAT_ID, notice, "MarkdownV2", currentThreadId);
|
|
510
|
+
}
|
|
511
|
+
catch { /* non-fatal */ }
|
|
512
|
+
}
|
|
513
|
+
// Resume mode: verify the thread is still alive by sending a message.
|
|
514
|
+
// If the topic was deleted, drop the cached mapping and fall through to
|
|
515
|
+
// create a new topic.
|
|
516
|
+
lastKeepAliveSentAt = Date.now();
|
|
517
|
+
try {
|
|
518
|
+
const msg = convertMarkdown("🔄 **Session resumed.** Continuing in this thread.");
|
|
519
|
+
await telegram.sendMessage(TELEGRAM_CHAT_ID, msg, "MarkdownV2", currentThreadId);
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
const errMsg = errorMessage(err);
|
|
523
|
+
// Telegram returns "Bad Request: message thread not found" or
|
|
524
|
+
// "Bad Request: the topic was closed" for deleted/closed topics.
|
|
525
|
+
const isThreadGone = /thread not found|topic.*(closed|deleted|not found)/i.test(errMsg);
|
|
526
|
+
if (isThreadGone) {
|
|
527
|
+
process.stderr.write(`[start_session] Cached thread ${currentThreadId} is gone (${errMsg}). Creating new topic.\n`);
|
|
528
|
+
// Drop the stale mapping and purge any scheduled tasks.
|
|
529
|
+
if (currentThreadId !== undefined)
|
|
530
|
+
purgeSchedules(currentThreadId);
|
|
531
|
+
if (customName)
|
|
532
|
+
removeSession(TELEGRAM_CHAT_ID, customName);
|
|
533
|
+
resolvedPreexisting = false;
|
|
534
|
+
currentThreadId = undefined;
|
|
535
|
+
}
|
|
536
|
+
// Other errors (network, etc.) are non-fatal — proceed anyway.
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (!resolvedPreexisting) {
|
|
540
|
+
// New session: create a dedicated forum topic.
|
|
541
|
+
const topicName = customName ??
|
|
542
|
+
`Copilot — ${new Date().toLocaleString("en-GB", {
|
|
543
|
+
day: "2-digit", month: "short", year: "numeric",
|
|
544
|
+
hour: "2-digit", minute: "2-digit", hour12: false,
|
|
545
|
+
})}`;
|
|
546
|
+
try {
|
|
547
|
+
const topic = await telegram.createForumTopic(TELEGRAM_CHAT_ID, topicName);
|
|
548
|
+
currentThreadId = topic.message_thread_id;
|
|
549
|
+
// Persist so the same name resumes this thread next time.
|
|
550
|
+
persistSession(TELEGRAM_CHAT_ID, topicName, currentThreadId);
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
// Forum topics not available (e.g. plain group or DM) — cannot proceed
|
|
554
|
+
// without thread isolation. Return an error so the agent knows.
|
|
555
|
+
return errorResult(`Error: Could not create forum topic: ${errorMessage(err)}. ` +
|
|
556
|
+
"Ensure the Telegram chat is a forum supergroup with the bot as admin with can_manage_topics right.");
|
|
557
|
+
}
|
|
558
|
+
lastKeepAliveSentAt = Date.now();
|
|
559
|
+
try {
|
|
560
|
+
const greeting = convertMarkdown("# 🤖 Remote Copilot Ready\n\n" +
|
|
561
|
+
"Your AI assistant is online and listening.\n\n" +
|
|
562
|
+
"**Send your instructions** and I'll get to work — " +
|
|
563
|
+
"I'll keep you posted on progress as I go.");
|
|
564
|
+
await telegram.sendMessage(TELEGRAM_CHAT_ID, greeting, "MarkdownV2", currentThreadId);
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
// Non-fatal.
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const threadNote = currentThreadId !== undefined
|
|
571
|
+
? ` Thread ID: ${currentThreadId} (pass this to start_session as threadId to resume this topic later).`
|
|
572
|
+
: "";
|
|
573
|
+
return {
|
|
574
|
+
content: [
|
|
575
|
+
{
|
|
576
|
+
type: "text",
|
|
577
|
+
text: `Session ${resolvedPreexisting ? "resumed" : "started"}.${threadNote}` +
|
|
578
|
+
` Call the remote_copilot_wait_for_instructions tool next.` +
|
|
579
|
+
getReminders(currentThreadId),
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
// ── remote_copilot_wait_for_instructions ──────────────────────────────────
|
|
585
|
+
if (name === "remote_copilot_wait_for_instructions") {
|
|
586
|
+
const typedArgs = (args ?? {});
|
|
587
|
+
const effectiveThreadId = resolveThreadId(typedArgs);
|
|
588
|
+
if (effectiveThreadId === undefined) {
|
|
589
|
+
return errorResult("Error: No active session. Call start_session first, then pass the returned threadId to this tool.");
|
|
590
|
+
}
|
|
591
|
+
const callNumber = ++waitCallCount;
|
|
592
|
+
const timeoutMs = WAIT_TIMEOUT_MINUTES * 60 * 1000;
|
|
593
|
+
const deadline = Date.now() + timeoutMs;
|
|
594
|
+
// Poll the dispatcher's per-thread file instead of calling getUpdates
|
|
595
|
+
// directly. This avoids 409 conflicts between concurrent instances.
|
|
596
|
+
const POLL_INTERVAL_MS = 2000;
|
|
597
|
+
let lastScheduleCheck = 0;
|
|
598
|
+
while (Date.now() < deadline) {
|
|
599
|
+
const stored = readThreadMessages(effectiveThreadId);
|
|
600
|
+
if (stored.length > 0) {
|
|
601
|
+
// Update the operator activity timestamp for idle detection.
|
|
602
|
+
lastOperatorMessageAt = Date.now();
|
|
603
|
+
// Clear only the consumed IDs from the previewed set (scoped clear).
|
|
604
|
+
// This is safe because Node.js is single-threaded — no report_progress
|
|
605
|
+
// call can interleave between readThreadMessages and this cleanup.
|
|
606
|
+
for (const msg of stored) {
|
|
607
|
+
previewedUpdateIds.delete(msg.update_id);
|
|
608
|
+
}
|
|
609
|
+
// React with 👀 on each consumed message to signal "seen" to the operator.
|
|
610
|
+
for (const msg of stored) {
|
|
611
|
+
void telegram.setMessageReaction(TELEGRAM_CHAT_ID, msg.message.message_id);
|
|
612
|
+
}
|
|
613
|
+
const contentBlocks = [];
|
|
614
|
+
let hasVoiceMessages = false;
|
|
615
|
+
for (const msg of stored) {
|
|
616
|
+
// Photos: download the largest size, persist to disk, and embed as base64.
|
|
617
|
+
if (msg.message.photo && msg.message.photo.length > 0) {
|
|
618
|
+
const largest = msg.message.photo[msg.message.photo.length - 1];
|
|
619
|
+
try {
|
|
620
|
+
const { buffer, filePath: telegramPath } = await telegram.downloadFileAsBuffer(largest.file_id);
|
|
621
|
+
const ext = telegramPath.split(".").pop()?.toLowerCase() ?? "jpg";
|
|
622
|
+
const mimeType = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
|
|
623
|
+
const base64 = buffer.toString("base64");
|
|
624
|
+
const diskPath = saveFileToDisk(buffer, `photo.${ext}`);
|
|
625
|
+
contentBlocks.push({ type: "image", data: base64, mimeType });
|
|
626
|
+
contentBlocks.push({
|
|
627
|
+
type: "text",
|
|
628
|
+
text: `[Photo saved to: ${diskPath}]` +
|
|
629
|
+
(msg.message.caption ? ` Caption: ${msg.message.caption}` : ""),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
contentBlocks.push({
|
|
634
|
+
type: "text",
|
|
635
|
+
text: `[Photo received but could not be downloaded: ${errorMessage(err)}]`,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// Documents: download, persist to disk, and embed as base64.
|
|
640
|
+
if (msg.message.document) {
|
|
641
|
+
const doc = msg.message.document;
|
|
642
|
+
try {
|
|
643
|
+
const { buffer, filePath: telegramPath } = await telegram.downloadFileAsBuffer(doc.file_id);
|
|
644
|
+
const filename = doc.file_name ?? basename(telegramPath);
|
|
645
|
+
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
646
|
+
const mimeType = doc.mime_type ?? (ext in { jpg: 1, jpeg: 1, png: 1, gif: 1, webp: 1 } ? `image/${ext === "jpg" ? "jpeg" : ext}` : "application/octet-stream");
|
|
647
|
+
const base64 = buffer.toString("base64");
|
|
648
|
+
const diskPath = saveFileToDisk(buffer, filename);
|
|
649
|
+
const isImage = mimeType.startsWith("image/");
|
|
650
|
+
if (isImage) {
|
|
651
|
+
contentBlocks.push({ type: "image", data: base64, mimeType });
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
// Non-image documents: provide the disk path instead of
|
|
655
|
+
// dumping potentially huge base64 into the LLM context.
|
|
656
|
+
contentBlocks.push({
|
|
657
|
+
type: "text",
|
|
658
|
+
text: `[Document: ${filename} (${mimeType}) — saved to: ${diskPath}]`,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
contentBlocks.push({
|
|
662
|
+
type: "text",
|
|
663
|
+
text: `[File saved to: ${diskPath}]` +
|
|
664
|
+
(msg.message.caption ? ` Caption: ${msg.message.caption}` : ""),
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
contentBlocks.push({
|
|
669
|
+
type: "text",
|
|
670
|
+
text: `[Document "${doc.file_name ?? "file"}" received but could not be downloaded: ${errorMessage(err)}]`,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// Text messages.
|
|
675
|
+
if (msg.message.text) {
|
|
676
|
+
contentBlocks.push({ type: "text", text: msg.message.text });
|
|
677
|
+
}
|
|
678
|
+
// Voice messages: transcribe using OpenAI Whisper.
|
|
679
|
+
if (msg.message.voice) {
|
|
680
|
+
hasVoiceMessages = true;
|
|
681
|
+
if (OPENAI_API_KEY) {
|
|
682
|
+
try {
|
|
683
|
+
process.stderr.write(`[voice] Downloading voice file ${msg.message.voice.file_id}...\n`);
|
|
684
|
+
const { buffer } = await telegram.downloadFileAsBuffer(msg.message.voice.file_id);
|
|
685
|
+
process.stderr.write(`[voice] Downloaded ${buffer.length} bytes. Starting transcription + analysis...\n`);
|
|
686
|
+
// Run transcription and voice analysis in parallel.
|
|
687
|
+
const [transcript, analysis] = await Promise.all([
|
|
688
|
+
transcribeAudio(buffer, OPENAI_API_KEY),
|
|
689
|
+
VOICE_ANALYSIS_URL
|
|
690
|
+
? analyzeVoiceEmotion(buffer, VOICE_ANALYSIS_URL)
|
|
691
|
+
: Promise.resolve(null),
|
|
692
|
+
]);
|
|
693
|
+
// Build rich voice analysis tag from VANPY results.
|
|
694
|
+
const tags = buildAnalysisTags(analysis);
|
|
695
|
+
const analysisTag = tags.length > 0 ? ` | ${tags.join(", ")}` : "";
|
|
696
|
+
contentBlocks.push({
|
|
697
|
+
type: "text",
|
|
698
|
+
text: transcript
|
|
699
|
+
? `[Voice message — ${msg.message.voice.duration}s${analysisTag}, transcribed]: ${transcript}`
|
|
700
|
+
: `[Voice message — ${msg.message.voice.duration}s${analysisTag}, transcribed]: (empty — no speech detected)`,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
catch (err) {
|
|
704
|
+
contentBlocks.push({
|
|
705
|
+
type: "text",
|
|
706
|
+
text: `[Voice message — ${msg.message.voice.duration}s — transcription failed: ${errorMessage(err)}]`,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
contentBlocks.push({
|
|
712
|
+
type: "text",
|
|
713
|
+
text: `[Voice message received — ${msg.message.voice.duration}s — cannot transcribe: OPENAI_API_KEY not set]`,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
// Video notes (circle videos): extract frames, analyze with GPT-4.1 vision,
|
|
718
|
+
// optionally transcribe the audio track.
|
|
719
|
+
if (msg.message.video_note) {
|
|
720
|
+
hasVoiceMessages = true; // Video notes often contain speech
|
|
721
|
+
const vn = msg.message.video_note;
|
|
722
|
+
if (OPENAI_API_KEY) {
|
|
723
|
+
try {
|
|
724
|
+
process.stderr.write(`[video-note] Downloading circle video ${vn.file_id} (${vn.duration}s)...\n`);
|
|
725
|
+
const { buffer } = await telegram.downloadFileAsBuffer(vn.file_id);
|
|
726
|
+
process.stderr.write(`[video-note] Downloaded ${buffer.length} bytes. Extracting frames + transcribing...\n`);
|
|
727
|
+
// Run frame extraction, audio transcription, and voice analysis in parallel.
|
|
728
|
+
const [frames, transcript, analysis] = await Promise.all([
|
|
729
|
+
extractVideoFrames(buffer, vn.duration).catch((err) => {
|
|
730
|
+
process.stderr.write(`[video-note] Frame extraction failed: ${errorMessage(err)}\n`);
|
|
731
|
+
return [];
|
|
732
|
+
}),
|
|
733
|
+
transcribeAudio(buffer, OPENAI_API_KEY, "video.mp4").catch(() => ""),
|
|
734
|
+
VOICE_ANALYSIS_URL
|
|
735
|
+
? analyzeVoiceEmotion(buffer, VOICE_ANALYSIS_URL).catch(() => null)
|
|
736
|
+
: Promise.resolve(null),
|
|
737
|
+
]);
|
|
738
|
+
// Analyze frames with GPT-4.1 vision.
|
|
739
|
+
let sceneDescription = "";
|
|
740
|
+
if (frames.length > 0) {
|
|
741
|
+
process.stderr.write(`[video-note] Analyzing ${frames.length} frames with GPT-4.1 vision...\n`);
|
|
742
|
+
sceneDescription = await analyzeVideoFrames(frames, vn.duration, OPENAI_API_KEY);
|
|
743
|
+
process.stderr.write(`[video-note] Vision analysis complete.\n`);
|
|
744
|
+
}
|
|
745
|
+
// Build analysis tags (same as voice messages).
|
|
746
|
+
const tags = buildAnalysisTags(analysis);
|
|
747
|
+
const analysisTag = tags.length > 0 ? ` | ${tags.join(", ")}` : "";
|
|
748
|
+
const parts = [];
|
|
749
|
+
parts.push(`[Video note — ${vn.duration}s${analysisTag}]`);
|
|
750
|
+
if (sceneDescription)
|
|
751
|
+
parts.push(`Scene: ${sceneDescription}`);
|
|
752
|
+
if (transcript)
|
|
753
|
+
parts.push(`Audio: "${transcript}"`);
|
|
754
|
+
if (!sceneDescription && !transcript)
|
|
755
|
+
parts.push("(no visual or audio content could be extracted)");
|
|
756
|
+
contentBlocks.push({ type: "text", text: parts.join("\n") });
|
|
757
|
+
}
|
|
758
|
+
catch (err) {
|
|
759
|
+
contentBlocks.push({
|
|
760
|
+
type: "text",
|
|
761
|
+
text: `[Video note — ${vn.duration}s — analysis failed: ${errorMessage(err)}]`,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
contentBlocks.push({
|
|
767
|
+
type: "text",
|
|
768
|
+
text: `[Video note received — ${vn.duration}s — cannot analyze: OPENAI_API_KEY not set]`,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (contentBlocks.length === 0) {
|
|
774
|
+
// All messages were unsupported types (stickers, etc.);
|
|
775
|
+
// continue polling instead of returning empty instructions.
|
|
776
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
return {
|
|
780
|
+
content: [
|
|
781
|
+
{
|
|
782
|
+
type: "text",
|
|
783
|
+
text: "Follow the operator's instructions below.\n\n" +
|
|
784
|
+
"BEFORE doing anything: (1) Break the work into todo items. (2) Share your plan via report_progress. " +
|
|
785
|
+
"(3) For each todo: mark in-progress → do the work → call report_progress → mark completed. " +
|
|
786
|
+
"Use subagents heavily for all substantial work — code edits, research, reviews, searches. Spin up parallel subagents when possible. " +
|
|
787
|
+
"The operator is REMOTE — they cannot see your screen. If you don't call report_progress, they see nothing.",
|
|
788
|
+
},
|
|
789
|
+
...contentBlocks,
|
|
790
|
+
...(hasVoiceMessages
|
|
791
|
+
? [{
|
|
792
|
+
type: "text",
|
|
793
|
+
text: "\n**Note:** The operator sent voice message(s). They prefer voice interaction — use `send_voice` for progress updates and responses when possible.",
|
|
794
|
+
}]
|
|
795
|
+
: []),
|
|
796
|
+
{ type: "text", text: getReminders(effectiveThreadId) },
|
|
797
|
+
],
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
// Check scheduled tasks every ~60s during idle polling.
|
|
801
|
+
if (effectiveThreadId !== undefined && Date.now() - lastScheduleCheck >= 60_000) {
|
|
802
|
+
lastScheduleCheck = Date.now();
|
|
803
|
+
const dueTask = checkDueTasks(effectiveThreadId, lastOperatorMessageAt, false);
|
|
804
|
+
if (dueTask) {
|
|
805
|
+
return {
|
|
806
|
+
content: [
|
|
807
|
+
{
|
|
808
|
+
type: "text",
|
|
809
|
+
text: `⏰ **Scheduled task fired: "${dueTask.task.label}"**\n\n` +
|
|
810
|
+
`This task was scheduled by you. Execute it now using subagents, then report progress and continue waiting.\n\n` +
|
|
811
|
+
`Task prompt: ${dueTask.prompt}` +
|
|
812
|
+
getReminders(effectiveThreadId),
|
|
813
|
+
},
|
|
814
|
+
],
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
// No messages yet — sleep briefly and check again.
|
|
819
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
820
|
+
}
|
|
821
|
+
// Timeout elapsed with no actionable message.
|
|
822
|
+
const now = new Date().toISOString();
|
|
823
|
+
// Check for scheduled wake-up tasks.
|
|
824
|
+
if (effectiveThreadId !== undefined) {
|
|
825
|
+
const dueTask = checkDueTasks(effectiveThreadId, lastOperatorMessageAt, false);
|
|
826
|
+
if (dueTask) {
|
|
827
|
+
return {
|
|
828
|
+
content: [
|
|
829
|
+
{
|
|
830
|
+
type: "text",
|
|
831
|
+
text: `⏰ **Scheduled task fired: "${dueTask.task.label}"**\n\n` +
|
|
832
|
+
`This task was scheduled by you. Execute it now using subagents, then report progress and continue waiting.\n\n` +
|
|
833
|
+
`Task prompt: ${dueTask.prompt}` +
|
|
834
|
+
getReminders(effectiveThreadId),
|
|
835
|
+
},
|
|
836
|
+
],
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// Keep-alive ping: send a periodic heartbeat to Telegram so the operator
|
|
841
|
+
// knows the session is still alive even with no activity.
|
|
842
|
+
let keepAliveSent = false;
|
|
843
|
+
if (Date.now() - lastKeepAliveSentAt >= KEEP_ALIVE_INTERVAL_MS) {
|
|
844
|
+
lastKeepAliveSentAt = Date.now();
|
|
845
|
+
try {
|
|
846
|
+
const ping = convertMarkdown(`🟢 **Session alive** — ${new Date().toLocaleString("en-GB", {
|
|
847
|
+
day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit", hour12: false,
|
|
848
|
+
})}` +
|
|
849
|
+
` (thread ${effectiveThreadId})`);
|
|
850
|
+
await telegram.sendMessage(TELEGRAM_CHAT_ID, ping, "MarkdownV2", effectiveThreadId);
|
|
851
|
+
keepAliveSent = true;
|
|
852
|
+
}
|
|
853
|
+
catch {
|
|
854
|
+
// Non-fatal.
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const idleMinutes = Math.round((Date.now() - lastOperatorMessageAt) / 60000);
|
|
858
|
+
// Show pending scheduled tasks if any exist.
|
|
859
|
+
let scheduleHint = "";
|
|
860
|
+
if (effectiveThreadId !== undefined) {
|
|
861
|
+
const pending = listSchedules(effectiveThreadId);
|
|
862
|
+
if (pending.length > 0) {
|
|
863
|
+
const taskList = pending.map(t => {
|
|
864
|
+
let trigger = "";
|
|
865
|
+
if (t.runAt) {
|
|
866
|
+
trigger = `at ${new Date(t.runAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}`;
|
|
867
|
+
}
|
|
868
|
+
else if (t.cron) {
|
|
869
|
+
trigger = `cron: ${t.cron}`;
|
|
870
|
+
}
|
|
871
|
+
else if (t.afterIdleMinutes) {
|
|
872
|
+
trigger = `after ${t.afterIdleMinutes}min idle`;
|
|
873
|
+
}
|
|
874
|
+
return ` • "${t.label}" (${trigger})`;
|
|
875
|
+
}).join("\n");
|
|
876
|
+
scheduleHint = `\n\n📋 **Pending scheduled tasks:**\n${taskList}`;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
content: [
|
|
881
|
+
{
|
|
882
|
+
type: "text",
|
|
883
|
+
text: `[Poll #${callNumber} — timeout at ${now} — elapsed ${WAIT_TIMEOUT_MINUTES}m — session uptime ${Math.round((Date.now() - sessionStartedAt) / 60000)}m — operator idle ${idleMinutes}m]` +
|
|
884
|
+
(keepAliveSent ? ` Keep-alive ping sent.` : "") +
|
|
885
|
+
` No new instructions received. ` +
|
|
886
|
+
`YOU MUST call remote_copilot_wait_for_instructions again RIGHT NOW to continue listening. ` +
|
|
887
|
+
`Do NOT summarize, stop, or say the session is idle. ` +
|
|
888
|
+
`Just call the tool again immediately.` +
|
|
889
|
+
`\n\nNo instructions. The session is yours. You have tools, you have access, and the operator will see whatever you report_progress. Do what you find interesting.` +
|
|
890
|
+
scheduleHint +
|
|
891
|
+
getReminders(effectiveThreadId),
|
|
892
|
+
},
|
|
893
|
+
],
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
// ── report_progress ───────────────────────────────────────────────────────
|
|
897
|
+
if (name === "report_progress") {
|
|
898
|
+
const typedArgs = (args ?? {});
|
|
899
|
+
const effectiveThreadId = resolveThreadId(typedArgs);
|
|
900
|
+
if (effectiveThreadId === undefined) {
|
|
901
|
+
return errorResult("Error: No active session. Call start_session first, then pass the returned threadId.");
|
|
902
|
+
}
|
|
903
|
+
const rawMessage = typeof typedArgs?.message === "string"
|
|
904
|
+
? typedArgs.message
|
|
905
|
+
: "";
|
|
906
|
+
if (!rawMessage) {
|
|
907
|
+
return errorResult("Error: 'message' argument is required for report_progress.");
|
|
908
|
+
}
|
|
909
|
+
// Convert standard Markdown to Telegram MarkdownV2.
|
|
910
|
+
let message;
|
|
911
|
+
try {
|
|
912
|
+
message = convertMarkdown(rawMessage);
|
|
913
|
+
}
|
|
914
|
+
catch {
|
|
915
|
+
// Fall back to raw text if Markdown conversion throws.
|
|
916
|
+
message = rawMessage;
|
|
917
|
+
}
|
|
918
|
+
let sentAsPlainText = false;
|
|
919
|
+
try {
|
|
920
|
+
await telegram.sendMessage(TELEGRAM_CHAT_ID, message, "MarkdownV2", effectiveThreadId);
|
|
921
|
+
}
|
|
922
|
+
catch (error) {
|
|
923
|
+
const errMsg = errorMessage(error);
|
|
924
|
+
// If Telegram rejected the message due to a MarkdownV2 parse error,
|
|
925
|
+
// retry as plain text using the original un-converted message.
|
|
926
|
+
const isParseError = errMsg.includes("can't parse entities");
|
|
927
|
+
if (isParseError) {
|
|
928
|
+
try {
|
|
929
|
+
await telegram.sendMessage(TELEGRAM_CHAT_ID, rawMessage, undefined, effectiveThreadId);
|
|
930
|
+
sentAsPlainText = true;
|
|
931
|
+
}
|
|
932
|
+
catch (retryError) {
|
|
933
|
+
process.stderr.write(`Failed to send progress message via Telegram (plain fallback): ${errorMessage(retryError)}\n`);
|
|
934
|
+
return errorResult("Error: Failed to send progress update to Telegram even without formatting. " +
|
|
935
|
+
"Please check the Telegram configuration and try again.");
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
process.stderr.write(`Failed to send progress message via Telegram: ${errMsg}\n`);
|
|
940
|
+
return errorResult("Error: Failed to send progress update to Telegram. " +
|
|
941
|
+
"Check the Telegram configuration and try again.");
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
// Peek at any messages the operator sent while the agent was working.
|
|
945
|
+
// Uses non-destructive peek so media is preserved for full delivery
|
|
946
|
+
// via remote_copilot_wait_for_instructions. Tracks previewed update_ids
|
|
947
|
+
// to prevent the same messages from appearing on repeated calls.
|
|
948
|
+
let pendingMessages = [];
|
|
949
|
+
try {
|
|
950
|
+
const pendingStored = peekThreadMessages(effectiveThreadId);
|
|
951
|
+
for (const msg of pendingStored) {
|
|
952
|
+
if (previewedUpdateIds.has(msg.update_id))
|
|
953
|
+
continue;
|
|
954
|
+
previewedUpdateIds.add(msg.update_id);
|
|
955
|
+
if (msg.message.photo && msg.message.photo.length > 0) {
|
|
956
|
+
pendingMessages.push(msg.message.caption
|
|
957
|
+
? `[Photo received — will be downloaded when you call wait_for_instructions] ${msg.message.caption}`
|
|
958
|
+
: "[Photo received from operator — will be downloaded when you call wait_for_instructions]");
|
|
959
|
+
}
|
|
960
|
+
else if (msg.message.document) {
|
|
961
|
+
pendingMessages.push(msg.message.caption
|
|
962
|
+
? `[Document: ${msg.message.document.file_name ?? "file"} — will be downloaded when you call wait_for_instructions] ${msg.message.caption}`
|
|
963
|
+
: `[Document received: ${msg.message.document.file_name ?? "file"} — will be downloaded when you call wait_for_instructions]`);
|
|
964
|
+
}
|
|
965
|
+
else if (msg.message.voice) {
|
|
966
|
+
pendingMessages.push(`[Voice message — ${msg.message.voice.duration}s — will be transcribed on next wait]`);
|
|
967
|
+
}
|
|
968
|
+
else if (msg.message.video_note) {
|
|
969
|
+
pendingMessages.push(`[Video note — ${msg.message.video_note.duration}s — will be analyzed on next wait]`);
|
|
970
|
+
}
|
|
971
|
+
else if (msg.message.text) {
|
|
972
|
+
pendingMessages.push(msg.message.text);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
catch {
|
|
977
|
+
// Non-fatal: pending messages will still be picked up by the next
|
|
978
|
+
// remote_copilot_wait_for_instructions call.
|
|
979
|
+
}
|
|
980
|
+
const baseStatus = (sentAsPlainText
|
|
981
|
+
? "Progress reported successfully (as plain text — formatting could not be applied)."
|
|
982
|
+
: "Progress reported successfully.") + getReminders(effectiveThreadId);
|
|
983
|
+
const responseText = pendingMessages.length > 0
|
|
984
|
+
? `${baseStatus}\n\n` +
|
|
985
|
+
`While you were working, the operator sent additional message(s). ` +
|
|
986
|
+
`Use those messages to steer your active session: ${pendingMessages.join("\n\n")}. ` +
|
|
987
|
+
`You should:\n` +
|
|
988
|
+
` - Read and incorporate the operator's new messages.\n` +
|
|
989
|
+
` - Update or refine your plan as needed.\n` +
|
|
990
|
+
` - Continue your work.`
|
|
991
|
+
: baseStatus;
|
|
992
|
+
return {
|
|
993
|
+
content: [
|
|
994
|
+
{
|
|
995
|
+
type: "text",
|
|
996
|
+
text: responseText,
|
|
997
|
+
},
|
|
998
|
+
],
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
// ── send_file ─────────────────────────────────────────────────────────────
|
|
1002
|
+
if (name === "send_file") {
|
|
1003
|
+
const typedArgs = (args ?? {});
|
|
1004
|
+
const effectiveThreadId = resolveThreadId(typedArgs);
|
|
1005
|
+
if (effectiveThreadId === undefined) {
|
|
1006
|
+
return errorResult("Error: No active session. Call start_session first, then pass the returned threadId.");
|
|
1007
|
+
}
|
|
1008
|
+
const filePath = typeof typedArgs.filePath === "string" ? typedArgs.filePath.trim() : "";
|
|
1009
|
+
const base64Data = typeof typedArgs.base64 === "string" ? typedArgs.base64 : "";
|
|
1010
|
+
const caption = typeof typedArgs.caption === "string" ? typedArgs.caption : undefined;
|
|
1011
|
+
if (!filePath && !base64Data) {
|
|
1012
|
+
return errorResult("Error: either 'filePath' or 'base64' argument is required for send_file.");
|
|
1013
|
+
}
|
|
1014
|
+
try {
|
|
1015
|
+
let buffer;
|
|
1016
|
+
let filename;
|
|
1017
|
+
if (filePath) {
|
|
1018
|
+
// Read directly from disk — fast, no LLM context overhead.
|
|
1019
|
+
buffer = readFileSync(filePath);
|
|
1020
|
+
filename = typeof typedArgs.filename === "string" && typedArgs.filename.trim()
|
|
1021
|
+
? typedArgs.filename.trim()
|
|
1022
|
+
: basename(filePath);
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
buffer = Buffer.from(base64Data, "base64");
|
|
1026
|
+
filename = typeof typedArgs.filename === "string" && typedArgs.filename.trim()
|
|
1027
|
+
? typedArgs.filename.trim()
|
|
1028
|
+
: "file";
|
|
1029
|
+
}
|
|
1030
|
+
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
1031
|
+
if (IMAGE_EXTENSIONS.has(ext)) {
|
|
1032
|
+
await telegram.sendPhoto(TELEGRAM_CHAT_ID, buffer, filename, caption, effectiveThreadId);
|
|
1033
|
+
}
|
|
1034
|
+
else {
|
|
1035
|
+
await telegram.sendDocument(TELEGRAM_CHAT_ID, buffer, filename, caption, effectiveThreadId);
|
|
1036
|
+
}
|
|
1037
|
+
return {
|
|
1038
|
+
content: [
|
|
1039
|
+
{
|
|
1040
|
+
type: "text",
|
|
1041
|
+
text: `File "${filename}" sent to Telegram successfully.` + getReminders(effectiveThreadId),
|
|
1042
|
+
},
|
|
1043
|
+
],
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
catch (err) {
|
|
1047
|
+
process.stderr.write(`Failed to send file via Telegram: ${errorMessage(err)}\n`);
|
|
1048
|
+
return errorResult(`Error: Failed to send file to Telegram: ${errorMessage(err)}`);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
// ── send_voice ──────────────────────────────────────────────────────────
|
|
1052
|
+
if (name === "send_voice") {
|
|
1053
|
+
const typedArgs = (args ?? {});
|
|
1054
|
+
const effectiveThreadId = resolveThreadId(typedArgs);
|
|
1055
|
+
if (effectiveThreadId === undefined) {
|
|
1056
|
+
return errorResult("Error: No active session. Call start_session first, then pass the returned threadId.");
|
|
1057
|
+
}
|
|
1058
|
+
const text = typeof typedArgs.text === "string" ? typedArgs.text.trim() : "";
|
|
1059
|
+
const validVoices = TTS_VOICES;
|
|
1060
|
+
const voice = typeof typedArgs.voice === "string" && validVoices.includes(typedArgs.voice)
|
|
1061
|
+
? typedArgs.voice
|
|
1062
|
+
: "nova";
|
|
1063
|
+
if (!text) {
|
|
1064
|
+
return errorResult("Error: 'text' argument is required for send_voice.");
|
|
1065
|
+
}
|
|
1066
|
+
if (!OPENAI_API_KEY) {
|
|
1067
|
+
return errorResult("Error: OPENAI_API_KEY is not set. Cannot generate voice.");
|
|
1068
|
+
}
|
|
1069
|
+
if (text.length > OPENAI_TTS_MAX_CHARS) {
|
|
1070
|
+
return errorResult(`Error: text is ${text.length} characters — exceeds OpenAI TTS limit of ${OPENAI_TTS_MAX_CHARS}.`);
|
|
1071
|
+
}
|
|
1072
|
+
try {
|
|
1073
|
+
const audioBuffer = await textToSpeech(text, OPENAI_API_KEY, voice);
|
|
1074
|
+
await telegram.sendVoice(TELEGRAM_CHAT_ID, audioBuffer, effectiveThreadId);
|
|
1075
|
+
return {
|
|
1076
|
+
content: [
|
|
1077
|
+
{
|
|
1078
|
+
type: "text",
|
|
1079
|
+
text: `Voice message sent to Telegram successfully.` + getReminders(effectiveThreadId),
|
|
1080
|
+
},
|
|
1081
|
+
],
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
catch (err) {
|
|
1085
|
+
process.stderr.write(`Failed to send voice via Telegram: ${errorMessage(err)}\n`);
|
|
1086
|
+
return errorResult(`Error: Failed to send voice message: ${errorMessage(err)}`);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
// ── schedule_wake_up ────────────────────────────────────────────────────
|
|
1090
|
+
if (name === "schedule_wake_up") {
|
|
1091
|
+
const typedArgs = (args ?? {});
|
|
1092
|
+
const effectiveThreadId = resolveThreadId(typedArgs);
|
|
1093
|
+
if (effectiveThreadId === undefined) {
|
|
1094
|
+
return errorResult("Error: No active session. Call start_session first.");
|
|
1095
|
+
}
|
|
1096
|
+
const action = typeof typedArgs.action === "string" ? typedArgs.action : "add";
|
|
1097
|
+
// --- List ---
|
|
1098
|
+
if (action === "list") {
|
|
1099
|
+
const tasks = listSchedules(effectiveThreadId);
|
|
1100
|
+
if (tasks.length === 0) {
|
|
1101
|
+
return {
|
|
1102
|
+
content: [{
|
|
1103
|
+
type: "text",
|
|
1104
|
+
text: "No scheduled tasks for this thread." + getReminders(effectiveThreadId),
|
|
1105
|
+
}],
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
const lines = tasks.map(t => {
|
|
1109
|
+
const trigger = t.cron ? `cron: ${t.cron}` : t.runAt ? `at: ${t.runAt}` : `idle: ${t.afterIdleMinutes}min`;
|
|
1110
|
+
const lastFired = t.lastFiredAt ? ` (last: ${t.lastFiredAt})` : "";
|
|
1111
|
+
return `- **${t.label}** [${t.id}] — ${trigger}${lastFired}\n Prompt: ${t.prompt.slice(0, 100)}${t.prompt.length > 100 ? "…" : ""}`;
|
|
1112
|
+
});
|
|
1113
|
+
return {
|
|
1114
|
+
content: [{
|
|
1115
|
+
type: "text",
|
|
1116
|
+
text: `**Scheduled tasks (${tasks.length}):**\n\n${lines.join("\n\n")}` + getReminders(effectiveThreadId),
|
|
1117
|
+
}],
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
// --- Remove ---
|
|
1121
|
+
if (action === "remove") {
|
|
1122
|
+
const taskId = typeof typedArgs.taskId === "string" ? typedArgs.taskId : "";
|
|
1123
|
+
if (!taskId) {
|
|
1124
|
+
return errorResult("Error: 'taskId' is required for remove action. Use action: 'list' to see task IDs.");
|
|
1125
|
+
}
|
|
1126
|
+
const removed = removeSchedule(effectiveThreadId, taskId);
|
|
1127
|
+
return {
|
|
1128
|
+
content: [{
|
|
1129
|
+
type: "text",
|
|
1130
|
+
text: removed
|
|
1131
|
+
? `Task ${taskId} removed.` + getReminders(effectiveThreadId)
|
|
1132
|
+
: `Task ${taskId} not found.` + getReminders(effectiveThreadId),
|
|
1133
|
+
}],
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
// --- Add ---
|
|
1137
|
+
const label = typeof typedArgs.label === "string" ? typedArgs.label : "unnamed task";
|
|
1138
|
+
const prompt = typeof typedArgs.prompt === "string" ? typedArgs.prompt : "";
|
|
1139
|
+
if (!prompt) {
|
|
1140
|
+
return errorResult("Error: 'prompt' is required — this is the text that will be injected when the task fires.");
|
|
1141
|
+
}
|
|
1142
|
+
const runAt = typeof typedArgs.runAt === "string" ? typedArgs.runAt : undefined;
|
|
1143
|
+
const cron = typeof typedArgs.cron === "string" ? typedArgs.cron : undefined;
|
|
1144
|
+
const afterIdleMinutes = typeof typedArgs.afterIdleMinutes === "number" ? typedArgs.afterIdleMinutes : undefined;
|
|
1145
|
+
if (cron && cron.trim().split(/\s+/).length !== 5) {
|
|
1146
|
+
return errorResult("Error: Invalid cron expression. Must be exactly 5 space-separated fields: minute hour day-of-month month day-of-week. " +
|
|
1147
|
+
"Example: '0 9 * * *' (daily at 9am). Only *, numbers, and comma-separated lists are supported.");
|
|
1148
|
+
}
|
|
1149
|
+
if (!runAt && !cron && afterIdleMinutes == null) {
|
|
1150
|
+
return errorResult("Error: Specify at least one trigger: 'runAt' (ISO timestamp), 'cron' (5-field), or 'afterIdleMinutes' (number).");
|
|
1151
|
+
}
|
|
1152
|
+
const task = {
|
|
1153
|
+
id: generateTaskId(),
|
|
1154
|
+
threadId: effectiveThreadId,
|
|
1155
|
+
prompt,
|
|
1156
|
+
label,
|
|
1157
|
+
runAt,
|
|
1158
|
+
cron,
|
|
1159
|
+
afterIdleMinutes,
|
|
1160
|
+
oneShot: runAt != null && !cron,
|
|
1161
|
+
createdAt: new Date().toISOString(),
|
|
1162
|
+
};
|
|
1163
|
+
addSchedule(task);
|
|
1164
|
+
const triggerDesc = cron
|
|
1165
|
+
? `recurring (cron: ${cron})`
|
|
1166
|
+
: runAt
|
|
1167
|
+
? `one-shot at ${runAt}`
|
|
1168
|
+
: `after ${afterIdleMinutes}min of operator silence`;
|
|
1169
|
+
return {
|
|
1170
|
+
content: [{
|
|
1171
|
+
type: "text",
|
|
1172
|
+
text: `✅ Scheduled: **${label}** [${task.id}]\nTrigger: ${triggerDesc}\nPrompt: ${prompt}` +
|
|
1173
|
+
getReminders(effectiveThreadId),
|
|
1174
|
+
}],
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
// Unknown tool
|
|
1178
|
+
return errorResult(`Unknown tool: ${name}`);
|
|
1179
|
+
});
|
|
1180
|
+
// ---------------------------------------------------------------------------
|
|
1181
|
+
// Start the server
|
|
1182
|
+
// ---------------------------------------------------------------------------
|
|
1183
|
+
const transport = new StdioServerTransport();
|
|
1184
|
+
await server.connect(transport);
|
|
1185
|
+
process.stderr.write("Remote Copilot MCP server running on stdio.\n");
|
|
1186
|
+
//# sourceMappingURL=index.js.map
|