deel-local-cli 1.8.0 → 1.10.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.
Files changed (49) hide show
  1. package/README.ko.md +1166 -1182
  2. package/README.md +1223 -1232
  3. package/bin/deel.js +55 -12
  4. package/package.json +3 -3
  5. package/src/acp/serve.js +40 -3
  6. package/src/agent/compact.js +314 -296
  7. package/src/agent/effort.js +27 -7
  8. package/src/agent/evidence.js +2 -0
  9. package/src/agent/filemem.js +141 -0
  10. package/src/agent/grade.js +51 -1
  11. package/src/agent/loop.js +202 -35
  12. package/src/agent/session.js +121 -5
  13. package/src/agent/store.js +186 -9
  14. package/src/agent/threads.js +26 -1
  15. package/src/backend/adapter.js +201 -18
  16. package/src/backend/learn.js +24 -0
  17. package/src/backend/mcp.js +96 -5
  18. package/src/backend/probe.js +131 -61
  19. package/src/backend/quota.js +29 -5
  20. package/src/backend/toolfit.js +325 -0
  21. package/src/commands.js +107 -27
  22. package/src/config.js +47 -2
  23. package/src/i18n/en.js +56 -2
  24. package/src/i18n/index.js +18 -0
  25. package/src/i18n/ja.js +58 -2
  26. package/src/i18n/ko.js +103 -2
  27. package/src/i18n/zh.js +58 -2
  28. package/src/lsp/client.js +49 -5
  29. package/src/oneshot.js +81 -3
  30. package/src/pack/sbom.js +30 -4
  31. package/src/pack/selfpack.js +25 -9
  32. package/src/pack/sheet.en.js +288 -0
  33. package/src/pack/tar.js +65 -2
  34. package/src/plugins/manage.js +46 -9
  35. package/src/repl.js +264 -35
  36. package/src/safety/audit.js +92 -8
  37. package/src/safety/authcmd.js +14 -3
  38. package/src/safety/guard.js +143 -0
  39. package/src/safety/keystore.js +62 -39
  40. package/src/safety/undo.js +24 -6
  41. package/src/tools/index.js +239 -34
  42. package/src/tools/jobs.js +158 -29
  43. package/src/tools/verify.js +33 -4
  44. package/src/tools/webfetch.js +92 -9
  45. package/src/ui/md.js +201 -5
  46. package/src/ui/motion.js +0 -1
  47. package/src/ui/pastechip.js +50 -3
  48. package/src/ui/pick.js +115 -0
  49. package/src/ui/screen.js +23 -3
@@ -0,0 +1,325 @@
1
+ // 도구 목록을 **보내기 직전에** 그 회사 규격에 맞춘다.
2
+ //
3
+ // ── 왜 여기서 하나 ──────────────────────────────────────────────────────
4
+ //
5
+ // 도구 목록을 만드는 자리(tools/index.js)는 어디로 보낼지 모른다. 같은 목록이
6
+ // 로컬 llama.cpp 로도 가고 Anthropic 으로도 간다. 그래서 만드는 자리에서
7
+ // 「Gemini 가 싫어하는 열쇠」 를 빼면, 그것을 아무렇지 않게 받는 서버에서도
8
+ // 값을 잃는다.
9
+ //
10
+ // 반대로 보내는 자리(adapter.js 의 chat·chatStream)는 conn 을 쥐고 있다.
11
+ // 주소를 보면 어디로 가는지 안다. 그러니 **거기서 한 번** 다듬는 것이 맞다.
12
+ //
13
+ // ── 어디로 가는지는 주소로 안다 ─────────────────────────────────────────
14
+ //
15
+ // 모델 이름으로 짐작하지 않는다. LiteLLM 같은 사내 게이트웨이 뒤에 Claude 가
16
+ // 있어도, 우리가 말을 거는 상대는 게이트웨이다. 그 게이트웨이가 제 나름대로
17
+ // 이미 다듬어서 넘긴다 — 우리가 모델 이름만 보고 「Anthropic 이니까」 하고
18
+ // 깎으면, 게이트웨이는 멀쩡히 받던 것을 못 받게 된다.
19
+ //
20
+ // 그래서 **주소의 호스트**만 본다. 모르는 주소면 아무것도 안 건드린다.
21
+ // 이 프로그램의 주된 자리(로컬 모델·사내 게이트웨이)가 바로 그 「모르는
22
+ // 주소」 라, 기본은 지금까지와 똑같이 도는 쪽이어야 한다.
23
+ //
24
+ // ── 이름을 바꾸면 되돌려야 한다 ─────────────────────────────────────────
25
+ //
26
+ // 이름이 규격에 안 맞아 고쳐 보내면, 모델은 **고친 이름**으로 부른다.
27
+ // 그대로 위로 올리면 loop.js 가 모르는 도구를 받는다 — 도구가 조용히
28
+ // 안 불리는, 제일 알아내기 어려운 고장이다. 그래서 다듬은 자리에서
29
+ // 되돌림 표를 같이 내놓고, 답이 오면 adapter.js 가 그 표로 되돌린다.
30
+ // 밖에서는 이 일이 있었는지조차 모른다.
31
+ import { 애저인가 } from './azure.js';
32
+
33
+ /**
34
+ * 이름 규칙.
35
+ *
36
+ * OpenAI 문서에 적힌 그대로다 — "alphanumeric characters, underscores, or
37
+ * dashes, with a maximum length of 64 characters". Anthropic·Bedrock 도 같은
38
+ * 모양을 쓰고, Gemini 는 여기에 점을 더 받는다. 제일 좁은 것 하나로 맞추면
39
+ * 어디로 보내도 통한다 — 회사마다 다르게 깎을 까닭이 없다.
40
+ */
41
+ const 이름규칙 = /^[a-zA-Z0-9_-]{1,64}$/;
42
+ const 이름최대 = 64;
43
+
44
+ /**
45
+ * Gemini 가 받는 스키마 열쇠.
46
+ *
47
+ * 문서가 "function calling supports only a subset of the OpenAPI schema" 라고
48
+ * 말한다. 무엇이 되는지를 적어 둔 목록은 있어도 **무엇이 안 되는지**를 적어
49
+ * 둔 목록은 없다. 그러니 허용 목록으로 간다 — 모르는 열쇠를 실어 보내는 쪽이
50
+ * 위험하다. 안 되는 열쇠 하나가 400 을 만들고, 그러면 그 턴이 통째로 죽는다.
51
+ *
52
+ * 우리 도구는 이 목록 안에서만 쓴다(type·description·properties·required·
53
+ * items·enum). 그래서 이 거르개에 걸리는 것은 사실상 **MCP 서버가 준 스키마**
54
+ * 뿐이다. 남이 만든 스키마에는 $schema·$ref·oneOf·additionalProperties 가
55
+ * 흔하다.
56
+ */
57
+ const 제미니열쇠 = new Set([
58
+ 'type', 'format', 'description', 'nullable', 'enum', 'items', 'properties',
59
+ 'required', 'minItems', 'maxItems', 'minimum', 'maximum', 'anyOf', 'title',
60
+ 'example', 'default', 'propertyOrdering',
61
+ ]);
62
+
63
+ /**
64
+ * format 은 값까지 본다.
65
+ *
66
+ * 열쇠는 받는데 모르는 **값**이면 거절하는 자리다. `format: 'uri'` ·
67
+ * `'uuid'` 같은 것이 MCP 스키마에 자주 있다. 지우면 뜻이 조금 얕아질 뿐이고,
68
+ * 실어 보내면 요청이 죽는다 — 어느 쪽이 나은지는 분명하다.
69
+ */
70
+ const 제미니format = {
71
+ string: new Set(['date-time', 'date', 'time', 'duration', 'enum']),
72
+ number: new Set(['float', 'double']),
73
+ integer: new Set(['int32', 'int64']),
74
+ };
75
+
76
+ /**
77
+ * 이 주소는 어느 회사인가.
78
+ *
79
+ * @returns {'anthropic'|'gemini'|'bedrock'|'openai'|'azure'|null} 모르면 null —
80
+ * 그때는 **아무것도 안 건드린다**.
81
+ *
82
+ * ── Azure 를 왜 따로 부르나 ─────────────────────────────────────────────
83
+ *
84
+ * 도구를 다듬는 데는 둘이 똑같다(아래 도구맞추기 에서 둘 다 이름만 손본다).
85
+ * 갈리는 자리는 **몸통**이다 — OpenAI 직통의 추론 모델은 출력 상한을 옛 이름
86
+ * (`max_tokens`)으로 주면 「지원 안 하는 인자」 라고 튕기는데, Azure 는 옛 판이
87
+ * 아직 많아서 그 이름을 그대로 받는다. 둘을 한 이름으로 묶어 두면 한쪽을
88
+ * 고치는 순간 다른 쪽이 깨진다.
89
+ *
90
+ * Azure 인지는 **azure.js 에게 묻는다.** 여기서 글자 조각으로 따로 판단하면
91
+ * detect 와 답이 갈리고, 갈리는 순간 한쪽만 맞는 자리가 생긴다.
92
+ */
93
+ export function 벤더(conn) {
94
+ let 호스트 = '';
95
+ try { 호스트 = new URL(String(conn?.base ?? '')).hostname.toLowerCase(); }
96
+ catch { 호스트 = ''; }
97
+
98
+ if (호스트) {
99
+ if (호스트 === 'anthropic.com' || 호스트.endsWith('.anthropic.com')) return 'anthropic';
100
+ if (호스트.endsWith('.googleapis.com')) return 'gemini';
101
+ // bedrock-runtime.<리전>.amazonaws.com. amazonaws 아래에는 남의 것도 많아서
102
+ // 앞머리까지 본다 — S3 주소를 Bedrock 으로 읽으면 안 된다.
103
+ if (호스트.startsWith('bedrock') && 호스트.endsWith('.amazonaws.com')) return 'bedrock';
104
+ if (애저인가(conn?.base)) return 'azure';
105
+ if (호스트 === 'api.openai.com') return 'openai';
106
+ }
107
+ /*
108
+ * 주소를 못 읽었을 때만 규격을 본다.
109
+ *
110
+ * 규격이 'anthropic' 이라는 것은 우리가 Anthropic **말투**로 말한다는 뜻이라,
111
+ * 상대가 그 규칙을 볼 확률이 높다. 주소가 있는데도 규격을 앞세우면, 사내
112
+ * 게이트웨이를 Anthropic 말투로 붙여 쓰는 사람이 손해를 본다.
113
+ */
114
+ if (!호스트 && conn?.kind === 'anthropic') return 'anthropic';
115
+ return null;
116
+ }
117
+
118
+ /** 규격에 맞는 이름으로. 못 쓰는 글자는 밑줄, 너무 길면 가운데를 지문으로 접는다. */
119
+ function 이름다듬기(원래, 쓴것) {
120
+ /*
121
+ * 못 쓰는 글자는 **덩어리째** 밑줄 하나로 바꾸고, 지문을 붙인다.
122
+ *
123
+ * 글자마다 밑줄로 바꾸면 이렇게 된다 —
124
+ *
125
+ * mcp__사내문서__검색 → mcp__________
126
+ * mcp__사내문서__열기 → mcp__________
127
+ * mcp__사내문서__지우기 → mcp___________
128
+ *
129
+ * 겹치는 것은 뒤에 번호를 붙여 면할 수 있다. 그런데 **모델이 보는 이름이
130
+ * 서로 구별이 안 된다.** 「검색해 줘」 라고 했는데 지우기가 불릴 수 있고,
131
+ * 그건 오류도 안 난다 — 이 파일이 없애려던 바로 그 고장이다.
132
+ *
133
+ * 그래서 지문을 붙인다. 읽히지는 않아도 **서로 다르고, 판마다 같다.**
134
+ * 무엇을 하는 도구인지는 설명이 그대로 지고 간다(`[사내문서] 검색`) —
135
+ * 이름이 못 읽히는 자리에서 뜻을 지는 것은 설명 쪽이다.
136
+ */
137
+ const 원본 = String(원래 ?? '');
138
+ let 새것 = 원본.replace(/[^a-zA-Z0-9_-]+/g, '_');
139
+ if (새것 !== 원본) 새것 = `${새것.replace(/_+$/, '')}_${지문(원본)}`;
140
+ if (!새것 || !/[a-zA-Z0-9]/.test(새것)) 새것 = `tool_${지문(원본)}`;
141
+ if (새것.length > 이름최대) {
142
+ /*
143
+ * 앞뒤를 남기고 가운데를 지문으로 접는다.
144
+ *
145
+ * 앞만 남기고 자르면 안 된다. `mcp__아주긴서버이름__read` 와 `..__write`
146
+ * 가 앞 64자에서 **똑같아진다**. 우리 쪽은 뒤에 번호를 붙여 겹침을 면할
147
+ * 수 있지만, 모델이 보는 이름은 둘 다 `mcp__아주긴서버이름…` 이라
148
+ * 무엇이 읽기고 무엇이 쓰기인지 알 길이 없다. 그러면 모델은 아무거나
149
+ * 고르고, 우리는 그것을 오류로도 못 잡는다.
150
+ *
151
+ * 도구 이름에서 뜻을 지고 있는 자리는 **뒤쪽**(도구 이름)이고, 앞쪽은
152
+ * 어느 서버인지를 말한다. 둘 다 남겨야 이름이 이름 노릇을 한다.
153
+ * 가운데를 지문으로 채우는 것은 그러고도 겹치지 않게 하려는 것이다.
154
+ */
155
+ const 지문6 = 지문(원래);
156
+ const 앞몫 = 26;
157
+ const 뒷몫 = 이름최대 - 앞몫 - 지문6.length - 2; // 밑줄 둘
158
+ 새것 = `${새것.slice(0, 앞몫)}_${지문6}_${새것.slice(-뒷몫)}`;
159
+ }
160
+ // 그래도 겹치면 뒤에 숫자를 붙인다. 겹치는 일은 드물지만, 나면 조용하다.
161
+ if (쓴것.has(새것)) {
162
+ let n = 2;
163
+ let 후보 = '';
164
+ do { 후보 = 새것.slice(0, 이름최대 - String(n).length - 1) + '_' + n; n++; } while (쓴것.has(후보));
165
+ 새것 = 후보;
166
+ }
167
+ return 새것;
168
+ }
169
+
170
+ /**
171
+ * 짧은 지문. 암호용이 아니라 이름이 안 겹치게 하려는 것뿐이다.
172
+ *
173
+ * **뒷자리를 남긴다.** 앞자리를 남기면 안 된다 — 32비트 값을 36진수로 적으면
174
+ * 길어야 일곱 자라, 여덟 자로 맞추려고 앞을 0 으로 채우게 된다. 그 상태에서
175
+ * 앞 여섯 자를 떼면 채워 넣은 0 을 세고 있는 셈이고, 정작 제일 자주 바뀌는
176
+ * 뒷자리 둘이 통째로 날아간다. 21억 가지로 갈리라고 만든 것이 330만 가지로
177
+ * 줄었다(이름 20만 개로 재 보니 겹침이 4건에서 5,825건으로 늘었다).
178
+ *
179
+ * 겹치면 아래에서 뒤에 번호를 붙여 면하는데, 그 번호는 **목록 차례를 탄다.**
180
+ * 그러니 지문이 겹치는 순간 이 파일이 없애려던 고장이 그대로 돌아온다 —
181
+ * MCP 서버가 다시 붙어 도구 차례가 바뀌면 검색과 지우기가 이름을 맞바꾼다.
182
+ */
183
+ function 지문(글, 자릿수 = 6) {
184
+ let h = 2166136261;
185
+ const s = String(글);
186
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
187
+ return (h >>> 0).toString(36).padStart(자릿수, '0').slice(-자릿수);
188
+ }
189
+
190
+ /**
191
+ * Gemini 가 받는 모양으로 스키마를 다시 짓는다.
192
+ *
193
+ * 못 받는 열쇠를 빼고 나면 `type` 이 없어지는 마디가 생긴다($ref 만 있던 곳).
194
+ * 그대로 두면 그 마디가 뜻 없는 빈 것이 되므로, 남은 것을 보고 갈래를
195
+ * 되짚어 준다. 되짚을 것조차 없으면 string 으로 둔다 — 없는 것보다 낫다.
196
+ */
197
+ function 제미니스키마(값) {
198
+ if (Array.isArray(값)) return 값.map(제미니스키마);
199
+ if (!값 || typeof 값 !== 'object') return 값;
200
+
201
+ const 새것 = {};
202
+ for (const [k, v] of Object.entries(값)) {
203
+ if (!제미니열쇠.has(k)) continue;
204
+ if (k === 'properties' && v && typeof v === 'object') {
205
+ const 속성 = {};
206
+ for (const [n, s] of Object.entries(v)) 속성[n] = 제미니스키마(s);
207
+ 새것.properties = 속성;
208
+ continue;
209
+ }
210
+ if (k === 'items' || k === 'anyOf') { 새것[k] = 제미니스키마(v); continue; }
211
+ 새것[k] = v;
212
+ }
213
+
214
+ /*
215
+ * `type: ['string','null']` 은 JSON Schema 의 말이지 OpenAPI 의 말이 아니다.
216
+ * 널을 받는다는 뜻이었으니 nullable 로 옮겨 적는다 — 그냥 버리면 "널을
217
+ * 줘도 된다" 는 정보가 사라진다.
218
+ */
219
+ if (Array.isArray(새것.type)) {
220
+ const 진짜 = 새것.type.filter((t) => t !== 'null');
221
+ if (새것.type.length !== 진짜.length) 새것.nullable = true;
222
+ 새것.type = 진짜[0] ?? 'string';
223
+ }
224
+
225
+ if (새것.format !== undefined) {
226
+ const 받는것 = 제미니format[String(새것.type)];
227
+ if (!받는것 || !받는것.has(String(새것.format))) delete 새것.format;
228
+ }
229
+
230
+ if (새것.type === undefined) {
231
+ if (새것.properties) 새것.type = 'object';
232
+ else if (새것.items) 새것.type = 'array';
233
+ else if (새것.anyOf) { /* anyOf 는 갈래를 안 적어도 된다 */ }
234
+ else 새것.type = 'string';
235
+ }
236
+ return 새것;
237
+ }
238
+
239
+ /** 규격이 요구하는 「인자는 객체」 를 지킨다. 빈 것도 객체여야 한다. */
240
+ function 객체로(p) {
241
+ if (p && typeof p === 'object' && !Array.isArray(p) && p.type === 'object') return p;
242
+ if (p && typeof p === 'object' && !Array.isArray(p) && p.properties) return { ...p, type: 'object' };
243
+ return { type: 'object', properties: {} };
244
+ }
245
+
246
+ /**
247
+ * 보내기 직전 다듬기.
248
+ *
249
+ * @param {Array|null} tools toolSchemas() 가 낸 OpenAI 모양 목록
250
+ * @param {object} conn
251
+ * @returns {{tools: Array|null, 되돌림: Map<string,string>|null, 손본것: {이름: number, 스키마: number}}}
252
+ *
253
+ * **되돌림이 null 이면 이름을 하나도 안 바꿨다는 뜻이다.** 부르는 쪽이 그
254
+ * 경우에 아무 일도 안 하도록, 빈 Map 대신 null 을 준다.
255
+ */
256
+ export function 도구맞추기(tools, conn) {
257
+ const 그대로 = { tools, 되돌림: null, 손본것: { 이름: 0, 스키마: 0 } };
258
+ if (!Array.isArray(tools) || !tools.length) return 그대로;
259
+ const v = 벤더(conn);
260
+ if (!v) return 그대로;
261
+
262
+ const 되돌림 = new Map();
263
+ const 쓴것 = new Set();
264
+ let 이름손봄 = 0;
265
+ let 스키마손봄 = 0;
266
+
267
+ const 새목록 = tools.map((t) => {
268
+ const f = t?.function ?? t;
269
+ const 원래이름 = String(f?.name ?? '');
270
+ let 이름 = 원래이름;
271
+ if (!이름규칙.test(이름)) {
272
+ 이름 = 이름다듬기(원래이름, 쓴것);
273
+ 되돌림.set(이름, 원래이름);
274
+ 이름손봄++;
275
+ }
276
+ 쓴것.add(이름);
277
+
278
+ let 인자 = f?.parameters ?? f?.input_schema ?? null;
279
+ if (v === 'gemini') {
280
+ const 다듬은것 = 객체로(제미니스키마(인자));
281
+ if (JSON.stringify(다듬은것) !== JSON.stringify(인자)) 스키마손봄++;
282
+ 인자 = 다듬은것;
283
+ } else if (v === 'anthropic' || v === 'bedrock') {
284
+ // 이 둘은 「인자는 객체」 를 규격으로 못 박는다. 나머지 열쇠는 안 건드린다 —
285
+ // 확인 못 한 것을 깎으면, 멀쩡히 쓰던 MCP 도구의 뜻이 조용히 얕아진다.
286
+ const 다듬은것 = 객체로(인자);
287
+ if (다듬은것 !== 인자) 스키마손봄++;
288
+ 인자 = 다듬은것;
289
+ }
290
+
291
+ if (이름 === 원래이름 && 인자 === (f?.parameters ?? f?.input_schema ?? null)) return t;
292
+ // 원래 없던 인자 칸을 만들어 두지 않는다. 이름만 고친 도구에 빈 칸이
293
+ // 새로 생기면, 그 자리가 무엇이었는지 밖에서 알 길이 없어진다.
294
+ const 새함수 = { ...f, name: 이름 };
295
+ if (인자 != null) 새함수.parameters = 인자;
296
+ return { ...t, function: 새함수 };
297
+ });
298
+
299
+ return {
300
+ tools: 새목록,
301
+ 되돌림: 되돌림.size ? 되돌림 : null,
302
+ 손본것: { 이름: 이름손봄, 스키마: 스키마손봄 },
303
+ };
304
+ }
305
+
306
+ /**
307
+ * 답에 실린 도구 이름을 원래 것으로 되돌린다.
308
+ *
309
+ * 안 바꾼 이름은 표에 없다 — 그때는 그대로 둔다. 표에 없다고 지우거나
310
+ * 비우면, 다듬을 일이 없던 도구까지 못 부르게 된다.
311
+ */
312
+ export function 이름되돌리기(message, 되돌림) {
313
+ if (!되돌림 || !message?.toolCalls?.length) return message;
314
+ let 바꾼적있나 = false;
315
+ const 새것 = message.toolCalls.map((tc) => {
316
+ const 이름 = tc?.function?.name ?? tc?.name;
317
+ const 원래 = 되돌림.get(String(이름 ?? ''));
318
+ if (원래 === undefined) return tc;
319
+ 바꾼적있나 = true;
320
+ return tc.function
321
+ ? { ...tc, function: { ...tc.function, name: 원래 } }
322
+ : { ...tc, name: 원래 };
323
+ });
324
+ return 바꾼적있나 ? { ...message, toolCalls: 새것 } : message;
325
+ }
package/src/commands.js CHANGED
@@ -12,7 +12,7 @@ import { allowEndpoint } from './safety/network.js';
12
12
  import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
13
13
  import { 주소가리기 } from './safety/secrets.js';
14
14
  import { pick, confirm } from './ui/prompt.js';
15
- import { load, save, resolveKey, upsert, 열쇠보관, configPath } from './config.js';
15
+ import { load, save, 저장시도, resolveKey, upsert, 열쇠보관, configPath } from './config.js';
16
16
  import { 지금상태 as 지금열쇠상태 } from './safety/authcmd.js';
17
17
  import { 제공자고르기 } from './providers/index.js';
18
18
  import { 종, 알릴만한초 } from './ui/notify.js';
@@ -171,6 +171,23 @@ function 경로처럼보이나(line) {
171
171
  }
172
172
 
173
173
  // 반환: { handled, exit? } handled=false 면 모델에게 보낸다.
174
+
175
+ /**
176
+ * 설정을 남기고, 못 남겼으면 화면에 한 줄 (config.js 의 저장시도).
177
+ *
178
+ * 여태 아홉 자리가 전부 `try { save(cfg) } catch {}` 였다. 못 남겨도 이번 판에는
179
+ * 먹으니 대화는 계속되는데, 바로 다음 줄에서 화면은 `✓ 바꿨습니다` 를 찍는다.
180
+ * 사람은 정해진 줄 알고 창을 닫았다가 다음에 옛 값을 보고, 그때는 무엇 때문인지
181
+ * 알 길이 없다 — 홈이 읽기 전용인지, 디스크가 찼는지.
182
+ *
183
+ * @returns {boolean} 남겼나
184
+ */
185
+ function 설정남기기(cfg, 옵션 = {}) {
186
+ const r = 저장시도(cfg, 옵션);
187
+ if (!r.ok) say(` ${mark.warn} ${c.yellow(말('common.cfgSaveFailed', { 왜: clip(r.왜, 70) }))}`);
188
+ return r.ok;
189
+ }
190
+
174
191
  /** /mode 아래에 규칙을 늘어놓는다. 아무것도 안 걸려 있으면 아무 말도 안 한다. */
175
192
  function 규칙보이기(규칙들) {
176
193
  if (!규칙들) return;
@@ -411,7 +428,7 @@ export async function handle(line, session, ctx) {
411
428
  // 「화면 말을 따라간다」 가 아니라 옛 값이 되살아난다.
412
429
  if (지시말따로정했나()) cfg.promptLang = 지시말();
413
430
  else delete cfg.promptLang;
414
- try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
431
+ 설정남기기(cfg);
415
432
  const p = 옮긴만큼();
416
433
  say('');
417
434
  say(` ${mark.ok} ${말('lang.changed', { 이름: 이름(언어()) })}`);
@@ -542,7 +559,7 @@ export async function handle(line, session, ctx) {
542
559
  return { handled: true };
543
560
  }
544
561
  cfg.bell = 켜는말.includes(값);
545
- try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
562
+ 설정남기기(cfg);
546
563
  say('');
547
564
  say(` ${mark.ok} ${말(cfg.bell ? 'bell.turnedOn' : 'bell.turnedOff')}`);
548
565
  // 켤 때는 한 번 울려 준다. "켰다는데 소리가 나나?" 를 그 자리에서 확인하게.
@@ -606,7 +623,7 @@ export async function handle(line, session, ctx) {
606
623
  }
607
624
 
608
625
  cfg.motion = 고른것.값;
609
- try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
626
+ 설정남기기(cfg);
610
627
  적용하기(cfg.motion);
611
628
 
612
629
  say('');
@@ -822,18 +839,32 @@ export async function handle(line, session, ctx) {
822
839
  case 'undo': {
823
840
  const n = Math.max(1, parseInt(arg, 10) || 1);
824
841
  const r = ctx.history.undo(n);
825
- ctx.audit.undo({ turns: r.turns, files: r.restored.length });
842
+ /*
843
+ * 세는 수는 **진짜로 되돌아간 것**이다.
844
+ *
845
+ * 전에는 restored.length 를 그대로 썼다. 그 배열에는 못 되돌린 것과
846
+ * 손대지 않은 것(바이너리처럼 내용을 못 떠 둔 파일)까지 들어 있어서,
847
+ * 하나도 못 되돌린 판에도 「파일 2개를 되돌렸습니다」 가 나올 수 있었다.
848
+ * 감사기록에도 그 부풀린 수가 그대로 남았다.
849
+ */
850
+ const 되돌린수 = r.되돌린수 ?? r.restored.length;
851
+ ctx.audit.undo({ turns: r.turns, files: 되돌린수 });
826
852
  if (!r.restored.length) {
827
853
  say(` ${c.gray(말('undo.nothing'))}`);
828
854
  say('');
829
855
  return { handled: true };
830
856
  }
831
- say(` ${mark.ok} ${말('undo.done', { turns: r.turns, files: r.restored.length })}`);
857
+ say(` ${mark.ok} ${말('undo.done', { turns: r.turns, files: 되돌린수 })}`);
832
858
  for (const f of r.restored) say(` ${c.gray(ctx.scope.show(f.path))} ${c.gray(f.how)}`);
859
+ // 못 되돌린 것이 있으면 그것만 따로 못 박는다. 위 목록에 섞여 있으면 안 읽힌다.
860
+ if (r.못한것?.length) {
861
+ say(` ${mark.warn} ${말('undo.failed', { n: r.못한것.length })}`);
862
+ }
833
863
 
834
864
  // /diff 가 세던 것에서도 뺀다. 되돌린 파일이 '바뀐 파일' 로 남아 있으면
835
- // 사람도 되돌아간 줄 모른다.
836
- for (const f of r.restored) if (!f.skipped) session.changes.delete(f.path);
865
+ // 사람도 되돌아간 줄 모른다. **되돌아간 것만** 뺀다 — 못 되돌린 파일을
866
+ // 여기서 빼면 진짜로 바뀌어 있는 파일이 /diff 에서도 사라진다.
867
+ for (const f of r.restored) if (f.ok === true) session.changes.delete(f.path);
837
868
  // 상태줄의 '되돌릴 턴' 도 여기서 줄여 준다. 안 줄이면 방금 되돌린 것을
838
869
  // 아직 되돌릴 수 있는 것처럼 세고 있게 된다.
839
870
  try { session.되돌릴턴 = ctx.history.turns().length; } catch { /* 못 세면 그냥 둔다 */ }
@@ -971,6 +1002,13 @@ export async function handle(line, session, ctx) {
971
1002
  say(` ${c.gray(pad(말('status.keyStore'), 10))} ${열쇠보관(load())}`);
972
1003
  }
973
1004
  say(` ${c.gray(pad(말('status.rules'), 10))} ${session.rules ? session.rules.name : 말('status.noRules')}`);
1005
+ // 파일이 있는데 못 읽은 것을 '없음' 으로 적으면, 규칙을 적어 둔 사람은
1006
+ // 걸려 있다고 믿는다. 안 걸린 채로 도는 것이 여기서 제일 나쁜 모양이다.
1007
+ if (session.규칙못읽음) {
1008
+ say(` ${c.gray(pad('', 10))} ${mark.warn} ${c.yellow(말('status.rulesUnread', {
1009
+ 이름: session.규칙못읽음.이름, 까닭: session.규칙못읽음.까닭,
1010
+ }))}`);
1011
+ }
974
1012
  const caps = [
975
1013
  k.tools ? c.green(말('status.capTools')) : c.red(말('status.capTools')),
976
1014
  k.streaming ? c.green(말('status.capStream')) : c.gray(말('status.capStream')),
@@ -1205,6 +1243,10 @@ export async function handle(line, session, ctx) {
1205
1243
  say(` ${c.gray('모델에게는')} ${c.white('mcp__<서버>__<도구>')} ${c.gray('라는 이름으로 보입니다.')}`);
1206
1244
  say(` ${c.yellow('※')} ${c.gray('이 도구들은 남의 프로그램이 돌립니다 —')} ${c.white('작업 범위(' + session.root + ') 를 안 지킵니다.')}`);
1207
1245
  say(` ${c.gray(' 무엇을 불렀는지는')} ${c.white('.deel/audit.jsonl')} ${c.gray('에 남습니다.')}`);
1246
+ // 바로 위 줄이 약속이다. 못 지키고 있으면 여기서 말해야 한다.
1247
+ if (ctx?.audit?.못쓴것?.()) {
1248
+ say(` ${mark.warn} ${c.yellow('지금 그 파일에 못 적고 있습니다')} ${c.gray('— /증거 에서 까닭을 보세요.')}`);
1249
+ }
1208
1250
  say('');
1209
1251
  return { handled: true };
1210
1252
  }
@@ -1254,7 +1296,19 @@ export async function handle(line, session, ctx) {
1254
1296
  }
1255
1297
  say('');
1256
1298
  say(` ${c.gray('이어하려면 나갔다가')} ${c.cyan('deel --continue')} ${c.gray('또는')} ${c.cyan(`deel --resume ${rows[0].id}`)}`);
1257
- say(` ${c.gray('지금 대화는 나가지 않아도 계속 저장되고 있습니다.')}`);
1299
+ /*
1300
+ * 바로 아래 줄이 약속이다. 못 지키고 있으면 약속을 되풀이하면 안 된다.
1301
+ *
1302
+ * /mcp 화면에서 감사기록을 두고 한 것과 같은 자리다 — 「남습니다」 라고
1303
+ * 적어 놓고 실제로는 안 남는 것이 제일 나쁘다. 이 화면은 이어하기를
1304
+ * 보러 온 화면이라, 여기서 안 말하면 사람이 알 자리가 없다.
1305
+ */
1306
+ const 저장샘 = ctx?.갈래?.현재store?.()?.못쓴것?.();
1307
+ if (저장샘) {
1308
+ say(` ${mark.warn} ${c.yellow(말('store.notSaving', { n: 저장샘.수, 까닭: 저장샘.까닭 }))}`);
1309
+ } else {
1310
+ say(` ${c.gray('지금 대화는 나가지 않아도 계속 저장되고 있습니다.')}`);
1311
+ }
1258
1312
  say('');
1259
1313
  return { handled: true };
1260
1314
  }
@@ -1659,13 +1713,26 @@ function showThink(session, { 자세히 = false } = {}) {
1659
1713
  const 개발자 = session.level === '개발자';
1660
1714
  const 단계 = t.rows.map((r) => `${r.label} ${r.level}`).join(c.gray(' · '));
1661
1715
 
1716
+ /*
1717
+ * 이 화면은 여태 통째로 한국어였다.
1718
+ *
1719
+ * `/lang en` 으로 켠 사람에게 「추론 강도 medium (첫 판단 medium · …)」 이
1720
+ * 뜨면 아무것도 안 읽힌다. 명령 이름만 영어고 화면이 한국어인 자리는
1721
+ * 「덜 옮겨졌다」 가 아니라 「고장 났다」 로 읽힌다 — i18n/index.js 머리말.
1722
+ *
1723
+ * 예로 드는 명령도 말을 따라간다. `/think 배분 절약` 은 영어로 켠 사람이
1724
+ * 칠 수 없는 글자다. 두 이름 다 진짜로 받는다(위 배분말·자세히 갈래).
1725
+ */
1726
+ const 한국어 = 언어() === 'ko';
1727
+ const 배분예 = 한국어 ? '/think 배분 절약' : '/think profile save';
1728
+ const 자세히예 = 한국어 ? '/think 자세히' : '/think detail';
1662
1729
  say('');
1663
- say(` ${c.gray('추론 강도')} ${c.bold(session.think)} ${c.gray(`(${단계})`)}`);
1664
- if (개발자 || 자세히) say(` ${c.gray('배분')} ${c.bold(t.name)} ${c.gray(t.desc)}`);
1730
+ say(` ${c.gray(말('think.effort'))} ${c.bold(session.think)} ${c.gray(`(${단계})`)}`);
1731
+ if (개발자 || 자세히) say(` ${c.gray(pad(말('think.profile'), 한국어 ? 8 : 10))}${c.bold(t.name)} ${c.gray(t.desc)}`);
1665
1732
 
1666
1733
  if (자세히) {
1667
1734
  say('');
1668
- say(` ${c.gray(pad('단계', 12) + pad('강도', 10) + pad('출력상한', 10) + '언제')}`);
1735
+ say(` ${c.gray(pad(말('think.col.stage'), 12) + pad(말('think.col.level'), 10) + pad(말('think.col.cap'), 10) + 말('think.col.when'))}`);
1669
1736
  for (const r of t.rows) {
1670
1737
  const 화살 = r.moved > 0 ? c.yellow('↑') : r.moved < 0 ? c.cyan('↓') : c.gray('·');
1671
1738
  say(` ${pad(r.label, 12)}${화살} ${pad(r.level, 8)}${pad(r.cap.toLocaleString(), 10, 'right')} ${c.gray(r.why)}`);
@@ -1673,21 +1740,21 @@ function showThink(session, { 자세히 = false } = {}) {
1673
1740
  say('');
1674
1741
  // 상한이 어디서 왔는지 밝힌다. 세 줄이 같은 값일 때 그게 고장인지 아닌지
1675
1742
  // 이 한 줄로 갈린다 — 아는 상한이 낮으면 셋이 같아지는 것이 맞다.
1676
- say(` ${c.gray('출력 상한은')} ${c.white((아는상한 ?? 16384).toLocaleString())} ${c.gray(
1677
- session.conn.maxTokens ? '(직접 정하신 값)' : session.conn.maxOut ? '(서버에서 알아낸 값)' : '(모르는 값이라 기본값)',
1678
- )}${c.gray(' 안에서 나눕니다 ')} ${c.cyan('/out')}`);
1679
- say(` ${c.gray('컨텍스트')} ${c.white(t.ctx.toLocaleString())}${c.gray(' · 지금 찬 양 ')}${c.white(t.used.toLocaleString())}`);
1743
+ const 어디서 = session.conn.maxTokens ? 말('think.cap.you')
1744
+ : session.conn.maxOut ? ('think.cap.server') : 말('think.cap.guess');
1745
+ say(` ${c.gray(말('think.cap.head'))} ${c.white((아는상한 ?? 16384).toLocaleString())} ${c.gray(어디서)}${c.gray(말('think.cap.tail'))} ${c.cyan('/out')}`);
1746
+ say(` ${c.gray(말('think.ctx'))} ${c.white(t.ctx.toLocaleString())}${c.gray(말('think.ctx.used'))}${c.white(t.used.toLocaleString())}`);
1680
1747
  say('');
1681
- say(` ${c.gray('강도')} ${THINK_LEVELS.join(' · ')} ${c.gray('')} ${c.cyan('/think high')}`);
1682
- say(` ${c.gray('배분')} ${Object.entries(PROFILES).map(([k, v]) => `${k}(${v.name})`).join(' · ')} ${c.gray('')} ${c.cyan('/think 배분 절약')}`);
1748
+ say(` ${c.gray(말('think.effort'))} ${THINK_LEVELS.join(' · ')} ${c.gray(말('think.eg'))} ${c.cyan('/think high')}`);
1749
+ say(` ${c.gray(말('think.profile'))} ${Object.entries(PROFILES).map(([k, v]) => { const n = 한국어 ? v.name : (v.en ?? v.name); return n === k ? k : `${k}(${n})`; }).join(' · ')} ${c.gray(말('think.eg'))} ${c.cyan(배분예)}`);
1683
1750
  } else if (개발자) {
1684
- say(` ${c.gray('강도')} ${c.cyan('/think high')} ${c.gray('배분')} ${c.cyan('/think 배분 절약')} ${c.gray('자세히')} ${c.cyan('/think 자세히')}`);
1751
+ say(` ${c.gray(말('think.effort'))} ${c.cyan('/think high')} ${c.gray(말('think.profile'))} ${c.cyan(배분예)} ${c.gray(말('think.detail'))} ${c.cyan(자세히예)}`);
1685
1752
  } else {
1686
- say(` ${c.gray('더 세게')} ${c.cyan('/think high')} ${c.gray('더 빠르게')} ${c.cyan('/think low')}`);
1753
+ say(` ${c.gray(말('think.harder'))} ${c.cyan('/think high')} ${c.gray(말('think.faster'))} ${c.cyan('/think low')}`);
1687
1754
  }
1688
1755
 
1689
1756
  if (!session.conn.think && session.think !== 'off') {
1690
- say(` ${c.yellow('이 연결은 모델 층 강도 조절이 적용되지 않습니다.')} ${c.gray('출력 상한만 단계별로 적용됩니다.')}`);
1757
+ say(` ${c.yellow(말('think.nomodel'))} ${c.gray(말('think.nomodel.note'))}`);
1691
1758
  }
1692
1759
  say('');
1693
1760
  }
@@ -1789,6 +1856,19 @@ function 증거명령(session, ctx, arg = '') {
1789
1856
  say('');
1790
1857
  rule('작업 증거', 70);
1791
1858
 
1859
+ /*
1860
+ * 기록이 새고 있으면 **목록보다 먼저** 말한다.
1861
+ *
1862
+ * 이 화면은 감사기록을 읽어서 만든다. 기록이 안 적히면 목록이 짧아지는
1863
+ * 게 아니라 「아무것도 안 했다」로 보인다 — 아래 이른 반환이 그 자리다.
1864
+ * 안 한 것을 말하라고 만든 화면이 안 한 것처럼 보이게 하면 안 된다.
1865
+ */
1866
+ if (e.기록못씀) {
1867
+ say(` ${mark.warn} ${c.yellow(`감사기록 ${e.기록못씀.수}건이 안 적혔습니다`)} ${c.gray(`— ${clip(e.기록못씀.까닭, 60)}`)}`);
1868
+ say(` ${c.gray('아래는 실제로 한 것보다 짧습니다.')}`);
1869
+ say('');
1870
+ }
1871
+
1792
1872
  if (!e.바꾼것.length && !e.돌린것.length) {
1793
1873
  say(` ${c.gray('이번 대화에서 아직 바꾸거나 돌린 것이 없습니다.')}`);
1794
1874
  say('');
@@ -2377,7 +2457,7 @@ async function 출력상한(session, arg = '') {
2377
2457
 
2378
2458
  if (말 === 'auto' || 말 === '자동') {
2379
2459
  session.conn.maxTokens = null;
2380
- if (prof) { delete prof.maxTokens; try { save(cfg); } catch {} }
2460
+ if (prof) { delete prof.maxTokens; 설정남기기(cfg); }
2381
2461
  say(` ${mark.ok} 직접 정한 값을 지웠습니다.`);
2382
2462
  say(` ${c.gray(알아낸것 ? `서버에서 알아낸 ${알아낸것.toLocaleString()} 토큰을 씁니다.` : '모르는 값이라 16,384 토큰으로 갑니다.')}`);
2383
2463
  say('');
@@ -2393,7 +2473,7 @@ async function 출력상한(session, arg = '') {
2393
2473
  return;
2394
2474
  }
2395
2475
  session.conn.maxTokens = 값;
2396
- if (prof) { prof.maxTokens = 값; try { save(cfg); } catch {} }
2476
+ if (prof) { prof.maxTokens = 값; 설정남기기(cfg); }
2397
2477
  say(` ${mark.ok} 답 길이 상한 ${c.bold(값.toLocaleString())} 토큰`);
2398
2478
  say(` ${c.gray('모델이 못 내는 값을 넣으면 서버가 거절합니다. 거절당하면')} ${c.cyan('/out auto')} ${c.gray('로 되돌리세요.')}`);
2399
2479
  say('');
@@ -2765,7 +2845,7 @@ async function 나가도되나묻기(session, p) {
2765
2845
  async function 골라적용(session, cfg, p) {
2766
2846
  if (!await 나가도되나묻기(session, p)) return;
2767
2847
  cfg.active = p.id;
2768
- try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 바뀐다 */ }
2848
+ 설정남기기(cfg);
2769
2849
  연결적용(session, p);
2770
2850
  say(` ${mark.ok} ${c.bold(p.name)} ${c.gray(p.model)} 로 바꿨습니다. 대화는 이어집니다.`);
2771
2851
  say('');
@@ -2794,8 +2874,8 @@ async function 모델만바꾸기(session, cfg, 모델) {
2794
2874
  // 서버는 그대로지만 문은 같이 지난다. 등록 안 된 서버로 옮겨 가는 길도
2795
2875
  // 여기라서, 여기를 열어 두면 자물쇠에 구멍이 하나 남는다.
2796
2876
  if (!await 나가도되나묻기(session, p)) return;
2797
- if (!이미) { try { save(upsert(cfg, p)); } catch { /* 못 남겨도 이번 세션에는 바뀐다 */ } }
2798
- else { cfg.active = p.id; try { save(cfg); } catch {} }
2877
+ if (!이미) 설정남기기(upsert(cfg, p));
2878
+ else { cfg.active = p.id; 설정남기기(cfg); }
2799
2879
  연결적용(session, p);
2800
2880
  say(` ${mark.ok} 모델을 ${c.bold(모델)} 로 바꿨습니다. ${c.gray('서버는 그대로입니다.')}`);
2801
2881
  await 길이맞추기(session, cfg, p);
@@ -2817,7 +2897,7 @@ async function 길이맞추기(session, cfg, prof) {
2817
2897
  session.conn.ctx = 값;
2818
2898
  if (prof) {
2819
2899
  prof.ctx = 값;
2820
- try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
2900
+ 설정남기기(cfg);
2821
2901
  }
2822
2902
  say(` ${c.gray('컨텍스트')} ${c.white(값.toLocaleString())} ${c.gray('토큰 (' + fmtSize(값) + ') — ' + (r?.source ? r.source + '에서 읽음' : '서버가 안 알려줘 기본값'))}`);
2823
2903
  if (r?.max && r?.loaded && r.max > r.loaded) {
package/src/config.js CHANGED
@@ -164,6 +164,23 @@ export function save(cfg, { toProject = false } = {}) {
164
164
  return p;
165
165
  }
166
166
 
167
+ /**
168
+ * 설정을 남긴다. 못 남겨도 던지지 않고, **왜 못 남겼는지를 돌려준다.**
169
+ *
170
+ * 부르는 자리가 아홉 군데인데 전부 `try { save(cfg) } catch {}` 였다. 「못
171
+ * 남겨도 이번 세션에는 먹는다」 는 맞는 말이지만, 그 뒤에 화면은 `✓` 를
172
+ * 찍는다 — 사람은 정해진 줄 알고 다음에 창을 열었다가 옛 값을 본다. 그리고
173
+ * 그때는 무엇 때문인지 알 길이 없다(홈이 읽기 전용인지, 디스크가 찼는지).
174
+ *
175
+ * 던지는 것은 안 바꾼다. 설정 하나 못 적었다고 대화가 끊기면 본말이 뒤집힌다.
176
+ *
177
+ * @returns {{ok: true, 자리: string} | {ok: false, 왜: string}}
178
+ */
179
+ export function 저장시도(cfg, 옵션 = {}) {
180
+ try { return { ok: true, 자리: save(cfg, 옵션) }; }
181
+ catch (err) { return { ok: false, 왜: err?.message ?? String(err) }; }
182
+ }
183
+
167
184
  export function activeProfile(cfg = load()) {
168
185
  if (!cfg.profiles.length) return null;
169
186
  return cfg.profiles.find((x) => x.id === cfg.active) ?? cfg.profiles[0];
@@ -172,6 +189,23 @@ export function activeProfile(cfg = load()) {
172
189
  // 환경변수가 있으면 파일보다 우선한다 — 사내망에서 키를 파일에 안 남기고 싶을 때 쓴다.
173
190
  const 푼것 = new Map(); // 잠긴 값 → 푼 글. 한 판에 한 번만 풀면 된다.
174
191
 
192
+ /**
193
+ * 잠근 열쇠를 못 푼 까닭. 화면이 한 번 읽어 가면 지워진다.
194
+ *
195
+ * ── 왜 있어야 하나 ──────────────────────────────────────────────────────
196
+ *
197
+ * keystore.js 의 풀기() 는 왜 못 풀었는지를 성실하게 만들어 돌려준다 —
198
+ * 「이 PC 의 이 계정에서 잠근 것만 풀립니다」 같은 말이다. 그 함수 머리말이
199
+ * 그렇게 하는 까닭까지 적어 뒀다: **빈 글자만 돌려주면 사람은 401 만 보고
200
+ * 게이트웨이를 의심한다.**
201
+ *
202
+ * 그런데 부르는 쪽이 그 말을 버리고 있었다. 빈 열쇠가 나가면 http.js 는
203
+ * Authorization 머리말을 아예 안 붙이고, 게이트웨이는 401 을 준다. 사람은
204
+ * 방화벽·주소·계정을 뒤진다. 진짜 까닭은 「설정 파일을 다른 PC 로 옮겼다」 인데.
205
+ */
206
+ let 열쇠탈 = null;
207
+ export function 열쇠탈소식() { const s = 열쇠탈; 열쇠탈 = null; return s; }
208
+
175
209
  export function resolveKey(profile) {
176
210
  const byName = profile?.id ? process.env[`DEEL_KEY_${profile.id.toUpperCase()}`] : null;
177
211
  const 값 = byName || process.env.DEEL_API_KEY || profile?.apiKey || '';
@@ -180,8 +214,19 @@ export function resolveKey(profile) {
180
214
  // 기다리는 시간이라, 판에 한 번만 풀고 들고 있는다.
181
215
  if (푼것.has(값)) return 푼것.get(값);
182
216
  const r = 풀기(값);
183
- 푼것.set(값, r.ok ? r.text : '');
184
- return r.ok ? r.text : '';
217
+ if (!r.ok) {
218
+ /*
219
+ * **실패는 안 담아 둔다.**
220
+ *
221
+ * 담아 두면 그 판 내내 다시 시도조차 안 한다. 사람이 그 사이에
222
+ * `deel setup` 으로 열쇠를 다시 넣어도 이 판에서는 영영 안 풀린다.
223
+ * 푸는 값이 0.2초라, 안 되는 것을 아껴서 얻을 것이 없다.
224
+ */
225
+ 열쇠탈 = r.why || '잠근 열쇠를 못 풀었습니다';
226
+ return '';
227
+ }
228
+ 푼것.set(값, r.text);
229
+ return r.text;
185
230
  }
186
231
 
187
232
  export function upsert(cfg, profile) {