deel-local-cli 1.17.9 → 1.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/README.ko.md CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  [![Node.js CI](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/test.yml?branch=main&logo=github&logoColor=white&label=Node.js%20CI)](https://github.com/jysvai/deel-local-cli/actions/workflows/test.yml)
22
22
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/codeql.yml?branch=main&logo=github&logoColor=white&label=CodeQL)](https://github.com/jysvai/deel-local-cli/actions/workflows/codeql.yml)
23
- [![tests](https://img.shields.io/badge/tests-8%2C140%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-8%2C404%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
24
24
 
25
25
  [![dependencies](https://img.shields.io/badge/dependencies-0-1a7f37)](https://www.npmjs.com/package/deel-local-cli?activeTab=dependencies)
26
26
  [![ESM](https://img.shields.io/badge/ESM-Node%2020%2B-5FA04E?logo=javascript&logoColor=white)](package.json)
@@ -301,7 +301,7 @@ deel --offline
301
301
  무엇이 어디로 갈 수 있는지는 켤 때 화면 맨 위에 늘 적혀 있습니다.
302
302
 
303
303
  ```
304
- deel 1.17.9 ⌂ 이 안
304
+ deel 1.18.0 ⌂ 이 안
305
305
  보냄 이 컴퓨터 안 127.0.0.1:11434 ← 여기 말고는 어디로도 안 갑니다
306
306
  ```
307
307
 
@@ -507,6 +507,7 @@ LM Studio 는 `/api/v0/models`, llama.cpp 는 `/props`. 못 알아보면 `(추
507
507
  | `/pin <말>` | 못 박기 — 접거나 요약해도 **안 지워지는** 말 |
508
508
  | `/evidence [파일]` | 증거 — 무엇을 바꿨고 무엇이 그걸 증명하나. **안 된 것도** 적습니다 |
509
509
  | `/commit [전부\|미리보기\|제목]` | 이번 대화가 바꾼 것만 담아 커밋. 메시지는 diff 와 증거에서. **push 는 안 합니다** |
510
+ | `/review` | 바꿔 둔 것을 **새 창에서** 한 번 더 보기 — 찾은 것만 말하고 아무것도 안 고칩니다 |
510
511
  | `/model` | 연결·모델 바꾸기 |
511
512
  | `/model 카드` | 이 모델을 겪어 본 결과 — 그래서 deel 이 무엇을 바꿔 뒀는지 |
512
513
  | `/think <강도>` | 추론 강도 (`off·low·medium·high·xhigh·max`) — 창구가 안 받는 칸은 받는 값으로 낮춰 보냅니다 |
@@ -522,6 +523,7 @@ LM Studio 는 `/api/v0/models`, llama.cpp 는 `/props`. 못 알아보면 `(추
522
523
  | `/undo [턴수]` | 파일 변경 되돌리기 |
523
524
  | `/diff [파일]` | 이번 대화에서 바뀐 파일 · 바뀐 자리 보기 |
524
525
  | `/preview [폴더\|파일\|off]` | 만든 웹을 이 자리에서 띄워 보기 — 브라우저가 같이 열립니다 |
526
+ | `/paste` | 클립보드에 든 화면 캡처 붙이기 (캡처 → `/paste`) |
525
527
  | `/tools` | 쓸 수 있는 도구 |
526
528
  | `/skills [검색어\|all\|off]` | 스킬 보기·검색·골라 올리기 |
527
529
  | `/plugin [install\|remove\|pack]` | 플러그인 관리 |
@@ -535,7 +537,7 @@ LM Studio 는 `/api/v0/models`, llama.cpp 는 `/props`. 못 알아보면 `(추
535
537
  | `/hooks` | 적어 둔 훅 보기 (어디서 읽었고 언제 도는지) |
536
538
  | `/agents` | 이름 붙인 하위 작업 보기 |
537
539
  | `/init` | `DEEL.md` 규칙 파일 만들기 |
538
- | `/exit` | 끝내기 |
540
+ | `/exit` `/quit` | 끝내기 |
539
541
 
540
542
  `/scan` 과 `/sessions` 는 나가지 않고도 씁니다. 로컬 서버를 새로 켰거나 모델을
541
543
  바꿔 올렸을 때 `/scan save` → `/model` 두 번이면 대화를 이어둔 채로 갈아탑니다.
@@ -1287,6 +1289,7 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
1287
1289
 
1288
1290
  | 판 | 무엇이 바뀌었나 |
1289
1291
  |---|---|
1292
+ | [1.18.0](docs/ko/releases/1.18.md#1180) | 있는 것과 걸리는 것은 다릅니다 — 검사 8,404개가 초록인 채로 열려 있던 자리들 |
1290
1293
  | [1.17.9](docs/ko/releases/1.17.md#1179) | 한도에 걸렸는데 1초 뒤에 또 두드리고 있었습니다 |
1291
1294
  | [1.17.8](docs/ko/releases/1.17.md#1178) | 긴 지시문을 통째로 붙여 넣으면 시킨 것과 반대로 갔습니다 — 그리고 망이 한 번 끊기면 턴이 사라졌습니다 |
1292
1295
  | [1.17.7](docs/ko/releases/1.17.md#1177) | 나란히 세워 재 봤고, 진 항목을 고쳤습니다 |
package/README.md CHANGED
@@ -20,7 +20,7 @@ Vendor APIs connect too — **only when you say so**
20
20
 
21
21
  [![Node.js CI](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/test.yml?branch=main&logo=github&logoColor=white&label=Node.js%20CI)](https://github.com/jysvai/deel-local-cli/actions/workflows/test.yml)
22
22
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/codeql.yml?branch=main&logo=github&logoColor=white&label=CodeQL)](https://github.com/jysvai/deel-local-cli/actions/workflows/codeql.yml)
23
- [![tests](https://img.shields.io/badge/tests-8%2C140%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-8%2C404%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
24
24
 
25
25
  [![dependencies](https://img.shields.io/badge/dependencies-0-1a7f37)](https://www.npmjs.com/package/deel-local-cli?activeTab=dependencies)
26
26
  [![ESM](https://img.shields.io/badge/ESM-Node%2020%2B-5FA04E?logo=javascript&logoColor=white)](package.json)
@@ -374,7 +374,7 @@ deel --offline
374
374
  The destination is printed at the top of every session:
375
375
 
376
376
  ```
377
- deel 1.17.9 ⌂ inside
377
+ deel 1.18.0 ⌂ inside
378
378
  Sends to this machine 127.0.0.1:11434 ← nowhere else
379
379
  ```
380
380
 
@@ -572,6 +572,7 @@ Names follow Claude Code / Codex conventions.
572
572
  | `/pin <text>` | Pin a line — folding and compaction **cannot reach it** |
573
573
  | `/evidence [file]` | Evidence — what changed, and what proves it. **What is unproven is listed too** |
574
574
  | `/commit [all\|preview\|title]` | Commits only what this session changed; message from the diff and the evidence. **Never pushes** |
575
+ | `/review` | Re-reads what you changed **in a fresh context** — reports findings only, changes nothing |
575
576
  | `/model` | Switch connection / model |
576
577
  | `/model card` | Model card — what this model has actually done here, and what deel changed because of it |
577
578
  | `/think <level>` | Reasoning level (`off·low·medium·high·xhigh·max`) — a rung this endpoint does not accept is lowered to one it does |
@@ -587,6 +588,7 @@ Names follow Claude Code / Codex conventions.
587
588
  | `/undo [turns]` | Revert file changes |
588
589
  | `/diff [file]` | Files changed this session, and the changed lines |
589
590
  | `/preview [folder\|file\|off]` | Serve what you built, right here — a browser opens with it |
591
+ | `/paste` | Attach the screenshot sitting in the clipboard (capture, then `/paste`) |
590
592
  | `/tools` | Available tools |
591
593
  | `/skills [query\|all\|off]` | Browse, search, load skills |
592
594
  | `/plugin [install\|remove\|pack]` | Manage plugins |
@@ -600,7 +602,7 @@ Names follow Claude Code / Codex conventions.
600
602
  | `/hooks` | Configured hooks — where they came from, when they run |
601
603
  | `/agents` | Named subagent definitions |
602
604
  | `/init` | Create a `DEEL.md` rules file |
603
- | `/exit` | Quit |
605
+ | `/exit` `/quit` | Quit |
604
606
 
605
607
  Discovered plugin commands are invoked as `/<plugin>:<name>`, with `$ARGUMENTS` substituted.
606
608
 
@@ -1348,6 +1350,7 @@ so one run tells you everything.
1348
1350
 
1349
1351
  | Version | What changed |
1350
1352
  |---|---|
1353
+ | [1.18.0](docs/en/releases/1.18.md#1180) | Having a rule is not the same as the rule firing — what was open while 8,404 checks stayed green |
1351
1354
  | [1.17.9](docs/en/releases/1.17.md#1179) | Rate-limited, and knocking again one second later |
1352
1355
  | [1.17.8](docs/en/releases/1.17.md#1178) | A long brief got the opposite of what it asked for — and one network blip threw the turn away |
1353
1356
  | [1.17.7](docs/en/releases/1.17.md#1177) | We measured it side by side, and fixed the row we lost |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "1.17.9",
3
+ "version": "1.18.0",
4
4
  "description": "A coding-agent CLI that runs on local models and private gateways. Zero dependencies, Node 20+.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,6 +54,7 @@
54
54
  "check": "node tools/check-src.mjs",
55
55
  "coverage": "node test/coverage.mjs",
56
56
  "mutate": "node tools/mutate.mjs",
57
+ "review2": "node tools/review2.mjs",
57
58
  "prepublishOnly": "npm run check && npm test"
58
59
  },
59
60
  "dependencies": {},
package/src/agent/loop.js CHANGED
@@ -986,9 +986,13 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
986
986
  * `low` 에서도 한 칸 더 내려간다 — `off` 로.
987
987
  *
988
988
  * 여태 여기서 멈춰 있었다. 그런데 지금은 「생각이 실제로 자리를 먹었을
989
- * 때만 다시 부른다」 이므로, low 인데 생각이 1,024토큰어치 자리를 먹은
990
- * 판에서 다시 부르기를 거절하는 것은 앞뒤가 안 맞는다. 되찾을 자리가
991
- * 있다고 재 놓고 안 되찾는 셈이다.
989
+ * 때만 다시 부른다」 이므로, low 인데 **한 칸 낮추면 1,024토큰어치를
990
+ * 되찾는** 판에서 다시 부르기를 거절하는 것은 앞뒤가 안 맞는다.
991
+ * 되찾을 자리가 있다고 재 놓고 안 되찾는 셈이다.
992
+ *
993
+ * (되찾는 자리가 1,024 라는 말이지 생각이 1,024 를 먹었다는 말이 아니다 —
994
+ * 아래 `생각이자리를먹었나` 는 `생각몫 × 되찾을몫(0.4) >= 1024` 이라
995
+ * 생각이 2,560 토큰은 먹었어야 참이 된다.)
992
996
  *
993
997
  * 이 턴 하나만 끈다. 다음 걸음은 사람이 정한 눈금으로 되돌아간다.
994
998
  */
@@ -16,7 +16,31 @@
16
16
  // 낱말과 점수. 큰 점수일수록 그 모드라는 증거가 강하다는 뜻이다.
17
17
  //
18
18
  // 한국어를 먼저 두되 영어도 같이 본다 — 오류 메시지를 그대로 붙여넣는 일이 흔하다.
19
- const 표 = {
19
+ /*
20
+ * 검사가 이 표를 그대로 읽는다 (test/자동모드.test.js).
21
+ *
22
+ * 규칙을 넣기만 하고 그게 실제로 걸리는지는 아무도 안 본다 — 안 걸리는 규칙은
23
+ * 없는 것과 같고, 그런데도 화면은 멀줦하다(다음 규칙이 받아 그럴듯한 모드를 낸다).
24
+ * 그래서 밖으로 내놓고, 규칙 하나하나가 실제 문장에 걸리는지를 재게 한다.
25
+ */
26
+ /*
27
+ * ── 「… 좀 해줘」 의 빈칸 ────────────────────────────────────────────────
28
+ *
29
+ * 낱말 뒤에 붙는 토씨를 `점검(해|좀|을|이)` 처럼 **붙여서만** 봤다. 그런데
30
+ * 한국말에서 부탁할 때는 그 사이가 거의 언제나 벌어진다 —
31
+ *
32
+ * "점검 좀 해줘" · "설명 좀 해줘" · "수정 좀 해줘" · "구현 좀 해줘"
33
+ *
34
+ * 이 꼴이 전부 **하나도 안 걸려서** 종합 모드에 그대로 남았다. 규칙이 안
35
+ * 걸리면 아무 데서도 안 터지므로(점수만 안 오른다) 여태 아무도 몰랐다.
36
+ * 자동모드.test.js 의 죽은 규칙 검사가 「이 규칙에 걸리는 실제 문장이 없다」
37
+ * 로 잡아 줬다.
38
+ *
39
+ * 그래서 '좀' **앞에만** 빈칸을 열어 둔다(`\s*좀`). 토씨 전체에 열면
40
+ * "점검 이력" 이 「점검 이」 로 걸리는 식으로 넓어진다 — 가르는 자리가
41
+ * 미묘해서, 여는 것도 좁게 연다.
42
+ */
43
+ export const 표 = {
20
44
  debug: [
21
45
  // 고장은 신호가 뚜렷하다. 그리고 디버그는 파일을 고칠 수 있으므로
22
46
  // 잘못 골라도 막히지 않는다. 그래서 세게 준다.
@@ -33,7 +57,7 @@ const 표 = {
33
57
  [/왜\s*(못|안)/, 3], [/뭐\s*때문(이|에)/, 3],
34
58
  // 원인을 짚으라는 말은 점검이 아니라 디버그다 — 증상이 이미 있다는 뜻이다.
35
59
  // 이게 없으면 "오류 원인 분석해줘" 가 둘 사이에서 비겨 종합에 그대로 남는다.
36
- [/원인(을|이|은)?\s*(찾|짚|파악|밝|알아|분석|살펴|봐)/, 4],
60
+ [/원인(을|이|은)?\s*(?:좀\s*)?(찾|짚|파악|밝|알아|분석|살펴|봐)/, 4],
37
61
  ],
38
62
 
39
63
  plan: [
@@ -72,8 +96,8 @@ const 표 = {
72
96
  * 파일을 못 고쳐서 사람이 막히므로, 문턱은 읽기 전용답게 높인다.
73
97
  */
74
98
  inspect: [
75
- [/점검(해|좀|을|이)/, 5], [/분석(해|좀|을|이)/, 5],
76
- [/검토(해|좀|를)/, 5], [/리뷰(해|좀)?/, 5],
99
+ [/점검(해|\s*좀|을|이)/, 5], [/분석(해|\s*좀|을|이)/, 5],
100
+ [/검토(해|\s*좀|를)/, 5], [/리뷰(해|\s*좀)?/, 5],
77
101
  // '감사합니다' 는 안 걸린다 — '합' 은 '해·하' 가 아니다.
78
102
  [/감사(를)?\s*(해|하)/, 4], [/훑어\s*(봐|보고|줘)/, 4],
79
103
  [/취약(점|한|성)/, 5], [/보안\s*(점검|검토|문제|취약|구멍)/, 5],
@@ -103,7 +127,7 @@ const 표 = {
103
127
  */
104
128
  [/뭐\s*하는\s*(파일|함수|코드|모듈|클래스|녀석|건|거|것)/, 5],
105
129
  [/뭐\s*하(는|나|니|냐)/, 3], [/무슨\s*(일|역할)/, 3],
106
- [/설명(해|좀|을)/, 5], [/알려\s*(줘|주세요|줄래)/, 3],
130
+ [/설명(해|\s*좀|을)/, 5], [/알려\s*(줘|주세요|줄래)/, 3],
107
131
  [/어떻게\s*(동작|작동|돌아가)/, 5],
108
132
  [/무슨\s*(뜻|의미)|의미(가|는)\s*뭐/, 5],
109
133
  [/차이(가|는|점)/, 3],
@@ -122,20 +146,20 @@ const 표 = {
122
146
  [/끝까지/, 3], [/마무리까지/, 3],
123
147
  [/여러\s*(개|군데|파일|단계|곳)/, 3],
124
148
  [/순서대로/, 3],
125
- [/통일(해|시켜|좀)|일괄/, 2],
149
+ [/통일(해|시켜|\s*좀)|일괄/, 2],
126
150
  [/흩어진|여기저기/, 2],
127
151
  [/\bend[- ]?to[- ]?end\b/i, 3],
128
152
  ],
129
153
 
130
154
  code: [
131
- [/고쳐|고쳐줘|수정(해|좀)/, 4],
132
- [/만들어|추가(해|좀)|넣어(줘|주세요)/, 4],
133
- [/구현(해|좀)/, 5],
134
- [/바꿔(줘|주세요)?|변경(해|좀)/, 3],
135
- [/지워(줘)?|삭제(해|좀)/, 3],
155
+ [/고쳐|고쳐줘|수정(해|\s*좀)/, 4],
156
+ [/만들어|추가(해|\s*좀)|넣어(줘|주세요)/, 4],
157
+ [/구현(해|\s*좀)/, 5],
158
+ [/바꿔(줘|주세요)?|변경(해|\s*좀)/, 3],
159
+ [/지워(줘)?|삭제(해|\s*좀)/, 3],
136
160
  [/이름\s*바꿔|리네임|rename/i, 3],
137
161
  [/옮겨(줘)?/, 2],
138
- [/리팩터(링)?(해|좀)/, 3],
162
+ [/리팩터(링)?(해|\s*좀)/, 3],
139
163
  [/\bimplement\b|\badd\b|\bfix\b|\bwrite\b/i, 2],
140
164
  [/써(줘|주세요)/, 3],
141
165
  ],
@@ -285,7 +309,7 @@ export function 겹친요청(text) {
285
309
  * 하나는 읽고 말하라는 말이고 하나는 고치라는 말이다. 그래서 어미가 아니라
286
310
  * **동사 목록**으로 본다. 목록에 없으면 손대라는 말이 아니다.
287
311
  */
288
- const 손대는동사 = '만들|고쳐|고치|바꿔|바꾸|추가|넣어|지워|삭제|옮겨|나눠|나누|합쳐|정리|개선|구현|작성|적용|설치|배포|리팩터링|리팩터|분리|통일|세팅|갈아|손봐|손보|수정';
312
+ export const 손대는동사 = '만들|고쳐|고치|바꿔|바꾸|추가|넣어|지워|삭제|옮겨|나눠|나누|합쳐|정리|개선|구현|작성|적용|설치|배포|리팩터링|리팩터|분리|통일|세팅|갈아|손봐|손보|수정';
289
313
  /*
290
314
  * 동사 바로 뒤에 시킴꼴이 붙어야 한다. 빈칸을 안 넘으므로 "나누는 설계만 봐줘"
291
315
  * 는 안 걸린다 — 그건 나누라는 말이 아니라 나누는 안을 보자는 말이다.
@@ -306,10 +330,34 @@ const 손대는동사 = '만들|고쳐|고치|바꿔|바꾸|추가|넣어|지워
306
330
  * 붙어 있나로 그 둘이 갈린다.
307
331
  * 「만들어라」 는 언제나 만들라는 말이다.
308
332
  */
309
- const 손대라는말 = new RegExp(`(${손대는동사})\\S{0,3}(줘|주세요|주라|해라|하라|하자|해다오|해줄래|할래|어라|아라|여라|라(?=[\s.!?~,]|$))`);
333
+ /*
334
+ * ── 여기 백슬래시 하나가 빠져 있었다 ────────────────────────────────────
335
+ *
336
+ * 이 무늬는 **템플릿 글**로 짓는다(동사 목록을 끼워 넣어야 해서). 그런데
337
+ * 템플릿 글 안에서 `\s` 는 정규식에 닿기 전에 자바스크립트가 먼저 먹는다 —
338
+ * 모르는 이스케이프라 백슬래시를 떼고 **글자 `s`** 만 남긴다.
339
+ *
340
+ * 적은 것 ...|라(?=[\s.!?~,]|$))
341
+ * 닿은 것 ...|라(?=[s.!?~,]|$)) ← 빈칸이 아니라 알파벳 s
342
+ *
343
+ * 그래서 맨 「라」 뒤에 **빈칸이나 줄바꿈이 오면 안 걸렸다.** 걸리는 것은
344
+ * 「라」 가 글 맨 끝이거나 바로 뒤에 . ! ? ~ , 가 붙을 때뿐이었다.
345
+ *
346
+ * "그것 좀 바꿔라" 걸림 (글 끝)
347
+ * "바꿔라 지금 해줘" 안 걸림 ← 여러 마디로 시키면 거의 다 이쪽이다
348
+ * "지워라 그리고 저장해" 안 걸림
349
+ *
350
+ * 위 주석이 「사람은 고쳐 달라고 세 번 적었는데 아무것도 안 바뀐 화면을
351
+ * 받는다」 고 적어 둔 바로 그 고장이, 고쳐 놓고도 백슬래시 하나 때문에
352
+ * 그대로 남아 있었다. `\\s` 로 적어야 정규식이 `\s` 를 받는다.
353
+ *
354
+ * (`\\S{0,3}` 는 처음부터 두 개라 멀쩡했다. 한 무늬 안에서 한쪽만 틀렸으니
355
+ * 눈으로는 더 안 보인다.)
356
+ */
357
+ const 손대라는말 = new RegExp(`(${손대는동사})\\S{0,3}(줘|주세요|주라|해라|하라|하자|해다오|해줄래|할래|어라|아라|여라|라(?=[\\s.!?~,]|$))`);
310
358
 
311
359
  // 파일을 못 고치는 모드. modes.js 에서 쓰기 도구를 안 주는 것들과 같아야 한다.
312
- const 읽기만하는모드 = new Set(['architect', 'plan', 'ask', 'inspect']);
360
+ export const 읽기만하는모드 = new Set(['architect', 'plan', 'ask', 'inspect']);
313
361
 
314
362
  /*
315
363
  * ── 영어로 시킨 말은 하나도 못 보고 있었다 ──────────────────────────────
@@ -249,7 +249,8 @@ export function buildBody(shape, { model, messages, tools, stream, json, think,
249
249
  const 박은것 = 나머지.length ? 메시지표식(나머지, 칸) : 나머지;
250
250
  if (시킴) {
251
251
  const 나눔 = 시킴[조각표];
252
- const 블록 = 시스템블록(Array.isArray(나눔) && 나눔.length > 1 ? 나눔 : [시킴.content], true, 칸);
252
+ const 블록 = 시스템블록(Array.isArray(나눔) && 나눔.length > 1 ? 나눔 : [시킴.content], true, 칸,
253
+ { 긴수명: 카드?.긴수명 !== false });
253
254
  대화 = [Array.isArray(블록) ? { ...시킴, content: 블록 } : 시킴, ...박은것];
254
255
  } else {
255
256
  대화 = 박은것;
@@ -414,7 +415,7 @@ function anthropic몸({ model, messages, tools, stream, json, think, maxTokens,
414
415
  const body = { model, messages: 대화, stream: !!stream, max_tokens: maxTokens };
415
416
  if (머리말.length) {
416
417
  body.system = 표식쓰나
417
- ? 시스템블록(조각들 ?? 머리말, true, )
418
+ ? 시스템블록(조각들 ?? 머리말, true, 칸, { 긴수명: 카드?.긴수명 !== false })
418
419
  : 머리말.join('\n\n');
419
420
  }
420
421
 
@@ -857,11 +858,6 @@ export function 본문글(m) {
857
858
  .join('\n');
858
859
  }
859
860
 
860
- /** 이 메시지가 도구를 불렀나. */
861
- export function 도구불렀나(m) {
862
- return 부른것들(m).length > 0;
863
- }
864
-
865
861
  /**
866
862
  * 도구 결과의 **글만** 갈아 끼운 사본. 짝지어 주는 것(id)은 그대로 둔다.
867
863
  *
@@ -41,9 +41,43 @@ import { estimateTokens } from './tokens.js';
41
41
  // 빈 글 블록에도 안 붙인다. 둘 다 거절당할 수 있고, 그 400 은 화면에서
42
42
  // 열쇠가 틀린 것과 구별이 안 된다.
43
43
 
44
- /** 표식 한 개. 기본 수명(5분) 쓴다 시간짜리는 쓰기 값이 두 배다. */
44
+ /** 표식 한 개. 기본 수명(5분). 굴러가는 꼬리에는 이걸 쓴다. */
45
45
  export const 표식 = { type: 'ephemeral' };
46
46
 
47
+ /**
48
+ * **굳은 앞머리**에 쓰는 표식. 한 시간을 산다.
49
+ *
50
+ * ── 왜 바꿨나 ───────────────────────────────────────────────────────────
51
+ *
52
+ * 여기 「한 시간짜리는 쓰기 값이 두 배라 안 쓴다」 고 적혀 있었다. 그 셈이
53
+ * **견줄 상대를 잘못 잡았다.**
54
+ *
55
+ * 5분이 지나 캐시가 죽으면 상대는 「싼 쓰기」 가 아니라 **전액 재전송**이다.
56
+ *
57
+ * 5분 쓰기 1.25배 → 5분 넘으면 죽는다 → 다음 턴에 또 1.25배 → 매 턴 1.25배
58
+ * 1시간 쓰기 2배 → 한 시간 산다 → 다음 턴부터 0.1배
59
+ *
60
+ * 두 턴만 넘어가도 1시간이 이긴다(2 + 0.1 = 2.1 대 1.25 + 1.25 = 2.5).
61
+ *
62
+ * 그리고 코딩 에이전트는 **5분을 넘기는 것이 예외가 아니라 일상**이다. 검사
63
+ * 한 번이 6분이고 빌드가 3분이다. 그 사이 도구가 도는 동안 모델은 안 불린다 —
64
+ * 5분 수명은 그 틈을 못 넘긴다.
65
+ *
66
+ * 나란히 재 봤을 때 이것이 숫자로 나왔다(docs/ko/benchmark.md). 같은 일을
67
+ * 시킨 비교에서 캐시 쓰기가 **1.5M 대 247.6K — 여섯 배**였고, 적중률이
68
+ * 94.3% 대 99.3% 였다. 토큰을 28.7% 적게 쓰고도 비용이 22% 높았다. 캐시를
69
+ * 자꾸 다시 쓰느라 그랬다.
70
+ *
71
+ * ── 왜 앞머리에만 붙이나 ────────────────────────────────────────────────
72
+ *
73
+ * 굳은 앞머리(도구 정의 + 시스템 글)는 **크고 안 바뀐다.** 오래 살려 둘 값이
74
+ * 있는 자리다. 반대로 대화 꼬리는 다음 턴에 새 메시지가 붙는 순간 어차피
75
+ * 무효가 된다 — 거기에 두 배를 내는 것은 그냥 버리는 돈이다.
76
+ *
77
+ * 그래서 수명을 자리에 맞춰 나눈다. 이게 「한 시간을 켠다」 와 다른 점이다.
78
+ */
79
+ export const 표식긴것 = { type: 'ephemeral', ttl: '1h' };
80
+
47
81
  /**
48
82
  * 표식을 적는 **칸 이름**들.
49
83
  *
@@ -61,10 +95,16 @@ export const 표식 = { type: 'ephemeral' };
61
95
  */
62
96
  export const 표식칸들 = ['cache_control', 'prompt_cache_breakpoint'];
63
97
 
64
- /** 그 칸에 적을 값. 칸마다 값의 모양이 다르다. */
65
- const 표식값 = (칸) => (칸 === 'prompt_cache_breakpoint'
66
- ? { mode: 'explicit' }
67
- : { ...표식 });
98
+ /**
99
+ * 칸에 적을 값. 칸마다 값의 모양이 다르다.
100
+ *
101
+ * `긴것` 은 Anthropic 규격에만 있다. OpenAI 규격의 `prompt_cache_breakpoint`
102
+ * 에는 수명 칸이 없으므로 붙이지 않는다 — 없는 칸을 지어내면 400 이다.
103
+ */
104
+ const 표식값 = (칸, 긴것 = false) => {
105
+ if (칸 === 'prompt_cache_breakpoint') return { mode: 'explicit' };
106
+ return 긴것 ? { ...표식긴것 } : { ...표식 };
107
+ };
68
108
 
69
109
  /*
70
110
  * 시스템 글의 「굳은 부분 / 매 턴 바뀌는 부분」 을 나눠 담는 표.
@@ -87,10 +127,43 @@ const 붙일수있나 = (b) => {
87
127
  return t === 'tool_result' || t === 'tool_use' || t === 'image' || t === 'document';
88
128
  };
89
129
 
90
- /** 대화가 이보다 길면 중간에 닻을 하나 더 박는다 (되돌아보기 창 스무 자리). */
130
+ /*
131
+ * ── 되돌아보기 창은 **블록** 스무 개다. 메시지 스무 개가 아니다 ─────────
132
+ *
133
+ * 서버는 표식에서 뒤로 **블록 20개**까지만 훑어 먼저 굳혀 둔 것을 찾는다.
134
+ * 그 안에 앞선 표식이 없으면 못 찾고, 못 찾으면 앞이 통째로 다시 나간다.
135
+ * 닻은 바로 그 일이 안 나게 꼬리 앞에 하나 더 박아 두는 것이다.
136
+ *
137
+ * 그런데 여기서 **메시지 수로** 세고 있었다. 그 둘은 도구를 도는 대화에서
138
+ * 완전히 다른 값이다 — 한 차례가 블록 여럿이기 때문이다.
139
+ *
140
+ * assistant [글 하나 + 도구 부름 둘] → 3블록
141
+ * user [도구 결과 둘] → 2블록
142
+ * 즉 한 바퀴가 메시지 2개인데 블록은 5개다.
143
+ *
144
+ * 그래서 「끝에서 메시지 12개」 는 실제로 **30블록**이었다. 창이 20인데.
145
+ * 닻이 창 밖에 박혀 있으니 꼬리 표식은 그걸 못 보고, 결국 매 턴 앞이 통째로
146
+ * 다시 나갔다. 닻을 박은 값이 하나도 없었던 셈이다.
147
+ *
148
+ * 더 나쁜 것은 **하필 도구를 많이 쓰는 대화에서만** 그랬다는 점이다. 그건
149
+ * 이 프로그램이 하는 일 그 자체다. 짧은 문답에서는 메시지와 블록 수가 비슷해
150
+ * 잘 맞고, 길게 일을 시킬수록 어긋난다 — 아낄 것이 제일 많은 자리에서만
151
+ * 안 아꼈다.
152
+ *
153
+ * 이제 블록으로 센다.
154
+ */
155
+ /** 서버가 표식에서 뒤로 훑는 칸 수. 이 값은 서버가 정한 것이다. */
156
+ export const 창블록 = 20;
157
+ /** 닻은 꼬리에서 이만큼 **블록** 앞에 박는다. 창(20)보다 넉넉히 안쪽이어야 한다. */
158
+ export const 닻블록 = 14;
159
+ /** 대화가 이 블록 수를 넘으면 닻을 하나 더 박는다. 그 아래면 꼬리 하나로 닿는다. */
91
160
  export const 닻문턱 = 20;
92
- /** 닻은 끝에서 이만큼 앞에 박는다. */
93
- const 닻거리 = 12;
161
+
162
+ /** 메시지 하나가 몇 블록인가. 글 한 덩이짜리는 1블록이다. */
163
+ const 블록수 = (m) => (Array.isArray(m?.content) ? m.content.length : 1);
164
+
165
+ /** 이 배열이 통틀어 몇 블록인가. */
166
+ const 통블록 = (ms) => ms.reduce((n, m) => n + 블록수(m), 0);
94
167
 
95
168
  /**
96
169
  * 메시지 하나의 **마지막 성한 블록**에 표식을 붙인 사본을 돌려준다.
@@ -123,13 +196,22 @@ export function 블록에붙이기(msg, 칸 = 'cache_control') {
123
196
  * @param {string[]} 조각들
124
197
  * @param {boolean} 표식쓰나
125
198
  */
126
- export function 시스템블록(조각들, 표식쓰나, 칸 = 'cache_control') {
199
+ export function 시스템블록(조각들, 표식쓰나, 칸 = 'cache_control', { 긴수명 = true } = {}) {
127
200
  const 있는것 = (조각들 ?? []).map((x) => String(x ?? '')).filter((x) => x.length);
128
201
  if (!있는것.length) return null;
129
202
  if (!표식쓰나) return 있는것.join('');
130
203
  const 굳은끝 = 0; // 첫 조각이 굳은 부분이다
204
+ /*
205
+ * 굳은 부분에는 **한 시간짜리**를 붙인다 (표식긴것).
206
+ *
207
+ * 이 자리가 크고 안 바뀐다 — 도구 정의와 시스템 글이다. 오래 살려 두면
208
+ * 도구가 도는 몇 분 동안에도 안 죽는다. 굴러가는 꼬리는 어차피 다음 턴에
209
+ * 무효가 되므로 거기까지 두 배를 낼 까닭이 없다 (메시지표식 은 짧은 것).
210
+ *
211
+ * 안 받는 창구가 있으면 카드가 끈다 (backend/wire.js 의 긴수명).
212
+ */
131
213
  return 있는것.map((text, i) => (
132
- i === 굳은끝 ? { type: 'text', text, [칸]: 표식값() } : { type: 'text', text }
214
+ i === 굳은끝 ? { type: 'text', text, [칸]: 표식값(칸, 긴수명) } : { type: 'text', text }
133
215
  ));
134
216
  }
135
217
 
@@ -163,15 +245,59 @@ function 붙는자리(ms, 부터) {
163
245
  return -1;
164
246
  }
165
247
 
248
+ /*
249
+ * ── 닻은 물러서면 안 된다. **다가서야** 한다 ────────────────────────────
250
+ *
251
+ * 위 자는 못 붙이면 **앞으로**(꼬리에서 멀어지는 쪽으로) 물러선다. 꼬리
252
+ * 표식에는 그게 맞다 — 끝 너머로는 갈 데가 없으니까.
253
+ *
254
+ * 그런데 닻에 같은 자를 쓰면 방향이 반대로 작동한다. 닻이 하는 일은
255
+ * 「꼬리에서 창(20블록) 안에 표식을 하나 더 두는 것」 인데, 못 붙였다고
256
+ * 뒤로 물러서면 꼬리와의 거리가 **늘어난다.** 막힌 자리가 몇 개만 이어져도
257
+ * 그대로 창 밖으로 나간다 — 이 파일이 고친 그 고장이 모양만 바꿔 돌아온다.
258
+ *
259
+ * 실제로 재 봤다. 닻 자리 언저리가 막혀 있으면 닻→꼬리가 25블록이 됐다.
260
+ * 창이 20인데.
261
+ *
262
+ * 그래서 닻은 **꼬리 쪽으로 먼저 다가선다.** 그러면 거리는 줄기만 하므로
263
+ * 창 안이 보장된다. 꼬리 앞까지 다 막혀 있을 때만 뒤로 물러선다 — 그때는
264
+ * 창 밖이라도 없는 것보다 낫다(앞쪽이 통째로 다시 나가는 것을 막는다).
265
+ */
266
+ function 닻붙는자리(ms, 부터, 꼬리) {
267
+ const 끝 = 꼬리 >= 0 ? 꼬리 : ms.length;
268
+ for (let i = Math.max(0, 부터); i < 끝; i++) {
269
+ if (블록에붙이기(ms[i], 'cache_control') !== ms[i]) return i;
270
+ }
271
+ return 붙는자리(ms, 부터);
272
+ }
273
+
274
+ /**
275
+ * 꼬리에서 **블록으로** 닻블록 만큼 앞선 자리를 찾는다.
276
+ *
277
+ * 뒤에서부터 블록을 세어 올라가다 닻블록 을 넘는 순간 멈춘다. 메시지 수로
278
+ * 세면 도구를 도는 대화에서 창 밖으로 나간다 (위 주석).
279
+ */
280
+ function 닻자리(ms, 꼬리) {
281
+ let 센것 = 0;
282
+ for (let i = 꼬리; i >= 0; i--) {
283
+ 센것 += 블록수(ms[i]);
284
+ if (센것 >= 닻블록) return i;
285
+ }
286
+ return 0;
287
+ }
288
+
166
289
  export function 메시지표식(messages, 칸 = 'cache_control') {
167
290
  const ms = messages ?? [];
168
291
  if (!ms.length) return ms;
169
292
  const 붙일자리 = new Set();
170
293
  const 꼬리 = 붙는자리(ms, ms.length - 1);
171
294
  if (꼬리 >= 0) 붙일자리.add(꼬리);
172
- // 도구를 길게 늘어놓는 턴에서 되돌아보기 창을 넘기지 않게 닻을 하나 더.
173
- if (ms.length > 닻문턱) {
174
- const = 붙는자리(ms, ms.length - 1 - 닻거리);
295
+ /*
296
+ * 도구를 길게 늘어놓는 턴에서 되돌아보기 창을 넘기지 않게 닻을 하나 더.
297
+ * 통틀어 안에 들어오면 꼬리 하나로 앞까지 닿으니 닻이 필요 없다.
298
+ */
299
+ if (통블록(ms) > 닻문턱) {
300
+ const 닻 = 닻붙는자리(ms, 닻자리(ms, 꼬리 >= 0 ? 꼬리 : ms.length - 1), 꼬리);
175
301
  // 꼬리와 같은 자리면 닻이 아니다 — 그건 표식 하나짜리다.
176
302
  if (닻 >= 0 && 닻 !== 꼬리) 붙일자리.add(닻);
177
303
  }
@@ -680,7 +680,18 @@ function 오류코드(err) {
680
680
 
681
681
  // fetch 는 무슨 일이든 'fetch failed' 라고만 말하고 까닭은 cause 에 숨긴다. 그래서
682
682
  // 말(m)만 보지 않고 코드도 본다 — 안 그러면 DNS 실패도 "주소·포트·프록시를 확인하세요" 가 된다.
683
- function normalizeError(err) {
683
+ /*
684
+ * 내보내는 까닭은 **검사 때문**이다.
685
+ *
686
+ * 이 사다리는 서버가 준 글을 정규식으로 읽어 사람 말로 갈아 끼운다. 그 중
687
+ * 한 줄은 열쇠가 화면과 진단 보고서에 평문으로 흘러나가는 것을 막는 줄이다.
688
+ * 그런 줄이 조용히 죽어도 아무 데서도 안 터진다 — 그냥 원문이 그대로 나가고,
689
+ * 그 원문 안에 열쇠가 들어 있다.
690
+ *
691
+ * 부르는 길이 `req()` 하나뿐이라 검사판에서 갈래마다 재려면 진짜 통신을
692
+ * 실패시켜야 했다. 그래서 안 재고 있었다. 재지는 조각으로 내놓는다.
693
+ */
694
+ export function normalizeError(err) {
684
695
  const m = String(err?.message ?? err);
685
696
  const 코드 = String(오류코드(err) ?? '');
686
697
  const 기본 = (() => {
@@ -28,6 +28,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
28
28
  import { createHash } from 'node:crypto';
29
29
  import { join } from 'node:path';
30
30
  import { VERSION } from '../version.js';
31
+ // 남의 저장소에 딸려 온 mcp.json 으로 남의 프로그램을 띄우지 않는다 (다붙이기 머리말).
32
+ import { 믿나 } from '../safety/trust.js';
31
33
 
32
34
  // 붙는 데 이만큼 넘게 걸리면 포기한다. 시작이 느려지면 안 쓰게 된다.
33
35
  export const 붙기제한 = 8000;
@@ -494,6 +496,40 @@ export async function 다붙이기(root, { offline = false, timeout = 붙기제
494
496
  if (설정.오류) return { 서버들: [], 못한것: [{ 이름: '(설정)', 왜: 설정.오류 }], 설정 };
495
497
  if (!설정.서버들.length) return { 서버들: [], 못한것: [], 설정 };
496
498
 
499
+ /*
500
+ * ── 믿는 폴더에서만 띄운다 ──────────────────────────────────────────
501
+ *
502
+ * 이 파일 머리말은 네 가지를 약속하는데(기본 꺼짐 · 자물쇠면 안 띄움 ·
503
+ * 감사기록 · 범위 밖이라고 말함) **정작 제일 앞 문은 없었다.**
504
+ *
505
+ * `mcp.json` 은 프로젝트 폴더에 있고, 그러니 **저장소에 같이 딸려 온다.**
506
+ * 남의 저장소를 clone 하고 그 안에서 deel 을 켜면, 거기 적힌 `command` 가
507
+ * 이 계정 권한으로 자식 프로세스가 된다. 도구 승인 화면은 안 거친다 —
508
+ * 모델이 부른 것이 아니라 우리가 「서버를 띄우려고」 부른 것이기 때문이다.
509
+ *
510
+ * { "mcpServers": { "sync": { "command": "cmd", "args": ["/c", "…"] } } }
511
+ *
512
+ * 훅(safety/hooks.js)은 정확히 이 위협에 대해 `믿나(root)` 를 강제하고,
513
+ * 그 파일 머리말은 「MCP 와 같은 무게로 다룬다」 고 적어 두었다. 그런데
514
+ * 무게를 견주던 쪽에 그 문이 없었다. 프로젝트 설정(config.js)도 같은 문을
515
+ * 지나간다 — 남의 프로그램을 띄우는 이 자리가 셋 중 제일 무거운데 혼자
516
+ * 그냥 열려 있었다.
517
+ *
518
+ * 안 믿으면 **조용히 넘어가지 않는다.** 못 붙였다고 화면에 그대로 말하고,
519
+ * 어떻게 하면 되는지(deel trust)까지 같이 말한다.
520
+ */
521
+ if (!믿나(root, { env })) {
522
+ return {
523
+ 서버들: [],
524
+ 못한것: 설정.서버들.map((s) => ({
525
+ 이름: s.이름,
526
+ 왜: '믿는 폴더가 아닙니다 — 남의 저장소에 딸려 온 설정일 수 있어 안 띄웁니다 (deel trust)',
527
+ })),
528
+ 설정,
529
+ 안믿음: true,
530
+ };
531
+ }
532
+
497
533
  // 자물쇠가 걸려 있으면 아예 안 띄운다. 자식 프로세스가 어디로 나가는지
498
534
  // 우리는 못 막는다 — 막을 수 없는 것을 막았다고 말하지 않는다.
499
535
  if (offline) {
@@ -41,7 +41,7 @@ const READ_TOOL = {
41
41
  * · 도구가 {type:'function', function:{…}} 모양으로 간다
42
42
  * · 답을 choices[0].message 에서 찾는다 (실제로는 content 블록 배열)
43
43
  *
44
- * 첫 칸(기본 대화)이 400 으로 죽으면 나머지 일곱 칸은 전부 「확인 불가」 로
44
+ * 첫 칸(기본 대화)이 400 으로 죽으면 나머지 여덟 칸은 전부 「확인 불가」 로
45
45
  * 건너뛴다. 그 결과 `deel setup` 이 저장하는 프로필에 streaming·tools·json·
46
46
  * vision 이 **다 false** 로 적힌다 — Claude 를 붙였는데 도구를 아예 안 쓰는
47
47
  * 연결이 만들어진다. 붙기는 붙으니 아무도 고장이라고 생각하지 않는다.
@@ -140,7 +140,7 @@ export async function probe(conn, onStep = () => {}) {
140
140
  * 붙어 있어서, 그대로 이으면
141
141
  * `.../deployments/gpt-4o?api-version=2024-10-21/chat/completions` 가 된다.
142
142
  * 그러면 **설치 화면이 제 검사에 통째로 실패한다** — 기본 대화가 안 되니
143
- * 나머지 일곱 칸이 다 '확인 불가' 로 건너뛰어지고, 프로필에는 스트리밍도
143
+ * 나머지 여덟 칸이 다 '확인 불가' 로 건너뛰어지고, 프로필에는 스트리밍도
144
144
  * 도구 호출도 안 된다고 적힌다. 붙기는 붙는데 반쪽짜리로 붙는다.
145
145
  */
146
146
  const url = (p) => 주소붙이기(base, p);