@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/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: '',
@@ -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
- console.error('[telegram] Poll error:', err);
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 === 'projects') {
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
- await handleNewTask(chatId, args.join(' '));
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 handleTunnel(chatId, args[0], args[1], msg.message_id);
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 [start|stop] remote access\n` +
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 handleTunnel(chatId: number, action?: string, password?: string, userMsgId?: number) {
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
- // start/stop require password
655
- if (action === 'start' || action === 'stop') {
656
- if (!settings.telegramTunnelPassword) {
657
- await send(chatId, '⚠️ Set telegram tunnel password in Settings first.');
658
- return;
659
- }
660
- if (!password || password !== settings.telegramTunnelPassword) {
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
- if (action === 'start') {
669
- const status = getTunnelStatus();
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
- await send(chatId, '🌐 Starting tunnel...');
675
- const result = await startTunnel();
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
- await send(chatId, '🛑 Tunnel stopped');
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
- // Status (no password needed)
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: /task project prompt' },
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 / start / stop' },
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' },