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 CHANGED
@@ -9,8 +9,13 @@
9
9
  ```
10
10
 
11
11
  - **의존성 0개** — Node 18+ 내장 기능만(`fetch`/`readline`/`node:test`)
12
- - **실시간 스트리밍** — 모델 응답이 토큰 단위로 흐름(`/stream` 토글, OpenAI·Claude·mock)
13
- - **실제 LLM 연결** — OpenAI · Anthropic(Claude) · OpenRouter, 또는없이 `mock`
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
- | `write_file` | 파일 생성/수정 | **diff 승인** |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdsa-harness",
3
- "version": "0.14.2",
3
+ "version": "0.18.0",
4
4
  "description": "AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 터미널 하네스. 실시간 스트리밍 + OpenAI/Claude/OpenRouter + MCP + npm 플러그인·크로스포맷 스킬.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/builtins.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // 스킬/플러그인/버전을 바꾼 뒤 `node scripts/gen-builtins.mjs` 로 재생성하세요.
3
3
  import p0 from "../plugins/hwpx_read.mjs";
4
4
 
5
- export const VERSION = "0.14.2";
5
+ export const VERSION = "0.18.0";
6
6
 
7
7
  export const BUILTIN_PLUGINS = [p0];
8
8
 
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: `🧠 ② LLM 호출 — 반복 ${d.iteration}`, color: "magenta" }));
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).split("\n"));
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: "🤖 ③ 모델 응답 (원본 판단)", color: "green" }));
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
- console.log(panel((ev.detail || "완료").split("\n"), { title: "✅ 완료", color: "green" }));
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.split("\n"), { title: "🤖 모델", color: "green" }));
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) return console.log(panel((ev.detail || "완료").split("\n"), { title: "✅ 완료", color: "green" }));
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: `🔐 파일 수정 제안 — ${req.path}`,
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 = cfg.provider === "mock" ? "-" : cfg.api_key ? "config.json" : ENV_KEYS[cfg.provider] && process.env[ENV_KEYS[cfg.provider]] ? `환경변수 ${ENV_KEYS[cfg.provider]}` : c.red("없음");
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("/guide")} 처음 사용자용 빠른 시작 안내`,
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
- ` ${c.cyan("/skills")} 스킬 목록(.cdsa/skills 의 /명령들)`,
401
- ` ${c.cyan("/plugins")} 플러그인 목록(파일·npm 추가 도구)`,
402
- ` ${c.cyan("/mcp")} 연결된 MCP 서버/도구(다른 에이전트와 공용)`,
403
- ` ${c.cyan("/reset")} 대화/컨텍스트 초기화`,
404
- ` ${c.cyan("/config")} 현재 설정값`,
405
- ` ${c.cyan("/quit")} 종료 (Ctrl+D)`,
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")}) mock (키 없이 연습)`,
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-4] (취소: Enter): "));
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
- const rl = readline.createInterface({ input: stdin, output: stdout });
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
- // 프롬프트 헬퍼: Ctrl+C(AbortError) 등은 null 돌려 호출부가 취소로 처리.
563
- const ask = async (q) => {
564
- try {
565
- return await rl.question(q);
566
- } catch {
567
- return null;
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(` 업데이트: npm i -g cdsa-harness@latest · exe 는 Releases 에서 새로 받기`) +
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
- if (low === "/reset") { loop.reset(); console.log(c.green("컨텍스트를 초기화했습니다.")); continue; }
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
- rule();
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
- rule();
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(`네트워크 오류: ${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) => {