cdsa-harness 0.21.1 → 0.22.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdsa-harness",
3
- "version": "0.21.1",
3
+ "version": "0.22.1",
4
4
  "description": "AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 터미널 하네스. 실시간 스트리밍 + OpenAI/Claude/OpenRouter + MCP + npm 플러그인·크로스포맷 스킬.",
5
5
  "type": "module",
6
6
  "bin": {
package/skills/hwpx.md CHANGED
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  argument-hint: "<파일.hwpx>"
3
+ triggers: .hwpx
3
4
  description: 한컴 HWPX(.hwpx) 문서를 읽어 핵심을 개조식으로 요약 [공공]
4
5
  ---
5
6
  $ARGUMENTS 파일을 hwpx_read 도구로 읽어 본문 텍스트를 추출한 뒤,
package/skills/minwon.md CHANGED
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  argument-hint: "<민원 내용 또는 파일경로>"
3
+ triggers: 민원
3
4
  description: 민원 내용을 분류(분야·담당부서·긴급도)하고 처리 방향 제안 [공공]
4
5
  ---
5
6
  아래 [민원]을 분석해줘.
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.21.1";
5
+ export const VERSION = "0.22.1";
6
6
 
7
7
  export const BUILTIN_PLUGINS = [p0];
8
8
 
@@ -11,144 +11,172 @@ export const BUILTIN_SKILLS = [
11
11
  "name": "answer",
12
12
  "description": "민원 답변서(회신문) 초안 작성 [공공]",
13
13
  "hint": "<민원 내용 또는 파일경로>",
14
+ "triggers": [],
14
15
  "body": "아래 [민원]에 대한 답변서(회신문) 초안을 작성해줘.\n※ [민원]이 명백한 파일 경로면 read_file 로 읽고, 아니면 그 문장 자체를 민원으로 본다(파일을 찾지 마라).\n구성: 인사말 / 민원요지 요약 / 검토결과(근거·관련규정[ ]) / 조치사항 / 맺음말.\n정중하고 공감하는 공공 민원 답변 문체로. 단정이 어려운 부분은 '확인 후 안내'로 표시.\n\n[민원]\n$ARGUMENTS"
15
16
  },
16
17
  {
17
18
  "name": "briefing",
18
19
  "description": "보도협조(브리핑) 요청문·요약 작성 [공공]",
19
20
  "hint": "<보도협조 주제>",
21
+ "triggers": [],
20
22
  "body": "\"$ARGUMENTS\" 를 주제로 언론 보도협조 요청문을 작성해줘.\n구성: 제목 / 협조요청 취지 / 핵심 메시지 3가지 / 일정·자료 / 담당자 연락처[ ].\n객관적이고 간결하게 한국어로. 과장 표현은 피해줘."
21
23
  },
22
24
  {
23
25
  "name": "budget",
24
26
  "description": "예산(안) 항목을 검토·요약하고 점검 포인트 제시 [공공]",
25
27
  "hint": "<예산 내용 또는 파일경로>",
28
+ "triggers": [],
26
29
  "body": "$ARGUMENTS (파일명이면 read_file 로 읽어)의 예산 내용을 한국어로 정리해줘.\n1) 총액·주요 항목 표(항목 / 금액 / 비중) 2) 전년 대비/특이사항 3) 점검 포인트(과다·누락·근거 불명).\n숫자는 그대로 인용하고, 계산이 필요하면 근거를 함께 보여줘. 파일은 수정하지 마."
27
30
  },
28
31
  {
29
32
  "name": "eli5",
30
33
  "description": "지정한 파일/개념을 5살도 알게 아주 쉽게 설명",
31
34
  "hint": "<파일명 또는 개념>",
35
+ "triggers": [],
32
36
  "body": "$ARGUMENTS 을(를) 다섯 살 아이도 이해할 수 있게 아주 쉬운 비유로 설명해줘.\n파일 이름이면 read_file 로 읽고 설명해. 전문용어는 풀어서 쓰고, 그림 그리듯 비유를 들어줘.\n파일은 수정하지 마."
33
37
  },
34
38
  {
35
39
  "name": "explain",
36
40
  "description": "파일/코드를 읽고 비개발자도 알게 쉽게 설명",
37
41
  "hint": "<파일명 또는 대상>",
42
+ "triggers": [],
38
43
  "body": "$ARGUMENTS 을(를) read_file 도구로 읽고, 무엇을 하는 코드/문서인지\n비개발자도 이해할 수 있게 한국어로 차근차근 설명해줘. 실제 수정은 하지 마."
39
44
  },
40
45
  {
41
46
  "name": "gongmun",
42
47
  "description": "공문서(기안문) 초안 작성 — 제목/수신/본문/붙임 [공공]",
43
48
  "hint": "<공문 주제·내용>",
49
+ "triggers": [],
44
50
  "body": "\"$ARGUMENTS\" 내용을 바탕으로 대한민국 행정 공문서(기안문) 초안을 작성해줘.\n형식: 제목 / 수신 / (경유) / 본문(개조식, 'ㅇ' 글머리) / 붙임.\n정중한 공공 문체로. 사실이 불확실한 부분은 [ ] 로 비워 '채울 항목'임을 표시해줘."
45
51
  },
46
52
  {
47
53
  "name": "haiku",
48
54
  "description": "파일이나 주제로 하이쿠(짧은 3줄 시) 짓기 — 재미용",
49
55
  "hint": "<주제 또는 파일명>",
56
+ "triggers": [],
50
57
  "body": "$ARGUMENTS (파일명이면 read_file 로 그 내용을 읽어)를 주제로,\n5-7-5 운율을 살린 한국어 하이쿠를 한 편 지어줘. 짧고 운치 있게.\n파일은 절대 건드리지 말고 시만 보여줘."
51
58
  },
52
59
  {
53
60
  "name": "hwpx",
54
61
  "description": "한컴 HWPX(.hwpx) 문서를 읽어 핵심을 개조식으로 요약 [공공]",
55
62
  "hint": "<파일.hwpx>",
63
+ "triggers": [
64
+ ".hwpx"
65
+ ],
56
66
  "body": "$ARGUMENTS 파일을 hwpx_read 도구로 읽어 본문 텍스트를 추출한 뒤,\n핵심 내용을 한국어 개조식('ㅇ' 글머리)으로 요약해줘. 파일은 수정하지 마.\n(.hwp 구버전이면 .hwpx 로 저장 후 다시 시도하라고 안내해줘.)"
57
67
  },
58
68
  {
59
69
  "name": "insa",
60
70
  "description": "인사발령 공지문 초안 작성 [공공]",
61
71
  "hint": "<인사발령 내용>",
72
+ "triggers": [],
62
73
  "body": "\"$ARGUMENTS\" 내용을 바탕으로 인사발령 공지문 초안을 작성해줘.\n형식: 제목 / 발령일자[ ] / 대상자(직위·성명·소속) 표 / 발령내용(전보·승진·신규 등) / 비고.\n정중한 공공 문체로. 확실하지 않은 항목은 [ ] 로 비워둬. 파일은 수정하지 마."
63
74
  },
64
75
  {
65
76
  "name": "loop",
66
77
  "description": "이 요청을 '에이전트 루프' 관점에서 어떻게 처리할지 메타 해설 (CDSA 특별)",
67
78
  "hint": "<요청>",
79
+ "triggers": [],
68
80
  "body": "다음 요청을 실제로 수행하기 전에, CDSA Harness 의 에이전트 루프\n(컨텍스트 구성 → LLM 호출 → 도구 판단 → 실행 → 결과 되먹임) 관점에서\n어떤 도구를 어떤 순서로 부를 계획인지 단계별로 한국어로 먼저 설명해줘: $ARGUMENTS\n설명만 하고, 이번에는 실제 도구 호출(특히 write_file)은 하지 마."
69
81
  },
70
82
  {
71
83
  "name": "minutes",
72
84
  "description": "회의 내용을 표준 회의록으로 정리(안건/논의/결정/조치) [공공]",
73
85
  "hint": "<회의 내용 또는 파일경로>",
86
+ "triggers": [],
74
87
  "body": "아래 [회의내용]을 회의록으로 정리해줘.\n※ [회의내용]이 명백한 파일 경로면 read_file 로 읽고, 아니면 그 문장 자체를 대상으로 본다.\n머리말: 일시[ ]/장소[ ]/참석[ ]. 본문: 안건별 [논의내용]·[결정사항]·[조치(담당/기한)]. 개조식. 파일은 수정하지 마.\n\n[회의내용]\n$ARGUMENTS"
75
88
  },
76
89
  {
77
90
  "name": "minwon",
78
91
  "description": "민원 내용을 분류(분야·담당부서·긴급도)하고 처리 방향 제안 [공공]",
79
92
  "hint": "<민원 내용 또는 파일경로>",
93
+ "triggers": [
94
+ "민원"
95
+ ],
80
96
  "body": "아래 [민원]을 분석해줘.\n※ [민원]이 `notes.txt`·`민원.hwpx` 같은 **파일 경로 형식**이면 먼저 read_file(.hwpx 는 hwpx_read)로 읽어 그 내용을 민원으로 삼고, 그 외 일반 문장이면 **그 문장 자체가 민원**이다(파일을 찾지 마라).\n표로 한국어 정리: 1) 분야 분류 2) 담당 부서(추정) 3) 긴급도(상/중/하) 4) 핵심 요지 1줄 5) 처리 방향 제안.\n개인정보가 보이면 마스킹. 파일은 수정하지 마.\n\n[민원]\n$ARGUMENTS"
81
97
  },
82
98
  {
83
99
  "name": "notice",
84
100
  "description": "공고문(모집·입찰·안내) 초안 작성 [공공]",
85
101
  "hint": "<공고 주제·내용>",
102
+ "triggers": [],
86
103
  "body": "\"$ARGUMENTS\" 를 주제로 공공 공고문 초안을 작성해줘.\n형식: 제목 / 공고번호[ ] / 목적 / 대상·자격 / 기간·일정 / 신청방법 / 문의처[ ] / 붙임.\n간결하고 명확한 공고 문체로 한국어 작성."
87
104
  },
88
105
  {
89
106
  "name": "plan",
90
107
  "description": "시킨 작업을 '실행하지 말고' 단계별 계획만 세우기",
91
108
  "hint": "<할 작업>",
109
+ "triggers": [],
92
110
  "body": "다음 작업을 실제로 수행하지 말고, 어떻게 처리할지 단계별 계획만 세워줘: $ARGUMENTS\n필요하면 read_file / list_dir 로 사실만 확인하되, write_file 은 절대 호출하지 마.\n각 단계마다 '왜 그 단계가 필요한지'도 한 줄씩 덧붙여줘."
93
111
  },
94
112
  {
95
113
  "name": "policyqa",
96
114
  "description": "정책·제도 질문에 근거를 들어 설명(모르면 모른다고) [공공]",
97
115
  "hint": "<질문>",
116
+ "triggers": [],
98
117
  "body": "다음 질문에 공공 정책·행정 관점에서 한국어로 답해줘: $ARGUMENTS\n필요하면 작업 폴더의 관련 문서를 read_file 로 확인해 근거로 삼아.\n확실하지 않은 부분은 추측하지 말고 '확인 필요'로 표시하고, 어디서/누구에게 확인하면 되는지 안내해줘."
99
118
  },
100
119
  {
101
120
  "name": "press",
102
121
  "description": "보도자료 초안 작성(제목/부제/리드/본문/문의처) [공공]",
103
122
  "hint": "<보도 주제>",
123
+ "triggers": [],
104
124
  "body": "\"$ARGUMENTS\" 를 주제로 공공기관 보도자료 초안을 작성해줘.\n구성: 제목 / 부제 / 리드(핵심 1문단) / 본문(육하원칙) / 기대효과 / 문의처[ ].\n객관적이고 간결한 보도 문체로 한국어로 작성해줘."
105
125
  },
106
126
  {
107
127
  "name": "privacy",
108
128
  "description": "개인정보(주민번호·연락처·이메일·계좌 등) 점검 및 마스킹 제안 [공공]",
109
129
  "hint": "<점검할 텍스트 또는 파일경로>",
130
+ "triggers": [],
110
131
  "body": "아래 [대상]에서 개인정보로 보이는 항목(주민등록번호·휴대전화·이메일·주소·계좌/카드번호·여권번호 등)을 찾아줘.\n※ [대상]이 명백한 파일 경로면 read_file(.hwpx 는 hwpx_read)로 읽고, 아니면 그 문장 자체를 점검 대상으로 본다.\n'유형 — 발견 위치 — 마스킹 예시(예: 010-****-1234)' 표로 정리. 없으면 없다고 명확히. 파일은 수정하지 말고 점검 보고만.\n\n[대상]\n$ARGUMENTS"
111
132
  },
112
133
  {
113
134
  "name": "quiz",
114
135
  "description": "작업 폴더 코드/문서로 학습 퀴즈 만들기",
115
136
  "hint": "[주제(선택)]",
137
+ "triggers": [],
116
138
  "body": "작업 폴더를 list_dir / read_file 로 살펴보고, 이 코드·문서를 공부할 수 있는\n객관식 퀴즈 3문제를 한국어로 만들어줘($ARGUMENTS 주제가 있으면 그쪽으로).\n각 문제에 보기 4개와 정답, 그리고 한 줄 해설을 달아줘. 파일은 수정하지 마."
117
139
  },
118
140
  {
119
141
  "name": "report",
120
142
  "description": "개조식 1쪽 보고서로 요약(추진배경/현황/문제점/개선방안) [공공]",
121
143
  "hint": "<요약할 내용 또는 파일경로>",
144
+ "triggers": [],
122
145
  "body": "아래 [내용]을 대한민국 공공 보고서 양식의 개조식으로 1쪽 요약해줘.\n※ [내용]이 명백한 파일 경로면 read_file(.hwpx 는 hwpx_read)로 읽고, 아니면 그 문장 자체를 대상으로 본다.\n항목: □ 추진배경 / □ 현황 / □ 문제점 / □ 개선방안 / □ 기대효과. 각 항목 'ㅇ' 글머리. 파일은 수정하지 마.\n\n[내용]\n$ARGUMENTS"
123
146
  },
124
147
  {
125
148
  "name": "review",
126
149
  "description": "파일을 읽고 버그 위험·개선점을 리뷰",
127
150
  "hint": "<파일명>",
151
+ "triggers": [],
128
152
  "body": "$ARGUMENTS 파일을 read_file 도구로 읽고, 버그 위험·가독성·개선점을 한국어로 짚어줘.\n실제 수정은 하지 말고, 무엇을 어떻게 고치면 좋을지 제안만 해줘."
129
153
  },
130
154
  {
131
155
  "name": "rubberduck",
132
156
  "description": "러버덕 디버깅 — 답 대신 질문으로 스스로 찾게 돕기",
133
157
  "hint": "<고민·문제>",
158
+ "triggers": [],
134
159
  "body": "당신은 '러버덕(고무오리)' 디버깅 파트너입니다. 사용자의 고민: $ARGUMENTS\n바로 정답을 주지 말고, 문제를 좁히는 좋은 질문 3~5개를 한국어로 던져줘.\n필요하면 read_file 로 관련 파일을 살펴봐도 되지만, 절대 수정하지 마.\n마지막에 \"이 중 어디부터 확인해볼까요?\" 라고 물어줘."
135
160
  },
136
161
  {
137
162
  "name": "summarize",
138
163
  "description": "지정한 파일을 읽고 한국어 3줄로 요약",
139
164
  "hint": "<파일명>",
165
+ "triggers": [],
140
166
  "body": "$ARGUMENTS 파일을 read_file 도구로 읽은 다음, 핵심 내용을 한국어 3줄로 요약해줘.\n파일을 수정하지는 말고, 요약만 보여줘."
141
167
  },
142
168
  {
143
169
  "name": "todo",
144
170
  "description": "작업 폴더에서 TODO/FIXME 주석을 모아 목록화",
145
171
  "hint": "[대상(선택)]",
172
+ "triggers": [],
146
173
  "body": "작업 폴더의 파일들을 list_dir / read_file 로 훑어 TODO, FIXME, XXX, HACK 같은\n미완성 표시가 있는 곳을 찾아 '파일 — 해당 줄 내용' 형태의 한국어 목록으로 정리해줘.\n하나도 없으면 없다고 분명히 말해줘. 파일은 수정하지 마."
147
174
  },
148
175
  {
149
176
  "name": "tour",
150
177
  "description": "작업 폴더를 둘러보고 이 프로젝트가 무엇인지 브리핑",
151
178
  "hint": "[주제(선택)]",
179
+ "triggers": [],
152
180
  "body": "작업 폴더를 list_dir 로 살펴보고, 핵심으로 보이는 파일 2~4개를 read_file 로 읽어\n이 프로젝트가 무엇이고 어떤 구조인지 한국어로 친절하게 브리핑해줘.\n$ARGUMENTS 가 있으면 그 주제에 초점을 맞춰. 파일은 절대 수정하지 마."
153
181
  }
154
182
  ];
package/src/cli.js CHANGED
@@ -492,11 +492,11 @@ function printHelp() {
492
492
  "",
493
493
  c.bold("대화"),
494
494
  ` ${c.cyan("/new")}(=/clear) 새 대화 · ${c.cyan("/compact")} 대화를 모델이 요약해 압축 · ${c.cyan("/resume")} 지난 대화 이어가기`,
495
- ` ${c.cyan("/undo")} 마지막 파일 변경 되돌리기 · ${c.cyan("/context")} 모델에 보내는 컨텍스트 보기`,
495
+ ` ${c.cyan("/undo")} ${c.cyan("/redo")} 변경 되돌리기/재적용 · ${c.cyan("/todos")} 에이전트 할일 · ${c.cyan("/context")} 컨텍스트`,
496
496
  "",
497
497
  c.bold("프로젝트"),
498
498
  ` ${c.cyan("/init")} 모델이 폴더를 파악해 AGENT.md(프로젝트 규칙) 생성 · ${c.cyan("/memory")} 규칙 파일 보기`,
499
- ` ${c.cyan("/workspace")} <폴더> 작업 폴더 보기/변경 ('.' = 현재 폴더)`,
499
+ ` ${c.cyan("/workspace")} <폴더> 작업 폴더 · ${c.cyan("/agent")} 모드(build/plan/커스텀) · ${c.cyan("!")}cmd 셸 직접실행`,
500
500
  "",
501
501
  c.bold("모델·설정"),
502
502
  ` ${c.cyan("/setup")} 연결 마법사 · ${c.cyan("/model")} <이름> 변경 · ${c.cyan("/models")} 목록(ollama 는 설치분) · ${c.cyan("/provider")} 전환`,
@@ -799,12 +799,26 @@ export async function main(argv = []) {
799
799
  }
800
800
  process.stdout.write(c.green(chunk));
801
801
  };
802
+ // question 도구 → 사용자에게 직접 질문(선택지는 방향키 메뉴)
803
+ const questionCallback = async (q, choices) => {
804
+ console.log(panel(renderMarkdown(q), { title: "❓ 에이전트의 질문", color: "cyan" }));
805
+ if (choices && choices.length) {
806
+ const sel = await selectMenu(choices.map(String), { title: "답을 선택하세요" });
807
+ if (sel !== undefined) return sel;
808
+ const a = await ask(c.cyan(`답 (1-${choices.length} 또는 텍스트): `));
809
+ const n = parseInt((a || "").trim(), 10);
810
+ return Number.isInteger(n) && choices[n - 1] ? choices[n - 1] : (a || "").trim();
811
+ }
812
+ return ((await ask(c.cyan("답변: "))) || "").trim();
813
+ };
814
+
802
815
  const loop = new AgentLoop({
803
816
  config: cfg,
804
817
  client: makeClient(cfg),
805
818
  toolbox,
806
819
  onEvent: makePrinter(cfg, stream),
807
820
  approvalCallback: makeApproval(ask, cfg),
821
+ questionCallback,
808
822
  session,
809
823
  onToken,
810
824
  });
@@ -829,17 +843,58 @@ export async function main(argv = []) {
829
843
 
830
844
  // 대화 자동저장(/resume 용) + 멘션 확장을 겸하는 실행 헬퍼.
831
845
  const convPath = path.join(configDir(), "last_session.json");
846
+ const convDir = path.join(configDir(), "conversations");
847
+ const sessionId = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
848
+ let sessionTitle = "";
849
+
850
+ // 트리거 스킬(OpenHands 마이크로에이전트식): 입력에 트리거 단어가 있으면 그 스킬 지식을 자동 첨부
851
+ const applyTriggers = (text) => {
852
+ let out = text;
853
+ let count = 0;
854
+ for (const s of Object.values(skills)) {
855
+ if (count >= 2 || !s.triggers || !s.triggers.length) continue;
856
+ if (s.triggers.some((tr) => tr && text.includes(tr))) {
857
+ out += `\n\n[자동 참고 — /${s.name} 스킬]\n${s.body.replace(/\$ARGUMENTS/g, text).slice(0, 1500)}`;
858
+ console.log(c.dim(`🧩 트리거 스킬 자동 적용: /${s.name}`));
859
+ count++;
860
+ }
861
+ }
862
+ return out;
863
+ };
864
+
865
+ // 대화가 길어지면 모델에게 요약시켜 자동 압축(auto_compact_tokens, 0=끔)
866
+ const maybeAutoCompact = async () => {
867
+ const limit = cfg.auto_compact_tokens || 0;
868
+ if (!limit || cfg.provider === "mock") return;
869
+ const est = loop.contextSummary().estTokens;
870
+ if (est < limit) return;
871
+ console.log(c.dim(`🗜️ 컨텍스트가 커져(≈${est} 토큰) 자동 압축합니다…`));
872
+ try {
873
+ const req = [...loop.messages, { role: "user", content: "지금까지의 대화를 다음 작업에 필요한 핵심(사실·결정·파일 변경·미완료 작업)만 남긴 한국어 요약으로. 요약문만 출력." }];
874
+ const r = await loop.client.chat(req, []);
875
+ const summary = (r.content || "").trim();
876
+ if (!summary) return;
877
+ loop.reset();
878
+ loop.messages.push({ role: "user", content: `[이전 대화 요약]\n${summary}` }, { role: "assistant", content: "요약을 확인했습니다. 이어서 진행하겠습니다." });
879
+ console.log(c.green(`🗜️ 자동 압축 완료 (≈${est} → ${loop.contextSummary().estTokens} 토큰)`));
880
+ } catch { /* 실패 시 조용히 계속 */ }
881
+ };
882
+
832
883
  const runTurn = async (promptText) => {
884
+ if (!sessionTitle) sessionTitle = promptText.replace(/\s+/g, " ").slice(0, 40);
833
885
  rule();
834
886
  try {
835
- await loop.run(expandMentions(promptText));
887
+ await loop.run(applyTriggers(expandMentions(promptText)));
836
888
  } catch (e) {
837
889
  console.log(c.red(`실행 오류: ${e?.message || e}`));
838
890
  }
839
891
  rule();
892
+ await maybeAutoCompact();
840
893
  try {
841
- fs.mkdirSync(configDir(), { recursive: true });
842
- fs.writeFileSync(convPath, JSON.stringify({ time: Date.now(), provider: cfg.provider, model: cfg.model, messages: loop.messages }), "utf8");
894
+ fs.mkdirSync(convDir, { recursive: true });
895
+ const payload = JSON.stringify({ time: Date.now(), title: sessionTitle, provider: cfg.provider, model: cfg.model, messages: loop.messages });
896
+ fs.writeFileSync(convPath, payload, "utf8"); // /resume (가장 최근)
897
+ fs.writeFileSync(path.join(convDir, `${sessionId}.json`), payload, "utf8"); // /sessions 목록용
843
898
  } catch { /* 저장 실패 무시 */ }
844
899
  };
845
900
 
@@ -871,6 +926,17 @@ export async function main(argv = []) {
871
926
  if (raw === null) break; // Ctrl+D / Ctrl+C / 스트림 종료
872
927
  const user = raw.trim();
873
928
  if (!user) continue;
929
+ // !명령 — 사용자가 직접 치는 셸 패스스루(모델 경유 없음)
930
+ if (user.startsWith("!") && user.length > 1) {
931
+ const cmd = user.slice(1);
932
+ try {
933
+ const out = execSync(cmd, { cwd: cfg.workspacePath(), encoding: "utf8", timeout: 60000, stdio: ["ignore", "pipe", "pipe"] });
934
+ console.log((out || "").trimEnd() || c.dim("(출력 없음)"));
935
+ } catch (e) {
936
+ console.log(((e.stdout || "") + (e.stderr || "")).trimEnd() || c.red(`실행 실패: ${e.message}`));
937
+ }
938
+ continue;
939
+ }
874
940
  const low = user.toLowerCase();
875
941
 
876
942
  if (["/quit", "/exit", "quit", "exit", ":q"].includes(low)) break;
@@ -997,6 +1063,58 @@ export async function main(argv = []) {
997
1063
  }
998
1064
  continue;
999
1065
  }
1066
+ // /redo — 되돌린 변경 다시 적용
1067
+ if (low === "/redo") {
1068
+ try { console.log(c.green(toolbox.redoLast().output)); } catch (e) { console.log(c.yellow(e.message)); }
1069
+ continue;
1070
+ }
1071
+ // /todos — 에이전트의 할일 목록 보기
1072
+ if (low === "/todos") {
1073
+ console.log(panel(loop._formatTodos().split("\n"), { title: "☑ 에이전트 할일 (todo_write 도구가 관리)", color: "cyan" }));
1074
+ continue;
1075
+ }
1076
+ // /agent — 모드/에이전트 전환: build(기본)·plan(읽기전용)·.cdsa/agents/*.md 커스텀
1077
+ if (low === "/agent" || low.startsWith("/agent ")) {
1078
+ const agentsDir = path.join(cfg.workspacePath(), ".cdsa", "agents");
1079
+ const custom = [];
1080
+ try {
1081
+ for (const f of fs.readdirSync(agentsDir)) {
1082
+ if (!f.endsWith(".md")) continue;
1083
+ const raw = fs.readFileSync(path.join(agentsDir, f), "utf8");
1084
+ const m = /^---\n([\s\S]*?)\n---\n?/.exec(raw);
1085
+ const meta = {};
1086
+ if (m) for (const ln of m[1].split("\n")) { const i = ln.indexOf(":"); if (i > 0) meta[ln.slice(0, i).trim()] = ln.slice(i + 1).trim(); }
1087
+ custom.push({ name: f.replace(/\.md$/, ""), desc: meta.description || "", body: m ? raw.slice(m[0].length).trim() : raw.trim() });
1088
+ }
1089
+ } catch { /* 폴더 없음 */ }
1090
+ const items = [
1091
+ `build — 기본(모든 도구 사용)`,
1092
+ `plan — 읽기 전용, 실행 계획만 (OpenCode Plan 모드)`,
1093
+ ...custom.map((a) => `${a.name} — ${a.desc || "커스텀 에이전트"}`),
1094
+ ];
1095
+ const argName = user.split(/\s+/)[1];
1096
+ let picked = argName || null;
1097
+ if (!picked) {
1098
+ const sel = await selectMenu(items, { title: `🎭 에이전트/모드 선택 (현재: ${loop.mode})`, current: items.find((i) => i.startsWith(loop.mode + " ")) });
1099
+ if (sel === null) continue;
1100
+ if (sel !== undefined) picked = sel.split(" ")[0];
1101
+ else {
1102
+ const a2 = await ask(c.cyan(`모드 [build/plan${custom.length ? "/" + custom.map((x) => x.name).join("/") : ""}]: `));
1103
+ picked = (a2 || "").trim();
1104
+ }
1105
+ }
1106
+ if (!picked) continue;
1107
+ const cu = custom.find((a) => a.name === picked);
1108
+ if (picked === "build" || picked === "plan" || cu) {
1109
+ loop.mode = picked;
1110
+ loop.modePrompt = cu ? cu.body : "";
1111
+ loop.refreshSystemPrompt();
1112
+ console.log(c.green(`에이전트 모드 → ${picked}`) + (picked === "plan" ? c.dim(" (파일 변경·셸 차단, 계획만)") : ""));
1113
+ } else {
1114
+ console.log(c.yellow(`알 수 없는 모드: ${picked}`));
1115
+ }
1116
+ continue;
1117
+ }
1000
1118
  // /auto — 자동 수락 토글(신뢰 전환). Claude Code 의 auto-accept 에 해당.
1001
1119
  if (low === "/auto") {
1002
1120
  cfg.approval_mode = cfg.approval_mode === "auto" ? "manual" : "auto";
@@ -1092,7 +1210,39 @@ export async function main(argv = []) {
1092
1210
  );
1093
1211
  continue;
1094
1212
  }
1095
- if (low === "/sessions") { console.log(c.dim(`세션 로그: ${sessionsDir()}`)); continue; }
1213
+ if (low === "/sessions") {
1214
+ let files = [];
1215
+ try {
1216
+ files = fs.readdirSync(convDir).filter((f) => f.endsWith(".json"))
1217
+ .map((f) => { const st = fs.statSync(path.join(convDir, f)); return { f, m: st.mtimeMs }; })
1218
+ .sort((a, b) => b.m - a.m).slice(0, 20);
1219
+ } catch { /* 없음 */ }
1220
+ if (!files.length) { console.log(c.dim(`저장된 대화가 없습니다. (로그: ${sessionsDir()})`)); continue; }
1221
+ const metas = files.map(({ f }) => {
1222
+ try {
1223
+ const d = JSON.parse(fs.readFileSync(path.join(convDir, f), "utf8"));
1224
+ return { f, label: `${(d.title || "(제목 없음)").slice(0, 34)} · ${new Date(d.time).toLocaleString("ko-KR", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })} · ${d.messages?.length || 0}msg` };
1225
+ } catch { return { f, label: f }; }
1226
+ });
1227
+ const sel = await selectMenu(metas.map((m) => m.label), { title: "🗂 지난 대화 — 골라서 이어가기" });
1228
+ let chosen = null;
1229
+ if (sel !== undefined && sel !== null) chosen = metas.find((m) => m.label === sel);
1230
+ else if (sel === undefined) {
1231
+ metas.forEach((m, i) => console.log(` ${c.bold(String(i + 1))}) ${m.label}`));
1232
+ const a = await ask(c.cyan("번호 (엔터=취소): "));
1233
+ const n = parseInt((a || "").trim(), 10);
1234
+ if (Number.isInteger(n) && metas[n - 1]) chosen = metas[n - 1];
1235
+ }
1236
+ if (chosen) {
1237
+ try {
1238
+ const d = JSON.parse(fs.readFileSync(path.join(convDir, chosen.f), "utf8"));
1239
+ loop.messages = d.messages || [];
1240
+ sessionTitle = d.title || "";
1241
+ console.log(c.green(`대화 복원: ${d.title || chosen.f} (메시지 ${loop.messages.length}개)`));
1242
+ } catch (e) { console.log(c.yellow(`복원 실패: ${e.message}`)); }
1243
+ }
1244
+ continue;
1245
+ }
1096
1246
  if (low === "/teach") {
1097
1247
  cfg.teach_mode = !cfg.teach_mode;
1098
1248
  console.log(c.green(`교육 모드 ${cfg.teach_mode ? "ON" : "OFF"}.`));
package/src/config.js CHANGED
@@ -48,6 +48,8 @@ const DEFAULTS = {
48
48
  update_check: true, // 시작 시 새 버전 확인(하루 1회, 실패 시 조용히 무시)
49
49
  plugins: [], // 추가로 불러올 npm 플러그인 패키지 이름(이름 규칙과 무관하게 강제 로드)
50
50
  mcpServers: {}, // MCP 서버 설정 (Claude Code/Cursor 와 동일한 형식)
51
+ permissions: {}, // 도구별 권한: { "run_shell": "allow"|"ask"|"deny", ... } (OpenCode 방식)
52
+ auto_compact_tokens: 12000, // 추정 토큰이 이 값을 넘으면 대화 자동 압축(0=끔)
51
53
  };
52
54
 
53
55
  export function configDir() {
package/src/loop.js CHANGED
@@ -97,17 +97,21 @@ function findRules(workspace) {
97
97
  }
98
98
 
99
99
  export class AgentLoop {
100
- constructor({ config, client, toolbox, onEvent, approvalCallback, session = null, onToken = null, depth = 0 }) {
100
+ constructor({ config, client, toolbox, onEvent, approvalCallback, questionCallback = null, session = null, onToken = null, depth = 0 }) {
101
101
  this.config = config;
102
102
  this.client = client;
103
103
  this.toolbox = toolbox;
104
104
  this.onEvent = onEvent;
105
105
  this.approvalCallback = approvalCallback;
106
+ this.questionCallback = questionCallback; // question 도구 → 사용자에게 직접 질문
106
107
  this.session = session;
107
108
  this.onToken = onToken; // 스트리밍 토큰 콜백(있으면 실시간 출력)
108
109
  this.depth = depth; // 0=최상위, 1=서브에이전트(중첩 위임 불가)
109
110
  this.messages = [];
110
111
  this.usage = { input: 0, output: 0, total: 0, calls: 0 }; // 세션 누적 토큰(/status, /cost)
112
+ this.todos = []; // todo_write/read 도구의 세션 할일 목록
113
+ this.mode = "build"; // build(기본) | plan(읽기전용) | 커스텀 에이전트명
114
+ this.modePrompt = ""; // 에이전트/모드 추가 시스템 프롬프트
111
115
  }
112
116
 
113
117
  _emit(step, title = "", detail = "", data = {}) {
@@ -135,7 +139,7 @@ export class AgentLoop {
135
139
  const parts = [
136
140
  "# 정체성",
137
141
  "당신은 CDSA Harness(made by CDSA) 안에서 동작하는 코딩·업무 에이전트입니다.",
138
- "작업 폴더의 파일을 도구(list_dir/read_file/search_files/edit_file/write_file)로 직접 읽고·검색하고·수정할 수 있습니다.",
142
+ "작업 폴더의 파일을 도구(read_file/glob/grep/edit_file/multi_edit/write_file)로 직접 읽고·검색하고·수정할 수 있으며, webfetch 로 웹 문서도 읽을 수 있습니다.",
139
143
  "'파일에 접근할 수 없다'고 답하지 마세요 — 필요한 도구를 호출하면 됩니다. 도구가 곧 당신의 손입니다.",
140
144
  "",
141
145
  "# 환경",
@@ -144,9 +148,14 @@ export class AgentLoop {
144
148
  `- 플랫폼: ${process.platform} · Node ${process.versions.node} · 오늘: ${now.toISOString().slice(0, 10)}(${day})`,
145
149
  "- 모든 도구는 작업 폴더 밖으로 나갈 수 없습니다(sandbox). 파일 수정·셸 실행은 사용자 승인 후에만 적용됩니다.",
146
150
  "",
151
+ this.mode !== "build" ? `# 모드: ${this.mode}` : null,
152
+ this.mode === "plan" ? "지금은 plan 모드 — 파일 변경·셸 실행이 금지된다. 조사(읽기/검색)만 하고, 구체적 실행 계획을 제시하라." : null,
153
+ this.modePrompt || null,
154
+ this.mode !== "build" ? "" : null,
147
155
  "# 도구 사용 원칙",
148
156
  "- 추측 금지: 파일 위치·내용이 불확실하면 먼저 search_files / list_dir / read_file 로 사실을 확인한다.",
149
- "- 기존 파일의 일부 수정은 edit_file(old_text 파일에서 유일해야 함), 새 파일·전체 교체만 write_file 을 쓴다.",
157
+ "- 위치를 모르면 glob(파일명)·grep(내용 정규식)으로 찾는다. 일부 수정은 edit_file, 파일 여러 곳은 multi_edit, 새 파일·전체 교체만 write_file.",
158
+ "- 3단계 이상 작업은 todo_write 로 계획을 기록하고 진행하며 갱신한다. 꼭 필요한 결정만 question 으로 사용자에게 묻는다.",
150
159
  "- 도구가 오류를 돌려주면 같은 호출을 반복하지 말고, 오류 메시지를 읽고 접근을 바꾼다.",
151
160
  "- 사용자가 승인을 거부하면 그 의사를 존중하고 대안을 제시한다.",
152
161
  this.depth === 0
@@ -331,10 +340,45 @@ export class AgentLoop {
331
340
  return out;
332
341
  }
333
342
 
343
+ // 도구별 권한: config.permissions[도구] = allow|ask|deny (OpenCode 방식).
344
+ // 기본: 파일변경·셸·웹은 ask, 읽기는 allow. plan 모드에선 변경 도구가 deny.
345
+ _permission(name) {
346
+ if (this.mode === "plan" && (this.toolbox.isMutating(name) || name === "run_shell")) return "deny";
347
+ const p = (this.config.permissions || {})[name];
348
+ if (p === "allow" || p === "ask" || p === "deny") return p;
349
+ if (this.toolbox.isMutating(name) || name === "webfetch") return "ask";
350
+ return "allow";
351
+ }
352
+
353
+ _formatTodos() {
354
+ if (!this.todos.length) return "(할일 목록 비어 있음)";
355
+ const icon = { pending: "☐", in_progress: "◐", done: "☑" };
356
+ return this.todos.map((t, i) => `${icon[t.status] || "☐"} ${i + 1}. ${t.content}${t.status === "in_progress" ? " ←진행중" : ""}`).join("\n");
357
+ }
358
+
334
359
  async _handleToolCall(tc) {
335
360
  if (tc.name === "spawn_agent") return this._runSubAgent(tc);
361
+ if (tc.name === "todo_write") {
362
+ this.todos = (tc.args.todos || []).slice(0, 30).map((t) => ({ content: String(t.content || ""), status: t.status || "pending" }));
363
+ this._emit(Step.TOOL_RESULT, "할일 갱신", this._formatTodos());
364
+ return "할일 목록 갱신됨:\n" + this._formatTodos();
365
+ }
366
+ if (tc.name === "todo_read") return this._formatTodos();
367
+ if (tc.name === "question") {
368
+ const q = (tc.args.question || "").trim();
369
+ if (!q) return "question 오류: 질문이 비어 있습니다.";
370
+ if (!this.questionCallback) return "이 환경에선 사용자에게 질문할 수 없습니다. 합리적인 기본값으로 진행하세요.";
371
+ const ans = await this.questionCallback(q, tc.args.choices || null);
372
+ return ans === null || ans === "" ? "(사용자가 답하지 않음 — 합리적 기본값으로 진행)" : `사용자 답변: ${ans}`;
373
+ }
336
374
  const label = this.toolbox.label ? this.toolbox.label(tc.name) : TOOL_LABELS[tc.name] || tc.name;
337
- const needsApproval = this.toolbox.isMutating(tc.name);
375
+ const perm = this._permission(tc.name);
376
+ if (perm === "deny") {
377
+ const why = this.mode === "plan" ? "plan 모드(읽기 전용)" : "권한 설정(deny)";
378
+ this._emit(Step.APPROVAL, `차단됨: ${label}`, `${why} 에 의해 이 도구는 사용할 수 없습니다.`);
379
+ return `'${label}' 도구는 ${why} 때문에 실행할 수 없습니다. 변경 없이 계획/설명으로 답하세요.`;
380
+ }
381
+ const needsApproval = perm === "ask";
338
382
 
339
383
  if (needsApproval && this.config.approval_mode === "manual") {
340
384
  const req = this._buildApprovalRequest(tc);
@@ -377,6 +421,13 @@ export class AgentLoop {
377
421
  const { path: p, diff } = this.toolbox.previewEdit(tc.args.path || "", tc.args.old_text || "", tc.args.new_text ?? "");
378
422
  return { toolName: "edit_file", toolLabel: TOOL_LABELS.edit_file, args: tc.args, path: p, diff };
379
423
  }
424
+ if (tc.name === "multi_edit") {
425
+ const { path: p, diff } = this.toolbox.previewMultiEdit(tc.args.path || "", tc.args.edits || []);
426
+ return { toolName: "edit_file", toolLabel: TOOL_LABELS.multi_edit, args: tc.args, path: p, diff };
427
+ }
428
+ if (tc.name === "webfetch") {
429
+ return { toolName: "webfetch", toolLabel: TOOL_LABELS.webfetch, args: tc.args, command: `GET ${tc.args.url || ""}` };
430
+ }
380
431
  if (tc.name === "run_shell") {
381
432
  return {
382
433
  toolName: "run_shell",
package/src/skills.js CHANGED
@@ -58,6 +58,7 @@ function addSkill(skills, name, file) {
58
58
  name,
59
59
  description: meta.description || "",
60
60
  hint: meta["argument-hint"] || meta.args || meta.usage || "",
61
+ triggers: (meta.triggers || "").split(",").map((s) => s.trim()).filter(Boolean),
61
62
  body,
62
63
  source: file,
63
64
  };
@@ -95,7 +96,7 @@ export function loadSkills(workspace, { importForeign = true, extraDirs = [] } =
95
96
  }
96
97
  // 패키지 내장 기본 스킬(임베드) — 가장 낮은 우선순위(사용자 파일이 덮어씀)
97
98
  for (const s of BUILTIN_SKILLS) {
98
- if (!skills[s.name]) skills[s.name] = { name: s.name, description: s.description || "", hint: s.hint || "", body: s.body, source: "(내장)" };
99
+ if (!skills[s.name]) skills[s.name] = { name: s.name, description: s.description || "", hint: s.hint || "", triggers: s.triggers || [], body: s.body, source: "(내장)" };
99
100
  }
100
101
  return skills;
101
102
  }
package/src/tools.js CHANGED
@@ -9,13 +9,20 @@ export const TOOL_LABELS = {
9
9
  read_file: "파일 읽기",
10
10
  write_file: "파일 쓰기",
11
11
  edit_file: "부분 수정",
12
+ multi_edit: "복수 부분수정",
12
13
  search_files: "파일 검색",
14
+ glob: "패턴 파일찾기",
15
+ grep: "정규식 검색",
16
+ webfetch: "웹 읽기",
13
17
  run_shell: "셸 실행",
14
18
  spawn_agent: "서브에이전트",
19
+ todo_write: "할일 기록",
20
+ todo_read: "할일 보기",
21
+ question: "사용자에게 질문",
15
22
  };
16
23
 
17
24
  // 사용자 승인이 필요한(환경을 바꾸는) 도구
18
- export const MUTATING_TOOLS = new Set(["write_file", "edit_file", "run_shell"]);
25
+ export const MUTATING_TOOLS = new Set(["write_file", "edit_file", "multi_edit", "run_shell"]);
19
26
 
20
27
  export class ToolError extends Error {}
21
28
 
@@ -135,25 +142,49 @@ export class Toolbox {
135
142
  return { path: this.rel(target), diff };
136
143
  }
137
144
 
138
- // /undo 용: 마지막 파일 변경 1건을 기억해 되돌릴 있게 한다(단순 프리미티브).
145
+ // 다단계 undo/redo 변경 스냅샷을 스택에 쌓는다(최대 50). OpenCode /undo /redo 대응.
139
146
  _backup(target) {
140
- this.lastChange = {
147
+ this.undoStack = this.undoStack || [];
148
+ this.redoStack = [];
149
+ this.undoStack.push({
141
150
  target,
142
151
  rel: this.rel(target),
143
152
  old: fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null, // null = 새 파일이었음
144
- };
153
+ });
154
+ if (this.undoStack.length > 50) this.undoStack.shift();
155
+ }
156
+
157
+ _applySnapshot(snap, intoStack) {
158
+ const cur = fs.existsSync(snap.target) ? fs.readFileSync(snap.target, "utf8") : null;
159
+ intoStack.push({ target: snap.target, rel: snap.rel, old: cur });
160
+ if (snap.old === null) {
161
+ fs.rmSync(snap.target, { force: true });
162
+ return `${snap.rel} 생성을 취소했습니다(파일 삭제).`;
163
+ }
164
+ fs.mkdirSync(path.dirname(snap.target), { recursive: true });
165
+ fs.writeFileSync(snap.target, snap.old, "utf8");
166
+ return `${snap.rel} 을 이전 상태로 되돌렸습니다.`;
145
167
  }
146
168
 
147
169
  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} 생성을 취소했습니다(파일 삭제).` };
170
+ if (!this.undoStack || !this.undoStack.length) throw new ToolError("되돌릴 파일 변경이 없습니다.");
171
+ this.redoStack = this.redoStack || [];
172
+ const msg = this._applySnapshot(this.undoStack.pop(), this.redoStack);
173
+ return { ok: true, output: msg + ` (undo 남은 ${this.undoStack.length} · redo 가능 ${this.redoStack.length})` };
174
+ }
175
+
176
+ redoLast() {
177
+ if (!this.redoStack || !this.redoStack.length) throw new ToolError("다시 적용할 변경이 없습니다.");
178
+ this.undoStack = this.undoStack || [];
179
+ const snap = this.redoStack.pop();
180
+ const cur = fs.existsSync(snap.target) ? fs.readFileSync(snap.target, "utf8") : null;
181
+ this.undoStack.push({ target: snap.target, rel: snap.rel, old: cur });
182
+ if (snap.old === null) fs.rmSync(snap.target, { force: true });
183
+ else {
184
+ fs.mkdirSync(path.dirname(snap.target), { recursive: true });
185
+ fs.writeFileSync(snap.target, snap.old, "utf8");
154
186
  }
155
- fs.writeFileSync(target, old, "utf8");
156
- return { ok: true, output: `${rel} 을 마지막 변경 이전 상태로 되돌렸습니다.` };
187
+ return { ok: true, output: `${snap.rel} 변경을 다시 적용했습니다. (undo ${this.undoStack.length} · redo ${this.redoStack.length})` };
157
188
  }
158
189
 
159
190
  writeFile(rel, content) {
@@ -255,6 +286,136 @@ export class Toolbox {
255
286
  return { ok: true, output: parts.join("\n\n") || `'${query}' 검색 결과가 없습니다.` };
256
287
  }
257
288
 
289
+ // 한 파일에 여러 부분수정을 원자적으로(전부 성공해야 적용, 승인 1회) — OpenCode patch 대응.
290
+ _computeMultiEdit(rel, edits) {
291
+ const target = this._resolve(rel);
292
+ if (!fs.existsSync(target) || !fs.statSync(target).isFile()) throw new ToolError(`파일이 없습니다: ${rel}`);
293
+ if (!Array.isArray(edits) || !edits.length) throw new ToolError("edits 배열이 비어 있습니다.");
294
+ const orig = fs.readFileSync(target, "utf8");
295
+ let cur = orig;
296
+ edits.forEach((e, i) => {
297
+ const o = e.old_text ?? "";
298
+ if (!o) throw new ToolError(`edits[${i}]: old_text 가 비어 있습니다.`);
299
+ const cnt = cur.split(o).length - 1;
300
+ if (cnt === 0) throw new ToolError(`edits[${i}]: old_text 를 찾지 못했습니다.`);
301
+ if (cnt > 1) throw new ToolError(`edits[${i}]: old_text 가 ${cnt}번 일치 — 더 길게 지정하세요.`);
302
+ cur = cur.replace(o, e.new_text ?? "");
303
+ });
304
+ return { target, orig, next: cur };
305
+ }
306
+
307
+ previewMultiEdit(rel, edits) {
308
+ try {
309
+ const { target, orig, next } = this._computeMultiEdit(rel, edits);
310
+ return { path: this.rel(target), diff: diffLines(orig, next).filter((l) => !l.startsWith(" ")).join("\n") || "(변화 없음)" };
311
+ } catch (e) {
312
+ return { path: rel, diff: `(미리보기 불가: ${e.message})` };
313
+ }
314
+ }
315
+
316
+ multiEdit(rel, edits) {
317
+ const { target, next } = this._computeMultiEdit(rel, edits);
318
+ this._backup(target);
319
+ fs.writeFileSync(target, next, "utf8");
320
+ return { ok: true, output: `${this.rel(target)} 에 ${edits.length}건 부분수정 적용 완료.` };
321
+ }
322
+
323
+ // glob 패턴 → 정규식 (** = 깊이무관, * = 세그먼트 내, ? = 한 글자)
324
+ _globToRe(pattern) {
325
+ let re = "";
326
+ for (let i = 0; i < pattern.length; i++) {
327
+ const ch = pattern[i];
328
+ if (ch === "*") {
329
+ if (pattern[i + 1] === "*") { re += "(?:.*)"; i++; if (pattern[i + 1] === "/") i++; }
330
+ else re += "[^/]*";
331
+ } else if (ch === "?") re += "[^/]";
332
+ else re += ch.replace(/[.+^$()|[\]{}\\]/g, "\\$&");
333
+ }
334
+ return new RegExp("^" + re + "$");
335
+ }
336
+
337
+ _walkFiles(sub, cb) {
338
+ const SKIP = new Set(["node_modules", ".git", "dist", "build", "__pycache__", ".venv"]);
339
+ const rootDir = this._resolve(sub);
340
+ const stack = [rootDir];
341
+ let visited = 0;
342
+ while (stack.length && visited < 5000) {
343
+ const dir = stack.pop();
344
+ let entries = [];
345
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
346
+ for (const e of entries) {
347
+ const full = path.join(dir, e.name);
348
+ if (e.isDirectory()) {
349
+ if (!SKIP.has(e.name) && !e.name.startsWith(".")) stack.push(full);
350
+ } else {
351
+ visited++;
352
+ if (cb(full, this.rel(full).replace(/\\/g, "/")) === false) return;
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ globFiles(pattern, sub = ".") {
359
+ if (!pattern || !pattern.trim()) throw new ToolError("pattern 이 비어 있습니다.");
360
+ const re = this._globToRe(pattern.trim());
361
+ const hits = [];
362
+ this._walkFiles(sub, (full, rel) => {
363
+ if (re.test(rel) || re.test(path.basename(rel))) {
364
+ let mtime = 0;
365
+ try { mtime = fs.statSync(full).mtimeMs; } catch { /* */ }
366
+ hits.push({ rel, mtime });
367
+ if (hits.length >= 200) return false;
368
+ }
369
+ });
370
+ hits.sort((a, b) => b.mtime - a.mtime);
371
+ const out = hits.slice(0, 100).map((h) => h.rel).join("\n");
372
+ return { ok: true, output: out || `'${pattern}' 에 맞는 파일이 없습니다.` };
373
+ }
374
+
375
+ grepFiles(pattern, sub = ".", fileGlob = "") {
376
+ if (!pattern || !pattern.trim()) throw new ToolError("pattern 이 비어 있습니다.");
377
+ let re;
378
+ try { re = new RegExp(pattern, "i"); } catch (e) { throw new ToolError(`정규식 오류: ${e.message}`); }
379
+ const fileRe = fileGlob ? this._globToRe(fileGlob) : null;
380
+ const hits = [];
381
+ this._walkFiles(sub, (full, rel) => {
382
+ if (fileRe && !fileRe.test(rel) && !fileRe.test(path.basename(rel))) return;
383
+ let st; try { st = fs.statSync(full); } catch { return; }
384
+ if (st.size > 512 * 1024) return;
385
+ let text; try { text = fs.readFileSync(full, "utf8"); } catch { return; }
386
+ if (text.includes("\u0000")) return;
387
+ const lines = text.split("\n");
388
+ for (let i = 0; i < lines.length; i++) {
389
+ if (re.test(lines[i])) {
390
+ hits.push(`${rel}:${i + 1} ${lines[i].trim().slice(0, 140)}`);
391
+ if (hits.length >= 60) return false;
392
+ }
393
+ }
394
+ });
395
+ return { ok: true, output: hits.join("\n") || `/${pattern}/ 일치 없음.` };
396
+ }
397
+
398
+ // 웹 페이지 텍스트 읽기(태그 제거). 폐쇄망이면 네트워크 오류를 정중히 반환.
399
+ async webFetch(url) {
400
+ if (!/^https?:\/\//i.test(url || "")) throw new ToolError("http(s) URL 이 필요합니다.");
401
+ const ctrl = new AbortController();
402
+ const timer = setTimeout(() => ctrl.abort(), 10000);
403
+ let res;
404
+ try {
405
+ res = await fetch(url, { signal: ctrl.signal, headers: { "user-agent": "cdsa-harness" } });
406
+ } catch (e) {
407
+ throw new ToolError(`웹 요청 실패: ${e.message} (폐쇄망일 수 있음)`);
408
+ } finally { clearTimeout(timer); }
409
+ if (!res.ok) throw new ToolError(`HTTP ${res.status}`);
410
+ let text = await res.text();
411
+ text = text
412
+ .replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ")
413
+ .replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
414
+ .replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
415
+ if (text.length > 8000) text = text.slice(0, 8000) + "\n... (이후 생략)";
416
+ return { ok: true, output: `[${url}]\n${text || "(본문 없음)"}` };
417
+ }
418
+
258
419
  runShell(command) {
259
420
  if (!this.allowShell) {
260
421
  throw new ToolError("셸 실행이 설정에서 비활성화되어 있습니다(allow_shell=false).");
@@ -283,7 +444,11 @@ export class Toolbox {
283
444
  if (name === "read_file") return this.readFile(args.path || "");
284
445
  if (name === "write_file") return this.writeFile(args.path || "", args.content || "");
285
446
  if (name === "edit_file") return this.editFile(args.path || "", args.old_text || "", args.new_text ?? "");
447
+ if (name === "multi_edit") return this.multiEdit(args.path || "", args.edits || []);
286
448
  if (name === "search_files") return this.searchFiles(args.query || "", args.path || ".");
449
+ if (name === "glob") return this.globFiles(args.pattern || "", args.path || ".");
450
+ if (name === "grep") return this.grepFiles(args.pattern || "", args.path || ".", args.glob || "");
451
+ if (name === "webfetch") return this.webFetch(args.url || "");
287
452
  if (name === "run_shell") return this.runShell(args.command || "");
288
453
  const plugin = this._pluginMap.get(name);
289
454
  if (plugin) {
@@ -391,6 +556,68 @@ export function toolSchemas(allowShell = false, plugins = []) {
391
556
  },
392
557
  });
393
558
  }
559
+ schemas.push(
560
+ {
561
+ type: "function",
562
+ function: {
563
+ name: "multi_edit",
564
+ description: "한 파일에 여러 부분수정을 한 번에 적용한다(전부 성공해야 적용, 승인 1회). 같은 파일을 여러 곳 고칠 때 edit_file 반복 대신 사용.",
565
+ parameters: {
566
+ type: "object",
567
+ properties: {
568
+ path: { type: "string", description: "수정할 파일 상대 경로" },
569
+ edits: { type: "array", items: { type: "object", properties: { old_text: { type: "string" }, new_text: { type: "string" } }, required: ["old_text", "new_text"] }, description: "순서대로 적용할 치환 목록" },
570
+ },
571
+ required: ["path", "edits"],
572
+ },
573
+ },
574
+ },
575
+ {
576
+ type: "function",
577
+ function: {
578
+ name: "glob",
579
+ description: "글롭 패턴으로 파일을 찾는다(예: **/*.js, docs/*.md). 최신 수정순.",
580
+ parameters: { type: "object", properties: { pattern: { type: "string" }, path: { type: "string", description: "시작 폴더(기본 .)" } }, required: ["pattern"] },
581
+ },
582
+ },
583
+ {
584
+ type: "function",
585
+ function: {
586
+ name: "grep",
587
+ description: "정규식으로 파일 내용을 검색한다. glob 인자로 대상 파일을 좁힐 수 있다.",
588
+ parameters: { type: "object", properties: { pattern: { type: "string", description: "정규식" }, path: { type: "string" }, glob: { type: "string", description: "대상 파일 글롭(선택)" } }, required: ["pattern"] },
589
+ },
590
+ },
591
+ {
592
+ type: "function",
593
+ function: {
594
+ name: "webfetch",
595
+ description: "웹 페이지(URL)를 가져와 텍스트로 읽는다. 문서·자료 확인용. 사용자 승인 후 실행된다.",
596
+ parameters: { type: "object", properties: { url: { type: "string" } }, required: ["url"] },
597
+ },
598
+ },
599
+ {
600
+ type: "function",
601
+ function: {
602
+ name: "todo_write",
603
+ description: "여러 단계 작업의 할일 목록을 기록/갱신한다. 3단계 이상 작업을 시작할 때, 그리고 각 단계 완료 시 상태를 갱신하라.",
604
+ parameters: {
605
+ type: "object",
606
+ properties: { todos: { type: "array", items: { type: "object", properties: { content: { type: "string" }, status: { type: "string", enum: ["pending", "in_progress", "done"] } }, required: ["content", "status"] } } },
607
+ required: ["todos"],
608
+ },
609
+ },
610
+ },
611
+ { type: "function", function: { name: "todo_read", description: "현재 할일 목록을 읽는다.", parameters: { type: "object", properties: {} } } },
612
+ {
613
+ type: "function",
614
+ function: {
615
+ name: "question",
616
+ description: "작업 진행에 꼭 필요한 결정을 사용자에게 직접 묻는다. 선택지가 있으면 choices 로 제공. 남용하지 말 것.",
617
+ parameters: { type: "object", properties: { question: { type: "string" }, choices: { type: "array", items: { type: "string" }, description: "선택지(선택)" } }, required: ["question"] },
618
+ },
619
+ }
620
+ );
394
621
  // 플러그인이 제공하는 도구를 모델에게도 노출한다.
395
622
  for (const p of plugins) {
396
623
  if (!p || !p.name || typeof p.handler !== "function") continue;