deel-local-cli 2.1.1 → 2.1.3

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/oneshot.js CHANGED
@@ -11,6 +11,7 @@
11
11
  // 그래서 '묻는 자리' 를 전부 없앤 길을 따로 낸다. 에이전트 루프는 그대로 쓴다 —
12
12
  // 여기서 루프를 다시 짜면 두 벌이 되고, 언젠가 한쪽만 고쳐진다.
13
13
  import { statSync } from 'node:fs';
14
+ import { resolve } from 'node:path';
14
15
  import { c, mark, clip, 화면글거르기 } from './ui/ansi.js';
15
16
  import { 규칙모으기, 정책읽기, 승인바닥 } from './safety/policy.js';
16
17
  import { 훅읽기 } from './safety/hooks.js';
@@ -19,7 +20,7 @@ import { 스키마읽기, 맞나, 답에서JSON뽑기, 시킬말 as 스키마시
19
20
  import { 남길것읽기 } from './safety/shellenv.js';
20
21
  import { 받기설정 } from './safety/authcmd.js';
21
22
  import { run } from './agent/loop.js';
22
- import { Session, 요청잘리나, 못박을길이 } from './agent/session.js';
23
+ import { Session, 요청잘리나, 못박을길이, 생성속도, repairToolPairs } from './agent/session.js';
23
24
  import { 양수크기 } from './agent/models.js';
24
25
  import { estimateTokens } from './backend/tokens.js';
25
26
  import { makeScope } from './safety/guard.js';
@@ -31,12 +32,14 @@ import { activeProfile, load, resolveKey, 소식줄들 } from './config.js';
31
32
  import { 말 as 옮긴말 } from './i18n/index.js';
32
33
  import { 알림채움, 알림말 } from './backend/retry.js';
33
34
  import { 전선붙이기, 세션이름짓기 } from './backend/wire.js';
34
- import { newId } from './agent/store.js';
35
+ import { newId, Store, latest, prune } from './agent/store.js';
36
+ import { 못박기 } from './agent/pins.js';
37
+ import { 바뀐파일모음, 사건적기열기 } from './runlog.js';
35
38
  import { discover, loadCommand } from './skills/discover.js';
36
39
  import { 다붙이기 } from './backend/mcp.js';
37
40
  import { 명령들 } from './cmdnames.js';
38
- // 슬래시 명령을 찾는 규칙은 대화 화면과 **같은 것 하나**를 쓴다 (commands.js 의 슬래시명령찾기 머리말).
39
- import { 슬래시명령찾기, 비슷한슬래시명령 } from './commands.js';
41
+ // 슬래시 명령을 찾는 규칙은 대화 화면과 **같은 것 하나**를 쓴다 (slash.js 의 슬래시명령찾기 머리말).
42
+ import { 슬래시명령찾기, 비슷한슬래시명령, MCP프롬프트펴기 } from './slash.js';
40
43
  import { allowEndpoint, setOffline } from './safety/network.js';
41
44
  import { 지금모드, 바깥인가, 나갈수있나, 봉인됐나 } from './safety/runmode.js';
42
45
  import { 주소가리기 } from './safety/secrets.js';
@@ -49,93 +52,10 @@ import { 모두끝내기 as 일감모두끝내기, 일감인자 } from './tools/
49
52
  import { 첫이름 } from './tools/label.js';
50
53
  import { 검사설정 } from './agent/donecheck.js';
51
54
 
52
- /**
53
- * 종료코드.
54
- *
55
- * 스크립트는 화면 글이 아니라 이 숫자만 본다. 그래서 '끝났다' 와 '끝난 척했다'
56
- * 를 반드시 갈라 놔야 한다 — 걸음 수 상한에 걸려 멈춘 것을 0 으로 돌려주면
57
- * 야간 배치가 아무 일도 안 하고 초록불을 켠다. 그게 제일 나쁜 결말이다.
58
- */
59
- export const EXIT = {
60
- done: 0, // 끝까지 답했다
61
- error: 1, // 오류로 끝났다 (연결 없음 · 시킬 말 없음 · 모델 오류)
62
- limit: 2, // 도구 호출 걸음 수 상한에 닿았다
63
- stuck: 3, // 같은 자리에서 헛돌아 스스로 멈췄다
64
- aborted: 4, // 도중에 끊겼다 (Ctrl+C)
65
- /*
66
- * 서버가 끝났다는 말을 한 번도 안 주고 답을 멈췄다.
67
- *
68
- * 여기에 자기 코드가 있어야 한다. 전에는 이걸 아예 안 보고 `done`(0) 으로
69
- * 끝냈다. `deel -p` 는 잡·CI 에서 돌고 그 뒤에 스크립트가 붙는데, 반쪽짜리
70
- * 답이 0 으로 넘어가면 그 스크립트는 온전한 답으로 알고 그대로 쓴다.
71
- * 사람이 나중에 결과를 보고 왜 반쪽인지 되짚을 방법이 없다.
72
- */
73
- cutoff: 5,
74
- /*
75
- * 모델이 안 하겠다고 했다 (안전 판정).
76
- *
77
- * 이것도 자기 코드가 있어야 한다. 거절한 답은 **아무 일도 안 한 답**인데,
78
- * 도구 호출이 없다는 것 말고는 짧은 답과 겉모습이 같다. 0 으로 끝내면
79
- * 뒤에 붙은 스크립트는 일이 된 줄 알고 그 다음 단계로 넘어간다.
80
- *
81
- * 오류(1)와도 갈라 둔다. 고칠 자리가 다르기 때문이다 — 오류는 연결이나
82
- * 열쇠를 보는 일이고, 거절은 **시킨 말을 바꾸는 일**이다.
83
- */
84
- refusal: 6,
85
- /*
86
- * 답이 --output-schema 에 안 맞았다.
87
- *
88
- * 여기에도 자기 코드가 있어야 한다. 「모양을 못 박아 달라」 고 한 사람에게
89
- * 모양이 안 맞는 것을 0 으로 넘기면, 파이프 뒤 스크립트가 그걸 온전한
90
- * 것으로 받아 쓴다 — 그게 이 기능이 없애려던 바로 그 상황이다.
91
- *
92
- * 오류(1)와도 가른다. 고칠 자리가 다르다 — 오류는 연결을 보는 일이고,
93
- * 이건 스키마나 시킨 말을 바꾸는 일이다.
94
- */
95
- schema: 7,
96
- /*
97
- * 완료 검사가 통과하지 않은 채 끝났다 (agent/donecheck.js · 2.1.0).
98
- *
99
- * 모델은 「다 됐습니다」 라고 했는데 사람이 정해 둔 검사(`--check` · 설정의 `check`)가
100
- * 정해 둔 판수를 다 돌고도 빨갛거나, 관문에 막혀 아예 못 돌았다. 0 으로 끝내면 야간
101
- * 배치가 빨간 검사 위에 초록불을 켠다 — 이 기능이 없애려던 바로 그 결말이다.
102
- *
103
- * 오류(1)와 가른다. 고칠 자리가 다르다 — 연결이 아니라 코드나 검사 쪽이다.
104
- */
105
- check: 8,
106
- /*
107
- * 인자를 잘못 줬다 — 모르는 깃발 · 값 없는 깃발 · 모르는 모드 이름 · 못 읽는 --ctx.
108
- *
109
- * 여기에 **따로** 둔다. 처음에는 `--work` 오타를 2 로 끝냈는데, 2 는 이 표에서 이미
110
- * 「걸음 수 상한」 이다. `deel run --jsn …` 을 CI 에 건 사람은 오타 하나로 「일이 커서
111
- * 멈췄다」 를 받고 작업을 쪼개러 간다. 고칠 자리가 스크립트 한 줄인데.
112
- *
113
- * 64 는 sysexits.h 의 EX_USAGE 다. 1~8 과 안 겹치고, 셸·CI 도구들이 「부른 모양이
114
- * 틀렸다」 로 이미 알아듣는 수라 새로 지어내지 않았다. 이 경우는 모델을 한 번도 안 부른다.
115
- */
116
- usage: 64,
117
- };
55
+ // 종료코드 표와 실패덩이는 src/exit.js 에 있다 — 진입점이 이 파일 없이 쓰게 (2.1.2).
56
+ export { EXIT, 실패덩이 } from './exit.js';
57
+ import { EXIT, 실패덩이 } from './exit.js';
118
58
 
119
- /**
120
- * 모델을 부르기 전에 선 실패 한 덩이 (사냥5 B5-10).
121
- *
122
- * 성공한 `--json` 에는 model 과 usage.prompt·cacheRead·cacheWrite·못잰것 이 있는데, 모델을
123
- * 부르기 전에 선 실패(64 · 7 · 시킬 말 없음 · 없는 뿌리 · 연결 없음 · 대화 화면 전용)에는
124
- * 그 칸들이 없었다. `jq .usage.prompt` 는 null 을, 모양을 못 박은 파서는 오류를 낸다 —
125
- * 실패를 제일 잘 다뤄야 할 자리에서 파서가 먼저 죽는다. 칸은 늘 같게 두고 값만 비운다.
126
- *
127
- * bin/deel.js 의 인자탈·마지막 catch 도 이걸로 짓는다. 세 벌이면 언젠가 한 벌만 고친다.
128
- */
129
- export function 실패덩이({ reason, code, why = '', model = null }) {
130
- return {
131
- ok: false, reason, code, text: '',
132
- tools: 0, steps: 0,
133
- usage: { in: 0, out: 0, prompt: 0, cacheRead: 0, cacheWrite: 0, calls: 0, ms: 0, 못잰것: 0, retries: 0 },
134
- model,
135
- ms: 0,
136
- ...(why ? { why } : {}),
137
- };
138
- }
139
59
 
140
60
  /**
141
61
  * 표준입력에 실려 온 말을 통째로 읽는다.
@@ -207,8 +127,12 @@ export async function runOnce(opts = {}) {
207
127
  let 알려진모델 = null;
208
128
  // 붙인 MCP 서버. 아래 내놓기 가 닫는다 — 그래서 여기서 선언한다 (2.0.2 · 문 맞춤).
209
129
  let mcp서버들 = [];
130
+ // --events 로 연 사건 파일 (runlog.js). 아래 내놓기 가 결과 줄을 적고 닫는다 (2.1.3).
131
+ let 사건 = null;
210
132
 
211
133
  const 내놓기 = (r) => {
134
+ // 사건 파일의 마지막 줄은 --json 과 같은 결과 한 덩이다. 어느 끝맺음이든 여기를 지난다.
135
+ if (사건) { 사건.결과(r); 사건.닫기(); }
212
136
  /*
213
137
  * 뒤에서 돌던 명령을 반드시 거둔다.
214
138
  *
@@ -307,6 +231,41 @@ export async function runOnce(opts = {}) {
307
231
  }
308
232
  }
309
233
 
234
+ /*
235
+ * --check-rounds · --check-timeout (2.1.2). 치는 자리에서 바로 고칠 수 있으니 틀린 값은 사용법 틀림(64)이다 —
236
+ * 설정 파일에 적힌 틀린 값은 기본값을 쓰고 화면에 말하는 것(donecheck.js 의 줄임)과 다르다.
237
+ */
238
+ if (opts.checkRounds !== undefined && !(Number.isInteger(Number(opts.checkRounds)) && Number(opts.checkRounds) >= 1)) {
239
+ return 못함('usage', `--check-rounds 는 1 이상의 정수여야 합니다: ${opts.checkRounds}`);
240
+ }
241
+ if (opts.checkTimeout !== undefined && !(Number.isFinite(Number(opts.checkTimeout)) && Number(opts.checkTimeout) > 0)) {
242
+ return 못함('usage', `--check-timeout 은 0 보다 큰 초여야 합니다: ${opts.checkTimeout}`);
243
+ }
244
+ /*
245
+ * 값 없는 --resume (2.1.3). 대화 화면은 목록을 띄워 고르게 하지만 여기는 고를 사람이 없다 —
246
+ * 아무거나 집어 이으면 스크립트는 제가 무엇을 이었는지 모른다. 치는 자리에서 고칠 것이라 64 다.
247
+ */
248
+ if (opts.resume === true) {
249
+ return 못함('usage', '--resume 에 이어 할 대화 이름을 주세요 (deel sessions 로 확인) — 가장 최근 것이면 --continue');
250
+ }
251
+
252
+ /*
253
+ * ── 사건 흐름 (--events <파일>) · 2.1.3 ──────────────────────────────
254
+ *
255
+ * 일을 시작하기 **전에** 연다. 다 돌고 나서 못 열었다고 하면 지켜보려던 사람은 아무것도 못 봤다.
256
+ * 경로는 --output-schema 처럼 부른 자리 기준이다.
257
+ */
258
+ if (opts.events != null) {
259
+ const 경로 = resolve(String(opts.events));
260
+ try {
261
+ 사건 = 사건적기열기(경로, {
262
+ 알림: (왜) => 삐끗(` ${mark.warn} ${c.yellow(`사건 파일에 더 못 적습니다 (${왜}) — 일은 계속합니다`)}`),
263
+ });
264
+ } catch (err) {
265
+ return 못함('events', `사건 파일을 못 열었습니다 (--events): ${경로} — ${err?.code ?? err?.message ?? err}`);
266
+ }
267
+ }
268
+
310
269
  // ── 시킬 말 ───────────────────────────────────────────────────────────
311
270
  // 인자로 준 것이 먼저다. 없을 때만 표준입력을 읽는다 —
312
271
  // 둘 다 있을 때 무엇이 이기는지가 헷갈리면 스크립트가 조용히 엉뚱한 일을 한다.
@@ -324,6 +283,41 @@ export async function runOnce(opts = {}) {
324
283
  * 고친다 — acp/serve.js 의 방만들기가 이미 고친 바로 그 섞임이다.
325
284
  */
326
285
  const root = opts.root ? String(opts.root) : process.cwd();
286
+
287
+ /*
288
+ * ── 이어 할 대화 (--continue · --resume <이름>) · 2.1.3 ────────────────
289
+ *
290
+ * 여태 `deel run` 은 오간 말을 어디에도 안 남겼다. 스크립트가 「방금 그 일에 이어서 테스트도 고쳐」 를
291
+ * 시킬 길이 없어 매번 처음부터 설명했고, 모델은 방금 읽은 파일을 다시 읽었다. 이제 대화 화면과 같은
292
+ * 자리(.deel/sessions)에 적고, 같은 두 깃발로 잇는다 — 대화 화면에서 한 대화를 배치가 이어도 된다.
293
+ *
294
+ * 설정을 읽기 **전에** 찾는다. 없는 이름이면 모델을 부르기 전에 서야 한다. 대화 화면은 못 찾으면
295
+ * 새로 시작하지만 여기서는 선다 — 이름을 집어 준 스크립트는 그 대화에 이어진다고 믿고 「아까 그것」 을
296
+ * 시킨다. 처음 보는 모델이 그 말을 받으면 짐작으로 일을 하고 0 으로 끝난다. `--continue` 는 다르다 —
297
+ * 「있으면 잇고 없으면 새로」 가 스크립트의 첫 판이 기대하는 뜻이다.
298
+ */
299
+ let 이어받은것 = null;
300
+ {
301
+ const 이을이름 = typeof opts.resume === 'string' ? opts.resume
302
+ : (opts.continue === true ? (latest(root)?.id ?? null) : null);
303
+ if (opts.continue === true && typeof opts.resume !== 'string' && !이을이름) {
304
+ 곁(` ${c.gray('· 이어 할 대화가 없어 새로 시작합니다.')}`);
305
+ }
306
+ if (이을이름) {
307
+ const 곳 = new Store(root, 이을이름);
308
+ const { messages: 적힌것, 못읽음 } = 곳.이름틀림 ? { messages: [] } : 곳.load();
309
+ // 도구가 도는 중에 죽은 대화는 호출만 있고 결과가 없다 — 그대로 보내면 서버가 400 을 낸다 (repl.js 와 같다).
310
+ const { messages, 고친것 } = repairToolPairs(적힌것);
311
+ if (!messages.length) {
312
+ return 못함('no-session', 곳.이름틀림
313
+ ? `대화 이름이 아닙니다 (--resume): ${이을이름}`
314
+ : 못읽음
315
+ ? `그 대화를 못 읽었습니다 (--resume): ${이을이름} — ${못읽음}`
316
+ : `그런 대화가 없거나 비어 있습니다 (--resume): ${이을이름} — deel sessions 로 이름을 확인하세요`);
317
+ }
318
+ 이어받은것 = { 곳, messages, 고친것 };
319
+ }
320
+ }
327
321
  /*
328
322
  * 설정을 못 읽어도 **이 문의 끝맺음으로** 끝낸다.
329
323
  *
@@ -433,6 +427,20 @@ export async function runOnce(opts = {}) {
433
427
  // session 에 실어 두는 까닭은, 대화 도중 /model 로 옮겨도 같은 자리를 보게 하려는 것이다.
434
428
  session.실행모드 = 실행모드;
435
429
 
430
+ /*
431
+ * 찾아 둔 대화를 싣는다 (위 「이어 할 대화」). 못 박은 것 · 남은 할 일 · 시킨 말 원문도 같이 —
432
+ * 오간 말만 돌아오고 그 셋이 빠지면 대화 화면에서 막아 둔 「껐다 켜면 까먹는다」 가 여기서 되살아난다.
433
+ * 적는 자리(store)는 아래 턴 바로 앞에서 연다 — 그 사이에 선 실패가 빈 대화 파일을 남기지 않게.
434
+ */
435
+ if (이어받은것) {
436
+ session.messages = 이어받은것.messages;
437
+ const 박힌것 = 이어받은것.곳.못박은것읽기();
438
+ if (박힌것.length) session.못박은것 = new 못박기(박힌것);
439
+ 이어받은것.곳.살림따라가기(session);
440
+ 곁(` ${mark.ok} ${c.gray(`${이어받은것.곳.id} — 메시지 ${이어받은것.messages.length}개를 이어 받았습니다.`)}`
441
+ + (이어받은것.고친것 ? ` ${c.gray(`(중단된 도구 호출 ${이어받은것.고친것}개는 걷어냈습니다)`)}` : ''));
442
+ }
443
+
436
444
  /*
437
445
  * ── 열쇠를 받아 오는 명령 (safety/authcmd.js) ───────────────────────
438
446
  *
@@ -556,7 +564,13 @@ export async function runOnce(opts = {}) {
556
564
  * 두 벌이니 한쪽만 고치면 또 갈린다. 그래서 규칙은 commands.js 한 군데에만 둔다.
557
565
  */
558
566
  const 찾은 = 붙박이 ? null : 슬래시명령찾기(found.commands, 부른이름);
559
- if (붙박이) {
567
+ // MCP 서버가 낸 프롬프트도 대화 화면과 같이 부른다 (2.1.3 · commands.js 의 MCP프롬프트펴기).
568
+ const 프롬프트 = 붙박이 || 찾은 ? null : await MCP프롬프트펴기(mcp서버들, 부른이름, 인자);
569
+ if (프롬프트?.탈) return 못함('command-read', `/${부른이름} — ${프롬프트.탈}`);
570
+ if (프롬프트) {
571
+ 곁(` ${c.cyan('⌘')} ${부른이름} ${c.gray(`MCP 프롬프트 · ${프롬프트.서버이름}`)}`);
572
+ 시킬말 = 프롬프트.text;
573
+ } else if (붙박이) {
560
574
  return 못함('repl-only', `/${부른이름} 은 대화 화면에서만 도는 명령입니다.`
561
575
  + ' 여기서 되는 것은 파일로 적어 둔 슬래시 명령뿐입니다 (.claude/commands · .deel/commands).');
562
576
  } else if (찾은) {
@@ -629,7 +643,11 @@ export async function runOnce(opts = {}) {
629
643
  // 안 거두면 배치가 끝나고도 언어 서버가 폴더를 물고 남는다.
630
644
  lsp: { 켬: true },
631
645
  // 끝내려는 자리에서 돌릴 검사 (agent/donecheck.js). `--check` 가 설정의 check 를 이긴다.
632
- 완료검사: 검사설정(cfg, opts.check),
646
+ 완료검사: 검사설정({
647
+ ...cfg,
648
+ ...(opts.checkRounds !== undefined ? { checkRounds: Number(opts.checkRounds) } : {}),
649
+ ...(opts.checkTimeout !== undefined ? { checkTimeout: Number(opts.checkTimeout) } : {}),
650
+ }, opts.check),
633
651
  // 되물을 사람이 없으니 기본값을 그대로 돌려준다.
634
652
  ask: async (_label, o = {}) => o?.def ?? '',
635
653
  // 엑셀 암호를 여기서 기다리면 그대로 선다. 없다고 바로 답한다 —
@@ -741,6 +759,28 @@ export async function runOnce(opts = {}) {
741
759
  }
742
760
  }
743
761
 
762
+ /*
763
+ * ── 오간 말을 남긴다 (2.1.3) ─────────────────────────────────────────
764
+ *
765
+ * 대화 화면처럼 걸음마다 적는다. 끝에 한 번에 적으면 걸음 수 상한이나 Ctrl+C 로 선 잡의 대화가
766
+ * 통째로 없다 — 이어서 시킬 일이 제일 많은 게 바로 그 잡이다. 여기까지 온 뒤에 연다 — 위에서 선
767
+ * 실패(없는 명령 · 너무 큰 말)가 머리글만 있는 빈 대화를 남기지 않게.
768
+ */
769
+ const store = 이어받은것?.곳 ?? new Store(root);
770
+ store.begin({ model: conn.model, base: 주소가리기(conn.base), root });
771
+ if (!이어받은것) store.살림따라가기(session);
772
+ // 지금 이어 쓰는 대화는 정리에서 뺀다 — 한 달 넘은 대화를 이으면 바로 여기서 지워졌다 (store.js 의 prune 머리말).
773
+ try { prune(root, { 남길것: [store.id] }); } catch { /* 못 치워도 이 잡은 돈다 — 다음 판이 다시 해 본다 */ }
774
+ let 적은데까지 = session.messages.length; // 이어 받은 말은 이미 그 파일에 있다
775
+ const 적기 = () => {
776
+ // 접기 · 비우기가 이력을 통째로 바꿨는데 그 사건을 못 봤으면(모양 되물음 도중) 덧붙이지 말고 새로 적는다.
777
+ if (session.messages.length < 적은데까지) store.replace(session.messages, '압축');
778
+ else for (const m of session.messages.slice(적은데까지)) store.append(m);
779
+ 적은데까지 = session.messages.length;
780
+ };
781
+ // 무엇이 바뀌었나 — --json 의 files (runlog.js).
782
+ const 바뀐 = 바뀐파일모음(root);
783
+
744
784
  // ── 한 턴 ─────────────────────────────────────────────────────────────
745
785
  const turn = new AbortController();
746
786
  const 끊김 = () => { if (!turn.signal.aborted) turn.abort(); };
@@ -765,10 +805,13 @@ export async function runOnce(opts = {}) {
765
805
  const 안쪽 = (ev) => (ev.depth ? ' '.repeat(ev.depth) : '');
766
806
 
767
807
  for await (const ev of run(session, ctx, 보낼글, { signal: turn.signal })) {
808
+ 사건?.적기(ev); // 지켜보는 쪽에 먼저 흘린다 (--events)
809
+ 바뀐.사건(ev);
768
810
  switch (ev.type) {
769
811
  case 'stage':
770
812
  steps = ev.step;
771
813
  이번단계글 = '';
814
+ 적기(); // 걸음마다 적어 둔다 — 도중에 죽어도 여기까지는 남는다
772
815
  break;
773
816
 
774
817
  // 중간 단계의 말은 표준출력에 안 싣는다. "이제 파일을 읽어보겠습니다" 까지
@@ -779,6 +822,7 @@ export async function runOnce(opts = {}) {
779
822
 
780
823
  case 'tool': {
781
824
  tools++;
825
+ 적기();
782
826
  /*
783
827
  * 실패는 실패로 보여야 한다.
784
828
  *
@@ -885,6 +929,9 @@ export async function runOnce(opts = {}) {
885
929
  ? ` ${c.cyan('◱')} ${c.yellow(`요약을 못 받아 옛 대화 ${ev.folded}개를 잘라 냈습니다 (${ev.before.toLocaleString()} → ${ev.after.toLocaleString()} 토큰)`)}`
886
930
  + (ev.why ? ` ${c.gray(`— ${clip(String(ev.why), 100)}`)}` : '')
887
931
  : ` ${c.cyan('◱')} ${c.gray(`대화 ${ev.folded}개를 요약으로 접었습니다 (${ev.before.toLocaleString()} → ${ev.after.toLocaleString()} 토큰)`)}`);
932
+ // 접히면 이력이 통째로 바뀐다. 덧붙이면 접히기 전과 뒤가 겹쳐 남으니 새로 적는다 (repl.js 와 같다).
933
+ store.replace(session.messages, ev.fallback ? `압축 못 함 — 옛 대화 ${ev.folded}개를 잘라 냄` : `압축 — ${ev.folded}개를 요약으로`);
934
+ 적은데까지 = session.messages.length;
888
935
  break;
889
936
 
890
937
  case 'compact_failed':
@@ -929,7 +976,7 @@ export async function runOnce(opts = {}) {
929
976
 
930
977
  case 'check_start':
931
978
  곁(` ${c.cyan('⧗')} ${c.gray(옮긴말('check.start', { 판: ev.판, 최대: ev.최대, 명령: ev.명령 }))}`);
932
- for (const 줄 of ev.줄임 ?? []) 곁(` ${c.yellow('⚠')} ${c.gray(옮긴말('check.clamped', 줄))}`);
979
+ for (const 줄 of ev.줄임 ?? []) 곁(` ${c.yellow('⚠')} ${c.gray(옮긴말(줄.틀림 ? 'check.invalid' : 'check.clamped', 줄))}`);
933
980
  break;
934
981
 
935
982
  case 'check':
@@ -943,6 +990,10 @@ export async function runOnce(opts = {}) {
943
990
  } else 곁(` ${c.yellow('⊘')} ${c.gray(옮긴말('check.skipped', { 까닭: ev.까닭 }))}`);
944
991
  break;
945
992
 
993
+ case 'textcalls':
994
+ 곁(` ${c.gray(`↳ ${옮긴말('ev.textCalls', { n: ev.count, 이름: ev.names.join(', ') })}`)}`);
995
+ break;
996
+
946
997
  case 'nudge':
947
998
  곁(` ${c.gray(`↺ ${ev.why === '요청누락'
948
999
  ? 옮긴말('ev.nudgeMissed', { n: ev.빠진?.length ?? 0 })
@@ -983,6 +1034,8 @@ export async function runOnce(opts = {}) {
983
1034
  곁(` ${c.cyan('◱')} ${c.gray((ev.kept?.할일 ?? 0)
984
1035
  ? 옮긴말('ev.reset', { 버린수: ev.dropped, 할일: ev.kept.할일 })
985
1036
  : 옮긴말('ev.resetBare', { 버린수: ev.dropped }))}`);
1037
+ store.replace(session.messages, `자리부족 — 앞선 대화 ${ev.dropped}개를 비움`);
1038
+ 적은데까지 = session.messages.length;
986
1039
  /*
987
1040
  * ── 비우면서 **시킨 말 뒤가 잘렸으면** 여기서 선다 (사냥5 B5-01) ────────────
988
1041
  *
@@ -1118,6 +1171,7 @@ export async function runOnce(opts = {}) {
1118
1171
  let 다시터짐 = '';
1119
1172
  try {
1120
1173
  for await (const ev of run(session, ctx, 스키마시킬말(출력스키마, { 다시: 잰것.탈 }), { signal: turn.signal, 고쳐쓰기: true })) {
1174
+ 사건?.적기(ev);
1121
1175
  if (ev.type === 'content') 다시글 += ev.text;
1122
1176
  else if (ev.type === 'done') 다시글 = ev.text ?? 다시글;
1123
1177
  else if (ev.type === 'aborted') { 다시끊김 = true; break; }
@@ -1183,12 +1237,22 @@ export async function runOnce(opts = {}) {
1183
1237
  */
1184
1238
  process.removeListener('SIGINT', 끊김);
1185
1239
 
1240
+ /*
1241
+ * 남은 말을 다 적고, 못 적은 것이 있으면 **말한다.** 이어 하려던 스크립트가 다음 판에서야
1242
+ * 「그 대화가 반쪽이다」 를 알면 늦다 — 그래서 못 적은 판은 --json 의 session 을 비운다.
1243
+ */
1244
+ try { 적기(); } catch { /* 셈은 store 가 한다 — 아래에서 본다 */ }
1245
+ const 저장탈 = store.처음못쓴것();
1246
+ if (저장탈) 삐끗(` ${mark.warn} ${c.yellow(옮긴말('run.notSaving', { n: 저장탈.수, 까닭: 저장탈.까닭 }))}`);
1247
+ const 대화이름 = store.이름틀림 || store.못쓴것() ? null : store.id;
1248
+
1186
1249
  const code = EXIT[reason] ?? EXIT.error;
1187
1250
  if (why) 삐끗(` ${reason === 'done' ? c.gray('·') : c.red('✗')} ${why}`);
1188
1251
  if (!json && !quiet) {
1189
1252
  const 조각 = [`${((Date.now() - t0) / 1000).toFixed(1)}초`];
1190
1253
  if (tools) 조각.push(`도구 ${tools}회`);
1191
1254
  조각.push(`↑${(session.usage.prompt || session.usage.in).toLocaleString()} ↓${session.usage.out.toLocaleString()}`);
1255
+ if (생성속도(session.usage) !== null) 조각.push(`${생성속도(session.usage)} tok/s`);
1192
1256
  곁(` ${c.gray('── ' + 조각.join(' · '))}`);
1193
1257
  }
1194
1258
 
@@ -1214,8 +1278,14 @@ export async function runOnce(opts = {}) {
1214
1278
  // 이 칸이 없으면 받는 쪽이 0 을 실측으로 읽는다 (backend/adapter.js 의 잰것).
1215
1279
  못잰것: session.usage.못잰것 ?? 0,
1216
1280
  retries: session.usage.retries ?? 0,
1281
+ // 생성 속도 (2.1.3). 흘려받은 부름만 잰다 — 못 쟀으면 null (session.js 의 생성속도).
1282
+ genMs: session.usage.genMs ?? 0,
1283
+ tokPerSec: 생성속도(session.usage),
1217
1284
  },
1218
1285
  model: conn.model,
1286
+ // 바뀐 파일 (작업 폴더 기준 · 슬래시) 과 이 대화의 이름 — `--resume <이름>` 으로 잇는다 (2.1.3).
1287
+ files: 바뀐.목록(),
1288
+ session: 대화이름,
1219
1289
  // 모양을 못 박았을 때만 실린다. --json 으로 받는 쪽은 text 를 다시 파싱할
1220
1290
  // 필요 없이 이 칸을 그대로 쓰면 된다.
1221
1291
  ...(스키마값 !== null ? { schema: 스키마값 } : {}),
package/src/repl.js CHANGED
@@ -22,7 +22,7 @@ import { handle, COMMANDS, 미리보기끄기, 적용하기 as 그림적용 } fr
22
22
  import { next as nextWork, get as getWork, canWrite, 보일이름, 보일한줄 } from './agent/modes.js';
23
23
  import { route } from './agent/route.js';
24
24
  import { run } from './agent/loop.js';
25
- import { Session, repairToolPairs } from './agent/session.js';
25
+ import { Session, repairToolPairs, 생성속도 } from './agent/session.js';
26
26
  import { makeScope } from './safety/guard.js';
27
27
  import { 언어서버있나 } from './tools/index.js';
28
28
  import { 모두끄기 as 언어서버다끄기 } from './lsp/client.js';
@@ -2224,6 +2224,8 @@ export async function chatLoop(opts = {}) {
2224
2224
  // 캐시가 걸린 턴일수록 실제보다 비싸게 뜬다.
2225
2225
  cacheRead: session.usage.cacheRead ?? 0,
2226
2226
  cacheWrite: session.usage.cacheWrite ?? 0,
2227
+ genMs: session.usage.genMs ?? 0,
2228
+ genOut: session.usage.genOut ?? 0,
2227
2229
  };
2228
2230
 
2229
2231
  /*
@@ -3042,6 +3044,13 @@ export async function chatLoop(opts = {}) {
3042
3044
  }
3043
3045
  break;
3044
3046
 
3047
+ // 글로 적은 도구 부름을 건졌다 (backend/textcalls.js) — 흘러 나온 표 글 뒤에 무엇을 했는지 한 줄.
3048
+ case 'textcalls':
3049
+ clearThinking();
3050
+ if (streamed) { 답비우기(); say(''); streamed = false; }
3051
+ say(` ${c.gray(`↳ ${옮긴말('ev.textCalls', { n: ev.count, 이름: ev.names.join(', ') })}`)}`);
3052
+ break;
3053
+
3045
3054
  case 'nudge':
3046
3055
  clearThinking();
3047
3056
  if (streamed) { 답비우기(); say(''); streamed = false; }
@@ -3057,7 +3066,7 @@ export async function chatLoop(opts = {}) {
3057
3066
  if (streamed) { 답비우기(); say(''); streamed = false; }
3058
3067
  say('');
3059
3068
  say(` ${c.cyan('⧗')} ${c.gray(옮긴말('check.start', { 판: ev.판, 최대: ev.최대, 명령: ev.명령 }))}`);
3060
- for (const 줄 of ev.줄임 ?? []) say(` ${c.yellow('⚠')} ${c.gray(옮긴말('check.clamped', 줄))}`);
3069
+ for (const 줄 of ev.줄임 ?? []) say(` ${c.yellow('⚠')} ${c.gray(옮긴말(줄.틀림 ? 'check.invalid' : 'check.clamped', 줄))}`);
3061
3070
  break;
3062
3071
 
3063
3072
  case 'check':
@@ -3216,6 +3225,9 @@ export async function chatLoop(opts = {}) {
3216
3225
  const d읽음 = (session.usage.cacheRead ?? 0) - before.cacheRead;
3217
3226
  const d씀 = (session.usage.cacheWrite ?? 0) - before.cacheWrite;
3218
3227
  if (dIn || dOut) bits.push(`↑${dIn.toLocaleString()} ↓${dOut.toLocaleString()}`);
3228
+ // 이번 턴의 생성 속도 — 흘려받은 부름만 잰다(session.js 의 생성속도). 못 쟀으면 안 적는다.
3229
+ const 이번속도 = 생성속도({ genMs: (session.usage.genMs ?? 0) - before.genMs, genOut: (session.usage.genOut ?? 0) - before.genOut });
3230
+ if (이번속도 !== null) bits.push(`${이번속도} tok/s`);
3219
3231
  /*
3220
3232
  * ── ↑ 안을 가른다 ────────────────────────────────────────────────────
3221
3233
  *
package/src/runlog.js ADDED
@@ -0,0 +1,99 @@
1
+ // `deel run` 이 밖으로 내는 두 가지 — 바뀐 파일 목록과 사건 흐름 (2.1.3).
2
+ //
3
+ // oneshot.js 에서 따로 둔다. 거기는 이미 천 줄이 넘고, 이 둘은 「루프가 내는 사건을 받아 밖에 적는다」
4
+ // 는 한 가지 일만 한다 — 루프의 속을 몰라도 되고, 그래서 따로 잴 수 있다.
5
+
6
+ import { openSync, writeSync, closeSync } from 'node:fs';
7
+ import { resolve, relative, isAbsolute, sep } from 'node:path';
8
+
9
+ /*
10
+ * ── 바뀐 파일 ─────────────────────────────────────────────────────────
11
+ *
12
+ * 루프는 턴이 끝날 때 손댄 파일을 `done` · `stuck` · `limit` 에 실어 준다(agent/loop.js 의 마무리).
13
+ * 그런데 끊기거나(aborted) 터진(error) 턴은 그 목록 없이 끝난다 — 스크립트가 제일 알고 싶은 것이
14
+ * 「반쯤 하다 멈췄는데 무엇을 건드렸나」 인데. 그래서 도구 사건마다 루프와 같은 세 칸을 보고 모은다.
15
+ * 끝 사건이 준 목록도 합친다 — 하위 작업이 바꾼 것은 거기에만 온다.
16
+ *
17
+ * 작업 폴더 기준 경로로, 슬래시로 적는다. 스크립트는 `jq -r '.files[]' | xargs git add` 처럼 쓴다 —
18
+ * 윈도우 역슬래시나 절대 경로가 섞이면 그 한 줄이 운영체제마다 달라진다. 폴더 밖은 절대 경로 그대로 둔다.
19
+ */
20
+ export function 바뀐파일모음(root) {
21
+ const 모음 = new Set();
22
+ const 담기 = (p) => {
23
+ if (typeof p !== 'string' || !p) return;
24
+ const 온경로 = resolve(root, p);
25
+ const 상대 = relative(root, 온경로);
26
+ const 밖인가 = !상대 || 상대.startsWith('..') || isAbsolute(상대);
27
+ 모음.add((밖인가 ? 온경로 : 상대).split(sep).join('/'));
28
+ };
29
+ return {
30
+ 사건(ev) {
31
+ if (!ev || typeof ev !== 'object') return;
32
+ if (ev.type === 'tool' && ev.result && !ev.result.error) {
33
+ const r = ev.result;
34
+ 담기(r.changed);
35
+ for (const f of r.여럿 ?? []) if (f?.ok) 담기(f.path);
36
+ for (const f of r.바뀐것들 ?? []) 담기(f);
37
+ }
38
+ for (const f of Array.isArray(ev.files) ? ev.files : []) 담기(f?.path);
39
+ },
40
+ 목록: () => [...모음].sort(),
41
+ };
42
+ }
43
+
44
+ /*
45
+ * ── 사건 흐름 (--events <파일>) ───────────────────────────────────────
46
+ *
47
+ * 끝날 때 한 덩이만 나오면 오래 도는 잡을 지켜볼 수가 없다. 사건을 한 줄에 하나씩 JSON 으로 흘린다 —
48
+ * `tail -f` 로 보고, 끝나면 `jq` 로 훑는다. 마지막 줄은 `--json` 과 같은 결과 한 덩이다(type: 'result').
49
+ *
50
+ * 글자 조각(content · thinking)은 안 싣는다. 흘려받는 서버면 한 턴에 수천 줄이 되고, 답은 마지막 줄에
51
+ * 이미 통째로 있다. 긴 글은 잘라 싣는다 — 큰 파일을 읽은 도구 결과 하나가 한 줄에 통째로 실리면
52
+ * 사건 파일이 읽은 파일들의 사본이 된다.
53
+ *
54
+ * `type` 칸은 약속이다. 나머지 칸은 판마다 늘 수 있다 — 루프가 화면에 주는 것을 그대로 옮긴다.
55
+ */
56
+ export const 사건글한도 = 4000;
57
+ const 안싣는것 = new Set(['content', 'thinking']);
58
+
59
+ export function 사건줄(ev, 흐른ms, { 통째 = false } = {}) {
60
+ const 자르기 = (_k, v) => (typeof v === 'string' && v.length > 사건글한도
61
+ ? `${v.slice(0, 사건글한도)}…(+${v.length - 사건글한도})`
62
+ : v);
63
+ try {
64
+ return JSON.stringify({ ...ev, elapsed: 흐른ms }, 통째 ? undefined : 자르기);
65
+ } catch {
66
+ // 제자리를 도는 값 같은 것. 무엇이 있었는지는 남긴다 — 통째로 빼면 그 자리가 비어 보인다.
67
+ return JSON.stringify({ type: String(ev?.type ?? '?'), elapsed: 흐른ms, 못적음: true });
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 사건 파일을 연다. 못 열면 던진다 — 부르는 쪽이 일을 시작하기 **전에** 선다.
73
+ *
74
+ * 다 돌고 나서 「사건 파일을 못 열었습니다」 면 지켜보려던 사람은 아무것도 못 봤다. 도중에 못 쓰게
75
+ * 되면(디스크 가득) 한 번 알리고 그만 적는다 — 기록을 남기려다 일을 죽이면 본말이 뒤집힌다.
76
+ */
77
+ export function 사건적기열기(경로, { 시작 = Date.now(), 알림 = () => {} } = {}) {
78
+ const fd = openSync(경로, 'w');
79
+ let 막힘 = false;
80
+ let 닫힘 = false;
81
+ const 한줄 = (글) => {
82
+ if (막힘 || 닫힘) return;
83
+ try { writeSync(fd, `${글}\n`); }
84
+ catch (err) { 막힘 = true; 알림(err?.code ?? err?.message ?? String(err)); }
85
+ };
86
+ return {
87
+ 적기(ev) {
88
+ if (!ev || 안싣는것.has(ev.type)) return;
89
+ 한줄(사건줄(ev, Date.now() - 시작));
90
+ },
91
+ // 결과 줄은 자르지 않는다 — 답이 거기에 통째로 있다는 것이 위 약속이다(--json 과 같은 덩이).
92
+ 결과(r) { 한줄(사건줄({ type: 'result', ...r }, Date.now() - 시작, { 통째: true })); },
93
+ 닫기() {
94
+ if (닫힘) return;
95
+ 닫힘 = true;
96
+ try { closeSync(fd); } catch { /* 이미 닫혔으면 그만 */ }
97
+ },
98
+ };
99
+ }