deel-local-cli 2.1.2 → 2.1.4

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
@@ -323,7 +323,7 @@ deel --offline
323
323
  무엇이 어디로 갈 수 있는지는 켤 때 화면 맨 위에 늘 적혀 있습니다.
324
324
 
325
325
  ```
326
- deel 2.1.2 ⌂ 이 안
326
+ deel 2.1.4 ⌂ 이 안
327
327
  보냄 이 컴퓨터 안 127.0.0.1:11434 ← 여기 말고는 어디로도 안 갑니다
328
328
  ```
329
329
 
@@ -369,10 +369,12 @@ deel setup
369
369
  ```
370
370
  1. 열쇠만 있습니다 — 어디 것인지 찾아 드립니다 빈칸 1개
371
371
  2. 주소를 직접 넣기 (사내 게이트웨이 · 로컬) 빈칸 2개
372
- 3. OpenAI (GPT) 빈칸 1개
373
- 4. Anthropic (Claude) 빈칸 1개
374
- 5. Google (Gemini) 빈칸 1개
375
- 6. AWS Bedrock 빈칸 2개
372
+ 3. 브라우저로 로그인 — OpenRouter 빈칸 0개
373
+ 4. OpenAI (GPT) 빈칸 1개
374
+ 5. Anthropic (Claude) 빈칸 1개
375
+ 6. Google (Gemini) 빈칸 1개
376
+ 7. OpenRouter (Claude · GPT · Grok · Kimi …) 빈칸 1개
377
+ 8. AWS Bedrock 빈칸 2개
376
378
  ```
377
379
 
378
380
  1번이 핵심입니다. 열쇠 앞머리로 어디 것인지 짚어서 **한 곳만** 묻습니다.
@@ -387,6 +389,12 @@ deel setup
387
389
  서버로 갑니다. 401 이 오고 끝이지만 **열쇠는 이미 갔습니다.** 그래서 모르는
388
390
  열쇠는 짐작하지 않고 사람에게 묻습니다.
389
391
 
392
+ **3번은 열쇠를 안 넣습니다** (2.1.4). 브라우저가 열리고 OpenRouter 에 로그인하면 그 계정에
393
+ `deel` 이름의 열쇠가 생기고, deel 이 그것을 받아 잠가 둡니다. 열쇠 하나로 Claude · GPT · Grok · Kimi
394
+ 를 다 쓰고 쓴 만큼 그 계정에서 빠집니다. SSH 처럼 브라우저가 다른 기기에 있으면
395
+ `deel setup --no-browser` — 링크를 아무 기기에서 열고 뜬 코드를 붙여 넣습니다. Claude · Codex 구독
396
+ 로그인을 빌려 쓰지는 않습니다 — 그 로그인은 그 회사 도구용이라 남의 도구가 쓰면 어느 날 막힙니다.
397
+
390
398
  Bedrock 은 리전을 고릅니다 — 서울(`ap-northeast-2`) 포함 다섯 곳과 「직접 입력」.
391
399
  Claude 는 몸통 규격이 달라서 여섯 자리를 따로 흡수했습니다
392
400
  ([1.7.0 릴리스 노트](docs/ko/releases/1.x.md#170)).
@@ -1333,7 +1341,9 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
1333
1341
 
1334
1342
  | 판 | 무엇이 바뀌었나 |
1335
1343
  |---|---|
1336
- | **[2.1.2](docs/ko/releases/2.1.md#212)** | 정책 · 작은 공백 · 속도 — confirm 이 읽기만 하지 않는 MCP 도구를 묻고, 에디터(ACP) 세션은 16개를 넘으면 내려놓았다가 다음 말에 되살리고, `--check-rounds`·`--check-timeout` 과 과제별 검사 설정이 `deel eval` 에 생겼고, 시작 때 읽는 파일이 164개에서 18개로 줄었습니다 |
1344
+ | **[2.1.4](docs/ko/releases/2.1.md#214)** | 브라우저로 로그인 — `deel setup` 3번이 열쇠 없이 OpenRouter 계정 로그인(OAuth PKCE)으로 붙습니다. 열쇠 하나로 Claude · GPT · Grok · Kimi. SSH 는 `--no-browser`, `sk-or-` 열쇠를 알아보고, 긴 모델 목록은 이름으로 좁힙니다 |
1345
+ | [2.1.3](docs/ko/releases/2.1.md#213) | 제품 기능 — 도구를 글로 부르는 모델도 알아듣고, `deel run --continue`·`--resume`·`--events` 와 결과의 바뀐 파일(`files`)이 생겼고, git 저장소면 스크립트가 바꾼 것도 `/undo` 로 돌아가고, 언어 서버 없이도 문법을 보고, Outline 이 많이 불리는 파일부터 싣고, MCP 가 주소로 붙어 자료 · 프롬프트까지 씁니다 |
1346
+ | [2.1.2](docs/ko/releases/2.1.md#212) | 정책 · 작은 공백 · 속도 — confirm 이 읽기만 하지 않는 MCP 도구를 묻고, 에디터(ACP) 세션은 16개를 넘으면 내려놓았다가 다음 말에 되살리고, `--check-rounds`·`--check-timeout` 과 과제별 검사 설정이 `deel eval` 에 생겼고, 시작 때 읽는 파일이 164개에서 18개로 줄었습니다 |
1337
1347
  | [2.1.1](docs/ko/releases/2.1.md#211) | 전체 검수에서 나온 것 — 한 칸 위를 물으면 `config explain` 이 열쇠를 보였고, 웹훅 주소·Sentry DSN 이 셸 환경 거르기와 가리기를 둘 다 지났고, `kill` 로 strict 를 지나 Jobs 서버를 껐고, Def·Refs 가 EUC-KR 을 못 읽었습니다 · `deel eval` 고침 |
1338
1348
  | [2.1.0](docs/ko/releases/2.1.md#210) | 맡기면 멈추지 않고, 묻게 하면 diff 를 보여 줍니다 — 「다 됐다」 는 검사가 정합니다(`check`) · 얼마나 해내나는 골든셋으로 잽니다(`deel eval`) · 관리 정책 `"approval"` 로 회사가 승인 바닥을 겁니다 |
1339
1349
  | [2.0.3](docs/ko/releases/2.0.md#203) | 2.0.2 는 npm 에 못 나갔습니다 — 이제 GitHub 릴리스는 npm 에 올라간 뒤에만 생깁니다 |
package/README.md CHANGED
@@ -396,7 +396,7 @@ deel --offline
396
396
  The destination is printed at the top of every session:
397
397
 
398
398
  ```
399
- deel 2.1.2 ⌂ inside
399
+ deel 2.1.4 ⌂ inside
400
400
  Sends to this machine 127.0.0.1:11434 ← nowhere else
401
401
  ```
402
402
 
@@ -443,10 +443,12 @@ Instead of asking for a URL, it asks **where you're connecting**.
443
443
  ```
444
444
  1. I only have a key — I'll figure out where it goes 1 blank
445
445
  2. Enter an address (corporate gateway · local) 2 blanks
446
- 3. OpenAI (GPT) 1 blank
447
- 4. Anthropic (Claude) 1 blank
448
- 5. Google (Gemini) 1 blank
449
- 6. AWS Bedrock 2 blanks
446
+ 3. Log in with a browser — OpenRouter 0 blanks
447
+ 4. OpenAI (GPT) 1 blank
448
+ 5. Anthropic (Claude) 1 blank
449
+ 6. Google (Gemini) 1 blank
450
+ 7. OpenRouter (Claude · GPT · Grok · Kimi …) 1 blank
451
+ 8. AWS Bedrock 2 blanks
450
452
  ```
451
453
 
452
454
  Option 1 is the point — the key prefix decides which single vendor is asked.
@@ -461,6 +463,13 @@ Probing vendors one by one would send an Anthropic key to OpenAI's server and
461
463
  then to Google's. You get a 401 and stop — but **the key has already left.**
462
464
  So an unrecognized key is never guessed at; you are asked.
463
465
 
466
+ **Option 3 takes no key at all** (2.1.4). A browser opens; log in to OpenRouter and a key named
467
+ `deel` is created on that account, which deel receives and locks away. One key covers Claude, GPT,
468
+ Grok and Kimi, billed to that account as you use it. If the browser is on another machine (SSH),
469
+ use `deel setup --no-browser` — open the link anywhere and paste the code it shows. deel does not
470
+ borrow a Claude or Codex subscription login: those belong to the vendors' own tools, and a
471
+ third-party tool riding on them gets cut off one day.
472
+
464
473
  Bedrock asks for a region — five including Seoul (`ap-northeast-2`), plus
465
474
  "enter it yourself." Claude has a different wire shape, absorbed in six places
466
475
  ([1.7.0 release notes](docs/en/releases/1.x.md#170)).
@@ -1415,7 +1424,9 @@ so one run tells you everything.
1415
1424
 
1416
1425
  | Version | What changed |
1417
1426
  |---|---|
1418
- | **[2.1.2](docs/en/releases/2.1.md#212)** | Policy, small gaps and speed — confirm asks before MCP tools that are not read-only, editor (ACP) sessions past 16 are let go and brought back on the next message, `--check-rounds`/`--check-timeout` and per-task check settings in `deel eval`, and startup reads 18 files instead of 164 |
1427
+ | **[2.1.4](docs/en/releases/2.1.md#214)** | Log in with a browser — `deel setup` option 3 connects to OpenRouter by logging in (OAuth PKCE), no key to paste; one key for Claude, GPT, Grok and Kimi. `--no-browser` for SSH, `sk-or-` keys recognized, and long model lists narrow by name |
1428
+ | [2.1.3](docs/en/releases/2.1.md#213) | Product features — models that write tool calls as text are understood, `deel run --continue`/`--resume`/`--events` and changed `files` in the result, `/undo` brings back what a script changed in a git repository, a syntax check without a language server, Outline loads the most imported files first, and MCP attaches by URL with resources and prompts |
1429
+ | [2.1.2](docs/en/releases/2.1.md#212) | Policy, small gaps and speed — confirm asks before MCP tools that are not read-only, editor (ACP) sessions past 16 are let go and brought back on the next message, `--check-rounds`/`--check-timeout` and per-task check settings in `deel eval`, and startup reads 18 files instead of 164 |
1419
1430
  | [2.1.1](docs/en/releases/2.1.md#211) | From a full review — `config explain` one level up showed keys, webhook URLs and Sentry DSNs passed both the shell-env filter and masking, `kill` stopped a Jobs server past strict, Def/Refs could not read EUC-KR, and `deel eval` fixes |
1420
1431
  | [2.1.0](docs/en/releases/2.1.md#210) | Hand it off and it keeps going; make it ask and it shows the diff — a check decides when it is done (`check`), a golden set measures how much gets done (`deel eval`), and `"approval"` in the managed policy lets a company set an approval floor |
1421
1432
  | [2.0.3](docs/en/releases/2.0.md#203) | 2.0.2 never reached npm — GitHub releases now appear only after npm has the version |
package/bin/deel.js CHANGED
@@ -619,6 +619,8 @@ async function runSbom(flags) {
619
619
  const BOOL = new Set([
620
620
  'help', 'version', 'offline', 'online', 'continue', 'json', 'quiet', 'yes',
621
621
  'no-tui', 'tui', 'all', 'no-hooks',
622
+ // setup --no-browser — 브라우저로 로그인할 때 콜백 대신 코드를 붙여 넣는다 (2.1.4 · providers/login.js).
623
+ 'no-browser',
622
624
  /*
623
625
  * `hard` 가 여기 빠져 있었다. 그래서 `deel reset --hard all --yes` 는
624
626
  * `--hard` 가 뒤의 `all` 을 제 값으로 삼켰다 — 지울 갈래가 사라지니 화면은
@@ -655,6 +657,8 @@ const 값깃발 = new Set([
655
657
  'check', 'repeat',
656
658
  // run --check-rounds <수> · --check-timeout <초> — 설정의 checkRounds · checkTimeout 을 이 실행에서만 덮는다 (2.1.2).
657
659
  'check-rounds', 'check-timeout',
660
+ // run --events <파일> — 사건을 한 줄에 하나씩 JSON 으로 흘린다 (2.1.3 · src/runlog.js).
661
+ 'events',
658
662
  ]);
659
663
  // 값을 줘도 되고 안 줘도 되는 깃발. `--resume` 만 치면 이어 할 대화를 고른다.
660
664
  const 값골라깃발 = new Set(['resume']);
@@ -787,7 +791,11 @@ function help() {
787
791
  say(` ${c.cyan('deel run "<시킬 말>"')} 한 번만 돌고 끝내기 (스크립트·배치용)`);
788
792
  say(` ${c.cyan('deel scan')} 이 PC 에 떠 있는 로컬 서버 전부 찾기`);
789
793
  say(` ${c.cyan('deel sessions')} 이 폴더에 남아 있는 대화 목록`);
790
- say(` ${c.cyan('deel setup')} 연결 설정 (주소·키·모델)`);
794
+ say(` ${c.cyan('deel setup')} 연결 설정 (주소·키·모델 · 브라우저로 로그인)`);
795
+ // 새 줄은 두 말로 적는다 — 영어 도움말에 한국어 줄을 더 늘리지 않는다 (아래 영어 대목 머리말).
796
+ say(언어() === 'ko'
797
+ ? ` ${c.cyan('deel setup --no-browser')} 로그인 코드를 붙여 넣기 ${c.gray('(SSH · 브라우저가 다른 기기에 있을 때)')}`
798
+ : ` ${c.cyan('deel setup --no-browser')} Paste the login code instead ${c.gray('(SSH · browser on another machine)')}`);
791
799
  say(` ${c.cyan('deel status')} 연결 상태 보기`);
792
800
  say(` ${c.cyan('deel diagnose')} 저장된 연결로 진단 다시 돌리기`);
793
801
  say(` ${c.cyan('deel reset')} 설정·기억·기록 지우기 ${c.gray('(그냥 치면 보여만 줍니다)')}`);
@@ -914,6 +922,8 @@ function help() {
914
922
  // 새로 넣는 도움말 줄은 말() 로 적는다 — 영어로 켠 사람에게 한국어 줄을 더 새게 하지 않는다(test/langleak.test.js 의 래칫).
915
923
  say(` ${c.gray('--check <cmd>')} ${말('cli.checkFlag')}`);
916
924
  say(` ${c.gray('--check-rounds <n> · --check-timeout <s>')} ${말('cli.checkRoundsFlag')}`);
925
+ say(` ${c.gray('--continue · --resume <id>')} ${말('cli.runResumeFlag')}`);
926
+ say(` ${c.gray('--events <file>')} ${말('cli.eventsFlag')}`);
917
927
  /*
918
928
  * 답의 모양을 못 박는 자리 (src/agent/outschema.js).
919
929
  *
@@ -1141,6 +1151,10 @@ async function main() {
1141
1151
  check: flags.check !== undefined ? String(flags.check) : undefined,
1142
1152
  checkRounds: flags['check-rounds'] !== undefined ? String(flags['check-rounds']) : undefined,
1143
1153
  checkTimeout: flags['check-timeout'] !== undefined ? String(flags['check-timeout']) : undefined,
1154
+ // 대화를 잇는다 (2.1.3). 값 없는 --resume 은 true 로 넘어가 runOnce 가 64 로 세운다 — 여기는 고를 사람이 없다.
1155
+ continue: flags.continue === true,
1156
+ resume: flags.resume,
1157
+ events: flags.events !== undefined ? String(flags.events) : undefined,
1144
1158
  });
1145
1159
  case '':
1146
1160
  case 'chat':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
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",
package/src/acp/serve.js CHANGED
@@ -994,6 +994,10 @@ export async function acp(opts = {}) {
994
994
  : 옮긴말('check.skipped', { 까닭: ev.까닭 })})_\n\n`);
995
995
  break;
996
996
 
997
+ case 'textcalls':
998
+ 말하기(`\n\n_(${옮긴말('ev.textCalls', { n: ev.count, 이름: ev.names.join(', ') })})_\n\n`);
999
+ break;
1000
+
997
1001
  case 'nudge':
998
1002
  말하기(`\n\n_(${ev.why === '요청누락'
999
1003
  ? 옮긴말('ev.nudgeMissed', { n: ev.빠진?.length ?? 0 })
package/src/agent/loop.js CHANGED
@@ -9,6 +9,7 @@ import { isMutating, 셸이파일에쓰나 } from '../safety/guard.js';
9
9
  import { effortFor, tokensFor, fullCap, wasCut, shiftLevel, 자동강도, 천장고르기, 인사인가 as 인사말인가 } from './effort.js';
10
10
  import { 배울전선, 카드고치기, 카드저장꼴, 전선붙이기 } from '../backend/wire.js';
11
11
  import { 읽기만하나 } from '../backend/mcp.js';
12
+ import { 글속부름 } from '../backend/textcalls.js';
12
13
  import { 살린쓰기 } from './salvage.js';
13
14
  import { 배울것, 길이문제인가 } from '../backend/learn.js';
14
15
  import { compact, shouldCompact, shouldFold, foldToolResults, foldImages, 못박을것, 접힌파일열쇠 } from './compact.js';
@@ -1084,6 +1085,11 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1084
1085
  });
1085
1086
 
1086
1087
  let msg;
1088
+ /*
1089
+ * 생성 속도를 잰다 (2.1.3 · session.js 의 생성속도). 흘려받는 부름만 — 첫 글자(생각 포함)부터 끝까지.
1090
+ * 요청을 보낸 때부터 재면 앞머리 읽기(prefill)가 섞여, 대화가 길수록 모델이 느려진 것처럼 보인다.
1091
+ */
1092
+ let 생성잰것 = null;
1087
1093
  // 흘러온 글을 우리도 모아 둔다. 중간에 끊기면 이것만이 남는 전부다 —
1088
1094
  // chatStream 은 끝까지 가야 message 를 주므로, 끊긴 순간에는 msg 가 비어 있다.
1089
1095
  let 흘린것 = '';
@@ -1096,12 +1102,17 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1096
1102
  const 셈하기 = () => { session.usage.retries = (session.usage.retries ?? 0) + 미룬셈; 미룬셈 = 0; };
1097
1103
  const 끝셈 = (err) => { if (err?.name === 'Aborted') 미룬셈 = 0; else 셈하기(); };
1098
1104
  const askModel = async function* (maxTokens, think, { 한번에 = false } = {}) {
1105
+ 생성잰것 = null;
1099
1106
  if (conn.streaming && !한번에) {
1107
+ let 첫글때 = 0;
1100
1108
  try {
1101
1109
  for await (const ev of chatStream(conn, ask(maxTokens, think))) {
1102
1110
  셈하기(); // 무슨 소식이든 왔다는 것은 앞의 다시 부름이 실제로 있었다는 뜻
1103
- if (ev.type === 'done') msg = ev.message;
1104
- else {
1111
+ if (ev.type === 'done') {
1112
+ msg = ev.message;
1113
+ if (첫글때) 생성잰것 = { ms: Date.now() - 첫글때 };
1114
+ } else {
1115
+ if (!첫글때 && (ev.type === 'content' || ev.type === 'thinking') && ev.text) 첫글때 = Date.now();
1105
1116
  if (ev.type === 'content') 흘린것 += ev.text ?? '';
1106
1117
  // 맞기 전에 비켜 준 것은 **다시 부른 것이 아니다.** 요청은 한 번만
1107
1118
  // 나갔다. 이걸 같이 세면 `/cost` 의 「다시 부른 횟수」 와 `--json` 의
@@ -1566,6 +1577,11 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1566
1577
  if (msg.usage?.잰것 === false) session.usage.못잰것 = (session.usage.못잰것 ?? 0) + 1;
1567
1578
  session.usage.in += msg.usage?.in ?? 0;
1568
1579
  session.usage.out += msg.usage?.out ?? 0;
1580
+ // 한 번에 받았거나 글이 하나도 안 흘렀으면 안 센다 — 느린 값을 지어내지 않는다 (위 생성잰것).
1581
+ if (생성잰것?.ms > 0 && (msg.usage?.out ?? 0) > 0) {
1582
+ session.usage.genMs = (session.usage.genMs ?? 0) + 생성잰것.ms;
1583
+ session.usage.genOut = (session.usage.genOut ?? 0) + msg.usage.out;
1584
+ }
1569
1585
  // 캐시가 얼마나 맞았나. 읽기와 쓰기를 따로 센다 — 「매번 쓰기만 하고 한
1570
1586
  // 번도 못 읽는」 것과 「잘 읽고 있는」 것은 완전히 다른 상태다.
1571
1587
  session.usage.cacheRead = (session.usage.cacheRead ?? 0) + (msg.usage?.cacheRead ?? 0);
@@ -1596,6 +1612,24 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1596
1612
  // 배운 배수를 디스크에도 남긴다. 다음에 켤 때 이 값으로 시작한다.
1597
1613
  if (새보정) ctx.배움?.보정본것(conn.model, 새보정);
1598
1614
 
1615
+ /*
1616
+ * ── 글로 적은 도구 부름을 건진다 (2.1.3 · backend/textcalls.js) ──────────
1617
+ *
1618
+ * 서버가 모델의 부름 꼴을 모르면 `tool_calls` 는 비어 오고 답 글에 `<tool_call>{…}` 이 실린다.
1619
+ * 여태는 「말로만 답했다」 로 읽고 턴을 끝냈다. 이번 걸음에 **내준 도구 이름**으로 적힌 것만
1620
+ * 건져 진짜 부름으로 바꾼다 — 이력에도 제 꼴(tool_calls · tool 결과)로 실려, 다음 요청은
1621
+ * 네이티브 부름과 똑같이 나간다. 건진 부름도 아래 관문(모드 · 승인 · 정책 바닥)을 그대로 지난다.
1622
+ * 거절 답은 안 건진다 — 안 하겠다는 말 속 예시를 부르면 안 된다.
1623
+ */
1624
+ if (!msg.toolCalls?.length && !msg.거절 && typeof msg.content === 'string' && tools.length) {
1625
+ const 건짐 = 글속부름(msg.content, tools.map((t) => t.function.name));
1626
+ if (건짐) {
1627
+ msg.toolCalls = 건짐.부름.map((x, i) => ({ id: `text_${steps}_${i + 1}`, name: x.name, args: x.args }));
1628
+ msg.content = 건짐.남은글;
1629
+ yield { type: 'textcalls', names: msg.toolCalls.map((x) => x.name), count: msg.toolCalls.length };
1630
+ }
1631
+ }
1632
+
1599
1633
  session.push(assistantMessage(conn.kind, msg));
1600
1634
 
1601
1635
  /*
@@ -2316,6 +2350,8 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
2316
2350
  // 하위가 쓴 토큰·시간도 이번 턴의 셈에 들어가야 한다. 안 그러면 /context 가 거짓말을 한다.
2317
2351
  session.usage.in += 자식.usage.in;
2318
2352
  session.usage.out += 자식.usage.out;
2353
+ session.usage.genMs = (session.usage.genMs ?? 0) + (자식.usage.genMs ?? 0);
2354
+ session.usage.genOut = (session.usage.genOut ?? 0) + (자식.usage.genOut ?? 0);
2319
2355
  /*
2320
2356
  * 새로 생긴 네 칸도 같이 더한다.
2321
2357
  *
@@ -707,6 +707,72 @@ export const 표 = {
707
707
  ],
708
708
  };
709
709
 
710
+ /*
711
+ * ── 일본어 · 중국어 (2.1.3) ─────────────────────────────────────────────
712
+ *
713
+ * 화면 말은 네 가지(ko · en · ja · zh)인데 여기는 한국어 · 영어만 알았다. 일본어 · 중국어로 시키면
714
+ * 무슨 말이든 종합에 남았고, 고치라는 말(손대라했나)도 몰라서 「説明して、直して」 같은 말에서
715
+ * 읽기 전용 모드를 빼 주지 못했다.
716
+ *
717
+ * 한국어 규칙처럼 촘촘하지는 않다 — 신호가 뚜렷한 말만 넣고, 문턱은 그대로 둔다. 애매하면 안 옮긴다는
718
+ * 원칙이 먼저다. 같은 뜻을 두 말에 따로 적으면 한 문장이 두 번 세어지므로(한자 「全部」 는 둘 다에
719
+ * 있다) 겹치는 것은 한 규칙에 모은다.
720
+ */
721
+ // 고치라는 말. 진행형(〜している)·부정(〜しないで)은 아니다. 「計画を作って」 는 계획을 달라는 말이다.
722
+ const 일본어시킴말 = /(?:(?<!(?:計画|プラン|方針|段取り)を?)作(?:って|成して)|実装して|追加して|書いて|直して|修正して|変更して|削除して|消して|置き換えて|書き換えて|リファクタ(?:リング)?して|移動して|リネームして)(?!い[るまた]|な[いく])/;
723
+ /*
724
+ * 중국어는 끝바꿈이 없어서 「고쳐라」 와 「고쳤다 · 어떻게 고쳤나」 가 같은 글자다. 그래서 시키는 표시
725
+ * (请 · 帮我 · 给我 · 麻烦 · 帮忙) 뒤이거나 문장 첫머리일 때만 시킴말로 본다. 하지 말라는 말(不要 · 别 …)은
726
+ * 아니고, 「写一个计划」 처럼 계획을 달라는 말도 아니다.
727
+ */
728
+ const 중국어시킴말 = /(?<!不要|别|不用|无需|不需要|不必|请勿|勿)(?:(?:请|帮我|给我|麻烦你?|帮忙)\s*(?:把[^。!?!?\n]{0,30}?)?(?:实现|添加|增加|新增|编写|写|创建|修复|修改|改|删除|删掉|去掉|重构|重命名|替换|更新)|(?:^|[。!?!?\n;;,,]\s*)(?:实现|添加|增加|新增|编写|创建|修复|修改|删除|删掉|重构|重命名)(?![的了吗呢]))(?!(?:一个|一份|个|份)?(?:计划|方案|规划))/;
729
+
730
+ const 다른말규칙 = {
731
+ debug: [
732
+ [/エラー|例外|バグ|不具合/, 4],
733
+ [/動か(?:ない|なく|ず)|うまく(?:いか|動か)ない|失敗(?:する|した|します|して)|落ちる|クラッシュ/, 4],
734
+ [/なぜ[^。??\n]{0,15}(?:ない|ません)/, 3],
735
+ [/报错|错误|异常|崩溃|闪退/, 4],
736
+ [/(?:不能|无法|没法)(?:运行|启动|工作|使用|编译|连接|登录)|(?:跑|启动|运行)不(?:起来|了)|不起作用|失败/, 4],
737
+ [/为什么[^。??\n]{0,15}(?:不|没)/, 3],
738
+ ],
739
+ plan: [
740
+ [/(?:計画|プラン|段取り)を?(?:立て|作っ|考え|練っ)/, 5],
741
+ [/(?:制定|做|写|列|出)(?:一个|一份|个|份)?(?:计划|方案|规划)/, 5],
742
+ [/先[^。!?\n]{0,6}(?:规划|计划)/, 5],
743
+ ],
744
+ architect: [
745
+ [/設計|アーキテクチャ/, 5],
746
+ [/(?:構成|構造)を?どう|どう分け/, 5],
747
+ [/架构/, 5],
748
+ [/(?:怎么|如何)(?:拆分|划分|分层|组织)/, 5],
749
+ [/设计(?:一下|方案|模块|接口|结构)/, 5],
750
+ ],
751
+ inspect: [
752
+ [/レビュー|点検|監査|脆弱性/, 5],
753
+ [/チェックして|見直して/, 4],
754
+ [/审查|审核|评审|审计|漏洞/, 5],
755
+ [/检查(?:一下|下)/, 4],
756
+ ],
757
+ ask: [
758
+ [/とは(?:何|なに)|って何|何を(?:する|している)(?:もの|の)|何(?:ですか|なの)/, 5],
759
+ [/説明して|教えて/, 5],
760
+ [/どう(?:やって|すれば)[^。??\n]{0,12}(?:使|動|設定|実行|起動)/, 5],
761
+ [/意味/, 3],
762
+ [/是什么|什么意思|啥意思/, 5],
763
+ [/解释|讲解|说明一下|介绍一下/, 5],
764
+ [/(?:怎么|如何)(?:用|使用|运行|配置|安装|启动|调用)/, 5],
765
+ ],
766
+ orchestrator: [
767
+ [/全部|すべて|全て|一つずつ|最後まで|所有|逐个|一个一个|从头到尾/, 3],
768
+ ],
769
+ code: [
770
+ [일본어시킴말, 5],
771
+ [중국어시킴말, 5],
772
+ ],
773
+ };
774
+ for (const [mode, 규칙들] of Object.entries(다른말규칙)) 표[mode].push(...규칙들);
775
+
710
776
  // 이만큼은 되어야 바꾼다. 못 미치면 종합 모드 그대로 둔다.
711
777
  //
712
778
  // 읽기 전용 모드는 문턱을 높인다. 잘못 보내면 파일을 못 고쳐서 사용자가 막힌다.
@@ -1207,7 +1273,7 @@ const 영어손대라 = 영어시킴말;
1207
1273
  /** 파일을 손대라는 말이 들었나. */
1208
1274
  export function 손대라했나(text) {
1209
1275
  const s = String(text ?? '');
1210
- return 손대라는말.test(s) || 반말시킴.test(s) || 영어손대라.test(s);
1276
+ return 손대라는말.test(s) || 반말시킴.test(s) || 영어손대라.test(s) || 일본어시킴말.test(s) || 중국어시킴말.test(s);
1211
1277
  }
1212
1278
 
1213
1279
  /*
@@ -348,7 +348,7 @@ export class Session {
348
348
  * 유료로 불렀고, 쓴 것을 우리가 모를 뿐이다. 「0 원 썼다」 와 「얼마
349
349
  * 썼는지 모른다」 는 사람이 하는 판단이 다르다 — 그래서 세어 둔다.
350
350
  */
351
- this.usage = { in: 0, out: 0, prompt: 0, calls: 0, ms: 0, retries: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, 못잰것: 0 };
351
+ this.usage = { in: 0, out: 0, prompt: 0, calls: 0, ms: 0, retries: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, 못잰것: 0, genMs: 0, genOut: 0 };
352
352
  /*
353
353
  * 이 대화의 이름. 게이트웨이에 「같은 대화다」 라고 알려 줄 때 쓴다.
354
354
  *
@@ -1423,6 +1423,18 @@ export class Session {
1423
1423
  * 접기와 줄이기가 **같은 것**을 박아야 하고, 한쪽만 고치면 물러서는 순간
1424
1424
  * 대화가 어긋난다.
1425
1425
  */
1426
+ /**
1427
+ * 생성 속도(토큰/초, 소수 한 자리). 흘려받은 부름의 첫 글자부터 끝까지만 잰 값이다 (agent/loop.js 의
1428
+ * 생성잰것). 잰 것이 없으면 null — 한 번에 받은 부름은 앞머리 읽기와 생성을 못 갈라 안 잰다. 0 을 적으면
1429
+ * 「멈춰 있었다」 로 읽힌다.
1430
+ */
1431
+ export function 생성속도(usage) {
1432
+ const ms = Number(usage?.genMs ?? 0);
1433
+ const 토큰 = Number(usage?.genOut ?? 0);
1434
+ if (!(ms > 0) || !(토큰 > 0)) return null;
1435
+ return Math.round((토큰 / ms) * 10000) / 10;
1436
+ }
1437
+
1426
1438
  export const 못박을길이 = 1200;
1427
1439
 
1428
1440
  /** 박아 넣으면서 시킨 말 뒤가 잘리나. oneshot.js 가 「잘린 채로 끝까지 했다」 를 막는 데 쓴다. */
@@ -13,6 +13,7 @@ import { 다시부를지, 기다리기, 정책고르기, 못부른말 } from './
13
13
  import { 도구맞추기, 이름되돌리기, 벤더 } from './toolfit.js';
14
14
  import { 눈금맞추기 } from './wire.js';
15
15
  import { 시스템블록, 메시지표식, 잡힐만한가, 조각표 } from './cachemark.js';
16
+ import { 느슨한JSON } from './loosejson.js';
16
17
 
17
18
  /*
18
19
  * Anthropic 규격의 판 이름.
@@ -891,22 +892,42 @@ export function 잘린모양인가(raw) {
891
892
  return 연따옴표 !== null || 깊이 > 0;
892
893
  }
893
894
 
895
+ /*
896
+ * 인자 글을 읽는다. 곧이곧대로 못 읽으면, **잘린 앞토막이 아닐 때만** 느슨하게 읽는다 (2.1.3).
897
+ *
898
+ * 홑따옴표 · 끝 쉼표 · 따옴표 없는 열쇠 · 글 속 날 줄바꿈은 끝까지 온 틀린 JSON 이다. 여태는 깨진
899
+ * 부름으로 돌려보내 「고쳐 다시 보내라」 했고, 작은 모델은 같은 꼴을 되풀이하며 걸음을 썼다. 뜻이
900
+ * 하나로 읽히면 여기서 읽고 `argsRepaired` 를 단다. 잘린 것은 닫아 읽지 않는다 — 반쪽 내용이
901
+ * 온전한 척 도구로 간다(backend/loosejson.js 머리말). 객체가 아닌 것도 안 받는다.
902
+ */
903
+ function 인자읽기(원문) {
904
+ try { return { args: JSON.parse(원문) }; } catch { /* 아래 */ }
905
+ if (잘린모양인가(원문)) return null;
906
+ try {
907
+ const v = 느슨한JSON(원문);
908
+ if (v && typeof v === 'object' && !Array.isArray(v)) return { args: v, 고침: true };
909
+ } catch { /* 못 읽는다 */ }
910
+ return null;
911
+ }
912
+
894
913
  export function normalizeCalls(list) {
895
914
  return list.map((tc, i) => {
896
915
  const fn = tc.function ?? tc;
897
916
  let args = fn.arguments ?? fn.args ?? {};
898
917
  let 깨짐 = false;
918
+ let 고침 = false;
899
919
  let 원문 = null;
900
920
  if (typeof args === 'string') {
901
921
  const s = args.trim();
902
922
  // 인자가 아예 없는 도구도 있다. 빈 것은 깨진 것이 아니다.
903
923
  if (!s) args = {};
904
924
  else {
905
- try { args = JSON.parse(s); }
906
- catch { 깨짐 = true; 원문 = args; args = {}; }
925
+ const 읽음 = 인자읽기(s);
926
+ if (읽음) { args = 읽음.args; 고침 = !!읽음.고침; } else { 깨짐 = true; 원문 = args; args = {}; }
907
927
  }
908
928
  }
909
929
  const call = { id: tc.id ?? `call_${i + 1}`, name: 이름글(fn.name), args };
930
+ if (고침) call.argsRepaired = true;
910
931
  // argsCut: 잘린 앞토막인가(위 잘린모양인가). false 면 끝까지 온 틀린 JSON 이다.
911
932
  if (깨짐) { call.argsBroken = true; call.rawArgs = 원문; call.argsCut = 잘린모양인가(원문); }
912
933
  return call;
@@ -2094,8 +2115,9 @@ function 도구마무리(acc) {
2094
2115
  if (!원문) return c.처음인자 ? { ...call, args: c.처음인자 } : call;
2095
2116
  // 객체로 온 인자는 이미 읽힌 것이다 (mergeDeltaCalls 머리말). 글로 바꿔 다시 읽으면 깨진다.
2096
2117
  if (typeof 원문 !== 'string') { call.args = 원문; return call; }
2097
- try { call.args = JSON.parse(원문); }
2098
- catch { call.argsBroken = true; call.rawArgs = c.args; call.argsCut = 잘린모양인가(원문); }
2118
+ const 읽음 = 인자읽기(원문);
2119
+ if (읽음) { call.args = 읽음.args; if (읽음.고침) call.argsRepaired = true; }
2120
+ else { call.argsBroken = true; call.rawArgs = c.args; call.argsCut = 잘린모양인가(원문); }
2099
2121
  return call;
2100
2122
  });
2101
2123
  }
@@ -0,0 +1,109 @@
1
+ /*
2
+ * 모양이 조금 틀린 JSON 을 읽는다 (2.1.3).
3
+ *
4
+ * 작은 모델이 도구 인자에 적는 JSON 은 자주 **끝까지 왔는데 모양만 틀렸다** — 홑따옴표 · 따옴표
5
+ * 없는 열쇠 · 끝 쉼표 · 글 속 날 줄바꿈 · 파이썬 꼴 True/False/None · // 설명. 여태는 이것을
6
+ * 깨진 부름으로 돌려보내 「JSON 을 고쳐 다시 보내라」 했고, 모델은 같은 꼴을 되풀이하다 걸음을
7
+ * 다 썼다. 뜻이 하나로 읽히는 것은 여기서 읽는다.
8
+ *
9
+ * **잘린 것은 읽지 않는다.** 잘린 앞토막(backend/adapter.js 의 잘린모양인가)을 닫아 읽으면 반쪽
10
+ * 내용이 온전한 것처럼 도구로 간다 — 그건 부르는 쪽에서 먼저 가른다. 여기는 괄호가 다 닫혔는데
11
+ * JSON.parse 만 못 읽는 것의 자리다.
12
+ */
13
+
14
+ /** 느슨하게 읽는다 — 홑따옴표 · 따옴표 없는 열쇠 · 끝 쉼표 · 글 속 날 줄바꿈 · True/False/None. 못 읽으면 던진다. */
15
+ export function 느슨한JSON(글) {
16
+ const s = String(글 ?? '');
17
+ let i = 0;
18
+ const 틀림 = (왜) => { throw new SyntaxError(`${왜} (${i}번째 글자)`); };
19
+ const 빈칸 = () => {
20
+ for (;;) {
21
+ while (i < s.length && /\s/.test(s[i])) i++;
22
+ // 주석 — 작은 모델이 JSON 에 // 설명을 붙이는 일이 있다.
23
+ if (s.startsWith('//', i)) { while (i < s.length && s[i] !== '\n') i++; continue; }
24
+ if (s.startsWith('/*', i)) { const 끝 = s.indexOf('*/', i + 2); i = 끝 < 0 ? s.length : 끝 + 2; continue; }
25
+ return;
26
+ }
27
+ };
28
+ const 글읽기 = () => {
29
+ const 따옴표 = s[i++];
30
+ let out = '';
31
+ while (i < s.length) {
32
+ const ch = s[i++];
33
+ if (ch === 따옴표) return out;
34
+ if (ch !== '\\') { out += ch; continue; }
35
+ const 다음 = s[i++];
36
+ if (다음 === undefined) break;
37
+ if (다음 === 'n') out += '\n';
38
+ else if (다음 === 't') out += '\t';
39
+ else if (다음 === 'r') out += '\r';
40
+ else if (다음 === 'b') out += '\x08'; // 백스페이스 — 역슬래시 b 로 적으면 정규식 실수처럼 읽힌다(자동모드 검사)
41
+ else if (다음 === 'f') out += '\f';
42
+ else if (다음 === 'u' && /^[0-9a-fA-F]{4}$/.test(s.slice(i, i + 4))) { out += String.fromCharCode(parseInt(s.slice(i, i + 4), 16)); i += 4; }
43
+ // 모르는 되받이(\' · \d)는 글자 그대로 — 모델이 정규식을 JSON 에 옮겨 적으면 이렇게 온다.
44
+ else out += 다음 === "'" || 다음 === '"' || 다음 === '\\' || 다음 === '/' ? 다음 : `\\${다음}`;
45
+ }
46
+ return 틀림('글이 안 닫혔습니다');
47
+ };
48
+ const 낱말 = () => {
49
+ const m = /^[A-Za-z_$\p{L}][\w$\p{L}\p{N}-]*/u.exec(s.slice(i));
50
+ if (!m) return null;
51
+ i += m[0].length;
52
+ return m[0];
53
+ };
54
+ const 값 = () => {
55
+ 빈칸();
56
+ const ch = s[i];
57
+ if (ch === '{') {
58
+ i++;
59
+ const o = {};
60
+ for (;;) {
61
+ 빈칸();
62
+ if (s[i] === '}') { i++; return o; }
63
+ let 열쇠;
64
+ if (s[i] === '"' || s[i] === "'") 열쇠 = 글읽기();
65
+ else { 열쇠 = 낱말(); if (열쇠 === null) 틀림('열쇠가 아닙니다'); }
66
+ 빈칸();
67
+ if (s[i] !== ':') 틀림('":" 가 없습니다');
68
+ i++;
69
+ o[열쇠] = 값();
70
+ 빈칸();
71
+ if (s[i] === ',') { i++; continue; }
72
+ if (s[i] === '}') { i++; return o; }
73
+ 틀림('"," 나 "}" 가 없습니다');
74
+ }
75
+ }
76
+ if (ch === '[') {
77
+ i++;
78
+ const a = [];
79
+ for (;;) {
80
+ 빈칸();
81
+ if (s[i] === ']') { i++; return a; }
82
+ a.push(값());
83
+ 빈칸();
84
+ if (s[i] === ',') { i++; continue; }
85
+ if (s[i] === ']') { i++; return a; }
86
+ 틀림('"," 나 "]" 가 없습니다');
87
+ }
88
+ }
89
+ if (ch === '"' || ch === "'") return 글읽기();
90
+ const 수 = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(s.slice(i));
91
+ if (수) { i += 수[0].length; return Number(수[0]); }
92
+ const 말 = 낱말();
93
+ if (말 === 'true' || 말 === 'True') return true;
94
+ if (말 === 'false' || 말 === 'False') return false;
95
+ if (말 === 'null' || 말 === 'None') return null;
96
+ return 틀림('값이 아닙니다');
97
+ };
98
+ const 결과 = 값();
99
+ 빈칸();
100
+ if (i < s.length) 틀림('뒤에 남은 것이 있습니다');
101
+ return 결과;
102
+ }
103
+
104
+ /** 글을 JSON 으로 — 먼저 곧이곧대로, 안 되면 느슨하게. 못 읽으면 undefined. */
105
+ export function 읽어보기(글) {
106
+ try { return JSON.parse(글); } catch { /* 느슨하게 */ }
107
+ try { return 느슨한JSON(글); } catch { return undefined; }
108
+ }
109
+