deel-local-cli 1.7.0 → 1.8.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/src/i18n/zh.js CHANGED
@@ -94,7 +94,7 @@ export const zh = {
94
94
  'consult.failed': '问不成 — {왜}',
95
95
  'consult.empty': '返回是空的。那个模型可能不带工具就不回答。',
96
96
  'consult.added': '已把那个回答加进对话,并标明是谁说的。',
97
- 'head.spec.ollama': 'Ollama API',
97
+ 'head.spec.ollama': 'Ollama 自有规格',
98
98
  'head.spec.openai': 'OpenAI 兼容 API',
99
99
  'head.spec.anthropic': 'Anthropic API',
100
100
  'head.mode.default': '本机内(默认)',
@@ -265,6 +265,151 @@ export const zh = {
265
265
  'ask.freePrompt': '回答',
266
266
  'loop.backoff': '服务端把请求推回来了 ({무엇}) — {초} 秒后再试 ({n}/{max})',
267
267
  'cost.retries': '重试',
268
+ // ── 计数词 ────────────────────────────────────────────────────────────
269
+ 'unit.files': '{n}个文件',
270
+ 'unit.files1': '1个文件',
271
+ 'unit.hits': '{n}处匹配',
272
+ 'unit.hits1': '1处匹配',
273
+ 'unit.lines': '{n}行',
274
+ 'unit.lines1': '1行',
275
+ 'unit.spots': '{n}处',
276
+ 'unit.spots1': '1处',
277
+ 'unit.places': '{n}处',
278
+ 'unit.places1': '1处',
279
+ 'unit.count': '{n}个',
280
+ 'unit.count1': '1个',
281
+ 'unit.chars': '{n}字',
282
+ 'unit.kchars': '{n}k字',
283
+ 'unit.calls': '{n}次',
284
+ 'unit.sec': '{n}秒',
285
+ 'unit.min': '{n}分钟',
286
+
287
+ 'sum.done': '完成',
288
+ 'sum.ok': '成功',
289
+ 'sum.exitCode': '退出码 {code}',
290
+ 'sum.killedBy': '被 {시그널} 结束',
291
+ 'sum.partial': '仅部分',
292
+ 'sum.cut': '已截断',
293
+ 'sum.notAllSeen': '未全部扫描',
294
+ 'sum.filesFailed': '{n}个失败',
295
+ 'sum.spotsFailed': '{n}处失败',
296
+ 'sum.moved': '移动{n}个',
297
+ 'sum.total': '共{줄}',
298
+ 'sum.addedLines': '+{줄}',
299
+ 'sum.noVision': '当前模型看不了图片',
300
+ 'sum.emptyAnswer': '空回答',
301
+ 'sum.unread': '读不了{n}个',
302
+ 'sum.doneOf': '{끝}/{전체} 完成',
303
+ 'sum.justDone': '刚完成{n}个',
304
+ 'sum.noChange': '没有变化',
305
+ 'sum.verified': '核对{n}项',
306
+ 'sum.readAs': '用 {쓴것} 转换后读取',
307
+ 'sum.linesOf': '{준}/{전체}行',
308
+ 'sum.countOf': '{n}/{전체}个',
309
+ 'sum.remembered': '已记住({n}行)',
310
+ 'sum.rememberFull': '位置满了,已移除最旧的',
311
+ 'sum.recall': '过往对话 {전체}条中 {맞음}条',
312
+ 'sum.jobStarted': '以{n}号启动',
313
+ 'sum.jobRunning': '运行中',
314
+ 'sum.jobEndedAlready': '已经结束',
315
+ 'sum.jobStopped': '已结束',
316
+
317
+ 'tier.exact': '完全匹配',
318
+ 'tier.trail': '忽略行尾空白和换行差异',
319
+ 'tier.indent': '忽略缩进差异',
320
+ 'tier.space': '忽略所有空白差异',
321
+
322
+ 'sum.image': '图片',
323
+ 'sum.imageDropped': '省略图片 {크기}',
324
+
325
+ 'sum.broken': '{n}处问题',
326
+ 'sum.unverified': '{n}项未核对',
327
+ 'lsp.noDef': '找不到定义。用 Grep 试试。',
328
+ 'lsp.defs': '定义 {n}',
329
+ 'lsp.noRefs': '没有任何地方用到。',
330
+ 'lsp.refs': '使用 {자리} · 文件 {파일}',
331
+
332
+ 'sum.nothingToCheck': '没有可核对的东西',
333
+ 'job.no': '{n}号',
334
+ 'job.stop': '结束',
335
+ 'more.files': '另外{n}个',
336
+ 'more.spots': '另外{n}处',
337
+
338
+ 'err.noSuchFile': '没有这个文件: {경로}',
339
+ 'err.alreadyThere': '已经存在: {경로}',
340
+ 'err.overwriteHint': '要覆盖请传 overwrite: true。',
341
+
342
+ 'run.ctxProbe': '正在确认模型启动时的上下文长度…',
343
+ 'run.noStream': '没有流式输出,回答会一次性出现',
344
+ 'run.hintHelp': '/help 命令列表',
345
+ 'run.hintEsc': 'ESC 中断',
346
+ 'run.hintQuit': 'Ctrl+C 中断·退出',
347
+ 'run.thinking': '思考中…',
348
+ 'run.thinkingChars': '思考中… {n}字',
349
+ 'run.turnFoot': '{초}秒 · 工具{n}次 · ↑{입력} ↓{출력}',
350
+ 'run.bye': '结束。',
351
+ 'run.byeStats': '模型调用{n}次 · 工具耗时{초}秒 · ↑{입력} ↓{출력}',
352
+ 'run.notMade': '— 没有被创建',
353
+
354
+ 'stage.plan': '先做判断',
355
+ 'stage.work': '接着做',
356
+ 'stage.fix': '卡住时',
357
+
358
+ 'scr.tools': '工具',
359
+ 'scr.conn': '连接',
360
+ 'scr.session': '本次会话',
361
+ 'status.kind': '规格',
362
+ 'status.url': '地址',
363
+ 'status.proxy': '代理',
364
+ 'status.proxyOff': '用不了',
365
+ 'status.model': '模型',
366
+ 'status.root': '工作目录',
367
+ 'status.shell': 'Shell',
368
+ 'status.keyStore': '密钥存放',
369
+ 'status.rules': '规则',
370
+ 'status.noRules': '无(可用 /init 建一个)',
371
+ 'status.caps': '支持',
372
+ 'status.capTools': '工具',
373
+ 'status.capStream': '流式',
374
+ 'status.capSchema': '结构化',
375
+ 'status.capThink': '推理',
376
+ 'cost.calls': '模型调用',
377
+ 'cost.tokIn': '输入 token',
378
+ 'cost.tokOut': '输出 token',
379
+ 'cost.toolMs': '工具耗时',
380
+ 'cost.elapsed': '已用时',
381
+ 'cost.money': '费用',
382
+ 'cost.quota': '服务器额度',
383
+ 'cost.quotaAge': '基于{초}秒前的响应',
384
+
385
+ 'scr.context': '上下文',
386
+ 'unit.tokens': '{n} token',
387
+ 'ctx.system': '系统提示',
388
+ 'ctx.rules': '规则 ({이름})',
389
+ 'ctx.rulesNone': '规则 (无)',
390
+ 'ctx.memory': '记忆 ({n}行)',
391
+ 'ctx.learned': '在这里学到的',
392
+ 'ctx.skills': '技能列表 ({실림}/{전체})',
393
+ 'ctx.tools': '工具定义',
394
+ 'ctx.history': '对话历史',
395
+ 'ctx.toolResults': '工具结果 (文件{n}个)',
396
+ 'ctx.left': '剩余',
397
+ 'ctx.compactHint': '折叠对话',
398
+ 'ctx.clearHint': '全部清空',
399
+ 'ctx.calibrated': '已按服务器实测值校正 {부호}{퍼센트}%(采样{번}次)。',
400
+ 'ctx.estimate': '数字为估算 — 没有使用精确的分词器。',
401
+
402
+ 'auth.waiting': '正在等待公司登录…',
403
+ 'auth.got': '已取得密钥 — 大约可用{분}分钟',
404
+ 'auth.failed': '没能取得密钥 — {왜}',
405
+ 'auth.from': '取得',
406
+ 'auth.fromPolicy': '取得(管理策略)',
407
+ 'auth.leftMin': '剩余{분}分钟',
408
+ 'auth.notYet': '尚未取得',
409
+
410
+ 'undo.wayRestored': '已还原',
411
+ 'undo.wayDeleted': '已删除(原本不存在的文件)',
412
+
268
413
  'common.unknownCommand': '不认识的命令',
269
414
  'common.seeHelp': '用 /help 看命令列表',
270
415
  'common.yes': '是',
package/src/oneshot.js CHANGED
@@ -11,7 +11,8 @@
11
11
  // 그래서 '묻는 자리' 를 전부 없앤 길을 따로 낸다. 에이전트 루프는 그대로 쓴다 —
12
12
  // 여기서 루프를 다시 짜면 두 벌이 되고, 언젠가 한쪽만 고쳐진다.
13
13
  import { c, mark, clip } from './ui/ansi.js';
14
- import { 규칙모으기 } from './safety/policy.js';
14
+ import { 규칙모으기, 정책읽기 } from './safety/policy.js';
15
+ import { 받기설정 } from './safety/authcmd.js';
15
16
  import { run } from './agent/loop.js';
16
17
  import { Session } from './agent/session.js';
17
18
  import { makeScope } from './safety/guard.js';
@@ -63,7 +64,7 @@ async function 표준입력읽기() {
63
64
  // 도구 한 줄 요약. 화면 그림이 아니라 로그에 남을 글이라 색을 아낀다.
64
65
  function 도구줄(name, args) {
65
66
  const a = args ?? {};
66
- const 첫 = a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ?? a.목적 ??
67
+ const 첫 = a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ?? a.purpose ?? a.목적 ??
67
68
  (a.command ? String(a.command).replace(/\s+/g, ' ') : null) ??
68
69
  // 뒤에서 도는 명령. 번호가 곧 그 일감의 이름이라, 이게 없으면 기록에
69
70
  // `Jobs()` 만 여러 줄 남아 나중에 무엇을 본 것인지 알 수 없다.
@@ -154,6 +155,14 @@ export async function runOnce(opts = {}) {
154
155
  const conn = {
155
156
  kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
156
157
  key: resolveKey(prof), model: prof.model,
158
+ /*
159
+ * 열쇠를 갖고 있는 대신 **받아 오는** 설정 (safety/authcmd.js).
160
+ *
161
+ * 여기서는 설정만 싣는다. 명령은 요청 직전에 부른다 — 켤 때 한 번
162
+ * 받아 두면 한 시간짜리 토큰이 세 시간짜리 대화 한복판에서 죽고,
163
+ * 그 401 은 화면에서 「열쇠가 틀렸다」 와 구별이 안 된다.
164
+ */
165
+ 열쇠받기: 받기설정(prof, { 정책값: 정책읽기().값 }),
157
166
  ctx: opts.ctx ?? prof.ctx ?? CTX_DEFAULT,
158
167
  // 답 길이 상한 — deel --max-tokens 32k 로 높일 수 있다(대화 화면의 /out 과 같은 값).
159
168
  maxTokens: opts.maxTokens ?? prof.maxTokens ?? null,
@@ -17,7 +17,7 @@ export const repoRoot = () => join(dirname(fileURLToPath(import.meta.url)), '..'
17
17
 
18
18
  const SKIP_DIRS = new Set(['node_modules', '.git', '.github', 'test', '.deel']);
19
19
  const SHIP = ['bin', 'src'];
20
- const SHIP_FILES = ['package.json', 'README.md', 'README.en.md', 'LICENSE'];
20
+ const SHIP_FILES = ['package.json', 'README.md', 'README.ko.md', 'LICENSE'];
21
21
 
22
22
  function walk(dir, base, out) {
23
23
  for (const e of readdirSync(dir, { withFileTypes: true })) {
package/src/repl.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
2
2
  import { createInterface, emitKeypressEvents } from 'node:readline';
3
- import { 규칙모으기 } from './safety/policy.js';
3
+ import { 규칙모으기, 정책읽기 } from './safety/policy.js';
4
+ import { 받기설정 } from './safety/authcmd.js';
4
5
  import { 주소가리기 } from './safety/secrets.js';
5
6
  import { homedir } from 'node:os';
6
7
  import { resolve, basename } from 'node:path';
@@ -8,7 +9,7 @@ import { c, say as 바로쓰기, mark, clip } from './ui/ansi.js';
8
9
  import { headerLines } from './ui/status.js';
9
10
  import { 종, 창제목, 제목되돌리기, 알릴까, 제목글 } from './ui/notify.js';
10
11
  import { 보이기 as 인트로, 기본곁말 } from './ui/intro.js';
11
- import { 언어잡기, 말 as 옮긴말 } from './i18n/index.js';
12
+ import { 언어잡기, 말 as 옮긴말, 세말 } from './i18n/index.js';
12
13
  import { 알림채움 } from './backend/retry.js';
13
14
  import { 화면고르기 } from './ui/screen.js';
14
15
  import { STAGES } from './agent/effort.js';
@@ -33,7 +34,7 @@ import { 못박기 } from './agent/pins.js';
33
34
  import { 카드 } from './agent/card.js';
34
35
  import { 배움 } from './agent/evolve.js';
35
36
  import { 마크다운 } from './ui/md.js';
36
- import { askHidden } from './ui/prompt.js';
37
+ import { askHidden, confirm } from './ui/prompt.js';
37
38
  import { explain, shows as levelShows } from './ui/level.js';
38
39
  import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/approve.js';
39
40
  import { 추천, 채울글 } from './ui/complete.js';
@@ -70,7 +71,7 @@ const TOOL_GLYPH = {
70
71
  function toolLabel(name, args) {
71
72
  const a = args ?? {};
72
73
  const first =
73
- a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ?? a.목적 ??
74
+ a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ?? a.purpose ?? a.목적 ??
74
75
  (a.command ? String(a.command).replace(/\s+/g, ' ').slice(0, 52) : null) ??
75
76
  // 뒤에서 도는 명령. 번호가 곧 그 일감의 이름이다 — 빈 괄호를 띄우면
76
77
  // 어느 것을 보고 있는지가 화면에서 사라진다. 서너 개를 띄워 놓고 나면
@@ -79,27 +80,27 @@ function toolLabel(name, args) {
79
80
  //
80
81
  // 이름 고르기는 jobs.js 에 맡긴다. 모델이 `job` 으로 보낼 수도 있는데,
81
82
  // 여기서 `a.번호` 만 보면 도구는 제대로 도는데 화면만 빈 괄호가 된다.
82
- (() => { const g = 일감인자(a); return g.번호 != null ? `${g.번호}번${g.끝내기 ? ' · 끝내기' : ''}` : null; })() ??
83
+ (() => { const g = 일감인자(a); return g.번호 != null ? `${옮긴말('job.no', { n: g.번호 })}${g.끝내기 ? ` · ${옮긴말('job.stop')}` : ''}` : null; })() ??
83
84
  // 한 번에 여러 파일을 쓸 때. 빈 괄호를 띄우면 화면만 보고는 무엇을
84
85
  // 만들었는지 알 수 없다 — 첫 파일과 개수를 적는다.
85
86
  (Array.isArray(a.files) && a.files.length
86
- ? `${a.files[0]?.file_path ?? '?'}${a.files.length > 1 ? ` ${a.files.length - 1}개` : ''}`
87
+ ? `${a.files[0]?.file_path ?? '?'}${a.files.length > 1 ? ` ${옮긴말('more.files', { n: a.files.length - 1 })}` : ''}`
87
88
  : null) ??
88
89
  // 한 번에 여러 군데를 고칠 때. 파일 수와 군데 수는 다르다 — 한 파일을
89
90
  // 여섯 군데 고치는 것이 보통이라 '외 5개' 라고 적으면 거짓이 된다.
90
91
  (Array.isArray(a.edits) && a.edits.length
91
- ? `${a.edits[0]?.file_path ?? '?'}${a.edits.length > 1 ? ` ${a.edits.length - 1}군데` : ''}`
92
+ ? `${a.edits[0]?.file_path ?? '?'}${a.edits.length > 1 ? ` ${옮긴말('more.spots', { n: a.edits.length - 1 })}` : ''}`
92
93
  : null) ??
93
- (Array.isArray(a.paths) && a.paths.length ? `${a.paths.length}개` : null) ??
94
+ (Array.isArray(a.paths) && a.paths.length ? 세말('count', a.paths.length) : null) ??
94
95
  // 할 일 목록은 보여줄 경로가 없다. 빈 괄호를 띄우느니 개수를 적는다.
95
- (Array.isArray(a.todos) ? `${a.todos.length}건` : null) ?? '';
96
+ (Array.isArray(a.todos) ? 세말('hits', a.todos.length) : null) ?? '';
96
97
  const g = TOOL_GLYPH[name] ?? c.cyan('⏺');
97
98
  const 안 = clip(String(first ?? ''), 56);
98
99
  return `${g} ${c.bold(name)}${안 ? `${c.gray('(')}${c.gray(안)}${c.gray(')')}` : ''}`;
99
100
  }
100
101
 
101
102
  function toolResultLine(result, ms) {
102
- const t = ms > 700 ? c.gray(` ${(ms / 1000).toFixed(1)}초`) : '';
103
+ const t = ms > 700 ? c.gray(` ${옮긴말('unit.sec', { n: (ms / 1000).toFixed(1) })}`) : '';
103
104
  if (result?.error) return `${c.red('└')} ${c.red(clip(String(result.error).split('\n')[0], 80))}${t}`;
104
105
 
105
106
  /*
@@ -111,16 +112,16 @@ function toolResultLine(result, ms) {
111
112
  */
112
113
  if (typeof result?.탈 === 'number') {
113
114
  const 조각 = [];
114
- if (result.탈) 조각.push(c.red(`탈 ${result.탈}개`));
115
- if (result.확인됨) 조각.push(c.green(`확인 ${result.확인됨}개`));
116
- if (result.못확인) 조각.push(c.yellow(`못 확인 ${result.못확인}개`));
115
+ if (result.탈) 조각.push(c.red(옮긴말('sum.broken', { n: result.탈 })));
116
+ if (result.확인됨) 조각.push(c.green(옮긴말('sum.verified', { n: result.확인됨 })));
117
+ if (result.못확인) 조각.push(c.yellow(옮긴말('sum.unverified', { n: result.못확인 })));
117
118
  return `${result.탈 ? c.red('└') : c.gray('└')} `
118
- + `${조각.join(c.gray(' · ')) || c.gray('확인할 것이 없었습니다')}${t}`;
119
+ + `${조각.join(c.gray(' · ')) || c.gray(옮긴말('sum.nothingToCheck'))}${t}`;
119
120
  }
120
121
 
121
122
  // 고친 자리는 몇 줄이 늘고 줄었는지를 요약 옆에 붙인다.
122
123
  const 셈 = result?.diff ? ` ${shortStat(result.diff)}` : '';
123
- return `${c.gray('└')} ${c.gray(clip(result?.summary ?? '완료', 80))}${셈}${t}`;
124
+ return `${c.gray('└')} ${c.gray(clip(result?.summary ?? 옮긴말('sum.done'), 80))}${셈}${t}`;
124
125
  }
125
126
 
126
127
  // 수준별로 몇 줄까지 펼칠지. 초보에게 60줄을 쏟으면 아무것도 안 읽는다.
@@ -185,6 +186,14 @@ export async function chatLoop(opts = {}) {
185
186
  const conn = {
186
187
  kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
187
188
  key: resolveKey(prof), model: prof.model,
189
+ /*
190
+ * 열쇠를 갖고 있는 대신 **받아 오는** 설정 (safety/authcmd.js).
191
+ *
192
+ * 여기서는 설정만 싣는다. 명령은 요청 직전에 부른다 — 켤 때 한 번
193
+ * 받아 두면 한 시간짜리 토큰이 세 시간짜리 대화 한복판에서 죽고,
194
+ * 그 401 은 화면에서 「열쇠가 틀렸다」 와 구별이 안 된다.
195
+ */
196
+ 열쇠받기: 받기설정(prof, { 정책값: 정책읽기().값 }),
188
197
  // 컨텍스트 길이. 순서가 곧 우선순위다 —
189
198
  // deel --ctx 655360 > 프로필에 저장된 값 > 기본값
190
199
  // 기본값으로 떨어졌다는 것은 '아직 못 쟀다' 는 뜻이다. 아래에서 그렇다고 말해 준다.
@@ -270,6 +279,44 @@ export async function chatLoop(opts = {}) {
270
279
  session.요금표 = cfg?.요금 ?? null;
271
280
  session.제공자 = prof?.제공자 ?? null;
272
281
 
282
+ /*
283
+ * ── 열쇠를 받아 오는 연결 (safety/authcmd.js) ───────────────────────
284
+ *
285
+ * 두 자리를 여기서 잇는다.
286
+ *
287
+ * 묻기 — 우리가 남이 적어 준 명령을 띄우려는 참이다. 한 번은 물어야
288
+ * 한다. 다만 **한 판에 한 번만** 묻는다 (authcmd 가 기억한다) —
289
+ * 한 시간짜리 토큰으로 세 시간 일하면 세 번 받아 오는데, 세 번
290
+ * 다 물으면 사람은 손이 가는 대로 누른다. 관리 정책이 준
291
+ * 명령은 아예 안 묻는다.
292
+ * 표시 — 사내 로그인은 브라우저를 열고 사람을 기다린다. 그동안 화면이
293
+ * 조용하면 사람은 멈춘 줄 안다. 무엇을 기다리는지 적는다.
294
+ */
295
+ if (conn.열쇠받기) {
296
+ session.열쇠물어보기 = async (설정) => {
297
+ say('');
298
+ say(` ${mark.warn} ${c.gray('이 연결은 열쇠를 받아 옵니다. 아래 명령을 띄웁니다:')}`);
299
+ say(` ${c.white(clip(설정.명령, 88))}`);
300
+ say(` ${c.gray('받은 열쇠는 이 판이 도는 동안 메모리에만 있고, 파일로 안 적습니다.')}`);
301
+ const 예 = await confirm(' 띄울까요?', true);
302
+ say('');
303
+ return 예;
304
+ };
305
+ session.onAuth = (것) => {
306
+ if (것?.type === '시작') return 화면.돌리기(옮긴말('auth.waiting'));
307
+ if (것?.type === '끝') return 화면.돌림멈춤('');
308
+ 화면.돌림멈춤('');
309
+ if (것?.ok) {
310
+ const 초 = Math.max(0, Math.round((것.만료 - Date.now()) / 1000));
311
+ return say(` ${mark.ok} ${c.gray(옮긴말('auth.got', { 분: Math.round(초 / 60) }))}`);
312
+ }
313
+ // 못 받았어도 대화를 안 끊는다. 원래 열쇠로 가 보고, 그것도 안 되면
314
+ // 서버가 한 말이 화면에 나온다. 여기서 끊으면 무엇이 문제인지 못 본다.
315
+ say(` ${mark.warn} ${c.gray(옮긴말('auth.failed', { 왜: 것?.왜 ?? '?' }))}`);
316
+ if (것?.보인것) say(` ${c.gray(clip(String(것.보인것), 88))}`);
317
+ };
318
+ }
319
+
273
320
  // ── 대화 이어하기 ─────────────────────────────────────────────────────
274
321
  // 껐다 켜도 이어지도록, 메시지가 오갈 때마다 .deel/sessions/ 에 바로 적는다.
275
322
  let store = null;
@@ -936,12 +983,12 @@ export async function chatLoop(opts = {}) {
936
983
  for (const f of files) {
937
984
  const 이름 = ctx?.scope ? ctx.scope.show(f.path) : f.path;
938
985
  if (f.missing) {
939
- say(` ${c.yellow('⚠')} ${c.white(이름)} ${c.gray('— 만들어지지 않았습니다')}`);
986
+ say(` ${c.yellow('⚠')} ${c.white(이름)} ${c.gray(옮긴말('run.notMade'))}`);
940
987
  continue;
941
988
  }
942
989
  if (f.dir) continue;
943
990
  const kb = f.bytes >= 1024 ? `${(f.bytes / 1024).toFixed(1)}KB` : `${f.bytes}B`;
944
- say(` ${c.green('✓')} ${c.white(이름)} ${c.gray(`· ${f.lines.toLocaleString()} · ${kb}`)}`);
991
+ say(` ${c.green('✓')} ${c.white(이름)} ${c.gray(`· ${세말('lines', f.lines)} · ${kb}`)}`);
945
992
  }
946
993
  };
947
994
 
@@ -1171,7 +1218,7 @@ export async function chatLoop(opts = {}) {
1171
1218
  if (opts.ctx == null) {
1172
1219
  // 이 컴퓨터 안의 서버면 눈 깜짝할 새다. 사내 게이트웨이는 몇 초 걸릴 수 있어
1173
1220
  // 무슨 일이 일어나는 중인지 알려 준다 — 멈춘 것처럼 보이면 안 된다.
1174
- 화면.돌리기('모델에 걸린 컨텍스트 길이를 확인하는 중…');
1221
+ 화면.돌리기(옮긴말('run.ctxProbe'));
1175
1222
  let r = null;
1176
1223
  try { r = await probeCtx(conn, { timeout: 6000 }); } catch { /* 못 물어보면 아래에서 처리 */ }
1177
1224
  화면.돌림멈춤('');
@@ -1258,14 +1305,14 @@ export async function chatLoop(opts = {}) {
1258
1305
  // 안 뜬 것은 조용히 빠지면 안 된다. "왜 그 도구가 없지" 를 영영 알 수 없다.
1259
1306
  for (const m of mcp붙임.못한것) warn.push(`MCP ${c.white(m.이름)} 을 못 붙였습니다 — ${m.왜}`);
1260
1307
  if (!conn.tools) warn.push('도구 호출이 확인되지 않았습니다 — deel diagnose 로 점검하세요');
1261
- if (!conn.streaming) warn.push('스트리밍이 없어 응답이 한 번에 나옵니다');
1308
+ if (!conn.streaming) warn.push(옮긴말('run.noStream'));
1262
1309
  warn.push(...길이경고);
1263
1310
  // 잘 된 것은 경고 표시를 달지 않는다. ⚠ 가 붙으면 뭘 고쳐야 하나 싶어진다.
1264
1311
  for (const l of 길이알림) say(` ${mark.ok} ${c.gray(l)}`);
1265
1312
  for (const w of warn) say(` ${mark.warn} ${c.gray(w)}`);
1266
1313
  // ESC 를 앞에 둔다 — 멈추는 길로는 이것이 먼저다. Ctrl+C 는 두 번 누르면
1267
1314
  // 끝나 버려서, 급히 멈추려던 사람이 대화를 통째로 닫는 일이 실제로 있었다.
1268
- say(` ${c.gray('/help 명령 목록')} ${c.gray('ESC 중단')} ${c.gray('Ctrl+C 중단·끝내기')}`);
1315
+ say(` ${c.gray(옮긴말('run.hintHelp'))} ${c.gray(옮긴말('run.hintEsc'))} ${c.gray(옮긴말('run.hintQuit'))}`);
1269
1316
 
1270
1317
  /*
1271
1318
  * ── 바깥으로 나가도 되는지 한 번 묻는다 ─────────────────────────────
@@ -1606,7 +1653,7 @@ export async function chatLoop(opts = {}) {
1606
1653
  break;
1607
1654
 
1608
1655
  case 'waiting':
1609
- 화면.기다림(c.gray(꼬리표(stage) + '생각 중…'));
1656
+ 화면.기다림(c.gray(꼬리표(stage) + 옮긴말('run.thinking')));
1610
1657
  // 지워야 할 줄이 화면에 있다고 표시해 둔다.
1611
1658
  //
1612
1659
  // 전에는 이 표시를 안 세웠다. \r 로 커서만 앞으로 보내 놓고 지우지는
@@ -1617,7 +1664,7 @@ export async function chatLoop(opts = {}) {
1617
1664
 
1618
1665
  case 'thinking':
1619
1666
  thinkChars += ev.text.length;
1620
- 화면.생각(`${mark.think} ${c.gray(꼬리표(stage))}${c.gray(`생각 중… ${thinkChars.toLocaleString()}자`)}`);
1667
+ 화면.생각(`${mark.think} ${c.gray(꼬리표(stage))}${c.gray(옮긴말('run.thinkingChars', { n: thinkChars.toLocaleString() }))}`);
1621
1668
  if (process.stdout.isTTY) thinkingShown = true;
1622
1669
  break;
1623
1670
 
@@ -2063,8 +2110,8 @@ export async function chatLoop(opts = {}) {
2063
2110
 
2064
2111
  // ── 꼬리말 — 이번 턴만의 숫자 ────────────────────────────────────────
2065
2112
  const secs = ((Date.now() - started) / 1000).toFixed(1);
2066
- const bits = [`${secs}초`];
2067
- if (tools) bits.push(`도구 ${tools}회`);
2113
+ const bits = [옮긴말('unit.sec', { n: secs })];
2114
+ if (tools) bits.push(`${옮긴말('scr.tools')} ${옮긴말('unit.calls', { n: tools })}`);
2068
2115
  const dIn = session.usage.in - before.in;
2069
2116
  const dOut = session.usage.out - before.out;
2070
2117
  if (dIn || dOut) bits.push(`↑${dIn.toLocaleString()} ↓${dOut.toLocaleString()}`);
@@ -2212,7 +2259,12 @@ export async function chatLoop(opts = {}) {
2212
2259
  // 끝맺음은 화면을 접기 **전에** 그린다. close() 가 상자를 걷어내므로,
2213
2260
  // 그 뒤에 찍으면 걷어낸 자리에 뜬금없이 한 줄이 남는다.
2214
2261
  say('');
2215
- say(` ${c.gray('끝냅니다.')} ${c.gray(`모델 호출 ${session.usage.calls}회 · 도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · ↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`)}`);
2262
+ say(` ${c.gray(옮긴말('run.bye'))} ${c.gray(옮긴말('run.byeStats', {
2263
+ n: session.usage.calls,
2264
+ 초: (session.usage.ms / 1000).toFixed(1),
2265
+ 입력: session.usage.in.toLocaleString(),
2266
+ 출력: session.usage.out.toLocaleString(),
2267
+ }))}`);
2216
2268
  say('');
2217
2269
  화면.close();
2218
2270
  return 0;
@@ -2227,6 +2279,8 @@ export async function chatLoop(opts = {}) {
2227
2279
  */
2228
2280
  function stageTag(ev, level) {
2229
2281
  if (!ev || level === '쉬움') return '';
2230
- const label = STAGES[ev.stage]?.label ?? ev.stage;
2282
+ // 아는 단계만 표를 거친다. 모르는 것에 말() 을 태우면 못 찾은 열쇠가
2283
+ // 그대로 화면에 찍혀 `stage.xyz 생각 중…` 이 된다.
2284
+ const label = STAGES[ev.stage] ? 옮긴말(`stage.${ev.stage}`) : ev.stage;
2231
2285
  return `${label}·${ev.level}`;
2232
2286
  }