@aion0/forge 0.1.10 → 0.2.1
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 +1 -0
- package/app/api/code/route.ts +194 -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 +510 -0
- package/components/Dashboard.tsx +33 -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 +40 -6
- package/lib/settings.ts +2 -0
- package/lib/telegram-bot.ts +403 -4
- 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 }>();
|
|
@@ -113,6 +116,20 @@ async function handleMessage(msg: any) {
|
|
|
113
116
|
const text: string = msg.text.trim();
|
|
114
117
|
const replyTo = msg.reply_to_message?.message_id;
|
|
115
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
|
+
|
|
116
133
|
// Check if replying to a task message → follow-up
|
|
117
134
|
if (replyTo && taskMessageMap.has(replyTo)) {
|
|
118
135
|
const taskId = taskMessageMap.get(replyTo)!;
|
|
@@ -125,7 +142,25 @@ async function handleMessage(msg: any) {
|
|
|
125
142
|
const num = parseInt(text);
|
|
126
143
|
const mode = chatListMode.get(chatId);
|
|
127
144
|
|
|
128
|
-
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') {
|
|
129
164
|
const projMap = chatNumberedProjects.get(chatId);
|
|
130
165
|
if (projMap?.has(num)) {
|
|
131
166
|
await sendSessionList(chatId, projMap.get(num)!);
|
|
@@ -149,6 +184,9 @@ async function handleMessage(msg: any) {
|
|
|
149
184
|
|
|
150
185
|
// Commands
|
|
151
186
|
if (text.startsWith('/')) {
|
|
187
|
+
// Any new command cancels pending states
|
|
188
|
+
pendingTaskProject.delete(chatId);
|
|
189
|
+
|
|
152
190
|
const [cmd, ...args] = text.split(/\s+/);
|
|
153
191
|
switch (cmd) {
|
|
154
192
|
case '/start':
|
|
@@ -161,7 +199,11 @@ async function handleMessage(msg: any) {
|
|
|
161
199
|
break;
|
|
162
200
|
case '/new':
|
|
163
201
|
case '/task':
|
|
164
|
-
|
|
202
|
+
if (args.length > 0) {
|
|
203
|
+
await handleNewTask(chatId, args.join(' '));
|
|
204
|
+
} else {
|
|
205
|
+
await startTaskCreation(chatId);
|
|
206
|
+
}
|
|
165
207
|
break;
|
|
166
208
|
case '/sessions':
|
|
167
209
|
case '/s':
|
|
@@ -185,6 +227,17 @@ async function handleMessage(msg: any) {
|
|
|
185
227
|
case '/unwatch':
|
|
186
228
|
await handleUnwatch(chatId, args[0]);
|
|
187
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;
|
|
188
241
|
case '/cancel':
|
|
189
242
|
await handleCancel(chatId, args[0]);
|
|
190
243
|
break;
|
|
@@ -238,6 +291,9 @@ async function sendHelp(chatId: number) {
|
|
|
238
291
|
`/watchers — list active watchers\n` +
|
|
239
292
|
`/unwatch <id> — stop watching\n\n` +
|
|
240
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` +
|
|
241
297
|
`🔧 /cancel <id> /retry <id>\n` +
|
|
242
298
|
`/projects — list projects\n\n` +
|
|
243
299
|
`🌐 /tunnel — tunnel status\n` +
|
|
@@ -437,6 +493,167 @@ async function sendSessionContent(chatId: number, projectName: string, sessionId
|
|
|
437
493
|
}
|
|
438
494
|
}
|
|
439
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
|
+
|
|
440
657
|
/**
|
|
441
658
|
* Parse task creation input. Supports:
|
|
442
659
|
* project-name instructions
|
|
@@ -444,6 +661,25 @@ async function sendSessionContent(chatId: number, projectName: string, sessionId
|
|
|
444
661
|
* project-name -in 30m instructions
|
|
445
662
|
* project-name -at 2024-01-01T10:00 instructions
|
|
446
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
|
+
|
|
447
683
|
async function handleNewTask(chatId: number, input: string) {
|
|
448
684
|
if (!input) {
|
|
449
685
|
await send(chatId,
|
|
@@ -754,6 +990,167 @@ async function handleTunnelPassword(chatId: number, password?: string, userMsgId
|
|
|
754
990
|
}
|
|
755
991
|
}
|
|
756
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
|
+
|
|
757
1154
|
// ─── Real-time Streaming ─────────────────────────────────────
|
|
758
1155
|
|
|
759
1156
|
function bufferLogEntry(taskId: string, chatId: number, entry: TaskLogEntry) {
|
|
@@ -877,13 +1274,15 @@ async function setBotCommands(token: string) {
|
|
|
877
1274
|
body: JSON.stringify({
|
|
878
1275
|
commands: [
|
|
879
1276
|
{ command: 'tasks', description: 'List tasks' },
|
|
880
|
-
{ command: 'task', description: 'Create task
|
|
1277
|
+
{ command: 'task', description: 'Create task (interactive or /task project prompt)' },
|
|
881
1278
|
{ command: 'sessions', description: 'Browse sessions' },
|
|
882
1279
|
{ command: 'projects', description: 'List projects' },
|
|
883
1280
|
{ command: 'tunnel', description: 'Tunnel status' },
|
|
884
1281
|
{ command: 'tunnel_start', description: 'Start tunnel' },
|
|
885
1282
|
{ command: 'tunnel_stop', description: 'Stop tunnel' },
|
|
886
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' },
|
|
887
1286
|
{ command: 'watch', description: 'Monitor session' },
|
|
888
1287
|
{ command: 'watchers', description: 'List watchers' },
|
|
889
1288
|
{ command: 'help', description: 'Show help' },
|
|
@@ -28,7 +28,7 @@ import { WebSocketServer, WebSocket } from 'ws';
|
|
|
28
28
|
import * as pty from 'node-pty';
|
|
29
29
|
import { execSync } from 'node:child_process';
|
|
30
30
|
import { homedir } from 'node:os';
|
|
31
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
31
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
|
32
32
|
import { join } from 'node:path';
|
|
33
33
|
|
|
34
34
|
const PORT = Number(process.env.TERMINAL_PORT) || 3001;
|
|
@@ -116,6 +116,19 @@ function listTmuxSessions(): { name: string; created: string; attached: boolean;
|
|
|
116
116
|
|
|
117
117
|
const MAX_SESSIONS = 10;
|
|
118
118
|
|
|
119
|
+
function getDefaultCwd(): string {
|
|
120
|
+
try {
|
|
121
|
+
const settingsPath = join(homedir(), '.forge', 'settings.yaml');
|
|
122
|
+
const raw = readFileSync(settingsPath, 'utf-8');
|
|
123
|
+
const match = raw.match(/projectRoots:\s*\n((?:\s+-\s+.+\n?)*)/);
|
|
124
|
+
if (match) {
|
|
125
|
+
const first = match[1].split('\n').map(l => l.replace(/^\s+-\s+/, '').trim()).filter(Boolean)[0];
|
|
126
|
+
if (first) return first.replace(/^~/, homedir());
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
return homedir();
|
|
130
|
+
}
|
|
131
|
+
|
|
119
132
|
function createTmuxSession(cols: number, rows: number): string {
|
|
120
133
|
// Auto-cleanup: if too many sessions, kill the oldest idle ones
|
|
121
134
|
const existing = listTmuxSessions();
|
|
@@ -132,7 +145,7 @@ function createTmuxSession(cols: number, rows: number): string {
|
|
|
132
145
|
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
133
146
|
const name = `${SESSION_PREFIX}${id}`;
|
|
134
147
|
execSync(`${TMUX} new-session -d -s ${name} -x ${cols} -y ${rows}`, {
|
|
135
|
-
cwd:
|
|
148
|
+
cwd: getDefaultCwd(),
|
|
136
149
|
env: { ...process.env, TERM: 'xterm-256color' },
|
|
137
150
|
});
|
|
138
151
|
// Enable mouse scrolling and set large scrollback buffer
|
|
@@ -305,7 +318,26 @@ wss.on('connection', (ws: WebSocket) => {
|
|
|
305
318
|
const cols = parsed.cols || 120;
|
|
306
319
|
const rows = parsed.rows || 30;
|
|
307
320
|
try {
|
|
308
|
-
|
|
321
|
+
// Support fixed session name (e.g. mw-docs-claude)
|
|
322
|
+
let name: string;
|
|
323
|
+
if (parsed.sessionName && parsed.sessionName.startsWith(SESSION_PREFIX)) {
|
|
324
|
+
// Create with fixed name if it doesn't exist, otherwise attach
|
|
325
|
+
if (tmuxSessionExists(parsed.sessionName)) {
|
|
326
|
+
attachToTmux(parsed.sessionName, cols, rows);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
name = parsed.sessionName;
|
|
330
|
+
execSync(`${TMUX} new-session -d -s ${name} -x ${cols} -y ${rows}`, {
|
|
331
|
+
cwd: homedir(),
|
|
332
|
+
env: { ...process.env, TERM: 'xterm-256color' },
|
|
333
|
+
});
|
|
334
|
+
try {
|
|
335
|
+
execSync(`${TMUX} set-option -t ${name} mouse on 2>/dev/null`);
|
|
336
|
+
execSync(`${TMUX} set-option -t ${name} history-limit 50000 2>/dev/null`);
|
|
337
|
+
} catch {}
|
|
338
|
+
} else {
|
|
339
|
+
name = createTmuxSession(cols, rows);
|
|
340
|
+
}
|
|
309
341
|
createdAt.set(ws, { session: name, time: Date.now() });
|
|
310
342
|
attachToTmux(name, cols, rows);
|
|
311
343
|
} catch (e: unknown) {
|
package/next-env.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/types/routes.d.ts";
|
|
3
|
+
import "./.next/dev/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aion0/forge",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"react": "^19.2.4",
|
|
43
43
|
"react-dom": "^19.2.4",
|
|
44
44
|
"react-markdown": "^10.1.0",
|
|
45
|
+
"remark-gfm": "^4.0.1",
|
|
45
46
|
"ws": "^8.19.0",
|
|
46
47
|
"yaml": "^2.8.2"
|
|
47
48
|
},
|