cdsa-harness 0.1.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 +116 -0
- package/bin/cdsa-harness.js +11 -0
- package/package.json +44 -0
- package/src/banner.js +19 -0
- package/src/cli.js +235 -0
- package/src/config.js +86 -0
- package/src/llm.js +146 -0
- package/src/loop.js +223 -0
- package/src/session.js +46 -0
- package/src/tools.js +220 -0
- package/src/ui.js +84 -0
- package/workspace/AGENT.md +17 -0
- package/workspace/notes.txt +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# CDSA Harness (Node.js CLI) ⌨️
|
|
2
|
+
|
|
3
|
+
> Claude Code / Codex CLI / OpenCode 와 **똑같은 방식(npm/npx)** 으로 설치하는,
|
|
4
|
+
> 좁은 의미의 AI 에이전트 하네스 교육용 터미널 앱.
|
|
5
|
+
|
|
6
|
+
시작하면 ASCII 배너가 뜨고, **Agent Loop** 의 모든 단계가 색으로 흐릅니다.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
입력 → 컨텍스트 구성 → LLM 호출 → 도구 판단 → 승인 → 실행 → 결과 반영 → 반복
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
- **의존성 0개** — Node 18+ 내장 기능만 사용(`fetch`/`readline`/`node:test`). `npx` 가 빠르고 설치 실패가 없습니다.
|
|
13
|
+
- 같은 저장소의 Python 버전(Mini Harness GUI / CDSA Harness TUI)과 **동일한 하네스 구조**를 그대로 옮긴 것입니다.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 설치 / 실행
|
|
18
|
+
|
|
19
|
+
### npx (설치 없이 즉시)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 로컬 체크아웃에서
|
|
23
|
+
cd node-cli
|
|
24
|
+
npx . # API Key 없으면 자동 mock 모드
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 전역 설치
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cd node-cli
|
|
31
|
+
npm install -g . # 또는: npm link
|
|
32
|
+
cdsa-harness # 어디서나 실행 (별칭: cdsa)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 그냥 node 로
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
cd node-cli
|
|
39
|
+
node bin/cdsa-harness.js
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
> npm 레지스트리에 publish 하면 `npm install -g cdsa-harness` / `npx cdsa-harness` 로 바로 설치됩니다.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 옵션 / 명령
|
|
47
|
+
|
|
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
|
+
```
|
|
54
|
+
|
|
55
|
+
터미널 슬래시 명령: `/help` · `/reset` · `/config` · `/sessions` · `/quit` (Ctrl+D 로도 종료)
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## 설정 (config.json)
|
|
60
|
+
|
|
61
|
+
`~/.cdsa_harness/config.json` 에 저장됩니다(실행 폴더에 `config.json` 이 있으면 우선).
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"provider": "openai",
|
|
66
|
+
"api_key": "",
|
|
67
|
+
"model": "gpt-4.1-mini",
|
|
68
|
+
"workspace": "./workspace",
|
|
69
|
+
"approval_mode": "manual",
|
|
70
|
+
"allow_shell": false,
|
|
71
|
+
"max_steps": 8,
|
|
72
|
+
"temperature": 0.2
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
작업 폴더 상대경로는 **현재 폴더(cwd)** 기준으로 해석합니다(CLI 답게).
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 도구 & 안전장치
|
|
81
|
+
|
|
82
|
+
| 도구 | 설명 | 승인 |
|
|
83
|
+
|------|------|:----:|
|
|
84
|
+
| `list_dir` | 폴더 나열 | 자동 |
|
|
85
|
+
| `read_file` | 파일 읽기 | 자동 |
|
|
86
|
+
| `write_file` | 파일 생성/수정 | **diff 승인** |
|
|
87
|
+
| `run_shell` | 셸 실행 | **승인**(기본 차단) |
|
|
88
|
+
|
|
89
|
+
모든 도구는 작업 폴더 밖으로 나갈 수 없습니다(경로 sandbox).
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 구조
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
node-cli/
|
|
97
|
+
├── bin/cdsa-harness.js # npm/npx 진입점 (#!/usr/bin/env node)
|
|
98
|
+
├── src/
|
|
99
|
+
│ ├── config.js # 설정 로드/저장
|
|
100
|
+
│ ├── llm.js # OpenAI/OpenRouter(fetch) + mock
|
|
101
|
+
│ ├── tools.js # 도구 + sandbox + diff + 스키마
|
|
102
|
+
│ ├── loop.js # ⭐ Agent Loop
|
|
103
|
+
│ ├── session.js # 세션 로그(JSONL)
|
|
104
|
+
│ ├── banner.js # CDSA HARNESS ASCII 배너
|
|
105
|
+
│ ├── ui.js # ANSI 색/박스/diff 렌더
|
|
106
|
+
│ └── cli.js # 터미널 REPL
|
|
107
|
+
├── workspace/ # 샘플 작업 폴더
|
|
108
|
+
└── test/core.test.js # node --test
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## 테스트
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
cd node-cli
|
|
115
|
+
npm test # node --test
|
|
116
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CDSA Harness — npm/npx 진입점.
|
|
3
|
+
// npx cdsa-harness 또는 cdsa-harness (전역 설치 시)
|
|
4
|
+
import { main } from "../src/cli.js";
|
|
5
|
+
|
|
6
|
+
main(process.argv.slice(2))
|
|
7
|
+
.then((code) => process.exit(code ?? 0))
|
|
8
|
+
.catch((err) => {
|
|
9
|
+
console.error(err?.stack || String(err));
|
|
10
|
+
process.exit(1);
|
|
11
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cdsa-harness",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "좁은 의미의 AI 에이전트 하네스를 터미널에서 체험하는 교육용 CLI (Claude Code / Codex / OpenCode 구조의 미니 런타임)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cdsa-harness": "bin/cdsa-harness.js",
|
|
8
|
+
"cdsa": "bin/cdsa-harness.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node bin/cdsa-harness.js",
|
|
15
|
+
"test": "node --test"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"src",
|
|
20
|
+
"workspace",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"ai",
|
|
25
|
+
"agent",
|
|
26
|
+
"harness",
|
|
27
|
+
"agent-loop",
|
|
28
|
+
"cli",
|
|
29
|
+
"tui",
|
|
30
|
+
"llm",
|
|
31
|
+
"education"
|
|
32
|
+
],
|
|
33
|
+
"author": "cdsassj00",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/cdsassj00/miniharness.git",
|
|
38
|
+
"directory": "node-cli"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/cdsassj00/miniharness/tree/main/node-cli#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/cdsassj00/miniharness/issues"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/banner.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// 시작 배너(ASCII 아트) — "CDSA HARNESS" (figlet slant 폰트로 미리 렌더, 하드코딩)
|
|
2
|
+
import { hex } from "./ui.js";
|
|
3
|
+
|
|
4
|
+
const ART = String.raw` __________ _____ ___ __ _____ ____ _ __________________
|
|
5
|
+
/ ____/ __ \/ ___// | / / / / | / __ \/ | / / ____/ ___/ ___/
|
|
6
|
+
/ / / / / /\__ \/ /| | / /_/ / /| | / /_/ / |/ / __/ \__ \\__ \
|
|
7
|
+
/ /___/ /_/ /___/ / ___ | / __ / ___ |/ _, _/ /| / /___ ___/ /__/ /
|
|
8
|
+
\____/_____//____/_/ |_| /_/ /_/_/ |_/_/ |_/_/ |_/_____//____/____/`;
|
|
9
|
+
|
|
10
|
+
const GRADIENT = ["#22d3ee", "#38bdf8", "#3b82f6", "#6366f1", "#8b5cf6"];
|
|
11
|
+
|
|
12
|
+
export function bannerText() {
|
|
13
|
+
return ART;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function renderBanner() {
|
|
17
|
+
const lines = ART.split("\n");
|
|
18
|
+
return lines.map((line, i) => hex(line, GRADIENT[i % GRADIENT.length])).join("\n") + "\n";
|
|
19
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// CDSA Harness TUI 본체 — 터미널 REPL.
|
|
2
|
+
// Python 의 ui/tui 와 동일한 흐름을 Node 내장 모듈만으로 구현.
|
|
3
|
+
import readline from "node:readline/promises";
|
|
4
|
+
import { stdin, stdout } from "node:process";
|
|
5
|
+
|
|
6
|
+
import { renderBanner } from "./banner.js";
|
|
7
|
+
import { Config, configPath, loadConfig, saveConfig } from "./config.js";
|
|
8
|
+
import { AgentLoop, Step, STEP_LABELS } from "./loop.js";
|
|
9
|
+
import { LLMClient } from "./llm.js";
|
|
10
|
+
import { SessionLog, sessionsDir } from "./session.js";
|
|
11
|
+
import { Toolbox } from "./tools.js";
|
|
12
|
+
import { c, panel, renderDiff } from "./ui.js";
|
|
13
|
+
|
|
14
|
+
const VERSION = "0.1.0";
|
|
15
|
+
|
|
16
|
+
const STEP_STYLE = {
|
|
17
|
+
[Step.USER_INPUT]: ["🧑", "cyan"],
|
|
18
|
+
[Step.BUILD_CONTEXT]: ["🧱", "grey"],
|
|
19
|
+
[Step.MODEL_CALL]: ["🧠", "magenta"],
|
|
20
|
+
[Step.MODEL_REPLY]: ["🤖", "green"],
|
|
21
|
+
[Step.TOOL_DECISION]: ["🤔", "yellow"],
|
|
22
|
+
[Step.APPROVAL]: ["🔐", "yellow"],
|
|
23
|
+
[Step.TOOL_RUN]: ["🔧", "blue"],
|
|
24
|
+
[Step.TOOL_RESULT]: ["📄", "grey"],
|
|
25
|
+
[Step.DONE]: ["✅", "green"],
|
|
26
|
+
[Step.ERROR]: ["❌", "red"],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function makePrinter() {
|
|
30
|
+
return (ev) => {
|
|
31
|
+
const [icon, color] = STEP_STYLE[ev.step] || ["•", "cyan"];
|
|
32
|
+
const paint = c[color] || ((x) => x);
|
|
33
|
+
|
|
34
|
+
// 승인 단계 UI 는 approvalCallback 이 직접 그린다(자동 승인 안내만 출력)
|
|
35
|
+
if (ev.step === Step.APPROVAL && !ev.title.includes("자동 승인")) return;
|
|
36
|
+
|
|
37
|
+
if (ev.step === Step.MODEL_REPLY) {
|
|
38
|
+
if (ev.detail && ev.detail !== "(텍스트 없음)") {
|
|
39
|
+
console.log(panel(ev.detail.split("\n"), { title: "🤖 모델", color: "green" }));
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (ev.step === Step.DONE) {
|
|
44
|
+
console.log(panel((ev.detail || "완료").split("\n"), { title: "✅ 완료", color: "green" }));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (ev.step === Step.ERROR) {
|
|
48
|
+
console.log(panel((ev.detail || "").split("\n"), { title: `❌ ${ev.title}`, color: "red" }));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
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
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function makeApproval(rl) {
|
|
61
|
+
return async (req) => {
|
|
62
|
+
if (req.toolName === "write_file") {
|
|
63
|
+
console.log(panel(renderDiff(req.diff || "(변경 미리보기 없음)"), {
|
|
64
|
+
title: `🔐 파일 수정 제안 — ${req.path}`,
|
|
65
|
+
color: "yellow",
|
|
66
|
+
}));
|
|
67
|
+
} else if (req.toolName === "run_shell") {
|
|
68
|
+
console.log(panel([req.command], { title: "🔐 셸 실행 제안", color: "red" }));
|
|
69
|
+
} else {
|
|
70
|
+
console.log(panel([JSON.stringify(req.args)], { title: `🔐 ${req.toolLabel}`, color: "yellow" }));
|
|
71
|
+
}
|
|
72
|
+
let ans = "";
|
|
73
|
+
try {
|
|
74
|
+
ans = (await rl.question(c.yellow("이 작업을 승인하시겠습니까? [y/N] "))).trim().toLowerCase();
|
|
75
|
+
} catch {
|
|
76
|
+
ans = "";
|
|
77
|
+
}
|
|
78
|
+
const approved = ans === "y" || ans === "yes";
|
|
79
|
+
return { approved, reason: approved ? "" : "사용자가 거부했습니다." };
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function printIntro(cfg) {
|
|
84
|
+
console.log(renderBanner());
|
|
85
|
+
console.log(c.dim("좁은 의미의 하네스를 터미널에서 체험하는 미니 AI 에이전트 런타임"));
|
|
86
|
+
console.log();
|
|
87
|
+
const rows = [
|
|
88
|
+
["버전", `v${VERSION}`],
|
|
89
|
+
["provider", cfg.provider],
|
|
90
|
+
["model", cfg.model],
|
|
91
|
+
["작업 폴더", cfg.workspacePath()],
|
|
92
|
+
["승인 모드", cfg.approval_mode],
|
|
93
|
+
["셸 실행", cfg.allow_shell ? "허용" : "차단"],
|
|
94
|
+
];
|
|
95
|
+
const lines = rows.map(([k, v]) => `${c.grey(k)} ${c.bold(v)}`);
|
|
96
|
+
console.log(panel(lines, { title: "⚙️ CDSA Harness 설정", color: "cyan" }));
|
|
97
|
+
console.log(
|
|
98
|
+
c.dim("명령: ") +
|
|
99
|
+
`${c.cyan("/help")} 도움말 · ${c.cyan("/reset")} 새 대화 · ${c.cyan("/config")} 설정값 · ${c.cyan("/quit")} 종료\n`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function printHelp() {
|
|
104
|
+
console.log(
|
|
105
|
+
panel(
|
|
106
|
+
[
|
|
107
|
+
c.bold("사용법"),
|
|
108
|
+
`그냥 시키고 싶은 일을 입력하면 됩니다. 예) ${c.cyan("notes.txt 맨 아래에 할 일 3개 추가해줘")}`,
|
|
109
|
+
"",
|
|
110
|
+
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 도 가능)`,
|
|
116
|
+
"",
|
|
117
|
+
c.bold("하네스 흐름 (각 단계가 색으로 표시됩니다)"),
|
|
118
|
+
" 입력 → 컨텍스트 구성 → LLM 호출 → 도구 판단 → 승인 → 실행 → 결과 반영 → 반복",
|
|
119
|
+
],
|
|
120
|
+
{ title: "도움말", color: "cyan" }
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseArgs(argv) {
|
|
126
|
+
const out = { _: [] };
|
|
127
|
+
for (let i = 0; i < argv.length; i++) {
|
|
128
|
+
const a = argv[i];
|
|
129
|
+
if (a === "--auto") out.auto = true;
|
|
130
|
+
else if (a === "--help" || a === "-h") out.help = true;
|
|
131
|
+
else if (a === "--provider") out.provider = argv[++i];
|
|
132
|
+
else if (a === "--model") out.model = argv[++i];
|
|
133
|
+
else if (a === "--workspace") out.workspace = argv[++i];
|
|
134
|
+
else out._.push(a);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function main(argv = []) {
|
|
140
|
+
const args = parseArgs(argv);
|
|
141
|
+
if (args.help) {
|
|
142
|
+
console.log(
|
|
143
|
+
"CDSA Harness — 미니 AI 에이전트 하네스 (터미널)\n\n" +
|
|
144
|
+
"사용법: cdsa-harness [옵션]\n" +
|
|
145
|
+
" --provider <openai|openrouter|mock>\n" +
|
|
146
|
+
" --model <모델명>\n" +
|
|
147
|
+
" --workspace <폴더경로>\n" +
|
|
148
|
+
" --auto 승인 자동(approval_mode=auto)\n" +
|
|
149
|
+
" -h, --help 도움말\n"
|
|
150
|
+
);
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const cfg = loadConfig();
|
|
155
|
+
if (args.provider) cfg.provider = args.provider;
|
|
156
|
+
if (args.model) cfg.model = args.model;
|
|
157
|
+
if (args.workspace) cfg.workspace = args.workspace;
|
|
158
|
+
if (args.auto) cfg.approval_mode = "auto";
|
|
159
|
+
|
|
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";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
printIntro(cfg);
|
|
170
|
+
|
|
171
|
+
const client = new LLMClient({
|
|
172
|
+
provider: cfg.provider,
|
|
173
|
+
apiKey: cfg.api_key,
|
|
174
|
+
model: cfg.model,
|
|
175
|
+
temperature: cfg.temperature,
|
|
176
|
+
});
|
|
177
|
+
const toolbox = new Toolbox(cfg.workspacePath(), cfg.allow_shell);
|
|
178
|
+
const session = SessionLog.create();
|
|
179
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
180
|
+
|
|
181
|
+
const loop = new AgentLoop({
|
|
182
|
+
config: cfg,
|
|
183
|
+
client,
|
|
184
|
+
toolbox,
|
|
185
|
+
onEvent: makePrinter(),
|
|
186
|
+
approvalCallback: makeApproval(rl),
|
|
187
|
+
session,
|
|
188
|
+
});
|
|
189
|
+
loop.reset();
|
|
190
|
+
|
|
191
|
+
const rule = () => console.log(c.grey("─".repeat(Math.min(80, stdout.columns || 80))));
|
|
192
|
+
|
|
193
|
+
while (true) {
|
|
194
|
+
let user;
|
|
195
|
+
try {
|
|
196
|
+
user = (await rl.question(c.bold(c.cyan("› ")))).trim();
|
|
197
|
+
} catch {
|
|
198
|
+
break; // Ctrl+D / 스트림 종료
|
|
199
|
+
}
|
|
200
|
+
if (!user) continue;
|
|
201
|
+
const low = user.toLowerCase();
|
|
202
|
+
if (["/quit", "/exit", "quit", "exit", ":q"].includes(low)) break;
|
|
203
|
+
if (low === "/help") {
|
|
204
|
+
printHelp();
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (low === "/reset") {
|
|
208
|
+
loop.reset();
|
|
209
|
+
console.log(c.green("컨텍스트를 초기화했습니다(새 세션)."));
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (low === "/config") {
|
|
213
|
+
printIntro(cfg);
|
|
214
|
+
console.log(c.dim(`config.json: ${configPath()}`));
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (low === "/sessions") {
|
|
218
|
+
console.log(c.dim(`세션 로그 폴더: ${sessionsDir()}`));
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
rule();
|
|
223
|
+
try {
|
|
224
|
+
await loop.run(user);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.log(c.red(`실행 오류: ${e?.message || e}`));
|
|
227
|
+
}
|
|
228
|
+
rule();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
rl.close();
|
|
232
|
+
session.close();
|
|
233
|
+
console.log(c.dim("\n종료합니다. 안녕히 가세요!"));
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// 앱 설정 로드/저장.
|
|
2
|
+
// 설정은 ~/.cdsa_harness/config.json 에 저장한다(실행 폴더에 config.json 이 있으면 우선).
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
export const PROVIDERS = ["openai", "openrouter", "mock"];
|
|
8
|
+
|
|
9
|
+
export const SUGGESTED_MODELS = {
|
|
10
|
+
openai: ["gpt-4.1-mini", "gpt-4o-mini", "gpt-4.1"],
|
|
11
|
+
openrouter: [
|
|
12
|
+
"openai/gpt-4.1-mini",
|
|
13
|
+
"anthropic/claude-3.5-sonnet",
|
|
14
|
+
"google/gemini-2.5-flash",
|
|
15
|
+
],
|
|
16
|
+
mock: ["mock-agent"],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const APPROVAL_MODES = ["manual", "auto"];
|
|
20
|
+
|
|
21
|
+
const DEFAULTS = {
|
|
22
|
+
provider: "mock",
|
|
23
|
+
api_key: "",
|
|
24
|
+
model: "mock-agent",
|
|
25
|
+
workspace: "./workspace",
|
|
26
|
+
approval_mode: "manual",
|
|
27
|
+
allow_shell: false,
|
|
28
|
+
max_steps: 8,
|
|
29
|
+
temperature: 0.2,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function configDir() {
|
|
33
|
+
return path.join(os.homedir(), ".cdsa_harness");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function configPath() {
|
|
37
|
+
const local = path.join(process.cwd(), "config.json");
|
|
38
|
+
if (fs.existsSync(local)) return local;
|
|
39
|
+
return path.join(configDir(), "config.json");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class Config {
|
|
43
|
+
constructor(data = {}) {
|
|
44
|
+
Object.assign(this, DEFAULTS);
|
|
45
|
+
for (const key of Object.keys(DEFAULTS)) {
|
|
46
|
+
if (data[key] !== undefined) this[key] = data[key];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// CLI 도구이므로 작업 폴더 상대경로는 '현재 폴더' 기준으로 해석한다(직관적).
|
|
51
|
+
workspacePath() {
|
|
52
|
+
let p = this.workspace || "./workspace";
|
|
53
|
+
if (!path.isAbsolute(p)) p = path.resolve(process.cwd(), p);
|
|
54
|
+
return p;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
isReady() {
|
|
58
|
+
if (this.provider === "mock") return true;
|
|
59
|
+
return Boolean((this.api_key || "").trim());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
toJSON() {
|
|
63
|
+
const out = {};
|
|
64
|
+
for (const key of Object.keys(DEFAULTS)) out[key] = this[key];
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function loadConfig() {
|
|
70
|
+
const p = configPath();
|
|
71
|
+
try {
|
|
72
|
+
if (fs.existsSync(p)) {
|
|
73
|
+
return new Config(JSON.parse(fs.readFileSync(p, "utf8")));
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// 손상된 설정은 무시하고 기본값
|
|
77
|
+
}
|
|
78
|
+
return new Config();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function saveConfig(cfg) {
|
|
82
|
+
const p = configPath();
|
|
83
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
84
|
+
fs.writeFileSync(p, JSON.stringify(cfg.toJSON(), null, 2), "utf8");
|
|
85
|
+
return p;
|
|
86
|
+
}
|
package/src/llm.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// LLM 호출 계층. OpenAI / OpenRouter (OpenAI 호환) + 키 없이 체험하는 mock.
|
|
2
|
+
// Node 18+ 내장 fetch 사용 → 외부 의존성 없음.
|
|
3
|
+
|
|
4
|
+
export class LLMError extends Error {}
|
|
5
|
+
|
|
6
|
+
const ENDPOINTS = {
|
|
7
|
+
openai: "https://api.openai.com/v1/chat/completions",
|
|
8
|
+
openrouter: "https://openrouter.ai/api/v1/chat/completions",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export class LLMClient {
|
|
12
|
+
constructor({ provider, apiKey, model, temperature = 0.2, timeout = 60000 }) {
|
|
13
|
+
this.provider = provider;
|
|
14
|
+
this.apiKey = apiKey;
|
|
15
|
+
this.model = model;
|
|
16
|
+
this.temperature = temperature;
|
|
17
|
+
this.timeout = timeout;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async chat(messages, tools) {
|
|
21
|
+
if (this.provider === "mock") return mockChat(messages);
|
|
22
|
+
if (!ENDPOINTS[this.provider]) {
|
|
23
|
+
throw new LLMError(`지원하지 않는 provider 입니다: ${this.provider}`);
|
|
24
|
+
}
|
|
25
|
+
return this._httpChat(messages, tools);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async _httpChat(messages, tools) {
|
|
29
|
+
const url = ENDPOINTS[this.provider];
|
|
30
|
+
const body = { model: this.model, messages, temperature: this.temperature };
|
|
31
|
+
if (tools && tools.length) {
|
|
32
|
+
body.tools = tools;
|
|
33
|
+
body.tool_choice = "auto";
|
|
34
|
+
}
|
|
35
|
+
const headers = {
|
|
36
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
};
|
|
39
|
+
if (this.provider === "openrouter") {
|
|
40
|
+
headers["HTTP-Referer"] = "https://github.com/cdsa-harness";
|
|
41
|
+
headers["X-Title"] = "CDSA Harness";
|
|
42
|
+
}
|
|
43
|
+
|
|
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
|
+
}
|
|
59
|
+
|
|
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);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseOpenAiReply(payload) {
|
|
70
|
+
const msg = payload?.choices?.[0]?.message;
|
|
71
|
+
if (!msg) {
|
|
72
|
+
throw new LLMError(
|
|
73
|
+
`예상치 못한 응답 형식입니다: ${JSON.stringify(payload).slice(0, 300)}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const toolCalls = [];
|
|
77
|
+
for (const tc of msg.tool_calls || []) {
|
|
78
|
+
let args = {};
|
|
79
|
+
try {
|
|
80
|
+
args = typeof tc.function?.arguments === "string"
|
|
81
|
+
? JSON.parse(tc.function.arguments || "{}")
|
|
82
|
+
: tc.function?.arguments || {};
|
|
83
|
+
} catch {
|
|
84
|
+
args = { _raw: tc.function?.arguments };
|
|
85
|
+
}
|
|
86
|
+
toolCalls.push({ id: tc.id || `call_${toolCalls.length}`, name: tc.function?.name || "", args });
|
|
87
|
+
}
|
|
88
|
+
return { content: msg.content ?? null, toolCalls };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Mock 에이전트: 키 없이 Agent Loop 전체를 체험.
|
|
93
|
+
// 히스토리에 쌓인 tool 결과 개수로 '현재 단계'를 판단하는 결정형 에이전트.
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
function mockChat(messages) {
|
|
96
|
+
const toolMsgs = messages.filter((m) => m.role === "tool");
|
|
97
|
+
const lastUser = [...messages].reverse().find((m) => m.role === "user")?.content || "";
|
|
98
|
+
const phase = toolMsgs.length;
|
|
99
|
+
|
|
100
|
+
if (phase === 0) {
|
|
101
|
+
return {
|
|
102
|
+
content: "작업 폴더 구조부터 확인하겠습니다.",
|
|
103
|
+
toolCalls: [{ id: "mock_1", name: "list_dir", args: { path: "." } }],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (phase === 1) {
|
|
107
|
+
const target = guessFile(toolMsgs[0].content || "", lastUser);
|
|
108
|
+
return {
|
|
109
|
+
content: `\`${target}\` 파일을 읽어 현재 내용을 확인하겠습니다.`,
|
|
110
|
+
toolCalls: [{ id: "mock_2", name: "read_file", args: { path: target } }],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (phase === 2) {
|
|
114
|
+
const target = guessFile(toolMsgs[0].content || "", lastUser);
|
|
115
|
+
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}\` 끝에 메모 한 줄을 추가하는 수정을 제안합니다.`,
|
|
120
|
+
toolCalls: [{ id: "mock_3", name: "write_file", args: { path: target, content: newContent } }],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
content:
|
|
125
|
+
"완료했습니다. 방금까지의 흐름이 바로 하네스의 Agent Loop 입니다:\n" +
|
|
126
|
+
"① 작업 폴더 보기 → ② 파일 읽기 → ③ 파일 수정 제안 → ④ 사용자 승인 → ⑤ 저장 → ⑥ 결과를 다시 모델에 전달.\n" +
|
|
127
|
+
"실제 LLM 을 쓰려면 --provider openai --model ... 와 API Key 를 설정하세요.",
|
|
128
|
+
toolCalls: [],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function guessFile(listing, userText) {
|
|
133
|
+
const files = [];
|
|
134
|
+
for (const line of listing.split("\n")) {
|
|
135
|
+
const t = line.trim();
|
|
136
|
+
if (t.startsWith("[FILE]")) {
|
|
137
|
+
const rest = t.slice("[FILE]".length).trim();
|
|
138
|
+
const name = rest.split(" ")[0].trim();
|
|
139
|
+
if (name) files.push(name);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const name of files) {
|
|
143
|
+
if (userText.includes(name)) return name;
|
|
144
|
+
}
|
|
145
|
+
return files[0] || "notes.txt";
|
|
146
|
+
}
|
package/src/loop.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Agent Loop — 하네스의 심장.
|
|
2
|
+
// 입력 → 컨텍스트 구성 → 모델 호출 → 도구 판단 → (수정/셸이면) 승인
|
|
3
|
+
// → 도구 실행 → 결과를 다시 모델에 전달 → 완료까지 반복
|
|
4
|
+
//
|
|
5
|
+
// UI 를 모른다. 각 단계를 onEvent 로 방출하고, 승인은 approvalCallback 으로 위임한다.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
import { LLMError } from "./llm.js";
|
|
10
|
+
import { MUTATING_TOOLS, TOOL_LABELS, ToolError, toolSchemas } from "./tools.js";
|
|
11
|
+
|
|
12
|
+
// 단계(Step) 상수
|
|
13
|
+
export const Step = {
|
|
14
|
+
USER_INPUT: "user_input",
|
|
15
|
+
BUILD_CONTEXT: "build_context",
|
|
16
|
+
MODEL_CALL: "model_call",
|
|
17
|
+
MODEL_REPLY: "model_reply",
|
|
18
|
+
TOOL_DECISION: "tool_decision",
|
|
19
|
+
APPROVAL: "approval",
|
|
20
|
+
TOOL_RUN: "tool_run",
|
|
21
|
+
TOOL_RESULT: "tool_result",
|
|
22
|
+
DONE: "done",
|
|
23
|
+
ERROR: "error",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const STEP_LABELS = {
|
|
27
|
+
user_input: "사용자 입력",
|
|
28
|
+
build_context: "컨텍스트 구성",
|
|
29
|
+
model_call: "LLM 호출",
|
|
30
|
+
model_reply: "모델 응답",
|
|
31
|
+
tool_decision: "도구 판단",
|
|
32
|
+
approval: "사용자 승인",
|
|
33
|
+
tool_run: "도구 실행",
|
|
34
|
+
tool_result: "결과 반영",
|
|
35
|
+
done: "완료",
|
|
36
|
+
error: "오류",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const RULES_FILENAMES = ["AGENT.md", "AGENTS.md", "CLAUDE.md", "rules.md", "RULES.md"];
|
|
40
|
+
|
|
41
|
+
function findRules(workspace) {
|
|
42
|
+
for (const name of RULES_FILENAMES) {
|
|
43
|
+
const p = path.join(workspace, name);
|
|
44
|
+
try {
|
|
45
|
+
if (fs.existsSync(p) && fs.statSync(p).isFile()) {
|
|
46
|
+
return { name, text: fs.readFileSync(p, "utf8") };
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
/* ignore */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { name: "", text: "" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class AgentLoop {
|
|
56
|
+
constructor({ config, client, toolbox, onEvent, approvalCallback, session = null }) {
|
|
57
|
+
this.config = config;
|
|
58
|
+
this.client = client;
|
|
59
|
+
this.toolbox = toolbox;
|
|
60
|
+
this.onEvent = onEvent;
|
|
61
|
+
this.approvalCallback = approvalCallback;
|
|
62
|
+
this.session = session;
|
|
63
|
+
this.messages = [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_emit(step, title = "", detail = "", data = {}) {
|
|
67
|
+
const ev = { step, title, detail, data };
|
|
68
|
+
if (this.session) this.session.record(ev);
|
|
69
|
+
this.onEvent(ev);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_systemPrompt() {
|
|
73
|
+
const ws = this.toolbox.workspace;
|
|
74
|
+
const { name: rulesName, text: rulesText } = findRules(ws);
|
|
75
|
+
let listing;
|
|
76
|
+
try {
|
|
77
|
+
listing = this.toolbox.listDir(".").output;
|
|
78
|
+
} catch {
|
|
79
|
+
listing = "(폴더를 읽을 수 없음)";
|
|
80
|
+
}
|
|
81
|
+
const toolNames = ["list_dir", "read_file", "write_file"].concat(
|
|
82
|
+
this.config.allow_shell ? ["run_shell"] : []
|
|
83
|
+
);
|
|
84
|
+
const toolsDesc = toolNames.map((n) => `${n}(${TOOL_LABELS[n]})`).join(", ");
|
|
85
|
+
|
|
86
|
+
const parts = [
|
|
87
|
+
"당신은 'CDSA Harness' 안에서 동작하는 소형 코딩 에이전트입니다.",
|
|
88
|
+
"당신은 직접 파일을 만질 수 없습니다. 반드시 제공된 도구로만 작업 폴더를 다룹니다.",
|
|
89
|
+
`사용 가능한 도구: ${toolsDesc}.`,
|
|
90
|
+
"파일을 수정할 때는 write_file 에 '파일 전체 내용'을 담아 호출하세요(부분 패치 아님).",
|
|
91
|
+
"추측하지 말고, 필요하면 먼저 read_file/list_dir 로 사실을 확인하세요.",
|
|
92
|
+
"작업이 끝나면 도구를 더 호출하지 말고 한국어로 결과를 요약하세요.",
|
|
93
|
+
`\n[작업 폴더 루트]\n${ws}`,
|
|
94
|
+
`\n[현재 폴더 내용]\n${listing}`,
|
|
95
|
+
];
|
|
96
|
+
if (rulesText) parts.push(`\n[규칙 파일 ${rulesName}]\n${rulesText.trim()}`);
|
|
97
|
+
return parts.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
reset() {
|
|
101
|
+
this.messages = [{ role: "system", content: this._systemPrompt() }];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async run(userInput) {
|
|
105
|
+
if (this.messages.length === 0) this.reset();
|
|
106
|
+
|
|
107
|
+
this._emit(Step.USER_INPUT, "사용자 입력", userInput);
|
|
108
|
+
this.messages.push({ role: "user", content: userInput });
|
|
109
|
+
|
|
110
|
+
this._emit(
|
|
111
|
+
Step.BUILD_CONTEXT,
|
|
112
|
+
"컨텍스트 구성",
|
|
113
|
+
"규칙 파일 + 작업 폴더 내용을 시스템 프롬프트로 묶어 모델에 전달합니다."
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const tools = toolSchemas(this.config.allow_shell);
|
|
117
|
+
let finalText = "";
|
|
118
|
+
|
|
119
|
+
for (let stepNo = 1; stepNo <= this.config.max_steps; stepNo++) {
|
|
120
|
+
this._emit(
|
|
121
|
+
Step.MODEL_CALL,
|
|
122
|
+
`LLM 호출 (반복 ${stepNo})`,
|
|
123
|
+
`메시지 ${this.messages.length}개를 모델에 전송합니다.`
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
let reply;
|
|
127
|
+
try {
|
|
128
|
+
reply = await this.client.chat(this.messages, tools);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (e instanceof LLMError) {
|
|
131
|
+
this._emit(Step.ERROR, "LLM 오류", e.message);
|
|
132
|
+
return finalText;
|
|
133
|
+
}
|
|
134
|
+
throw e;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this._emit(Step.MODEL_REPLY, "모델 응답", reply.content || "(텍스트 없음)", {
|
|
138
|
+
toolCalls: reply.toolCalls.map((tc) => ({ name: tc.name, args: tc.args })),
|
|
139
|
+
});
|
|
140
|
+
if (reply.content) finalText = reply.content;
|
|
141
|
+
|
|
142
|
+
if (!reply.toolCalls.length) {
|
|
143
|
+
this._emit(Step.TOOL_DECISION, "도구 판단", "추가 도구 호출이 필요 없습니다. 작업을 마칩니다.");
|
|
144
|
+
this.messages.push({ role: "assistant", content: reply.content || "" });
|
|
145
|
+
this._emit(Step.DONE, "완료", reply.content || "");
|
|
146
|
+
return finalText;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const names = reply.toolCalls.map((tc) => `${tc.name}(${TOOL_LABELS[tc.name] || tc.name})`).join(", ");
|
|
150
|
+
this._emit(Step.TOOL_DECISION, "도구 판단", `모델이 도구 호출을 요청했습니다: ${names}`);
|
|
151
|
+
|
|
152
|
+
this.messages.push({
|
|
153
|
+
role: "assistant",
|
|
154
|
+
content: reply.content || "",
|
|
155
|
+
tool_calls: reply.toolCalls.map((tc) => ({
|
|
156
|
+
id: tc.id,
|
|
157
|
+
type: "function",
|
|
158
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
|
159
|
+
})),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
for (const tc of reply.toolCalls) {
|
|
163
|
+
const resultText = await this._handleToolCall(tc);
|
|
164
|
+
this.messages.push({ role: "tool", tool_call_id: tc.id, content: resultText });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this._emit(Step.DONE, "반복 한도 도달", `max_steps(${this.config.max_steps})에 도달해 종료했습니다.`);
|
|
169
|
+
return finalText;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async _handleToolCall(tc) {
|
|
173
|
+
const label = TOOL_LABELS[tc.name] || tc.name;
|
|
174
|
+
const needsApproval = MUTATING_TOOLS.has(tc.name);
|
|
175
|
+
|
|
176
|
+
if (needsApproval && this.config.approval_mode === "manual") {
|
|
177
|
+
const req = this._buildApprovalRequest(tc);
|
|
178
|
+
this._emit(
|
|
179
|
+
Step.APPROVAL,
|
|
180
|
+
`사용자 승인 대기: ${label}`,
|
|
181
|
+
req.diff || req.command || JSON.stringify(tc.args),
|
|
182
|
+
{ tool: tc.name, path: req.path }
|
|
183
|
+
);
|
|
184
|
+
const decision = await this.approvalCallback(req);
|
|
185
|
+
if (!decision.approved) {
|
|
186
|
+
this._emit(Step.APPROVAL, `거부됨: ${label}`, decision.reason || "사용자가 거부함");
|
|
187
|
+
return `사용자가 '${label}' 실행을 거부했습니다. 사유: ${decision.reason || "(없음)"}`;
|
|
188
|
+
}
|
|
189
|
+
this._emit(Step.APPROVAL, `승인됨: ${label}`, "사용자가 승인했습니다.");
|
|
190
|
+
} else if (needsApproval) {
|
|
191
|
+
this._emit(Step.APPROVAL, `자동 승인: ${label}`, "approval_mode=auto 라 자동 승인되었습니다.");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
this._emit(Step.TOOL_RUN, `도구 실행: ${label}`, JSON.stringify(tc.args).slice(0, 2000));
|
|
195
|
+
try {
|
|
196
|
+
const result = this.toolbox.execute(tc.name, tc.args);
|
|
197
|
+
this._emit(Step.TOOL_RESULT, `결과 반영: ${label}`, (result.output || "").slice(0, 4000));
|
|
198
|
+
return result.output;
|
|
199
|
+
} catch (e) {
|
|
200
|
+
if (e instanceof ToolError) {
|
|
201
|
+
this._emit(Step.TOOL_RESULT, `도구 오류: ${label}`, e.message);
|
|
202
|
+
return `도구 오류: ${e.message}`;
|
|
203
|
+
}
|
|
204
|
+
throw e;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_buildApprovalRequest(tc) {
|
|
209
|
+
if (tc.name === "write_file") {
|
|
210
|
+
const { path: p, diff } = this.toolbox.previewWrite(tc.args.path || "", tc.args.content || "");
|
|
211
|
+
return { toolName: "write_file", toolLabel: TOOL_LABELS.write_file, args: tc.args, path: p, diff };
|
|
212
|
+
}
|
|
213
|
+
if (tc.name === "run_shell") {
|
|
214
|
+
return {
|
|
215
|
+
toolName: "run_shell",
|
|
216
|
+
toolLabel: TOOL_LABELS.run_shell,
|
|
217
|
+
args: tc.args,
|
|
218
|
+
command: tc.args.command || "",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return { toolName: tc.name, toolLabel: TOOL_LABELS[tc.name] || tc.name, args: tc.args };
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// 세션 로그(JSONL). 하네스는 "무슨 일이 있었는지"를 남긴다.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export function sessionsDir() {
|
|
7
|
+
const d = path.join(os.homedir(), ".cdsa_harness", "sessions");
|
|
8
|
+
fs.mkdirSync(d, { recursive: true });
|
|
9
|
+
return d;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class SessionLog {
|
|
13
|
+
constructor(filePath) {
|
|
14
|
+
this.path = filePath;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static create() {
|
|
18
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
19
|
+
const log = new SessionLog(path.join(sessionsDir(), `session-${ts}.jsonl`));
|
|
20
|
+
log._append({ type: "session_start", time: Date.now() });
|
|
21
|
+
return log;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_append(obj) {
|
|
25
|
+
try {
|
|
26
|
+
fs.appendFileSync(this.path, JSON.stringify(obj) + "\n", "utf8");
|
|
27
|
+
} catch {
|
|
28
|
+
// 로그 실패가 앱을 멈추게 하지 않는다.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
record(ev) {
|
|
33
|
+
this._append({
|
|
34
|
+
type: "event",
|
|
35
|
+
time: Date.now(),
|
|
36
|
+
step: ev.step,
|
|
37
|
+
title: ev.title,
|
|
38
|
+
detail: ev.detail,
|
|
39
|
+
data: ev.data || {},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
close() {
|
|
44
|
+
this._append({ type: "session_end", time: Date.now() });
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// 하네스가 제공하는 도구들. 모두 작업 폴더(workspace) 안으로만 접근 제한(sandbox).
|
|
2
|
+
// LLM 은 생각만 하고, 실제 파일 조작/명령 실행은 전부 여기서 이뤄진다.
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
export const TOOL_LABELS = {
|
|
8
|
+
list_dir: "폴더 보기",
|
|
9
|
+
read_file: "파일 읽기",
|
|
10
|
+
write_file: "파일 수정",
|
|
11
|
+
run_shell: "셸 실행",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// 사용자 승인이 필요한(환경을 바꾸는) 도구
|
|
15
|
+
export const MUTATING_TOOLS = new Set(["write_file", "run_shell"]);
|
|
16
|
+
|
|
17
|
+
export class ToolError extends Error {}
|
|
18
|
+
|
|
19
|
+
// 간단한 LCS 기반 unified diff (교육용 표시). +추가 / -삭제 / (공백)유지
|
|
20
|
+
export function diffLines(oldText, newText) {
|
|
21
|
+
const a = oldText ? oldText.split("\n") : [];
|
|
22
|
+
const b = newText ? newText.split("\n") : [];
|
|
23
|
+
const m = a.length;
|
|
24
|
+
const n = b.length;
|
|
25
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
26
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
27
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
28
|
+
dp[i][j] =
|
|
29
|
+
a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const out = [];
|
|
33
|
+
let i = 0;
|
|
34
|
+
let j = 0;
|
|
35
|
+
while (i < m && j < n) {
|
|
36
|
+
if (a[i] === b[j]) {
|
|
37
|
+
out.push(" " + a[i]);
|
|
38
|
+
i++;
|
|
39
|
+
j++;
|
|
40
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
41
|
+
out.push("-" + a[i]);
|
|
42
|
+
i++;
|
|
43
|
+
} else {
|
|
44
|
+
out.push("+" + b[j]);
|
|
45
|
+
j++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
while (i < m) out.push("-" + a[i++]);
|
|
49
|
+
while (j < n) out.push("+" + b[j++]);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class Toolbox {
|
|
54
|
+
constructor(workspace, allowShell = false) {
|
|
55
|
+
this.workspace = path.resolve(workspace);
|
|
56
|
+
fs.mkdirSync(this.workspace, { recursive: true });
|
|
57
|
+
this.allowShell = allowShell;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_resolve(rel) {
|
|
61
|
+
rel = (rel || ".").trim();
|
|
62
|
+
const candidate = path.resolve(this.workspace, rel);
|
|
63
|
+
const root = this.workspace + path.sep;
|
|
64
|
+
if (candidate !== this.workspace && !candidate.startsWith(root)) {
|
|
65
|
+
throw new ToolError(
|
|
66
|
+
`작업 폴더 밖 경로에는 접근할 수 없습니다: ${rel} (허용 루트: ${this.workspace})`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return candidate;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
rel(p) {
|
|
73
|
+
const r = path.relative(this.workspace, p);
|
|
74
|
+
return r === "" ? "." : r;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
listDir(rel = ".") {
|
|
78
|
+
const target = this._resolve(rel);
|
|
79
|
+
if (!fs.existsSync(target)) throw new ToolError(`경로가 없습니다: ${rel}`);
|
|
80
|
+
const stat = fs.statSync(target);
|
|
81
|
+
if (stat.isFile()) return { ok: true, output: `(파일) ${this.rel(target)}` };
|
|
82
|
+
const names = fs.readdirSync(target).sort();
|
|
83
|
+
if (names.length === 0) return { ok: true, output: "(빈 폴더)" };
|
|
84
|
+
const lines = names.map((name) => {
|
|
85
|
+
const full = path.join(target, name);
|
|
86
|
+
const s = fs.statSync(full);
|
|
87
|
+
const kind = s.isDirectory() ? "DIR " : "FILE";
|
|
88
|
+
const size = s.isDirectory() ? "" : ` ${s.size}B`;
|
|
89
|
+
return `[${kind}] ${this.rel(full)}${size}`;
|
|
90
|
+
});
|
|
91
|
+
return { ok: true, output: lines.join("\n") };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
readFile(rel) {
|
|
95
|
+
const target = this._resolve(rel);
|
|
96
|
+
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
|
97
|
+
throw new ToolError(`파일이 없습니다: ${rel}`);
|
|
98
|
+
}
|
|
99
|
+
let text = fs.readFileSync(target, "utf8");
|
|
100
|
+
if (text.length > 20000) text = text.slice(0, 20000) + "\n... (이후 생략)";
|
|
101
|
+
return { ok: true, output: text, detail: text };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// write_file 의 '제안' 미리보기. 실제로 쓰지는 않는다.
|
|
105
|
+
previewWrite(rel, content) {
|
|
106
|
+
const target = this._resolve(rel);
|
|
107
|
+
let old = "";
|
|
108
|
+
if (fs.existsSync(target) && fs.statSync(target).isFile()) {
|
|
109
|
+
old = fs.readFileSync(target, "utf8");
|
|
110
|
+
}
|
|
111
|
+
const diff = diffLines(old, content || "").join("\n") || "(내용 변화 없음)";
|
|
112
|
+
return { path: this.rel(target), diff };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
writeFile(rel, content) {
|
|
116
|
+
const target = this._resolve(rel);
|
|
117
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
118
|
+
const existed = fs.existsSync(target);
|
|
119
|
+
fs.writeFileSync(target, content || "", "utf8");
|
|
120
|
+
const verb = existed ? "수정" : "생성";
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
output: `${this.rel(target)} 파일을 ${verb}했습니다 (${(content || "").length}자).`,
|
|
124
|
+
detail: content || "",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
runShell(command) {
|
|
129
|
+
if (!this.allowShell) {
|
|
130
|
+
throw new ToolError("셸 실행이 설정에서 비활성화되어 있습니다(allow_shell=false).");
|
|
131
|
+
}
|
|
132
|
+
if (!command || !command.trim()) throw new ToolError("실행할 명령이 비어 있습니다.");
|
|
133
|
+
let out;
|
|
134
|
+
let code = 0;
|
|
135
|
+
try {
|
|
136
|
+
out = execSync(command, {
|
|
137
|
+
cwd: this.workspace,
|
|
138
|
+
encoding: "utf8",
|
|
139
|
+
timeout: 30000,
|
|
140
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
141
|
+
});
|
|
142
|
+
} catch (e) {
|
|
143
|
+
code = e.status ?? 1;
|
|
144
|
+
out = (e.stdout || "") + (e.stderr ? "\n[stderr]\n" + e.stderr : "");
|
|
145
|
+
}
|
|
146
|
+
out = (out || "").trim() || "(출력 없음)";
|
|
147
|
+
if (out.length > 8000) out = out.slice(0, 8000) + "\n... (이후 생략)";
|
|
148
|
+
return { ok: code === 0, output: `$ ${command}\n(exit=${code})\n${out}` };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
execute(name, args = {}) {
|
|
152
|
+
if (name === "list_dir") return this.listDir(args.path || ".");
|
|
153
|
+
if (name === "read_file") return this.readFile(args.path || "");
|
|
154
|
+
if (name === "write_file") return this.writeFile(args.path || "", args.content || "");
|
|
155
|
+
if (name === "run_shell") return this.runShell(args.command || "");
|
|
156
|
+
throw new ToolError(`알 수 없는 도구입니다: ${name}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function toolSchemas(allowShell = false) {
|
|
161
|
+
const schemas = [
|
|
162
|
+
{
|
|
163
|
+
type: "function",
|
|
164
|
+
function: {
|
|
165
|
+
name: "list_dir",
|
|
166
|
+
description: "작업 폴더(workspace) 안의 폴더 내용을 나열한다.",
|
|
167
|
+
parameters: {
|
|
168
|
+
type: "object",
|
|
169
|
+
properties: {
|
|
170
|
+
path: { type: "string", description: "작업 폴더 기준 상대 경로. 루트는 '.'" },
|
|
171
|
+
},
|
|
172
|
+
required: [],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
type: "function",
|
|
178
|
+
function: {
|
|
179
|
+
name: "read_file",
|
|
180
|
+
description: "작업 폴더 안의 텍스트 파일을 읽어 내용을 반환한다.",
|
|
181
|
+
parameters: {
|
|
182
|
+
type: "object",
|
|
183
|
+
properties: { path: { type: "string", description: "읽을 파일의 상대 경로" } },
|
|
184
|
+
required: ["path"],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
type: "function",
|
|
190
|
+
function: {
|
|
191
|
+
name: "write_file",
|
|
192
|
+
description:
|
|
193
|
+
"작업 폴더 안의 파일을 새 내용으로 만들거나 덮어쓴다. 전체 파일 내용을 content 로 전달. 사용자 승인 후 적용된다.",
|
|
194
|
+
parameters: {
|
|
195
|
+
type: "object",
|
|
196
|
+
properties: {
|
|
197
|
+
path: { type: "string", description: "저장할 파일의 상대 경로" },
|
|
198
|
+
content: { type: "string", description: "파일 전체 내용" },
|
|
199
|
+
},
|
|
200
|
+
required: ["path", "content"],
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
if (allowShell) {
|
|
206
|
+
schemas.push({
|
|
207
|
+
type: "function",
|
|
208
|
+
function: {
|
|
209
|
+
name: "run_shell",
|
|
210
|
+
description: "작업 폴더에서 셸 명령을 실행한다. 사용자 승인 후 실행된다.",
|
|
211
|
+
parameters: {
|
|
212
|
+
type: "object",
|
|
213
|
+
properties: { command: { type: "string", description: "실행할 명령" } },
|
|
214
|
+
required: ["command"],
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return schemas;
|
|
220
|
+
}
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// 터미널 출력 헬퍼: ANSI 색, 박스 패널, diff 렌더. (외부 의존성 없음)
|
|
2
|
+
|
|
3
|
+
const ESC = "\x1b[";
|
|
4
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
5
|
+
|
|
6
|
+
function wrap(open, close) {
|
|
7
|
+
return (s) => (useColor ? `${ESC}${open}m${s}${ESC}${close}m` : String(s));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const c = {
|
|
11
|
+
bold: wrap(1, 22),
|
|
12
|
+
dim: wrap(2, 22),
|
|
13
|
+
italic: wrap(3, 23),
|
|
14
|
+
red: wrap(31, 39),
|
|
15
|
+
green: wrap(32, 39),
|
|
16
|
+
yellow: wrap(33, 39),
|
|
17
|
+
blue: wrap(34, 39),
|
|
18
|
+
magenta: wrap(35, 39),
|
|
19
|
+
cyan: wrap(36, 39),
|
|
20
|
+
grey: wrap(90, 39),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// 24bit truecolor (그라데이션 배너용). hex "#rrggbb"
|
|
24
|
+
export function hex(s, hexColor) {
|
|
25
|
+
if (!useColor) return String(s);
|
|
26
|
+
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hexColor);
|
|
27
|
+
if (!m) return String(s);
|
|
28
|
+
const [r, g, b] = [m[1], m[2], m[3]].map((x) => parseInt(x, 16));
|
|
29
|
+
return `${ESC}38;2;${r};${g};${b}m${s}${ESC}39m`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
33
|
+
export function stripAnsi(s) {
|
|
34
|
+
return String(s).replace(ANSI_RE, "");
|
|
35
|
+
}
|
|
36
|
+
// 화면상 표시 폭(한글=2칸) 계산
|
|
37
|
+
export function visibleWidth(s) {
|
|
38
|
+
const plain = stripAnsi(s);
|
|
39
|
+
let w = 0;
|
|
40
|
+
for (const ch of plain) {
|
|
41
|
+
const code = ch.codePointAt(0);
|
|
42
|
+
w += code > 0x1100 && isWide(code) ? 2 : 1;
|
|
43
|
+
}
|
|
44
|
+
return w;
|
|
45
|
+
}
|
|
46
|
+
function isWide(code) {
|
|
47
|
+
return (
|
|
48
|
+
(code >= 0x1100 && code <= 0x115f) || // 한글 자모
|
|
49
|
+
(code >= 0x2e80 && code <= 0xa4cf) || // CJK
|
|
50
|
+
(code >= 0xac00 && code <= 0xd7a3) || // 한글 음절
|
|
51
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
52
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
53
|
+
(code >= 0x1f300 && code <= 0x1faff) // 이모지(대략)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 둥근 박스 패널. lines: 문자열 배열(ANSI 포함 가능)
|
|
58
|
+
export function panel(lines, { title = "", color = "cyan" } = {}) {
|
|
59
|
+
const paint = c[color] || ((x) => x);
|
|
60
|
+
const body = Array.isArray(lines) ? lines : String(lines).split("\n");
|
|
61
|
+
const contentWidth = Math.max(
|
|
62
|
+
visibleWidth(title) + 2,
|
|
63
|
+
...body.map((l) => visibleWidth(l)),
|
|
64
|
+
10
|
|
65
|
+
);
|
|
66
|
+
const top = paint("╭─ ") + paint(c.bold(title)) + paint(" " + "─".repeat(Math.max(0, contentWidth - visibleWidth(title) - 2)) + "╮");
|
|
67
|
+
const bottom = paint("╰" + "─".repeat(contentWidth + 2) + "╯");
|
|
68
|
+
const mid = body.map((l) => {
|
|
69
|
+
const pad = " ".repeat(Math.max(0, contentWidth - visibleWidth(l)));
|
|
70
|
+
return paint("│ ") + l + pad + paint(" │");
|
|
71
|
+
});
|
|
72
|
+
const head = title ? top : paint("╭" + "─".repeat(contentWidth + 2) + "╮");
|
|
73
|
+
return [head, ...mid, bottom].join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// diff 문자열을 색으로 렌더 (+초록 / -빨강 / 그외 흐림)
|
|
77
|
+
export function renderDiff(diff) {
|
|
78
|
+
return diff.split("\n").map((line) => {
|
|
79
|
+
if (line.startsWith("+")) return c.green(line);
|
|
80
|
+
if (line.startsWith("-")) return c.red(line);
|
|
81
|
+
if (line.startsWith("@")) return c.cyan(line);
|
|
82
|
+
return c.dim(line);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# 작업 규칙 (AGENT.md)
|
|
2
|
+
|
|
3
|
+
이 파일은 CDSA Harness 가 매 호출마다 읽어 모델의 시스템 프롬프트에 합치는 **규칙 파일**입니다.
|
|
4
|
+
Claude Code 의 `CLAUDE.md`, Codex 의 `AGENTS.md` 와 같은 역할을 합니다.
|
|
5
|
+
|
|
6
|
+
## 이 폴더에서의 규칙
|
|
7
|
+
|
|
8
|
+
- 모든 답변과 파일 내용은 한국어로 작성한다.
|
|
9
|
+
- 파일을 수정할 때는 기존 내용을 함부로 지우지 말고, 필요한 부분만 신중히 바꾼다.
|
|
10
|
+
- 새 메모는 `notes.txt` 에 추가하는 것을 기본으로 한다.
|
|
11
|
+
- 확실하지 않으면 먼저 파일을 읽어 사실을 확인한 뒤 작업한다.
|
|
12
|
+
|
|
13
|
+
## 실험 아이디어
|
|
14
|
+
|
|
15
|
+
1. "notes.txt 맨 아래에 오늘 할 일 3가지를 추가해줘"
|
|
16
|
+
2. "이 폴더에 어떤 파일이 있는지 알려줘"
|
|
17
|
+
3. "hello.js 라는 파일을 만들어 'Hello, Harness!' 를 출력하게 해줘"
|