cdsa-harness 0.1.1 → 0.2.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
@@ -1,81 +1,93 @@
1
- # CDSA Harness (Node.js CLI) ⌨️
1
+ # CDSA Harness (Node.js CLI) 🎓⌨️
2
2
 
3
- > Claude Code / Codex CLI / OpenCode **똑같은 방식(npm/npx)** 으로 설치하는,
4
- > 좁은 의미의 AI 에이전트 하네스 교육용 터미널 앱.
5
-
6
- 시작하면 ASCII 배너가 뜨고, **Agent Loop** 의 모든 단계가 색으로 흐릅니다.
3
+ > **AI 에이전트가 내부에서 실제로 하는지** 단계별로 드러내는 교육용 터미널 하네스.
4
+ > Claude Code·Codex 과정을 숨기지만, CDSA Harness 는 **컨텍스트 구성 → API 요청 → 모델의 판단 → 토큰/응답시간 → 도구 실행 → 결과 되먹임**을 전부 펼쳐 보여줍니다.
7
5
 
8
6
  ```
9
- 입력 → 컨텍스트 구성 → LLM 호출 도구 판단 승인 실행 → 결과 반영 → 반복
7
+ 입력 → LLM 호출(보낼 컨텍스트·도구) 모델 응답(토큰·지연·tool_call)
8
+ → ④ 도구 판단 → ⑤ 실행/승인 → ⑥ 결과 되먹임 → (반복)
10
9
  ```
11
10
 
12
- - **의존성 0개** — Node 18+ 내장 기능만 사용(`fetch`/`readline`/`node:test`). `npx` 가 빠르고 설치 실패가 없습니다.
13
- - 같은 저장소의 Python 버전(Mini Harness GUI / CDSA Harness TUI) **동일한 하네스 구조**를 그대로 옮긴 것입니다.
11
+ - **의존성 0개** — Node 18+ 내장 기능만(`fetch`/`readline`/`node:test`)
12
+ - **실제 LLM 연결** OpenAI · Anthropic(Claude) · OpenRouter, 또는 없이 `mock`
13
+ - **교육 모드** — 매 반복마다 모델에 보내는 메시지 구성·추정 토큰·시스템 프롬프트, 실제 토큰 사용량/응답시간까지 그대로 표시
14
14
 
15
15
  ---
16
16
 
17
17
  ## 설치 / 실행
18
18
 
19
- ### npx (설치 없이 즉시)
20
-
21
19
  ```bash
22
- # 로컬 체크아웃에서
23
- cd node-cli
24
- npx . # API Key 없으면 자동 mock 모드
20
+ npx cdsa-harness # 설치 없이 즉시 (키 없으면 mock)
21
+ npm install -g cdsa-harness # 전역 설치 → 'cdsa-harness' / 'cdsa'
25
22
  ```
26
23
 
27
- ### 전역 설치
24
+ ## 실제 AI 연결하기
28
25
 
26
+ 가장 쉬운 길 — 실행 후 `/setup` 입력(대화형으로 제공자·키·모델 선택):
29
27
  ```bash
30
- cd node-cli
31
- npm install -g . # 또는: npm link
32
- cdsa-harness # 어디서나 실행 (별칭: cdsa)
28
+ cdsa-harness
29
+ /setup
33
30
  ```
34
31
 
35
- ### 그냥 node 로
36
-
32
+ 또는 플래그/환경변수로:
37
33
  ```bash
38
- cd node-cli
39
- node bin/cdsa-harness.js
40
- ```
34
+ cdsa-harness --provider openai --model gpt-4o-mini
35
+ cdsa-harness --provider anthropic --model claude-3-5-haiku-latest
41
36
 
42
- > npm 레지스트리에 publish 하면 `npm install -g cdsa-harness` / `npx cdsa-harness` 로 바로 설치됩니다.
37
+ # 키는 환경변수로도 자동 인식(파일에 저장 함)
38
+ export OPENAI_API_KEY=sk-...
39
+ export ANTHROPIC_API_KEY=sk-ant-...
40
+ export OPENROUTER_API_KEY=sk-or-...
41
+ ```
43
42
 
44
43
  ---
45
44
 
46
- ## 옵션 / 명령
45
+ ## 슬래시 명령
47
46
 
48
- ```bash
49
- cdsa-harness --provider openai --model gpt-4.1-mini # 실제 LLM
50
- cdsa-harness --provider openrouter --model anthropic/claude-3.5-sonnet
51
- cdsa-harness --workspace ./my-project # 작업 폴더 지정
52
- cdsa-harness --auto # 승인 자동
53
- ```
47
+ | 명령 | 설명 |
48
+ |------|------|
49
+ | `/setup` | 제공자·API 키·모델 대화형 연결 |
50
+ | `/provider <이름>` | openai · anthropic · openrouter · mock 전환 |
51
+ | `/model <이름>` | 모델 변경 |
52
+ | `/teach` | 교육 모드 켜기/끄기 |
53
+ | `/context` | 지금 모델에 보내는 컨텍스트 들여다보기 |
54
+ | `/reset` | 대화/컨텍스트 초기화 |
55
+ | `/config` | 현재 설정값 |
56
+ | `/quit` | 종료 (Ctrl+D) |
57
+
58
+ ## 플래그
54
59
 
55
- 터미널 슬래시 명령: `/help` · `/reset` · `/config` · `/sessions` · `/quit` (Ctrl+D 로도 종료)
60
+ ```
61
+ --provider <openai|anthropic|openrouter|mock>
62
+ --model <모델명>
63
+ --workspace <폴더경로>
64
+ --setup 대화형 연결 설정
65
+ --no-teach 교육 모드 끄고 간결 출력
66
+ --auto 승인 자동
67
+ ```
56
68
 
57
69
  ---
58
70
 
59
71
  ## 설정 (config.json)
60
72
 
61
- `~/.cdsa_harness/config.json` 에 저장됩니다(실행 폴더에 `config.json` 있으면 우선).
73
+ `~/.cdsa_harness/config.json` (실행 폴더에 `config.json` 있으면 우선).
62
74
 
63
75
  ```json
64
76
  {
65
77
  "provider": "openai",
66
78
  "api_key": "",
67
- "model": "gpt-4.1-mini",
79
+ "model": "gpt-4o-mini",
68
80
  "workspace": "./workspace",
69
81
  "approval_mode": "manual",
70
82
  "allow_shell": false,
71
83
  "max_steps": 8,
72
- "temperature": 0.2
84
+ "temperature": 0.2,
85
+ "max_tokens": 1024,
86
+ "teach_mode": true
73
87
  }
74
88
  ```
75
89
 
76
- 작업 폴더 상대경로는 **현재 폴더(cwd)** 기준으로 해석합니다(CLI 답게).
77
-
78
- ---
90
+ > `api_key` 비어 있으면 해당 provider 의 환경변수를 자동으로 찾습니다.
79
91
 
80
92
  ## 도구 & 안전장치
81
93
 
@@ -94,23 +106,20 @@ cdsa-harness --auto # 승인 자동
94
106
 
95
107
  ```
96
108
  node-cli/
97
- ├── bin/cdsa-harness.js # npm/npx 진입점 (#!/usr/bin/env node)
109
+ ├── bin/cdsa-harness.js # npm/npx 진입점
98
110
  ├── src/
99
- │ ├── config.js # 설정 로드/저장
100
- │ ├── llm.js # OpenAI/OpenRouter(fetch) + mock
101
- │ ├── tools.js # 도구 + sandbox + diff + 스키마
102
- │ ├── loop.js # ⭐ Agent Loop
111
+ │ ├── config.js # 설정 + 환경변수 키 감지
112
+ │ ├── llm.js # OpenAI/Anthropic/OpenRouter + mock, 응답 정규화(토큰·지연)
113
+ │ ├── tools.js # 도구 + sandbox + diff
114
+ │ ├── loop.js # ⭐ Agent Loop + 교육용 이벤트(컨텍스트/되먹임)
103
115
  │ ├── session.js # 세션 로그(JSONL)
104
- │ ├── banner.js # CDSA HARNESS ASCII 배너
105
- ├── ui.js # ANSI 색/박스/diff 렌더
106
- │ └── cli.js # 터미널 REPL
107
- ├── workspace/ # 샘플 작업 폴더
116
+ │ ├── banner.js / ui.js # 배너 · ANSI/박스/diff 렌더
117
+ └── cli.js # REPL + 교육 모드 렌더 + /setup
108
118
  └── test/core.test.js # node --test
109
119
  ```
110
120
 
111
121
  ## 테스트
112
122
 
113
123
  ```bash
114
- cd node-cli
115
124
  npm test # node --test
116
125
  ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cdsa-harness",
3
- "version": "0.1.1",
4
- "description": "좁은 의미의 AI 에이전트 하네스를 터미널에서 체험하는 교육용 CLI (Claude Code / Codex / OpenCode 구조의 미니 런타임)",
3
+ "version": "0.2.0",
4
+ "description": "AI 에이전트의 내부 동작(컨텍스트·API요청·토큰·도구·되먹임)을 단계별로 드러내는 교육용 터미널 하네스. OpenAI/Claude/OpenRouter 지원.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cdsa-harness": "bin/cdsa-harness.js",
package/src/cli.js CHANGED
@@ -1,17 +1,25 @@
1
1
  // CDSA Harness TUI 본체 — 터미널 REPL.
2
- // Python ui/tui 동일한 흐름을 Node 내장 모듈만으로 구현.
2
+ // 핵심 차별점: '교육 모드' 실제 API 붙여도 에이전트 내부에서 벌어지는 일
3
+ // (컨텍스트 구성 → API 요청 → 모델 판단 → 토큰/지연 → 도구 실행 → 결과 되먹임)을 단계별로 드러낸다.
3
4
  import readline from "node:readline/promises";
4
5
  import { stdin, stdout } from "node:process";
5
6
 
6
7
  import { renderBanner } from "./banner.js";
7
- import { Config, configPath, loadConfig, saveConfig } from "./config.js";
8
- import { AgentLoop, Step, STEP_LABELS } from "./loop.js";
8
+ import {
9
+ ENV_KEYS,
10
+ PROVIDERS,
11
+ SUGGESTED_MODELS,
12
+ configPath,
13
+ loadConfig,
14
+ saveConfig,
15
+ } from "./config.js";
16
+ import { AgentLoop, Step } from "./loop.js";
9
17
  import { LLMClient } from "./llm.js";
10
18
  import { SessionLog, sessionsDir } from "./session.js";
11
19
  import { Toolbox } from "./tools.js";
12
20
  import { c, panel, renderDiff } from "./ui.js";
13
21
 
14
- const VERSION = "0.1.0";
22
+ const VERSION = "0.2.0";
15
23
 
16
24
  const STEP_STYLE = {
17
25
  [Step.USER_INPUT]: ["🧑", "cyan"],
@@ -22,39 +30,118 @@ const STEP_STYLE = {
22
30
  [Step.APPROVAL]: ["🔐", "yellow"],
23
31
  [Step.TOOL_RUN]: ["🔧", "blue"],
24
32
  [Step.TOOL_RESULT]: ["📄", "grey"],
33
+ [Step.FEEDBACK]: ["↩️", "grey"],
25
34
  [Step.DONE]: ["✅", "green"],
26
35
  [Step.ERROR]: ["❌", "red"],
27
36
  };
28
37
 
29
- function makePrinter() {
38
+ function clip(s, n) {
39
+ s = String(s ?? "");
40
+ return s.length > n ? s.slice(0, n) + " …" : s;
41
+ }
42
+
43
+ // cfg.teach_mode 를 실행 중 토글할 수 있으므로 closure 로 cfg 를 잡아둔다.
44
+ function makePrinter(cfg) {
30
45
  return (ev) => {
31
- const [icon, color] = STEP_STYLE[ev.step] || ["•", "cyan"];
32
- const paint = c[color] || ((x) => x);
46
+ if (cfg.teach_mode) return printTeach(ev);
47
+ return printCompact(ev);
48
+ };
49
+ }
33
50
 
34
- // 승인 단계 UI approvalCallback 직접 그린다(자동 승인 안내만 출력)
35
- if (ev.step === Step.APPROVAL && !ev.title.includes("자동 승인")) return;
51
+ // ---- 교육(teach) 렌더: 내부 과정을 패널로 펼쳐 보여준다 ----
52
+ function printTeach(ev) {
53
+ const d = ev.data || {};
54
+ switch (ev.step) {
55
+ case Step.USER_INPUT:
56
+ console.log(`${c.cyan("🧑 ①")} ${c.bold("사용자 입력")} ${c.grey(clip(ev.detail, 200))}`);
57
+ return;
58
+
59
+ case Step.BUILD_CONTEXT:
60
+ console.log(`${c.grey("🧱")} ${c.dim(ev.detail)}`);
61
+ return;
36
62
 
37
- if (ev.step === Step.MODEL_REPLY) {
38
- if (ev.detail && ev.detail !== "(텍스트 없음)") {
39
- console.log(panel(ev.detail.split("\n"), { title: "🤖 모델", color: "green" }));
63
+ case Step.MODEL_CALL: {
64
+ const lines = [];
65
+ lines.push(`${c.grey("provider/model")} ${c.bold(`${d.provider} · ${d.model}`)}`);
66
+ lines.push(c.grey(`모델에 보내는 메시지 ${d.messages?.length || 0}개 · 추정 ${d.estTokens} 토큰 · ${d.totalChars}자`));
67
+ for (const m of d.messages || []) {
68
+ const roleColor = m.role === "system" ? c.magenta : m.role === "user" ? c.cyan : m.role === "assistant" ? c.green : c.yellow;
69
+ lines.push(` ${roleColor(m.role.padEnd(9))} ${c.grey(`${m.chars}자${m.extra || ""}`)}`);
70
+ }
71
+ lines.push(c.grey(`제공 도구(${d.tools?.length || 0}): ${(d.tools || []).join(", ")}`));
72
+ console.log(panel(lines, { title: `🧠 ② LLM 호출 — 반복 ${d.iteration}`, color: "magenta" }));
73
+ if (d.systemPrompt) {
74
+ console.log(panel(clip(d.systemPrompt, 600).split("\n"), {
75
+ title: "📜 시스템 프롬프트 (규칙+폴더가 여기 주입됨)",
76
+ color: "grey",
77
+ }));
40
78
  }
41
79
  return;
42
80
  }
43
- if (ev.step === Step.DONE) {
44
- console.log(panel((ev.detail || "완료").split("\n"), { title: "✅ 완료", color: "green" }));
81
+
82
+ case Step.MODEL_REPLY: {
83
+ const lines = [];
84
+ if (ev.detail && ev.detail !== "(텍스트 없음)") lines.push(...clip(ev.detail, 1200).split("\n"));
85
+ for (const tc of d.toolCalls || []) {
86
+ lines.push(c.yellow(`↳ 도구 호출 요청: ${c.bold(tc.name)}(${clip(JSON.stringify(tc.args), 200)})`));
87
+ }
88
+ const meta = [];
89
+ if (d.latencyMs != null) meta.push(`응답 ${d.latencyMs}ms`);
90
+ if (d.usage) meta.push(`토큰 입력 ${d.usage.input ?? "?"}/출력 ${d.usage.output ?? "?"}/합계 ${d.usage.total ?? "?"}`);
91
+ if (d.request?.bodyBytes) meta.push(`요청 ${d.request.bodyBytes}B`);
92
+ if (meta.length) lines.push(c.grey("─ " + meta.join(" · ")));
93
+ else lines.push(c.dim("(mock: 토큰/지연 측정 없음)"));
94
+ console.log(panel(lines.length ? lines : ["(빈 응답)"], { title: "🤖 ③ 모델 응답 (원본 판단)", color: "green" }));
45
95
  return;
46
96
  }
47
- if (ev.step === Step.ERROR) {
97
+
98
+ case Step.TOOL_DECISION:
99
+ console.log(`${c.yellow("🤔 ④")} ${c.bold("도구 판단")} ${c.grey(clip(ev.detail, 200))}`);
100
+ return;
101
+
102
+ case Step.TOOL_RUN:
103
+ console.log(`${c.blue("🔧 ⑤")} ${c.bold(ev.title)} ${c.grey(clip(ev.detail, 200))}`);
104
+ return;
105
+
106
+ case Step.TOOL_RESULT:
107
+ console.log(panel(clip(ev.detail, 1500).split("\n"), { title: `📄 ${ev.title}`, color: "grey" }));
108
+ return;
109
+
110
+ case Step.FEEDBACK:
111
+ console.log(`${c.grey("↩️ ⑥ 결과 되먹임")} ${c.dim(clip(ev.detail, 200))}`);
112
+ console.log(c.dim(" └ 도구 결과가 컨텍스트에 더해진 채로 ②부터 다시 — 이 반복이 'Agent Loop' 입니다."));
113
+ return;
114
+
115
+ case Step.APPROVAL:
116
+ if (ev.title.includes("자동 승인")) console.log(`${c.yellow("🔓")} ${c.dim(ev.title)}`);
117
+ return;
118
+
119
+ case Step.DONE:
120
+ console.log(panel((ev.detail || "완료").split("\n"), { title: "✅ 완료", color: "green" }));
121
+ return;
122
+
123
+ case Step.ERROR:
48
124
  console.log(panel((ev.detail || "").split("\n"), { title: `❌ ${ev.title}`, color: "red" }));
49
125
  return;
50
- }
126
+ }
127
+ }
51
128
 
52
- let detail = (ev.detail || "").trim().replace(/\s+/g, " ");
53
- if (detail.length > 110) detail = detail.slice(0, 110) + " …";
54
- let line = `${paint(icon)} ${paint(c.bold(ev.title))}`;
55
- if (detail && ev.step !== Step.USER_INPUT) line += ` ${c.grey(detail)}`;
56
- console.log(line);
57
- };
129
+ // ---- 간결(compact) 렌더: 위주 ----
130
+ function printCompact(ev) {
131
+ const [icon, color] = STEP_STYLE[ev.step] || ["•", "cyan"];
132
+ const paint = c[color] || ((x) => x);
133
+ if (ev.step === Step.APPROVAL && !ev.title.includes("자동 승인")) return;
134
+ if (ev.step === Step.MODEL_REPLY) {
135
+ if (ev.detail && ev.detail !== "(텍스트 없음)") console.log(panel(ev.detail.split("\n"), { title: "🤖 모델", color: "green" }));
136
+ return;
137
+ }
138
+ if (ev.step === Step.DONE) return console.log(panel((ev.detail || "완료").split("\n"), { title: "✅ 완료", color: "green" }));
139
+ if (ev.step === Step.ERROR) return console.log(panel((ev.detail || "").split("\n"), { title: `❌ ${ev.title}`, color: "red" }));
140
+ if (ev.step === Step.FEEDBACK) return;
141
+ let detail = clip((ev.detail || "").trim().replace(/\s+/g, " "), 110);
142
+ let line = `${paint(icon)} ${paint(c.bold(ev.title))}`;
143
+ if (detail && ev.step !== Step.USER_INPUT) line += ` ${c.grey(detail)}`;
144
+ console.log(line);
58
145
  }
59
146
 
60
147
  function makeApproval(rl) {
@@ -80,23 +167,36 @@ function makeApproval(rl) {
80
167
  };
81
168
  }
82
169
 
170
+ function makeClient(cfg) {
171
+ return new LLMClient({
172
+ provider: cfg.provider,
173
+ apiKey: cfg.resolvedKey(),
174
+ model: cfg.model,
175
+ temperature: cfg.temperature,
176
+ maxTokens: cfg.max_tokens,
177
+ });
178
+ }
179
+
83
180
  function printIntro(cfg) {
84
181
  console.log(renderBanner());
85
- console.log(c.dim("좁은 의미의 하네스를 터미널에서 체험하는 미니 AI 에이전트 런타임"));
182
+ console.log(c.dim("AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 하네스"));
86
183
  console.log();
184
+ 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("없음");
87
185
  const rows = [
88
186
  ["버전", `v${VERSION}`],
89
187
  ["provider", cfg.provider],
90
188
  ["model", cfg.model],
189
+ ["API 키", keySource],
190
+ ["교육 모드", cfg.teach_mode ? c.green("ON (과정 펼쳐보기)") : "OFF"],
91
191
  ["작업 폴더", cfg.workspacePath()],
92
192
  ["승인 모드", cfg.approval_mode],
93
193
  ["셸 실행", cfg.allow_shell ? "허용" : "차단"],
94
194
  ];
95
- const lines = rows.map(([k, v]) => `${c.grey(k)} ${c.bold(v)}`);
195
+ const lines = rows.map(([k, v]) => `${c.grey(k.padEnd(9))} ${c.bold(v)}`);
96
196
  console.log(panel(lines, { title: "⚙️ CDSA Harness 설정", color: "cyan" }));
97
197
  console.log(
98
198
  c.dim("명령: ") +
99
- `${c.cyan("/help")} 도움말 · ${c.cyan("/reset")} 대화 · ${c.cyan("/config")} 설정값 · ${c.cyan("/quit")} 종료\n`
199
+ `${c.cyan("/setup")} 연결 · ${c.cyan("/teach")} 교육모드 · ${c.cyan("/context")} 컨텍스트 · ${c.cyan("/help")} 도움말 · ${c.cyan("/quit")} 종료\n`
100
200
  );
101
201
  }
102
202
 
@@ -105,29 +205,85 @@ function printHelp() {
105
205
  panel(
106
206
  [
107
207
  c.bold("사용법"),
108
- `그냥 시키고 싶은 일을 입력하면 됩니다. 예) ${c.cyan("notes.txt 맨 아래에 할 일 3개 추가해줘")}`,
208
+ `시키고 싶은 일을 한국어로 입력하세요. 예) ${c.cyan("notes.txt 맨 아래에 할 일 3개 추가해줘")}`,
109
209
  "",
110
210
  c.bold("슬래시 명령"),
111
- ` ${c.cyan("/help")} 도움말`,
112
- ` ${c.cyan("/reset")} 대화/컨텍스트 초기화(새 세션)`,
113
- ` ${c.cyan("/config")} 현재 설정값과 config.json 경로`,
114
- ` ${c.cyan("/sessions")} 세션 로그 폴더 경로`,
115
- ` ${c.cyan("/quit")} 종료 (Ctrl+D 가능)`,
211
+ ` ${c.cyan("/setup")} 제공자·API 키·모델 연결(대화형)`,
212
+ ` ${c.cyan("/provider")} <openai|anthropic|openrouter|mock> 제공자 변경`,
213
+ ` ${c.cyan("/model")} <이름> 모델 변경`,
214
+ ` ${c.cyan("/teach")} 교육 모드 켜기/끄기(내부 과정 펼쳐보기)`,
215
+ ` ${c.cyan("/context")} 지금 모델에 보내는 컨텍스트 들여다보기`,
216
+ ` ${c.cyan("/reset")} 대화/컨텍스트 초기화`,
217
+ ` ${c.cyan("/config")} 현재 설정값`,
218
+ ` ${c.cyan("/quit")} 종료 (Ctrl+D)`,
116
219
  "",
117
- c.bold("하네스 흐름 (각 단계가 색으로 표시됩니다)"),
118
- " 입력 → 컨텍스트 구성 → LLM 호출 → 도구 판단 승인 실행 → 결과 반영 → 반복",
220
+ c.bold("교육 모드에서 보이는 단계"),
221
+ " 입력 → LLM 호출(컨텍스트·도구) 모델 응답(토큰·지연) →",
222
+ " ④ 도구 판단 → ⑤ 실행/승인 → ⑥ 결과 되먹임 → (반복)",
119
223
  ],
120
224
  { title: "도움말", color: "cyan" }
121
225
  )
122
226
  );
123
227
  }
124
228
 
229
+ // 대화형 연결 설정. 키는 환경변수가 있으면 그걸 우선 안내(파일 저장 안 함).
230
+ async function runSetup(rl, cfg) {
231
+ console.log(panel(
232
+ [
233
+ "어떤 AI 에 연결할까요? 번호를 입력하세요.",
234
+ ` ${c.bold("1")}) openai (GPT, 키: ${ENV_KEYS.openai})`,
235
+ ` ${c.bold("2")}) anthropic (Claude, 키: ${ENV_KEYS.anthropic})`,
236
+ ` ${c.bold("3")}) openrouter (여러 모델 중계, 키: ${ENV_KEYS.openrouter})`,
237
+ ` ${c.bold("4")}) mock (키 없이 연습)`,
238
+ ],
239
+ { title: "🔌 연결 설정 (/setup)", color: "cyan" }
240
+ ));
241
+ const pick = (await rl.question(c.cyan("제공자 번호 [1-4]: "))).trim();
242
+ const provider = { "1": "openai", "2": "anthropic", "3": "openrouter", "4": "mock" }[pick];
243
+ if (!provider) {
244
+ console.log(c.yellow("취소했습니다."));
245
+ return false;
246
+ }
247
+ cfg.provider = provider;
248
+
249
+ if (provider === "mock") {
250
+ cfg.model = "mock-agent";
251
+ } else {
252
+ const envName = ENV_KEYS[provider];
253
+ const envVal = (process.env[envName] || "").trim();
254
+ if (envVal) {
255
+ const useEnv = (await rl.question(c.cyan(`환경변수 ${envName} 에서 키를 찾았어요. 사용할까요? [Y/n] `))).trim().toLowerCase();
256
+ if (useEnv === "" || useEnv === "y" || useEnv === "yes") {
257
+ cfg.api_key = ""; // 환경변수 사용 → 파일엔 저장 안 함
258
+ } else {
259
+ const k = (await rl.question(c.cyan("API 키 붙여넣기(입력이 보일 수 있음): "))).trim();
260
+ cfg.api_key = k;
261
+ }
262
+ } else {
263
+ console.log(c.dim(`(또는 종료 후 환경변수 ${envName} 에 키를 넣어두면 파일에 저장하지 않아도 됩니다)`));
264
+ const k = (await rl.question(c.cyan("API 키 붙여넣기(입력이 보일 수 있음): "))).trim();
265
+ cfg.api_key = k;
266
+ }
267
+ const sugg = SUGGESTED_MODELS[provider] || [];
268
+ const def = sugg[0] || "";
269
+ const m = (await rl.question(c.cyan(`모델 [${def}] (추천: ${sugg.join(", ")}): `))).trim();
270
+ cfg.model = m || def;
271
+ }
272
+
273
+ const saved = saveConfig(cfg);
274
+ console.log(c.green(`설정을 저장했습니다 → ${saved}`));
275
+ if (!cfg.isReady()) console.log(c.yellow("⚠️ 아직 키가 없어 호출이 실패할 수 있어요. 환경변수나 /setup 으로 키를 넣어주세요."));
276
+ return true;
277
+ }
278
+
125
279
  function parseArgs(argv) {
126
280
  const out = { _: [] };
127
281
  for (let i = 0; i < argv.length; i++) {
128
282
  const a = argv[i];
129
283
  if (a === "--auto") out.auto = true;
130
284
  else if (a === "--help" || a === "-h") out.help = true;
285
+ else if (a === "--setup") out.setup = true;
286
+ else if (a === "--no-teach") out.noTeach = true;
131
287
  else if (a === "--provider") out.provider = argv[++i];
132
288
  else if (a === "--model") out.model = argv[++i];
133
289
  else if (a === "--workspace") out.workspace = argv[++i];
@@ -140,13 +296,16 @@ export async function main(argv = []) {
140
296
  const args = parseArgs(argv);
141
297
  if (args.help) {
142
298
  console.log(
143
- "CDSA Harness — 미니 AI 에이전트 하네스 (터미널)\n\n" +
299
+ "CDSA Harness — AI 에이전트 내부를 드러내는 교육용 하네스 (터미널)\n\n" +
144
300
  "사용법: cdsa-harness [옵션]\n" +
145
- " --provider <openai|openrouter|mock>\n" +
301
+ " --provider <openai|anthropic|openrouter|mock>\n" +
146
302
  " --model <모델명>\n" +
147
303
  " --workspace <폴더경로>\n" +
304
+ " --setup 대화형 연결 설정 실행\n" +
305
+ " --no-teach 교육 모드 끄고 간결하게\n" +
148
306
  " --auto 승인 자동(approval_mode=auto)\n" +
149
- " -h, --help 도움말\n"
307
+ " -h, --help 도움말\n\n" +
308
+ "API 키는 환경변수로도 인식됩니다: OPENAI_API_KEY / ANTHROPIC_API_KEY / OPENROUTER_API_KEY\n"
150
309
  );
151
310
  return 0;
152
311
  }
@@ -156,33 +315,26 @@ export async function main(argv = []) {
156
315
  if (args.model) cfg.model = args.model;
157
316
  if (args.workspace) cfg.workspace = args.workspace;
158
317
  if (args.auto) cfg.approval_mode = "auto";
318
+ if (args.noTeach) cfg.teach_mode = false;
159
319
 
160
- if (!cfg.isReady()) {
161
- console.log(
162
- c.yellow("API Key 가 없어 mock 모드로 실행합니다. ") +
163
- `실제 LLM 을 쓰려면 ${configPath()} 에 provider/api_key 를 설정하세요.\n`
164
- );
165
- cfg.provider = "mock";
166
- cfg.model = "mock-agent";
320
+ const rl = readline.createInterface({ input: stdin, output: stdout });
321
+
322
+ if (args.setup) {
323
+ await runSetup(rl, cfg);
324
+ } else if (!cfg.isReady() && cfg.provider !== "mock") {
325
+ console.log(c.yellow(`provider=${cfg.provider} 인데 API 키가 없습니다. 연결 설정을 시작합니다.\n`));
326
+ await runSetup(rl, cfg);
167
327
  }
168
328
 
169
329
  printIntro(cfg);
170
330
 
171
- const client = new LLMClient({
172
- provider: cfg.provider,
173
- apiKey: cfg.api_key,
174
- model: cfg.model,
175
- temperature: cfg.temperature,
176
- });
177
331
  const toolbox = new Toolbox(cfg.workspacePath(), cfg.allow_shell);
178
332
  const session = SessionLog.create();
179
- const rl = readline.createInterface({ input: stdin, output: stdout });
180
-
181
333
  const loop = new AgentLoop({
182
334
  config: cfg,
183
- client,
335
+ client: makeClient(cfg),
184
336
  toolbox,
185
- onEvent: makePrinter(),
337
+ onEvent: makePrinter(cfg),
186
338
  approvalCallback: makeApproval(rl),
187
339
  session,
188
340
  });
@@ -195,27 +347,51 @@ export async function main(argv = []) {
195
347
  try {
196
348
  user = (await rl.question(c.bold(c.cyan("› ")))).trim();
197
349
  } catch {
198
- break; // Ctrl+D / 스트림 종료
350
+ break;
199
351
  }
200
352
  if (!user) continue;
201
353
  const low = user.toLowerCase();
354
+
202
355
  if (["/quit", "/exit", "quit", "exit", ":q"].includes(low)) break;
203
- if (low === "/help") {
204
- printHelp();
356
+ if (low === "/help") { printHelp(); continue; }
357
+ if (low === "/reset") { loop.reset(); console.log(c.green("컨텍스트를 초기화했습니다.")); continue; }
358
+ if (low === "/config") { printIntro(cfg); console.log(c.dim(`config.json: ${configPath()}`)); continue; }
359
+ if (low === "/sessions") { console.log(c.dim(`세션 로그: ${sessionsDir()}`)); continue; }
360
+ if (low === "/teach") {
361
+ cfg.teach_mode = !cfg.teach_mode;
362
+ console.log(c.green(`교육 모드 ${cfg.teach_mode ? "ON" : "OFF"}.`));
363
+ continue;
364
+ }
365
+ if (low === "/setup" || low === "/login") {
366
+ await runSetup(rl, cfg);
367
+ loop.client = makeClient(cfg);
368
+ loop.reset();
205
369
  continue;
206
370
  }
207
- if (low === "/reset") {
371
+ if (low.startsWith("/provider")) {
372
+ const p = user.split(/\s+/)[1];
373
+ if (!PROVIDERS.includes(p)) { console.log(c.yellow(`provider 는 ${PROVIDERS.join("/")} 중 하나.`)); continue; }
374
+ cfg.provider = p;
375
+ if (SUGGESTED_MODELS[p]?.length) cfg.model = SUGGESTED_MODELS[p][0];
376
+ loop.client = makeClient(cfg);
208
377
  loop.reset();
209
- console.log(c.green("컨텍스트를 초기화했습니다( 세션)."));
378
+ console.log(c.green(`provider=${p}, model=${cfg.model} (키: ${cfg.isReady() ? "OK" : c.red("없음 — /setup")})`));
210
379
  continue;
211
380
  }
212
- if (low === "/config") {
213
- printIntro(cfg);
214
- console.log(c.dim(`config.json: ${configPath()}`));
381
+ if (low.startsWith("/model")) {
382
+ const m = user.split(/\s+/).slice(1).join(" ").trim();
383
+ if (!m) { console.log(c.dim(`현재 모델: ${cfg.model} · 추천: ${(SUGGESTED_MODELS[cfg.provider] || []).join(", ")}`)); continue; }
384
+ cfg.model = m;
385
+ loop.client = makeClient(cfg);
386
+ console.log(c.green(`model=${m}`));
215
387
  continue;
216
388
  }
217
- if (low === "/sessions") {
218
- console.log(c.dim(`세션 로그 폴더: ${sessionsDir()}`));
389
+ if (low === "/context") {
390
+ const ctx = loop.contextSummary();
391
+ const lines = [c.grey(`메시지 ${ctx.rows.length}개 · 추정 ${ctx.estTokens} 토큰 · ${ctx.totalChars}자`)];
392
+ for (const m of ctx.rows) lines.push(` ${c.bold(m.role.padEnd(9))} ${c.grey(`${m.chars}자${m.extra || ""}`)}`);
393
+ console.log(panel(lines, { title: "🧩 현재 컨텍스트(다음 호출에 전송됨)", color: "magenta" }));
394
+ console.log(panel(clip(ctx.systemPrompt, 800).split("\n"), { title: "📜 시스템 프롬프트", color: "grey" }));
219
395
  continue;
220
396
  }
221
397
 
package/src/config.js CHANGED
@@ -4,18 +4,26 @@ 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", "openrouter", "mock"];
7
+ export const PROVIDERS = ["openai", "anthropic", "openrouter", "mock"];
8
8
 
9
9
  export const SUGGESTED_MODELS = {
10
- openai: ["gpt-4.1-mini", "gpt-4o-mini", "gpt-4.1"],
10
+ openai: ["gpt-4o-mini", "gpt-4.1-mini", "gpt-4.1"],
11
+ anthropic: ["claude-3-5-haiku-latest", "claude-3-5-sonnet-latest", "claude-sonnet-4-5"],
11
12
  openrouter: [
12
- "openai/gpt-4.1-mini",
13
+ "openai/gpt-4o-mini",
13
14
  "anthropic/claude-3.5-sonnet",
14
15
  "google/gemini-2.5-flash",
15
16
  ],
16
17
  mock: ["mock-agent"],
17
18
  };
18
19
 
20
+ // provider 별로 자동 감지하는 환경변수(파일에 키를 저장하지 않아도 됨)
21
+ export const ENV_KEYS = {
22
+ openai: "OPENAI_API_KEY",
23
+ anthropic: "ANTHROPIC_API_KEY",
24
+ openrouter: "OPENROUTER_API_KEY",
25
+ };
26
+
19
27
  export const APPROVAL_MODES = ["manual", "auto"];
20
28
 
21
29
  const DEFAULTS = {
@@ -27,6 +35,8 @@ const DEFAULTS = {
27
35
  allow_shell: false,
28
36
  max_steps: 8,
29
37
  temperature: 0.2,
38
+ max_tokens: 1024,
39
+ teach_mode: true,
30
40
  };
31
41
 
32
42
  export function configDir() {
@@ -54,9 +64,17 @@ export class Config {
54
64
  return p;
55
65
  }
56
66
 
67
+ // 파일에 저장된 키가 없으면 환경변수에서 찾는다.
68
+ resolvedKey() {
69
+ const direct = (this.api_key || "").trim();
70
+ if (direct) return direct;
71
+ const envName = ENV_KEYS[this.provider];
72
+ return envName ? (process.env[envName] || "").trim() : "";
73
+ }
74
+
57
75
  isReady() {
58
76
  if (this.provider === "mock") return true;
59
- return Boolean((this.api_key || "").trim());
77
+ return Boolean(this.resolvedKey());
60
78
  }
61
79
 
62
80
  toJSON() {
package/src/llm.js CHANGED
@@ -1,31 +1,58 @@
1
- // LLM 호출 계층. OpenAI / OpenRouter (OpenAI 호환) + 없이 체험하는 mock.
1
+ // LLM 호출 계층. OpenAI / OpenRouter (OpenAI 호환) / Anthropic(Claude) + mock.
2
2
  // Node 18+ 내장 fetch 사용 → 외부 의존성 없음.
3
+ //
4
+ // 모든 provider 의 응답을 아래 한 가지 형태로 정규화해서 돌려준다(교육 모드용 메타 포함):
5
+ // { content, toolCalls:[{id,name,args}], usage:{input,output,total}|null,
6
+ // latencyMs, request:{ provider, endpoint, model, temperature, toolCount, bodyBytes } }
3
7
 
4
8
  export class LLMError extends Error {}
5
9
 
6
10
  const ENDPOINTS = {
7
11
  openai: "https://api.openai.com/v1/chat/completions",
8
12
  openrouter: "https://openrouter.ai/api/v1/chat/completions",
13
+ anthropic: "https://api.anthropic.com/v1/messages",
9
14
  };
10
15
 
11
16
  export class LLMClient {
12
- constructor({ provider, apiKey, model, temperature = 0.2, timeout = 60000 }) {
17
+ constructor({ provider, apiKey, model, temperature = 0.2, maxTokens = 1024, timeout = 60000 }) {
13
18
  this.provider = provider;
14
19
  this.apiKey = apiKey;
15
20
  this.model = model;
16
21
  this.temperature = temperature;
22
+ this.maxTokens = maxTokens;
17
23
  this.timeout = timeout;
18
24
  }
19
25
 
20
26
  async chat(messages, tools) {
21
27
  if (this.provider === "mock") return mockChat(messages);
22
- if (!ENDPOINTS[this.provider]) {
23
- throw new LLMError(`지원하지 않는 provider 입니다: ${this.provider}`);
28
+ if (this.provider === "anthropic") return this._anthropicChat(messages, tools);
29
+ if (ENDPOINTS[this.provider]) return this._openaiChat(messages, tools);
30
+ throw new LLMError(`지원하지 않는 provider 입니다: ${this.provider}`);
31
+ }
32
+
33
+ async _post(url, headers, body) {
34
+ const json = JSON.stringify(body);
35
+ const ctrl = new AbortController();
36
+ const timer = setTimeout(() => ctrl.abort(), this.timeout);
37
+ const started = Date.now();
38
+ let res;
39
+ try {
40
+ res = await fetch(url, { method: "POST", headers, body: json, signal: ctrl.signal });
41
+ } catch (e) {
42
+ throw new LLMError(`네트워크 오류: ${e.message}`);
43
+ } finally {
44
+ clearTimeout(timer);
45
+ }
46
+ const latencyMs = Date.now() - started;
47
+ if (!res.ok) {
48
+ const text = await res.text().catch(() => "");
49
+ throw new LLMError(`API 오류 ${res.status}: ${trim(text) || res.statusText}`);
24
50
  }
25
- return this._httpChat(messages, tools);
51
+ return { payload: await res.json(), latencyMs, bodyBytes: Buffer.byteLength(json, "utf8") };
26
52
  }
27
53
 
28
- async _httpChat(messages, tools) {
54
+ // --- OpenAI / OpenRouter (chat/completions 형식) ---
55
+ async _openaiChat(messages, tools) {
29
56
  const url = ENDPOINTS[this.provider];
30
57
  const body = { model: this.model, messages, temperature: this.temperature };
31
58
  if (tools && tools.length) {
@@ -37,96 +64,206 @@ export class LLMClient {
37
64
  "Content-Type": "application/json",
38
65
  };
39
66
  if (this.provider === "openrouter") {
40
- headers["HTTP-Referer"] = "https://github.com/cdsa-harness";
67
+ headers["HTTP-Referer"] = "https://github.com/cdsassj00/miniharness";
41
68
  headers["X-Title"] = "CDSA Harness";
42
69
  }
70
+ const { payload, latencyMs, bodyBytes } = await this._post(url, headers, body);
71
+ const parsed = parseOpenAiReply(payload);
72
+ return {
73
+ ...parsed,
74
+ latencyMs,
75
+ request: this._meta(url, tools, bodyBytes),
76
+ };
77
+ }
43
78
 
44
- const ctrl = new AbortController();
45
- const timer = setTimeout(() => ctrl.abort(), this.timeout);
46
- let res;
47
- try {
48
- res = await fetch(url, {
49
- method: "POST",
50
- headers,
51
- body: JSON.stringify(body),
52
- signal: ctrl.signal,
53
- });
54
- } catch (e) {
55
- throw new LLMError(`네트워크 오류: ${e.message}`);
56
- } finally {
57
- clearTimeout(timer);
58
- }
79
+ // --- Anthropic (messages 형식) ---
80
+ async _anthropicChat(messages, tools) {
81
+ const url = ENDPOINTS.anthropic;
82
+ const body = toAnthropicBody(messages, tools, this.model, this.temperature, this.maxTokens);
83
+ const headers = {
84
+ "x-api-key": this.apiKey,
85
+ "anthropic-version": "2023-06-01",
86
+ "Content-Type": "application/json",
87
+ };
88
+ const { payload, latencyMs, bodyBytes } = await this._post(url, headers, body);
89
+ const parsed = parseAnthropicReply(payload);
90
+ return {
91
+ ...parsed,
92
+ latencyMs,
93
+ request: this._meta(url, tools, bodyBytes),
94
+ };
95
+ }
59
96
 
60
- if (!res.ok) {
61
- const text = await res.text().catch(() => "");
62
- throw new LLMError(`API 오류 ${res.status}: ${text || res.statusText}`);
63
- }
64
- const payload = await res.json();
65
- return parseOpenAiReply(payload);
97
+ _meta(endpoint, tools, bodyBytes) {
98
+ return {
99
+ provider: this.provider,
100
+ endpoint,
101
+ model: this.model,
102
+ temperature: this.temperature,
103
+ toolCount: tools ? tools.length : 0,
104
+ bodyBytes,
105
+ };
66
106
  }
67
107
  }
68
108
 
109
+ function trim(s) {
110
+ s = String(s || "");
111
+ return s.length > 400 ? s.slice(0, 400) + " …" : s;
112
+ }
113
+
69
114
  function parseOpenAiReply(payload) {
70
115
  const msg = payload?.choices?.[0]?.message;
71
116
  if (!msg) {
72
- throw new LLMError(
73
- `예상치 못한 응답 형식입니다: ${JSON.stringify(payload).slice(0, 300)}`
74
- );
117
+ throw new LLMError(`예상치 못한 응답 형식입니다: ${JSON.stringify(payload).slice(0, 300)}`);
75
118
  }
76
119
  const toolCalls = [];
77
120
  for (const tc of msg.tool_calls || []) {
78
121
  let args = {};
79
122
  try {
80
- args = typeof tc.function?.arguments === "string"
81
- ? JSON.parse(tc.function.arguments || "{}")
82
- : tc.function?.arguments || {};
123
+ args =
124
+ typeof tc.function?.arguments === "string"
125
+ ? JSON.parse(tc.function.arguments || "{}")
126
+ : tc.function?.arguments || {};
83
127
  } catch {
84
128
  args = { _raw: tc.function?.arguments };
85
129
  }
86
130
  toolCalls.push({ id: tc.id || `call_${toolCalls.length}`, name: tc.function?.name || "", args });
87
131
  }
88
- return { content: msg.content ?? null, toolCalls };
132
+ const u = payload.usage;
133
+ const usage = u
134
+ ? { input: u.prompt_tokens ?? null, output: u.completion_tokens ?? null, total: u.total_tokens ?? null }
135
+ : null;
136
+ return { content: msg.content ?? null, toolCalls, usage };
137
+ }
138
+
139
+ // 내부(OpenAI 형식) 메시지를 Anthropic messages 형식으로 변환.
140
+ // - system 메시지 → 최상위 system 필드
141
+ // - assistant tool_calls → content 의 tool_use 블록
142
+ // - tool 메시지 → user 의 tool_result 블록 (연속된 것은 하나의 user 로 합침)
143
+ export function toAnthropicBody(messages, tools, model, temperature, maxTokens) {
144
+ let system = "";
145
+ const out = [];
146
+ for (const m of messages) {
147
+ if (m.role === "system") {
148
+ system += (system ? "\n" : "") + (m.content || "");
149
+ } else if (m.role === "user") {
150
+ pushUserBlock(out, { type: "text", text: m.content || "" });
151
+ } else if (m.role === "assistant") {
152
+ const content = [];
153
+ if (m.content) content.push({ type: "text", text: m.content });
154
+ for (const tc of m.tool_calls || []) {
155
+ let input = {};
156
+ try {
157
+ input = JSON.parse(tc.function?.arguments || "{}");
158
+ } catch {
159
+ input = {};
160
+ }
161
+ content.push({ type: "tool_use", id: tc.id, name: tc.function?.name, input });
162
+ }
163
+ out.push({ role: "assistant", content: content.length ? content : [{ type: "text", text: "" }] });
164
+ } else if (m.role === "tool") {
165
+ pushUserBlock(out, { type: "tool_result", tool_use_id: m.tool_call_id, content: m.content || "" });
166
+ }
167
+ }
168
+ const body = { model, max_tokens: maxTokens, system, messages: out, temperature };
169
+ if (tools && tools.length) {
170
+ body.tools = tools.map((t) => ({
171
+ name: t.function.name,
172
+ description: t.function.description,
173
+ input_schema: t.function.parameters,
174
+ }));
175
+ }
176
+ return body;
177
+ }
178
+
179
+ // 직전 메시지가 user 면 블록을 이어붙이고, 아니면 새 user 메시지를 만든다.
180
+ function pushUserBlock(out, block) {
181
+ const last = out[out.length - 1];
182
+ if (last && last.role === "user") last.content.push(block);
183
+ else out.push({ role: "user", content: [block] });
184
+ }
185
+
186
+ function parseAnthropicReply(payload) {
187
+ if (payload?.type === "error") {
188
+ throw new LLMError(`Anthropic 오류: ${payload.error?.message || JSON.stringify(payload)}`);
189
+ }
190
+ const blocks = payload?.content || [];
191
+ let text = "";
192
+ const toolCalls = [];
193
+ for (const b of blocks) {
194
+ if (b.type === "text") text += b.text;
195
+ else if (b.type === "tool_use") toolCalls.push({ id: b.id, name: b.name, args: b.input || {} });
196
+ }
197
+ const u = payload?.usage;
198
+ const usage = u
199
+ ? {
200
+ input: u.input_tokens ?? null,
201
+ output: u.output_tokens ?? null,
202
+ total: (u.input_tokens || 0) + (u.output_tokens || 0),
203
+ }
204
+ : null;
205
+ return { content: text || null, toolCalls, usage };
89
206
  }
90
207
 
91
208
  // ---------------------------------------------------------------------------
92
209
  // Mock 에이전트: 키 없이 Agent Loop 전체를 체험.
93
- // 히스토리에 쌓인 tool 결과 개수로 '현재 단계'를 판단하는 결정형 에이전트.
210
+ // 인사/잡담엔 그냥 대화로 답하고, 파일 작업을 시킬 때만 도구 데모를 돌린다.
94
211
  // ---------------------------------------------------------------------------
212
+ const FILE_TASK_RE =
213
+ /(파일|폴더|디렉|notes|\.txt|\.md|\.js|읽|쓰|수정|고치|만들|생성|추가|삭제|편집|list|read|write|만들어|적어|기록)/i;
214
+
215
+ function mockReturn(extra) {
216
+ return { usage: null, latencyMs: 1, request: { provider: "mock", endpoint: "(mock)", model: "mock-agent", temperature: 0, toolCount: 0, bodyBytes: 0 }, ...extra };
217
+ }
218
+
95
219
  function mockChat(messages) {
96
220
  const toolMsgs = messages.filter((m) => m.role === "tool");
97
221
  const lastUser = [...messages].reverse().find((m) => m.role === "user")?.content || "";
98
222
  const phase = toolMsgs.length;
99
223
 
224
+ // 아직 도구를 쓰기 전 + 파일 작업 요청이 아니면 → 그냥 대화로 응답(도구 X)
225
+ if (phase === 0 && !FILE_TASK_RE.test(lastUser)) {
226
+ return mockReturn({
227
+ content:
228
+ "안녕하세요! 저는 CDSA Harness 의 mock(연습) 에이전트예요. 실제 AI 는 아니고, " +
229
+ "에이전트가 도구로 파일을 다루는 흐름을 보여주는 데모입니다.\n" +
230
+ "예) \"notes.txt 맨 아래에 할 일 3개 추가해줘\" 처럼 파일 작업을 시켜보세요. " +
231
+ "진짜 AI 와 대화하려면 /setup 으로 OpenAI·Claude 키를 연결하면 됩니다.",
232
+ toolCalls: [],
233
+ });
234
+ }
235
+
100
236
  if (phase === 0) {
101
- return {
237
+ return mockReturn({
102
238
  content: "작업 폴더 구조부터 확인하겠습니다.",
103
239
  toolCalls: [{ id: "mock_1", name: "list_dir", args: { path: "." } }],
104
- };
240
+ });
105
241
  }
106
242
  if (phase === 1) {
107
243
  const target = guessFile(toolMsgs[0].content || "", lastUser);
108
- return {
244
+ return mockReturn({
109
245
  content: `\`${target}\` 파일을 읽어 현재 내용을 확인하겠습니다.`,
110
246
  toolCalls: [{ id: "mock_2", name: "read_file", args: { path: target } }],
111
- };
247
+ });
112
248
  }
113
249
  if (phase === 2) {
114
250
  const target = guessFile(toolMsgs[0].content || "", lastUser);
115
251
  const current = toolMsgs[1].content || "";
116
- const addition = `\n\n# (CDSA Harness mock 에이전트가 추가) 요청: ${lastUser.trim().slice(0, 60)}`;
117
- const newContent = current.replace(/\n+$/, "") + addition + "\n";
118
- return {
119
- content: `\`${target}\` 끝에 메모 한 줄을 추가하는 수정을 제안합니다.`,
252
+ const base = /파일이 없습니다|경로가 없습니다/.test(current) ? "" : current;
253
+ const addition = `# (CDSA Harness mock 에이전트가 추가) 요청: ${lastUser.trim().slice(0, 60)}`;
254
+ const newContent = (base.replace(/\n+$/, "") + "\n\n" + addition + "\n").replace(/^\n+/, "");
255
+ return mockReturn({
256
+ content: `\`${target}\` 에 메모 한 줄을 추가하는 수정을 제안합니다.`,
120
257
  toolCalls: [{ id: "mock_3", name: "write_file", args: { path: target, content: newContent } }],
121
- };
258
+ });
122
259
  }
123
- return {
260
+ return mockReturn({
124
261
  content:
125
262
  "완료했습니다. 방금까지의 흐름이 바로 하네스의 Agent Loop 입니다:\n" +
126
- "① 작업 폴더 보기 → ② 파일 읽기 → ③ 파일 수정 제안 → ④ 사용자 승인 → ⑤ 저장 → ⑥ 결과를 다시 모델에 전달.\n" +
127
- "실제 LLM 쓰려면 --provider openai --model ... API Key 를 설정하세요.",
263
+ "① 폴더 보기 → ② 파일 읽기 → ③ 수정 제안 → ④ 승인 → ⑤ 저장 → ⑥ 결과를 다시 모델에 전달.\n" +
264
+ "실제 LLM 으로 같은 흐름을 보려면 /setup 으로 키를 연결하세요.",
128
265
  toolCalls: [],
129
- };
266
+ });
130
267
  }
131
268
 
132
269
  function guessFile(listing, userText) {
@@ -142,5 +279,7 @@ function guessFile(listing, userText) {
142
279
  for (const name of files) {
143
280
  if (userText.includes(name)) return name;
144
281
  }
282
+ const m = userText.match(/[\w./-]+\.(txt|md|js|json|py|ts|csv)/i);
283
+ if (m) return m[0];
145
284
  return files[0] || "notes.txt";
146
285
  }
package/src/loop.js CHANGED
@@ -19,6 +19,7 @@ export const Step = {
19
19
  APPROVAL: "approval",
20
20
  TOOL_RUN: "tool_run",
21
21
  TOOL_RESULT: "tool_result",
22
+ FEEDBACK: "feedback",
22
23
  DONE: "done",
23
24
  ERROR: "error",
24
25
  };
@@ -32,10 +33,35 @@ export const STEP_LABELS = {
32
33
  approval: "사용자 승인",
33
34
  tool_run: "도구 실행",
34
35
  tool_result: "결과 반영",
36
+ feedback: "결과 되먹임",
35
37
  done: "완료",
36
38
  error: "오류",
37
39
  };
38
40
 
41
+ // 아주 거친 토큰 추정(영문 ~4자/토큰, 한글은 더 많지만 교육용 어림값).
42
+ export function estimateTokens(text) {
43
+ return Math.max(1, Math.round((text || "").length / 4));
44
+ }
45
+
46
+ // messages 를 교육용으로 요약: 역할별 글자수 + 도구호출 수.
47
+ function summarizeMessages(messages) {
48
+ let totalChars = 0;
49
+ const rows = messages.map((m) => {
50
+ const text = m.content || "";
51
+ let chars = text.length;
52
+ let extra = "";
53
+ if (m.tool_calls && m.tool_calls.length) {
54
+ const j = JSON.stringify(m.tool_calls);
55
+ chars += j.length;
56
+ extra = ` +tool_calls(${m.tool_calls.length})`;
57
+ }
58
+ if (m.role === "tool") extra = " (도구 결과)";
59
+ totalChars += chars;
60
+ return { role: m.role, chars, extra };
61
+ });
62
+ return { rows, totalChars, estTokens: estimateTokens(messages.map((m) => (m.content || "") + JSON.stringify(m.tool_calls || "")).join("")) };
63
+ }
64
+
39
65
  const RULES_FILENAMES = ["AGENT.md", "AGENTS.md", "CLAUDE.md", "rules.md", "RULES.md"];
40
66
 
41
67
  function findRules(workspace) {
@@ -98,7 +124,13 @@ export class AgentLoop {
98
124
  }
99
125
 
100
126
  reset() {
101
- this.messages = [{ role: "system", content: this._systemPrompt() }];
127
+ this.systemPromptText = this._systemPrompt();
128
+ this.messages = [{ role: "system", content: this.systemPromptText }];
129
+ }
130
+
131
+ // /context 명령용: 현재 대화 컨텍스트 요약.
132
+ contextSummary() {
133
+ return { ...summarizeMessages(this.messages), systemPrompt: this.systemPromptText };
102
134
  }
103
135
 
104
136
  async run(userInput) {
@@ -114,13 +146,26 @@ export class AgentLoop {
114
146
  );
115
147
 
116
148
  const tools = toolSchemas(this.config.allow_shell);
149
+ const toolNames = tools.map((t) => t.function.name);
117
150
  let finalText = "";
118
151
 
119
152
  for (let stepNo = 1; stepNo <= this.config.max_steps; stepNo++) {
153
+ // ② 모델에 보내는 컨텍스트를 그대로 드러낸다(교육 모드 핵심).
154
+ const ctx = summarizeMessages(this.messages);
120
155
  this._emit(
121
156
  Step.MODEL_CALL,
122
- `LLM 호출 (반복 ${stepNo})`,
123
- `메시지 ${this.messages.length}개를 모델에 전송합니다.`
157
+ `LLM 호출 (반복 ${stepNo}/${this.config.max_steps})`,
158
+ `메시지 ${this.messages.length}개(추정 ${ctx.estTokens} 토큰)를 모델에 전송합니다.`,
159
+ {
160
+ iteration: stepNo,
161
+ provider: this.config.provider,
162
+ model: this.config.model,
163
+ messages: ctx.rows,
164
+ totalChars: ctx.totalChars,
165
+ estTokens: ctx.estTokens,
166
+ tools: toolNames,
167
+ systemPrompt: stepNo === 1 ? this.systemPromptText : null,
168
+ }
124
169
  );
125
170
 
126
171
  let reply;
@@ -134,8 +179,12 @@ export class AgentLoop {
134
179
  throw e;
135
180
  }
136
181
 
182
+ // ③ 모델의 원본 판단 + 실측 메타(응답시간/토큰/요청크기)를 드러낸다.
137
183
  this._emit(Step.MODEL_REPLY, "모델 응답", reply.content || "(텍스트 없음)", {
138
184
  toolCalls: reply.toolCalls.map((tc) => ({ name: tc.name, args: tc.args })),
185
+ usage: reply.usage || null,
186
+ latencyMs: reply.latencyMs ?? null,
187
+ request: reply.request || null,
139
188
  });
140
189
  if (reply.content) finalText = reply.content;
141
190
 
@@ -163,6 +212,14 @@ export class AgentLoop {
163
212
  const resultText = await this._handleToolCall(tc);
164
213
  this.messages.push({ role: "tool", tool_call_id: tc.id, content: resultText });
165
214
  }
215
+
216
+ // ⑤ 도구 결과를 messages 에 넣고 다시 ②로 — 이 되먹임이 'Loop' 의 정체.
217
+ this._emit(
218
+ Step.FEEDBACK,
219
+ "결과 되먹임",
220
+ `도구 결과를 대화에 추가했습니다(현재 메시지 ${this.messages.length}개). 같은 컨텍스트로 다시 모델을 호출합니다.`,
221
+ { messageCount: this.messages.length }
222
+ );
166
223
  }
167
224
 
168
225
  this._emit(Step.DONE, "반복 한도 도달", `max_steps(${this.config.max_steps})에 도달해 종료했습니다.`);