cdsa-harness 0.14.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -3
- package/package.json +1 -1
- package/skills/answer.md +7 -2
- package/skills/briefing.md +1 -0
- package/skills/budget.md +1 -0
- package/skills/eli5.md +1 -0
- package/skills/explain.md +1 -0
- package/skills/gongmun.md +1 -0
- package/skills/haiku.md +1 -0
- package/skills/hwpx.md +1 -0
- package/skills/insa.md +1 -0
- package/skills/loop.md +1 -0
- package/skills/minutes.md +7 -4
- package/skills/minwon.md +8 -4
- package/skills/notice.md +1 -0
- package/skills/plan.md +1 -0
- package/skills/policyqa.md +1 -0
- package/skills/press.md +1 -0
- package/skills/privacy.md +7 -4
- package/skills/quiz.md +1 -0
- package/skills/report.md +7 -3
- package/skills/review.md +1 -0
- package/skills/rubberduck.md +1 -0
- package/skills/summarize.md +1 -0
- package/skills/todo.md +1 -0
- package/skills/tour.md +1 -0
- package/src/builtins.js +30 -6
- package/src/cli.js +330 -63
- package/src/config.js +4 -1
- package/src/llm.js +12 -2
- package/src/loop.js +118 -19
- package/src/skills.js +9 -3
- package/src/tools.js +147 -3
- package/src/ui.js +51 -0
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
|
-
|
|
122
|
+
// 폴더 '전체 목록'을 박아넣지 않는다(낡은 정보·토큰 낭비 방지) — 최상위 이름만 참고로 주고
|
|
123
|
+
// 최신 상태는 도구(list_dir/search_files)로 확인하게 한다. (OpenCode/Claude Code 방식)
|
|
124
|
+
let topLevel = "";
|
|
103
125
|
try {
|
|
104
|
-
|
|
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
|
-
|
|
129
|
+
topLevel = "(읽을 수 없음)";
|
|
107
130
|
}
|
|
108
|
-
const
|
|
109
|
-
|
|
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
|
-
"
|
|
115
|
-
"당신은
|
|
116
|
-
|
|
117
|
-
"
|
|
118
|
-
"
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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/skills.js
CHANGED
|
@@ -44,7 +44,7 @@ function parseFrontmatter(text) {
|
|
|
44
44
|
const meta = {};
|
|
45
45
|
for (const line of m[1].split("\n")) {
|
|
46
46
|
const i = line.indexOf(":");
|
|
47
|
-
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
47
|
+
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, "");
|
|
48
48
|
}
|
|
49
49
|
return { meta, body: text.slice(m[0].length).trim() };
|
|
50
50
|
}
|
|
@@ -54,7 +54,13 @@ function addSkill(skills, name, file) {
|
|
|
54
54
|
try {
|
|
55
55
|
const raw = fs.readFileSync(file, "utf8");
|
|
56
56
|
const { meta, body } = parseFrontmatter(raw);
|
|
57
|
-
skills[name] = {
|
|
57
|
+
skills[name] = {
|
|
58
|
+
name,
|
|
59
|
+
description: meta.description || "",
|
|
60
|
+
hint: meta["argument-hint"] || meta.args || meta.usage || "",
|
|
61
|
+
body,
|
|
62
|
+
source: file,
|
|
63
|
+
};
|
|
58
64
|
} catch {
|
|
59
65
|
/* ignore unreadable skill */
|
|
60
66
|
}
|
|
@@ -89,7 +95,7 @@ export function loadSkills(workspace, { importForeign = true, extraDirs = [] } =
|
|
|
89
95
|
}
|
|
90
96
|
// 패키지 내장 기본 스킬(임베드) — 가장 낮은 우선순위(사용자 파일이 덮어씀)
|
|
91
97
|
for (const s of BUILTIN_SKILLS) {
|
|
92
|
-
if (!skills[s.name]) skills[s.name] = { name: s.name, description: s.description || "", body: s.body, source: "(내장)" };
|
|
98
|
+
if (!skills[s.name]) skills[s.name] = { name: s.name, description: s.description || "", hint: s.hint || "", body: s.body, source: "(내장)" };
|
|
93
99
|
}
|
|
94
100
|
return skills;
|
|
95
101
|
}
|
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
|
-
"
|
|
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) => {
|