deel-local-cli 1.5.7 → 1.6.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 (57) hide show
  1. package/README.en.md +24 -13
  2. package/README.md +23 -12
  3. package/bin/deel.js +10 -0
  4. package/package.json +2 -2
  5. package/src/acp/map.js +143 -0
  6. package/src/acp/serve.js +160 -13
  7. package/src/agent/commit.js +511 -0
  8. package/src/agent/compact.js +269 -226
  9. package/src/agent/loop.js +154 -12
  10. package/src/agent/mention.js +56 -10
  11. package/src/agent/review.js +192 -0
  12. package/src/agent/session.js +20 -2
  13. package/src/agent/threads.js +1 -1
  14. package/src/backend/adapter.js +342 -280
  15. package/src/backend/azure.js +151 -0
  16. package/src/backend/ctxsize.js +19 -0
  17. package/src/backend/detect.js +106 -0
  18. package/src/backend/http.js +350 -30
  19. package/src/backend/probe.js +48 -1
  20. package/src/backend/proxy.js +151 -0
  21. package/src/backend/quota.js +133 -0
  22. package/src/backend/retry.js +132 -0
  23. package/src/backend/vision.js +185 -0
  24. package/src/commands.js +341 -8
  25. package/src/completion.js +264 -0
  26. package/src/config.js +117 -1
  27. package/src/i18n/en.js +13 -1
  28. package/src/i18n/index.js +22 -3
  29. package/src/i18n/ja.js +266 -0
  30. package/src/i18n/ko.js +12 -0
  31. package/src/i18n/zh.js +266 -0
  32. package/src/oneshot.js +28 -3
  33. package/src/pack/sbom.js +25 -0
  34. package/src/pack/selfpack.js +9 -3
  35. package/src/plugins/manage.js +34 -7
  36. package/src/repl.js +113 -15
  37. package/src/report.js +26 -2
  38. package/src/safety/guard.js +82 -3
  39. package/src/safety/keystore.js +237 -0
  40. package/src/safety/network.js +76 -14
  41. package/src/safety/policy.js +209 -0
  42. package/src/safety/secrets.js +32 -0
  43. package/src/setup.js +40 -7
  44. package/src/tools/clipboard.js +178 -0
  45. package/src/tools/fastgrep.js +217 -0
  46. package/src/tools/fsutil.js +55 -12
  47. package/src/tools/ignore.js +192 -0
  48. package/src/tools/index.js +282 -39
  49. package/src/tools/jobs.js +4 -12
  50. package/src/tools/outline.js +3 -1
  51. package/src/tools/pdf.js +1326 -0
  52. package/src/tools/shell.js +123 -0
  53. package/src/tools/verify.js +20 -4
  54. package/src/tools/webfetch.js +18 -5
  55. package/src/ui/inputbox.js +2 -2
  56. package/src/ui/screen.js +4 -1
  57. package/src/ui/status.js +22 -0
@@ -0,0 +1,133 @@
1
+ // 게이트웨이가 남았다고 알려 주는 할당량.
2
+ //
3
+ // ── 왜 필요한가 ────────────────────────────────────────────────────────
4
+ //
5
+ // 사내 게이트웨이는 사람마다 할당량을 건다. 그런데 지금까지 그걸 아는 방법은
6
+ // **429 를 맞는 것뿐**이었다. 일하는 도중에 갑자기 막히고, 화면에는
7
+ // "잠깐 막혔습니다" 가 뜨고, 사람은 언제 풀리는지 모른 채 기다린다.
8
+ //
9
+ // 그런데 서버는 매 응답에 남은 양을 실어 보내고 있었다. 우리가 안 읽었을
10
+ // 뿐이다. 읽어서 보여 주면 사람은 막히기 전에 안다 — 큰 작업을 시작할지,
11
+ // 오늘은 여기까지 할지 스스로 정할 수 있다.
12
+ //
13
+ // ── 이름이 제각각이다 ──────────────────────────────────────────────────
14
+ //
15
+ // 표준이 없다. OpenAI 계열은 `x-ratelimit-remaining-requests`, Azure 는
16
+ // `x-ratelimit-remaining-tokens` 를 쓰기도 하고 아예 안 주기도 한다.
17
+ // 그래서 **아는 이름만 읽고, 없으면 없다고 한다.** 없는 것을 0 으로 치면
18
+ // 화면에 "0 남음" 이 떠서, 멀쩡한데 다 썼다고 믿게 된다.
19
+
20
+ // 읽을 이름들. 앞에서부터 처음 있는 것 하나를 쓴다.
21
+ const 요청남음 = ['x-ratelimit-remaining-requests', 'ratelimit-remaining-requests', 'x-ratelimit-remaining'];
22
+ const 토큰남음 = ['x-ratelimit-remaining-tokens', 'ratelimit-remaining-tokens'];
23
+ const 요청한도 = ['x-ratelimit-limit-requests', 'ratelimit-limit-requests'];
24
+ const 토큰한도 = ['x-ratelimit-limit-tokens', 'ratelimit-limit-tokens'];
25
+ const 다시언제 = ['retry-after', 'x-ratelimit-reset-requests', 'x-ratelimit-reset-tokens', 'ratelimit-reset'];
26
+
27
+ function 골라(머리, 이름들) {
28
+ if (!머리) return null;
29
+ const 보기 = (k) => (typeof 머리.get === 'function' ? 머리.get(k) : 머리[k] ?? 머리[k.toLowerCase()]);
30
+ for (const k of 이름들) {
31
+ const v = 보기(k);
32
+ if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /*
38
+ * 숫자로 읽는다. 못 읽으면 null — 0 이 아니다.
39
+ *
40
+ * 이 구분이 여기서 제일 중요하다. 못 읽은 것을 0 으로 치면 화면에
41
+ * "남은 요청 0" 이 뜨고, 사람은 멀쩡한 할당량을 다 썼다고 믿는다.
42
+ */
43
+ function 숫자(v) {
44
+ if (v === null) return null;
45
+ const n = Number(String(v).replace(/[,_\s]/g, ''));
46
+ return Number.isFinite(n) ? n : null;
47
+ }
48
+
49
+ /*
50
+ * `retry-after` 는 초일 수도 날짜일 수도 있다 (RFC 9110).
51
+ * 날짜면 지금과의 차이를 초로 바꾼다. 못 읽으면 null.
52
+ */
53
+ export function 언제풀리나(v) {
54
+ if (v === null || v === undefined) return null;
55
+ const s = String(v).trim();
56
+ const n = Number(s);
57
+ if (Number.isFinite(n)) return Math.max(0, Math.round(n));
58
+ const t = Date.parse(s);
59
+ if (Number.isFinite(t)) return Math.max(0, Math.round((t - Date.now()) / 1000));
60
+ // `1m30s` 같은 꼴을 주는 게이트웨이가 있다.
61
+ const m = /^(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?$/.exec(s);
62
+ if (m && (m[1] || m[2])) return Math.round((Number(m[1] ?? 0) * 60) + Number(m[2] ?? 0));
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * 응답 머리에서 할당량을 읽는다.
68
+ *
69
+ * @returns {{요청:number|null, 요청한도:number|null, 토큰:number|null, 토큰한도:number|null, 풀림:number|null, 있나:boolean}}
70
+ */
71
+ export function 할당량읽기(머리) {
72
+ const 것 = {
73
+ 요청: 숫자(골라(머리, 요청남음)),
74
+ 요청한도: 숫자(골라(머리, 요청한도)),
75
+ 토큰: 숫자(골라(머리, 토큰남음)),
76
+ 토큰한도: 숫자(골라(머리, 토큰한도)),
77
+ 풀림: 언제풀리나(골라(머리, 다시언제)),
78
+ };
79
+ 것.있나 = 것.요청 !== null || 것.토큰 !== null || 것.풀림 !== null;
80
+ return 것;
81
+ }
82
+
83
+ /*
84
+ * 얼마나 남았을 때 화면에 띄울까.
85
+ *
86
+ * 한도를 알면 비율로 본다(10% 아래). 한도를 안 알려주는 서버가 많아서,
87
+ * 그때는 남은 수 자체로 본다 — 요청 20회 아래, 토큰 20,000 아래.
88
+ * 넉넉할 때 자꾸 띄우면 사람이 그 줄을 안 읽게 된다.
89
+ */
90
+ export const 요청바닥 = 20;
91
+ export const 토큰바닥 = 20000;
92
+
93
+ export function 아슬아슬한가(것) {
94
+ if (!것?.있나) return false;
95
+ if (것.풀림 !== null && 것.풀림 > 0) return true;
96
+ if (것.요청 !== null) {
97
+ if (것.요청한도) { if (것.요청 / 것.요청한도 <= 0.1) return true; }
98
+ else if (것.요청 <= 요청바닥) return true;
99
+ }
100
+ if (것.토큰 !== null) {
101
+ if (것.토큰한도) { if (것.토큰 / 것.토큰한도 <= 0.1) return true; }
102
+ else if (것.토큰 <= 토큰바닥) return true;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ /** 화면 한 줄. 아는 것만 적는다 — 모르는 자리는 아예 안 적는다. */
108
+ export function 할당량말(것) {
109
+ if (!것?.있나) return '';
110
+ const 조각 = [];
111
+ if (것.요청 !== null) 조각.push(`요청 ${것.요청.toLocaleString()}${것.요청한도 ? `/${것.요청한도.toLocaleString()}` : ''}`);
112
+ if (것.토큰 !== null) 조각.push(`토큰 ${것.토큰.toLocaleString()}${것.토큰한도 ? `/${것.토큰한도.toLocaleString()}` : ''}`);
113
+ if (것.풀림 !== null) 조각.push(`${것.풀림}초 뒤 풀림`);
114
+ return 조각.join(' · ');
115
+ }
116
+
117
+ /*
118
+ * 마지막으로 본 할당량. 응답마다 덮어쓴다.
119
+ *
120
+ * 세션에 안 두고 모듈에 두는 까닭: 이걸 읽는 자리가 여럿인데(상태줄, /cost,
121
+ * 시작 화면) 그 자리들이 세션을 다 들고 있지는 않다. 그리고 값 자체가
122
+ * '지금 서버가 말한 것' 이라 한 벌이면 충분하다.
123
+ */
124
+ let 마지막 = null;
125
+
126
+ export function 할당량기억(머리) {
127
+ const 것 = 할당량읽기(머리);
128
+ if (것.있나) 마지막 = { ...것, 때: Date.now() };
129
+ return 것;
130
+ }
131
+
132
+ export function 마지막할당량() { return 마지막; }
133
+ export function 할당량잊기() { 마지막 = null; return null; }
@@ -0,0 +1,132 @@
1
+ // 잠깐 막힌 것과 진짜 안 되는 것을 가른다. 그리고 얼마나 기다릴지 정한다.
2
+ //
3
+ // 왜 필요한가:
4
+ // 사내 게이트웨이는 사람마다 할당량이 있어 429 를 자주 준다. 뒤에 붙은 모델이
5
+ // 재시작하면 502·503 이 몇 초 온다. 연결이 그냥 끊기기도 한다. 이 셋은
6
+ // **몇 초 뒤에 같은 요청을 다시 보내면 되는** 것들인데, 전에는 전부 턴을
7
+ // 통째로 죽였다 — adapter 가 !ok 면 던지고, 루프는 잘린 답과 빈 답만 다시
8
+ // 불렀지 상태 코드는 안 봤다. 사람은 같은 말을 다시 치고, 그 사이 읽어 둔
9
+ // 도구 결과는 날아갔다. Task 로 여럿을 떼어 주면 그만큼 자주 걸린다.
10
+ //
11
+ // 어디까지만 다시 부르나:
12
+ // · 같은 요청을 그대로 다시 보내도 탈이 없는 자리에서만. 모델 호출은 서버에
13
+ // 아무것도 안 남기니 그렇다. 파일을 바꾸는 도구는 여기 안 온다 — 그쪽은
14
+ // guard.js 가 "한 번 실패한 바꾸는 명령은 다시 안 돌린다" 로 지킨다.
15
+ // · 본문이 한 글자라도 흘러온 **뒤에** 끊긴 것은 안 부른다. 반쯤 온 답을 두 벌
16
+ // 만들면 안 된다 — 그건 살려 쓰기(agent/salvage.js) 가 받는다.
17
+ // · 세 번까지. 그 뒤로는 사실대로 말한다. 열 번 두드리는 것은 할당량을 더 빨리
18
+ // 태우는 짓이다.
19
+ // · 400·401·403·404 는 다시 불러 봐야 같다. 한 번만 부르고 서버가 한 말을 보여 준다.
20
+ // · ECONNREFUSED 는 서버가 꺼진 것이고, 시간 초과는 5분을 또 기다릴 일이 아니다.
21
+ // 둘 다 안 부른다.
22
+ //
23
+ // 얼마나 기다리나:
24
+ // 서버가 Retry-After 로 말해 주면 그것(60초에서 자른다 — 그 이상은 사람이 결정할
25
+ // 일이다). 안 주면 1초 → 2초 → 4초. 여기에 30% 안에서 흔든다 — 같은 게이트웨이를
26
+ // 쓰는 사람 여럿이 같은 박자로 다시 두드리면 그게 또 429 를 만든다.
27
+ import { Aborted } from './http.js';
28
+
29
+ /** 기본 정책. 검사는 base 를 짧게 바꿔 준다 — 모양은 같고 시간만 다르다. */
30
+ export function 기본정책() {
31
+ return { 최대: 3, base: [1000, 2000, 4000], 흔들림: 0.3, 상한: 60000 };
32
+ }
33
+
34
+ // 잠깐 막힌 것으로 보는 상태 코드. 529 는 Anthropic 계열 게이트웨이의 '과부하' 다.
35
+ const 다시부를상태 = new Set([408, 429, 500, 502, 503, 504, 529]);
36
+ // 머리말도 못 받고 끊긴 것. undici 는 상대가 닫으면 UND_ERR_SOCKET 으로 온다.
37
+ const 다시부를코드 = new Set(['ECONNRESET', 'EPIPE', 'UND_ERR_SOCKET', 'ECONNABORTED']);
38
+
39
+ /**
40
+ * 이 실패를 두고 다시 불러도 되나.
41
+ * @param {{status?: number, code?: string|null, attempt?: number}} 실패 attempt 는 방금 실패한 것이 몇 번째였나 (1부터)
42
+ */
43
+ export function 다시부를까({ status = 0, code = null, attempt = 1 } = {}, 정책 = 기본정책()) {
44
+ if (attempt > 정책.최대) return false;
45
+ if (status) return 다시부를상태.has(Number(status));
46
+ return !!code && 다시부를코드.has(String(code));
47
+ }
48
+
49
+ /**
50
+ * 몇 ms 기다릴까. 서버가 말해 준 것이 있으면 그것, 없으면 사다리.
51
+ * @param {{attempt?: number, retryAfter?: string|number|null}} 자리
52
+ */
53
+ export function 기다릴시간({ attempt = 1, retryAfter = null } = {}, 정책 = 기본정책()) {
54
+ const 서버말 = retryAfter읽기(retryAfter);
55
+ if (서버말 !== null) return Math.min(서버말, 정책.상한);
56
+ // 사다리가 비었으면(설정이 이상하면) 기본 사다리로 — NaN 초를 기다릴 수는 없다.
57
+ const 사다리 = Array.isArray(정책.base) && 정책.base.some(Number.isFinite)
58
+ ? 정책.base.filter(Number.isFinite)
59
+ : 기본정책().base;
60
+ const 칸 = 사다리[Math.min(attempt, 사다리.length) - 1] ?? 사다리[사다리.length - 1];
61
+ return Math.min(정책.상한, Math.round(칸 * (1 + Math.random() * 정책.흔들림)));
62
+ }
63
+
64
+ /*
65
+ * Retry-After 는 초이거나 HTTP 날짜다. 못 읽으면 null — 그때는 사다리로 간다.
66
+ *
67
+ * 규격은 정수 초지만 `1.5` 처럼 소수로 주는 서버가 실제로 있다. 그리고 `1,5` · `5;` ·
68
+ * `-1` 같은 것을 Date.parse 에 그냥 주면 엉뚱한 옛날 날짜로 읽혀 **0초**가 된다 —
69
+ * 그러면 세 번을 연달아 두드린다. 그래서 글자가 든 것만 날짜로 본다.
70
+ */
71
+ function retryAfter읽기(값) {
72
+ if (값 == null || 값 === '') return null;
73
+ const s = String(값).trim();
74
+ if (/^\d+(\.\d+)?$/.test(s)) return Math.round(Number(s) * 1000);
75
+ if (!/[a-z]/i.test(s)) return null;
76
+ const t = Date.parse(s);
77
+ if (Number.isNaN(t)) return null;
78
+ return Math.max(0, t - Date.now());
79
+ }
80
+
81
+ /**
82
+ * 기다린다. 그 사이 Ctrl+C 가 오면 **바로** Aborted 로 던진다 — 5초를 기다리라고
83
+ * 해 놓고 사람이 끊었는데 5초를 더 붙들면 안 된다.
84
+ */
85
+ export function 기다리기(ms, signal = null) {
86
+ return new Promise((resolve, reject) => {
87
+ if (signal?.aborted) return reject(new Aborted());
88
+ if (!(ms > 0)) return resolve();
89
+ const 끊기 = () => { clearTimeout(t); reject(new Aborted()); };
90
+ const t = setTimeout(() => { signal?.removeEventListener?.('abort', 끊기); resolve(); }, ms);
91
+ signal?.addEventListener?.('abort', 끊기, { once: true });
92
+ });
93
+ }
94
+
95
+ /**
96
+ * 실패한 응답 하나를 보고 "기다렸다 다시 부른다" 알림을 만든다. 안 부를 것이면 null.
97
+ * 화면·기록이 이 한 덩이를 그대로 쓴다 — 여기 없는 숫자는 화면에도 없다.
98
+ */
99
+ export function 다시부를지(r, attempt, 정책 = 기본정책()) {
100
+ const status = r?.status ?? 0;
101
+ const code = r?.code ?? null;
102
+ if (!다시부를까({ status, code, attempt }, 정책)) return null;
103
+ const retryAfter = r?.headers?.get?.('retry-after') ?? r?.res?.headers?.get?.('retry-after') ?? null;
104
+ return {
105
+ type: 'backoff',
106
+ status,
107
+ code,
108
+ wait: 기다릴시간({ attempt, retryAfter }, 정책),
109
+ attempt,
110
+ max: 정책.최대,
111
+ retryAfter: retryAfter ?? null,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * 알림 한 덩이를 화면 말(i18n 의 loop.backoff)에 끼울 자리로 바꾼다.
117
+ * 세 화면(repl · deel run · acp)이 같은 것을 본다 — 한 군데만 고치면 셋이 어긋난다.
118
+ */
119
+ export function 알림채움(ev) {
120
+ const 초 = (ev?.wait ?? 0) / 1000;
121
+ return {
122
+ 무엇: ev?.status ? `HTTP ${ev.status}` : (ev?.code ?? 'socket'),
123
+ 초: 초 >= 10 ? String(Math.round(초)) : String(Math.round(초 * 10) / 10),
124
+ n: ev?.attempt ?? 1,
125
+ max: ev?.max ?? 기본정책().최대,
126
+ };
127
+ }
128
+
129
+ /** 부르는 쪽(conn·opts)이 준 것을 기본 위에 얹는다. 검사가 사다리를 짧게 줄 때 쓴다. */
130
+ export function 정책고르기(conn, opts) {
131
+ return { ...기본정책(), ...(conn?.retry ?? {}), ...(opts?.retry ?? {}) };
132
+ }
@@ -0,0 +1,185 @@
1
+ // 그림을 모델에게 보여 준다.
2
+ //
3
+ // ── 왜 필요한가 ────────────────────────────────────────────────────────
4
+ //
5
+ // 사람이 버그를 설명하는 가장 흔한 방법은 화면 사진이다. "여기 이 화면
6
+ // 좀 봐" 는 글로 옮기기 어렵고, 옮기다 보면 정작 중요한 것(빨간 줄이
7
+ // 어디에 떴는지, 글자가 어디서 깨졌는지)이 빠진다.
8
+ //
9
+ // 그런데 지금까지 `Read shot.png` 는 그 파일을 **글로 읽으려고** 했다.
10
+ // PNG 를 글로 읽으면 깨진 글자 수천 자가 나온다. 그게 통째로 대화에
11
+ // 실려서 자리를 먹고, 모델은 그걸 코드로 착각하고 뭔가 말하려 든다.
12
+ // 사람은 왜 이상한 답이 오는지 모른다.
13
+ //
14
+ // ── 무엇을 하나 ────────────────────────────────────────────────────────
15
+ //
16
+ // 그림이면 그림으로 싣는다. 규격이 둘이라 모양도 둘이다.
17
+ //
18
+ // OpenAI 호환 content 배열에 { type:'image_url', image_url:{ url:'data:…' } }
19
+ // Ollama 메시지에 images: ['<base64>'] (data: 머리말 없이)
20
+ //
21
+ // 못 보는 모델에게는 **바이트를 아예 안 보낸다.** 보내 봐야 400 이 오거나,
22
+ // 더 나쁘게는 서버가 조용히 무시하고 답을 지어낸다. 대신 한 줄로 말한다.
23
+ import { readFileSync, statSync } from 'node:fs';
24
+
25
+ // 다룰 확장자. 이 목록에 없는 것은 예전처럼 글로 읽는다.
26
+ const 확장자 = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
27
+
28
+ /** 한 장 최대 크기. 창 크기와 상관없이 이 위로는 안 싣는다. */
29
+ export const 기본한도 = 4 * 1024 * 1024;
30
+
31
+ /** 경로만 보고 그림인지. 실제로 그림인지는 그림읽기() 가 속을 보고 정한다. */
32
+ export function 그림인가(경로) {
33
+ const s = String(경로 ?? '').toLowerCase();
34
+ const i = s.lastIndexOf('.');
35
+ return i > 0 && 확장자.has(s.slice(i));
36
+ }
37
+
38
+ /*
39
+ * 속을 보고 종류를 정한다 — 확장자를 믿지 않는다.
40
+ *
41
+ * 사내에서 화면을 캡처해 붙이는 길이 여럿이라, 이름만 .png 이고 속은 JPEG 인
42
+ * 파일이 흔하다. 그런 것을 `image/png` 라고 적어 보내면 게이트웨이가 400 을
43
+ * 준다 — 그러면 화면에는 "그림을 못 보냈습니다" 만 남고, 파일은 멀쩡해서
44
+ * 사람은 원인을 못 찾는다. 여기서 속을 보고 맞는 이름을 붙이면 그냥 된다.
45
+ *
46
+ * 또 하나. 사내망에서 그림 주소를 받아 저장하면 로그인 페이지 HTML 이
47
+ * shot.png 라는 이름으로 저장돼 있는 일이 있다. 그건 그림이 아니다.
48
+ */
49
+ export function 그림종류(buf) {
50
+ const b = buf;
51
+ if (!b || b.length < 12) return null;
52
+ if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png';
53
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
54
+ if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return 'image/gif';
55
+ // RIFF....WEBP
56
+ if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46
57
+ && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return 'image/webp';
58
+ return null;
59
+ }
60
+
61
+ /** 사람에게 보여 줄 크기. */
62
+ export function 크기말(bytes) {
63
+ const n = Number(bytes) || 0;
64
+ if (n < 1024) return `${n}B`;
65
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)}KB`;
66
+ return `${(n / (1024 * 1024)).toFixed(1)}MB`;
67
+ }
68
+
69
+ /**
70
+ * 그림 파일 하나를 실을 수 있는 모양으로 읽는다.
71
+ *
72
+ * 못 실을 이유가 있으면 **왜 그런지를 돌려준다.** 부르는 쪽이 그 말을 그대로
73
+ * 화면과 모델에게 보여 준다 — "그림을 못 읽었습니다" 로 뭉뚱그리면 사람이
74
+ * 파일을 줄여야 하는지 형식을 바꿔야 하는지 알 수 없다.
75
+ *
76
+ * @returns {{ok:true, b64:string, mime:string, bytes:number} | {ok:false, 왜:string, bytes:number}}
77
+ */
78
+ export function 그림읽기(abs, { 한도 = 기본한도 } = {}) {
79
+ let bytes = 0;
80
+ try { bytes = statSync(abs).size; }
81
+ catch (err) { return { ok: false, 왜: `못 읽었습니다: ${err.message}`, bytes: 0 }; }
82
+
83
+ // 크기부터 본다. 4MB 를 통째로 읽어 base64 로 부풀린 다음 버리면
84
+ // 그만큼의 메모리와 시간이 헛간다.
85
+ if (bytes > 한도) {
86
+ return {
87
+ ok: false,
88
+ bytes,
89
+ 왜: `그림이 큽니다 (${크기말(bytes)} · 한 장 한도 ${크기말(한도)}) — 줄여서 저장한 뒤 다시 주세요.`
90
+ + ' 여기서는 크기를 줄이지 않습니다. 줄이려면 다른 프로그램이 필요한데,'
91
+ + ' 이 도구는 아무것도 안 깔고 도는 것이 규칙입니다.',
92
+ };
93
+ }
94
+ if (bytes === 0) return { ok: false, 왜: '빈 파일입니다.', bytes: 0 };
95
+
96
+ let buf;
97
+ try { buf = readFileSync(abs); }
98
+ catch (err) { return { ok: false, 왜: `못 읽었습니다: ${err.message}`, bytes }; }
99
+
100
+ const mime = 그림종류(buf);
101
+ if (!mime) {
102
+ return {
103
+ ok: false,
104
+ bytes,
105
+ 왜: '이름은 그림인데 속은 그림이 아닙니다 (PNG·JPEG·GIF·WebP 중 무엇도 아님).'
106
+ + ' 내려받다 만 파일이거나, 로그인 화면 HTML 이 그림 이름으로 저장된 것일 수 있습니다.',
107
+ };
108
+ }
109
+ return { ok: true, b64: buf.toString('base64'), mime, bytes };
110
+ }
111
+
112
+ /*
113
+ * 그림 한 장을 몇 토큰으로 셀 것인가.
114
+ *
115
+ * 정직하게 말하면 **모른다.** 서버는 그림을 잘게 나눠 세는데, 몇 조각이 되는지는
116
+ * 서버와 모델이 정한다. 우리가 아는 것은 바이트 수뿐이고, 바이트 수와 토큰 수는
117
+ * 관계가 거의 없다 (같은 화면을 PNG 로 저장하면 2MB, JPEG 로 저장하면 200KB 인데
118
+ * 모델이 보는 그림은 같다).
119
+ *
120
+ * 그래서 한 장에 이만큼이라고 **딱 정해 두고**, 대신 서버가 실제 값을 알려주면
121
+ * 그쪽으로 고쳐 잡는다 (session.js 의 배운다()). 1,000 은 1024×1024 한 장이
122
+ * OpenAI 셈법으로 1,105 인 데서 왔다.
123
+ *
124
+ * 여기서 중요한 것은 정확한 값이 아니라 **base64 글자 수로 세지 않는 것**이다.
125
+ * 그렇게 세면 4MB 그림 한 장이 150만 토큰으로 잡혀서, 창이 다 찬 줄 알고
126
+ * 대화를 통째로 접어 버린다. 그림을 한 장 보여 준 죄로 하던 일을 잃는다.
127
+ */
128
+ export const 그림한장토큰 = 1000;
129
+
130
+ /** 메시지 하나에 그림이 몇 장 실려 있나. 규격 두 가지를 다 본다. */
131
+ export function 그림장수(m) {
132
+ if (!m) return 0;
133
+ if (Array.isArray(m.images)) return m.images.length; // Ollama
134
+ if (Array.isArray(m.content)) return m.content.filter((p) => p?.type === 'image_url').length;
135
+ return 0;
136
+ }
137
+
138
+ /** 토큰 셈에서 쓸, 그림을 뺀 글만. */
139
+ export function 글만(m) {
140
+ if (typeof m?.content === 'string') return m.content;
141
+ if (Array.isArray(m?.content)) {
142
+ return m.content.filter((p) => p?.type === 'text').map((p) => p?.text ?? '').join('\n');
143
+ }
144
+ return '';
145
+ }
146
+
147
+ /**
148
+ * 그림을 실은 사람 메시지를 만든다.
149
+ *
150
+ * 그림은 **사람 말 자리로 간다.** 도구 결과(role:'tool')에 넣을 수 없어서다 —
151
+ * OpenAI 규격에서 도구 결과의 content 는 글 한 덩어리여야 하고, 배열을 넣으면
152
+ * 게이트웨이가 400 을 준다. 그래서 도구 결과에는 "그림을 열었다" 는 말만 남기고,
153
+ * 그림 자체는 바로 뒤에 사람 말로 붙인다. 규격상 도구 결과 다음에 사람 말이
154
+ * 오는 것은 정상이다.
155
+ */
156
+ export function 그림메시지(shape, { 글 = '', 그림들 = [] } = {}) {
157
+ const 것들 = 그림들.filter((g) => g?.b64);
158
+ if (shape === 'ollama') {
159
+ // Ollama 는 data: 머리말을 안 받는다. base64 알맹이만 준다.
160
+ return { role: 'user', content: 글, images: 것들.map((g) => g.b64) };
161
+ }
162
+ return {
163
+ role: 'user',
164
+ content: [
165
+ { type: 'text', text: 글 },
166
+ ...것들.map((g) => ({ type: 'image_url', image_url: { url: `data:${g.mime};base64,${g.b64}` } })),
167
+ ],
168
+ };
169
+ }
170
+
171
+ /*
172
+ * 눈이 있는지 물어보는 데 쓸 1×1 짜리 PNG.
173
+ *
174
+ * 진짜 그림을 쓰면 안 된다. 어떤 화면이든 그 안에 무엇이 찍혀 있을지 모르고,
175
+ * 확인하자고 사내 화면을 바깥으로 내보낼 수는 없다. 이건 흰 점 하나다.
176
+ */
177
+ export const 한점PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
178
+
179
+ /** 눈 검사에 쓸 메시지. 규격 두 가지 다. */
180
+ export function 눈검사메시지(shape) {
181
+ return 그림메시지(shape, {
182
+ 글: '이 그림에 무엇이 있습니까? 한 단어로 답하세요.',
183
+ 그림들: [{ b64: 한점PNG, mime: 'image/png' }],
184
+ });
185
+ }