deel-local-cli 1.2.0 → 1.4.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.
Files changed (50) hide show
  1. package/README.en.md +301 -1537
  2. package/README.md +272 -1582
  3. package/bin/deel.js +73 -4
  4. package/package.json +8 -3
  5. package/src/acp/jsonrpc.js +230 -0
  6. package/src/acp/map.js +219 -0
  7. package/src/acp/serve.js +556 -0
  8. package/src/agent/card.js +129 -0
  9. package/src/agent/compact.js +10 -2
  10. package/src/agent/effort.js +5 -0
  11. package/src/agent/evidence.js +186 -0
  12. package/src/agent/grade.js +20 -0
  13. package/src/agent/loop.js +134 -13
  14. package/src/agent/models.js +169 -0
  15. package/src/agent/modes.js +47 -1
  16. package/src/agent/pins.js +140 -0
  17. package/src/agent/preset.js +113 -0
  18. package/src/agent/project.js +10 -4
  19. package/src/agent/session.js +233 -13
  20. package/src/agent/store.js +31 -0
  21. package/src/backend/adapter.js +14 -0
  22. package/src/commands.js +616 -52
  23. package/src/i18n/en.js +265 -0
  24. package/src/i18n/index.js +126 -0
  25. package/src/i18n/ko.js +252 -0
  26. package/src/lsp/client.js +459 -0
  27. package/src/lsp/diag.js +112 -0
  28. package/src/lsp/rpc.js +84 -0
  29. package/src/lsp/servers.js +218 -0
  30. package/src/oneshot.js +19 -0
  31. package/src/pack/sbom.js +218 -0
  32. package/src/pack/selfpack.js +19 -3
  33. package/src/preview/serve.js +19 -2
  34. package/src/repl.js +193 -8
  35. package/src/safety/secrets.js +205 -0
  36. package/src/safety/undo.js +10 -3
  37. package/src/tools/desc.en.js +221 -0
  38. package/src/tools/docs.js +252 -0
  39. package/src/tools/index.js +190 -6
  40. package/src/tools/lsp.js +327 -0
  41. package/src/tools/task.js +30 -2
  42. package/src/ui/ansi.js +45 -0
  43. package/src/ui/approve.js +25 -21
  44. package/src/ui/banner.js +245 -0
  45. package/src/ui/export.js +217 -0
  46. package/src/ui/inputbox.js +37 -7
  47. package/src/ui/intro.js +206 -0
  48. package/src/ui/level.js +11 -5
  49. package/src/ui/notify.js +101 -0
  50. package/src/ui/status.js +181 -35
package/bin/deel.js CHANGED
@@ -1,16 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  // deel 진입점. 외부 의존성 없음 — Node 표준 기능만 씁니다.
3
3
  import { join } from 'node:path';
4
- import { readFileSync } from 'node:fs';
4
+ import { readFileSync, writeFileSync } from 'node:fs';
5
5
  import { c, say, mark, rule } from '../src/ui/ansi.js';
6
6
  import { runSetup, runDiagnose, showStatus, banner } from '../src/setup.js';
7
7
  import { chatLoop } from '../src/repl.js';
8
8
  import { runOnce } from '../src/oneshot.js';
9
9
  import { packSelf, audit, reviewSheet } from '../src/pack/selfpack.js';
10
+ import { sbom, 심사명세, 명세요약 } from '../src/pack/sbom.js';
10
11
  import { runScan } from '../src/backend/scanui.js';
11
12
  import { closeConnections } from '../src/backend/http.js';
12
13
  import { parseSize } from '../src/backend/ctxsize.js';
13
14
  import { runSessions } from '../src/agent/sessionui.js';
15
+ import { acp } from '../src/acp/serve.js';
14
16
 
15
17
  const MIN_NODE = 20;
16
18
 
@@ -47,7 +49,8 @@ function runPack(flags) {
47
49
  say(` ${c.gray('네트워크 호출')} ${a.calls.net.length}곳 ${c.gray('(설정한 주소로만)')}`);
48
50
  say(` ${c.gray('포트 열기')} ${a.calls.listen.length === 0 ? c.green('없음') : c.red(a.calls.listen.length + '곳')}`);
49
51
  say('');
50
- say(` ${c.gray('안에 반입심사서.txt 같이 들어 있습니다 그대로 제출하시면 됩니다.')}`);
52
+ say(` ${c.gray('안에 반입심사서.txt · sbom.cdx.json · 심사명세.json 같이 들어 있습니다.')}`);
53
+ say(` ${c.gray('사람이 읽을 것 한 장, 스캐너에 넣을 것 두 장입니다 — 그대로 제출하시면 됩니다.')}`);
51
54
  say(` ${c.gray('내용만 먼저 보시려면')} ${c.cyan('deel audit')}`);
52
55
  say('');
53
56
  return 0;
@@ -60,6 +63,43 @@ function runAudit() {
60
63
  return 0;
61
64
  }
62
65
 
66
+ /*
67
+ * 기계가 읽는 심사 서류만 따로 뽑기.
68
+ *
69
+ * 반입 심사는 사람만 보는 절차가 아니다. 보안팀은 SBOM 을 스캐너에 먹이고,
70
+ * 운영팀은 감사기록 사양을 보고 수집 규칙을 짠다. zip 을 통째로 만들지 않고
71
+ * 그 두 장만 필요할 때가 실제로 더 잦다 — 심사 양식에 첨부하는 자리다.
72
+ */
73
+ function runSbom(flags) {
74
+ const a = audit();
75
+ const at = new Date();
76
+ const 어느것 = String(flags.only ?? '').toLowerCase();
77
+ const 낼것 = 어느것 === 'sbom' ? sbom(a, { at })
78
+ : 어느것 === '명세' || 어느것 === 'spec' ? 심사명세(a, { at })
79
+ : { sbom: sbom(a, { at }), 심사명세: 심사명세(a, { at }) };
80
+ const 글 = JSON.stringify(낼것, null, 2);
81
+
82
+ if (flags.out) {
83
+ const 자리 = String(flags.out);
84
+ writeFileSync(자리, 글, 'utf8');
85
+ say('');
86
+ say(` ${mark.ok} ${c.bold(자리)}`);
87
+ say('');
88
+ for (const 줄 of 명세요약(심사명세(a, { at })).split('\n')) say(` ${c.gray(줄)}`);
89
+ say('');
90
+ return 0;
91
+ }
92
+
93
+ /*
94
+ * 표준출력으로 그냥 흘린다.
95
+ *
96
+ * `deel sbom > sbom.json` 이나 `deel sbom | jq` 로 쓰는 자리다. 여기에
97
+ * 안내 글을 섞으면 그 파이프가 통째로 깨진다 — say() 를 쓰지 않는 이유다.
98
+ */
99
+ process.stdout.write(글 + '\n');
100
+ return 0;
101
+ }
102
+
63
103
  // 값을 안 받는 깃발. 뒤에 오는 낱말을 제 값으로 삼키지 않게 여기 적어 둔다.
64
104
  //
65
105
  // 실제로 이랬다 — deel run --json "검사 돌려줘" 를 쳤더니 --json 이 뒤의 말을
@@ -107,10 +147,19 @@ function help() {
107
147
  say(` ${c.gray('--key <키>')} 키가 필요한 로컬 서버일 때`);
108
148
  say(` ${c.gray('대화 중')} ${c.cyan('/model')} ${c.gray('로 서버·모델을 골라 바꿉니다.')}`);
109
149
  say('');
150
+ say(` ${c.bold('에디터 안에서 쓰기')} ${c.gray('— Zed · JetBrains · Neovim · Emacs')}`);
151
+ say('');
152
+ say(` ${c.cyan('deel acp')} 에디터가 띄우는 자리 (ACP). 사람이 직접 칠 명령은 아닙니다`);
153
+ say(` ${c.gray('에디터 설정에 이 명령을 적어 두면 그 안에서 deel 이 돕니다.')}`);
154
+ say(` ${c.gray('승인 창·모드 고르개·고친 파일 링크가 에디터 것으로 그려집니다.')}`);
155
+ say('');
110
156
  say(` ${c.bold('사내 반입')}`);
111
157
  say('');
112
- say(` ${c.cyan('deel audit')} 의존성·네트워크 호출 자리 심사서 보기`);
113
- say(` ${c.cyan('deel pack')} 심사서 + 소스를 zip 하나로 묶기`);
158
+ say(` ${c.cyan('deel audit')} 의존성·네트워크 호출 자리 심사서 ${c.gray('(사람이 읽는 글)')}`);
159
+ say(` ${c.cyan('deel sbom')} SBOM·통신 목록·감사 사양 ${c.gray('(기계가 읽는 JSON)')}`);
160
+ say(` ${c.gray('--out <파일>')} 파일로 적기. 안 주면 표준출력 — ${c.cyan('deel sbom | jq')}`);
161
+ say(` ${c.gray('--only sbom|명세')} 한 장만`);
162
+ say(` ${c.cyan('deel pack')} 위 셋 + 소스를 zip 하나로 묶기`);
114
163
  say(` ${c.gray('--out <파일>')} 묶음 파일 이름. 기본은 deel-반입.zip`);
115
164
  say('');
116
165
  say(` ${c.bold('대화 시작 옵션')}`);
@@ -213,6 +262,24 @@ async function main() {
213
262
  continue: flags.continue === true || flags.c === true,
214
263
  sessionId: typeof flags.resume === 'string' ? flags.resume : (flags.resume === true ? null : undefined),
215
264
  });
265
+ /*
266
+ * 에디터가 자식 프로세스로 띄우는 자리 (ACP).
267
+ *
268
+ * 사람이 직접 칠 명령이 아니다. 쳐도 안 죽고 그냥 기다리는데, 그건 규격이
269
+ * 그렇게 정한 것이라 맞다 — 에디터가 표준입력으로 말을 걸어 주기를 기다린다.
270
+ * 왜 아무 반응이 없는지는 표준오류에 적어 둔다.
271
+ */
272
+ case 'acp':
273
+ return acp({
274
+ root: flags.root ? String(flags.root) : undefined,
275
+ mode: flags.mode ? String(flags.mode) : undefined,
276
+ work: flags.work ? String(flags.work) : undefined,
277
+ ctx: flags.ctx ? parseSize(String(flags.ctx)) : undefined,
278
+ maxTokens: flags['max-tokens'] ? parseSize(String(flags['max-tokens'])) : undefined,
279
+ think: flags.think ? String(flags.think) : undefined,
280
+ effort: flags.effort ? String(flags.effort) : undefined,
281
+ offline: flags.offline === true || flags.offline === 'true',
282
+ });
216
283
  case 'status':
217
284
  return showStatus();
218
285
  case 'setup':
@@ -224,6 +291,8 @@ async function main() {
224
291
  return runPack(flags);
225
292
  case 'audit':
226
293
  return runAudit();
294
+ case 'sbom':
295
+ return runSbom(flags);
227
296
  case 'scan':
228
297
  return runScan(flags);
229
298
  case 'sessions':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "1.2.0",
3
+ "version": "1.4.1",
4
4
  "description": "로컬 모델·사내 게이트웨이 전용 코딩 에이전트 CLI — 외부 의존성 0개 / Zero-dependency coding agent CLI for local LLMs and private gateways",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,7 +27,11 @@
27
27
  "offline",
28
28
  "air-gapped",
29
29
  "zero-dependency",
30
- "agent"
30
+ "agent",
31
+ "acp",
32
+ "agent-client-protocol",
33
+ "zed",
34
+ "korean"
31
35
  ],
32
36
  "repository": {
33
37
  "type": "git",
@@ -43,10 +47,11 @@
43
47
  "diagnose": "node bin/deel.js diagnose",
44
48
  "chat": "node bin/deel.js",
45
49
  "test": "node test/run.mjs",
50
+ "docs": "node tools/check-docs.mjs",
46
51
  "verify": "node test/no-bundle.test.js && node test/network.test.js && node test/web.test.js",
47
52
  "bench": "node test/edit-bench.js",
48
53
  "demo": "node test/demo.js",
49
- "check": "node --check bin/deel.js && node --check src/repl.js && node --check src/oneshot.js && node --check src/commands.js && node --check src/agent/loop.js && node --check src/agent/session.js && node --check src/agent/effort.js && node --check src/agent/modes.js && node --check src/agent/route.js && node --check src/agent/mention.js && node --check src/agent/project.js && node --check src/tools/encoding.js && node --check src/tools/excel-com.js && node --check src/tools/excel.js && node --check src/tools/xlsx.js && node --check src/agent/compact.js && node --check src/agent/store.js && node --check src/agent/sessionui.js && node --check src/backend/adapter.js && node --check src/backend/ctxsize.js && node --check src/backend/probe.js && node --check src/backend/detect.js && node --check src/backend/http.js && node --check src/backend/scan.js && node --check src/backend/scanui.js && node --check src/tools/index.js && node --check src/tools/fsutil.js && node --check src/tools/edit-match.js && node --check src/tools/webfetch.js && node --check src/tools/todo.js && node --check src/tools/jobs.js && node --check src/skills/discover.js && node --check src/plugins/manage.js && node --check src/pack/zip.js && node --check src/pack/tar.js && node --check src/pack/selfpack.js && node --check src/safety/guard.js && node --check src/safety/undo.js && node --check src/safety/audit.js && node --check src/safety/network.js && node --check src/setup.js && node --check src/report.js && node --check src/config.js && node --check src/ui/diff.js && node --check src/ui/ansi.js && node --check src/ui/status.js && node --check src/ui/level.js && node --check src/ui/prompt.js && node --check src/ui/spinner.js && node --check src/ui/motion.js && node --check src/preview/serve.js && node --check src/ui/working.js && node --check src/ui/inputbox.js && node --check src/ui/md.js && node --check src/agent/threads.js && node --check src/agent/evolve.js && echo OK",
54
+ "check": "node --check bin/deel.js && node --check src/repl.js && node --check src/oneshot.js && node --check src/commands.js && node --check src/agent/loop.js && node --check src/agent/session.js && node --check src/agent/effort.js && node --check src/agent/modes.js && node --check src/agent/route.js && node --check src/agent/models.js && node --check src/agent/grade.js && node --check src/agent/mention.js && node --check src/agent/project.js && node --check src/tools/encoding.js && node --check src/tools/excel-com.js && node --check src/tools/excel.js && node --check src/tools/xlsx.js && node --check src/tools/docs.js && node --check src/agent/compact.js && node --check src/agent/store.js && node --check src/agent/sessionui.js && node --check src/backend/adapter.js && node --check src/backend/ctxsize.js && node --check src/backend/probe.js && node --check src/backend/detect.js && node --check src/backend/http.js && node --check src/backend/scan.js && node --check src/backend/scanui.js && node --check src/tools/index.js && node --check src/tools/desc.en.js && node --check src/tools/lsp.js && node --check src/lsp/rpc.js && node --check src/lsp/servers.js && node --check src/lsp/client.js && node --check src/lsp/diag.js && node --check src/tools/fsutil.js && node --check src/tools/edit-match.js && node --check src/tools/webfetch.js && node --check src/tools/todo.js && node --check src/tools/jobs.js && node --check src/skills/discover.js && node --check src/plugins/manage.js && node --check src/pack/zip.js && node --check src/pack/tar.js && node --check src/pack/selfpack.js && node --check src/safety/guard.js && node --check src/safety/undo.js && node --check src/safety/audit.js && node --check src/safety/network.js && node --check src/setup.js && node --check src/report.js && node --check src/config.js && node --check src/ui/diff.js && node --check src/ui/ansi.js && node --check src/ui/status.js && node --check src/ui/level.js && node --check src/ui/prompt.js && node --check src/ui/spinner.js && node --check src/ui/motion.js && node --check src/ui/notify.js && node --check src/ui/intro.js && node --check src/ui/banner.js && node --check src/ui/export.js && node --check src/i18n/index.js && node --check src/i18n/ko.js && node --check src/i18n/en.js && node --check src/preview/serve.js && node --check src/ui/working.js && node --check src/ui/inputbox.js && node --check src/ui/md.js && node --check src/agent/threads.js && node --check src/agent/evolve.js && node --check src/agent/pins.js && node --check src/agent/card.js && node --check src/agent/preset.js && node --check src/agent/evidence.js && node --check src/acp/jsonrpc.js && node --check src/acp/map.js && node --check src/acp/serve.js && node --check src/pack/sbom.js && node --check src/safety/secrets.js && echo OK",
50
55
  "coverage": "node test/coverage.mjs",
51
56
  "prepublishOnly": "npm run check && npm test"
52
57
  },
@@ -0,0 +1,230 @@
1
+ // JSON-RPC 2.0 을 줄 단위로 주고받는 관. ACP 가 표준입출력에서 쓰는 그 방식이다.
2
+ //
3
+ // ── 왜 따로 냈나 ────────────────────────────────────────────────────────
4
+ //
5
+ // 이 파일에는 deel 이야기가 한 줄도 없다. 넣을 수 있었지만 안 넣었다.
6
+ //
7
+ // 붙이는 쪽(serve.js)은 진짜 프로세스를 띄우고, 진짜 파이프로 주고받고, 진짜
8
+ // 모델을 부른다. 그 안에서 "덩이가 반 토막 나서 왔을 때 잘 붙이는가" 를 재려면
9
+ // 대화 한 판을 통째로 돌려야 한다. 그러면 검사가 느려지고, 무엇보다 **틀린 자리를
10
+ // 짚어 주지 못한다** — 화면에는 "답이 안 왔습니다" 만 남는다.
11
+ //
12
+ // 관을 따로 떼어 두면 그 자리를 함수 하나로 잴 수 있다. 실제로 이 파일에서
13
+ // 잡은 것들이다:
14
+ //
15
+ // · 한 덩이에 메시지가 두 개 실려 온다 (파이프는 줄 단위로 안 끊어 준다)
16
+ // · 한 메시지가 세 덩이로 쪼개져 온다 (한글이 섞이면 더 잘 그런다)
17
+ // · id 가 0 인 요청 — `if (msg.id)` 로 보면 알림으로 오해한다. ACP 는 0부터 센다
18
+ // · 다루는 함수가 늦게 끝나는 사이에 취소가 들어온다
19
+ //
20
+ // ── 지켜야 하는 것 ──────────────────────────────────────────────────────
21
+ //
22
+ // ACP 규격은 "메시지는 개행으로 나뉘며 **개행을 품어서는 안 된다**" 고 못 박는다.
23
+ // JSON.stringify 는 문자열 안의 개행을 \n 두 글자로 바꾸므로 이 조건은 저절로
24
+ // 지켜진다 — 대신 여기 말고 다른 데서 표준출력에 무언가를 적으면 그 순간
25
+ // 관이 깨진다. 그 자리는 serve.js 가 막는다.
26
+ import { EOL } from 'node:os';
27
+ import { StringDecoder } from 'node:string_decoder';
28
+
29
+ /** 규격이 정한 오류 번호. 우리가 새로 지어내면 클라이언트가 못 알아본다. */
30
+ export const 오류번호 = {
31
+ 파싱: -32700,
32
+ 잘못된요청: -32600,
33
+ 모르는방법: -32601,
34
+ 잘못된인자: -32602,
35
+ 안쪽오류: -32603,
36
+ };
37
+
38
+ /**
39
+ * 이어 붙여 오는 바이트를 줄 단위로 끊어 준다.
40
+ *
41
+ * 왜 직접 만드나: readline 을 쓰면 될 것 같지만, readline 은 스트림 하나를
42
+ * 통째로 물고 자기 방식으로 끝을 판단한다. 검사에서 문자열을 조각내 밀어 넣는
43
+ * 식으로 흉내 내기가 어렵고, 무엇보다 관을 닫는 시점을 우리가 못 정한다.
44
+ * 하는 일이 이만큼이라면 직접 두는 편이 읽기 쉽다.
45
+ *
46
+ * @param {(줄: string) => void} 한줄 완성된 줄 하나가 나올 때마다 불린다
47
+ * @returns {(덩이: Buffer|string) => void}
48
+ */
49
+ export function 줄나누기(한줄) {
50
+ let 남은것 = '';
51
+ /*
52
+ * 덩이마다 toString('utf8') 하면 안 된다.
53
+ *
54
+ * 파이프는 글자 단위가 아니라 바이트 단위로 끊긴다. '녕' 한가운데서 끊기면
55
+ * 앞 덩이의 남은 바이트와 뒤 덩이의 첫 바이트가 각자 U+FFFD 로 바뀐다.
56
+ * JSON 은 여전히 파싱되므로 오류가 안 나고 **글자만 조용히 뭉개진다** —
57
+ * 프롬프트도 파일 내용도 도구 결과도 전부 한글인 이 프로그램에서는
58
+ * 반드시 밟는 자리다. StringDecoder 는 잘린 바이트를 물고 있다가 다음
59
+ * 덩이와 붙여 준다.
60
+ */
61
+ const 해독기 = new StringDecoder('utf8');
62
+ return (덩이) => {
63
+ 남은것 += typeof 덩이 === 'string' ? 덩이 : 해독기.write(덩이);
64
+ /*
65
+ * \r\n 도 받는다.
66
+ *
67
+ * 규격은 \n 만 말하지만, 윈도우에서 다른 언어로 짠 클라이언트가 줄을 쓸 때
68
+ * \r 이 딸려 오는 일이 실제로 있다. 여기서 한 글자 지워 주는 값이
69
+ * "왜 아무 반응이 없지" 를 며칠 파는 값보다 훨씬 싸다.
70
+ */
71
+ let 자리;
72
+ while ((자리 = 남은것.indexOf('\n')) >= 0) {
73
+ const 줄 = 남은것.slice(0, 자리).replace(/\r$/, '');
74
+ 남은것 = 남은것.slice(자리 + 1);
75
+ if (줄.trim()) 한줄(줄); // 빈 줄은 그냥 넘긴다. 오류로 칠 것이 아니다
76
+ }
77
+ };
78
+ }
79
+
80
+ /**
81
+ * 한쪽 끝. 요청을 받아 답하고, 저쪽에 요청을 걸어 답을 기다린다.
82
+ *
83
+ * ACP 에서는 에이전트와 클라이언트가 **둘 다** 요청을 건다. 에이전트가
84
+ * "이 명령을 돌려도 되나" 하고 되묻는 자리가 그것이다. 그래서 이 물건은
85
+ * 서버도 클라이언트도 아니고 그냥 '한쪽 끝' 이다.
86
+ */
87
+ export class 연결 {
88
+ /**
89
+ * @param {object} o
90
+ * @param {(줄: string) => void} o.보내기 한 줄을 저쪽으로 내보낸다 (개행은 여기서 붙인다)
91
+ * @param {(방법: string, 인자: object) => any} o.다루기
92
+ * 저쪽에서 온 요청·알림을 처리한다. 값을 돌려주면 그것이 답이 된다.
93
+ * 모르는 방법이면 모르는방법오류() 를 던진다.
94
+ */
95
+ constructor({ 보내기, 다루기 }) {
96
+ this.보내기줄 = 보내기;
97
+ this.다루기 = 다루기;
98
+ this.다음번호 = 1;
99
+ this.기다리는것 = new Map(); // 번호 → {풀기, 깨기}
100
+ this.닫혔나 = false;
101
+ }
102
+
103
+ #쓰기(덩이) {
104
+ if (this.닫혔나) return;
105
+ // 개행을 여기 한 곳에서만 붙인다. 붙이는 자리가 둘이 되면 언젠가
106
+ // 빈 줄이 하나 더 나가고, 그건 저쪽에서 파싱 오류로 보인다.
107
+ this.보내기줄(JSON.stringify(덩이) + '\n');
108
+ }
109
+
110
+ /** 저쪽에 요청을 걸고 답을 기다린다. */
111
+ 요청(방법, 인자) {
112
+ const id = this.다음번호++;
113
+ return new Promise((풀기, 깨기) => {
114
+ if (this.닫혔나) { 깨기(new Error('관이 닫혔습니다')); return; }
115
+ this.기다리는것.set(id, { 풀기, 깨기 });
116
+ this.#쓰기({ jsonrpc: '2.0', id, method: 방법, params: 인자 ?? {} });
117
+ });
118
+ }
119
+
120
+ /** 답을 안 받는 한마디. */
121
+ 알림(방법, 인자) {
122
+ this.#쓰기({ jsonrpc: '2.0', method: 방법, params: 인자 ?? {} });
123
+ }
124
+
125
+ /**
126
+ * 저쪽에서 온 줄 하나.
127
+ *
128
+ * 일부러 await 하지 않는다. 여기서 기다리면 앞의 요청이 끝날 때까지 다음
129
+ * 줄을 못 읽는데, 그러면 **취소가 영영 안 닿는다** — 취소는 늘 무언가가
130
+ * 돌고 있는 중에 온다. 그게 취소의 정의다.
131
+ */
132
+ 받았다(줄) {
133
+ let 온것;
134
+ try {
135
+ 온것 = JSON.parse(줄);
136
+ } catch {
137
+ // id 를 모르니 null 로 답한다. 규격이 그렇게 하라고 적어 두었다.
138
+ this.#쓰기({ jsonrpc: '2.0', id: null, error: { code: 오류번호.파싱, message: '읽을 수 없는 JSON 입니다' } });
139
+ return;
140
+ }
141
+ if (!온것 || typeof 온것 !== 'object') {
142
+ this.#쓰기({ jsonrpc: '2.0', id: null, error: { code: 오류번호.잘못된요청, message: '객체가 아닙니다' } });
143
+ return;
144
+ }
145
+
146
+ // 저쪽이 우리 요청에 답한 것.
147
+ //
148
+ // 'id' in 온것 으로 본다. id 가 0 일 수 있기 때문이다 — ACP 클라이언트는
149
+ // 실제로 0번부터 센다. 여기서 truthy 로 보면 첫 요청의 답을 통째로 잃는다.
150
+ if ('id' in 온것 && !온것.method) {
151
+ const 기다림 = this.기다리는것.get(온것.id);
152
+ if (!기다림) return; // 이미 포기한 것. 늦게 온 답은 조용히 버린다
153
+ this.기다리는것.delete(온것.id);
154
+ if (온것.error) {
155
+ const e = new Error(온것.error?.message ?? '저쪽에서 오류로 답했습니다');
156
+ e.code = 온것.error?.code;
157
+ e.data = 온것.error?.data;
158
+ 기다림.깨기(e);
159
+ } else {
160
+ 기다림.풀기(온것.result);
161
+ }
162
+ return;
163
+ }
164
+
165
+ if (typeof 온것.method !== 'string') {
166
+ if ('id' in 온것) {
167
+ this.#쓰기({ jsonrpc: '2.0', id: 온것.id, error: { code: 오류번호.잘못된요청, message: 'method 가 없습니다' } });
168
+ }
169
+ return;
170
+ }
171
+
172
+ const 알림인가 = !('id' in 온것) || 온것.id === null;
173
+
174
+ Promise.resolve()
175
+ .then(() => this.다루기(온것.method, 온것.params ?? {}))
176
+ .then((결과) => {
177
+ if (알림인가) return; // 알림에는 답하지 않는다. 답하면 저쪽이 짝 없는 답을 받는다
178
+ this.#쓰기({ jsonrpc: '2.0', id: 온것.id, result: 결과 ?? {} });
179
+ })
180
+ .catch((err) => {
181
+ /*
182
+ * 알림을 다루다 터진 것은 답할 데가 없다.
183
+ *
184
+ * 그렇다고 삼키면 무엇이 터졌는지 아무도 모른다. 표준오류에 적는다 —
185
+ * ACP 는 에이전트가 stderr 에 로그를 적어도 된다고 분명히 허락한다.
186
+ */
187
+ if (알림인가) {
188
+ try { process.stderr.write(`[acp] ${온것.method} 처리 중 오류: ${err?.message ?? err}${EOL}`); } catch { /* 여기서 또 터지면 할 게 없다 */ }
189
+ return;
190
+ }
191
+ this.#쓰기({
192
+ jsonrpc: '2.0',
193
+ id: 온것.id,
194
+ error: {
195
+ code: Number.isInteger(err?.code) ? err.code : 오류번호.안쪽오류,
196
+ message: String(err?.message ?? err ?? '알 수 없는 오류'),
197
+ ...(err?.data !== undefined ? { data: err.data } : {}),
198
+ },
199
+ });
200
+ });
201
+ }
202
+
203
+ /**
204
+ * 관을 닫는다. 아직 답을 기다리던 요청은 전부 깨뜨린다.
205
+ *
206
+ * 안 깨뜨리면 그 프라미스를 붙들고 있던 자리가 영영 안 끝난다. 사람 눈에는
207
+ * "에디터가 멈췄다" 로 보인다 — 원인은 이미 죽은 프로세스라 어디에도 안 남는다.
208
+ */
209
+ 닫기(왜 = '관이 닫혔습니다') {
210
+ this.닫혔나 = true;
211
+ for (const [, 기다림] of this.기다리는것) {
212
+ try { 기다림.깨기(new Error(왜)); } catch { /* 이미 정리된 것 */ }
213
+ }
214
+ this.기다리는것.clear();
215
+ }
216
+ }
217
+
218
+ /** 모르는 방법이라고 답하게 하는 오류. 다루기 함수가 이걸 던지면 된다. */
219
+ export function 모르는방법오류(방법) {
220
+ const e = new Error(`모르는 방법입니다: ${방법}`);
221
+ e.code = 오류번호.모르는방법;
222
+ return e;
223
+ }
224
+
225
+ /** 인자가 틀렸다고 답하게 하는 오류. */
226
+ export function 잘못된인자오류(무엇) {
227
+ const e = new Error(무엇);
228
+ e.code = 오류번호.잘못된인자;
229
+ return e;
230
+ }
package/src/acp/map.js ADDED
@@ -0,0 +1,219 @@
1
+ // deel 이 흘리는 것을 ACP 가 아는 모양으로 옮긴다.
2
+ //
3
+ // ── 왜 옮기는 자리를 따로 두나 ──────────────────────────────────────────
4
+ //
5
+ // 붙이는 일의 값어치는 대부분 여기 있다. 관을 잇는 것은 한 시간이면 되지만,
6
+ // **에디터가 무엇을 보여 줄 수 있는가** 는 전부 이 표에서 갈린다.
7
+ //
8
+ // 갈래(kind)를 안 주면 → 전부 똑같은 회색 점으로 그려진다
9
+ // 자리(locations)를 안 주면 → 고친 파일을 눌러도 안 열린다
10
+ // 상태(status)를 안 주면 → 도는 중인지 끝났는지 안 보인다
11
+ //
12
+ // 이 셋은 있어도 없어도 규격에는 안 걸린다. 그래서 대충 붙인 구현은 죄다
13
+ // 안 준다. 여기서만 챙기면 같은 프로토콜을 쓰고도 화면이 달라진다.
14
+ //
15
+ // ── 순수하게 둔다 ───────────────────────────────────────────────────────
16
+ //
17
+ // 이 파일은 아무것도 안 부르고 아무 데도 안 쓴다. 값을 넣으면 값이 나온다.
18
+ // 그래야 진짜 에디터 없이 검사할 수 있다 — 붙인 것이 맞는지 확인하려고
19
+ // Zed 를 띄워야 한다면 아무도 확인 안 하게 된다.
20
+
21
+ /**
22
+ * 도구 이름 → ACP 갈래.
23
+ *
24
+ * 규격이 정한 낱말만 쓴다: read·edit·delete·move·search·execute·think·fetch·
25
+ * switch_mode·other. 모르는 것은 other 다 — 지어내면 클라이언트가 못 알아본다.
26
+ */
27
+ const 갈래표 = {
28
+ Read: 'read',
29
+ Outline: 'read',
30
+ Write: 'edit',
31
+ Append: 'edit',
32
+ Edit: 'edit',
33
+ Glob: 'search',
34
+ Grep: 'search',
35
+ Recall: 'search',
36
+ Bash: 'execute',
37
+ Jobs: 'execute',
38
+ Verify: 'execute',
39
+ WebFetch: 'fetch',
40
+ Task: 'think',
41
+ TodoWrite: 'think',
42
+ Skill: 'think',
43
+ Remember: 'other',
44
+ };
45
+
46
+ export function 도구갈래(이름) {
47
+ return 갈래표[String(이름 ?? '')] ?? 'other';
48
+ }
49
+
50
+ /**
51
+ * 사람이 읽을 한 줄.
52
+ *
53
+ * `Read` 만 적으면 열 줄이 전부 `Read` 다. 무엇을 읽었는지가 빠지면 목록을
54
+ * 훑어보는 뜻이 없어진다 — 그럴 거면 아예 안 보여 주는 편이 낫다.
55
+ */
56
+ export function 도구이름표(이름, 인자) {
57
+ const a = 인자 ?? {};
58
+ const 첫 = a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ?? a.목적
59
+ ?? (a.command ? String(a.command).replace(/\s+/g, ' ') : null)
60
+ ?? (Array.isArray(a.files) && a.files.length
61
+ ? `${a.files[0]?.file_path ?? '?'}${a.files.length > 1 ? ` 외 ${a.files.length - 1}개` : ''}`
62
+ : null)
63
+ ?? (Array.isArray(a.edits) && a.edits.length
64
+ ? `${a.edits[0]?.file_path ?? '?'}${a.edits.length > 1 ? ` 외 ${a.edits.length - 1}군데` : ''}`
65
+ : null)
66
+ ?? (Array.isArray(a.paths) && a.paths.length ? `${a.paths.length}개` : null)
67
+ ?? (Array.isArray(a.todos) ? `${a.todos.length}건` : null);
68
+ const 안 = 첫 == null ? '' : 자르기(String(첫), 80);
69
+ return 안 ? `${이름}(${안})` : String(이름 ?? '도구');
70
+ }
71
+
72
+ /**
73
+ * 이 호출이 건드린 파일 자리.
74
+ *
75
+ * 결과에 실린 실제 경로(changed)를 먼저 본다. 인자에 적힌 것은 상대 경로일 수
76
+ * 있는데, 에디터는 절대 경로라야 연다. 인자만 보고 넘기면 눌러도 안 열리는
77
+ * 링크가 되고, 그건 없느니만 못하다.
78
+ */
79
+ export function 도구자리(이름, 인자, 결과) {
80
+ const 모은것 = [];
81
+ const 넣기 = (p) => {
82
+ const s = typeof p === 'string' ? p.trim() : '';
83
+ if (s && !모은것.includes(s)) 모은것.push(s);
84
+ };
85
+
86
+ 넣기(결과?.changed);
87
+ for (const x of 결과?.여럿 ?? []) 넣기(x?.changed);
88
+
89
+ const a = 인자 ?? {};
90
+ 넣기(a.file_path);
91
+ 넣기(a.path);
92
+ for (const f of Array.isArray(a.files) ? a.files : []) 넣기(f?.file_path);
93
+ for (const e of Array.isArray(a.edits) ? a.edits : []) 넣기(e?.file_path);
94
+ for (const p of Array.isArray(a.paths) ? a.paths : []) 넣기(p);
95
+
96
+ return 모은것.slice(0, 20).map((path) => ({ path }));
97
+ }
98
+
99
+ /**
100
+ * 도구가 실제로 탈이 났는가.
101
+ *
102
+ * `error` 만 보면 안 된다. Bash 는 종료코드를, Verify 는 "탈 2개" 를 요약에
103
+ * 담아 돌려준다 — 그것들을 성공으로 칠하면 화면에서 성공과 구별되지 않는다.
104
+ * `deel run` 쪽에서 이미 한 번 데인 자리라 여기서도 같은 눈으로 본다.
105
+ */
106
+ export function 도구탈났나(결과) {
107
+ return !!(결과?.error || 결과?.failed);
108
+ }
109
+
110
+ /**
111
+ * 도구 결과를 ACP 가 그릴 수 있는 내용으로.
112
+ *
113
+ * 모델에게 가는 본문을 그대로 실으면 안 된다. 파일 하나를 읽어도 수만 자가
114
+ * 오는데, 그것이 전부 에디터 창으로 흘러가면 사람이 아무것도 못 읽는다.
115
+ * 보여 줄 만큼만 자른다 — 모델이 받는 양은 이것과 무관하게 그대로다.
116
+ */
117
+ export function 도구내용(결과, 최대 = 2000) {
118
+ const r = 결과 ?? {};
119
+ const 글 = r.error
120
+ ? String(r.error)
121
+ : (r.content != null ? String(r.content) : (r.summary != null ? String(r.summary) : ''));
122
+ if (!글.trim()) return [];
123
+ return [{ type: 'content', content: { type: 'text', text: 자르기(글, 최대) } }];
124
+ }
125
+
126
+ /**
127
+ * 한 걸음 끝난 도구 호출을 ACP 한 덩이로.
128
+ *
129
+ * @param {string} 아이디 이 세션 안에서 유일한 번호
130
+ * @param {object} ev loop.js 가 흘린 `tool` 이벤트
131
+ */
132
+ export function 도구끝남(아이디, ev) {
133
+ return {
134
+ sessionUpdate: 'tool_call_update',
135
+ toolCallId: 아이디,
136
+ title: 도구이름표(ev?.name, ev?.args),
137
+ kind: 도구갈래(ev?.name),
138
+ status: 도구탈났나(ev?.result) ? 'failed' : 'completed',
139
+ content: 도구내용(ev?.result),
140
+ locations: 도구자리(ev?.name, ev?.args, ev?.result),
141
+ };
142
+ }
143
+
144
+ /** 이제 막 시작한 도구 호출. */
145
+ export function 도구시작(아이디, 이름, 인자) {
146
+ return {
147
+ sessionUpdate: 'tool_call',
148
+ toolCallId: 아이디,
149
+ title: 도구이름표(이름, 인자),
150
+ kind: 도구갈래(이름),
151
+ status: 'in_progress',
152
+ content: [],
153
+ locations: 도구자리(이름, 인자, null),
154
+ };
155
+ }
156
+
157
+ /**
158
+ * deel 이 턴을 끝낸 까닭 → ACP 가 아는 낱말.
159
+ *
160
+ * 규격이 가진 낱말은 다섯뿐이다: end_turn·max_tokens·max_turn_requests·
161
+ * refusal·cancelled. deel 의 '헛돎' 은 여기 딱 맞는 것이 없다.
162
+ *
163
+ * refusal 로 보내고 싶은 마음이 들지만 그러면 안 된다 — 규격은 refusal 일 때
164
+ * "그 사용자 말과 그 뒤의 것은 다음 프롬프트에 넣지 말라" 고 적어 두었다.
165
+ * deel 은 헛돌았을 때 대화를 버리지 않는다. 그래서 end_turn 으로 보내고,
166
+ * **왜 멈췄는지는 말로 따로 흘려 준다**. 낱말이 안 맞으면 낱말을 억지로 맞추는
167
+ * 대신 사람이 읽을 것을 준다.
168
+ */
169
+ export function 멈춘까닭(까닭) {
170
+ switch (까닭) {
171
+ case 'aborted': return 'cancelled';
172
+ case 'limit': return 'max_turn_requests';
173
+ case 'stuck': return 'end_turn';
174
+ default: return 'end_turn';
175
+ }
176
+ }
177
+
178
+ /**
179
+ * 프롬프트로 온 덩이들에서 글만 뽑는다.
180
+ *
181
+ * resource 는 알맹이가 실려 오므로 그대로 쓴다. resource_link 는 주소만 오는데,
182
+ * 그래도 주소를 적어 준다 — 모델이 그 자리를 Read 로 열어 볼 수 있다.
183
+ * 그림·소리는 읽을 방법이 없다. 조용히 버리지 않고 무엇을 못 읽었는지 적는다.
184
+ */
185
+ export function 프롬프트글(덩이들) {
186
+ const 조각 = [];
187
+ for (const b of Array.isArray(덩이들) ? 덩이들 : []) {
188
+ if (!b || typeof b !== 'object') continue;
189
+ switch (b.type) {
190
+ case 'text':
191
+ if (typeof b.text === 'string' && b.text) 조각.push(b.text);
192
+ break;
193
+ case 'resource': {
194
+ const r = b.resource ?? {};
195
+ if (typeof r.text === 'string' && r.text) {
196
+ 조각.push(`--- ${r.uri ?? '붙임'} ---\n${r.text}`);
197
+ } else if (r.uri) {
198
+ 조각.push(`(붙임: ${r.uri} — 글이 아니라 못 읽었습니다)`);
199
+ }
200
+ break;
201
+ }
202
+ case 'resource_link':
203
+ if (b.uri) 조각.push(`(붙임: ${b.uri})`);
204
+ break;
205
+ case 'image':
206
+ case 'audio':
207
+ 조각.push(`(${b.type === 'image' ? '그림' : '소리'}이 붙어 왔지만 이 모델로는 못 읽습니다)`);
208
+ break;
209
+ default:
210
+ break;
211
+ }
212
+ }
213
+ return 조각.join('\n\n').trim();
214
+ }
215
+
216
+ function 자르기(s, n) {
217
+ const t = String(s ?? '');
218
+ return t.length > n ? `${t.slice(0, n)}\n… (${t.length - n}자 줄임)` : t;
219
+ }