deel-local-cli 1.8.0 → 1.12.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 (61) hide show
  1. package/README.ko.md +10 -25
  2. package/README.md +43 -51
  3. package/bin/deel.js +56 -13
  4. package/package.json +60 -60
  5. package/src/acp/serve.js +810 -732
  6. package/src/agent/budget.js +156 -153
  7. package/src/agent/compact.js +420 -296
  8. package/src/agent/effort.js +308 -177
  9. package/src/agent/evidence.js +2 -0
  10. package/src/agent/evolve.js +263 -213
  11. package/src/agent/filemem.js +155 -0
  12. package/src/agent/grade.js +51 -1
  13. package/src/agent/loop.js +1707 -1349
  14. package/src/agent/memory.js +156 -152
  15. package/src/agent/mention.js +210 -210
  16. package/src/agent/session.js +1049 -832
  17. package/src/agent/store.js +186 -9
  18. package/src/agent/threads.js +26 -1
  19. package/src/backend/adapter.js +1159 -615
  20. package/src/backend/cachemark.js +150 -0
  21. package/src/backend/detect.js +327 -288
  22. package/src/backend/learn.js +24 -0
  23. package/src/backend/mcp.js +96 -5
  24. package/src/backend/probe.js +131 -61
  25. package/src/backend/quota.js +250 -133
  26. package/src/backend/retry.js +30 -6
  27. package/src/backend/tokens.js +37 -0
  28. package/src/backend/toolfit.js +352 -0
  29. package/src/backend/wire.js +600 -0
  30. package/src/commands.js +3135 -2918
  31. package/src/config.js +47 -2
  32. package/src/i18n/en.js +544 -466
  33. package/src/i18n/index.js +18 -0
  34. package/src/i18n/ja.js +497 -417
  35. package/src/i18n/ko.js +591 -466
  36. package/src/i18n/zh.js +497 -417
  37. package/src/lsp/client.js +49 -5
  38. package/src/oneshot.js +620 -471
  39. package/src/pack/sbom.js +30 -4
  40. package/src/pack/selfpack.js +25 -9
  41. package/src/pack/sheet.en.js +288 -0
  42. package/src/pack/tar.js +65 -2
  43. package/src/plugins/manage.js +46 -9
  44. package/src/providers/bedrock.js +17 -0
  45. package/src/repl.js +2602 -2286
  46. package/src/safety/audit.js +92 -8
  47. package/src/safety/authcmd.js +14 -3
  48. package/src/safety/guard.js +143 -0
  49. package/src/safety/keystore.js +62 -39
  50. package/src/safety/undo.js +24 -6
  51. package/src/tools/fsutil.js +265 -250
  52. package/src/tools/index.js +239 -34
  53. package/src/tools/jobs.js +158 -29
  54. package/src/tools/verify.js +358 -328
  55. package/src/tools/webfetch.js +92 -9
  56. package/src/ui/md.js +201 -5
  57. package/src/ui/motion.js +0 -1
  58. package/src/ui/pastechip.js +50 -3
  59. package/src/ui/pick.js +115 -0
  60. package/src/ui/screen.js +23 -3
  61. package/src/ui/status.js +613 -610
@@ -1,210 +1,210 @@
1
- // @파일 지목 — 말 속에 @경로 를 쓰면 그 파일을 바로 붙여 보낸다.
2
- //
3
- // 왜 필요한가:
4
- // 지금은 파일 하나 보여주려면 모델이 Read 도구를 스스로 불러야 한다. 왕복이
5
- // 한 번 더 든다. 로컬 모델은 도구 호출이 약해서 그 한 번이 자주 헛돈다 —
6
- // 엉뚱한 경로를 부르거나, 아예 안 부르고 지어낸다. 사람은 이미 어느 파일인지
7
- // 알고 있는데 모델더러 찾아보라고 시키는 셈이다.
8
- //
9
- // 여기서 어려운 것은 붙이는 일이 아니라 '아닌 것을 파일로 오해하지 않기' 다.
10
- // @ 로 시작하는 것은 세상에 널렸다 — 이메일 주소, CSS 의 @media, 파이썬 장식자,
11
- // npm 의 @scope/pkg, 사람 아이디. 잘못 붙이면 엉뚱한 글이 대화에 섞여 들어가고,
12
- // 사람은 자기가 안 보낸 것이 왜 거기 있는지 모른다.
13
- //
14
- // 그래서 규칙을 하나로 뒀다: **실제로 있는 경로일 때만 붙인다.**
15
- // 없으면 아무 말 없이 글자 그대로 둔다. 지목이 아니었을 테니 조용한 편이 맞다.
16
- import { existsSync, statSync, readdirSync } from 'node:fs';
17
- import { readTextFull, 내부살림 } from '../tools/fsutil.js';
18
- import { 계보규칙읽기, 무시하나 } from '../tools/ignore.js';
19
- import { 그림인가, 그림읽기, 크기말 } from '../backend/vision.js';
20
- import { estimateTokens } from './session.js';
21
-
22
- // 붙일 수 있는 최대 토큰. 부르는 쪽에서 컨텍스트에 맞춰 넘겨준다.
23
- const 기본예산 = 6000;
24
- const 폴더최대 = 60; // 폴더를 지목했을 때 늘어놓을 항목 수
25
- const 뒤에붙는것 = /[,.?!;:)\]}"'」』]+$/;
26
-
27
- /**
28
- * 글에서 @지목을 찾는다. 붙일지 말지는 여기서 안 정한다 — 자리만 찾는다.
29
- *
30
- * @returns {Array<{path:string, start:number, end:number, raw:string}>}
31
- */
32
- export function findMentions(text) {
33
- const s = String(text ?? '');
34
- const out = [];
35
- for (let i = 0; i < s.length; i++) {
36
- if (s[i] !== '@') continue;
37
- // 앞이 글자면 지목이 아니다 — hong@example.com 의 @ 가 여기서 걸러진다.
38
- const 앞 = i > 0 ? s[i - 1] : ' ';
39
- if (!/[\s(\[{'"]/.test(앞)) continue;
40
-
41
- // @"띄어쓰기 든 이름.txt" — 따옴표로 묶으면 통째로 하나다.
42
- if (s[i + 1] === '"' || s[i + 1] === "'") {
43
- const 따옴 = s[i + 1];
44
- const 끝 = s.indexOf(따옴, i + 2);
45
- if (끝 > i + 2) {
46
- out.push({ path: s.slice(i + 2, 끝), start: i, end: 끝 + 1, raw: s.slice(i, 끝 + 1) });
47
- i = 끝;
48
- continue;
49
- }
50
- }
51
-
52
- let j = i + 1;
53
- while (j < s.length && !/[\s"'`]/.test(s[j])) j++;
54
- if (j === i + 1) continue; // @ 뒤가 비었다
55
- const raw = s.slice(i, j);
56
- out.push({ path: s.slice(i + 1, j), start: i, end: j, raw });
57
- i = j - 1;
58
- }
59
- return out;
60
- }
61
-
62
- /** 이 지목이 가리키는 실제 자리. 못 찾거나 범위 밖이면 왜인지 같이 준다. */
63
- function 찾아보기(m, scope) {
64
- const 후보 = [m.path];
65
- // 뒤에 붙은 문장부호를 떼고도 한 번 본다 — "@src/a.js, 이것" 처럼 쓴다.
66
- const 뗀것 = m.path.replace(뒤에붙는것, '');
67
- if (뗀것 && 뗀것 !== m.path) 후보.push(뗀것);
68
-
69
- for (const p of 후보) {
70
- if (!p) continue;
71
- let abs;
72
- try { abs = scope.resolve(p); }
73
- catch (err) { return { why: 'blocked', note: err.message, path: p }; }
74
- if (existsSync(abs)) return { abs, path: p };
75
- }
76
- return { why: 'missing', path: m.path };
77
- }
78
-
79
- function 폴더내용(abs, show, root = null) {
80
- let 목록 = [];
81
- try { 목록 = readdirSync(abs, { withFileTypes: true }); }
82
- catch (err) { return `(폴더를 못 읽었습니다: ${err.message})`; }
83
- // git 이 안 보는 것은 여기서도 안 늘어놓는다 — Glob · Grep 과 같은 규칙 (tools/ignore.js). 뺀 수는 적는다.
84
- const rel = show === '.' ? '' : String(show).replace(/\\/g, '/');
85
- const 규칙 = root ? 계보규칙읽기(root, rel) : [];
86
- const 보임 = 규칙.length ? 목록.filter((e) => !무시하나(rel ? `${rel}/${e.name}` : e.name, e.isDirectory(), 규칙)) : 목록;
87
- const 뺀 = 목록.length - 보임.length;
88
- const 줄들 = 보임.slice(0, 폴더최대).map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
89
- const 더 = 보임.length > 폴더최대 ? `\n… 전체 ${보임.length}개 중 ${폴더최대}개까지` : '';
90
- const 건너뜀 = 뺀 ? `\n(.gitignore 로 ${뺀}개 건너뜀 — 경로를 직접 주면 Read 된다)` : '';
91
- return `${show} 는 폴더입니다. 안에 든 것:\n${줄들.join('\n')}${더}${건너뜀}`;
92
- }
93
-
94
- /**
95
- * @지목을 실제 내용으로 바꿔 붙인다.
96
- *
97
- * 글 자체는 안 건드린다 — 사람이 쓴 말은 그대로 두고, 뒤에 파일을 덧붙인다.
98
- * 지목한 자리를 내용으로 갈아치우면 "@src/a.js 이거 왜 느려?" 가 코드 덩어리
99
- * 한복판에서 끝나는 문장이 되어 버려서, 모델이 무엇을 물었는지 놓친다.
100
- *
101
- * @param {string} text
102
- * @param {{scope?:object, budget?:number, seen?:Set<string>, onRead?:Function}} opts
103
- * seen 을 주면 통째로 붙은 파일을 '읽은 것' 으로 적어 둔다 — 그래야 Edit 이
104
- * 'Read 부터 하라' 며 되돌려 보내지 않는다. 잘린 파일은 안 적는다.
105
- * 안 본 데를 본 것으로 치면 그 자리를 그냥 고쳐 버린다.
106
- */
107
- export function expand(text, { scope = null, budget = 기본예산, seen = null, onRead = null, 눈있나 = false } = {}) {
108
- const 원문 = String(text ?? '');
109
- const 빈답 = { text: 원문, attached: [], missing: [], blocked: [], 그림들: [] };
110
- if (!원문.includes('@') || !scope?.resolve) return 빈답;
111
-
112
- const 지목들 = findMentions(원문);
113
- if (!지목들.length) return 빈답;
114
-
115
- const attached = [];
116
- const missing = [];
117
- const blocked = [];
118
- const 그림들 = [];
119
- const 붙일것 = [];
120
- const 본것 = new Set();
121
- let 남은예산 = Math.max(200, Number(budget) || 기본예산);
122
-
123
- for (const m of 지목들) {
124
- const 자리 = 찾아보기(m, scope);
125
- if (자리.why === 'blocked') { blocked.push({ path: 자리.path, why: 자리.note }); continue; }
126
- if (자리.why === 'missing') { missing.push(자리.path); continue; }
127
-
128
- /*
129
- * 살림 파일은 @ 로도 못 붙인다.
130
- *
131
- * Read 도구는 .deel/config.json 을 막고 있었는데 **여기는 안 막고 있었다.**
132
- * 그래서 `@.deel/config.json` 한 줄이면 게이트웨이 열쇠가 대화에 실려
133
- * 그대로 바깥으로 나갔다. 도구는 막고 @ 는 안 막으면 막은 것이 아니다.
134
- * (검사: test/mention-secret.test.js)
135
- */
136
- const 살림 = 내부살림(자리.abs);
137
- if (살림) { blocked.push({ path: 자리.path, why: 살림 }); continue; }
138
-
139
- if (본것.has(자리.abs)) continue; // 같은 파일을 두 번 적지 않는다
140
- 본것.add(자리.abs);
141
-
142
- const show = scope.show ? scope.show(자리.abs) : 자리.path;
143
- let 몸통;
144
- let 통째로 = true;
145
-
146
- try {
147
- if (statSync(자리.abs).isDirectory()) {
148
- 몸통 = 폴더내용(자리.abs, show, scope.root ?? null);
149
- } else if (그림인가(자리.abs)) {
150
- /*
151
- * 그림은 글로 안 붙인다. 붙이면 깨진 글자 수천 자가 대화에 실린다.
152
- *
153
- * 볼 수 있는 모델이면 그림 자체를 이 말과 함께 싣고(repl.js → loop.js),
154
- * 못 보는 모델이면 **바이트를 아예 안 싣는다.** 안 보이는 모델에게
155
- * 보내 봐야 400 이 오거나, 더 나쁘게는 서버가 조용히 무시하고 답을
156
- * 지어낸다. 그러면 사람은 모델이 화면을 봤다고 믿는다.
157
- */
158
- const 것 = 눈있나 ? 그림읽기(자리.abs) : { ok: false, 왜: null, bytes: 0 };
159
- if (것.ok) {
160
- 그림들.push({ b64: 것.b64, mime: 것.mime, bytes: 것.bytes, show });
161
- 몸통 = `(그림 · ${크기말(것.bytes)} — 이 말과 함께 붙였습니다)`;
162
- attached.push({ path: 자리.abs, show, full: true, 그림: true, bytes: 것.bytes });
163
- 붙일것.push(`
164
-
165
- --- ${show} ---
166
- ${몸통}
167
- --- ${show} 끝 ---`);
168
- continue;
169
- }
170
- 몸통 = 눈있나
171
- ? `(못 붙였습니다: ${것.왜})`
172
- : '(그림입니다. 지금 붙어 있는 모델은 그림을 못 봅니다 — 안 붙였습니다.)';
173
- 통째로 = false;
174
- } else {
175
- // 사내 파일은 CP949 가 흔하다. 그냥 읽으면 통째로 깨진다.
176
- const 읽음 = readTextFull(자리.abs);
177
- const 줄들 = 읽음.text.split('\n');
178
- const 붙은글 = 줄들.map((l, i) => `${String(i + 1).padStart(6)}\t${l}`).join('\n');
179
- if (estimateTokens(붙은글) <= 남은예산) {
180
- 몸통 = 붙은글;
181
- } else {
182
- // 예산만큼만 앞에서 자른다. 글자 수로 어림잡되 남는 쪽으로 잡는다.
183
- 통째로 = false;
184
- const 어림 = Math.max(1, Math.floor(남은예산 * 3));
185
- const 자른것 = 붙은글.slice(0, 어림);
186
- const 마지막줄 = 자른것.split('\n').length;
187
- 몸통 = `${자른것}\n… 전체 ${줄들.length}줄 중 ${마지막줄}줄까지만 붙였습니다 (나머지는 Read 로 읽으세요)`;
188
- }
189
- if (onRead) onRead(자리.abs, 읽음.text);
190
- if (통째로 && seen) seen.add(자리.abs);
191
- }
192
- } catch (err) {
193
- // 못 읽는 파일(바이너리 등)은 붙이지 않는다. 그렇다고 말은 해 준다.
194
- 몸통 = `(못 읽었습니다: ${err.message})`;
195
- 통째로 = false;
196
- }
197
-
198
- const 토막 = `\n\n--- ${show} ---\n${몸통}\n--- ${show} 끝 ---`;
199
- 남은예산 -= estimateTokens(토막);
200
- 붙일것.push(토막);
201
- attached.push({ path: 자리.abs, show, full: 통째로 });
202
- if (남은예산 <= 0) break;
203
- }
204
-
205
- if (!붙일것.length) return { text: 원문, attached, missing, blocked, 그림들 };
206
- const 머리 = attached.length === 1
207
- ? '아래는 사용자가 @ 로 지목한 파일입니다. 이미 읽은 것으로 치고 답하세요.'
208
- : `아래는 사용자가 @ 로 지목한 파일 ${attached.length}개입니다. 이미 읽은 것으로 치고 답하세요.`;
209
- return { text: `${원문}\n\n${머리}${붙일것.join('')}`, attached, missing, blocked, 그림들 };
210
- }
1
+ // @파일 지목 — 말 속에 @경로 를 쓰면 그 파일을 바로 붙여 보낸다.
2
+ //
3
+ // 왜 필요한가:
4
+ // 지금은 파일 하나 보여주려면 모델이 Read 도구를 스스로 불러야 한다. 왕복이
5
+ // 한 번 더 든다. 로컬 모델은 도구 호출이 약해서 그 한 번이 자주 헛돈다 —
6
+ // 엉뚱한 경로를 부르거나, 아예 안 부르고 지어낸다. 사람은 이미 어느 파일인지
7
+ // 알고 있는데 모델더러 찾아보라고 시키는 셈이다.
8
+ //
9
+ // 여기서 어려운 것은 붙이는 일이 아니라 '아닌 것을 파일로 오해하지 않기' 다.
10
+ // @ 로 시작하는 것은 세상에 널렸다 — 이메일 주소, CSS 의 @media, 파이썬 장식자,
11
+ // npm 의 @scope/pkg, 사람 아이디. 잘못 붙이면 엉뚱한 글이 대화에 섞여 들어가고,
12
+ // 사람은 자기가 안 보낸 것이 왜 거기 있는지 모른다.
13
+ //
14
+ // 그래서 규칙을 하나로 뒀다: **실제로 있는 경로일 때만 붙인다.**
15
+ // 없으면 아무 말 없이 글자 그대로 둔다. 지목이 아니었을 테니 조용한 편이 맞다.
16
+ import { existsSync, statSync, readdirSync } from 'node:fs';
17
+ import { readTextFull, 내부살림 } from '../tools/fsutil.js';
18
+ import { 계보규칙읽기, 무시하나 } from '../tools/ignore.js';
19
+ import { 그림인가, 그림읽기, 크기말 } from '../backend/vision.js';
20
+ import { estimateTokens } from './session.js';
21
+
22
+ // 붙일 수 있는 최대 토큰. 부르는 쪽에서 컨텍스트에 맞춰 넘겨준다.
23
+ const 기본예산 = 6000;
24
+ const 폴더최대 = 60; // 폴더를 지목했을 때 늘어놓을 항목 수
25
+ const 뒤에붙는것 = /[,.?!;:)\]}"'」』]+$/;
26
+
27
+ /**
28
+ * 글에서 @지목을 찾는다. 붙일지 말지는 여기서 안 정한다 — 자리만 찾는다.
29
+ *
30
+ * @returns {Array<{path:string, start:number, end:number, raw:string}>}
31
+ */
32
+ export function findMentions(text) {
33
+ const s = String(text ?? '');
34
+ const out = [];
35
+ for (let i = 0; i < s.length; i++) {
36
+ if (s[i] !== '@') continue;
37
+ // 앞이 글자면 지목이 아니다 — hong@example.com 의 @ 가 여기서 걸러진다.
38
+ const 앞 = i > 0 ? s[i - 1] : ' ';
39
+ if (!/[\s(\[{'"]/.test(앞)) continue;
40
+
41
+ // @"띄어쓰기 든 이름.txt" — 따옴표로 묶으면 통째로 하나다.
42
+ if (s[i + 1] === '"' || s[i + 1] === "'") {
43
+ const 따옴 = s[i + 1];
44
+ const 끝 = s.indexOf(따옴, i + 2);
45
+ if (끝 > i + 2) {
46
+ out.push({ path: s.slice(i + 2, 끝), start: i, end: 끝 + 1, raw: s.slice(i, 끝 + 1) });
47
+ i = 끝;
48
+ continue;
49
+ }
50
+ }
51
+
52
+ let j = i + 1;
53
+ while (j < s.length && !/[\s"'`]/.test(s[j])) j++;
54
+ if (j === i + 1) continue; // @ 뒤가 비었다
55
+ const raw = s.slice(i, j);
56
+ out.push({ path: s.slice(i + 1, j), start: i, end: j, raw });
57
+ i = j - 1;
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /** 이 지목이 가리키는 실제 자리. 못 찾거나 범위 밖이면 왜인지 같이 준다. */
63
+ function 찾아보기(m, scope) {
64
+ const 후보 = [m.path];
65
+ // 뒤에 붙은 문장부호를 떼고도 한 번 본다 — "@src/a.js, 이것" 처럼 쓴다.
66
+ const 뗀것 = m.path.replace(뒤에붙는것, '');
67
+ if (뗀것 && 뗀것 !== m.path) 후보.push(뗀것);
68
+
69
+ for (const p of 후보) {
70
+ if (!p) continue;
71
+ let abs;
72
+ try { abs = scope.resolve(p); }
73
+ catch (err) { return { why: 'blocked', note: err.message, path: p }; }
74
+ if (existsSync(abs)) return { abs, path: p };
75
+ }
76
+ return { why: 'missing', path: m.path };
77
+ }
78
+
79
+ function 폴더내용(abs, show, root = null) {
80
+ let 목록 = [];
81
+ try { 목록 = readdirSync(abs, { withFileTypes: true }); }
82
+ catch (err) { return `(폴더를 못 읽었습니다: ${err.message})`; }
83
+ // git 이 안 보는 것은 여기서도 안 늘어놓는다 — Glob · Grep 과 같은 규칙 (tools/ignore.js). 뺀 수는 적는다.
84
+ const rel = show === '.' ? '' : String(show).replace(/\\/g, '/');
85
+ const 규칙 = root ? 계보규칙읽기(root, rel) : [];
86
+ const 보임 = 규칙.length ? 목록.filter((e) => !무시하나(rel ? `${rel}/${e.name}` : e.name, e.isDirectory(), 규칙)) : 목록;
87
+ const 뺀 = 목록.length - 보임.length;
88
+ const 줄들 = 보임.slice(0, 폴더최대).map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
89
+ const 더 = 보임.length > 폴더최대 ? `\n… 전체 ${보임.length}개 중 ${폴더최대}개까지` : '';
90
+ const 건너뜀 = 뺀 ? `\n(.gitignore 로 ${뺀}개 건너뜀 — 경로를 직접 주면 Read 된다)` : '';
91
+ return `${show} 는 폴더입니다. 안에 든 것:\n${줄들.join('\n')}${더}${건너뜀}`;
92
+ }
93
+
94
+ /**
95
+ * @지목을 실제 내용으로 바꿔 붙인다.
96
+ *
97
+ * 글 자체는 안 건드린다 — 사람이 쓴 말은 그대로 두고, 뒤에 파일을 덧붙인다.
98
+ * 지목한 자리를 내용으로 갈아치우면 "@src/a.js 이거 왜 느려?" 가 코드 덩어리
99
+ * 한복판에서 끝나는 문장이 되어 버려서, 모델이 무엇을 물었는지 놓친다.
100
+ *
101
+ * @param {string} text
102
+ * @param {{scope?:object, budget?:number, seen?:Set<string>, onRead?:Function}} opts
103
+ * seen 을 주면 통째로 붙은 파일을 '읽은 것' 으로 적어 둔다 — 그래야 Edit 이
104
+ * 'Read 부터 하라' 며 되돌려 보내지 않는다. 잘린 파일은 안 적는다.
105
+ * 안 본 데를 본 것으로 치면 그 자리를 그냥 고쳐 버린다.
106
+ */
107
+ export function expand(text, { scope = null, budget = 기본예산, seen = null, onRead = null, 눈있나 = false } = {}) {
108
+ const 원문 = String(text ?? '');
109
+ const 빈답 = { text: 원문, attached: [], missing: [], blocked: [], 그림들: [] };
110
+ if (!원문.includes('@') || !scope?.resolve) return 빈답;
111
+
112
+ const 지목들 = findMentions(원문);
113
+ if (!지목들.length) return 빈답;
114
+
115
+ const attached = [];
116
+ const missing = [];
117
+ const blocked = [];
118
+ const 그림들 = [];
119
+ const 붙일것 = [];
120
+ const 본것 = new Set();
121
+ let 남은예산 = Math.max(200, Number(budget) || 기본예산);
122
+
123
+ for (const m of 지목들) {
124
+ const 자리 = 찾아보기(m, scope);
125
+ if (자리.why === 'blocked') { blocked.push({ path: 자리.path, why: 자리.note }); continue; }
126
+ if (자리.why === 'missing') { missing.push(자리.path); continue; }
127
+
128
+ /*
129
+ * 살림 파일은 @ 로도 못 붙인다.
130
+ *
131
+ * Read 도구는 .deel/config.json 을 막고 있었는데 **여기는 안 막고 있었다.**
132
+ * 그래서 `@.deel/config.json` 한 줄이면 게이트웨이 열쇠가 대화에 실려
133
+ * 그대로 바깥으로 나갔다. 도구는 막고 @ 는 안 막으면 막은 것이 아니다.
134
+ * (검사: test/mention.test.js 의 「살림 파일은 @ 로도 못 붙인다」)
135
+ */
136
+ const 살림 = 내부살림(자리.abs);
137
+ if (살림) { blocked.push({ path: 자리.path, why: 살림 }); continue; }
138
+
139
+ if (본것.has(자리.abs)) continue; // 같은 파일을 두 번 적지 않는다
140
+ 본것.add(자리.abs);
141
+
142
+ const show = scope.show ? scope.show(자리.abs) : 자리.path;
143
+ let 몸통;
144
+ let 통째로 = true;
145
+
146
+ try {
147
+ if (statSync(자리.abs).isDirectory()) {
148
+ 몸통 = 폴더내용(자리.abs, show, scope.root ?? null);
149
+ } else if (그림인가(자리.abs)) {
150
+ /*
151
+ * 그림은 글로 안 붙인다. 붙이면 깨진 글자 수천 자가 대화에 실린다.
152
+ *
153
+ * 볼 수 있는 모델이면 그림 자체를 이 말과 함께 싣고(repl.js → loop.js),
154
+ * 못 보는 모델이면 **바이트를 아예 안 싣는다.** 안 보이는 모델에게
155
+ * 보내 봐야 400 이 오거나, 더 나쁘게는 서버가 조용히 무시하고 답을
156
+ * 지어낸다. 그러면 사람은 모델이 화면을 봤다고 믿는다.
157
+ */
158
+ const 것 = 눈있나 ? 그림읽기(자리.abs) : { ok: false, 왜: null, bytes: 0 };
159
+ if (것.ok) {
160
+ 그림들.push({ b64: 것.b64, mime: 것.mime, bytes: 것.bytes, show });
161
+ 몸통 = `(그림 · ${크기말(것.bytes)} — 이 말과 함께 붙였습니다)`;
162
+ attached.push({ path: 자리.abs, show, full: true, 그림: true, bytes: 것.bytes });
163
+ 붙일것.push(`
164
+
165
+ --- ${show} ---
166
+ ${몸통}
167
+ --- ${show} 끝 ---`);
168
+ continue;
169
+ }
170
+ 몸통 = 눈있나
171
+ ? `(못 붙였습니다: ${것.왜})`
172
+ : '(그림입니다. 지금 붙어 있는 모델은 그림을 못 봅니다 — 안 붙였습니다.)';
173
+ 통째로 = false;
174
+ } else {
175
+ // 사내 파일은 CP949 가 흔하다. 그냥 읽으면 통째로 깨진다.
176
+ const 읽음 = readTextFull(자리.abs);
177
+ const 줄들 = 읽음.text.split('\n');
178
+ const 붙은글 = 줄들.map((l, i) => `${String(i + 1).padStart(6)}\t${l}`).join('\n');
179
+ if (estimateTokens(붙은글) <= 남은예산) {
180
+ 몸통 = 붙은글;
181
+ } else {
182
+ // 예산만큼만 앞에서 자른다. 글자 수로 어림잡되 남는 쪽으로 잡는다.
183
+ 통째로 = false;
184
+ const 어림 = Math.max(1, Math.floor(남은예산 * 3));
185
+ const 자른것 = 붙은글.slice(0, 어림);
186
+ const 마지막줄 = 자른것.split('\n').length;
187
+ 몸통 = `${자른것}\n… 전체 ${줄들.length}줄 중 ${마지막줄}줄까지만 붙였습니다 (나머지는 Read 로 읽으세요)`;
188
+ }
189
+ if (onRead) onRead(자리.abs, 읽음.text);
190
+ if (통째로 && seen) seen.add(자리.abs);
191
+ }
192
+ } catch (err) {
193
+ // 못 읽는 파일(바이너리 등)은 붙이지 않는다. 그렇다고 말은 해 준다.
194
+ 몸통 = `(못 읽었습니다: ${err.message})`;
195
+ 통째로 = false;
196
+ }
197
+
198
+ const 토막 = `\n\n--- ${show} ---\n${몸통}\n--- ${show} 끝 ---`;
199
+ 남은예산 -= estimateTokens(토막);
200
+ 붙일것.push(토막);
201
+ attached.push({ path: 자리.abs, show, full: 통째로 });
202
+ if (남은예산 <= 0) break;
203
+ }
204
+
205
+ if (!붙일것.length) return { text: 원문, attached, missing, blocked, 그림들 };
206
+ const 머리 = attached.length === 1
207
+ ? '아래는 사용자가 @ 로 지목한 파일입니다. 이미 읽은 것으로 치고 답하세요.'
208
+ : `아래는 사용자가 @ 로 지목한 파일 ${attached.length}개입니다. 이미 읽은 것으로 치고 답하세요.`;
209
+ return { text: `${원문}\n\n${머리}${붙일것.join('')}`, attached, missing, blocked, 그림들 };
210
+ }