companionbot 0.1.1 → 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.
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Sub-agent 시스템 - 복잡한 작업을 독립적인 agent에게 위임
3
+ */
4
+ export { setAgentBot, spawnAgent, listAgents, cancelAgent, getAgent } from "./manager.js";
@@ -0,0 +1,171 @@
1
+ /**
2
+ * AgentManager - Sub-agent 생성 및 관리
3
+ *
4
+ * 각 sub-agent는:
5
+ * - 별도의 Claude API 호출로 독립 실행
6
+ * - 메인 conversation과 별개의 context
7
+ * - 비동기로 실행, 완료 시 callback
8
+ */
9
+ import Anthropic from "@anthropic-ai/sdk";
10
+ import { randomUUID } from "crypto";
11
+ // Agent 저장소
12
+ const agents = new Map();
13
+ // Bot 인스턴스 (결과 전송용)
14
+ let botInstance = null;
15
+ // Anthropic 클라이언트
16
+ let anthropic = null;
17
+ function getClient() {
18
+ if (!anthropic) {
19
+ anthropic = new Anthropic();
20
+ }
21
+ return anthropic;
22
+ }
23
+ /**
24
+ * Bot 인스턴스 설정 (시작 시 호출)
25
+ */
26
+ export function setAgentBot(bot) {
27
+ botInstance = bot;
28
+ }
29
+ /**
30
+ * Sub-agent 생성 및 실행
31
+ */
32
+ export async function spawnAgent(task, chatId) {
33
+ const id = randomUUID().slice(0, 8);
34
+ const agent = {
35
+ id,
36
+ task,
37
+ status: "running",
38
+ chatId,
39
+ createdAt: new Date(),
40
+ };
41
+ agents.set(id, agent);
42
+ // 비동기로 agent 실행 (await 하지 않음)
43
+ runAgent(agent).catch((err) => {
44
+ console.error(`[Agent ${id}] Error:`, err);
45
+ });
46
+ return id;
47
+ }
48
+ /**
49
+ * Agent 실행 (내부 함수)
50
+ */
51
+ async function runAgent(agent) {
52
+ const client = getClient();
53
+ const systemPrompt = `You are a sub-agent assistant. Your job is to complete a specific task and report the result concisely.
54
+
55
+ TASK: ${agent.task}
56
+
57
+ Guidelines:
58
+ - Focus only on the given task
59
+ - Be concise but thorough
60
+ - Report results clearly
61
+ - If you cannot complete the task, explain why
62
+
63
+ Complete the task and provide your final answer.`;
64
+ try {
65
+ console.log(`[Agent ${agent.id}] Starting: ${agent.task.slice(0, 50)}...`);
66
+ const response = await client.messages.create({
67
+ model: "claude-sonnet-4-20250514",
68
+ max_tokens: 2048,
69
+ system: systemPrompt,
70
+ messages: [
71
+ {
72
+ role: "user",
73
+ content: `Please complete this task: ${agent.task}`,
74
+ },
75
+ ],
76
+ });
77
+ // 결과 추출
78
+ const textBlock = response.content.find((block) => block.type === "text");
79
+ const result = textBlock?.text ?? "No response generated.";
80
+ // Agent 상태 업데이트
81
+ agent.status = "completed";
82
+ agent.completedAt = new Date();
83
+ agent.result = result;
84
+ console.log(`[Agent ${agent.id}] Completed`);
85
+ // 결과를 원래 chat에 전송
86
+ await sendAgentResult(agent);
87
+ }
88
+ catch (error) {
89
+ agent.status = "failed";
90
+ agent.completedAt = new Date();
91
+ agent.error = error instanceof Error ? error.message : String(error);
92
+ console.error(`[Agent ${agent.id}] Failed:`, agent.error);
93
+ // 실패도 알림
94
+ await sendAgentResult(agent);
95
+ }
96
+ }
97
+ /**
98
+ * Agent 결과를 chat에 전송
99
+ */
100
+ async function sendAgentResult(agent) {
101
+ if (!botInstance) {
102
+ console.warn("[Agent] No bot instance, cannot send result");
103
+ return;
104
+ }
105
+ let message;
106
+ if (agent.status === "completed") {
107
+ message = `🤖 **Sub-agent 완료** (${agent.id})\n\n📋 Task: ${agent.task.slice(0, 100)}${agent.task.length > 100 ? "..." : ""}\n\n✅ Result:\n${agent.result}`;
108
+ }
109
+ else if (agent.status === "failed") {
110
+ message = `🤖 **Sub-agent 실패** (${agent.id})\n\n📋 Task: ${agent.task.slice(0, 100)}${agent.task.length > 100 ? "..." : ""}\n\n❌ Error: ${agent.error}`;
111
+ }
112
+ else if (agent.status === "cancelled") {
113
+ message = `🤖 **Sub-agent 취소됨** (${agent.id})`;
114
+ }
115
+ else {
116
+ return; // running 상태면 전송 안 함
117
+ }
118
+ try {
119
+ await botInstance.api.sendMessage(agent.chatId, message);
120
+ }
121
+ catch (err) {
122
+ console.error(`[Agent ${agent.id}] Failed to send result:`, err);
123
+ }
124
+ }
125
+ /**
126
+ * Agent 목록 조회
127
+ */
128
+ export function listAgents(chatId) {
129
+ const allAgents = Array.from(agents.values());
130
+ if (chatId !== undefined) {
131
+ return allAgents.filter((a) => a.chatId === chatId);
132
+ }
133
+ return allAgents;
134
+ }
135
+ /**
136
+ * Agent 취소
137
+ */
138
+ export function cancelAgent(agentId) {
139
+ const agent = agents.get(agentId);
140
+ if (!agent) {
141
+ return false;
142
+ }
143
+ if (agent.status !== "running") {
144
+ return false; // 이미 완료된 agent는 취소 불가
145
+ }
146
+ // 실제로 실행 중인 API 호출을 취소할 수는 없지만
147
+ // 상태를 cancelled로 표시하고 결과 전송 시 무시되도록 함
148
+ agent.status = "cancelled";
149
+ agent.completedAt = new Date();
150
+ console.log(`[Agent ${agentId}] Cancelled`);
151
+ return true;
152
+ }
153
+ /**
154
+ * Agent 상태 조회
155
+ */
156
+ export function getAgent(agentId) {
157
+ return agents.get(agentId);
158
+ }
159
+ /**
160
+ * 오래된 agent 정리 (1시간 이상)
161
+ */
162
+ export function cleanupOldAgents() {
163
+ const oneHourAgo = Date.now() - 60 * 60 * 1000;
164
+ for (const [id, agent] of agents.entries()) {
165
+ if (agent.completedAt && agent.completedAt.getTime() < oneHourAgo) {
166
+ agents.delete(id);
167
+ }
168
+ }
169
+ }
170
+ // 10분마다 정리
171
+ setInterval(cleanupOldAgents, 10 * 60 * 1000);
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Sub-agent 시스템 타입 정의
3
+ */
4
+ export {};
package/dist/cli/setup.js CHANGED
@@ -4,9 +4,11 @@ async function showStatus() {
4
4
  console.log("\n=== CompanionBot 설정 상태 ===\n");
5
5
  const telegram = await getSecret("telegram-token");
6
6
  const anthropic = await getSecret("anthropic-api-key");
7
+ const brave = await getSecret("brave-api-key");
7
8
  const workspaceReady = await isWorkspaceInitialized();
8
9
  console.log(`Telegram Bot Token: ${telegram ? "✓ 설정됨" : "✗ 미설정"}`);
9
10
  console.log(`Anthropic API Key: ${anthropic ? "✓ 설정됨" : "✗ 미설정"}`);
11
+ console.log(`Brave API Key: ${brave ? "✓ 설정됨" : "✗ 미설정 (선택)"}`);
10
12
  console.log(`워크스페이스: ${workspaceReady ? "✓ 초기화됨" : "✗ 미초기화"}`);
11
13
  if (workspaceReady) {
12
14
  console.log(` 경로: ${getWorkspacePath()}`);
@@ -29,6 +31,14 @@ async function setupAnthropic(key) {
29
31
  await setSecret("anthropic-api-key", key.trim());
30
32
  console.log("✓ Anthropic API Key가 OS 키체인에 저장되었습니다.");
31
33
  }
34
+ async function setupBrave(key) {
35
+ if (!key.trim()) {
36
+ console.log("Error: API 키를 입력해주세요.");
37
+ return;
38
+ }
39
+ await setSecret("brave-api-key", key.trim());
40
+ console.log("✓ Brave API Key가 OS 키체인에 저장되었습니다.");
41
+ }
32
42
  async function main() {
33
43
  const args = process.argv.slice(2);
34
44
  const command = args[0];
@@ -51,6 +61,13 @@ async function main() {
51
61
  }
52
62
  await setupAnthropic(value);
53
63
  break;
64
+ case "brave":
65
+ if (!value) {
66
+ console.log("사용법: npm run setup brave <API_KEY>");
67
+ return;
68
+ }
69
+ await setupBrave(value);
70
+ break;
54
71
  case "delete":
55
72
  if (value === "telegram") {
56
73
  await deleteSecret("telegram-token");
@@ -60,8 +77,12 @@ async function main() {
60
77
  await deleteSecret("anthropic-api-key");
61
78
  console.log("✓ Anthropic API Key 삭제됨");
62
79
  }
80
+ else if (value === "brave") {
81
+ await deleteSecret("brave-api-key");
82
+ console.log("✓ Brave API Key 삭제됨");
83
+ }
63
84
  else {
64
- console.log("사용법: npm run setup delete <telegram|anthropic>");
85
+ console.log("사용법: npm run setup delete <telegram|anthropic|brave>");
65
86
  }
66
87
  break;
67
88
  case "init":
@@ -91,12 +112,13 @@ async function main() {
91
112
  CompanionBot 설정
92
113
 
93
114
  사용법:
94
- npm run setup status 현재 설정 상태 확인
95
- npm run setup telegram <TOKEN> Telegram Bot Token 설정
96
- npm run setup anthropic <API_KEY> Anthropic API Key 설정
97
- npm run setup delete <telegram|anthropic> 삭제
98
- npm run setup init 워크스페이스 초기화
99
- npm run setup reset workspace 워크스페이스 리셋
115
+ npm run setup status 현재 설정 상태 확인
116
+ npm run setup telegram <TOKEN> Telegram Bot Token 설정
117
+ npm run setup anthropic <API_KEY> Anthropic API Key 설정
118
+ npm run setup brave <API_KEY> Brave API Key 설정 (선택, 웹 검색용)
119
+ npm run setup delete <telegram|anthropic|brave> 키 삭제
120
+ npm run setup init 워크스페이스 초기화
121
+ npm run setup reset workspace 워크스페이스 리셋
100
122
  `);
101
123
  }
102
124
  }
@@ -2,6 +2,7 @@ import { Bot } from "grammy";
2
2
  import { setBotInstance, restoreReminders } from "../reminders/index.js";
3
3
  import { setBriefingBot, restoreBriefings } from "../briefing/index.js";
4
4
  import { setHeartbeatBot, restoreHeartbeats } from "../heartbeat/index.js";
5
+ import { setAgentBot } from "../agents/index.js";
5
6
  import { registerCommands, registerMessageHandlers } from "./handlers/index.js";
6
7
  // Re-export for external use
7
8
  export { invalidateWorkspaceCache } from "./utils/index.js";
@@ -19,6 +20,8 @@ export function createBot(token) {
19
20
  // Heartbeat 초기화
20
21
  setHeartbeatBot(bot);
21
22
  restoreHeartbeats().catch((err) => console.error("Failed to restore heartbeats:", err));
23
+ // Sub-agent 시스템 초기화
24
+ setAgentBot(bot);
22
25
  // 에러 핸들링
23
26
  bot.catch((err) => {
24
27
  console.error("Bot error:", err);
@@ -1,8 +1,9 @@
1
1
  import * as fs from "fs/promises";
2
2
  import * as fsSync from "fs";
3
3
  import * as path from "path";
4
- import { exec } from "child_process";
4
+ import { exec, spawn } from "child_process";
5
5
  import { promisify } from "util";
6
+ import { randomUUID } from "crypto";
6
7
  import { MODELS } from "../ai/claude.js";
7
8
  import { getCurrentChatId, setModel, getModel } from "../session/state.js";
8
9
  import { getWorkspacePath, saveWorkspaceFile, appendToMemory, deleteBootstrap, } from "../workspace/index.js";
@@ -11,7 +12,21 @@ import { createReminder, deleteReminder, getReminders, parseTimeExpression, } fr
11
12
  import { isCalendarConfigured, getEvents, addEvent, deleteEvent, formatEvent, parseDateExpression, } from "../calendar/index.js";
12
13
  import { setHeartbeatConfig, getHeartbeatConfig, disableHeartbeat, runHeartbeatNow, } from "../heartbeat/index.js";
13
14
  import { setBriefingConfig, getBriefingConfig, disableBriefing, sendBriefingNow, } from "../briefing/index.js";
15
+ import { spawnAgent, listAgents, cancelAgent, } from "../agents/index.js";
16
+ import * as cheerio from "cheerio";
14
17
  const execAsync = promisify(exec);
18
+ // 메모리에 세션 저장
19
+ const sessions = new Map();
20
+ // Output buffer 최대 크기 (라인 수)
21
+ const MAX_OUTPUT_LINES = 1000;
22
+ function appendOutput(session, data) {
23
+ const lines = data.split("\n");
24
+ session.outputBuffer.push(...lines);
25
+ // 버퍼 크기 제한
26
+ if (session.outputBuffer.length > MAX_OUTPUT_LINES) {
27
+ session.outputBuffer = session.outputBuffer.slice(-MAX_OUTPUT_LINES);
28
+ }
29
+ }
15
30
  // 홈 디렉토리
16
31
  const home = process.env.HOME || "";
17
32
  // 허용된 디렉토리 (보안을 위해 제한)
@@ -118,7 +133,13 @@ export const tools = [
118
133
  },
119
134
  {
120
135
  name: "run_command",
121
- description: "Run a shell command. Use with caution. Only for safe commands like git status, npm run, etc.",
136
+ description: `Run a shell command. Use with caution. Only for safe commands like git status, npm run, etc.
137
+
138
+ When background=true:
139
+ - Command runs in detached mode
140
+ - Returns a session ID immediately
141
+ - Use list_sessions, get_session_log, kill_session to manage
142
+ - Useful for long-running commands (npm run dev, servers, etc.)`,
122
143
  input_schema: {
123
144
  type: "object",
124
145
  properties: {
@@ -130,10 +151,70 @@ export const tools = [
130
151
  type: "string",
131
152
  description: "The working directory to run the command in (optional)",
132
153
  },
154
+ background: {
155
+ type: "boolean",
156
+ description: "Run in background and return session ID (default: false)",
157
+ },
158
+ timeout: {
159
+ type: "number",
160
+ description: "Timeout in seconds for foreground commands (default: 30)",
161
+ },
133
162
  },
134
163
  required: ["command"],
135
164
  },
136
165
  },
166
+ {
167
+ name: "list_sessions",
168
+ description: "List all background command sessions. Shows running and recently completed sessions.",
169
+ input_schema: {
170
+ type: "object",
171
+ properties: {
172
+ status: {
173
+ type: "string",
174
+ enum: ["all", "running", "completed"],
175
+ description: "Filter by status (default: all)",
176
+ },
177
+ },
178
+ required: [],
179
+ },
180
+ },
181
+ {
182
+ name: "get_session_log",
183
+ description: "Get the output log of a background session.",
184
+ input_schema: {
185
+ type: "object",
186
+ properties: {
187
+ session_id: {
188
+ type: "string",
189
+ description: "The session ID to get logs from",
190
+ },
191
+ tail: {
192
+ type: "number",
193
+ description: "Number of lines from the end (default: 50)",
194
+ },
195
+ },
196
+ required: ["session_id"],
197
+ },
198
+ },
199
+ {
200
+ name: "kill_session",
201
+ description: "Kill a running background session.",
202
+ input_schema: {
203
+ type: "object",
204
+ properties: {
205
+ session_id: {
206
+ type: "string",
207
+ description: "The session ID to kill",
208
+ },
209
+ signal: {
210
+ type: "string",
211
+ enum: ["SIGTERM", "SIGKILL", "SIGINT"],
212
+ description: "Signal to send (default: SIGTERM)",
213
+ },
214
+ },
215
+ required: ["session_id"],
216
+ },
217
+ },
137
218
  {
138
219
  name: "change_model",
139
220
  description: `Change the AI model for this conversation. Use this when the user asks to switch models, or when you determine a different model would be better suited for the task.
@@ -399,6 +480,101 @@ Use this when the user says things like:
399
480
  required: [],
400
481
  },
401
482
  },
483
+ // ============== Sub-Agent 도구 ==============
484
+ {
485
+ name: "spawn_agent",
486
+ description: `Create a sub-agent to handle a complex or time-consuming task independently.
487
+
488
+ The sub-agent will:
489
+ - Run in the background with its own Claude API context
490
+ - Complete the task independently
491
+ - Report results back to this chat when done
492
+
493
+ Use this for:
494
+ - Tasks that require deep focus or analysis
495
+ - Long-running research or summarization
496
+ - Work that can be done in parallel while you handle other things
497
+
498
+ Example: "서브에이전트한테 이 코드 분석 시켜줘"`,
499
+ input_schema: {
500
+ type: "object",
501
+ properties: {
502
+ task: {
503
+ type: "string",
504
+ description: "Detailed description of the task for the sub-agent",
505
+ },
506
+ },
507
+ required: ["task"],
508
+ },
509
+ },
510
+ {
511
+ name: "list_agents",
512
+ description: "List all sub-agents and their status (running, completed, failed, cancelled).",
513
+ input_schema: {
514
+ type: "object",
515
+ properties: {},
516
+ required: [],
517
+ },
518
+ },
519
+ {
520
+ name: "cancel_agent",
521
+ description: "Cancel a running sub-agent by its ID.",
522
+ input_schema: {
523
+ type: "object",
524
+ properties: {
525
+ agent_id: {
526
+ type: "string",
527
+ description: "The sub-agent ID to cancel",
528
+ },
529
+ },
530
+ required: ["agent_id"],
531
+ },
532
+ },
533
+ // ============== 웹 검색/가져오기 ==============
534
+ {
535
+ name: "web_search",
536
+ description: `Search the web using Brave Search API. Use when the user asks to search for information online.
537
+
538
+ Examples:
539
+ - "최신 뉴스 검색해줘" → query: "최신 뉴스"
540
+ - "React 19 새로운 기능" → query: "React 19 new features"`,
541
+ input_schema: {
542
+ type: "object",
543
+ properties: {
544
+ query: {
545
+ type: "string",
546
+ description: "Search query",
547
+ },
548
+ count: {
549
+ type: "number",
550
+ description: "Number of results to return (default: 5, max: 20)",
551
+ },
552
+ },
553
+ required: ["query"],
554
+ },
555
+ },
556
+ {
557
+ name: "web_fetch",
558
+ description: `Fetch and extract readable content from a URL. Use when you need to read the content of a web page.
559
+
560
+ Examples:
561
+ - "이 링크 내용 요약해줘" → url: "https://..."
562
+ - "이 기사 읽어줘" → url: "https://..."`,
563
+ input_schema: {
564
+ type: "object",
565
+ properties: {
566
+ url: {
567
+ type: "string",
568
+ description: "The URL to fetch",
569
+ },
570
+ maxChars: {
571
+ type: "number",
572
+ description: "Maximum characters to return (default: 5000)",
573
+ },
574
+ },
575
+ required: ["url"],
576
+ },
577
+ },
402
578
  ];
403
579
  // Tool 실행 함수
404
580
  export async function executeTool(name, input) {
@@ -434,6 +610,8 @@ export async function executeTool(name, input) {
434
610
  case "run_command": {
435
611
  const command = input.command;
436
612
  const cwd = input.cwd || path.join(home, "Documents");
613
+ const background = input.background || false;
614
+ const timeout = (input.timeout || 30) * 1000;
437
615
  // 화이트리스트 방식: 허용된 명령어만 실행
438
616
  const ALLOWED_COMMANDS = [
439
617
  "git", "npm", "npx", "node", "ls", "pwd", "cat", "head", "tail",
@@ -455,18 +633,66 @@ export async function executeTool(name, input) {
455
633
  if (dangerousArgs.some(arg => parts.includes(arg))) {
456
634
  return `Error: Dangerous argument detected.`;
457
635
  }
458
- try {
459
- // 환경 변수는 필요한 것만 화이트리스트로 전달 (민감 정보 노출 방지)
460
- const safeEnv = {
461
- PATH: process.env.PATH || "",
462
- HOME: process.env.HOME || "",
463
- USER: process.env.USER || "",
464
- LANG: process.env.LANG || "en_US.UTF-8",
465
- TERM: process.env.TERM || "xterm",
636
+ // 환경 변수는 필요한 것만 화이트리스트로 전달 (민감 정보 노출 방지)
637
+ const safeEnv = {
638
+ PATH: process.env.PATH || "",
639
+ HOME: process.env.HOME || "",
640
+ USER: process.env.USER || "",
641
+ LANG: process.env.LANG || "en_US.UTF-8",
642
+ TERM: process.env.TERM || "xterm",
643
+ };
644
+ // Background 실행
645
+ if (background) {
646
+ const sessionId = randomUUID().slice(0, 8);
647
+ const child = spawn("sh", ["-c", command], {
648
+ cwd,
649
+ env: safeEnv,
650
+ detached: true,
651
+ stdio: ["ignore", "pipe", "pipe"],
652
+ });
653
+ const session = {
654
+ id: sessionId,
655
+ pid: child.pid,
656
+ command,
657
+ cwd,
658
+ startTime: new Date(),
659
+ outputBuffer: [],
660
+ process: child,
661
+ status: "running",
466
662
  };
663
+ // stdout/stderr 캡처
664
+ child.stdout?.on("data", (data) => {
665
+ appendOutput(session, data.toString());
666
+ });
667
+ child.stderr?.on("data", (data) => {
668
+ appendOutput(session, `[stderr] ${data.toString()}`);
669
+ });
670
+ // 프로세스 종료 핸들링
671
+ child.on("close", (code) => {
672
+ session.endTime = new Date();
673
+ session.exitCode = code;
674
+ session.status = code === 0 ? "completed" : "error";
675
+ });
676
+ child.on("error", (err) => {
677
+ session.status = "error";
678
+ appendOutput(session, `[error] ${err.message}`);
679
+ });
680
+ // unref로 부모 프로세스와 분리
681
+ child.unref();
682
+ sessions.set(sessionId, session);
683
+ return `Background session started.
684
+ Session ID: ${sessionId}
685
+ PID: ${child.pid}
686
+ Command: ${command}
687
+ CWD: ${cwd}
688
+
689
+ Use list_sessions to see all sessions, get_session_log to view output, kill_session to terminate.`;
690
+ }
691
+ // Foreground 실행 (기존 방식)
692
+ try {
467
693
  const { stdout, stderr } = await execAsync(command, {
468
694
  cwd,
469
- timeout: 30000,
695
+ timeout,
470
696
  env: safeEnv,
471
697
  });
472
698
  return stdout || stderr || "Command executed (no output)";
@@ -475,6 +701,87 @@ export async function executeTool(name, input) {
475
701
  return `Error: ${error instanceof Error ? error.message : String(error)}`;
476
702
  }
477
703
  }
704
+ case "list_sessions": {
705
+ const statusFilter = input.status || "all";
706
+ const sessionList = [];
707
+ for (const [id, session] of sessions) {
708
+ // 상태 필터링
709
+ if (statusFilter !== "all") {
710
+ if (statusFilter === "running" && session.status !== "running")
711
+ continue;
712
+ if (statusFilter === "completed" && session.status === "running")
713
+ continue;
714
+ }
715
+ const runtime = session.endTime
716
+ ? `${Math.round((session.endTime.getTime() - session.startTime.getTime()) / 1000)}s`
717
+ : `${Math.round((Date.now() - session.startTime.getTime()) / 1000)}s (running)`;
718
+ const status = session.status === "running"
719
+ ? "🟢 running"
720
+ : session.status === "completed"
721
+ ? "✅ completed"
722
+ : session.status === "killed"
723
+ ? "🔴 killed"
724
+ : "❌ error";
725
+ sessionList.push(`[${id}] ${status}
726
+ Command: ${session.command}
727
+ PID: ${session.pid}
728
+ Runtime: ${runtime}
729
+ Exit code: ${session.exitCode ?? "N/A"}`);
730
+ }
731
+ if (sessionList.length === 0) {
732
+ return `No sessions found${statusFilter !== "all" ? ` with status "${statusFilter}"` : ""}.`;
733
+ }
734
+ return `Sessions (${sessionList.length}):\n\n${sessionList.join("\n\n")}`;
735
+ }
736
+ case "get_session_log": {
737
+ const sessionId = input.session_id;
738
+ const tail = input.tail || 50;
739
+ const session = sessions.get(sessionId);
740
+ if (!session) {
741
+ return `Error: Session "${sessionId}" not found. Use list_sessions to see available sessions.`;
742
+ }
743
+ const lines = session.outputBuffer.slice(-tail);
744
+ if (lines.length === 0) {
745
+ return `Session ${sessionId} has no output yet.
746
+ Status: ${session.status}
747
+ Command: ${session.command}`;
748
+ }
749
+ const header = `Session: ${sessionId} (${session.status})
750
+ Command: ${session.command}
751
+ Showing last ${lines.length} lines:
752
+ ${"─".repeat(40)}`;
753
+ return `${header}\n${lines.join("\n")}`;
754
+ }
755
+ case "kill_session": {
756
+ const sessionId = input.session_id;
757
+ const signal = input.signal || "SIGTERM";
758
+ const session = sessions.get(sessionId);
759
+ if (!session) {
760
+ return `Error: Session "${sessionId}" not found.`;
761
+ }
762
+ if (session.status !== "running") {
763
+ return `Session ${sessionId} is not running (status: ${session.status}).`;
764
+ }
765
+ try {
766
+ // Process group kill (negative PID)
767
+ process.kill(-session.pid, signal);
768
+ session.status = "killed";
769
+ session.endTime = new Date();
770
+ return `Session ${sessionId} (PID ${session.pid}) killed with ${signal}.`;
771
+ }
772
+ catch (error) {
773
+ // 단일 프로세스 kill 시도
774
+ try {
775
+ session.process.kill(signal);
776
+ session.status = "killed";
777
+ session.endTime = new Date();
778
+ return `Session ${sessionId} killed with ${signal}.`;
779
+ }
780
+ catch (e) {
781
+ return `Error killing session: ${error instanceof Error ? error.message : String(error)}`;
782
+ }
783
+ }
784
+ }
478
785
  case "change_model": {
479
786
  const modelId = input.model;
480
787
  const reason = input.reason || "";
@@ -750,6 +1057,138 @@ export async function executeTool(name, input) {
750
1057
  await sendBriefingNow(chatId);
751
1058
  return "Briefing sent!";
752
1059
  }
1060
+ // ============== Sub-Agent 도구 ==============
1061
+ case "spawn_agent": {
1062
+ const chatId = getCurrentChatId();
1063
+ if (!chatId) {
1064
+ return "Error: No active chat session";
1065
+ }
1066
+ const task = input.task;
1067
+ if (!task || task.trim().length === 0) {
1068
+ return "Error: Task description is required";
1069
+ }
1070
+ const agentId = await spawnAgent(task, chatId);
1071
+ return `Sub-agent spawned! 🤖\nID: ${agentId}\nTask: ${task.slice(0, 100)}${task.length > 100 ? "..." : ""}\n\nThe agent is working in the background. Results will be sent to this chat when complete.`;
1072
+ }
1073
+ case "list_agents": {
1074
+ const chatId = getCurrentChatId();
1075
+ const agents = listAgents(chatId || undefined);
1076
+ if (agents.length === 0) {
1077
+ return "No sub-agents found.";
1078
+ }
1079
+ const lines = agents.map((a) => {
1080
+ const status = {
1081
+ running: "🔄 Running",
1082
+ completed: "✅ Completed",
1083
+ failed: "❌ Failed",
1084
+ cancelled: "⏹️ Cancelled",
1085
+ }[a.status];
1086
+ const time = a.completedAt
1087
+ ? `(${Math.round((a.completedAt.getTime() - a.createdAt.getTime()) / 1000)}s)`
1088
+ : "";
1089
+ return `${a.id}: ${status} ${time}\n Task: ${a.task.slice(0, 60)}${a.task.length > 60 ? "..." : ""}`;
1090
+ });
1091
+ return `Sub-agents:\n${lines.join("\n\n")}`;
1092
+ }
1093
+ case "cancel_agent": {
1094
+ const agentId = input.agent_id;
1095
+ if (!agentId) {
1096
+ return "Error: Agent ID is required";
1097
+ }
1098
+ const success = cancelAgent(agentId);
1099
+ if (success) {
1100
+ return `Sub-agent ${agentId} cancelled.`;
1101
+ }
1102
+ else {
1103
+ return `Could not cancel agent ${agentId}. It may not exist or already completed.`;
1104
+ }
1105
+ }
1106
+ // ============== 웹 검색/가져오기 ==============
1107
+ case "web_search": {
1108
+ const query = input.query;
1109
+ const count = Math.min(Math.max(input.count || 5, 1), 20);
1110
+ const apiKey = await getSecret("brave-api-key");
1111
+ if (!apiKey) {
1112
+ return "Error: Brave API key not configured. Ask user to set it up with: npm run setup brave <API_KEY>";
1113
+ }
1114
+ try {
1115
+ const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
1116
+ const response = await fetch(url, {
1117
+ headers: {
1118
+ "Accept": "application/json",
1119
+ "X-Subscription-Token": apiKey,
1120
+ },
1121
+ });
1122
+ if (!response.ok) {
1123
+ return `Error: Brave Search API returned ${response.status}: ${response.statusText}`;
1124
+ }
1125
+ const data = await response.json();
1126
+ const results = data.web?.results || [];
1127
+ if (results.length === 0) {
1128
+ return `No results found for "${query}"`;
1129
+ }
1130
+ const formatted = results.map((r, i) => {
1131
+ return `${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.description || ""}`;
1132
+ });
1133
+ return `Search results for "${query}":\n\n${formatted.join("\n\n")}`;
1134
+ }
1135
+ catch (error) {
1136
+ return `Error searching: ${error instanceof Error ? error.message : String(error)}`;
1137
+ }
1138
+ }
1139
+ case "web_fetch": {
1140
+ const url = input.url;
1141
+ const maxChars = input.maxChars || 5000;
1142
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
1143
+ return "Error: URL must start with http:// or https://";
1144
+ }
1145
+ try {
1146
+ const response = await fetch(url, {
1147
+ headers: {
1148
+ "User-Agent": "Mozilla/5.0 (compatible; CompanionBot/1.0)",
1149
+ },
1150
+ });
1151
+ if (!response.ok) {
1152
+ return `Error: Failed to fetch URL (${response.status}: ${response.statusText})`;
1153
+ }
1154
+ const html = await response.text();
1155
+ const $ = cheerio.load(html);
1156
+ // 불필요한 요소 제거
1157
+ $("script, style, nav, header, footer, aside, iframe, noscript").remove();
1158
+ // 본문 텍스트 추출
1159
+ let text = "";
1160
+ // article 태그 우선
1161
+ const article = $("article");
1162
+ if (article.length > 0) {
1163
+ text = article.text();
1164
+ }
1165
+ else {
1166
+ // main 태그 시도
1167
+ const main = $("main");
1168
+ if (main.length > 0) {
1169
+ text = main.text();
1170
+ }
1171
+ else {
1172
+ // body 전체
1173
+ text = $("body").text();
1174
+ }
1175
+ }
1176
+ // 공백 정리
1177
+ text = text
1178
+ .replace(/\s+/g, " ")
1179
+ .replace(/\n\s*\n/g, "\n")
1180
+ .trim();
1181
+ // 길이 제한
1182
+ if (text.length > maxChars) {
1183
+ text = text.slice(0, maxChars) + "... (truncated)";
1184
+ }
1185
+ const title = $("title").text().trim() || "No title";
1186
+ return `Title: ${title}\n\nContent:\n${text}`;
1187
+ }
1188
+ catch (error) {
1189
+ return `Error fetching URL: ${error instanceof Error ? error.message : String(error)}`;
1190
+ }
1191
+ }
753
1192
  default:
754
1193
  return `Error: Unknown tool: ${name}`;
755
1194
  }
@@ -772,6 +1211,10 @@ export function getToolsDescription(modelId) {
772
1211
 
773
1212
  ## 시스템
774
1213
  - run_command: 셸 명령어 실행 (git, npm 등)
1214
+ - background=true: 백그라운드 실행, 세션 ID 반환
1215
+ - list_sessions: 백그라운드 세션 목록
1216
+ - get_session_log: 세션 출력 로그 조회
1217
+ - kill_session: 세션 종료
775
1218
  - change_model: AI 모델 변경
776
1219
  - sonnet: 범용 (기본)
777
1220
  - opus: 복잡한 작업
@@ -804,5 +1247,14 @@ export function getToolsDescription(modelId) {
804
1247
  ## 온보딩
805
1248
  - save_persona: 페르소나 설정 저장 (온보딩 완료 시)
806
1249
 
1250
+ ## Sub-Agent (백그라운드 작업)
1251
+ - spawn_agent: 복잡한 작업을 sub-agent에게 위임 (독립 실행)
1252
+ - list_agents: 활성 sub-agent 목록
1253
+ - cancel_agent: sub-agent 취소
1254
+
1255
+ ## 웹 검색/가져오기
1256
+ - web_search: Brave Search API로 웹 검색 (query, count)
1257
+ - web_fetch: URL에서 본문 텍스트 추출 (url, maxChars)
1258
+
807
1259
  허용된 경로: ${path.join(home, "Documents")}, ${path.join(home, "projects")}, 워크스페이스`;
808
1260
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "companionbot",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "AI 친구 텔레그램 봇 - Claude API 기반 개인화된 대화 상대",
5
5
  "keywords": [
6
6
  "telegram",