@aion0/forge 0.2.4 → 0.2.6

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.
@@ -39,6 +39,9 @@ function db() {
39
39
  return getDb(getDbPath());
40
40
  }
41
41
 
42
+ // Per-task model overrides (used by pipeline to set pipelineModel)
43
+ export const taskModelOverrides = new Map<string, string>();
44
+
42
45
  // ─── CRUD ────────────────────────────────────────────────────
43
46
 
44
47
  export function createTask(opts: {
@@ -239,6 +242,12 @@ function executeTask(task: Task): Promise<void> {
239
242
 
240
243
  const args = ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions'];
241
244
 
245
+ // Use model override if set, otherwise fall back to taskModel setting
246
+ const model = taskModelOverrides.get(task.id) || settings.taskModel;
247
+ if (model && model !== 'default') {
248
+ args.push('--model', model);
249
+ }
250
+
242
251
  // Resume specific session to continue the conversation
243
252
  if (task.conversationId) {
244
253
  args.push('--resume', task.conversationId);
@@ -254,7 +263,7 @@ function executeTask(task: Task): Promise<void> {
254
263
 
255
264
  // Resolve the actual claude CLI script path (claude is a symlink to a .js file)
256
265
  const resolvedClaude = resolveClaudePath(claudePath);
257
- console.log(`[task] ${task.projectName}: "${task.prompt.slice(0, 60)}..."`);
266
+ console.log(`[task] ${task.projectName} [${model || 'default'}]: "${task.prompt.slice(0, 60)}..."`);
258
267
 
259
268
  const child = spawn(resolvedClaude.cmd, [...resolvedClaude.prefix, ...args], {
260
269
  cwd: task.projectPath,
@@ -266,6 +275,7 @@ function executeTask(task: Task): Promise<void> {
266
275
  let resultText = '';
267
276
  let totalCost = 0;
268
277
  let sessionId = '';
278
+ let modelUsed = '';
269
279
 
270
280
  child.on('error', (err) => {
271
281
  console.error(`[task-runner] Spawn error:`, err.message);
@@ -296,6 +306,9 @@ function executeTask(task: Task): Promise<void> {
296
306
  }
297
307
 
298
308
  if (parsed.session_id) sessionId = parsed.session_id;
309
+ if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.model) {
310
+ modelUsed = parsed.model;
311
+ }
299
312
  if (parsed.type === 'result') {
300
313
  resultText = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
301
314
  totalCost = parsed.total_cost_usd || 0;
@@ -381,6 +394,12 @@ function executeTask(task: Task): Promise<void> {
381
394
  * Sends a visible bell character so the user knows to resume.
382
395
  */
383
396
  function notifyTerminalSession(task: Task, status: 'done' | 'failed', sessionId?: string) {
397
+ // Skip pipeline tasks — they have their own notification system
398
+ try {
399
+ const { pipelineTaskIds } = require('./pipeline');
400
+ if (pipelineTaskIds.has(task.id)) return;
401
+ } catch {}
402
+
384
403
  try {
385
404
  const out = execSync(
386
405
  `tmux list-sessions -F "#{session_name}" 2>/dev/null`,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Telegram Bot — remote interface for My Workflow.
2
+ * Telegram Bot — remote interface for Forge.
3
3
  *
4
4
  * Optimized for mobile:
5
5
  * - /tasks shows numbered list, reply with number to see details
@@ -19,8 +19,8 @@ import type { Task, TaskLogEntry } from '@/src/types';
19
19
  // Prevent duplicate polling and state loss across hot-reloads
20
20
  const globalKey = Symbol.for('mw-telegram-state');
21
21
  const g = globalThis as any;
22
- if (!g[globalKey]) g[globalKey] = { polling: false, pollTimer: null, lastUpdateId: 0 };
23
- const botState: { polling: boolean; pollTimer: ReturnType<typeof setTimeout> | null; lastUpdateId: number } = g[globalKey];
22
+ if (!g[globalKey]) g[globalKey] = { polling: false, pollTimer: null, lastUpdateId: 0, taskListenerAttached: false, pollActive: false, processedMsgIds: new Set<number>(), pollGeneration: 0 };
23
+ const botState: { polling: boolean; pollTimer: ReturnType<typeof setTimeout> | null; lastUpdateId: number; taskListenerAttached: boolean; pollActive: boolean; processedMsgIds: Set<number>; pollGeneration: number } = g[globalKey];
24
24
 
25
25
  // Track which Telegram message maps to which task (for reply-based interaction)
26
26
  const taskMessageMap = new Map<number, string>(); // messageId → taskId
@@ -46,64 +46,110 @@ const logBuffers = new Map<string, { entries: string[]; timer: ReturnType<typeof
46
46
  // ─── Start/Stop ──────────────────────────────────────────────
47
47
 
48
48
  export function startTelegramBot() {
49
- if (botState.polling) return;
50
49
  const settings = loadSettings();
51
50
  if (!settings.telegramBotToken || !settings.telegramChatId) return;
52
51
 
52
+ // Kill any existing poll loop (handles hot-reload creating duplicates)
53
+ if (botState.polling) {
54
+ botState.pollGeneration++;
55
+ if (botState.pollTimer) { clearTimeout(botState.pollTimer); botState.pollTimer = null; }
56
+ botState.pollActive = false;
57
+ }
58
+
53
59
  botState.polling = true;
54
60
  console.log('[telegram] Bot started');
55
61
 
56
62
  // Set bot command menu
57
63
  setBotCommands(settings.telegramBotToken);
58
64
 
59
- // Listen for task events → stream to Telegram
60
- onTaskEvent((taskId, event, data) => {
61
- const settings = loadSettings();
62
- if (!settings.telegramBotToken || !settings.telegramChatId) return;
63
- const chatId = Number(settings.telegramChatId);
65
+ // Listen for task events → stream to Telegram (only once)
66
+ if (!botState.taskListenerAttached) {
67
+ botState.taskListenerAttached = true;
68
+ onTaskEvent((taskId, event, data) => {
69
+ const settings = loadSettings();
70
+ if (!settings.telegramBotToken || !settings.telegramChatId) return;
64
71
 
65
- if (event === 'log') {
66
- bufferLogEntry(taskId, chatId, data as TaskLogEntry);
67
- } else if (event === 'status') {
68
- handleStatusChange(taskId, chatId, data as string);
69
- }
70
- });
72
+ // Skip pipeline tasks — they have their own notification
73
+ try {
74
+ const { pipelineTaskIds } = require('./pipeline');
75
+ if (pipelineTaskIds.has(taskId)) return;
76
+ } catch {}
71
77
 
72
- poll();
78
+ const chatId = Number(settings.telegramChatId.split(',')[0].trim());
79
+
80
+ if (event === 'log') {
81
+ bufferLogEntry(taskId, chatId, data as TaskLogEntry);
82
+ } else if (event === 'status') {
83
+ handleStatusChange(taskId, chatId, data as string);
84
+ }
85
+ });
86
+ }
87
+
88
+ // Skip stale updates on startup — set offset to -1 to get only new messages
89
+ if (botState.lastUpdateId === 0) {
90
+ fetch(`https://api.telegram.org/bot${settings.telegramBotToken}/getUpdates?offset=-1`)
91
+ .then(r => r.json())
92
+ .then(data => {
93
+ if (data.ok && data.result?.length > 0) {
94
+ botState.lastUpdateId = data.result[data.result.length - 1].update_id;
95
+ }
96
+ poll();
97
+ })
98
+ .catch(() => poll());
99
+ } else {
100
+ poll();
101
+ }
73
102
  }
74
103
 
75
104
  export function stopTelegramBot() {
76
105
  botState.polling = false;
106
+ botState.pollActive = false;
77
107
  if (botState.pollTimer) { clearTimeout(botState.pollTimer); botState.pollTimer = null; }
78
108
  }
79
109
 
80
110
  // ─── Polling ─────────────────────────────────────────────────
81
111
 
112
+ function schedulePoll(delay: number = 1000) {
113
+ if (botState.pollTimer) clearTimeout(botState.pollTimer);
114
+ botState.pollTimer = setTimeout(poll, delay);
115
+ }
116
+
82
117
  async function poll() {
83
- if (!botState.polling) return;
118
+ const myGeneration = botState.pollGeneration;
119
+
120
+ // Prevent concurrent polls
121
+ if (!botState.polling || botState.pollActive) return;
122
+ botState.pollActive = true;
84
123
 
85
124
  try {
86
125
  const settings = loadSettings();
126
+ const controller = new AbortController();
127
+ const timeout = setTimeout(() => controller.abort(), 35000);
128
+
87
129
  const url = `https://api.telegram.org/bot${settings.telegramBotToken}/getUpdates?offset=${botState.lastUpdateId + 1}&timeout=30`;
88
- const res = await fetch(url);
130
+ const res = await fetch(url, { signal: controller.signal });
131
+ clearTimeout(timeout);
132
+
89
133
  const data = await res.json();
90
134
 
91
- if (data.ok && data.result) {
135
+ if (data.ok && data.result && data.result.length > 0) {
136
+ console.log(`[telegram] Poll got ${data.result.length} updates, lastId=${botState.lastUpdateId}`);
92
137
  for (const update of data.result) {
138
+ if (update.update_id <= botState.lastUpdateId) continue;
93
139
  botState.lastUpdateId = update.update_id;
94
140
  if (update.message?.text) {
141
+ console.log(`[telegram] Processing msg ${update.message.message_id}: ${update.message.text.slice(0, 30)}`);
95
142
  await handleMessage(update.message);
96
143
  }
97
144
  }
98
145
  }
99
- } catch (err: any) {
100
- const isNetworkError = err?.cause?.code === 'ECONNRESET' || err?.message?.includes('fetch failed');
101
- if (!isNetworkError) {
102
- console.error('[telegram] Poll error:', err);
103
- }
146
+ } catch {
147
+ // Network errors during sleep/wake silent
104
148
  }
105
149
 
106
- botState.pollTimer = setTimeout(poll, 1000);
150
+ botState.pollActive = false;
151
+ // Only continue polling if this is still the current generation
152
+ if (botState.polling && myGeneration === botState.pollGeneration) schedulePoll(1000);
107
153
  }
108
154
 
109
155
  // ─── Message Handler ─────────────────────────────────────────
@@ -119,6 +165,16 @@ async function handleMessage(msg: any) {
119
165
  }
120
166
 
121
167
  // Message received (logged silently)
168
+ // Dedup: skip if we already processed this message
169
+ const msgId = msg.message_id;
170
+ if (botState.processedMsgIds.has(msgId)) return;
171
+ botState.processedMsgIds.add(msgId);
172
+ // Keep set size bounded
173
+ if (botState.processedMsgIds.size > 200) {
174
+ const oldest = [...botState.processedMsgIds].slice(0, 100);
175
+ oldest.forEach(id => botState.processedMsgIds.delete(id));
176
+ }
177
+
122
178
  const text: string = msg.text.trim();
123
179
  const replyTo = msg.reply_to_message?.message_id;
124
180
 
@@ -232,28 +288,30 @@ async function handleMessage(msg: any) {
232
288
  await sendProjectList(chatId);
233
289
  break;
234
290
  case '/watch':
235
- await handleWatch(chatId, args[0], args[1]);
236
- break;
237
- case '/watchers':
238
291
  case '/w':
239
- await sendWatcherList(chatId);
292
+ if (args.length > 0) {
293
+ await handleWatch(chatId, args[0], args[1]);
294
+ } else {
295
+ await sendWatcherList(chatId);
296
+ }
240
297
  break;
241
298
  case '/unwatch':
242
299
  await handleUnwatch(chatId, args[0]);
243
300
  break;
301
+ case '/docs':
302
+ case '/doc':
303
+ await handleDocs(chatId, args.join(' '));
304
+ break;
244
305
  case '/peek':
306
+ case '/sessions':
307
+ case '/s':
245
308
  if (args.length > 0) {
246
309
  await handlePeek(chatId, args[0], args[1]);
247
310
  } else {
248
311
  await startPeekSelection(chatId);
249
312
  }
250
313
  break;
251
- case '/docs':
252
- case '/doc':
253
- await handleDocs(chatId, args.join(' '));
254
- break;
255
314
  case '/note':
256
- case '/docs_write':
257
315
  await handleDocsWrite(chatId, args.join(' '));
258
316
  break;
259
317
  case '/cancel':
@@ -301,23 +359,19 @@ async function handleMessage(msg: any) {
301
359
  async function sendHelp(chatId: number) {
302
360
  await send(chatId,
303
361
  `🤖 Forge\n\n` +
304
- `📋 /tasksnumbered task list\n` +
305
- `/tasks running filter by status\n` +
306
- `🔍 /sessions — browse session content\n` +
307
- `/sessions <project>sessions for project\n\n` +
308
- `👁 /watch <project> [sessionId] monitor session\n` +
309
- `/watcherslist active watchers\n` +
310
- `/unwatch <id> stop watching\n\n` +
311
- `📝 Submit task:\nproject-name: your instructions\n\n` +
312
- `👀 /peek [project] [sessionId] — session summary\n` +
313
- `📖 /docs — docs session summary\n` +
314
- `/docs <filename> — view doc file\n` +
315
- `📝 /note — quick note to docs claude\n\n` +
362
+ `📋 /taskcreate task (interactive)\n` +
363
+ `/tasks — task list\n\n` +
364
+ `👀 /sessions — session summary (select project)\n` +
365
+ `📖 /docsdocs summary / view file\n` +
366
+ `📝 /note quick note to docs\n\n` +
367
+ `👁 /watch <project> monitor session\n` +
368
+ `/watchlist watchers\n` +
369
+ `/unwatch <id> stop\n\n` +
316
370
  `🔧 /cancel <id> /retry <id>\n` +
371
+ `/sessions — browse sessions\n` +
317
372
  `/projects — list projects\n\n` +
318
- `🌐 /tunnel — tunnel status\n` +
319
- `/tunnel_start start tunnel\n` +
320
- `/tunnel_stop — stop tunnel\n` +
373
+ `🌐 /tunnel — status\n` +
374
+ `/tunnel_start / /tunnel_stop\n` +
321
375
  `/tunnel_password <pw> — get login password\n\n` +
322
376
  `Reply number to select`
323
377
  );
@@ -629,39 +683,16 @@ async function handlePeek(chatId: number, projectArg?: string, sessionArg?: stri
629
683
  contextLen += line.length;
630
684
  }
631
685
 
632
- // AI summary
633
- let summary = '';
634
- try {
635
- const apiKey = process.env.ANTHROPIC_API_KEY;
636
- if (apiKey) {
637
- const res = await fetch('https://api.anthropic.com/v1/messages', {
638
- method: 'POST',
639
- headers: {
640
- 'Content-Type': 'application/json',
641
- 'x-api-key': apiKey,
642
- 'anthropic-version': '2023-06-01',
643
- },
644
- body: JSON.stringify({
645
- model: 'claude-haiku-4-5-20251001',
646
- max_tokens: 500,
647
- messages: [{
648
- role: 'user',
649
- content: `Summarize this Claude Code session in 2-3 sentences. What was the user trying to do? What's the current status? Answer in the same language as the session content.\n\n${contextEntries.join('\n')}`,
650
- }],
651
- }),
652
- });
653
- if (res.ok) {
654
- const data = await res.json();
655
- summary = data.content?.[0]?.text || '';
656
- }
657
- }
658
- } catch {}
686
+ const telegramModel = loadSettings().telegramModel || 'sonnet';
687
+ const summary = contextEntries.length > 3
688
+ ? await aiSummarize(contextEntries.join('\n'), 'Summarize this Claude Code session in 2-3 sentences. What was the user working on? What is the current status? Answer in the same language as the content.')
689
+ : '';
659
690
 
660
691
  // Format output
661
- const header = `📋 ${project.name} / ${session.sessionId.slice(0, 8)}\n${entries.length} entries${session.gitBranch ? ` • ${session.gitBranch}` : ''}`;
692
+ const header = `📋 ${project.name} / ${session.sessionId.slice(0, 8)}\n${entries.length} entries${session.gitBranch ? ` • ${session.gitBranch}` : ''}${summary ? ` • AI: ${telegramModel}` : ''}`;
662
693
 
663
694
  const summaryBlock = summary
664
- ? `\n\n📝 Summary:\n${summary}`
695
+ ? `\n\n📝 Summary (${telegramModel}):\n${summary}`
665
696
  : '';
666
697
 
667
698
  const rawBlock = `\n\n--- Recent ---\n${recentRaw.join('\n\n')}`;
@@ -1009,6 +1040,47 @@ async function handleTunnelPassword(chatId: number, password?: string, userMsgId
1009
1040
  }
1010
1041
  }
1011
1042
 
1043
+ // ─── AI Summarize (using Claude Code subscription) ───────────
1044
+
1045
+ async function aiSummarize(content: string, instruction: string): Promise<string> {
1046
+ try {
1047
+ const settings = loadSettings();
1048
+ const claudePath = settings.claudePath || process.env.CLAUDE_PATH || 'claude';
1049
+ const model = settings.telegramModel || 'sonnet';
1050
+ const { execSync } = require('child_process');
1051
+ const { realpathSync } = require('fs');
1052
+
1053
+ // Resolve claude path
1054
+ let cmd = claudePath;
1055
+ try {
1056
+ const which = execSync(`which ${claudePath}`, { encoding: 'utf-8' }).trim();
1057
+ cmd = realpathSync(which);
1058
+ } catch {}
1059
+
1060
+ const args = ['-p', '--model', model, '--max-turns', '1'];
1061
+ const prompt = `${instruction}\n\nContent:\n${content.slice(0, 8000)}`;
1062
+
1063
+ let execCmd: string;
1064
+ if (cmd.endsWith('.js') || cmd.endsWith('.mjs')) {
1065
+ execCmd = `${process.execPath} ${cmd} ${args.join(' ')}`;
1066
+ } else {
1067
+ execCmd = `${cmd} ${args.join(' ')}`;
1068
+ }
1069
+
1070
+ const result = execSync(execCmd, {
1071
+ input: prompt,
1072
+ encoding: 'utf-8',
1073
+ timeout: 30000,
1074
+ stdio: ['pipe', 'pipe', 'pipe'],
1075
+ env: { ...process.env, CLAUDECODE: undefined },
1076
+ }).trim();
1077
+
1078
+ return result.slice(0, 1000);
1079
+ } catch {
1080
+ return '';
1081
+ }
1082
+ }
1083
+
1012
1084
  // ─── Docs ────────────────────────────────────────────────────
1013
1085
 
1014
1086
  async function handleDocs(chatId: number, input: string) {
@@ -1130,38 +1202,14 @@ async function handleDocs(chatId: number, input: string) {
1130
1202
  } catch {}
1131
1203
 
1132
1204
  const recent = entries.slice(-8).join('\n\n');
1133
- const header = `📖 Docs: ${docRoot.split('/').pop()}\n📋 Session: ${sessionId.slice(0, 12)}\n`;
1205
+ const header = `📖 Docs: ${docRoot.split('/').pop()}\n📋 Session: ${sessionId.slice(0, 12)}${summary ? ` • AI: ${tModel}` : ''}\n`;
1134
1206
 
1135
- // Try AI summary if available
1136
- let summary = '';
1137
- try {
1138
- const apiKey = process.env.ANTHROPIC_API_KEY;
1139
- if (apiKey && entries.length > 2) {
1140
- const contextText = entries.slice(-15).join('\n');
1141
- const res = await fetch('https://api.anthropic.com/v1/messages', {
1142
- method: 'POST',
1143
- headers: {
1144
- 'Content-Type': 'application/json',
1145
- 'x-api-key': apiKey,
1146
- 'anthropic-version': '2023-06-01',
1147
- },
1148
- body: JSON.stringify({
1149
- model: 'claude-haiku-4-5-20251001',
1150
- max_tokens: 300,
1151
- messages: [{
1152
- role: 'user',
1153
- content: `Summarize this Claude Code session in 2-3 sentences. What was the user working on? What's the current status? Answer in the same language as the content.\n\n${contextText}`,
1154
- }],
1155
- }),
1156
- });
1157
- if (res.ok) {
1158
- const data = await res.json();
1159
- summary = data.content?.[0]?.text || '';
1160
- }
1161
- }
1162
- } catch {}
1207
+ const tModel = loadSettings().telegramModel || 'sonnet';
1208
+ const summary = entries.length > 3
1209
+ ? await aiSummarize(entries.slice(-15).join('\n'), 'Summarize this Claude Code session in 2-3 sentences. What was the user working on? What is the current status? Answer in the same language as the content.')
1210
+ : '';
1211
+ const summaryBlock = summary ? `\n📝 (${tModel}) ${summary}\n` : '';
1163
1212
 
1164
- const summaryBlock = summary ? `\n📝 ${summary}\n` : '';
1165
1213
  const fullText = header + summaryBlock + '\n--- Recent ---\n' + recent;
1166
1214
 
1167
1215
  const chunks = splitMessage(fullText, 4000);
@@ -1365,19 +1413,16 @@ async function setBotCommands(token: string) {
1365
1413
  headers: { 'Content-Type': 'application/json' },
1366
1414
  body: JSON.stringify({
1367
1415
  commands: [
1416
+ { command: 'task', description: 'Create task' },
1368
1417
  { command: 'tasks', description: 'List tasks' },
1369
- { command: 'task', description: 'Create task (interactive or /task project prompt)' },
1370
- { command: 'sessions', description: 'Browse sessions' },
1371
- { command: 'projects', description: 'List projects' },
1418
+ { command: 'sessions', description: 'Session summary (AI)' },
1419
+ { command: 'docs', description: 'Docs summary / view file' },
1420
+ { command: 'note', description: 'Quick note to docs' },
1421
+ { command: 'watch', description: 'Monitor session / list watchers' },
1372
1422
  { command: 'tunnel', description: 'Tunnel status' },
1373
1423
  { command: 'tunnel_start', description: 'Start tunnel' },
1374
1424
  { command: 'tunnel_stop', description: 'Stop tunnel' },
1375
1425
  { command: 'tunnel_password', description: 'Get login password' },
1376
- { command: 'peek', description: 'Session summary (AI + recent)' },
1377
- { command: 'docs', description: 'Docs session summary / view file' },
1378
- { command: 'note', description: 'Quick note to docs Claude' },
1379
- { command: 'watch', description: 'Monitor session' },
1380
- { command: 'watchers', description: 'List watchers' },
1381
1426
  { command: 'help', description: 'Show help' },
1382
1427
  ],
1383
1428
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
package/tsconfig.json CHANGED
@@ -33,7 +33,8 @@
33
33
  "**/*.ts",
34
34
  "**/*.tsx",
35
35
  ".next/types/**/*.ts",
36
- ".next/dev/types/**/*.ts"
36
+ ".next/dev/types/**/*.ts",
37
+ ".next/dev/dev/types/**/*.ts"
37
38
  ],
38
39
  "exclude": [
39
40
  "node_modules"