cdsa-harness 0.14.2 → 0.19.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.
@@ -0,0 +1,56 @@
1
+ // Tab 자동완성 — readline completer (의존성 0).
2
+ // 지원: /슬래시명령(내장+스킬) · /provider 인자 · @파일명 멘션(작업 폴더 최상위)
3
+ import fs from "node:fs";
4
+
5
+ export const BUILTIN_COMMANDS = [
6
+ "/help", "/guide", "/tutorial", "/about",
7
+ "/setup", "/provider", "/model", "/models", "/update",
8
+ "/teach", "/stream", "/color", "/auto",
9
+ "/context", "/status", "/cost", "/config", "/sessions",
10
+ "/workspace", "/cd", "/init", "/memory",
11
+ "/skills", "/plugins", "/mcp",
12
+ "/new", "/clear", "/reset", "/compact", "/resume", "/undo",
13
+ "/quit", "/exit",
14
+ ];
15
+
16
+ // ctx: { skills(): {name:...}, workspace(): string|null, providers: string[] }
17
+ export function makeCompleter(ctx) {
18
+ return (line) => {
19
+ // 1) /provider <인자> 자동완성
20
+ if (line.startsWith("/provider ")) {
21
+ const frag = line.slice("/provider ".length);
22
+ const hits = (ctx.providers || [])
23
+ .filter((p) => p.startsWith(frag))
24
+ .map((p) => "/provider " + p);
25
+ return [hits, line];
26
+ }
27
+ // 2) 슬래시 명령(내장 + 스킬) — 첫 단어 입력 중일 때
28
+ if (line.startsWith("/") && !line.includes(" ")) {
29
+ let skills = {};
30
+ try {
31
+ skills = ctx.skills() || {};
32
+ } catch { /* 초기화 전 */ }
33
+ const names = [...new Set([...BUILTIN_COMMANDS, ...Object.keys(skills).map((s) => "/" + s)])].sort();
34
+ const hits = names.filter((n) => n.startsWith(line));
35
+ return [hits.length ? hits : names, line];
36
+ }
37
+ // 3) @파일명 멘션 — 작업 폴더 최상위 파일
38
+ const m = /@([\w가-힣./\\-]*)$/.exec(line);
39
+ if (m) {
40
+ let ws = null;
41
+ try {
42
+ ws = ctx.workspace();
43
+ } catch { /* 초기화 전 */ }
44
+ if (ws) {
45
+ let files = [];
46
+ try {
47
+ files = fs.readdirSync(ws).filter((f) => !f.startsWith("."));
48
+ } catch { /* ignore */ }
49
+ const head = line.slice(0, m.index);
50
+ const hits = files.filter((f) => f.startsWith(m[1])).map((f) => head + "@" + f);
51
+ return [hits, line];
52
+ }
53
+ }
54
+ return [[], line];
55
+ };
56
+ }
package/src/config.js CHANGED
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
 
7
- export const PROVIDERS = ["openai", "anthropic", "openrouter", "mock"];
7
+ export const PROVIDERS = ["openai", "anthropic", "openrouter", "ollama", "mock"];
8
8
 
9
9
  export const SUGGESTED_MODELS = {
10
10
  openai: ["gpt-4o-mini", "gpt-4.1-mini", "gpt-4.1"],
@@ -15,6 +15,8 @@ export const SUGGESTED_MODELS = {
15
15
  "anthropic/claude-3.7-sonnet",
16
16
  "google/gemini-2.0-flash-001",
17
17
  ],
18
+ // 로컬/폐쇄망(Ollama). 도구 호출(tools) 지원 모델 권장. exaone 은 한국어 특화(LG).
19
+ ollama: ["qwen2.5:7b", "llama3.1:8b", "exaone3.5:7.8b"],
18
20
  mock: ["mock-agent"],
19
21
  };
20
22
 
@@ -83,6 +85,7 @@ export class Config {
83
85
 
84
86
  isReady() {
85
87
  if (this.provider === "mock") return true;
88
+ if (this.provider === "ollama") return true; // 로컬 LLM — API 키 불필요
86
89
  return Boolean(this.resolvedKey());
87
90
  }
88
91
 
package/src/llm.js CHANGED
@@ -11,6 +11,8 @@ const ENDPOINTS = {
11
11
  openai: "https://api.openai.com/v1/chat/completions",
12
12
  openrouter: "https://openrouter.ai/api/v1/chat/completions",
13
13
  anthropic: "https://api.anthropic.com/v1/messages",
14
+ // Ollama(로컬/폐쇄망)는 OpenAI 호환 API 를 제공한다. OLLAMA_HOST 로 호스트 변경 가능.
15
+ ollama: `${(process.env.OLLAMA_HOST || "http://localhost:11434").replace(/\/$/, "")}/v1/chat/completions`,
14
16
  };
15
17
 
16
18
  export class LLMClient {
@@ -55,7 +57,7 @@ export class LLMClient {
55
57
  res = await fetch(url, { method: "POST", headers, body: json, signal: ctrl.signal });
56
58
  } catch (e) {
57
59
  clearTimeout(timer);
58
- throw new LLMError(`네트워크 오류: ${e.message}`);
60
+ throw new LLMError(this._netErrorMessage(e));
59
61
  }
60
62
  if (!res.ok) {
61
63
  clearTimeout(timer);
@@ -65,6 +67,14 @@ export class LLMClient {
65
67
  return { res, started, timer, bodyBytes: Buffer.byteLength(json, "utf8") };
66
68
  }
67
69
 
70
+ _netErrorMessage(e) {
71
+ let msg = `네트워크 오류: ${e.message}`;
72
+ if (this.provider === "ollama") {
73
+ msg += "\n ↳ Ollama 가 실행 중인지 확인하세요: `ollama serve` (모델 설치: `ollama pull qwen2.5:7b`)";
74
+ }
75
+ return msg;
76
+ }
77
+
68
78
  async _post(url, headers, body) {
69
79
  const json = JSON.stringify(body);
70
80
  const ctrl = new AbortController();
@@ -74,7 +84,7 @@ export class LLMClient {
74
84
  try {
75
85
  res = await fetch(url, { method: "POST", headers, body: json, signal: ctrl.signal });
76
86
  } catch (e) {
77
- throw new LLMError(`네트워크 오류: ${e.message}`);
87
+ throw new LLMError(this._netErrorMessage(e));
78
88
  } finally {
79
89
  clearTimeout(timer);
80
90
  }
package/src/loop.js CHANGED
@@ -62,6 +62,24 @@ function summarizeMessages(messages) {
62
62
  return { rows, totalChars, estTokens: estimateTokens(messages.map((m) => (m.content || "") + JSON.stringify(m.tool_calls || "")).join("")) };
63
63
  }
64
64
 
65
+ // 서브에이전트 위임 도구 — 최상위(depth 0)에서만 노출해 중첩 위임을 막는다.
66
+ const SPAWN_AGENT_SCHEMA = {
67
+ type: "function",
68
+ function: {
69
+ name: "spawn_agent",
70
+ description:
71
+ "독립적인 하위 작업을 서브에이전트에게 위임한다. 서브에이전트는 같은 도구(읽기/검색/수정)로 작업하고 결과 텍스트를 돌려준다. 크고 스스로 완결되는 작업 덩어리에만 사용하고, 간단한 일은 직접 도구를 호출하라.",
72
+ parameters: {
73
+ type: "object",
74
+ properties: {
75
+ task: { type: "string", description: "위임할 작업 지시(구체적으로)" },
76
+ context: { type: "string", description: "작업에 필요한 배경 정보(선택)" },
77
+ },
78
+ required: ["task"],
79
+ },
80
+ },
81
+ };
82
+
65
83
  const RULES_FILENAMES = ["AGENT.md", "AGENTS.md", "CLAUDE.md", "rules.md", "RULES.md"];
66
84
 
67
85
  function findRules(workspace) {
@@ -79,7 +97,7 @@ function findRules(workspace) {
79
97
  }
80
98
 
81
99
  export class AgentLoop {
82
- constructor({ config, client, toolbox, onEvent, approvalCallback, session = null, onToken = null }) {
100
+ constructor({ config, client, toolbox, onEvent, approvalCallback, session = null, onToken = null, depth = 0 }) {
83
101
  this.config = config;
84
102
  this.client = client;
85
103
  this.toolbox = toolbox;
@@ -87,7 +105,9 @@ export class AgentLoop {
87
105
  this.approvalCallback = approvalCallback;
88
106
  this.session = session;
89
107
  this.onToken = onToken; // 스트리밍 토큰 콜백(있으면 실시간 출력)
108
+ this.depth = depth; // 0=최상위, 1=서브에이전트(중첩 위임 불가)
90
109
  this.messages = [];
110
+ this.usage = { input: 0, output: 0, total: 0, calls: 0 }; // 세션 누적 토큰(/status, /cost)
91
111
  }
92
112
 
93
113
  _emit(step, title = "", detail = "", data = {}) {
@@ -99,28 +119,45 @@ export class AgentLoop {
99
119
  _systemPrompt() {
100
120
  const ws = this.toolbox.workspace;
101
121
  const { name: rulesName, text: rulesText } = findRules(ws);
102
- let listing;
122
+ // 폴더 '전체 목록'을 박아넣지 않는다(낡은 정보·토큰 낭비 방지) — 최상위 이름만 참고로 주고
123
+ // 최신 상태는 도구(list_dir/search_files)로 확인하게 한다. (OpenCode/Claude Code 방식)
124
+ let topLevel = "";
103
125
  try {
104
- listing = this.toolbox.listDir(".").output;
126
+ const names = fs.readdirSync(ws).filter((n) => !n.startsWith(".")).sort();
127
+ topLevel = names.slice(0, 15).join(", ") + (names.length > 15 ? ` … (총 ${names.length}개)` : "");
105
128
  } catch {
106
- listing = "(폴더를 읽을 수 없음)";
129
+ topLevel = "(읽을 수 없음)";
107
130
  }
108
- const toolNames = ["list_dir", "read_file", "write_file"].concat(
109
- this.config.allow_shell ? ["run_shell"] : []
110
- );
111
- const toolsDesc = toolNames.map((n) => `${n}(${TOOL_LABELS[n]})`).join(", ");
131
+ const pluginNames = (this.toolbox.plugins || []).map((p) => p.name).join(", ");
132
+ const now = new Date();
133
+ const day = ["일", "월", "화", "수", "목", "금", "토"][now.getDay()];
112
134
 
113
135
  const parts = [
114
- "당신은 'CDSA Harness' 안에서 동작하는 소형 코딩 에이전트입니다.",
115
- "당신은 직접 파일을 만질 없습니다. 반드시 제공된 도구로만 작업 폴더를 다룹니다.",
116
- `사용 가능한 도구: ${toolsDesc}.`,
117
- "파일을 수정할 때는 write_file 에 '파일 전체 내용'을 담아 호출하세요(부분 패치 아님).",
118
- "추측하지 말고, 필요하면 먼저 read_file/list_dir 로 사실을 확인하세요.",
119
- "작업이 끝나면 도구를 더 호출하지 말고 한국어로 결과를 요약하세요.",
120
- `\n[작업 폴더 루트]\n${ws}`,
121
- `\n[현재 폴더 내용]\n${listing}`,
122
- ];
123
- if (rulesText) parts.push(`\n[규칙 파일 ${rulesName}]\n${rulesText.trim()}`);
136
+ "# 정체성",
137
+ "당신은 CDSA Harness(made by CDSA) 안에서 동작하는 코딩·업무 에이전트입니다.",
138
+ "파일시스템에 직접 접근할 수 없으며, 제공된 도구 호출로만 작업 폴더를 다룹니다.",
139
+ "",
140
+ "# 환경",
141
+ `- 작업 폴더(루트): ${ws}`,
142
+ `- 최상위 항목(참고용 스냅샷): ${topLevel || "(빈 폴더)"}`,
143
+ `- 플랫폼: ${process.platform} · Node ${process.versions.node} · 오늘: ${now.toISOString().slice(0, 10)}(${day})`,
144
+ "- 모든 도구는 작업 폴더 밖으로 나갈 수 없습니다(sandbox). 파일 수정·셸 실행은 사용자 승인 후에만 적용됩니다.",
145
+ "",
146
+ "# 도구 사용 원칙",
147
+ "- 추측 금지: 파일 위치·내용이 불확실하면 먼저 search_files / list_dir / read_file 로 사실을 확인한다.",
148
+ "- 기존 파일의 일부 수정은 edit_file(old_text 는 파일에서 유일해야 함), 새 파일·전체 교체만 write_file 을 쓴다.",
149
+ "- 도구가 오류를 돌려주면 같은 호출을 반복하지 말고, 오류 메시지를 읽고 접근을 바꾼다.",
150
+ "- 사용자가 승인을 거부하면 그 의사를 존중하고 대안을 제시한다.",
151
+ this.depth === 0
152
+ ? "- 크고 독립적으로 나눌 수 있는 작업은 spawn_agent 로 서브에이전트에 위임할 수 있다(간단한 일엔 쓰지 말 것)."
153
+ : "- 당신은 상위 에이전트가 위임한 하위 작업을 수행하는 서브에이전트다. 주어진 작업만 완수하고, 결과를 명확한 텍스트로 보고하라.",
154
+ pluginNames ? `- 추가 도구: ${pluginNames}` : null,
155
+ "",
156
+ "# 응답 스타일",
157
+ "- 한국어로, 간결하게. 불필요한 서론·사과 없이 핵심부터.",
158
+ "- 작업이 끝나면 도구를 더 호출하지 말고 무엇을 했는지 요약한다.",
159
+ ].filter((p) => p !== null);
160
+ if (rulesText) parts.push("", `# 프로젝트 규칙 (${rulesName})`, rulesText.trim());
124
161
  return parts.join("\n");
125
162
  }
126
163
 
@@ -129,6 +166,16 @@ export class AgentLoop {
129
166
  this.messages = [{ role: "system", content: this.systemPromptText }];
130
167
  }
131
168
 
169
+ // 매 턴 시작 시 시스템 프롬프트를 신선하게 재구성한다(대화는 유지, 환경·스냅샷만 갱신).
170
+ refreshSystemPrompt() {
171
+ this.systemPromptText = this._systemPrompt();
172
+ if (this.messages.length && this.messages[0].role === "system") {
173
+ this.messages[0] = { role: "system", content: this.systemPromptText };
174
+ } else {
175
+ this.messages.unshift({ role: "system", content: this.systemPromptText });
176
+ }
177
+ }
178
+
132
179
  // /context 명령용: 현재 대화 컨텍스트 요약.
133
180
  contextSummary() {
134
181
  return { ...summarizeMessages(this.messages), systemPrompt: this.systemPromptText };
@@ -136,6 +183,7 @@ export class AgentLoop {
136
183
 
137
184
  async run(userInput) {
138
185
  if (this.messages.length === 0) this.reset();
186
+ else this.refreshSystemPrompt(); // 환경·규칙·스냅샷을 매 턴 최신으로 (대화 이력은 유지)
139
187
 
140
188
  this._emit(Step.USER_INPUT, "사용자 입력", userInput);
141
189
  this.messages.push({ role: "user", content: userInput });
@@ -143,10 +191,11 @@ export class AgentLoop {
143
191
  this._emit(
144
192
  Step.BUILD_CONTEXT,
145
193
  "컨텍스트 구성",
146
- "규칙 파일 + 작업 폴더 내용을 시스템 프롬프트로 묶어 모델에 전달합니다."
194
+ "정체성·환경·도구원칙·프로젝트규칙(AGENT.md)을 시스템 프롬프트로 묶어 턴 신선하게 전달합니다."
147
195
  );
148
196
 
149
197
  const tools = toolSchemas(this.config.allow_shell, this.toolbox.plugins);
198
+ if (this.depth === 0) tools.push(SPAWN_AGENT_SCHEMA); // 서브에이전트는 재위임 불가
150
199
  const toolNames = tools.map((t) => t.function.name);
151
200
  let finalText = "";
152
201
 
@@ -183,6 +232,13 @@ export class AgentLoop {
183
232
 
184
233
  // ③ 모델의 원본 판단 + 실측 메타(응답시간/토큰/요청크기)를 드러낸다.
185
234
  // streamed=true 면 텍스트는 이미 실시간 출력됨 → UI 는 메타만 덧붙인다.
235
+ this.usage.calls += 1; // 모든 LLM 호출 횟수(usage 미제공 provider 포함)
236
+ if (reply.usage) {
237
+ this.usage.input += reply.usage.input || 0;
238
+ this.usage.output += reply.usage.output || 0;
239
+ this.usage.total += reply.usage.total || 0;
240
+ }
241
+
186
242
  this._emit(Step.MODEL_REPLY, "모델 응답", reply.content || "(텍스트 없음)", {
187
243
  toolCalls: reply.toolCalls.map((tc) => ({ name: tc.name, args: tc.args })),
188
244
  usage: reply.usage || null,
@@ -230,7 +286,46 @@ export class AgentLoop {
230
286
  return finalText;
231
287
  }
232
288
 
289
+ // 서브에이전트: 하위 AgentLoop 를 만들어 위임 작업을 수행시키고 결과 텍스트를 회수한다.
290
+ async _runSubAgent(tc) {
291
+ const task = (tc.args.task || "").trim();
292
+ if (!task) return "spawn_agent 오류: task 가 비어 있습니다.";
293
+ if (this.depth > 0) return "spawn_agent 오류: 서브에이전트는 다시 위임할 수 없습니다.";
294
+ this._emit(Step.TOOL_RUN, "도구 실행: 서브에이전트 위임", task.slice(0, 300));
295
+
296
+ const child = new AgentLoop({
297
+ config: this.config,
298
+ client: this.client,
299
+ toolbox: this.toolbox, // 같은 sandbox·같은 승인 정책 공유
300
+ session: this.session,
301
+ approvalCallback: this.approvalCallback,
302
+ onToken: null, // 하위 스트리밍은 화면 소음 — 패널로만 표시
303
+ onEvent: (ev) =>
304
+ this.onEvent({ ...ev, title: `┆ ${ev.title}`, data: { ...(ev.data || {}), sub: 1 } }),
305
+ depth: this.depth + 1,
306
+ });
307
+ child.reset();
308
+ const prompt = (tc.args.context ? `[배경]\n${tc.args.context}\n\n` : "") + `[위임된 작업]\n${task}`;
309
+
310
+ let result = "";
311
+ try {
312
+ result = await child.run(prompt);
313
+ } catch (e) {
314
+ result = `서브에이전트 실행 오류: ${e?.message || e}`;
315
+ }
316
+ // 하위 토큰 사용량을 상위(/status)에 합산
317
+ this.usage.input += child.usage.input;
318
+ this.usage.output += child.usage.output;
319
+ this.usage.total += child.usage.total;
320
+ this.usage.calls += child.usage.calls;
321
+
322
+ const out = (result || "").trim() || "(서브에이전트가 결과 텍스트를 반환하지 않았습니다)";
323
+ this._emit(Step.TOOL_RESULT, "결과 반영: 서브에이전트", out.slice(0, 4000));
324
+ return out;
325
+ }
326
+
233
327
  async _handleToolCall(tc) {
328
+ if (tc.name === "spawn_agent") return this._runSubAgent(tc);
234
329
  const label = this.toolbox.label ? this.toolbox.label(tc.name) : TOOL_LABELS[tc.name] || tc.name;
235
330
  const needsApproval = this.toolbox.isMutating(tc.name);
236
331
 
@@ -271,6 +366,10 @@ export class AgentLoop {
271
366
  const { path: p, diff } = this.toolbox.previewWrite(tc.args.path || "", tc.args.content || "");
272
367
  return { toolName: "write_file", toolLabel: TOOL_LABELS.write_file, args: tc.args, path: p, diff };
273
368
  }
369
+ if (tc.name === "edit_file") {
370
+ const { path: p, diff } = this.toolbox.previewEdit(tc.args.path || "", tc.args.old_text || "", tc.args.new_text ?? "");
371
+ return { toolName: "edit_file", toolLabel: TOOL_LABELS.edit_file, args: tc.args, path: p, diff };
372
+ }
274
373
  if (tc.name === "run_shell") {
275
374
  return {
276
375
  toolName: "run_shell",
package/src/tools.js CHANGED
@@ -7,12 +7,15 @@ import path from "node:path";
7
7
  export const TOOL_LABELS = {
8
8
  list_dir: "폴더 보기",
9
9
  read_file: "파일 읽기",
10
- write_file: "파일 수정",
10
+ write_file: "파일 쓰기",
11
+ edit_file: "부분 수정",
12
+ search_files: "파일 검색",
11
13
  run_shell: "셸 실행",
14
+ spawn_agent: "서브에이전트",
12
15
  };
13
16
 
14
17
  // 사용자 승인이 필요한(환경을 바꾸는) 도구
15
- export const MUTATING_TOOLS = new Set(["write_file", "run_shell"]);
18
+ export const MUTATING_TOOLS = new Set(["write_file", "edit_file", "run_shell"]);
16
19
 
17
20
  export class ToolError extends Error {}
18
21
 
@@ -132,10 +135,32 @@ export class Toolbox {
132
135
  return { path: this.rel(target), diff };
133
136
  }
134
137
 
138
+ // /undo 용: 마지막 파일 변경 1건을 기억해 되돌릴 수 있게 한다(단순 프리미티브).
139
+ _backup(target) {
140
+ this.lastChange = {
141
+ target,
142
+ rel: this.rel(target),
143
+ old: fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null, // null = 새 파일이었음
144
+ };
145
+ }
146
+
147
+ undoLast() {
148
+ if (!this.lastChange) throw new ToolError("되돌릴 파일 변경이 없습니다.");
149
+ const { target, rel, old } = this.lastChange;
150
+ this.lastChange = null;
151
+ if (old === null) {
152
+ fs.rmSync(target, { force: true });
153
+ return { ok: true, output: `${rel} 생성을 취소했습니다(파일 삭제).` };
154
+ }
155
+ fs.writeFileSync(target, old, "utf8");
156
+ return { ok: true, output: `${rel} 을 마지막 변경 이전 상태로 되돌렸습니다.` };
157
+ }
158
+
135
159
  writeFile(rel, content) {
136
160
  const target = this._resolve(rel);
137
161
  fs.mkdirSync(path.dirname(target), { recursive: true });
138
162
  const existed = fs.existsSync(target);
163
+ this._backup(target);
139
164
  fs.writeFileSync(target, content || "", "utf8");
140
165
  const verb = existed ? "수정" : "생성";
141
166
  return {
@@ -145,6 +170,91 @@ export class Toolbox {
145
170
  };
146
171
  }
147
172
 
173
+ // 부분 수정: old_text 를 딱 한 번 찾아 new_text 로 바꾼다(전체 덮어쓰기 불필요).
174
+ _computeEdit(rel, oldText, newText) {
175
+ const target = this._resolve(rel);
176
+ if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
177
+ throw new ToolError(`파일이 없습니다: ${rel}`);
178
+ }
179
+ if (!oldText) throw new ToolError("old_text 가 비어 있습니다.");
180
+ const cur = fs.readFileSync(target, "utf8");
181
+ const count = cur.split(oldText).length - 1;
182
+ if (count === 0) throw new ToolError("old_text 를 파일에서 찾지 못했습니다. 파일을 다시 read_file 로 확인하세요.");
183
+ if (count > 1) throw new ToolError(`old_text 가 ${count}번 일치합니다 — 주변 문맥을 포함해 더 길게 지정하세요.`);
184
+ return { target, cur, next: cur.replace(oldText, newText ?? "") };
185
+ }
186
+
187
+ previewEdit(rel, oldText, newText) {
188
+ try {
189
+ const { target, cur, next } = this._computeEdit(rel, oldText, newText);
190
+ return { path: this.rel(target), diff: diffLines(cur, next).filter((l) => !l.startsWith(" ")).join("\n") || "(변화 없음)" };
191
+ } catch (e) {
192
+ return { path: rel, diff: `(미리보기 불가: ${e.message})` };
193
+ }
194
+ }
195
+
196
+ editFile(rel, oldText, newText) {
197
+ const { target, next } = this._computeEdit(rel, oldText, newText);
198
+ this._backup(target);
199
+ fs.writeFileSync(target, next, "utf8");
200
+ return { ok: true, output: `${this.rel(target)} 부분 수정 완료 (old ${oldText.length}자 → new ${(newText ?? "").length}자).` };
201
+ }
202
+
203
+ // 파일명 + 내용 검색(재귀). node_modules/.git 등 노이즈 제외, 대소문자 무시.
204
+ searchFiles(query, sub = ".") {
205
+ if (!query || !query.trim()) throw new ToolError("검색어가 비어 있습니다.");
206
+ const q = query.toLowerCase();
207
+ const rootDir = this._resolve(sub);
208
+ const SKIP = new Set(["node_modules", ".git", "dist", "build", "__pycache__", ".venv"]);
209
+ const nameHits = [];
210
+ const contentHits = [];
211
+ let scanned = 0;
212
+ const walk = (dir) => {
213
+ let entries = [];
214
+ try {
215
+ entries = fs.readdirSync(dir, { withFileTypes: true });
216
+ } catch {
217
+ return;
218
+ }
219
+ for (const e of entries) {
220
+ if (scanned > 2000 || contentHits.length >= 40) return;
221
+ const full = path.join(dir, e.name);
222
+ if (e.isDirectory()) {
223
+ if (!SKIP.has(e.name) && !e.name.startsWith(".")) walk(full);
224
+ continue;
225
+ }
226
+ scanned++;
227
+ const rel = this.rel(full);
228
+ if (e.name.toLowerCase().includes(q)) nameHits.push(rel);
229
+ let st;
230
+ try {
231
+ st = fs.statSync(full);
232
+ } catch {
233
+ continue;
234
+ }
235
+ if (st.size > 512 * 1024) continue; // 큰 파일 스킵
236
+ let text;
237
+ try {
238
+ text = fs.readFileSync(full, "utf8");
239
+ } catch {
240
+ continue;
241
+ }
242
+ if (text.includes("")) continue; // 바이너리 스킵
243
+ const lines = text.split("\n");
244
+ for (let i = 0; i < lines.length && contentHits.length < 40; i++) {
245
+ if (lines[i].toLowerCase().includes(q)) {
246
+ contentHits.push(`${rel}:${i + 1} ${lines[i].trim().slice(0, 120)}`);
247
+ }
248
+ }
249
+ }
250
+ };
251
+ walk(rootDir);
252
+ const parts = [];
253
+ if (nameHits.length) parts.push(`[파일명 일치 ${nameHits.length}건]\n` + nameHits.slice(0, 20).join("\n"));
254
+ if (contentHits.length) parts.push(`[내용 일치]\n` + contentHits.join("\n"));
255
+ return { ok: true, output: parts.join("\n\n") || `'${query}' 검색 결과가 없습니다.` };
256
+ }
257
+
148
258
  runShell(command) {
149
259
  if (!this.allowShell) {
150
260
  throw new ToolError("셸 실행이 설정에서 비활성화되어 있습니다(allow_shell=false).");
@@ -172,6 +282,8 @@ export class Toolbox {
172
282
  if (name === "list_dir") return this.listDir(args.path || ".");
173
283
  if (name === "read_file") return this.readFile(args.path || "");
174
284
  if (name === "write_file") return this.writeFile(args.path || "", args.content || "");
285
+ if (name === "edit_file") return this.editFile(args.path || "", args.old_text || "", args.new_text ?? "");
286
+ if (name === "search_files") return this.searchFiles(args.query || "", args.path || ".");
175
287
  if (name === "run_shell") return this.runShell(args.command || "");
176
288
  const plugin = this._pluginMap.get(name);
177
289
  if (plugin) {
@@ -221,7 +333,7 @@ export function toolSchemas(allowShell = false, plugins = []) {
221
333
  function: {
222
334
  name: "write_file",
223
335
  description:
224
- "작업 폴더 안의 파일을 내용으로 만들거나 덮어쓴다. 전체 파일 내용을 content 전달. 사용자 승인 후 적용된다.",
336
+ " 파일을 만들거나 파일 전체를 덮어쓴다. 기존 파일의 일부만 고칠 write_file 대신 edit_file 을 써라. 사용자 승인 후 적용된다.",
225
337
  parameters: {
226
338
  type: "object",
227
339
  properties: {
@@ -232,6 +344,38 @@ export function toolSchemas(allowShell = false, plugins = []) {
232
344
  },
233
345
  },
234
346
  },
347
+ {
348
+ type: "function",
349
+ function: {
350
+ name: "edit_file",
351
+ description:
352
+ "기존 파일의 일부만 수정한다(권장). old_text 는 파일 안에서 정확히 한 번만 일치해야 하며 new_text 로 치환된다. 사용자 승인 후 적용된다.",
353
+ parameters: {
354
+ type: "object",
355
+ properties: {
356
+ path: { type: "string", description: "수정할 파일의 상대 경로" },
357
+ old_text: { type: "string", description: "바꿀 기존 텍스트(문맥 포함, 파일에서 유일해야 함)" },
358
+ new_text: { type: "string", description: "새 텍스트" },
359
+ },
360
+ required: ["path", "old_text", "new_text"],
361
+ },
362
+ },
363
+ },
364
+ {
365
+ type: "function",
366
+ function: {
367
+ name: "search_files",
368
+ description: "작업 폴더에서 파일명·파일내용을 재귀 검색한다(대소문자 무시). 어떤 파일에 뭐가 있는지 찾을 때 사용.",
369
+ parameters: {
370
+ type: "object",
371
+ properties: {
372
+ query: { type: "string", description: "검색어" },
373
+ path: { type: "string", description: "검색 시작 폴더(기본 '.')" },
374
+ },
375
+ required: ["query"],
376
+ },
377
+ },
378
+ },
235
379
  ];
236
380
  if (allowShell) {
237
381
  schemas.push({
package/src/ui.js CHANGED
@@ -110,6 +110,57 @@ export function panel(lines, { title = "", color = "cyan" } = {}) {
110
110
  return [head, ...mid, bottom].join("\n");
111
111
  }
112
112
 
113
+ // 모델 응답의 마크다운을 터미널 ANSI 로 렌더한다(OpenCode 류 체감 개선).
114
+ // 지원: #제목 / **굵게** / *기울임* / `코드` / ```펜스``` / -·* 목록 / > 인용 / 표(|) / 구분선
115
+ export function renderMarkdown(text) {
116
+ const out = [];
117
+ let inFence = false;
118
+ for (const raw of String(text ?? "").split("\n")) {
119
+ // 코드 펜스
120
+ const fence = /^\s*```(\w*)/.exec(raw);
121
+ if (fence) {
122
+ inFence = !inFence;
123
+ out.push(c.grey(" ┄┄┄" + (inFence && fence[1] ? " " + fence[1] + " " : "") + "┄┄┄"));
124
+ continue;
125
+ }
126
+ if (inFence) {
127
+ out.push(" " + c.yellow(raw));
128
+ continue;
129
+ }
130
+ let line = raw;
131
+ // 구분선
132
+ if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
133
+ out.push(c.grey("─".repeat(30)));
134
+ continue;
135
+ }
136
+ // 제목
137
+ const h = /^(#{1,4})\s+(.*)$/.exec(line);
138
+ if (h) {
139
+ out.push(c.bold(c.cyan(h[2])));
140
+ continue;
141
+ }
142
+ // 인용
143
+ line = line.replace(/^(\s*)>\s?/, (_, sp) => sp + c.grey("│ "));
144
+ // 목록 글머리
145
+ line = line.replace(/^(\s*)[-*]\s+/, (_, sp) => sp + c.cyan("• "));
146
+ // 표: | 를 은은한 세로선으로
147
+ if (/^\s*\|.*\|\s*$/.test(raw)) {
148
+ if (/^\s*\|[\s:|-]+\|\s*$/.test(raw)) {
149
+ out.push(c.grey(raw.replace(/[|:-]/g, (m) => (m === "|" ? "┼" : "─")).trim()));
150
+ continue;
151
+ }
152
+ line = line.replace(/\|/g, c.grey("│"));
153
+ }
154
+ // 인라인: `코드` → 노랑, **굵게**, *기울임*
155
+ line = line
156
+ .replace(/`([^`]+)`/g, (_, s) => c.yellow(s))
157
+ .replace(/\*\*([^*]+)\*\*/g, (_, s) => c.bold(s))
158
+ .replace(/(^|\s)\*([^*\s][^*]*)\*(?=\s|$|[.,!?)])/g, (_, pre, s) => pre + c.italic(s));
159
+ out.push(line);
160
+ }
161
+ return out;
162
+ }
163
+
113
164
  // diff 문자열을 색으로 렌더 (+초록 / -빨강 / 그외 흐림)
114
165
  export function renderDiff(diff) {
115
166
  return diff.split("\n").map((line) => {