agentp 0.11.2 → 0.11.4
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/bin/agentp +226 -0
- package/bin/tgagentp +344 -59
- package/docs/specification.md +1 -1
- package/lib/file-share.js +59 -0
- package/package.json +1 -1
package/bin/agentp
CHANGED
|
@@ -77,6 +77,8 @@ async function main() {
|
|
|
77
77
|
let serverBase = 'http://localhost:4096';
|
|
78
78
|
let qaMode = false;
|
|
79
79
|
let flushMode = false;
|
|
80
|
+
let sessionName = null;
|
|
81
|
+
let newSession = false;
|
|
80
82
|
// tgMode: false = disabled, 'auto' = auto-detect, true = explicit --tg
|
|
81
83
|
let tgMode = null;
|
|
82
84
|
|
|
@@ -97,6 +99,8 @@ async function main() {
|
|
|
97
99
|
console.error(' --no-tg Do not forward to Telegram');
|
|
98
100
|
console.error(' --flush Flush tgagentp\'s recorded buffer without prepending it to output');
|
|
99
101
|
console.error(' --getLast Retrieve last N assistant answers (or QA pairs with --qa) from session history');
|
|
102
|
+
console.error(' --session Target a specific session by name (creates it if not found with --new)');
|
|
103
|
+
console.error(' --new Create a new session with the given title (requires --session)');
|
|
100
104
|
console.error(' --help Show this help message');
|
|
101
105
|
console.error('');
|
|
102
106
|
console.error('By default, --qa auto-detects tgagentp; standalone mode implies --no-tg.');
|
|
@@ -109,6 +113,10 @@ async function main() {
|
|
|
109
113
|
console.error('--flush clears the tgagentp recorded buffer and also prevents the prepended');
|
|
110
114
|
console.error('context from appearing in --qa output. Use it when you want fresh context.');
|
|
111
115
|
console.error('');
|
|
116
|
+
console.error('--session <name> targets a specific session by exact or partial name match.');
|
|
117
|
+
console.error('If the session does not exist, agentp errors (use --new to create it).');
|
|
118
|
+
console.error('--new creates a new session with the given title (does not match existing sessions).');
|
|
119
|
+
console.error('');
|
|
112
120
|
console.error('Arguments:');
|
|
113
121
|
console.error(' url OpenCode server URL or port number (default: 4096)');
|
|
114
122
|
console.error(' Examples: 4096, http://localhost:4096, http://192.168.1.50:4096');
|
|
@@ -119,6 +127,8 @@ async function main() {
|
|
|
119
127
|
console.error('Examples:');
|
|
120
128
|
console.error(' printf "Summarize this file" | agentp');
|
|
121
129
|
console.error(' cat prompt.txt | agentp $(ocmux) # talk to current project\'s server');
|
|
130
|
+
console.error(' cat prompt.txt | agentp --session "My Task" $(ocmux)');
|
|
131
|
+
console.error(' cat prompt.txt | agentp --session "New Task" --new $(ocmux)');
|
|
122
132
|
console.error(' # Vim/Neovim filter, preserving prompt + answer:');
|
|
123
133
|
console.error(" :'<,'>!agentp --qa $(ocmux)");
|
|
124
134
|
console.error(' # Retrieve last 3 assistant answers:');
|
|
@@ -130,6 +140,15 @@ async function main() {
|
|
|
130
140
|
qaMode = true;
|
|
131
141
|
} else if (args[i] === '--flush') {
|
|
132
142
|
flushMode = true;
|
|
143
|
+
} else if (args[i] === '--session') {
|
|
144
|
+
i++;
|
|
145
|
+
sessionName = args[i];
|
|
146
|
+
if (!sessionName) {
|
|
147
|
+
console.error('Error: --session requires a session name');
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
} else if (args[i] === '--new') {
|
|
151
|
+
newSession = true;
|
|
133
152
|
} else if (args[i] === '--getLast') {
|
|
134
153
|
i++;
|
|
135
154
|
const nStr = args[i];
|
|
@@ -137,6 +156,213 @@ async function main() {
|
|
|
137
156
|
console.error('Error: --getLast requires a number (e.g. --getLast 5)');
|
|
138
157
|
process.exit(1);
|
|
139
158
|
}
|
|
159
|
+
const n = parseInt(nStr, 10);
|
|
160
|
+
try {
|
|
161
|
+
const sessions = await listSessions(serverBase);
|
|
162
|
+
if (!sessions || sessions.length === 0) {
|
|
163
|
+
console.error('No sessions found.');
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
const sorted = sessions
|
|
167
|
+
.filter(s => s.time && (s.time.updated || s.time.created))
|
|
168
|
+
.sort((a, b) => {
|
|
169
|
+
const aTime = (a.time && a.time.updated) || (a.time && a.time.created) || 0;
|
|
170
|
+
const bTime = (b.time && b.time.updated) || (b.time && b.time.created) || 0;
|
|
171
|
+
return bTime - aTime;
|
|
172
|
+
});
|
|
173
|
+
const sessionId = sorted[0].id;
|
|
174
|
+
|
|
175
|
+
function buildPairsFromMessages(messages) {
|
|
176
|
+
const pairs = [];
|
|
177
|
+
let cur = null;
|
|
178
|
+
for (const raw of messages) {
|
|
179
|
+
// Normalize: export gives {role, parts: [{type, text}]}, SQLite gives {role, text}
|
|
180
|
+
const m = raw.parts ? raw : { role: raw.role, parts: [{ type: 'text', text: raw.text }] };
|
|
181
|
+
const role = m.role;
|
|
182
|
+
const text = (m.parts || []).filter(p => p.type === 'text').map(p => p.text).join('').trim();
|
|
183
|
+
if (!text) continue;
|
|
184
|
+
if (role === 'user') {
|
|
185
|
+
if (cur) pairs.push(cur);
|
|
186
|
+
cur = { prompt: text, answer: '' };
|
|
187
|
+
} else if (role === 'assistant' && cur) {
|
|
188
|
+
if (cur.answer) cur.answer += '\n';
|
|
189
|
+
cur.answer += text;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Don't push incomplete pair (no answer yet)
|
|
193
|
+
return pairs;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function formatPairs(pairs) {
|
|
197
|
+
return pairs.map(p =>
|
|
198
|
+
HUMAN_ROW + p.prompt + '\n' + AGENT_ROW + p.answer + '\n' + FINAL_ROW
|
|
199
|
+
).join('\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const formatAsQa = qaMode || args.includes('--qa');
|
|
203
|
+
function extractAnswers(pairs) {
|
|
204
|
+
return pairs.map(p => p.answer);
|
|
205
|
+
}
|
|
206
|
+
let output = '';
|
|
207
|
+
try {
|
|
208
|
+
const raw = child_process.execFileSync('opencode', ['export', sessionId], { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 });
|
|
209
|
+
const jsonStart = raw.indexOf('{');
|
|
210
|
+
const body = jsonStart >= 0 ? raw.slice(jsonStart) : raw;
|
|
211
|
+
const exportData = JSON.parse(body);
|
|
212
|
+
const messages = exportData.messages || [];
|
|
213
|
+
const pairs = buildPairsFromMessages(messages);
|
|
214
|
+
const lastN = pairs.slice(-n);
|
|
215
|
+
output = formatAsQa ? formatPairs(lastN) : extractAnswers(lastN).join('\n');
|
|
216
|
+
} catch {
|
|
217
|
+
// export JSON is malformed (opencode bug);
|
|
218
|
+
// fall back to SQLite
|
|
219
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
220
|
+
const dbCandidates = [
|
|
221
|
+
`${home}/.local/share/opencode/opencode.db`,
|
|
222
|
+
`${home}/Library/Application Support/opencode/opencode.db`,
|
|
223
|
+
`${home}/.opencode/opencode.db`,
|
|
224
|
+
];
|
|
225
|
+
let dbPath = null;
|
|
226
|
+
for (const p of dbCandidates) {
|
|
227
|
+
try { fs.accessSync(p); dbPath = p; break; } catch {}
|
|
228
|
+
}
|
|
229
|
+
if (dbPath) {
|
|
230
|
+
const sql = `SELECT m.data->>'role' as role, group_concat(p.data->>'text', '') as text FROM message m JOIN part p ON p.message_id = m.id WHERE m.session_id = '${sessionId.replace(/'/g, "''")}' AND p.data->>'type' = 'text' AND m.data->>'role' IN ('user','assistant') GROUP BY m.id ORDER BY m.time_created ASC`;
|
|
231
|
+
const out = child_process.execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 });
|
|
232
|
+
const messages = JSON.parse(out);
|
|
233
|
+
const pairs = buildPairsFromMessages(messages);
|
|
234
|
+
const lastN = pairs.slice(-n);
|
|
235
|
+
output = formatAsQa ? formatPairs(lastN) : extractAnswers(lastN).join('\n');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (!output) {
|
|
239
|
+
console.error(formatAsQa ? 'No complete prompt-answer pairs found in session history.' : 'No assistant answers found in session history.');
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
process.stdout.write(output);
|
|
243
|
+
if (!output.endsWith('\n')) process.stdout.write('\n');
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error('Error retrieving session history:', err.message);
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
} else if (args[i] === '--tg') {
|
|
250
|
+
tgMode = true;
|
|
251
|
+
} else if (args[i] === '--no-tg') {
|
|
252
|
+
tgMode = false;
|
|
253
|
+
} else if (args[i].includes('://')) {
|
|
254
|
+
serverBase = args[i].replace(/\/+$/, '');
|
|
255
|
+
} else if (/^\d+$/.test(args[i])) {
|
|
256
|
+
serverBase = 'http://localhost:' + args[i];
|
|
257
|
+
} else if (args[i].startsWith('--')) {
|
|
258
|
+
console.error(`Error: unknown option '${args[i]}'`);
|
|
259
|
+
console.error(`Usage: agentp [options] [url]`);
|
|
260
|
+
console.error(`Try 'agentp --help' for more information.`);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
} else {
|
|
263
|
+
console.error(`Error: unexpected argument '${args[i]}'`);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Resolve default: --qa → auto-detect, standalone → disabled
|
|
269
|
+
if (tgMode === null) {
|
|
270
|
+
tgMode = qaMode ? 'auto' : false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Pre-send: probe tgagentp gateway if enabled
|
|
274
|
+
let tgPort = null;
|
|
275
|
+
if (tgMode) {
|
|
276
|
+
try {
|
|
277
|
+
tgPort = fs.readFileSync(TGAGENTP_PORT_FILE, 'utf8').trim();
|
|
278
|
+
if (!tgPort || !/^\d+$/.test(tgPort)) tgPort = null;
|
|
279
|
+
} catch {
|
|
280
|
+
tgPort = null;
|
|
281
|
+
}
|
|
282
|
+
if (!tgPort && tgMode === true) {
|
|
283
|
+
console.error('Error: tgagentp gateway not found. Is tgagentp running?');
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
// auto mode: tgPort stays null → silently skip tgagentp
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const promptText = await readAllStdin();
|
|
290
|
+
|
|
291
|
+
// Find most recent session or create one
|
|
292
|
+
let sessions;
|
|
293
|
+
try {
|
|
294
|
+
sessions = await listSessions(serverBase);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
console.error('Error connecting to server:', err.message);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let sessionId = null;
|
|
301
|
+
|
|
302
|
+
if (sessionName) {
|
|
303
|
+
// Find session by name
|
|
304
|
+
const matches = sessions.filter(s => {
|
|
305
|
+
const title = s.title || s.slug || s.id;
|
|
306
|
+
return title.toLowerCase() === sessionName.toLowerCase();
|
|
307
|
+
});
|
|
308
|
+
if (matches.length === 0) {
|
|
309
|
+
// Try partial match
|
|
310
|
+
const partial = sessions.filter(s => {
|
|
311
|
+
const title = s.title || s.slug || s.id;
|
|
312
|
+
return title.toLowerCase().includes(sessionName.toLowerCase());
|
|
313
|
+
});
|
|
314
|
+
if (partial.length === 1) {
|
|
315
|
+
sessionId = partial[0].id;
|
|
316
|
+
} else if (partial.length > 1) {
|
|
317
|
+
console.error(`Error: multiple sessions match "${sessionName}":`);
|
|
318
|
+
partial.forEach(s => console.error(` - ${s.title || s.slug || s.id}`));
|
|
319
|
+
process.exit(1);
|
|
320
|
+
} else if (newSession) {
|
|
321
|
+
// Create new session
|
|
322
|
+
try {
|
|
323
|
+
const session = await createSession(serverBase, sessionName);
|
|
324
|
+
sessionId = session.id;
|
|
325
|
+
await selectSession(serverBase, sessionId);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
console.error('Error creating session:', err.message);
|
|
328
|
+
process.exit(1);
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
console.error(`Error: no session found matching "${sessionName}"`);
|
|
332
|
+
console.error('Use --new to create a new session with this name');
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
sessionId = matches[0].id;
|
|
337
|
+
}
|
|
338
|
+
} else if (sessions && sessions.length > 0) {
|
|
339
|
+
const sorted = sessions
|
|
340
|
+
.filter(s => s.time && (s.time.updated || s.time.created))
|
|
341
|
+
.sort((a, b) => {
|
|
342
|
+
const aTime = (a.time && a.time.updated) || (a.time && a.time.created) || 0;
|
|
343
|
+
const bTime = (b.time && b.time.updated) || (b.time && b.time.created) || 0;
|
|
344
|
+
return bTime - aTime;
|
|
345
|
+
});
|
|
346
|
+
if (sorted.length > 0) sessionId = sorted[0].id;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!sessionId) {
|
|
350
|
+
try {
|
|
351
|
+
const session = await createSession(serverBase, 'agentp');
|
|
352
|
+
sessionId = session.id;
|
|
353
|
+
} catch (err) {
|
|
354
|
+
console.error('Error creating session:', err.message);
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let answer;
|
|
360
|
+
try {
|
|
361
|
+
answer = await sendToSession(serverBase, sessionId, promptText);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
console.error('Error sending prompt:', err.message);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
140
366
|
const n = parseInt(nStr, 10);
|
|
141
367
|
try {
|
|
142
368
|
const sessions = await listSessions(serverBase);
|
package/bin/tgagentp
CHANGED
|
@@ -310,6 +310,8 @@ async function sendLongMessage(token, chatIdOrState, text, replyTo, threadId) {
|
|
|
310
310
|
// Returns empty string if the message isn't a reply or has no text.
|
|
311
311
|
// Uses a dynamic code-fence length (max consecutive backticks + 1)
|
|
312
312
|
// so nested backticks never break the structure.
|
|
313
|
+
const { ensureSharedDir, saveUploadedFile, formatFileSize } = require('../lib/file-share.js');
|
|
314
|
+
|
|
313
315
|
function quoteReply(msg) {
|
|
314
316
|
const rm = msg.reply_to_message;
|
|
315
317
|
if (!rm || !rm.text) return '';
|
|
@@ -326,6 +328,58 @@ function quoteReply(msg) {
|
|
|
326
328
|
return `In response to ${replyFrom} (${senderType}):\n\n${fence}\n${rm.text}\n${fence}\n\n`;
|
|
327
329
|
}
|
|
328
330
|
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// File upload helpers
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
// Download a file from Telegram's file storage.
|
|
336
|
+
// Returns a Buffer of the file contents.
|
|
337
|
+
async function downloadTelegramFile(token, fileId) {
|
|
338
|
+
const fileInfo = await telegramRequest(token, 'getFile', { file_id: fileId });
|
|
339
|
+
if (!fileInfo.ok || !fileInfo.result?.file_path) {
|
|
340
|
+
throw new Error('Failed to get file info from Telegram');
|
|
341
|
+
}
|
|
342
|
+
const filePath = fileInfo.result.file_path;
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
const req = https.get(`https://api.telegram.org/file/bot${token}/${filePath}`, (res) => {
|
|
345
|
+
const chunks = [];
|
|
346
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
347
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
348
|
+
});
|
|
349
|
+
req.on('error', reject);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Handle an uploaded file from Telegram.
|
|
354
|
+
// Downloads the file, saves it, and returns a notification message.
|
|
355
|
+
async function handleFileUpload(token, msg, projectDir) {
|
|
356
|
+
const doc = msg.document;
|
|
357
|
+
const photo = msg.photo;
|
|
358
|
+
if (!doc && !photo) return null;
|
|
359
|
+
|
|
360
|
+
let fileId;
|
|
361
|
+
let fileName;
|
|
362
|
+
let fileSize;
|
|
363
|
+
|
|
364
|
+
if (doc) {
|
|
365
|
+
fileId = doc.file_id;
|
|
366
|
+
fileName = doc.file_name || 'upload.bin';
|
|
367
|
+
fileSize = doc.file_size || 0;
|
|
368
|
+
} else if (photo) {
|
|
369
|
+
// Use the largest photo
|
|
370
|
+
const largest = photo[photo.length - 1];
|
|
371
|
+
fileId = largest.file_id;
|
|
372
|
+
fileName = 'photo.jpg';
|
|
373
|
+
fileSize = largest.file_size || 0;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const fileData = await downloadTelegramFile(token, fileId);
|
|
377
|
+
ensureSharedDir(projectDir);
|
|
378
|
+
const relativePath = saveUploadedFile(projectDir, fileName, fileData);
|
|
379
|
+
const sizeStr = fileSize > 0 ? ` (${formatFileSize(fileSize)})` : '';
|
|
380
|
+
return `📎 File uploaded: \`${relativePath}\`${sizeStr}`;
|
|
381
|
+
}
|
|
382
|
+
|
|
329
383
|
// ---------------------------------------------------------------------------
|
|
330
384
|
// Telegram command handlers
|
|
331
385
|
// ---------------------------------------------------------------------------
|
|
@@ -604,7 +658,17 @@ async function cmdServers(token, chatId, args, chatState, setServerForChat, setA
|
|
|
604
658
|
}
|
|
605
659
|
const activeUrl = chatState.serverBase ? normalizeUrl(chatState.serverBase) : null;
|
|
606
660
|
const lines = servers.map(s => {
|
|
607
|
-
const
|
|
661
|
+
const normalizedUrl = normalizeUrl(s.url);
|
|
662
|
+
const isActive = activeUrl && normalizedUrl === activeUrl;
|
|
663
|
+
const isOwned = Object.keys(serverOwners).some(url => normalizeUrl(url) === normalizedUrl);
|
|
664
|
+
let bullet;
|
|
665
|
+
if (isActive) {
|
|
666
|
+
bullet = '▶ ';
|
|
667
|
+
} else if (isOwned) {
|
|
668
|
+
bullet = ' · ';
|
|
669
|
+
} else {
|
|
670
|
+
bullet = '🔌 ';
|
|
671
|
+
}
|
|
608
672
|
return bullet + `${s.url} → ${s.dir} (${s.status})`;
|
|
609
673
|
});
|
|
610
674
|
const prefix = chatState.serverBase ? 'Active servers:' : 'Available servers:';
|
|
@@ -809,7 +873,7 @@ async function cmdSessions(token, chatId, args, chatState, setServerForChat, set
|
|
|
809
873
|
|
|
810
874
|
const name = sessionName(s);
|
|
811
875
|
const pos = idx + 1;
|
|
812
|
-
const bullet = s.id === activeId ? '▶ ' : '
|
|
876
|
+
const bullet = s.id === activeId ? '▶ ' : '🔌 ';
|
|
813
877
|
lines.push('`' + bullet + pos + ' ' + timeLabel + ' – ' + name.replace(/`/g, "'") + '`');
|
|
814
878
|
});
|
|
815
879
|
|
|
@@ -938,7 +1002,7 @@ function startAgentpGateway(token, serverBaseResolver) {
|
|
|
938
1002
|
return new Promise((resolve) => {
|
|
939
1003
|
const server = http.createServer((req, res) => {
|
|
940
1004
|
try {
|
|
941
|
-
if (req.method !== 'POST'
|
|
1005
|
+
if (req.method !== 'POST') {
|
|
942
1006
|
res.writeHead(405);
|
|
943
1007
|
res.end('Method Not Allowed');
|
|
944
1008
|
return;
|
|
@@ -948,59 +1012,14 @@ function startAgentpGateway(token, serverBaseResolver) {
|
|
|
948
1012
|
res.end('Unauthorized');
|
|
949
1013
|
return;
|
|
950
1014
|
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
req.
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
return;
|
|
960
|
-
}
|
|
961
|
-
const { text, server } = data;
|
|
962
|
-
if (typeof text !== 'string' || typeof server !== 'string') {
|
|
963
|
-
res.writeHead(400);
|
|
964
|
-
res.end('Bad Request: text and server are required');
|
|
965
|
-
return;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
// Find the chat that owns this server
|
|
969
|
-
const targetUrl = normalizeUrl(server);
|
|
970
|
-
const owningChatId = Object.entries(serverOwners).find(([url]) => normalizeUrl(url) === targetUrl)?.[1];
|
|
971
|
-
|
|
972
|
-
// Return recorded conversation buffer for the owning chat
|
|
973
|
-
const buffered = owningChatId ? flushRecorded(owningChatId.chatId, owningChatId.threadId) : [];
|
|
974
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
975
|
-
res.end(JSON.stringify({ buffered }));
|
|
976
|
-
|
|
977
|
-
if (!owningChatId) return;
|
|
978
|
-
const activeUrl = normalizeUrl(serverBaseResolver());
|
|
979
|
-
|
|
980
|
-
if (targetUrl === activeUrl) {
|
|
981
|
-
sendLongMessage(token, owningChatId, text.trim()).catch(() => {});
|
|
982
|
-
} else {
|
|
983
|
-
if (!agentpQueues[server]) {
|
|
984
|
-
agentpQueues[server] = { messages: [], notifyTimer: null, notified: false };
|
|
985
|
-
}
|
|
986
|
-
const q = agentpQueues[server];
|
|
987
|
-
q.messages.push(text);
|
|
988
|
-
if (!q.notifyTimer) {
|
|
989
|
-
q.notifyTimer = setTimeout(() => {
|
|
990
|
-
q.notifyTimer = null;
|
|
991
|
-
if (q.messages.length > 0 && !q.notified) {
|
|
992
|
-
q.notified = true;
|
|
993
|
-
sendLongMessage(token, owningChatId,
|
|
994
|
-
`📥 ${q.messages.length} agentp message(s) queued for server \`${server}\`. Switch servers to view them.`)
|
|
995
|
-
.catch(() => {});
|
|
996
|
-
}
|
|
997
|
-
}, TGAGENTP_DEBOUNCE_MS);
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
} catch (err) {
|
|
1001
|
-
log.error(`Agentp gateway handler error: ${err.message}`);
|
|
1002
|
-
}
|
|
1003
|
-
});
|
|
1015
|
+
if (req.url === '/send') {
|
|
1016
|
+
handleSendRequest(req, res, token, serverBaseResolver);
|
|
1017
|
+
} else if (req.url === '/send-file') {
|
|
1018
|
+
handleSendFileRequest(req, res, token, serverBaseResolver);
|
|
1019
|
+
} else {
|
|
1020
|
+
res.writeHead(404);
|
|
1021
|
+
res.end('Not Found');
|
|
1022
|
+
}
|
|
1004
1023
|
} catch (err) {
|
|
1005
1024
|
log.error(`Agentp gateway handler error: ${err.message}`);
|
|
1006
1025
|
}
|
|
@@ -1017,6 +1036,193 @@ function startAgentpGateway(token, serverBaseResolver) {
|
|
|
1017
1036
|
});
|
|
1018
1037
|
}
|
|
1019
1038
|
|
|
1039
|
+
function handleSendRequest(req, res, token, serverBaseResolver) {
|
|
1040
|
+
let body = '';
|
|
1041
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
1042
|
+
req.on('end', () => {
|
|
1043
|
+
try {
|
|
1044
|
+
let data;
|
|
1045
|
+
try { data = JSON.parse(body); } catch {
|
|
1046
|
+
res.writeHead(400);
|
|
1047
|
+
res.end('Bad Request');
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const { text, server } = data;
|
|
1051
|
+
if (typeof text !== 'string' || typeof server !== 'string') {
|
|
1052
|
+
res.writeHead(400);
|
|
1053
|
+
res.end('Bad Request: text and server are required');
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Find the chat that owns this server
|
|
1058
|
+
const targetUrl = normalizeUrl(server);
|
|
1059
|
+
const owningChatId = Object.entries(serverOwners).find(([url]) => normalizeUrl(url) === targetUrl)?.[1];
|
|
1060
|
+
|
|
1061
|
+
// Return recorded conversation buffer for the owning chat
|
|
1062
|
+
const buffered = owningChatId ? flushRecorded(owningChatId.chatId, owningChatId.threadId) : [];
|
|
1063
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1064
|
+
res.end(JSON.stringify({ buffered }));
|
|
1065
|
+
|
|
1066
|
+
if (!owningChatId) return;
|
|
1067
|
+
const activeUrl = normalizeUrl(serverBaseResolver());
|
|
1068
|
+
|
|
1069
|
+
if (targetUrl === activeUrl) {
|
|
1070
|
+
sendLongMessage(token, owningChatId, text.trim()).catch(() => {});
|
|
1071
|
+
} else {
|
|
1072
|
+
if (!agentpQueues[server]) {
|
|
1073
|
+
agentpQueues[server] = { messages: [], notifyTimer: null, notified: false };
|
|
1074
|
+
}
|
|
1075
|
+
const q = agentpQueues[server];
|
|
1076
|
+
q.messages.push(text);
|
|
1077
|
+
if (!q.notifyTimer) {
|
|
1078
|
+
q.notifyTimer = setTimeout(() => {
|
|
1079
|
+
q.notifyTimer = null;
|
|
1080
|
+
if (q.messages.length > 0 && !q.notified) {
|
|
1081
|
+
q.notified = true;
|
|
1082
|
+
sendLongMessage(token, owningChatId,
|
|
1083
|
+
`📥 ${q.messages.length} agentp message(s) queued for server \`${server}\`. Switch servers to view them.`)
|
|
1084
|
+
.catch(() => {});
|
|
1085
|
+
}
|
|
1086
|
+
}, TGAGENTP_DEBOUNCE_MS);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
log.error(`Agentp gateway handler error: ${err.message}`);
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function handleSendFileRequest(req, res, token, serverBaseResolver) {
|
|
1096
|
+
let body = '';
|
|
1097
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
1098
|
+
req.on('end', () => {
|
|
1099
|
+
try {
|
|
1100
|
+
let data;
|
|
1101
|
+
try { data = JSON.parse(body); } catch {
|
|
1102
|
+
res.writeHead(400);
|
|
1103
|
+
res.end('Bad Request');
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
const { filePath, server, message } = data;
|
|
1107
|
+
if (typeof filePath !== 'string' || typeof server !== 'string') {
|
|
1108
|
+
res.writeHead(400);
|
|
1109
|
+
res.end('Bad Request: filePath and server are required');
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Find the chat that owns this server
|
|
1114
|
+
const targetUrl = normalizeUrl(server);
|
|
1115
|
+
const owningChatId = Object.entries(serverOwners).find(([url]) => normalizeUrl(url) === targetUrl)?.[1];
|
|
1116
|
+
|
|
1117
|
+
if (!owningChatId) {
|
|
1118
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1119
|
+
res.end(JSON.stringify({ sent: false, reason: 'no owner' }));
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Resolve the file path relative to the server directory
|
|
1124
|
+
const dir = serverDir(server);
|
|
1125
|
+
if (!dir) {
|
|
1126
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1127
|
+
res.end(JSON.stringify({ sent: false, reason: 'no server dir' }));
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const fullPath = path.resolve(dir, filePath);
|
|
1131
|
+
if (!fs.existsSync(fullPath)) {
|
|
1132
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1133
|
+
res.end(JSON.stringify({ sent: false, reason: 'file not found' }));
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const fileData = fs.readFileSync(fullPath);
|
|
1138
|
+
const fileName = path.basename(fullPath);
|
|
1139
|
+
|
|
1140
|
+
// Send file to Telegram
|
|
1141
|
+
sendTelegramDocument(token, owningChatId, fileData, fileName, message).then(() => {
|
|
1142
|
+
// Clean up: only delete files from telegram-shared/downloads/
|
|
1143
|
+
// to prevent accidental deletion of project files
|
|
1144
|
+
const downloadsDir = path.join(dir, 'telegram-shared', 'downloads');
|
|
1145
|
+
if (fullPath.startsWith(downloadsDir + path.sep)) {
|
|
1146
|
+
try { fs.unlinkSync(fullPath); } catch (_) {}
|
|
1147
|
+
}
|
|
1148
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1149
|
+
res.end(JSON.stringify({ sent: true }));
|
|
1150
|
+
}).catch((err) => {
|
|
1151
|
+
log.error(`Failed to send file: ${err.message}`);
|
|
1152
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1153
|
+
res.end(JSON.stringify({ sent: false, reason: err.message }));
|
|
1154
|
+
});
|
|
1155
|
+
} catch (err) {
|
|
1156
|
+
log.error(`Agentp gateway handler error: ${err.message}`);
|
|
1157
|
+
res.writeHead(500);
|
|
1158
|
+
res.end('Internal Server Error');
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Send a document to Telegram using multipart/form-data.
|
|
1164
|
+
async function sendTelegramDocument(token, chatIdOrState, fileData, fileName, caption) {
|
|
1165
|
+
const chatId = typeof chatIdOrState === 'object' && chatIdOrState.chatId != null
|
|
1166
|
+
? chatIdOrState.chatId : chatIdOrState;
|
|
1167
|
+
const threadId = typeof chatIdOrState === 'object' ? chatIdOrState.threadId : undefined;
|
|
1168
|
+
|
|
1169
|
+
const boundary = '----tgagentpBoundary' + Math.random().toString(36).slice(2);
|
|
1170
|
+
const parts = [];
|
|
1171
|
+
|
|
1172
|
+
// chat_id field
|
|
1173
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`));
|
|
1174
|
+
|
|
1175
|
+
// message_thread_id field (if present)
|
|
1176
|
+
if (threadId) {
|
|
1177
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="message_thread_id"\r\n\r\n${threadId}\r\n`));
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// document field (the file)
|
|
1181
|
+
const mimeType = 'application/octet-stream';
|
|
1182
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="document"; filename="${fileName}"\r\nContent-Type: ${mimeType}\r\n\r\n`));
|
|
1183
|
+
parts.push(fileData);
|
|
1184
|
+
parts.push(Buffer.from('\r\n'));
|
|
1185
|
+
|
|
1186
|
+
// caption field (optional)
|
|
1187
|
+
if (caption) {
|
|
1188
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\n${caption}\r\n`));
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// closing boundary
|
|
1192
|
+
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
|
1193
|
+
|
|
1194
|
+
const body = Buffer.concat(parts);
|
|
1195
|
+
|
|
1196
|
+
return new Promise((resolve, reject) => {
|
|
1197
|
+
const options = {
|
|
1198
|
+
hostname: 'api.telegram.org',
|
|
1199
|
+
port: 443,
|
|
1200
|
+
path: `/bot${token}/sendDocument`,
|
|
1201
|
+
method: 'POST',
|
|
1202
|
+
headers: {
|
|
1203
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
1204
|
+
'Content-Length': body.length,
|
|
1205
|
+
},
|
|
1206
|
+
};
|
|
1207
|
+
const req = https.request(options, (res) => {
|
|
1208
|
+
let data = '';
|
|
1209
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
1210
|
+
res.on('end', () => {
|
|
1211
|
+
try {
|
|
1212
|
+
const result = JSON.parse(data);
|
|
1213
|
+
if (result.ok) resolve(result);
|
|
1214
|
+
else reject(new Error(result.description || 'Telegram API error'));
|
|
1215
|
+
} catch (e) {
|
|
1216
|
+
reject(new Error(`Failed to parse Telegram response: ${data}`));
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
});
|
|
1220
|
+
req.on('error', reject);
|
|
1221
|
+
req.write(body);
|
|
1222
|
+
req.end();
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1020
1226
|
async function cmdAllow(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
1021
1227
|
const st = getState(chatState.serverBase);
|
|
1022
1228
|
if (!st.pendingPermission) {
|
|
@@ -2001,12 +2207,9 @@ async function main() {
|
|
|
2001
2207
|
continue;
|
|
2002
2208
|
}
|
|
2003
2209
|
|
|
2004
|
-
if (typeof msg.text !== 'string') continue;
|
|
2005
|
-
|
|
2006
2210
|
const chatId = String(msg.chat.id);
|
|
2007
2211
|
const threadId = msg.message_thread_id || null;
|
|
2008
2212
|
const replyTo = msg.message_id;
|
|
2009
|
-
const text = msg.text;
|
|
2010
2213
|
const from = msg.from?.username
|
|
2011
2214
|
? `@${msg.from.username}`
|
|
2012
2215
|
: [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(' ') || 'unknown';
|
|
@@ -2027,6 +2230,88 @@ async function main() {
|
|
|
2027
2230
|
// Per-chat state: get or create for this chat (thread-aware)
|
|
2028
2231
|
const cs = getChatState(chatId, threadId);
|
|
2029
2232
|
|
|
2233
|
+
// Handle file uploads (documents and photos)
|
|
2234
|
+
if (msg.document || msg.photo) {
|
|
2235
|
+
if (!cs.serverBase) {
|
|
2236
|
+
await sendLongMessage(token, cs, '🔌 Not connected to any server. Upload ignored.');
|
|
2237
|
+
continue;
|
|
2238
|
+
}
|
|
2239
|
+
const projectDir = serverDir(cs.serverBase);
|
|
2240
|
+
if (!projectDir) {
|
|
2241
|
+
await sendLongMessage(token, cs, '❌ Could not find project directory for this server.');
|
|
2242
|
+
continue;
|
|
2243
|
+
}
|
|
2244
|
+
try {
|
|
2245
|
+
const notification = await handleFileUpload(token, msg, projectDir);
|
|
2246
|
+
// Verify the file is actually on disk before notifying
|
|
2247
|
+
const relativePath = notification.match(/`([^`]+)`/)?.[1];
|
|
2248
|
+
if (relativePath) {
|
|
2249
|
+
const fullPath = path.join(projectDir, relativePath);
|
|
2250
|
+
let retries = 5;
|
|
2251
|
+
while (!fs.existsSync(fullPath) && retries > 0) {
|
|
2252
|
+
await new Promise(r => setTimeout(r, 100));
|
|
2253
|
+
retries--;
|
|
2254
|
+
}
|
|
2255
|
+
if (!fs.existsSync(fullPath)) {
|
|
2256
|
+
log.error(`File not found after upload: ${fullPath}`);
|
|
2257
|
+
await sendLongMessage(token, cs, `❌ File upload failed: file not saved to disk.`);
|
|
2258
|
+
continue;
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
const userText = msg.caption || msg.text || '';
|
|
2262
|
+
const fullMessage = notification + (userText ? '\n\nUser message: ' + userText : '');
|
|
2263
|
+
log.debug(`[chat ${chatId}] ${from}: [file upload]`);
|
|
2264
|
+
msgLog('in', chatId, from, '[file upload]');
|
|
2265
|
+
|
|
2266
|
+
// Buffer the upload notification for /record
|
|
2267
|
+
bufferMessage(chatId, threadId, 'user', fullMessage);
|
|
2268
|
+
|
|
2269
|
+
const st = getState(cs.serverBase);
|
|
2270
|
+
if (st.serverDead) {
|
|
2271
|
+
const alive = await isServerAlive(cs.serverBase);
|
|
2272
|
+
if (alive) st.serverDead = false;
|
|
2273
|
+
}
|
|
2274
|
+
if (st.serverDead) {
|
|
2275
|
+
if (!st.messageQueue) st.messageQueue = [];
|
|
2276
|
+
st.messageQueue.push({ text: fullMessage, replyTo });
|
|
2277
|
+
const qlen = st.messageQueue.length;
|
|
2278
|
+
await sendLongMessage(token, cs,
|
|
2279
|
+
`⚠️ Server \`${cs.serverBase}\` is not reachable.\n\n` +
|
|
2280
|
+
`Your file upload has been queued (#${qlen}). It will be sent when the server becomes available.\n\n` +
|
|
2281
|
+
`**Options:**\n` +
|
|
2282
|
+
`• Start the server: \`ocmux serve\`\n` +
|
|
2283
|
+
`• Switch to another server: \`/servers switch <name>\`\n` +
|
|
2284
|
+
`• Flush the queue: \`/flush\``);
|
|
2285
|
+
continue;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
if (st.busy) {
|
|
2289
|
+
if (!st.messageQueue) st.messageQueue = [];
|
|
2290
|
+
st.messageQueue.push({ text: fullMessage, replyTo });
|
|
2291
|
+
await sendLongMessage(token, cs, `⏳ Server is busy. File upload queued (#${st.messageQueue.length}).`);
|
|
2292
|
+
continue;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// Auto-switch tmux window
|
|
2296
|
+
const srvList = listServers();
|
|
2297
|
+
const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(cs.serverBase));
|
|
2298
|
+
if (srvMatch) activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
|
|
2299
|
+
|
|
2300
|
+
st.busy = true;
|
|
2301
|
+
const myTaskId = ++st.taskId;
|
|
2302
|
+
log.debug(` → processing file upload notification (task ${myTaskId})`);
|
|
2303
|
+
processMessageAsync(chatId, fullMessage, cs.serverBase, !!st.activeSessionId, st.activeSessionId, myTaskId, replyTo, threadId);
|
|
2304
|
+
} catch (err) {
|
|
2305
|
+
log.error(`File upload error: ${err.message}`);
|
|
2306
|
+
await sendLongMessage(token, cs, `❌ File upload failed: ${err.message}`);
|
|
2307
|
+
}
|
|
2308
|
+
continue;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
if (typeof msg.text !== 'string') continue;
|
|
2312
|
+
|
|
2313
|
+
const text = msg.text;
|
|
2314
|
+
|
|
2030
2315
|
log.debug(`[chat ${chatId}] ${from}: ${text}`);
|
|
2031
2316
|
msgLog('in', chatId, from, text);
|
|
2032
2317
|
|
package/docs/specification.md
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// Ensure telegram-shared/{uploads,downloads}/ directories exist
|
|
7
|
+
// and telegram-shared/ is in .gitignore.
|
|
8
|
+
function ensureSharedDir(projectDir) {
|
|
9
|
+
const sharedDir = path.join(projectDir, 'telegram-shared');
|
|
10
|
+
const uploadsDir = path.join(sharedDir, 'uploads');
|
|
11
|
+
const downloadsDir = path.join(sharedDir, 'downloads');
|
|
12
|
+
if (!fs.existsSync(uploadsDir)) {
|
|
13
|
+
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
if (!fs.existsSync(downloadsDir)) {
|
|
16
|
+
fs.mkdirSync(downloadsDir, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
// Ensure .gitignore has telegram-shared/
|
|
19
|
+
const gitignorePath = path.join(projectDir, '.gitignore');
|
|
20
|
+
let gitignoreContent = '';
|
|
21
|
+
try {
|
|
22
|
+
gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
|
|
23
|
+
} catch (_) {
|
|
24
|
+
// .gitignore doesn't exist, will create it
|
|
25
|
+
}
|
|
26
|
+
if (!gitignoreContent.split('\n').some(line => line.trim() === 'telegram-shared/' || line.trim() === 'telegram-shared')) {
|
|
27
|
+
const append = gitignoreContent.endsWith('\n') || gitignoreContent === '' ? '' : '\n';
|
|
28
|
+
fs.writeFileSync(gitignorePath, gitignoreContent + append + 'telegram-shared/\n');
|
|
29
|
+
}
|
|
30
|
+
return sharedDir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Save an uploaded file to the project's telegram-shared/uploads/ directory.
|
|
34
|
+
// Returns the relative path (e.g., 'telegram-shared/uploads/20250610_143022_notes.md').
|
|
35
|
+
function saveUploadedFile(projectDir, fileName, fileData) {
|
|
36
|
+
const uploadsDir = path.join(projectDir, 'telegram-shared', 'uploads');
|
|
37
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
38
|
+
const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
39
|
+
const uniqueName = `${timestamp}_${safeName}`;
|
|
40
|
+
const filePath = path.join(uploadsDir, uniqueName);
|
|
41
|
+
fs.writeFileSync(filePath, fileData);
|
|
42
|
+
// Ensure data is flushed to disk so the file is immediately visible
|
|
43
|
+
const fd = fs.openSync(filePath, 'r');
|
|
44
|
+
fs.fsyncSync(fd);
|
|
45
|
+
fs.closeSync(fd);
|
|
46
|
+
return path.join('telegram-shared', 'uploads', uniqueName);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatFileSize(bytes) {
|
|
50
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
51
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
52
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
ensureSharedDir,
|
|
57
|
+
saveUploadedFile,
|
|
58
|
+
formatFileSize,
|
|
59
|
+
};
|