cdsa-harness 0.14.0 → 0.18.0

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