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.
- package/dist/agents/index.js +4 -0
- package/dist/agents/manager.js +171 -0
- package/dist/agents/types.js +4 -0
- package/dist/cli/setup.js +29 -7
- package/dist/telegram/bot.js +3 -0
- package/dist/tools/index.js +463 -11
- package/package.json +1 -1
|
@@ -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);
|
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>
|
|
96
|
-
npm run setup anthropic <API_KEY>
|
|
97
|
-
npm run setup
|
|
98
|
-
npm run setup
|
|
99
|
-
npm run setup
|
|
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
|
}
|
package/dist/telegram/bot.js
CHANGED
|
@@ -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);
|
package/dist/tools/index.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
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
|
}
|