opc-agent 4.0.8 → 4.0.10

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.
@@ -1,3 +1,4 @@
1
+ import type { Message } from '../core/types';
1
2
  import { BaseChannel } from './index';
2
3
  /**
3
4
  * Telegram channel — supports both long-polling and webhook modes.
@@ -35,6 +36,10 @@ export declare class TelegramChannel extends BaseChannel {
35
36
  private startWebhook;
36
37
  private stopWebhook;
37
38
  private processUpdate;
39
+ private streamResponse;
40
+ private streamHandler?;
41
+ setStreamHandler(handler: (msg: Message) => AsyncIterable<string>): void;
42
+ sendMarkdown(chatId: number | string, text: string, replyTo?: number): Promise<any>;
38
43
  sendMessage(chatId: number | string, text: string): Promise<void>;
39
44
  private apiCall;
40
45
  private splitText;
@@ -152,12 +152,21 @@ class TelegramChannel extends index_1.BaseChannel {
152
152
  // ─── Shared ──────────────────────────────────────────────
153
153
  async processUpdate(update) {
154
154
  const message = update.message || update.edited_message;
155
- if (!message?.text || !this.handler)
155
+ if (!message || !this.handler)
156
+ return;
157
+ // Handle /start command
158
+ if (message.text === '/start') {
159
+ await this.sendMarkdown(message.chat.id, '👋 Hello! I\'m ready to help. Send me a message to get started.');
160
+ return;
161
+ }
162
+ // Handle text, photo captions, document captions
163
+ const text = message.text || message.caption;
164
+ if (!text)
156
165
  return;
157
166
  const msg = {
158
167
  id: `tg_${message.message_id}`,
159
168
  role: 'user',
160
- content: message.text,
169
+ content: text,
161
170
  timestamp: message.date * 1000,
162
171
  metadata: {
163
172
  sessionId: `tg_${message.chat.id}`,
@@ -167,31 +176,132 @@ class TelegramChannel extends index_1.BaseChannel {
167
176
  firstName: message.from?.first_name,
168
177
  platform: 'telegram',
169
178
  chatType: message.chat.type,
179
+ replyToMessageId: message.message_id,
170
180
  },
171
181
  };
172
- // Show typing indicator while processing
182
+ // Show typing indicator
173
183
  await this.apiCall('sendChatAction', { chat_id: message.chat.id, action: 'typing' });
174
184
  const typingInterval = setInterval(() => {
175
185
  this.apiCall('sendChatAction', { chat_id: message.chat.id, action: 'typing' }).catch(() => { });
176
186
  }, 4000);
177
187
  try {
178
- const response = await this.handler(msg);
179
- await this.sendMessage(message.chat.id, response.content);
188
+ // Try streaming if provider supports it
189
+ if (this.streamHandler) {
190
+ await this.streamResponse(message.chat.id, msg, message.message_id);
191
+ }
192
+ else {
193
+ const response = await this.handler(msg);
194
+ await this.sendMarkdown(message.chat.id, response.content, message.message_id);
195
+ }
196
+ }
197
+ catch (err) {
198
+ console.error('[TelegramChannel] Error processing message:', err);
199
+ await this.sendMarkdown(message.chat.id, '⚠️ Sorry, something went wrong. Please try again.');
180
200
  }
181
201
  finally {
182
202
  clearInterval(typingInterval);
183
203
  }
184
204
  }
185
- async sendMessage(chatId, text) {
186
- // Telegram max message length is 4096
205
+ async streamResponse(chatId, msg, replyTo) {
206
+ if (!this.streamHandler)
207
+ return;
208
+ let sentMessageId = null;
209
+ let fullText = '';
210
+ let lastEditTime = 0;
211
+ const EDIT_INTERVAL = 1000; // Edit at most once per second
212
+ let pendingEdit = false;
213
+ const doEdit = async () => {
214
+ if (!sentMessageId || !fullText)
215
+ return;
216
+ pendingEdit = false;
217
+ try {
218
+ await this.apiCall('editMessageText', {
219
+ chat_id: chatId,
220
+ message_id: sentMessageId,
221
+ text: fullText,
222
+ parse_mode: 'Markdown',
223
+ });
224
+ lastEditTime = Date.now();
225
+ }
226
+ catch (err) {
227
+ // If Markdown fails, try plain text
228
+ if (err?.message?.includes('parse') || err?.message?.includes('entities')) {
229
+ try {
230
+ await this.apiCall('editMessageText', {
231
+ chat_id: chatId,
232
+ message_id: sentMessageId,
233
+ text: fullText,
234
+ });
235
+ }
236
+ catch { }
237
+ }
238
+ }
239
+ };
240
+ try {
241
+ for await (const chunk of this.streamHandler(msg)) {
242
+ fullText += chunk;
243
+ if (!sentMessageId) {
244
+ // Send first message
245
+ const result = await this.sendMarkdown(chatId, fullText, replyTo);
246
+ sentMessageId = result?.message_id;
247
+ lastEditTime = Date.now();
248
+ }
249
+ else {
250
+ const now = Date.now();
251
+ if (now - lastEditTime >= EDIT_INTERVAL) {
252
+ await doEdit();
253
+ }
254
+ else if (!pendingEdit) {
255
+ pendingEdit = true;
256
+ }
257
+ }
258
+ }
259
+ // Final edit with complete text
260
+ if (sentMessageId && fullText) {
261
+ await doEdit();
262
+ }
263
+ }
264
+ catch (err) {
265
+ // If streaming fails, send what we have
266
+ if (!sentMessageId && fullText) {
267
+ await this.sendMarkdown(chatId, fullText, replyTo);
268
+ }
269
+ throw err;
270
+ }
271
+ }
272
+ // Stream handler — set by runtime when provider supports streaming
273
+ streamHandler;
274
+ setStreamHandler(handler) {
275
+ this.streamHandler = handler;
276
+ }
277
+ async sendMarkdown(chatId, text, replyTo) {
187
278
  const chunks = this.splitText(text, 4096);
279
+ let lastResult = null;
188
280
  for (const chunk of chunks) {
189
- await this.apiCall('sendMessage', {
190
- chat_id: chatId,
191
- text: chunk,
192
- parse_mode: 'Markdown',
193
- });
281
+ try {
282
+ lastResult = await this.apiCall('sendMessage', {
283
+ chat_id: chatId,
284
+ text: chunk,
285
+ parse_mode: 'Markdown',
286
+ ...(replyTo ? { reply_to_message_id: replyTo } : {}),
287
+ });
288
+ // Only reply to first chunk
289
+ replyTo = undefined;
290
+ }
291
+ catch (err) {
292
+ // Markdown parse failed — send as plain text
293
+ lastResult = await this.apiCall('sendMessage', {
294
+ chat_id: chatId,
295
+ text: chunk,
296
+ ...(replyTo ? { reply_to_message_id: replyTo } : {}),
297
+ });
298
+ replyTo = undefined;
299
+ }
194
300
  }
301
+ return lastResult?.result;
302
+ }
303
+ async sendMessage(chatId, text) {
304
+ await this.sendMarkdown(chatId, text);
195
305
  }
196
306
  async apiCall(method, body) {
197
307
  const url = `https://api.telegram.org/bot${this.token}/${method}`;
@@ -165,10 +165,21 @@ class AgentRuntime {
165
165
  this.logger.info('Bound web channel', { port });
166
166
  }
167
167
  else if (ch.type === 'telegram') {
168
- this.agent.bindChannel(new telegram_1.TelegramChannel({
168
+ const tgChannel = new telegram_1.TelegramChannel({
169
169
  token: ch.config?.token,
170
170
  port: ch.port,
171
- }));
171
+ });
172
+ // Wire up streaming for real-time message editing
173
+ const agentRef = this.agent;
174
+ tgChannel.setStreamHandler(async function* (msg) {
175
+ const history = [msg];
176
+ const provider = agentRef.provider;
177
+ const systemPrompt = agentRef.getSystemPrompt();
178
+ for await (const chunk of provider.chatStream(history, systemPrompt)) {
179
+ yield chunk;
180
+ }
181
+ });
182
+ this.agent.bindChannel(tgChannel);
172
183
  this.logger.info('Bound telegram channel');
173
184
  }
174
185
  else if (ch.type === 'websocket') {
@@ -406,9 +406,65 @@ class ClaudeCLIProvider {
406
406
  }
407
407
  }
408
408
  async *chatStream(messages, systemPrompt) {
409
- // Claude CLI --print doesn't support streaming well, so we do single-shot
410
- const result = await this.chat(messages, systemPrompt);
411
- yield result;
409
+ // Build prompt same as chat()
410
+ const parts = [];
411
+ if (systemPrompt) {
412
+ parts.push(`[System]: ${systemPrompt}`);
413
+ }
414
+ for (const m of messages) {
415
+ const role = m.role === 'user' ? 'Human' : 'Assistant';
416
+ parts.push(`${role}: ${m.content}`);
417
+ }
418
+ const prompt = parts.join('\n\n');
419
+ const args = ['-p', '--output-format', 'text'];
420
+ if (this.model) {
421
+ args.push('--model', this.model);
422
+ }
423
+ // Write system prompt to temp file if needed
424
+ let tmpFile;
425
+ if (systemPrompt) {
426
+ const { writeFileSync } = await Promise.resolve().then(() => __importStar(require('fs')));
427
+ const { join } = await Promise.resolve().then(() => __importStar(require('path')));
428
+ const { tmpdir } = await Promise.resolve().then(() => __importStar(require('os')));
429
+ tmpFile = join(tmpdir(), `opc-claude-stream-${Date.now()}.txt`);
430
+ writeFileSync(tmpFile, systemPrompt);
431
+ args.push('--system-prompt-file', tmpFile);
432
+ }
433
+ const lastMsg = messages[messages.length - 1];
434
+ args.push(lastMsg?.content ?? prompt);
435
+ const { spawn } = await Promise.resolve().then(() => __importStar(require('child_process')));
436
+ try {
437
+ const proc = spawn('claude', args, {
438
+ env: { ...process.env },
439
+ stdio: ['pipe', 'pipe', 'pipe'],
440
+ });
441
+ proc.stdin.end();
442
+ // Yield chunks as they arrive from stdout
443
+ const readable = proc.stdout;
444
+ for await (const chunk of readable) {
445
+ yield chunk.toString();
446
+ }
447
+ // Wait for process to finish
448
+ await new Promise((resolve, reject) => {
449
+ proc.on('close', (code) => {
450
+ if (code !== 0 && code !== null) {
451
+ // Already yielded content, just log
452
+ console.warn(`[ClaudeCLI] Process exited with code ${code}`);
453
+ }
454
+ resolve();
455
+ });
456
+ proc.on('error', reject);
457
+ });
458
+ }
459
+ finally {
460
+ if (tmpFile) {
461
+ try {
462
+ const { unlinkSync } = await Promise.resolve().then(() => __importStar(require('fs')));
463
+ unlinkSync(tmpFile);
464
+ }
465
+ catch { }
466
+ }
467
+ }
412
468
  }
413
469
  }
414
470
  function createProvider(name = 'openai', model, baseUrl, apiKey) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opc-agent",
3
- "version": "4.0.8",
3
+ "version": "4.0.10",
4
4
  "description": "Open Agent Framework — Build, test, and run AI Agents for business workstations",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,4 +1,5 @@
1
1
  import type { Message } from '../core/types';
2
+ import type { LLMProvider } from '../providers/index';
2
3
  import { BaseChannel } from './index';
3
4
 
4
5
  /**
@@ -152,12 +153,22 @@ export class TelegramChannel extends BaseChannel {
152
153
 
153
154
  private async processUpdate(update: any): Promise<void> {
154
155
  const message = update.message || update.edited_message;
155
- if (!message?.text || !this.handler) return;
156
+ if (!message || !this.handler) return;
157
+
158
+ // Handle /start command
159
+ if (message.text === '/start') {
160
+ await this.sendMarkdown(message.chat.id, '👋 Hello! I\'m ready to help. Send me a message to get started.');
161
+ return;
162
+ }
163
+
164
+ // Handle text, photo captions, document captions
165
+ const text = message.text || message.caption;
166
+ if (!text) return;
156
167
 
157
168
  const msg: Message = {
158
169
  id: `tg_${message.message_id}`,
159
170
  role: 'user',
160
- content: message.text,
171
+ content: text,
161
172
  timestamp: message.date * 1000,
162
173
  metadata: {
163
174
  sessionId: `tg_${message.chat.id}`,
@@ -167,33 +178,133 @@ export class TelegramChannel extends BaseChannel {
167
178
  firstName: message.from?.first_name,
168
179
  platform: 'telegram',
169
180
  chatType: message.chat.type,
181
+ replyToMessageId: message.message_id,
170
182
  },
171
183
  };
172
184
 
173
- // Show typing indicator while processing
185
+ // Show typing indicator
174
186
  await this.apiCall('sendChatAction', { chat_id: message.chat.id, action: 'typing' });
175
187
  const typingInterval = setInterval(() => {
176
188
  this.apiCall('sendChatAction', { chat_id: message.chat.id, action: 'typing' }).catch(() => {});
177
189
  }, 4000);
178
190
 
179
191
  try {
180
- const response = await this.handler(msg);
181
- await this.sendMessage(message.chat.id, response.content);
192
+ // Try streaming if provider supports it
193
+ if (this.streamHandler) {
194
+ await this.streamResponse(message.chat.id, msg, message.message_id);
195
+ } else {
196
+ const response = await this.handler(msg);
197
+ await this.sendMarkdown(message.chat.id, response.content, message.message_id);
198
+ }
199
+ } catch (err) {
200
+ console.error('[TelegramChannel] Error processing message:', err);
201
+ await this.sendMarkdown(message.chat.id, '⚠️ Sorry, something went wrong. Please try again.');
182
202
  } finally {
183
203
  clearInterval(typingInterval);
184
204
  }
185
205
  }
186
206
 
187
- async sendMessage(chatId: number | string, text: string): Promise<void> {
188
- // Telegram max message length is 4096
207
+ private async streamResponse(chatId: number | string, msg: Message, replyTo?: number): Promise<void> {
208
+ if (!this.streamHandler) return;
209
+
210
+ let sentMessageId: number | null = null;
211
+ let fullText = '';
212
+ let lastEditTime = 0;
213
+ const EDIT_INTERVAL = 1000; // Edit at most once per second
214
+ let pendingEdit = false;
215
+
216
+ const doEdit = async () => {
217
+ if (!sentMessageId || !fullText) return;
218
+ pendingEdit = false;
219
+ try {
220
+ await this.apiCall('editMessageText', {
221
+ chat_id: chatId,
222
+ message_id: sentMessageId,
223
+ text: fullText,
224
+ parse_mode: 'Markdown',
225
+ });
226
+ lastEditTime = Date.now();
227
+ } catch (err: any) {
228
+ // If Markdown fails, try plain text
229
+ if (err?.message?.includes('parse') || err?.message?.includes('entities')) {
230
+ try {
231
+ await this.apiCall('editMessageText', {
232
+ chat_id: chatId,
233
+ message_id: sentMessageId,
234
+ text: fullText,
235
+ });
236
+ } catch {}
237
+ }
238
+ }
239
+ };
240
+
241
+ try {
242
+ for await (const chunk of this.streamHandler(msg)) {
243
+ fullText += chunk;
244
+
245
+ if (!sentMessageId) {
246
+ // Send first message
247
+ const result = await this.sendMarkdown(chatId, fullText, replyTo);
248
+ sentMessageId = result?.message_id;
249
+ lastEditTime = Date.now();
250
+ } else {
251
+ const now = Date.now();
252
+ if (now - lastEditTime >= EDIT_INTERVAL) {
253
+ await doEdit();
254
+ } else if (!pendingEdit) {
255
+ pendingEdit = true;
256
+ }
257
+ }
258
+ }
259
+
260
+ // Final edit with complete text
261
+ if (sentMessageId && fullText) {
262
+ await doEdit();
263
+ }
264
+ } catch (err) {
265
+ // If streaming fails, send what we have
266
+ if (!sentMessageId && fullText) {
267
+ await this.sendMarkdown(chatId, fullText, replyTo);
268
+ }
269
+ throw err;
270
+ }
271
+ }
272
+
273
+ // Stream handler — set by runtime when provider supports streaming
274
+ private streamHandler?: (msg: Message) => AsyncIterable<string>;
275
+
276
+ setStreamHandler(handler: (msg: Message) => AsyncIterable<string>): void {
277
+ this.streamHandler = handler;
278
+ }
279
+
280
+ async sendMarkdown(chatId: number | string, text: string, replyTo?: number): Promise<any> {
189
281
  const chunks = this.splitText(text, 4096);
282
+ let lastResult: any = null;
190
283
  for (const chunk of chunks) {
191
- await this.apiCall('sendMessage', {
192
- chat_id: chatId,
193
- text: chunk,
194
- parse_mode: 'Markdown',
195
- });
284
+ try {
285
+ lastResult = await this.apiCall('sendMessage', {
286
+ chat_id: chatId,
287
+ text: chunk,
288
+ parse_mode: 'Markdown',
289
+ ...(replyTo ? { reply_to_message_id: replyTo } : {}),
290
+ });
291
+ // Only reply to first chunk
292
+ replyTo = undefined;
293
+ } catch (err: any) {
294
+ // Markdown parse failed — send as plain text
295
+ lastResult = await this.apiCall('sendMessage', {
296
+ chat_id: chatId,
297
+ text: chunk,
298
+ ...(replyTo ? { reply_to_message_id: replyTo } : {}),
299
+ });
300
+ replyTo = undefined;
301
+ }
196
302
  }
303
+ return lastResult?.result;
304
+ }
305
+
306
+ async sendMessage(chatId: number | string, text: string): Promise<void> {
307
+ await this.sendMarkdown(chatId, text);
197
308
  }
198
309
 
199
310
  private async apiCall(method: string, body?: Record<string, unknown>): Promise<any> {
@@ -136,10 +136,21 @@ export class AgentRuntime {
136
136
  this.agent.bindChannel(webChannel);
137
137
  this.logger.info('Bound web channel', { port });
138
138
  } else if (ch.type === 'telegram') {
139
- this.agent.bindChannel(new TelegramChannel({
139
+ const tgChannel = new TelegramChannel({
140
140
  token: ch.config?.token as string,
141
141
  port: ch.port,
142
- }));
142
+ });
143
+ // Wire up streaming for real-time message editing
144
+ const agentRef = this.agent;
145
+ tgChannel.setStreamHandler(async function* (msg) {
146
+ const history = [msg];
147
+ const provider = agentRef.provider;
148
+ const systemPrompt = agentRef.getSystemPrompt();
149
+ for await (const chunk of provider.chatStream(history, systemPrompt)) {
150
+ yield chunk;
151
+ }
152
+ });
153
+ this.agent.bindChannel(tgChannel);
143
154
  this.logger.info('Bound telegram channel');
144
155
  } else if (ch.type === 'websocket') {
145
156
  this.agent.bindChannel(new WebSocketChannel(ch.port ?? 3002));
@@ -410,9 +410,67 @@ class ClaudeCLIProvider implements LLMProvider {
410
410
  }
411
411
 
412
412
  async *chatStream(messages: Message[], systemPrompt?: string): AsyncIterable<string> {
413
- // Claude CLI --print doesn't support streaming well, so we do single-shot
414
- const result = await this.chat(messages, systemPrompt);
415
- yield result;
413
+ // Build prompt same as chat()
414
+ const parts: string[] = [];
415
+ if (systemPrompt) {
416
+ parts.push(`[System]: ${systemPrompt}`);
417
+ }
418
+ for (const m of messages) {
419
+ const role = m.role === 'user' ? 'Human' : 'Assistant';
420
+ parts.push(`${role}: ${m.content}`);
421
+ }
422
+ const prompt = parts.join('\n\n');
423
+
424
+ const args = ['-p', '--output-format', 'text'];
425
+ if (this.model) {
426
+ args.push('--model', this.model);
427
+ }
428
+
429
+ // Write system prompt to temp file if needed
430
+ let tmpFile: string | undefined;
431
+ if (systemPrompt) {
432
+ const { writeFileSync } = await import('fs');
433
+ const { join } = await import('path');
434
+ const { tmpdir } = await import('os');
435
+ tmpFile = join(tmpdir(), `opc-claude-stream-${Date.now()}.txt`);
436
+ writeFileSync(tmpFile, systemPrompt);
437
+ args.push('--system-prompt-file', tmpFile);
438
+ }
439
+
440
+ const lastMsg = messages[messages.length - 1];
441
+ args.push(lastMsg?.content ?? prompt);
442
+
443
+ const { spawn } = await import('child_process');
444
+
445
+ try {
446
+ const proc = spawn('claude', args, {
447
+ env: { ...process.env },
448
+ stdio: ['pipe', 'pipe', 'pipe'],
449
+ });
450
+ proc.stdin.end();
451
+
452
+ // Yield chunks as they arrive from stdout
453
+ const readable = proc.stdout;
454
+ for await (const chunk of readable) {
455
+ yield (chunk as Buffer).toString();
456
+ }
457
+
458
+ // Wait for process to finish
459
+ await new Promise<void>((resolve, reject) => {
460
+ proc.on('close', (code) => {
461
+ if (code !== 0 && code !== null) {
462
+ // Already yielded content, just log
463
+ console.warn(`[ClaudeCLI] Process exited with code ${code}`);
464
+ }
465
+ resolve();
466
+ });
467
+ proc.on('error', reject);
468
+ });
469
+ } finally {
470
+ if (tmpFile) {
471
+ try { const { unlinkSync } = await import('fs'); unlinkSync(tmpFile); } catch {}
472
+ }
473
+ }
416
474
  }
417
475
  }
418
476