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 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 bullet = activeUrl && normalizeUrl(s.url) === activeUrl ? '▶ ' : ' · ';
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' || req.url !== '/send') {
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
- let body = '';
952
- req.on('data', (chunk) => { body += chunk; });
953
- req.on('end', () => {
954
- try {
955
- let data;
956
- try { data = JSON.parse(body); } catch {
957
- res.writeHead(400);
958
- res.end('Bad Request');
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
 
@@ -16,7 +16,7 @@ All tools share `lib/opencode.js` (HTTP session API client) and `lib/ocmux.js` (
16
16
 
17
17
  ### `package.json`
18
18
 
19
- **Version:** 0.11.2
19
+ **Version:** 0.11.4
20
20
 
21
21
  Fields:
22
22
  - `"bin"` — registers `agentp`, `ocmux`, `tgagentp`
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Pipe prompts into a running OpenCode TUI session and stream the final answer",
5
5
  "bin": {
6
6
  "agentp": "bin/agentp",