@aion0/forge 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +44 -0
- package/app/api/code/route.ts +160 -0
- package/app/api/docs/route.ts +85 -0
- package/app/api/docs/sessions/route.ts +54 -0
- package/app/api/terminal-cwd/route.ts +19 -0
- package/components/CodeViewer.tsx +474 -0
- package/components/Dashboard.tsx +34 -14
- package/components/DocTerminal.tsx +168 -0
- package/components/DocsViewer.tsx +254 -0
- package/components/MarkdownContent.tsx +24 -8
- package/components/SettingsModal.tsx +55 -0
- package/components/WebTerminal.tsx +32 -7
- package/lib/settings.ts +2 -0
- package/lib/telegram-bot.ts +469 -49
- package/lib/terminal-standalone.ts +35 -3
- package/next-env.d.ts +1 -1
- package/package.json +2 -1
package/lib/settings.ts
CHANGED
|
@@ -7,6 +7,7 @@ const SETTINGS_FILE = join(homedir(), '.forge', 'settings.yaml');
|
|
|
7
7
|
|
|
8
8
|
export interface Settings {
|
|
9
9
|
projectRoots: string[]; // Multiple project directories
|
|
10
|
+
docRoots: string[]; // Markdown document directories (e.g. Obsidian vaults)
|
|
10
11
|
claudePath: string; // Path to claude binary
|
|
11
12
|
telegramBotToken: string; // Telegram Bot API token
|
|
12
13
|
telegramChatId: string; // Telegram chat ID to send notifications to
|
|
@@ -18,6 +19,7 @@ export interface Settings {
|
|
|
18
19
|
|
|
19
20
|
const defaults: Settings = {
|
|
20
21
|
projectRoots: [],
|
|
22
|
+
docRoots: [],
|
|
21
23
|
claudePath: '',
|
|
22
24
|
telegramBotToken: '',
|
|
23
25
|
telegramChatId: '',
|
package/lib/telegram-bot.ts
CHANGED
|
@@ -34,7 +34,10 @@ const chatNumberedTasks = new Map<number, Map<number, string>>();
|
|
|
34
34
|
const chatNumberedSessions = new Map<number, Map<number, { projectName: string; sessionId: string }>>();
|
|
35
35
|
const chatNumberedProjects = new Map<number, Map<number, string>>();
|
|
36
36
|
// Track what the last numbered list was for
|
|
37
|
-
const chatListMode = new Map<number, 'tasks' | 'projects' | 'sessions'>();
|
|
37
|
+
const chatListMode = new Map<number, 'tasks' | 'projects' | 'sessions' | 'task-create' | 'peek'>();
|
|
38
|
+
|
|
39
|
+
// Pending task creation: waiting for prompt text
|
|
40
|
+
const pendingTaskProject = new Map<number, { name: string; path: string }>(); // chatId → project
|
|
38
41
|
|
|
39
42
|
// Buffer for streaming logs
|
|
40
43
|
const logBuffers = new Map<string, { entries: string[]; timer: ReturnType<typeof setTimeout> | null }>();
|
|
@@ -94,8 +97,12 @@ async function poll() {
|
|
|
94
97
|
}
|
|
95
98
|
}
|
|
96
99
|
}
|
|
97
|
-
} catch (err) {
|
|
98
|
-
|
|
100
|
+
} catch (err: any) {
|
|
101
|
+
// Network errors (ECONNRESET, fetch failed) are normal during sleep/wake — silent retry
|
|
102
|
+
const isNetworkError = err?.cause?.code === 'ECONNRESET' || err?.message?.includes('fetch failed');
|
|
103
|
+
if (!isNetworkError) {
|
|
104
|
+
console.error('[telegram] Poll error:', err);
|
|
105
|
+
}
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
pollTimer = setTimeout(poll, 1000);
|
|
@@ -109,6 +116,20 @@ async function handleMessage(msg: any) {
|
|
|
109
116
|
const text: string = msg.text.trim();
|
|
110
117
|
const replyTo = msg.reply_to_message?.message_id;
|
|
111
118
|
|
|
119
|
+
// Check if waiting for task prompt
|
|
120
|
+
const pending = pendingTaskProject.get(chatId);
|
|
121
|
+
if (pending && !text.startsWith('/')) {
|
|
122
|
+
pendingTaskProject.delete(chatId);
|
|
123
|
+
const task = createTask({
|
|
124
|
+
projectName: pending.name,
|
|
125
|
+
projectPath: pending.path,
|
|
126
|
+
prompt: text,
|
|
127
|
+
});
|
|
128
|
+
const msgId = await send(chatId, `✅ Task ${task.id} created\n📁 ${task.projectName}\n\n${text.slice(0, 200)}`);
|
|
129
|
+
if (msgId) { taskMessageMap.set(msgId, task.id); taskChatMap.set(task.id, chatId); }
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
112
133
|
// Check if replying to a task message → follow-up
|
|
113
134
|
if (replyTo && taskMessageMap.has(replyTo)) {
|
|
114
135
|
const taskId = taskMessageMap.get(replyTo)!;
|
|
@@ -121,7 +142,25 @@ async function handleMessage(msg: any) {
|
|
|
121
142
|
const num = parseInt(text);
|
|
122
143
|
const mode = chatListMode.get(chatId);
|
|
123
144
|
|
|
124
|
-
if (mode === '
|
|
145
|
+
if (mode === 'task-create') {
|
|
146
|
+
const projMap = chatNumberedProjects.get(chatId);
|
|
147
|
+
if (projMap?.has(num)) {
|
|
148
|
+
const projectName = projMap.get(num)!;
|
|
149
|
+
const projects = scanProjects();
|
|
150
|
+
const project = projects.find(p => p.name === projectName);
|
|
151
|
+
if (project) {
|
|
152
|
+
pendingTaskProject.set(chatId, { name: project.name, path: project.path });
|
|
153
|
+
await send(chatId, `📁 ${project.name}\n\nSend the task prompt:`);
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
} else if (mode === 'peek') {
|
|
158
|
+
const projMap = chatNumberedProjects.get(chatId);
|
|
159
|
+
if (projMap?.has(num)) {
|
|
160
|
+
await handlePeek(chatId, projMap.get(num)!);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
} else if (mode === 'projects') {
|
|
125
164
|
const projMap = chatNumberedProjects.get(chatId);
|
|
126
165
|
if (projMap?.has(num)) {
|
|
127
166
|
await sendSessionList(chatId, projMap.get(num)!);
|
|
@@ -145,6 +184,9 @@ async function handleMessage(msg: any) {
|
|
|
145
184
|
|
|
146
185
|
// Commands
|
|
147
186
|
if (text.startsWith('/')) {
|
|
187
|
+
// Any new command cancels pending states
|
|
188
|
+
pendingTaskProject.delete(chatId);
|
|
189
|
+
|
|
148
190
|
const [cmd, ...args] = text.split(/\s+/);
|
|
149
191
|
switch (cmd) {
|
|
150
192
|
case '/start':
|
|
@@ -157,7 +199,11 @@ async function handleMessage(msg: any) {
|
|
|
157
199
|
break;
|
|
158
200
|
case '/new':
|
|
159
201
|
case '/task':
|
|
160
|
-
|
|
202
|
+
if (args.length > 0) {
|
|
203
|
+
await handleNewTask(chatId, args.join(' '));
|
|
204
|
+
} else {
|
|
205
|
+
await startTaskCreation(chatId);
|
|
206
|
+
}
|
|
161
207
|
break;
|
|
162
208
|
case '/sessions':
|
|
163
209
|
case '/s':
|
|
@@ -181,6 +227,17 @@ async function handleMessage(msg: any) {
|
|
|
181
227
|
case '/unwatch':
|
|
182
228
|
await handleUnwatch(chatId, args[0]);
|
|
183
229
|
break;
|
|
230
|
+
case '/peek':
|
|
231
|
+
if (args.length > 0) {
|
|
232
|
+
await handlePeek(chatId, args[0], args[1]);
|
|
233
|
+
} else {
|
|
234
|
+
await startPeekSelection(chatId);
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
case '/docs':
|
|
238
|
+
case '/doc':
|
|
239
|
+
await handleDocs(chatId, args.join(' '));
|
|
240
|
+
break;
|
|
184
241
|
case '/cancel':
|
|
185
242
|
await handleCancel(chatId, args[0]);
|
|
186
243
|
break;
|
|
@@ -188,7 +245,13 @@ async function handleMessage(msg: any) {
|
|
|
188
245
|
await handleRetry(chatId, args[0]);
|
|
189
246
|
break;
|
|
190
247
|
case '/tunnel':
|
|
191
|
-
await
|
|
248
|
+
await handleTunnelStatus(chatId);
|
|
249
|
+
break;
|
|
250
|
+
case '/tunnel_start':
|
|
251
|
+
await handleTunnelStart(chatId);
|
|
252
|
+
break;
|
|
253
|
+
case '/tunnel_stop':
|
|
254
|
+
await handleTunnelStop(chatId);
|
|
192
255
|
break;
|
|
193
256
|
case '/tunnel_password':
|
|
194
257
|
await handleTunnelPassword(chatId, args[0], msg.message_id);
|
|
@@ -228,9 +291,14 @@ async function sendHelp(chatId: number) {
|
|
|
228
291
|
`/watchers — list active watchers\n` +
|
|
229
292
|
`/unwatch <id> — stop watching\n\n` +
|
|
230
293
|
`📝 Submit task:\nproject-name: your instructions\n\n` +
|
|
294
|
+
`👀 /peek [project] [sessionId] — session summary\n` +
|
|
295
|
+
`📖 /docs — docs session summary\n` +
|
|
296
|
+
`/docs <filename> — view doc file\n\n` +
|
|
231
297
|
`🔧 /cancel <id> /retry <id>\n` +
|
|
232
298
|
`/projects — list projects\n\n` +
|
|
233
|
-
`🌐 /tunnel
|
|
299
|
+
`🌐 /tunnel — tunnel status\n` +
|
|
300
|
+
`/tunnel_start — start tunnel\n` +
|
|
301
|
+
`/tunnel_stop — stop tunnel\n` +
|
|
234
302
|
`/tunnel_password <pw> — get login password\n\n` +
|
|
235
303
|
`Reply number to select`
|
|
236
304
|
);
|
|
@@ -425,6 +493,167 @@ async function sendSessionContent(chatId: number, projectName: string, sessionId
|
|
|
425
493
|
}
|
|
426
494
|
}
|
|
427
495
|
|
|
496
|
+
async function startPeekSelection(chatId: number) {
|
|
497
|
+
const projects = scanProjects();
|
|
498
|
+
if (projects.length === 0) {
|
|
499
|
+
await send(chatId, 'No projects configured.');
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Filter to projects that have sessions
|
|
504
|
+
const withSessions = projects.filter(p => listClaudeSessions(p.name).length > 0);
|
|
505
|
+
if (withSessions.length === 0) {
|
|
506
|
+
await send(chatId, 'No projects with sessions found.');
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const numbered = new Map<number, string>();
|
|
511
|
+
const lines = withSessions.slice(0, 15).map((p, i) => {
|
|
512
|
+
numbered.set(i + 1, p.name);
|
|
513
|
+
const sessions = listClaudeSessions(p.name);
|
|
514
|
+
const latest = sessions[0];
|
|
515
|
+
const info = latest?.summary || latest?.firstPrompt?.slice(0, 40) || '';
|
|
516
|
+
return `${i + 1}. ${p.name}${info ? `\n ${info}` : ''}`;
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
chatNumberedProjects.set(chatId, numbered);
|
|
520
|
+
chatListMode.set(chatId, 'peek');
|
|
521
|
+
|
|
522
|
+
await send(chatId, `👀 Peek — select project:\n\n${lines.join('\n')}`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function handlePeek(chatId: number, projectArg?: string, sessionArg?: string) {
|
|
526
|
+
const projects = scanProjects();
|
|
527
|
+
|
|
528
|
+
// If no project specified, use the most recent task's project
|
|
529
|
+
let projectName = projectArg;
|
|
530
|
+
let sessionId = sessionArg;
|
|
531
|
+
|
|
532
|
+
if (!projectName) {
|
|
533
|
+
// Find most recent running or done task
|
|
534
|
+
const tasks = listTasks();
|
|
535
|
+
const recent = tasks.find(t => t.status === 'running') || tasks[0];
|
|
536
|
+
if (recent) {
|
|
537
|
+
projectName = recent.projectName;
|
|
538
|
+
} else {
|
|
539
|
+
await send(chatId, 'No project specified and no recent tasks.\nUsage: /peek [project] [sessionId]');
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const project = projects.find(p => p.name === projectName || p.name.toLowerCase() === projectName!.toLowerCase());
|
|
545
|
+
if (!project) {
|
|
546
|
+
await send(chatId, `Project not found: ${projectName}`);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Find session
|
|
551
|
+
const sessions = listClaudeSessions(project.name);
|
|
552
|
+
if (sessions.length === 0) {
|
|
553
|
+
await send(chatId, `No sessions for ${project.name}`);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const session = sessionId
|
|
558
|
+
? sessions.find(s => s.sessionId.startsWith(sessionId!))
|
|
559
|
+
: sessions[0]; // most recent
|
|
560
|
+
|
|
561
|
+
if (!session) {
|
|
562
|
+
await send(chatId, `Session not found: ${sessionId}`);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const filePath = getSessionFilePath(project.name, session.sessionId);
|
|
567
|
+
if (!filePath) {
|
|
568
|
+
await send(chatId, 'Session file not found');
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
await send(chatId, `🔍 Loading ${project.name} / ${session.sessionId.slice(0, 8)}...`);
|
|
573
|
+
|
|
574
|
+
const entries = readSessionEntries(filePath);
|
|
575
|
+
if (entries.length === 0) {
|
|
576
|
+
await send(chatId, 'Session is empty');
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Collect last N meaningful entries for raw display
|
|
581
|
+
const recentRaw: string[] = [];
|
|
582
|
+
let rawCount = 0;
|
|
583
|
+
for (let i = entries.length - 1; i >= 0 && rawCount < 8; i--) {
|
|
584
|
+
const e = entries[i];
|
|
585
|
+
if (e.type === 'user') {
|
|
586
|
+
recentRaw.unshift(`👤 ${e.content.slice(0, 300)}`);
|
|
587
|
+
rawCount++;
|
|
588
|
+
} else if (e.type === 'assistant_text') {
|
|
589
|
+
recentRaw.unshift(`🤖 ${e.content.slice(0, 300)}`);
|
|
590
|
+
rawCount++;
|
|
591
|
+
} else if (e.type === 'tool_use') {
|
|
592
|
+
recentRaw.unshift(`🔧 ${e.toolName || 'tool'}`);
|
|
593
|
+
rawCount++;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Build context for AI summary (last ~50 entries)
|
|
598
|
+
const contextEntries: string[] = [];
|
|
599
|
+
let contextLen = 0;
|
|
600
|
+
const MAX_CONTEXT = 8000;
|
|
601
|
+
for (let i = entries.length - 1; i >= 0 && contextLen < MAX_CONTEXT; i--) {
|
|
602
|
+
const e = entries[i];
|
|
603
|
+
let line = '';
|
|
604
|
+
if (e.type === 'user') line = `User: ${e.content}`;
|
|
605
|
+
else if (e.type === 'assistant_text') line = `Assistant: ${e.content}`;
|
|
606
|
+
else if (e.type === 'tool_use') line = `Tool: ${e.toolName || 'tool'}`;
|
|
607
|
+
else continue;
|
|
608
|
+
if (contextLen + line.length > MAX_CONTEXT) break;
|
|
609
|
+
contextEntries.unshift(line);
|
|
610
|
+
contextLen += line.length;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// AI summary
|
|
614
|
+
let summary = '';
|
|
615
|
+
try {
|
|
616
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
617
|
+
if (apiKey) {
|
|
618
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
619
|
+
method: 'POST',
|
|
620
|
+
headers: {
|
|
621
|
+
'Content-Type': 'application/json',
|
|
622
|
+
'x-api-key': apiKey,
|
|
623
|
+
'anthropic-version': '2023-06-01',
|
|
624
|
+
},
|
|
625
|
+
body: JSON.stringify({
|
|
626
|
+
model: 'claude-haiku-4-5-20251001',
|
|
627
|
+
max_tokens: 500,
|
|
628
|
+
messages: [{
|
|
629
|
+
role: 'user',
|
|
630
|
+
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')}`,
|
|
631
|
+
}],
|
|
632
|
+
}),
|
|
633
|
+
});
|
|
634
|
+
if (res.ok) {
|
|
635
|
+
const data = await res.json();
|
|
636
|
+
summary = data.content?.[0]?.text || '';
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
} catch {}
|
|
640
|
+
|
|
641
|
+
// Format output
|
|
642
|
+
const header = `📋 ${project.name} / ${session.sessionId.slice(0, 8)}\n${entries.length} entries${session.gitBranch ? ` • ${session.gitBranch}` : ''}`;
|
|
643
|
+
|
|
644
|
+
const summaryBlock = summary
|
|
645
|
+
? `\n\n📝 Summary:\n${summary}`
|
|
646
|
+
: '';
|
|
647
|
+
|
|
648
|
+
const rawBlock = `\n\n--- Recent ---\n${recentRaw.join('\n\n')}`;
|
|
649
|
+
|
|
650
|
+
const fullText = header + summaryBlock + rawBlock;
|
|
651
|
+
const chunks = splitMessage(fullText, 4000);
|
|
652
|
+
for (const chunk of chunks) {
|
|
653
|
+
await send(chatId, chunk);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
428
657
|
/**
|
|
429
658
|
* Parse task creation input. Supports:
|
|
430
659
|
* project-name instructions
|
|
@@ -432,6 +661,25 @@ async function sendSessionContent(chatId: number, projectName: string, sessionId
|
|
|
432
661
|
* project-name -in 30m instructions
|
|
433
662
|
* project-name -at 2024-01-01T10:00 instructions
|
|
434
663
|
*/
|
|
664
|
+
async function startTaskCreation(chatId: number) {
|
|
665
|
+
const projects = scanProjects();
|
|
666
|
+
if (projects.length === 0) {
|
|
667
|
+
await send(chatId, 'No projects configured. Add project roots in Settings.');
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const numbered = new Map<number, string>();
|
|
672
|
+
const lines = projects.slice(0, 15).map((p, i) => {
|
|
673
|
+
numbered.set(i + 1, p.name);
|
|
674
|
+
return `${i + 1}. ${p.name}`;
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
chatNumberedProjects.set(chatId, numbered);
|
|
678
|
+
chatListMode.set(chatId, 'task-create');
|
|
679
|
+
|
|
680
|
+
await send(chatId, `📝 New Task\n\nSelect project:\n${lines.join('\n')}`);
|
|
681
|
+
}
|
|
682
|
+
|
|
435
683
|
async function handleNewTask(chatId: number, input: string) {
|
|
436
684
|
if (!input) {
|
|
437
685
|
await send(chatId,
|
|
@@ -644,57 +892,64 @@ async function handleUnwatch(chatId: number, watcherId?: string) {
|
|
|
644
892
|
|
|
645
893
|
// ─── Tunnel Commands ─────────────────────────────────────────
|
|
646
894
|
|
|
647
|
-
async function
|
|
895
|
+
async function handleTunnelStatus(chatId: number) {
|
|
648
896
|
const settings = loadSettings();
|
|
649
|
-
if (String(chatId) !== settings.telegramChatId) {
|
|
650
|
-
await send(chatId, '⛔ Unauthorized');
|
|
651
|
-
return;
|
|
652
|
-
}
|
|
897
|
+
if (String(chatId) !== settings.telegramChatId) { await send(chatId, '⛔ Unauthorized'); return; }
|
|
653
898
|
|
|
654
|
-
|
|
655
|
-
if (
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
await send(chatId, `⛔ Password required\nUsage: /tunnel ${action} <password>`);
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
// Delete user's message containing password
|
|
665
|
-
if (userMsgId) deleteMessageLater(chatId, userMsgId, 0);
|
|
899
|
+
const status = getTunnelStatus();
|
|
900
|
+
if (status.status === 'running' && status.url) {
|
|
901
|
+
await sendHtml(chatId, `🌐 Tunnel running:\n<a href="${status.url}">${status.url}</a>\n\n/tunnel_stop — stop tunnel`);
|
|
902
|
+
} else if (status.status === 'starting') {
|
|
903
|
+
await send(chatId, '⏳ Tunnel is starting...');
|
|
904
|
+
} else {
|
|
905
|
+
await send(chatId, `🌐 Tunnel is ${status.status}\n\n/tunnel_start — start tunnel`);
|
|
666
906
|
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async function handleTunnelStart(chatId: number) {
|
|
910
|
+
const settings = loadSettings();
|
|
911
|
+
if (String(chatId) !== settings.telegramChatId) { await send(chatId, '⛔ Unauthorized'); return; }
|
|
912
|
+
|
|
913
|
+
// Check if tunnel is already running and still reachable
|
|
914
|
+
const status = getTunnelStatus();
|
|
915
|
+
if (status.status === 'running' && status.url) {
|
|
916
|
+
// Verify it's actually alive
|
|
917
|
+
let alive = false;
|
|
918
|
+
try {
|
|
919
|
+
const controller = new AbortController();
|
|
920
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
921
|
+
const res = await fetch(status.url, { method: 'HEAD', signal: controller.signal, redirect: 'manual' });
|
|
922
|
+
clearTimeout(timeout);
|
|
923
|
+
alive = res.status > 0;
|
|
924
|
+
} catch {}
|
|
667
925
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
if (status.status === 'running' && status.url) {
|
|
671
|
-
await send(chatId, `🌐 Tunnel already running:\n${status.url}`);
|
|
926
|
+
if (alive) {
|
|
927
|
+
await sendHtml(chatId, `🌐 Tunnel already running:\n<a href="${status.url}">${status.url}</a>`);
|
|
672
928
|
return;
|
|
673
929
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
if (result.url) {
|
|
677
|
-
await send(chatId, '✅ Tunnel started:');
|
|
678
|
-
await sendHtml(chatId, `<a href="${result.url}">${result.url}</a>`);
|
|
679
|
-
} else {
|
|
680
|
-
await send(chatId, `❌ Failed: ${result.error}`);
|
|
681
|
-
}
|
|
682
|
-
} else if (action === 'stop') {
|
|
930
|
+
// Tunnel process alive but URL unreachable — kill and restart
|
|
931
|
+
await send(chatId, '🌐 Tunnel URL unreachable, restarting...');
|
|
683
932
|
stopTunnel();
|
|
684
|
-
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
await send(chatId, '🌐 Starting tunnel...');
|
|
936
|
+
const result = await startTunnel();
|
|
937
|
+
if (result.url) {
|
|
938
|
+
await send(chatId, '✅ Tunnel started:');
|
|
939
|
+
await sendHtml(chatId, `<a href="${result.url}">${result.url}</a>`);
|
|
685
940
|
} else {
|
|
686
|
-
|
|
687
|
-
const status = getTunnelStatus();
|
|
688
|
-
if (status.status === 'running' && status.url) {
|
|
689
|
-
await send(chatId, `🌐 Tunnel running:\n${status.url}\n\n/tunnel stop <pw> — stop tunnel`);
|
|
690
|
-
} else if (status.status === 'starting') {
|
|
691
|
-
await send(chatId, '⏳ Tunnel is starting...');
|
|
692
|
-
} else {
|
|
693
|
-
await send(chatId, `🌐 Tunnel is ${status.status}\n\n/tunnel start <pw> — start tunnel`);
|
|
694
|
-
}
|
|
941
|
+
await send(chatId, `❌ Failed: ${result.error}`);
|
|
695
942
|
}
|
|
696
943
|
}
|
|
697
944
|
|
|
945
|
+
async function handleTunnelStop(chatId: number) {
|
|
946
|
+
const settings = loadSettings();
|
|
947
|
+
if (String(chatId) !== settings.telegramChatId) { await send(chatId, '⛔ Unauthorized'); return; }
|
|
948
|
+
|
|
949
|
+
stopTunnel();
|
|
950
|
+
await send(chatId, '🛑 Tunnel stopped');
|
|
951
|
+
}
|
|
952
|
+
|
|
698
953
|
async function handleTunnelPassword(chatId: number, password?: string, userMsgId?: number) {
|
|
699
954
|
const settings = loadSettings();
|
|
700
955
|
if (String(chatId) !== settings.telegramChatId) {
|
|
@@ -735,6 +990,167 @@ async function handleTunnelPassword(chatId: number, password?: string, userMsgId
|
|
|
735
990
|
}
|
|
736
991
|
}
|
|
737
992
|
|
|
993
|
+
// ─── Docs ────────────────────────────────────────────────────
|
|
994
|
+
|
|
995
|
+
async function handleDocs(chatId: number, input: string) {
|
|
996
|
+
const settings = loadSettings();
|
|
997
|
+
if (String(chatId) !== settings.telegramChatId) { await send(chatId, '⛔ Unauthorized'); return; }
|
|
998
|
+
|
|
999
|
+
const docRoots = (settings.docRoots || []).map((r: string) => r.replace(/^~/, require('os').homedir()));
|
|
1000
|
+
if (docRoots.length === 0) {
|
|
1001
|
+
await send(chatId, '⚠️ No document directories configured.\nAdd them in Settings → Document Roots');
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const docRoot = docRoots[0];
|
|
1006
|
+
const { homedir: getHome } = require('os');
|
|
1007
|
+
const { join, extname } = require('path');
|
|
1008
|
+
const { existsSync, readFileSync, readdirSync } = require('fs');
|
|
1009
|
+
|
|
1010
|
+
// /docs <filename> — search and show file content
|
|
1011
|
+
if (input.trim()) {
|
|
1012
|
+
const query = input.trim().toLowerCase();
|
|
1013
|
+
|
|
1014
|
+
// Recursive search for matching .md files
|
|
1015
|
+
const matches: string[] = [];
|
|
1016
|
+
function searchDir(dir: string, depth: number) {
|
|
1017
|
+
if (depth > 5 || matches.length >= 5) return;
|
|
1018
|
+
try {
|
|
1019
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1020
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
1021
|
+
const full = join(dir, entry.name);
|
|
1022
|
+
if (entry.isDirectory()) {
|
|
1023
|
+
searchDir(full, depth + 1);
|
|
1024
|
+
} else if (entry.name.toLowerCase().includes(query) && extname(entry.name) === '.md') {
|
|
1025
|
+
matches.push(full);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
} catch {}
|
|
1029
|
+
}
|
|
1030
|
+
searchDir(docRoot, 0);
|
|
1031
|
+
|
|
1032
|
+
if (matches.length === 0) {
|
|
1033
|
+
await send(chatId, `No docs matching "${input.trim()}"`);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Show first match
|
|
1038
|
+
const filePath = matches[0];
|
|
1039
|
+
const relPath = filePath.replace(docRoot + '/', '');
|
|
1040
|
+
try {
|
|
1041
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
1042
|
+
const preview = content.slice(0, 3500);
|
|
1043
|
+
const truncated = content.length > 3500 ? '\n\n... (truncated)' : '';
|
|
1044
|
+
await send(chatId, `📄 ${relPath}\n\n${preview}${truncated}`);
|
|
1045
|
+
if (matches.length > 1) {
|
|
1046
|
+
const others = matches.slice(1).map(m => ` ${m.replace(docRoot + '/', '')}`).join('\n');
|
|
1047
|
+
await send(chatId, `Other matches:\n${others}`);
|
|
1048
|
+
}
|
|
1049
|
+
} catch {
|
|
1050
|
+
await send(chatId, `Failed to read: ${relPath}`);
|
|
1051
|
+
}
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// /docs — show summary of latest Claude session for docs
|
|
1056
|
+
const hash = docRoot.replace(/\//g, '-');
|
|
1057
|
+
const claudeDir = join(getHome(), '.claude', 'projects', hash);
|
|
1058
|
+
|
|
1059
|
+
if (!existsSync(claudeDir)) {
|
|
1060
|
+
await send(chatId, `📖 Docs: ${docRoot.split('/').pop()}\n\nNo Claude sessions yet. Open Docs tab to start.`);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Find latest session
|
|
1065
|
+
let latestFile = '';
|
|
1066
|
+
let latestTime = 0;
|
|
1067
|
+
try {
|
|
1068
|
+
for (const f of readdirSync(claudeDir)) {
|
|
1069
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
1070
|
+
const { statSync } = require('fs');
|
|
1071
|
+
const stat = statSync(join(claudeDir, f));
|
|
1072
|
+
if (stat.mtimeMs > latestTime) {
|
|
1073
|
+
latestTime = stat.mtimeMs;
|
|
1074
|
+
latestFile = f;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
} catch {}
|
|
1078
|
+
|
|
1079
|
+
if (!latestFile) {
|
|
1080
|
+
await send(chatId, `📖 Docs: ${docRoot.split('/').pop()}\n\nNo sessions found.`);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const sessionId = latestFile.replace('.jsonl', '');
|
|
1085
|
+
const filePath = join(claudeDir, latestFile);
|
|
1086
|
+
|
|
1087
|
+
// Read recent entries
|
|
1088
|
+
let entries: string[] = [];
|
|
1089
|
+
try {
|
|
1090
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
1091
|
+
const lines = content.split('\n').filter(Boolean);
|
|
1092
|
+
const recentLines = lines.slice(-30);
|
|
1093
|
+
|
|
1094
|
+
for (const line of recentLines) {
|
|
1095
|
+
try {
|
|
1096
|
+
const entry = JSON.parse(line);
|
|
1097
|
+
if (entry.type === 'human' || entry.role === 'user') {
|
|
1098
|
+
const text = typeof entry.message === 'string' ? entry.message : entry.message?.content?.[0]?.text || '';
|
|
1099
|
+
if (text) entries.push(`👤 ${text.slice(0, 200)}`);
|
|
1100
|
+
} else if (entry.type === 'assistant' && entry.message?.content) {
|
|
1101
|
+
for (const block of entry.message.content) {
|
|
1102
|
+
if (block.type === 'text' && block.text) {
|
|
1103
|
+
entries.push(`🤖 ${block.text.slice(0, 200)}`);
|
|
1104
|
+
} else if (block.type === 'tool_use') {
|
|
1105
|
+
entries.push(`🔧 ${block.name || 'tool'}`);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
} catch {}
|
|
1110
|
+
}
|
|
1111
|
+
} catch {}
|
|
1112
|
+
|
|
1113
|
+
const recent = entries.slice(-8).join('\n\n');
|
|
1114
|
+
const header = `📖 Docs: ${docRoot.split('/').pop()}\n📋 Session: ${sessionId.slice(0, 12)}\n`;
|
|
1115
|
+
|
|
1116
|
+
// Try AI summary if available
|
|
1117
|
+
let summary = '';
|
|
1118
|
+
try {
|
|
1119
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
1120
|
+
if (apiKey && entries.length > 2) {
|
|
1121
|
+
const contextText = entries.slice(-15).join('\n');
|
|
1122
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
1123
|
+
method: 'POST',
|
|
1124
|
+
headers: {
|
|
1125
|
+
'Content-Type': 'application/json',
|
|
1126
|
+
'x-api-key': apiKey,
|
|
1127
|
+
'anthropic-version': '2023-06-01',
|
|
1128
|
+
},
|
|
1129
|
+
body: JSON.stringify({
|
|
1130
|
+
model: 'claude-haiku-4-5-20251001',
|
|
1131
|
+
max_tokens: 300,
|
|
1132
|
+
messages: [{
|
|
1133
|
+
role: 'user',
|
|
1134
|
+
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}`,
|
|
1135
|
+
}],
|
|
1136
|
+
}),
|
|
1137
|
+
});
|
|
1138
|
+
if (res.ok) {
|
|
1139
|
+
const data = await res.json();
|
|
1140
|
+
summary = data.content?.[0]?.text || '';
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
} catch {}
|
|
1144
|
+
|
|
1145
|
+
const summaryBlock = summary ? `\n📝 ${summary}\n` : '';
|
|
1146
|
+
const fullText = header + summaryBlock + '\n--- Recent ---\n' + recent;
|
|
1147
|
+
|
|
1148
|
+
const chunks = splitMessage(fullText, 4000);
|
|
1149
|
+
for (const chunk of chunks) {
|
|
1150
|
+
await send(chatId, chunk);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
738
1154
|
// ─── Real-time Streaming ─────────────────────────────────────
|
|
739
1155
|
|
|
740
1156
|
function bufferLogEntry(taskId: string, chatId: number, entry: TaskLogEntry) {
|
|
@@ -858,11 +1274,15 @@ async function setBotCommands(token: string) {
|
|
|
858
1274
|
body: JSON.stringify({
|
|
859
1275
|
commands: [
|
|
860
1276
|
{ command: 'tasks', description: 'List tasks' },
|
|
861
|
-
{ command: 'task', description: 'Create task
|
|
1277
|
+
{ command: 'task', description: 'Create task (interactive or /task project prompt)' },
|
|
862
1278
|
{ command: 'sessions', description: 'Browse sessions' },
|
|
863
1279
|
{ command: 'projects', description: 'List projects' },
|
|
864
|
-
{ command: 'tunnel', description: 'Tunnel status
|
|
1280
|
+
{ command: 'tunnel', description: 'Tunnel status' },
|
|
1281
|
+
{ command: 'tunnel_start', description: 'Start tunnel' },
|
|
1282
|
+
{ command: 'tunnel_stop', description: 'Stop tunnel' },
|
|
865
1283
|
{ command: 'tunnel_password', description: 'Get login password' },
|
|
1284
|
+
{ command: 'peek', description: 'Session summary (AI + recent)' },
|
|
1285
|
+
{ command: 'docs', description: 'Docs session summary / view file' },
|
|
866
1286
|
{ command: 'watch', description: 'Monitor session' },
|
|
867
1287
|
{ command: 'watchers', description: 'List watchers' },
|
|
868
1288
|
{ command: 'help', description: 'Show help' },
|