cdsa-harness 0.14.2 → 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/src/builtins.js +1 -1
- package/src/cli.js +316 -60
- package/src/config.js +4 -1
- package/src/llm.js +12 -2
- package/src/loop.js +118 -19
- package/src/tools.js +147 -3
- package/src/ui.js +51 -0
package/README.md
CHANGED
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
- **의존성 0개** — Node 18+ 내장 기능만(`fetch`/`readline`/`node:test`)
|
|
12
|
-
- **실시간 스트리밍** — 모델 응답이 토큰 단위로 흐름(`/stream`
|
|
13
|
-
- **실제 LLM 연결** — OpenAI · Anthropic(Claude) · OpenRouter
|
|
12
|
+
- **실시간 스트리밍** — 모델 응답이 토큰 단위로 흐름(`/stream` 토글)
|
|
13
|
+
- **실제 LLM 연결** — OpenAI · Anthropic(Claude) · OpenRouter · **Ollama(로컬·폐쇄망, 키 불필요)** · `mock`
|
|
14
|
+
- **에이전트 도구** — 폴더/파일 읽기 · `edit_file` 부분 수정 · `search_files` 재귀 검색 · 셸(옵션) · HWPX
|
|
15
|
+
- **`@파일명` 멘션** — 입력에 `@memo.txt` 처럼 쓰면 파일 내용 자동 첨부
|
|
16
|
+
- **`/resume`** — 지난 대화 자동 저장·이어가기, ↑↓ 명령 히스토리도 세션 간 유지
|
|
17
|
+
- **서브에이전트** — 모델이 큰 작업을 `spawn_agent` 로 하위 에이전트에 위임(화면엔 `┆` 로 구분, 중첩 위임 차단)
|
|
18
|
+
- **`/update`** — 명령 한 번으로 자기 자신을 최신 버전으로 업데이트(npm 설치본)
|
|
14
19
|
- **교육 모드** — 매 반복마다 모델에 보내는 메시지 구성·추정 토큰·시스템 프롬프트, 실제 토큰 사용량/응답시간까지 그대로 표시
|
|
15
20
|
- **MCP 클라이언트** — Claude Code·Cursor 등과 **공용 표준**. MCP 서버를 그대로 붙여 도구로 사용
|
|
16
21
|
- **플러그인** — npm 으로 설치하거나 `.cdsa/plugins/` 에 JS 파일 → **도구 자동 등록**
|
|
@@ -66,8 +71,15 @@ export OPENROUTER_API_KEY=sk-or-...
|
|
|
66
71
|
|
|
67
72
|
## 슬래시 명령
|
|
68
73
|
|
|
74
|
+
Claude Code·OpenCode 컨벤션을 따릅니다.
|
|
75
|
+
|
|
69
76
|
| 명령 | 설명 |
|
|
70
77
|
|------|------|
|
|
78
|
+
| `/new` (=`/clear`) · `/compact` · `/resume` | 새 대화 · **모델이 대화를 요약해 압축** · 지난 대화 이어가기 |
|
|
79
|
+
| `/undo` | 마지막 파일 변경 되돌리기 |
|
|
80
|
+
| `/init` · `/memory` | **모델이 폴더를 파악해 AGENT.md 생성** · 규칙 파일 보기 |
|
|
81
|
+
| `/auto` | 자동 수락 토글 (신뢰가 쌓이면 diff 승인 생략) |
|
|
82
|
+
| `/status` (=`/cost`) · `/models` | 상태·세션 누적 토큰 · 모델 목록(ollama 는 설치분 실시간) |
|
|
71
83
|
| `/guide` · `/tutorial` | 빠른 시작 안내 · 단계별 인터랙티브 튜토리얼 |
|
|
72
84
|
| `/workspace <폴더>` | 작업 폴더 보기/변경 (`.` = 현재 폴더) |
|
|
73
85
|
| `/color` | 색상 켜기/끄기(흑백) |
|
|
@@ -186,6 +198,14 @@ npm pack cdsa-harness # → cdsa-harness-x.y.z.tgz 생성
|
|
|
186
198
|
npm install -g ./cdsa-harness-x.y.z.tgz # 인터넷 불필요
|
|
187
199
|
```
|
|
188
200
|
|
|
201
|
+
**Ollama — 인터넷 없이 로컬 LLM 으로 (가장 쉬움):**
|
|
202
|
+
```bash
|
|
203
|
+
# 폐쇄망 PC 에 Ollama 와 모델(예: qwen2.5:7b, exaone3.5)을 반입·설치한 뒤
|
|
204
|
+
cdsa-harness
|
|
205
|
+
› /setup → 4) ollama 선택 (키 불필요, 설치된 모델 자동 감지)
|
|
206
|
+
```
|
|
207
|
+
> 원격 Ollama 서버는 주소만 입력(예: `http://10.0.0.5:11434`). `OLLAMA_HOST` 환경변수도 인식.
|
|
208
|
+
|
|
189
209
|
**사내/폐쇄망 LLM (OpenAI 호환 서버 — vLLM, 내부 게이트웨이 등):**
|
|
190
210
|
`config.json` 의 `base_url` 에 **전체 엔드포인트**를 적으면 그쪽으로 호출합니다.
|
|
191
211
|
```json
|
|
@@ -226,8 +246,12 @@ npm install -g ./cdsa-harness-x.y.z.tgz # 인터넷 불필요
|
|
|
226
246
|
|------|------|:----:|
|
|
227
247
|
| `list_dir` | 폴더 나열 | 자동 |
|
|
228
248
|
| `read_file` | 파일 읽기 | 자동 |
|
|
229
|
-
| `
|
|
249
|
+
| `search_files` | 파일명·내용 재귀 검색 | 자동 |
|
|
250
|
+
| `write_file` | 새 파일/전체 덮어쓰기 | **diff 승인** |
|
|
251
|
+
| `edit_file` | **부분 수정**(old→new 치환) | **diff 승인** |
|
|
230
252
|
| `run_shell` | 셸 실행 | **승인**(기본 차단) |
|
|
253
|
+
| `spawn_agent` | 하위 에이전트에 작업 위임(중첩 불가) | 자동 |
|
|
254
|
+
| `hwpx_read` | 한컴 .hwpx 본문 추출 | 자동 |
|
|
231
255
|
|
|
232
256
|
모든 도구는 작업 폴더 밖으로 나갈 수 없습니다(경로 sandbox).
|
|
233
257
|
|
|
@@ -252,6 +276,13 @@ node-cli/
|
|
|
252
276
|
└── test/core.test.js # node --test
|
|
253
277
|
```
|
|
254
278
|
|
|
279
|
+
## 설계 철학
|
|
280
|
+
|
|
281
|
+
1. **모델이 먼저다** — 하드코딩 대신 모델에게 시킨다. `/init`(규칙 파일 생성)·`/compact`(대화 압축)는 로직이 아니라 **모델이 직접 수행**한다. 지금 70% 작동하는 것이 다음 모델에선 90%가 된다.
|
|
282
|
+
2. **단순함이 속도다** — 웹앱이 아닌 CLI. `read + edit + search + shell` 최소 프리미티브 위에 스킬·플러그인·MCP 로 나머지 전부를 쌓는다. 의존성 0개.
|
|
283
|
+
3. **신뢰의 전환** — 처음엔 모든 변경을 diff 로 승인받고, 신뢰가 쌓이면 `/auto` 로 자동 수락. 승인이 반복되면 도구가 먼저 전환을 제안한다.
|
|
284
|
+
4. **접근성의 민주화** — 예산 없는 조직도 쓸 수 있게: 폐쇄망 Ollama·단일 실행파일·공공 업무 스킬 기본 내장.
|
|
285
|
+
|
|
255
286
|
## 테스트
|
|
256
287
|
|
|
257
288
|
```bash
|
package/package.json
CHANGED
package/src/builtins.js
CHANGED
package/src/cli.js
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
loadConfig,
|
|
18
18
|
saveConfig,
|
|
19
19
|
} from "./config.js";
|
|
20
|
-
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
21
21
|
|
|
22
22
|
import { AgentLoop, Step } from "./loop.js";
|
|
23
23
|
import { LLMClient } from "./llm.js";
|
|
@@ -26,7 +26,7 @@ import { discoverNpmExtensions, loadPlugins } from "./plugins.js";
|
|
|
26
26
|
import { SessionLog, sessionsDir } from "./session.js";
|
|
27
27
|
import { loadSkills, renderSkill } from "./skills.js";
|
|
28
28
|
import { Toolbox } from "./tools.js";
|
|
29
|
-
import { c, panel, renderDiff, setColor } from "./ui.js";
|
|
29
|
+
import { c, panel, renderDiff, renderMarkdown, setColor } from "./ui.js";
|
|
30
30
|
|
|
31
31
|
// VERSION 은 src/builtins.js(생성물)에서 가져온다 — npm/exe 양쪽에서 동일.
|
|
32
32
|
|
|
@@ -87,8 +87,8 @@ function printTeach(ev, stream) {
|
|
|
87
87
|
lines.push(` ${roleColor(m.role.padEnd(9))} ${c.grey(`${m.chars}자${m.extra || ""}`)}`);
|
|
88
88
|
}
|
|
89
89
|
lines.push(c.grey(`제공 도구(${d.tools?.length || 0}): ${(d.tools || []).join(", ")}`));
|
|
90
|
-
console.log(panel(lines, { title:
|
|
91
|
-
if (d.systemPrompt) {
|
|
90
|
+
console.log(panel(lines, { title: `${d.sub ? "┆ " : ""}🧠 ② LLM 호출 — 반복 ${d.iteration}`, color: "magenta" }));
|
|
91
|
+
if (d.systemPrompt && !d.sub) {
|
|
92
92
|
console.log(panel(clip(d.systemPrompt, 600).split("\n"), {
|
|
93
93
|
title: "📜 시스템 프롬프트 (규칙+폴더가 여기 주입됨)",
|
|
94
94
|
color: "grey",
|
|
@@ -112,14 +112,14 @@ function printTeach(ev, stream) {
|
|
|
112
112
|
return;
|
|
113
113
|
}
|
|
114
114
|
const lines = [];
|
|
115
|
-
if (ev.detail && ev.detail !== "(텍스트 없음)") lines.push(...clip(ev.detail, 1200)
|
|
115
|
+
if (ev.detail && ev.detail !== "(텍스트 없음)") lines.push(...renderMarkdown(clip(ev.detail, 1200)));
|
|
116
116
|
for (const tc of d.toolCalls || []) {
|
|
117
117
|
lines.push(c.yellow(`↳ 도구 호출 요청: ${c.bold(tc.name)}(${clip(JSON.stringify(tc.args), 200)})`));
|
|
118
118
|
}
|
|
119
119
|
const meta = replyMetaLine(d);
|
|
120
120
|
if (meta) lines.push(c.grey("─ " + meta));
|
|
121
121
|
else lines.push(c.dim("(mock: 토큰/지연 측정 없음)"));
|
|
122
|
-
console.log(panel(lines.length ? lines : ["(빈 응답)"], { title: "🤖 ③ 모델 응답 (원본 판단)
|
|
122
|
+
console.log(panel(lines.length ? lines : ["(빈 응답)"], { title: `${d.sub ? "┆ " : ""}🤖 ③ 모델 응답 (원본 판단)`, color: "green" }));
|
|
123
123
|
return;
|
|
124
124
|
}
|
|
125
125
|
|
|
@@ -145,7 +145,8 @@ function printTeach(ev, stream) {
|
|
|
145
145
|
return;
|
|
146
146
|
|
|
147
147
|
case Step.DONE:
|
|
148
|
-
|
|
148
|
+
if (d.sub) { console.log(c.grey("┆ ") + c.green("✅ 서브에이전트 완료")); return; }
|
|
149
|
+
console.log(panel(renderMarkdown(ev.detail || "완료"), { title: "✅ 완료", color: "green" }));
|
|
149
150
|
return;
|
|
150
151
|
|
|
151
152
|
case Step.ERROR:
|
|
@@ -166,10 +167,13 @@ function printCompact(ev, stream) {
|
|
|
166
167
|
for (const tc of d.toolCalls || []) console.log(c.yellow(` ↳ ${tc.name}(${clip(JSON.stringify(tc.args), 120)})`));
|
|
167
168
|
return;
|
|
168
169
|
}
|
|
169
|
-
if (ev.detail && ev.detail !== "(텍스트 없음)") console.log(panel(ev.detail
|
|
170
|
+
if (ev.detail && ev.detail !== "(텍스트 없음)") console.log(panel(renderMarkdown(ev.detail), { title: `${d.sub ? "┆ " : ""}🤖 모델`, color: "green" }));
|
|
170
171
|
return;
|
|
171
172
|
}
|
|
172
|
-
if (ev.step === Step.DONE)
|
|
173
|
+
if (ev.step === Step.DONE) {
|
|
174
|
+
if (ev.data && ev.data.sub) return console.log(c.grey("┆ ") + c.green("✅ 서브에이전트 완료"));
|
|
175
|
+
return console.log(panel(renderMarkdown(ev.detail || "완료"), { title: "✅ 완료", color: "green" }));
|
|
176
|
+
}
|
|
173
177
|
if (ev.step === Step.ERROR) return console.log(panel((ev.detail || "").split("\n"), { title: `❌ ${ev.title}`, color: "red" }));
|
|
174
178
|
if (ev.step === Step.FEEDBACK) return;
|
|
175
179
|
let detail = clip((ev.detail || "").trim().replace(/\s+/g, " "), 110);
|
|
@@ -178,11 +182,14 @@ function printCompact(ev, stream) {
|
|
|
178
182
|
console.log(line);
|
|
179
183
|
}
|
|
180
184
|
|
|
181
|
-
function makeApproval(ask) {
|
|
185
|
+
function makeApproval(ask, cfg) {
|
|
186
|
+
let streak = 0; // 연속 승인 횟수 — 신뢰가 쌓이면 자동 수락을 제안한다
|
|
187
|
+
let hinted = false;
|
|
182
188
|
return async (req) => {
|
|
183
|
-
if (req.toolName === "write_file") {
|
|
189
|
+
if (req.toolName === "write_file" || req.toolName === "edit_file") {
|
|
190
|
+
const label = req.toolName === "edit_file" ? "부분 수정 제안" : "파일 쓰기 제안";
|
|
184
191
|
console.log(panel(renderDiff(req.diff || "(변경 미리보기 없음)"), {
|
|
185
|
-
title: `🔐
|
|
192
|
+
title: `🔐 ${label} — ${req.path}`,
|
|
186
193
|
color: "yellow",
|
|
187
194
|
}));
|
|
188
195
|
} else if (req.toolName === "run_shell") {
|
|
@@ -193,6 +200,11 @@ function makeApproval(ask) {
|
|
|
193
200
|
const raw = await ask(c.yellow("이 작업을 승인하시겠습니까? [y/N] "));
|
|
194
201
|
const ans = (raw || "").trim().toLowerCase();
|
|
195
202
|
const approved = ans === "y" || ans === "yes";
|
|
203
|
+
streak = approved ? streak + 1 : 0;
|
|
204
|
+
if (approved && streak >= 3 && !hinted && cfg.approval_mode === "manual") {
|
|
205
|
+
hinted = true;
|
|
206
|
+
console.log(c.dim("💡 계속 승인 중이시네요 — 신뢰가 쌓였다면 ") + c.cyan("/auto") + c.dim(" 로 자동 수락을 켤 수 있어요."));
|
|
207
|
+
}
|
|
196
208
|
return { approved, reason: approved ? "" : "사용자가 거부했습니다." };
|
|
197
209
|
};
|
|
198
210
|
}
|
|
@@ -271,7 +283,11 @@ function printIntro(cfg) {
|
|
|
271
283
|
console.log(renderBanner());
|
|
272
284
|
console.log(c.dim("AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 하네스") + " " + c.cyan("· made by CDSA"));
|
|
273
285
|
console.log();
|
|
274
|
-
const keySource =
|
|
286
|
+
const keySource =
|
|
287
|
+
cfg.provider === "mock" ? "-"
|
|
288
|
+
: cfg.provider === "ollama" ? "불필요(로컬)"
|
|
289
|
+
: cfg.api_key ? "config.json"
|
|
290
|
+
: ENV_KEYS[cfg.provider] && process.env[ENV_KEYS[cfg.provider]] ? `환경변수 ${ENV_KEYS[cfg.provider]}` : c.red("없음");
|
|
275
291
|
const rows = [
|
|
276
292
|
["버전", `v${VERSION}`],
|
|
277
293
|
["provider", cfg.provider],
|
|
@@ -384,25 +400,26 @@ function printHelp() {
|
|
|
384
400
|
[
|
|
385
401
|
c.bold("사용법"),
|
|
386
402
|
`시키고 싶은 일을 한국어로 입력하세요. 예) ${c.cyan("notes.txt 맨 아래에 할 일 3개 추가해줘")}`,
|
|
403
|
+
`${c.cyan("@파일명")} 을 쓰면 그 파일 내용이 자동 첨부됩니다. 예) ${c.cyan("@memo.txt 이거 요약해줘")}`,
|
|
404
|
+
"",
|
|
405
|
+
c.bold("대화"),
|
|
406
|
+
` ${c.cyan("/new")}(=/clear) 새 대화 · ${c.cyan("/compact")} 대화를 모델이 요약해 압축 · ${c.cyan("/resume")} 지난 대화 이어가기`,
|
|
407
|
+
` ${c.cyan("/undo")} 마지막 파일 변경 되돌리기 · ${c.cyan("/context")} 모델에 보내는 컨텍스트 보기`,
|
|
387
408
|
"",
|
|
388
|
-
c.bold("
|
|
389
|
-
` ${c.cyan("/
|
|
390
|
-
` ${c.cyan("/tutorial")} 단계별 인터랙티브 튜토리얼`,
|
|
391
|
-
` ${c.cyan("/color")} 색상 켜기/끄기(흑백)`,
|
|
392
|
-
` ${c.cyan("/about")} 이 도구 정보(made by CDSA)`,
|
|
393
|
-
` ${c.cyan("/setup")} 제공자·API 키·모델 연결(대화형)`,
|
|
394
|
-
` ${c.cyan("/provider")} <openai|anthropic|openrouter|mock> 제공자 변경`,
|
|
395
|
-
` ${c.cyan("/model")} <이름> 모델 변경`,
|
|
396
|
-
` ${c.cyan("/teach")} 교육 모드 켜기/끄기(내부 과정 펼쳐보기)`,
|
|
397
|
-
` ${c.cyan("/stream")} 실시간 스트리밍 출력 켜기/끄기`,
|
|
398
|
-
` ${c.cyan("/context")} 지금 모델에 보내는 컨텍스트 들여다보기`,
|
|
409
|
+
c.bold("프로젝트"),
|
|
410
|
+
` ${c.cyan("/init")} 모델이 폴더를 파악해 AGENT.md(프로젝트 규칙) 생성 · ${c.cyan("/memory")} 규칙 파일 보기`,
|
|
399
411
|
` ${c.cyan("/workspace")} <폴더> 작업 폴더 보기/변경 ('.' = 현재 폴더)`,
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
` ${c.cyan("/
|
|
403
|
-
` ${c.cyan("/
|
|
404
|
-
` ${c.cyan("/
|
|
405
|
-
|
|
412
|
+
"",
|
|
413
|
+
c.bold("모델·설정"),
|
|
414
|
+
` ${c.cyan("/setup")} 연결 마법사 · ${c.cyan("/model")} <이름> 변경 · ${c.cyan("/models")} 목록(ollama 는 설치분) · ${c.cyan("/provider")} 전환`,
|
|
415
|
+
` ${c.cyan("/auto")} 자동 수락 토글 · ${c.cyan("/status")}(=/cost) 상태·누적 토큰 · ${c.cyan("/update")} 자기 업데이트`,
|
|
416
|
+
` ${c.cyan("/teach")} 내부과정 펼치기 · ${c.cyan("/stream")} 실시간 출력 · ${c.cyan("/color")} 색상`,
|
|
417
|
+
"",
|
|
418
|
+
c.bold("확장"),
|
|
419
|
+
` ${c.cyan("/skills")} 스킬 목록 · ${c.cyan("/plugins")} 플러그인 · ${c.cyan("/mcp")} MCP 서버`,
|
|
420
|
+
"",
|
|
421
|
+
c.bold("기타"),
|
|
422
|
+
` ${c.cyan("/guide")} 빠른 시작 · ${c.cyan("/tutorial")} 튜토리얼 · ${c.cyan("/about")} 정보 · ${c.cyan("/quit")} 종료(Ctrl+D)`,
|
|
406
423
|
"",
|
|
407
424
|
c.bold("교육 모드에서 보이는 단계"),
|
|
408
425
|
" ① 입력 → ② LLM 호출(컨텍스트·도구) → ③ 모델 응답(토큰·지연) →",
|
|
@@ -427,13 +444,14 @@ async function runSetup(ask, cfg) {
|
|
|
427
444
|
` ${c.bold("1")}) openai (GPT, 키: ${ENV_KEYS.openai})`,
|
|
428
445
|
` ${c.bold("2")}) anthropic (Claude, 키: ${ENV_KEYS.anthropic})`,
|
|
429
446
|
` ${c.bold("3")}) openrouter (여러 모델 중계, 키: ${ENV_KEYS.openrouter})`,
|
|
430
|
-
` ${c.bold("4")})
|
|
447
|
+
` ${c.bold("4")}) ollama (로컬·폐쇄망 LLM, 키 불필요) 🏢`,
|
|
448
|
+
` ${c.bold("5")}) mock (키 없이 연습)`,
|
|
431
449
|
],
|
|
432
450
|
{ title: "🔌 연결 설정 (/setup)", color: "cyan" }
|
|
433
451
|
));
|
|
434
|
-
const pickRaw = await ask(c.cyan("제공자 번호 [1-
|
|
452
|
+
const pickRaw = await ask(c.cyan("제공자 번호 [1-5] (취소: Enter): "));
|
|
435
453
|
if (pickRaw === null) return false;
|
|
436
|
-
const provider = { "1": "openai", "2": "anthropic", "3": "openrouter", "4": "mock" }[pickRaw.trim()];
|
|
454
|
+
const provider = { "1": "openai", "2": "anthropic", "3": "openrouter", "4": "ollama", "5": "mock" }[pickRaw.trim()];
|
|
437
455
|
if (!provider) {
|
|
438
456
|
console.log(c.yellow("취소했습니다."));
|
|
439
457
|
return false;
|
|
@@ -442,6 +460,29 @@ async function runSetup(ask, cfg) {
|
|
|
442
460
|
|
|
443
461
|
if (provider === "mock") {
|
|
444
462
|
cfg.model = "mock-agent";
|
|
463
|
+
} else if (provider === "ollama") {
|
|
464
|
+
const hostAns = await ask(c.cyan("Ollama 주소 [http://localhost:11434]: "));
|
|
465
|
+
if (hostAns === null) return false;
|
|
466
|
+
const host = (hostAns.trim() || "http://localhost:11434").replace(/\/+$/, "");
|
|
467
|
+
cfg.base_url = `${host}/v1/chat/completions`;
|
|
468
|
+
cfg.api_key = "ollama"; // 로컬 LLM 은 키가 필요 없다(호환용 자리표시)
|
|
469
|
+
// 설치된 모델 자동 감지(/api/tags) — 실패해도 조용히 추천 목록 사용
|
|
470
|
+
let installed = [];
|
|
471
|
+
try {
|
|
472
|
+
const ctrl = new AbortController();
|
|
473
|
+
const t = setTimeout(() => ctrl.abort(), 2000);
|
|
474
|
+
const res = await fetch(`${host}/api/tags`, { signal: ctrl.signal });
|
|
475
|
+
clearTimeout(t);
|
|
476
|
+
if (res.ok) installed = ((await res.json()).models || []).map((m) => m.name);
|
|
477
|
+
} catch {
|
|
478
|
+
console.log(c.yellow(" (Ollama 에 연결하지 못했어요 — `ollama serve` 실행 여부를 확인하세요. 계속 진행합니다.)"));
|
|
479
|
+
}
|
|
480
|
+
if (installed.length) console.log(c.green(` 설치된 모델: ${installed.slice(0, 8).join(", ")}`));
|
|
481
|
+
const sugg = installed.length ? installed : SUGGESTED_MODELS.ollama;
|
|
482
|
+
const def = sugg[0];
|
|
483
|
+
const m = await ask(c.cyan(`모델 [${def}]: `));
|
|
484
|
+
if (m === null) return false;
|
|
485
|
+
cfg.model = m.trim() || def;
|
|
445
486
|
} else {
|
|
446
487
|
const envName = ENV_KEYS[provider];
|
|
447
488
|
const envVal = (process.env[envName] || "").trim();
|
|
@@ -504,7 +545,7 @@ export async function main(argv = []) {
|
|
|
504
545
|
"CDSA Harness — AI 에이전트 내부를 드러내는 교육용 하네스 (터미널)\n\n" +
|
|
505
546
|
"사용법: cdsa-harness [옵션]\n" +
|
|
506
547
|
" cdsa-harness add <npm-패키지> 플러그인 설치(이후 자동 로드)\n" +
|
|
507
|
-
" --provider <openai|anthropic|openrouter|mock>\n" +
|
|
548
|
+
" --provider <openai|anthropic|openrouter|ollama|mock>\n" +
|
|
508
549
|
" --model <모델명>\n" +
|
|
509
550
|
" --workspace <폴더경로>\n" +
|
|
510
551
|
" --setup 대화형 연결 설정 실행\n" +
|
|
@@ -546,12 +587,25 @@ export async function main(argv = []) {
|
|
|
546
587
|
if (args.noColor) cfg.no_color = true;
|
|
547
588
|
if (cfg.no_color) setColor(false); // 색상 끄기(흑백)
|
|
548
589
|
|
|
549
|
-
|
|
590
|
+
// 명령 히스토리(↑↓)를 세션 간에도 기억한다.
|
|
591
|
+
const histPath = path.join(configDir(), "history");
|
|
592
|
+
let savedHistory = [];
|
|
593
|
+
try {
|
|
594
|
+
savedHistory = fs.readFileSync(histPath, "utf8").split("\n").filter(Boolean).slice(0, 200);
|
|
595
|
+
} catch { /* 첫 실행 */ }
|
|
596
|
+
const rl = readline.createInterface({ input: stdin, output: stdout, history: savedHistory, historySize: 200 });
|
|
597
|
+
const saveHistory = () => {
|
|
598
|
+
try {
|
|
599
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
600
|
+
fs.writeFileSync(histPath, (rl.history || []).slice(0, 200).join("\n"), "utf8");
|
|
601
|
+
} catch { /* ignore */ }
|
|
602
|
+
};
|
|
550
603
|
let session = null;
|
|
551
604
|
let mcp = { tools: [], servers: [], errors: [], closeAll: () => {} };
|
|
552
605
|
|
|
553
606
|
// Ctrl+C → 깔끔하게 종료(스택트레이스 없이). 어디서 누르든 안전.
|
|
554
607
|
const gracefulExit = () => {
|
|
608
|
+
saveHistory();
|
|
555
609
|
try { rl.close(); } catch { /* */ }
|
|
556
610
|
try { session && session.close(); } catch { /* */ }
|
|
557
611
|
try { mcp && mcp.closeAll && mcp.closeAll(); } catch { /* */ }
|
|
@@ -559,14 +613,50 @@ export async function main(argv = []) {
|
|
|
559
613
|
process.exit(0);
|
|
560
614
|
};
|
|
561
615
|
rl.on("SIGINT", gracefulExit);
|
|
562
|
-
// 프롬프트
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
616
|
+
// 프롬프트 헬퍼. TTY 면 rl.question 그대로(히스토리·편집 지원).
|
|
617
|
+
// 파이프/스크립트 입력(non-TTY)은 줄을 큐로 받아 어떤 타이밍에 물어도 유실되지 않게 한다
|
|
618
|
+
// (모델 호출 중 도착한 승인 답변 줄이 사라지던 문제 해결 — CI/데모 자동화 가능).
|
|
619
|
+
let ask;
|
|
620
|
+
if (stdin.isTTY) {
|
|
621
|
+
ask = async (q) => {
|
|
622
|
+
try {
|
|
623
|
+
return await rl.question(q);
|
|
624
|
+
} catch {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
} else {
|
|
629
|
+
const lineQueue = [];
|
|
630
|
+
let pendingResolve = null;
|
|
631
|
+
let closed = false;
|
|
632
|
+
rl.on("line", (l) => {
|
|
633
|
+
if (pendingResolve) {
|
|
634
|
+
const r = pendingResolve;
|
|
635
|
+
pendingResolve = null;
|
|
636
|
+
r(l);
|
|
637
|
+
} else lineQueue.push(l);
|
|
638
|
+
});
|
|
639
|
+
rl.on("close", () => {
|
|
640
|
+
closed = true;
|
|
641
|
+
if (pendingResolve) {
|
|
642
|
+
const r = pendingResolve;
|
|
643
|
+
pendingResolve = null;
|
|
644
|
+
r(null);
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
ask = (q) => {
|
|
648
|
+
process.stdout.write(q);
|
|
649
|
+
if (lineQueue.length) {
|
|
650
|
+
const l = lineQueue.shift();
|
|
651
|
+
process.stdout.write(l + "\n");
|
|
652
|
+
return Promise.resolve(l);
|
|
653
|
+
}
|
|
654
|
+
if (closed) return Promise.resolve(null);
|
|
655
|
+
return new Promise((res) => {
|
|
656
|
+
pendingResolve = res;
|
|
657
|
+
});
|
|
658
|
+
};
|
|
659
|
+
}
|
|
570
660
|
|
|
571
661
|
if (args.setup) {
|
|
572
662
|
await runSetup(ask, cfg);
|
|
@@ -582,7 +672,7 @@ export async function main(argv = []) {
|
|
|
582
672
|
if (newer) {
|
|
583
673
|
console.log(
|
|
584
674
|
c.yellow(`⬆️ 새 버전 v${newer} 가 나왔어요!`) +
|
|
585
|
-
c.dim(`
|
|
675
|
+
c.dim(` 지금 바로: `) + c.cyan("/update") + c.dim(` · exe 는 Releases 에서 새로 받기`) +
|
|
586
676
|
"\n"
|
|
587
677
|
);
|
|
588
678
|
}
|
|
@@ -618,7 +708,7 @@ export async function main(argv = []) {
|
|
|
618
708
|
client: makeClient(cfg),
|
|
619
709
|
toolbox,
|
|
620
710
|
onEvent: makePrinter(cfg, stream),
|
|
621
|
-
approvalCallback: makeApproval(ask),
|
|
711
|
+
approvalCallback: makeApproval(ask, cfg),
|
|
622
712
|
session,
|
|
623
713
|
onToken,
|
|
624
714
|
});
|
|
@@ -626,6 +716,37 @@ export async function main(argv = []) {
|
|
|
626
716
|
|
|
627
717
|
const rule = () => console.log(c.grey("─".repeat(Math.min(80, stdout.columns || 80))));
|
|
628
718
|
|
|
719
|
+
// @파일 멘션: 입력 속 @경로 가 작업 폴더의 실제 파일이면 내용을 프롬프트에 첨부한다.
|
|
720
|
+
const expandMentions = (text) => {
|
|
721
|
+
const tokens = [...text.matchAll(/@([\w가-힣./\\-]+)/g)].map((m) => m[1]);
|
|
722
|
+
let out = text;
|
|
723
|
+
for (const t of [...new Set(tokens)]) {
|
|
724
|
+
try {
|
|
725
|
+
const r = toolbox.readFile(t);
|
|
726
|
+
const clipped = (r.output || "").slice(0, 4000);
|
|
727
|
+
out += `\n\n[첨부 @${t}]\n${clipped}`;
|
|
728
|
+
console.log(c.dim(`📎 @${t} 첨부됨 (${clipped.length}자)`));
|
|
729
|
+
} catch { /* 파일이 아니면 무시(이메일 등) */ }
|
|
730
|
+
}
|
|
731
|
+
return out;
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
// 대화 자동저장(/resume 용) + 멘션 확장을 겸하는 실행 헬퍼.
|
|
735
|
+
const convPath = path.join(configDir(), "last_session.json");
|
|
736
|
+
const runTurn = async (promptText) => {
|
|
737
|
+
rule();
|
|
738
|
+
try {
|
|
739
|
+
await loop.run(expandMentions(promptText));
|
|
740
|
+
} catch (e) {
|
|
741
|
+
console.log(c.red(`실행 오류: ${e?.message || e}`));
|
|
742
|
+
}
|
|
743
|
+
rule();
|
|
744
|
+
try {
|
|
745
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
746
|
+
fs.writeFileSync(convPath, JSON.stringify({ time: Date.now(), provider: cfg.provider, model: cfg.model, messages: loop.messages }), "utf8");
|
|
747
|
+
} catch { /* 저장 실패 무시 */ }
|
|
748
|
+
};
|
|
749
|
+
|
|
629
750
|
// 첫 실행 온보딩(한 번만 — ~/.cdsa_harness/.welcomed 표시): 작업 폴더 설정 + 튜토리얼
|
|
630
751
|
const markerPath = path.join(configDir(), ".welcomed");
|
|
631
752
|
if (stdin.isTTY && !fs.existsSync(markerPath)) {
|
|
@@ -695,7 +816,154 @@ export async function main(argv = []) {
|
|
|
695
816
|
], { title: "ℹ️ about", color: "cyan" }));
|
|
696
817
|
continue;
|
|
697
818
|
}
|
|
698
|
-
|
|
819
|
+
// /clear·/new — Claude Code·OpenCode 컨벤션(= 기존 /reset)
|
|
820
|
+
if (low === "/reset" || low === "/clear" || low === "/new") {
|
|
821
|
+
loop.reset();
|
|
822
|
+
console.log(c.green("새 대화를 시작합니다(컨텍스트 초기화)."));
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
// /compact — 대화를 '모델이 직접' 요약해 컨텍스트를 압축 (Claude Code 방식, 모델-퍼스트)
|
|
826
|
+
if (low === "/compact") {
|
|
827
|
+
if (loop.messages.length < 4) {
|
|
828
|
+
console.log(c.dim("아직 압축할 대화가 충분하지 않습니다."));
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
const before = loop.messages.length;
|
|
832
|
+
console.log(c.dim("모델에게 대화 요약을 맡겨 컨텍스트를 압축합니다…"));
|
|
833
|
+
try {
|
|
834
|
+
const req = [
|
|
835
|
+
...loop.messages,
|
|
836
|
+
{
|
|
837
|
+
role: "user",
|
|
838
|
+
content:
|
|
839
|
+
"지금까지의 대화를 다음 작업에 꼭 필요한 것만 남긴 한국어 요약으로 만들어줘: 확인된 사실, 내린 결정, 파일 변경 내역, 미완료 작업. 요약문만 출력해.",
|
|
840
|
+
},
|
|
841
|
+
];
|
|
842
|
+
const r = await loop.client.chat(req, []);
|
|
843
|
+
const summary = (r.content || "").trim();
|
|
844
|
+
if (!summary) throw new Error("빈 요약");
|
|
845
|
+
loop.reset();
|
|
846
|
+
loop.messages.push(
|
|
847
|
+
{ role: "user", content: `[이전 대화 요약]\n${summary}` },
|
|
848
|
+
{ role: "assistant", content: "요약을 확인했습니다. 이어서 진행하겠습니다." }
|
|
849
|
+
);
|
|
850
|
+
console.log(c.green(`압축 완료 — 메시지 ${before}개 → ${loop.messages.length}개`));
|
|
851
|
+
} catch (e) {
|
|
852
|
+
console.log(c.yellow(`압축 실패: ${e.message}`));
|
|
853
|
+
}
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
// /undo — 마지막 파일 변경 1건 되돌리기 (OpenCode 컨벤션)
|
|
857
|
+
if (low === "/undo") {
|
|
858
|
+
try {
|
|
859
|
+
console.log(c.green(toolbox.undoLast().output));
|
|
860
|
+
} catch (e) {
|
|
861
|
+
console.log(c.yellow(e.message));
|
|
862
|
+
}
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
// /update — 자기 자신을 최신 버전으로 업데이트(npm 설치본). exe 는 다운로드 안내.
|
|
866
|
+
if (low === "/update") {
|
|
867
|
+
let isSea = false;
|
|
868
|
+
try {
|
|
869
|
+
isSea = (await import("node:sea")).isSea();
|
|
870
|
+
} catch { /* 구버전 Node — SEA 아님 */ }
|
|
871
|
+
if (isSea) {
|
|
872
|
+
console.log(c.yellow("단일 실행파일(exe) 버전은 파일 교체 방식이라 자동 업데이트가 없어요."));
|
|
873
|
+
console.log(c.dim("최신 exe 다운로드: ") + c.cyan("https://github.com/cdsassj00/miniharness/releases/latest"));
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
console.log(c.cyan("최신 버전으로 업데이트합니다… (npm i -g cdsa-harness@latest)"));
|
|
877
|
+
try {
|
|
878
|
+
execSync("npm i -g cdsa-harness@latest", { stdio: "inherit" });
|
|
879
|
+
console.log(c.green("✅ 업데이트 완료! /quit 후 다시 실행하면 새 버전이 적용됩니다."));
|
|
880
|
+
} catch (e) {
|
|
881
|
+
console.log(c.red(`업데이트 실패: ${e.message}`));
|
|
882
|
+
console.log(c.dim("직접 실행: npm i -g cdsa-harness@latest"));
|
|
883
|
+
}
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
// /auto — 자동 수락 토글(신뢰 전환). Claude Code 의 auto-accept 에 해당.
|
|
887
|
+
if (low === "/auto") {
|
|
888
|
+
cfg.approval_mode = cfg.approval_mode === "auto" ? "manual" : "auto";
|
|
889
|
+
console.log(
|
|
890
|
+
cfg.approval_mode === "auto"
|
|
891
|
+
? c.green("자동 수락 ON — 파일 수정·셸을 묻지 않고 실행합니다. (해제: /auto)")
|
|
892
|
+
: c.green("자동 수락 OFF — 변경 전 diff 를 보여주고 승인을 받습니다.")
|
|
893
|
+
);
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
// /init — '모델에게' 폴더를 파악시켜 AGENT.md 프로젝트 규칙을 만들게 한다 (Claude Code /init)
|
|
897
|
+
if (low === "/init") {
|
|
898
|
+
await runTurn(
|
|
899
|
+
"이 작업 폴더를 list_dir/read_file/search_files 로 파악한 뒤, 앞으로의 에이전트 작업 지침이 될 " +
|
|
900
|
+
"AGENT.md 파일을 write_file 로 생성해줘. 포함: ①프로젝트/폴더 개요 ②작업 시 지켜야 할 규칙(말투·제약·주의) " +
|
|
901
|
+
"③자주 할 작업 예시. 이미 AGENT.md 가 있으면 읽고 부족한 부분만 개선해."
|
|
902
|
+
);
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
// /status·/cost — 현재 상태 + 세션 누적 토큰 (Claude Code 컨벤션)
|
|
906
|
+
if (low === "/status" || low === "/cost") {
|
|
907
|
+
const u = loop.usage;
|
|
908
|
+
console.log(
|
|
909
|
+
panel(
|
|
910
|
+
[
|
|
911
|
+
`${c.grey("모델".padEnd(6))} ${c.bold(`${cfg.provider} · ${cfg.model}`)}`,
|
|
912
|
+
`${c.grey("폴더".padEnd(6))} ${cfg.workspacePath()}`,
|
|
913
|
+
`${c.grey("승인".padEnd(6))} ${cfg.approval_mode === "auto" ? c.yellow("자동 수락") : "수동(diff 확인)"} · 셸 ${cfg.allow_shell ? "허용" : "차단"}`,
|
|
914
|
+
`${c.grey("대화".padEnd(6))} 메시지 ${loop.messages.length}개 (/compact 로 압축 가능)`,
|
|
915
|
+
`${c.grey("토큰".padEnd(6))} 입력 ${u.input.toLocaleString()} · 출력 ${u.output.toLocaleString()} · 합계 ${c.bold(u.total.toLocaleString())} (호출 ${u.calls}회)`,
|
|
916
|
+
],
|
|
917
|
+
{ title: "📊 상태 (/status)", color: "cyan" }
|
|
918
|
+
)
|
|
919
|
+
);
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
// /models — 쓸 수 있는 모델 나열 (ollama 는 설치된 모델 실시간 조회)
|
|
923
|
+
if (low === "/models") {
|
|
924
|
+
if (cfg.provider === "ollama") {
|
|
925
|
+
const host = (cfg.base_url || "http://localhost:11434/v1/chat/completions").replace(/\/v1\/.*$/, "");
|
|
926
|
+
try {
|
|
927
|
+
const ctrl = new AbortController();
|
|
928
|
+
const t = setTimeout(() => ctrl.abort(), 2000);
|
|
929
|
+
const res = await fetch(`${host}/api/tags`, { signal: ctrl.signal });
|
|
930
|
+
clearTimeout(t);
|
|
931
|
+
const models = ((await res.json()).models || []).map((m) => m.name);
|
|
932
|
+
console.log(panel(models.length ? models.map((m) => (m === cfg.model ? c.green("● " + m) : " " + m)) : [c.dim("설치된 모델 없음 — ollama pull <모델>")], { title: `🤖 Ollama 모델 (${host})`, color: "cyan" }));
|
|
933
|
+
} catch {
|
|
934
|
+
console.log(c.yellow(`Ollama(${host})에 연결하지 못했습니다 — ollama serve 확인.`));
|
|
935
|
+
}
|
|
936
|
+
} else {
|
|
937
|
+
const sugg = SUGGESTED_MODELS[cfg.provider] || [];
|
|
938
|
+
console.log(panel(sugg.map((m) => (m === cfg.model ? c.green("● " + m) : " " + m)), { title: `🤖 추천 모델 (${cfg.provider}) — 변경: /model <이름>`, color: "cyan" }));
|
|
939
|
+
}
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
// /memory — 프로젝트 규칙 파일(AGENT.md) 보기 (Claude Code /memory)
|
|
943
|
+
if (low === "/memory") {
|
|
944
|
+
const candidates = ["AGENT.md", "AGENTS.md", "CLAUDE.md", "rules.md"].map((n) => path.join(cfg.workspacePath(), n));
|
|
945
|
+
const found = candidates.find((p) => fs.existsSync(p));
|
|
946
|
+
if (found) {
|
|
947
|
+
console.log(panel(fs.readFileSync(found, "utf8").split("\n").slice(0, 40), { title: `🧠 프로젝트 규칙 — ${path.basename(found)}`, color: "cyan" }));
|
|
948
|
+
console.log(c.dim(`전체 경로: ${found} (매 턴 시스템 프롬프트에 주입됩니다)`));
|
|
949
|
+
} else {
|
|
950
|
+
console.log(c.yellow("프로젝트 규칙 파일이 없습니다. ") + c.cyan("/init") + c.dim(" 을 실행하면 모델이 폴더를 파악해 AGENT.md 를 만들어줍니다."));
|
|
951
|
+
}
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (low === "/resume") {
|
|
955
|
+
try {
|
|
956
|
+
const saved = JSON.parse(fs.readFileSync(convPath, "utf8"));
|
|
957
|
+
if (!Array.isArray(saved.messages) || !saved.messages.length) throw new Error("빈 세션");
|
|
958
|
+
loop.messages = saved.messages;
|
|
959
|
+
const when = new Date(saved.time).toLocaleString("ko-KR");
|
|
960
|
+
console.log(c.green(`지난 대화를 이어갑니다 — ${when} · ${saved.model} · 메시지 ${saved.messages.length}개 복원`));
|
|
961
|
+
console.log(c.dim("(새로 시작하려면 /reset)"));
|
|
962
|
+
} catch {
|
|
963
|
+
console.log(c.yellow("이어갈 지난 대화가 없습니다."));
|
|
964
|
+
}
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
699
967
|
if (low === "/config") { printIntro(cfg); console.log(c.dim(`config.json: ${configPath()}`)); continue; }
|
|
700
968
|
if (low.startsWith("/workspace") || low.startsWith("/cd")) {
|
|
701
969
|
const arg = user.split(/\s+/).slice(1).join(" ").trim();
|
|
@@ -821,26 +1089,14 @@ export async function main(argv = []) {
|
|
|
821
1089
|
continue;
|
|
822
1090
|
}
|
|
823
1091
|
console.log(c.dim(`(스킬 '/${name}' 실행)`));
|
|
824
|
-
|
|
825
|
-
try {
|
|
826
|
-
await loop.run(renderSkill(skills[name], argStr));
|
|
827
|
-
} catch (e) {
|
|
828
|
-
console.log(c.red(`실행 오류: ${e?.message || e}`));
|
|
829
|
-
}
|
|
830
|
-
rule();
|
|
1092
|
+
await runTurn(renderSkill(skills[name], argStr));
|
|
831
1093
|
} else {
|
|
832
1094
|
console.log(c.yellow(`알 수 없는 명령/스킬: /${name} — ${c.cyan("/help")}, ${c.cyan("/skills")} 참고`));
|
|
833
1095
|
}
|
|
834
1096
|
continue;
|
|
835
1097
|
}
|
|
836
1098
|
|
|
837
|
-
|
|
838
|
-
try {
|
|
839
|
-
await loop.run(user);
|
|
840
|
-
} catch (e) {
|
|
841
|
-
console.log(c.red(`실행 오류: ${e?.message || e}`));
|
|
842
|
-
}
|
|
843
|
-
rule();
|
|
1099
|
+
await runTurn(user);
|
|
844
1100
|
}
|
|
845
1101
|
|
|
846
1102
|
rl.close();
|
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(
|
|
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(
|
|
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
|
-
|
|
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/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) => {
|