deel-local-cli 1.17.9 → 1.19.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.
@@ -565,14 +565,88 @@ export class Session {
565
565
  /** 모델이 읽는 시스템 글 전체. 조각을 그대로 이어 붙인 것이다. */
566
566
  systemPrompt() { return this.시스템조각().join(''); }
567
567
 
568
- // 프롬프트에 실제로 올릴 스킬: 가까운 자리(프로젝트 > 사용자 > 플러그인) 순으로 상한까지.
568
+ /*
569
+ * 프롬프트에 실제로 올릴 스킬: 가까운 자리부터 상한까지.
570
+ *
571
+ * ── 갈래 하나를 안 적었다 ────────────────────────────────
572
+ *
573
+ * 자리표가 `{ project, user, plugin }` 이었고 거기에 **`builtin` 이 없었다.**
574
+ * 그래서 내장 스킬은 `?? 맨뒤` 로 떨어져 모르는 갈래와 한 덩어리가
575
+ * 됐고, 앞이 40칸을 채우면 통째로 잘렸다.
576
+ *
577
+ * 빈 폴더에서 그대로 재 봤다. 시스템 프롬프트 9,399자 중 6,700자가
578
+ * 스킬 목록이었는데 그 40개 안에 내장은 **한 개도 없었다** — 사용자
579
+ * 것 11개와 남의 플러그인 29개였다. 잘린 일곱은 하필 deel 이 무엇인지를
580
+ * 적어 둔 것들이다 — 검사-먼저 · 끝까지-하기 · 스스로-검토 …
581
+ * 플러그인을 많이 까은 PC 일수록 deel 은 조용히 제 방법을 잃었다.
582
+ *
583
+ * 그래서 둘을 한다. 자리표에 `builtin` 을 적고, **먼저 떼어 둔다.**
584
+ * 자리만 주면 사용자 스킬이 40개를 넘는 PC 에서 같은 구멍이 다시 생긴다 —
585
+ * 숫자를 올리는 것으로는 막을 수 없는 부류다. 내장은 일곩 뿐이고
586
+ * deel 의 방법 그 자체라, 그 일곩은 자리를 따로 매어 둔다.
587
+ */
569
588
  listedSkills() {
570
- const rank = { project: 0, user: 1, plugin: 2 };
571
- return this.skills
572
- .filter((s) => s.enabled)
573
- .slice()
574
- .sort((a, b) => (rank[a.source] ?? 3) - (rank[b.source] ?? 3))
575
- .slice(0, this.maxSkillsListed);
589
+ const rank = { project: 0, user: 1, builtin: 2, plugin: 3 };
590
+ const 자리 = (x) => rank[x.source] ?? 9;
591
+ const 줄세운것 = this.skills.filter((s) => s.enabled).slice()
592
+ .sort((a, b) => 자리(a) - 자리(b));
593
+ /*
594
+ * 내장 몫은 **상한의 1/4 까지**다.
595
+ *
596
+ * 처음에는 상한까지 무조건 먼저 뗐다. 그랬더니 살리려던 것이 굶기는
597
+ * 쪽이 됐다 — 2차 리뷰가 재 준 자리다.
598
+ *
599
+ * 상한 10 · project 10개 + builtin 7개 → project 3개 · builtin 7개
600
+ *
601
+ * 자리표가 말하는 순서(프로젝트 > 사용자 > 내장 > 플러그인)와 정반대다.
602
+ * 그리고 그 때문에 스킬자리.test.js 의 자리표 검사가 **죽은 검사**였다 —
603
+ * 내장은 자리표를 안 거치고 뽑혀 올라오니, rank 에서 builtin 을 지워도
604
+ * 빨개지지 않았다. 구멍을 막으려고 넣은 것이 또 다른 구멍을 만들었다.
605
+ *
606
+ * 기본 상한 40 에서는 몫이 10칸이라 일곱이 다 들어간다 — 지금과 같다.
607
+ * 상한이 좁을 때만 가까운 것에게 자리를 돌려준다. 매어 두는 값은
608
+ * 「통째로 사라지지 않는다」 이지 「무조건 다 실린다」 가 아니다.
609
+ */
610
+ /*
611
+ * 하한을 두지 않는다. `Math.max(1, …)` 로 「적어도 하나」 를 매어 뒀더니
612
+ * 상한이 1일 때 내장이 그 한 칸을 차지해 **최우선인 프로젝트가 밀렸고**,
613
+ * 상한이 0인데도 하나가 나왔다. 「상한의 1/4」 이라고 적어 놓고 하는 일은
614
+ * 그게 아니었던 것이다 — 적은 말과 어긋난 쪽은 코드였다.
615
+ * 상한이 넷보다 작으면 몫은 0이고, 그때는 자리표가 그냥 정하면 된다.
616
+ */
617
+ /*
618
+ * 상한을 **먼저 성한 수로 만든다.** 음수가 들어오면 `Math.floor(-1/4)`
619
+ * 이 -1 이고 `slice(0, -1)` 은 「끝 하나만 뺀 전부」 라, 0 개를 실어야 할
620
+ * 자리에서 넷이 실렸다(8차 리뷰). 음수 상한은 「싣지 마라」 는 뜻이지
621
+ * 「뒤에서 하나 빼고 다」 가 아니다.
622
+ *
623
+ * 그 울타리를 `Number.isFinite` 로 세웠더니 옮긴 자리에 틈이 났다
624
+ * (9차 리뷰). 그 함수는 **숫자꼴 글자열과 무한대를 함께 내친다.**
625
+ *
626
+ * maxSkillsListed = '10' → 0개 (앞 판은 10개)
627
+ * maxSkillsListed = Infinity → 0개 (앞 판은 전부)
628
+ *
629
+ * 설정 파일에서 온 값은 글자열이기 쉽고, 무한대는 「상한을 두지 마라」
630
+ * 는 뜻이다. 둘 다 스킬을 통째로 지워 버렸다 — 막으려던 것보다 나쁘다.
631
+ *
632
+ * 그래서 **먼저 숫자로 읽고**, 숫자가 아닐 때만 0 으로 본다.
633
+ * `Math.floor(Infinity)` 은 Infinity 이고 `slice(0, Infinity)` 는 전부라,
634
+ * 무한대는 저절로 「상한 없음」 이 된다.
635
+ */
636
+ const 잰값 = Number(this.maxSkillsListed);
637
+ const 상한 = Number.isNaN(잰값) ? 0 : Math.max(0, Math.floor(잰값));
638
+ const 몫 = Math.floor(상한 / 4);
639
+ const 매어둔것 = new Set(줄세운것.filter((s) => s.source === 'builtin').slice(0, 몫));
640
+ const 나머지 = new Set(줄세운것
641
+ .filter((s) => !매어둔것.has(s))
642
+ .slice(0, Math.max(0, 상한 - 매어둔것.size)));
643
+ /*
644
+ * 줄세운 것에서 **골라내기만** 한다. 두 덩이로 쪼개 이어 붙였더니 앞
645
+ * 번호 내장이 뒤 번호 내장 뒤로 밀려, 같은 갈래 안에서 차례가 뒤집혔다
646
+ * (b2 b3 b4 b5 b6 b0 b1). 「안정 정렬이라 원래 차례 그대로」 라고 적어 둔
647
+ * 주석이 그 자리에서 거짓이 됐다. 한 줄에서 거르면 그럴 일이 없다.
648
+ */
649
+ return 줄세운것.filter((s) => 매어둔것.has(s) || 나머지.has(s));
576
650
  }
577
651
 
578
652
  /*
@@ -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);
@@ -29,6 +29,31 @@ const 확장자 = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
29
29
  /** 한 장 최대 크기. 창 크기와 상관없이 이 위로는 안 싣는다. */
30
30
  export const 기본한도 = 4 * 1024 * 1024;
31
31
 
32
+ /**
33
+ * 한 변의 최대 픽셀. 이 위로는 서버가 400 을 준다.
34
+ *
35
+ * ── 왜 바이트만으로는 안 되나 ───────────────────────────────────────────
36
+ *
37
+ * 여태 크기만 보고 있었다. 그런데 **바이트와 픽셀은 따로 논다** — 화면을 길게
38
+ * 찍은 스크린샷은 잘 압축돼서 12000×3000 이어도 4MB 를 한참 밑돈다. 그래서
39
+ * 크기 검사를 멀쩡히 통과하고, 게이트웨이에서 이렇게 튕겼다.
40
+ *
41
+ * messages.8.content.1.image.source.bytes:
42
+ * At least one of the image dimensions exceed max allowed size: 8000 pixels
43
+ *
44
+ * ── 그리고 이건 그 턴 하나로 안 끝난다 ──────────────────────────────────
45
+ *
46
+ * 튕긴 뒤에도 그 그림은 **대화에 남는다.** 다음에 무슨 말을 걸어도 같은
47
+ * 메시지가 같이 나가서 같은 400 을 받는다. 「대시보드 실행해줘」 처럼 그림과
48
+ * 아무 상관 없는 말까지 안 된다 — 세션이 통째로 죽는다. 사람이 보기에는
49
+ * 프로그램이 갑자기 고장 난 것이고, 원인은 아홉 번째 메시지에 있다.
50
+ *
51
+ * 그러니 **들어오기 전에 막아야 한다.** 한 번 실려 들어가면 그 뒤는 늦다.
52
+ *
53
+ * 8000 은 Bedrock 이 거절한 값이고 Anthropic 규격이 적어 둔 값과 같다.
54
+ */
55
+ export const 픽셀한도 = 8000;
56
+
32
57
  /** 경로만 보고 그림인지. 실제로 그림인지는 그림읽기() 가 속을 보고 정한다. */
33
58
  export function 그림인가(경로) {
34
59
  const s = String(경로 ?? '').toLowerCase();
@@ -107,7 +132,91 @@ export function 그림읽기(abs, { 한도 = 기본한도 } = {}) {
107
132
  + ' 내려받다 만 파일이거나, 로그인 화면 HTML 이 그림 이름으로 저장된 것일 수 있습니다.',
108
133
  };
109
134
  }
110
- return { ok: true, b64: buf.toString('base64'), mime, bytes };
135
+
136
+ /*
137
+ * 치수를 본다. 못 읽으면 **막지 않는다** — 우리가 못 읽는 모양이라고 서버도
138
+ * 못 읽는다는 뜻은 아니다. 모르는 것을 이유로 막으면 멀쩡한 그림이 안 실린다.
139
+ */
140
+ const 잰것 = 치수읽기(buf, mime);
141
+ if (잰것 && (잰것.가로 > 픽셀한도 || 잰것.세로 > 픽셀한도)) {
142
+ return {
143
+ ok: false,
144
+ bytes,
145
+ 가로: 잰것.가로,
146
+ 세로: 잰것.세로,
147
+ 왜: `그림의 한 변이 깁니다 (${잰것.가로}×${잰것.세로} · 한 변 한도 ${픽셀한도}px) —`
148
+ + ' 잘라서 저장한 뒤 다시 주세요. 화면 전체를 길게 찍은 사진이 흔히 여기 걸립니다.'
149
+ + ' 여기서는 크기를 줄이지 않습니다. 줄이려면 다른 프로그램이 필요한데,'
150
+ + ' 이 도구는 아무것도 안 깔고 도는 것이 규칙입니다.',
151
+ };
152
+ }
153
+ return { ok: true, b64: buf.toString('base64'), mime, bytes, ...(잰것 ?? {}) };
154
+ }
155
+
156
+ /**
157
+ * 그림의 가로·세로를 **머리말만 읽어서** 잰다. 못 알아보면 null.
158
+ *
159
+ * 파일을 통째로 해석하지 않는다. 각 형식이 앞쪽에 치수를 적어 두므로 그 자리만
160
+ * 본다 — 의존성 0개를 지키면서 픽셀을 아는 유일한 길이다.
161
+ */
162
+ export function 치수읽기(buf, mime = null) {
163
+ // 10 은 형식들 가운데 제일 짧은 머리말(GIF)이다. 그보다 크게 잡으면 GIF 가
164
+ // 형식별 검사에 닿기도 전에 잘린다 — 실제로 그랬다.
165
+ if (!buf || buf.length < 10) return null;
166
+ const 꼴 = mime ?? 그림종류(buf);
167
+ try {
168
+ // PNG: 서명 8바이트 + 길이 4 + 'IHDR' 4 뒤에 가로·세로가 빅엔디안 4바이트씩.
169
+ if (꼴 === 'image/png') {
170
+ if (buf.length < 24 || buf.toString('ascii', 12, 16) !== 'IHDR') return null;
171
+ return { 가로: buf.readUInt32BE(16), 세로: buf.readUInt32BE(20) };
172
+ }
173
+ // GIF: 머리말 6바이트 뒤에 가로·세로가 리틀엔디안 2바이트씩.
174
+ if (꼴 === 'image/gif') {
175
+ if (buf.length < 10) return null;
176
+ return { 가로: buf.readUInt16LE(6), 세로: buf.readUInt16LE(8) };
177
+ }
178
+ if (꼴 === 'image/webp') return webp치수(buf);
179
+ if (꼴 === 'image/jpeg') return jpeg치수(buf);
180
+ } catch { /* 잘린 파일이면 그냥 모르는 것으로 둔다 */ }
181
+ return null;
182
+ }
183
+
184
+ /*
185
+ * JPEG 는 치수가 고정된 자리에 없다. SOF 표시를 찾아 그 안에서 읽는다.
186
+ *
187
+ * SOF 는 0xFFC0~0xFFCF 인데 C4(허프만표)·C8·CC 는 SOF 가 아니다. 그 셋을 빼야
188
+ * 엉뚱한 토막에서 숫자를 읽고 「3×1 그림」 같은 답을 내지 않는다.
189
+ */
190
+ function jpeg치수(buf) {
191
+ let i = 2; // 0xFFD8 다음부터
192
+ while (i + 9 < buf.length) {
193
+ if (buf[i] !== 0xff) { i += 1; continue; } // 채움 바이트를 건너뛴다
194
+ const 표시 = buf[i + 1];
195
+ if (표시 === 0xd8 || 표시 === 0x01 || (표시 >= 0xd0 && 표시 <= 0xd7)) { i += 2; continue; }
196
+ const 길이 = buf.readUInt16BE(i + 2);
197
+ if (길이 < 2) return null;
198
+ const SOF = 표시 >= 0xc0 && 표시 <= 0xcf && 표시 !== 0xc4 && 표시 !== 0xc8 && 표시 !== 0xcc;
199
+ if (SOF) return { 가로: buf.readUInt16BE(i + 7), 세로: buf.readUInt16BE(i + 5) };
200
+ i += 2 + 길이;
201
+ }
202
+ return null;
203
+ }
204
+
205
+ /* WebP 는 속이 셋(VP8 · VP8L · VP8X)이고 치수가 저마다 다른 자리에 다르게 담긴다. */
206
+ function webp치수(buf) {
207
+ if (buf.length < 30) return null;
208
+ const 갈래 = buf.toString('ascii', 12, 16);
209
+ if (갈래 === 'VP8 ') return { 가로: buf.readUInt16LE(26) & 0x3fff, 세로: buf.readUInt16LE(28) & 0x3fff };
210
+ if (갈래 === 'VP8L') {
211
+ const b = buf.readUInt32LE(21);
212
+ return { 가로: (b & 0x3fff) + 1, 세로: ((b >> 14) & 0x3fff) + 1 };
213
+ }
214
+ if (갈래 === 'VP8X') {
215
+ // 24비트 리틀엔디안으로 '한 변 - 1' 이 적혀 있다.
216
+ const 셋 = (at) => (buf[at] | (buf[at + 1] << 8) | (buf[at + 2] << 16)) + 1;
217
+ return { 가로: 셋(24), 세로: 셋(27) };
218
+ }
219
+ return null;
111
220
  }
112
221
 
113
222
  /*
@@ -163,6 +163,18 @@ export function 기본카드(conn) {
163
163
  표식칸: 'cache_control',
164
164
  // 캐시 표식이 잡히는 최소 크기 (그 아래면 붙여도 안 잡힌다 — 탈은 아니다)
165
165
  캐시최소: 1024,
166
+ /*
167
+ * 굳은 앞머리를 **한 시간** 살릴까 (backend/cachemark.js 의 표식긴것).
168
+ *
169
+ * 기본으로 켠다. 코딩 에이전트는 검사 한 번에 6분을 쓰는 일이 흔한데,
170
+ * 5분 수명은 그 틈을 못 넘긴다 — 그러면 도구가 돌 때마다 앞머리를 통째로
171
+ * 다시 쓴다. 나란히 재 봤을 때 캐시 쓰기가 여섯 배로 나온 자리다.
172
+ *
173
+ * 안 받는 창구는 400 으로 말해 주고, 그러면 아래 배울전선 이 이 칸만
174
+ * 끈다 — 캐시 자체를 끄지 않는다. 그 둘을 섞으면 `ttl` 하나를 모르는
175
+ * 창구에서 캐시를 통째로 잃는다.
176
+ */
177
+ 긴수명: true,
166
178
  /*
167
179
  * 출력 상한을 **어느 이름으로** 싣나 — '둘다' · '새것' · '옛것'
168
180
  *
@@ -474,6 +486,20 @@ export function 배울전선(문구, 규격) {
474
486
  * 조용히 비싸지는 쪽이라 화면에 아무 표시도 안 난다 — 이 파일이 없애려던
475
487
  * 바로 그 고장이다.
476
488
  */
489
+ /*
490
+ * ── `ttl` 만 모르는 창구 ────────────────────────────────────────────
491
+ *
492
+ * 이 갈래가 아래 `cache_control` 갈래보다 **먼저** 와야 한다. 순서가
493
+ * 바뀌면 수명 칸 하나를 모르는 창구에서 **캐시를 통째로 끈다** — 받을 수
494
+ * 있었던 것까지 잃고, 그건 조용히 비싸지는 쪽이라 화면에 아무 표시도
495
+ * 안 난다.
496
+ *
497
+ * 한 시간 수명은 정식 기능이지만 게이트웨이가 몸통을 그대로 넘겨 주지
498
+ * 않고 제 스키마로 검사하는 자리가 있어서, 거기서는 이 칸만 튕긴다.
499
+ */
500
+ if (/\bttl\b/i.test(s) && 거절.test(s)) {
501
+ return { 무엇: '긴수명', 값: false, 왜: 짧게(s) };
502
+ }
477
503
  if (/cache_control/i.test(s) && 거절.test(s)) {
478
504
  /*
479
505
  * 다른 이름으로 바꿔 보는 것은 **OpenAI 규격일 때만** 이다.
@@ -494,14 +520,38 @@ export function 배울전선(문구, 규격) {
494
520
  if (규격 === 'anthropic') return { 무엇: '캐시', 값: 'none', 왜: 짧게(s) };
495
521
  return { 무엇: '표식칸', 값: 'prompt_cache_breakpoint', 왜: 짧게(s) };
496
522
  }
497
- if (/prompt_cache_breakpoint|prompt_cache_key|cache_creation/i.test(s) && 거절.test(s)) {
523
+ /*
524
+ * 여기 `cache_creation` 도 적혀 있었다. 지워야 하는 낱말이었다.
525
+ *
526
+ * 우리는 그런 이름의 칸을 **보내지 않는다.** 그 낱말이 실제로 나오는 자리는
527
+ * 답장의 셈판 하나뿐이다 — `cache_creation_input_tokens`(adapter.js 의 씀).
528
+ * 그러니 이 갈래는 우리가 보낸 것을 두고는 영영 안 걸리고, 대신 창구가 답장
529
+ * 칸 이름을 오류 문장에 얹어 주는 날 **엉뚱하게 걸린다.** 그리고 걸리면 캐시를
530
+ * 통째로 끄고 그 값은 디스크에 남는다. 안 걸리는 규칙은 없느니만 못하고,
531
+ * 엉뚱하게 걸리는 규칙은 없는 것보다 나쁘다.
532
+ */
533
+ if (/prompt_cache_breakpoint|prompt_cache_key/i.test(s) && 거절.test(s)) {
498
534
  return { 무엇: '캐시', 값: 'none', 왜: 짧게(s) };
499
535
  }
500
536
 
501
537
  // ── 세션 이름 ───────────────────────────────────────────────────────
502
538
  // 'user' 는 흔한 낱말이라 **칸 이야기일 때만** 본다. 안 그러면 아무
503
539
  // 오류 문장에나 걸려서 멀쩡한 칸을 꺼 버린다.
504
- if (/(?:parameter|property|field|argument)[^\n]{0,24}['"`]?(?:user|metadata)['"`]?/i.test(s) && 거절.test(s)) {
540
+ /*
541
+ * 낱말 경계가 있어야 한다. 없으면 **이름 안에 든 것까지** 걸린다.
542
+ *
543
+ * Unrecognized request argument supplied: username → 걸렸다
544
+ * Invalid property 'browser_metadata_extra' in request → 걸렸다
545
+ * Unrecognized request argument supplied: user_agent → 걸렸다
546
+ *
547
+ * 셋 다 세션 이름과 아무 상관이 없는 칸이다. 그런데 걸리면 「이 창구는
548
+ * 세션 이름을 안 받는구나」 로 배우고 **그 값을 디스크에 남긴다.** 멀쩡히
549
+ * 되던 기능이 그 창구에서 영영 꺼지고, 되돌릴 길은 화면에 안 보인다.
550
+ *
551
+ * 밑줄은 낱말 글자라 `\b` 가 제 일을 한다 — `user_agent` 의 `user` 뒤는
552
+ * 경계가 아니고, `username` 도 마찬가지다. 따옴표·빈칸·점 앞에서만 걸린다.
553
+ */
554
+ if (/(?:parameter|property|field|argument)[^\n]{0,24}['"`]?\b(?:user|metadata)\b['"`]?/i.test(s) && 거절.test(s)) {
505
555
  return { 무엇: '세션자리', 값: null, 왜: 짧게(s) };
506
556
  }
507
557
  if (/['"`](?:user|metadata)['"`][^\n]{0,40}(?:not supported|unsupported|unknown|unexpected|invalid)/i.test(s)) {
@@ -562,7 +612,7 @@ export function 카드고치기(카드, 고침) {
562
612
  return 새;
563
613
  }
564
614
 
565
- export const 카드칸들 = ['생각형식', '눈금', '끄는말', '효력칸', '캐시', '표식칸', '출력칸', '세션자리', '스트림usage'];
615
+ export const 카드칸들 = ['생각형식', '눈금', '끄는말', '효력칸', '캐시', '표식칸', '긴수명', '출력칸', '세션자리', '스트림usage'];
566
616
 
567
617
  /**
568
618
  * 집 파일에 적을 만한 것만. 주소·모델은 부르는 쪽이 열쇠로 쓴다.