deel-local-cli 1.13.0 → 1.14.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 (54) hide show
  1. package/README.ko.md +32 -26
  2. package/README.md +32 -26
  3. package/bin/deel.js +6 -1
  4. package/package.json +3 -2
  5. package/src/acp/map.js +367 -367
  6. package/src/acp/serve.js +830 -810
  7. package/src/agent/budget.js +156 -156
  8. package/src/agent/commit.js +534 -534
  9. package/src/agent/compact.js +441 -441
  10. package/src/agent/effort.js +308 -308
  11. package/src/agent/evolve.js +263 -263
  12. package/src/agent/loop.js +1755 -1728
  13. package/src/agent/memory.js +156 -156
  14. package/src/agent/mention.js +210 -210
  15. package/src/agent/recall.js +222 -222
  16. package/src/agent/session.js +1056 -1056
  17. package/src/backend/adapter.js +1355 -1355
  18. package/src/backend/cachemark.js +223 -223
  19. package/src/backend/detect.js +327 -327
  20. package/src/backend/http.js +460 -460
  21. package/src/backend/mcp.js +407 -407
  22. package/src/backend/price.js +260 -260
  23. package/src/backend/probe.js +487 -487
  24. package/src/backend/quota.js +274 -274
  25. package/src/backend/tokens.js +59 -59
  26. package/src/backend/toolfit.js +352 -352
  27. package/src/backend/wire.js +715 -715
  28. package/src/cmdnames.js +68 -0
  29. package/src/commands.js +3116 -3184
  30. package/src/config.js +283 -283
  31. package/src/i18n/en.js +545 -545
  32. package/src/i18n/ja.js +498 -498
  33. package/src/i18n/ko.js +592 -592
  34. package/src/i18n/zh.js +498 -498
  35. package/src/oneshot.js +685 -625
  36. package/src/pack/selfpack.js +15 -2
  37. package/src/pack/sheet.en.js +16 -2
  38. package/src/pack/tar.js +154 -154
  39. package/src/pack/zip.js +235 -235
  40. package/src/plugins/manage.js +416 -416
  41. package/src/repl.js +2616 -2616
  42. package/src/safety/audit.js +148 -148
  43. package/src/safety/guard.js +714 -641
  44. package/src/safety/network.js +200 -200
  45. package/src/tools/fastgrep.js +229 -229
  46. package/src/tools/fsutil.js +312 -291
  47. package/src/tools/index.js +2590 -2560
  48. package/src/tools/jobs.js +876 -876
  49. package/src/tools/spawn.js +213 -213
  50. package/src/tools/verify.js +358 -358
  51. package/src/tools/webfetch.js +418 -418
  52. package/src/ui/export.js +233 -233
  53. package/src/ui/pick.js +115 -115
  54. package/src/ui/status.js +617 -617
package/src/acp/map.js CHANGED
@@ -1,367 +1,367 @@
1
- // deel 이 흘리는 것을 ACP 가 아는 모양으로 옮긴다.
2
- //
3
- // ── 왜 옮기는 자리를 따로 두나 ──────────────────────────────────────────
4
- //
5
- // 붙이는 일의 값어치는 대부분 여기 있다. 관을 잇는 것은 한 시간이면 되지만,
6
- // **에디터가 무엇을 보여 줄 수 있는가** 는 전부 이 표에서 갈린다.
7
- //
8
- // 갈래(kind)를 안 주면 → 전부 똑같은 회색 점으로 그려진다
9
- // 자리(locations)를 안 주면 → 고친 파일을 눌러도 안 열린다
10
- // 상태(status)를 안 주면 → 도는 중인지 끝났는지 안 보인다
11
- //
12
- // 이 셋은 있어도 없어도 규격에는 안 걸린다. 그래서 대충 붙인 구현은 죄다
13
- // 안 준다. 여기서만 챙기면 같은 프로토콜을 쓰고도 화면이 달라진다.
14
- //
15
- // ── 순수하게 둔다 ───────────────────────────────────────────────────────
16
- //
17
- // 이 파일은 **아무것도 건드리지 않는다** — 파일도, 그물도, 화면도. 값을
18
- // 넣으면 값이 나온다. 그래야 진짜 에디터 없이 검사할 수 있다 — 붙인 것이
19
- // 맞는지 확인하려고 Zed 를 띄워야 한다면 아무도 확인 안 하게 된다.
20
- //
21
- // 「아무것도 안 부른다」 로 적어 뒀던 적이 있고, 그 말 때문에 도구 부름을
22
- // 읽는 규칙을 여기에 **한 벌 더** 적었다. 그 벌은 OpenAI 꼴만 알았고,
23
- // Anthropic 으로 대화하면 되살린 화면에서 도구가 통째로 사라졌다. 아무 말도
24
- // 없이 — 「도구를 안 썼구나」 와 구별이 안 된다.
25
- //
26
- // 읽기만 하는 순수 함수를 들여오는 것은 순수함을 안 깬다. 규격을 읽는 규칙은
27
- // backend/adapter.js 한 곳에만 있어야 한다 (집안 규칙: 자는 한 벌).
28
- import { 부른것들, 결과들, 본문글 } from '../backend/adapter.js';
29
- import { 첫이름 } from '../tools/label.js';
30
-
31
- /**
32
- * 도구 이름 → ACP 갈래.
33
- *
34
- * 규격이 정한 낱말만 쓴다: read·edit·delete·move·search·execute·think·fetch·
35
- * switch_mode·other. 모르는 것은 other 다 — 지어내면 클라이언트가 못 알아본다.
36
- */
37
- const 갈래표 = {
38
- Read: 'read',
39
- Outline: 'read',
40
- Write: 'edit',
41
- Append: 'edit',
42
- Edit: 'edit',
43
- Glob: 'search',
44
- Grep: 'search',
45
- Recall: 'search',
46
- Bash: 'execute',
47
- Jobs: 'execute',
48
- Verify: 'execute',
49
- WebFetch: 'fetch',
50
- Task: 'think',
51
- TodoWrite: 'think',
52
- Skill: 'think',
53
- Remember: 'other',
54
- };
55
-
56
- export function 도구갈래(이름) {
57
- return 갈래표[String(이름 ?? '')] ?? 'other';
58
- }
59
-
60
- /**
61
- * 사람이 읽을 한 줄.
62
- *
63
- * `Read` 만 적으면 열 줄이 전부 `Read` 다. 무엇을 읽었는지가 빠지면 목록을
64
- * 훑어보는 뜻이 없어진다 — 그럴 거면 아예 안 보여 주는 편이 낫다.
65
- */
66
- export function 도구이름표(이름, 인자) {
67
- const a = 인자 ?? {};
68
- // 어느 이름을 먼저 보는지는 tools/label.js 한 곳에 있다. 여기에 또 적으면,
69
- // 새 인자 이름이 생겼을 때 다섯 자리 중 몇 곳만 고쳐진다.
70
- const 첫 = 첫이름(a)
71
- ?? (a.command ? String(a.command).replace(/\s+/g, ' ') : null)
72
- ?? (Array.isArray(a.files) && a.files.length
73
- ? `${a.files[0]?.file_path ?? '?'}${a.files.length > 1 ? ` 외 ${a.files.length - 1}개` : ''}`
74
- : null)
75
- ?? (Array.isArray(a.edits) && a.edits.length
76
- ? `${a.edits[0]?.file_path ?? '?'}${a.edits.length > 1 ? ` 외 ${a.edits.length - 1}군데` : ''}`
77
- : null)
78
- ?? (Array.isArray(a.paths) && a.paths.length ? `${a.paths.length}개` : null)
79
- ?? (Array.isArray(a.todos) ? `${a.todos.length}건` : null);
80
- const 안 = 첫 == null ? '' : 자르기(String(첫), 80);
81
- return 안 ? `${이름}(${안})` : String(이름 ?? '도구');
82
- }
83
-
84
- /**
85
- * 이 호출이 건드린 파일 자리.
86
- *
87
- * 결과에 실린 실제 경로(changed)를 먼저 본다. 인자에 적힌 것은 상대 경로일 수
88
- * 있는데, 에디터는 절대 경로라야 연다. 인자만 보고 넘기면 눌러도 안 열리는
89
- * 링크가 되고, 그건 없느니만 못하다.
90
- */
91
- export function 도구자리(이름, 인자, 결과) {
92
- const 모은것 = [];
93
- const 넣기 = (p) => {
94
- const s = typeof p === 'string' ? p.trim() : '';
95
- if (s && !모은것.includes(s)) 모은것.push(s);
96
- };
97
-
98
- 넣기(결과?.changed);
99
- for (const x of 결과?.여럿 ?? []) 넣기(x?.changed);
100
-
101
- const a = 인자 ?? {};
102
- 넣기(a.file_path);
103
- 넣기(a.path);
104
- for (const f of Array.isArray(a.files) ? a.files : []) 넣기(f?.file_path);
105
- for (const e of Array.isArray(a.edits) ? a.edits : []) 넣기(e?.file_path);
106
- for (const p of Array.isArray(a.paths) ? a.paths : []) 넣기(p);
107
-
108
- return 모은것.slice(0, 20).map((path) => ({ path }));
109
- }
110
-
111
- /**
112
- * 도구가 실제로 탈이 났는가.
113
- *
114
- * `error` 만 보면 안 된다. Bash 는 종료코드를, Verify 는 "탈 2개" 를 요약에
115
- * 담아 돌려준다 — 그것들을 성공으로 칠하면 화면에서 성공과 구별되지 않는다.
116
- * `deel run` 쪽에서 이미 한 번 데인 자리라 여기서도 같은 눈으로 본다.
117
- */
118
- export function 도구탈났나(결과) {
119
- return !!(결과?.error || 결과?.failed);
120
- }
121
-
122
- /**
123
- * 도구 결과를 ACP 가 그릴 수 있는 내용으로.
124
- *
125
- * 모델에게 가는 본문을 그대로 실으면 안 된다. 파일 하나를 읽어도 수만 자가
126
- * 오는데, 그것이 전부 에디터 창으로 흘러가면 사람이 아무것도 못 읽는다.
127
- * 보여 줄 만큼만 자른다 — 모델이 받는 양은 이것과 무관하게 그대로다.
128
- */
129
- export function 도구내용(결과, 최대 = 2000) {
130
- const r = 결과 ?? {};
131
- const 글 = r.error
132
- ? String(r.error)
133
- : (r.content != null ? String(r.content) : (r.summary != null ? String(r.summary) : ''));
134
- if (!글.trim()) return [];
135
- return [{ type: 'content', content: { type: 'text', text: 자르기(글, 최대) } }];
136
- }
137
-
138
- /**
139
- * 한 걸음 끝난 도구 호출을 ACP 한 덩이로.
140
- *
141
- * @param {string} 아이디 이 세션 안에서 유일한 번호
142
- * @param {object} ev loop.js 가 흘린 `tool` 이벤트
143
- */
144
- export function 도구끝남(아이디, ev) {
145
- return {
146
- sessionUpdate: 'tool_call_update',
147
- toolCallId: 아이디,
148
- title: 도구이름표(ev?.name, ev?.args),
149
- kind: 도구갈래(ev?.name),
150
- status: 도구탈났나(ev?.result) ? 'failed' : 'completed',
151
- content: 도구내용(ev?.result),
152
- locations: 도구자리(ev?.name, ev?.args, ev?.result),
153
- };
154
- }
155
-
156
- /** 이제 막 시작한 도구 호출. */
157
- export function 도구시작(아이디, 이름, 인자) {
158
- return {
159
- sessionUpdate: 'tool_call',
160
- toolCallId: 아이디,
161
- title: 도구이름표(이름, 인자),
162
- kind: 도구갈래(이름),
163
- status: 'in_progress',
164
- content: [],
165
- locations: 도구자리(이름, 인자, null),
166
- };
167
- }
168
-
169
- /**
170
- * deel 이 턴을 끝낸 까닭 → ACP 가 아는 낱말.
171
- *
172
- * 규격이 가진 낱말은 다섯뿐이다: end_turn·max_tokens·max_turn_requests·
173
- * refusal·cancelled. deel 의 '헛돎' 은 여기 딱 맞는 것이 없다.
174
- *
175
- * refusal 로 보내고 싶은 마음이 들지만 그러면 안 된다 — 규격은 refusal 일 때
176
- * "그 사용자 말과 그 뒤의 것은 다음 프롬프트에 넣지 말라" 고 적어 두었다.
177
- * deel 은 헛돌았을 때 대화를 버리지 않는다. 그래서 end_turn 으로 보내고,
178
- * **왜 멈췄는지는 말로 따로 흘려 준다**. 낱말이 안 맞으면 낱말을 억지로 맞추는
179
- * 대신 사람이 읽을 것을 준다.
180
- */
181
- export function 멈춘까닭(까닭) {
182
- switch (까닭) {
183
- case 'aborted': return 'cancelled';
184
- case 'limit': return 'max_turn_requests';
185
- case 'stuck': return 'end_turn';
186
- default: return 'end_turn';
187
- }
188
- }
189
-
190
- /**
191
- * 메시지 하나에서 사람이 읽을 글만 뽑는다.
192
- *
193
- * content 는 글 한 덩어리일 때도 있고, 그림이 섞인 배열일 때도 있다
194
- * (backend/vision.js). 배열에서 글만 빼고 그림은 조용히 버리면, 되살린
195
- * 화면에서 "이 말을 왜 했는지" 가 사라진다 — 몇 장이 붙어 있었는지는 적는다.
196
- */
197
- function 글만(m) {
198
- const c = m?.content;
199
- let 장수 = Array.isArray(m?.images) ? m.images.length : 0; // ollama 규격
200
- if (Array.isArray(c)) for (const p of c) if (p?.type === 'image_url' || p?.type === 'image') 장수++;
201
- // 글을 뽑는 규칙은 규격마다 다르다 — 그 규칙은 adapter.js 한 곳에 있다.
202
- let 글 = 본문글(m).trim();
203
- if (장수) 글 = 글 ? `${글}\n\n_(그림 ${장수}장)_` : `_(그림 ${장수}장)_`;
204
- return 글;
205
- }
206
-
207
- /** 저장된 도구 인자. 규격에 따라 글일 수도, 이미 풀린 것일 수도 있다. */
208
- function 인자풀기(x) {
209
- if (x && typeof x === 'object') return x;
210
- if (typeof x !== 'string' || !x.trim()) return {};
211
- try { return JSON.parse(x); } catch { return {}; }
212
- }
213
-
214
- /**
215
- * 저장된 대화를 에디터에 다시 흘려 줄 알림 묶음으로.
216
- *
217
- * ── 왜 이 자리가 필요한가 ───────────────────────────────────────────────
218
- *
219
- * session/load 는 "지난 대화를 되살려라" 는 부름인데, 규격에는 되살린 것을
220
- * **답으로 돌려주는 자리가 없다.** 오간 말을 전부 session/update 로 다시
221
- * 흘려야 한다. 그러니까 에디터가 그리는 지난 대화는 여기서 만든 것이 전부다 —
222
- * 여기서 빠뜨린 것은 화면에서 통째로 사라지고, 사람은 기록이 날아간 줄 안다.
223
- *
224
- * 도구 호출은 시작·끝 두 번으로 안 나눈다. 이미 끝난 일을 '도는 중' 으로 한 번
225
- * 그렸다가 고칠 까닭이 없다. 끝난 모습 한 덩이만 준다.
226
- *
227
- * @param {object[]} messages store.js 가 읽어 온 대화
228
- * @returns {object[]} session/update 의 update 자리에 그대로 넣을 것들
229
- */
230
- export function 되살린것(messages, { 최대내용 = 2000 } = {}) {
231
- const 줄 = Array.isArray(messages) ? messages : [];
232
-
233
- /*
234
- * 도구 답을 먼저 줄 세운다.
235
- *
236
- * 답(role:'tool')은 부름보다 뒤에 온다. 앞에서부터 한 번에 그리려면 미리
237
- * 찾아 둬야 한다. OpenAI 규격은 id 로 짝을 짓고, ollama 규격은 id 를 안 주므로
238
- * 이름별 차례로 짝을 짓는다 (session.js 의 repairToolPairs 와 같은 눈).
239
- */
240
- const id로 = new Map();
241
- const 이름으로 = new Map();
242
- for (const m of 줄) {
243
- // Anthropic 꼴 결과는 role 이 'user' 다. role 로 고르면 통째로 놓친다.
244
- for (const r of 결과들(m)) {
245
- if (r.id != null) id로.set(r.id, r.글);
246
- else if (r.name) {
247
- const q = 이름으로.get(r.name) ?? [];
248
- q.push(r.글);
249
- 이름으로.set(r.name, q);
250
- }
251
- }
252
- }
253
-
254
- const 나온것 = [];
255
- let 말번호 = 0;
256
- let 도구번호 = 0;
257
-
258
- for (const m of 줄) {
259
- if (!m || typeof m !== 'object') continue;
260
- // 시스템 프롬프트는 사람이 한 말이 아니다. 되살리면 매번 대화 머리에
261
- // 수천 자짜리 지시문이 붙어서, 정작 무슨 얘기를 했는지가 안 보인다.
262
- if (m.role === 'system' || m.role === 'tool') continue;
263
-
264
- if (m.role === 'user') {
265
- const 글 = 글만(m);
266
- if (글) 나온것.push({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 글 } });
267
- continue;
268
- }
269
- if (m.role !== 'assistant') continue;
270
-
271
- 말번호++;
272
- const 생각 = typeof m.thinking === 'string' ? m.thinking.trim() : '';
273
- if (생각) {
274
- 나온것.push({
275
- sessionUpdate: 'agent_thought_chunk',
276
- content: { type: 'text', text: 자르기(생각, 최대내용) },
277
- messageId: `h${말번호}`,
278
- });
279
- }
280
- const 글 = 글만(m);
281
- if (글) {
282
- 나온것.push({
283
- sessionUpdate: 'agent_message_chunk',
284
- content: { type: 'text', text: 글 },
285
- messageId: `h${말번호}`,
286
- });
287
- }
288
-
289
- for (const tc of 부른것들(m)) {
290
- const 이름 = String(tc.name ?? '도구');
291
- const 인자 = 인자풀기(tc.args);
292
-
293
- let 답;
294
- if (tc?.id != null && id로.has(tc.id)) 답 = id로.get(tc.id);
295
- else {
296
- const q = 이름으로.get(이름);
297
- if (q?.length) 답 = q.shift();
298
- }
299
-
300
- /*
301
- * 답이 안 남은 부름 = 그때 프로그램이 도구 도는 중에 끊긴 것이다.
302
- *
303
- * 성공으로 그리면 안 된다. 그 도구가 끝까지 갔는지 아닌지가 다음에 무엇을
304
- * 시킬지를 정한다 — 파일을 고치던 중이었을 수도 있다.
305
- */
306
- const 없음 = 답 === undefined || 답 === null;
307
- 나온것.push({
308
- sessionUpdate: 'tool_call',
309
- toolCallId: `h${말번호}-${++도구번호}`,
310
- title: 도구이름표(이름, 인자),
311
- kind: 도구갈래(이름),
312
- // 남은 글만 보고 성공·실패를 점치지 않는다. 도구마다 실패를 적는 말이
313
- // 달라서, 맞히려 들면 멀쩡한 것을 빨갛게 칠하게 된다.
314
- status: 없음 ? 'failed' : 'completed',
315
- content: 없음
316
- ? [{ type: 'content', content: { type: 'text', text: '결과가 안 남았습니다 — 이 도구가 도는 중에 끊겼습니다.' } }]
317
- : [{ type: 'content', content: { type: 'text', text: 자르기(String(답), 최대내용) } }],
318
- locations: 도구자리(이름, 인자, null),
319
- });
320
- }
321
- }
322
-
323
- return 나온것;
324
- }
325
-
326
- /**
327
- * 프롬프트로 온 덩이들에서 글만 뽑는다.
328
- *
329
- * resource 는 알맹이가 실려 오므로 그대로 쓴다. resource_link 는 주소만 오는데,
330
- * 그래도 주소를 적어 준다 — 모델이 그 자리를 Read 로 열어 볼 수 있다.
331
- * 그림·소리는 읽을 방법이 없다. 조용히 버리지 않고 무엇을 못 읽었는지 적는다.
332
- */
333
- export function 프롬프트글(덩이들) {
334
- const 조각 = [];
335
- for (const b of Array.isArray(덩이들) ? 덩이들 : []) {
336
- if (!b || typeof b !== 'object') continue;
337
- switch (b.type) {
338
- case 'text':
339
- if (typeof b.text === 'string' && b.text) 조각.push(b.text);
340
- break;
341
- case 'resource': {
342
- const r = b.resource ?? {};
343
- if (typeof r.text === 'string' && r.text) {
344
- 조각.push(`--- ${r.uri ?? '붙임'} ---\n${r.text}`);
345
- } else if (r.uri) {
346
- 조각.push(`(붙임: ${r.uri} — 글이 아니라 못 읽었습니다)`);
347
- }
348
- break;
349
- }
350
- case 'resource_link':
351
- if (b.uri) 조각.push(`(붙임: ${b.uri})`);
352
- break;
353
- case 'image':
354
- case 'audio':
355
- 조각.push(`(${b.type === 'image' ? '그림' : '소리'}이 붙어 왔지만 이 모델로는 못 읽습니다)`);
356
- break;
357
- default:
358
- break;
359
- }
360
- }
361
- return 조각.join('\n\n').trim();
362
- }
363
-
364
- function 자르기(s, n) {
365
- const t = String(s ?? '');
366
- return t.length > n ? `${t.slice(0, n)}\n… (${t.length - n}자 줄임)` : t;
367
- }
1
+ // deel 이 흘리는 것을 ACP 가 아는 모양으로 옮긴다.
2
+ //
3
+ // ── 왜 옮기는 자리를 따로 두나 ──────────────────────────────────────────
4
+ //
5
+ // 붙이는 일의 값어치는 대부분 여기 있다. 관을 잇는 것은 한 시간이면 되지만,
6
+ // **에디터가 무엇을 보여 줄 수 있는가** 는 전부 이 표에서 갈린다.
7
+ //
8
+ // 갈래(kind)를 안 주면 → 전부 똑같은 회색 점으로 그려진다
9
+ // 자리(locations)를 안 주면 → 고친 파일을 눌러도 안 열린다
10
+ // 상태(status)를 안 주면 → 도는 중인지 끝났는지 안 보인다
11
+ //
12
+ // 이 셋은 있어도 없어도 규격에는 안 걸린다. 그래서 대충 붙인 구현은 죄다
13
+ // 안 준다. 여기서만 챙기면 같은 프로토콜을 쓰고도 화면이 달라진다.
14
+ //
15
+ // ── 순수하게 둔다 ───────────────────────────────────────────────────────
16
+ //
17
+ // 이 파일은 **아무것도 건드리지 않는다** — 파일도, 그물도, 화면도. 값을
18
+ // 넣으면 값이 나온다. 그래야 진짜 에디터 없이 검사할 수 있다 — 붙인 것이
19
+ // 맞는지 확인하려고 Zed 를 띄워야 한다면 아무도 확인 안 하게 된다.
20
+ //
21
+ // 「아무것도 안 부른다」 로 적어 뒀던 적이 있고, 그 말 때문에 도구 부름을
22
+ // 읽는 규칙을 여기에 **한 벌 더** 적었다. 그 벌은 OpenAI 꼴만 알았고,
23
+ // Anthropic 으로 대화하면 되살린 화면에서 도구가 통째로 사라졌다. 아무 말도
24
+ // 없이 — 「도구를 안 썼구나」 와 구별이 안 된다.
25
+ //
26
+ // 읽기만 하는 순수 함수를 들여오는 것은 순수함을 안 깬다. 규격을 읽는 규칙은
27
+ // backend/adapter.js 한 곳에만 있어야 한다 (집안 규칙: 자는 한 벌).
28
+ import { 부른것들, 결과들, 본문글 } from '../backend/adapter.js';
29
+ import { 첫이름 } from '../tools/label.js';
30
+
31
+ /**
32
+ * 도구 이름 → ACP 갈래.
33
+ *
34
+ * 규격이 정한 낱말만 쓴다: read·edit·delete·move·search·execute·think·fetch·
35
+ * switch_mode·other. 모르는 것은 other 다 — 지어내면 클라이언트가 못 알아본다.
36
+ */
37
+ const 갈래표 = {
38
+ Read: 'read',
39
+ Outline: 'read',
40
+ Write: 'edit',
41
+ Append: 'edit',
42
+ Edit: 'edit',
43
+ Glob: 'search',
44
+ Grep: 'search',
45
+ Recall: 'search',
46
+ Bash: 'execute',
47
+ Jobs: 'execute',
48
+ Verify: 'execute',
49
+ WebFetch: 'fetch',
50
+ Task: 'think',
51
+ TodoWrite: 'think',
52
+ Skill: 'think',
53
+ Remember: 'other',
54
+ };
55
+
56
+ export function 도구갈래(이름) {
57
+ return 갈래표[String(이름 ?? '')] ?? 'other';
58
+ }
59
+
60
+ /**
61
+ * 사람이 읽을 한 줄.
62
+ *
63
+ * `Read` 만 적으면 열 줄이 전부 `Read` 다. 무엇을 읽었는지가 빠지면 목록을
64
+ * 훑어보는 뜻이 없어진다 — 그럴 거면 아예 안 보여 주는 편이 낫다.
65
+ */
66
+ export function 도구이름표(이름, 인자) {
67
+ const a = 인자 ?? {};
68
+ // 어느 이름을 먼저 보는지는 tools/label.js 한 곳에 있다. 여기에 또 적으면,
69
+ // 새 인자 이름이 생겼을 때 다섯 자리 중 몇 곳만 고쳐진다.
70
+ const 첫 = 첫이름(a)
71
+ ?? (a.command ? String(a.command).replace(/\s+/g, ' ') : null)
72
+ ?? (Array.isArray(a.files) && a.files.length
73
+ ? `${a.files[0]?.file_path ?? '?'}${a.files.length > 1 ? ` 외 ${a.files.length - 1}개` : ''}`
74
+ : null)
75
+ ?? (Array.isArray(a.edits) && a.edits.length
76
+ ? `${a.edits[0]?.file_path ?? '?'}${a.edits.length > 1 ? ` 외 ${a.edits.length - 1}군데` : ''}`
77
+ : null)
78
+ ?? (Array.isArray(a.paths) && a.paths.length ? `${a.paths.length}개` : null)
79
+ ?? (Array.isArray(a.todos) ? `${a.todos.length}건` : null);
80
+ const 안 = 첫 == null ? '' : 자르기(String(첫), 80);
81
+ return 안 ? `${이름}(${안})` : String(이름 ?? '도구');
82
+ }
83
+
84
+ /**
85
+ * 이 호출이 건드린 파일 자리.
86
+ *
87
+ * 결과에 실린 실제 경로(changed)를 먼저 본다. 인자에 적힌 것은 상대 경로일 수
88
+ * 있는데, 에디터는 절대 경로라야 연다. 인자만 보고 넘기면 눌러도 안 열리는
89
+ * 링크가 되고, 그건 없느니만 못하다.
90
+ */
91
+ export function 도구자리(이름, 인자, 결과) {
92
+ const 모은것 = [];
93
+ const 넣기 = (p) => {
94
+ const s = typeof p === 'string' ? p.trim() : '';
95
+ if (s && !모은것.includes(s)) 모은것.push(s);
96
+ };
97
+
98
+ 넣기(결과?.changed);
99
+ for (const x of 결과?.여럿 ?? []) 넣기(x?.changed);
100
+
101
+ const a = 인자 ?? {};
102
+ 넣기(a.file_path);
103
+ 넣기(a.path);
104
+ for (const f of Array.isArray(a.files) ? a.files : []) 넣기(f?.file_path);
105
+ for (const e of Array.isArray(a.edits) ? a.edits : []) 넣기(e?.file_path);
106
+ for (const p of Array.isArray(a.paths) ? a.paths : []) 넣기(p);
107
+
108
+ return 모은것.slice(0, 20).map((path) => ({ path }));
109
+ }
110
+
111
+ /**
112
+ * 도구가 실제로 탈이 났는가.
113
+ *
114
+ * `error` 만 보면 안 된다. Bash 는 종료코드를, Verify 는 "탈 2개" 를 요약에
115
+ * 담아 돌려준다 — 그것들을 성공으로 칠하면 화면에서 성공과 구별되지 않는다.
116
+ * `deel run` 쪽에서 이미 한 번 데인 자리라 여기서도 같은 눈으로 본다.
117
+ */
118
+ export function 도구탈났나(결과) {
119
+ return !!(결과?.error || 결과?.failed);
120
+ }
121
+
122
+ /**
123
+ * 도구 결과를 ACP 가 그릴 수 있는 내용으로.
124
+ *
125
+ * 모델에게 가는 본문을 그대로 실으면 안 된다. 파일 하나를 읽어도 수만 자가
126
+ * 오는데, 그것이 전부 에디터 창으로 흘러가면 사람이 아무것도 못 읽는다.
127
+ * 보여 줄 만큼만 자른다 — 모델이 받는 양은 이것과 무관하게 그대로다.
128
+ */
129
+ export function 도구내용(결과, 최대 = 2000) {
130
+ const r = 결과 ?? {};
131
+ const 글 = r.error
132
+ ? String(r.error)
133
+ : (r.content != null ? String(r.content) : (r.summary != null ? String(r.summary) : ''));
134
+ if (!글.trim()) return [];
135
+ return [{ type: 'content', content: { type: 'text', text: 자르기(글, 최대) } }];
136
+ }
137
+
138
+ /**
139
+ * 한 걸음 끝난 도구 호출을 ACP 한 덩이로.
140
+ *
141
+ * @param {string} 아이디 이 세션 안에서 유일한 번호
142
+ * @param {object} ev loop.js 가 흘린 `tool` 이벤트
143
+ */
144
+ export function 도구끝남(아이디, ev) {
145
+ return {
146
+ sessionUpdate: 'tool_call_update',
147
+ toolCallId: 아이디,
148
+ title: 도구이름표(ev?.name, ev?.args),
149
+ kind: 도구갈래(ev?.name),
150
+ status: 도구탈났나(ev?.result) ? 'failed' : 'completed',
151
+ content: 도구내용(ev?.result),
152
+ locations: 도구자리(ev?.name, ev?.args, ev?.result),
153
+ };
154
+ }
155
+
156
+ /** 이제 막 시작한 도구 호출. */
157
+ export function 도구시작(아이디, 이름, 인자) {
158
+ return {
159
+ sessionUpdate: 'tool_call',
160
+ toolCallId: 아이디,
161
+ title: 도구이름표(이름, 인자),
162
+ kind: 도구갈래(이름),
163
+ status: 'in_progress',
164
+ content: [],
165
+ locations: 도구자리(이름, 인자, null),
166
+ };
167
+ }
168
+
169
+ /**
170
+ * deel 이 턴을 끝낸 까닭 → ACP 가 아는 낱말.
171
+ *
172
+ * 규격이 가진 낱말은 다섯뿐이다: end_turn·max_tokens·max_turn_requests·
173
+ * refusal·cancelled. deel 의 '헛돎' 은 여기 딱 맞는 것이 없다.
174
+ *
175
+ * refusal 로 보내고 싶은 마음이 들지만 그러면 안 된다 — 규격은 refusal 일 때
176
+ * "그 사용자 말과 그 뒤의 것은 다음 프롬프트에 넣지 말라" 고 적어 두었다.
177
+ * deel 은 헛돌았을 때 대화를 버리지 않는다. 그래서 end_turn 으로 보내고,
178
+ * **왜 멈췄는지는 말로 따로 흘려 준다**. 낱말이 안 맞으면 낱말을 억지로 맞추는
179
+ * 대신 사람이 읽을 것을 준다.
180
+ */
181
+ export function 멈춘까닭(까닭) {
182
+ switch (까닭) {
183
+ case 'aborted': return 'cancelled';
184
+ case 'limit': return 'max_turn_requests';
185
+ case 'stuck': return 'end_turn';
186
+ default: return 'end_turn';
187
+ }
188
+ }
189
+
190
+ /**
191
+ * 메시지 하나에서 사람이 읽을 글만 뽑는다.
192
+ *
193
+ * content 는 글 한 덩어리일 때도 있고, 그림이 섞인 배열일 때도 있다
194
+ * (backend/vision.js). 배열에서 글만 빼고 그림은 조용히 버리면, 되살린
195
+ * 화면에서 "이 말을 왜 했는지" 가 사라진다 — 몇 장이 붙어 있었는지는 적는다.
196
+ */
197
+ function 글만(m) {
198
+ const c = m?.content;
199
+ let 장수 = Array.isArray(m?.images) ? m.images.length : 0; // ollama 규격
200
+ if (Array.isArray(c)) for (const p of c) if (p?.type === 'image_url' || p?.type === 'image') 장수++;
201
+ // 글을 뽑는 규칙은 규격마다 다르다 — 그 규칙은 adapter.js 한 곳에 있다.
202
+ let 글 = 본문글(m).trim();
203
+ if (장수) 글 = 글 ? `${글}\n\n_(그림 ${장수}장)_` : `_(그림 ${장수}장)_`;
204
+ return 글;
205
+ }
206
+
207
+ /** 저장된 도구 인자. 규격에 따라 글일 수도, 이미 풀린 것일 수도 있다. */
208
+ function 인자풀기(x) {
209
+ if (x && typeof x === 'object') return x;
210
+ if (typeof x !== 'string' || !x.trim()) return {};
211
+ try { return JSON.parse(x); } catch { return {}; }
212
+ }
213
+
214
+ /**
215
+ * 저장된 대화를 에디터에 다시 흘려 줄 알림 묶음으로.
216
+ *
217
+ * ── 왜 이 자리가 필요한가 ───────────────────────────────────────────────
218
+ *
219
+ * session/load 는 "지난 대화를 되살려라" 는 부름인데, 규격에는 되살린 것을
220
+ * **답으로 돌려주는 자리가 없다.** 오간 말을 전부 session/update 로 다시
221
+ * 흘려야 한다. 그러니까 에디터가 그리는 지난 대화는 여기서 만든 것이 전부다 —
222
+ * 여기서 빠뜨린 것은 화면에서 통째로 사라지고, 사람은 기록이 날아간 줄 안다.
223
+ *
224
+ * 도구 호출은 시작·끝 두 번으로 안 나눈다. 이미 끝난 일을 '도는 중' 으로 한 번
225
+ * 그렸다가 고칠 까닭이 없다. 끝난 모습 한 덩이만 준다.
226
+ *
227
+ * @param {object[]} messages store.js 가 읽어 온 대화
228
+ * @returns {object[]} session/update 의 update 자리에 그대로 넣을 것들
229
+ */
230
+ export function 되살린것(messages, { 최대내용 = 2000 } = {}) {
231
+ const 줄 = Array.isArray(messages) ? messages : [];
232
+
233
+ /*
234
+ * 도구 답을 먼저 줄 세운다.
235
+ *
236
+ * 답(role:'tool')은 부름보다 뒤에 온다. 앞에서부터 한 번에 그리려면 미리
237
+ * 찾아 둬야 한다. OpenAI 규격은 id 로 짝을 짓고, ollama 규격은 id 를 안 주므로
238
+ * 이름별 차례로 짝을 짓는다 (session.js 의 repairToolPairs 와 같은 눈).
239
+ */
240
+ const id로 = new Map();
241
+ const 이름으로 = new Map();
242
+ for (const m of 줄) {
243
+ // Anthropic 꼴 결과는 role 이 'user' 다. role 로 고르면 통째로 놓친다.
244
+ for (const r of 결과들(m)) {
245
+ if (r.id != null) id로.set(r.id, r.글);
246
+ else if (r.name) {
247
+ const q = 이름으로.get(r.name) ?? [];
248
+ q.push(r.글);
249
+ 이름으로.set(r.name, q);
250
+ }
251
+ }
252
+ }
253
+
254
+ const 나온것 = [];
255
+ let 말번호 = 0;
256
+ let 도구번호 = 0;
257
+
258
+ for (const m of 줄) {
259
+ if (!m || typeof m !== 'object') continue;
260
+ // 시스템 프롬프트는 사람이 한 말이 아니다. 되살리면 매번 대화 머리에
261
+ // 수천 자짜리 지시문이 붙어서, 정작 무슨 얘기를 했는지가 안 보인다.
262
+ if (m.role === 'system' || m.role === 'tool') continue;
263
+
264
+ if (m.role === 'user') {
265
+ const 글 = 글만(m);
266
+ if (글) 나온것.push({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 글 } });
267
+ continue;
268
+ }
269
+ if (m.role !== 'assistant') continue;
270
+
271
+ 말번호++;
272
+ const 생각 = typeof m.thinking === 'string' ? m.thinking.trim() : '';
273
+ if (생각) {
274
+ 나온것.push({
275
+ sessionUpdate: 'agent_thought_chunk',
276
+ content: { type: 'text', text: 자르기(생각, 최대내용) },
277
+ messageId: `h${말번호}`,
278
+ });
279
+ }
280
+ const 글 = 글만(m);
281
+ if (글) {
282
+ 나온것.push({
283
+ sessionUpdate: 'agent_message_chunk',
284
+ content: { type: 'text', text: 글 },
285
+ messageId: `h${말번호}`,
286
+ });
287
+ }
288
+
289
+ for (const tc of 부른것들(m)) {
290
+ const 이름 = String(tc.name ?? '도구');
291
+ const 인자 = 인자풀기(tc.args);
292
+
293
+ let 답;
294
+ if (tc?.id != null && id로.has(tc.id)) 답 = id로.get(tc.id);
295
+ else {
296
+ const q = 이름으로.get(이름);
297
+ if (q?.length) 답 = q.shift();
298
+ }
299
+
300
+ /*
301
+ * 답이 안 남은 부름 = 그때 프로그램이 도구 도는 중에 끊긴 것이다.
302
+ *
303
+ * 성공으로 그리면 안 된다. 그 도구가 끝까지 갔는지 아닌지가 다음에 무엇을
304
+ * 시킬지를 정한다 — 파일을 고치던 중이었을 수도 있다.
305
+ */
306
+ const 없음 = 답 === undefined || 답 === null;
307
+ 나온것.push({
308
+ sessionUpdate: 'tool_call',
309
+ toolCallId: `h${말번호}-${++도구번호}`,
310
+ title: 도구이름표(이름, 인자),
311
+ kind: 도구갈래(이름),
312
+ // 남은 글만 보고 성공·실패를 점치지 않는다. 도구마다 실패를 적는 말이
313
+ // 달라서, 맞히려 들면 멀쩡한 것을 빨갛게 칠하게 된다.
314
+ status: 없음 ? 'failed' : 'completed',
315
+ content: 없음
316
+ ? [{ type: 'content', content: { type: 'text', text: '결과가 안 남았습니다 — 이 도구가 도는 중에 끊겼습니다.' } }]
317
+ : [{ type: 'content', content: { type: 'text', text: 자르기(String(답), 최대내용) } }],
318
+ locations: 도구자리(이름, 인자, null),
319
+ });
320
+ }
321
+ }
322
+
323
+ return 나온것;
324
+ }
325
+
326
+ /**
327
+ * 프롬프트로 온 덩이들에서 글만 뽑는다.
328
+ *
329
+ * resource 는 알맹이가 실려 오므로 그대로 쓴다. resource_link 는 주소만 오는데,
330
+ * 그래도 주소를 적어 준다 — 모델이 그 자리를 Read 로 열어 볼 수 있다.
331
+ * 그림·소리는 읽을 방법이 없다. 조용히 버리지 않고 무엇을 못 읽었는지 적는다.
332
+ */
333
+ export function 프롬프트글(덩이들) {
334
+ const 조각 = [];
335
+ for (const b of Array.isArray(덩이들) ? 덩이들 : []) {
336
+ if (!b || typeof b !== 'object') continue;
337
+ switch (b.type) {
338
+ case 'text':
339
+ if (typeof b.text === 'string' && b.text) 조각.push(b.text);
340
+ break;
341
+ case 'resource': {
342
+ const r = b.resource ?? {};
343
+ if (typeof r.text === 'string' && r.text) {
344
+ 조각.push(`--- ${r.uri ?? '붙임'} ---\n${r.text}`);
345
+ } else if (r.uri) {
346
+ 조각.push(`(붙임: ${r.uri} — 글이 아니라 못 읽었습니다)`);
347
+ }
348
+ break;
349
+ }
350
+ case 'resource_link':
351
+ if (b.uri) 조각.push(`(붙임: ${b.uri})`);
352
+ break;
353
+ case 'image':
354
+ case 'audio':
355
+ 조각.push(`(${b.type === 'image' ? '그림' : '소리'}이 붙어 왔지만 이 모델로는 못 읽습니다)`);
356
+ break;
357
+ default:
358
+ break;
359
+ }
360
+ }
361
+ return 조각.join('\n\n').trim();
362
+ }
363
+
364
+ function 자르기(s, n) {
365
+ const t = String(s ?? '');
366
+ return t.length > n ? `${t.slice(0, n)}\n… (${t.length - n}자 줄임)` : t;
367
+ }